Skip to content

Commit b5c4c7a

Browse files
authored
Add stop support for pending git actions (#402)
- Track in-flight pull and stacked-action requests per websocket - Add a stop action API and surface stop buttons in the UI - Treat interrupted git actions as a handled info state
1 parent 2a22f8a commit b5c4c7a

9 files changed

Lines changed: 306 additions & 26 deletions

File tree

apps/server/src/wsServer.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2142,6 +2142,60 @@ describe("WebSocket Server", () => {
21422142
);
21432143
});
21442144

2145+
it("stops a pending git action for the initiating websocket", async () => {
2146+
const runStackedAction: GitManagerShape["runStackedAction"] = (input, options) =>
2147+
Effect.gen(function* () {
2148+
if (options?.progressReporter) {
2149+
yield* options.progressReporter.publish({
2150+
actionId: options.actionId ?? input.actionId,
2151+
cwd: input.cwd,
2152+
action: input.action,
2153+
kind: "phase_started",
2154+
phase: "commit",
2155+
label: "Committing...",
2156+
});
2157+
}
2158+
return yield* Effect.never;
2159+
});
2160+
const gitManager: GitManagerShape = {
2161+
status: vi.fn(() => Effect.void as any),
2162+
resolvePullRequest: vi.fn(() => Effect.void as any),
2163+
preparePullRequestThread: vi.fn(() => Effect.void as any),
2164+
runStackedAction,
2165+
listPullRequests: vi.fn(() => Effect.succeed({ pullRequests: [] })),
2166+
};
2167+
2168+
const { cwd } = makeWorkspaceFixture("test");
2169+
server = await createTestServer({ cwd, gitManager });
2170+
const addr = server.address();
2171+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2172+
2173+
const [ws] = await connectAndAwaitWelcome(port);
2174+
connections.push(ws);
2175+
2176+
const actionResponsePromise = sendRequest(ws, WS_METHODS.gitRunStackedAction, {
2177+
actionId: "client-action-stop",
2178+
cwd,
2179+
action: "commit",
2180+
});
2181+
await waitForPush(ws, WS_CHANNELS.gitActionProgress);
2182+
2183+
const stopResponse = await sendRequest(ws, WS_METHODS.gitStopAction, {
2184+
cwd,
2185+
actionId: "client-action-stop",
2186+
});
2187+
2188+
expect(stopResponse.error).toBeUndefined();
2189+
await expect(actionResponsePromise).resolves.toEqual(
2190+
expect.objectContaining({
2191+
error: expect.objectContaining({
2192+
code: "git_action_stopped",
2193+
message: "Git action stopped.",
2194+
}),
2195+
}),
2196+
);
2197+
});
2198+
21452199
it("rejects websocket connections without a valid auth token", async () => {
21462200
const { cwd } = makeWorkspaceFixture("test");
21472201
server = await createTestServer({ cwd, authToken: "secret-token" });

apps/server/src/wsServer.ts

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
Effect,
3939
Exit,
4040
FileSystem,
41+
Fiber,
4142
Layer,
4243
Path,
4344
Ref,
@@ -331,6 +332,13 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
331332
message: Schema.String,
332333
}) {}
333334

335+
class GitActionStoppedError extends Schema.TaggedErrorClass<GitActionStoppedError>()(
336+
"GitActionStoppedError",
337+
{
338+
message: Schema.String,
339+
},
340+
) {}
341+
334342
export const createServer = Effect.fn(function* (): Effect.fn.Return<
335343
http.Server,
336344
ServerLifecycleError,
@@ -374,6 +382,89 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
374382
const clients = yield* Ref.make(new Set<WebSocket>());
375383
const logger = createLogger("ws");
376384
const readiness = yield* makeServerReadiness;
385+
type ActiveGitRequestKind = "pull" | "stacked_action";
386+
type ActiveGitRequestHandle = {
387+
readonly kind: ActiveGitRequestKind;
388+
readonly cwd: string;
389+
readonly actionId: string | null;
390+
readonly fiber: Fiber.Fiber<unknown, unknown>;
391+
};
392+
const activeGitRequests = new WeakMap<WebSocket, Set<ActiveGitRequestHandle>>();
393+
394+
const registerActiveGitRequest = (ws: WebSocket, handle: ActiveGitRequestHandle) =>
395+
Effect.sync(() => {
396+
const handles = activeGitRequests.get(ws) ?? new Set<ActiveGitRequestHandle>();
397+
handles.add(handle);
398+
activeGitRequests.set(ws, handles);
399+
});
400+
401+
const unregisterActiveGitRequest = (ws: WebSocket, handle: ActiveGitRequestHandle) =>
402+
Effect.sync(() => {
403+
const handles = activeGitRequests.get(ws);
404+
if (!handles) {
405+
return;
406+
}
407+
handles.delete(handle);
408+
if (handles.size === 0) {
409+
activeGitRequests.delete(ws);
410+
}
411+
});
412+
413+
const interruptActiveGitRequests = (ws: WebSocket) =>
414+
Effect.gen(function* () {
415+
const handles = Array.from(activeGitRequests.get(ws) ?? []);
416+
activeGitRequests.delete(ws);
417+
for (const handle of handles) {
418+
yield* Fiber.interrupt(handle.fiber).pipe(Effect.ignore);
419+
}
420+
});
421+
422+
const stopActiveGitRequest = (
423+
ws: WebSocket,
424+
input: { cwd: string; actionId?: string | undefined },
425+
) =>
426+
Effect.gen(function* () {
427+
const handles = Array.from(activeGitRequests.get(ws) ?? []);
428+
const handle =
429+
input.actionId != null
430+
? handles.find(
431+
(candidate) => candidate.cwd === input.cwd && candidate.actionId === input.actionId,
432+
)
433+
: handles.find((candidate) => candidate.cwd === input.cwd);
434+
435+
if (!handle) {
436+
return;
437+
}
438+
439+
yield* Fiber.interrupt(handle.fiber);
440+
});
441+
442+
const runTrackedGitRequest = <A, E>(
443+
ws: WebSocket,
444+
meta: { kind: ActiveGitRequestKind; cwd: string; actionId?: string | undefined },
445+
effect: Effect.Effect<A, E, never>,
446+
interruptedMessage: string,
447+
): Effect.Effect<A, E | GitActionStoppedError> =>
448+
Effect.gen(function* () {
449+
const fiber = yield* Effect.forkScoped(effect);
450+
const handle: ActiveGitRequestHandle = {
451+
kind: meta.kind,
452+
cwd: meta.cwd,
453+
actionId: meta.actionId ?? null,
454+
fiber,
455+
};
456+
yield* registerActiveGitRequest(ws, handle);
457+
const exit = yield* Fiber.await(fiber).pipe(
458+
Effect.ensuring(unregisterActiveGitRequest(ws, handle)),
459+
);
460+
if (Exit.isSuccess(exit)) {
461+
return exit.value;
462+
}
463+
if (Cause.hasInterruptsOnly(exit.cause)) {
464+
return yield* new GitActionStoppedError({ message: interruptedMessage });
465+
}
466+
return yield* Effect.failCause(exit.cause as Cause.Cause<E>);
467+
}) as Effect.Effect<A, E | GitActionStoppedError, never>;
377468

378469
function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) {
379470
if (!logWebSocketEvents) return;
@@ -1117,24 +1208,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
11171208
const body = stripRequestTag(request.body);
11181209
const snapshot = yield* projectionReadModelQuery.getSnapshot();
11191210
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
1120-
return yield* git
1121-
.syncCurrentBranch(body.cwd)
1122-
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
1211+
return yield* runTrackedGitRequest(
1212+
ws,
1213+
{ kind: "pull", cwd: body.cwd },
1214+
git.syncCurrentBranch(body.cwd).pipe(Effect.provideService(RuntimeEnv, gitEnv)),
1215+
"Git pull stopped.",
1216+
);
1217+
}
1218+
1219+
case WS_METHODS.gitStopAction: {
1220+
const body = stripRequestTag(request.body);
1221+
yield* stopActiveGitRequest(ws, body);
1222+
return {};
11231223
}
11241224

11251225
case WS_METHODS.gitRunStackedAction: {
11261226
const body = stripRequestTag(request.body);
11271227
const snapshot = yield* projectionReadModelQuery.getSnapshot();
11281228
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
1129-
return yield* gitManager
1130-
.runStackedAction(body, {
1131-
actionId: body.actionId,
1132-
progressReporter: {
1133-
publish: (event) =>
1134-
pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid),
1135-
},
1136-
})
1137-
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
1229+
return yield* runTrackedGitRequest(
1230+
ws,
1231+
{ kind: "stacked_action", cwd: body.cwd, actionId: body.actionId },
1232+
gitManager
1233+
.runStackedAction(body, {
1234+
actionId: body.actionId,
1235+
progressReporter: {
1236+
publish: (event) =>
1237+
pushBus
1238+
.publishClient(ws, WS_CHANNELS.gitActionProgress, event)
1239+
.pipe(Effect.asVoid),
1240+
},
1241+
})
1242+
.pipe(Effect.provideService(RuntimeEnv, gitEnv)),
1243+
"Git action stopped.",
1244+
);
11381245
}
11391246

11401247
case WS_METHODS.gitResolvePullRequest: {
@@ -1702,6 +1809,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
17021809
};
17031810
}
17041811

1812+
if (
1813+
(request.body._tag === WS_METHODS.gitRunStackedAction ||
1814+
request.body._tag === WS_METHODS.gitPull) &&
1815+
Schema.is(GitActionStoppedError)(squashed)
1816+
) {
1817+
return {
1818+
message: redactSensitiveText(squashed.message),
1819+
code: "git_action_stopped",
1820+
};
1821+
}
1822+
17051823
if (squashed instanceof Error) {
17061824
return { message: redactSensitiveText(squashed.message) };
17071825
}
@@ -1798,19 +1916,25 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
17981916

17991917
ws.on("close", () => {
18001918
void runPromise(
1801-
Ref.update(clients, (clients) => {
1802-
clients.delete(ws);
1803-
return clients;
1804-
}),
1919+
Effect.all([
1920+
interruptActiveGitRequests(ws),
1921+
Ref.update(clients, (clients) => {
1922+
clients.delete(ws);
1923+
return clients;
1924+
}),
1925+
]).pipe(Effect.asVoid),
18051926
);
18061927
});
18071928

18081929
ws.on("error", () => {
18091930
void runPromise(
1810-
Ref.update(clients, (clients) => {
1811-
clients.delete(ws);
1812-
return clients;
1813-
}),
1931+
Effect.all([
1932+
interruptActiveGitRequests(ws),
1933+
Ref.update(clients, (clients) => {
1934+
clients.delete(ws);
1935+
return clients;
1936+
}),
1937+
]).pipe(Effect.asVoid),
18141938
);
18151939
});
18161940
});

apps/web/src/components/BranchToolbar.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
55

66
import {
77
gitPullMutationOptions,
8+
gitStopActionMutationOptions,
89
gitQueryKeys,
910
gitStatusQueryOptions,
1011
invalidateGitQueries,
@@ -13,6 +14,7 @@ import { newCommandId } from "../lib/utils";
1314
import { readNativeApi } from "../nativeApi";
1415
import { useComposerDraftStore } from "../composerDraftStore";
1516
import { useStore } from "../store";
17+
import { isWsRequestError } from "../wsTransport";
1618
import {
1719
EnvMode,
1820
resolveDraftEnvModeAfterBranchChange,
@@ -127,6 +129,7 @@ export default function BranchToolbar({
127129
const isDiverged = aheadCount > 0 && behindCount > 0;
128130
const needsSync = behindCount > 0 && !hasServerThread;
129131
const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
132+
const stopPullMutation = useMutation(gitStopActionMutationOptions({ cwd: gitCwd, queryClient }));
130133

131134
// Force a fresh git-status fetch when a draft thread mounts so we catch
132135
// upstream changes immediately instead of waiting for the next poll cycle.
@@ -164,8 +167,11 @@ export default function BranchToolbar({
164167
})
165168
.catch((error) => {
166169
toastManager.add({
167-
type: "error",
168-
title: "Pull failed",
170+
type: isWsRequestError(error) && error.code === "git_action_stopped" ? "info" : "error",
171+
title:
172+
isWsRequestError(error) && error.code === "git_action_stopped"
173+
? "Pull stopped"
174+
: "Pull failed",
169175
description: error instanceof Error ? error.message : "An error occurred.",
170176
});
171177
})
@@ -174,6 +180,17 @@ export default function BranchToolbar({
174180
});
175181
}, [pullMutation, queryClient]);
176182

183+
const handleStopPull = useCallback(() => {
184+
if (!pullMutation.isPending || stopPullMutation.isPending) return;
185+
void stopPullMutation.mutateAsync({}).catch((error) => {
186+
toastManager.add({
187+
type: "error",
188+
title: "Unable to stop pull",
189+
description: error instanceof Error ? error.message : "An error occurred.",
190+
});
191+
});
192+
}, [pullMutation.isPending, stopPullMutation]);
193+
177194
if (!activeThreadId || !activeProject) return null;
178195

179196
return (
@@ -259,6 +276,16 @@ export default function BranchToolbar({
259276
</TooltipPopup>
260277
</Tooltip>
261278
) : null}
279+
{pullMutation.isPending ? (
280+
<Button
281+
variant="destructive-outline"
282+
size="xs"
283+
disabled={stopPullMutation.isPending}
284+
onClick={handleStopPull}
285+
>
286+
Stop
287+
</Button>
288+
) : null}
262289
<BranchToolbarBranchSelector
263290
activeProjectCwd={activeProject.cwd}
264291
activeThreadBranch={activeThreadBranch}

0 commit comments

Comments
 (0)