diff --git a/README.md b/README.md index fe7c2219b2..c3e6db0325 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ We are very very early in this project. Expect bugs. We are not accepting contributions yet. +Observability guide: [docs/observability.md](./docs/observability.md) + ## If you REALLY want to contribute still.... read this first Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index bc1b2ec86d..37ad666e7f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -27,6 +27,7 @@ import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; @@ -79,6 +80,7 @@ const LOG_DIR = Path.join(STATE_DIR, "logs"); const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); +const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; @@ -102,8 +104,10 @@ let aboutCommitHashCache: string | null | undefined; let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; +let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let destructiveMenuIconCache: Electron.NativeImage | null | undefined; +const expectedBackendExitChildren = new WeakSet(); const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -124,6 +128,21 @@ function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function readPersistedBackendObservabilitySettings(): { + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; +} { + try { + if (!FS.existsSync(SERVER_SETTINGS_PATH)) { + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } + return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); + } catch (error) { + console.warn("[desktop] failed to read persisted backend observability settings", error); + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; @@ -964,6 +983,7 @@ function scheduleBackendRestart(reason: string): void { function startBackend(): void { if (isQuitting || backendProcess) return; + backendObservabilitySettings = readPersistedBackendObservabilitySettings(); const backendEntry = resolveBackendEntry(); if (!FS.existsSync(backendEntry)) { scheduleBackendRestart(`missing server entry at ${backendEntry}`); @@ -992,6 +1012,12 @@ function startBackend(): void { port: backendPort, t3Home: BASE_DIR, authToken: backendAuthToken, + ...(backendObservabilitySettings.otlpTracesUrl + ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } + : {}), + ...(backendObservabilitySettings.otlpMetricsUrl + ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } + : {}), })}\n`, ); bootstrapStream.end(); @@ -1018,21 +1044,26 @@ function startBackend(): void { }); child.on("error", (error) => { + const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; } closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); + if (wasExpected) { + return; + } scheduleBackendRestart(error.message); }); child.on("exit", (code, signal) => { + const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; } closeBackendSession( `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, ); - if (isQuitting) return; + if (isQuitting || wasExpected) return; const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; scheduleBackendRestart(reason); }); @@ -1049,6 +1080,7 @@ function stopBackend(): void { if (!child) return; if (child.exitCode === null && child.signalCode === null) { + expectedBackendExitChildren.add(child); child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { @@ -1069,6 +1101,7 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { if (!child) return; const backendChild = child; if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; + expectedBackendExitChildren.add(backendChild); await new Promise((resolve) => { let settled = false; diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 27bc60b1be..038d22e8f3 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -9,6 +9,18 @@ import { deriveServerPaths } from "./config"; import { resolveServerConfig } from "./cli"; it.layer(NodeServices.layer)("cli config resolution", (it) => { + const defaultObservabilityConfig = { + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + } as const; + const openBootstrapFd = Effect.fn(function* (payload: Record) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); @@ -62,6 +74,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Warn", + ...defaultObservabilityConfig, mode: "desktop", port: 4001, cwd: process.cwd(), @@ -123,6 +136,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Debug", + ...defaultObservabilityConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -153,6 +167,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); @@ -187,6 +203,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", + ...defaultObservabilityConfig, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -241,6 +260,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { resolved.attachmentsDir, resolved.worktreesDir, path.dirname(resolved.serverLogPath), + path.dirname(resolved.serverTracePath), ]) { expect(yield* fs.exists(directory)).toBe(true); } @@ -300,6 +320,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Debug", + ...defaultObservabilityConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -315,4 +336,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("falls back to persisted observability settings when env vars are absent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" }); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + devUrl: Option.none(), + noBrowser: Option.none(), + authToken: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); + expect(resolved).toEqual({ + logLevel: "Info", + ...defaultObservabilityConfig, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + mode: "desktop", + port: 4888, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: "127.0.0.1", + staticDir: resolved.staticDir, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 2f76d25b7b..09c17278a5 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,5 +1,6 @@ import { NetService } from "@t3tools/shared/Net"; -import { Config, Effect, LogLevel, Option, Schema } from "effect"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { @@ -27,6 +28,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({ authToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), + otlpTracesUrl: Schema.optional(Schema.String), + otlpMetricsUrl: Schema.optional(Schema.String), }); const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( @@ -81,6 +84,27 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( 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), + ), + traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), + traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), + traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), + otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + 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")), mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -131,12 +155,25 @@ const resolveOptionPrecedence = ( ...values: ReadonlyArray> ): Option.Option => Option.firstSomeOf(values); +const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } + + const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); + return parsePersistedServerObservabilitySettings(raw); +}); + export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, ) => Effect.gen(function* () { const { findAvailablePort } = yield* NetService; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; const env = yield* EnvServerConfig; const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -190,6 +227,11 @@ export const resolveServerConfig = ( ); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); yield* ensureServerDirectories(derivedPaths); + const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( + derivedPaths.settingsPath, + ); + const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; + yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); const noBrowser = resolveBooleanFlag( flags.noBrowser, Option.getOrElse( @@ -248,11 +290,35 @@ export const resolveServerConfig = ( const config: ServerConfigShape = { logLevel, + traceMinLevel: env.traceMinLevel, + traceTimingEnabled: env.traceTimingEnabled, + traceBatchWindowMs: env.traceBatchWindowMs, + traceMaxBytes: env.traceMaxBytes, + traceMaxFiles: env.traceMaxFiles, + otlpTracesUrl: + env.otlpTracesUrl ?? + Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.otlpTracesUrl), + ), + ) ?? + persistedObservabilitySettings.otlpTracesUrl, + otlpMetricsUrl: + env.otlpMetricsUrl ?? + Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.otlpMetricsUrl), + ), + ) ?? + persistedObservabilitySettings.otlpMetricsUrl, + otlpExportIntervalMs: env.otlpExportIntervalMs, + otlpServiceName: env.otlpServiceName, mode, port, cwd: process.cwd(), baseDir, ...derivedPaths, + serverTracePath, host, staticDir, devUrl, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 21cd6f3150..9ceea4c13c 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -25,6 +25,7 @@ export interface ServerDerivedPaths { readonly attachmentsDir: string; readonly logsDir: string; readonly serverLogPath: string; + readonly serverTracePath: string; readonly providerLogsDir: string; readonly providerEventLogPath: string; readonly terminalLogsDir: string; @@ -36,6 +37,15 @@ export interface ServerDerivedPaths { */ export interface ServerConfigShape extends ServerDerivedPaths { readonly logLevel: LogLevel.LogLevel; + readonly traceMinLevel: LogLevel.LogLevel; + readonly traceTimingEnabled: boolean; + readonly traceBatchWindowMs: number; + readonly traceMaxBytes: number; + readonly traceMaxFiles: number; + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; + readonly otlpExportIntervalMs: number; + readonly otlpServiceName: string; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -68,6 +78,7 @@ export const deriveServerPaths = Effect.fn(function* ( attachmentsDir, logsDir, serverLogPath: join(logsDir, "server.log"), + serverTracePath: join(logsDir, "server.trace.ndjson"), providerLogsDir, providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), @@ -117,6 +128,15 @@ export class ServerConfig extends ServiceMap.Service { }), ); + it.effect("paginates branch results and returns paging metadata", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); + + const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); + expect(firstPage.totalCount).toBe(4); + expect(firstPage.nextCursor).toBe(2); + expect(firstPage.branches.map((branch) => branch.name)).toEqual([ + initialBranch, + "feature-a", + ]); + + const secondPage = yield* (yield* GitCore).listBranches({ + cwd: tmp, + cursor: firstPage.nextCursor ?? 0, + limit: 2, + }); + expect(secondPage.totalCount).toBe(4); + expect(secondPage.nextCursor).toBeNull(); + expect(secondPage.branches.map((branch) => branch.name)).toEqual([ + "feature-b", + "feature-c", + ]); + }), + ); + it.effect("parses separate branch names when column.ui is always enabled", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => { expect(remoteBranch?.remoteName).toBe(remoteName); }), ); + + it.effect( + "filters branch queries before pagination and dedupes origin refs with local matches", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const tmp = yield* makeTmpDir(); + + yield* git(remote, ["init", "--bare"]); + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", initialBranch]); + + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); + yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); + + yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); + yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); + yield* git(tmp, ["checkout", initialBranch]); + yield* git(tmp, ["branch", "-D", "feature/remote-only"]); + + const result = yield* (yield* GitCore).listBranches({ + cwd: tmp, + query: "feature/", + limit: 10, + }); + + expect(result.totalCount).toBe(2); + expect(result.nextCursor).toBeNull(); + expect(result.branches.map((branch) => branch.name)).toEqual([ + "feature/demo", + "origin/feature/remote-only", + ]); + }), + ); }); // ── checkoutGitBranch ── @@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => { ) { return ok(); } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } return Effect.fail( new GitCommandError({ operation: input.operation, @@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => { ) { return ok(); } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } return Effect.fail( new GitCommandError({ operation: input.operation, diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 0a11abab5b..a65e8173b6 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -17,8 +17,12 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import nodePath from "node:path"; -import { GitCommandError } from "@t3tools/contracts"; +import { GitCommandError, type GitBranch } from "@t3tools/contracts"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { compactTraceAttributes } from "../../observability/Attributes.ts"; +import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts"; import { GitCore, type ExecuteGitProgress, @@ -49,6 +53,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; +const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; type TraceTailState = { processedChars: number; @@ -183,6 +188,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul }; } +function filterBranchesForListQuery( + branches: ReadonlyArray, + query?: string, +): ReadonlyArray { + if (!query) { + return branches; + } + + const normalizedQuery = query.toLowerCase(); + return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); +} + +function paginateBranches(input: { + branches: ReadonlyArray; + cursor?: number | undefined; + limit?: number | undefined; +}): { + branches: ReadonlyArray; + nextCursor: number | null; + totalCount: number; +} { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; + const totalCount = input.branches.length; + const branches = input.branches.slice(cursor, cursor + limit); + const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; + + return { + branches, + nextCursor, + totalCount, + }; +} + function sanitizeRemoteName(value: string): string { const sanitized = value .trim() @@ -336,6 +375,18 @@ interface Trace2Monitor { readonly flush: Effect.Effect; } +const nowUnixNano = (): bigint => BigInt(Date.now()) * 1_000_000n; + +const addCurrentSpanEvent = (name: string, attributes: Record) => + Effect.currentSpan.pipe( + Effect.tap((span) => + Effect.sync(() => { + span.event(name, nowUnixNano(), compactTraceAttributes(attributes)); + }), + ), + Effect.catch(() => Effect.void), + ); + function trace2ChildKey(record: Record): string | null { const childId = record.child_id; if (typeof childId === "number" || typeof childId === "string") { @@ -408,6 +459,9 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_start") { hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); + yield* addCurrentSpanEvent("git.hook.started", { + hookName, + }); if (progress.onHookStarted) { yield* progress.onHookStarted(hookName); } @@ -416,12 +470,19 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); + const code = traceRecord.success.code; + const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; + const durationMs = started ? Math.max(0, Date.now() - started.startedAtMs) : null; + yield* addCurrentSpanEvent("git.hook.finished", { + hookName: started?.hookName ?? hookName, + exitCode, + durationMs, + }); if (progress.onHookFinished) { - const code = traceRecord.success.code; yield* progress.onHookFinished({ hookName: started?.hookName ?? hookName, - exitCode: typeof code === "number" && Number.isInteger(code) ? code : null, - durationMs: started ? Math.max(0, Date.now() - started.startedAtMs) : null, + exitCode, + durationMs, }); } } @@ -573,13 +634,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const path = yield* Path.Path; const { worktreesDir } = yield* ServerConfig; - let execute: GitCoreShape["execute"]; + let executeRaw: GitCoreShape["execute"]; if (options?.executeOverride) { - execute = options.executeOverride; + executeRaw = options.executeOverride; } else { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - execute = Effect.fnUntraced(function* (input) { + executeRaw = Effect.fnUntraced(function* (input) { const commandInput = { ...input, args: [...input.args], @@ -680,6 +741,25 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); } + const execute: GitCoreShape["execute"] = (input) => + executeRaw(input).pipe( + withMetrics({ + counter: gitCommandsTotal, + timer: gitCommandDuration, + attributes: { + operation: input.operation, + }, + }), + Effect.withSpan(input.operation, { + kind: "client", + attributes: { + "git.operation": input.operation, + "git.repo": nodePath.basename(input.cwd), + "git.args_count": input.args.length, + }, + }), + ); + const executeGit = ( operation: string, cwd: string, @@ -1101,15 +1181,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( - [ - runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]), - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]), - ], - { concurrency: "unbounded" }, + const statusResult = yield* executeGit( + "GitCore.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + { + allowNonZeroExit: true, + }, ); + if (statusResult.code !== 0) { + const stderr = statusResult.stderr.trim(); + return yield* createGitCommandError( + "GitCore.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + stderr || "git status failed", + ); + } + + const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = + yield* Effect.all( + [ + runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + executeGit( + "GitCore.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + allowNonZeroExit: true, + }, + ), + originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + ], + { concurrency: "unbounded" }, + ); + const statusStdout = statusResult.stdout; + const defaultBranch = + defaultRefResult.code === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + let branch: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; @@ -1176,6 +1293,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { files.sort((a, b) => a.path.localeCompare(b.path)); return { + isRepo: true, + hasOriginRemote, + isDefaultBranch: + branch !== null && + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))), branch, upstreamRef, hasWorkingTreeChanges, @@ -1193,6 +1316,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ + isRepo: details.isRepo, + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, @@ -1341,7 +1467,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { "push", "-u", publishRemoteName, - branch, + `HEAD:refs/heads/${branch}`, ]); return { status: "pushed" as const, @@ -1571,7 +1697,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { if (localBranchResult.code !== 0) { const stderr = localBranchResult.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { - return { branches: [], isRepo: false, hasOriginRemote: false }; + return { + branches: [], + isRepo: false, + hasOriginRemote: false, + nextCursor: null, + totalCount: 0, + }; } return yield* createGitCommandError( "GitCore.listBranches", @@ -1735,9 +1867,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }) : []; - const branches = [...localBranches, ...remoteBranches]; + const branches = paginateBranches({ + branches: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, + }); - return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; + return { + branches: [...branches.branches], + isRepo: true, + hasOriginRemote: remoteNames.includes("origin"), + nextCursor: branches.nextCursor, + totalCount: branches.totalCount, + }; }); const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index ce1cfe87f5..8fcac582ff 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); + expect(status.isRepo).toBe(true); + expect(status.hasOriginRemote).toBe(true); + expect(status.isDefaultBranch).toBe(false); expect(status.branch).toBe("feature/status-open-pr"); expect(status.pr).toEqual({ number: 13, @@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status returns an explicit non-repo result for non-git directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }), + ); + it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 20aa21aa9a..abc4985abd 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -5,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f import { GitActionProgressEvent, GitActionProgressPhase, + GitCommandError, GitRunStackedActionResult, GitStackedAction, ModelSelection, @@ -22,7 +23,7 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; -import { GitCore } from "../Services/GitCore.ts"; +import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; @@ -39,6 +40,10 @@ type StripProgressContext = T extends any ? Omit; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; +function isNotGitRepositoryError(error: GitCommandError): boolean { + return error.message.toLowerCase().includes("not a git repository"); +} + interface OpenPrInfo { number: number; title: string; @@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd); + const details = yield* gitCore.statusDetails(cwd).pipe( + Effect.catchIf(isNotGitRepositoryError, () => + Effect.succeed({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails), + ), + ); const pr = - details.branch !== null + details.isRepo && details.branch !== null ? yield* findLatestPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, @@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { : null; return { + isRepo: details.isRepo, + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, @@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return parsed[0] ?? null; }); - const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) { - const branches = yield* gitCore.listBranches({ cwd }); - const currentBranch = branches.branches.find((candidate) => candidate.name === branch); - return currentBranch?.isDefault ?? (branch === "main" || branch === "master"); - }); - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { upstreamRef: finalStatus.upstreamRef, hasUpstream: finalStatus.hasUpstream, }; - currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( - Effect.catch(() => - Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), - ), - ); + currentBranchIsDefault = finalStatus.isDefaultBranch; } } diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts new file mode 100644 index 0000000000..4b495598ea --- /dev/null +++ b/apps/server/src/observability/Attributes.test.ts @@ -0,0 +1,46 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts"; + +describe("Attributes", () => { + it("normalizes circular arrays, maps, and sets without recursing forever", () => { + const array: Array = ["alpha"]; + array.push(array); + + const map = new Map(); + map.set("self", map); + + const set = new Set(); + set.add(set); + + assert.deepStrictEqual( + compactTraceAttributes({ + array, + map, + set, + }), + { + array: ["alpha", "[Circular]"], + map: { self: "[Circular]" }, + set: ["[Circular]"], + }, + ); + }); + + it("normalizes invalid dates without throwing", () => { + assert.deepStrictEqual( + compactTraceAttributes({ + invalidDate: new Date("not-a-real-date"), + }), + { + invalidDate: "Invalid Date", + }, + ); + }); + + it("groups GPT-family models under a shared metric label", () => { + assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); + assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); + assert.strictEqual(normalizeModelMetricLabel("claude-sonnet-4"), "claude"); + }); +}); diff --git a/apps/server/src/observability/Attributes.ts b/apps/server/src/observability/Attributes.ts new file mode 100644 index 0000000000..5760c27c2d --- /dev/null +++ b/apps/server/src/observability/Attributes.ts @@ -0,0 +1,136 @@ +import { Cause, Exit } from "effect"; + +export type MetricAttributeValue = string; +export type MetricAttributes = Readonly>; +export type TraceAttributes = Readonly>; +export type ObservabilityOutcome = "success" | "failure" | "interrupt"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function markSeen(value: object, seen: Set): boolean { + if (seen.has(value)) { + return true; + } + seen.add(value); + return false; +} + +function normalizeJsonValue(value: unknown, seen: Set = new Set()): unknown { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value ?? null; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); + } + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + }; + } + if (Array.isArray(value)) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + const result = value.map((entry) => normalizeJsonValue(entry, seen)); + seen.delete(value); + return result; + } + if (value instanceof Map) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + const result = Object.fromEntries( + Array.from(value.entries(), ([key, entryValue]) => [ + String(key), + normalizeJsonValue(entryValue, seen), + ]), + ); + seen.delete(value); + return result; + } + if (value instanceof Set) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + const result = Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); + seen.delete(value); + return result; + } + if (!isPlainObject(value)) { + return String(value); + } + if (markSeen(value, seen)) { + return "[Circular]"; + } + const result = Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), + ); + seen.delete(value); + return result; +} + +export function compactTraceAttributes( + attributes: Readonly>, +): TraceAttributes { + return Object.fromEntries( + Object.entries(attributes) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, normalizeJsonValue(value)]), + ); +} + +export function compactMetricAttributes( + attributes: Readonly>, +): MetricAttributes { + return Object.fromEntries( + Object.entries(attributes).flatMap(([key, value]) => { + if (value === undefined || value === null) { + return []; + } + if (typeof value === "string") { + return [[key, value]]; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return [[key, String(value)]]; + } + return []; + }), + ); +} + +export function outcomeFromExit(exit: Exit.Exit): ObservabilityOutcome { + if (Exit.isSuccess(exit)) { + return "success"; + } + return Cause.hasInterruptsOnly(exit.cause) ? "interrupt" : "failure"; +} + +export function normalizeModelMetricLabel(model: string | null | undefined): string | undefined { + const normalized = model?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized.includes("gpt")) { + return "gpt"; + } + if (normalized.includes("claude")) { + return "claude"; + } + if (normalized.includes("gemini")) { + return "gemini"; + } + return "other"; +} diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts new file mode 100644 index 0000000000..a0ffd33fb5 --- /dev/null +++ b/apps/server/src/observability/Layers/Observability.ts @@ -0,0 +1,67 @@ +import { Effect, Layer, References, Tracer } from "effect"; +import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +import { ServerConfig } from "../../config.ts"; +import { ServerLoggerLive } from "../../serverLogger.ts"; +import { makeLocalFileTracer } from "../LocalFileTracer.ts"; + +const otlpSerializationLayer = OtlpSerialization.layerJson; + +export const ObservabilityLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + + const otlpTracesUrl = config.otlpTracesUrl?.trim() || undefined; + const otlpMetricsUrl = config.otlpMetricsUrl?.trim() || undefined; + + const traceReferencesLayer = Layer.mergeAll( + Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), + Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), + ); + + const tracerLayer = Layer.effect( + Tracer.Tracer, + Effect.gen(function* () { + const delegate = + otlpTracesUrl === undefined + ? undefined + : yield* OtlpTracer.make({ + url: otlpTracesUrl, + exportInterval: `${config.otlpExportIntervalMs} millis`, + resource: { + serviceName: config.otlpServiceName, + attributes: { + "service.runtime": "t3-server", + "service.mode": config.mode, + }, + }, + }); + + return yield* makeLocalFileTracer({ + filePath: config.serverTracePath, + maxBytes: config.traceMaxBytes, + maxFiles: config.traceMaxFiles, + batchWindowMs: config.traceBatchWindowMs, + ...(delegate ? { delegate } : {}), + }); + }), + ).pipe(Layer.provideMerge(otlpSerializationLayer)); + + const metricsLayer = + otlpMetricsUrl === undefined + ? Layer.empty + : OtlpMetrics.layer({ + url: otlpMetricsUrl, + exportInterval: `${config.otlpExportIntervalMs} millis`, + resource: { + serviceName: config.otlpServiceName, + attributes: { + "service.runtime": "t3-server", + "service.mode": config.mode, + }, + }, + }).pipe(Layer.provideMerge(otlpSerializationLayer)); + + return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); + }), +); diff --git a/apps/server/src/observability/LocalFileTracer.test.ts b/apps/server/src/observability/LocalFileTracer.test.ts new file mode 100644 index 0000000000..1bc5a34305 --- /dev/null +++ b/apps/server/src/observability/LocalFileTracer.test.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer, Logger, References, Tracer } from "effect"; + +import type { TraceRecord } from "./TraceRecord.ts"; +import { makeLocalFileTracer } from "./LocalFileTracer.ts"; + +const makeTestLayer = (tracePath: string) => + Layer.mergeAll( + Layer.effect( + Tracer.Tracer, + makeLocalFileTracer({ + filePath: tracePath, + maxBytes: 1024 * 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }), + ), + Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + +const readTraceRecords = (tracePath: string): Array => + fs + .readFileSync(tracePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as TraceRecord); + +describe("LocalFileTracer", () => { + it.effect("writes nested spans to disk and captures log messages as span events", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); + const tracePath = path.join(tempDir, "server.trace.ndjson"); + + try { + yield* Effect.scoped( + Effect.gen(function* () { + const program = Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "demo.parent": true, + }); + yield* Effect.logInfo("parent event"); + yield* Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "demo.child": true, + }); + yield* Effect.logInfo("child event"); + }).pipe(Effect.withSpan("child-span")); + }).pipe(Effect.withSpan("parent-span")); + + yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); + }), + ); + + const records = readTraceRecords(tracePath); + assert.equal(records.length, 2); + + const parent = records.find((record) => record.name === "parent-span"); + const child = records.find((record) => record.name === "child-span"); + + assert.notEqual(parent, undefined); + assert.notEqual(child, undefined); + if (!parent || !child) { + return; + } + + assert.equal(child.parentSpanId, parent.spanId); + assert.equal(parent.attributes["demo.parent"], true); + assert.equal(child.attributes["demo.child"], true); + assert.equal( + parent.events.some((event) => event.name === "parent event"), + true, + ); + assert.equal( + child.events.some((event) => event.name === "child event"), + true, + ); + assert.equal( + child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), + true, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("serializes interrupted spans with an interrupted exit status", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); + const tracePath = path.join(tempDir, "server.trace.ndjson"); + + try { + yield* Effect.scoped( + Effect.exit( + Effect.interrupt.pipe( + Effect.withSpan("interrupt-span"), + Effect.provide(makeTestLayer(tracePath)), + ), + ), + ); + + const records = readTraceRecords(tracePath); + assert.equal(records.length, 1); + assert.equal(records[0]?.name, "interrupt-span"); + assert.equal(records[0]?.exit._tag, "Interrupted"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); +}); diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts new file mode 100644 index 0000000000..7f75eb0bf9 --- /dev/null +++ b/apps/server/src/observability/LocalFileTracer.ts @@ -0,0 +1,104 @@ +import type * as Exit from "effect/Exit"; +import { Effect, Option, Tracer } from "effect"; + +import { spanToTraceRecord } from "./TraceRecord.ts"; +import { makeTraceSink } from "./TraceSink.ts"; + +export interface LocalFileTracerOptions { + readonly filePath: string; + readonly maxBytes: number; + readonly maxFiles: number; + readonly batchWindowMs: number; + readonly delegate?: Tracer.Tracer; +} + +class LocalFileSpan implements Tracer.Span { + readonly _tag = "Span"; + readonly name: string; + readonly spanId: string; + readonly traceId: string; + readonly parent: Option.Option; + readonly annotations: Tracer.Span["annotations"]; + readonly links: Array; + readonly sampled: boolean; + readonly kind: Tracer.SpanKind; + + status: Tracer.SpanStatus; + attributes: Map; + events: Array<[name: string, startTime: bigint, attributes: Record]>; + + constructor( + options: Parameters[0], + private readonly delegate: Tracer.Span, + private readonly push: (record: ReturnType) => void, + ) { + this.name = delegate.name; + this.spanId = delegate.spanId; + this.traceId = delegate.traceId; + this.parent = options.parent; + this.annotations = options.annotations; + this.links = [...options.links]; + this.sampled = delegate.sampled; + this.kind = delegate.kind; + this.status = { + _tag: "Started", + startTime: options.startTime, + }; + this.attributes = new Map(); + this.events = []; + } + + end(endTime: bigint, exit: Exit.Exit): void { + this.status = { + _tag: "Ended", + startTime: this.status.startTime, + endTime, + exit, + }; + this.delegate.end(endTime, exit); + + if (this.sampled) { + this.push(spanToTraceRecord(this)); + } + } + + attribute(key: string, value: unknown): void { + this.attributes.set(key, value); + this.delegate.attribute(key, value); + } + + event(name: string, startTime: bigint, attributes?: Record): void { + const nextAttributes = attributes ?? {}; + this.events.push([name, startTime, nextAttributes]); + this.delegate.event(name, startTime, nextAttributes); + } + + addLinks(links: ReadonlyArray): void { + this.links.push(...links); + this.delegate.addLinks(links); + } +} + +export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( + options: LocalFileTracerOptions, +) { + const sink = yield* makeTraceSink({ + filePath: options.filePath, + maxBytes: options.maxBytes, + maxFiles: options.maxFiles, + batchWindowMs: options.batchWindowMs, + }); + + const delegate = + options.delegate ?? + Tracer.make({ + span: (spanOptions) => new Tracer.NativeSpan(spanOptions), + }); + + return Tracer.make({ + span(spanOptions) { + return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); + }, + ...(delegate.context ? { context: delegate.context } : {}), + }); +}); diff --git a/apps/server/src/observability/Metrics.test.ts b/apps/server/src/observability/Metrics.test.ts new file mode 100644 index 0000000000..4604f43b63 --- /dev/null +++ b/apps/server/src/observability/Metrics.test.ts @@ -0,0 +1,111 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Metric } from "effect"; + +import { withMetrics } from "./Metrics.ts"; + +const hasMetricSnapshot = ( + snapshots: ReadonlyArray, + id: string, + attributes: Readonly>, +) => + snapshots.some( + (snapshot) => + snapshot.id === id && + Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), + ); + +describe("withMetrics", () => { + it.effect("supports pipe-style usage", () => + Effect.gen(function* () { + const counter = Metric.counter("with_metrics_pipe_total"); + const timer = Metric.timer("with_metrics_pipe_duration"); + + const result = yield* Effect.succeed("ok").pipe( + withMetrics({ + counter, + timer, + attributes: { + operation: "pipe", + }, + }), + ); + + assert.equal(result, "ok"); + + const snapshots = yield* Metric.snapshot; + assert.equal( + hasMetricSnapshot(snapshots, "with_metrics_pipe_total", { + operation: "pipe", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "with_metrics_pipe_duration", { + operation: "pipe", + }), + true, + ); + }), + ); + + it.effect("supports direct invocation", () => + Effect.gen(function* () { + const counter = Metric.counter("with_metrics_direct_total"); + + yield* withMetrics(Effect.fail("boom"), { + counter, + attributes: { + operation: "direct", + }, + }).pipe(Effect.exit); + + const snapshots = yield* Metric.snapshot; + assert.equal( + hasMetricSnapshot(snapshots, "with_metrics_direct_total", { + operation: "direct", + outcome: "failure", + }), + true, + ); + }), + ); + + it.effect("evaluates attributes lazily after the wrapped effect runs", () => + Effect.gen(function* () { + const counter = Metric.counter("with_metrics_lazy_total"); + const timer = Metric.timer("with_metrics_lazy_duration"); + let provider = "unknown"; + + yield* Effect.sync(() => { + provider = "codex"; + }).pipe( + withMetrics({ + counter, + timer, + attributes: () => ({ + provider, + operation: "lazy", + }), + }), + ); + + const snapshots = yield* Metric.snapshot; + assert.equal( + hasMetricSnapshot(snapshots, "with_metrics_lazy_total", { + provider: "codex", + operation: "lazy", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", { + provider: "codex", + operation: "lazy", + }), + true, + ); + }), + ); +}); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts new file mode 100644 index 0000000000..3e527c7cb4 --- /dev/null +++ b/apps/server/src/observability/Metrics.ts @@ -0,0 +1,160 @@ +import { Duration, Effect, Exit, Metric } from "effect"; +import { dual } from "effect/Function"; + +import { + compactMetricAttributes, + normalizeModelMetricLabel, + outcomeFromExit, +} from "./Attributes.ts"; + +export const rpcRequestsTotal = Metric.counter("t3_rpc_requests_total", { + description: "Total RPC requests handled by the websocket RPC server.", +}); + +export const rpcRequestDuration = Metric.timer("t3_rpc_request_duration", { + description: "RPC request handling duration.", +}); + +export const orchestrationCommandsTotal = Metric.counter("t3_orchestration_commands_total", { + description: "Total orchestration commands dispatched.", +}); + +export const orchestrationCommandDuration = Metric.timer("t3_orchestration_command_duration", { + description: "Orchestration command dispatch duration.", +}); + +export const orchestrationCommandAckDuration = Metric.timer( + "t3_orchestration_command_ack_duration", + { + description: + "Time from orchestration command dispatch to the first committed domain event emitted for that command.", + }, +); + +export const orchestrationEventsProcessedTotal = Metric.counter( + "t3_orchestration_events_processed_total", + { + description: "Total orchestration intent events processed by runtime reactors.", + }, +); + +export const providerSessionsTotal = Metric.counter("t3_provider_sessions_total", { + description: "Total provider session lifecycle operations.", +}); + +export const providerTurnsTotal = Metric.counter("t3_provider_turns_total", { + description: "Total provider turn lifecycle operations.", +}); + +export const providerTurnDuration = Metric.timer("t3_provider_turn_duration", { + description: "Provider turn request duration.", +}); + +export const providerRuntimeEventsTotal = Metric.counter("t3_provider_runtime_events_total", { + description: "Total canonical provider runtime events processed.", +}); + +export const gitCommandsTotal = Metric.counter("t3_git_commands_total", { + description: "Total git commands executed by the server runtime.", +}); + +export const gitCommandDuration = Metric.timer("t3_git_command_duration", { + description: "Git command execution duration.", +}); + +export const terminalSessionsTotal = Metric.counter("t3_terminal_sessions_total", { + description: "Total terminal sessions started.", +}); + +export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total", { + description: "Total terminal restart requests handled.", +}); + +export const metricAttributes = ( + attributes: Readonly>, +): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes)); + +export const increment = ( + metric: Metric.Metric, + attributes: Readonly>, + amount = 1, +) => Metric.update(Metric.withAttributes(metric, metricAttributes(attributes)), amount); + +export interface WithMetricsOptions { + readonly counter?: Metric.Metric; + readonly timer?: Metric.Metric; + readonly attributes?: + | Readonly> + | (() => Readonly>); + readonly outcomeAttributes?: ( + outcome: ReturnType, + ) => Readonly>; +} + +const withMetricsImpl = ( + effect: Effect.Effect, + options: WithMetricsOptions, +): Effect.Effect => + Effect.gen(function* () { + const startedAt = Date.now(); + const exit = yield* Effect.exit(effect); + const duration = Duration.millis(Math.max(0, Date.now() - startedAt)); + const baseAttributes = + typeof options.attributes === "function" ? options.attributes() : (options.attributes ?? {}); + + if (options.timer) { + yield* Metric.update( + Metric.withAttributes(options.timer, metricAttributes(baseAttributes)), + duration, + ); + } + + if (options.counter) { + const outcome = outcomeFromExit(exit); + yield* Metric.update( + Metric.withAttributes( + options.counter, + metricAttributes({ + ...baseAttributes, + outcome, + ...(options.outcomeAttributes ? options.outcomeAttributes(outcome) : {}), + }), + ), + 1, + ); + } + + if (Exit.isSuccess(exit)) { + return exit.value; + } + return yield* Effect.failCause(exit.cause); + }); + +export const withMetrics: { + ( + options: WithMetricsOptions, + ): (effect: Effect.Effect) => Effect.Effect; + (effect: Effect.Effect, options: WithMetricsOptions): Effect.Effect; +} = dual(2, withMetricsImpl); + +export const providerMetricAttributes = ( + provider: string, + extra?: Readonly>, +) => + compactMetricAttributes({ + provider, + ...extra, + }); + +export const providerTurnMetricAttributes = (input: { + readonly provider: string; + readonly model: string | null | undefined; + readonly extra?: Readonly>; +}) => { + const modelFamily = normalizeModelMetricLabel(input.model); + return compactMetricAttributes({ + provider: input.provider, + ...(modelFamily ? { modelFamily } : {}), + ...input.extra, + }); +}; diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts new file mode 100644 index 0000000000..d29b05f3c2 --- /dev/null +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -0,0 +1,161 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Exit, Metric, Stream } from "effect"; + +import { + observeRpcEffect, + observeRpcStream, + observeRpcStreamEffect, +} from "./RpcInstrumentation.ts"; + +const hasMetricSnapshot = ( + snapshots: ReadonlyArray, + id: string, + attributes: Readonly>, +) => + snapshots.some( + (snapshot) => + snapshot.id === id && + Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), + ); + +describe("RpcInstrumentation", () => { + it.effect("records success metrics for unary RPC handlers", () => + Effect.gen(function* () { + yield* observeRpcEffect("rpc.instrumentation.success", Effect.succeed("ok"), { + "rpc.aggregate": "test", + }).pipe(Effect.withSpan("rpc.instrumentation.success.span")); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { + method: "rpc.instrumentation.success", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { + method: "rpc.instrumentation.success", + }), + true, + ); + }), + ); + + it.effect("records failure outcomes for unary RPC handlers", () => + Effect.gen(function* () { + yield* Effect.exit( + observeRpcEffect("rpc.instrumentation.failure", Effect.fail("boom"), { + "rpc.aggregate": "test", + }).pipe(Effect.withSpan("rpc.instrumentation.failure.span")), + ); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { + method: "rpc.instrumentation.failure", + outcome: "failure", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { + method: "rpc.instrumentation.failure", + }), + true, + ); + }), + ); + + it.effect("records subscription activation metrics for stream RPC handlers", () => + Effect.gen(function* () { + const events = yield* Stream.runCollect( + observeRpcStreamEffect( + "rpc.instrumentation.stream", + Effect.succeed(Stream.make("a", "b")), + { "rpc.aggregate": "test" }, + ).pipe(Stream.withSpan("rpc.instrumentation.stream.span")), + ); + + assert.deepStrictEqual(Array.from(events), ["a", "b"]); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { + method: "rpc.instrumentation.stream", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { + method: "rpc.instrumentation.stream", + }), + true, + ); + }), + ); + + it.effect("records failure outcomes for direct stream RPC handlers during consumption", () => + Effect.gen(function* () { + const exit = yield* Stream.runCollect( + observeRpcStream( + "rpc.instrumentation.stream.failure", + Stream.make("a").pipe(Stream.concat(Stream.fail("boom"))), + { "rpc.aggregate": "test" }, + ).pipe(Stream.withSpan("rpc.instrumentation.stream.failure.span")), + ).pipe(Effect.exit); + + assert.equal(Exit.isFailure(exit), true); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { + method: "rpc.instrumentation.stream.failure", + outcome: "failure", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { + method: "rpc.instrumentation.stream.failure", + }), + true, + ); + }), + ); + + it.effect("records failure outcomes when a stream RPC effect produces a failing stream", () => + Effect.gen(function* () { + const exit = yield* Stream.runCollect( + observeRpcStreamEffect( + "rpc.instrumentation.stream.effect.failure", + Effect.succeed(Stream.fail("boom")), + { "rpc.aggregate": "test" }, + ).pipe(Stream.withSpan("rpc.instrumentation.stream.effect.failure.span")), + ).pipe(Effect.exit); + + assert.equal(Exit.isFailure(exit), true); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { + method: "rpc.instrumentation.stream.effect.failure", + outcome: "failure", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { + method: "rpc.instrumentation.stream.effect.failure", + }), + true, + ); + }), + ); +}); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts new file mode 100644 index 0000000000..a3ac29aa02 --- /dev/null +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -0,0 +1,89 @@ +import { Duration, Effect, Exit, Metric, Stream } from "effect"; + +import { outcomeFromExit } from "./Attributes.ts"; +import { metricAttributes, rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts"; + +const annotateRpcSpan = ( + method: string, + traceAttributes?: Readonly>, +): Effect.Effect => + Effect.annotateCurrentSpan({ + "rpc.method": method, + ...traceAttributes, + }); + +const recordRpcStreamMetrics = ( + method: string, + startedAt: number, + exit: Exit.Exit, +): Effect.Effect => + Effect.gen(function* () { + yield* Metric.update( + Metric.withAttributes(rpcRequestDuration, metricAttributes({ method })), + Duration.millis(Math.max(0, Date.now() - startedAt)), + ); + yield* Metric.update( + Metric.withAttributes( + rpcRequestsTotal, + metricAttributes({ + method, + outcome: outcomeFromExit(exit), + }), + ), + 1, + ); + }); + +export const observeRpcEffect = ( + method: string, + effect: Effect.Effect, + traceAttributes?: Readonly>, +): Effect.Effect => + Effect.gen(function* () { + yield* annotateRpcSpan(method, traceAttributes); + + return yield* effect.pipe( + withMetrics({ + counter: rpcRequestsTotal, + timer: rpcRequestDuration, + attributes: { + method, + }, + }), + ); + }); + +export const observeRpcStream = ( + method: string, + stream: Stream.Stream, + traceAttributes?: Readonly>, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + yield* annotateRpcSpan(method, traceAttributes); + const startedAt = Date.now(); + return stream.pipe(Stream.onExit((exit) => recordRpcStreamMetrics(method, startedAt, exit))); + }), + ); + +export const observeRpcStreamEffect = ( + method: string, + effect: Effect.Effect, EffectError, EffectContext>, + traceAttributes?: Readonly>, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + yield* annotateRpcSpan(method, traceAttributes); + const startedAt = Date.now(); + const exit = yield* Effect.exit(effect); + + if (Exit.isFailure(exit)) { + yield* recordRpcStreamMetrics(method, startedAt, exit); + return yield* Effect.failCause(exit.cause); + } + + return exit.value.pipe( + Stream.onExit((streamExit) => recordRpcStreamMetrics(method, startedAt, streamExit)), + ); + }), + ); diff --git a/apps/server/src/observability/TraceRecord.ts b/apps/server/src/observability/TraceRecord.ts new file mode 100644 index 0000000000..3aee1b267c --- /dev/null +++ b/apps/server/src/observability/TraceRecord.ts @@ -0,0 +1,100 @@ +import { Cause, Exit, Option, Tracer } from "effect"; + +import { compactTraceAttributes } from "./Attributes.ts"; + +export interface TraceRecord { + readonly type: "effect-span"; + readonly name: string; + readonly traceId: string; + readonly spanId: string; + readonly parentSpanId?: string; + readonly sampled: boolean; + readonly kind: Tracer.SpanKind; + readonly startTimeUnixNano: string; + readonly endTimeUnixNano: string; + readonly durationMs: number; + readonly attributes: Readonly>; + readonly events: ReadonlyArray<{ + readonly name: string; + readonly timeUnixNano: string; + readonly attributes: Readonly>; + }>; + readonly links: ReadonlyArray<{ + readonly traceId: string; + readonly spanId: string; + readonly attributes: Readonly>; + }>; + readonly exit: + | { + readonly _tag: "Success"; + } + | { + readonly _tag: "Interrupted"; + readonly cause: string; + } + | { + readonly _tag: "Failure"; + readonly cause: string; + }; +} + +interface SerializableSpan { + readonly name: string; + readonly traceId: string; + readonly spanId: string; + readonly parent: Option.Option; + readonly status: Tracer.SpanStatus; + readonly sampled: boolean; + readonly kind: Tracer.SpanKind; + readonly attributes: ReadonlyMap; + readonly links: ReadonlyArray; + readonly events: ReadonlyArray< + readonly [name: string, startTime: bigint, attributes: Record] + >; +} + +function formatTraceExit(exit: Exit.Exit): TraceRecord["exit"] { + if (Exit.isSuccess(exit)) { + return { _tag: "Success" }; + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return { + _tag: "Interrupted", + cause: Cause.pretty(exit.cause), + }; + } + return { + _tag: "Failure", + cause: Cause.pretty(exit.cause), + }; +} + +export function spanToTraceRecord(span: SerializableSpan): TraceRecord { + const status = span.status as Extract; + const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; + + return { + type: "effect-span", + name: span.name, + traceId: span.traceId, + spanId: span.spanId, + ...(parentSpanId ? { parentSpanId } : {}), + sampled: span.sampled, + kind: span.kind, + startTimeUnixNano: String(status.startTime), + endTimeUnixNano: String(status.endTime), + durationMs: Number(status.endTime - status.startTime) / 1_000_000, + attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), + events: span.events.map(([name, startTime, attributes]) => ({ + name, + timeUnixNano: String(startTime), + attributes: compactTraceAttributes(attributes), + })), + links: span.links.map((link) => ({ + traceId: link.span.traceId, + spanId: link.span.spanId, + attributes: compactTraceAttributes(link.attributes), + })), + exit: formatTraceExit(status.exit), + }; +} diff --git a/apps/server/src/observability/TraceSink.test.ts b/apps/server/src/observability/TraceSink.test.ts new file mode 100644 index 0000000000..f4db90516b --- /dev/null +++ b/apps/server/src/observability/TraceSink.test.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import type { TraceRecord } from "./TraceRecord.ts"; +import { makeTraceSink } from "./TraceSink.ts"; + +const makeRecord = (name: string, suffix = ""): TraceRecord => ({ + type: "effect-span", + name, + traceId: `trace-${name}-${suffix}`, + spanId: `span-${name}-${suffix}`, + sampled: true, + kind: "internal", + startTimeUnixNano: "1", + endTimeUnixNano: "2", + durationMs: 1, + attributes: { + payload: suffix, + }, + events: [], + links: [], + exit: { + _tag: "Success", + }, +}); + +describe("TraceSink", () => { + it.effect("flushes buffered records on close", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "server.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + sink.push(makeRecord("alpha")); + sink.push(makeRecord("beta")); + yield* sink.close(); + + const lines = fs + .readFileSync(tracePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as TraceRecord); + + assert.equal(lines.length, 2); + assert.equal(lines[0]?.name, "alpha"); + assert.equal(lines[1]?.name, "beta"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("rotates the trace file when the configured max size is exceeded", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "server.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 180, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + for (let index = 0; index < 8; index += 1) { + sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); + yield* sink.flush; + } + yield* sink.close(); + + const matchingFiles = fs + .readdirSync(tempDir) + .filter( + (entry) => + entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."), + ) + .toSorted(); + + assert.equal( + matchingFiles.some((entry) => entry === "server.trace.ndjson.1"), + true, + ); + assert.equal( + matchingFiles.some((entry) => entry === "server.trace.ndjson.3"), + false, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("drops only the invalid record when serialization fails", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "server.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + const circular: Array = []; + circular.push(circular); + + sink.push(makeRecord("alpha")); + sink.push({ + ...makeRecord("invalid"), + attributes: { + circular, + }, + } as TraceRecord); + sink.push(makeRecord("beta")); + yield* sink.close(); + + const lines = fs + .readFileSync(tracePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as TraceRecord); + + assert.deepStrictEqual( + lines.map((line) => line.name), + ["alpha", "beta"], + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); +}); diff --git a/apps/server/src/observability/TraceSink.ts b/apps/server/src/observability/TraceSink.ts new file mode 100644 index 0000000000..a9124591bd --- /dev/null +++ b/apps/server/src/observability/TraceSink.ts @@ -0,0 +1,75 @@ +import { RotatingFileSink } from "@t3tools/shared/logging"; +import { Effect } from "effect"; + +import type { TraceRecord } from "./TraceRecord.ts"; + +const FLUSH_BUFFER_THRESHOLD = 32; +const MAX_BUFFERED_BYTES = 5 * 1024 * 1024; // 5 MB + +export interface TraceSinkOptions { + readonly filePath: string; + readonly maxBytes: number; + readonly maxFiles: number; + readonly batchWindowMs: number; +} + +export interface TraceSink { + readonly filePath: string; + push: (record: TraceRecord) => void; + flush: Effect.Effect; + close: () => Effect.Effect; +} + +export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { + const sink = new RotatingFileSink({ + filePath: options.filePath, + maxBytes: options.maxBytes, + maxFiles: options.maxFiles, + }); + + let buffer: Array = []; + + const flushUnsafe = () => { + if (buffer.length === 0) { + return; + } + + const chunk = buffer.join(""); + buffer = []; + + try { + sink.write(chunk); + } catch { + buffer.unshift(chunk); + // Drop oldest chunks if buffer exceeds the cap to prevent unbounded memory growth + let totalBytes = buffer.reduce((sum, c) => sum + c.length, 0); + while (totalBytes > MAX_BUFFERED_BYTES && buffer.length > 0) { + const dropped = buffer.pop()!; + totalBytes -= dropped.length; + } + } + }; + + const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); + + yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); + yield* Effect.forkScoped( + Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), + ); + + return { + filePath: options.filePath, + push(record) { + try { + buffer.push(`${JSON.stringify(record)}\n`); + if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { + flushUnsafe(); + } + } catch { + return; + } + }, + flush, + close: () => flush, + } satisfies TraceSink; +}); diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 2f7b78d7b6..c9848fb989 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -108,11 +108,11 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); - const intellijLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "intellij" }, + const ideaLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "idea" }, "linux", ); - assert.deepEqual(intellijLaunch, { + assert.deepEqual(ideaLaunch, { command: "idea", args: ["/tmp/workspace"], }); @@ -178,8 +178,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); - it.effect("uses --goto when editor supports line/column suffixes", () => - // Use "linux" to avoid macOS .app fallback logic for deterministic results. + it.effect("applies launch-style-specific navigation arguments", () => Effect.gen(function* () { const lineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, @@ -261,6 +260,33 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { command: "positron", args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + + const zedLineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, + "darwin", + ); + assert.deepEqual(zedLineOnly, { + command: "zed", + args: ["/tmp/workspace/AGENTS.md:48"], + }); + + const ideaLineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, + "linux", + ); + assert.deepEqual(ideaLineOnly, { + command: "idea", + args: ["--line", "48", "/tmp/workspace/AGENTS.md"], + }); + + const ideaLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "idea" }, + "linux", + ); + assert.deepEqual(ideaLineAndColumn, { + command: "idea", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); }), ); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 1258b8239e..ef8b00d615 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -35,15 +35,52 @@ interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; } -const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; + +function parseTargetPathAndPosition(target: string): { + path: string; + line: string | undefined; + column: string | undefined; +} | null { + const match = TARGET_WITH_POSITION_PATTERN.exec(target); + if (!match?.[1] || !match[2]) { + return null; + } + + return { + path: match[1], + line: match[2], + column: match[3], + }; +} -function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { - return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); +function resolveCommandEditorArgs( + editor: (typeof EDITORS)[number], + target: string, +): ReadonlyArray { + const parsedTarget = parseTargetPathAndPosition(target); + + switch (editor.launchStyle) { + case "direct-path": + return [target]; + case "goto": + return parsedTarget ? ["--goto", target] : [target]; + case "line-column": { + if (!parsedTarget) { + return [target]; + } + + const { path, line, column } = parsedTarget; + return [...(line ? ["--line", line] : []), ...(column ? ["--column", column] : []), path]; + } + } } /** Editors that are terminals requiring --working-directory instead of a positional path arg. */ const WORKING_DIRECTORY_EDITORS = new Set(["ghostty"]); +const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; + function stripLineColumnSuffix(target: string): string { return target.replace(LINE_COLUMN_SUFFIX_PATTERN, ""); } @@ -77,7 +114,7 @@ const MAC_APP_NAMES: Partial> = { positron: "Positron", sublime: "Sublime Text", webstorm: "WebStorm", - intellij: "IntelliJ IDEA", + idea: "IntelliJ IDEA", fleet: "Fleet", ghostty: "Ghostty", }; @@ -256,25 +293,21 @@ export class Open extends ServiceMap.Service()("t3/open") {} // Implementations // ============================== -export const resolveEditorLaunch = Effect.fnUntraced(function* ( +export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, ): Effect.fn.Return { + yield* Effect.annotateCurrentSpan({ + "open.editor": input.editor, + "open.target.hasPosition": /:\d+/.test(input.cwd), + "open.platform": platform, + }); const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } if (editorDef.command) { - if (shouldUseGotoFlag(editorDef, input.cwd)) { - if (platform === "darwin" && !isCommandAvailable(editorDef.command)) { - const macApp = MAC_APP_NAMES[editorDef.id]; - if (macApp && isMacAppInstalled(macApp)) { - return { command: "open", args: ["-a", macApp, "--args", "--goto", input.cwd] }; - } - } - return { command: editorDef.command, args: ["--goto", input.cwd] }; - } if (WORKING_DIRECTORY_EDITORS.has(editorDef.id)) { const workingDirectory = resolveWorkingDirectoryTarget(input.cwd); if (platform === "darwin") { @@ -288,13 +321,19 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } return { command: editorDef.command, args: [`--working-directory=${workingDirectory}`] }; } - if (platform === "darwin" && !isCommandAvailable(editorDef.command)) { + + const args = resolveCommandEditorArgs(editorDef, input.cwd); + + // On macOS, fall back to `open -a` when the CLI tool isn't in PATH + // but the .app bundle is installed. + if (platform === "darwin" && !isCommandAvailable(editorDef.command, { platform })) { const macApp = MAC_APP_NAMES[editorDef.id]; if (macApp && isMacAppInstalled(macApp)) { - return { command: "open", args: ["-a", macApp, input.cwd] }; + return { command: "open", args: ["-a", macApp, "--args", ...args] }; } } - return { command: editorDef.command, args: [input.cwd] }; + + return { command: editorDef.command, args }; } if (editorDef.id !== "file-manager") { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 28609cecb3..03abebaf3a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -119,7 +119,7 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); - const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( + const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* ( threadId: ThreadId, ): Effect.fn.Return> { const readModel = yield* orchestrationEngine.getReadModel(); @@ -153,7 +153,7 @@ const make = Effect.gen(function* () { // active provider session CWD and falling back to the thread/project config. // Returns undefined when no CWD can be determined or the workspace is not // a git repository. - const resolveCheckpointCwd = Effect.fnUntraced(function* (input: { + const resolveCheckpointCwd = Effect.fn("resolveCheckpointCwd")(function* (input: { readonly threadId: ThreadId; readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; @@ -188,7 +188,7 @@ const make = Effect.gen(function* () { // Shared tail for both capture paths: creates the git checkpoint ref, diffs // it against the previous turn, then dispatches the domain events to update // the orchestration read model. - const captureAndDispatchCheckpoint = Effect.fnUntraced(function* (input: { + const captureAndDispatchCheckpoint = Effect.fn("captureAndDispatchCheckpoint")(function* (input: { readonly threadId: ThreadId; readonly turnId: TurnId; readonly thread: { @@ -321,70 +321,70 @@ const make = Effect.gen(function* () { }); // Captures a real git checkpoint when a turn completes via a runtime event. - const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( - event: Extract, - ) { - const turnId = toTurnId(event.turnId); - if (!turnId) { - return; - } - - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); - if (!thread) { - return; - } + const captureCheckpointFromTurnCompletion = Effect.fn("captureCheckpointFromTurnCompletion")( + function* (event: Extract) { + const turnId = toTurnId(event.turnId); + if (!turnId) { + return; + } - // When a primary turn is active, only that turn may produce completion checkpoints. - if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { - return; - } + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); + if (!thread) { + return; + } - // Only skip if a real (non-placeholder) checkpoint already exists for this turn. - // ProviderRuntimeIngestion may insert placeholder entries with status "missing" - // before this reactor runs; those must not prevent real git capture. - if ( - thread.checkpoints.some( - (checkpoint) => checkpoint.turnId === turnId && checkpoint.status !== "missing", - ) - ) { - return; - } + // When a primary turn is active, only that turn may produce completion checkpoints. + if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { + return; + } - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId: thread.id, - thread, - projects: readModel.projects, - preferSessionRuntime: true, - }); - if (!checkpointCwd) { - return; - } + // Only skip if a real (non-placeholder) checkpoint already exists for this turn. + // ProviderRuntimeIngestion may insert placeholder entries with status "missing" + // before this reactor runs; those must not prevent real git capture. + if ( + thread.checkpoints.some( + (checkpoint) => checkpoint.turnId === turnId && checkpoint.status !== "missing", + ) + ) { + return; + } - // If a placeholder checkpoint exists for this turn, reuse its turn count - // instead of incrementing past it. - const existingPlaceholder = thread.checkpoints.find( - (checkpoint) => checkpoint.turnId === turnId && checkpoint.status === "missing", - ); - const currentTurnCount = thread.checkpoints.reduce( - (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), - 0, - ); - const nextTurnCount = existingPlaceholder - ? existingPlaceholder.checkpointTurnCount - : currentTurnCount + 1; + const checkpointCwd = yield* resolveCheckpointCwd({ + threadId: thread.id, + thread, + projects: readModel.projects, + preferSessionRuntime: true, + }); + if (!checkpointCwd) { + return; + } - yield* captureAndDispatchCheckpoint({ - threadId: thread.id, - turnId, - thread, - cwd: checkpointCwd, - turnCount: nextTurnCount, - status: checkpointStatusFromRuntime(event.payload.state), - assistantMessageId: undefined, - createdAt: event.createdAt, - }); - }); + // If a placeholder checkpoint exists for this turn, reuse its turn count + // instead of incrementing past it. + const existingPlaceholder = thread.checkpoints.find( + (checkpoint) => checkpoint.turnId === turnId && checkpoint.status === "missing", + ); + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const nextTurnCount = existingPlaceholder + ? existingPlaceholder.checkpointTurnCount + : currentTurnCount + 1; + + yield* captureAndDispatchCheckpoint({ + threadId: thread.id, + turnId, + thread, + cwd: checkpointCwd, + turnCount: nextTurnCount, + status: checkpointStatusFromRuntime(event.payload.state), + assistantMessageId: undefined, + createdAt: event.createdAt, + }); + }, + ); // Captures a real git checkpoint when a placeholder checkpoint (status "missing") // is detected via a domain event. This replaces the placeholder with a real @@ -394,7 +394,7 @@ const make = Effect.gen(function* () { // events from the Codex runtime. This handler fires when the corresponding // domain event arrives, allowing the reactor to capture the actual filesystem // state into a git ref and dispatch a replacement checkpoint. - const captureCheckpointFromPlaceholder = Effect.fnUntraced(function* ( + const captureCheckpointFromPlaceholder = Effect.fn("captureCheckpointFromPlaceholder")(function* ( event: Extract, ) { const { threadId, turnId, checkpointTurnCount, status } = event.payload; @@ -448,57 +448,59 @@ const make = Effect.gen(function* () { }); }); - const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* ( - event: Extract, - ) { - const turnId = toTurnId(event.turnId); - if (!turnId) { - return; - } + const ensurePreTurnBaselineFromTurnStart = Effect.fn("ensurePreTurnBaselineFromTurnStart")( + function* (event: Extract) { + const turnId = toTurnId(event.turnId); + if (!turnId) { + return; + } - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); - if (!thread) { - return; - } + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); + if (!thread) { + return; + } - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId: thread.id, - thread, - projects: readModel.projects, - preferSessionRuntime: false, - }); - if (!checkpointCwd) { - return; - } + const checkpointCwd = yield* resolveCheckpointCwd({ + threadId: thread.id, + thread, + projects: readModel.projects, + preferSessionRuntime: false, + }); + if (!checkpointCwd) { + return; + } - const currentTurnCount = thread.checkpoints.reduce( - (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), - 0, - ); - const baselineCheckpointRef = checkpointRefForThreadTurn(thread.id, currentTurnCount); - const baselineExists = yield* checkpointStore.hasCheckpointRef({ - cwd: checkpointCwd, - checkpointRef: baselineCheckpointRef, - }); - if (baselineExists) { - return; - } + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const baselineCheckpointRef = checkpointRefForThreadTurn(thread.id, currentTurnCount); + const baselineExists = yield* checkpointStore.hasCheckpointRef({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + if (baselineExists) { + return; + } - yield* checkpointStore.captureCheckpoint({ - cwd: checkpointCwd, - checkpointRef: baselineCheckpointRef, - }); - yield* receiptBus.publish({ - type: "checkpoint.baseline.captured", - threadId: thread.id, - checkpointTurnCount: currentTurnCount, - checkpointRef: baselineCheckpointRef, - createdAt: event.createdAt, - }); - }); + yield* checkpointStore.captureCheckpoint({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + yield* receiptBus.publish({ + type: "checkpoint.baseline.captured", + threadId: thread.id, + checkpointTurnCount: currentTurnCount, + checkpointRef: baselineCheckpointRef, + createdAt: event.createdAt, + }); + }, + ); - const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( + const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn( + "ensurePreTurnBaselineFromDomainTurnStart", + )(function* ( event: Extract< OrchestrationEvent, { type: "thread.turn-start-requested" | "thread.message-sent" } @@ -557,7 +559,7 @@ const make = Effect.gen(function* () { }); }); - const handleRevertRequested = Effect.fnUntraced(function* ( + const handleRevertRequested = Effect.fn("handleRevertRequested")(function* ( event: Extract, ) { const now = new Date().toISOString(); @@ -685,7 +687,7 @@ const make = Effect.gen(function* () { ); }); - const processDomainEvent = Effect.fnUntraced(function* (event: OrchestrationEvent) { + const processDomainEvent = Effect.fn("processDomainEvent")(function* (event: OrchestrationEvent) { if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") { yield* ensurePreTurnBaselineFromDomainTurnStart(event); return; @@ -724,7 +726,9 @@ const make = Effect.gen(function* () { } }); - const processRuntimeEvent = Effect.fnUntraced(function* (event: ProviderRuntimeEvent) { + const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( + event: ProviderRuntimeEvent, + ) { if (event.type === "turn.started") { yield* ensurePreTurnBaselineFromTurnStart(event); return; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ec783069ea..932d262169 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -8,7 +8,7 @@ import { TurnId, type OrchestrationEvent, } from "@t3tools/contracts"; -import { Effect, Layer, ManagedRuntime, Option, Queue, Stream } from "effect"; +import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; @@ -62,6 +62,17 @@ function now() { return new Date().toISOString(); } +const hasMetricSnapshot = ( + snapshots: ReadonlyArray, + id: string, + attributes: Readonly>, +) => + snapshots.some( + (snapshot) => + snapshot.id === id && + Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), + ); + describe("OrchestrationEngine", () => { it("bootstraps the in-memory read model from persisted projections", async () => { const failOnHistoricalReplayStore: OrchestrationEventStoreShape = { @@ -341,6 +352,95 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("records command ack duration using the first committed event type", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-ack-create"), + projectId: asProjectId("project-ack"), + title: "Ack Project", + workspaceRoot: "/tmp/project-ack", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }), + ); + + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-ack-create"), + threadId: ThreadId.makeUnsafe("thread-ack"), + projectId: asProjectId("project-ack"), + title: "Ack Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + + const snapshots = await system.run(Metric.snapshot); + expect( + hasMetricSnapshot(snapshots, "t3_orchestration_command_ack_duration", { + commandType: "thread.create", + aggregateKind: "thread", + ackEventType: "thread.created", + }), + ).toBe(true); + + await system.dispose(); + }); + + it("records failed command dispatches as metric failures", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await expect( + system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-missing-project"), + threadId: ThreadId.makeUnsafe("thread-missing-project"), + projectId: asProjectId("project-missing"), + title: "Missing Project Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ), + ).rejects.toThrow("does not exist"); + + const snapshots = await system.run(Metric.snapshot); + expect( + hasMetricSnapshot(snapshots, "t3_orchestration_commands_total", { + commandType: "thread.create", + aggregateKind: "thread", + outcome: "failure", + }), + ).toBe(true); + + await system.dispose(); + }); + it("stores completed checkpoint summaries even when no files changed", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index bd3581deb6..ddd1718faf 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -5,9 +5,28 @@ import type { ThreadId, } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; -import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; +import { + Cause, + Deferred, + Duration, + Effect, + Exit, + Layer, + Metric, + Option, + PubSub, + Queue, + Schema, + Stream, +} from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + metricAttributes, + orchestrationCommandAckDuration, + orchestrationCommandsTotal, + orchestrationCommandDuration, +} from "../../observability/Metrics.ts"; import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; @@ -28,6 +47,7 @@ import { interface CommandEnvelope { command: OrchestrationCommand; result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; + startedAtMs: number; } function commandToAggregateRef(command: OrchestrationCommand): { @@ -64,6 +84,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => { const dispatchStartSequence = readModel.snapshotSequence; + const processingStartedAtMs = Date.now(); + const aggregateRef = commandToAggregateRef(envelope.command); + const baseMetricAttributes = { + commandType: envelope.command.type, + aggregateKind: aggregateRef.aggregateKind, + } as const; const reconcileReadModelAfterDispatchFailure = Effect.gen(function* () { const persistedEvents = yield* Stream.runCollect( eventStore.readFromSequence(dispatchStartSequence), @@ -83,113 +109,160 @@ const makeOrchestrationEngine = Effect.gen(function* () { } }); - return Effect.gen(function* () { - const existingReceipt = yield* commandReceiptRepository.getByCommandId({ - commandId: envelope.command.commandId, - }); - if (Option.isSome(existingReceipt)) { - if (existingReceipt.value.status === "accepted") { - yield* Deferred.succeed(envelope.result, { - sequence: existingReceipt.value.resultSequence, - }); - return; - } - yield* Deferred.fail( - envelope.result, - new OrchestrationCommandPreviouslyRejectedError({ + return Effect.exit( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "orchestration.command_id": envelope.command.commandId, + "orchestration.command_type": envelope.command.type, + "orchestration.aggregate_kind": aggregateRef.aggregateKind, + "orchestration.aggregate_id": aggregateRef.aggregateId, + }); + + const existingReceipt = yield* commandReceiptRepository.getByCommandId({ + commandId: envelope.command.commandId, + }); + if (Option.isSome(existingReceipt)) { + if (existingReceipt.value.status === "accepted") { + return { + sequence: existingReceipt.value.resultSequence, + }; + } + return yield* new OrchestrationCommandPreviouslyRejectedError({ commandId: envelope.command.commandId, detail: existingReceipt.value.error ?? "Previously rejected.", - }), - ); - return; - } + }); + } - const eventBase = yield* decideOrchestrationCommand({ - command: envelope.command, - readModel, - }); - const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; - const committedCommand = yield* sql - .withTransaction( - Effect.gen(function* () { - const committedEvents: OrchestrationEvent[] = []; - let nextReadModel = readModel; - - for (const nextEvent of eventBases) { - const savedEvent = yield* eventStore.append(nextEvent); - nextReadModel = yield* projectEvent(nextReadModel, savedEvent); - yield* projectionPipeline.projectEvent(savedEvent); - committedEvents.push(savedEvent); - } + const eventBase = yield* decideOrchestrationCommand({ + command: envelope.command, + readModel, + }); + const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; + const committedCommand = yield* sql + .withTransaction( + Effect.gen(function* () { + const committedEvents: OrchestrationEvent[] = []; + let nextReadModel = readModel; - const lastSavedEvent = committedEvents.at(-1) ?? null; - if (lastSavedEvent === null) { - return yield* new OrchestrationCommandInvariantError({ - commandType: envelope.command.type, - detail: "Command produced no events.", - }); - } + for (const nextEvent of eventBases) { + const savedEvent = yield* eventStore.append(nextEvent); + nextReadModel = yield* projectEvent(nextReadModel, savedEvent); + yield* projectionPipeline.projectEvent(savedEvent); + committedEvents.push(savedEvent); + } - yield* commandReceiptRepository.upsert({ - commandId: envelope.command.commandId, - aggregateKind: lastSavedEvent.aggregateKind, - aggregateId: lastSavedEvent.aggregateId, - acceptedAt: lastSavedEvent.occurredAt, - resultSequence: lastSavedEvent.sequence, - status: "accepted", - error: null, - }); + const lastSavedEvent = committedEvents.at(-1) ?? null; + if (lastSavedEvent === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: "Command produced no events.", + }); + } - return { - committedEvents, - lastSequence: lastSavedEvent.sequence, - nextReadModel, - } as const; - }), - ) - .pipe( - Effect.catchTag("SqlError", (sqlError) => - Effect.fail( - toPersistenceSqlError("OrchestrationEngine.processEnvelope:transaction")(sqlError), + yield* commandReceiptRepository.upsert({ + commandId: envelope.command.commandId, + aggregateKind: lastSavedEvent.aggregateKind, + aggregateId: lastSavedEvent.aggregateId, + acceptedAt: lastSavedEvent.occurredAt, + resultSequence: lastSavedEvent.sequence, + status: "accepted", + error: null, + }); + + return { + committedEvents, + lastSequence: lastSavedEvent.sequence, + nextReadModel, + } as const; + }), + ) + .pipe( + Effect.catchTag("SqlError", (sqlError) => + Effect.fail( + toPersistenceSqlError("OrchestrationEngine.processEnvelope:transaction")(sqlError), + ), ), - ), - ); + ); - readModel = committedCommand.nextReadModel; - for (const event of committedCommand.committedEvents) { - yield* PubSub.publish(eventPubSub, event); - } - yield* Deferred.succeed(envelope.result, { sequence: committedCommand.lastSequence }); - }).pipe( - Effect.catch((error) => - Effect.gen(function* () { - yield* reconcileReadModelAfterDispatchFailure.pipe( - Effect.catch(() => - Effect.logWarning( - "failed to reconcile orchestration read model after dispatch failure", - ).pipe( - Effect.annotateLogs({ - commandId: envelope.command.commandId, - snapshotSequence: readModel.snapshotSequence, + readModel = committedCommand.nextReadModel; + for (const [index, event] of committedCommand.committedEvents.entries()) { + yield* PubSub.publish(eventPubSub, event); + if (index === 0) { + yield* Metric.update( + Metric.withAttributes( + orchestrationCommandAckDuration, + metricAttributes({ + ...baseMetricAttributes, + ackEventType: event.type, }), ), + Duration.millis(Math.max(0, Date.now() - envelope.startedAtMs)), + ); + } + } + return { sequence: committedCommand.lastSequence }; + }).pipe(Effect.withSpan(`orchestration.command.${envelope.command.type}`)), + ).pipe( + Effect.flatMap((exit) => + Effect.gen(function* () { + const outcome = Exit.isSuccess(exit) + ? "success" + : Cause.hasInterruptsOnly(exit.cause) + ? "interrupt" + : "failure"; + yield* Metric.update( + Metric.withAttributes( + orchestrationCommandDuration, + metricAttributes(baseMetricAttributes), + ), + Duration.millis(Math.max(0, Date.now() - processingStartedAtMs)), + ); + yield* Metric.update( + Metric.withAttributes( + orchestrationCommandsTotal, + metricAttributes({ + ...baseMetricAttributes, + outcome, + }), ), + 1, ); - if (Schema.is(OrchestrationCommandInvariantError)(error)) { - const aggregateRef = commandToAggregateRef(envelope.command); - yield* commandReceiptRepository - .upsert({ - commandId: envelope.command.commandId, - aggregateKind: aggregateRef.aggregateKind, - aggregateId: aggregateRef.aggregateId, - acceptedAt: new Date().toISOString(), - resultSequence: readModel.snapshotSequence, - status: "rejected", - error: error.message, - }) - .pipe(Effect.catch(() => Effect.void)); + if (Exit.isSuccess(exit)) { + yield* Deferred.succeed(envelope.result, exit.value); + return; } + + const error = Cause.squash(exit.cause) as OrchestrationDispatchError; + if (!Schema.is(OrchestrationCommandPreviouslyRejectedError)(error)) { + yield* reconcileReadModelAfterDispatchFailure.pipe( + Effect.catch(() => + Effect.logWarning( + "failed to reconcile orchestration read model after dispatch failure", + ).pipe( + Effect.annotateLogs({ + commandId: envelope.command.commandId, + snapshotSequence: readModel.snapshotSequence, + }), + ), + ), + ); + + if (Schema.is(OrchestrationCommandInvariantError)(error)) { + yield* commandReceiptRepository + .upsert({ + commandId: envelope.command.commandId, + aggregateKind: aggregateRef.aggregateKind, + aggregateId: aggregateRef.aggregateId, + acceptedAt: new Date().toISOString(), + resultSequence: readModel.snapshotSequence, + status: "rejected", + error: error.message, + }) + .pipe(Effect.catch(() => Effect.void)); + } + } + yield* Deferred.fail(envelope.result, error); }), ), @@ -214,7 +287,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const dispatch: OrchestrationEngineShape["dispatch"] = (command) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); - yield* Queue.offer(commandQueue, { command, result }); + yield* Queue.offer(commandQueue, { command, result, startedAtMs: Date.now() }); return yield* Deferred.await(result); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8e2a19bcd1..419e3f3bf2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -16,6 +16,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -212,12 +213,12 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); - const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const resolveThread = Effect.fn("resolveThread")(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); return readModel.threads.find((entry) => entry.id === threadId); }); - const ensureSessionForThread = Effect.fnUntraced(function* ( + const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* ( threadId: ThreadId, createdAt: string, options?: { @@ -357,7 +358,7 @@ const make = Effect.gen(function* () { return startedSession.threadId; }); - const sendTurnForThread = Effect.fnUntraced(function* (input: { + const sendTurnForThread = Effect.fn("sendTurnForThread")(function* (input: { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -409,7 +410,9 @@ const make = Effect.gen(function* () { }); }); - const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { + const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn( + "maybeGenerateAndRenameWorktreeBranchForFirstTurn", + )(function* (input: { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; @@ -461,50 +464,52 @@ const make = Effect.gen(function* () { ); }); - const maybeGenerateThreadTitleForFirstTurn = Effect.fnUntraced(function* (input: { - readonly threadId: ThreadId; - readonly cwd: string; - readonly messageText: string; - readonly attachments?: ReadonlyArray; - readonly titleSeed?: string; - }) { - const attachments = input.attachments ?? []; - yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: input.cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }); - if (!generated) return; + const maybeGenerateThreadTitleForFirstTurn = Effect.fn("maybeGenerateThreadTitleForFirstTurn")( + function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + readonly titleSeed?: string; + }) { + const attachments = input.attachments ?? []; + yield* Effect.gen(function* () { + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }); + if (!generated) return; - const thread = yield* resolveThread(input.threadId); - if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { - return; - } + const thread = yield* resolveThread(input.threadId); + if (!thread) return; + if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { + return; + } - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("thread-title-rename"), - threadId: input.threadId, - title: generated.title, - }); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to generate or rename thread title", { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("thread-title-rename"), threadId: input.threadId, - cwd: input.cwd, - cause: Cause.pretty(cause), - }), - ), - ); - }); + title: generated.title, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename thread title", { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), + }), + ), + ); + }, + ); - const processTurnStartRequested = Effect.fnUntraced(function* ( + const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( event: Extract, ) { const key = turnStartKeyForEvent(event); @@ -583,7 +588,7 @@ const make = Effect.gen(function* () { ); }); - const processTurnInterruptRequested = Effect.fnUntraced(function* ( + const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { const thread = yield* resolveThread(event.payload.threadId); @@ -606,7 +611,7 @@ const make = Effect.gen(function* () { yield* providerService.interruptTurn({ threadId: event.payload.threadId }); }); - const processApprovalResponseRequested = Effect.fnUntraced(function* ( + const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( event: Extract, ) { const thread = yield* resolveThread(event.payload.threadId); @@ -653,50 +658,52 @@ const make = Effect.gen(function* () { ); }); - const processUserInputResponseRequested = Effect.fnUntraced(function* ( - event: Extract, - ) { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { - return; - } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { - return yield* appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - detail: "No active provider session is bound to this thread.", - turnId: null, - createdAt: event.payload.createdAt, - requestId: event.payload.requestId, - }); - } + const processUserInputResponseRequested = Effect.fn("processUserInputResponseRequested")( + function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: "No active provider session is bound to this thread.", + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + } - yield* providerService - .respondToUserInput({ - threadId: event.payload.threadId, - requestId: event.payload.requestId, - answers: event.payload.answers, - }) - .pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - detail: isUnknownPendingUserInputRequestError(cause) - ? stalePendingRequestDetail("user-input", event.payload.requestId) - : Cause.pretty(cause), - turnId: null, - createdAt: event.payload.createdAt, - requestId: event.payload.requestId, - }), - ), - ); - }); + yield* providerService + .respondToUserInput({ + threadId: event.payload.threadId, + requestId: event.payload.requestId, + answers: event.payload.answers, + }) + .pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: isUnknownPendingUserInputRequestError(cause) + ? stalePendingRequestDetail("user-input", event.payload.requestId) + : Cause.pretty(cause), + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }), + ), + ); + }, + ); - const processSessionStopRequested = Effect.fnUntraced(function* ( + const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( event: Extract, ) { const thread = yield* resolveThread(event.payload.threadId); @@ -727,6 +734,14 @@ const make = Effect.gen(function* () { const processDomainEvent = Effect.fn("processDomainEvent")(function* ( event: ProviderIntentEvent, ) { + yield* Effect.annotateCurrentSpan({ + "orchestration.event_type": event.type, + "orchestration.thread_id": event.payload.threadId, + ...(event.commandId ? { "orchestration.command_id": event.commandId } : {}), + }); + yield* increment(orchestrationEventsProcessedTotal, { + eventType: event.type, + }); switch (event.type) { case "thread.runtime-mode-set": { const thread = yield* resolveThread(event.payload.threadId); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 830657e5c1..ac1d9a9aa6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -602,7 +602,7 @@ const make = Effect.fn("make")(function* () { const assistantSegmentStateByKey = new Map(); const assistantSegmentKeysByTurnKey = new Map>(); - const isGitRepoForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const isGitRepoForThread = Effect.fn("isGitRepoForThread")(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); if (!thread) { @@ -1085,9 +1085,9 @@ const make = Effect.fn("make")(function* () { // turn.completed) still get turn-level usage in the completion summary. const pendingTokenUsageByThread = new Map>(); - const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fnUntraced(function* ( - threadId: ThreadId, - ) { + const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( + "getSourceProposedPlanReferenceForPendingTurnStart", + )(function* (threadId: ThreadId) { const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ threadId, }); @@ -1107,16 +1107,17 @@ const make = Effect.fn("make")(function* () { } as const; }); - const getExpectedProviderTurnIdForThread = Effect.fnUntraced(function* (threadId: ThreadId) { - const sessions = yield* providerService.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); - return session?.activeTurnId; - }); - - const getSourceProposedPlanReferenceForAcceptedTurnStart = Effect.fnUntraced(function* ( - threadId: ThreadId, - eventTurnId: TurnId | undefined, - ) { + const getExpectedProviderTurnIdForThread = Effect.fn("getExpectedProviderTurnIdForThread")( + function* (threadId: ThreadId) { + const sessions = yield* providerService.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + return session?.activeTurnId; + }, + ); + + const getSourceProposedPlanReferenceForAcceptedTurnStart = Effect.fn( + "getSourceProposedPlanReferenceForAcceptedTurnStart", + )(function* (threadId: ThreadId, eventTurnId: TurnId | undefined) { if (eventTurnId === undefined) { return null; } @@ -1129,34 +1130,36 @@ const make = Effect.fn("make")(function* () { return yield* getSourceProposedPlanReferenceForPendingTurnStart(threadId); }); - const markSourceProposedPlanImplemented = Effect.fnUntraced(function* ( - sourceThreadId: ThreadId, - sourcePlanId: OrchestrationProposedPlanId, - implementationThreadId: ThreadId, - implementedAt: string, - ) { - const readModel = yield* orchestrationEngine.getReadModel(); - const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId); - const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); - if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { - return; - } + const markSourceProposedPlanImplemented = Effect.fn("markSourceProposedPlanImplemented")( + function* ( + sourceThreadId: ThreadId, + sourcePlanId: OrchestrationProposedPlanId, + implementationThreadId: ThreadId, + implementedAt: string, + ) { + const readModel = yield* orchestrationEngine.getReadModel(); + const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId); + const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); + if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { + return; + } - yield* orchestrationEngine.dispatch({ - type: "thread.proposed-plan.upsert", - commandId: CommandId.makeUnsafe( - `provider:source-proposed-plan-implemented:${implementationThreadId}:${crypto.randomUUID()}`, - ), - threadId: sourceThread.id, - proposedPlan: { - ...sourcePlan, - implementedAt, - implementationThreadId, - updatedAt: implementedAt, - }, - createdAt: implementedAt, - }); - }); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: CommandId.makeUnsafe( + `provider:source-proposed-plan-implemented:${implementationThreadId}:${crypto.randomUUID()}`, + ), + threadId: sourceThread.id, + proposedPlan: { + ...sourcePlan, + implementedAt, + implementationThreadId, + updatedAt: implementedAt, + }, + createdAt: implementedAt, + }); + }, + ); const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( event: ProviderRuntimeEvent, diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 07cbf44794..58556099db 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -6,6 +6,7 @@ import { ServerConfig } from "../../config.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; + readonly spanAttributes?: Record; }; type Loader = { @@ -41,7 +42,16 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( const path = yield* Path.Path; yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); - return Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: dbPath })); + return Layer.provideMerge( + setup, + makeRuntimeSqliteLayer({ + filename: dbPath, + spanAttributes: { + "db.name": path.basename(dbPath), + "service.name": "t3-server", + }, + }), + ); }, Layer.unwrap); export const SqlitePersistenceMemory = Layer.provideMerge( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index c575084381..43a593ebae 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -20,7 +20,7 @@ import { import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; +import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { @@ -229,6 +229,17 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const hasMetricSnapshot = ( + snapshots: ReadonlyArray, + id: string, + attributes: Readonly>, +) => + snapshots.some( + (snapshot) => + snapshot.id === id && + Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), + ); + function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter("claudeAgent"); @@ -1059,6 +1070,120 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); }), ); + + it.effect("records provider metrics with the routed provider label", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-metrics"), { + provider: "claudeAgent", + threadId: asThreadId("thread-metrics"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + yield* provider.interruptTurn({ threadId: session.threadId }); + yield* provider.respondToRequest({ + threadId: session.threadId, + requestId: asRequestId("req-metrics-1"), + decision: "accept", + }); + yield* provider.respondToUserInput({ + threadId: session.threadId, + requestId: asRequestId("req-metrics-2"), + answers: { + sandbox_mode: "workspace-write", + }, + }); + yield* provider.rollbackConversation({ + threadId: session.threadId, + numTurns: 1, + }); + yield* provider.stopSession({ threadId: session.threadId }); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_turns_total", { + provider: "claudeAgent", + operation: "interrupt", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_turns_total", { + provider: "claudeAgent", + operation: "approval-response", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_turns_total", { + provider: "claudeAgent", + operation: "user-input-response", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_turns_total", { + provider: "claudeAgent", + operation: "rollback", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_sessions_total", { + provider: "claudeAgent", + operation: "stop", + outcome: "success", + }), + true, + ); + }), + ); + + it.effect( + "records sendTurn metrics with the resolved provider when modelSelection is omitted", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { + provider: "claudeAgent", + threadId: asThreadId("thread-send-metrics"), + cwd: "/tmp/project-send-metrics", + runtimeMode: "full-access", + }); + + yield* provider.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const snapshots = yield* Metric.snapshot; + + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_turns_total", { + provider: "claudeAgent", + operation: "send", + outcome: "success", + }), + true, + ); + assert.equal( + hasMetricSnapshot(snapshots, "t3_provider_turn_duration", { + provider: "claudeAgent", + operation: "send", + }), + true, + ); + }), + ); }); const validation = makeProviderServiceLayer(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f348829598..da2c293fac 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -24,6 +24,16 @@ import { } from "@t3tools/contracts"; import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; +import { + increment, + providerMetricAttributes, + providerRuntimeEventsTotal, + providerSessionsTotal, + providerTurnDuration, + providerTurnsTotal, + providerTurnMetricAttributes, + withMetrics, +} from "../../observability/Metrics.ts"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; @@ -179,7 +189,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const providers = yield* registry.listProviders(); const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - publishRuntimeEvent(event); + increment(providerRuntimeEventsTotal, { + provider: event.provider, + eventType: event.type, + }).pipe(Effect.andThen(publishRuntimeEvent(event))); const worker = Effect.forever( Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), @@ -196,58 +209,72 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly binding: ProviderRuntimeBinding; readonly operation: string; }) { - const adapter = yield* registry.getByProvider(input.binding.provider); - const hasResumeCursor = - input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; - const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); - if (hasActiveSession) { - const activeSessions = yield* adapter.listSessions(); - const existing = activeSessions.find( - (session) => session.threadId === input.binding.threadId, - ); - if (existing) { - yield* upsertSessionBinding(existing, input.binding.threadId); - yield* analytics.record("provider.session.recovered", { - provider: existing.provider, - strategy: "adopt-existing", - hasResumeCursor: existing.resumeCursor !== undefined, - }); - return { adapter, session: existing } as const; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "recover-session", + "provider.kind": input.binding.provider, + "provider.thread_id": input.binding.threadId, + }); + return yield* Effect.gen(function* () { + const adapter = yield* registry.getByProvider(input.binding.provider); + const hasResumeCursor = + input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; + const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); + if (hasActiveSession) { + const activeSessions = yield* adapter.listSessions(); + const existing = activeSessions.find( + (session) => session.threadId === input.binding.threadId, + ); + if (existing) { + yield* upsertSessionBinding(existing, input.binding.threadId); + yield* analytics.record("provider.session.recovered", { + provider: existing.provider, + strategy: "adopt-existing", + hasResumeCursor: existing.resumeCursor !== undefined, + }); + return { adapter, session: existing } as const; + } } - } - if (!hasResumeCursor) { - return yield* toValidationError( - input.operation, - `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, - ); - } + if (!hasResumeCursor) { + return yield* toValidationError( + input.operation, + `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, + ); + } - const persistedCwd = readPersistedCwd(input.binding.runtimePayload); - const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const resumed = yield* adapter.startSession({ - threadId: input.binding.threadId, - provider: input.binding.provider, - ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), - runtimeMode: input.binding.runtimeMode ?? "full-access", - }); - if (resumed.provider !== adapter.provider) { - return yield* toValidationError( - input.operation, - `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, - ); - } + const resumed = yield* adapter.startSession({ + threadId: input.binding.threadId, + provider: input.binding.provider, + ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), + ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", + }); + if (resumed.provider !== adapter.provider) { + return yield* toValidationError( + input.operation, + `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, + ); + } - yield* upsertSessionBinding(resumed, input.binding.threadId); - yield* analytics.record("provider.session.recovered", { - provider: resumed.provider, - strategy: "resume-thread", - hasResumeCursor: resumed.resumeCursor !== undefined, - }); - return { adapter, session: resumed } as const; + yield* upsertSessionBinding(resumed, input.binding.threadId); + yield* analytics.record("provider.session.recovered", { + provider: resumed.provider, + strategy: "resume-thread", + hasResumeCursor: resumed.resumeCursor !== undefined, + }); + return { adapter, session: resumed } as const; + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + attributes: providerMetricAttributes(input.binding.provider, { + operation: "recover", + }), + }), + ); }); const resolveRoutableSession = Effect.fn("resolveRoutableSession")(function* (input: { @@ -291,52 +318,69 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId, provider: parsed.provider ?? "codex", }; - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError((error) => - toValidationError( - "ProviderService.startSession", - `Failed to load provider settings: ${error.message}`, - error, + yield* Effect.annotateCurrentSpan({ + "provider.operation": "start-session", + "provider.kind": input.provider, + "provider.thread_id": threadId, + "provider.runtime_mode": input.runtimeMode, + }); + return yield* Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((error) => + toValidationError( + "ProviderService.startSession", + `Failed to load provider settings: ${error.message}`, + error, + ), ), - ), - ); - if (!settings.providers[input.provider].enabled) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider '${input.provider}' is disabled in T3 Code settings.`, ); - } - const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.provider === input.provider ? persistedBinding.resumeCursor : undefined); - const adapter = yield* registry.getByProvider(input.provider); - const session = yield* adapter.startSession({ - ...input, - ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), - }); + if (!settings.providers[input.provider].enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider '${input.provider}' is disabled in T3 Code settings.`, + ); + } + const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + const effectiveResumeCursor = + input.resumeCursor ?? + (persistedBinding?.provider === input.provider + ? persistedBinding.resumeCursor + : undefined); + const adapter = yield* registry.getByProvider(input.provider); + const session = yield* adapter.startSession({ + ...input, + ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + }); - if (session.provider !== adapter.provider) { - return yield* toValidationError( - "ProviderService.startSession", - `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, - ); - } + if (session.provider !== adapter.provider) { + return yield* toValidationError( + "ProviderService.startSession", + `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, + ); + } - yield* upsertSessionBinding(session, threadId, { - modelSelection: input.modelSelection, - }); - yield* analytics.record("provider.session.started", { - provider: session.provider, - runtimeMode: input.runtimeMode, - hasResumeCursor: session.resumeCursor !== undefined, - hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, - hasModel: - typeof input.modelSelection?.model === "string" && - input.modelSelection.model.trim().length > 0, - }); + yield* upsertSessionBinding(session, threadId, { + modelSelection: input.modelSelection, + }); + yield* analytics.record("provider.session.started", { + provider: session.provider, + runtimeMode: input.runtimeMode, + hasResumeCursor: session.resumeCursor !== undefined, + hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, + hasModel: + typeof input.modelSelection?.model === "string" && + input.modelSelection.model.trim().length > 0, + }); - return session; + return session; + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + attributes: providerMetricAttributes(input.provider, { + operation: "start", + }), + }), + ); }, ); @@ -357,32 +401,61 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "Either input text or at least one attachment is required", ); } - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.sendTurn", - allowRecovery: true, - }); - const turn = yield* routed.adapter.sendTurn(input); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - status: "running", - ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), - runtimePayload: { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - activeTurnId: turn.turnId, - lastRuntimeEvent: "provider.sendTurn", - lastRuntimeEventAt: new Date().toISOString(), - }, - }); - yield* analytics.record("provider.turn.sent", { - provider: routed.adapter.provider, - model: input.modelSelection?.model, - interactionMode: input.interactionMode, - attachmentCount: input.attachments.length, - hasInput: typeof input.input === "string" && input.input.trim().length > 0, + yield* Effect.annotateCurrentSpan({ + "provider.operation": "send-turn", + "provider.thread_id": input.threadId, + "provider.interaction_mode": input.interactionMode, + "provider.attachment_count": input.attachments.length, }); - return turn; + let metricProvider = "unknown"; + let metricModel = input.modelSelection?.model; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.sendTurn", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + metricModel = input.modelSelection?.model; + yield* Effect.annotateCurrentSpan({ + "provider.kind": routed.adapter.provider, + ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), + }); + const turn = yield* routed.adapter.sendTurn(input); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + status: "running", + ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), + runtimePayload: { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + activeTurnId: turn.turnId, + lastRuntimeEvent: "provider.sendTurn", + lastRuntimeEventAt: new Date().toISOString(), + }, + }); + yield* analytics.record("provider.turn.sent", { + provider: routed.adapter.provider, + model: input.modelSelection?.model, + interactionMode: input.interactionMode, + attachmentCount: input.attachments.length, + hasInput: typeof input.input === "string" && input.input.trim().length > 0, + }); + return turn; + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + timer: providerTurnDuration, + attributes: () => + providerTurnMetricAttributes({ + provider: metricProvider, + model: metricModel, + extra: { + operation: "send", + }, + }), + }), + ); }); const interruptTurn: ProviderServiceShape["interruptTurn"] = Effect.fn("interruptTurn")( @@ -392,15 +465,33 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( schema: ProviderInterruptTurnInput, payload: rawInput, }); - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.interruptTurn", - allowRecovery: true, - }); - yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); - yield* analytics.record("provider.turn.interrupted", { - provider: routed.adapter.provider, - }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.interruptTurn", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "interrupt-turn", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.turn_id": input.turnId, + }); + yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + yield* analytics.record("provider.turn.interrupted", { + provider: routed.adapter.provider, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "interrupt", + }), + }), + ); }, ); @@ -411,16 +502,34 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( schema: ProviderRespondToRequestInput, payload: rawInput, }); - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.respondToRequest", - allowRecovery: true, - }); - yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); - yield* analytics.record("provider.request.responded", { - provider: routed.adapter.provider, - decision: input.decision, - }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.respondToRequest", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "respond-to-request", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.request_id": input.requestId, + }); + yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); + yield* analytics.record("provider.request.responded", { + provider: routed.adapter.provider, + decision: input.decision, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "approval-response", + }), + }), + ); }, ); @@ -432,12 +541,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( schema: ProviderRespondToUserInputInput, payload: rawInput, }); - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.respondToUserInput", - allowRecovery: true, - }); - yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.respondToUserInput", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "respond-to-user-input", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.request_id": input.requestId, + }); + yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "user-input-response", + }), + }), + ); }); const stopSession: ProviderServiceShape["stopSession"] = Effect.fn("stopSession")( @@ -447,18 +574,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( schema: ProviderStopSessionInput, payload: rawInput, }); - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.stopSession", - allowRecovery: false, - }); - if (routed.isActive) { - yield* routed.adapter.stopSession(routed.threadId); - } - yield* directory.remove(input.threadId); - yield* analytics.record("provider.session.stopped", { - provider: routed.adapter.provider, - }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.stopSession", + allowRecovery: false, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "stop-session", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + if (routed.isActive) { + yield* routed.adapter.stopSession(routed.threadId); + } + yield* directory.remove(input.threadId); + yield* analytics.record("provider.session.stopped", { + provider: routed.adapter.provider, + }); + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "stop", + }), + }), + ); }, ); @@ -524,16 +668,34 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (input.numTurns === 0) { return; } - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.rollbackConversation", - allowRecovery: true, - }); - yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); - yield* analytics.record("provider.conversation.rolled_back", { - provider: routed.adapter.provider, - turns: input.numTurns, - }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.rollbackConversation", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "rollback-conversation", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.rollback_turns": input.numTurns, + }); + yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); + yield* analytics.record("provider.conversation.rolled_back", { + provider: routed.adapter.provider, + turns: input.numTurns, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "rollback", + }), + }), + ); }); const runStopAll = Effect.fn("runStopAll")(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 53829f37b8..93990322ff 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -139,8 +139,17 @@ const buildAppUnderTest = (options?: { const baseDir = options?.config?.baseDir ?? tempBaseDir; const devUrl = options?.config?.devUrl; const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - const config = { + const config: ServerConfigShape = { logLevel: "Info", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", mode: "web", port: 0, host: "127.0.0.1", @@ -154,7 +163,7 @@ const buildAppUnderTest = (options?: { autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, - } satisfies ServerConfigShape; + }; const layerConfig = Layer.succeed(ServerConfig, config); const appLayer = HttpRouter.serve(makeRoutesLayer, { @@ -533,6 +542,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { } as const; yield* buildAppUnderTest({ + config: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, layers: { keybindings: { loadConfigState: Effect.succeed({ @@ -561,6 +574,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); + assert.equal(first.config.observability.logsDirectoryPath.split(/[\\/]/).at(-1), "logs"); + assert.equal(first.config.observability.localTracingEnabled, true); + assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); + assert.equal(first.config.observability.otlpTracesEnabled, true); + assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); + assert.equal(first.config.observability.otlpMetricsEnabled, true); assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); } assert.deepEqual(second, { @@ -822,6 +841,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { status: () => Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, branch: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, @@ -922,6 +944,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ], isRepo: true, hasOriginRemote: true, + nextCursor: null, + totalCount: 1, }), createWorktree: () => Effect.succeed({ @@ -1188,6 +1212,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("closes thread terminals after a successful archive command", () => + Effect.gen(function* () { + const threadId = ThreadId.makeUnsafe("thread-archive"); + const closeInputs: Array[0]> = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + closeInputs.push(input); + }), + }, + orchestrationEngine: { + dispatch: () => Effect.succeed({ sequence: 8 }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.makeUnsafe("cmd-thread-archive"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 8); + assert.deepEqual(closeInputs, [{ threadId }]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 22511cce9b..bef9e09e03 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,7 +35,6 @@ import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; import { KeybindingsLive } from "./keybindings"; -import { ServerLoggerLive } from "./serverLogger"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; @@ -48,6 +47,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; +import { ObservabilityLive } from "./observability/Layers/Observability"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -253,7 +253,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), Layer.provideMerge(HttpServerLive), - Layer.provide(ServerLoggerLive), + Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(PlatformServicesLive), ); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index aea53aacfb..ea098dcbbe 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -4,11 +4,8 @@ import { ServerConfig } from "./config"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; - const { serverLogPath } = config; - - const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); - const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], { + const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false, }); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 026145eca5..7c9231ac93 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -141,6 +141,8 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( + Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), + Effect.withSpan("server.startup.heartbeat.record"), Effect.ignoreCause({ log: true }), Effect.forkScoped, Effect.asVoid, @@ -248,7 +250,14 @@ const maybeOpenBrowser = Effect.gen(function* () { ); }); +const runStartupPhase = (phase: string, effect: Effect.Effect) => + effect.pipe( + Effect.annotateSpans({ "startup.phase": phase }), + Effect.withSpan(`server.startup.${phase}`), + ); + const makeServerRuntimeStartup = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; const lifecycleEvents = yield* ServerLifecycleEvents; @@ -262,46 +271,65 @@ const makeServerRuntimeStartup = Effect.gen(function* () { const startup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: starting keybindings runtime"); - yield* keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), + yield* runStartupPhase( + "keybindings.start", + keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + Effect.forkScoped, ), - Effect.forkScoped, ); yield* Effect.logDebug("startup phase: starting server settings runtime"); - yield* serverSettings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start server settings runtime", { - path: error.settingsPath, - detail: error.detail, - cause: error.cause, - }), + yield* runStartupPhase( + "settings.start", + serverSettings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start server settings runtime", { + path: error.settingsPath, + detail: error.detail, + cause: error.cause, + }), + ), + Effect.forkScoped, ), - Effect.forkScoped, ); yield* Effect.logDebug("startup phase: starting orchestration reactors"); - yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* runStartupPhase( + "reactors.start", + orchestrationReactor.start().pipe(Scope.provide(reactorScope)), + ); yield* Effect.logDebug("startup phase: preparing welcome payload"); - const welcome = yield* autoBootstrapWelcome; + const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); yield* Effect.logDebug("startup phase: publishing welcome event", { cwd: welcome.cwd, projectName: welcome.projectName, bootstrapProjectId: welcome.bootstrapProjectId, bootstrapThreadId: welcome.bootstrapThreadId, }); - yield* lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: welcome, - }); - }); + yield* runStartupPhase( + "welcome.publish", + lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: welcome, + }), + ); + }).pipe( + Effect.annotateSpans({ + "server.mode": serverConfig.mode, + "server.port": serverConfig.port, + "server.host": serverConfig.host ?? "default", + }), + Effect.withSpan("server.startup", { kind: "server", root: true }), + ); yield* Effect.forkScoped( Effect.gen(function* () { @@ -319,18 +347,21 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("Accepting commands"); yield* commandGate.signalCommandReady; yield* Effect.logDebug("startup phase: waiting for http listener"); - yield* Deferred.await(httpListening); + yield* runStartupPhase("http.wait", Deferred.await(httpListening)); yield* Effect.logDebug("startup phase: publishing ready event"); - yield* lifecycleEvents.publish({ - version: 1, - type: "ready", - payload: { at: new Date().toISOString() }, - }); + yield* runStartupPhase( + "ready.publish", + lifecycleEvents.publish({ + version: 1, + type: "ready", + payload: { at: new Date().toISOString() }, + }), + ); yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); - yield* maybeOpenBrowser; + yield* runStartupPhase("browser.open", maybeOpenBrowser); yield* Effect.logDebug("startup phase: complete"); }), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d276cca2f4..9c58856a8b 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -199,6 +199,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("trims observability settings when updates are applied", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + observability: { + otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + }, + }); + + assert.deepEqual(next.observability, { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("defaults blank binary paths to provider executables", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -225,6 +243,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, providers: { codex: { binaryPath: "/opt/homebrew/bin/codex", @@ -236,6 +258,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, providers: { codex: { binaryPath: "/opt/homebrew/bin/codex", diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 06ecfd0237..e933576dff 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -125,17 +125,19 @@ const makeAnalyticsService = Effect.gen(function* () { } }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); - const record: AnalyticsServiceShape["record"] = Effect.fnUntraced(function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; - - const enqueueResult = yield* enqueueBufferedEvent(event, properties); - if (enqueueResult.dropped) { - yield* Effect.logDebug("analytics buffer full; dropping oldest event", { - size: enqueueResult.size, - event, - }); - } - }); + const record: AnalyticsServiceShape["record"] = Effect.fn("record")( + function* (event, properties) { + if (!telemetryConfig.enabled || !identifier) return; + + const enqueueResult = yield* enqueueBufferedEvent(event, properties); + if (enqueueResult.dropped) { + yield* Effect.logDebug("analytics buffer full; dropping oldest event", { + size: enqueueResult.size, + event, + }); + } + }, + ); yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { disableYield: true, diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cb4dd98175..a6183825ff 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -23,6 +23,11 @@ import { } from "effect"; import { ServerConfig } from "../../config"; +import { + increment, + terminalRestartsTotal, + terminalSessionsTotal, +} from "../../observability/Metrics"; import { runProcess } from "../../processRunner"; import { TerminalCwdError, @@ -1303,6 +1308,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith eventType: "started" | "restarted", ) { yield* stopProcess(session); + yield* Effect.annotateCurrentSpan({ + "terminal.thread_id": session.threadId, + "terminal.id": session.terminalId, + "terminal.event_type": eventType, + "terminal.cwd_basename": path.basename(input.cwd), + }); yield* modifyManagerState((state) => { session.status = "starting"; @@ -1323,45 +1334,49 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith let startedShell: string | null = null; const startResult = yield* Effect.result( - Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); - ptyProcess = spawnResult.process; - startedShell = spawnResult.shellLabel; - - const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - - yield* modifyManagerState((state) => { - session.process = ptyProcess; - session.pid = processPid; - session.status = "running"; - session.updatedAt = new Date().toISOString(); - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; - return [undefined, state] as const; - }); - - yield* publishEvent({ - type: eventType, - threadId: session.threadId, - terminalId: session.terminalId, - createdAt: new Date().toISOString(), - snapshot: snapshot(session), - }); - }), + increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( + Effect.andThen( + Effect.gen(function* () { + const shellCandidates = resolveShellCandidates(shellResolver); + const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + ptyProcess = spawnResult.process; + startedShell = spawnResult.shellLabel; + + const processPid = ptyProcess.pid; + const unsubscribeData = ptyProcess.onData((data) => { + if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + return; + } + runFork(drainProcessEvents(session, processPid)); + }); + const unsubscribeExit = ptyProcess.onExit((event) => { + if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { + return; + } + runFork(drainProcessEvents(session, processPid)); + }); + + yield* modifyManagerState((state) => { + session.process = ptyProcess; + session.pid = processPid; + session.status = "running"; + session.updatedAt = new Date().toISOString(); + session.unsubscribeData = unsubscribeData; + session.unsubscribeExit = unsubscribeExit; + return [undefined, state] as const; + }); + + yield* publishEvent({ + type: eventType, + threadId: session.threadId, + terminalId: session.terminalId, + createdAt: new Date().toISOString(), + snapshot: snapshot(session), + }); + }), + ), + ), ); if (startResult._tag === "Success") { @@ -1736,6 +1751,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith withThreadLock( input.threadId, Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; yield* assertValidCwd(input.cwd); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cff7e26efa..de263ecb92 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -28,6 +28,11 @@ import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { + observeRpcEffect, + observeRpcStream, + observeRpcStreamEffect, +} from "./observability/RpcInstrumentation"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerRuntimeStartup } from "./serverRuntimeStartup"; @@ -67,72 +72,114 @@ const WsRpcLayer = WsRpcGroup.toLayer( issues: keybindingsConfig.issues, providers, availableEditors: resolveAvailableEditors(), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, settings, }; }); return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - projectionSnapshotQuery.getSnapshot().pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, - }), + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getSnapshot, + projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => - Effect.gen(function* () { - const normalizedCommand = yield* normalizeDispatchCommand(command); - return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand)); - }).pipe( - Effect.mapError((cause) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: "Failed to dispatch orchestration command", - cause, - }), + observeRpcEffect( + ORCHESTRATION_WS_METHODS.dispatchCommand, + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand(command); + const result = yield* startup.enqueueCommand( + orchestrationEngine.dispatch(normalizedCommand), + ); + if (normalizedCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } + return result; + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getTurnDiff, + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, - }), + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), + observeRpcEffect( + ORCHESTRATION_WS_METHODS.replayEvents, + Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), + ), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => - Stream.unwrap( + observeRpcStreamEffect( + WS_METHODS.subscribeOrchestrationDomainEvents, Effect.gen(function* () { const snapshot = yield* orchestrationEngine.getReadModel(); const fromSequenceExclusive = snapshot.snapshotSequence; @@ -187,82 +234,167 @@ const WsRpcLayer = WsRpcGroup.toLayer( Stream.flatMap((events) => Stream.fromIterable(events)), ); }), + { "rpc.aggregate": "orchestration" }, ), - [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig, + [WS_METHODS.serverGetConfig]: (_input) => + observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRefreshProviders]: (_input) => - providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + observeRpcEffect( + WS_METHODS.serverRefreshProviders, + providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; + observeRpcEffect( + WS_METHODS.serverUpsertKeybinding, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverGetSettings]: (_input) => + observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { + "rpc.aggregate": "server", + }), + [WS_METHODS.serverUpdateSettings]: ({ patch }) => + observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { + "rpc.aggregate": "server", }), - [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings, - [WS_METHODS.serverUpdateSettings]: ({ patch }) => serverSettings.updateSettings(patch), [WS_METHODS.projectsSearchEntries]: (input) => - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, - cause, - }), + observeRpcEffect( + WS_METHODS.projectsSearchEntries, + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + message: `Failed to search workspace entries: ${cause.detail}`, + cause, + }), + ), ), + { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsWriteFile]: (input) => - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = Schema.is(WorkspacePathOutsideRootError)(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + observeRpcEffect( + WS_METHODS.projectsWriteFile, + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Workspace file path must stay within the project root." + : "Failed to write workspace file"; + return new ProjectWriteFileError({ + message, + cause, + }); + }), + ), + { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input), - [WS_METHODS.gitStatus]: (input) => gitManager.status(input), - [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd), + [WS_METHODS.shellOpenInEditor]: (input) => + observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.gitStatus]: (input) => + observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitPull]: (input) => + observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { + "rpc.aggregate": "git", + }), [WS_METHODS.gitRunStackedAction]: (input) => - Stream.callback((queue) => - gitManager - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), - }), - ), + observeRpcStream( + WS_METHODS.gitRunStackedAction, + Stream.callback((queue) => + gitManager + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), + }), + ), + ), + { "rpc.aggregate": "git" }, ), - [WS_METHODS.gitResolvePullRequest]: (input) => gitManager.resolvePullRequest(input), + [WS_METHODS.gitResolvePullRequest]: (input) => + observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { + "rpc.aggregate": "git", + }), [WS_METHODS.gitPreparePullRequestThread]: (input) => - gitManager.preparePullRequestThread(input), - [WS_METHODS.gitListBranches]: (input) => git.listBranches(input), - [WS_METHODS.gitCreateWorktree]: (input) => git.createWorktree(input), - [WS_METHODS.gitRemoveWorktree]: (input) => git.removeWorktree(input), - [WS_METHODS.gitCreateBranch]: (input) => git.createBranch(input), - [WS_METHODS.gitCheckout]: (input) => Effect.scoped(git.checkoutBranch(input)), - [WS_METHODS.gitInit]: (input) => git.initRepo(input), - [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), - [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), - [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), - [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), - [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), - [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), + observeRpcEffect( + WS_METHODS.gitPreparePullRequestThread, + gitManager.preparePullRequestThread(input), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitListBranches]: (input) => + observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitCreateWorktree]: (input) => + observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitRemoveWorktree]: (input) => + observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitCreateBranch]: (input) => + observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitCheckout]: (input) => + observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitInit]: (input) => + observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), + [WS_METHODS.terminalOpen]: (input) => + observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalWrite]: (input) => + observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalResize]: (input) => + observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClear]: (input) => + observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalRestart]: (input) => + observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClose]: (input) => + observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { + "rpc.aggregate": "terminal", + }), [WS_METHODS.subscribeTerminalEvents]: (_input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), + observeRpcStream( + WS_METHODS.subscribeTerminalEvents, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), ), + { "rpc.aggregate": "terminal" }, ), [WS_METHODS.subscribeServerConfig]: (_input) => - Stream.unwrap( + observeRpcStreamEffect( + WS_METHODS.subscribeServerConfig, Effect.gen(function* () { const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ @@ -297,9 +429,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), ); }), + { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeServerLifecycle]: (_input) => - Stream.unwrap( + observeRpcStreamEffect( + WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( @@ -310,6 +444,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); }), + { "rpc.aggregate": "server" }, ), }); }), @@ -317,9 +452,13 @@ const WsRpcLayer = WsRpcGroup.toLayer( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe( - Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)), - ); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + spanPrefix: "ws.rpc", + spanAttributes: { + "rpc.transport": "websocket", + "rpc.system": "effect-rpc", + }, + }).pipe(Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson))); return HttpRouter.add( "GET", "/ws", diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 99928441c7..c9e336bf48 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,9 @@ import type { GitBranch } from "@t3tools/contracts"; import { Schema } from "effect"; +export { + dedupeRemoteBranchesWithLocalMatches, + deriveLocalBranchNameFromRemoteRef, +} from "@t3tools/shared/git"; export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; @@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } -export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { - const firstSeparatorIndex = branchName.indexOf("/"); - if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { - return branchName; - } - return branchName.slice(firstSeparatorIndex + 1); -} - -function deriveLocalBranchNameCandidatesFromRemoteRef( - branchName: string, - remoteName?: string, -): ReadonlyArray { - const candidates = new Set(); - const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); - if (firstSlashCandidate.length > 0) { - candidates.add(firstSlashCandidate); - } - - if (remoteName) { - const remotePrefix = `${remoteName}/`; - if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { - candidates.add(branchName.slice(remotePrefix.length)); - } - } - - return [...candidates]; -} - -export function dedupeRemoteBranchesWithLocalMatches( - branches: ReadonlyArray, -): ReadonlyArray { - const localBranchNames = new Set( - branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), - ); - - return branches.filter((branch) => { - if (!branch.isRemote) { - return true; - } - - if (branch.remoteName !== "origin") { - return true; - } - - const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( - branch.name, - branch.remoteName, - ); - return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); - }); -} - export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c1a43adc90..e1dbb8756c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import type { GitBranch } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -15,7 +15,7 @@ import { } from "react"; import { - gitBranchesQueryOptions, + gitBranchSearchInfiniteQueryOptions, gitQueryKeys, gitStatusQueryOptions, invalidateGitQueries, @@ -23,7 +23,6 @@ import { import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { - dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, EnvMode, resolveBranchSelectionTarget, @@ -38,6 +37,7 @@ import { ComboboxItem, ComboboxList, ComboboxPopup, + ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; import { toastManager } from "./ui/toast"; @@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const trimmedBranchQuery = branchQuery.trim(); + const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + + useEffect(() => { + if (!branchCwd) return; + void queryClient.prefetchInfiniteQuery( + gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + ); + }, [branchCwd, queryClient]); + + const { + data: branchesSearchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending: isBranchesSearchPending, + } = useInfiniteQuery( + gitBranchSearchInfiniteQueryOptions({ + cwd: branchCwd, + query: deferredTrimmedBranchQuery, + enabled: isBranchMenuOpen, + }), + ); const branches = useMemo( - () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), - [branchesQuery.data?.branches], + () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], + [branchesSearchData?.pages], ); const currentGitBranch = branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; @@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({ () => new Map(branches.map((branch) => [branch.name, branch] as const)), [branches], ); - const trimmedBranchQuery = branchQuery.trim(); - const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); const isSelectingWorktreeBase = @@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({ ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; + const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; + const branchStatusText = isBranchesSearchPending + ? "Loading branches..." + : isFetchingNextPage + ? "Loading more branches..." + : hasNextPage + ? `Showing ${branches.length} of ${totalBranchCount} branches` + : null; const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { @@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({ let nextBranchName = selectedBranchName; if (branch.isRemote) { - const status = await api.git.status({ cwd: branchCwd }).catch(() => null); + const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); if (status?.branch) { nextBranchName = status.branch; } @@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({ ); const branchListScrollElementRef = useRef(null); + const maybeFetchNextBranchPage = useCallback(() => { + if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { + return; + } + + const scrollElement = branchListScrollElementRef.current; + if (!scrollElement) { + return; + } + + const distanceFromBottom = + scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; + if (distanceFromBottom > 96) { + return; + } + + void fetchNextPage().catch(() => undefined); + }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListVirtualizer = useVirtualizer({ count: filteredBranchPickerItems.length, estimateSize: (index) => @@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({ shouldVirtualizeBranchList, ]); + useEffect(() => { + if (!isBranchMenuOpen) { + return; + } + + branchListScrollElementRef.current?.scrollTo({ top: 0 }); + }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); + + useEffect(() => { + const scrollElement = branchListScrollElementRef.current; + if (!scrollElement || !isBranchMenuOpen) { + return; + } + + const handleScroll = () => { + maybeFetchNextBranchPage(); + }; + + scrollElement.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); + return () => { + scrollElement.removeEventListener("scroll", handleScroll); + }; + }, [isBranchMenuOpen, maybeFetchNextBranchPage]); + + useEffect(() => { + maybeFetchNextBranchPage(); + }, [branches.length, maybeFetchNextBranchPage]); + const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, @@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({ } className="text-muted-foreground/70 hover:text-foreground/80" - disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} + disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} > {triggerLabel} @@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({ filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) )} + {branchStatusText ? {branchStatusText} : null} ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 55dd4c1523..388def4fbe 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -135,6 +135,12 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + observability: { + logsDirectoryPath: "/repo/project/.t3/logs", + localTracingEnabled: true, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, settings: { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, @@ -632,6 +638,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { return { isRepo: true, hasOriginRemote: true, + nextCursor: null, + totalCount: 1, branches: [ { name: "main", @@ -644,6 +652,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { } if (tag === WS_METHODS.gitStatus) { return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, branch: "main", hasWorkingTreeChanges: false, workingTree: { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 80c842d91f..8d49bc07f2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,10 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, + reconcileMountedTerminalThreadIds, waitForStartedServerThread, } from "./ChatView.logic"; @@ -75,6 +77,98 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("reconcileMountedTerminalThreadIds", () => { + it("keeps previously mounted open threads and adds the active open thread", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ + ThreadId.makeUnsafe("thread-hidden"), + ThreadId.makeUnsafe("thread-stale"), + ], + openThreadIds: [ThreadId.makeUnsafe("thread-hidden"), ThreadId.makeUnsafe("thread-active")], + activeThreadId: ThreadId.makeUnsafe("thread-active"), + activeThreadTerminalOpen: true, + }), + ).toEqual([ThreadId.makeUnsafe("thread-hidden"), ThreadId.makeUnsafe("thread-active")]); + }); + + it("drops mounted threads once their terminal drawer is no longer open", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ThreadId.makeUnsafe("thread-closed")], + openThreadIds: [], + activeThreadId: ThreadId.makeUnsafe("thread-closed"), + activeThreadTerminalOpen: false, + }), + ).toEqual([]); + }); + + it("keeps only the most recently active hidden terminal threads", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ], + openThreadIds: [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ThreadId.makeUnsafe("thread-4"), + ], + activeThreadId: ThreadId.makeUnsafe("thread-4"), + activeThreadTerminalOpen: true, + maxHiddenThreadCount: 2, + }), + ).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ThreadId.makeUnsafe("thread-4"), + ]); + }); + + it("moves the active thread to the end so it is treated as most recently used", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ + ThreadId.makeUnsafe("thread-a"), + ThreadId.makeUnsafe("thread-b"), + ThreadId.makeUnsafe("thread-c"), + ], + openThreadIds: [ + ThreadId.makeUnsafe("thread-a"), + ThreadId.makeUnsafe("thread-b"), + ThreadId.makeUnsafe("thread-c"), + ], + activeThreadId: ThreadId.makeUnsafe("thread-a"), + activeThreadTerminalOpen: true, + maxHiddenThreadCount: 2, + }), + ).toEqual([ + ThreadId.makeUnsafe("thread-b"), + ThreadId.makeUnsafe("thread-c"), + ThreadId.makeUnsafe("thread-a"), + ]); + }); + + it("defaults to the hidden mounted terminal cap", () => { + const currentThreadIds = Array.from( + { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, + (_, index) => ThreadId.makeUnsafe(`thread-${index + 1}`), + ); + + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: currentThreadIds, + activeThreadId: null, + activeThreadTerminalOpen: false, + }), + ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); + }); +}); + const makeThread = (input?: { id?: ThreadId; latestTurn?: { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 63b5721f74..37d238c02c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -19,6 +19,7 @@ import { } from "../lib/terminalContext"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; +export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; const WORKTREE_BRANCH_PREFIX = "t3code"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -51,6 +52,37 @@ export function buildLocalDraftThread( }; } +export function reconcileMountedTerminalThreadIds(input: { + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: ThreadId | null; + activeThreadTerminalOpen: boolean; + maxHiddenThreadCount?: number; +}): ThreadId[] { + const openThreadIdSet = new Set(input.openThreadIds); + const hiddenThreadIds = input.currentThreadIds.filter( + (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), + ); + const maxHiddenThreadCount = Math.max( + 0, + input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + ); + const nextThreadIds = + hiddenThreadIds.length > maxHiddenThreadCount + ? hiddenThreadIds.slice(-maxHiddenThreadCount) + : hiddenThreadIds; + + if ( + input.activeThreadId && + input.activeThreadTerminalOpen && + !nextThreadIds.includes(input.activeThreadId) + ) { + nextThreadIds.push(input.activeThreadId); + } + + return nextThreadIds; +} + export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1af33577d9..78d3e3cc71 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -173,6 +173,7 @@ import { import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, @@ -186,6 +187,7 @@ import { type LocalDispatchSnapshot, PullRequestDialogState, readFileAsDataUrl, + reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, threadHasStarted, @@ -404,6 +406,178 @@ function useLocalDispatchState(input: { }; } +interface PersistentThreadTerminalDrawerProps { + threadId: ThreadId; + visible: boolean; + focusRequestId: number; + splitShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +function PersistentThreadTerminalDrawer({ + threadId, + visible, + focusRequestId, + splitShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const serverThread = useThreadById(threadId); + const draftThread = useComposerDraftStore( + (store) => store.draftThreadsByThreadId[threadId] ?? null, + ); + const projectId = serverThread ? serverThread.projectId : draftThread?.projectId; + const project = useProjectById(projectId); + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadId, threadId), + ); + const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); + const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread + ? serverThread.worktreePath + : (draftThread?.worktreePath ?? null); + const cwd = useMemo( + () => + project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath, + }) + : null, + [project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadId, height); + }, + [storeSetTerminalHeight, threadId], + ); + + const activeDrawerTerminalGroup = + terminalState.terminalGroups.find( + (group) => group.id === terminalState.activeTerminalGroupId, + ) ?? + terminalState.terminalGroups.find((group) => + group.terminalIds.includes(terminalState.activeTerminalId), + ) ?? + null; + const hasReachedDrawerSplitLimit = + (activeDrawerTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; + + const splitTerminal = useCallback(() => { + if (hasReachedDrawerSplitLimit) return; + storeSplitTerminal(threadId, `terminal-${randomUUID()}`); + bumpFocusRequestId(); + }, [bumpFocusRequestId, hasReachedDrawerSplitLimit, storeSplitTerminal, threadId]); + + const createNewTerminal = useCallback(() => { + storeNewTerminal(threadId, `terminal-${randomUUID()}`); + bumpFocusRequestId(); + }, [bumpFocusRequestId, storeNewTerminal, threadId]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadId, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadId], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + const api = readNativeApi(); + if (!api) return; + const isFinalTerminal = terminalState.terminalIds.length <= 1; + const fallbackExitWrite = () => + api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); + + if ("close" in api.terminal && typeof api.terminal.close === "function") { + void (async () => { + if (isFinalTerminal) { + await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); + } + await api.terminal.close({ + threadId, + terminalId, + deleteHistory: true, + }); + })().catch(() => fallbackExitWrite()); + } else { + void fallbackExitWrite(); + } + + storeCloseTerminal(threadId, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || !terminalState.terminalOpen || !cwd) { + return null; + } + + return ( +
+ +
+ ); +} + export default function ChatView({ threadId }: ChatViewProps) { const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); @@ -565,15 +739,31 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), + const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); + const terminalState = useMemo( + () => selectThreadTerminalState(terminalStateByThreadId, threadId), + [terminalStateByThreadId, threadId], + ); + const openTerminalThreadIds = useMemo( + () => + Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + ), + [terminalStateByThreadId], ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const threads = useStore((state) => state.threads); + const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); + const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); + const draftThreadIds = useMemo( + () => Object.keys(draftThreadsByThreadId) as ThreadId[], + [draftThreadsByThreadId], + ); + const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { @@ -655,6 +845,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; + const existingOpenTerminalThreadIds = useMemo(() => { + const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); + return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); + }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -673,6 +867,21 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), [activeThread?.activities], ); + useLayoutEffect(() => { + setMountedTerminalThreadIds((currentThreadIds) => { + const nextThreadIds = reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: existingOpenTerminalThreadIds, + activeThreadId, + activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); + return currentThreadIds.length === nextThreadIds.length && + currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) + ? currentThreadIds + : nextThreadIds; + }); + }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = useProjectById(activeThread?.projectId); @@ -1202,7 +1411,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); + const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( @@ -1334,19 +1543,8 @@ export default function ChatView({ threadId }: ChatViewProps) { () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); - const activeProjectCwd = activeProject?.cwd ?? null; - const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const threadTerminalRuntimeEnv = useMemo(() => { - if (!activeProjectCwd) return {}; - return projectScriptRuntimeEnv({ - project: { - cwd: activeProjectCwd, - }, - worktreePath: activeThreadWorktreePath, - }); - }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. - const isGitRepo = branchesQuery.data?.isRepo ?? true; + const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -1489,13 +1687,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, storeSetTerminalOpen], ); - const setTerminalHeight = useCallback( - (height: number) => { - if (!activeThreadId) return; - storeSetTerminalHeight(activeThreadId, height); - }, - [activeThreadId, storeSetTerminalHeight], - ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; setTerminalOpen(!terminalState.terminalOpen); @@ -1512,14 +1703,6 @@ export default function ChatView({ threadId }: ChatViewProps) { storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeNewTerminal]); - const activateTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadId) return; - storeSetActiveTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeSetActiveTerminal], - ); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); @@ -2001,13 +2184,13 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { + if (scrolledUp && !isNearBottom) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { + if (scrolledUp && !isNearBottom) { shouldAutoScrollRef.current = false; } } else if (shouldAutoScrollRef.current && !isNearBottom) { @@ -4293,34 +4476,21 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* end horizontal flex container */} - {(() => { - if (!terminalState.terminalOpen || !activeProject) { - return null; - } - return ( - - ); - })()} + {mountedTerminalThreadIds.map((mountedThreadId) => ( + + ))} {expandedImage && expandedImageItem && (
project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); - const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; + const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index a46b552a30..267cbec180 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -13,6 +13,9 @@ import { function status(overrides: Partial = {}): GitStatusResult { return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, branch: "feature/test", hasWorkingTreeChanges: false, workingTree: { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index efda19d527..03fe58185f 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -43,14 +43,12 @@ import { Textarea } from "~/components/ui/textarea"; import { toastManager, type ThreadToastData } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; import { - gitBranchesQueryOptions, gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, gitStatusQueryOptions, invalidateGitStatusQuery, - invalidateGitQueries, } from "~/lib/gitReactQuery"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -289,21 +287,10 @@ export default function GitActionsControl({ ); const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); - - const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); // Default to true while loading so we don't flash init controls. - const isRepo = branchList?.isRepo ?? true; - const hasOriginRemote = branchList?.hasOriginRemote ?? true; - const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; - const isGitStatusOutOfSync = - !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; - - useEffect(() => { - if (!isGitStatusOutOfSync) return; - void invalidateGitQueries(queryClient, { cwd: gitCwd }); - }, [gitCwd, isGitStatusOutOfSync, queryClient]); - - const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; + const isRepo = gitStatus?.isRepo ?? true; + const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; + const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); @@ -347,11 +334,8 @@ export default function GitActionsControl({ ]); const isDefaultBranch = useMemo(() => { - const branchName = gitStatusForActions?.branch; - if (!branchName) return false; - const current = branchList?.branches.find((branch) => branch.name === branchName); - return current?.isDefault ?? (branchName === "main" || branchName === "master"); - }, [branchList?.branches, gitStatusForActions?.branch]); + return gitStatusForActions?.isDefaultBranch ?? false; + }, [gitStatusForActions?.isDefaultBranch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), @@ -892,11 +876,6 @@ export default function GitActionsControl({ Behind upstream. Pull/rebase first.

)} - {isGitStatusOutOfSync && ( -

- Refreshing git status... -

- )} {gitStatusError && (

{gitStatusError.message}

)} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 6bb00bec0a..b22c1aeb0c 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -407,6 +407,107 @@ export const AntigravityIcon: Icon = (props) => ( ); +export const IntelliJIdeaIcon: Icon = (props) => { + const id = useId(); + const gradientAId = `${id}-idea-a`; + const gradientBId = `${id}-idea-b`; + const gradientCId = `${id}-idea-c`; + const gradientDId = `${id}-idea-d`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + export const OpenCodeIcon: Icon = ({ monochrome, ...props }) => ( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 23625bdd33..f0b2ea89fa 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -1,6 +1,7 @@ import "../index.css"; import { + DEFAULT_SERVER_SETTINGS, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -56,7 +57,14 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + observability: { + logsDirectoryPath: "/repo/project/.t3/logs", + localTracingEnabled: true, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, settings: { + ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, @@ -163,11 +171,16 @@ function resolveWsRpc(tag: string): unknown { return { isRepo: true, hasOriginRemote: true, + nextCursor: null, + totalCount: 1, branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } if (tag === WS_METHODS.gitStatus) { return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, branch: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fbe4e0528a..89ced46454 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ import { type GitStatusResult, } from "@t3tools/contracts"; import { useQueries } from "@tanstack/react-query"; -import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -1970,7 +1970,11 @@ export default function Sidebar() { + Code @@ -1978,7 +1982,7 @@ export default function Sidebar() { {APP_STAGE_LABEL} -
+ } /> diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 4d59583777..d9740cc305 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { resolveTerminalSelectionActionPosition, + selectPendingTerminalEventEntries, + selectTerminalEventEntriesAfterSnapshot, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, } from "./ThreadTerminalDrawer"; @@ -72,4 +74,64 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); + + it("replays only terminal events newer than the open snapshot", () => { + expect( + selectTerminalEventEntriesAfterSnapshot( + [ + { + id: 1, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:00.000Z", + type: "output", + data: "before", + }, + }, + { + id: 2, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:01.000Z", + type: "output", + data: "after", + }, + }, + ], + "2026-04-02T20:00:00.500Z", + ).map((entry) => entry.id), + ).toEqual([2]); + }); + + it("applies only terminal events that have not already been consumed", () => { + expect( + selectPendingTerminalEventEntries( + [ + { + id: 1, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:00.000Z", + type: "output", + data: "one", + }, + }, + { + id: 2, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:01.000Z", + type: "output", + data: "two", + }, + }, + ], + 1, + ).map((entry) => entry.id), + ).toEqual([2]); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8d5057944c..0ba15dd856 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,12 +1,17 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; -import { type ThreadId } from "@t3tools/contracts"; +import { + type TerminalEvent, + type TerminalSessionSnapshot, + type ThreadId, +} from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -33,6 +38,7 @@ import { } from "../accentColor"; import { readNativeApi } from "~/nativeApi"; import { resolveTerminalFontFamily } from "../lib/terminalFont"; +import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -72,6 +78,27 @@ function mixHexWithWhite(hex: string, ratio: number): string { return `#${mr.toString(16).padStart(2, "0")}${mg.toString(16).padStart(2, "0")}${mb.toString(16).padStart(2, "0")}`; } +function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { + terminal.write("\u001bc"); + if (snapshot.history.length > 0) { + terminal.write(snapshot.history); + } +} + +export function selectTerminalEventEntriesAfterSnapshot( + entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, + snapshotUpdatedAt: string, +): ReadonlyArray<{ id: number; event: TerminalEvent }> { + return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt); +} + +export function selectPendingTerminalEventEntries( + entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, + lastAppliedTerminalEventId: number, +): ReadonlyArray<{ id: number; event: TerminalEvent }> { + return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -247,27 +274,21 @@ function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); - const onSessionExitedRef = useRef(onSessionExited); - const onAddTerminalContextRef = useRef(onAddTerminalContext); - const terminalLabelRef = useRef(terminalLabel); const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); - - useEffect(() => { - onSessionExitedRef.current = onSessionExited; - }, [onSessionExited]); - - useEffect(() => { - onAddTerminalContextRef.current = onAddTerminalContext; - }, [onAddTerminalContext]); - - useEffect(() => { - terminalLabelRef.current = terminalLabel; - }, [terminalLabel]); + const lastAppliedTerminalEventIdRef = useRef(0); + const terminalHydratedRef = useRef(false); + const handleSessionExited = useEffectEvent(() => { + onSessionExited(); + }); + const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { + onAddTerminalContext(selection); + }); + const readTerminalLabel = useEffectEvent(() => terminalLabel); useEffect(() => { const mount = containerRef.current; @@ -334,7 +355,7 @@ function TerminalViewport({ position, selection: { terminalId, - terminalLabel: terminalLabelRef.current, + terminalLabel: readTerminalLabel(), lineStart, lineEnd, text: normalizedText, @@ -361,7 +382,7 @@ function TerminalViewport({ if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { return; } - onAddTerminalContextRef.current(nextAction.selection); + handleAddTerminalContext(nextAction.selection); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); } finally { @@ -506,43 +527,15 @@ function TerminalViewport({ attributeFilter: ["class", "style"], }); - const openTerminal = async () => { - try { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - activeFitAddon.fit(); - const snapshot = await api.terminal.open({ - threadId, - terminalId, - cwd, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv ? { env: runtimeEnv } : {}), - }); - if (disposed) return; - activeTerminal.write("\u001bc"); - if (snapshot.history.length > 0) { - activeTerminal.write(snapshot.history); - } - if (autoFocus) { - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); - } - } catch (err) { - if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); + const applyTerminalEvent = (event: TerminalEvent) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) { + return; } - }; - const unsubscribe = api?.terminal.onEvent((event) => { - if (event.threadId !== threadId || event.terminalId !== terminalId) return; - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; + if (event.type === "activity") { + return; + } if (event.type === "output") { activeTerminal.write(event.data); @@ -553,10 +546,7 @@ function TerminalViewport({ if (event.type === "started" || event.type === "restarted") { hasHandledExitRef.current = false; clearSelectionAction(); - activeTerminal.write("\u001bc"); - if (event.snapshot.history.length > 0) { - activeTerminal.write(event.snapshot.history); - } + writeTerminalSnapshot(activeTerminal, event.snapshot); return; } @@ -572,30 +562,121 @@ function TerminalViewport({ return; } - if (event.type === "exited") { - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); - if (hasHandledExitRef.current) { + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((value): value is string => value !== null) + .join(", "); + writeSystemMessage( + activeTerminal, + details.length > 0 ? `Process exited (${details})` : "Process exited", + ); + if (hasHandledExitRef.current) { + return; + } + hasHandledExitRef.current = true; + window.setTimeout(() => { + if (!hasHandledExitRef.current) { return; } - hasHandledExitRef.current = true; - window.setTimeout(() => { - if (!hasHandledExitRef.current) { - return; - } - onSessionExitedRef.current(); - }, 0); + handleSessionExited(); + }, 0); + }; + const applyPendingTerminalEvents = ( + terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>, + ) => { + const pendingEntries = selectPendingTerminalEventEntries( + terminalEventEntries, + lastAppliedTerminalEventIdRef.current, + ); + if (pendingEntries.length === 0) { + return; } + for (const entry of pendingEntries) { + applyTerminalEvent(entry.event); + } + lastAppliedTerminalEventIdRef.current = + pendingEntries.at(-1)?.id ?? lastAppliedTerminalEventIdRef.current; + }; + + const unsubscribeTerminalEvents = useTerminalStateStore.subscribe((state, previousState) => { + if (!terminalHydratedRef.current) { + return; + } + + const previousLastEntryId = + selectTerminalEventEntries( + previousState.terminalEventEntriesByKey, + threadId, + terminalId, + ).at(-1)?.id ?? 0; + const nextEntries = selectTerminalEventEntries( + state.terminalEventEntriesByKey, + threadId, + terminalId, + ); + const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; + if (nextLastEntryId === previousLastEntryId) { + return; + } + + applyPendingTerminalEvents(nextEntries); }); + const openTerminal = async () => { + try { + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + activeFitAddon.fit(); + const snapshot = await api.terminal.open({ + threadId, + terminalId, + cwd, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }); + if (disposed) return; + writeTerminalSnapshot(activeTerminal, snapshot); + const bufferedEntries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + threadId, + terminalId, + ); + const replayEntries = selectTerminalEventEntriesAfterSnapshot( + bufferedEntries, + snapshot.updatedAt, + ); + for (const entry of replayEntries) { + applyTerminalEvent(entry.event); + } + lastAppliedTerminalEventIdRef.current = bufferedEntries.at(-1)?.id ?? 0; + terminalHydratedRef.current = true; + // Catch-up: apply any events that arrived during hydration + const catchUpEntries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + threadId, + terminalId, + ); + if (catchUpEntries.length > 0) { + applyPendingTerminalEvents(catchUpEntries); + } + if (autoFocus) { + window.requestAnimationFrame(() => { + activeTerminal.focus(); + }); + } + } catch (err) { + if (disposed) return; + writeSystemMessage( + terminal, + err instanceof Error ? err.message : "Failed to open terminal", + ); + } + }; + const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; @@ -619,8 +700,10 @@ function TerminalViewport({ return () => { disposed = true; + terminalHydratedRef.current = false; + lastAppliedTerminalEventIdRef.current = 0; + unsubscribeTerminalEvents(); window.clearTimeout(fitTimer); - unsubscribe(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -684,6 +767,7 @@ interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; runtimeEnv?: Record; + visible?: boolean; height: number; terminalIds: string[]; activeTerminalId: string; @@ -734,6 +818,7 @@ export default function ThreadTerminalDrawer({ threadId, cwd, runtimeEnv, + visible = true, height, terminalIds, activeTerminalId, @@ -948,6 +1033,10 @@ export default function ThreadTerminalDrawer({ ); useEffect(() => { + if (!visible) { + return; + } + const onWindowResize = () => { const clampedHeight = clampDrawerHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; @@ -964,7 +1053,14 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight]); + }, [syncHeight, visible]); + + useEffect(() => { + if (!visible) { + return; + } + setResizeEpoch((value) => value + 1); + }, [visible]); useEffect(() => { return () => { diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index d45b3239bb..b6768f74e0 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -6,7 +6,15 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; -import { AntigravityIcon, CursorIcon, Icon, TraeIcon, VisualStudioCode, Zed } from "../Icons"; +import { + AntigravityIcon, + CursorIcon, + Icon, + TraeIcon, + IntelliJIdeaIcon, + VisualStudioCode, + Zed, +} from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; @@ -47,6 +55,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray { + beforeEach(() => { + resetServerStateForTests(); + __resetNativeApiForTests(); + localStorage.clear(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + resetServerStateForTests(); + __resetNativeApiForTests(); + document.body.innerHTML = ""; + }); + + it("shows diagnostics inside About with a single logs-folder action", async () => { + setServerConfigSnapshot(createBaseServerConfig()); + + await render( + + + , + ); + + await expect.element(page.getByText("About")).toBeInTheDocument(); + await expect.element(page.getByText("Diagnostics")).toBeInTheDocument(); + await expect.element(page.getByText("Open logs folder")).toBeInTheDocument(); + await expect + .element(page.getByText("/repo/project/.t3/logs", { exact: true })) + .toBeInTheDocument(); + await expect + .element( + page.getByText( + "Local trace file. OTLP exporting traces to http://localhost:4318/v1/traces.", + ), + ) + .toBeInTheDocument(); + }); + + it("opens the logs folder in the preferred editor", async () => { + const openInEditor = vi.fn().mockResolvedValue(undefined); + window.nativeApi = { + shell: { + openInEditor, + }, + } as unknown as NativeApi; + + setServerConfigSnapshot(createBaseServerConfig()); + + await render( + + + , + ); + + const openLogsButton = page.getByText("Open logs folder"); + await openLogsButton.click(); + + expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 906614c789..4451cf2b9f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -62,6 +62,7 @@ import { ProjectFavicon } from "../ProjectFavicon"; import { useServerAvailableEditors, useServerKeybindingsConfigPath, + useServerObservability, useServerProviders, } from "../../rpc/serverState"; @@ -518,8 +519,13 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [openingPathByTarget, setOpeningPathByTarget] = useState({ + keybindings: false, + logsDirectory: false, + }); + const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< + Partial> + >({}); const [openProviderDetails, setOpenProviderDetails] = useState>({ codex: Boolean( settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || @@ -573,8 +579,21 @@ export function GeneralSettingsPanel() { const keybindingsConfigPath = useServerKeybindingsConfigPath(); const availableEditors = useServerAvailableEditors(); + const observability = useServerObservability(); const serverProviders = useServerProviders(); const codexHomePath = settings.providers.codex.homePath; + const logsDirectoryPath = observability?.logsDirectoryPath ?? null; + const diagnosticsDescription = (() => { + const exports: string[] = []; + if (observability?.otlpTracesEnabled && observability.otlpTracesUrl) { + exports.push(`traces to ${observability.otlpTracesUrl}`); + } + if (observability?.otlpMetricsEnabled && observability.otlpMetricsUrl) { + exports.push(`metrics to ${observability.otlpMetricsUrl}`); + } + const mode = observability?.localTracingEnabled ? "Local trace file" : "Terminal logs only"; + return exports.length > 0 ? `${mode}. OTLP exporting ${exports.join(" and ")}.` : `${mode}.`; + })(); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenProvider = textGenerationModelSelection.provider; @@ -591,27 +610,49 @@ export function GeneralSettingsPanel() { DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const openInPreferredEditor = useCallback( + (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { + if (!path) return; + setOpenPathErrorByTarget((existing) => ({ ...existing, [target]: null })); + setOpeningPathByTarget((existing) => ({ ...existing, [target]: true })); + + const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + if (!editor) { + setOpenPathErrorByTarget((existing) => ({ + ...existing, + [target]: "No available editors found.", + })); + setOpeningPathByTarget((existing) => ({ ...existing, [target]: false })); + return; + } + + void ensureNativeApi() + .shell.openInEditor(path, editor) + .catch((error) => { + setOpenPathErrorByTarget((existing) => ({ + ...existing, + [target]: error instanceof Error ? error.message : failureMessage, + })); + }) + .finally(() => { + setOpeningPathByTarget((existing) => ({ ...existing, [target]: false })); + }); + }, + [availableEditors], + ); + const openKeybindingsFile = useCallback(() => { - if (!keybindingsConfigPath) return; - setOpenKeybindingsError(null); - setIsOpeningKeybindings(true); - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); - if (!editor) { - setOpenKeybindingsError("No available editors found."); - setIsOpeningKeybindings(false); - return; - } - void ensureNativeApi() - .shell.openInEditor(keybindingsConfigPath, editor) - .catch((error) => { - setOpenKeybindingsError( - error instanceof Error ? error.message : "Unable to open keybindings file.", - ); - }) - .finally(() => { - setIsOpeningKeybindings(false); - }); - }, [availableEditors, keybindingsConfigPath]); + openInPreferredEditor("keybindings", keybindingsConfigPath, "Unable to open keybindings file."); + }, [keybindingsConfigPath, openInPreferredEditor]); + + const openLogsDirectory = useCallback(() => { + openInPreferredEditor("logsDirectory", logsDirectoryPath, "Unable to open logs folder."); + }, [logsDirectoryPath, openInPreferredEditor]); + + const openKeybindingsError = openPathErrorByTarget.keybindings ?? null; + const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; + const isOpeningKeybindings = openingPathByTarget.keybindings; + const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; const addCustomModel = useCallback( (provider: ProviderKind) => { @@ -1419,6 +1460,30 @@ export function GeneralSettingsPanel() { description="Current version of the application." /> )} + + + {logsDirectoryPath ?? "Resolving logs directory..."} + + {openDiagnosticsError ? ( + {openDiagnosticsError} + ) : null} + + } + control={ + + } + /> ); diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index be644d8ff6..d260c2aee8 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({ getWsRpcClient: vi.fn(), })); +import type { InfiniteData } from "@tanstack/react-query"; +import type { GitListBranchesResult } from "@t3tools/contracts"; + import { - gitBranchesQueryOptions, + gitBranchSearchInfiniteQueryOptions, gitMutationKeys, gitQueryKeys, gitPreparePullRequestThreadMutationOptions, @@ -21,6 +24,19 @@ import { invalidateGitQueries, } from "./gitReactQuery"; +const BRANCH_QUERY_RESULT: GitListBranchesResult = { + branches: [], + isRepo: true, + hasOriginRemote: true, + nextCursor: null, + totalCount: 0, +}; + +const BRANCH_SEARCH_RESULT: InfiniteData = { + pages: [BRANCH_QUERY_RESULT], + pageParams: [0], +}; + describe("gitMutationKeys", () => { it("scopes stacked action keys by cwd", () => { expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( @@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => { const queryClient = new QueryClient(); queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); + queryClient.setQueryData( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/a", + query: "feature", + }).queryKey, + BRANCH_SEARCH_RESULT, + ); queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); - queryClient.setQueryData(gitQueryKeys.branches("/repo/b"), { ok: "b-branches" }); + queryClient.setQueryData( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/b", + query: "feature", + }).queryKey, + BRANCH_SEARCH_RESULT, + ); await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); @@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => { queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, ).toBe(true); expect( - queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, + queryClient.getQueryState( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/a", + query: "feature", + }).queryKey, + )?.isInvalidated, ).toBe(true); expect( queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, ).toBe(false); expect( - queryClient.getQueryState(gitBranchesQueryOptions("/repo/b").queryKey)?.isInvalidated, + queryClient.getQueryState( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/b", + query: "feature", + }).queryKey, + )?.isInvalidated, ).toBe(false); }); }); @@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => { const queryClient = new QueryClient(); queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); await invalidateGitStatusQuery(queryClient, "/repo/a"); @@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => { expect( queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, ).toBe(true); - expect( - queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(false); expect( queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, ).toBe(false); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 25411db7bd..53f04848c7 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,5 +1,10 @@ import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; -import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + mutationOptions, + queryOptions, + type QueryClient, +} from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; import { getWsRpcClient } from "../wsRpcClient"; @@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000; const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; +const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, status: (cwd: string | null) => ["git", "status", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, + branchSearch: (cwd: string | null, query: string) => + ["git", "branches", cwd, "search", query] as const, }; export const gitMutationKeys = { @@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) { }); } -export function gitBranchesQueryOptions(cwd: string | null) { - return queryOptions({ - queryKey: gitQueryKeys.branches(cwd), - queryFn: async () => { +export function gitBranchSearchInfiniteQueryOptions(input: { + cwd: string | null; + query: string; + enabled?: boolean; +}) { + const normalizedQuery = input.query.trim(); + + return infiniteQueryOptions({ + queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), + initialPageParam: 0, + queryFn: async ({ pageParam }) => { const api = ensureNativeApi(); - if (!cwd) throw new Error("Git branches are unavailable."); - return api.git.listBranches({ cwd }); + if (!input.cwd) throw new Error("Git branches are unavailable."); + return api.git.listBranches({ + cwd: input.cwd, + ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), + cursor: pageParam, + limit: GIT_BRANCHES_PAGE_SIZE, + }); }, - enabled: cwd !== null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + enabled: input.cwd !== null && (input.enabled ?? true), staleTime: GIT_BRANCHES_STALE_TIME_MS, refetchOnWindowFocus: true, refetchOnReconnect: true, diff --git a/apps/web/src/lib/terminalStateCleanup.test.ts b/apps/web/src/lib/terminalStateCleanup.test.ts index 8bc3c37300..faf2c477cb 100644 --- a/apps/web/src/lib/terminalStateCleanup.test.ts +++ b/apps/web/src/lib/terminalStateCleanup.test.ts @@ -9,8 +9,8 @@ describe("collectActiveTerminalThreadIds", () => { it("retains non-deleted server threads", () => { const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ - { id: threadId("server-1"), deletedAt: null }, - { id: threadId("server-2"), deletedAt: null }, + { id: threadId("server-1"), deletedAt: null, archivedAt: null }, + { id: threadId("server-2"), deletedAt: null, archivedAt: null }, ], draftThreadIds: [], }); @@ -18,15 +18,41 @@ describe("collectActiveTerminalThreadIds", () => { expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")])); }); - it("ignores deleted server threads and keeps local draft threads", () => { + it("ignores deleted and archived server threads and keeps local draft threads", () => { const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ - { id: threadId("server-active"), deletedAt: null }, - { id: threadId("server-deleted"), deletedAt: "2026-03-05T08:00:00.000Z" }, + { id: threadId("server-active"), deletedAt: null, archivedAt: null }, + { + id: threadId("server-deleted"), + deletedAt: "2026-03-05T08:00:00.000Z", + archivedAt: null, + }, + { + id: threadId("server-archived"), + deletedAt: null, + archivedAt: "2026-03-05T09:00:00.000Z", + }, ], draftThreadIds: [threadId("local-draft")], }); expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")])); }); + + it("does not keep draft-linked terminal state for archived server threads", () => { + const archivedThreadId = threadId("server-archived"); + + const activeThreadIds = collectActiveTerminalThreadIds({ + snapshotThreads: [ + { + id: archivedThreadId, + deletedAt: null, + archivedAt: "2026-03-05T09:00:00.000Z", + }, + ], + draftThreadIds: [archivedThreadId, threadId("local-draft")], + }); + + expect(activeThreadIds).toEqual(new Set([threadId("local-draft")])); + }); }); diff --git a/apps/web/src/lib/terminalStateCleanup.ts b/apps/web/src/lib/terminalStateCleanup.ts index 5f2bfdafaa..f11b30af92 100644 --- a/apps/web/src/lib/terminalStateCleanup.ts +++ b/apps/web/src/lib/terminalStateCleanup.ts @@ -3,6 +3,7 @@ import type { ThreadId } from "@t3tools/contracts"; interface TerminalRetentionThread { id: ThreadId; deletedAt: string | null; + archivedAt: string | null; } interface CollectActiveTerminalThreadIdsInput { @@ -14,11 +15,20 @@ export function collectActiveTerminalThreadIds( input: CollectActiveTerminalThreadIdsInput, ): Set { const activeThreadIds = new Set(); + const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread])); for (const thread of input.snapshotThreads) { if (thread.deletedAt !== null) continue; + if (thread.archivedAt !== null) continue; activeThreadIds.add(thread.id); } for (const draftThreadId of input.draftThreadIds) { + const snapshotThread = snapshotThreadById.get(draftThreadId); + if ( + snapshotThread && + (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) + ) { + continue; + } activeThreadIds.add(draftThreadId); } return activeThreadIds; diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 263610bb95..9829ba9455 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -42,6 +42,7 @@ describe("deriveOrchestrationBatchEffects", () => { it("targets draft promotion and terminal cleanup from thread lifecycle events", () => { const createdThreadId = ThreadId.makeUnsafe("thread-created"); const deletedThreadId = ThreadId.makeUnsafe("thread-deleted"); + const archivedThreadId = ThreadId.makeUnsafe("thread-archived"); const effects = deriveOrchestrationBatchEffects([ makeEvent("thread.created", { @@ -60,11 +61,16 @@ describe("deriveOrchestrationBatchEffects", () => { threadId: deletedThreadId, deletedAt: "2026-02-27T00:00:01.000Z", }), + makeEvent("thread.archived", { + threadId: archivedThreadId, + archivedAt: "2026-02-27T00:00:02.000Z", + updatedAt: "2026-02-27T00:00:02.000Z", + }), ]); expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); - expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId]); + expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); expect(effects.needsProviderInvalidation).toBe(false); }); @@ -105,4 +111,24 @@ describe("deriveOrchestrationBatchEffects", () => { expect(effects.removeTerminalStateThreadIds).toEqual([]); expect(effects.needsProviderInvalidation).toBe(true); }); + + it("does not retain archive cleanup when a thread is unarchived later in the same batch", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + + const effects = deriveOrchestrationBatchEffects([ + makeEvent("thread.archived", { + threadId, + archivedAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + makeEvent("thread.unarchived", { + threadId, + updatedAt: "2026-02-27T00:00:02.000Z", + }), + ]); + + expect(effects.clearPromotedDraftThreadIds).toEqual([]); + expect(effects.clearDeletedThreadIds).toEqual([]); + expect(effects.removeTerminalStateThreadIds).toEqual([]); + }); }); diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts index 166deba52a..d33382c4b1 100644 --- a/apps/web/src/orchestrationEventEffects.ts +++ b/apps/web/src/orchestrationEventEffects.ts @@ -47,6 +47,28 @@ export function deriveOrchestrationBatchEffects( break; } + case "thread.archived": { + const existingArchived = threadLifecycleEffects.get(event.payload.threadId); + threadLifecycleEffects.set(event.payload.threadId, { + ...existingArchived, + clearPromotedDraft: existingArchived?.clearPromotedDraft ?? false, + clearDeletedThread: existingArchived?.clearDeletedThread ?? false, + removeTerminalState: true, + }); + break; + } + + case "thread.unarchived": { + const existingUnarchived = threadLifecycleEffects.get(event.payload.threadId); + threadLifecycleEffects.set(event.payload.threadId, { + ...existingUnarchived, + clearPromotedDraft: existingUnarchived?.clearPromotedDraft ?? false, + clearDeletedThread: existingUnarchived?.clearDeletedThread ?? false, + removeTerminalState: false, + }); + break; + } + default: { break; } diff --git a/apps/web/src/orchestrationRecovery.test.ts b/apps/web/src/orchestrationRecovery.test.ts index bea16cdbce..fbdbea4ee3 100644 --- a/apps/web/src/orchestrationRecovery.test.ts +++ b/apps/web/src/orchestrationRecovery.test.ts @@ -97,4 +97,37 @@ describe("createOrchestrationRecoveryCoordinator", () => { reason: "replay-failed", }); }); + + it("keeps enough state to explain why bootstrap snapshot recovery requests replay", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true); + expect(coordinator.classifyDomainEvent(4)).toBe("defer"); + expect(coordinator.completeSnapshotRecovery(2)).toBe(true); + + expect(coordinator.getState()).toMatchObject({ + latestSequence: 2, + highestObservedSequence: 4, + bootstrapped: true, + pendingReplay: false, + inFlight: null, + }); + }); + + it("reports skip state when snapshot recovery is requested while replay is in flight", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true); + + expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(false); + expect(coordinator.getState()).toMatchObject({ + pendingReplay: true, + inFlight: { + kind: "replay", + reason: "sequence-gap", + }, + }); + }); }); diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts index d68d907dc8..e57bd22166 100644 --- a/apps/web/src/orchestrationRecovery.ts +++ b/apps/web/src/orchestrationRecovery.ts @@ -34,11 +34,16 @@ export function createOrchestrationRecoveryCoordinator() { state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence); }; - const shouldReplayAfterRecovery = (): boolean => { - const shouldReplay = - state.pendingReplay || state.highestObservedSequence > state.latestSequence; + const resolveReplayNeedAfterRecovery = () => { + const pendingReplayBeforeReset = state.pendingReplay; + const observedAhead = state.highestObservedSequence > state.latestSequence; + const shouldReplay = pendingReplayBeforeReset || observedAhead; state.pendingReplay = false; - return shouldReplay; + return { + shouldReplay, + pendingReplayBeforeReset, + observedAhead, + }; }; return { @@ -93,7 +98,7 @@ export function createOrchestrationRecoveryCoordinator() { state.highestObservedSequence = Math.max(state.highestObservedSequence, snapshotSequence); state.bootstrapped = true; state.inFlight = null; - return shouldReplayAfterRecovery(); + return resolveReplayNeedAfterRecovery().shouldReplay; }, failSnapshotRecovery(): void { @@ -124,7 +129,7 @@ export function createOrchestrationRecoveryCoordinator() { state.pendingReplay = false; return false; } - return shouldReplayAfterRecovery(); + return resolveReplayNeedAfterRecovery().shouldReplay; }, failReplayRecovery(): void { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ded4915ad5..9f98a7bc6b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,12 +22,12 @@ import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readNativeApi } from "../nativeApi"; import { getServerConfigUpdatedNotification, - type ServerConfigUpdateSource, + ServerConfigUpdatedNotification, + startServerStateSync, useServerConfig, useServerConfigUpdatedSubscription, useServerWelcomeSubscription, } from "../rpc/serverState"; -import { ServerStateBootstrap } from "../rpc/serverStateBootstrap"; import { clearPromotedDraftThread, clearPromotedDraftThreads, @@ -43,6 +43,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; +import { getWsRpcClient } from "~/wsRpcClient"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -68,18 +69,15 @@ function RootRouteView() { } return ( - <> - - - - - - - - - - - + + + + + + + + + ); } @@ -191,6 +189,12 @@ function coalesceOrchestrationUiEvents( return coalesced; } +function ServerStateBootstrap() { + useEffect(() => startServerStateSync(getWsRpcClient().server), []); + + return null; +} + function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); @@ -205,16 +209,16 @@ function EventRouter() { const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); - const pathnameRef = useRef(pathname); + const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); const serverConfig = useServerConfig(); - pathnameRef.current = pathname; + const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { + if (!payload) return; - const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload) => { migrateLocalSettingsToServer(); void (async () => { await bootstrapFromSnapshotRef.current(); @@ -227,7 +231,7 @@ function EventRouter() { } setProjectExpanded(payload.bootstrapProjectId, true); - if (pathnameRef.current !== "/") { + if (readPathname() !== "/") { return; } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { @@ -243,15 +247,10 @@ function EventRouter() { }); const handleServerConfigUpdated = useEffectEvent( - ({ - id, - payload, - source, - }: { - readonly id: number; - readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; - readonly source: ServerConfigUpdateSource; - }) => { + (notification: ServerConfigUpdatedNotification | null) => { + if (!notification) return; + + const { id, payload, source } = notification; if (id <= seenServerConfigUpdateIdRef.current) { return; } @@ -329,7 +328,11 @@ function EventRouter() { useComposerDraftStore.getState().draftThreadsByThreadId, ) as ThreadId[]; const activeThreadIds = collectActiveTerminalThreadIds({ - snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })), + snapshotThreads: threads.map((thread) => ({ + id: thread.id, + deletedAt: null, + archivedAt: thread.archivedAt ?? null, + })), draftThreadIds, }); removeOrphanedTerminalStates(activeThreadIds); @@ -425,8 +428,9 @@ function EventRouter() { return; } + const fromSequenceExclusive = recovery.getState().latestSequence; try { - const events = await api.orchestration.replayEvents(recovery.getState().latestSequence); + const events = await api.orchestration.replayEvents(fromSequenceExclusive); if (!disposed) { applyEventBatch(events); } @@ -442,7 +446,22 @@ function EventRouter() { }; const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { - if (!recovery.beginSnapshotRecovery(reason)) { + const started = recovery.beginSnapshotRecovery(reason); + if (import.meta.env.MODE !== "test") { + const state = recovery.getState(); + console.info("[orchestration-recovery]", "Snapshot recovery requested.", { + reason, + skipped: !started, + ...(started + ? {} + : { + blockedBy: state.inFlight?.kind ?? null, + blockedByReason: state.inFlight?.reason ?? null, + }), + state, + }); + } + if (!started) { return; } @@ -482,6 +501,11 @@ function EventRouter() { } }); const unsubTerminalEvent = api.terminal.onEvent((event) => { + const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId); + if (!thread || thread.archivedAt !== null) { + return; + } + useTerminalStateStore.getState().recordTerminalEvent(event); const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess === null) { return; @@ -522,8 +546,3 @@ function EventRouter() { return null; } - -function DesktopProjectBootstrap() { - // Desktop hydration runs through EventRouter project + orchestration sync. - return null; -} diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 8da5846fe6..721ce25fb5 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -57,6 +57,12 @@ const baseServerConfig: ServerConfig = { issues: [], providers: defaultProviders, availableEditors: ["cursor"], + observability: { + logsDirectoryPath: "/tmp/workspace/.config/logs", + localTracingEnabled: true, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, settings: DEFAULT_SERVER_SETTINGS, }; diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts index ff27190399..3e8dbab28c 100644 --- a/apps/web/src/rpc/serverState.ts +++ b/apps/web/src/rpc/serverState.ts @@ -11,6 +11,7 @@ import { type ServerSettings, } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useRef } from "react"; import type { WsRpcClient } from "../wsRpcClient"; import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; @@ -49,6 +50,7 @@ const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray config?.keybindings ?? EMPTY_KEYBINDINGS; const selectKeybindingsConfigPath = (config: ServerConfig | null) => config?.keybindingsConfigPath ?? null; +const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; const selectProviders = (config: ServerConfig | null) => config?.providers ?? EMPTY_SERVER_PROVIDERS; const selectSettings = (config: ServerConfig | null): ServerSettings => @@ -242,17 +244,18 @@ function subscribeLatest( function useLatestAtomSubscription( atom: Atom.Atom, listener: (value: NonNullable) => void, -) { - useAtomSubscribe( - atom, - (value) => { - if (value === null) { - return; - } - listener(value as NonNullable); - }, - { immediate: true }, - ); +): void { + const listenerRef = useRef(listener); + listenerRef.current = listener; + + const stableListener = useCallback((value: A | null) => { + if (value === null) { + return; + } + listenerRef.current(value as NonNullable); + }, []); + + useAtomSubscribe(atom, stableListener, { immediate: true }); } export function useServerConfig(): ServerConfig | null { @@ -279,6 +282,10 @@ export function useServerKeybindingsConfigPath(): string | null { return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); } +export function useServerObservability(): ServerConfig["observability"] | null { + return useAtomValue(serverConfigAtom, selectObservability); +} + export function useServerWelcomeSubscription( listener: (payload: ServerLifecycleWelcomePayload) => void, ): void { diff --git a/apps/web/src/rpc/serverStateBootstrap.tsx b/apps/web/src/rpc/serverStateBootstrap.tsx deleted file mode 100644 index c5c5c12eae..0000000000 --- a/apps/web/src/rpc/serverStateBootstrap.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useEffect } from "react"; - -import { getWsRpcClient } from "../wsRpcClient"; -import { startServerStateSync } from "./serverState"; - -export function ServerStateBootstrap() { - useEffect(() => startServerStateSync(getWsRpcClient().server), []); - - return null; -} diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 40f7add321..99f1ff2615 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Ensure a full Storage-compatible localStorage exists before any module that @@ -34,14 +34,64 @@ const { mockLocalStorage } = vi.hoisted(() => { return { mockLocalStorage }; }); -import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; +import { + selectTerminalEventEntries, + selectThreadTerminalState, + useTerminalStateStore, +} from "./terminalStateStore"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +function makeTerminalEvent( + type: TerminalEvent["type"], + overrides: Partial = {}, +): TerminalEvent { + const base = { + threadId: THREAD_ID, + terminalId: "default", + createdAt: "2026-04-02T20:00:00.000Z", + }; + + switch (type) { + case "output": + return { ...base, type, data: "hello\n", ...overrides } as TerminalEvent; + case "activity": + return { ...base, type, hasRunningSubprocess: true, ...overrides } as TerminalEvent; + case "error": + return { ...base, type, message: "boom", ...overrides } as TerminalEvent; + case "cleared": + return { ...base, type, ...overrides } as TerminalEvent; + case "exited": + return { ...base, type, exitCode: 0, exitSignal: null, ...overrides } as TerminalEvent; + case "started": + case "restarted": + return { + ...base, + type, + snapshot: { + threadId: THREAD_ID, + terminalId: "default", + cwd: "/tmp/workspace", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-02T20:00:00.000Z", + }, + ...overrides, + } as TerminalEvent; + } +} + describe("terminalStateStore actions", () => { beforeEach(() => { mockLocalStorage.clear(); - useTerminalStateStore.setState({ terminalStateByThreadId: {} }); + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + terminalEventEntriesByKey: {}, + nextTerminalEventId: 1, + }); }); it("returns a closed default terminal state for unknown threads", () => { @@ -185,4 +235,43 @@ describe("terminalStateStore actions", () => { { id: "group-default", terminalIds: ["default", "terminal-2"] }, ]); }); + + it("buffers terminal events outside persisted terminal UI state", () => { + const store = useTerminalStateStore.getState(); + store.recordTerminalEvent(makeTerminalEvent("output")); + store.recordTerminalEvent(makeTerminalEvent("activity")); + + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "default", + ); + + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]); + }); + + it("clears buffered terminal events when a thread terminal state is removed", () => { + const store = useTerminalStateStore.getState(); + store.recordTerminalEvent(makeTerminalEvent("output")); + store.removeTerminalState(THREAD_ID); + + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "default", + ); + + expect(entries).toEqual([]); + }); + + it("is a no-op when clearing terminal state for a thread with no state or buffered events", () => { + const store = useTerminalStateStore.getState(); + const before = useTerminalStateStore.getState(); + + store.clearTerminalState(THREAD_ID); + + expect(useTerminalStateStore.getState()).toBe(before); + }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 62e0883516..cd17aa295f 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -5,7 +5,7 @@ * API constrained to store actions/selectors. */ -import type { ThreadId } from "@t3tools/contracts"; +import type { TerminalEvent, ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; @@ -26,7 +26,14 @@ interface ThreadTerminalState { activeTerminalGroupId: string; } +export interface TerminalEventEntry { + id: number; + event: TerminalEvent; +} + const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray = []; +const MAX_TERMINAL_EVENT_BUFFER = 200; function createTerminalStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); @@ -227,6 +234,10 @@ function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0; } +function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; +} + function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { return groups.map((group) => ({ id: group.id, @@ -471,8 +482,24 @@ function updateTerminalStateByThreadId( }; } +export function selectTerminalEventEntries( + terminalEventEntriesByKey: Record>, + threadId: ThreadId, + terminalId: string, +): ReadonlyArray { + if (threadId.length === 0 || terminalId.trim().length === 0) { + return EMPTY_TERMINAL_EVENT_ENTRIES; + } + return ( + terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ?? + EMPTY_TERMINAL_EVENT_ENTRIES + ); +} + interface TerminalStateStoreState { terminalStateByThreadId: Record; + terminalEventEntriesByKey: Record>; + nextTerminalEventId: number; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; @@ -484,6 +511,7 @@ interface TerminalStateStoreState { terminalId: string, hasRunningSubprocess: boolean, ) => void; + recordTerminalEvent: (event: TerminalEvent) => void; clearTerminalState: (threadId: ThreadId) => void; removeTerminalState: (threadId: ThreadId) => void; removeOrphanedTerminalStates: (activeThreadIds: Set) => void; @@ -513,6 +541,8 @@ export const useTerminalStateStore = create()( return { terminalStateByThreadId: {}, + terminalEventEntriesByKey: {}, + nextTerminalEventId: 1, setTerminalOpen: (threadId, open) => updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => @@ -529,28 +559,97 @@ export const useTerminalStateStore = create()( updateTerminal(threadId, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), + recordTerminalEvent: (event) => + set((state) => { + const key = terminalEventBufferKey(event.threadId as ThreadId, event.terminalId); + const currentEntries = + state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; + const nextEntry: TerminalEventEntry = { + id: state.nextTerminalEventId, + event, + }; + const nextEntries = + currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER + ? [...currentEntries.slice(1), nextEntry] + : [...currentEntries, nextEntry]; + return { + terminalEventEntriesByKey: { + ...state.terminalEventEntriesByKey, + [key]: nextEntries, + }, + nextTerminalEventId: state.nextTerminalEventId + 1, + }; + }), clearTerminalState: (threadId) => - updateTerminal(threadId, () => createDefaultThreadTerminalState()), + set((state) => { + const nextTerminalStateByThreadId = updateTerminalStateByThreadId( + state.terminalStateByThreadId, + threadId, + () => createDefaultThreadTerminalState(), + ); + const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + let removedEventEntries = false; + for (const key of Object.keys(nextTerminalEventEntriesByKey)) { + if (key.startsWith(`${threadId}\u0000`)) { + delete nextTerminalEventEntriesByKey[key]; + removedEventEntries = true; + } + } + if ( + nextTerminalStateByThreadId === state.terminalStateByThreadId && + !removedEventEntries + ) { + return state; + } + return { + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + }; + }), removeTerminalState: (threadId) => set((state) => { - if (state.terminalStateByThreadId[threadId] === undefined) { + const hasThreadState = state.terminalStateByThreadId[threadId] !== undefined; + const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + let removedEventEntries = false; + for (const key of Object.keys(nextTerminalEventEntriesByKey)) { + if (key.startsWith(`${threadId}\u0000`)) { + delete nextTerminalEventEntriesByKey[key]; + removedEventEntries = true; + } + } + if (!hasThreadState && !removedEventEntries) { return state; } const next = { ...state.terminalStateByThreadId }; delete next[threadId]; - return { terminalStateByThreadId: next }; + return { + terminalStateByThreadId: next, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + }; }), removeOrphanedTerminalStates: (activeThreadIds) => set((state) => { const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( (id) => !activeThreadIds.has(id as ThreadId), ); - if (orphanedIds.length === 0) return state; + const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + let removedEventEntries = false; + for (const key of Object.keys(nextTerminalEventEntriesByKey)) { + const [threadId] = key.split("\u0000"); + if (threadId && !activeThreadIds.has(threadId as ThreadId)) { + delete nextTerminalEventEntriesByKey[key]; + removedEventEntries = true; + } + } + if (orphanedIds.length === 0 && !removedEventEntries) return state; const next = { ...state.terminalStateByThreadId }; for (const id of orphanedIds) { delete next[id as ThreadId]; } - return { terminalStateByThreadId: next }; + return { + terminalStateByThreadId: next, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + }; }), }; }, diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 4f85eee504..0b3165345a 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -163,6 +163,12 @@ const baseServerConfig: ServerConfig = { issues: [], providers: defaultProviders, availableEditors: ["cursor"], + observability: { + logsDirectoryPath: "/tmp/workspace/.config/logs", + localTracingEnabled: true, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, settings: DEFAULT_SERVER_SETTINGS, }; diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000000..00893c9ef1 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,519 @@ +# Observability + +T3 Code has one server-side observability model: + +- pretty logs go to stdout for humans +- completed spans go to a local NDJSON trace file +- traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM + +The local trace file is the persisted source of truth. There is no separate persisted server log file anymore. + +## Where To Find Things + +### Logs + +Logs are human-facing only: + +- destination: stdout +- format: `Logger.consolePretty()` +- persistence: none + +If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. + +### Traces + +Completed spans are written as NDJSON records to `serverTracePath` (by default, `~/.t3/userdata/logs/server.trace.ndjson`). + +Important fields in each record: + +- `name`: span name +- `traceId`, `spanId`, `parentSpanId`: correlation +- `durationMs`: elapsed time +- `attributes`: structured context +- `events`: embedded logs and custom events +- `exit`: `Success`, `Failure`, or `Interrupted` + +The schema lives in `apps/server/src/observability/TraceRecord.ts`. + +### Metrics + +Metrics are not written to a local file. + +- local persistence: none +- remote export: OTLP only, when configured +- current definitions: `apps/server/src/observability/Metrics.ts` + +If OTLP is not configured, metrics still exist in-process, but you will not have a local artifact to inspect. + +### Related Artifacts + +Provider event NDJSON files still exist for provider runtime streams. Those are separate from the main server trace file. + +## Run The Server In Instrumented Mode + +There are two useful modes: + +- local-only: stdout + local `server.trace.ndjson` +- full local observability: stdout + local trace file + OTLP export to Grafana/Tempo/Prometheus + +The local trace file is always on. OTLP export is opt-in. + +### Option 1: Local Traces Only + +You do not need any extra env vars. Just run the app normally and inspect `server.trace.ndjson`. + +Examples: + +```bash +npx t3 +``` + +```bash +bun dev +``` + +```bash +bun dev:desktop +``` + +### Option 2: Run With A Local LGTM Stack + +#### 1. Start Grafana LGTM + +```bash +docker run --name lgtm \ + -p 3000:3000 \ + -p 4317:4317 \ + -p 4318:4318 \ + --rm -ti \ + grafana/otel-lgtm +``` + +Then open `http://localhost:3000`. + +Default Grafana login: + +- username: `admin` +- password: `admin` + +#### 2. Export OTLP env vars + +```bash +export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces +export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics +export T3CODE_OTLP_SERVICE_NAME=t3-local +``` + +Optional: + +```bash +export T3CODE_TRACE_MIN_LEVEL=Info +export T3CODE_TRACE_TIMING_ENABLED=true +``` + +#### 3. Launch the app from that same shell + +CLI: + +```bash +npx t3 +``` + +Monorepo web/server dev: + +```bash +bun dev +``` + +Monorepo desktop dev: + +```bash +bun dev:desktop +``` + +Packaged desktop app: + +Launch the actual app executable from the same shell so the desktop app and embedded backend inherit `T3CODE_OTLP_*`. + +macOS app bundle example: + +```bash +T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ +T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ +T3CODE_OTLP_SERVICE_NAME=t3-desktop \ +"/Applications/T3 Code.app/Contents/MacOS/T3 Code" +``` + +Direct binary example: + +```bash +T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ +T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ +T3CODE_OTLP_SERVICE_NAME=t3-desktop \ +./path/to/your/desktop-app-binary +``` + +Do not rely on launching from Finder, Spotlight, the dock, or the Start menu after setting shell env vars. Those launches usually will not pick them up. + +#### 4. Fully restart after changing env + +The backend reads observability config at process start. If you change OTLP env vars, stop the app completely and start it again. + +## How To Use Traces And Metrics To Debug The Server + +### Start With The Local Trace File + +The trace file is the fastest way to inspect raw span data. + +Tail it: + +```bash +tail -f "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +In monorepo dev, use: + +```bash +tail -f ./dev/logs/server.trace.ndjson +``` + +Show failed spans: + +```bash +jq -c 'select(.exit._tag != "Success") | { + name, + durationMs, + exit, + attributes +}' "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +Show slow spans: + +```bash +jq -c 'select(.durationMs > 1000) | { + name, + durationMs, + traceId, + spanId +}' "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +Inspect embedded log events: + +```bash +jq -c 'select(any(.events[]?; .attributes["effect.logLevel"] != null)) | { + name, + durationMs, + events: [ + .events[] + | select(.attributes["effect.logLevel"] != null) + | { + message: .name, + level: .attributes["effect.logLevel"] + } + ] +}' "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +Follow one trace: + +```bash +jq -r 'select(.traceId == "TRACE_ID_HERE") | [ + .name, + .spanId, + (.parentSpanId // "-"), + .durationMs +] | @tsv' "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +Filter orchestration commands: + +```bash +jq -c 'select(.attributes["orchestration.command_type"] != null) | { + name, + durationMs, + commandType: .attributes["orchestration.command_type"], + aggregateKind: .attributes["orchestration.aggregate_kind"] +}' "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +Filter git activity: + +```bash +jq -c 'select(.attributes["git.operation"] != null) | { + name, + durationMs, + operation: .attributes["git.operation"], + cwd: .attributes["git.cwd"], + hookEvents: [ + .events[] + | select(.name == "git.hook.started" or .name == "git.hook.finished") + ] +}' "${T3CODE_HOME:-~/.t3}/userdata/logs/server.trace.ndjson" +``` + +### Use Tempo When You Need A Real Trace Viewer + +Tempo is better than raw NDJSON when you want to: + +- search across many traces +- inspect parent/child relationships visually +- compare many slow traces +- drill into one failing request without hand-joining by `traceId` + +Recommended flow in Grafana: + +1. Open `Explore`. +2. Pick the `Tempo` data source. +3. Set the time range to something recent like `Last 15 minutes`. +4. Start broad. Do not begin with a very narrow query. +5. Look for spans from your configured service name, then narrow by span name or attributes. + +Good first searches: + +- service name such as `t3-local`, `t3-dev`, or `t3-desktop` +- span names like `sql.execute`, `git.runCommand`, `provider.sendTurn` +- orchestration spans with attributes like `orchestration.command_type` + +Once you know traces are arriving, narrower TraceQL queries like `name = "sql.execute"` become useful. + +### Use Metrics To See Systemic Problems + +Traces are best for one request. Metrics are best for trends. + +Good metric families to watch: + +- `t3_rpc_request_duration` +- `t3_orchestration_command_duration` +- `t3_orchestration_command_ack_duration` +- `t3_provider_turn_duration` +- `t3_git_command_duration` +- `t3_db_query_duration` + +Counters tell you volume and failure rate: + +- `t3_rpc_requests_total` +- `t3_orchestration_commands_total` +- `t3_provider_turns_total` +- `t3_git_commands_total` +- `t3_db_queries_total` + +Use metrics when the question is: + +- "is this always slow?" +- "did this get worse after a change?" +- "which command type is failing most often?" + +Use traces when the question is: + +- "what happened in this specific request?" +- "which child span caused this one slow interaction?" +- "what logs were emitted inside the failing flow?" + +### What The New Ack Metric Means + +`t3_orchestration_command_ack_duration` measures: + +- start: command dispatch enters the orchestration engine +- end: the first committed domain event for that command is published by the server + +That is a server-side acknowledgment metric. It does not measure: + +- websocket transit to the browser +- client receipt +- React render time + +If you need those later, add client-side instrumentation or a dedicated server fanout metric. + +## Common Workflows + +### "Why did this request fail?" + +1. Start with the local NDJSON file. +2. Find spans where `exit._tag != "Success"`. +3. Group by `traceId`. +4. Inspect sibling spans and span events. +5. If needed, move to Tempo for the full trace tree. + +### "Why is the UI feeling slow?" + +1. Search for slow top-level spans in the trace file or Tempo. +2. Check child spans for sqlite, git, provider, or terminal work. +3. Look at the matching duration metrics to see whether the slowness is systemic. + +### "Did this command take too long to acknowledge?" + +1. Check `t3_orchestration_command_ack_duration` by `commandType`. +2. If it is high, inspect the corresponding orchestration trace. +3. Look at child spans for projection, sqlite, provider, or git work. + +### "Are git hooks causing latency?" + +1. Filter `git.operation` spans. +2. Inspect `git.hook.started` and `git.hook.finished` events. +3. Compare hook timing to the enclosing git span duration. + +### "Why do I have spans locally but nothing in Grafana?" + +Usually one of these is true: + +- `T3CODE_OTLP_TRACES_URL` was not set +- the app was launched from a different environment than the one where you exported the vars +- the app was not fully restarted after changing env +- Grafana is looking at the wrong time range or service name + +If the local NDJSON file is updating, local tracing is working. The problem is almost always OTLP export configuration or process startup. + +## How To Think About Adding Tracing To Future Code + +### Prefer Boundaries Over Tiny Helpers + +Good span boundaries: + +- RPC methods +- orchestration command handling +- provider adapter calls +- external process calls +- persistence writes +- queue handoffs + +Avoid tracing every tiny helper. Most helpers should inherit the active span rather than create a new one. + +### Reuse `Effect.fn(...)` Where It Already Exists + +The codebase already uses `Effect.fn("name")` heavily. That should usually be your first tracing boundary. + +For ad hoc work: + +```ts +import { Effect } from "effect"; + +const runThing = Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "thing.id": "abc123", + "thing.kind": "example", + }); + + yield* Effect.logInfo("starting thing"); + return yield* doWork(); +}).pipe(Effect.withSpan("thing.run")); +``` + +### Put High-Cardinality Detail On Spans + +Use span annotations for IDs, paths, and other detailed context: + +```ts +yield * + Effect.annotateCurrentSpan({ + "provider.thread_id": input.threadId, + "provider.request_id": input.requestId, + "git.cwd": input.cwd, + }); +``` + +### Keep Metric Labels Low Cardinality + +Good metric labels: + +- operation kind +- method name +- provider kind +- aggregate kind +- outcome + +Bad metric labels: + +- raw thread IDs +- command IDs +- file paths +- cwd +- full prompts +- full model strings when a normalized family label would do + +Detailed context belongs on spans, not metrics. + +### Use Logs As Span Events + +Logs inside a span become part of the trace story: + +```ts +yield * Effect.logInfo("starting provider turn"); +yield * Effect.logDebug("waiting for approval response"); +``` + +Those messages show up as span events because `Logger.tracerLogger` is installed. + +### Use The Pipeable Metrics API + +`withMetrics(...)` is the default way to attach a counter and timer to an effect: + +```ts +import { someCounter, someDuration, withMetrics } from "../observability/Metrics.ts"; + +const program = doWork().pipe( + withMetrics({ + counter: someCounter, + timer: someDuration, + attributes: { + operation: "work", + }, + }), +); +``` + +## Detailed API Reference + +### Runtime Wiring + +The server observability layer is assembled in `apps/server/src/observability/Layers/Observability.ts`. + +It provides: + +- pretty stdout logger +- `Logger.tracerLogger` +- local NDJSON tracer +- optional OTLP trace exporter +- optional OTLP metrics exporter +- Effect trace-level and timing refs + +### Env Vars + +Local trace file: + +- `T3CODE_TRACE_FILE`: override trace file path +- `T3CODE_TRACE_MAX_BYTES`: per-file rotation size, default `10485760` +- `T3CODE_TRACE_MAX_FILES`: rotated file count, default `10` +- `T3CODE_TRACE_BATCH_WINDOW_MS`: flush window, default `200` +- `T3CODE_TRACE_MIN_LEVEL`: minimum trace level, default `Info` +- `T3CODE_TRACE_TIMING_ENABLED`: enable timing metadata, default `true` + +OTLP export: + +- `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint +- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint +- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` +- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` + +If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. + +### What Is Instrumented Today + +Current high-value span and metric boundaries include: + +- Effect RPC websocket request spans from `effect/rpc` +- RPC request metrics in `apps/server/src/observability/RpcInstrumentation.ts` +- startup phases +- orchestration command processing +- orchestration command acknowledgment latency +- provider session and turn operations +- git command execution and git hook events +- terminal session lifecycle +- sqlite query execution + +### Current Constraints + +- logs outside spans are not persisted +- metrics are not snapshotted locally +- the old `serverLogPath` still exists in config for compatibility, but the trace file is the persisted artifact that matters diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index f29711204b..8e7114d02d 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,28 +1,38 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; +export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); +export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; + +type EditorDefinition = { + readonly id: string; + readonly label: string; + readonly command: string | null; + readonly launchStyle: EditorLaunchStyle; +}; + export const EDITORS = [ - { id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true }, - { id: "trae", label: "Trae", command: "trae", supportsGoto: true }, - { id: "windsurf", label: "Windsurf", command: "windsurf", supportsGoto: true }, - { id: "vscode", label: "VS Code", command: "code", supportsGoto: true }, + { id: "cursor", label: "Cursor", command: "cursor", launchStyle: "goto" }, + { id: "trae", label: "Trae", command: "trae", launchStyle: "goto" }, + { id: "windsurf", label: "Windsurf", command: "windsurf", launchStyle: "goto" }, + { id: "vscode", label: "VS Code", command: "code", launchStyle: "goto" }, { id: "vscode-insiders", label: "VS Code Insiders", command: "code-insiders", - supportsGoto: true, + launchStyle: "goto", }, - { id: "vscodium", label: "VSCodium", command: "codium", supportsGoto: true }, - { id: "zed", label: "Zed", command: "zed", supportsGoto: false }, - { id: "positron", label: "Positron", command: "positron", supportsGoto: true }, - { id: "sublime", label: "Sublime Text", command: "subl", supportsGoto: false }, - { id: "webstorm", label: "WebStorm", command: "webstorm", supportsGoto: false }, - { id: "intellij", label: "IntelliJ IDEA", command: "idea", supportsGoto: false }, - { id: "fleet", label: "Fleet", command: "fleet", supportsGoto: false }, - { id: "ghostty", label: "Ghostty", command: "ghostty", supportsGoto: false }, - { id: "antigravity", label: "Antigravity", command: "agy", supportsGoto: false }, - { id: "file-manager", label: "File Manager", command: null, supportsGoto: false }, -] as const; + { id: "vscodium", label: "VSCodium", command: "codium", launchStyle: "goto" }, + { id: "zed", label: "Zed", command: "zed", launchStyle: "direct-path" }, + { id: "positron", label: "Positron", command: "positron", launchStyle: "goto" }, + { id: "sublime", label: "Sublime Text", command: "subl", launchStyle: "direct-path" }, + { id: "webstorm", label: "WebStorm", command: "webstorm", launchStyle: "direct-path" }, + { id: "idea", label: "IntelliJ IDEA", command: "idea", launchStyle: "line-column" }, + { id: "fleet", label: "Fleet", command: "fleet", launchStyle: "direct-path" }, + { id: "ghostty", label: "Ghostty", command: "ghostty", launchStyle: "direct-path" }, + { id: "antigravity", label: "Antigravity", command: "agy", launchStyle: "goto" }, + { id: "file-manager", label: "File Manager", command: null, launchStyle: "direct-path" }, +] as const satisfies ReadonlyArray; export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); export type EditorId = typeof EditorId.Type; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b40286f47c..b72d6f168d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -3,6 +3,7 @@ import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchema import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; +const GIT_LIST_BRANCHES_MAX_LIMIT = 200; // Domain Types @@ -123,6 +124,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; export const GitListBranchesInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional( + PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), + ), }); export type GitListBranchesInput = typeof GitListBranchesInput.Type; @@ -183,7 +189,10 @@ const GitStatusPr = Schema.Struct({ }); export const GitStatusResult = Schema.Struct({ - branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + isRepo: Schema.Boolean, + hasOriginRemote: Schema.Boolean, + isDefaultBranch: Schema.Boolean, + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array( @@ -207,6 +216,8 @@ export const GitListBranchesResult = Schema.Struct({ branches: Schema.Array(GitBranch), isRepo: Schema.Boolean, hasOriginRemote: Schema.Boolean, + nextCursor: NonNegativeInt.pipe(Schema.NullOr), + totalCount: NonNegativeInt, }); export type GitListBranchesResult = typeof GitListBranchesResult.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d4038deb3a..776a0a89e9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -72,6 +72,16 @@ export type ServerProvider = typeof ServerProvider.Type; export const ServerProviders = Schema.Array(ServerProvider); export type ServerProviders = typeof ServerProviders.Type; +export const ServerObservability = Schema.Struct({ + logsDirectoryPath: TrimmedNonEmptyString, + localTracingEnabled: Schema.Boolean, + otlpTracesUrl: Schema.optional(TrimmedNonEmptyString), + otlpTracesEnabled: Schema.Boolean, + otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString), + otlpMetricsEnabled: Schema.Boolean, +}); +export type ServerObservability = typeof ServerObservability.Type; + export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, @@ -79,6 +89,7 @@ export const ServerConfig = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviders, availableEditors: Schema.Array(EditorId), + observability: ServerObservability, settings: ServerSettings, }); export type ServerConfig = typeof ServerConfig.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9b65b243e9..7083ce775d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -85,6 +85,12 @@ export const GenericProviderSettings = Schema.Struct({ }); export type GenericProviderSettings = typeof GenericProviderSettings.Type; +export const ObservabilitySettings = Schema.Struct({ + otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), +}); +export type ObservabilitySettings = typeof ObservabilitySettings.Type; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( @@ -108,6 +114,7 @@ export const ServerSettings = Schema.Struct({ amp: GenericProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), kilo: GenericProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), + observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -243,6 +250,12 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + observability: Schema.optionalKey( + Schema.Struct({ + otlpTracesUrl: Schema.optionalKey(Schema.String), + otlpMetricsUrl: Schema.optionalKey(Schema.String), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/shared/package.json b/packages/shared/package.json index b35d23ef15..a80b514dd2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,6 +40,10 @@ "types": "./src/Struct.ts", "import": "./src/Struct.ts" }, + "./serverSettings": { + "types": "./src/serverSettings.ts", + "import": "./src/serverSettings.ts" + }, "./String": { "types": "./src/String.ts", "import": "./src/String.ts" diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index bbd290393f..90bc655a76 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,3 +1,5 @@ +import type { GitBranch } from "@t3tools/contracts"; + /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. * Strips quotes, collapses separators, limits to 64 chars. @@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName( return `${resolvedBase}-${suffix}`; } + +/** + * Strip the remote prefix from a remote ref such as `origin/feature/demo`. + */ +export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { + const firstSeparatorIndex = branchName.indexOf("/"); + if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { + return branchName; + } + return branchName.slice(firstSeparatorIndex + 1); +} + +function deriveLocalBranchNameCandidatesFromRemoteRef( + branchName: string, + remoteName?: string, +): ReadonlyArray { + const candidates = new Set(); + const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); + if (firstSlashCandidate.length > 0) { + candidates.add(firstSlashCandidate); + } + + if (remoteName) { + const remotePrefix = `${remoteName}/`; + if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { + candidates.add(branchName.slice(remotePrefix.length)); + } + } + + return [...candidates]; +} + +/** + * Hide `origin/*` remote refs when a matching local branch already exists. + */ +export function dedupeRemoteBranchesWithLocalMatches( + branches: ReadonlyArray, +): ReadonlyArray { + const localBranchNames = new Set( + branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), + ); + + return branches.filter((branch) => { + if (!branch.isRemote) { + return true; + } + + if (branch.remoteName !== "origin") { + return true; + } + + const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( + branch.name, + branch.remoteName, + ); + return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); + }); +} diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts new file mode 100644 index 0000000000..0ac5e415df --- /dev/null +++ b/packages/shared/src/serverSettings.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + extractPersistedServerObservabilitySettings, + normalizePersistedServerSettingString, + parsePersistedServerObservabilitySettings, +} from "./serverSettings"; + +describe("serverSettings helpers", () => { + it("normalizes optional persisted strings", () => { + expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); + expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); + expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe( + "http://localhost:4318/v1/traces", + ); + }); + + it("extracts persisted observability settings", () => { + expect( + extractPersistedServerObservabilitySettings({ + observability: { + otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + }, + }), + ).toEqual({ + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }); + }); + + it("parses lenient persisted settings JSON", () => { + expect( + parsePersistedServerObservabilitySettings( + JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, + }), + ), + ).toEqual({ + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }); + }); + + it("falls back cleanly when persisted settings are invalid", () => { + expect(parsePersistedServerObservabilitySettings("{")).toEqual({ + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + }); + }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts new file mode 100644 index 0000000000..9ab74a31e4 --- /dev/null +++ b/packages/shared/src/serverSettings.ts @@ -0,0 +1,52 @@ +import { Schema } from "effect"; +import { fromLenientJson } from "./schemaJson"; + +/** Narrow schema that only decodes the observability subtree, avoiding + * validation failures from unrelated ServerSettings fields. */ +const ObservabilitySubtreeJson = fromLenientJson( + Schema.Struct({ + observability: Schema.optional( + Schema.Struct({ + otlpTracesUrl: Schema.optional(Schema.String), + otlpMetricsUrl: Schema.optional(Schema.String), + }), + ), + }), +); + +export interface PersistedServerObservabilitySettings { + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; +} + +export function normalizePersistedServerSettingString( + value: string | null | undefined, +): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function extractPersistedServerObservabilitySettings(input: { + readonly observability?: + | { + readonly otlpTracesUrl?: string | undefined; + readonly otlpMetricsUrl?: string | undefined; + } + | undefined; +}): PersistedServerObservabilitySettings { + return { + otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), + otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), + }; +} + +export function parsePersistedServerObservabilitySettings( + raw: string, +): PersistedServerObservabilitySettings { + try { + const decoded = Schema.decodeUnknownSync(ObservabilitySubtreeJson)(raw); + return extractPersistedServerObservabilitySettings(decoded); + } catch { + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } +} diff --git a/scripts/package.json b/scripts/package.json index d9db897ea2..157763b398 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,8 +4,6 @@ "type": "module", "scripts": { "prepare": "effect-language-service patch", - "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts", - "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/turbo.json b/turbo.json index 9359376f59..47d7091385 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,17 @@ "T3CODE_NO_BROWSER", "T3CODE_HOME", "T3CODE_AUTH_TOKEN", - "T3CODE_DESKTOP_WS_URL" + "T3CODE_DESKTOP_WS_URL", + "T3CODE_TRACE_MIN_LEVEL", + "T3CODE_TRACE_TIMING_ENABLED", + "T3CODE_TRACE_FILE", + "T3CODE_TRACE_MAX_BYTES", + "T3CODE_TRACE_MAX_FILES", + "T3CODE_TRACE_BATCH_WINDOW_MS", + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_EXPORT_INTERVAL_MS", + "T3CODE_OTLP_SERVICE_NAME" ], "tasks": { "build": {