diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 6d42a589f07b..a0e2d14bdc78 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -79,6 +79,11 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly remoteName: string; }) => Effect.Effect; + readonly remoteBranchExists: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly refName: string; + }) => Effect.Effect; readonly resolveRemoteTrackingCommit: (input: { readonly cwd: string; readonly refName: string; @@ -396,6 +401,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( Effect.andThen(git.remoteExists(input)), ), + remoteBranchExists: (input) => + ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe( + Effect.andThen(git.remoteBranchExists(input)), + ), resolveRemoteTrackingCommit: (input) => ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, { allowBare: true, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index d81d66574db5..0c0bf0c28801 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,8 +3,10 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; @@ -34,6 +36,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; const runtimeMock = { state: { runVersionError: null as Error | null, + runVersionPending: false, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, connectionError: null as Error | null, @@ -52,6 +55,7 @@ const runtimeMock = { }, reset() { this.state.runVersionError = null; + this.state.runVersionPending = false; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.connectionError = null; @@ -114,15 +118,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }; }), runOpenCodeCommand: () => - runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + runtimeMock.state.runVersionPending + ? Effect.never + : runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType; @@ -218,6 +224,24 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("times out a hanging local CLI version probe", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index ed935fb31402..a094fe8601a2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -31,9 +31,10 @@ const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; +const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause: unknown; + readonly cause?: unknown; readonly detail: string; }> {} @@ -401,6 +402,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu Effect.mapError( (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeProbeError({ + detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + }), + ), + }), ), ); if (versionExit._tag === "Failure") { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 07d8e5f9c1c4..97d77654134e 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -565,11 +565,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", shell: spawnCommand.shell, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); + const terminateCommandGroup = + hostPlatform === "win32" + ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), "SIGKILL"); + } catch { + // The command and its process group may already have exited. + } + }); + yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); const collectOptions = input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; const [stdout, stderr, code] = yield* Effect.all( diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 5baf18a1ff6a..98e21d75bc58 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -97,6 +97,46 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("reads an Azure pull request page larger than the VCS default output limit", () => + Effect.gen(function* () { + const rows = pullRequestRows(100, 1).map((row) => ({ + ...row, + description: "x".repeat(10_000), + })); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const response = JSON.stringify(rows); + expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); + + mockedExecute.mockImplementationOnce((input) => { + const maxOutputBytes = + "maxOutputBytes" in input && typeof input.maxOutputBytes === "number" + ? input.maxOutputBytes + : 1_000_000; + return Effect.succeed( + maxOutputBytes >= Buffer.byteLength(response) + ? output(response) + : { + ...output(response.slice(0, maxOutputBytes)), + stdoutTruncated: true, + }, + ); + }); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "merged", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 549a172b3646..96dc4ea1d30e 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -111,6 +111,7 @@ export type AzureDevOpsPullRequestCliError = /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ const REST_API_VERSION = "7.1"; +const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; export class AzureDevOpsPullRequestCli extends Context.Service< AzureDevOpsPullRequestCli, @@ -258,10 +259,15 @@ export const make = Effect.gen(function* () { // how to read all of them. const detectArgs = ["--detect", "true"] as const; - const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray }) => + const executeJson = (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly maxOutputBytes?: number; + }) => azure.execute({ cwd: input.cwd, args: [...input.args, "--only-show-errors", "--output", "json"], + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }); /** @@ -290,6 +296,7 @@ export const make = Effect.gen(function* () { const top = remaining + 1; return executeJson({ cwd: input.cwd, + maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, args: [ "repos", "pr", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 99e975d033cb..903d4919a2de 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8674,6 +8674,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { bootstrapGitOperations.push("fetch"); }), ); + const remoteBranchExists = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("remote-branch-exists"); + return true; + }), + ); const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; const resolveRemoteTrackingCommit = vi.fn( (_: Parameters[0]) => @@ -8724,6 +8731,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitVcsDriver: { remoteExists, fetchRemote, + remoteBranchExists, resolveRemoteTrackingCommit, createWorktree, }, @@ -8820,6 +8828,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/project", remoteName: "origin", }); + assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + refName: "main", + }); assert.deepEqual(resolveRemoteTrackingCommit.mock.calls[0]?.[0], { cwd: "/tmp/project", refName: "main", @@ -8828,6 +8841,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(bootstrapGitOperations, [ "remote-exists", "fetch", + "remote-branch-exists", "resolve-remote-commit", "create-worktree", ]); @@ -8864,122 +8878,136 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "falls back to the local base branch when startFromOrigin is set but no origin remote exists", - () => - Effect.gen(function* () { - const dispatchedCommands: Array = []; - const remoteExists = vi.fn( - (_: Parameters[0]) => - Effect.succeed(false), - ); - const fetchRemote = vi.fn( - (_: Parameters[0]) => Effect.void, - ); - const resolveRemoteTrackingCommit = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - commitSha: "0123456789abcdef0123456789abcdef01234567", - remoteRefName: "origin/main", - }), - ); - const createWorktree = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - worktree: { - refName: "t3code/bootstrap-refName", - path: "/tmp/bootstrap-worktree", - }, - }), - ); - - yield* buildAppUnderTest({ - layers: { - gitVcsDriver: { - remoteExists, - fetchRemote, - resolveRemoteTrackingCommit, - createWorktree, - }, - orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - return { sequence: dispatchedCommands.length }; - }), - readEvents: () => Stream.empty, + it.effect.each([ + { caseName: "the origin remote is missing", hasOrigin: false }, + { caseName: "the base branch exists only locally", hasOrigin: true }, + ])("falls back to the local base branch when $caseName", ({ hasOrigin }) => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(hasOrigin), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const remoteBranchExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", }, - // The fork's bootstrap waits for the worktree to appear in the - // projection before starting the provider; satisfy it the same way - // the neighbouring bootstrap tests do. - projectionSnapshotQuery: { - getThreadShellById: (threadId) => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, - worktreePath: "/tmp/bootstrap-worktree", - }), - ), + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + remoteBranchExists, + resolveRemoteTrackingCommit, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + // The fork's bootstrap waits for the worktree to appear in the + // projection before starting the provider; satisfy it the same way + // the neighbouring bootstrap tests do. + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath: "/tmp/bootstrap-worktree", + }), ), - }, + ), }, - }); + }, + }); - const createdAt = "2026-01-01T00:00:00.000Z"; - const wsUrl = yield* getWsServerUrl("/ws"); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), - threadId: ThreadId.make("thread-bootstrap-no-origin"), - message: { - messageId: MessageId.make("msg-bootstrap-no-origin"), - role: "user", - text: "hello", - attachments: [], + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, }, - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - bootstrap: { - createThread: { - projectId: defaultProjectId, - title: "Bootstrap Thread", - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt, - }, - prepareWorktree: { - projectCwd: "/tmp/project", - baseBranch: "main", - branch: "t3code/bootstrap-refName", - startFromOrigin: true, - }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, }, - createdAt, - }), - ), - ); + }, + createdAt, + }), + ), + ); - assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, hasOrigin ? 1 : 0); + assert.equal(remoteBranchExists.mock.calls.length, hasOrigin ? 1 : 0); + if (hasOrigin) { + assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { cwd: "/tmp/project", remoteName: "origin", - }); - assert.equal(fetchRemote.mock.calls.length, 0); - assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); - assert.deepEqual(createWorktree.mock.calls[0]?.[0], { - cwd: "/tmp/project", refName: "main", - newRefName: "t3code/bootstrap-refName", - baseRefName: "main", - path: null, }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + } + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("checks out the base branch directly when bootstrap reuses it", () => diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index a5bb9a9e30af..f0cb52003029 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -332,6 +332,28 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("forwards explicit output limits to the process boundary", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.execute({ + cwd: "/repo", + args: ["repos", "pr", "list"], + maxOutputBytes: 16 * 1024 * 1024, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: ["repos", "pr", "list"], + cwd: "/repo", + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves VCS causes without copying upstream details into messages", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 556dc4bf213d..f05f4a7588c4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -218,6 +218,7 @@ export class AzureDevOpsCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listPullRequests: (input: { @@ -362,6 +363,7 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 1fda985250a2..b06238bb4fb2 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -206,6 +206,10 @@ export interface GitRemoteExistsInput { remoteName: string; } +export interface GitRemoteBranchExistsInput extends GitRemoteExistsInput { + refName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -297,6 +301,9 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; + readonly remoteBranchExists: ( + input: GitRemoteBranchExistsInput, + ) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 95bab8d7fa06..85344ebb627b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1815,6 +1815,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + assert.equal( + yield* driver.remoteBranchExists({ + cwd, + remoteName: "origin", + refName: initialBranch, + }), + true, + ); + assert.equal( + yield* driver.remoteBranchExists({ + cwd, + remoteName: "origin", + refName: "local-only", + }), + false, + ); + const resolvedBase = yield* driver.resolveRemoteTrackingCommit({ cwd, refName: initialBranch, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ffe1cff1dbd9..91acabf192b9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1321,15 +1321,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); - const remoteBranchExists = ( - cwd: string, - remoteName: string, - refName: string, - ): Effect.Effect => + const remoteBranchExists: GitVcsDriver.GitVcsDriver["Service"]["remoteBranchExists"] = (input) => executeGit( "GitVcsDriver.remoteBranchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.remoteName}/${input.refName}`], { allowNonZeroExit: true, }, @@ -1476,7 +1472,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if ( primaryRemoteName && - (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) ) { return `${primaryRemoteName}/${normalizedCandidate}`; } @@ -2022,9 +2022,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.orElseSucceed(() => false), - ); + const hasRemoteBranch = yield* remoteBranchExists({ + cwd, + remoteName: publishRemoteName, + refName: branch, + }).pipe(Effect.orElseSucceed(() => false)); if (hasRemoteBranch) { return { status: "skipped_up_to_date" as const, @@ -3452,6 +3454,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* resolveDefaultBranchName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), remoteExists, + remoteBranchExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3e576fa73a77..4aa3080b8ca2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1325,9 +1325,9 @@ const makeWsRpcLayer = ( } else if ( prepareWorktree.startFromOrigin === true && // "Start from origin" is a stored default; repos without an - // origin remote fall back to the local base branch instead of - // failing the whole bootstrap on `git fetch origin`. Checked - // lazily so the reuse path never touches the remote. + // origin remote, or without the requested remote branch, fall + // back to the local base branch instead of failing bootstrap. + // Checked lazily so the reuse path never touches the remote. (yield* gitWorkflow.remoteExists({ cwd: prepareWorktree.projectCwd, remoteName: "origin", @@ -1337,12 +1337,19 @@ const makeWsRpcLayer = ( cwd: prepareWorktree.projectCwd, remoteName: "origin", }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ cwd: prepareWorktree.projectCwd, refName: prepareWorktree.baseBranch, - fallbackRemoteName: "origin", + remoteName: "origin", }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + if (remoteBaseExists) { + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } } const worktree = yield* gitWorkflow.createWorktree({ cwd: prepareWorktree.projectCwd, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6e69bc492b7a..20c2fc80154b 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1700,13 +1700,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const removeProject = useCallback( - async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { + async (member: SidebarProjectGroupMember) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const result = await deleteProject({ environmentId: member.environmentId, input: { projectId: member.id, - ...(options.force === true ? { force: true } : {}), + force: true, }, }); if (result._tag === "Failure") { @@ -1774,7 +1774,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - "This permanently clears conversation history for those threads.", + "This permanently clears conversation history for those threads and any archived threads.", "This removes only this project entry.", "This action cannot be undone.", ].join("\n") @@ -1784,6 +1784,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This permanently clears any archived conversation history.", "This removes only this project entry.", ].join("\n"), { variant: "destructive" }, @@ -1792,7 +1793,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const result = await removeProject(member, { force: true }); + const result = await removeProject(member); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1833,6 +1834,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec `Remove project "${member.title}"?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This permanently clears any archived conversation history.", "This removes only this project entry.", ].join("\n"); const confirmed = await api.dialogs.confirm(message, { variant: "destructive" }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 1acbb0c1d205..0874bbb1e662 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -73,6 +73,98 @@ describe("FileSaveCoordinator", () => { expect(onPendingChange.mock.calls.at(-1)).toEqual([false]); }); + it("saves an edit made inside the debounce window when the editor closes", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("unsaved"); + coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("unsaved"); + }); + + it("flushes an edit made while a write was in flight when the editor closes", async () => { + vi.useFakeTimers(); + const inFlight = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("first"); + await vi.advanceTimersByTimeAsync(500); + coordinator.change("latest"); + coordinator.dispose(); + inFlight.resolve(AsyncResult.success(undefined)); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("latest"); + }); + + it("does not rewrite a write that lands while the editor closes", async () => { + vi.useFakeTimers(); + const inFlight = deferred(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("only"); + await vi.advanceTimersByTimeAsync(500); + coordinator.dispose(); + inFlight.resolve(AsyncResult.success(undefined)); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledOnce(); + }); + + it("retries a failed write when the editor closes", async () => { + vi.useFakeTimers(); + const persist = vi + .fn() + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("write failed")))) + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("latest"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenCalledOnce(); + + coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenLastCalledWith("latest"); + }); + it("leaves the file pending when the latest write fails", async () => { vi.useFakeTimers(); const onPendingChange = vi.fn(); @@ -91,4 +183,49 @@ describe("FileSaveCoordinator", () => { expect(onPendingChange).toHaveBeenCalledWith(true); expect(onPendingChange).not.toHaveBeenCalledWith(false); }); + + it("ignores editor changes emitted after disposal", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.dispose(); + coordinator.change("stale contents"); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange).not.toHaveBeenCalled(); + }); + + it("does not persist confirmed contents again on disposal", async () => { + vi.useFakeTimers(); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + coordinator.change("temporary edit"); + await vi.advanceTimersByTimeAsync(500); + coordinator.change("original contents"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenCalledTimes(2); + + coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 138f01d360e3..e9d3f11e9cd7 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -11,6 +11,7 @@ export class FileSaveCoordinator { private timer: ReturnType | null = null; private latestContents = ""; private latestRevision = 0; + private confirmedRevision = 0; private lastChangeAt = 0; private saving = false; private disposed = false; @@ -18,6 +19,7 @@ export class FileSaveCoordinator { constructor(private readonly options: FileSaveCoordinatorOptions) {} change(contents: string): void { + if (this.disposed) return; this.latestContents = contents; this.latestRevision += 1; this.lastChangeAt = Date.now(); @@ -46,7 +48,7 @@ export class FileSaveCoordinator { } private async persistLatest(): Promise { - if (this.saving || this.latestRevision === 0) return; + if (this.saving || this.latestRevision === this.confirmedRevision) return; this.saving = true; const contents = this.latestContents; @@ -54,6 +56,7 @@ export class FileSaveCoordinator { const result = await this.options.persist(contents); const succeeded = result._tag === "Success"; if (succeeded) { + this.confirmedRevision = revision; this.options.onConfirmed(contents); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 37d95823c879..77450e79301f 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -710,8 +710,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ] : [`This removes ${members.length} grouped project entries.`]), ...(projectThreads.length > 0 - ? ["This permanently clears conversation history for those threads."] - : []), + ? [ + "This permanently clears conversation history for those threads and any archived threads.", + ] + : ["This permanently clears any archived conversation history."]), isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", @@ -733,7 +735,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { environmentId: member.environmentId, input: { projectId: member.id, - ...(memberThreads.length > 0 ? { force: true } : {}), + force: true, }, }), () => undefined, diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7075a24bfd45..37b2e7146140 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -23,6 +23,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** @@ -245,8 +246,11 @@ export function ProviderModelsSection({ {model.name} {hasDetails ? ( - - + - - + +
{model.slug} {capLabels.length > 0 ? ( @@ -271,8 +275,8 @@ export function ProviderModelsSection({
) : null} -
-
+ + ) : null} {isHidden ? ( hidden diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 63a116b088d6..1f154560b517 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -154,6 +154,8 @@ rustup target add aarch64-pc-windows-msvc Windows supplies `tar.exe`; it is checked when `--wsl-prebuild` makes the artifact include the WSL runtime. NSIS is downloaded by electron-builder and does not need a separate installation. +When `T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR=true` points the build at an existing resource monitor, +the artifact script skips the Rust and Visual Studio checks because it does not compile the monitor. Unsigned local builds need no Azure credentials. Builds using `--signed` additionally require the Azure Trusted Signing configuration described below. diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 829177ad980a..d2bf6940b8e9 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -965,6 +965,48 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); + it.effect("does not require MSVC when reusing a prebuilt Windows resource monitor", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-windows-preflight-" }); + const pythonPath = path.join(tempDir, "python.exe"); + yield* fs.writeFileString(pythonPath, "python"); + const commands: string[] = []; + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { readonly command: string }; + commands.push(childProcess.command); + return Effect.succeed(mockProcess(childProcess.command === "powershell.exe" ? 1 : 0)); + }), + ); + + yield* preflightWindowsDesktopBuild({ + arch: "x64", + bundlesWslRuntime: true, + }).pipe( + Effect.provide( + Layer.merge( + spawner, + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + npm_config_python: pythonPath, + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: "true", + }, + }), + ), + ), + ), + ); + + assert.notInclude(commands, "powershell.exe"); + }), + ), + ); + it.effect("rejects a PATH-discovered Python executable that is not Python 3", () => Effect.scoped( Effect.gen(function* () { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index b85c6ccf461e..f1a47873bc37 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1766,16 +1766,18 @@ export const preflightWindowsDesktopBuild = Effect.fn("preflightWindowsDesktopBu rustTargetIsInstalled(rustTarget), ]).pipe(Effect.map(([cargo, target]) => cargo && target)), python: Effect.succeed(python !== undefined), - msvc: desktopBuildProbeSucceeds( - ChildProcess.make("powershell.exe", [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - windowsVswherePrerequisiteScript(input.arch), - ]), - "Visual Studio Build Tools", - ), + msvc: reuseResourceMonitor + ? Effect.succeed(true) + : desktopBuildProbeSucceeds( + ChildProcess.make("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + windowsVswherePrerequisiteScript(input.arch), + ]), + "Visual Studio Build Tools", + ), tar: input.bundlesWslRuntime ? desktopBuildProbeSucceeds(ChildProcess.make("tar.exe", ["--version"]), "tar") : Effect.succeed(true),