From 92ad79fd5e39716fd37d420a638d56208295999e Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:10:30 +1100 Subject: [PATCH] fix(server): a git action that fails partway refreshes the Git panel Co-Authored-By: Claude Opus 5.5 --- apps/server/src/ws.test.ts | 71 ++++++++++++++++++++++++++++++++++++++ apps/server/src/ws.ts | 4 ++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index caa06c2e97a80..d5c0765f5c985 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -3,12 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AuthSessionId, + AuthSourceControlWriteScope, EnvironmentId, + GitManagerError, ProjectId, ThreadId, ProviderDriverKind, ProviderInstanceId, WS_METHODS, + WsRpcGroup, WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, @@ -58,6 +61,7 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; +import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -1035,6 +1039,73 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), ); +it.effect("a git action that fails partway still refreshes the repository status", () => + Effect.gen(function* () { + const failure = new GitManagerError({ operation: "test", cwd: "/repo", detail: "push failed" }); + const refreshed = yield* Deferred.make(); + const exits = yield* Queue.unbounded(); + const services = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(GitWorkflowService.GitWorkflowService)({ + runStackedAction: () => Effect.fail(failure), + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshStatus: (cwd) => + Deferred.succeed(refreshed, cwd).pipe(Effect.andThen(Effect.fail(failure))), + }), + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(UsageLimitSources.UsageLimitSources)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + ); + const server = yield* RpcServer.makeNoSerialization(WsRpcGroup, { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Exit" ? Queue.offer(exits, response.exit._tag) : Effect.void, + }).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("git-test"), + subject: "git-test", + method: "browser-session-cookie", + scopes: [AuthSourceControlWriteScope], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + ).pipe( + Layer.provide(services), + Layer.merge(RpcAuthorization.layer([AuthSourceControlWriteScope])), + ), + ), + ); + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("git"), + tag: WS_METHODS.gitRunStackedAction, + payload: { actionId: "action", cwd: "/repo", action: "commit_push" }, + headers: Headers.empty, + }); + assert.equal(yield* Queue.take(exits), "Failure"); + assert.equal(yield* Deferred.await(refreshed), "/repo"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 00842afde41b7..d77622c3d5011 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3031,7 +3031,9 @@ export const layerWsRpc = ( }) .pipe( Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), + // Earlier steps (branch, commit) may have landed before the failing one. + onFailure: (cause) => + refreshGitStatus(input.cwd).pipe(Effect.andThen(Queue.failCause(queue, cause))), onSuccess: (result) => (input.threadId === undefined ? Effect.void