* feat(admin): separate billing configuration
Add dedicated storage egress and downloader credit billing contracts, usecases, RPC wrappers, drawers, generated client updates, and coverage.
Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0
* fix(billing): preserve not found ordering
Check storage and downloader existence before quota_store gating in dedicated billing usecases, and cover enabled missing-resource requests at usecase and route levels.
Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0
---------
Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
* feat: make forcePathStyle configurable per storage
Previously hardcoded to true, which breaks S3-compatible backends that require
virtual-hosted-style addressing (e.g. Alibaba Cloud OSS). Now configurable via
admin storage settings with a toggle switch, defaulting to true for backwards
compatibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: cover storage force path style
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: saltbo <saltbo@foxmail.com>
Clears all 23 open CodeQL alerts:
- actions/missing-workflow-permissions (19, medium): add a top-level
least-privilege `permissions: contents: read` to ci.yml and the 7
deploy workflows. The one CI job that needs `packages: write` already
declares its own block; all deploys authenticate via static secrets
(no OIDC / id-token, no repo writes), so read is sufficient.
- js/insecure-randomness (1, high): `genPassword()` built share
passwords with Math.random(); switch to crypto.getRandomValues() over
the same unambiguous alphabet (length/charset/uniqueness preserved).
- js/incomplete-url-substring-sanitization (2, high): two test fetch
stubs routed on `String(url).includes('api.github.com')`; tighten to
`new URL(url).hostname === 'api.github.com'` — precise and no longer
flagged.
- js/stack-trace-exposure (1, medium): the E2E S3 mock echoed
error.message in 500 responses; log server-side and return a generic
body instead.
Verified: typecheck green; share-dialog/changelog/system.integration
tests pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Create SECURITY.md for security policy
Added a security policy document outlining supported versions and vulnerability reporting.
Signed-off-by: Jasper Van <saltbo@foxmail.com>
* docs: fill in real SECURITY.md policy for ZPan
Replace the GitHub default template (placeholder 5.x/4.x versions and
boilerplate) with an actual policy: supported 2.x versions, private
vulnerability reporting via GitHub Security Advisories, and response-time
expectations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: saltbo <saltbo@foxmail.com>
* docs: express supported versions relative to latest release
Avoid pinning a concrete minor (2.7.x) that goes stale on every release.
ZPan ships fixes forward on a single release train and does not backport,
so 'latest release only' is both accurate and zero-maintenance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: saltbo <saltbo@foxmail.com>
---------
Signed-off-by: Jasper Van <saltbo@foxmail.com>
Signed-off-by: saltbo <saltbo@foxmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve Dependabot alert #36 (esbuild <= 0.24.2, medium): "esbuild enables
any website to send any requests to the development server and read the
response."
The vulnerable esbuild 0.18.20 was dragged in transitively by drizzle-kit's
deprecated @esbuild-kit/esm-loader → @esbuild-kit/core-utils (both "Merged
into tsx"). drizzle-kit 0.31.10 — the latest — still declares the legacy
loader even though it now uses tsx, so bumping drizzle-kit can't fix it.
Scoped pnpm override forces only @esbuild-kit/core-utils>esbuild to 0.25.12
(already resolved in the tree via drizzle-kit's own esbuild ^0.25.4), so it
dedups to a single version and leaves vite/tsup/vitest esbuild untouched.
Verified: drizzle-kit `db:generate` loads the TS config + full schema and
exits 0 with no spurious migrations; typecheck and production build green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve Dependabot security alerts:
- nodemailer (high): the message-level `raw` option bypassed
disableFileAccess/disableUrlAccess (arbitrary file read + SSRF). Our
email gateway only sends `html` over an SMTP transport (no `raw`, no
remote attachment fetching, no OAuth2/proxy), so neither the vuln nor
the 9.0 TLS-cert-validation breaking change affects our usage.
- undici (3 high / 2 med / 2 low): the override pinned undici at 7.24.8
(a leftover dedup pin from the pnpm migration, not a real constraint).
Bump it to 7.28.0; consumers (jsdom/vitest/better-auth/miniflare/
wrangler) all accept ^7, so it dedups to a single patched version.
Verified: typecheck, 4347 unit/integration + 59 CF Workers tests, and a
production vite build all green.
Supersedes #458.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No callers remain after select('public') was removed in #456 — the
public-bucket flow is gone, so the gateway method, its port declaration
and its tests are dead code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(avatar): refresh session after change and show fallback on remove
Two avatar-display bugs surfaced post-#456:
- After uploading/removing an avatar the UI showed the old image until a full
reload. refreshSession() now calls getSession({ disableCookieCache: true }) to
re-read user.image past the 5-min session cookie cache, then
$store.notify('$sessionSignal') so useSession() actually refetches and
re-renders (an external endpoint never toggles better-auth's session signal).
- Removing an avatar left a blank circle: the conditional `{user.image && <AvatarImage>}`
unmounts the radix Image, which keeps a stale "loaded" status so the Fallback
stays hidden. Always render <AvatarImage src={user?.image ?? undefined}> so radix
re-runs its loading status (src -> undefined => "error") and shows the initials.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(avatar): self-host avatars on R2 when deployed on Workers
The image-upload gateway now branches per request on the AVATARS R2 binding:
- binding present (Cloudflare) -> upload straight to R2 (key `scope/id`, content
type in R2 metadata, a content-hash `?v=` cache-buster) and return either an
AVATARS_PUBLIC_URL (R2 custom domain) URL or a relative /api/avatar-blobs URL.
- binding absent (Node/Docker, or a Worker without it) -> the existing ZPan Cloud
avatar service, unchanged.
Adds a public GET /api/avatar-blobs/:scope/:id route that streams the blob from
the AVATARS binding (so local miniflare, which gives R2 no public URL, can serve
avatars too). AVATARS_BINDING / R2BucketLike live in platform/interface so both
the adapter and the http route can use them without crossing the arch boundary.
wrangler.toml declares the AVATARS bucket (prod + staging).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(deploy): provision the zpan-avatars R2 bucket + AVATARS_PUBLIC_URL
The AVATARS R2 binding added for self-hosted avatars needs the bucket to exist on
deploy. Mirror the resource-provisioning pattern (D1/Queue): create zpan-avatars if
missing, enable its managed public URL, and upsert AVATARS_PUBLIC_URL so prod serves
avatars straight from R2 (zero Worker egress). Without the secret the app still works
via its /api/avatar-blobs route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(avatar): reuse the original PUBLIC_IMAGES bucket/binding/env names
Keep the same Cloudflare resource names as before #456 removed them so the existing
`zpan-public-images` bucket is reused (not orphaned) and the API token scopes still
apply: R2 binding PUBLIC_IMAGES, bucket zpan-public-images(-staging), public-URL
secret PUBLIC_IMAGES_URL. Pure rename of the AVATARS naming I'd introduced — no
behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode
Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).
- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
(MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
bearer header so both the image content-type and Authorization survive hono's
per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy
The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
---------
Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
signInAsAdmin waited for the /files redirect then immediately goto'd
/admin/storages, but the client redirect to /files could still be in
flight and interrupt the navigation ("interrupted by another navigation
to /files"), flaking responsive-admin. Wrap the goto + URL assertion in
expect(...).toPass() so it re-navigates until /admin/storages commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final piece of the restart→download→upload→seed robustness pass. nextTaskWorkStage
can route a task to uploadExistingResult based on the server checkpoint (upload
progress / runtime phase / download totals), but the engine may not report the
download complete yet — aria2 re-checks on-disk files after a restart (showing
downloaded=0/total=nil transiently), or the download was lost. The old code
panic()'d on that mismatch, which recoverTaskPanic turned into a permanently
failed task (this is what failed Moonfall after it had fully downloaded 7.4GB).
Now uploadExistingResult falls back to downloadThenUpload when the runtime isn't
complete (or has no result). downloadThenUpload re-attaches to the session
download, waits for aria2 to finish re-checking, and uploads on completion — no
data loss, no spurious failure. Test updated to assert resume-not-fail.
isAria2RPCDisconnected called err.Error() unconditionally, so a nil error
panicked (nil pointer deref). findSeed reaches it with a nil error whenever
tellStatus succeeds but the status isn't a seed — which crashed the downloader at
startup while restoring a retained seed from the ledger. Guard nil -> false.
Second restart bug behind the same stuck Moonfall: aria2 auto-seeds a torrent
the instant its download finishes (--seed-time), so a completed-but-not-yet-
uploaded download shows up in ListSeeds. reconcileEngineSeeds runs at startup
BEFORE the task loop marks tasks running, so 'running' was empty and it adopted
that seed as a done seed for managed expiry — skipping the upload entirely. The
task then sat at 'downloading' forever and the file would be deleted when the
seed expired.
Skip seeds whose task is still assigned/unfinished (AssignedTasks: assigned/
downloading/interrupted/uploading) — those belong to the task loop, which will
upload then seed them. Only genuinely orphaned seeds (completed tasks) are
adopted. Confirmed on prod: Moonfall finished (7.4GB on disk) with
result_object_id=null, stuck downloading.
After a downloader restart, the web UI showed a frozen download with 0 speed
even though aria2 was still downloading. Cause: on restart a task comes back as
'interrupted', but shouldAttachExistingAria2Task only attached for
downloading/uploading — so the worker RE-ADDED the magnet. aria2 had already
reloaded that download from its saved session, so the re-add hit error 12
(infohash already registered) and produced a dead duplicate gid. The worker then
polled the dead duplicate (bytes=0, speed=0) instead of the live download;
mergeTaskProgress's max() kept the stale byte count, so the UI froze. The real
download kept going, orphaned and unreported.
Fixes:
- shouldAttachExistingAria2Task also returns true for 'interrupted', so restart
attaches to the session-restored download instead of re-adding.
- findTask skips error/removed entries so it never attaches to a dead duplicate.
Confirmed on prod: Moonfall had two aria2 gids — 29ded179 (live, 6.8GB) and
3203aca6 (error-12 duplicate) — and the server runtime was frozen at the
pre-restart snapshot.
Several magnet tasks sat at 0 bytes / 0 connections forever: their embedded
trackers were dead (coppersurfer.tk, leechers-paradise, etc.) and aria2 had no
other way to find peers, so metadata never downloaded — while still holding a
download slot and blocking the queue.
aria2 startArgs set no BT discovery config at all. Add:
- --bt-tracker fed from the maintained XIU2 TrackersListCollection 'best' list,
fetched live at startup (FetchBtTrackers) with a bundled snapshot fallback, to
supplement each magnet's own (often rotted) announce list,
- explicit --enable-dht / --enable-peer-exchange,
- --dht-file-path in the state dir so the DHT routing table is warm across
restarts instead of bootstrapping cold,
- --bt-load-saved-metadata to reuse fetched metadata.
On restart aria2 reloads the saved session and re-announces these magnets with
the live trackers, so the stuck ones can finally resolve.
Download/upload totals were getting wiped on completed tasks. Two causes, both
mine:
1. nextTaskRuntime treated an incoming runtime as a full snapshot and REPLACED
the stored one. reportSeedingStopped sends only {phase, seeding} (no
progress), so the cumulative download/upload progress was erased — a completed
task ended up as {phase: completed, seeding: {active: false}} with no
transfer record. Progress is cumulative, not a per-report snapshot: carry it
forward when a runtime report omits it (progress patches still apply on top).
2. clearStaleSeedingRuntime nulled the entire runtime to drop the seeding phase,
which also erased progress. Now it surgically edits the JSON (phase ->
completed, remove the seeding object) and keeps progress + file list.
Confirmed on prod: affected tasks had runtime collapsed to just engine/phase/
seeding with progress gone. Tests updated to assert a phase-only report
preserves progress.
Two follow-ups:
- aria2 peers now report progress (0..1) derived from the peer's piece bitfield
(seeder => 1.0), matching qBittorrent. Previously the Peers list showed '—'
for aria2 because the field was never populated.
- The server builder copied the whole repo via 'COPY . .', so a cmd/-only
(downloader) change busted the layer and forced a full vite/tsup rebuild. Copy
only the build inputs + final-stage sources (src, server, shared, public,
index.html, vite.config.ts, tsconfig.json, migrations, scripts) so
downloader-only pushes reuse the cached server image. Verified the builder
stage still builds and produces dist/dist-server/migrations/entrypoint.
The docker-dev build took ~14 min, dominated by a ~12-min server-image build —
its arm64 half runs under QEMU emulation (native better-sqlite3/sharp).
- Dev images (:dev, :dev-cli) now build linux/amd64 only, skipping arm64 QEMU
entirely. Releases stay multi-arch. (The primary dev consumer runs amd64.)
- The Go CLI builder now runs on $BUILDPLATFORM and cross-compiles via
GOOS/GOARCH (CGO off), so the release arm64 CLI builds natively instead of
under QEMU — arm64 'go build' verified at ~5s vs emulated minutes. Adds
go build/mod cache mounts for fast incremental rebuilds.
aria2's tellStatus only returns the keys it's asked for, but aria2StatusKeys
omitted uploadLength, uploadSpeed, connections and numSeeders — while
aria2Detail reads status.UploadLength/UploadSpeed/Connections/NumSeeders. So the
runtime reported them all as 0: during seeding the Overview showed 0 B/s upload
and 0 B uploaded, even though the Peers list had per-peer upload activity (that
comes from a separate getPeers call). Confirmed on live aria2: the seeding
torrent reported uploadSpeed ~3.26MB/s and uploadLength ~2.5GB when those keys
were requested. Adds the missing keys + a guard test.
reportRemoteDownloadUnit short-circuited on a locally-cached 'blocked' usage
record and threw without re-contacting the cloud. Once a unit was blocked, the
task was wedged in 'suspended' permanently — recharging credits had no effect,
because the next charge attempt never re-asked the cloud (confirmed on prod: the
stuck task's unit 1 was status='blocked').
Drop the short-circuit so a previously-blocked unit re-syncs with the cloud on
the next attempt; if credits are now available it's accepted and the task leaves
suspended. A 'reported' (already-charged) unit still short-circuits, so no
double-charge.
Test: a unit blocked on first attempt is accepted on retry after a top-up, and
the task transitions back to downloading.
The first cut keyed off listUnreachableIds (existing downloaders with a stale
heartbeat), which misses a task whose downloader was DELETED — its id is gone
from the table, so it never appears in any stale list. Confirmed on prod: the
lone stuck task was owned by a downloader id absent from the downloaders table.
clearStaleSeedingRuntime now drops the stale seeding runtime on any completed
task NOT owned by a live (recently-heartbeating) downloader — offline, deleted,
or unassigned — and runs every sweep regardless of new staleness. A live
downloader's genuine seed is preserved.
Test now asserts both: a deleted owner's seed is cleared, a live owner's is kept.
A completed task keeps runtime.phase='seeding' while its downloader seeds. If
that downloader goes offline without reporting the seed stopped, the task shows
'Phase: Seeding' forever — the clearStaleSeedingReports one-shot only covers the
current downloader's own (assignedTo=me) tasks, not another offline downloader's.
Stale recovery now also drops the stale seeding runtime on completed tasks of
unreachable downloaders (alongside the canceling/pausing settle), so they stop
showing as seeding. Runs every sweep, idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Billing was charge-in-arrears: a credit unit was only charged once the
downloader had already reported downloading into it, and the first unit only
after the first progress report — so a no-credit task still pulled bytes before
being blocked, then suspended with no explanation.
- Pre-authorize one unit ahead of the bytes pulled: targetUnits =
min(ceil(downloaded/unit) + 1, ceil(total/unit)). The downloader never fetches
bytes it hasn't paid for, and the cap keeps the lifetime charge at exactly
ceil(total/unit) — same total as before, only billed earlier.
- Charge the first unit on the transition into 'downloading' (zero bytes), so a
task that can't afford a unit is suspended at the gate and pulls nothing. The
worker reads the authoritative status from that transition response and does
not start downloading when it comes back suspended (progress reports stay pure
telemetry; control still flows through the poll).
- On suspend, set runtime.message so the UI shows why (insufficient credits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branding's logo/favicon are now encoded as `data:${mime};base64,…` URIs and
stored directly in the `branding_logo_url` / `branding_favicon_url` system
options instead of being uploaded to a `mode='public'` S3 bucket. This removes
branding's dependency on public storage entirely (#456 Part 1).
- uploadBrandingImage encodes the raw file bytes and drops select('public') /
s3.putObject / s3.getPublicUrl; s3 + storages removed from BrandingDeps.
- Per-field raw-byte caps replace the single 2 MiB limit: logo ≤ 256 KB,
favicon ≤ 64 KB; over-cap returns 413 naming the field's limit.
- The 503 "no public storage" path is gone: uploads succeed with no public
storage configured, and the updateBranding route no longer advertises 503
(operationId unchanged; Go OpenAPI client regenerated).
- GET shape unchanged; legacy absolute-URL values keep rendering as-is (no
migration, no backfill, old _system/branding objects untouched).
Out of scope (#456 Parts 2-3): avatar/team-logo upload, storages.mode,
StorageRepo.select.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The previous fix gated control-task settling on listStaleIds, which only
returns downloaders that are still status='online' with a stale heartbeat — the
online→offline transition. A downloader marked offline by an earlier sweep is
never returned again, so a canceling/pausing task it held stayed stuck forever
(exactly the observed case). The early 'no new stale downloaders' return made it
worse.
Add listUnreachableIds (heartbeat past the lease, any status) and settle
canceling→canceled / pausing→paused for those every sweep (idempotent),
independent of the requeue+markOffline transition path.
Regression test now flips the downloader to status='offline' before recovery —
the case the prior fix missed.
When a downloader went offline holding a 'canceling' (or 'pausing') task, the
stale-lease recovery only requeued [assigned,downloading,uploading,interrupted]
— so the control task was never resolved and sat in 'canceling' forever, since
no live downloader would ever ack it.
recoverStaleDownloaderAssignments now also settles control states for stale
downloaders: canceling→canceled (terminal, assignment cleared) and
pausing→paused (resumable). Adds the resolveControlAssignedToMany repo method.
Integration test: a stale downloader's canceling task settles to canceled once
recovery runs.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The progress-report path was making control decisions by string-matching error
bodies — and missed the suspended case entirely, so a billing-suspended task
kept downloading while the UI showed Suspended.
Reporting is now pure telemetry: updateTask just syncs progress/status and a
failed report is logged, never acted on. All stop-control (pause/cancel/suspend)
flows through the existing task poll:
- AssignedControlTasks now also queries 'suspended'; cancelRunning cancels the
running download with errTaskSuspended and leaves the server-owned suspended
status untouched.
- Removed errBillingPaused, the insufficient_credits string-match, and the
isControlledTaskUpdateError/resolveControlledTaskUpdate error-string matching.
pause/cancel already flowed through the poll; the report-path handling was a
redundant, brittle second mechanism.
Worker-only; no server change (status=suspended is already pollable and keeps
assignedDownloaderId). Tests cover the suspended poll cause and the control
status query.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a retained seed was cleaned up (expiry/ratio/cache/missing), the worker
removed it from aria2, the ledger, and local disk — but never told the server
seeding had ended. The task's last runtime report (phase=seeding,
Seeding.Active=true) stuck, so the dashboard showed completed tasks as
seeding forever. reportRetainedSeedsStopped only ran on worker shutdown, not
on per-seed expiry.
- cleanupRetainedSeed now reports the task runtime as completed/not-seeding
after a seed is cleaned, so the UI clears it. Task status stays completed.
- clearStaleSeedingReports runs once at startup: lists this downloader's
completed tasks the server still marks seeding (new client SeedingTasks),
and reports stopped for any the worker is no longer actually seeding (after
reconciliation), clearing entries left stale by older builds.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two coupled bugs starved aria2 downloads on long-running nodes.
Concurrency: max_concurrent_tasks is the download budget only, but aria2
counts seeding torrents as active downloads, so retained seeds were eating
the shared --max-concurrent-downloads (default 5) and new downloads queued
forever in 'waiting' with no error. Seeding now gets its own budget via a new
Downloader-local config downloader.seed.max_concurrent (default 10); aria2's
--max-concurrent-downloads is set to max_concurrent_tasks + that budget, so
seeds can never consume a download slot. The worker still caps real download
concurrency itself.
Orphan seeds: aria2 was told SeedTime=1000000 (~694 days), so it never
stopped seeding on its own; the worker was the sole authority, and any drift
between aria2's session and the worker's ledger (e.g. across restarts) left
torrents seeding forever, holding slots and disk, never expired. Two fixes:
- aria2 SeedTime is now the configured seed_duration (+ seed-ratio), so aria2
stops seeding on its own even if the worker loses track.
- The worker reconciles on startup and periodically: any torrent the engine
is still seeding but the worker no longer tracks is adopted into the ledger
with an expiry (skipping in-flight/already-tracked tasks), so normal
time/ratio/cache cleanup applies instead of leaking.
Adds SeedLister + aria2 ListSeeds. Tests cover the new config, aria2 args,
seed-time mapping, and orphan adoption (skipping running/tracked tasks).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three related changes hardening the remote downloader, bundled because the
module-path rename touches every file — splitting would leave a mid-history
commit that doesn't build.
Engine supervision: a managed engine subprocess (aria2c / qbittorrent-nox)
that exits unexpectedly now crashes the worker. watchEngineProcess waits on
the child, logs the exit at error level, and cancels the run context so
`downloader up` returns non-zero; the container (restart: unless-stopped)
then restarts the whole stack. Previously the exit error was discarded and
the worker kept heartbeating as healthy while every task failed against the
dead RPC. A deliberate shutdown kill is told apart from a crash via a
stopping flag.
aria2 GID recovery: waitAria2 re-discovers the live GID via findTask when
aria2 reports 'GID ... is not found' mid-download (e.g. after an aria2
restart) instead of failing the task. Adds isAria2GIDNotFound, which matches
tellStatus's message format that isAria2DownloadNotFound missed.
CLI install: move the entrypoint to the module root (cmd/main.go) and rename
the module github.com/saltbo/zpan/cmd -> github.com/saltbo/zpan so
`go install .` from cmd/ yields a zpan binary. Internal imports shorten to
github.com/saltbo/zpan/internal/...; the Dockerfile builds the module root.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>