diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index bf2298427e411..855e59440ace4 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -4,12 +4,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AuthSessionId, + AuthSourceControlWriteScope, EnvironmentId, ProjectId, ThreadId, ProviderDriverKind, ProviderInstanceId, + SourceControlRepositoryError, WS_METHODS, + WsRpcGroup, WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, @@ -63,6 +66,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"; @@ -1240,6 +1244,94 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), ); +it.effect("a publish whose push fails still refreshes the new remote", () => + Effect.gen(function* () { + const refreshes = yield* Queue.unbounded(); + const exits = yield* Queue.unbounded(); + const services = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + publishRepository: (input) => + Effect.fail( + new SourceControlRepositoryError({ + provider: input.provider, + operation: "publishRepository", + detail: 'Created owner/repo and added it as remote "origin", but the push failed.', + }), + ), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: (cwd, options) => + Queue.offer(refreshes, `identity ${cwd} refresh=${options?.refresh === true}`).pipe( + Effect.as(null), + ), + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshStatus: (cwd) => + Queue.offer(refreshes, `status ${cwd}`).pipe(Effect.andThen(Effect.die("unused"))), + }), + 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("publish-test"), + subject: "publish-test", + method: "browser-session-cookie", + scopes: [AuthSourceControlWriteScope], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + { secretReferences: true }, + ).pipe( + Layer.provide(services), + Layer.merge(RpcAuthorization.layer([AuthSourceControlWriteScope])), + ), + ), + ); + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("publish"), + tag: WS_METHODS.sourceControlPublishRepository, + payload: { + cwd: "/repo", + provider: "github", + repository: "owner/repo", + visibility: "private", + }, + headers: Headers.empty, + }); + assert.equal(yield* Queue.take(exits), "Failure"); + assert.deepEqual( + [yield* Queue.take(refreshes), yield* Queue.take(refreshes)], + ["identity /repo refresh=true", "status /repo"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Pruned history cannot be replayed: a cursor below the boundary must resync from a snapshot. it.effect.each(["thread", "shell"] as const)( "%s resume from a cursor below the pruned boundary loads a snapshot", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e3c7fdaa47476..079adfb19ce44 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2921,8 +2921,12 @@ export const layerWsRpc = ( [WS_METHODS.subscribeProjectClones]: () => projectCloneTracker.stream, [WS_METHODS.sourceControlPublishRepository]: (input) => sourceControlRepositories.publishRepository(input).pipe( - Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), - Effect.tap(() => refreshGitStatus(input.cwd)), + // A failed push still leaves the new remote added. + Effect.ensuring( + repositoryIdentityResolver + .resolve(input.cwd, { refresh: true }) + .pipe(Effect.andThen(refreshGitStatus(input.cwd))), + ), ), [WS_METHODS.projectsSearchEntries]: (input) => workspaceEntries.search(input).pipe(