diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 39bf3ea01052..32def6bc0c16 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -59,8 +59,9 @@ different code depending on it: Then work from evidence, not assumption. In rough order of value: -- The server log and the trace file (`server.trace.ndjson`) around the time of the - problem. Recent failures usually leave a trail here. +- The trace file (`server.trace.ndjson`) around the time of the problem, plus the + service log or desktop backend logs from the context file if they exist. Recent + failures usually leave a trail here. - The provider event log, for problems with claude/codex/cursor sessions. - The SQLite database. Read it freely, but only write when a write is necessary to fix the problem the user described, and get their explicit permission diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 215998631e92..e23d78aa2161 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -93,6 +93,9 @@ const collectorLayer = (requests: Array) => ), ); +// A developer's own OTEL_* variables would otherwise pick the endpoints. +const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + const encodeObservabilitySettingsFile = Schema.encodeSync( Schema.fromJsonString( Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }), @@ -182,7 +185,7 @@ describe("DesktopObservability", () => { assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -260,7 +263,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -300,7 +303,7 @@ describe("DesktopObservability", () => { assert.equal(records.at(-1)?.annotations.details, "code=1"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -344,7 +347,7 @@ describe("DesktopObservability", () => { assert.isFalse(text.includes("y")); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -378,7 +381,7 @@ describe("DesktopObservability", () => { assert.equal(lines.length, 258); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -424,7 +427,136 @@ describe("DesktopObservability", () => { assert.lengthOf(record?.events ?? [], 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), + ); + }); + + it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel export").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const [request] = requests; + assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); + assert.strictEqual(request?.headers["x-otel"], "desktop"); + assert.strictEqual(request?.headers["x-scope"], undefined); + assert.strictEqual(request?.headers["content-type"], "application/json"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", + }, + }), + ), + ), + ), + ); + }); + + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop service name").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-desktop"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ), + ); + }); + + it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel off").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", + }, + }), + ), + ), + ), ); }); @@ -495,7 +627,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -519,7 +651,7 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d39b517759ad..ce8233e56612 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -3,6 +3,7 @@ import { makeLocalFileTracer, makeTraceSink, otlpSerializationLayer, + type SignalExport, } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { @@ -350,8 +351,10 @@ const readPersistedObservabilitySettings: Effect.Effect< }); /** - * Settings is read once for every signal, so the main process cannot - * resolve traces against one revision of the file and logs against another. + * Resolved as the server resolves them, with persisted Settings as the + * fallback. Settings is read once for every signal, so the main process + * cannot resolve traces against one revision of the file and logs against + * another. */ const resolveOtlpEndpoints = Effect.gen(function* () { const otel = yield* OtelEnvironment.load; @@ -367,10 +370,30 @@ const resolveOtlpEndpoints = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; + const signalExport: SignalExport = { + protocol: environment.otlpProtocol, + headers: Option.getOrUndefined(environment.otlpHeaders), + exportIntervalMs: environment.otlpExportIntervalMs, + }; return { - traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, - metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, - logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + traces: OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport }, + persisted.otlpTracesUrl, + ), + metrics: OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport }, + persisted.otlpMetricsUrl, + ), + logs: OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport }, + persisted.otlpLogsUrl, + ), warnings: otel.warnings, resourceAttributes: otel.resourceAttributes, }; @@ -602,11 +625,10 @@ const telemetryLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; - const headers = Option.getOrUndefined(environment.otlpHeaders); - const serializationLayer = otlpSerializationLayer(environment.otlpProtocol); const resource = { - serviceName: "desktop", + serviceName: "t3code-desktop", attributes: { + "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", }, @@ -629,14 +651,19 @@ const telemetryLayer = Layer.unwrap( : [ Logger.consolePretty(), OtlpLogger.make({ - url: endpoints.logs, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.logs.url, + exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`, + headers: endpoints.logs.export.headers, resource, }), ], { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer)); + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide( + otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol), + ), + ); const tracerLayer = Layer.unwrap( Effect.gen(function* () { @@ -651,11 +678,11 @@ const telemetryLayer = Layer.unwrap( endpoints.traces === undefined ? undefined : yield* OtlpTracer.make({ - url: endpoints.traces, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.traces.url, + exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`, + headers: endpoints.traces.export.headers, resource, - }).pipe(Effect.provide(serializationLayer)); + }).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -679,11 +706,11 @@ const telemetryLayer = Layer.unwrap( // endpoints.metrics === undefined // ? Layer.empty // : OtlpMetrics.layer({ - // url: endpoints.metrics, - // exportInterval: `${environment.otlpExportIntervalMs} millis`, - // headers, + // url: endpoints.metrics.url, + // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, + // headers: endpoints.metrics.export.headers, // resource, - // }).pipe(Layer.provide(serializationLayer)); + // }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol))); // Logged once the loggers above are installed, so the warnings use them. const otelWarningsLayer = Layer.effectDiscard( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d297750f784..07644a8af47c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -780,12 +780,11 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); // No settings.json exists here: the endpoints come from the desktop - // process's env, which a WSL child cannot inherit, so the bootstrap - // has to carry them or log export stays off inside the distro. + // environment, and the bootstrap carries them for a WSL child that + // lacks the variables. assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -996,6 +995,73 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect( + "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const standard = { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", + T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", + }; + const previousWslEnv = process.env.WSLENV; + // A developer's own OTLP variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter( + ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), + ); + try { + for (const [name] of ambientOtel) delete process.env[name]; + delete process.env.WSLENV; + Object.assign(process.env, standard); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal( + config.env.OTEL_EXPORTER_OTLP_ENDPOINT, + "https://collector.example.com:4318/base?api_key=secret", + ); + assert.equal( + config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, + "authorization=Bearer%20token", + ); + // Without a flag, WSL passes the values through untranslated. + const wslEnv = (config.env.WSLENV ?? "").split(":"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); + assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); + assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + for (const name of Object.keys(standard)) delete process.env[name]; + restoreEnv("WSLENV", previousWslEnv); + for (const [name, value] of ambientOtel) restoreEnv(name, value); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1008,7 +1074,10 @@ describe("DesktopBackendConfiguration", () => { const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; + // A developer's own OTEL_* variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); try { + for (const [name] of ambientOtel) delete process.env[name]; process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; @@ -1065,6 +1134,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); + for (const [name, value] of ambientOtel) restoreEnv(name, value); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d400c7a3a878..c2012299802d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -90,9 +90,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [ ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. The dev-server URL is handled -// separately via a `--dev-url` CLI flag because WSLENV translation of -// URL-shaped values (colons / slashes) is unreliable. +// across the wsl.exe boundary without WSLENV. The dev-server URL travels as +// the `--dev-url` CLI flag instead. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -101,6 +100,27 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", + // Forwarded without a WSLENV flag, so the values arrive untranslated. The + // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs + // travel as variables to keep winning inside the distro as they do on Windows. + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_LOGS_URL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_TRACES_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -221,12 +241,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -// The bootstrap is the only channel that carries an OTLP endpoint to every -// backend. A Windows-native child inherits the desktop process's env, but a -// WSL child gets nothing across wsl.exe that WSLENV does not declare, and -// WSLENV translation of URL-shaped values is unreliable, so the endpoints are -// deliberately not forwarded that way. Env beats the persisted settings file, -// matching the precedence resolveServerConfig and DesktopObservability apply. +// The bootstrap carries the OTLP endpoints to every backend, including a WSL +// child that lacks the variables. The T3 URLs also travel as variables in +// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats +// the persisted settings file, matching the precedence resolveServerConfig and +// DesktopObservability apply. const readBackendObservabilitySettings = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedBackendObservabilitySettings; @@ -742,10 +761,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. Inheriting through - // WSLENV is unreliable in practice (URL-shaped values with colons / - // slashes get translated unpredictably depending on flags), and the - // packaged build leaves devServerUrl as None anyway. + // config resolution lands in dev/ instead of userdata/. The packaged build + // leaves devServerUrl as None. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], diff --git a/apps/marketing/public/harnesses/openai_dark.svg b/apps/marketing/public/harnesses/openai_dark.svg index b78a51db7bc6..956f87c99f60 100644 --- a/apps/marketing/public/harnesses/openai_dark.svg +++ b/apps/marketing/public/harnesses/openai_dark.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 1d9c2b93ede8..0ef49b9c6540 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo } from "react"; +import { createContext, useCallback, useContext, useMemo, useRef } from "react"; import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { findNodeHandle, @@ -7,11 +7,14 @@ import { Platform, StyleSheet, Text as RNText, + type AccessibilityActionEvent, + type GestureResponderEvent, type TextStyle, useColorScheme, View, } from "react-native"; +import { stepAndroidLinkPress, type AndroidLinkGesture } from "./androidLinkPress"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; import { markdownLinkIconSource } from "./markdownLinkIcons"; @@ -192,6 +195,17 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle }; } +/** Opens a markdown link through the block callback, or the system URL handler. */ +function openMarkdownLink(href: string, onLinkPress: ((href: string) => void) | undefined) { + if (onLinkPress) onLinkPress(href); + else void Linking.openURL(href); +} + +/** + * Renders one selectable markdown text block. + * On Android, a link still opens on tap, and a finger that has moved far enough + * to select text does not open it. + */ export function NativeMarkdownSelectableText(props: { readonly runs: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; @@ -325,6 +339,45 @@ export function NativeMarkdownSelectableText(props: { props.textStyle.dividerColor, props.textStyle.contextChipBorderColor, ].join(":"); + // One gesture for the block: a nested link is not a real view, so only one + // press is live. Read it from the handlers below, never while rendering. + const androidLinkGesture = useRef(null); + + /** Records where an Android markdown link press started. */ + function beginAndroidMarkdownLinkPress(event: GestureResponderEvent) { + androidLinkGesture.current = stepAndroidLinkPress( + androidLinkGesture.current, + "start", + event, + ).gesture; + } + + /** Marks the press as a selection drag once the finger passes the slop. */ + function trackAndroidMarkdownLinkPress(event: GestureResponderEvent) { + androidLinkGesture.current = stepAndroidLinkPress( + androidLinkGesture.current, + "move", + event, + ).gesture; + } + + /** Drops a press the responder system cancelled before finger-up. */ + function cancelAndroidMarkdownLinkPress() { + androidLinkGesture.current = stepAndroidLinkPress(androidLinkGesture.current, "cancel").gesture; + } + + /** + * Opens a markdown link on finger-up. + * On Android, a selection drag leaves the native selection in place instead. + */ + function pressMarkdownLink(href: string, event: GestureResponderEvent) { + if (Platform.OS === "android") { + const transition = stepAndroidLinkPress(androidLinkGesture.current, "end", event); + androidLinkGesture.current = transition.gesture; + if (!transition.open) return; + } + openMarkdownLink(href, props.onLinkPress); + } return ( { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; - const onPress = href - ? () => { - if (props.onLinkPress) props.onLinkPress(href); - else void Linking.openURL(href); - } - : undefined; + const androidLink = href != null && Platform.OS === "android"; + /** Opens this link unless the Android gesture was a text-selection drag. */ + function openPressedLink(event: GestureResponderEvent) { + if (href) pressMarkdownLink(href, event); + } + /** Accessibility activate opens the link even when a drag is in progress. */ + function activateLink(event: AccessibilityActionEvent) { + if (event.nativeEvent.actionName === "activate" && href) { + openMarkdownLink(href, props.onLinkPress); + } + } return ( menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) @@ -394,15 +459,9 @@ export function NativeMarkdownSelectableText(props: { { - if (event.nativeEvent.actionName === "activate") onPress(); - } - : undefined - } + accessibilityRole={href ? "button" : "image"} + accessibilityActions={href ? [{ name: "activate" }] : undefined} + onAccessibilityAction={href ? activateLink : undefined} style={{ width: androidChip.width, height: androidChip.boxHeight }} > { + it("opens a tap and keeps a selection drag from opening the link", () => { + const gesture = gestureSlot(); + const opened: Array<"open"> = []; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened.push("open"); + }); + + handlers.onPressIn(touch(100, 200)); + handlers.onResponderMove(touch(104, 203)); + handlers.onPress(release(104, 203)); + expect(opened).toEqual(["open"]); + + handlers.onPressIn(touch(100, 200)); + handlers.onResponderMove(touch(100 + ANDROID_LINK_SELECTION_DRAG_SLOP_DP + 8, 200)); + handlers.onResponderMove(touch(100, 200)); + handlers.onPress(release(100, 200)); + expect(opened).toEqual(["open"]); + + handlers.onPressIn(touch(10, 20)); + handlers.onPress(release(10 + ANDROID_LINK_SELECTION_DRAG_SLOP_DP, 20)); + expect(opened).toEqual(["open", "open"]); + + handlers.onPressIn(touch(10, 20)); + handlers.onPress(release(10 + ANDROID_LINK_SELECTION_DRAG_SLOP_DP + 1, 20)); + expect(opened).toEqual(["open", "open"]); + }); + + it("still opens the next tap after a drag, and opens an accessibility activate", () => { + const gesture = gestureSlot(); + let opened = 0; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened += 1; + }); + + handlers.onPressIn(touch(0, 0)); + handlers.onResponderMove(touch(40, 0)); + handlers.onPress(release(40, 0)); + expect(opened).toBe(0); + + handlers.onPressIn(touch(0, 0)); + handlers.onPress(release(2, 1)); + expect(opened).toBe(1); + + handlers.onPress({ nativeEvent: {} }); + expect(opened).toBe(2); + expect(gesture.current).toBeNull(); + }); + + it("treats a diagonal past the slop as a selection drag", () => { + const gesture = gestureSlot(); + let opened = 0; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened += 1; + }); + + handlers.onPressIn(touch(0, 0)); + handlers.onPress(release(7, 7)); + expect(opened).toBe(1); + + handlers.onPressIn(touch(0, 0)); + handlers.onPress(release(8, 8)); + expect(opened).toBe(1); + }); + + it("clears a cancelled drag so the next tap can open the link", () => { + const gesture = gestureSlot(); + let opened = 0; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened += 1; + }); + + handlers.onPressIn(touch(0, 0)); + handlers.onResponderMove(touch(40, 0)); + handlers.onResponderTerminate(); + expect(gesture.current).toBeNull(); + + handlers.onPressIn(touch(8, 8)); + handlers.onPress(release(9, 9)); + expect(opened).toBe(1); + }); + + it("reads the press point from the touch Pressability uses", () => { + expect( + androidLinkPressPoint({ + nativeEvent: { pageX: 9, pageY: 9, touches: [{ pageX: 1, pageY: 2 }] }, + }), + ).toEqual({ pageX: 1, pageY: 2 }); + expect( + androidLinkPressPoint({ + nativeEvent: { changedTouches: [{ pageX: 3, pageY: 4 }] }, + }), + ).toEqual({ pageX: 3, pageY: 4 }); + expect(androidLinkPressPoint({ nativeEvent: { pageX: 5, pageY: 6 } })).toEqual({ + pageX: 5, + pageY: 6, + }); + expect(androidLinkPressPoint(undefined)).toBeNull(); + expect(androidLinkPressPoint({ nativeEvent: {} })).toBeNull(); + expect(androidLinkPressPoint({ nativeEvent: { pageX: Number.NaN, pageY: 1 } })).toBeNull(); + expect( + androidLinkPressPoint({ nativeEvent: { pageX: Number.POSITIVE_INFINITY, pageY: 1 } }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts b/apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts new file mode 100644 index 000000000000..ef3264015004 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts @@ -0,0 +1,184 @@ +/** + * Android markdown links are nested React Native `Text` nodes with `onPress`. + * Pressability fires that callback on finger-up while the touch is still + * inside the link. Nested text measures as an empty rect, so a move never + * counts as leaving, and there is no `onLongPress` to cancel the press. + * A selection drag that starts on the link therefore opens it and dismisses + * the selection. + * + * Pressability's own long-press deactivation distance is 10dp, but it bails + * out before applying that check when the measured rect is empty. Apply the + * same distance here: a tap still opens, a drag does not. + */ + +/** Distance, in dp, past which a link press is a text-selection drag. */ +export const ANDROID_LINK_SELECTION_DRAG_SLOP_DP = 10; + +/** Page coordinates of one Android link touch, in dp. */ +export interface AndroidLinkPressPoint { + readonly pageX: number; + readonly pageY: number; +} + +/** Where an Android link press began, and whether it has already passed the slop. */ +export interface AndroidLinkGesture { + pageX: number; + pageY: number; + moved: boolean; +} + +interface PressEventLike { + readonly nativeEvent?: { + readonly pageX?: number; + readonly pageY?: number; + readonly touches?: ReadonlyArray>; + readonly changedTouches?: ReadonlyArray>; + }; +} + +/** One step of an Android link gesture. `open` is only true on a tap's finger-up. */ +export interface AndroidLinkPressTransition { + readonly gesture: AndroidLinkGesture | null; + readonly open: boolean; +} + +/** Which edge of the gesture `stepAndroidLinkPress` should apply. */ +export type AndroidLinkPressPhase = "start" | "move" | "end" | "cancel"; + +/** Press handlers a nested Android `Text` link needs in order to ignore selection drags. */ +export interface AndroidMarkdownLinkPressHandlers { + /** Opens the link when the finished gesture was a tap. */ + onPress: (event?: PressEventLike) => void; + /** Records the finger-down point for a new gesture. */ + onPressIn: (event: PressEventLike) => void; + /** Marks the gesture moved once the finger passes the selection slop. */ + onResponderMove: (event: PressEventLike) => void; + /** Drops the gesture when the responder system cancels it. */ + onResponderTerminate: () => void; +} + +/** + * Reads the touch Pressability uses for hit testing: the active touch, then + * the changed touch, then the event's own page coordinates. + */ +export function androidLinkPressPoint( + event: PressEventLike | undefined, +): AndroidLinkPressPoint | null { + const native = event?.nativeEvent; + if (native == null) return null; + const touch = native.touches?.[0] ?? native.changedTouches?.[0] ?? native; + const { pageX, pageY } = touch; + if (typeof pageX !== "number" || typeof pageY !== "number") return null; + if (!Number.isFinite(pageX) || !Number.isFinite(pageY)) return null; + return { pageX, pageY }; +} + +/** Starts a link gesture at `point`. The finger has not moved yet. */ +export function beginAndroidLinkGesture(point: AndroidLinkPressPoint): AndroidLinkGesture { + return { pageX: point.pageX, pageY: point.pageY, moved: false }; +} + +/** True once the finger has traveled far enough to be a selection drag. */ +export function androidLinkPressMoved( + start: AndroidLinkPressPoint, + point: AndroidLinkPressPoint, + slopDp = ANDROID_LINK_SELECTION_DRAG_SLOP_DP, +): boolean { + if ( + !Number.isFinite(start.pageX) || + !Number.isFinite(start.pageY) || + !Number.isFinite(point.pageX) || + !Number.isFinite(point.pageY) || + !Number.isFinite(slopDp) + ) { + return false; + } + const dx = point.pageX - start.pageX; + const dy = point.pageY - start.pageY; + return dx * dx + dy * dy > slopDp * slopDp; +} + +/** Sets `gesture.moved` once and then leaves it set for the rest of the press. */ +export function trackAndroidLinkGestureMove( + gesture: AndroidLinkGesture, + point: AndroidLinkPressPoint, + slopDp = ANDROID_LINK_SELECTION_DRAG_SLOP_DP, +): void { + if (gesture.moved) return; + if (androidLinkPressMoved(gesture, point, slopDp)) gesture.moved = true; +} + +/** + * A link opens on finger-up when the gesture never moved past the slop. + * No tracked gesture (an accessibility activate) still opens. + */ +export function shouldOpenAndroidMarkdownLink( + gesture: AndroidLinkGesture | null, + end: AndroidLinkPressPoint | null, + slopDp = ANDROID_LINK_SELECTION_DRAG_SLOP_DP, +): boolean { + if (gesture == null) return true; + if (gesture.moved) return false; + if (end == null) return true; + return !androidLinkPressMoved(gesture, end, slopDp); +} + +/** + * Advances one Android link gesture. + * `open` is true only for a finger-up that stayed inside the selection slop. + */ +export function stepAndroidLinkPress( + gesture: AndroidLinkGesture | null, + phase: AndroidLinkPressPhase, + event?: PressEventLike, +): AndroidLinkPressTransition { + switch (phase) { + case "start": { + const point = androidLinkPressPoint(event); + return { gesture: point == null ? null : beginAndroidLinkGesture(point), open: false }; + } + case "move": { + if (gesture == null) return { gesture: null, open: false }; + const point = androidLinkPressPoint(event); + if (point != null) trackAndroidLinkGestureMove(gesture, point); + return { gesture, open: false }; + } + case "cancel": + return { gesture: null, open: false }; + case "end": + return { + gesture: null, + open: shouldOpenAndroidMarkdownLink(gesture, androidLinkPressPoint(event)), + }; + } +} + +/** + * Builds the nested-text handlers for one Android link. + * `slot` is plain mutable state owned by the caller, not a React ref. + */ +export function androidMarkdownLinkPressHandlers( + slot: { current: AndroidLinkGesture | null }, + openLink: () => void, +): AndroidMarkdownLinkPressHandlers { + return { + /** Records the finger-down point for a new gesture. */ + onPressIn(event) { + slot.current = stepAndroidLinkPress(slot.current, "start", event).gesture; + }, + /** Marks the gesture moved once the finger passes the selection slop. */ + onResponderMove(event) { + slot.current = stepAndroidLinkPress(slot.current, "move", event).gesture; + }, + /** Drops the gesture when the responder system cancels it. */ + onResponderTerminate() { + slot.current = stepAndroidLinkPress(slot.current, "cancel").gesture; + }, + /** Opens the link when the finished gesture was a tap. */ + onPress(event) { + const transition = stepAndroidLinkPress(slot.current, "end", event); + slot.current = transition.gesture; + if (transition.open) openLink(); + }, + }; +} diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index dfa0dea8d05a..4c465a9f9ef4 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -7,9 +7,9 @@ import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { OverlayPortal } from "./OverlayPortal"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { MaterialMenuPopup } from "./MaterialMenuPopup"; -const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; const ANCHOR_GAP = 6; @@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = { * menus use the native popup for placement, animation and dismissal. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. @@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { // are converted into this frame, so the menu lands correctly no matter // where the portal host sits (status bar, keyboard resize, etc.). const [overlay, setOverlay] = useState(null); + const menuWidth = + overlay === null + ? desiredMenuWidth + : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN)); const anchorRef = useRef(null); const overlayRef = useRef(null); @@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { ? 0 : local.x + local.width / 2 <= overlay.width / 2 ? local.x - : local.x + local.width - MENU_WIDTH; + : local.x + local.width - menuWidth; const left = overlay === null ? 0 - : Math.min( - Math.max(preferredLeft, SCREEN_MARGIN), - overlay.width - MENU_WIDTH - SCREEN_MARGIN, - ); + : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN); // The keyboard stays up while the menu is open (in-window overlay, no // focus change), so the space it covers is not usable — without this the // composer-pill menus "open down" into the IME and can't be tapped. @@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; @@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: { return ( setHeaderWidth(event.nativeEvent.layout.width)} - className="border-b border-header-border bg-header px-2 pb-2" + className="border-b border-header-border bg-header px-2" style={{ - paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), + ...headerPadding, borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - + {props.onBack ? ( - + Code - + {stageLabel} diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx index 8ea70a45c020..b6b3d6e78a49 100644 --- a/apps/mobile/src/components/ComposerAttachmentButton.tsx +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -1,6 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { Pressable } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { SymbolView } from "./AppSymbol"; import { ControlPillMenu } from "./ControlPill"; @@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: { readonly onPickMedia: () => Promise; readonly onPickFiles: () => Promise; }) { + const { scale } = useAndroidControlSizing(); const button = ( ["name"]; - readonly iconNode?: ReactNode; + readonly renderIcon?: (size: number) => ReactNode; readonly label: string; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; @@ -39,6 +41,7 @@ export function ComposerInlineControl(props: { readonly chevronDirection?: "down" | "right"; readonly showChevron?: boolean; }) { + const { scale, smallIconSize } = useAndroidControlSizing(); return ( - {props.iconNode ? ( - {props.iconNode} + {props.renderIcon ? ( + + {props.renderIcon(smallIconSize)} + ) : props.icon ? ( void; readonly variant?: "primary" | "danger"; }) { + const { scale, smallIconSize } = useAndroidControlSizing(); + const circleSize = Math.round(30 * scale); return ( { @@ -133,7 +135,7 @@ export function ControlPill(props: { ) : props.icon ? ( diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx index f2df6464f103..f7a62f1ecb0b 100644 --- a/apps/mobile/src/components/MaterialButton.android.tsx +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -14,10 +14,12 @@ import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import type { MaterialButtonProps } from "./MaterialButton"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialButton(props: MaterialButtonProps) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); + const { scale, mediumIconSize } = useAndroidControlSizing(); const tone = props.tone ?? "secondary"; const Component = tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button; @@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) { {props.loading ? ( <> - + ) : null} {props.label} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx index 2882d0d91ec0..29f396fc9d6d 100644 --- a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -6,8 +6,9 @@ import { LargeFloatingActionButton, Text, } from "@expo/ui/jetpack-compose"; -import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { defaultMinSize, height, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View, type StyleProp, type ViewStyle } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: { }) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); + const { scale, iconSize: standardIconSize, fabSize, largeFabSize } = useAndroidControlSizing(); + const buttonSize = props.variant === "large" ? largeFabSize : fabSize; const primary = props.tone === "primary"; const containerColor = colors[primary ? "--color-primary" : "--color-secondary"]; const contentColor = @@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: { : props.variant === "large" ? LargeFloatingActionButton : FloatingActionButton; - const iconSize = props.variant === "large" ? 36 : 24; + const iconSize = props.variant === "large" ? Math.round(36 * scale) : standardIconSize; return ( { if (!props.disabled) props.onPress?.(); }} - style={{ width: 48, height: 48 }} + style={{ width: buttonSize, height: buttonSize }} > diff --git a/apps/mobile/src/components/MaterialListRow.tsx b/apps/mobile/src/components/MaterialListRow.tsx index 539472e4bd96..cef104a02f50 100644 --- a/apps/mobile/src/components/MaterialListRow.tsx +++ b/apps/mobile/src/components/MaterialListRow.tsx @@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; /** Shared geometry for Material navigation and selection lists. Group rows in one card. */ export function MaterialListRow({ @@ -23,6 +24,7 @@ export function MaterialListRow({ readonly trailing?: ReactNode; }) { const { themeVariables } = useAppearancePreferences(); + const { smallIconSize } = useAndroidControlSizing(); return ( + ) : null} ); diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx index c49db1815039..4f9fe3d1ada4 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.android.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -7,9 +7,11 @@ import { RNHostView, Text, } from "@expo/ui/jetpack-compose"; -import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; +import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; +import { resolveScaledTextRole } from "../lib/appearancePreferences"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -19,12 +21,16 @@ function MenuIcon(props: { readonly destructive?: boolean; readonly disabled?: boolean; }) { + const { iconSize } = useAndroidControlSizing(); return ( - - + + {props.parent ? ( - + - + {props.parent.title} ) : props.title ? ( - + {props.title} ) : null} @@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { props.onPress(action)} > + {action.image && isAppSymbolName(action.image) ? ( + + + + ) : null} {action.subtitle ? ( - + {action.subtitle} ) : null} - {action.image && isAppSymbolName(action.image) ? ( - - - - ) : null} {(action.subactions?.length ?? 0) > 0 ? ( @@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets matchContents - style={{ width: 250 }} + style={{ width: props.menuWidth }} > {items} diff --git a/apps/mobile/src/components/MaterialMenuPopup.tsx b/apps/mobile/src/components/MaterialMenuPopup.tsx index 3a23e5de7ab4..06019a0e1512 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.tsx @@ -1,6 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; export interface MaterialMenuPopupProps { + readonly menuWidth: number; readonly anchor: { readonly x: number; readonly y: number; diff --git a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx index 018dbdc19b42..8c653e67ee9a 100644 --- a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx @@ -1,9 +1,17 @@ import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose"; -import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers"; +import { + defaultMinSize, + fillMaxWidth, + graphicsLayer, + height, + onSizeChanged, + size, +} from "@expo/ui/jetpack-compose/modifiers"; import { useCallback, useState } from "react"; import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { resolveScaledTextRole } from "../lib/appearancePreferences"; /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ export function MaterialScrollComposeButton(props: { @@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: { readonly className?: string; readonly style?: StyleProp; }) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const typography = useScaledTextRole("footnote"); - const [expandedWidth, setExpandedWidth] = useState(56); + const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); + const { iconSize, fabSize } = useAndroidControlSizing(); + // Scale the native 56dp minimum; keep text and icons at their requested sizes. + const nativeSize = Math.max(56, fabSize); + const scale = fabSize / nativeSize; + const nativeIconSize = Math.round(iconSize / scale); + const [buttonWidth, setButtonWidth] = useState(nativeSize); const rememberWidth = useCallback(({ width }: { width: number }) => { - setExpandedWidth((previous) => Math.max(previous, width)); + setButtonWidth((previous) => Math.max(previous, width)); }, []); return ( - + - + @@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: { New thread @@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: { right: 0, top: 0, bottom: 0, - width: props.expanded ? expandedWidth : 56, - borderRadius: 16, + // Release the label area as soon as collapse starts, before native measurements arrive. + width: props.expanded ? buttonWidth * scale : fabSize, + borderRadius: 16 * scale, overflow: "hidden", }} /> diff --git a/apps/mobile/src/components/MaterialSearchField.tsx b/apps/mobile/src/components/MaterialSearchField.tsx index ab96440f5f09..3609430a6e1a 100644 --- a/apps/mobile/src/components/MaterialSearchField.tsx +++ b/apps/mobile/src/components/MaterialSearchField.tsx @@ -2,6 +2,7 @@ import type { RefObject } from "react"; import { Pressable, TextInput, View } from "react-native"; import { SymbolView } from "./AppSymbol"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialSearchField({ inputRef, @@ -18,9 +19,21 @@ export function MaterialSearchField({ readonly value: string; readonly onChangeText: (value: string) => void; }) { + const { scale, mediumIconSize } = useAndroidControlSizing(); return ( - - + + @@ -49,7 +63,7 @@ export function MaterialSearchField({ > diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index e1d883a01026..83505730d808 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,8 +1,9 @@ import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, @@ -11,6 +12,12 @@ import { import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import { projectFaviconUrlAtom } from "../state/assets"; +import { + countGlyphs, + projectIconColorClassNames, + resolveProjectIconGlyph, + type ProjectIconGlyph, +} from "../lib/projectIcon"; import { beginProjectFaviconRequest, @@ -30,10 +37,12 @@ export function ProjectFavicon(props: { readonly projectTitle: string; readonly workspaceRoot?: string | null; readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; }) { const size = props.size ?? 42; + const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle); const faviconUrl = useAtomValue( - props.workspaceRoot == null + props.workspaceRoot == null || glyph !== null ? EMPTY_FAVICON_URL : projectFaviconUrlAtom({ environmentId: props.environmentId, @@ -51,6 +60,10 @@ export function ProjectFavicon(props: { : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; + if (glyph !== null) { + return ; + } + return ( + + {glyph.emoji} + + + ); + } + + const colors = projectIconColorClassNames(glyph.color); + return ( + + + {glyph.text} + + + ); +} + function ProjectFaviconImage(props: { readonly cacheKey: string | null; readonly faviconUrl: string | null; @@ -105,7 +168,7 @@ function ProjectFaviconImage(props: { {!showImage ? ( diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..49de96a8d74c 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -75,10 +75,12 @@ export function ProviderIcon(props: ProviderIconProps) { // codex (and unknown drivers) return ( - + ); diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx index 61a38c3a00c2..1d3232fd1641 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; import { NativeStackScreenOptions } from "../native/StackHeader"; import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill"; import { MaterialSearchField } from "./MaterialSearchField"; import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; import type { ScreenHeaderProps } from "./ScreenHeader.types"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function ScreenHeader(props: ScreenHeaderProps) { const { search } = props; - const insets = useSafeAreaInsets(); + const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); + const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing(); const { themeVariables } = useAppearancePreferences(); const inputRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); @@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { @@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) { <> {options} - + {props.onBack ? ( ) : null} - + @@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) { autoCapitalize="none" onChangeText={search.onChangeText} value={search.value} - placeholder={search.placeholder} + placeholder={ + search.compactToolbar + ? (search.compactPlaceholder ?? search.placeholder) + : search.placeholder + } placeholderTextColorClassName="accent-placeholder" - className="flex-1 py-2 text-base font-sans text-header-foreground" + className="flex-1 text-base font-sans text-header-foreground" + style={{ paddingVertical: 7 * scale }} /> {menuView} @@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) { {header} {searching ? ( - - + + Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { - const payload = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( + Effect.mapError((cause) => persistenceError("save-shell", cause)), + ); + const payload = yield* Effect.try({ + try: () => + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* database .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-shell"))); diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index ea150180393c..b786f9f4066c 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: { ({ + View: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../components/AppText", () => ({ + AppText: ({ + accessibilityElementsHidden, + importantForAccessibility, + children, + }: { + accessibilityElementsHidden?: boolean; + importantForAccessibility?: string; + children: ReactNode; + }) => ( + + {children} + + ), + AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => ( + + ), +})); + +import { ConnectionFormField } from "./ConnectionFormField"; + +describe("ConnectionFormField accessibility", () => { + it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(``); + expect(markup).toContain(``); + }); +}); diff --git a/apps/mobile/src/features/connection/ConnectionFormField.tsx b/apps/mobile/src/features/connection/ConnectionFormField.tsx index 0d5e21148e54..b492dd9ce43c 100644 --- a/apps/mobile/src/features/connection/ConnectionFormField.tsx +++ b/apps/mobile/src/features/connection/ConnectionFormField.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText"; import { cn } from "../../lib/cn"; -type ConnectionFormFieldProps = Omit & { +type ConnectionFormFieldProps = Omit & { readonly label: string; readonly className?: string; }; @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit & { export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) { return ( - + {label} diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx index 61d007ea5c3e..c652ab67d377 100644 --- a/apps/mobile/src/features/files/MaterialFilesHeader.tsx +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { BackHandler, Keyboard, type TextInput, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; import { MaterialSearchField } from "../../components/MaterialSearchField"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; /** Keep Files search in the same header row on compact and expanded layouts. */ export function MaterialFilesHeader(props: { @@ -15,7 +15,7 @@ export function MaterialFilesHeader(props: { readonly onBack?: () => void; readonly leading?: ReactNode; }) { - const insets = useSafeAreaInsets(); + const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: { />
{searching ? ( - - + + ) { const insets = useSafeAreaInsets(); + const { appearance } = useAppearancePreferences(); + const { fontScale } = useWindowDimensions(); + const [layoutWidth, setLayoutWidth] = useState(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); const scrollState = useRef({ anchor: 0, expanded: true }); @@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps + setLayoutWidth(event.nativeEvent.layout.width)}> {props.children} - {state.hasConnections ? ( + {state.hasConnections && layoutWidth !== null ? ( (null); const insets = useSafeAreaInsets(); + const { fabClearance } = useAndroidControlSizing(); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT @@ -264,8 +278,45 @@ export function HomeScreen(props: HomeScreenProps) { openSwipeableRef.current?.close(); }, []); const onMaterialFabScroll = useMaterialFabScroll(); + const listRef = useRef(null); + const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); + const activateVisibleRows = useCallback( + (rows: ReadonlyArray) => { + const state = listRef.current?.getState(); + if (state === undefined || !(state.end >= 0)) return; + swipeRowActivation.activate( + rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), + ); + }, + [swipeRowActivation], + ); + // Status-bar, accessibility and programmatic scrolls never arm the scroll + // gate, so every scroll also activates the visible rows once it settles. + const activationTimerRef = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(activationTimerRef.current), []); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + onMaterialFabScroll?.(event); + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout( + () => activateVisibleRows(listRef.current?.getState().data ?? []), + 200, + ); + }, + [activateVisibleRows, onMaterialFabScroll], + ); + const trackListTouches = useCallback( + (event: GestureResponderEvent, started: boolean) => { + const { changedTouches, touches } = event.nativeEvent; + swipeRowActivation.trackTouches( + started ? changedTouches.map((touch) => touch.identifier) : [], + touches.map((touch) => touch.identifier), + ); + }, + [swipeRowActivation], + ); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: onMaterialFabScroll, + onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -655,6 +706,9 @@ export function HomeScreen(props: HomeScreenProps) { ); useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); + useEffect(() => { + if (swipeEnabled) activateVisibleRows(threadListV2Items); + }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = useCallback( ({ item }: { readonly item: ThreadListV2ListItem }) => { @@ -762,6 +816,7 @@ export function HomeScreen(props: HomeScreenProps) { onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} + activationKey={item.key} /> ); }, @@ -950,15 +1005,20 @@ export function HomeScreen(props: HomeScreenProps) { {/* Shared with the iPad sidebar: cells are reused across data rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated shell update) from re-rendering untouched rows. */} - + activateVisibleRows(threadListV2Items)} + onTouchStart={(event) => trackListTouches(event, true)} + onTouchEnd={(event) => trackListTouches(event, false)} + onTouchCancel={(event) => trackListTouches(event, false)} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} getItemType={(item) => item.type} itemsAreEqual={threadListV2ListItemsAreEqual} estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} - drawDistance={500} + drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} recycleItems extraData={v2ExtraData} ListHeaderComponent={v2ListHeader} @@ -983,7 +1043,7 @@ export function HomeScreen(props: HomeScreenProps) { paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), + : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), }} /> diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 2d852f8f7be2..6cea625aced9 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; import { useWorkspaceState } from "../../state/workspace"; -import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function MaterialThreadListToolbar(props: { @@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: { readonly onRequestVisibility?: () => void; }) { const insets = useSafeAreaInsets(); - const toolbarHeight = useMaterialToolbarHeight(); + const { fabSize } = useAndroidControlSizing(); + const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; const searchRef = useRef(null); @@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: { {searching ? ( @@ -116,14 +116,14 @@ export function MaterialThreadListToolbar(props: { )} - {/* Sit 8dp above the 56dp extended New thread FAB. */} + {/* Keep the filter above the New thread FAB at every text size. */} {state.hasConnections ? ( diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 9b9333b46c7f..d2b068a07c59 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,9 +1,10 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useEffect, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Animated, Pressable, View } from "react-native"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { brandTitleOffset, CompactBrandTitle, @@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: { }) { const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; + const { scale } = useAndroidControlSizing(); if (status === null) { return props.grow ? ( @@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: { hitSlop={8} onPress={props.onPress} className="flex-row items-center gap-2" - style={{ flexShrink: 1, marginLeft: props.statusOffset ?? 0 }} + style={[ + { flexShrink: 1, marginLeft: props.statusOffset ?? 0 }, + Platform.OS === "android" && { gap: 7 * scale }, + ]} > {status.showsProgress ? ( - + ) : ( )} {status.label} diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts new file mode 100644 index 000000000000..43f8dcf0ca7e --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createSwipeRowActivation } from "./swipe-row-activation"; + +describe("createSwipeRowActivation", () => { + it("activates exactly the requested rows and notifies only on change", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + activation.subscribe(listener); + + activation.activate(["a", "b"]); + activation.activate(["b", "a"]); + + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("c")).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("defers changes while a finger is on the list so a press is never remounted", () => { + const activation = createSwipeRowActivation(); + activation.activate(["a"]); + + activation.trackTouches(["1"], ["1"]); + activation.activate(["b"]); + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("b")).toBe(false); + expect(activation.isActive("c")).toBe(true); + }); + + it("ignores fingers that did not start on the list", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1", "2"]); + activation.activate(["a"]); + + // The list finger lifts while finger 2 stays on another control. + activation.trackTouches([], ["2"]); + expect(activation.isActive("a")).toBe(true); + }); + + it("drops a list finger whose end event never arrived", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1"]); + activation.activate(["a"]); + + activation.trackTouches(["2"], ["2"]); + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(true); + }); + + it("stops notifying after unsubscribe", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + const unsubscribe = activation.subscribe(listener); + unsubscribe(); + activation.activate(["a"]); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts new file mode 100644 index 000000000000..d56d12cba56f --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -0,0 +1,62 @@ +import { createContext, use, useSyncExternalStore } from "react"; + +/** + * Full swipe rows (pan gesture, animated actions, hidden action buttons) only + * exist around the viewport. Every other Home row renders a dormant frame that + * paints the same content with a fraction of the native views, so a row the + * list rebuilds while scrolling is cheap. The scroll gate already disables + * swipes while the list moves, so rows are activated once it rests. + */ +export function createSwipeRowActivation() { + let activeKeys = new Set(); + // Swapping a row's frame remounts it, which would cancel a press or long + // press in progress, so changes wait until every finger that started on the + // list has lifted. + const listTouches = new Set(); + let pendingKeys: ReadonlyArray | null = null; + const listeners = new Set<() => void>(); + const apply = (keys: ReadonlyArray) => { + if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + activeKeys = new Set(keys); + for (const listener of listeners) listener(); + }; + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + isActive: (key: string) => activeKeys.has(key), + activate(keys: ReadonlyArray) { + if (listTouches.size > 0) pendingKeys = keys; + else apply(keys); + }, + /** + * `started` are touches that just began on the list; `onScreen` is every + * finger still down anywhere. A finger on another control never holds + * changes, and one whose end event went missing is dropped here. + */ + trackTouches(started: ReadonlyArray, onScreen: ReadonlyArray) { + for (const id of started) listTouches.add(id); + for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); + if (listTouches.size > 0 || pendingKeys === null) return; + const keys = pendingKeys; + pendingKeys = null; + apply(keys); + }, + }; +} + +export type SwipeRowActivation = ReturnType; + +export const SwipeRowActivationContext = createContext(null); + +const subscribeNever = () => () => {}; + +/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ +export function useSwipeRowDormant(key: string | undefined): boolean { + const activation = use(SwipeRowActivationContext); + return useSyncExternalStore( + activation?.subscribe ?? subscribeNever, + () => activation !== null && key !== undefined && !activation.isActive(key), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 44f3c36e6655..e62935c28861 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; + readonly activation?: SwipeRowActivation; readonly children: ReactNode; }) { return ( - {props.children} + + {props.children} + ); } @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { * open/mid-drag state can't leak onto another row. */ readonly resetKey?: string; + /** Paints the row without swipe machinery; see swipe-row-activation. */ + readonly dormant?: boolean; readonly simultaneousWithExternalGesture?: ComponentProps< typeof ReanimatedSwipeable >["simultaneousWithExternalGesture"]; readonly threadTitle: string; } +const closeDormant = () => {}; + export function ThreadSwipeable(props: ThreadSwipeableProps) { + if (props.dormant) { + // Mirrors ReanimatedSwipeable's container and children views. + return ( + + + {props.children(closeDormant)} + + + ); + } // Recycled content gets fresh native and animation state. Late callbacks // from the previous row retain its action, never the replacement's action. return ; diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index eb73abba292b..ae204413e777 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -25,7 +25,7 @@ export function resolveTracingConfig(): TracingConfig | null { export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { - serviceName: "t3-mobile-relay-client", + serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", client: `mobile-${resource.appVariant}`, diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index 8c260e871a0d..29377b42dd83 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -96,9 +96,10 @@ function ProjectOverviewContent(props: { diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index 62f918a0e6b0..561cc53e44a7 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -1,10 +1,6 @@ import { useMemo } from "react"; -import { - DEFAULT_BASE_FONT_SIZE, - normalizeBaseFontSize, - scaledTypographyLineHeight, -} from "../../../lib/appearancePreferences"; +import { resolveScaledTextRole } from "../../../lib/appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; import { useAppearancePreferences } from "./AppearancePreferencesProvider"; @@ -20,15 +16,8 @@ export interface ScaledTextRole { */ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { const { appearance } = useAppearancePreferences(); - return useMemo(() => { - const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize); - const typography = MOBILE_TYPOGRAPHY[role]; - return { - fontSize: Math.max( - 8, - Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)), - ), - lineHeight: scaledTypographyLineHeight(typography, baseFontSize), - }; - }, [appearance.baseFontSize, role]); + return useMemo( + () => resolveScaledTextRole(role, appearance.baseFontSize), + [appearance.baseFontSize, role], + ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 681f6c5ee52d..7e90b0743374 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -132,30 +132,40 @@ import { fileRoutePathSegments } from "../files/filePath"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; readonly worktreePath: string | null; + readonly size: number; }) { if (props.workspaceMode === "local" && props.worktreePath === null) { return ( ); } + const boxSize = (14 * props.size) / 16; return ( - + - + @@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: { accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} chevronDirection="right" disabled={isComposerInteractionLocked || voiceInput.isBusy} - iconNode={ + renderIcon={(size) => ( - } + )} label={`on ${selectedEnvironmentLabel}`} maxWidth={260} onPress={ @@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: { accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} accessibilityLabel={workspaceLabel} disabled={isComposerInteractionLocked || voiceInput.isBusy} - iconNode={ + renderIcon={(size) => ( - } + )} label={workspaceLabel} maxWidth={flow.workspaceMode === "local" ? 220 : 148} onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} @@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: { accessibilityLabel="Model and reasoning settings" disabled={isComposerInteractionLocked} emphasized - iconNode={ + renderIcon={(size) => ( - } + )} label={flow.selectedModelOption?.label ?? "Choose model"} maxWidth="100%" onPress={settingsSheetPresentation.open} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f74542076be2..27a9b3217c7e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - } + renderIcon={(size) => ( + + )} label={currentModelOption?.label ?? currentModelSelection.model} maxWidth="100%" onPress={openSettings} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e32240748861..14beefd6d178 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -679,10 +679,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const workspaceContentWidth = useWorkspaceContentWidth(); + // Clearing animated width can retain the unfolded width after Android resumes folded. + // Assign both layouts explicitly so the dock always follows its current parent. const composerWidthStyle = useAnimatedStyle(() => isSplitLayout && workspaceContentWidth !== null - ? { width: workspaceContentWidth.value, right: undefined } - : { width: undefined, right: 0 }, + ? { width: workspaceContentWidth.value } + : { width: "100%" }, ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); @@ -964,7 +966,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* 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 20848cd2ad10..83b0bef9c022 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2477,7 +2477,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; // on Android the declarative contentInset floor covers this same window. - const listMountKey = `${feedThreadKey}:${presentedFeed.length === 0 ? "empty" : "filled"}`; + // The thinking row a running thread shows while its messages load is not + // content: the list must still remount, and so open at the end, when they + // arrive. + const listMountKey = `${feedThreadKey}:${presentedFeed.some((entry) => entry.type !== "thinking") ? "filled" : "empty"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index c478bae6312a..d9e165bd0107 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,4 @@ +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { computeThreadMoveAvailability } from "./threadOrder"; import type { @@ -50,7 +51,7 @@ import { } from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; -import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -133,6 +134,7 @@ function ThreadNavigationSidebarPane( const drawerColor = materialTheme["--color-drawer"]; const insets = useSafeAreaInsets(); + const { fabClearance } = useAndroidControlSizing(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -603,14 +605,14 @@ function ThreadNavigationSidebarPane( ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); - const materialToolbarHeight = useMaterialToolbarHeight(); + const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout(); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no // hardcoded per-variant constants. const stickyHeaderHeight = measuredHeaderHeight ?? (Platform.OS === "android" - ? Math.max(insets.top, 12) + materialToolbarHeight + 8 + ? paddingTop + height + paddingBottom : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); const topListInset = stickyHeaderHeight + 6; const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { @@ -1039,7 +1041,7 @@ function ThreadNavigationSidebarPane( { paddingBottom: Platform.OS === "android" - ? Math.max(insets.bottom, 16) + 148 - insets.bottom + ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom : 16 + insets.bottom, paddingTop: Platform.OS === "android" ? 6 : topListInset, }, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index e2dd3cc725df..09ffe404ee79 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { @@ -294,8 +295,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props ) : null} @@ -517,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** List key checked against the Home swipe row activation. */ + readonly activationKey?: string; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -544,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const dormant = useSwipeRowDormant(props.activationKey); const pr = useThreadPr(thread); @@ -890,8 +895,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} @@ -1022,27 +1028,25 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} {pr ? ( - {pr.kind === "stack" || pr.others > 0 ? ( - - ) : null} + - {pr.kind === "stack" || pr.others > 0 ? pr.label : `#${pr.label}`} + {pr.label} ) : null} @@ -1124,8 +1128,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -1177,6 +1182,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> )} `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); - const orderedSettled = [...settled].sort( - (left, right) => - parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - - parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), - ); + const orderedSettled = sortSettledThreads(settled); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const pagedSettled = orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 4e3e7339765c..0bcc0cd74958 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -5,13 +5,15 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, + cursorUsageWindowDetails, + displayLimitWindows, formatDuration, formatResetsIn, remainingPercent, type LimitAccount, type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; -import { useId, useState } from "react"; +import { Fragment, type ReactNode, useId, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -70,11 +72,15 @@ function PoolWindowCard({ color, now, environmentIds, + label, + description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; readonly environmentIds: readonly string[] | null; + readonly label?: string; + readonly description?: string; }) { const navigation = useNavigation(); const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); @@ -96,7 +102,7 @@ function PoolWindowCard({ - {pool.label} + {label ?? pool.label} {pool.remainingPercent}% @@ -108,6 +114,7 @@ function PoolWindowCard({ {PACE_LABEL[pool.pace]} ) : null} + {description ? {description} : null} {nextRefill ? ( ↻ +{nextRefill.restoresPercent}%{" "} @@ -196,10 +203,12 @@ export function UsageLimitsSection({ now, failedLabels, selectedEnvironmentIds, + cursorPrompt, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; + readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const selected = @@ -209,34 +218,54 @@ export function UsageLimitsSection({ const pools = collectLimitPools(collectLimitAccounts(selected), now); const notices = collectLimitNotices(selected); const colors = useProviderColors(); + const cursorPromptAt = + Math.max( + pools.findIndex((pool) => pool.driver === "codex"), + pools.findIndex((pool) => pool.driver === "claudeAgent"), + ) + 1; return ( - {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && !cursorPrompt ? ( {selected.size === 0 ? "Select an environment to see limits." : "No provider on the selected environments reports subscription limits."} ) : null} - {pools.map((pool) => ( - - - - - {DRIVER_LABEL[pool.driver] ?? pool.driver} - - - {pool.windows.map((window) => ( - - ))} - - ))} + {pools.map((pool, index) => { + const windows = displayLimitWindows(pool); + return ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {windows.map((window) => { + const details = + pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; + return ( + + ); + })} + + + ); + })} + {cursorPromptAt === pools.length ? cursorPrompt : null} {notices.length > 0 || failedLabels.length > 0 ? ( ([]); - const refresh = async (automatic = false) => { + const refresh = async (automatic = false, afterPending = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && @@ -307,6 +307,7 @@ export function useRefreshLimits( environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, + afterPending, ); if (result === undefined) return; setFailedEnvironments((previous) => [ @@ -354,5 +355,11 @@ export function useRefreshLimits( selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh: refreshManually }; + return { + now, + refreshing, + failedLabels, + refresh: refreshManually, + refreshAfterEnable: () => refresh(false, true), + }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 7494ac9b34bd..4582c3135690 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,6 +1,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; +import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -15,6 +16,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageContractMismatch, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -25,9 +27,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/SegmentedControl"; import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; @@ -59,6 +64,7 @@ const METRIC_OPTIONS = [ ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; const CHART_HEIGHT = 180; +const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; /** * Two tabs over one screen. Usage is the transcript-derived spend for a @@ -97,6 +103,27 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); + const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); + const refreshAfterCursorEnable = () => { + void refresh(); + void limits.refreshAfterEnable(); + }; + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + !source.action && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -251,7 +278,6 @@ export function UsageRouteScreen() { } > - 0 ? ( + + ) : null + } /> ) : ( <> @@ -301,6 +335,11 @@ export function UsageRouteScreen() { ) : ( <> + {sourceMessages.map((message) => ( + + {message} + + ))} - + 1} + onCursorEnabled={refreshAfterCursorEnable} + /> @@ -324,6 +369,110 @@ export function UsageRouteScreen() { ); } +function CursorEnableAction({ + environmentId, + label, + onEnabled, + buttonText = "Enable", +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onEnabled: () => void; + readonly buttonText?: string; +}) { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "enable Cursor account usage", + }); + const [pending, setPending] = useState(false); + const enable = async () => { + setPending(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: true } }, + }); + if (result._tag === "Success") onEnabled(); + } finally { + setPending(false); + } + }; + return ( + void enable()} + className="rounded-full bg-primary px-4 py-2" + > + {buttonText} + + ); +} + +function CursorEnableRow({ + environmentId, + label, + showEnvironment, + bordered, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly showEnvironment: boolean; + readonly bordered: boolean; + readonly onEnabled: () => void; +}) { + const colors = useProviderColors(); + return ( + + + + + Cursor{showEnvironment ? ` · ${label}` : ""} + + + + + ); +} + +function CursorEnableLimits({ + environments, + onEnabled, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly onEnabled: () => void; +}) { + return ( + + + + Cursor + + + {CURSOR_KEYCHAIN_COPY} + + {environments.map((environment) => ( + 1 ? `Enable on ${environment.label}` : "Enable"} + onEnabled={onEnabled} + /> + ))} + + + + ); +} + /** Headline figure, the animated daily chart, and its legend, in one card. */ function ChartCard(props: { readonly merged: MergedUsage; @@ -400,20 +549,53 @@ function ChartCard(props: { function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; + readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; + readonly showCursorEnvironment: boolean; + readonly onCursorEnabled: () => void; }) { const { merged, metric } = props; const colors = useProviderColors(); - if (merged.providers.length === 0) return null; + if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null; // Ranked by whatever the toggle is showing, so the rows always descend. // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. const ordered = [...merged.providers].sort((a, b) => metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); + const rows: Array< + | { readonly kind: "usage"; readonly provider: (typeof ordered)[number] } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = ordered.map((provider) => ({ kind: "usage", provider })); + const cursorInsertAt = + Math.max( + ordered.findIndex((provider) => provider.provider === "codex"), + ordered.findIndex((provider) => provider.provider === "claude"), + ) + 1; + rows.splice( + cursorInsertAt, + 0, + ...props.cursorAccessEnvironments.map((environment) => ({ + kind: "enable" as const, + environment, + })), + ); return ( - {ordered.map((provider, index) => { + {rows.map((row, index) => { + if (row.kind === "enable") { + return ( + 0} + onEnabled={props.onCursorEnabled} + /> + ); + } + const provider = row.provider; const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( = { claude: "Claude Code", codex: "Codex", grok: "Grok Build", + cursor: "Cursor", + opencode: "OpenCode", + antigravity: "Antigravity", }; /** @@ -23,5 +33,8 @@ export function useProviderColors(): Record { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + cursor: "#8b8b8b", + opencode: "#5b9bbd", + antigravity: "#8c7bd1", }; } diff --git a/apps/mobile/src/lib/androidControlSizing.test.ts b/apps/mobile/src/lib/androidControlSizing.test.ts new file mode 100644 index 000000000000..8b9fd9751cbb --- /dev/null +++ b/apps/mobile/src/lib/androidControlSizing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAndroidControlSizing } from "./androidControlSizing"; + +describe("Android control sizing", () => { + it.each([ + [11, 17, 48, 48, 172, 48], + [16, 24, 48, 56, 250, 48], + [22, 33, 66, 77, 344, 66], + ])( + "scales controls at %ipt", + (fontSize, iconSize, buttonSize, fabSize, menuWidth, menuItemHeight) => { + expect(resolveAndroidControlSizing(fontSize)).toMatchObject({ + iconSize, + buttonSize, + fabSize, + menuWidth, + menuItemHeight, + }); + }, + ); +}); diff --git a/apps/mobile/src/lib/androidControlSizing.ts b/apps/mobile/src/lib/androidControlSizing.ts new file mode 100644 index 000000000000..f6868ad07bdd --- /dev/null +++ b/apps/mobile/src/lib/androidControlSizing.ts @@ -0,0 +1,23 @@ +import { DEFAULT_BASE_FONT_SIZE, normalizeBaseFontSize } from "./appearancePreferences"; + +/** Android controls follow the app's text size; buttons and menu rows retain a 48dp touch target. */ +export function resolveAndroidControlSizing(baseFontSize: number) { + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + const iconSize = Math.round(24 * scale); + const buttonSize = Math.max(48, Math.round(48 * scale)); + const fabSize = Math.max(48, Math.round(56 * scale)); + + return { + scale, + iconSize, + smallIconSize: Math.round(16 * scale), + mediumIconSize: Math.round(18 * scale), + buttonSize, + fabSize, + largeFabSize: Math.round(96 * scale), + menuWidth: Math.round(250 * scale), + menuItemHeight: Math.max(48, Math.round(48 * scale)), + // Two floating actions, their gap, and the space below the lower action. + fabClearance: fabSize * 2 + 36, + }; +} diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index 2ce6a8b5a367..981b35808a54 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -219,6 +219,16 @@ export function scaledTypographyLineHeight( return Math.max(10, Math.round(role.lineHeight * scale)); } +/** Text dimensions shared by React Native and Compose consumers of an appearance role. */ +export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) { + const typography = MOBILE_TYPOGRAPHY[role]; + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + return { + fontSize: Math.max(8, Math.round(typography.fontSize * scale)), + lineHeight: scaledTypographyLineHeight(typography, baseFontSize), + }; +} + export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { const fontSizes = resolveMarkdownFontSizes(baseFontSize); return { diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts new file mode 100644 index 000000000000..885c583aa543 --- /dev/null +++ b/apps/mobile/src/lib/projectIcon.ts @@ -0,0 +1,81 @@ +import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; + +export type ProjectIconGlyph = + | { readonly kind: "emoji"; readonly emoji: string } + | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; + +/** + * Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining + * marks are folded into their base character instead of full grapheme segmentation. + */ +export function countGlyphs(text: string): number { + return Array.from(text.replace(/\p{M}/gu, "")).length; +} + +/** Mirrors the automatic monogram web derives from a project name when it has no favicon. */ +export function projectMonogram(projectName: string): string { + const words = + projectName + .normalize("NFKC") + .trim() + .match(/[\p{L}\p{N}]+/gu) ?? []; + const firstWord = words[0]; + if (!firstWord) return "PR"; + + const glyphs = Array.from(firstWord); + const first = glyphs[0] ?? "P"; + const second = + glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? + (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? + first; + return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); +} + +/** + * Picks what mobile draws for an assigned project icon. Mobile does not bundle + * the Lucide set, so a Lucide override keeps its color and falls back to the + * project's monogram instead of the folder glyph. + */ +export function resolveProjectIconGlyph( + projectIcon: ProjectIconOverride | null | undefined, + projectTitle: string, +): ProjectIconGlyph | null { + switch (projectIcon?.kind) { + case "emoji": + return { kind: "emoji", emoji: projectIcon.emoji }; + case "monogram": + return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; + case "lucide": + return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; + case undefined: + return null; + } +} + +const PROJECT_ICON_COLOR_CLASSES: Record< + ProjectIconColor, + { readonly text: string; readonly background: string } +> = { + gray: { text: "text-gray-500", background: "bg-gray-500/15" }, + red: { text: "text-red-500", background: "bg-red-500/15" }, + orange: { text: "text-orange-500", background: "bg-orange-500/15" }, + amber: { text: "text-amber-500", background: "bg-amber-500/15" }, + yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" }, + lime: { text: "text-lime-500", background: "bg-lime-500/15" }, + green: { text: "text-green-500", background: "bg-green-500/15" }, + emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" }, + teal: { text: "text-teal-500", background: "bg-teal-500/15" }, + cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" }, + sky: { text: "text-sky-500", background: "bg-sky-500/15" }, + blue: { text: "text-blue-500", background: "bg-blue-500/15" }, + indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" }, + violet: { text: "text-violet-500", background: "bg-violet-500/15" }, + purple: { text: "text-purple-500", background: "bg-purple-500/15" }, + fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" }, + pink: { text: "text-pink-500", background: "bg-pink-500/15" }, + rose: { text: "text-rose-500", background: "bg-rose-500/15" }, +}; + +export function projectIconColorClassNames(color: ProjectIconColor) { + return PROJECT_ICON_COLOR_CLASSES[color]; +} diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index d49c26a40a44..c5895d353fef 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; + readonly needsCursorKeychainAccess: boolean; } /** @@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); } return statuses; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 0df54be2f701..5e3b4c6e4afe 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -26,6 +26,7 @@ import * as Tracer from "effect/Tracer"; import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; +import * as TerminalManager from "../src/terminal/Manager.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; @@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = ( tryHandlePromptCommand: () => Effect.succeed(false), }), ), + Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })), Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), @@ -418,6 +420,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => Effect.void, }), ), diff --git a/apps/server/package.json b/apps/server/package.json index b7f1bce88768..105e4d0d96c3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,10 +27,11 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", - "node-pty": "^1.1.0", + "node-pty": "^1.2.0-beta.15", "stream-chain": "^4.2.5", "stream-json": "3.6.0", "yaml": "catalog:", diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index a841f4fd444c..5e026f584273 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -18,6 +18,9 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +/** Secret store name prefix of DPoP replay markers. The server prunes expired ones. */ +export const DPOP_REPLAY_MARKER_PREFIX = "dpop-proof-"; + export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => { switch (code) { case "time_window": @@ -96,7 +99,7 @@ export const verifyRequestDpopProof = (input: { ); yield* secretStore .create( - `dpop-proof-${replayKey}`, + `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`, new TextEncoder().encode( [ `thumbprint=${result.thumbprint}`, diff --git a/apps/server/src/auth/replayMarkers.test.ts b/apps/server/src/auth/replayMarkers.test.ts new file mode 100644 index 000000000000..76c31b0162bd --- /dev/null +++ b/apps/server/src/auth/replayMarkers.test.ts @@ -0,0 +1,92 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +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 TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import { pruneExpiredReplayMarkers, REPLAY_MARKER_MAX_AGE } from "./replayMarkers.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; + +// Every secret name the server stores today. The last three stand for names +// built from an id at runtime. +const REAL_SECRET_NAMES = [ + "server-signing-key", + "asset-access-signing-key", + "cloud-cli-oauth-token", + "cloud-cli-desired-link", + "cloud-link-ed25519-key-pair", + "cloud-link-ed25519-private-key", + "cloud-link-ed25519-public-key", + "cloud-mint-ed25519-public-key", + "cloud-endpoint-runtime-config", + "cloud-endpoint-confirmed-origin", + "cloud-linked-user-id", + "cloud-relay-url", + "cloud-relay-issuer", + "cloud-relay-environment-credential", + "cloud-publish-agent-activity", + "provider-env-Y29kZXg-T1BFTkFJX0FQSV9LRVk", + "provider-auth-0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", + "usage-limit-source-aHVi", +]; + +it.layer(NodeServices.layer)("replayMarkers", (it) => { + it.effect("prunes only replay markers older than the max age", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const { secretsDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const now = DateTime.makeUnsafe("2026-01-01T00:00:00Z"); + const setAge = (fileName: string, age: Duration.Duration) => { + const mtime = DateTime.toDateUtc(DateTime.subtractDuration(now, age)); + return fileSystem.utimes(path.join(secretsDir, fileName), mtime, mtime); + }; + const writeAged = (name: string, age: Duration.Duration) => + secretStore + .create(name, Uint8Array.from([1])) + .pipe(Effect.andThen(setAge(`${name}.bin`, age))); + + const justExpired = Duration.sum(REPLAY_MARKER_MAX_AGE, Duration.seconds(1)); + const expiredMarkers = [ + "dpop-proof-old", + "cloud-mint-jti-old", + "cloud-mint-nonce-old", + "cloud-health-jti-old", + "cloud-health-nonce-old", + ]; + for (const name of expiredMarkers) yield* writeAged(name, justExpired); + yield* writeAged("dpop-proof-at-max-age", REPLAY_MARKER_MAX_AGE); + for (const name of REAL_SECRET_NAMES) yield* writeAged(name, Duration.days(30)); + const pendingSetFile = "dpop-proof-pending.bin.0000.tmp"; + yield* fileSystem.writeFile(path.join(secretsDir, pendingSetFile), Uint8Array.from([1])); + yield* setAge(pendingSetFile, Duration.days(30)); + yield* TestClock.setTime(DateTime.toEpochMillis(now)); + + yield* pruneExpiredReplayMarkers(); + + const remaining = yield* fileSystem.readDirectory(secretsDir); + assert.deepStrictEqual( + remaining.toSorted(), + [ + ...REAL_SECRET_NAMES.map((name) => `${name}.bin`), + "dpop-proof-at-max-age.bin", + pendingSetFile, + ].toSorted(), + ); + }).pipe( + Effect.provide( + ServerSecretStore.layer.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-replay-markers-test-" }), + ), + ), + ), + ), + ); +}); diff --git a/apps/server/src/auth/replayMarkers.ts b/apps/server/src/auth/replayMarkers.ts new file mode 100644 index 000000000000..67b56b722334 --- /dev/null +++ b/apps/server/src/auth/replayMarkers.ts @@ -0,0 +1,77 @@ +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; + +import { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/http.ts"; +import * as ServerConfig from "../config.ts"; +import { forkParked } from "../serverActivation.ts"; +import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts"; + +const REPLAY_MARKER_PREFIXES = [DPOP_REPLAY_MARKER_PREFIX, ...CLOUD_REPLAY_MARKER_PREFIXES]; + +/** + * How long a replay marker stays on disk. A marker only matters while its proof + * can pass the time check (about 5 minutes for DPoP, 7 for cloud proofs). After + * that, the time check rejects a replay by itself. The sweep and the time check + * both use the wall clock, so a pruned marker can let a replay through only if + * the clock moves back by almost a day, or if the filesystem stamps mtimes almost + * a day behind. Markers are files, so a restart does not reset them. + */ +export const REPLAY_MARKER_MAX_AGE = Duration.days(1); + +/** + * Deletes replay markers whose mtime is older than `REPLAY_MARKER_MAX_AGE`. + * `ServerSecretStore` saves each secret as `.bin`, so only + * `*.bin` names match. Other secrets and the `*.bin..tmp` + * files that `set` writes are never touched. + */ +export const pruneExpiredReplayMarkers = Effect.fn("replayMarkers.pruneExpired")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { secretsDir } = yield* ServerConfig.ServerConfig; + const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(REPLAY_MARKER_MAX_AGE); + const markers = (yield* fileSystem.readDirectory(secretsDir)).filter( + (name) => + name.endsWith(".bin") && REPLAY_MARKER_PREFIXES.some((prefix) => name.startsWith(prefix)), + ); + // `partition` visits every marker, so one locked file does not stop the sweep. + const [failures, removed] = yield* Effect.partition(markers, (name) => { + const markerPath = path.join(secretsDir, name); + return fileSystem.stat(markerPath).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) + ? fileSystem.remove(markerPath).pipe(Effect.as(true)) + : Effect.succeed(false), + ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ); + }); + yield* Effect.annotateCurrentSpan({ + "replay_markers.matched": markers.length, + "replay_markers.removed": removed.filter(Boolean).length, + "replay_markers.failed": failures.length, + }); + if (failures.length > 0) { + yield* Effect.logWarning("Failed to prune some replay markers", { + failed: failures.length, + cause: failures[0], + }); + } +}); + +/** Prunes expired replay markers after server activation, then every hour. */ +export const layer = Layer.effectDiscard( + forkParked( + pruneExpiredReplayMarkers().pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to prune expired replay markers", { cause }), + ), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + ), + ), +); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 31a96304c3e5..aecf82eeac67 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -106,7 +106,6 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 0538b94fcde9..e30870aac873 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -24,6 +24,7 @@ import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { sshHelperCommand } from "./cli/sshHelper.ts"; import { themeCommand } from "./cli/theme.ts"; +import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => servicePreflightCommand, sshHelperCommand, themeCommand, + traceCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 7fa6065109c3..6df7d7534130 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -84,6 +84,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -293,6 +295,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -370,6 +373,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -432,6 +436,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f9b45caf7442..42932b927008 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -56,7 +56,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -509,7 +508,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { resolved.terminalLogsDir, resolved.attachmentsDir, resolved.worktreesDir, - path.dirname(resolved.serverLogPath), path.dirname(resolved.serverTracePath), ]) { expect(yield* fs.exists(directory)).toBe(true); @@ -999,4 +997,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); }), ); + + const minimalWebFlags = (baseDir: string) => ({ + mode: Option.some("web" as const), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }); + + it.effect( + "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-precedence-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + otlpMetricsUrl: "http://bootstrap:4318/v1/metrics", + // Blank, not an endpoint: it must not stand in front of Settings. + otlpLogsUrl: "", + }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + T3CODE_OTLP_HEADERS: "x-key=secret", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-traces:4318/custom", + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "http://otel-metrics:4318/custom", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=otel", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL wins over the OTEL variable for the same + // signal, and keeps T3 Code's own headers since T3 Code still owns it. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + expect(resolved.otlpTracesExport.headers).toEqual({ "x-key": "secret" }); + // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins + // over the bootstrap envelope and brings the OTEL headers and protocol. + expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom"); + expect(resolved.otlpMetricsExport).toEqual({ + ...DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-key": "otel" }, + }); + // Logs named no T3 or OTEL endpoint and a blank bootstrap value, so + // Settings answers, and logs keep the shared headers since no OTEL + // endpoint claimed them. + expect(resolved.otlpLogsUrl).toBe("http://settings:4318/v1/logs"); + expect(resolved.otlpLogsExport.headers).toEqual({ "x-key": "secret" }); + }), + ); + + it.effect( + "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-off-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL still wins outright. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + // The OTEL endpoint claimed metrics and logs, so neither the bootstrap + // envelope nor Settings receives them with T3 Code's headers. + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 1b6449433139..62461e286748 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -82,16 +82,22 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.optional, ); +// Trace file location, shared by the server and `t3 trace summary`. +export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe( + Config.option, + Config.map(Option.getOrUndefined), +); +export const traceMaxFilesConfig = Config.Int("T3CODE_TRACE_MAX_FILES").pipe( + Config.withDefault(10), +); + const EnvServerConfig = Config.all({ logLevel: Config.LogLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), traceMinLevel: Config.LogLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), traceTimingEnabled: Config.Boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), - traceFile: Config.String("T3CODE_TRACE_FILE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), + traceFile: traceFileConfig, traceMaxBytes: Config.Int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), - traceMaxFiles: Config.Int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), + traceMaxFiles: traceMaxFilesConfig, traceBatchWindowMs: Config.Int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)), otlpTracesUrl: Config.String("T3CODE_OTLP_TRACES_URL").pipe( Config.option, @@ -108,7 +114,6 @@ const EnvServerConfig = Config.all({ otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpServiceName: Config.String("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -396,6 +401,27 @@ export const resolveServerConfig = ( headers: env.otlpHeaders, exportIntervalMs: env.otlpExportIntervalMs, }; + const traces = OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: env.otlpTracesUrl, export: signalExport }, + bootstrap?.otlpTracesUrl, + persistedObservabilitySettings.otlpTracesUrl, + ); + const metrics = OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: env.otlpMetricsUrl, export: signalExport }, + bootstrap?.otlpMetricsUrl, + persistedObservabilitySettings.otlpMetricsUrl, + ); + const logs = OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: env.otlpLogsUrl, export: signalExport }, + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ); const config: ServerConfig.ServerConfig["Service"] = { logLevel, @@ -404,23 +430,12 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: otel.disabled - ? undefined - : (env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl), - otlpMetricsUrl: otel.disabled - ? undefined - : (env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl), - otlpLogsUrl: otel.disabled - ? undefined - : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl), - otlpTracesExport: signalExport, - otlpMetricsExport: signalExport, - otlpLogsExport: signalExport, - otlpServiceName: env.otlpServiceName, + otlpTracesUrl: traces?.url, + otlpMetricsUrl: metrics?.url, + otlpLogsUrl: logs?.url, + otlpTracesExport: traces?.export ?? signalExport, + otlpMetricsExport: metrics?.export ?? signalExport, + otlpLogsExport: logs?.export ?? signalExport, otelEnvironment: otel, mode, port, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 6b29ef60927f..04ce0332c14c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -326,7 +326,6 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, diff --git a/apps/server/src/cli/trace.test.ts b/apps/server/src/cli/trace.test.ts new file mode 100644 index 000000000000..b81a3ed53f25 --- /dev/null +++ b/apps/server/src/cli/trace.test.ts @@ -0,0 +1,100 @@ +import { assert, it } from "@effect/vitest"; + +import { makeTraceSpanSummary } from "./trace.ts"; + +const MINUTE_MS = 60_000; + +function span(name: string, durationMs: number, endMs: number, exitTag = "Success") { + return JSON.stringify({ + type: "effect-span", + name, + traceId: "trace", + spanId: "span", + durationMs, + endTimeUnixNano: String(BigInt(endMs) * 1_000_000n), + exit: { _tag: exitTag, cause: "cause" }, + }); +} + +function browserSpan(name: string, status: { code: string; message?: string }) { + return JSON.stringify({ + type: "otlp-span", + name, + durationMs: 1, + endTimeUnixNano: "1000000", + status, + }); +} + +it("reports count, rate, percentiles, and exits per span name", () => { + // Ten `refresh` spans of 1..10 ms end over minutes 0..9, and one `probe` + // span ends at minute 10, so the recorded window is 10 minutes. + const refreshes = Array.from({ length: 10 }, (_, index) => + span( + "refresh", + index + 1, + index * MINUTE_MS, + index === 0 ? "Interrupted" : index === 1 ? "Failure" : "Success", + ), + ); + const summarizer = makeTraceSpanSummary(); + [...refreshes, span("probe", 2_500, 10 * MINUTE_MS)].forEach(summarizer.addLine); + const summary = summarizer.finish(); + + assert.strictEqual(summary.spanCount, 11); + assert.strictEqual(summary.minutes, 10); + assert.deepStrictEqual(summary.spans, [ + { + name: "refresh", + count: 10, + perMinute: 1, + p50Ms: 5, + p90Ms: 9, + maxMs: 10, + interrupted: 1, + failures: 1, + }, + { + name: "probe", + count: 1, + perMinute: 0.1, + p50Ms: 2_500, + p90Ms: 2_500, + maxMs: 2_500, + interrupted: 0, + failures: 0, + }, + ]); +}); + +it("drops spans that ended before the window and counts unreadable lines", () => { + const summarizer = makeTraceSpanSummary(MINUTE_MS); + [ + span("old", 1, 0), + "", + "{not json", + JSON.stringify({ name: "no-duration" }), + // Ends past the largest Date, so the report could not print it. + JSON.stringify({ name: "far-future", durationMs: 1, endTimeUnixNano: "9".repeat(22) }), + span("recent", 4, 5 * MINUTE_MS), + ].forEach(summarizer.addLine); + const summary = summarizer.finish(); + + assert.strictEqual(summary.skippedLineCount, 3); + assert.deepStrictEqual( + summary.spans.map((entry) => [entry.name, entry.count, entry.perMinute]), + [["recent", 1, undefined]], + ); +}); + +it("reads failures and interrupts of browser spans from their OTLP status", () => { + const summarizer = makeTraceSpanSummary(); + [ + browserSpan("render", { code: "2", message: "boom" }), + 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]); +}); diff --git a/apps/server/src/cli/trace.ts b/apps/server/src/cli/trace.ts new file mode 100644 index 000000000000..61a209471d6e --- /dev/null +++ b/apps/server/src/cli/trace.ts @@ -0,0 +1,208 @@ +/** + * `t3 trace summary` - per-span counts, rates, and latency percentiles from + * the local server trace file and its rotated backups. It reads the files + * directly, so it works while the server is stalled or stopped. + */ +import { PositiveInt } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; + +import * as ServerConfig from "../config.ts"; +import { streamTraceFileLines, toRotatedTracePaths } from "../diagnostics/TraceDiagnostics.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag, DurationFromString, traceFileConfig, traceMaxFilesConfig } from "./config.ts"; + +// Only the fields the summary needs. Other record fields are ignored. +const decodeTraceSpanLine = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + name: Schema.String, + durationMs: Schema.Finite, + endTimeUnixNano: Schema.FiniteFromString, + // 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. + status: Schema.optional( + Schema.Struct({ + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +/** + * Groups trace NDJSON by span name. Call `addLine` once per line as the files + * stream in, then `finish` for the summary. Spans that ended before `sinceMs` + * are left out. Rates are per minute between the first and last span end, + * since spans are written when they end. + */ +export function makeTraceSpanSummary(sinceMs = -Infinity) { + // Keep each span's duration (8 bytes, a few MB for the default 110 MB of + // rotated traces) for exact percentiles. A bounded sketch would save little + // and make p50 and p90 approximate. + const byName = new Map(); + let spanCount = 0; + let skippedLineCount = 0; + let firstEndMs = Infinity; + let lastEndMs = -Infinity; + + const addLine = (line: string) => { + if (line.trim().length === 0) return; + const span = Option.getOrUndefined(decodeTraceSpanLine(line)); + if (span === undefined) { + skippedLineCount += 1; + return; + } + const endMs = span.endTimeUnixNano / 1_000_000; + // The report prints end times as dates, so skip ones outside the Date range. + if (Option.isNone(DateTime.make(endMs))) { + skippedLineCount += 1; + return; + } + if (endMs < sinceMs) return; + + spanCount += 1; + firstEndMs = Math.min(firstEndMs, endMs); + 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") { + stats.interrupted += 1; + } + if (span.exit?._tag === "Failure" || span.status?.code === "2") stats.failures += 1; + byName.set(span.name, stats); + }; + + const finish = () => { + const minutes = (lastEndMs - firstEndMs) / 60_000; + const spans = [...byName] + .map(([name, { durations, interrupted, failures }]) => { + const sorted = durations.toSorted((left, right) => left - right); + // Nearest-rank percentile. + const percentile = (p: number) => sorted[Math.ceil(p * sorted.length) - 1]!; + return { + name, + count: sorted.length, + perMinute: minutes > 0 ? sorted.length / minutes : undefined, + p50Ms: percentile(0.5), + p90Ms: percentile(0.9), + maxMs: sorted[sorted.length - 1]!, + interrupted, + failures, + }; + }) + .toSorted((left, right) => right.count - left.count || left.name.localeCompare(right.name)); + + return { spanCount, skippedLineCount, firstEndMs, lastEndMs, minutes, spans }; + }; + + return { addLine, finish }; +} + +const formatMs = (ms: number) => + ms < 1_000 ? `${Math.round(ms)}ms` : `${(ms / 1_000).toFixed(1)}s`; + +function formatTraceSummary( + summary: ReturnType["finish"]>, + limit: number, +) { + const header = ["span", "count", "/min", "p50", "p90", "max", "interrupted", "failed"]; + const rows = summary.spans + .slice(0, limit) + .map((span) => [ + span.name, + String(span.count), + span.perMinute === undefined + ? "-" + : span.perMinute < 0.1 + ? "<0.1" + : span.perMinute.toFixed(1), + formatMs(span.p50Ms), + formatMs(span.p90Ms), + formatMs(span.maxMs), + String(span.interrupted), + String(span.failures), + ]); + const table = [header, ...rows]; + const widths = header.map((_, column) => Math.max(...table.map((row) => row[column]!.length))); + const formatIso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + return [ + `${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1)} min).`, + ...(summary.skippedLineCount > 0 + ? [`Skipped ${summary.skippedLineCount} lines that are not spans.`] + : []), + "", + ...table.map((row) => + row + .map((cell, column) => + column === 0 ? cell.padEnd(widths[column]!) : cell.padStart(widths[column]!), + ) + .join(" "), + ), + ...(summary.spans.length > limit + ? ["", `${summary.spans.length - limit} more span names. Use --limit to show more.`] + : []), + ].join("\n"); +} + +const traceSummaryCommand = Command.make("summary", { + baseDir: baseDirFlag, + since: Flag.String("since").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("Only count spans that ended in this window, for example 30m or 2h."), + Flag.optional, + ), + limit: Flag.Int("limit").pipe( + Flag.withSchema(PositiveInt), + Flag.withDescription("Number of span names to show, busiest first."), + Flag.withDefault(25), + ), +}).pipe( + Command.withDescription("Summarize the local server trace file: counts, rates, and latency."), + Command.withHandler( + Effect.fn("cli.trace.summary")(function* (flags) { + const fs = yield* FileSystem.FileSystem; + // T3CODE_TRACE_FILE, else the userdata trace file for --base-dir or + // T3CODE_HOME. Implicit dev runs write elsewhere; set T3CODE_TRACE_FILE. + const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined(Option.orElse(flags.baseDir, () => envHome)), + ); + const traceFilePath = + (yield* traceFileConfig) ?? + (yield* ServerConfig.deriveServerPaths(baseDir, undefined)).serverTracePath; + const sinceMs = Option.isSome(flags.since) + ? (yield* Clock.currentTimeMillis) - Duration.toMillis(flags.since.value) + : undefined; + const summarizer = makeTraceSpanSummary(sinceMs); + yield* Effect.forEach( + toRotatedTracePaths(traceFilePath, yield* traceMaxFilesConfig), + (path) => streamTraceFileLines(fs, path, summarizer.addLine), + { discard: true }, + ); + const summary = summarizer.finish(); + + yield* Console.log( + summary.spanCount === 0 + ? `No spans found in ${traceFilePath} or its rotated files${sinceMs === undefined ? "" : " in that window"}.${summary.skippedLineCount > 0 ? ` Skipped ${summary.skippedLineCount} lines that are not spans.` : ""}` + : formatTraceSummary(summary, flags.limit), + ); + }), + ), +); + +export const traceCommand = Command.make("trace").pipe( + Command.withDescription("Inspect the local server trace file."), + Command.withSubcommands([traceSummaryCommand]), +); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index c621d331c404..b408e3550112 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -27,6 +27,7 @@ import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; @@ -201,7 +202,11 @@ export const triageCommand = Command.make("triage", { dbPath: paths.dbPath, settingsPath: paths.settingsPath, logsDir: paths.logsDir, - serverLogPath: paths.serverLogPath, + // The server writes no log file of its own. Service installs and the + // desktop app capture its output. The glob covers every desktop backend + // (such as WSL) and rotated copies; names come from DesktopObservability.ts. + serviceLogPath: path.join(paths.logsDir, BootService.BOOT_SERVICE_LOG_FILE), + desktopBackendLogGlob: path.join(paths.logsDir, "server-child*.log*"), serverTracePath: paths.serverTracePath, providerEventLogPath: paths.providerEventLogPath, terminalLogsDir: paths.terminalLogsDir, diff --git a/apps/server/src/cli/triagePrompt.test.ts b/apps/server/src/cli/triagePrompt.test.ts index bf1ac5dbbe5e..fe65bf0dc444 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -52,7 +52,8 @@ it("context file carries every path the playbook depends on", () => { dbPath: "/home/u/.t3/userdata/state.sqlite", settingsPath: "/home/u/.t3/userdata/settings.json", logsDir: "/home/u/.t3/userdata/logs", - serverLogPath: "/home/u/.t3/userdata/logs/server.log", + serviceLogPath: "/home/u/.t3/userdata/logs/boot-service.log", + desktopBackendLogGlob: "/home/u/.t3/userdata/logs/server-child*.log*", serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", @@ -63,6 +64,8 @@ it("context file carries every path the playbook depends on", () => { }); assert.include(context, "/home/u/.t3/userdata/state.sqlite"); assert.include(context, "/home/u/.t3/userdata/logs/server.trace.ndjson"); + assert.include(context, "/home/u/.t3/userdata/logs/boot-service.log"); + assert.include(context, "/home/u/.t3/userdata/logs/server-child*.log*"); assert.include(context, "/home/u/.t3/userdata/logs/provider/events.log"); assert.include(context, "/home/u/.t3/userdata/secrets"); assert.include(context, "/home/u/.t3/source"); diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index c2b93a1840a1..1df712854263 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/cli/triagePrompt.ts @@ -71,8 +71,9 @@ different code depending on it: Then work from evidence, not assumption. In rough order of value: -- The server log and the trace file (\`server.trace.ndjson\`) around the time of the - problem. Recent failures usually leave a trail here. +- The trace file (\`server.trace.ndjson\`) around the time of the problem, plus the + service log or desktop backend logs from the context file if they exist. Recent + failures usually leave a trail here. - The provider event log, for problems with claude/codex/cursor sessions. - The SQLite database. Read it freely, but only write when a write is necessary to fix the problem the user described, and get their explicit permission @@ -176,7 +177,8 @@ export interface TriageContextInput { readonly dbPath: string; readonly settingsPath: string; readonly logsDir: string; - readonly serverLogPath: string; + readonly serviceLogPath: string; + readonly desktopBackendLogGlob: string; readonly serverTracePath: string; readonly providerEventLogPath: string; readonly terminalLogsDir: string; @@ -205,7 +207,8 @@ Generated by \`t3 triage\` at ${input.generatedAt}. - Database (SQLite; write only with the user's explicit permission): ${input.paths.dbPath} - Settings: ${input.paths.settingsPath} - Logs dir: ${input.paths.logsDir} -- Server log: ${input.paths.serverLogPath} +- Service log (systemd/launchd service installs only): ${input.paths.serviceLogPath} +- Desktop backend logs (glob; one file per backend plus rotated copies; written only when a backend crashes or fails to start): ${input.paths.desktopBackendLogGlob} - Server trace (ndjson): ${input.paths.serverTracePath} - Provider event log: ${input.paths.providerEventLogPath} - Terminal logs: ${input.paths.terminalLogsDir} diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0a57ed822af4..fc8fc1549a6b 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -43,6 +43,8 @@ const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; +/** File in the logs dir that receives the service's stdout and stderr. `t3 triage` points agents at it. */ +export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ function escapeSystemdSpecifiers(value: string): string { @@ -599,7 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { environmentPath, }); const unitPath = detectedManager?.unitPath ?? ""; - const logPath = path.join(input.logsDir, "boot-service.log"); + const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 64a512a33aba..1d6b78e56cef 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -33,6 +33,7 @@ import { type ServiceUpdateRecord, } from "./serviceProtocol.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { @@ -81,6 +82,12 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); +// Linking wakes the awareness relay; these tests do not run it. +const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ + publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, + start: () => Effect.void, +}); const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect( Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest), ); @@ -262,6 +269,7 @@ describe("reconcileDesiredCloudLink", () => { HttpClient.HttpClient, HttpClient.make(() => unusedSecretStoreOperation()), ), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provide(NodeServices.layer), ), ); @@ -375,6 +383,7 @@ describe("releaseManagedTunnelOnShutdown", () => { (effect: Effect.Effect) => effect.pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provideService( ServerEnvironment.ServerEnvironment, ServerEnvironment.ServerEnvironment.of({ diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index b4c366a500c1..a7e3de8c7283 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -69,6 +69,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { SERVICE_STATE_FILE, @@ -104,6 +105,13 @@ const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; +/** Secret store name prefixes of cloud replay markers. The server prunes expired ones. */ +export const CLOUD_REPLAY_MARKER_PREFIXES = [ + CLOUD_MINT_NONCE_PREFIX, + CLOUD_MINT_JTI_PREFIX, + CLOUD_HEALTH_NONCE_PREFIX, + CLOUD_HEALTH_JTI_PREFIX, +] as const; const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; // The desktop app stops its backends within seconds of writing the marker. @@ -404,6 +412,7 @@ interface CloudHttpDependencies { readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; readonly httpClient: HttpClient.HttpClient; + readonly awarenessRelay: AgentAwarenessRelay.AgentAwarenessRelay["Service"]; } const cloudHttpDependencies = Effect.gen(function* () { @@ -414,6 +423,7 @@ const cloudHttpDependencies = Effect.gen(function* () { environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, httpClient: yield* HttpClient.HttpClient, + awarenessRelay: yield* AgentAwarenessRelay.AgentAwarenessRelay, } satisfies CloudHttpDependencies; }); @@ -661,6 +671,7 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey), ); + yield* dependencies.awarenessRelay.requestCatchUp(); if (payload.endpointRuntime) { const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); yield* dependencies.secrets.set( @@ -1342,6 +1353,7 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes(String(payload.publishAgentActivity)), ); + yield* dependencies.awarenessRelay.requestCatchUp(); return yield* readCloudLinkState(dependencies); }, Effect.catchIf( diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index e35c94545a5e..eeea28a2b68f 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -7,14 +7,14 @@ const relayClientTracingConfig = resolveRelayClientTracingConfig(); export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( relayClientTracingConfig, { - serviceName: "t3-headless-relay-client", + serviceName: "t3code-server", runtime: "node", client: "headless-cli", }, ); export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { - serviceName: "t3-server", + serviceName: "t3code-server", runtime: "node", client: "environment-server", component: "relay-broker", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 344619c0eb93..d8dad5ae4d24 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -44,7 +44,6 @@ export interface ServerDerivedPaths { /** Screenshots the agent asks the collaborative browser to keep for the user. */ readonly browserArtifactsDir: string; readonly logsDir: string; - readonly serverLogPath: string; readonly serverTracePath: string; readonly providerLogsDir: string; readonly providerEventLogPath: string; @@ -82,7 +81,6 @@ export class ServerConfig extends Context.Service< readonly otlpTracesExport: SignalExport; readonly otlpMetricsExport: SignalExport; readonly otlpLogsExport: SignalExport; - readonly otlpServiceName: string; readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; @@ -119,8 +117,9 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) * logs report the same service identity to the collector. */ export const otlpResource = (config: ServerConfig["Service"]) => ({ - serviceName: config.otlpServiceName, + serviceName: "t3code-server", attributes: { + "service.namespace": "t3code", "service.runtime": "t3-server", "service.mode": config.mode, }, @@ -154,7 +153,6 @@ export const deriveServerPaths = Effect.fn(function* ( attachmentsDir, browserArtifactsDir: join(stateDir, "browser-artifacts"), logsDir, - serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), providerLogsDir, providerEventLogPath: join(providerLogsDir, "events.log"), @@ -222,7 +220,6 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd, baseDir, diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 70bb4dc815c3..3b86841b99ba 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -7,6 +7,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as Stream from "effect/Stream"; import * as TraceDiagnostics from "./TraceDiagnostics.ts"; @@ -40,72 +41,78 @@ function record(input: { }); } +const traceFilePath = "/tmp/server.trace.ndjson"; +const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + +/** Aggregates whole lines in memory, the way diagnostics read traces before streaming. */ +function aggregateLines(lines: ReadonlyArray) { + const aggregator = TraceDiagnostics.makeTraceDiagnosticsAggregator(); + lines.forEach(aggregator.addLine); + return aggregator.finish({ + traceFilePath, + scannedFilePaths: TraceDiagnostics.toRotatedTracePaths(traceFilePath, 1), + readAt, + }); +} + +/** Reads the trace file and one rotated backup through a fake file system. */ +function readTraces(fileSystem: Partial) { + return TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt }).pipe( + Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(FileSystem.layerNoop(fileSystem)))), + ); +} + describe("TraceDiagnostics", () => { it.effect("aggregates failures, slow spans, log levels, and parse errors", () => Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - slowSpanThresholdMs: 1_000, - files: [ - { - path: "/tmp/server.trace.ndjson.1", - text: [ - record({ - name: "server.getConfig", - traceId: "trace-a", - spanId: "span-a", - startMs: 1_000, - durationMs: 50, - }), - "not-json", - ].join("\n"), - }, - { - path: "/tmp/server.trace.ndjson", - text: [ - record({ - name: "orchestration.dispatch", - traceId: "trace-b", - spanId: "span-b", - startMs: 2_000, - durationMs: 1_500, - exit: { _tag: "Failure", cause: "Provider crashed" }, - events: [ - { - name: "provider failed", - timeUnixNano: ns(3_400), - attributes: { "effect.logLevel": "Error" }, - }, - ], - }), - record({ - name: "orchestration.dispatch", - traceId: "trace-c", - spanId: "span-c", - startMs: 4_000, - durationMs: 250, - exit: { _tag: "Failure", cause: "Provider crashed" }, - }), - record({ - name: "git.status", - traceId: "trace-d", - spanId: "span-d", - startMs: 5_000, - durationMs: 25, - exit: { _tag: "Interrupted", cause: "Interrupted" }, - events: [ - { - name: "status delayed", - timeUnixNano: ns(5_010), - attributes: { "effect.logLevel": "Warning" }, - }, - ], - }), - ].join("\n"), - }, - ], - }); + const diagnostics = aggregateLines([ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + "not-json", + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: "provider failed", + timeUnixNano: ns(3_400), + attributes: { "effect.logLevel": "Error" }, + }, + ], + }), + record({ + name: "orchestration.dispatch", + traceId: "trace-c", + spanId: "span-c", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed" }, + }), + record({ + name: "git.status", + traceId: "trace-d", + spanId: "span-d", + startMs: 5_000, + durationMs: 25, + exit: { _tag: "Interrupted", cause: "Interrupted" }, + events: [ + { + name: "status delayed", + timeUnixNano: ns(5_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ]); assert.equal(diagnostics.recordCount, 4); assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z"); @@ -139,47 +146,110 @@ describe("TraceDiagnostics", () => { ); it.effect("returns a not-found diagnostic when no files are available", () => - Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/missing.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [], - }); + Effect.gen(function* () { + const diagnostics = yield* readTraces({}); assert.equal(diagnostics.recordCount, 0); assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-not-found"); }), ); - it.effect("preserves full failure causes and log messages", () => - Effect.sync(() => { - const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); - const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [ - { - path: "/tmp/server.trace.ndjson", - text: record({ - name: "VcsProcess.run", - traceId: "trace-long", - spanId: "span-long", + it.effect("streams rotated files into the same result as reading them whole", () => + Effect.gen(function* () { + // CRLF and LF endings plus multi-byte text, served one byte per chunk so + // chunks split lines, line endings, and characters. + const files = new Map([ + [ + `${traceFilePath}.1`, + [ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", startMs: 1_000, + durationMs: 50, + }), + "not-json", + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" }, + }), + "", + ].join("\r\n"), + ], + [ + traceFilePath, + [ + record({ + name: "git.status", + traceId: "trace-c", + spanId: "span-c", + startMs: 3_000, durationMs: 25, - exit: { _tag: "Failure", cause: longCause }, + exit: { _tag: "Interrupted", cause: "Interrupted" }, events: [ { - name: longMessage, - timeUnixNano: ns(1_010), + name: "status delayed ⏳", + timeUnixNano: ns(3_010), attributes: { "effect.logLevel": "Warning" }, }, ], }), - }, + "", + record({ + name: "orchestration.dispatch", + traceId: "trace-d", + spanId: "span-d", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" }, + }), + ].join("\n"), ], + ]); + const encoder = new TextEncoder(); + + const diagnostics = yield* readTraces({ + stream: (path) => + Stream.fromIterable( + Array.from(encoder.encode(files.get(path)), (byte) => Uint8Array.of(byte)), + ), }); + assert.equal(diagnostics.recordCount, 4); + assert.deepStrictEqual( + diagnostics, + aggregateLines([...files.values()].flatMap((text) => text.split(/\r?\n/))), + ); + }), + ); + + it.effect("preserves full failure causes and log messages", () => + Effect.sync(() => { + const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); + const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); + const diagnostics = aggregateLines([ + record({ + name: "VcsProcess.run", + traceId: "trace-long", + spanId: "span-long", + startMs: 1_000, + durationMs: 25, + exit: { _tag: "Failure", cause: longCause }, + events: [ + { + name: longMessage, + timeUnixNano: ns(1_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ]); + assert.equal(diagnostics.latestFailures[0]?.cause, longCause); assert.equal(diagnostics.commonFailures[0]?.cause, longCause); assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, longMessage); @@ -188,45 +258,34 @@ describe("TraceDiagnostics", () => { it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { - const traceFilePath = "/tmp/server.trace.ndjson"; const readFailure = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", - method: "readFileString", + method: "open", description: "permission denied", pathOrDescriptor: `${traceFilePath}.1`, }); - const fileSystemLayer = FileSystem.layerNoop({ - readFileString: (path) => - path === `${traceFilePath}.1` - ? Effect.fail(readFailure) - : Effect.succeed( - record({ - name: "server.getConfig", - traceId: "trace-a", - spanId: "span-a", - startMs: 1_000, - durationMs: 50, - }), - ), - }); const logAnnotations: Array> = []; const logger = Logger.make((options) => { logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) }); }); - const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ - traceFilePath, - maxFiles: 1, - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - }).pipe( - Effect.provide( - Layer.mergeAll( - TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); + const diagnostics = yield* readTraces({ + stream: (path) => + path === `${traceFilePath}.1` + ? Stream.fail(readFailure) + : Stream.make( + new TextEncoder().encode( + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + ), + ), + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); assert.equal(diagnostics.recordCount, 1); assert.equal( @@ -251,32 +310,43 @@ describe("TraceDiagnostics", () => { }), ); - it.effect("keeps only the slowest span occurrences while aggregating large inputs", () => + it.effect("keeps only the top spans, failures, and warning logs from large inputs", () => Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [ - { - path: "/tmp/server.trace.ndjson", - text: Array.from({ length: 25 }, (_, index) => - record({ - name: `span-${index}`, - traceId: `trace-${index}`, - spanId: `span-${index}`, - startMs: index * 1_000, - durationMs: index, - }), - ).join("\n"), - }, - ], - }); + // Shuffled, so some older records arrive after the lists are full. + const indexes = Array.from({ length: 30 }, (_, step) => (step * 7) % 30); + const diagnostics = aggregateLines( + indexes.map((index) => + record({ + name: `span-${index}`, + traceId: `trace-${index}`, + spanId: `span-${index}`, + startMs: index * 1_000, + durationMs: index, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: `warning ${index}`, + timeUnixNano: ns(index * 1_000), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ), + ); + const newestTwenty = Array.from({ length: 20 }, (_, rank) => `trace-${29 - rank}`); - assert.equal(diagnostics.recordCount, 25); - assert.equal(diagnostics.slowestSpans.length, 10); + assert.equal(diagnostics.recordCount, 30); assert.deepStrictEqual( diagnostics.slowestSpans.map((span) => span.durationMs), - [24, 23, 22, 21, 20, 19, 18, 17, 16, 15], + [29, 28, 27, 26, 25, 24, 23, 22, 21, 20], + ); + assert.deepStrictEqual( + diagnostics.latestFailures.map((failure) => failure.traceId), + newestTwenty, + ); + assert.deepStrictEqual( + diagnostics.latestWarningAndErrorLogs.map((log) => log.traceId), + newestTwenty, ); }), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index ca5552058f3f..6dbc4afdec93 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -16,6 +16,7 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; interface TraceRecordLike { readonly name?: unknown; @@ -63,16 +64,6 @@ export class TraceDiagnostics extends Context.Service< } >()("t3/diagnostics/TraceDiagnostics") {} -interface TraceDiagnosticsInput { - readonly traceFilePath: string; - readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>; - readonly scannedFilePaths?: ReadonlyArray; - readonly slowSpanThresholdMs?: number; - readonly readAt: DateTime.Utc; - readonly error?: TraceDiagnosticsErrorSummary; - readonly partialFailure?: boolean; -} - interface TraceDiagnosticsErrorSummary { readonly kind: ServerTraceDiagnosticsErrorKind; readonly message: string; @@ -81,7 +72,12 @@ interface TraceDiagnosticsErrorSummary { const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000; const TOP_LIMIT = 10; const RECENT_LIMIT = 20; -function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray { + +/** The trace file and its rotated backups, oldest first. */ +export function toRotatedTracePaths( + traceFilePath: string, + maxFiles: number, +): ReadonlyArray { const backupCount = Math.max(0, Math.floor(maxFiles)); const backups = Array.from( { length: backupCount }, @@ -168,44 +164,50 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } -function insertBoundedSlowestSpan( - slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], - span: ServerTraceDiagnosticsSpanOccurrence, +/** + * Adds `item` to `items`, which stays sorted by `order` and holds at most + * `limit` entries. Same result as a stable sort and slice over every item, but + * memory stays bounded however many items stream in. + */ +function insertBounded( + items: A[], + item: A, + limit: number, + order: (left: A, right: A) => number, ): void { - if ( - slowestSpans.length >= TOP_LIMIT && - span.durationMs <= slowestSpans[slowestSpans.length - 1]!.durationMs - ) { + if (items.length >= limit && order(item, items[items.length - 1]!) >= 0) { return; } - slowestSpans.push(span); - slowestSpans.sort((left, right) => right.durationMs - left.durationMs); - if (slowestSpans.length > TOP_LIMIT) { - slowestSpans.length = TOP_LIMIT; + items.push(item); + items.sort(order); + if (items.length > limit) { + items.length = limit; } } -export function aggregateTraceDiagnostics( - input: TraceDiagnosticsInput, -): ServerTraceDiagnosticsResult { - const readAt = input.readAt; - const slowSpanThresholdMs = input.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; - const scannedFilePaths = input.scannedFilePaths ?? input.files.map((file) => file.path); - if (input.files.length === 0) { - return makeEmptyDiagnostics({ - traceFilePath: input.traceFilePath, - scannedFilePaths, - readAt, - slowSpanThresholdMs, - error: input.error ?? { - kind: "trace-file-not-found", - message: "No local trace files were found.", - }, - ...(input.partialFailure ? { partialFailure: true } : {}), - }); - } - +const slowestFirst = ( + left: ServerTraceDiagnosticsSpanOccurrence, + right: ServerTraceDiagnosticsSpanOccurrence, +) => right.durationMs - left.durationMs; + +const latestEndedFirst = ( + left: ServerTraceDiagnosticsRecentFailure, + right: ServerTraceDiagnosticsRecentFailure, +) => DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt); + +const latestSeenFirst = ( + left: ServerTraceDiagnosticsLogEvent, + right: ServerTraceDiagnosticsLogEvent, +) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt); + +/** + * Folds trace NDJSON into diagnostics. Call `addLine` once per line as the + * rotated files stream in, then `finish` for the result. + */ +export function makeTraceDiagnosticsAggregator( + slowSpanThresholdMs = DEFAULT_SLOW_SPAN_THRESHOLD_MS, +) { let parseErrorCount = 0; let recordCount = 0; let failureCount = 0; @@ -224,182 +226,184 @@ export function aggregateTraceDiagnostics( const latestWarningAndErrorLogs: ServerTraceDiagnosticsLogEvent[] = []; const logLevelCounts: Record = {}; - for (const file of input.files) { - const lines = file.text.split(/\r?\n/); - for (const line of lines) { - if (line.trim().length === 0) continue; - - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - parseErrorCount += 1; - continue; - } + const addLine = (line: string) => { + if (line.trim().length === 0) return; - if (!isRecordObject(parsed)) { - parseErrorCount += 1; - continue; - } + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + parseErrorCount += 1; + return; + } - const name = toStringValue(parsed.name); - const traceId = toStringValue(parsed.traceId); - const spanId = toStringValue(parsed.spanId); - const durationMs = toNumberValue(parsed.durationMs); - const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); - const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); + if (!isRecordObject(parsed)) { + parseErrorCount += 1; + return; + } - if (!name || !traceId || !spanId || durationMs === null || !endedAt) { - parseErrorCount += 1; - continue; - } + const name = toStringValue(parsed.name); + const traceId = toStringValue(parsed.traceId); + const spanId = toStringValue(parsed.spanId); + const durationMs = toNumberValue(parsed.durationMs); + const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); + const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); - recordCount += 1; - firstSpanAt = - startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) - ? startedAt - : firstSpanAt; - lastSpanAt = - lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; - - const exitTag = readExitTag(parsed.exit); - const isFailure = exitTag === "Failure"; - const isInterrupted = exitTag === "Interrupted"; - if (isFailure) failureCount += 1; - if (isInterrupted) interruptionCount += 1; - - const spanSummary = spansByName.get(name) ?? { - count: 0, - failureCount: 0, - totalDurationMs: 0, - maxDurationMs: 0, - }; - spanSummary.count += 1; - spanSummary.totalDurationMs += durationMs; - spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); - if (isFailure) spanSummary.failureCount += 1; - spansByName.set(name, spanSummary); - - const spanItem = { name, durationMs, endedAt, traceId, spanId }; - if (durationMs >= slowSpanThresholdMs) { - slowSpanCount += 1; - } - insertBoundedSlowestSpan(slowestSpans, spanItem); - - if (isFailure) { - const cause = readExitCause(parsed.exit); - latestFailures.push({ ...spanItem, cause }); - - const failureKey = `${name}\0${cause}`; - const existing = failuresByKey.get(failureKey); - const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); - failuresByKey.set(failureKey, { - name, - cause, - count: (existing?.count ?? 0) + 1, - lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, - traceId: isLatestFailure ? traceId : existing!.traceId, - spanId: isLatestFailure ? spanId : existing!.spanId, - }); - } + if (!name || !traceId || !spanId || durationMs === null || !endedAt) { + parseErrorCount += 1; + return; + } - if (Array.isArray(parsed.events)) { - for (const rawEvent of parsed.events) { - if (!isTraceEvent(rawEvent)) continue; - const attributes = readEventAttributes(rawEvent); - const level = toStringValue(attributes["effect.logLevel"]); - if (!level) continue; - - logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; - const normalizedLevel = level.toLowerCase(); - if ( - normalizedLevel !== "warning" && - normalizedLevel !== "warn" && - normalizedLevel !== "error" && - normalizedLevel !== "fatal" - ) { - continue; - } - - const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; - const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; - latestWarningAndErrorLogs.push({ - spanName: name, - level, - message, - seenAt, - traceId, - spanId, - }); + recordCount += 1; + firstSpanAt = + startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) + ? startedAt + : firstSpanAt; + lastSpanAt = + lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; + + const exitTag = readExitTag(parsed.exit); + const isFailure = exitTag === "Failure"; + const isInterrupted = exitTag === "Interrupted"; + if (isFailure) failureCount += 1; + if (isInterrupted) interruptionCount += 1; + + const spanSummary = spansByName.get(name) ?? { + count: 0, + failureCount: 0, + totalDurationMs: 0, + maxDurationMs: 0, + }; + spanSummary.count += 1; + spanSummary.totalDurationMs += durationMs; + spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); + if (isFailure) spanSummary.failureCount += 1; + spansByName.set(name, spanSummary); + + const spanItem = { name, durationMs, endedAt, traceId, spanId }; + if (durationMs >= slowSpanThresholdMs) { + slowSpanCount += 1; + } + insertBounded(slowestSpans, spanItem, TOP_LIMIT, slowestFirst); + + if (isFailure) { + const cause = readExitCause(parsed.exit); + insertBounded(latestFailures, { ...spanItem, cause }, RECENT_LIMIT, latestEndedFirst); + + const failureKey = `${name}\0${cause}`; + const existing = failuresByKey.get(failureKey); + const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); + failuresByKey.set(failureKey, { + name, + cause, + count: (existing?.count ?? 0) + 1, + lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, + traceId: isLatestFailure ? traceId : existing!.traceId, + spanId: isLatestFailure ? spanId : existing!.spanId, + }); + } + + if (Array.isArray(parsed.events)) { + for (const rawEvent of parsed.events) { + if (!isTraceEvent(rawEvent)) continue; + const attributes = readEventAttributes(rawEvent); + const level = toStringValue(attributes["effect.logLevel"]); + if (!level) continue; + + logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; + const normalizedLevel = level.toLowerCase(); + if ( + normalizedLevel !== "warning" && + normalizedLevel !== "warn" && + normalizedLevel !== "error" && + normalizedLevel !== "fatal" + ) { + continue; } + + const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; + const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; + insertBounded( + latestWarningAndErrorLogs, + { spanName: name, level, message, seenAt, traceId, spanId }, + RECENT_LIMIT, + latestSeenFirst, + ); } } - } - - const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] - .map(([name, span]) => ({ - name, - count: span.count, - failureCount: span.failureCount, - totalDurationMs: span.totalDurationMs, - averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, - maxDurationMs: span.maxDurationMs, - })) - .toSorted((left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs) - .slice(0, TOP_LIMIT); + }; - return { - traceFilePath: input.traceFilePath, - scannedFilePaths, - readAt, - recordCount, - parseErrorCount, - firstSpanAt: Option.fromNullishOr(firstSpanAt), - lastSpanAt: Option.fromNullishOr(lastSpanAt), - failureCount, - interruptionCount, - slowSpanThresholdMs, - slowSpanCount, - logLevelCounts, - topSpansByCount, - slowestSpans, - commonFailures: [...failuresByKey.values()] - .toSorted( - (left, right) => - right.count - left.count || - DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), - ) - .slice(0, TOP_LIMIT), - latestFailures: latestFailures - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt), - ) - .slice(0, RECENT_LIMIT), - latestWarningAndErrorLogs: latestWarningAndErrorLogs + const finish = (input: { + readonly traceFilePath: string; + readonly scannedFilePaths: ReadonlyArray; + readonly readAt: DateTime.Utc; + readonly error?: TraceDiagnosticsErrorSummary; + readonly partialFailure?: boolean; + }): ServerTraceDiagnosticsResult => { + const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] + .map(([name, span]) => ({ + name, + count: span.count, + failureCount: span.failureCount, + totalDurationMs: span.totalDurationMs, + averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, + maxDurationMs: span.maxDurationMs, + })) .toSorted( - (left, right) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt), + (left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs, ) - .slice(0, RECENT_LIMIT), - partialFailure: input.partialFailure ? Option.some(true) : Option.none(), - error: Option.fromNullishOr(input.error), + .slice(0, TOP_LIMIT); + + return { + traceFilePath: input.traceFilePath, + scannedFilePaths: input.scannedFilePaths, + readAt: input.readAt, + recordCount, + parseErrorCount, + firstSpanAt: Option.fromNullishOr(firstSpanAt), + lastSpanAt: Option.fromNullishOr(lastSpanAt), + failureCount, + interruptionCount, + slowSpanThresholdMs, + slowSpanCount, + logLevelCounts, + topSpansByCount, + slowestSpans, + commonFailures: [...failuresByKey.values()] + .toSorted( + (left, right) => + right.count - left.count || + DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), + ) + .slice(0, TOP_LIMIT), + latestFailures, + latestWarningAndErrorLogs, + partialFailure: input.partialFailure ? Option.some(true) : Option.none(), + error: Option.fromNullishOr(input.error), + }; }; -} -type TraceFileReadResult = - | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } - | { readonly _tag: "Missing"; readonly path: string }; + return { addLine, finish }; +} -function readTraceFile( +/** + * Feeds each line of one trace file to `onLine`, streaming so only one chunk of + * text is in memory at a time. Succeeds with false when the file does not exist. + */ +export function streamTraceFileLines( fileSystem: FileSystem.FileSystem, path: string, -): Effect.Effect { - return fileSystem.readFileString(path).pipe( - Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })), + onLine: (line: string) => void, +): Effect.Effect { + return fileSystem.stream(path).pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEachArray((lines) => Effect.sync(() => lines.forEach(onLine))), + Effect.as(true), Effect.catchTags({ PlatformError: (cause) => isNotFoundError(cause) - ? Effect.succeed({ _tag: "Missing", path }) + ? Effect.succeed(false) : Effect.fail( new TraceFileReadError({ traceFilePath: path, @@ -420,10 +424,11 @@ export const make = Effect.gen(function* () { const readAt = options.readAt ?? (yield* DateTime.now); const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); + const aggregator = makeTraceDiagnosticsAggregator(slowSpanThresholdMs); const results = yield* Effect.forEach( paths, (path) => - readTraceFile(fileSystem, path).pipe( + streamTraceFileLines(fileSystem, path, aggregator.addLine).pipe( Effect.tapError((cause) => Effect.logWarning("Failed to read local trace file.").pipe( Effect.annotateLogs({ @@ -435,15 +440,10 @@ export const make = Effect.gen(function* () { ), Effect.result, ), - { - concurrency: 1, - }, - ); - const files = results.flatMap((result) => - Result.isSuccess(result) && result.success._tag === "Loaded" - ? [{ path: result.success.path, text: result.success.text }] - : [], + // Every file feeds one aggregator, so read them one at a time, oldest first. + { concurrency: 1 }, ); + const foundFile = results.some((result) => Result.isSuccess(result) && result.success); const readFailure = results.find(Result.isFailure); const readFailureError = readFailure ? ({ @@ -452,7 +452,7 @@ export const make = Effect.gen(function* () { } satisfies TraceDiagnosticsErrorSummary) : undefined; - if (files.length === 0) { + if (!foundFile) { return makeEmptyDiagnostics({ traceFilePath: options.traceFilePath, scannedFilePaths: paths, @@ -467,12 +467,10 @@ export const make = Effect.gen(function* () { }); } - return aggregateTraceDiagnostics({ + return aggregator.finish({ traceFilePath: options.traceFilePath, - files, scannedFilePaths: paths, readAt, - slowSpanThresholdMs, ...(readFailureError ? { partialFailure: true, error: readFailureError } : {}), }); }, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 12ae321c1cf3..6eb95c84b477 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -59,7 +59,6 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7533b8c1db19..30b368e62167 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -311,9 +311,12 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; - readonly bodyJson: OtlpTracer.TraceData; }> {} +// Renderers export up to once a second while they have spans buffered, so +// tracing this proxy would add more server spans than it forwards. +// withTracerEnabled(false) drops the handler's spans, including the forward. +// untracedRequestsLayer drops the HTTP server span. export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -330,15 +333,10 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( yield* Effect.try({ try: () => decodeOtlpTraceRecords(bodyJson), - catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), + catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }), }).pipe( Effect.flatMap((records) => browserTraceCollector.record(records)), - Effect.catch((cause) => - Effect.logWarning("Failed to decode browser OTLP traces", { - cause, - bodyJson, - }), - ), + Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })), ); if (otlpTracesUrl === undefined) { @@ -369,9 +367,25 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( EnvironmentInternalError: HttpServerRespondable.toResponse, EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, }), + Effect.withTracerEnabled(false), ), ); +const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_PATH]); + +// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts +// before routing, so a route handler cannot skip it. TracerDisabledWhen is one +// predicate for the whole server and the last layer to provide it wins, so +// makeRoutesLayer provides this one last. Add paths here instead of providing +// TracerDisabledWhen again; server.test.ts fails if a later layer replaces it. +// The query string is ignored, as in routing. +export const untracedRequestsLayer = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { + const queryIndex = request.url.indexOf("?"); + return UNTRACED_REQUEST_PATHS.has( + queryIndex === -1 ? request.url : request.url.slice(0, queryIndex), + ); +}); + export const assetRouteLayer = HttpRouter.add( "GET", `${ASSET_ROUTE_PREFIX}/*`, diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index d2c23860c9b7..b6a8ed3d6323 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -59,7 +59,7 @@ export function agentDeviceQuickStart( ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`, ` ${executable} install ${target}`, `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`, - "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.", + "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.", "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.", "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.", platformNotes, diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts new file mode 100644 index 000000000000..fbe30157aec2 --- /dev/null +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -0,0 +1,77 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import { type EventLoopReadings, layerWith, stallMs } from "./EventLoopMonitor.ts"; + +const ms = (value: number) => value * 1e6; + +// Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. +const stalled: EventLoopReadings = { + delayMaxNs: ms(5_950), + activeMs: 6_200, + utilization: 0.176, + usage: { + userCPUTime: 310_400, + systemCPUTime: 95_600, + majorPageFault: 8_412, + minorPageFault: 20_031, + involuntaryContextSwitches: 57, + }, + rssBytes: 1536 * 1024 * 1024, +}; +// Over the threshold as read, but not once the resolution is subtracted. +const quiet: EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; + +describe("EventLoopMonitor", () => { + it.effect("records a warning span only for samples that saw a stall", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + // The first sample covers startup, so the monitor discards it. + const samples = [stalled, quiet, stalled]; + + yield* Effect.gen(function* () { + yield* Layer.build(layerWith(Effect.succeed(Effect.sync(() => samples.shift() ?? quiet)))); + yield* TestClock.adjust("60 seconds"); + assert.lengthOf(spans, 0); + yield* TestClock.adjust("30 seconds"); + }).pipe(Effect.scoped, Effect.withTracer(tracer)); + + assert.deepStrictEqual( + spans.map((span) => span.name), + ["server.eventLoop.stall"], + ); + const [span] = spans; + assert.deepStrictEqual(Object.fromEntries(span!.attributes), { + delayMaxMs: 4_950, + utilization: 0.18, + cpuUserMs: 310, + cpuSystemMs: 96, + majorPageFaults: 8_412, + minorPageFaults: 20_031, + involuntaryContextSwitches: 57, + rssMb: 1536, + }); + assert.deepStrictEqual( + span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), + [["event loop stalled for 4950 ms", "WARN"]], + ); + }), + ); + + it("ignores delay the loop spent idle, such as a system sleep", () => { + // Waking from sleep reads as a long gap, but the loop was idle in poll for it. + const asleep: EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), activeMs: 900 }; + assert.isUndefined(stallMs(asleep)); + assert.strictEqual(stallMs({ ...asleep, activeMs: 600_000 }), 599_000); + }); +}); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts new file mode 100644 index 000000000000..13b7b48c4cf7 --- /dev/null +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -0,0 +1,135 @@ +// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. +import * as NodePerfHooks from "node:perf_hooks"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +// Node's delay histogram wakes a native timer every RESOLUTION_MS and records the +// gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S +// reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can +// undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s +// is caught, at 1 wakeup per second that never enters JS. +const RESOLUTION_MS = 1000; +const STALL_THRESHOLD_MS = 2000; +const SAMPLE_INTERVAL = "30 seconds"; + +/** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ +export interface EventLoopReadings { + readonly delayMaxNs: number; + readonly activeMs: number; + readonly utilization: number; + readonly usage: Pick< + NodeJS.ResourceUsage, + | "userCPUTime" + | "systemCPUTime" + | "majorPageFault" + | "minorPageFault" + | "involuntaryContextSwitches" + >; + readonly rssBytes: number; +} + +// Enables the delay histogram for the layer's lifetime. Each read returns the +// readings since the previous read and resets the histogram. Node skips the first +// gap after a reset, so a stall right at a sample boundary can be missed. +const makeNodeSampler = Effect.gen(function* () { + const histogram = yield* Effect.acquireRelease( + Effect.sync(() => { + const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution: RESOLUTION_MS }); + histogram.enable(); + return histogram; + }), + (histogram) => Effect.sync(() => histogram.disable()), + ); + let elu = NodePerfHooks.performance.eventLoopUtilization(); + let usage = process.resourceUsage(); + + // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. + return Effect.sync(() => { + const nextElu = NodePerfHooks.performance.eventLoopUtilization(); + const nextUsage = process.resourceUsage(); + const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); + const readings: EventLoopReadings = { + delayMaxNs: histogram.max, + activeMs: loop.active, + utilization: loop.utilization, + usage: { + userCPUTime: nextUsage.userCPUTime - usage.userCPUTime, + systemCPUTime: nextUsage.systemCPUTime - usage.systemCPUTime, + majorPageFault: nextUsage.majorPageFault - usage.majorPageFault, + minorPageFault: nextUsage.minorPageFault - usage.minorPageFault, + involuntaryContextSwitches: + nextUsage.involuntaryContextSwitches - usage.involuntaryContextSwitches, + }, + rssBytes: process.memoryUsage.rss(), + }; + histogram.reset(); + elu = nextElu; + usage = nextUsage; + return readings; + }); +}); + +/** + * Returns the stall to report for one sample in ms, or undefined when there was none. + */ +export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { + const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; + // A stall is time the loop spent running code, so it counts as active time. libuv's + // clock keeps running while the system sleeps on macOS and Windows, so a sleep also + // reads as delay, but the loop spent it idle in poll. + if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; + return delayMs; +}; + +/** + * Samples event loop health every 30 s and records a `server.eventLoop.stall` span + * with a warning when the loop stalled for more than 2 s, so stalls land in + * the local trace file and Settings > Diagnostics without OTLP. Takes the sampler + * so tests can inject readings. + */ +export const layerWith = ( + makeSampler: Effect.Effect, never, Scope.Scope>, +) => + Layer.effectDiscard( + Effect.gen(function* () { + const sample = yield* makeSampler; + const tick = Effect.gen(function* () { + const readings = yield* sample; + const delayMaxMs = stallMs(readings); + if (delayMaxMs === undefined) return; + const { utilization, usage, rssBytes } = readings; + // Root, as the stall has no caller to attach to. Warn level keeps it when + // T3CODE_TRACE_MIN_LEVEL is raised to cut trace noise. + yield* Effect.logWarning(`event loop stalled for ${delayMaxMs} ms`).pipe( + Effect.withSpan("server.eventLoop.stall", { + root: true, + level: "Warn", + attributes: { + delayMaxMs, + utilization: Math.round(utilization * 100) / 100, + cpuUserMs: Math.round(usage.userCPUTime / 1000), + cpuSystemMs: Math.round(usage.systemCPUTime / 1000), + majorPageFaults: usage.majorPageFault, + minorPageFaults: usage.minorPageFault, + involuntaryContextSwitches: usage.involuntaryContextSwitches, + rssMb: Math.round(rssBytes / 1024 / 1024), + }, + }), + ); + }); + const wait = Effect.sleep(SAMPLE_INTERVAL); + // The layer builds before the rest of the server, so the first sample covers + // startup work such as migrations and projection bootstrap. That can block the + // loop for seconds on a large database, so skip it rather than warn at every + // launch. Layers build outside any span, so this fiber retains no parent span. + yield* wait.pipe( + Effect.andThen(sample), + Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), + Effect.forkScoped, + ); + }), + ); + +export const layer = layerWith(makeNodeSampler); diff --git a/apps/server/src/observability/HeapSnapshot.test.ts b/apps/server/src/observability/HeapSnapshot.test.ts new file mode 100644 index 000000000000..0beb466e5019 --- /dev/null +++ b/apps/server/src/observability/HeapSnapshot.test.ts @@ -0,0 +1,36 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests fake a failed write at the native v8 boundary. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeV8 from "node:v8"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { vi } from "vite-plus/test"; + +import { writeHeapSnapshot } from "./HeapSnapshot.ts"; + +vi.mock("node:v8", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeHeapSnapshot: vi.fn(actual.writeHeapSnapshot) }; +}); + +it.layer(NodeServices.layer)("writeHeapSnapshot", (it) => { + it.effect("removes the partial file when the write fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const logsDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-heap-snapshot-test-" }); + let partialPath: string | undefined; + vi.mocked(NodeV8.writeHeapSnapshot).mockImplementationOnce((path) => { + partialPath = path; + if (path) NodeFS.writeFileSync(path, "partial"); + throw new Error("ENOSPC: no space left on device"); + }); + + yield* writeHeapSnapshot(logsDir); + + assert.strictEqual(NodePath.dirname(partialPath ?? ""), logsDir); + assert.deepEqual(yield* fs.readDirectory(logsDir), []); + }), + ); +}); diff --git a/apps/server/src/observability/HeapSnapshot.ts b/apps/server/src/observability/HeapSnapshot.ts new file mode 100644 index 000000000000..827dfab2c6d6 --- /dev/null +++ b/apps/server/src/observability/HeapSnapshot.ts @@ -0,0 +1,52 @@ +// @effect-diagnostics nodeBuiltinImport:off - v8.writeHeapSnapshot has no Effect equivalent. +import * as NodePath from "node:path"; +import * as NodeV8 from "node:v8"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; + +/** + * Writes one V8 heap snapshot into `logsDir` and logs its path. A failed write + * logs a warning and removes any partial file, because that file can hold + * secrets and the failure is often a full disk. + */ +export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( + function* (logsDir: string) { + const fs = yield* FileSystem.FileSystem; + const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-"); + const path = NodePath.join(logsDir, `server-${process.pid}-${timestamp}.heapsnapshot`); + yield* Effect.annotateCurrentSpan({ path }); + yield* Effect.try(() => NodeV8.writeHeapSnapshot(path)).pipe( + Effect.tapError(() => fs.remove(path, { force: true }).pipe(Effect.ignore)), + ); + yield* Effect.logInfo("Wrote heap snapshot.", { path }); + }, + Effect.catch((cause) => Effect.logWarning("Failed to write heap snapshot.", { cause })), +); + +/** + * Writes a heap snapshot when the process gets SIGUSR2 (`kill -USR2 `), + * so a maintainer can see what a long-running server holds. See "Heap + * Snapshots" in docs/operations/observability.md. + * + * The write blocks the event loop, so two snapshots never overlap: a signal + * sent during a write waits until it finishes. Windows has no SIGUSR2, so the + * layer does nothing there. + */ +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { logsDir } = yield* ServerConfig.ServerConfig; + const runFork = Effect.runForkWith(yield* Effect.context()); + const onSignal = () => void runFork(writeHeapSnapshot(logsDir)); + yield* Effect.acquireRelease( + Effect.sync(() => process.on("SIGUSR2", onSignal)), + () => Effect.sync(() => process.off("SIGUSR2", onSignal)), + ); + }), +); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index f855771b8f01..9ccf78ca1744 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -435,6 +435,7 @@ describe("OrchestrationEngine", () => { updatedAt: projectionSnapshot.updatedAt, }), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d25c442ac8b2..207ad9a20d34 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -108,6 +108,7 @@ describe("OrchestrationReactor", () => { Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => { started.push("agent-awareness-relay"); return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 179d04843c7e..e8c7154f38e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -21,6 +21,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 * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; @@ -114,6 +115,66 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cleanup-span-")))( + "OrchestrationProjectionPipeline attachment cleanup span", + (it) => { + it.effect("runs attachment cleanup only for events that remove attachments", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + let cleanupSpans = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "applyAttachmentSideEffects") cleanupSpans += 1; + return new Tracer.NativeSpan(options); + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-cleanup-span"); + const threadId = ThreadId.make("thread-cleanup-span"); + + const projectCreated = yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-cleanup-span-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-cleanup-span-project"), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + projectId, + title: "Cleanup span project", + workspaceRoot: "/tmp/project-cleanup-span", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionPipeline.projectEvent(projectCreated).pipe(Effect.withTracer(tracer)); + assert.strictEqual(cleanupSpans, 0); + + const threadDeleted = yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.make("evt-cleanup-span-thread-delete"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-cleanup-span-thread-delete"), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + yield* projectionPipeline.projectEvent(threadDeleted).pipe(Effect.withTracer(tracer)); + assert.strictEqual(cleanupSpans, 1); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( "imported thread shell projection", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4163168157e7..1acb3c360b15 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1991,13 +1991,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const applyAttachmentSideEffects = Effect.fn("applyAttachmentSideEffects")( function* (event: OrchestrationEvent, sideEffects: AttachmentSideEffects) { - if ( - sideEffects.deletedThreadIds.size === 0 && - sideEffects.prunedThreadRelativePaths.size === 0 - ) { - return; - } - const deletedThreadIds = new Set(); for (const threadId of sideEffects.deletedThreadIds) { const recreatedLater = yield* eventStore.hasEventAfter({ @@ -2113,9 +2106,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }), ); + const hasCleanup = + attachmentSideEffects.deletedThreadIds.size > 0 || + attachmentSideEffects.prunedThreadRelativePaths.size > 0; // Return the cleanup effect so the caller runs it after the outer transaction commits. + // Most events have no cleanup, so they skip the call and write no cleanup span. // @effect-diagnostics-next-line returnEffectInGen:off - return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid); + return hasCleanup + ? applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid) + : Effect.void; }, Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 890c8ae55c53..cfba90297633 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -30,6 +30,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; +import { readSweepSnapshot } from "../ThreadPullRequestReactor.ts"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -3503,6 +3504,224 @@ it.effect("omits foreign-host PRs from legacy snapshots while preserving native }).pipe(Effect.provide(layer)); }); +it.effect( + "lists linked threads like the shell snapshot, in one query and without identities", + () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at, archived_at, deleted_at, settled_override, settled_at) + VALUES + ('t-late', 'p1', 'Late', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z', NULL, NULL, 'settled', '2026-09-04T00:00:00Z'), + ('t-early', 'p2', 'Early', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-first', 'p1', 'First', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-plain', 'p1', 'Plain', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-archived', 'p1', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-05T00:00:00Z', NULL, NULL, NULL), + ('t-deleted', 'p1', 'Deleted', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, '2026-09-05T00:00:00Z', NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json) + VALUES + ('t-late', 'github.com', 'acme/web', 3, 'https://github.com/acme/web/pull/3', 'manual', '2026-09-03T00:00:00Z', NULL), + ('t-early', 'github.com', 'acme/api', 4, 'https://github.com/acme/api/pull/4', 'agent', '2026-09-01T00:00:00Z', NULL), + ('t-first', 'github.com', 'acme/web', 2, 'https://github.com/acme/web/pull/2', 'stack-dismissed', '2026-09-02T00:00:00Z', NULL), + ('t-first', 'github.com', 'acme/web', 1, 'https://github.com/acme/web/pull/1', 'created', '2026-09-02T00:00:00Z', + '{"state":"open","title":"One","headBranch":"one","baseBranch":"main","isDraft":false,"updatedAt":null,"syncedAt":"2026-09-02T00:00:00Z"}'), + ('t-archived', 'github.com', 'acme/web', 5, 'https://github.com/acme/web/pull/5', 'manual', '2026-09-02T00:00:00Z', NULL), + ('t-deleted', 'github.com', 'acme/web', 6, 'https://github.com/acme/web/pull/6', 'manual', '2026-09-02T00:00:00Z', NULL)`; + const expected = (yield* query.getShellSnapshot()).threads + .filter((thread) => thread.pullRequests.length > 0) + .map(({ id, projectId, settledOverride, settledAt, pullRequests }) => ({ + id, + projectId, + settledOverride, + settledAt, + pullRequests, + })); + resolved.length = 0; + + const counter = makeSqlStatementCounter(); + const threads = yield* query + .listThreadsWithPullRequests() + .pipe(Effect.withTracer(counter.tracer)); + assert.deepStrictEqual( + threads.map((thread) => [thread.id, thread.pullRequests.map((link) => link.number)]), + [ + ["t-first", [1, 2]], + ["t-late", [3]], + ["t-early", [4]], + ], + ); + assert.deepStrictEqual(threads, expected); + assert.strictEqual(counter.count(), 1); + assert.deepStrictEqual(resolved, []); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("reads one sweep thread and its projects like the shell snapshot", () => { + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'), + ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, branch_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, snoozed_until, snoozed_at, created_at, updated_at, settled_override, settled_at) + VALUES + ('t-linked', 'p1', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', '/one/wt', NULL, 'turn-1', '2026-09-02T00:00:00Z', 1, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL), + ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'other', NULL, + '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}', + NULL, NULL, 0, '2026-09-10T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', 'settled', '2026-09-03T00:00:00Z'), + ('t-other', 'p3', 'Other', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('t-linked', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z')`; + yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json) + VALUES ('t-linked', 'turn-1', 'completed', '2026-09-02T00:00:00Z', '2026-09-02T00:00:01Z', '2026-09-02T00:00:02Z', '[]')`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at) + VALUES ('t-linked', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:03Z')`; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql`INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 9, '2026-09-02T00:00:03Z')`; + } + + const full = yield* query.getShellSnapshot(); + // The seeded fields must reach the snapshot, or the parity check is empty. + const linked = full.threads.find((thread) => thread.id === ThreadId.make("t-linked")); + assert.strictEqual(full.snapshotSequence, 9); + assert.strictEqual(linked?.linkedPullRequest?.number, 7); + assert.strictEqual(linked?.latestTurn?.turnId, asTurnId("turn-1")); + assert.strictEqual(linked?.session?.status, "ready"); + + for (const [threadId, projectIds] of [ + [ThreadId.make("t-linked"), [asProjectId("p1")]], + // Settlement also needs the project that the saved branch PR names. + [ThreadId.make("t-branch"), [asProjectId("p1"), asProjectId("p2")]], + ] as const) { + assert.deepStrictEqual(yield* readSweepSnapshot(query, threadId), { + snapshotSequence: full.snapshotSequence, + projects: full.projects.filter((project) => projectIds.includes(project.id)), + threads: full.threads.filter((thread) => thread.id === threadId), + }); + } + }).pipe(Effect.provide(layer)); +}); + +it.effect("reads a full sweep from unsettled threads and every project", () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'), + ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z'), + ('p4', 'Four', '/four', '[]', '2026-09-04T00:00:00Z', '2026-09-04T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('t-open', 'p1', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-open', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL), + ('t-resumed', 'p1', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'active', NULL), + ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', + '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}', + NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL), + ('t-settled', 'p3', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-settled', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'settled', '2026-09-03T00:00:00Z'), + ('t-archived', 'p4', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-04T00:00:00Z', NULL, NULL)`; + // The open and the settled thread both have a row in each joined table. The + // settled thread's turn and session are the newest rows, so updatedAt shows + // whether those two reads skip it. + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('t-open', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z'), + ('t-settled', 'github.com', 'acme/web', 9, 'https://github.com/acme/web/pull/9', 'agent', '2026-09-02T00:00:00Z')`; + yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, checkpoint_files_json) + VALUES ('t-open', 'turn-open', 'completed', '2026-09-02T00:00:00Z', '[]'), + ('t-settled', 'turn-settled', 'completed', '2026-09-09T00:00:00Z', '[]')`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at) + VALUES ('t-open', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:00Z'), + ('t-settled', 'stopped', 'codex', NULL, NULL, '2026-09-10T00:00:00Z')`; + + const full = yield* query.getShellSnapshot(); + // The settled thread's rows must reach the full read, or skipping them proves nothing. + const settled = full.threads.find((thread) => thread.id === ThreadId.make("t-settled")); + assert.strictEqual(settled?.pullRequests[0]?.number, 9); + assert.strictEqual(settled?.latestTurn?.turnId, asTurnId("turn-settled")); + assert.strictEqual(settled?.session?.status, "stopped"); + assert.strictEqual(full.updatedAt, "2026-09-10T00:00:00Z"); + resolved.length = 0; + + const sweep = yield* readSweepSnapshot(query, null); + assert.strictEqual(sweep.snapshotSequence, full.snapshotSequence); + assert.deepStrictEqual( + sweep.threads, + full.threads.filter((thread) => thread.id !== ThreadId.make("t-settled")), + ); + assert.deepStrictEqual( + sweep.threads.map((thread) => thread.id), + ["t-branch", "t-open", "t-resumed"], + ); + // Like the full read, the sweep resolves every project, so it keeps the + // repository identity cache warm for client connects. + assert.deepStrictEqual(sweep.projects, full.projects); + assert.deepStrictEqual(resolved.toSorted(), ["/four", "/one", "/three", "/two"]); + // A settled thread's link that no longer decodes breaks the full read, but + // not the sweep, which never reads it. + yield* sql`UPDATE projection_thread_pull_requests SET snapshot_json = 'invalid-json' WHERE thread_id = 't-settled'`; + assert.strictEqual((yield* Effect.exit(query.getShellSnapshot()))._tag, "Failure"); + const unsettled = yield* query.getShellSnapshot({ unsettledOnly: true }); + assert.deepStrictEqual(unsettled.threads, sweep.threads); + assert.strictEqual(unsettled.updatedAt, "2026-09-04T00:00:00Z"); + }).pipe(Effect.provide(layer)); +}); + projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => { it.effect("lists one kind across active threads only, without hydrating the threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1b44054c7a32..07d30e47e27b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,7 +12,7 @@ import { OrchestrationProposedPlanId, OrchestrationReadModel, OrchestrationThreadSearchSource, - OrchestrationShellSnapshot, + type OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, @@ -77,11 +77,11 @@ import { type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionThreadDetailQuery, + type ProjectionThreadPullRequests, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); -const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( Schema.fromJsonString( @@ -185,6 +185,7 @@ const EventReplayStatsRowSchema = Schema.Struct({ eventCount: Schema.Number, payloadBytes: Schema.Number, }); +const ActiveThreadRowsRequest = Schema.Struct({ unsettledOnly: Schema.Boolean }); const ProjectionThreadSearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int, @@ -600,10 +601,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Background sweeps skip settled threads, the same check PR discovery makes. + const unsettledThreadsFilter = (unsettledOnly: boolean) => + unsettledOnly + ? sql`AND threads.settled_at IS NULL AND threads.settled_override IS NOT 'settled'` + : sql``; + const listActiveThreadRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadDbRowSchema, - execute: () => + execute: (request) => sql` SELECT thread_id AS "threadId", @@ -637,9 +644,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" - FROM projection_threads + FROM projection_threads threads WHERE deleted_at IS NULL AND archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); @@ -779,9 +787,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveThreadPullRequestRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadPullRequestDbRowSchema, - execute: () => + execute: (request) => sql` SELECT links.thread_id AS "threadId", @@ -798,10 +806,46 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = links.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC `, }); + // One row per link, in the shell snapshot's thread order and link order. + const listActiveThreadPullRequestSyncRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema.mapFields( + Struct.assign({ + projectId: ProjectionThread.fields.projectId, + settledOverride: ProjectionThread.fields.settledOverride, + settledAt: ProjectionThread.fields.settledAt, + }), + ), + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.settled_override AS "settledOverride", + threads.settled_at AS "settledAt", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY threads.project_id ASC, threads.created_at ASC, threads.thread_id ASC, + links.linked_at ASC, links.number ASC + `, + }); + const listArchivedThreadPullRequestRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadPullRequestDbRowSchema, @@ -872,9 +916,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveThreadSessionRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadSessionDbRowSchema, - execute: () => + execute: (request) => sql` SELECT sessions.thread_id AS "threadId", @@ -892,6 +936,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY sessions.thread_id ASC `, }); @@ -966,9 +1011,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveLatestTurnRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionLatestTurnDbRowSchema, - execute: () => + execute: (request) => sql` SELECT turns.thread_id AS "threadId", @@ -987,6 +1032,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL AND threads.latest_turn_id IS NOT NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY turns.thread_id ASC `, }); @@ -2619,8 +2665,9 @@ pending_approval_requests AS ( }), ); - const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => - sql + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = (options) => { + const unsettledOnly = options?.unsettledOnly === true; + return sql .withTransaction( Effect.all([ listProjectRows(undefined).pipe( @@ -2631,7 +2678,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadRows(undefined).pipe( + listActiveThreadRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", @@ -2639,7 +2686,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadSessionRows(undefined).pipe( + listActiveThreadSessionRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", @@ -2647,7 +2694,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadPullRequestRows(undefined).pipe( + listActiveThreadPullRequestRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", @@ -2655,7 +2702,7 @@ pending_approval_requests AS ( ), ), ), - listActiveLatestTurnRows(undefined).pipe( + listActiveLatestTurnRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", @@ -2710,7 +2757,10 @@ pending_approval_requests AS ( ); const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); - const snapshot = { + // Built from schema-decoded rows, so no second decode here. The HTTP + // and RPC layers encode it against OrchestrationShellSnapshot on the + // way out, like the per-item shells from getThreadShellById. + return { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null @@ -2764,15 +2814,7 @@ pending_approval_requests AS ( : Result.failVoid, ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; - - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", - ), - ), - ); + } satisfies OrchestrationShellSnapshot; }), ), Effect.mapError((error) => { @@ -2782,6 +2824,36 @@ pending_approval_requests AS ( return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); }), ); + }; + + const listThreadsWithPullRequests: ProjectionSnapshotQueryShape["listThreadsWithPullRequests"] = + () => + listActiveThreadPullRequestSyncRows(undefined).pipe( + Effect.map((rows) => { + const threads = new Map< + ThreadId, + ProjectionThreadPullRequests & { readonly pullRequests: Array } + >(); + for (const row of rows) { + const thread = threads.get(row.threadId) ?? { + id: row.threadId, + projectId: row.projectId, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + pullRequests: [], + }; + thread.pullRequests.push(mapPullRequestRow(row)); + threads.set(row.threadId, thread); + } + return [...threads.values()]; + }), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.listThreadsWithPullRequests:query", + "ProjectionSnapshotQuery.listThreadsWithPullRequests:decodeRows", + ), + ), + ); const getArchivedShellSnapshot: ProjectionSnapshotQueryShape["getArchivedShellSnapshot"] = () => sql @@ -2876,7 +2948,7 @@ pending_approval_requests AS ( sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), ); - const snapshot = { + return { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null && activeProjectIds.has(row.projectId) @@ -2926,15 +2998,7 @@ pending_approval_requests AS ( planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; - - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", - ), - ), - ); + } satisfies OrchestrationShellSnapshot; }), ), Effect.mapError((error) => { @@ -3778,6 +3842,7 @@ pending_approval_requests AS ( listActivitiesByKind, getSnapshot, getShellSnapshot, + listThreadsWithPullRequests, getArchivedShellSnapshot, getDeletedWorktreeThreads, searchThreads, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7d9417bc75b..87f9bd03d46c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -55,6 +55,7 @@ import { import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../../terminal/Manager.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => { }), ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void); const createWorktree = vi.fn( (input: { readonly refName: string; readonly path: string | null }) => Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), @@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), + Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + closeIdleTerminals, refreshStatus, generateBranchName, generateThreadTitle, @@ -887,8 +891,92 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); }); + effectIt.effect("forwards only a user-renamed title when starting a provider session", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initialTitle: "Add a progressive blur as you scroll" }), + ); + const now = "2026-01-01T00:00:00.000Z"; + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const startTurn = (threadId: string, text: string, titleSeed: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-title-${threadId}`), + threadId: ThreadId.make(threadId), + message: { + messageId: asMessageId(`message-${threadId}`), + role: "user", + text, + attachments: [], + }, + titleSeed, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* startTurn( + "thread-1", + "Add a progressive blur as you scroll", + "Add a progressive blur as you scroll", + ); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-renamed"), + threadId: ThreadId.make("thread-renamed"), + projectId: asProjectId("project-1"), + title: "New thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-rename"), + threadId: ThreadId.make("thread-renamed"), + title: "Keep this name", + }); + yield* startTurn("thread-renamed", "hello there", "hello there"); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 2)); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ title: "Keep this name" }); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-seeded"), + threadId: ThreadId.make("thread-seeded"), + projectId: asProjectId("project-1"), + title: "New thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-autotitle"), + threadId: ThreadId.make("thread-seeded"), + title: "hello there", + }); + yield* startTurn("thread-seeded", "hello there", "hello there"); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 3)); + expect(harness.startSession.mock.calls[2]?.[1]).not.toHaveProperty("title"); + }), + ); + effectIt.effect("projects inline context before sending the provider turn", () => Effect.gen(function* () { const harness = yield* Effect.promise(() => createHarness()); @@ -4339,6 +4427,77 @@ describe("ProviderCommandReactor", () => { expect(thread?.settledOverride).toBe("settled"); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); }), ); + + effectIt.effect("closes idle terminals when a thread without a session settles", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const terminalsClosed = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementation(() => + Deferred.succeed(terminalsClosed, undefined).pipe(Effect.asVoid), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-without-session"), + threadId: ThreadId.make("thread-1"), + }); + yield* Deferred.await(terminalsClosed); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect( + "keeps terminals when the thread is un-settled before its settle event runs", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = ThreadId.make("thread-1"); + const firstCloseStarted = yield* Deferred.make(); + const releaseFirstClose = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementationOnce(() => + Deferred.succeed(firstCloseStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstClose)), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-first"), + threadId, + }); + // The reactor is busy with the first settle while the user changes their mind. + yield* Deferred.await(firstCloseStarted); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-first"), + threadId, + reason: "user", + }); + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-second"), + threadId, + }); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-second"), + threadId, + reason: "user", + }); + yield* Deferred.succeed(releaseFirstClose, undefined); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bff5a6096421..77022a8518d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -65,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -222,6 +223,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const terminalManager = yield* TerminalManager.TerminalManager; /** Environment settings with the thread's project overrides applied. */ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const settings = yield* serverSettingsService.getSettings; @@ -573,6 +575,9 @@ const make = Effect.gen(function* () { options?: { readonly modelSelection?: ModelSelection; readonly pendingTurnStart?: boolean; + // First-turn prompt seed. A manual title that still equals this seed was + // written by the client's auto-title, not a user rename. + readonly titleSeed?: string; }, ) { const thread = yield* resolveThreadShell(threadId); @@ -710,6 +715,15 @@ const make = Effect.gen(function* () { .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) .pipe(Effect.forkDetach) : Effect.void; + // OpenCode skips SessionPrompt.ensureTitle when session.create already has + // a title. Prompt seeds and "New thread" are not user titles, so omit them + // and let the provider generate one. A real rename is source "manual" and + // differs from the first-turn prompt seed (the web client writes that seed + // through thread.meta.update, which also marks the title manual). + const manualTitle = thread.titleState?.source === "manual" ? thread.title.trim() : ""; + const promptSeed = options?.titleSeed?.trim(); + const sessionTitle = + manualTitle.length > 0 && manualTitle !== promptSeed ? thread.title : undefined; const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -721,7 +735,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), + ...(sessionTitle ? { title: sessionTitle } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, @@ -837,6 +851,7 @@ const make = Effect.gen(function* () { readonly modelSelection?: ModelSelection; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; + readonly titleSeed?: string; }) { const thread = yield* resolveThreadShell(input.threadId); if (!thread) { @@ -846,6 +861,7 @@ const make = Effect.gen(function* () { } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.titleSeed !== undefined ? { titleSeed: input.titleSeed } : {}), pendingTurnStart: true, }); if (input.modelSelection !== undefined) { @@ -1486,6 +1502,11 @@ const make = Effect.gen(function* () { : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, + // Later turns must not reuse the current title as titleSeed. Only the + // first prompt seed should suppress a not-yet-renamed session title. + ...(!hasOtherUserMessages && event.payload.titleSeed !== undefined + ? { titleSeed: event.payload.titleSeed } + : {}), }).pipe( Effect.asSome, Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), @@ -1822,11 +1843,14 @@ const make = Effect.gen(function* () { return; case "thread.settled": { const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); - if ( - Option.isNone(thread) || - thread.value.session == null || - thread.value.session.status === "stopped" - ) { + // A thread re-engaged before this event ran keeps its shells and session. + if (Option.isNone(thread) || thread.value.settledOverride !== "settled") { + return; + } + // Idle shells close so they stop holding the worktree. A terminal that + // runs a command (a dev server, an editor) stays for the user to close. + yield* terminalManager.closeIdle({ threadId: event.payload.threadId }); + if (thread.value.session == null || thread.value.session.status === "stopped") { return; } yield* orchestrationEngine.dispatch({ diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts index 5502376e691e..e6a18c2856a1 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -172,6 +172,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const snapshots = yield* Ref.make(options.snapshot); const events = yield* PubSub.unbounded(); const snapshotReads = yield* Queue.unbounded(); + const shellSnapshotReads = yield* Ref.make(0); const syncCommands = yield* Ref.make>([]); const linkCommands = yield* Ref.make>([]); const summaryCalls = yield* Ref.make>([]); @@ -209,8 +210,16 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ + listThreadsWithPullRequests: () => + Queue.offer(snapshotReads, undefined).pipe( + Effect.andThen(Ref.get(snapshots)), + Effect.map((snapshot) => snapshot.threads), + ), getShellSnapshot: () => - Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))), + Ref.update(shellSnapshotReads, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(snapshotReads, undefined)), + Effect.andThen(Ref.get(snapshots)), + ), }), Layer.mock(PullRequestService)({ summary, @@ -235,6 +244,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: activation, snapshots, snapshotReads, + shellSnapshotReads, syncCommands, linkCommands, summaryCalls, @@ -511,6 +521,8 @@ describe("PullRequestSyncReactor", () => { ], ); assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + // Reads only linked threads, never the full shell snapshot of every thread. + assert.strictEqual(yield* Ref.get(fixture.shellSnapshotReads), 0); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 2bdf3ff9e71d..ffcc9fb3c26d 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -1,7 +1,6 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, - type OrchestrationThreadShell, type PullRequestSummary, type ThreadPullRequestKey, type ThreadPullRequestLink, @@ -36,7 +35,7 @@ const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; type SnapshotFields = Omit; interface LinkEntry { - readonly thread: OrchestrationThreadShell; + readonly thread: ProjectionSnapshotQuery.ProjectionThreadPullRequests; readonly link: ThreadPullRequestLink; } @@ -104,15 +103,15 @@ function stacksEqual( ); } -function isUnsettled(thread: OrchestrationThreadShell): boolean { +function isUnsettled(thread: ProjectionSnapshotQuery.ProjectionThreadPullRequests): boolean { return thread.settledOverride !== "settled" && thread.settledAt === null; } /** * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads - * the shell snapshot, groups visible links by pull request so the host is asked once per PR - * no matter how many threads share it, and writes back only what changed. Native stacks the - * host reports are auto-linked to the thread as `source: "stack"`. + * only the active threads that have links, groups visible links by pull request so the host + * is asked once per PR no matter how many threads share it, and writes back only what + * changed. Native stacks the host reports are auto-linked to the thread as `source: "stack"`. */ export class PullRequestSyncReactor extends Context.Service< PullRequestSyncReactor, @@ -153,14 +152,13 @@ export const make = Effect.gen(function* () { Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) { - const snapshot = yield* snapshots.getShellSnapshot(); + const threads = yield* snapshots.listThreadsWithPullRequests(); const now = yield* DateTime.now; const nowMs = DateTime.toEpochMillis(now); const nowIso = DateTime.formatIso(now); const groups = new Map>(); - for (const thread of snapshot.threads) { - if (thread.archivedAt !== null) continue; + for (const thread of threads) { for (const link of visibleThreadPullRequests(thread.pullRequests)) { const key = threadPullRequestKeyOf(link); const entries = groups.get(key) ?? []; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index eac3ede9c1ee..08da6e00940c 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -64,6 +64,12 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +/** The thread fields pull request sync reads, for a thread with at least one link. */ +export type ProjectionThreadPullRequests = Pick< + OrchestrationThreadShell, + "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests" +>; + export interface ProjectionThreadDetailQuery { /** * Limit activities before SQLite returns and decodes their payloads. @@ -114,11 +120,15 @@ export interface ProjectionSnapshotQueryShape { * * Returns only projects and thread shell summaries so clients can bootstrap * lightweight navigation state without hydrating every thread body. + * + * `unsettledOnly` is for background sweeps, not clients. It skips settled + * threads and their sessions, PR links, and turns, and its `updatedAt` + * ignores those rows. It still resolves every project, which keeps + * repository identities cached for client connects. */ - readonly getShellSnapshot: () => Effect.Effect< - OrchestrationShellSnapshot, - ProjectionRepositoryError - >; + readonly getShellSnapshot: (options?: { + readonly unsettledOnly?: boolean; + }) => Effect.Effect; /** * Read archived thread shell summaries for the archive page. @@ -131,6 +141,16 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read active (not deleted, not archived) threads that have at least one pull + * request link, in shell snapshot order. Skips repository identity, so no + * legacy `linkedPullRequest` is derived. + */ + readonly listThreadsWithPullRequests: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** Durable worktree ownership retained after thread deletion, including across restarts. */ readonly getDeletedWorktreeThreads: () => Effect.Effect< ReadonlyArray<{ diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index d6572083dad2..2a08dd03bc54 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -21,18 +21,27 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { PullRequestService } from "../pullRequest/PullRequestService.ts"; import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { ServerActivation } from "../serverActivation.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; const NOW = "2026-09-01T12:00:00.000Z"; @@ -134,6 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options readonly existingWorktrees?: ReadonlyArray; readonly project?: OrchestrationProjectShell; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; + /** Serve full sweep reads from this instead of `threads`. */ + readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"]; }) { const activation = yield* Deferred.make(); const snapshots = yield* Ref.make({ @@ -142,7 +153,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options threads: options.threads, updatedAt: NOW, }); - const reads = yield* Queue.unbounded(); + // Each shell read: a thread id for a one-thread read, null for a full read. + const reads = yield* Queue.unbounded(); const events = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< @@ -152,8 +164,27 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options let uuid = 0; const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => - Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), + getShellSnapshot: (readOptions) => + (options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)).pipe( + Effect.tap(() => Queue.offer(reads, null)), + ), + getSnapshotSequence: () => + Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), + getThreadShellById: (threadId) => + Ref.get(snapshots).pipe( + Effect.map(({ threads }) => + Option.fromUndefinedOr( + threads.find((thread) => thread.id === threadId && thread.archivedAt === null), + ), + ), + Effect.tap(() => Queue.offer(reads, threadId)), + ), + getProjectShells: (projectIds) => + Ref.get(snapshots).pipe( + Effect.map(({ projects }) => + projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), + ), }), Layer.mock(GitManager)({ branchPullRequest: (input, readOptions) => @@ -376,7 +407,7 @@ describe("ThreadPullRequestReactor", () => { : [checkpointEvent, sessionEvent]; for (const event of events) { yield* fixture.publish(event); - yield* Queue.take(fixture.reads); + expect(yield* Queue.take(fixture.reads)).toBe(current.id); yield* reactor.drain; } expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); @@ -388,6 +419,51 @@ describe("ThreadPullRequestReactor", () => { ), ); + it.effect("refreshes the project identity when a turn adds the remote", () => + Effect.scoped( + Effect.gen(function* () { + const current = thread("new-remote"); + const fixture = yield* makeHarness({ + threads: [current], + project: { ...project, repositoryIdentity: null }, + branchPullRequest: () => Effect.succeed(branchPullRequest()), + resolveRepositoryIdentity: (_cwd, options) => + Effect.succeed(options?.refresh ? project.repositoryIdentity : null), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + + yield* fixture.publish({ + type: "thread.turn-diff-completed", + sequence: 2, + eventId: EventId.make("checkpoint-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: current.id, + turnId: TurnId.make("turn"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: NOW, + }, + }); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses live worktrees and falls back to the project for removed worktrees", () => Effect.scoped( Effect.gen(function* () { @@ -516,6 +592,23 @@ describe("ThreadPullRequestReactor", () => { yield* Effect.gen(function* () { const reactor = yield* fixture.start(); expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + // A one-thread read cannot show that other pending threads are gone. + const gone = ThreadId.make("gone"); + yield* fixture.publish({ + type: "thread.unarchived", + sequence: 2, + eventId: EventId.make("gone-unarchived"), + aggregateKind: "thread", + aggregateId: gone, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: gone, updatedAt: NOW }, + }); + expect(yield* Queue.take(fixture.reads)).toBe(gone); + yield* reactor.drain; yield* Ref.set(online, true); yield* TestClock.adjust("1 minute"); yield* Queue.take(fixture.reads); @@ -564,6 +657,113 @@ describe("ThreadPullRequestReactor", () => { ), ); + it.effect("discovers the same PRs from the unsettled read as from the full read", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}), + ('dormant', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('open', 'project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open', NULL, ${NOW}, ${NOW}, NULL, NULL, NULL), + ('resumed', 'project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'resumed', NULL, ${NOW}, ${NOW}, NULL, 'active', NULL), + ('linked', 'project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'linked', '{"projectId":"project","repository":"owner/repository","number":3,"url":"https://github.com/owner/repository/pull/3"}', ${NOW}, ${NOW}, NULL, NULL, NULL), + ('settled', 'project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'settled', '{"projectId":"project","repository":"owner/repository","number":4,"url":"https://github.com/owner/repository/pull/4"}', ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('backfill', 'project', 'Backfill', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'backfill', NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('imported', 'dormant', 'Imported', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('archived', 'project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'archived', NULL, ${NOW}, ${NOW}, ${NOW}, NULL, NULL)`; + const numbers = new Map([ + ["open", 1], + ["resumed", 2], + ["linked", 3], + ["settled", 4], + ["backfill", 5], + ["archived", 6], + ]); + + // Startup, then two periodic passes. The startup backfill lookup fails, + // so the first periodic pass retries it from the full read. + const discover = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) => + Effect.gen(function* () { + const online = yield* Ref.make(false); + const reads: Array> = []; + const fixture = yield* makeHarness({ + threads: [], + getShellSnapshot: (options) => + read(options).pipe( + Effect.tap((snapshot) => + Effect.sync(() => reads.push(snapshot.threads.map(({ id }) => id).toSorted())), + ), + ), + branchPullRequest: ({ cwd, branch }) => + Effect.gen(function* () { + if (branch === "backfill" && !(yield* Ref.get(online))) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }); + } + const number = numbers.get(branch); + return number === undefined ? null : branchPullRequest(number); + }), + }); + return yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + const startupCalls = (yield* Ref.get(fixture.branchCalls)).length; + const startupCommands = (yield* Ref.get(fixture.commands)).length; + yield* Ref.set(online, true); + for (let pass = 0; pass < 2; pass++) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + return { + reads, + branchCalls: (yield* Ref.get(fixture.branchCalls)) + .slice(startupCalls) + .map(({ branch }) => branch) + .toSorted(), + commands: (yield* Ref.get(fixture.commands)) + .slice(startupCommands) + .map( + ({ threadId, branchPullRequest }) => `${threadId} ${branchPullRequest?.number}`, + ) + .toSorted(), + }; + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.scoped); + + const { reads: unsettledReads, ...unsettled } = yield* discover(query.getShellSnapshot); + const { reads: fullReads, ...full } = yield* discover(() => query.getShellSnapshot()); + expect(unsettled).toEqual(full); + expect(unsettled.commands).toEqual([ + "backfill 5", + "open 1", + "open 1", + "resumed 2", + "resumed 2", + ]); + // The last pass has no backfill left, so it reads no settled thread. + expect(fullReads.at(-1)).toContain("imported"); + expect(unsettledReads.at(-1)).toEqual(["linked", "open", "resumed"]); + }).pipe( + Effect.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver, { + resolve: () => Effect.succeed(project.repositoryIdentity), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), + ); + it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 17efc74e4f40..66ff81d5a9e7 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -6,6 +6,7 @@ import { CommandId, type OrchestrationEvent, type OrchestrationProjectShell, + type OrchestrationShellSnapshot, type ThreadId, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; @@ -16,11 +17,13 @@ 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 Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; @@ -69,6 +72,37 @@ export function pullRequestMatchesProject( ); } +/** + * Read the shell state for a discovery or settlement sweep. A sweep for one + * thread reads that thread and the projects it names, not every thread. A + * sweep over all threads reads only unsettled threads, since both sweeps skip + * settled ones. Discovery's backfill does its own full read. + */ +export const readSweepSnapshot = ( + snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape, + threadId: ThreadId | null, +): Effect.Effect< + Pick, + ProjectionRepositoryError +> => + threadId === null + ? snapshots.getShellSnapshot({ unsettledOnly: true }) + : Effect.gen(function* () { + // Read the sequence first. The thread is then at least this new, so a + // command guarded by the sequence is rejected rather than missing a change. + const { snapshotSequence } = yield* snapshots.getSnapshotSequence(); + const thread = yield* snapshots.getThreadShellById(threadId); + if (Option.isNone(thread)) return { snapshotSequence, projects: [], threads: [] }; + // Settlement also checks the project a saved pull request names. + const reference = thread.value.linkedPullRequest ?? thread.value.branchPullRequest; + const projects = yield* snapshots.getProjectShells( + reference == null + ? [thread.value.projectId] + : [thread.value.projectId, reference.projectId], + ); + return { snapshotSequence, projects, threads: [thread.value] }; + }); + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -97,7 +131,11 @@ export const make = Effect.gen(function* () { const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( request: RefreshRequest, ) { - const snapshot = yield* snapshots.getShellSnapshot(); + // Backfill looks up settled threads, so its passes read every thread. + const snapshot = + request.threadId === null && (request.backfill || pendingBackfill.size > 0) + ? yield* snapshots.getShellSnapshot() + : yield* readSweepSnapshot(snapshots, request.threadId); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { @@ -109,14 +147,19 @@ export const make = Effect.gen(function* () { } } } - const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); - for (const threadId of pendingBackfill.keys()) { - if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); + // A single-thread read only shows whether its own thread is gone. A thread + // with no branch has nothing to look up, and its entry would keep every + // periodic pass on the full read. + const branchThreadIds = new Set( + snapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + ); + const checkedIds = request.threadId === null ? pendingBackfill.keys() : [request.threadId]; + for (const threadId of checkedIds) { + if (!branchThreadIds.has(threadId)) pendingBackfill.delete(threadId); } const threads = snapshot.threads.filter( (thread) => thread.archivedAt === null && - (request.threadId === null || thread.id === request.threadId) && ((thread.settledOverride !== "settled" && thread.settledAt === null) || request.threadId !== null || pendingBackfill.has(thread.id)) && @@ -131,8 +174,19 @@ export const make = Effect.gen(function* () { (group) => Effect.gen(function* () { const first = group[0]!; - const project = projects.get(first.projectId); - if (project === undefined) return finishBackfill(group); + const snapshotProject = projects.get(first.projectId); + if (snapshotProject === undefined) return finishBackfill(group); + // A finished turn may have added the remote this PR lives on. A failed + // refresh resolves to null, so keep the snapshot's identity then. + const project = request.refresh + ? { + ...snapshotProject, + repositoryIdentity: + (yield* repositoryIdentities.resolve(snapshotProject.workspaceRoot, { + refresh: true, + })) ?? snapshotProject.repositoryIdentity, + } + : snapshotProject; const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 07afbaf05e6a..cad45007665c 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -24,13 +24,17 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { PullRequestService, type PullRequestMergeEvent, @@ -38,11 +42,17 @@ import { import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, } from "./Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Path from "effect/Path"; @@ -168,6 +178,8 @@ function makeBranchPullRequest( interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; + /** Serve full sweep reads from this instead of `snapshot`. */ + readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"]; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; @@ -181,7 +193,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const activation = yield* Deferred.make(); const snapshots = yield* Ref.make(options.snapshot); const snapshotReadCount = yield* Ref.make(0); - const snapshotReads = yield* Queue.unbounded(); + // Each shell read: a thread id for a one-thread read, null for a full read. + const snapshotReads = yield* Queue.unbounded(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsReads = yield* Queue.unbounded(); const settingsChanges = yield* PubSub.unbounded(); @@ -254,10 +267,27 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => - Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( - Effect.tap((count) => Queue.offer(snapshotReads, count)), - Effect.andThen(Ref.get(snapshots)), + getShellSnapshot: (readOptions) => + Ref.update(snapshotReadCount, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(snapshotReads, null)), + Effect.andThen(options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)), + ), + getSnapshotSequence: () => + Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), + getThreadShellById: (threadId) => + Ref.get(snapshots).pipe( + Effect.map(({ threads }) => + Option.fromUndefinedOr( + threads.find((thread) => thread.id === threadId && thread.archivedAt === null), + ), + ), + Effect.tap(() => Queue.offer(snapshotReads, threadId)), + ), + getProjectShells: (projectIds) => + Ref.get(snapshots).pipe( + Effect.map(({ projects }) => + projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), ), }), Layer.mock(GitManager)({ @@ -313,7 +343,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], activation: Deferred.Deferred, - snapshotReads: Queue.Queue, + snapshotReads: Queue.Queue, ) { yield* reactor.start(); yield* Deferred.succeed(activation, undefined); @@ -443,7 +473,7 @@ describe("ThreadSettlementReactor", () => { updatedAt: NOW, }, }); - yield* Queue.take(fixture.snapshotReads); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), thread.id); yield* reactor.drain; } assert.deepStrictEqual( @@ -463,7 +493,7 @@ describe("ThreadSettlementReactor", () => { aggregateId: readySession.threadId, payload: { threadId: readySession.threadId, session: readySession }, }); - yield* Queue.take(fixture.snapshotReads); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), readySession.threadId); yield* reactor.drain; assert.deepStrictEqual( (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), @@ -1418,6 +1448,88 @@ describe("ThreadSettlementReactor", () => { }), ), ); + + it.effect("settles the same threads from the unsettled read as from the full read", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('settlement-project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}), + ('linked-settlement-project', 'Linked', '/workspace/linked', '[]', ${NOW}, ${NOW}), + ('dormant-project', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, latest_user_message_at, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('idle', 'settlement-project', 'Idle', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('merged', 'settlement-project', 'Merged', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', + '{"projectId":"linked-settlement-project","repository":"owner/repository","number":42,"url":"https://example.test/owner/repository/pull/42"}', + '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('linked', 'settlement-project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('open', 'settlement-project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open-feature', NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('resumed', 'settlement-project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'active', NULL), + ('settled', 'dormant-project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'done', NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'settled', '2026-08-21T00:00:00.000Z'), + ('archived', 'settlement-project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, ${NOW}, NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json) + VALUES ('linked', 'example.test', 'owner/repository', 7, 'https://example.test/owner/repository/pull/7', 'manual', ${NOW}, + '{"state":"merged","title":"Review","headBranch":"linked","baseBranch":"main","isDraft":false,"updatedAt":"2026-08-28T12:00:00.000Z","syncedAt":"2026-08-28T12:00:00.000Z","mergedAt":"2026-08-28T12:00:00.000Z","closedAt":null}')`; + + const sweep = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) => + Effect.gen(function* () { + const readThreadIds: Array = []; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + getShellSnapshot: (options) => + read(options).pipe( + Effect.tap((snapshot) => + Effect.sync(() => readThreadIds.push(...snapshot.threads.map(({ id }) => id))), + ), + ), + branchPullRequest: ({ branch }) => + Effect.succeed(branch === "open-feature" ? makeBranchPullRequest("open") : null), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), + }); + return yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + return { + readThreadIds: readThreadIds.toSorted(), + commands: (yield* Ref.get(fixture.commands)) + .map(({ threadId, settledAt }) => `${threadId} ${settledAt}`) + .toSorted(), + branchCalls: (yield* Ref.get(fixture.branchCalls)) + .map(({ branch }) => branch) + .toSorted(), + summaryCalls: yield* Ref.get(fixture.summaryCalls), + }; + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.scoped); + + const { readThreadIds: unsettledReads, ...unsettled } = yield* sweep(query.getShellSnapshot); + const { readThreadIds: fullReads, ...full } = yield* sweep(() => query.getShellSnapshot()); + assert.deepStrictEqual(unsettled, full); + // A settle for inactivity, for a synced merged link, and for a saved + // branch PR whose project only that PR names. + assert.deepStrictEqual(unsettled.commands, [ + "idle 2026-08-20T00:00:00.000Z", + "linked 2026-08-27T00:00:00.000Z", + "merged 2026-08-27T00:00:00.000Z", + ]); + assert.deepStrictEqual(fullReads, [...unsettledReads, "settled"].toSorted()); + assert.deepStrictEqual(unsettledReads, ["idle", "linked", "merged", "open", "resumed"]); + }).pipe( + Effect.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver, { resolve: () => Effect.succeed(null) }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), + ); }); describe("storage cleanup", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4192896efed5..22d21ff27996 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -23,7 +23,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; -import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; +import { pullRequestMatchesProject, readSweepSnapshot } from "./ThreadPullRequestReactor.ts"; import { isAutoSettlementCandidate, resolveAutoSettlementAt, @@ -95,20 +95,16 @@ export const make = Effect.gen(function* () { if (!autoSettlementConfigured(settings)) { return; } - const snapshot = yield* snapshots.getShellSnapshot(); + const snapshot = yield* readSweepSnapshot(snapshots, threadId ?? null); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); // A merge rechecks all candidates, including branches that discovery has // not linked yet. Those lookups can still have cached the PR as open. - const candidates = snapshot.threads.filter( - (thread) => - (threadId === undefined || thread.id === threadId) && - isAutoSettlementCandidate(thread, now), - ); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); // Return the thread when it still needs a pull request decision. A rejected // dispatch skips it for this snapshot instead of retrying through a lookup. - const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( + const settleThread = Effect.fnUntraced( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { const settings = resolveProjectSettings( yield* settingsService.getSettings, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 85d9db3fdfed..dd418b95b9bc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -117,12 +117,26 @@ function settledTurnStateForSessionStatus( } } +// Runs for every thread event (including streaming deltas) against every +// thread the server has ever seen, so copy the array rather than map it. function updateThread( threads: ReadonlyArray, threadId: ThreadId, patch: ThreadPatch, -): OrchestrationThread[] { - return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +): ReadonlyArray { + const index = threads.findIndex((thread) => thread.id === threadId); + return index === -1 ? threads : patchThreadAt(threads, index, patch); +} + +/** For callers that already located the thread and must not scan again. */ +function patchThreadAt( + threads: ReadonlyArray, + index: number, + patch: ThreadPatch, +): ReadonlyArray { + const next = threads.slice(); + next[index] = { ...threads[index]!, ...patch }; + return next; } /** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ @@ -767,7 +781,8 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const threadIndex = nextBase.threads.findIndex((entry) => entry.id === payload.threadId); + const thread = nextBase.threads[threadIndex]; if (!thread) { return nextBase; } @@ -815,7 +830,7 @@ export function projectEvent( return { ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { + threads: patchThreadAt(nextBase.threads, threadIndex, { messages: cappedMessages, updatedAt: event.occurredAt, }), @@ -1055,7 +1070,8 @@ export function projectEvent( "payload", ).pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const threadIndex = nextBase.threads.findIndex((entry) => entry.id === payload.threadId); + const thread = nextBase.threads[threadIndex]; if (!thread) { return nextBase; } @@ -1069,7 +1085,7 @@ export function projectEvent( return { ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { + threads: patchThreadAt(nextBase.threads, threadIndex, { activities, updatedAt: event.occurredAt, }), diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts index 0b64e4f7fdcb..5bcbd35e918c 100644 --- a/apps/server/src/persistence/Layers/Sqlite.test.ts +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { + SqlitePersistenceMemory, + WAL_SIZE_LIMIT_BYTES, + makeSqlitePersistenceLive, +} from "./Sqlite.ts"; const lockHolderSource = ` const { DatabaseSync } = require("node:sqlite"); @@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", ( ); }); +it.effect("shrinks the WAL file back to the size limit after a large write", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; + // About 25% more 4 KB rows than the limit holds, in one transaction. + const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE wal_probe(payload BLOB)`; + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) + INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n + `; + assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); + + // The auto-checkpoint after the large commit copied every frame into the + // database, so the next commit restarts the WAL and cuts the file back. + yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; + assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + it.effect("applies busy_timeout in the shared persistence setup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 88342cbf1fad..56536087d69c 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -8,6 +8,9 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +// Size the -wal file is cut back to on the first commit after a WAL reset. +export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; + const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -15,6 +18,9 @@ const setup = Layer.effectDiscard( yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; + // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its + // largest size until the last connection closes. + yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); }), ); @@ -32,7 +38,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( filename: dbPath, spanAttributes: { "db.name": path.basename(dbPath), - "service.name": "t3-server", + "service.name": "t3code-server", }, }), ); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 80588f58858f..1ec7adcd623b 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -102,11 +102,14 @@ export class ProviderSessionRuntimeRepository extends Context.Service< >; /** - * List all provider runtime rows. + * List provider runtime rows. * - * Returned in ascending last-seen order. + * Returned in ascending last-seen order. `excludeStopped` filters stopped + * rows in SQL. Long-lived installs keep thousands for their resume cursors. */ - readonly list: () => Effect.Effect< + readonly list: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionRuntimeRepositoryError >; @@ -336,9 +339,9 @@ export const make = Effect.gen(function* () { }); const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: Schema.Struct({ excludeStopped: Schema.Boolean }), Result: ProviderSessionRuntimeRawDbRowSchema, - execute: () => + execute: ({ excludeStopped }) => sql` SELECT thread_id AS "threadId", @@ -351,6 +354,7 @@ export const make = Effect.gen(function* () { resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime + ${excludeStopped ? sql`WHERE status != 'stopped'` : sql``} ORDER BY last_seen_at ASC, thread_id ASC `, }); @@ -414,8 +418,8 @@ export const make = Effect.gen(function* () { ), ); - const list: ProviderSessionRuntimeRepository["Service"]["list"] = () => - listRuntimeRows(undefined).pipe( + const list: ProviderSessionRuntimeRepository["Service"]["list"] = (options) => + listRuntimeRows({ excludeStopped: options?.excludeStopped === true }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.list:query", diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 7fa15defeca9..d790002f95f1 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { expect } from "vite-plus/test"; import { FetchHttpClient } from "effect/unstable/http"; @@ -441,6 +442,26 @@ effectIt.effect("stops probing a subscriber's configured paths after its scope c }).pipe(Effect.scoped, Effect.provide(layer)); }); +effectIt.effect("writes no poll span while no client retains the scanner", () => { + let pollSpans = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "PortDiscovery.pollTick") pollSpans += 1; + return new Tracer.NativeSpan(options); + }, + }); + const layer = makeProbeFailureLayer(processProbeFailure); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* TestClock.adjust(Duration.seconds(15)); + expect(pollSpans).toBe(0); + + yield* scanner.retain; + expect(pollSpans).toBe(1); + }).pipe(Effect.scoped, Effect.provide(layer), Effect.withTracer(tracer)); +}); + effectIt.effect("uses the current configured fragment when readiness comes from cache", () => { const requests: string[] = []; const fetchFn = ((input: Parameters[0]) => { diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index f4d73d62320d..9eee1a3e215e 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -550,7 +550,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { - if ((yield* Ref.get(stateRef)).retainCount <= 0) return; const configuredUrls = [ ...new Set( [...(yield* Ref.get(stateRef)).listeners.values()].flatMap( @@ -579,9 +578,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ), ); - // Single layer-scoped polling fiber. Ticks are no-ops when no client is - // currently retained, so the cost is one Ref.get every POLL_INTERVAL. - yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + // Single layer-scoped polling fiber. Ticks skip the scan and its span when no + // client is currently retained, so the cost is one Ref.get every POLL_INTERVAL. + const pollIfRetained = Ref.get(stateRef).pipe( + Effect.flatMap((state) => (state.retainCount > 0 ? pollTick() : Effect.void)), + ); + yield* Effect.forkScoped(pollIfRetained.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = yield* Ref.modify(stateRef, (state) => [ diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index f714a70f783d..aab4d78f4d71 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -923,6 +923,18 @@ it.effect("discovers editors through the service API", () => ); for (const { platform, installPath, editor, args } of [ + { + platform: "darwin", + installPath: "Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide", + editor: "antigravity", + args: ["--goto", "/workspace with spaces/file.ts:12:4"], + }, + { + platform: "linux", + installPath: ".local/bin/antigravity-ide", + editor: "antigravity", + args: ["--goto", "/workspace with spaces/file.ts:12:4"], + }, { platform: "darwin", installPath: "Applications/Cursor.app/Contents/Resources/app/bin/code", @@ -1028,6 +1040,45 @@ for (const { platform, installPath, editor, args } of [ ); } +// `agy` is the standalone Antigravity CLI, which installs to ~/.local/bin on +// macOS and Linux and to its own bin folder on Windows. It is not the IDE. +for (const { platform, installPath, onPath } of [ + { platform: "darwin", installPath: ".local/bin/agy", onPath: true }, + { platform: "linux", installPath: ".local/bin/agy", onPath: false }, + { platform: "win32", installPath: "agy/bin/agy.cmd", onPath: true }, +] as const) { + it.effect.skipIf(windowsHost && platform !== "win32")( + `does not report the agy CLI as the Antigravity IDE on ${platform}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-cli-" }); + const executable = path.join(home, installPath); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform, + env: { + HOME: home, + LOCALAPPDATA: home, + PATH: onPath ? path.dirname(executable) : path.join(home, "empty"), + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + ); + assert.notInclude(editors, "antigravity"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launchers first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 29c25e790c61..ab287e4d78ee 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -20,7 +20,11 @@ import { } from "@t3tools/contracts"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + isCommandAvailable, + resolveSpawnCommand, + withPathDirectoryListings, +} from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -442,7 +446,7 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; - return yield* buildAvailableEditors(platform, env); + return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings); }); const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..d7f3799e103b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -412,3 +412,11 @@ describe("isWindowsCommandNotFound", () => { }), ); }); + +describe("commandName", () => { + it("drops the directory from POSIX and Windows paths", () => { + expect(ProcessRunner.commandName("/Users/me/.local/bin/claude")).toBe("claude"); + expect(ProcessRunner.commandName("C:\\Program Files\\nodejs\\npx.cmd")).toBe("npx.cmd"); + expect(ProcessRunner.commandName("git")).toBe("git"); + }); +}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 0a9bb9b04a43..049125de3fbf 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -171,7 +171,8 @@ export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsComman }, ); -const collectText = Effect.fn("processRunner.collectText")(function* (input: { +// Untraced: no attributes, and its time is the runProcessCore span. Errors fail that span. +const collectText = Effect.fnUntraced(function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string | undefined; @@ -285,10 +286,14 @@ function finalizeRunProcess( ); } +/** The executable name without its directory, recorded as `process.command` on process spans. */ +export const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); + const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], input: ProcessRunInput, ): Effect.fn.Return { + yield* Effect.annotateCurrentSpan("process.command", commandName(input.command)); const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 7d623c91b326..2438edca8b1b 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr import { ServerSettingsService } from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../terminal/Manager.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; @@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Layer.provide(Layer.mock(GitWorkflowService)({})), Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })), Layer.provide(ServerSettingsService.layerTest()), ); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 792ef92310ff..2dde0643ed60 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -48,6 +48,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< updatedAt: "2026-01-01T00:00:00.000Z", }), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index dd341a7f7859..69c553a4deaf 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -34,6 +34,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), @@ -58,7 +59,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => }); type TerminalOverrides = Pick & - Partial>; + Partial>; const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { @@ -67,6 +68,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) => clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + closeIdle: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), ...overrides, @@ -262,6 +264,7 @@ describe("ProjectSetupScriptRunner", () => { listener = null; }); }); + const closeIdle = vi.fn(() => Effect.void); const project = makeProject([ { id: "setup", @@ -329,14 +332,82 @@ describe("ProjectSetupScriptRunner", () => { ]); // The subscription is torn down once the sentinel arrives. expect(listener).toBeNull(); + // A failed run keeps its shell open for a look. + expect(closeIdle).not.toHaveBeenCalled(); }).pipe( - Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), ); }, ); + it.effect("closes the idle setup shell after a clean exit", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + let written = ""; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void (written = input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const closeIdle = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + if (result.status !== "started" || !result.completion) { + return yield* Effect.die("expected an observed setup run"); + } + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + yield* listener!({ + threadId: "thread-1", + terminalId: "setup-setup", + type: "output", + data: `${sentinel}0\r\n`, + }); + + expect((yield* result.completion).exitCode).toBe(0); + expect(closeIdle).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup" }); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }); + it.effect("unsubscribes from terminal output when the command cannot be written", () => { const open = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 16cbfaa59496..74bc41e5e8cd 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted { * Resolves when the script's shell prints the completion sentinel. The * exit code is null when the terminal exited or was closed before the * sentinel arrived. Only present when `observeCompletion` was requested. + * An exit code of 0 closes the setup shell if it has nothing left running. */ readonly completion?: Effect.Effect; } @@ -412,6 +413,16 @@ export const make = Effect.gen(function* () { Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); + // A clean run leaves only an idle prompt behind; its output stays in the + // terminal history. A failed run keeps its shell open for a look. + const completion = observed?.completion.pipe( + Effect.tap(({ exitCode }) => + exitCode === 0 + ? terminalManager.closeIdle({ threadId: input.threadId, terminalId }) + : Effect.void, + ), + ); + return { status: "started", scriptId: script.id, @@ -420,7 +431,7 @@ export const make = Effect.gen(function* () { terminalId, cwd, async: script.async !== false, - ...(observed ? { completion: observed.completion } : {}), + ...(completion ? { completion } : {}), } as const; }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index d6ddb0b9263f..58f199b834e2 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -94,6 +94,8 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const first = yield* resolver.resolve("/repo/packages/web"); rootPath = "/repo/packages/web"; + // Longer than the one-minute cadence of the background sweeps. + yield* TestClock.adjust(Duration.minutes(10)); const second = yield* resolver.resolve("/repo/packages/web"); expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); @@ -123,10 +125,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const unavailable = yield* resolver.resolve(rootPath, { refresh: true }); expect(unavailable?.webUrl).toBeUndefined(); expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo"); - }).pipe(Effect.provide(resolverLayer)); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); - it.effect("retries Git root discovery after a failed lookup", () => { + it.effect("retries Git root discovery after the negative TTL", () => { const calls: Array> = []; let rootAttempts = 0; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { @@ -159,7 +161,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + yield* TestClock.adjust(Duration.minutes(1)); const recovered = yield* resolver.resolve("/repo/packages/web"); expect(recovered?.rootPath).toBe("/repo"); expect(calls).toEqual([ @@ -167,7 +171,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo", "remote", "-v"], ]); - }).pipe(Effect.provide(resolverLayer)); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 2d7f5d02d02e..5acafa47e2e2 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -13,7 +13,11 @@ import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +// Background sweeps resolve every project each minute. A long TTL keeps them +// from spawning git each time. Clone, publish, and PR discovery (after a turn +// and before it saves links) resolve with `refresh: true`. +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15); +// Short, so a folder that gains a repository or a remote shows up quickly. const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); export interface RepositoryIdentityResolverOptions { @@ -142,20 +146,23 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const processRunner = yield* ProcessRunner.ProcessRunner; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const refine = options.refine ?? Effect.succeed; + // Git errors and timeouts resolve to null, so they use the negative TTL like + // "no repository" or "no remote". Only interrupts and defects skip the cache. + const timeToLive = (exit: Exit.Exit) => + Exit.match(exit, { + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }); const repositoryRootCache = yield* Cache.makeWith( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), - { - capacity: cacheCapacity, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + { capacity: cacheCapacity, timeToLive }, ); const repositoryIdentityCache = yield* Cache.makeWith( @@ -167,27 +174,20 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)), ), ), - { - capacity: cacheCapacity, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null - ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) - : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + { capacity: cacheCapacity, timeToLive }, ); - const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd, options) { - if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); - const cacheKey = yield* Cache.get(repositoryRootCache, cwd); - if (cacheKey === null) return null; - if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); - return yield* Cache.get(repositoryIdentityCache, cacheKey); - }); + // Untraced because almost every call is a cache hit. The lookups that spawn + // git keep their own spans. + const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced( + function* (cwd, options) { + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }, + ); return RepositoryIdentityResolver.of({ resolve }); }); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2ed593d159bc..e134ebfc68cb 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -12,7 +12,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices -The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.`; +The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Prefer these tools and \`agent-device\` for opening and driving devices. Platform tools such as \`xcrun simctl\` and \`adb\` remain available for anything they do not cover, such as builds, logs, or port forwarding. If \`device_list\` reports a platform as unavailable, say so.`; export interface T3CodeToolAvailability { readonly browser: boolean; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index d2ad661b4af4..521c1b943e84 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -148,9 +148,15 @@ export const CursorDriver: ProviderDriver = { snapshot.auth.status === "authenticated" ), (snapshot) => - readCursorUsageLimits(effectiveConfig, processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ), + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const usageLimits = yield* readCursorUsageLimits( + effectiveConfig, + processEnv, + settings.cursorKeychainUsageEnabled, + ); + return { ...snapshot, usageLimits }; + }), ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e12a320c4d48..c5e6400d11b4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -786,9 +786,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi stopOwned, Effect.gen(function* () { const mcp = McpProviderSession.readMcpProviderSession(input.threadId); - // The attachments dir grant lets the agent read pasted files at - // the paths ProviderService injects into the turn text. It is a - // leaf directory holding only uploads. + // The attachments dir grant lets the agent read path-only uploads + // at the paths ProviderService injects into the turn text. It is + // a leaf directory holding only uploads. const runtime = yield* options.makeRuntime({ cwd, clientInfo: { name: "t3-code", version: "0.0.0" }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 502620359836..295926b26644 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -6691,6 +6691,56 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("completed turns keep their ids but not the SDK messages", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + const completedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + yield* Fiber.join(completedFiber); + + const snapshot = yield* adapter.readThread(session.threadId); + assert.deepEqual( + snapshot.turns.map((entry) => ({ id: String(entry.id), items: entry.items })), + [{ id: String(turn.turnId), items: [] }], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("rewinds Claude history when the fork omits retained system messages", () => { const forkCalls: Array>> = []; let firstTurnId = ""; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 11a5322b4eee..6e30f5241780 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -267,7 +267,6 @@ interface ClaudeTurnState { * steered instead (the queued message continues the same turn). */ readonly synthetic?: boolean; - readonly items: Array; readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; @@ -422,10 +421,11 @@ interface ClaudeSessionContext { resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; - readonly turns: Array<{ - id: TurnId; - items: Array; - }>; + /** Completed turn ids, reported by readThread and trimmed on rollback. + * SDK messages are not kept: rollback reads Claude's own history through + * turnStartMessageIds, and a long-lived session would otherwise hold every + * message it ever produced. */ + readonly turns: Array<{ readonly id: TurnId }>; readonly inFlightTools: Map; readonly claudeTasks: Map; readonly taskAgents: Map; @@ -2176,10 +2176,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } return { threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), + turns: context.turns.map((turn) => ({ id: turn.id, items: [] })), }; }); @@ -2808,10 +2805,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - context.turns.push({ - id: turnState.turnId, - items: [...turnState.items], - }); + context.turns.push({ id: turnState.turnId }); yield* emitThreadTokenUsage(context, usageSnapshot, { rawMethod: "claude/result", @@ -3175,10 +3169,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - if (context.turnState) { - context.turnState.items.push(message.message); - } - for (const toolResult of toolResultBlocksFromUserMessage(message)) { const toolEntry = Array.from(context.inFlightTools.entries()).find( ([, tool]) => tool.itemId === toolResult.toolUseId, @@ -3378,7 +3368,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turnId, startedAt, synthetic: true, - items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), @@ -3461,7 +3450,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( cwd: path.resolve(context.session.cwd ?? "."), }); } - context.turnState.items.push(message.message); if ( normalizeClaudeActiveTokenUsage( message.message.usage, @@ -5205,7 +5193,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, - items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 47826fcc8704..aa5207f3aef2 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -991,21 +991,21 @@ describe("Cursor usage limits", () => { { id: "totalPercentUsed", kind: "monthly", - label: "Monthly", + label: "Overall", usedPercent: 72.4, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "autoPercentUsed", kind: "monthly", - label: "Monthly · Auto", + label: "Cursor Models", usedPercent: 69.5, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "apiPercentUsed", kind: "monthly", - label: "Monthly · API", + label: "Other Models", usedPercent: 100, resetsAt: "2026-09-20T03:53:06.000Z", }, @@ -1019,10 +1019,10 @@ describe("Cursor usage limits", () => { ); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); it("reads the instance's credentials and endpoint even when usage enabled is false", async () => { @@ -1079,6 +1079,10 @@ describe("Cursor usage limits", () => { AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), }, + false, + async () => { + throw new Error("must not read Keychain before opt-in"); + }, ).pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService( @@ -1108,6 +1112,71 @@ describe("Cursor usage limits", () => { } }); + it("reads the default macOS Cursor login from Keychain for limits", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stale credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer keychain-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 42 } }), + ), + ); + }), + ), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + }); + + it("reports a Keychain initialization failure without failing the provider refresh", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { + throw new Error("Keychain initialization failed"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request limits without a login")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + }); + + it("does not read Keychain or send its token to a custom endpoint", async () => { + for (const [apiEndpoint, environment] of [ + ["http://localhost:3000", {}], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ["https://cursor-proxy.example", {}], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ] as const) { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { + throw new Error("must not read Keychain for a custom endpoint"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.unavailable?.message).toContain("default Cursor endpoint"); + } + }); + it("reports failed requests without exposing credentials or response bodies", async () => { const limits = await runNode( readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 36d884d8ac98..7f0465b9c401 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -648,6 +648,78 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +it.effect("ProviderServiceLive shutdown leaves settled session rows untouched", () => + Effect.gen(function* () { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(); + const persistence = yield* Layer.build( + ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory))), + ), + ); + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide(persistence), + ); + const seed = (threadId: ThreadId, status: "running" | "stopped", activeTurnId: TurnId | null) => + directory.upsert({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + status, + runtimePayload: { cwd: "/repo", activeTurnId }, + }); + const readBindings = directory + .listBindings() + .pipe( + Effect.map((bindings) => new Map(bindings.map((binding) => [binding.threadId, binding]))), + ); + const settledId = asThreadId("shutdown-settled"); + const runningId = asThreadId("shutdown-running"); + const stoppedWithTurnId = asThreadId("shutdown-stopped-with-turn"); + yield* seed(settledId, "stopped", null); + yield* seed(runningId, "running", asTurnId("running-turn")); + yield* seed(stoppedWithTurnId, "stopped", asTurnId("stale-turn")); + const settledBefore = (yield* readBindings).get(settledId); + assert(settledBefore !== undefined); + + const scope = yield* Scope.make(); + yield* Layer.build( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory)), + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]), + ), + ), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ).pipe(Scope.provide(scope)); + yield* TestClock.adjust("1 minute"); + yield* Scope.close(scope, Exit.void); + + const byThread = yield* readBindings; + assert.deepStrictEqual(byThread.get(settledId), settledBefore); + for (const threadId of [runningId, stoppedWithTurnId]) { + const binding = byThread.get(threadId); + assert.equal(binding?.status, "stopped"); + assert.propertyVal(binding?.runtimePayload, "activeTurnId", null); + assert.propertyVal(binding?.runtimePayload, "lastRuntimeEvent", "provider.stopAll"); + } + const [stoppedAll] = recordedAnalytics.eventsByName("provider.sessions.stopped_all"); + assert.equal(stoppedAll?.properties?.stoppedSessionCount, 2); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive flushes deferred completions during shutdown", () => Effect.gen(function* () { const recordedAnalytics = makeRecordingAnalytics(); @@ -4729,6 +4801,57 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); + it.effect("rejects a file when its path cannot fit in the prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + validation.codex.sendTurn.mockClear(); + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-file-path-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [ + { + type: "file", + id: "thread-attach-12345678-1234-1234-1234-123456789abc-zip", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 1024, + }, + ], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + + it.effect("sends a native image when its path cannot fit in the prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-image-path-context-limit"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + validation.codex.sendTurn.mockClear(); + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-png", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1024, + }; + const input = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + yield* provider.sendTurn({ threadId, input, attachments: [attachment] }); + assert.equal(validation.codex.sendTurn.mock.calls[0]?.[0].input, input); + assert.deepEqual(validation.codex.sendTurn.mock.calls[0]?.[0].attachments, [attachment]); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -4969,6 +5092,7 @@ describe("agent browser access", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 5e5052d5ef23..f4e7b0b39bdb 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -427,6 +427,14 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +/** Stopped rows with no active turn are settled; shutdown leaves them untouched. */ +function isSettledBinding(binding: ProviderSessionDirectory.ProviderRuntimeBinding): boolean { + if (binding.status !== "stopped") return false; + const payload = binding.runtimePayload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true; + return !("activeTurnId" in payload) || payload.activeTurnId == null; +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -1626,10 +1634,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); - if (isPastedText && !appended) { + // Most adapters see generic files only through this path line, so a file + // without one would be silently dropped. Images still go natively. + if (!appended && attachment.type === "file") { return yield* toValidationError( "ProviderService.sendTurn", - `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + `Input plus attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, ); } } @@ -2331,7 +2341,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return [completed, state] as const; }); yield* recordCompletedTurnProperties(properties); - const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => adapter.listSessions().pipe( @@ -2362,7 +2371,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); McpProviderSession.clearAllMcpProviderSessions(); - const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); + // Stopped rows stay for their resume cursors, so long-lived installs hold + // thousands. Only rewrite the ones this shutdown actually stops. + const bindings = yield* directory.listBindings().pipe( + Effect.map((all) => all.filter((binding) => !isSettledBinding(binding))), + Effect.orElseSucceed(() => []), + ); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { const providerInstanceId = dieOnMissingBindingInstanceId( @@ -2382,8 +2396,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); }), ).pipe(Effect.asVoid); + // Not `sessionCount`: that older property counted every row, so a new name + // keeps the two meanings in separate series. yield* analytics.record("provider.sessions.stopped_all", { - sessionCount: threadIds.length, + stoppedSessionCount: bindings.length, }); yield* analytics.flush; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 8b41bd3e518c..45615fe7b440 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -372,6 +372,41 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }), ); + it.effect("lists only bindings that are not stopped when asked", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const statuses = ["running", "starting", "error", "stopped"] as const; + const threadIds = new Set(); + + for (const status of statuses) { + const threadId = ThreadId.make(`thread-exclude-stopped-${status}`); + threadIds.add(threadId); + yield* runtimeRepository.upsert({ + threadId, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status, + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); + } + + const liveStatuses = (yield* directory.listBindings({ excludeStopped: true })) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + const allStatuses = (yield* directory.listBindings()) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + + assert.deepEqual(liveStatuses.toSorted(), ["error", "running", "starting"]); + assert.deepEqual(allStatuses.toSorted(), ["error", "running", "starting", "stopped"]); + }), + ); + it.effect( "resets adapterKey to the new provider when provider changes without an explicit adapter key", () => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 03f1ece0b5e0..0e9de8fb868f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -182,8 +182,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); - const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => - repository.list().pipe( + const listBindings: ProviderSessionDirectoryShape["listBindings"] = (options) => + repository.list(options).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), Effect.flatMap((rows) => Effect.forEach( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index d8226648e9f3..7b1fec90f867 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -239,6 +239,7 @@ describe("ProviderSessionReaper", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 8f04bd1821fd..bf8199f80eac 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -35,15 +35,13 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); const sweep = Effect.gen(function* () { - const bindings = yield* directory.listBindings(); + // Stopped rows stay for their resume cursors and far outnumber live + // ones, so the query skips them. + const bindings = yield* directory.listBindings({ excludeStopped: true }); const now = yield* Clock.currentTimeMillis; let reapedCount = 0; for (const binding of bindings) { - if (binding.status === "stopped") { - continue; - } - const lastSeenMs = Date.parse(binding.lastSeenAt); if (Number.isNaN(lastSeenMs)) { yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { @@ -122,7 +120,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = if (reapedCount > 0) { yield* Effect.logInfo("provider.session.reaper.sweep-complete", { reapedCount, - totalBindings: bindings.length, + liveBindings: bindings.length, }); } }); diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 2fbaec59adb0..e0611e0c50ab 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -13,8 +14,10 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "../providerUsageLimits.ts"; +import { readMacCursorAccessToken } from "../cursorCredentialStore.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), @@ -39,15 +42,11 @@ export function cursorUsageResponseToLimits( : undefined; const windows: ServerProviderUsageWindow[] = []; if (response.planUsage) { - for (const [key, label] of [ - ["totalPercentUsed", "Monthly"], - ["autoPercentUsed", "Monthly · Auto"], - ["apiPercentUsed", "Monthly · API"], - ] as const) { - const usedPercent = response.planUsage[key]; + for (const { id, label } of CURSOR_USAGE_WINDOWS) { + const usedPercent = response.planUsage[id]; if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; windows.push({ - id: key, + id, kind: "monthly", label, usedPercent: clampPercent(usedPercent), @@ -63,30 +62,49 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, + allowKeychain = false, + keychainToken: () => Promise = readMacCursorAccessToken, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; + const endpoint = ( + settings.apiEndpoint.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + DEFAULT_CURSOR_API_ENDPOINT + ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); // An explicit API key can name a different account from the stored login. if (!token && environment.CURSOR_API_KEY?.trim()) { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); } const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if ( - !token && - (credentialStore === "memory" || (platform === "darwin" && credentialStore !== "file")) - ) { - // Cursor's default macOS login lives in the keychain; a leftover file may be another account. + if (!token && credentialStore === "memory") { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported", - message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.", + message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", }); } - if (!token) { + if (!token && platform === "darwin" && credentialStore !== "file") { + if (!allowKeychain) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Enable Cursor account usage in T3 Code to read its Keychain login.", + }); + } + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", + }); + } + token = (yield* Effect.tryPromise(keychainToken))?.trim(); + } else if (!token) { const home = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); const directory = @@ -106,11 +124,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; - const endpoint = ( - settings.apiEndpoint.trim() || - environment.CURSOR_API_ENDPOINT?.trim() || - "https://api2.cursor.sh" - ).replace(/\/$/, ""); const response = yield* client.execute( HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( HttpClientRequest.bearerToken(token), diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 9dbafd3e804e..1b5c47ec1d62 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -70,7 +70,10 @@ export interface ProviderSessionDirectoryShape { ProviderSessionDirectoryPersistenceError >; - readonly listBindings: () => Effect.Effect< + /** `excludeStopped` skips stopped rows in the query, not after decoding. */ + readonly listBindings: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError >; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index aee10782a613..7d6cfb1e2620 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ANTIGRAVITY_DEFAULT_MODEL, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, type RuntimeMode, @@ -15,7 +14,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES, antigravityPermissionMode, applyAntigravityAcpModelSelection, buildAntigravityPrompt, @@ -324,6 +322,41 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect.each([ + { name: "archive.zip", mimeType: "application/zip" }, + { name: "clip.mp4", mimeType: "video/mp4" }, + { name: "recording.aiff", mimeType: "audio/aiff" }, + { name: "large.pdf", mimeType: "application/pdf", sizeBytes: 75_000_000 }, + { name: "large.txt", mimeType: "text/plain", sizeBytes: 1024 * 1024 + 1 }, + { name: "large.wav", mimeType: "audio/wav", sizeBytes: 20 * 1024 * 1024 + 1 }, + ])("keeps $name as a file path without reading or spending the native media budget", (file) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const attachment = { ...textAttachment, sizeBytes: 50 * 1024 * 1024, ...file }; + const upload = yield* fixture.write(attachment, ""); + yield* fixture.fs.truncate(upload.filePath, attachment.sizeBytes); + const pdf = yield* fixture.write(pdfAttachment, ""); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024); + const input = `Inspect the file at ${upload.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [attachment, pdfAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => { + throw new Error("Path attachments must not be read into the prompt"); + }, + }), + ); + expect(prompt).toEqual([ + { type: "text", text: input }, + { type: "resource_link", uri: pdf.uri, name: "report.pdf", mimeType: "application/pdf" }, + ]); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); @@ -378,8 +411,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { it.effect.each([ { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, - { ...textAttachment, name: "archive.zip", mimeType: "application/zip" }, - { ...textAttachment, name: "recording.aiff", mimeType: "audio/aiff" }, ] satisfies ReadonlyArray)( "rejects $name instead of silently dropping it from a valid prompt", (attachment) => @@ -400,32 +431,25 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect.each([ - { attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 }, - { attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }, - { attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }, - ])( - "rejects oversized $attachment.name using file size instead of upload metadata", - ({ attachment, bytes }) => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const upload = yield* fixture.write(attachment, ""); - yield* fixture.fs.truncate(upload.filePath, bytes); - const error = yield* buildAntigravityPrompt({ - input: "Read this attachment.", - attachments: [attachment], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining(`'${attachment.name}' is too large`), - }); - }), + it.effect("rejects oversized images using file size instead of upload metadata", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const upload = yield* fixture.write(imageAttachment, ""); + yield* fixture.fs.truncate(upload.filePath, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); + const error = yield* buildAntigravityPrompt({ + input: "Read this attachment.", + attachments: [imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining("'screen.png' is too large"), + }); + }), ); - it.effect("accepts 50 MiB in total but rejects one byte more across files", () => + it.effect("keeps PDF overflow on the file-path route", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); const secondAttachment = { @@ -435,25 +459,57 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }; const first = yield* fixture.write(pdfAttachment, ""); const second = yield* fixture.write(secondAttachment, ""); - yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); + yield* fixture.fs.truncate(first.filePath, (50 * 1024 * 1024) / 2); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2); const input = { - input: undefined, + input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], attachmentsDir: fixture.attachmentsDir, }; const prompt = yield* buildAntigravityPrompt(input); expect(prompt).toEqual([ - { type: "resource_link", uri: first.uri, name: "report.pdf", mimeType: "application/pdf" }, - { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" }, + { type: "text", text: input.input }, + { + type: "resource_link", + uri: first.uri, + name: "report.pdf", + mimeType: "application/pdf", + }, + { + type: "resource_link", + uri: second.uri, + name: "second.pdf", + mimeType: "application/pdf", + }, ]); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1); - const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2 + 1); + expect(yield* buildAntigravityPrompt(input)).toEqual([ + { type: "text", text: input.input }, + { + type: "resource_link", + uri: first.uri, + name: "report.pdf", + mimeType: "application/pdf", + }, + ]); + }), + ); + + it.effect("still rejects images when the native budget is full", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pdf = yield* fixture.write(pdfAttachment, ""); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024); + yield* fixture.write(imageAttachment, new Uint8Array([1])); + const error = yield* buildAntigravityPrompt({ + input: "Inspect both attachments.", + attachments: [pdfAttachment, imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining("'second.pdf' is too large"), + errorMessage: expect.stringContaining("'screen.png' is too large"), }); }), ); @@ -463,7 +519,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const fixture = yield* makeAttachmentFixture(); const pdf = yield* fixture.write(pdfAttachment, ""); const text = yield* fixture.write(textAttachment, "a"); - yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024 - 1); const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [pdfAttachment, textAttachment], diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index f2f370068181..8f2877330bbe 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -243,10 +243,14 @@ const TEXT_FILE_EXTENSIONS = new Set([ ".ini", ".conf", ]); -export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; +const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; -/** Sends uploads as native ACP content instead of workspace path hints. */ +/** + * Sends supported uploads as native ACP content. Other files, and native + * candidates over their limits, reach the agent through the saved path + * ProviderService puts in the text block. + */ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; @@ -280,7 +284,9 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi (mimeType.startsWith("text/") || TEXT_MIME_TYPES.has(mimeType) || TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); - if (!image && !audio && !pdf && !textFile) { + const isPathOnly = + attachment.type === "file" && (isPastedText || (!audio && !pdf && !textFile)); + if (attachment.type === "image" && !image) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, ); @@ -303,26 +309,30 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); - if (isPastedText) { - if (info.type !== "File") { - return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Could not read attachment '${attachment.name}'.`, - ); - } - continue; + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); } + if (isPathOnly) continue; const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES : audio ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES : pdf - ? PROVIDER_SEND_TURN_MAX_FILE_BYTES + ? MAX_TOTAL_ATTACHMENT_BYTES : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + if ( + attachment.type === "file" && + (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES) + ) { + continue; + } totalBytes += size; - if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + if (size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Attachment '${attachment.name}' is too large. Antigravity accepts text files up to 1 MiB, images up to 10 MiB, audio up to 20 MiB, and 50 MiB total attachments.`, + `Image '${attachment.name}' is too large. Antigravity accepts images up to 10 MiB and 50 MiB of native attachments per message.`, ); } const uri = yield* path.toFileUrl(attachmentPath).pipe( diff --git a/apps/server/src/provider/cursorCredentialStore.test.ts b/apps/server/src/provider/cursorCredentialStore.test.ts new file mode 100644 index 000000000000..f212ce110ea6 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.test.ts @@ -0,0 +1,46 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + CursorKeychainTimeoutError, + makeCachedCursorAccessTokenReader, +} from "./cursorCredentialStore.ts"; + +describe("Cursor Keychain reader", () => { + it("shares concurrent reads and rechecks after the cache expires", async () => { + let reads = 0; + let time = 0; + const read = makeCachedCursorAccessTokenReader( + async () => { + reads++; + return `token-${reads}`; + }, + () => time, + ); + assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); + assert.strictEqual(await read(), "token-1"); + assert.strictEqual(reads, 1); + time = 5 * 60_000; + assert.strictEqual(await read(), "token-2"); + }); + + it("gives up on an unanswered prompt and reuses it on the next read", async () => { + let reads = 0; + let allow: (token: string) => void = () => {}; + const read = makeCachedCursorAccessTokenReader( + () => { + reads++; + return new Promise((resolve) => { + allow = resolve; + }); + }, + () => 0, + 1, + ); + const error = await read().catch((cause: unknown) => cause); + assert.instanceOf(error, CursorKeychainTimeoutError); + const retry = read(); + allow("token"); + assert.strictEqual(await retry, "token"); + assert.strictEqual(reads, 1); + }); +}); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts new file mode 100644 index 000000000000..2857683e4821 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -0,0 +1,55 @@ +import * as NodeModule from "node:module"; + +const CACHE_MS = 5 * 60_000; + +const requireForKeyring = NodeModule.createRequire(import.meta.url); + +/** Rejected when nobody answers the macOS Keychain prompt in time. */ +export class CursorKeychainTimeoutError extends Error { + constructor() { + super("Timed out waiting for Keychain access."); + } +} + +/** + * Share one Keychain request across usage history and limits in this server process. + * + * macOS shows the access prompt on the server's own screen, which a remote + * client cannot answer, so callers give up after `timeoutMs`. The read stays in + * flight: the next call reuses it instead of stacking a second prompt, and picks + * up the token once someone allows access. + */ +export function makeCachedCursorAccessTokenReader( + read: () => Promise, + now: () => number = Date.now, + timeoutMs = 30_000, +): () => Promise { + let cached: { token: string; until: number } | null = null; + let pending: Promise | null = null; + return () => { + if (cached && cached.until > now()) return Promise.resolve(cached.token); + pending ??= read() + .then((token) => { + cached = token ? { token, until: now() + CACHE_MS } : null; + return token; + }) + .finally(() => { + pending = null; + }); + const deadline = AbortSignal.timeout(timeoutMs); + return Promise.race([ + pending, + new Promise((_, reject) => { + deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { + once: true, + }); + }), + ]); + }; +} + +/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ +export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { + const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); + return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index e87f758e0e3d..d8319bc44a71 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -78,7 +78,6 @@ export function resolveOpenCodeServerPassword( : input.environment.OPENCODE_SERVER_PASSWORD; } -const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; @@ -289,11 +288,8 @@ export interface OpenCodeRuntimeShape { function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { - if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { - continue; - } - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - return match?.[1] ?? null; + const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); + if (match?.[1]) return match[1]; } return null; } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index db4f1f53b710..71ec0719325d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -28,6 +28,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -975,3 +976,114 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ), ); }); + +describe.sequential("startup catch-up", () => { + // An unlinked relay with publishing off. `link` writes the link secrets and + // `enablePublishing` the opt-in. Counts link checks (relay URL reads) and + // catch-up publishes (shell snapshot reads). + function makeUnlinkedRelay() { + const secrets = makeMemorySecretStore(); + const counts = { linkChecks: 0, catchUpPublishes: 0 }; + const countingStore = { + ...secrets.store, + get: (name: string) => + Effect.suspend(() => { + if (name === RELAY_URL_SECRET) counts.linkChecks += 1; + return secrets.store.get(name); + }), + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + + const layer = AgentAwarenessRelay.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, countingStore), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed("env-1" as EnvironmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(OrchestrationEngineService, { + streamDomainEvents: Stream.never, + } as unknown as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => + Effect.sync(() => { + counts.catchUpPublishes += 1; + return { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-05-25T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + }), + } as unknown as ProjectionSnapshotQueryShape), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + const link = Effect.all( + [ + secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"), + secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"), + ], + { discard: true }, + ); + const enablePublishing = secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + return { counts, layer, link, enablePublishing }; + } + + it.effect("checks an unlinked environment once a minute and still catches up once linked", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Get past the backoff ramp, then count checks in a steady window. + yield* TestClock.adjust("10 minutes"); + const checksBeforeWindow = counts.linkChecks; + yield* TestClock.adjust("10 minutes"); + expect(counts.linkChecks - checksBeforeWindow).toBe(10); + expect(counts.catchUpPublishes).toBe(0); + + yield* link; + yield* TestClock.adjust("1 minute"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("publishes at once when this process links while the check is backed off", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Backed off to 60 s: the next check is still seconds away. + yield* TestClock.adjust("10 minutes"); + yield* link; + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(0); + + yield* relay.requestCatchUp(); + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("catches up within 5 s when another process enables publishing on a link", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* link; + yield* relay.start(); + + yield* TestClock.adjust("10 minutes"); + expect(counts.catchUpPublishes).toBe(0); + + // `t3 connect publish` writes the opt-in without waking this process. + yield* enablePublishing; + yield* TestClock.adjust("5 seconds"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 29d2abecebb7..c9acf6b510fc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -25,6 +25,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -51,6 +52,8 @@ export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, { readonly publishThread: (threadId: ThreadId) => Effect.Effect; + /** Retries a pending catch-up publish now. Call after this process links or enables publishing. */ + readonly requestCatchUp: () => Effect.Effect; readonly start: () => Effect.Effect; } >()("t3/relay/AgentAwarenessRelay") {} @@ -309,6 +312,8 @@ export const make = Effect.gen(function* () { const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); + // Holds at most one pending wake, so a burst of requests costs one retry. + const catchUpRequests = yield* Queue.dropping(1); const publishedStateByThreadRef = yield* Ref.make(new Map()); const readSecretString = (name: string) => @@ -534,18 +539,29 @@ export const make = Effect.gen(function* () { withRelayClientTracing, ); + // Publishes the active threads once. Returns why it did not, so the retry + // knows whether it is waiting on a link or on the publish setting. const publishActiveThreadsUnsafe = Effect.gen(function* () { + // One secret read settles the common never-linked case; the full link + // config is read only once publishing is on. + const relayUrl = yield* readSecretString(RELAY_URL_SECRET).pipe( + Effect.orElseSucceed(() => null), + ); + if (!relayUrl) { + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); + return "unlinked" as const; + } const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), ); if (!publishAgentActivity) { yield* Effect.logDebug("agent activity snapshot skipped; publication disabled"); - return false; + return "disabled" as const; } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); - return false; + return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; const snapshot = yield* snapshotQuery.getShellSnapshot(); @@ -557,20 +573,29 @@ export const make = Effect.gen(function* () { }); if (activeThreadIds.length === 0) { yield* Effect.logDebug("agent activity snapshot has no publishable threads"); - return true; + return "published" as const; } yield* Effect.logInfo("publishing active agent activity snapshot", { count: activeThreadIds.length, }); yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); - return true; + return "published" as const; }); + // Publishes the catch-up snapshot of active threads once the environment is + // linked and publishing is enabled. Many environments never link, so while + // unlinked the retry backs off from 5 s to 60 s. Only this process writes + // the link, and it calls `requestCatchUp`, which ends the wait early. A + // linked environment keeps the 5 s retry, because `t3 connect publish` can + // turn publishing on from another process. const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => Effect.gen(function* () { + let unlinkedRetryDelayMs = 5_000; while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { + const result = yield* publishActiveThreadsUnsafe.pipe( + Effect.orElseSucceed(() => "failed" as const), + ); + if (result === "published") { yield* Ref.set(activeSnapshotPublishedRef, true); if (logEnabledWhenReady) { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); @@ -580,7 +605,11 @@ export const make = Effect.gen(function* () { } return; } - yield* Effect.sleep("5 seconds"); + const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; + yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); + if (result === "unlinked") { + unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + } } }); @@ -657,6 +686,7 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, + requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), start, }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 12f37780701d..43595dbafce1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -176,10 +176,12 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import { REPLAY_MARKER_MAX_AGE } from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; @@ -489,7 +491,7 @@ const makeBrowserOtlpPayload = (spanName: string) => url: collector.url, exportInterval: "10 millis", resource: { - serviceName: "t3-web", + serviceName: "t3code-web", attributes: { "service.runtime": "t3-web", "service.mode": "browser", @@ -561,6 +563,7 @@ const buildAppUnderTest = (options?: { CloudManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"] >; relayClient?: Partial; + agentAwarenessRelay?: Partial; cloudCliTokenManager?: Partial; httpClient?: HttpClient.HttpClient; nativeTelemetryClient?: Partial; @@ -588,7 +591,6 @@ const buildAppUnderTest = (options?: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, @@ -1173,14 +1175,20 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.succeed( - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: () => Effect.succeed({ status: "disabled" }), - recoveryRequests: Stream.empty, - requestRecovery: () => Effect.void, - withLinkStateLock: (effect) => effect, - ...options?.layers?.cloudManagedEndpointRuntime, + Layer.mergeAll( + Layer.succeed( + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ + requestCatchUp: () => Effect.void, + ...options?.layers?.agentAwarenessRelay, }), ), ), @@ -2644,6 +2652,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects a DPoP replay by time alone once its marker can be pruned", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const acceptedAt = yield* DateTime.now; + // The longest-lived proof: `iat` at the 5 s future skew the verifier allows. + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(acceptedAt.epochMilliseconds / 1_000) + 5, + }); + const exchange = exchangeAccessToken(credential.credential, { + headers: { dpop: dpop.proof }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal((yield* exchange).response.status, 200); + // While the proof is fresh, only the replay marker rejects it. + assert.equal((yield* exchange).body.dpopFailureReason, "replay"); + // Once the marker can be pruned, the time check rejects the proof by itself. + yield* TestClock.setTime( + acceptedAt.epochMilliseconds + Duration.toMillis(REPLAY_MARKER_MAX_AGE), + ); + assert.equal((yield* exchange).body.dpopFailureReason, "time_window"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("ignores forwarded host headers when validating token exchange DPoP URLs", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3080,6 +3122,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("wakes the agent awareness relay when this server links or changes publishing", () => + Effect.gen(function* () { + let catchUpRequests = 0; + yield* buildAppUnderTest({ + layers: { + agentAwarenessRelay: { + requestCatchUp: () => + Effect.sync(() => { + catchUpRequests += 1; + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/relay-config"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }, + ); + assert.equal(relayConfigResponse.status, 200); + assert.equal(catchUpRequests, 1); + + const preferencesResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/preferences"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ publishAgentActivity: true }), + }, + ); + assert.equal(preferencesResponse.status, 200); + assert.equal(catchUpRequests, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects relay config with an invalid cloud mint public key", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3711,6 +3802,82 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects cloud replays by time alone once their markers can be pruned", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/relay-config"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }, + ); + assert.equal(relayConfigResponse.status, 200); + + const acceptedAt = yield* DateTime.now; + // The longest-lived proofs: `iat` at the 60 s future skew the handlers + // allow, and the 5 minute maximum lifetime. + const issuedAt = DateTime.add(acceptedAt, { minutes: 1 }); + const proofTimes = { + issuedAt: DateTime.formatIso(issuedAt), + expiresAt: DateTime.formatIso(DateTime.add(issuedAt, { minutes: 5 })), + }; + const requests = [ + [ + "/api/t3-connect/health", + makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + nonce: "cloud-health-nonce-pruned", + ...proofTimes, + }), + ], + [ + "/api/t3-connect/mint-credential", + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + nonce: "cloud-mint-nonce-pruned", + ...proofTimes, + }), + ], + ] as const; + const postAll = Effect.forEach(requests, ([pathname, request]) => + Effect.gen(function* () { + const response = yield* fetchEffect(yield* getHttpServerUrl(pathname), { + method: "POST", + headers: { "content-type": "application/json" }, + body: jsonRequestBody(request), + }); + return response.status; + }), + ); + + assert.deepStrictEqual(yield* postAll, [200, 200]); + // While the proofs are fresh, only the replay markers reject them (409). + assert.deepStrictEqual(yield* postAll, [409, 409]); + // Once the markers can be pruned, the time checks reject the proofs by themselves (401). + yield* TestClock.setTime( + acceptedAt.epochMilliseconds + Duration.toMillis(REPLAY_MARKER_MAX_AGE), + ); + assert.deepStrictEqual(yield* postAll, [401, 401]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "validates cloud proofs against the configured relay issuer, not the transport URL", () => @@ -5283,7 +5450,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { attributes: [ { key: "service.name", - value: { stringValue: "t3-web" }, + value: { stringValue: "t3code-web" }, }, ], }, @@ -5425,7 +5592,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "rpc.method": "server.getSettings", }, resourceAttributes: { - "service.name": "t3-web", + "service.name": "t3code-web", }, scope: { name: "effect", @@ -5556,7 +5723,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { // the stub's utf8 decode even though the surrounding bytes don't. assert.notEqual(forwarded.body[0], "{"); assert.include(forwarded.body, "client.protobuf.test"); - assert.include(forwarded.body, "t3-web"); + assert.include(forwarded.body, "t3code-web"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5657,11 +5824,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(record.links, []); assert.equal(record.scope.name, scopeSpan.scope.name); assert.deepEqual(record.scope.attributes, {}); - assert.equal(record.resourceAttributes["service.name"], "t3-web"); + assert.equal(record.resourceAttributes["service.name"], "t3code-web"); assert.equal(record.status?.code, String(span.status.code)); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("does not trace browser OTLP trace exports on the server", () => + Effect.gen(function* () { + const spanNames: Array = []; + const forwardedUrls: Array = []; + yield* buildAppUnderTest({ + config: { otlpTracesUrl: "http://collector.test/v1/traces" }, + layers: { + httpClient: HttpClient.make((request) => + Effect.sync(() => { + forwardedUrls.push(request.url); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })); + }), + ), + }, + }).pipe( + Effect.provideService( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + spanNames.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ), + ); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + spanNames.length = 0; + + // The query string must not bring back the HTTP server span. + for (const url of ["/api/observability/v1/traces", "/api/observability/v1/traces?x=1"]) { + const response = yield* HttpClient.post(url, { + headers: { cookie, "content-type": "application/json" }, + body: yield* HttpBody.json({ resourceSpans: [] }), + }); + assert.equal(response.status, 204); + } + + assert.deepEqual(forwardedUrls, [ + "http://collector.test/v1/traces", + "http://collector.test/v1/traces", + ]); + assert.deepEqual(spanNames, []); + + // Other routes keep their HTTP server span. + const session = yield* HttpClient.get("/api/auth/session", { headers: { cookie } }); + assert.equal(session.status, 200); + assert.include(spanNames, "http.server GET"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4f264ae1cb0d..88c08fd3bdee 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import { staticAndDevRouteLayer, browserApiCorsLayer, httpCompressionLayer, + untracedRequestsLayer, } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; @@ -119,9 +120,12 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; +import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { @@ -182,7 +186,8 @@ export const HTTP_ROUTER_CONFIG = { // those finalizers get a chance to run. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const ResourceAttributionLayerLive = ResourceAttribution.layer; -const ApplicationObservabilityLive = ObservabilityLive.pipe( +const ApplicationObservabilityLive = EventLoopMonitor.layer.pipe( + Layer.provideMerge(ObservabilityLive), Layer.provideMerge(ResourceAttributionLayerLive), ); @@ -499,6 +504,7 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(AntigravityInstallationRefreshLive), + Layer.provideMerge(ReplayMarkers.layer), Layer.provideMerge(ProviderAuthServiceLive), // Core Services Layer.provideMerge(ServerSettingsLayerLive), @@ -600,6 +606,8 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), + // Last, so no route layer can replace the server's one TracerDisabledWhen. + untracedRequestsLayer, ).pipe( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. @@ -957,6 +965,7 @@ const makeServerLayer = Layer.unwrap( runtimeStateLayer.pipe(Layer.provide(launcherLayer)), tailscaleServeLayer, cloudDesiredLinkReconcileLayer, + HeapSnapshot.layer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index a5437582d2e3..43843b249eea 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -1,6 +1,7 @@ import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as NodeOS from "node:os"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -57,7 +58,6 @@ const configLayer = (overrides: Partial) = otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, @@ -146,11 +146,38 @@ describe("ServerLoggerLive", () => { const [request] = requests; assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "server logger under test"); - assert.include(request?.body ?? "", "t3-server"); + assert.include(request?.body ?? "", "t3code-server"); assert.include(request?.body ?? "", "service.runtime"); }), ); + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-server"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }), + ); + it.effect("stays off the network when no logs endpoint is configured", () => Effect.gen(function* () { const requests = yield* logThrough({}); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index df879a2cf307..4bbdb2e9c8b5 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -169,6 +169,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -298,6 +299,7 @@ it.effect.each([ getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -385,6 +387,7 @@ it.effect( getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -450,6 +453,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 80ab2c43e42c..f04161cfbd3c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1230,6 +1230,73 @@ it.layer( }), ); + it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => + Effect.gen(function* () { + // FakePtyAdapter assigns pids from 9000 in open order. + const { manager, ptyAdapter } = yield* createManager(5, { + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + // An async prompt worker: a copy of the shell with no children. + { pid: 100, ppid: 9000, name: "zsh" }, + { pid: 9001, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9001, name: "node" }, + { pid: 9002, ppid: 1, name: "zsh" }, + // A subshell with a child is real work. + { pid: 300, ppid: 9002, name: "zsh" }, + { pid: 301, ppid: 300, name: "sleep" }, + { pid: 9003, ppid: 1, name: "zsh" }, + ]), + }).pipe(Effect.provide(withHostPlatform("linux"))); + yield* manager.open(openInput({ terminalId: "idle" })); + yield* manager.open(openInput({ terminalId: "dev-server" })); + yield* manager.open(openInput({ terminalId: "subshell" })); + yield* manager.open(openInput({ threadId: "thread-2" })); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ + true, + false, + false, + false, + ]); + }), + ); + + it.effect("keeps terminals that get input or output while closeIdle checks them", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + // The typed command's process misses the snapshot, but its input or echo lands. + let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; + const { manager, getEvents } = yield* createManager(5, { + ptyAdapter, + subprocessPollIntervalMs: 60_000, + subprocessInspector: (pid) => + duringCheck(pid).pipe( + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), + }); + yield* manager.open(openInput({ terminalId: "typed" })); + yield* manager.open(openInput({ terminalId: "echoed" })); + const [typed, echoed] = ptyAdapter.processes; + duringCheck = (pid) => + pid === typed!.pid + ? manager + .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) + .pipe(Effect.orDie) + : Effect.gen(function* () { + echoed!.emitData("make build\r\n"); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + }).pipe(Effect.orDie); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); + }), + ); + it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => Effect.gen(function* () { const fallbackCalls: Array = []; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 1a65587430d5..75d592c00c0d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service< */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * Close a thread's terminals that wait at an idle shell prompt. A terminal + * that runs a command stays open. When `terminalId` is set, only that + * terminal is considered. Used when a thread settles and when a setup + * script finishes. + */ + readonly closeIdle: (input: { + readonly threadId: string; + readonly terminalId?: string; + }) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * @@ -275,6 +286,8 @@ interface TerminalSessionState { exitSignal: number | null; updatedAt: string; eventSequence: number; + /** Counts writes, so closeIdle can see input that has not echoed yet. */ + inputCount: number; cols: number; rows: number; process: PtyAdapter.PtyProcess | null; @@ -692,7 +705,17 @@ function deriveSubprocessInspectResult( terminalPid: number, platform: NodeJS.Platform, ): TerminalSubprocessInspectResult { - const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0]; + const commandName = (pid: number) => + normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); + const shellName = commandName(terminalPid); + // Async prompt themes fork the shell into a helper that waits with no + // children of its own. That copy is not a command the user started. + const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( + (pid) => + shellName === null || + commandName(pid) !== shellName || + (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0, + ); if (childPid === undefined) { return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } @@ -707,7 +730,7 @@ function deriveSubprocessInspectResult( pending.push(pid); } } - const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); + const normalized = commandName(childPid); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, @@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } + session.inputCount += 1; yield* Effect.try({ try: () => process.write(input.data), catch: (cause) => @@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const running = (yield* sessionsForThread(input.threadId)).filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && + Number.isInteger(session.pid) && + (input.terminalId === undefined || session.terminalId === input.terminalId), + ); + if (running.length === 0) return; + // A command started during the process check can miss the snapshot, + // but its input or echo still lands. Both counters only grow, so the + // sum changes when either one does. + const activityMark = (session: TerminalSessionState) => + session.eventSequence + session.inputCount; + const marks = new Map( + running.map((session) => [session.terminalId, activityMark(session)]), + ); + // Inspect now instead of trusting the last poll, so a command started + // since then keeps its terminal. + const { inspector } = yield* acquireSubprocessInspector; + yield* Effect.forEach( + running, + (session) => + inspector(session.pid).pipe( + Effect.flatMap((result) => + result.hasRunningSubprocess || + activityMark(session) !== marks.get(session.terminalId) + ? Effect.void + : closeSession(input.threadId, session.terminalId, false), + ), + ), + { discard: true }, + ); + }), + ).pipe( + // The process check failed, so every terminal stays open. + Effect.catch((error) => + Effect.logWarning("failed to close idle terminals", { + threadId: input.threadId, + error: error.message, + }), + ), + ); + return TerminalManager.of({ open, attachStream, @@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func clear, restart, close, + closeIdle, subscribe, subscribeMetadata, }); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b391e213ab9a..15ea4b673f22 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3,10 +3,11 @@ 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 { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { EnvironmentId, @@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { @@ -82,9 +84,11 @@ const serviceLayers = (input: { /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; readonly environment?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -101,7 +105,12 @@ const serviceLayers = (input: { ), Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { + HOME: input.home, GROK_HOME: NodePath.join(input.home, "grok"), + OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), + ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), + XDG_CONFIG_HOME: NodePath.join(input.home, "config"), + APPDATA: NodePath.join(input.home, "config"), ...input.environment, }), ), @@ -112,6 +121,226 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("omits Cursor account usage when no file login is saved", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const platform of ["linux", "win32", "darwin"] as const) { + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-no-login-${platform}`, + home, + settings, + platform, + environment: { AGENT_CLI_CREDENTIAL_STORE: "file" }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live("keeps Cursor credential errors visible when a saved login cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const authPath = NodePath.join(home, "config", "cursor", "auth.json"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile(authPath, "invalid json"); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-cursor-invalid-login", home, settings }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.message, "Cursor credentials could not be read."); + }).pipe(Effect.scoped), + ); + + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cursor-keychain-disabled", + home, + settings, + platform: "darwin", + environment: {}, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.strictEqual(cursor?.action, "enableCursorKeychain"); + }).pipe(Effect.scoped), + ); + + it.live("ignores stale Cursor file logins when the active credential store differs", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const [index, testCase] of [ + { + platform: "darwin" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: [".cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: ["config", "cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { CURSOR_API_KEY: "different-account" }, + authPath: ["config", "cursor", "auth.json"], + }, + ].entries()) { + const authPath = NodePath.join(home, ...testCase.authPath); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile( + authPath, + encodeUnknownJsonString({ accessToken: "stale-token" }), + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-store-${index}`, + home, + settings, + platform: testCase.platform, + environment: testCase.environment, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.include(cursor?.message ?? "", "Cursor CLI login"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live( + "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", + () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const root = NodePath.join(home, "opencode"); + const message = yield* encodeUnknownJson({ + id: "msg_1", + sessionID: "session-1", + role: "assistant", + modelID: "example-model", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } }, + }); + const bubble = yield* encodeUnknownJson({ + type: 2, + createdAt: "2026-08-01T10:00:00Z", + modelInfo: { modelName: "example-model" }, + tokenCount: { inputTokens: 100, outputTokens: 20 }, + }); + yield* Effect.promise(async () => { + const directory = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message); + const desktop = NodePath.join(home, "config", "Cursor", "User", "globalStorage"); + await NodeFSP.mkdir(desktop, { recursive: true }); + const db = new NodeSqlite.DatabaseSync(NodePath.join(desktop, "state.vscdb")); + try { + db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)"); + db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)").run( + "bubbleId:session:assistant", + bubble, + ); + } finally { + db.close(); + } + }); + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.provider, "opencode"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); + assert.strictEqual( + summary.buckets[0]?.sourcePath, + yield* Effect.promise(() => NodeFSP.realpath(root)), + ); + assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "opencode") + ?.distinctSessions, + 1, + ); + }).pipe(Effect.scoped), + ); + + it.live("counts aliased OpenCode and Antigravity directories once", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const opencode = NodePath.join(home, "opencode-store"); + const opencodeAlias = NodePath.join(home, "opencode-alias"); + const conversations = NodePath.join(home, "antigravity-conversations"); + const antigravityA = NodePath.join(home, "antigravity-a"); + const antigravityB = NodePath.join(home, "antigravity-b"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(opencode); + await NodeFSP.symlink(opencode, opencodeAlias, "junction"); + await NodeFSP.mkdir(conversations); + await NodeFSP.mkdir(antigravityA); + await NodeFSP.mkdir(antigravityB); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityA, "conversations"), + "junction", + ); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityB, "conversations"), + "junction", + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-aliased-roots-test", + home, + settings, + environment: { + OPENCODE_DATA_DIR: `${opencode},${opencodeAlias}`, + ANTIGRAVITY_DATA_DIR: `${antigravityA},${antigravityB}`, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const sourcesFor = (provider: "opencode" | "antigravity") => + summary.sources.filter((source) => source.fingerprint.provider === provider); + assert.strictEqual(sourcesFor("opencode").length, 1); + assert.strictEqual(sourcesFor("antigravity").length, 1); + assert.strictEqual( + sourcesFor("opencode")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(opencode)), + ); + assert.strictEqual( + sourcesFor("antigravity")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(conversations)), + ); + }).pipe(Effect.scoped), + ); + it.live("reads configured and disabled accounts once across shared and aliased homes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -258,9 +487,12 @@ describe("UsageService", () => { const service = yield* UsageService.make; const first = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(first), 7); + const configuredProjects = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(configured, "projects")), + ); assert.include( first.sources.map((source) => source.fingerprint.resolvedHomePath), - NodePath.join(configured, "projects"), + configuredProjects, ); yield* settingsService.updateSettings({ providerInstances: { @@ -275,9 +507,12 @@ describe("UsageService", () => { }); const second = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(second), 8); + const environmentProjects = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(environmentHome, "projects")), + ); assert.include( second.sources.map((source) => source.fingerprint.resolvedHomePath), - NodePath.join(environmentHome, "projects"), + environmentProjects, ); }).pipe( Effect.provide( @@ -505,6 +740,9 @@ describe("UsageService", () => { Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + const transcriptDir = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(home, "claude", "projects")), + ); yield* Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; @@ -519,7 +757,7 @@ describe("UsageService", () => { exists: (path) => fileSystem.exists(path).pipe( Effect.tap(() => { - if (path !== NodePath.join(home, "claude", "projects")) return Effect.void; + if (path !== transcriptDir) return Effect.void; homeProbes += 1; return Deferred.succeed( homeProbes === 1 ? firstScanStarted : secondScanStarted, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 1bf3e6f8c202..1e5cb6db20fe 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,14 +1,14 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files (Claude Code, Codex, and - * Grok Build) rather than T3 Code's orchestration projections, so usage covers - * turns driven outside T3 Code too. This is the approach `ccusage` takes. + * The scan reads native session files and databases, including work driven + * outside T3 Code. Cursor's local records provide only partial coverage. * - * Transcripts are append-only, so parsed records are memoised per file by + * 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. + * SQLite readers query live databases each scan so WAL writes remain visible. * * @module UsageService */ @@ -19,6 +19,7 @@ import { CodexSettings, type ProviderInstanceConfig, USAGE_CONTRACT_VERSION, + ProviderInstanceId, type ServerSettings as ServerSettingsValue, type UsageProviderKind, type UsageSource, @@ -27,10 +28,11 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } 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"; @@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -144,12 +150,14 @@ export 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; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; const fileCache: ScanCache = new Map(); const sourceCache = new Map(); @@ -446,6 +454,10 @@ export const make = Effect.gen(function* () { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly hostId?: string; + readonly status?: UsageSource["status"]; + readonly message?: string; + readonly action?: UsageSource["action"]; /** Parsed records per file, or `null` when the directory does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] @@ -481,6 +493,173 @@ export const make = Effect.gen(function* () { } scanned.push({ provider, dir, volumeId, files: parsedFiles }); } + + 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(); + for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [ + path.join( + dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), + "opencode", + ), + ])) { + const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); + scanned.push({ + 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." } : {}), + }); + } + 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, + }), + ), + ); + 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 antigravity = yield* Effect.promise(() => + readAntigravityUsage([...antigravityDirs], windowStartMs), + ); + for (const dir of antigravityDirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const failed = antigravity.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 : antigravity.files.filter((file) => file.root === dir), + status: failed ? "partial" : "ok", + ...(failed ? { message: "Some Antigravity history could not be read." } : {}), + }); + } + 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"; + if ( + platform === "darwin" && + credentialStore !== "file" && + !loginUnavailable && + !settings.cursorKeychainUsageEnabled + ) { + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: "", + files: null, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }); + return scanned; + } + const cursorUntilMs = yield* Clock.currentTimeMillis; + const account = loginUnavailable + ? { + accountKey: null, + records: [], + missing: true, + error: "Cursor account history needs a Cursor CLI login on this server.", + } + : yield* Effect.promise(() => + readCursorAccountUsage( + platform === "darwin" && credentialStore !== "file" + ? { kind: "keychain" } + : cursorAuthPath, + windowStartMs, + cursorUntilMs, + ), + ); + // No saved login means there is no account source to report, not a setup error. + if (account.missing && account.error === null) return scanned; + if (account.accountKey !== null && account.error === null && !account.missing) { + // 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:${account.accountKey}`; + scanned.push({ + provider: "cursor", + dir: source, + hostId: "cursor.com", + volumeId: account.accountKey, + files: [{ path: source, records: account.records }], + status: "ok", + }); + return scanned; + } + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), + // Never combine a local fallback with another server's account-wide history. + files: null, + message: + account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.", + }); return scanned; }); @@ -555,7 +734,16 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - for (const { provider, dir, volumeId, files } of scannedDirs) { + for (const { + provider, + dir, + volumeId, + files, + status, + message, + action, + hostId: sourceHostId, + } of scannedDirs) { const retainedFiles = [...(files ?? [])]; const livePaths = new Set(retainedFiles.map((file) => file.path)); // Cleanup may remove transcripts, but the usage we already saved still @@ -601,21 +789,23 @@ export const make = Effect.gen(function* () { } // Only sessions contributing in-window count; the mtime slack can // admit boundary files whose records fall outside the range. - if (aggregator.add(usageRecord) && record.sessionId.length > 0) { + if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } } sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, // Clients exclude missing sources, so saved records remain an available source. - status: files === null && scannedFiles === 0 ? "missing" : "ok", + status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"), scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: files === null ? "No transcript directory on this environment." : null, + message: + message ?? (files === null ? "No transcript directory on this environment." : null), + ...(action ? { action } : {}), }); } @@ -650,6 +840,7 @@ export const make = Effect.gen(function* () { const scanKey = ( input: UsageSummaryInput, priceOverrides: ServerSettingsValue["usagePriceOverrides"], + cursorKeychainUsageEnabled: boolean, ): string => JSON.stringify([ input.timeZone, @@ -659,11 +850,12 @@ export const make = Effect.gen(function* () { input.sinceTime ?? null, input.untilTime ?? null, priceOverrides, + cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides); + const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts new file mode 100644 index 000000000000..54c00b804b7a --- /dev/null +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -0,0 +1,374 @@ +// node:sqlite reads live conversation databases while Node fs discovers them. +// @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 type { UsageRecord } from "./usageTranscripts.ts"; + +type FieldValue = number | bigint | Uint8Array; +type Fields = Map; + +/** Antigravity stores usage metadata as protobuf, independently of conversation text. */ +function fields(bytes: Uint8Array): Fields { + let offset = 0; + const result: Fields = new Map(); + const varint = () => { + let value = 0n; + for (let shift = 0n; shift < 70n; shift += 7n) { + const byte = bytes[offset++]; + if (byte === undefined || (shift === 63n && byte > 1)) { + throw new Error("Invalid Antigravity protobuf varint"); + } + value |= BigInt(byte & 127) << shift; + if (byte < 128) { + return value > BigInt(Number.MAX_SAFE_INTEGER) ? value : Number(value); + } + } + throw new Error("Invalid Antigravity protobuf varint"); + }; + while (offset < bytes.length) { + const tag = varint(); + if (typeof tag !== "number") throw new Error("Invalid protobuf field"); + const number = Math.floor(tag / 8); + const wire = tag % 8; + if (number === 0) throw new Error("Invalid protobuf field"); + let value: FieldValue; + if (wire === 0) { + value = varint(); + } else if (wire === 1 || wire === 5 || wire === 2) { + const length = wire === 2 ? varint() : wire === 1 ? 8 : 4; + if (typeof length !== "number") throw new Error("Invalid protobuf field length"); + if (length > bytes.length - offset) throw new Error("Truncated protobuf field"); + value = bytes.subarray(offset, offset + length); + offset += length; + if (wire !== 2) continue; + } else { + throw new Error("Unsupported protobuf wire type"); + } + const entries = result.get(number) ?? []; + entries.push(value); + result.set(number, entries); + } + return result; +} + +const numberAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return typeof entry === "number" ? entry : 0; +}; +const bytesAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return entry instanceof Uint8Array ? entry : undefined; +}; +const nested = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? new Map() : fields(bytes); +}; +const textAt = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? "" : new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim(); +}; +const timestamp = (value: Fields) => { + const seconds = numberAt(value, 1); + return seconds > 0 ? seconds * 1000 + Math.floor(numberAt(value, 2) / 1_000_000) : null; +}; + +const MODEL_IDS: Record = { + 246: "gemini-2.5-pro", + 312: "gemini-2.5-flash", + 313: "gemini-2.5-flash-thinking", + 329: "gemini-2.5-flash-thinking", + 330: "gemini-2.5-flash-lite", + 281: "claude-sonnet-4", + 282: "claude-sonnet-4", + 290: "claude-opus-4", + 291: "claude-opus-4", + 333: "claude-sonnet-4-5", + 334: "claude-sonnet-4-5", + 340: "claude-haiku-4-5", + 341: "claude-haiku-4-5", + 1026: "claude-opus-4-6", + 1035: "claude-sonnet-4-6", + 1016: "gemini-3.1-pro", + 1036: "gemini-3.1-pro", + 1037: "gemini-3.1-pro", + 1018: "gemini-3-flash-preview", + 1084: "gemini-3-flash-preview", + 1047: "gemini-3-flash-preview", +}; + +function modelName(name: string, id: number): string { + if (name) { + const normalized = name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, "") + .replaceAll(" ", "-"); + if (normalized.startsWith("claude-")) { + return normalized + .replace(/^claude-(4(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1") + .replaceAll(".", "-"); + } + return normalized; + } + return MODEL_IDS[id] ?? (id > 0 ? `antigravity-model-${id}` : ""); +} + +interface Metadata { + model: string; + timestampMs: number | null; + usages: Fields[]; +} + +function metadata(bytes: Uint8Array, step: boolean): Metadata { + const root = fields(bytes); + if (!step && bytesAt(root, 1) === undefined) { + throw new Error("Missing Antigravity generation metadata"); + } + const data = step ? root : nested(root, 1); + const model = step ? nested(data, 24) : data; + const usage = bytesAt(data, step ? 9 : 4); + const usages = usage === undefined ? [] : [fields(usage)]; + for (const retry of data.get(step ? 28 : 17) ?? []) { + if (!(retry instanceof Uint8Array)) throw new Error("Invalid retry metadata"); + const retryUsage = bytesAt(fields(retry), 2); + if (retryUsage !== undefined) usages.push(fields(retryUsage)); + } + return { + model: modelName( + textAt(model, step ? 12 : 19) || textAt(model, step ? 8 : 21), + numberAt(model, step ? 1 : 3), + ), + timestampMs: step + ? (timestamp(nested(data, 8)) ?? timestamp(nested(data, 1))) + : timestamp(nested(nested(data, 9), 4)), + usages, + }; +} + +function blob(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) throw new Error("Invalid Antigravity metadata blob"); + return value; +} + +interface UsageCandidate { + record: UsageRecord; + keys: readonly string[]; + 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"); + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("gen_metadata") && !tables.has("steps")) { + throw new Error("Missing Antigravity usage tables"); + } + const readMetadata = async (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(); + } + return entries; + }; + const generations = tables.has("gen_metadata") + ? await 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)); + } + } + const steps = tables.has("steps") + ? await 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, + fast: false, + 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(); + } +} + +/** Reads and merges aliases across every configured Antigravity store before date filtering. */ +export async function readAntigravityUsage( + conversationsDirectories: string | readonly string[], + sinceMs: number, +) { + const roots = + typeof conversationsDirectories === "string" + ? [conversationsDirectories] + : conversationsDirectories; + const files: Array<{ root: string; path: string; records: UsageRecord[] }> = []; + const errors: string[] = []; + const identities = new Map(); + const groups: Array< + UsageCandidate & { parent: number; size: number; owner: number; fileIndex: number } + > = []; + const find = (index: number): number => { + let root = index; + while (groups[root]!.parent !== root) root = groups[root]!.parent; + while (index !== root) { + const parent = groups[index]!.parent; + groups[index]!.parent = root; + index = parent; + } + return root; + }; + const merge = (left: number, right: number): number => { + let a = find(left); + let b = find(right); + if (a === b) return a; + if (groups[a]!.size < groups[b]!.size) [a, b] = [b, a]; + const target = groups[a]!; + const source = groups[b]!; + const first = target.owner < source.owner ? target : source; + const bestTime = + source.timestampQuality > target.timestampQuality || + (source.timestampQuality === target.timestampQuality && + source.record.timestampMs < target.record.timestampMs) + ? source + : target; + const x = target.record.totals; + const y = source.record.totals; + target.record = { + ...first.record, + model: + first.record.model === "antigravity-unknown" + ? first === target + ? source.record.model + : target.record.model + : first.record.model, + timestampMs: bestTime.record.timestampMs, + totals: { + uncachedInputTokens: Math.max(x.uncachedInputTokens, y.uncachedInputTokens), + cachedInputTokens: Math.max(x.cachedInputTokens, y.cachedInputTokens), + cacheCreationTokens: Math.max(x.cacheCreationTokens, y.cacheCreationTokens), + outputTokens: Math.max(x.outputTokens, y.outputTokens), + reasoningTokens: Math.max(x.reasoningTokens, y.reasoningTokens), + }, + }; + target.timestampQuality = bestTime.timestampQuality; + target.owner = first.owner; + target.fileIndex = first.fileIndex; + target.size += source.size; + source.parent = a; + return a; + }; + const append = (candidate: UsageCandidate, fileIndex: number) => { + const index = groups.length; + groups.push({ ...candidate, parent: index, size: 1, owner: index, fileIndex }); + for (const key of candidate.keys) { + const existing = identities.get(key); + if (existing !== undefined) merge(index, existing); + identities.set(key, index); + } + }; + 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 candidates = await readDatabase(path, stat.mtimeMs); + 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(); + } + } catch { + errors.push(path); + } + } + } + }; + for (const root of roots) await walk(root, root); + for (const [index, group] of groups.entries()) { + if (group.parent === index && group.record.timestampMs >= sinceMs) { + files[group.fileIndex]!.records.push(group.record); + } + } + return { files, errors }; +} diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts new file mode 100644 index 000000000000..4bfa647c0aff --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -0,0 +1,22 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts"; +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.", + }); + }); +}); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts new file mode 100644 index 000000000000..573506d818a1 --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -0,0 +1,272 @@ +// 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 type { UsageRecord } from "./usageTranscripts.ts"; +import { + CursorKeychainTimeoutError, + readMacCursorAccessToken, +} from "../provider/cursorCredentialStore.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; +} + +/** + * 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; + 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.timeout(60_000); + const records: UsageRecord[] = []; + const occurrences = new Map(); + const pages: unknown[][] = []; + let completed = false; + const pageSize = 1000; + let total: number | undefined; + for (let page = 1; ; page++) { + // 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; + 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, + fast: false, + 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.", + }; + } +} diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts new file mode 100644 index 000000000000..45d6ef33a584 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -0,0 +1,188 @@ +// 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, + fast: false, + 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.ts b/apps/server/src/usage/usageAggregation.ts index 684f0a520614..92abfef74108 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -112,7 +112,7 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord): boolean { + add(record: UsageRecord, sourcePath?: string): boolean { if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -146,7 +146,7 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -180,12 +180,14 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = + key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], model, + ...(sourcePath === "" ? {} : { sourcePath }), totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index ca340a44a64e..db4f68c2cb42 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; +import { cursorRateModel } from "./cursorUsageReader.ts"; import { cacheSavingsUsd, createOverrideRateTable, @@ -49,6 +50,26 @@ describe("usage pricing", () => { expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5); }); + it("prices Cursor cache savings at the base model rate", () => { + const table = parseRateTable({ + "claude-fable-5-1": rate(10e-6, 1e-6), + "xai/grok-4.7": rate(2e-6, 0.5e-6), + "openrouter/x-ai/grok-4.7": rate(3e-6, 0.5e-6), + }); + const cursorRecord = (model: string) => ({ + ...record(model, 0.25), + rateModel: cursorRateModel(model), + }); + + expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); + expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); + expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0); + expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({ + costUsd: 0.25, + costSource: "providerReported", + }); + }); + it("prices unknown models offline and uses input prices for omitted cache rates", () => { const table = parseRateTable({}); const overrides = createOverrideRateTable({ diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 60bf31b93e31..78f6cf2c5cd9 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -184,7 +184,10 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { } /** The parts of a transcript record that decide its price. */ -export type PricedRecord = Pick; +export type PricedRecord = Pick< + UsageRecord, + "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" +>; export interface PricedUsage { readonly costUsd: number; @@ -208,7 +211,7 @@ export function priceUsage( return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = override ?? lookupRate(table, model); + const rate = override ?? lookupRate(table, record.rateModel ?? model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const standardCostUsd = @@ -232,7 +235,8 @@ export function cacheSavingsUsd( record: PricedRecord, overrides?: RateTable, ): number { - const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.model); + const rate = + overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; return ( record.totals.cachedInputTokens * diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..6c95a36df19b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -4,13 +4,40 @@ 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 { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; +import { 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-")); }); @@ -208,3 +235,530 @@ describe("readTranscriptRecords resume", () => { 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], + ); + }); + + 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, + ); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 5da13168a1af..6e01c2c5a8ed 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -12,6 +12,11 @@ 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; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4a5dff870fd2..85c4d1a59d1d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1449,6 +1449,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + for (const splitIndex of [false, true]) { + it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitattributes", "stable.txt filter=probe\n"); + yield* writeTextFile(cwd, "stable.txt", "unchanged\n"); + yield* writeTextFile( + cwd, + ".git/filter.cjs", + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "cache stable file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + const indexPath = path.join(cwd, ".git", "index"); + yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000); + const originalIndex = yield* fs.readFile(indexPath); + const originalMtime = (yield* fs.stat(indexPath)).mtime; + yield* writeTextFile(cwd, ".git/filter-runs", ""); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "working-tree")!.files, + [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), ""); + assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); + }), + ); + } + for (const [timestamp, splitIndex] of [ [1_700_000_000, false], [1_700_000_000.9999, false], @@ -1880,6 +1918,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not start Git auto-maintenance from background upstream fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["repack", "-d"]); + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push"]); + yield* git(cwd, ["repack", "-d"]); + // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch. + yield* git(cwd, ["config", "gc.autoPackLimit", "1"]); + yield* git(cwd, ["config", "gc.autoDetach", "false"]); + yield* git(cwd, ["config", "maintenance.autoDetach", "false"]); + const packCount = git(cwd, ["count-objects", "-v"]).pipe( + Effect.map((stdout) => stdout.match(/^packs: (\d+)$/m)?.[1]), + ); + assert.equal(yield* packCount, "2"); + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(yield* packCount, "2"); + }), + ); + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b15cd28b4e92..0dd73af687f9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -549,7 +549,9 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); const decodeTrace2Record = decodeJsonResult(Trace2Record); -const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( +// Untraced because it runs on every git spawn and returns at once without hook +// callbacks. Its errors fail the runGitCommand span. +const createTrace2Monitor = Effect.fnUntraced(function* ( input: Pick, progress: GitVcsDriver.ExecuteGitProgress | undefined, ): Effect.fn.Return< @@ -1125,10 +1127,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps + // this poll from starting `git gc --auto`. When that gc fails, for example on a repository + // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` + // behind each time, so a background poll could fill the disk. return executeGit( "GitVcsDriver.fetchRemoteForStatus", fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName], { env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", @@ -2358,9 +2364,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (indexExists) { const { mtime } = yield* fileSystem.stat(indexPath); yield* fileSystem.copyFile(indexPath, tempIndexPath); - // A newer copy timestamp hides racily clean edits. Round down before Git reads or rewrites it. + // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. + // Flooring preserves the source second without making preceding-second files racy. const indexTime = Option.isSome(mtime) - ? Math.max(0, Math.floor((mtime.value.getTime() - 1) / 1000)) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) : 0; yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 830bb35b86db..077087a7d84e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3038,9 +3038,13 @@ const makeWsRpcLayer = ( [WS_METHODS.sourceControlPublishRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlPublishRepository, - sourceControlRepositories - .publishRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + sourceControlRepositories.publishRepository(input).pipe( + // A new remote can change the cached identity. Only the `cwd` entry + // refreshes, so after a publish from a linked worktree the project + // root entry waits for its TTL. + Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), { "rpc.aggregate": "source-control", }, diff --git a/apps/web/package.json b/apps/web/package.json index ec7334e1fd86..665cdbf71736 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -75,6 +75,7 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "jsdom": "^30.0.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "unified": "^11.0.5", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0cf965c4bd22..05d965d95d06 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -79,6 +79,7 @@ function readInitialThreadSidebarWidth(): number { } function SidebarControl() { + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); @@ -86,7 +87,9 @@ function SidebarControl() { const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", ); - const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); + const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { + context: { usagePageOpen }, + }); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -106,7 +109,11 @@ function SidebarControl() { // available everywhere else, including the plain-text composer. return; } - if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + if ( + resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !== + "sidebar.toggle" + ) + return; event.preventDefault(); event.stopPropagation(); @@ -116,7 +123,7 @@ function SidebarControl() { // Capture before focused editors consume commands such as Mod+B for rich-text formatting. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); - }, [keybindings, toggleSidebar]); + }, [keybindings, toggleSidebar, usagePageOpen]); return ( // The right-side layout controls carry mr-px (border compensation inside @@ -297,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" + role="navigation" + aria-label={isOnSettings ? "Settings" : "Threads"} resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5c3825cee33..61f1770c4bef 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -324,12 +324,13 @@ import { terminalContextReference, } from "../lib/composerContextRecords"; import { - isQueuedMessageDue, latestCompletedToolActivityId, type QueuedComposerMessage, + type QueuedMessageSendSettings, useQueuedMessages, useQueuedMessageStore, } from "../queuedMessageStore"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -917,7 +918,14 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + // Hidden drawers stay mounted (see MAX_HIDDEN_MOUNTED_TERMINAL_THREADS), so they read only + // the shell: a detail subscription would keep each hidden thread's history in memory. The + // active drawer shares ChatView's detail, which also covers archived threads (no shell). + const activeServerThread = useThread(active ? threadRef : null, { + waitForShell: draftThread !== null, + }); + const serverThreadShell = useThreadShell(threadRef); + const serverThread = activeServerThread ?? serverThreadShell; const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -2697,68 +2705,40 @@ export default function ChatView(props: ChatViewProps) { unavailableConnection !== null && (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); - // Reconnecting to a version-skewed server with no update in flight - // usually means the server is restarting mid-update and a refresh wiped - // the in-memory update state. Fold the reconnect and version banners - // into one calm line instead of stacking "Failed to connect" on - // "versions differ". A failed update never folds: its error and retry - // action must stay visible. - const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. const suppressUnavailableBanner = - environmentReconnecting && - (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); + environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - if (reconnectingThroughVersionSkew) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: "default", - // Prioritize live connection progress among the notices. - priority: "urgent", - icon: ( -
{isDraftHeroState ? (
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 74e8736f04e7..2a5fe2ba0588 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -29,6 +29,7 @@ import { useMemo, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -371,6 +372,16 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { .run(); }, [editor, nodePos]); + // Put the caret right after the chip so Enter sends and typing continues the prompt. + const onRestoreFocus = useCallback(() => { + if (!editor.isEditable) return; + const pos = nodePos(); + if (pos === null) return; + const current = editor.state.doc.nodeAt(pos); + if (!current || current.type.name !== "composer-citation") return; + editor.commands.focus(pos + current.nodeSize); + }, [editor, nodePos]); + return ( ) => { + // Tab from the comment button returns to the caret after the chip. + if ( + !editor.isEditable || + event.key !== "Tab" || + event.shiftKey || + event.altKey || + event.metaKey || + event.ctrlKey || + !(event.target instanceof HTMLElement) || + event.target.dataset.citationCommentTrigger === undefined + ) { + return; + } + event.preventDefault(); + onRestoreFocus(); + }} > @@ -856,6 +885,32 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; } } + // Shift+Tab from just after a citation reaches its comment button, which + // native tab order skips because the chip lives inside the editor. + if ( + event.key === "Tab" && + event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + view.state.selection.empty + ) { + const { $from } = view.state.selection; + const citation = $from.nodeBefore; + if (citation?.type.name === "composer-citation") { + const chip = view.nodeDOM($from.pos - citation.nodeSize); + const commentButton = + chip instanceof HTMLElement + ? chip.querySelector("[data-citation-comment-trigger]") + : null; + if (commentButton) { + event.preventDefault(); + event.stopPropagation(); + commentButton.focus(); + return true; + } + } + } if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { event.stopPropagation(); return true; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2a4463c11a06..df7ea7b1c95e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -547,10 +547,14 @@ export const OpenAI: Icon = ({ className, ...props }) => ( - + ); diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index de125d384eb1..2da1c0aadb8c 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -2,9 +2,11 @@ import { PlusIcon } from "lucide-react"; import { useCallback } from "react"; import { openCommandPalette } from "../commandPaletteBus"; +import { isElectron } from "../env"; import { Button } from "./ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; +import { WorkspacePageHeader } from "./WorkspacePageHeader"; export function NoProjectsHero() { const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); @@ -12,6 +14,8 @@ export function NoProjectsHero() { return (
+ {/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} + {isElectron ? : null}
diff --git a/apps/web/src/components/QueuedMessageSender.test.tsx b/apps/web/src/components/QueuedMessageSender.test.tsx new file mode 100644 index 000000000000..dc31694f8b96 --- /dev/null +++ b/apps/web/src/components/QueuedMessageSender.test.tsx @@ -0,0 +1,231 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useQueuedMessageStore, type QueuedComposerMessage } from "../queuedMessageStore"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; +import { QueuedMessageSender } from "./QueuedMessageSender"; + +const io = vi.hoisted(() => ({ + run: vi.fn(), + upload: vi.fn(), + toast: vi.fn(), + thread: null as unknown, + shell: { runtimeMode: "full-access", interactionMode: "default" } as Record, +})); +const config = { + environment: { capabilities: { attachmentUploads: true, inlineMessageContext: true } }, +}; +vi.mock("@t3tools/client-runtime/state/runtime", async (load) => ({ + ...(await load()), + runAtomCommand: (...args: unknown[]) => io.run(...args), +})); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { get: () => new Map([["env-a", config]]) }, +})); +vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); +vi.mock("../state/threads", () => ({ + threadEnvironment: { + updateMetadata: "metadata", + setRuntimeMode: "runtime", + setInteractionMode: "interaction", + startTurn: "start", + }, +})); +vi.mock("../state/environments", () => ({ + useEnvironment: () => ({ connection: { phase: "connected" } }), +})); +vi.mock("../state/entities", () => ({ + useThread: () => io.thread, + useThreadStatus: () => "live", + useServerConfigs: () => new Map([["env-a", config]]), + readThreadShell: () => io.shell, + readThread: () => io.thread, +})); +vi.mock("./ui/toast", () => ({ toastManager: { add: (...args: unknown[]) => io.toast(...args) } })); +vi.mock("../lib/attachmentUploadQueue", () => ({ + startAttachmentUpload: vi.fn(), + awaitAttachmentUploads: (...args: unknown[]) => io.upload(...args), + getUploadedAttachments: () => [ + { type: "image", id: "uploaded", name: "a.png", mimeType: "image/png", sizeBytes: 4 }, + ], + releaseDraftAttachments: vi.fn(), +})); + +const threadRef = scopeThreadRef(EnvironmentId.make("env-a"), ThreadId.make("thread-a")); +const threadKey = scopedThreadKey(threadRef); +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }; + +function enqueue(overrides: Partial = {}) { + return useQueuedMessageStore.getState().enqueue(threadKey, { + prompt: "follow up", + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + sendSettings: { + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + promptEffort: null, + }, + queuedAfterToolActivityId: null, + createdAt: "2026-09-25T00:00:00Z", + ...overrides, + }); +} + +const commandsRun = () => io.run.mock.calls.map((call) => call[1]); +const queue = () => useQueuedMessageStore.getState().queuesByThreadKey[threadKey]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + useQueuedMessageStore.setState({ queuesByThreadKey: {}, lastDispatchByThreadKey: {} }); + io.thread = null; + io.run.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + io.upload.mockReset().mockResolvedValue(undefined); + io.toast.mockReset(); + io.shell = { + modelSelection, + branch: null, + runtimeMode: "full-access", + interactionMode: "default", + }; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("QueuedMessageSender", () => { + const thread = ( + status: string, + { toolActivityIds = [] as string[], userMessageIds = [] as string[] } = {}, + ) => ({ + session: { status, activeTurnId: null, updatedAt: status }, + activities: toolActivityIds.map((id, index) => ({ + id, + kind: "tool.completed", + sequence: index, + createdAt: "2026-09-25T00:00:01Z", + })), + messages: userMessageIds.map((id) => ({ id, role: "user" })), + latestTurn: null, + }); + let root: ReactTestRenderer | null = null; + const render = () => + act(() => { + if (root) root.update(createElement(QueuedMessageSender)); + else root = create(createElement(QueuedMessageSender)); + }); + afterEach(async () => { + await act(() => root?.unmount()); + root = null; + }); + + it("sends a queued message when the turn ends, with no chat view open", async () => { + enqueue(); + io.thread = thread("running"); + await render(); + expect(commandsRun()).toEqual([]); + + io.thread = thread("ready"); + await render(); + + expect(commandsRun()).toEqual(["start"]); + expect(io.run.mock.calls[0]?.[2]).toMatchObject({ + environmentId: "env-a", + input: { threadId: "thread-a", message: { text: "follow up" }, modelSelection }, + }); + expect(queue()).toBeUndefined(); + }); + + it("holds the next message until the server picks up the one before it", async () => { + enqueue({ prompt: "first" }); + enqueue({ prompt: "second" }); + io.thread = thread("ready"); + await render(); + await render(); + expect(commandsRun()).toEqual(["start"]); + + // The first message started a turn; the second waits for its next tool call. + io.thread = thread("running", { userMessageIds: ["first"] }); + await render(); + expect(commandsRun()).toEqual(["start"]); + io.thread = thread("running", { userMessageIds: ["first"], toolActivityIds: ["tool-1"] }); + await render(); + expect(commandsRun()).toEqual(["start", "start"]); + }); + + it("moves on to the next message after a failed one is cancelled", async () => { + io.run.mockResolvedValueOnce({ _tag: "Failure", cause: Cause.fail(new Error("offline")) }); + const first = enqueue({ prompt: "first" }); + enqueue({ prompt: "second" }); + io.thread = thread("ready"); + await render(); + expect(queue()?.[0]).toMatchObject({ prompt: "first", holdUntilUserAction: true }); + + await act(() => { + useQueuedMessageStore.getState().remove(threadKey, first.id); + }); + await render(); + + expect(commandsRun()).toEqual(["start", "start"]); + expect(io.run.mock.calls[1]?.[2]).toMatchObject({ input: { message: { text: "second" } } }); + }); +}); + +describe("sendQueuedMessage", () => { + it("saves a mode changed before queueing, then starts the turn", async () => { + io.shell = { ...io.shell, runtimeMode: "approval-required" }; + const message = enqueue(); + + await sendQueuedMessage(threadRef, message.id); + + expect(commandsRun()).toEqual(["runtime", "start"]); + expect(io.run.mock.calls[1]?.[2]).toMatchObject({ input: { runtimeMode: "full-access" } }); + expect(queue()).toBeUndefined(); + }); + + it("gives a message back to Stop while its upload runs, without starting a turn", async () => { + let finishUpload!: () => void; + io.upload.mockReturnValue(new Promise((resolve) => (finishUpload = resolve))); + const image = { + type: "image" as const, + id: "image-1", + name: "a.png", + mimeType: "image/png", + sizeBytes: 4, + previewUrl: "data:image/png;base64,AAAA", + file: new File(["AAAA"], "a.png", { type: "image/png" }), + }; + const message = enqueue({ images: [image] }); + + const sending = sendQueuedMessage(threadRef, message.id); + expect(useQueuedMessageStore.getState().drain(threadKey)).toHaveLength(1); + finishUpload(); + await sending; + + expect(commandsRun()).toEqual([]); + expect(io.toast).not.toHaveBeenCalled(); + expect(queue()).toBeUndefined(); + }); + + it("holds a message at the head when the turn start fails", async () => { + io.run.mockResolvedValue({ _tag: "Failure", cause: Cause.fail(new Error("offline")) }); + enqueue({ prompt: "first" }); + const second = enqueue({ prompt: "second" }); + + await sendQueuedMessage(threadRef, second.id); + + expect(queue()?.map((entry) => [entry.prompt, entry.holdUntilUserAction])).toEqual([ + ["second", true], + ["first", undefined], + ]); + expect(io.toast).toHaveBeenCalledWith(expect.objectContaining({ description: "offline" })); + }); +}); diff --git a/apps/web/src/components/QueuedMessageSender.tsx b/apps/web/src/components/QueuedMessageSender.tsx new file mode 100644 index 000000000000..86dfa8a265fe --- /dev/null +++ b/apps/web/src/components/QueuedMessageSender.tsx @@ -0,0 +1,100 @@ +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; +import { useEffect, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { + isQueuedMessageDue, + latestCompletedToolActivityId, + useQueuedMessageStore, + useQueuedMessages, +} from "../queuedMessageStore"; +import { derivePhase } from "../session-logic"; +import { useServerConfigs, useThread, useThreadStatus } from "../state/entities"; +import { useEnvironment } from "../state/environments"; +import { hasServerAcknowledgedLocalDispatch, latestTurnStartFailureId } from "./ChatView.logic"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; + +/** + * Sends queued messages when they are due, for every thread with a queue, + * whether or not the thread is on screen. Mounted once at the root. + */ +export function QueuedMessageSender() { + const threadKeys = useQueuedMessageStore( + useShallow((state) => Object.keys(state.queuesByThreadKey)), + ); + return threadKeys.map((threadKey) => ); +} + +/** + * Watches one thread while it has queued messages. Reading the thread keeps + * its detail subscribed, so tool boundaries and the end of the turn are + * visible while the user is elsewhere. + */ +function ThreadQueueSender({ threadKey }: { threadKey: string }) { + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const thread = useThread(threadRef); + const threadStatus = useThreadStatus(threadRef); + const environmentId = threadRef?.environmentId ?? null; + const environment = useEnvironment(environmentId); + const serverConfigs = useServerConfigs(); + const serverConfigLoaded = environmentId !== null && serverConfigs.has(environmentId); + const rewinding = useComposerDraftStore((store) => store.rewindingThreadKeys.has(threadKey)); + const queue = useQueuedMessages(threadKey); + const next = queue[0]; + const sending = queue.some((message) => message.sending); + const activities = thread?.activities; + const latestToolActivityId = useMemo( + () => latestCompletedToolActivityId(activities ?? []), + [activities], + ); + const pendingRequests = useMemo(() => derivePendingRequests(activities ?? []), [activities]); + const phase = derivePhase(thread?.session ?? null); + + // A send that starts a new turn leaves the thread idle until the server + // picks it up. Hold the next message until then, as the composer does for + // its own sends. + const lastDispatch = useQueuedMessageStore( + (state) => state.lastDispatchByThreadKey[threadKey]?.thread ?? null, + ); + const latestUserMessageId = thread?.messages.findLast((m) => m.role === "user")?.id ?? null; + const waitingForServer = + lastDispatch !== null && + !hasServerAcknowledgedLocalDispatch({ + localDispatch: lastDispatch, + phase, + latestTurn: thread?.latestTurn ?? null, + latestUserMessageId, + session: thread?.session ?? null, + hasPendingApproval: pendingRequests.approvals.length > 0, + hasPendingUserInput: pendingRequests.userInputs.length > 0, + latestTurnStartFailureId: latestTurnStartFailureId(thread ?? undefined, latestUserMessageId), + threadError: null, + }); + + // Approvals and questions block the agent; a steer landing on top of them + // would answer nothing and confuse the turn, so the queue holds until the + // user resolves them. + const blocked = + threadRef === null || + thread === null || + threadStatus !== "live" || + (environment !== null && environment.connection.phase !== "connected") || + !serverConfigLoaded || + rewinding || + sending || + waitingForServer || + pendingRequests.approvals.length > 0 || + pendingRequests.userInputs.length > 0; + const due = + next !== undefined && + !blocked && + isQueuedMessageDue({ message: next, phase, latestToolActivityId }); + const nextId = next?.id; + useEffect(() => { + if (!due || !threadRef || nextId === undefined) return; + void sendQueuedMessage(threadRef, nextId); + }, [due, nextId, threadRef]); + return null; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 30044e641137..fede3c183448 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -23,6 +23,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveSidebarRowAccessibility, resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, @@ -31,7 +32,6 @@ import { shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebar, resolveSidebarDropTarget, pinOrderKeyBetween, planPinnedReorder, @@ -51,6 +51,7 @@ import { resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { EnvironmentId, OrchestrationLatestTurn, @@ -69,6 +70,35 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("resolveSidebarRowAccessibility", () => { + it.each([ + { + title: "Can you audit the UI?", + statusLabel: "Working", + projectDisplayName: "T3 Code", + isActive: true, + expected: { label: "Can you audit the UI?, Working, T3 Code", current: "page" }, + }, + { + title: "The audit is done", + statusLabel: null, + projectDisplayName: "T3 Code", + isActive: false, + expected: { label: "The audit is done, T3 Code", current: undefined }, + }, + { + title: "Untitled task", + statusLabel: null, + projectDisplayName: null, + isActive: false, + expected: { label: "Untitled task", current: undefined }, + }, + ])("leads with the title without folding row actions into its name: %j", (input) => { + const { expected, ...state } = input; + expect(resolveSidebarRowAccessibility(state)).toEqual(expected); + }); +}); + describe("animateSidebarLayoutChanges", () => { const baseArgs: Parameters[0] = { active: null, @@ -1690,12 +1720,12 @@ describe("applySidebarThreadDrop", () => { }; const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; expect(preview).toEqual({ ...final, settledAt: now }); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([ "dragged", "newer", ]); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual( - sortSettledThreadsForSidebar([existing, final]).map((row) => row.id), + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual( + sortSettledThreads([existing, final]).map((row) => row.id), ); }); @@ -1710,7 +1740,7 @@ describe("applySidebarThreadDrop", () => { const final = { ...source, snoozedAt: null, snoozedUntil: null }; const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; expect(preview).toEqual(final); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([ "newer", "dragged", ]); @@ -1820,74 +1850,6 @@ describe("sortPinnedThreadsForSidebar", () => { }); }); -describe("sortSettledThreadsForSidebar", () => { - const settled = (input: { - id: string; - settledAt?: string | null; - latestUserMessageAt?: string | null; - latestTurn?: OrchestrationLatestTurn | null; - updatedAt?: string; - }) => ({ - id: input.id, - settledAt: input.settledAt ?? null, - latestUserMessageAt: input.latestUserMessageAt ?? null, - latestTurn: input.latestTurn ?? null, - updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", - }); - - it("orders by settle time, most recently settled first", () => { - const sorted = sortSettledThreadsForSidebar([ - settled({ - id: "settled-first", - settledAt: "2026-03-09T10:00:00.000Z", - // Created/active later than the other thread: settle time must win. - latestUserMessageAt: "2026-03-09T09:59:00.000Z", - }), - settled({ - id: "settled-last", - settledAt: "2026-03-09T12:00:00.000Z", - latestUserMessageAt: "2026-03-09T08:00:00.000Z", - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); - }); - - it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { - const sorted = sortSettledThreadsForSidebar([ - settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), - settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); - }); - - it("counts a turn completion as activity for auto-settled threads", () => { - // The message came in before the other thread's, but its turn finished - // after: completion time is the real "work ended" moment. - const sorted = sortSettledThreadsForSidebar([ - settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), - settled({ - id: "completed-later", - latestUserMessageAt: "2026-03-09T10:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); - }); - - it("breaks timestamp ties by id so the order is stable", () => { - const sorted = sortSettledThreadsForSidebar([ - settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); - }); -}); - describe("resolveWorkingStartedAt", () => { const session = { threadId: ThreadId.make("thread-1"), @@ -2375,6 +2337,49 @@ describe("sortProjectsForSidebar", () => { ]); }); + it.each(["updated_at", "created_at"] as const)( + "matches the per-comparison %s order on a shuffled list with ties", + (sortOrder) => { + const minute = (value: number) => `2026-03-09T10:0${value}:00.000Z`; + // (index * 7) % 24 scrambles the input order. Titles repeat, and + // projects 16-23 have no threads, so they use their own stamps. + const projects = Array.from({ length: 24 }, (_, index) => { + const n = (index * 7) % 24; + return makeProject({ + id: ProjectId.make(`project-${n}`), + title: n % 2 === 0 ? "Alpha" : "Beta", + createdAt: minute(n % 3), + updatedAt: n % 5 === 0 ? "invalid" : minute(n % 2), + }); + }); + const threads = Array.from({ length: 48 }, (_, n) => ({ + projectId: ProjectId.make(`project-${n % 16}`), + createdAt: minute(n % 6), + updatedAt: minute(n % 3), + latestUserMessageAt: n % 4 === 0 ? null : minute(n % 5), + })); + // The comparator this sort replaced: it walked each project's threads + // on every call. + const timestamp = (project: Project) => + getProjectSortTimestamp( + project, + threads.filter((thread) => thread.projectId === project.id), + sortOrder, + ); + const expected = projects.toSorted((left, right) => { + const rightTimestamp = timestamp(right); + const leftTimestamp = timestamp(left); + const byTimestamp = + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; + return ( + byTimestamp || left.title.localeCompare(right.title) || left.id.localeCompare(right.id) + ); + }); + + expect(sortProjectsForSidebar(projects, threads, sortOrder)).toEqual(expected); + }, + ); + it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f4b13a5000bc..2796b2f7f885 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,10 +16,8 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { getThreadSortTimestamp, - resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, - type SettledThreadTimestampInput, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; @@ -44,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: { const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; + +export function resolveSidebarRowAccessibility(input: { + readonly title: string; + readonly statusLabel: string | null; + readonly projectDisplayName: string | null; + readonly isActive: boolean; +}): { readonly label: string; readonly current: "page" | undefined } { + return { + // The title is the row's identity and must lead when users scan tasks. + // Only static context belongs here; nested action labels remain separate controls. + label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "), + current: input.isActive ? "page" : undefined, + }; +} + // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed // thread holds a live, fully hydrated detail subscription (all messages and @@ -937,20 +950,6 @@ export function reduceSidebarProjectScopeMenuState( } } -// Settled rows are history, so they order by when the work ENDED, not when -// the thread was created or last touched. -export function sortSettledThreadsForSidebar< - T extends SettledThreadTimestampInput & { readonly id: string }, ->(threads: readonly T[]): T[] { - const timestampMs = (thread: T) => { - const timestamp = resolveSettledThreadTimestamp(thread); - return timestamp === null ? 0 : Date.parse(timestamp); - }; - return [...threads].toSorted( - (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), - ); -} - /** The timestamp a working thread's elapsed label counts from: the running turn's start (request time until adoption), falling back to the session's last transition when the turn projection lags behind. Malformed @@ -1136,13 +1135,19 @@ function sortProjectsByActivity( return [...projects]; } - return [...projects].toSorted((left, right) => { - const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder); - const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - return byTimestamp || compareTies(left, right); - }); + // Each project's timestamp walks all of its threads, so compute it once + // per project instead of once per comparison. + return projects + .map((project) => ({ + project, + timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder), + })) + .sort((left, right) => { + const byTimestamp = + right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1; + return byTimestamp || compareTies(left.project, right.project); + }) + .map(({ project }) => project); } export function sortProjectsForSidebar< diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7cbae330c318..90f65ea2b941 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,7 +21,10 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { + resolveSettledThreadTimestamp, + sortSettledThreads, +} from "@t3tools/client-runtime/state/thread-sort"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, @@ -170,6 +173,7 @@ import { resolveAdjacentThreadId, resolveSidebarDropTarget, resolveSidebarDropVerb, + resolveSidebarRowAccessibility, type SidebarDropVerb, resolveSidebarThreadStatus, searchSidebarThreads, @@ -181,7 +185,6 @@ import { sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, - sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, @@ -726,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { promptPreview.length > 0 ? promptPreview : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + const accessibility = resolveSidebarRowAccessibility({ + title: preview, + statusLabel: "Unsent draft", + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -753,14 +762,17 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
+ {preview}
@@ -788,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
-
{preview}
+
+ {preview} +
@@ -1390,7 +1404,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // a useful hierarchy nor a reliable hover cue. Status now lives in the row // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( - "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" @@ -1445,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: topStatus?.label ?? null, + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + const title = isRenaming ? ( ) : ( ); + const accessibleTitle = isRenaming ? null : {thread.title}; // Stacks show their layer count; multiple unrelated links show their total count. // Plain clicks open T3; individual PR links also support opening the host in a new tab. @@ -1591,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ref={rowRef} role="button" tabIndex={0} + aria-label={accessibility.label} + aria-current={accessibility.current} data-testid="sidebar-row-slim" aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} @@ -1601,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > + {accessibleTitle} {/* Settled history recedes: dimmed favicon at rest, restored on hover so the tail stays scannable when you're hunting. */} } > + {accessibleTitle}
{draftIndicator} @@ -1965,7 +1994,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { showBadge={showInstanceBadge} // Glyph dims, badge stays saturated; offset matches the composer trigger. iconClassName="size-3.5 opacity-60" - badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-3xs" + badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-5xs" /> ) : null} @@ -2006,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: null, + projectDisplayName: props.projectDisplayName, + isActive: props.isRouteActive, + }); const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2082,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { // which owns all keyboard interaction for the listbox. tabIndex={-1} aria-selected={props.isHighlighted} - aria-current={props.isRouteActive ? "page" : undefined} - aria-label={ - props.projectDisplayName - ? `${thread.title}, ${props.projectDisplayName}` - : thread.title - } + aria-current={accessibility.current} + aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( @@ -2626,7 +2657,7 @@ export default function Sidebar() { firstValidTimestampMs(left.snoozedUntil ?? null) - firstValidTimestampMs(right.snoozedUntil ?? null), ), - settledThreads: sortSettledThreadsForSidebar(settled), + settledThreads: sortSettledThreads(settled), snoozeNow: preciseNow, }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); @@ -3452,7 +3483,7 @@ export default function Sidebar() { if (dragState === null || thread === undefined) return []; const key = (candidate: EnvironmentThreadShell) => scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); - return sortSettledThreadsForSidebar([ + return sortSettledThreads([ ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), applySidebarThreadDrop(thread, "settled", dragState.occurredAt), ]).map(key); @@ -4571,7 +4602,7 @@ export default function Sidebar() { } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? (