Skip to content

Commit 8668d7b

Browse files
authored
Use task pause/resume API instead of workspace stop/start (#811)
Use the dedicated pauseTask/resumeTask endpoints which set the correct build_reason for log snapshot capture and task lifecycle tracking. Falls back to stopWorkspace/startWorkspace on 404 for older Coder servers. Closes #795
1 parent 57fa4c2 commit 8668d7b

File tree

2 files changed

+139
-46
lines changed

2 files changed

+139
-46
lines changed

src/webviews/tasks/tasksPanelProvider.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class TasksPanelProvider
8888

8989
private view?: vscode.WebviewView;
9090
private disposables: vscode.Disposable[] = [];
91+
private useLegacyPauseResume = false;
9192

9293
// Workspace log streaming
9394
private readonly buildLogStream = new LazyStream<ProvisionerJobLog>();
@@ -285,28 +286,54 @@ export class TasksPanelProvider
285286
);
286287
}
287288

288-
private async handlePauseTask(taskId: string): Promise<void> {
289-
const task = await this.client.getTask("me", taskId);
290-
if (!task.workspace_id) {
291-
throw new Error("Task has no workspace");
292-
}
289+
private handlePauseTask(taskId: string): Promise<void> {
290+
return this.pauseOrResumeTask(
291+
taskId,
292+
() => this.client.pauseTask("me", taskId),
293+
(workspaceId) => this.client.stopWorkspace(workspaceId),
294+
);
295+
}
296+
297+
private handleResumeTask(taskId: string): Promise<void> {
298+
return this.pauseOrResumeTask(
299+
taskId,
300+
() => this.client.resumeTask("me", taskId),
301+
(workspaceId, task) =>
302+
this.client.startWorkspace(workspaceId, task.template_version_id),
303+
);
304+
}
293305

294-
await this.client.stopWorkspace(task.workspace_id);
306+
private async pauseOrResumeTask(
307+
taskId: string,
308+
taskApiCall: () => Promise<unknown>,
309+
legacyCall: (workspaceId: string, task: Task) => Promise<unknown>,
310+
): Promise<void> {
311+
if (this.useLegacyPauseResume) {
312+
return this.legacyPauseOrResume(taskId, legacyCall);
313+
}
295314

296-
await this.refreshAndNotifyTask(taskId);
315+
try {
316+
await taskApiCall();
317+
await this.refreshAndNotifyTask(taskId);
318+
} catch (err) {
319+
if (isAxiosError(err) && err.response?.status === 404) {
320+
this.useLegacyPauseResume = true;
321+
return this.legacyPauseOrResume(taskId, legacyCall);
322+
}
323+
throw err;
324+
}
297325
}
298326

299-
private async handleResumeTask(taskId: string): Promise<void> {
327+
private async legacyPauseOrResume(
328+
taskId: string,
329+
legacyCall: (workspaceId: string, task: Task) => Promise<unknown>,
330+
): Promise<void> {
300331
const task = await this.client.getTask("me", taskId);
301-
if (!task.workspace_id) {
332+
const { workspace_id } = task;
333+
if (!workspace_id) {
302334
throw new Error("Task has no workspace");
303335
}
304-
305-
await this.client.startWorkspace(
306-
task.workspace_id,
307-
task.template_version_id,
308-
);
309-
336+
await legacyCall(workspace_id, task);
310337
await this.refreshAndNotifyTask(taskId);
311338
}
312339

test/unit/webviews/tasks/tasksPanelProvider.test.ts

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ type TasksPanelClient = Pick<
7373
| "getTemplateVersionPresets"
7474
| "startWorkspace"
7575
| "stopWorkspace"
76+
| "pauseTask"
77+
| "resumeTask"
7678
| "sendTaskInput"
7779
| "getHost"
7880
| "getWorkspace"
@@ -91,6 +93,8 @@ function createClient(baseUrl = "https://coder.example.com"): MockClient {
9193
getTemplateVersionPresets: vi.fn().mockResolvedValue([]),
9294
startWorkspace: vi.fn().mockResolvedValue(undefined),
9395
stopWorkspace: vi.fn().mockResolvedValue(undefined),
96+
pauseTask: vi.fn().mockResolvedValue(undefined),
97+
resumeTask: vi.fn().mockResolvedValue(undefined),
9498
sendTaskInput: vi.fn().mockResolvedValue(undefined),
9599
getHost: vi.fn().mockReturnValue(baseUrl),
96100
getWorkspace: vi.fn().mockResolvedValue(workspace()),
@@ -413,40 +417,102 @@ describe("TasksPanelProvider", () => {
413417
});
414418

415419
describe("pauseTask / resumeTask", () => {
416-
interface WorkspaceControlTestCase {
417-
method: typeof TasksApi.pauseTask;
418-
clientMethod: keyof MockClient;
419-
taskOverrides: Partial<Task>;
420-
}
421-
it.each<WorkspaceControlTestCase>([
422-
{
423-
method: TasksApi.pauseTask,
424-
clientMethod: "stopWorkspace",
425-
taskOverrides: { workspace_id: "ws-1" },
426-
},
427-
{
428-
method: TasksApi.resumeTask,
429-
clientMethod: "startWorkspace",
430-
taskOverrides: { workspace_id: "ws-1", template_version_id: "tv-1" },
431-
},
432-
])(
433-
"$method.method calls $clientMethod",
434-
async ({ method, clientMethod, taskOverrides }) => {
435-
const h = createHarness();
436-
h.client.getTask.mockResolvedValue(task(taskOverrides));
420+
it("pauseTask calls client.pauseTask", async () => {
421+
const h = createHarness();
422+
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));
437423

438-
const res = await h.request(method, {
439-
taskId: "task-1",
440-
taskName: "Test Task",
441-
});
424+
const res = await h.request(TasksApi.pauseTask, {
425+
taskId: "task-1",
426+
taskName: "Test Task",
427+
});
442428

443-
expect(res.success).toBe(true);
444-
expect(h.client[clientMethod]).toHaveBeenCalled();
445-
},
446-
);
429+
expect(res.success).toBe(true);
430+
expect(h.client.pauseTask).toHaveBeenCalledWith("me", "task-1");
431+
expect(h.client.stopWorkspace).not.toHaveBeenCalled();
432+
});
433+
434+
it("resumeTask calls client.resumeTask", async () => {
435+
const h = createHarness();
436+
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));
437+
438+
const res = await h.request(TasksApi.resumeTask, {
439+
taskId: "task-1",
440+
taskName: "Test Task",
441+
});
442+
443+
expect(res.success).toBe(true);
444+
expect(h.client.resumeTask).toHaveBeenCalledWith("me", "task-1");
445+
expect(h.client.startWorkspace).not.toHaveBeenCalled();
446+
});
447+
448+
it("pauseTask falls back to stopWorkspace on 404", async () => {
449+
const h = createHarness();
450+
h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found"));
451+
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));
452+
453+
const res = await h.request(TasksApi.pauseTask, {
454+
taskId: "task-1",
455+
taskName: "Test Task",
456+
});
457+
458+
expect(res.success).toBe(true);
459+
expect(h.client.stopWorkspace).toHaveBeenCalledWith("ws-1");
460+
});
461+
462+
it("resumeTask falls back to startWorkspace on 404", async () => {
463+
const h = createHarness();
464+
h.client.resumeTask.mockRejectedValue(createAxiosError(404, "Not found"));
465+
h.client.getTask.mockResolvedValue(
466+
task({ workspace_id: "ws-1", template_version_id: "tv-1" }),
467+
);
468+
469+
const res = await h.request(TasksApi.resumeTask, {
470+
taskId: "task-1",
471+
taskName: "Test Task",
472+
});
473+
474+
expect(res.success).toBe(true);
475+
expect(h.client.startWorkspace).toHaveBeenCalledWith("ws-1", "tv-1");
476+
});
477+
478+
it("caches legacy fallback after first 404", async () => {
479+
const h = createHarness();
480+
h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found"));
481+
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));
482+
483+
await h.request(TasksApi.pauseTask, {
484+
taskId: "task-1",
485+
taskName: "Test Task",
486+
});
487+
h.client.pauseTask.mockClear();
488+
489+
await h.request(TasksApi.pauseTask, {
490+
taskId: "task-1",
491+
taskName: "Test Task",
492+
});
493+
494+
expect(h.client.pauseTask).not.toHaveBeenCalled();
495+
expect(h.client.stopWorkspace).toHaveBeenCalledTimes(2);
496+
});
497+
498+
it("propagates non-404 errors without fallback", async () => {
499+
const h = createHarness();
500+
h.client.pauseTask.mockRejectedValue(
501+
createAxiosError(500, "Internal server error"),
502+
);
503+
504+
const res = await h.request(TasksApi.pauseTask, {
505+
taskId: "task-1",
506+
taskName: "Test Task",
507+
});
508+
509+
expect(res.success).toBe(false);
510+
expect(h.client.stopWorkspace).not.toHaveBeenCalled();
511+
});
447512

448-
it("pauseTask fails when no workspace", async () => {
513+
it("legacy pause fails when task has no workspace", async () => {
449514
const h = createHarness();
515+
h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found"));
450516
h.client.getTask.mockResolvedValue(task({ workspace_id: null }));
451517

452518
const res = await h.request(TasksApi.pauseTask, {
@@ -719,7 +785,7 @@ describe("TasksPanelProvider", () => {
719785

720786
it("shows error notification for user action failures", async () => {
721787
const h = createHarness();
722-
h.client.getTask.mockRejectedValue(new Error("Workspace unavailable"));
788+
h.client.pauseTask.mockRejectedValue(new Error("Workspace unavailable"));
723789

724790
const res = await h.request(TasksApi.pauseTask, {
725791
taskId: "task-1",

0 commit comments

Comments
 (0)