Skip to content

feat: downloads UX, failed-download cleanup, live progress and uploads view - #102

Closed
khaosdoctor wants to merge 11 commits into
roziscoding:mainfrom
khaosdoctor:feat/auto-remove-failed-downloads
Closed

khaosdoctor wants to merge 11 commits into
roziscoding:mainfrom
khaosdoctor:feat/auto-remove-failed-downloads

Conversation

@khaosdoctor

@khaosdoctor khaosdoctor commented Nov 8, 2026 •

Copy link
Copy Markdown

What does this PR do?

Combines #100, #101 and #103 with three follow-ups, as asked, so it can be reviewed as one change. One commit per change, so they can still be read separately.

  • Wrap long file names in the Downloads table so the action buttons stay in view.
  • Bulk actions on Downloads: a checkbox column (and per-card checkbox on mobile) with Retry, Cancel and Delete for the selection. Each button counts the rows it applies to and skips the rest. Uses the existing per-download endpoints. Changing a filter clears the selection.
  • Retry, then remove failed downloads (downloads.removeFailedDownloads, off by default, applies live). A failed download is retried up to failedDownloadRetries times (minimum and default 3, since one network error is often enough to fail an attempt), with the delay doubling from failedRetryBaseDelayMs and capped at retryMaxDelayMs. If it keeps failing, the row and its files are deleted the way Delete does it. User cancels are left alone. Retry counts are in memory, so a restart starts them over.
  • Version in the sidebar and a repository link in Settings -> About.
  • Correct version in the UI. The UI read apps/ui/package.json, which stays at 0.1.0. CI now passes the image's org.opencontainers.image.version label value as APP_VERSION. Release images show v1.2.3, branch and PR images show main / pr-<n>. Local builds fall back to the latest git tag, then dev.
  • Live progress, speed and ETA. The transfer loop now reports bytes about once a second in memory (no fsync, no DB write). Rows are still written every 10 s / 64 MiB as before. The status API overlays the live count and adds speedBps / etaSeconds. The downloads and overview streams update at most once a second.
  • Uploads view. Files peers pull from this instance are recorded in a new uploads table (migration 0014), one row per request with its range start. Files still go out zero-copy. Counting bytes in JS cost about 7x the CPU per GiB in a local benchmark (0.30 s vs 0.04 s), so jack doesn't touch the body:
    • A peer disconnecting early fires the request's abort signal, which marks the row aborted.
    • Bun gives no signal when a zero-copy response finishes, so the downloading jack reports back: it sends X-Jack-Receipt: 1, gets X-Jack-Upload-Id in the response, and after the file is in place posts { bytes, durationMs } to the new POST /peer/uploads/:id/receipt. That marks the row completed with an average speed. The POST is fire-and-forget with a 10 s timeout and never affects the download.
    • Peers without receipts (older versions), or a lost receipt, leave the row unconfirmed with unknown size and speed. Rows still waiting after 48 h are expired to unconfirmed.
    • There is no live speed for uploads. New Uploads page plus an uploads card on the Dashboard.

Related issue

Supersedes #100, #101 and #103.

Checklist

  • Commits follow Conventional Commits
  • Commits are signed (GitHub enforces this — unsigned commits are rejected)
  • mise run lint:fix was run and its output is committed
  • mise run test passes (and mise run test:e2e if transfer/request paths changed): unit tests pass (645); e2e not run, and the peer file route and transfer loop did change
  • Docs updated if behavior or configuration changed

Copilot AI balanced review requested due to automatic review settings November 8, 2026 21:07
@greptile-apps

greptile-apps Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 0/5

[High impact] The PR changes how Jack retries downloads, serves peer files, and reports transfer history.

Do not merge yet; the earlier findings still include unintended file deletion, failed downloads, and receipts that can change another peer's upload.

Summary

Adds bulk download actions, automatic failed-download cleanup, live download progress, upload history, and build-version display.

The latest changes add:

  • Failed downloads get retries before their files are removed.
  • Active downloads show progress, speed, and an estimated finish time.
  • The Downloads page lets you select rows and run bulk actions.
  • Uploads from this Jack instance appear in a live history page.
  • The dashboard adds transfer, peer, failure, and catalog history.
  • The UI shows the version built into its image.

New findings cover missing zero readings for empty peer catalogs and required formatting for UI section names.

Reviews (4) · Last reviewed commit: "fix: base dashboard upload speed on peer..." · Reviewed by Greptile

Comment thread apps/backend/src/index.ts
const failedDownloadReaper = downloadsConfig
? new FailedDownloadReaper({
// Read per tick, like unlinkImportedFiles, so the UI toggle applies live.
enabled: () => configService?.getRawDownloads()?.removeFailedDownloads ?? downloadsConfig.removeFailedDownloads,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cleanup stays enabled

If removeFailedDownloads was true at startup, turning the switch off does not stop automatic deletion. The UI sends null, and updateDownloads removes the saved key. This expression then falls back to the startup value, which is still true. Failed downloads and their files keep getting deleted until jack restarts.

When configService exists, treat an absent saved key as false. Only use the startup value when that service is unavailable.

Suggested change
enabled: () => configService?.getRawDownloads()?.removeFailedDownloads ?? downloadsConfig.removeFailedDownloads,
enabled: () => configService
? configService.getRawDownloads()?.removeFailedDownloads ?? false
: downloadsConfig.removeFailedDownloads,

Fix in Claude Code Fix in Codex

Comment on lines +97 to +99
if (entry.nextAt === undefined) {
if (entry.retries >= this.options.maxRetries || !canRetry(row)) {
await this.removeRow(row, entry.retries)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cleanup deletes restarted downloads

The reaper uses a snapshot taken before any awaited work. While it waits for an earlier import retry or deletion, a user can retry a later row whose automatic retries are spent. The reaper still deletes that row using its old state. DownloadsService.delete then cancels the restarted transfer and removes its files, or removes a file now queued for import.

Check that the row is still failed and not cancelled inside the same per-row lock used for deletion.

Fix in Claude Code Fix in Codex

***Revert*** discards your edits. `unlinkImportedFiles` takes effect the moment
you save. **Every other key here is read at startup, so restart jack** to apply
***Revert*** discards your edits. `unlinkImportedFiles` and
`removeFailedDownloads` take effect the moment you save. **Every other key here is read at startup, so restart jack** to apply

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Documentation exceeds required width

The added sentence exceeds 80 columns. The repository writing guide requires prose under website/ to wrap at 80 columns. Split this sentence across lines without changing the rendered text. This repository requirement must be satisfied before merging.

Suggested change
`removeFailedDownloads` take effect the moment you save. **Every other key here is read at startup, so restart jack** to apply
`removeFailedDownloads` take effect the moment you save. **Every other key here is
read at startup, so restart jack** to apply

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical issues allow automatic retries and file deletion to continue after the feature is disabled, while stale state checks can restart or delete user-cancelled downloads and their retained partial files.

2 open issues
What changed in this PR

Adds opt-in retries with exponential backoff for failed downloads in jack,
followed by removal when retries are exhausted.

Changes:

  • Adds a scheduled reaper that uses existing management retry and delete paths.
  • Exposes configuration in the settings UI and documentation, with tests for
    backoff, retry limits, and cancellation exclusions.
File Description
website/​reference/​configuration.md Documents failed-download settings and retry behavior.
website/​guide/​management-ui.md Describes the live removal toggle.
apps/​ui/​app/​types/​management.ts Adds failed-download configuration types.
apps/​ui/​app/​components/​DownloadsSection.vue Adds the removal toggle and retry controls.
apps/​backend/​src/​modules/​downloads/​failed-download-reaper.ts Implements retry scheduling and eventual removal.
apps/​backend/​src/​modules/​config/​config.service.ts Updates comments about live configuration.
apps/​backend/​src/​modules/​config/​config.router.ts Updates API documentation for live settings.
apps/​backend/​src/​lib/​config.ts Defines configuration validation and defaults.
apps/​backend/​src/​index.ts Wires the reaper into startup and shutdown.
apps/​backend/​src/​__tests__/​failed-download-reaper.test.ts Tests retry timing, removal, and exclusions.
apps/​backend/​src/​__tests__/​downloads-service.test.ts Adds new defaults to the configuration fixture.

Review effort: Balanced

Comment thread apps/backend/src/index.ts
const failedDownloadReaper = downloadsConfig
? new FailedDownloadReaper({
// Read per tick, like unlinkImportedFiles, so the UI toggle applies live.
enabled: () => configService?.getRawDownloads()?.removeFailedDownloads ?? downloadsConfig.removeFailedDownloads,
Comment on lines +91 to +92
if (row.status !== 'failed' || row.errorCode === 'CANCELLED')
continue
@khaosdoctor khaosdoctor changed the title feat: retry failed downloads with backoff, then remove them feat: downloads UX, failed-download cleanup, live progress and uploads view Nov 8, 2026

/** Starts a row for this request and returns the body to send instead of `body`. */
track(input: CreateUploadInput, body: Blob, signal?: AbortSignal): ReadableStream<Uint8Array> {
const upload = this.repository.create(input)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 History errors block downloads

If SQLite cannot write, for example because the disk holding the database is full, repository.create() throws out of track() before the file response is returned. The peer gets HTTP 500 even when the library file is readable.

Catch the initial insert failure and serve the file without tracking, just as later tracking writes are prevented from breaking the transfer.

Fix in Claude Code Fix in Codex

Comment on lines +45 to +55
get(id: number): LiveProgress | null {
const window = this.samples.get(id)
if (!window?.length)
return null
const first = window[0]!
const last = window.at(-1)!
const elapsedMs = last.t - first.t
return {
downloadedBytes: last.bytes,
speedBps: elapsedMs > 0
? ((last.bytes - first.bytes) * 1000) / elapsedMs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stopped transfers look active

get() keeps returning the last positive speed after a transfer stops receiving bytes. Old samples are removed only by record(), which does not run during a stall or retry delay. The Downloads and Uploads pages keep showing an active speed and an outdated time remaining.

Expire the speed when samples become stale and notify open pages when that happens, while keeping the last byte count.

Fix in Claude Code Fix in Codex

// Row selection for bulk actions, keyed by download id (see get-row-id) so it
// survives live updates and paging.
const rowSelection = ref<Record<string, boolean>>({})
const selected = computed(() => (data.value?.downloads ?? []).filter(d => rowSelection.value[String(d.id)]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hidden downloads get deleted

selected includes rows that live updates have hidden from the current filter. Select two downloading rows under the Downloading filter and let one finish. That row disappears but remains a target of bulk Delete. Deleting the remaining visible selection also removes the hidden row and its completed file.

Limit bulk targets to selected rows that still match filtered, or remove their selection when they stop matching.

Fix in Claude Code Fix in Codex

Comment on lines 260 to +267
@requiresInitialization
/**
* Tell the serving jack this file arrived and how long it took. It sends files
* zero-copy, so it can see an early disconnect but not the end of a transfer.
* Fire and forget: a lost receipt only leaves that upload's speed unknown, and
* a peer too old to issue upload ids gets none.
*/
private sendUploadReceipt(uploadId: string | null, bytes: number, durationMs: number): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Downloads skip the handshake

@requiresInitialization now decorates sendUploadReceipt instead of downloadFile. Resumed downloads can therefore fetch files from a peer whose handshake failed, including one rejected for an incompatible protocol version. The handshake runs only after the file has been saved, through an unawaited receipt call.

Move the decorator back to downloadFile and keep receipt failures contained.

Fix in Claude Code Fix in Codex

updatedAt: timestamp,
finishedAt: timestamp,
})
.where(and(eq(uploads.id, id), eq(uploads.peerName, peerName), inArray(uploads.status, [...OPEN])))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Peers can change others' uploads

Receipts use peerName to decide who owns an upload, but API key names are optional, editable, and not unique. Two unnamed keys both become unnamed, so either peer can submit a receipt for the other's numbered upload and replace its status, byte count, and duration. The real peer's later receipt is then ignored.

Store a stable key ID for ownership and keep the name only for display.

How this was verified: Separate authenticated keys can share apiKeyName, and complete() checks that name rather than the authenticated key's identity before updating an upload.

Fix in Claude Code Fix in Codex

const upload = this.repository.create(input, expectsReceipt ? 'uploading' : 'unconfirmed')
// Bun aborts the request signal only when the peer disconnects before the
// whole body went out; a finished response never fires it.
signal?.addEventListener('abort', () => this.repository.markAborted(upload.id, 'peer disconnected'), { once: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Disconnected uploads look active

start() no longer checks whether the request signal has already aborted. The route waits for streamFile() before calling it, so a peer can disconnect while jack waits for the source file path. Adding a listener afterward does not replay the abort. The new row then appears as uploading for up to the 48-hour expiry instead of being marked aborted.

Check signal.aborted before attaching the listener.

Fix in Claude Code Fix in Codex

@khaosdoctor khaosdoctor closed this Nov 8, 2026
Comment on lines +44 to +46
for (const peer of peers) {
const name = peerNames.get(peer.peerId)
if (name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty catalogs keep old counts

When a configured peer's catalog becomes empty, recordCatalogSizes() writes no row for that peer. An earlier nonzero reading from the same day stays unchanged; on later days, the chart shows gaps instead of zero. Iterate over peerNames and use zero counts when a configured peer has no cached titles.

Fix in Claude Code Fix in Codex

Comment on lines +41 to +43
- **Lifetime**: files imported, bytes downloaded and uploaded, the
upload/download ratio, and the number of transfers.
- **Connections**: each peer and server, and whether it is reachable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Section names use wrong formatting

The new dashboard section names use plain bold. The writing guide requires UI references, including section names, to use bold and italic. Change **Lifetime**, **Connections**, and the other section names in this list to triple-asterisk formatting. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants