diff --git a/docs/openapi/downloader.json b/docs/openapi/downloader.json index c557d2a8..c6e8d596 100644 --- a/docs/openapi/downloader.json +++ b/docs/openapi/downloader.json @@ -184,6 +184,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -331,6 +332,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -708,6 +710,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -1008,6 +1011,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -1194,6 +1198,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -1471,6 +1476,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -1728,6 +1734,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", @@ -2119,6 +2126,7 @@ "billing_paused", "pausing", "paused", + "interrupted", "uploading", "canceling", "completed", diff --git a/downloader/internal/client/client.go b/downloader/internal/client/client.go index 9069acfd..180913ac 100644 --- a/downloader/internal/client/client.go +++ b/downloader/internal/client/client.go @@ -196,6 +196,7 @@ func (c *Client) AssignedTasks(ctx context.Context) ([]DownloadTask, error) { return c.assignedTasks(ctx, []openapi.GetApiDownloadTasksParamsStatus{ openapi.GetApiDownloadTasksParamsStatusAssigned, openapi.GetApiDownloadTasksParamsStatusRunning, + openapi.GetApiDownloadTasksParamsStatusInterrupted, openapi.GetApiDownloadTasksParamsStatusUploading, }) } diff --git a/downloader/internal/client/client_test.go b/downloader/internal/client/client_test.go index 753f079d..6cb742ef 100644 --- a/downloader/internal/client/client_test.go +++ b/downloader/internal/client/client_test.go @@ -53,12 +53,12 @@ func TestAssignedTasksFetchesRecoverableStatuses(t *testing.T) { t.Fatal(err) } sort.Strings(statuses) - expected := []string{"assigned", "running", "uploading"} + expected := []string{"assigned", "interrupted", "running", "uploading"} if !reflect.DeepEqual(statuses, expected) { t.Fatalf("expected recoverable statuses %v, got %v", expected, statuses) } - if len(tasks) != 3 { - t.Fatalf("expected three tasks, got %d", len(tasks)) + if len(tasks) != 4 { + t.Fatalf("expected four tasks, got %d", len(tasks)) } } diff --git a/downloader/internal/openapi/client.gen.go b/downloader/internal/openapi/client.gen.go index 3cf05f9c..766e4eb4 100644 --- a/downloader/internal/openapi/client.gen.go +++ b/downloader/internal/openapi/client.gen.go @@ -171,6 +171,7 @@ const ( GetApiDownloadTasksParamsStatusCanceling GetApiDownloadTasksParamsStatus = "canceling" GetApiDownloadTasksParamsStatusCompleted GetApiDownloadTasksParamsStatus = "completed" GetApiDownloadTasksParamsStatusFailed GetApiDownloadTasksParamsStatus = "failed" + GetApiDownloadTasksParamsStatusInterrupted GetApiDownloadTasksParamsStatus = "interrupted" GetApiDownloadTasksParamsStatusPaused GetApiDownloadTasksParamsStatus = "paused" GetApiDownloadTasksParamsStatusPausing GetApiDownloadTasksParamsStatus = "pausing" GetApiDownloadTasksParamsStatusQueued GetApiDownloadTasksParamsStatus = "queued" @@ -193,6 +194,8 @@ func (e GetApiDownloadTasksParamsStatus) Valid() bool { return true case GetApiDownloadTasksParamsStatusFailed: return true + case GetApiDownloadTasksParamsStatusInterrupted: + return true case GetApiDownloadTasksParamsStatusPaused: return true case GetApiDownloadTasksParamsStatusPausing: @@ -354,6 +357,7 @@ const ( GetApiDownloadTasks200JSONResponseBodyItemsStatusCanceling GetApiDownloadTasks200JSONResponseBodyItemsStatus = "canceling" GetApiDownloadTasks200JSONResponseBodyItemsStatusCompleted GetApiDownloadTasks200JSONResponseBodyItemsStatus = "completed" GetApiDownloadTasks200JSONResponseBodyItemsStatusFailed GetApiDownloadTasks200JSONResponseBodyItemsStatus = "failed" + GetApiDownloadTasks200JSONResponseBodyItemsStatusInterrupted GetApiDownloadTasks200JSONResponseBodyItemsStatus = "interrupted" GetApiDownloadTasks200JSONResponseBodyItemsStatusPaused GetApiDownloadTasks200JSONResponseBodyItemsStatus = "paused" GetApiDownloadTasks200JSONResponseBodyItemsStatusPausing GetApiDownloadTasks200JSONResponseBodyItemsStatus = "pausing" GetApiDownloadTasks200JSONResponseBodyItemsStatusQueued GetApiDownloadTasks200JSONResponseBodyItemsStatus = "queued" @@ -376,6 +380,8 @@ func (e GetApiDownloadTasks200JSONResponseBodyItemsStatus) Valid() bool { return true case GetApiDownloadTasks200JSONResponseBodyItemsStatusFailed: return true + case GetApiDownloadTasks200JSONResponseBodyItemsStatusInterrupted: + return true case GetApiDownloadTasks200JSONResponseBodyItemsStatusPaused: return true case GetApiDownloadTasks200JSONResponseBodyItemsStatusPausing: @@ -492,6 +498,7 @@ const ( PostApiDownloadTasks201JSONResponseBodyStatusCanceling PostApiDownloadTasks201JSONResponseBodyStatus = "canceling" PostApiDownloadTasks201JSONResponseBodyStatusCompleted PostApiDownloadTasks201JSONResponseBodyStatus = "completed" PostApiDownloadTasks201JSONResponseBodyStatusFailed PostApiDownloadTasks201JSONResponseBodyStatus = "failed" + PostApiDownloadTasks201JSONResponseBodyStatusInterrupted PostApiDownloadTasks201JSONResponseBodyStatus = "interrupted" PostApiDownloadTasks201JSONResponseBodyStatusPaused PostApiDownloadTasks201JSONResponseBodyStatus = "paused" PostApiDownloadTasks201JSONResponseBodyStatusPausing PostApiDownloadTasks201JSONResponseBodyStatus = "pausing" PostApiDownloadTasks201JSONResponseBodyStatusQueued PostApiDownloadTasks201JSONResponseBodyStatus = "queued" @@ -514,6 +521,8 @@ func (e PostApiDownloadTasks201JSONResponseBodyStatus) Valid() bool { return true case PostApiDownloadTasks201JSONResponseBodyStatusFailed: return true + case PostApiDownloadTasks201JSONResponseBodyStatusInterrupted: + return true case PostApiDownloadTasks201JSONResponseBodyStatusPaused: return true case PostApiDownloadTasks201JSONResponseBodyStatusPausing: @@ -537,6 +546,7 @@ const ( GetApiDownloadTasksEventsParamsStatusCanceling GetApiDownloadTasksEventsParamsStatus = "canceling" GetApiDownloadTasksEventsParamsStatusCompleted GetApiDownloadTasksEventsParamsStatus = "completed" GetApiDownloadTasksEventsParamsStatusFailed GetApiDownloadTasksEventsParamsStatus = "failed" + GetApiDownloadTasksEventsParamsStatusInterrupted GetApiDownloadTasksEventsParamsStatus = "interrupted" GetApiDownloadTasksEventsParamsStatusPaused GetApiDownloadTasksEventsParamsStatus = "paused" GetApiDownloadTasksEventsParamsStatusPausing GetApiDownloadTasksEventsParamsStatus = "pausing" GetApiDownloadTasksEventsParamsStatusQueued GetApiDownloadTasksEventsParamsStatus = "queued" @@ -559,6 +569,8 @@ func (e GetApiDownloadTasksEventsParamsStatus) Valid() bool { return true case GetApiDownloadTasksEventsParamsStatusFailed: return true + case GetApiDownloadTasksEventsParamsStatusInterrupted: + return true case GetApiDownloadTasksEventsParamsStatusPaused: return true case GetApiDownloadTasksEventsParamsStatusPausing: @@ -720,6 +732,7 @@ const ( GetApiDownloadTasksId200JSONResponseBodyStatusCanceling GetApiDownloadTasksId200JSONResponseBodyStatus = "canceling" GetApiDownloadTasksId200JSONResponseBodyStatusCompleted GetApiDownloadTasksId200JSONResponseBodyStatus = "completed" GetApiDownloadTasksId200JSONResponseBodyStatusFailed GetApiDownloadTasksId200JSONResponseBodyStatus = "failed" + GetApiDownloadTasksId200JSONResponseBodyStatusInterrupted GetApiDownloadTasksId200JSONResponseBodyStatus = "interrupted" GetApiDownloadTasksId200JSONResponseBodyStatusPaused GetApiDownloadTasksId200JSONResponseBodyStatus = "paused" GetApiDownloadTasksId200JSONResponseBodyStatusPausing GetApiDownloadTasksId200JSONResponseBodyStatus = "pausing" GetApiDownloadTasksId200JSONResponseBodyStatusQueued GetApiDownloadTasksId200JSONResponseBodyStatus = "queued" @@ -742,6 +755,8 @@ func (e GetApiDownloadTasksId200JSONResponseBodyStatus) Valid() bool { return true case GetApiDownloadTasksId200JSONResponseBodyStatusFailed: return true + case GetApiDownloadTasksId200JSONResponseBodyStatusInterrupted: + return true case GetApiDownloadTasksId200JSONResponseBodyStatusPaused: return true case GetApiDownloadTasksId200JSONResponseBodyStatusPausing: @@ -816,6 +831,7 @@ const ( PatchApiDownloadTasksIdJSONBodyStatusCanceling PatchApiDownloadTasksIdJSONBodyStatus = "canceling" PatchApiDownloadTasksIdJSONBodyStatusCompleted PatchApiDownloadTasksIdJSONBodyStatus = "completed" PatchApiDownloadTasksIdJSONBodyStatusFailed PatchApiDownloadTasksIdJSONBodyStatus = "failed" + PatchApiDownloadTasksIdJSONBodyStatusInterrupted PatchApiDownloadTasksIdJSONBodyStatus = "interrupted" PatchApiDownloadTasksIdJSONBodyStatusPaused PatchApiDownloadTasksIdJSONBodyStatus = "paused" PatchApiDownloadTasksIdJSONBodyStatusPausing PatchApiDownloadTasksIdJSONBodyStatus = "pausing" PatchApiDownloadTasksIdJSONBodyStatusQueued PatchApiDownloadTasksIdJSONBodyStatus = "queued" @@ -838,6 +854,8 @@ func (e PatchApiDownloadTasksIdJSONBodyStatus) Valid() bool { return true case PatchApiDownloadTasksIdJSONBodyStatusFailed: return true + case PatchApiDownloadTasksIdJSONBodyStatusInterrupted: + return true case PatchApiDownloadTasksIdJSONBodyStatusPaused: return true case PatchApiDownloadTasksIdJSONBodyStatusPausing: @@ -933,6 +951,7 @@ const ( PatchApiDownloadTasksId200JSONResponseBodyStatusCanceling PatchApiDownloadTasksId200JSONResponseBodyStatus = "canceling" PatchApiDownloadTasksId200JSONResponseBodyStatusCompleted PatchApiDownloadTasksId200JSONResponseBodyStatus = "completed" PatchApiDownloadTasksId200JSONResponseBodyStatusFailed PatchApiDownloadTasksId200JSONResponseBodyStatus = "failed" + PatchApiDownloadTasksId200JSONResponseBodyStatusInterrupted PatchApiDownloadTasksId200JSONResponseBodyStatus = "interrupted" PatchApiDownloadTasksId200JSONResponseBodyStatusPaused PatchApiDownloadTasksId200JSONResponseBodyStatus = "paused" PatchApiDownloadTasksId200JSONResponseBodyStatusPausing PatchApiDownloadTasksId200JSONResponseBodyStatus = "pausing" PatchApiDownloadTasksId200JSONResponseBodyStatusQueued PatchApiDownloadTasksId200JSONResponseBodyStatus = "queued" @@ -955,6 +974,8 @@ func (e PatchApiDownloadTasksId200JSONResponseBodyStatus) Valid() bool { return true case PatchApiDownloadTasksId200JSONResponseBodyStatusFailed: return true + case PatchApiDownloadTasksId200JSONResponseBodyStatusInterrupted: + return true case PatchApiDownloadTasksId200JSONResponseBodyStatusPaused: return true case PatchApiDownloadTasksId200JSONResponseBodyStatusPausing: @@ -1077,6 +1098,7 @@ const ( PostApiDownloadTasksIdActions200JSONResponseBody0StatusCanceling PostApiDownloadTasksIdActions200JSONResponseBody0Status = "canceling" PostApiDownloadTasksIdActions200JSONResponseBody0StatusCompleted PostApiDownloadTasksIdActions200JSONResponseBody0Status = "completed" PostApiDownloadTasksIdActions200JSONResponseBody0StatusFailed PostApiDownloadTasksIdActions200JSONResponseBody0Status = "failed" + PostApiDownloadTasksIdActions200JSONResponseBody0StatusInterrupted PostApiDownloadTasksIdActions200JSONResponseBody0Status = "interrupted" PostApiDownloadTasksIdActions200JSONResponseBody0StatusPaused PostApiDownloadTasksIdActions200JSONResponseBody0Status = "paused" PostApiDownloadTasksIdActions200JSONResponseBody0StatusPausing PostApiDownloadTasksIdActions200JSONResponseBody0Status = "pausing" PostApiDownloadTasksIdActions200JSONResponseBody0StatusQueued PostApiDownloadTasksIdActions200JSONResponseBody0Status = "queued" @@ -1099,6 +1121,8 @@ func (e PostApiDownloadTasksIdActions200JSONResponseBody0Status) Valid() bool { return true case PostApiDownloadTasksIdActions200JSONResponseBody0StatusFailed: return true + case PostApiDownloadTasksIdActions200JSONResponseBody0StatusInterrupted: + return true case PostApiDownloadTasksIdActions200JSONResponseBody0StatusPaused: return true case PostApiDownloadTasksIdActions200JSONResponseBody0StatusPausing: diff --git a/downloader/internal/worker/worker.go b/downloader/internal/worker/worker.go index 58a5f5b6..7589cf36 100644 --- a/downloader/internal/worker/worker.go +++ b/downloader/internal/worker/worker.go @@ -256,12 +256,12 @@ func (w *Worker) process(ctx context.Context, task client.DownloadTask) { } zero := int64(0) if _, updateErr := w.updateTask(context.WithoutCancel(ctx), task.ID, client.TaskPatch{ - Status: "paused", + Status: "interrupted", DownloadBps: &zero, StorageUploadBps: &zero, - Detail: shutdownPauseDetail(currentDetail), + Detail: interruptedDetail(currentDetail), }); updateErr != nil { - log.Error("failed to mark task paused after shutdown", "error", updateErr) + log.Error("failed to mark task interrupted after shutdown", "error", updateErr) } log.Info("task stopped by context cancellation") return @@ -331,14 +331,14 @@ func (w *Worker) uploadAndComplete( uploadingDetail.Phase = "uploading" uploadingDetail.PeerUploadBps = nil if _, updateErr := w.updateTask(context.WithoutCancel(ctx), task.ID, client.TaskPatch{ - Status: "paused", + Status: "interrupted", DownloadedBytes: &downloadedBytes, TotalBytes: &downloadedBytes, DownloadBps: &zero, StorageUploadBps: &zero, - Detail: shutdownPauseDetail(uploadingDetail), + Detail: interruptedDetail(uploadingDetail), }); updateErr != nil { - log.Error("failed to mark task paused after upload shutdown", "error", updateErr) + log.Error("failed to mark task interrupted after upload shutdown", "error", updateErr) } log.Info("task upload stopped by context cancellation") return @@ -407,11 +407,11 @@ func withDownloadETA(detail *client.DownloadTaskDetail, downloaded int64, total return detail } -func shutdownPauseDetail(detail *client.DownloadTaskDetail) *client.DownloadTaskDetail { +func interruptedDetail(detail *client.DownloadTaskDetail) *client.DownloadTaskDetail { if detail == nil { detail = &client.DownloadTaskDetail{} } - detail.Message = "Paused because the downloader stopped" + detail.Message = "Interrupted because the downloader stopped" return detail } diff --git a/downloader/internal/worker/worker_test.go b/downloader/internal/worker/worker_test.go index 65417cae..693bf27b 100644 --- a/downloader/internal/worker/worker_test.go +++ b/downloader/internal/worker/worker_test.go @@ -256,7 +256,7 @@ func TestWorkerLifecycleRetriesUploadWithoutRedownloading(t *testing.T) { } } -func TestDownloadShutdownMarksTaskPaused(t *testing.T) { +func TestDownloadShutdownMarksTaskInterrupted(t *testing.T) { api := &recordingAPI{} eng := &recordingEngine{downloadErr: context.Canceled} w := NewWithAPI(config.Config{}, api) @@ -264,16 +264,16 @@ func TestDownloadShutdownMarksTaskPaused(t *testing.T) { w.process(context.Background(), client.DownloadTask{ID: "task-1", Status: "running"}) - patch := lastPatchWithStatus(t, api.patches, "paused") + patch := lastPatchWithStatus(t, api.patches, "interrupted") if patch.DownloadBps == nil || *patch.DownloadBps != 0 { t.Fatalf("expected download speed to be reset, got %#v", patch.DownloadBps) } if patch.Detail == nil || patch.Detail.Message == "" { - t.Fatalf("expected shutdown pause message, got %#v", patch.Detail) + t.Fatalf("expected interrupted detail message, got %#v", patch.Detail) } } -func TestUploadShutdownMarksTaskPaused(t *testing.T) { +func TestUploadShutdownMarksTaskInterrupted(t *testing.T) { payloadPath := writeTempFile(t, "downloaded payload") api := &recordingAPI{ createObjectDraft: client.ObjectDraft{ID: "object-1", Name: "payload.bin", UploadURL: "http://127.0.0.1:1"}, @@ -290,12 +290,12 @@ func TestUploadShutdownMarksTaskPaused(t *testing.T) { nil, ) - patch := lastPatchWithStatus(t, api.patches, "paused") + patch := lastPatchWithStatus(t, api.patches, "interrupted") if patch.DownloadedBytes == nil || *patch.DownloadedBytes != int64(len("downloaded payload")) { t.Fatalf("expected upload shutdown to preserve downloaded checkpoint, got %#v", patch.DownloadedBytes) } if patch.Detail == nil || patch.Detail.Phase != "uploading" || patch.Detail.Message == "" { - t.Fatalf("expected upload shutdown to preserve phase and add message, got %#v", patch.Detail) + t.Fatalf("expected upload shutdown to preserve phase and add interrupted message, got %#v", patch.Detail) } if _, ok := findPatchWithStatus(api.patches, "failed"); ok { t.Fatalf("expected upload shutdown not to mark failed, got %#v", api.patches) diff --git a/server/routes/download-tasks.integration.test.ts b/server/routes/download-tasks.integration.test.ts index 20d32966..86088c09 100644 --- a/server/routes/download-tasks.integration.test.ts +++ b/server/routes/download-tasks.integration.test.ts @@ -619,6 +619,96 @@ describe('Download tasks API integration', () => { await expect(deleteRes.json()).resolves.toEqual({ id: createdTask.id, deleted: true }) }) + it('lets the assigned downloader recover interrupted tasks without resuming user-paused tasks', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + + const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'interrupted-downloader') + const downloaderHeaders = { + Authorization: `Bearer ${createdDownloader.token}`, + 'Content-Type': 'application/json', + } + const heartbeatRes = await app.request('/api/downloader/heartbeat', { + method: 'POST', + headers: downloaderHeaders, + body: JSON.stringify({ ...heartbeat, currentTasks: 0 }), + }) + expect(heartbeatRes.status).toBe(200) + + const user = await authedHeaders(app, 'download-interrupted-user@example.com') + const createTaskRes = await app.request('/api/download-tasks', { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: { type: 'http', uri: 'https://example.com/interrupted.bin' }, + targetFolder: '', + }), + }) + expect(createTaskRes.status).toBe(201) + const createdTask = (await createTaskRes.json()) as { id: string; status: string; assignedDownloaderId: string } + expect(createdTask.status).toBe('assigned') + expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id) + + const interruptedRes = await app.request(`/api/download-tasks/${createdTask.id}`, { + method: 'PATCH', + headers: downloaderHeaders, + body: JSON.stringify({ + status: 'interrupted', + downloadedBytes: 1024, + totalBytes: 4096, + detail: { phase: 'downloading', message: 'Interrupted because the downloader stopped' }, + }), + }) + expect(interruptedRes.status).toBe(200) + await expect(interruptedRes.json()).resolves.toMatchObject({ + status: 'interrupted', + downloadedBytes: 1024, + totalBytes: 4096, + }) + + const interruptedAssignedRes = await app.request('/api/download-tasks?assignedTo=me&status=interrupted', { + headers: { Authorization: `Bearer ${createdDownloader.token}` }, + }) + expect(interruptedAssignedRes.status).toBe(200) + const interruptedAssigned = (await interruptedAssignedRes.json()) as { + items: Array<{ id: string; status: string; uploadToken?: string }> + } + const interruptedTask = interruptedAssigned.items.find((item) => item.id === createdTask.id) + expect(interruptedTask).toMatchObject({ status: 'interrupted' }) + expect(interruptedTask?.uploadToken).toBeTruthy() + + const resumedProgressRes = await app.request(`/api/download-tasks/${createdTask.id}`, { + method: 'PATCH', + headers: downloaderHeaders, + body: JSON.stringify({ status: 'running', downloadedBytes: 2048, downloadBps: 256 }), + }) + expect(resumedProgressRes.status).toBe(200) + await expect(resumedProgressRes.json()).resolves.toMatchObject({ status: 'running', downloadedBytes: 2048 }) + + const pauseRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'pause' }), + }) + expect(pauseRes.status).toBe(200) + await expect(pauseRes.json()).resolves.toMatchObject({ status: 'pausing' }) + + const pausedRes = await app.request(`/api/download-tasks/${createdTask.id}`, { + method: 'PATCH', + headers: downloaderHeaders, + body: JSON.stringify({ status: 'paused' }), + }) + expect(pausedRes.status).toBe(200) + + const pausedProgressRes = await app.request(`/api/download-tasks/${createdTask.id}`, { + method: 'PATCH', + headers: downloaderHeaders, + body: JSON.stringify({ status: 'running', downloadedBytes: 3072 }), + }) + expect(pausedProgressRes.status).toBe(409) + await expect(pausedProgressRes.json()).resolves.toEqual({ error: 'Task is paused' }) + }) + it('preserves the completed download checkpoint when retrying an upload failure', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) diff --git a/server/services/downloads/core.ts b/server/services/downloads/core.ts index 72333997..2ee48482 100644 --- a/server/services/downloads/core.ts +++ b/server/services/downloads/core.ts @@ -25,11 +25,12 @@ const CANCELABLE_TASK_STATUSES = [ 'running', 'billing_paused', 'paused', + 'interrupted', 'uploading', 'pausing', ] as const const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'canceled'] as const -const DOWNLOADER_TOKEN_TASK_STATUSES = ['assigned', 'running', 'uploading', 'billing_paused'] as const +const DOWNLOADER_TOKEN_TASK_STATUSES = ['assigned', 'running', 'uploading', 'interrupted', 'billing_paused'] as const export async function createDownloader( platform: Platform, @@ -142,6 +143,7 @@ export async function deleteDownloader(platform: Platform, id: string): Promise< 'billing_paused', 'pausing', 'paused', + 'interrupted', 'uploading', 'canceling', ]), @@ -464,7 +466,9 @@ export async function performDownloadTaskAction( } if (action === 'resume') { - if (task.status !== 'paused') throw new DownloadError('invalid_state', 'Only paused tasks can be resumed') + if (!['paused', 'interrupted'].includes(task.status)) { + throw new DownloadError('invalid_state', 'Only paused or interrupted tasks can be resumed') + } await platform.db .update(downloadTasks) .set({ @@ -486,10 +490,10 @@ export async function performDownloadTaskAction( if (action === 'cancel') { if (task.status === 'canceled') return toDownloadTask(task) if (!CANCELABLE_TASK_STATUSES.includes(task.status as (typeof CANCELABLE_TASK_STATUSES)[number])) { - throw new DownloadError('invalid_state', 'Only active or paused tasks can be canceled') + throw new DownloadError('invalid_state', 'Only active, interrupted, or paused tasks can be canceled') } const status = - task.assignedDownloaderId && ['assigned', 'running', 'uploading', 'pausing'].includes(task.status) + task.assignedDownloaderId && ['assigned', 'running', 'uploading', 'pausing', 'interrupted'].includes(task.status) ? 'canceling' : 'canceled' await platform.db diff --git a/shared/schemas/downloads.ts b/shared/schemas/downloads.ts index 326ddba6..76712a01 100644 --- a/shared/schemas/downloads.ts +++ b/shared/schemas/downloads.ts @@ -9,6 +9,7 @@ export const downloadTaskStatusSchema = z.enum([ 'billing_paused', 'pausing', 'paused', + 'interrupted', 'uploading', 'canceling', 'completed', diff --git a/shared/types/index.ts b/shared/types/index.ts index ead1b7fa..df2b4663 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -238,6 +238,7 @@ export type DownloadTaskStatus = | 'billing_paused' | 'pausing' | 'paused' + | 'interrupted' | 'uploading' | 'canceling' | 'completed' diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d5bb9e51..f61e58b9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -161,6 +161,7 @@ "downloads.status.billing_paused": "Billing paused", "downloads.status.pausing": "Pausing", "downloads.status.paused": "Paused", + "downloads.status.interrupted": "Interrupted", "downloads.status.uploading": "Uploading", "downloads.status.canceling": "Canceling", "downloads.status.completed": "Completed", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8dc64099..c88b3952 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -161,6 +161,7 @@ "downloads.status.billing_paused": "计费暂停", "downloads.status.pausing": "暂停中", "downloads.status.paused": "已暂停", + "downloads.status.interrupted": "已中断", "downloads.status.uploading": "上传中", "downloads.status.canceling": "取消中", "downloads.status.completed": "已完成", diff --git a/src/routes/_authenticated/downloads/index.tsx b/src/routes/_authenticated/downloads/index.tsx index 654ac66a..b3a43c63 100644 --- a/src/routes/_authenticated/downloads/index.tsx +++ b/src/routes/_authenticated/downloads/index.tsx @@ -103,6 +103,7 @@ const STATUS_FILTERS: Array<{ value: DownloadTaskStatus | 'all'; labelKey: strin { value: 'running', labelKey: 'downloads.status.running' }, { value: 'uploading', labelKey: 'downloads.status.uploading' }, { value: 'paused', labelKey: 'downloads.status.paused' }, + { value: 'interrupted', labelKey: 'downloads.status.interrupted' }, { value: 'completed', labelKey: 'downloads.status.completed' }, { value: 'failed', labelKey: 'downloads.status.failed' }, { value: 'canceled', labelKey: 'downloads.status.canceled' }, @@ -1269,7 +1270,7 @@ function TaskMenuItem({ function taskActions(task: DownloadTask): DownloadTaskAction[] { if (PAUSABLE_STATUSES.has(task.status)) return ['pause', 'cancel'] - if (task.status === 'paused') return ['resume', 'cancel'] + if (task.status === 'paused' || task.status === 'interrupted') return ['resume', 'cancel'] if (task.status === 'billing_paused' || task.status === 'uploading' || task.status === 'pausing') return ['cancel'] if (task.status === 'failed' || task.status === 'canceled') return ['retry', 'delete'] if (task.status === 'completed') return ['delete'] @@ -1283,7 +1284,7 @@ function availableBulkActions(tasks: DownloadTask[]): DownloadTaskAction[] { function primaryTaskAction(task: DownloadTask): DownloadTaskAction | null { if (PAUSABLE_STATUSES.has(task.status)) return 'pause' - if (task.status === 'paused') return 'resume' + if (task.status === 'paused' || task.status === 'interrupted') return 'resume' if (task.status === 'failed' || task.status === 'canceled') return 'retry' return null } @@ -1720,6 +1721,11 @@ function StatusBadge({ status }: { status: DownloadTaskDisplayStatus }) { 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300', icon: , }, + interrupted: { + className: + 'border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950/40 dark:text-yellow-300', + icon: , + }, uploading: { className: 'border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-800 dark:bg-teal-950/40 dark:text-teal-300', icon: ,