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: ,