Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 42 additions & 15 deletions src/webviews/tasks/tasksPanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class TasksPanelProvider

private view?: vscode.WebviewView;
private disposables: vscode.Disposable[] = [];
private useLegacyPauseResume = false;

// Workspace log streaming
private readonly buildLogStream = new LazyStream<ProvisionerJobLog>();
Expand Down Expand Up @@ -285,28 +286,54 @@ export class TasksPanelProvider
);
}

private async handlePauseTask(taskId: string): Promise<void> {
const task = await this.client.getTask("me", taskId);
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
private handlePauseTask(taskId: string): Promise<void> {
return this.pauseOrResumeTask(
taskId,
() => this.client.pauseTask("me", taskId),
(workspaceId) => this.client.stopWorkspace(workspaceId),
);
}

private handleResumeTask(taskId: string): Promise<void> {
return this.pauseOrResumeTask(
taskId,
() => this.client.resumeTask("me", taskId),
(workspaceId, task) =>
this.client.startWorkspace(workspaceId, task.template_version_id),
);
}

await this.client.stopWorkspace(task.workspace_id);
private async pauseOrResumeTask(
taskId: string,
taskApiCall: () => Promise<unknown>,
legacyCall: (workspaceId: string, task: Task) => Promise<unknown>,
): Promise<void> {
if (this.useLegacyPauseResume) {
return this.legacyPauseOrResume(taskId, legacyCall);
}

await this.refreshAndNotifyTask(taskId);
try {
await taskApiCall();
await this.refreshAndNotifyTask(taskId);
} catch (err) {
if (isAxiosError(err) && err.response?.status === 404) {
this.useLegacyPauseResume = true;
return this.legacyPauseOrResume(taskId, legacyCall);
}
throw err;
}
}

private async handleResumeTask(taskId: string): Promise<void> {
private async legacyPauseOrResume(
taskId: string,
legacyCall: (workspaceId: string, task: Task) => Promise<unknown>,
): Promise<void> {
const task = await this.client.getTask("me", taskId);
if (!task.workspace_id) {
const { workspace_id } = task;
if (!workspace_id) {
throw new Error("Task has no workspace");
}

await this.client.startWorkspace(
task.workspace_id,
task.template_version_id,
);

await legacyCall(workspace_id, task);
await this.refreshAndNotifyTask(taskId);
}

Expand Down
128 changes: 97 additions & 31 deletions test/unit/webviews/tasks/tasksPanelProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ type TasksPanelClient = Pick<
| "getTemplateVersionPresets"
| "startWorkspace"
| "stopWorkspace"
| "pauseTask"
| "resumeTask"
| "sendTaskInput"
| "getHost"
| "getWorkspace"
Expand All @@ -91,6 +93,8 @@ function createClient(baseUrl = "https://coder.example.com"): MockClient {
getTemplateVersionPresets: vi.fn().mockResolvedValue([]),
startWorkspace: vi.fn().mockResolvedValue(undefined),
stopWorkspace: vi.fn().mockResolvedValue(undefined),
pauseTask: vi.fn().mockResolvedValue(undefined),
resumeTask: vi.fn().mockResolvedValue(undefined),
sendTaskInput: vi.fn().mockResolvedValue(undefined),
getHost: vi.fn().mockReturnValue(baseUrl),
getWorkspace: vi.fn().mockResolvedValue(workspace()),
Expand Down Expand Up @@ -413,40 +417,102 @@ describe("TasksPanelProvider", () => {
});

describe("pauseTask / resumeTask", () => {
interface WorkspaceControlTestCase {
method: typeof TasksApi.pauseTask;
clientMethod: keyof MockClient;
taskOverrides: Partial<Task>;
}
it.each<WorkspaceControlTestCase>([
{
method: TasksApi.pauseTask,
clientMethod: "stopWorkspace",
taskOverrides: { workspace_id: "ws-1" },
},
{
method: TasksApi.resumeTask,
clientMethod: "startWorkspace",
taskOverrides: { workspace_id: "ws-1", template_version_id: "tv-1" },
},
])(
"$method.method calls $clientMethod",
async ({ method, clientMethod, taskOverrides }) => {
const h = createHarness();
h.client.getTask.mockResolvedValue(task(taskOverrides));
it("pauseTask calls client.pauseTask", async () => {
const h = createHarness();
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));

const res = await h.request(method, {
taskId: "task-1",
taskName: "Test Task",
});
const res = await h.request(TasksApi.pauseTask, {
taskId: "task-1",
taskName: "Test Task",
});

expect(res.success).toBe(true);
expect(h.client[clientMethod]).toHaveBeenCalled();
},
);
expect(res.success).toBe(true);
expect(h.client.pauseTask).toHaveBeenCalledWith("me", "task-1");
expect(h.client.stopWorkspace).not.toHaveBeenCalled();
});

it("resumeTask calls client.resumeTask", async () => {
const h = createHarness();
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));

const res = await h.request(TasksApi.resumeTask, {
taskId: "task-1",
taskName: "Test Task",
});

expect(res.success).toBe(true);
expect(h.client.resumeTask).toHaveBeenCalledWith("me", "task-1");
expect(h.client.startWorkspace).not.toHaveBeenCalled();
});

it("pauseTask falls back to stopWorkspace on 404", async () => {
const h = createHarness();
h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found"));
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));

const res = await h.request(TasksApi.pauseTask, {
taskId: "task-1",
taskName: "Test Task",
});

expect(res.success).toBe(true);
expect(h.client.stopWorkspace).toHaveBeenCalledWith("ws-1");
});

it("resumeTask falls back to startWorkspace on 404", async () => {
const h = createHarness();
h.client.resumeTask.mockRejectedValue(createAxiosError(404, "Not found"));
h.client.getTask.mockResolvedValue(
task({ workspace_id: "ws-1", template_version_id: "tv-1" }),
);

const res = await h.request(TasksApi.resumeTask, {
taskId: "task-1",
taskName: "Test Task",
});

expect(res.success).toBe(true);
expect(h.client.startWorkspace).toHaveBeenCalledWith("ws-1", "tv-1");
});

it("caches legacy fallback after first 404", async () => {
const h = createHarness();
h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found"));
h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" }));

await h.request(TasksApi.pauseTask, {
taskId: "task-1",
taskName: "Test Task",
});
h.client.pauseTask.mockClear();

await h.request(TasksApi.pauseTask, {
taskId: "task-1",
taskName: "Test Task",
});

expect(h.client.pauseTask).not.toHaveBeenCalled();
expect(h.client.stopWorkspace).toHaveBeenCalledTimes(2);
});

it("propagates non-404 errors without fallback", async () => {
const h = createHarness();
h.client.pauseTask.mockRejectedValue(
createAxiosError(500, "Internal server error"),
);

const res = await h.request(TasksApi.pauseTask, {
taskId: "task-1",
taskName: "Test Task",
});

expect(res.success).toBe(false);
expect(h.client.stopWorkspace).not.toHaveBeenCalled();
});

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

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

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

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