Verification tests for outcome-contract clause
failed-job-error-exposed-for-ui-banner.
- db.test.ts: listJobs includes error for failed jobs, null for non-failed
- jobs-list-endpoint.test.ts: route-level integration with real DB,
exercises GET handler end-to-end
D2 already merged (f03b757+); non-blocking verification only.
Add health preflight check in runJob() after ensureWhisperRunning()
and before submitJob(). Uses existing getHealth() from whisper.ts
with retryWithBackoff() for transient failures.
- Healthy whisper (status 'ok') -> job proceeds to submission
- Unreachable whisper -> job fails early with 'whisper health check failed: ...'
- Non-ok status (e.g. 'starting') -> job fails early with actual status
- getHealth() 5000ms timeout preserved, no new HTTP client
Tests: health-preflight.test.ts covers error message contracts
and pipeline integration (YouTube + upload source)
Queue page:
- Remove && job.source?.startsWith('http') guard on retry button
- Move retry button outside .row-actions hover zone (always visible)
- Add job.error inline in meta line for failed/cancelled jobs
- data-status=failed attribute on failed rows
- Error tint on failed row title
Job detail page:
- Remove source guard from canRetry derived
- Enhanced error banner: warning icon, 'Transcription failed' title,
error message, inline Retry + Delete action buttons
- btn, btn.primary, btn.ghost CSS styles
Home page:
- Add job.error as third line in recent items for failed jobs
- recent-failed CSS class with error-text tint on meta
- recent-err-line styling
fixes #story-d2-retry-widen-upload--d1-d6-evised
Gate-zero import check runs:
docker compose -p $SH_COMPOSE_PROJECT -f compose.yaml run --rm app ...
When SH_COMPOSE_PROJECT is unset/empty, -p consumes -f as project name
(invalid). Adding 'name: tonemark' to compose.yaml declares the project
name in the file itself — the compose file is self-sufficient.
All 14 tests pass, build + typecheck clean.
retryJob() now wraps the file read in a fire-and-forget async IIFE using
fs/promises.readFile instead of readFileSync. When the original upload
file has been cleaned up (common after failure), the IIFE catches the
ENOENT error and marks the job as 'failed' with a 'retry failed:' prefix
asynchronously — the endpoint returns 200 immediately.
Includes 4 new integration tests:
- Missing file returns immediately (no throw), job async-fails
- ENOENT message propagated in job.error
- 404 on unknown job still works (unchanged contract)
- File present resets job to pending (runJob triggered)
Added db schema migration script (scripts/migrate.js) for fresh deployments,
updated Dockerfile to include scripts directory, and fixed job-progress test
to include newly added retry fields after D3 foundation merge.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Remove http-only guard in retry endpoint (src/routes/api/jobs/[id]/retry/+server.ts)
- Extend pipeline.ts retryJob() to handle upload-source jobs:
read file from persistent storage, pass buffer to runJob
- Change saveUploadedFile() to save to DATA_DIR/uploads/{jobId}/
(survives cleanupJobTmp, available for retry on failure)
- Add getUploadPath(), cleanupUploadDir() to downloader.ts
- Remove cleanupFiles(rawAudioPath) from catch block in runJob()
— cleanupJobTmp handles TMP_DIR for YouTube, uploads survive
- Add cleanupUploadDir() on webhook success path
- Add retry-endpoint.test.ts with 9 tests covering:
- 404 for unknown job
- 409 for non-retryable status (done, pending)
- 200 for cancelled (retryable)
- 200 for failed YouTube job (existing behavior)
- 200 for failed upload-source job (new behavior, .webm and .mp3)
- Negative: retryJob not called on 404/409
- Add downloader.test.ts tests for saveUploadedFile persistent path,
getUploadPath, cleanupUploadDir, isolation, and non-existent cleanup
- Fix webhook.test.ts mock to include cleanupUploadDir
All 189 tests pass across 12 test files.
Add .catch(() => {}) before vi.runAllTimersAsync() on 3 tests that
test the rejection path — prevents vitest exit code 1 (CI/CD gate)
while still allowing .rejects assertion to work.
Adds a beforeEach hook that clears subscriptions and resets mocks before
each test, making the suite robust against any state left by a previous
test even if afterEach didn't run cleanly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously, if a job completed while the model-warming notice was shown
(e.g. model loaded mid-job), the 'Warming up model' banner persisted on
the Done screen because the SSE 'done' handler didn't clear modelWarming.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two bugs triggered together when the model was unloaded during a job:
1. submitJob() created FormData/createReadStream once outside the retry loop.
After a 503, the audio ReadStream was consumed and subsequent retries sent
an empty body to whisper, causing it to return segments:undefined.
2. webhook handler cast whisperJob.segments as Segment[] without guarding
against undefined, so deduplicateSegments(undefined) crashed with
'Cannot read properties of undefined (reading 'map')' — stored as job.error.
Fixes:
- Move FormData + createReadStream inside the retry loop (fresh stream per attempt)
- Use (whisperJob.segments ?? []) in webhook handler
- Add Array.isArray guard at top of deduplicateSegments() as belt-and-suspenders
Tests:
- New: verifies createReadStream called once per attempt (3 attempts = 3 streams)
- New: webhook handles segments:undefined without throwing
- New: webhook handles segments:null without throwing
- 150/150 passing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lock file was generated with npm 11 (Node 24), CI runs npm 10 (Node 22).
npm install avoids the strict sync check and matches the Dockerfile.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Run vitest before building the image so a failing test blocks the push.
build-and-push now depends on the test job passing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add cancelJob() to whisper.ts: sends DELETE /jobs/:id to the whisper
server (best-effort, errors silently ignored)
- DELETE /api/jobs/[id] now calls cancelJob() when cancelling an active
job that has a whisperJobId, stopping GPU use immediately
- Webhook handler guards against locally-cancelled jobs: returns ok early
so whisper's late completion cannot overwrite cancelled status or send
a phantom 'Transcript ready' notification
- Replace blind sleep(Retry-After + 1s) in submitJob() with
waitForModelReady(): subscribes to /model/events SSE and proceeds as
soon as state:ready arrives; falls back to the Retry-After timeout if
SSE is unreachable or closes without model_ready
- Refactor retry tests to use URL-aware makeJobFetch() helper; add 7 new
tests (3 SSE-triggered retry, 3 cancelJob, 1 webhook cancelled-guard)
— 144/144 passing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
submitJob — 503 retry behavior (10 new tests):
- calls onModelWaiting with correct state + retryAfterSecs on each 503
- retries until model ready and returns job_id
- tracks all three model states (unloaded, loading, waiting_for_gpu)
- uses retry_after_secs from response body
- falls back to Retry-After header when body field absent
- falls back to 15s when both body and header are absent
- throws after maxAttempts exhausted (fetch called exactly N times)
- does NOT call onModelWaiting for non-503 errors
- does NOT retry on non-503 errors (throws immediately, one fetch call)
- works correctly without an onModelWaiting callback
getModelStatus (6 new tests):
- returns parsed status for each model state tag
- includes optional fields (loaded_at, vram_*, retry_in_secs)
- calls the correct WHISPER_URL/model/status endpoint
- throws when server returns non-ok
Uses vi.useFakeTimers()/runAllTimersAsync() to eliminate real delays.
Rejection handler attached before timer advance to avoid unhandled-rejection
false positives from Vitest's detector.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- whisper.ts: add getModelStatus(); fix submitJob() to retry on 503 using
Retry-After header instead of throwing; optional onModelWaiting callback
lets the pipeline surface model state to the UI during the wait
- pipeline.ts: pass onModelWaiting callback → emits model_warming SSE event
so the job detail page can show 'Warming up model…' while waiting
- types.ts: add ModelStateTag union and ModelStatus interface
- api/model/status: GET route proxies whisper /model/status (falls back to
{state:'unloaded'} if whisper unreachable)
- api/model/events: GET route relays whisper SSE stream to the browser;
AbortController tied to request.signal cleans up on disconnect
- layout.svelte: status pill is now live — initial fetch + EventSource on
/api/model/events; dot colour + label reflect real model state with a
pulsing animation while loading or waiting_for_gpu
- jobs/[id]/+page.svelte: handle model_warming event type → show a yellow
'Warming up model…' sub-label with spinner inside the progress card
- whisper.test.ts: update submitJob mocks to status:202 to match real API
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Default SvelteKit node adapter body limit is 512KB — too small for
audio recordings (30s ~556KB, longer recordings much larger).
Set bodySize: 500MB in adapter config. Also set BODY_SIZE_LIMIT env
in production compose .env as belt-and-suspenders.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The prebuilt yt-dlp binary is compiled against glibc and fails on
Alpine Linux (musl libc) with 'cannot execute'. Install python3 +
py3-pip and use pip to install yt-dlp instead.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SvelteKit's CSRF check runs before the handle hook and blocks POSTs
whose Origin header doesn't match the site origin. Web Share Target
POSTs from any external app (YouTube, Chrome share sheet, etc.) are
legitimately cross-origin.
checkOrigin: false is safe here — the app has no cookie-based session
auth, so there is no CSRF attack surface.
Also remove the ineffective hooks.server.ts approach.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SvelteKit's CSRF guard rejects POST requests whose Origin header doesn't
match the site's own origin. Web Share Target POSTs legitimately arrive
from external origins (e.g. youtube.com, OS share sheet). Strip the
Origin header in a handle hook for /share POST only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- runtime: use node user (uid=1000, gid=1000) instead of custom tonemark uid=1001
- add ffmpeg and yt-dlp to runtime image (required by audio pipeline)
- add tzdata, set TZ=Europe/Zurich
- +page.svelte: replace hardcoded ACCENT constant with $derived($accent.value)
so the home page reacts to accent store changes from Settings
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
npm ci fails with optional platform-specific dependencies (@emnapi/core,
@emnapi/runtime) that are not recorded in the lock file for Alpine Linux.
npm install handles optional dependencies correctly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github.actor and github.token are the correct Gitea Actions context
variables (gitea.* context doesn't exist in act_runner).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Avoids needing to set custom REGISTRY_USERNAME/REGISTRY_TOKEN secrets.
The built-in secrets.GITEA_TOKEN has write:package access for pushing
to the Gitea container registry.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tonemark is a SvelteKit PWA for transcribing YouTube videos, audio
and video files, and microphone recordings using a local Whisper backend.
Features:
- Dark glassmorphic UI with electric-lime accent (5 switchable themes)
- Rail nav (desktop) / tab bar (mobile) layout
- Drop zone, YouTube URL input, and live audio recording inputs
- Audio mode waveform cards (none / standard / aggressive / auto)
- Real-time transcription progress with animated waveform
- Job queue with SSE streaming updates
- Push notifications on job completion
- PWA with native SvelteKit service worker
- SRT / TXT / MD / JSON transcript downloads
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>