Skip to content

Commit 4d3addd

Browse files
authored
feat(sessions): add stop run control for cloud runs (#3382)
1 parent f118d10 commit 4d3addd

26 files changed

Lines changed: 802 additions & 32 deletions

apps/code/src/main/di/container.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,12 @@ container.bind(CLOUD_TASK_AUTH).toDynamicValue((ctx) => ({
419419
ctx
420420
.get<AuthService>(MAIN_AUTH_SERVICE)
421421
.authenticatedFetch(fetch, url, init),
422+
getCloudContext: async () => {
423+
const auth = ctx.get<AuthService>(MAIN_AUTH_SERVICE);
424+
const { apiHost } = await auth.getValidAccessToken();
425+
const teamId = auth.getState().currentProjectId;
426+
return teamId === null ? null : { apiHost, teamId };
427+
},
422428
}));
423429
container.bind(MAIN_CLOUD_TASK_SERVICE).toService(CLOUD_TASK_SERVICE);
424430
container.load(contextMenuCoreModule);

packages/core/src/archive/archiveOrchestration.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Harness {
2626
restoreCommandCenter: vi.fn(),
2727
getFocusedWorktreePath: vi.fn().mockReturnValue(null),
2828
disableFocus: vi.fn().mockResolvedValue(undefined),
29+
stopCloudRun: vi.fn().mockResolvedValue(true),
2930
disconnectFromTask: vi.fn().mockResolvedValue(undefined),
3031
archive: vi.fn().mockResolvedValue(undefined),
3132
logError: vi.fn(),
@@ -122,6 +123,43 @@ describe("archiveTask", () => {
122123

123124
expect(harness.deps.clearTerminalStates).not.toHaveBeenCalled();
124125
});
126+
127+
it("stops a running cloud task before archiving it", async () => {
128+
await archiveTask(TASK_ID, harness.deps);
129+
130+
expect(harness.deps.stopCloudRun).toHaveBeenCalledWith(TASK_ID);
131+
expect(
132+
vi.mocked(harness.deps.stopCloudRun).mock.invocationCallOrder[0],
133+
).toBeLessThan(
134+
vi.mocked(harness.deps.archive).mock.invocationCallOrder[0] ?? Infinity,
135+
);
136+
});
137+
138+
it("does not archive when a running cloud task cannot be stopped", async () => {
139+
harness.deps.stopCloudRun = vi.fn().mockResolvedValue(false);
140+
141+
await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow(
142+
"Couldn't stop the task",
143+
);
144+
145+
expect(harness.deps.archive).not.toHaveBeenCalled();
146+
expect(harness.ids).not.toContain(TASK_ID);
147+
});
148+
149+
it.each([
150+
["local workspace", { mode: "local" }],
151+
["task without workspace state", null],
152+
])(
153+
"checks a %s for a cloud run before archiving",
154+
async (_name, workspace) => {
155+
harness.deps.getWorkspace = vi.fn().mockResolvedValue(workspace);
156+
157+
await archiveTask(TASK_ID, harness.deps);
158+
159+
expect(harness.deps.stopCloudRun).toHaveBeenCalledWith(TASK_ID);
160+
expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID);
161+
},
162+
);
125163
});
126164

127165
describe("archiveTasks", () => {

packages/core/src/archive/archiveOrchestration.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface ArchiveOrchestrationDeps {
3636
): void;
3737
getFocusedWorktreePath(): string | null | undefined;
3838
disableFocus(): Promise<void>;
39+
stopCloudRun(taskId: string, runId?: string): Promise<boolean>;
3940
disconnectFromTask(taskId: string): Promise<void>;
4041
archive(taskId: string): Promise<void>;
4142
logError(message: string, error: unknown): void;
@@ -58,8 +59,13 @@ export async function archiveTask(
5859
deps: ArchiveOrchestrationDeps,
5960
options?: ArchiveTaskOptions,
6061
): Promise<void> {
61-
const optimistic = options?.optimistic ?? true;
6262
const workspace = await deps.getWorkspace(taskId);
63+
const stopped = await deps.stopCloudRun(taskId);
64+
if (!stopped) {
65+
throw new Error("Couldn't stop the task. Try again in a moment.");
66+
}
67+
68+
const optimistic = options?.optimistic ?? true;
6369
const pinnedTaskIds = await deps.getPinnedTaskIds();
6470
const wasPinned = pinnedTaskIds.includes(taskId);
6571

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { CloudTaskService } from "./cloud-task";
2626

2727
const mockAuthService = {
2828
authenticatedFetch: vi.fn(),
29+
getCloudContext: vi.fn(),
2930
};
3031

3132
function createJsonResponse(
@@ -114,6 +115,11 @@ describe("CloudTaskService", () => {
114115
),
115116
);
116117
mockAuthService.authenticatedFetch.mockReset();
118+
mockAuthService.getCloudContext.mockReset();
119+
mockAuthService.getCloudContext.mockResolvedValue({
120+
apiHost: "https://us.posthog.com",
121+
teamId: 2,
122+
});
117123
vi.stubGlobal("fetch", fetchRouter);
118124

119125
mockAuthService.authenticatedFetch.mockImplementation(
@@ -2335,6 +2341,42 @@ describe("CloudTaskService", () => {
23352341
).toBe(false);
23362342
});
23372343

2344+
it("stops a cloud run through the run cancel endpoint", async () => {
2345+
mockNetFetch.mockResolvedValueOnce(
2346+
createJsonResponse({ id: "run-1", status: "in_progress" }, 202),
2347+
);
2348+
2349+
const result = await service.stop({
2350+
taskId: "task-1",
2351+
runId: "run-1",
2352+
});
2353+
2354+
expect(result).toEqual({ success: true, runStatus: "in_progress" });
2355+
const [url, init] = mockNetFetch.mock.calls[0] as [string, RequestInit];
2356+
expect(url).toBe(
2357+
"https://us.posthog.com/api/projects/2/tasks/task-1/runs/run-1/cancel/",
2358+
);
2359+
expect(init.method).toBe("POST");
2360+
});
2361+
2362+
it("surfaces the backend error and retryability when a stop fails", async () => {
2363+
mockNetFetch.mockResolvedValueOnce(
2364+
createJsonResponse(
2365+
{ error: "Could not reach the run's workflow; try again" },
2366+
503,
2367+
),
2368+
);
2369+
2370+
const result = await service.stop({
2371+
taskId: "task-1",
2372+
runId: "run-1",
2373+
});
2374+
2375+
expect(result.success).toBe(false);
2376+
expect(result.error).toBe("Could not reach the run's workflow; try again");
2377+
expect(result.retryable).toBe(true);
2378+
});
2379+
23382380
it("does not let a stale backend-error count inflate a transport reconnect delay", async () => {
23392381
vi.useFakeTimers();
23402382

packages/core/src/cloud-task/cloud-task.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
isTerminalStatus,
2020
type SendCommandInput,
2121
type SendCommandOutput,
22+
type StopInput,
23+
type StopOutput,
2224
type TaskRunStatus,
2325
type WatchInput,
2426
} from "./schemas";
@@ -520,6 +522,65 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
520522
}
521523
}
522524

525+
async stop(input: StopInput): Promise<StopOutput> {
526+
try {
527+
const context = await this.auth.getCloudContext();
528+
if (!context) {
529+
return { success: false, error: "No active cloud project" };
530+
}
531+
const url = `${context.apiHost}/api/projects/${context.teamId}/tasks/${input.taskId}/runs/${input.runId}/cancel/`;
532+
const response = await this.auth.authenticatedFetch(url, {
533+
method: "POST",
534+
headers: {
535+
"Content-Type": "application/json",
536+
},
537+
body: JSON.stringify(input.reason ? { reason: input.reason } : {}),
538+
});
539+
540+
if (!response.ok) {
541+
const errorText = await response.text().catch(() => "");
542+
let errorMessage = `Stop failed with status ${response.status}`;
543+
try {
544+
const errorJson = JSON.parse(errorText) as { error?: unknown };
545+
if (typeof errorJson.error === "string" && errorJson.error) {
546+
errorMessage = errorJson.error;
547+
}
548+
} catch {
549+
if (errorText) errorMessage = errorText;
550+
}
551+
552+
this.log.warn("Cloud run stop failed", {
553+
taskId: input.taskId,
554+
runId: input.runId,
555+
status: response.status,
556+
error: errorMessage,
557+
});
558+
return {
559+
success: false,
560+
error: errorMessage,
561+
retryable: response.status === 503 || response.status >= 500,
562+
};
563+
}
564+
565+
const data = (await response.json()) as { status?: string };
566+
this.log.info("Cloud run stop accepted", {
567+
taskId: input.taskId,
568+
runId: input.runId,
569+
runStatus: data.status,
570+
});
571+
return { success: true, runStatus: data.status };
572+
} catch (error) {
573+
const errorMessage =
574+
error instanceof Error ? error.message : "Unknown error";
575+
this.log.error("Cloud run stop error", {
576+
taskId: input.taskId,
577+
runId: input.runId,
578+
error: errorMessage,
579+
});
580+
return { success: false, error: errorMessage, retryable: true };
581+
}
582+
}
583+
523584
@preDestroy()
524585
unwatchAll(): void {
525586
for (const key of [...this.watchers.keys()]) {

packages/core/src/cloud-task/identifiers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export const CLOUD_TASK_AUTH = Symbol.for("posthog.core.cloudTaskAuth");
33

44
export interface ICloudTaskAuth {
55
authenticatedFetch(url: string, init?: RequestInit): Promise<Response>;
6+
getCloudContext(): Promise<{ apiHost: string; teamId: number } | null>;
67
}

packages/core/src/cloud-task/schemas.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,20 @@ export const sendCommandOutput = z.object({
7777
});
7878

7979
export type SendCommandOutput = z.infer<typeof sendCommandOutput>;
80+
81+
export const stopInput = z.object({
82+
taskId: z.string(),
83+
runId: z.string(),
84+
reason: z.string().optional(),
85+
});
86+
87+
export type StopInput = z.infer<typeof stopInput>;
88+
89+
export const stopOutput = z.object({
90+
success: z.boolean(),
91+
runStatus: z.string().optional(),
92+
error: z.string().optional(),
93+
retryable: z.boolean().optional(),
94+
});
95+
96+
export type StopOutput = z.infer<typeof stopOutput>;

packages/core/src/context-menu/context-menu.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,22 @@ describe("ContextMenuService.showTaskContextMenu", () => {
103103
expect(labels(menu.lastItems)).not.toContain("Suspend");
104104
});
105105

106+
it("offers Stop task only for a stoppable run", async () => {
107+
const running = new FakeContextMenu();
108+
const result = makeService(running).showTaskContextMenu({
109+
...baseTask,
110+
canStop: true,
111+
});
112+
await running.shown;
113+
findItem(running.lastItems, "Stop task").click();
114+
expect(await result).toEqual({ action: { type: "stop" } });
115+
116+
const idle = new FakeContextMenu();
117+
makeService(idle).showTaskContextMenu(baseTask);
118+
await idle.shown;
119+
expect(labels(idle.lastItems)).not.toContain("Stop task");
120+
});
121+
106122
it("hides Add to Command Center when already in it", async () => {
107123
const inCc = new FakeContextMenu();
108124
makeService(inCc).showTaskContextMenu({

packages/core/src/context-menu/context-menu.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class ContextMenuService {
114114
folderPath,
115115
isPinned,
116116
isSuspended,
117+
canStop,
117118
isInCommandCenter,
118119
hasEmptyCommandCenterCell,
119120
channels,
@@ -141,6 +142,9 @@ export class ContextMenuService {
141142
return this.showMenu<TaskAction>([
142143
this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }),
143144
this.item("Rename", { type: "rename" }),
145+
...(canStop
146+
? [this.separator(), this.item("Stop task", { type: "stop" as const })]
147+
: []),
144148
...(worktreePath
145149
? [
146150
this.separator(),

packages/core/src/context-menu/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const taskContextMenuInput = z.object({
66
folderPath: z.string().optional(),
77
isPinned: z.boolean().optional(),
88
isSuspended: z.boolean().optional(),
9+
canStop: z.boolean().optional(),
910
isInCommandCenter: z.boolean().optional(),
1011
hasEmptyCommandCenterCell: z.boolean().optional(),
1112
// Top-level desktop_file_system channels available as "File to…" targets.
@@ -45,6 +46,7 @@ const taskAction = z.discriminatedUnion("type", [
4546
z.object({ type: z.literal("rename") }),
4647
z.object({ type: z.literal("pin") }),
4748
z.object({ type: z.literal("suspend") }),
49+
z.object({ type: z.literal("stop") }),
4850
z.object({ type: z.literal("archive") }),
4951
z.object({ type: z.literal("archive-prior") }),
5052
z.object({ type: z.literal("delete") }),

0 commit comments

Comments
 (0)