This recording's audio left the homelab: it was transcribed by a cloud service (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(provider)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 71, Col: 96}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ") because the local engine could not.
Two transcripts were made but could not be reconciled (")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
Only one engine produced this transcript (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 191, Col: 101}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 195, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "); the text shown is ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "), so there was no second opinion to reconcile it with.
Nothing new for ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
The service stopped reporting ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 244, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 245, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case healthQuiet:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
Nothing new for ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var57 string
+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 250, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
--
2.54.0
From ba18668796f29ed41a934d48c9a04f3f2866a9e1 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:03:07 +0200
Subject: [PATCH 06/27] docs(feature): cloud-served transcripts say the audio
left the homelab; empty transcript also triggers the fallback (#52)
Co-Authored-By: Claude Sonnet 5.5
---
docs/homelab-integration.md | 3 ++-
docs/use-cases/audio_upload.feature | 5 +++++
test/acceptance/scenario_coverage_test.go | 2 ++
3 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md
index 6537d96..7472084 100644
--- a/docs/homelab-integration.md
+++ b/docs/homelab-integration.md
@@ -14,7 +14,8 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved.
- **Speech-to-text (ADR-033):** the same gateway serves `POST /v1/audio/transcriptions` for
`iguana/kb-whisper` (primary, local) and `berget/kb-whisper` (fallback, EU cloud — private audio
- leaves the homelab if it is used). **Call it via the NodePort / in-cluster address above, NOT
+ leaves the homelab if it is used: when the primary fails **or returns an empty transcript**,
+ e.g. a silent or music-only file; the transcript page then says so, #52). **Call it via the NodePort / in-cluster address above, NOT
`https://llm-api.d-ma.be`:** that public ingress (openresty) returns `413` for bodies over
roughly 8–10 MB (~5 minutes of 16 kHz WAV), while the NodePort took 174 MB (90 minutes) in one
request (measured 2026-10-01, `docs/research/audio-length-ceiling-2026-10-01.md`). Send multipart
diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature
index e45c465..cd3ed30 100644
--- a/docs/use-cases/audio_upload.feature
+++ b/docs/use-cases/audio_upload.feature
@@ -240,6 +240,11 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Then it is transcribed anyway and its audio step says how long the audio really is
And a complete recording whose header over-estimated its length is not refused
+ Scenario: A transcript made by the cloud fallback says the audio left the homelab
+ When the transcript was produced by an endpoint that is not local
+ Then its page says in plain words that the audio left the homelab and names the provider
+ And a locally transcribed recording carries no such note
+
Scenario: A full queue is a retry, not an error
When too many recordings are already waiting
Then the upload is answered as busy, its row is failed honestly and its audio is deleted
diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go
index 9bbf026..0dfcbba 100644
--- a/test/acceptance/scenario_coverage_test.go
+++ b/test/acceptance/scenario_coverage_test.go
@@ -122,6 +122,8 @@ var scenarioCoverage = map[string]string{
"I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
"A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
+ "A transcript made by the cloud fallback says the audio left the homelab": "TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab",
+
// audio_upload.feature, ADR-034
"Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled",
"Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel",
--
2.54.0
From 68815755e82bfbd046b479b196f70352c17239a0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:03:37 +0200
Subject: [PATCH 07/27] test(progress): a hung save or beat must not stall the
reporter or the heartbeat (#51), red
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker_stall_test.go | 98 +++++++++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 internal/progress/tracker_stall_test.go
diff --git a/internal/progress/tracker_stall_test.go b/internal/progress/tracker_stall_test.go
new file mode 100644
index 0000000..ce59436
--- /dev/null
+++ b/internal/progress/tracker_stall_test.go
@@ -0,0 +1,98 @@
+package progress_test
+
+// Observes what a database that stops answering does to the job it reports on (#51):
+// progress is cosmetic, so a save that never returns must not stall the engine calls that
+// report through the tracker, nor the heartbeat that says the process is alive.
+
+import (
+ "bytes"
+ "context"
+ "log/slog"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
+)
+
+// stallSink never answers until its context ends, like a database connection that hung.
+type stallSink struct{ beats atomic.Int32 }
+
+func (s *stallSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error {
+ <-ctx.Done()
+ return ctx.Err()
+}
+
+func (s *stallSink) Beat(ctx context.Context) error {
+ s.beats.Add(1)
+ <-ctx.Done()
+ return ctx.Err()
+}
+
+type lockedBuf struct {
+ mu sync.Mutex
+ b bytes.Buffer
+}
+
+func (l *lockedBuf) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.Write(p)
+}
+
+func (l *lockedBuf) String() string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.String()
+}
+
+func stallTracker(sink progress.Sink, buf *lockedBuf) *progress.Tracker {
+ log := slog.New(slog.NewTextHandler(buf, nil))
+ return progress.NewTracker(context.Background(), sink, log,
+ []progress.Planned{{Key: "a", Label: "A"}}, progress.WithSaveTimeout(50*time.Millisecond))
+}
+
+func TestTracker_AHungSaveDoesNotBlockTheReporterPastItsTimeout(t *testing.T) {
+ buf := &lockedBuf{}
+ tr := stallTracker(&stallSink{}, buf)
+
+ done := make(chan struct{})
+ go func() { tr.Begin("a", 0); tr.Advance("a", 1); tr.Done("a"); close(done) }()
+
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("the reporter was blocked by a save that never returned")
+ }
+ require.Contains(t, buf.String(), "progress: not saved", "a dropped save is logged")
+}
+
+func TestTracker_AHungBeatDoesNotStopTheHeartbeat(t *testing.T) {
+ sink := &stallSink{}
+ tr := stallTracker(sink, &lockedBuf{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
+
+ require.Eventually(t, func() bool { return sink.beats.Load() >= 3 }, 3*time.Second, 5*time.Millisecond,
+ "each beat is abandoned at its timeout and the next one is tried")
+}
+
+func TestTracker_TheStillRunningLineIsNotHeldUpBehindAHungSave(t *testing.T) {
+ buf := &lockedBuf{}
+ tr := stallTracker(&stallSink{}, buf)
+ go tr.Begin("a", 0) // holds the tracker's lock inside a save that hangs until its timeout
+ time.Sleep(10 * time.Millisecond)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go tr.Heartbeat(ctx, time.Hour, 5*time.Millisecond)
+
+ require.Eventually(t, func() bool { return strings.Contains(buf.String(), "still running") }, 3*time.Second, 5*time.Millisecond)
+}
--
2.54.0
From 2ec1557a8cedc83b8915d7968fec897c622f0f3c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:03:56 +0200
Subject: [PATCH 08/27] fix(progress): bound every save and heartbeat write so
a hung database cannot stall the job (#51)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker.go | 23 ++++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go
index 71c3d2f..d8878b4 100644
--- a/internal/progress/tracker.go
+++ b/internal/progress/tracker.go
@@ -41,8 +41,9 @@ type Sink interface {
}
const (
- defaultMinSave = 2 * time.Second
- logAdvanceEach = time.Minute
+ defaultMinSave = 2 * time.Second
+ defaultSaveTimeout = 5 * time.Second
+ logAdvanceEach = time.Minute
)
// Option tunes a Tracker.
@@ -55,6 +56,11 @@ func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now =
// Done, Fail and Finish always save.
func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } }
+// WithSaveTimeout bounds one save or heartbeat write. Progress is cosmetic, so a database
+// that stops answering costs a dropped write, never a stalled job: saves run under the
+// tracker's lock, which Begin and Advance (called by the engines) need (#51).
+func WithSaveTimeout(d time.Duration) Option { return func(t *Tracker) { t.saveTimeout = d } }
+
// Tracker implements Reporter for one job.
type Tracker struct {
ctx context.Context
@@ -63,6 +69,8 @@ type Tracker struct {
now func() time.Time
minSave time.Duration
+ saveTimeout time.Duration
+
mu sync.Mutex
steps []domain.ProgressStep
lastAdvanceSave time.Time
@@ -72,7 +80,7 @@ type Tracker struct {
// NewTracker plans a job's phases. ctx is used for the saves, so it should outlive the
// job's own timeout: a job that timed out still gets its failure recorded.
func NewTracker(ctx context.Context, sink Sink, log *slog.Logger, plan []Planned, opts ...Option) *Tracker {
- t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave}
+ t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave, saveTimeout: defaultSaveTimeout}
for _, o := range opts {
o(t)
}
@@ -212,7 +220,10 @@ func (t *Tracker) Heartbeat(ctx context.Context, beatEvery, stillEvery time.Dura
case <-ctx.Done():
return
case <-beat.C:
- if err := t.sink.Beat(ctx); err != nil && ctx.Err() == nil {
+ beatCtx, cancel := context.WithTimeout(ctx, t.saveTimeout)
+ err := t.sink.Beat(beatCtx)
+ cancel()
+ if err != nil && ctx.Err() == nil {
t.log.Warn("progress: heartbeat not saved", "err", err)
}
case <-still.C:
@@ -254,7 +265,9 @@ func (t *Tracker) find(key string) *domain.ProgressStep {
// dropped: progress is cosmetic and must never fail the job it describes.
func (t *Tracker) save() {
snapshot := append([]domain.ProgressStep(nil), t.steps...)
- if err := t.sink.SaveProgress(t.ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil {
+ ctx, cancel := context.WithTimeout(t.ctx, t.saveTimeout)
+ defer cancel()
+ if err := t.sink.SaveProgress(ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil {
t.log.Warn("progress: not saved", "err", err)
}
}
--
2.54.0
From 813192e5ba5b1b005cf36e3b1a3c610a24c675b5 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:04:15 +0200
Subject: [PATCH 09/27] test: heartbeats are stamped with the application
clock, not the database's NOW() (#51), red
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 2 +-
.../uploaded_transcripts_progress_test.go | 8 ++---
internal/progress/tracker_stall_test.go | 2 +-
internal/progress/tracker_test.go | 33 +++++++++++++++----
internal/uploads/worker_test.go | 2 +-
5 files changed, 33 insertions(+), 14 deletions(-)
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
index d96aeb2..cbedef2 100644
--- a/cmd/tapir/uploads_test.go
+++ b/cmd/tapir/uploads_test.go
@@ -44,7 +44,7 @@ func (f *fakeUploadsStore) SaveProgress(context.Context, string, string, []domai
return nil
}
-func (f *fakeUploadsStore) Heartbeat(context.Context, string, string) error { return nil }
+func (f *fakeUploadsStore) Heartbeat(context.Context, string, string, time.Time) error { return nil }
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
f.sweeps++
diff --git a/internal/adapters/store/uploaded_transcripts_progress_test.go b/internal/adapters/store/uploaded_transcripts_progress_test.go
index 3456df2..7fdf827 100644
--- a/internal/adapters/store/uploaded_transcripts_progress_test.go
+++ b/internal/adapters/store/uploaded_transcripts_progress_test.go
@@ -68,13 +68,13 @@ func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
- before := time.Now().Add(-time.Second)
+ beatAt := at.Add(90 * time.Second) // deliberately not the database's NOW()
- require.NoError(t, s.Heartbeat(ctx, userA, id))
+ require.NoError(t, s.Heartbeat(ctx, userA, id, beatAt))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
- assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now")
+ assert.True(t, got.AliveAt.Equal(beatAt), "the heartbeat is stamped with the application's clock, the same one progress_at uses, not the database's NOW() (#51)")
assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
}
@@ -99,7 +99,7 @@ func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) {
ctx := context.Background()
errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
- errBeat := s.Heartbeat(ctx, userB, id)
+ errBeat := s.Heartbeat(ctx, userB, id, time.Now())
assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
diff --git a/internal/progress/tracker_stall_test.go b/internal/progress/tracker_stall_test.go
index ce59436..f31f425 100644
--- a/internal/progress/tracker_stall_test.go
+++ b/internal/progress/tracker_stall_test.go
@@ -28,7 +28,7 @@ func (s *stallSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _
return ctx.Err()
}
-func (s *stallSink) Beat(ctx context.Context) error {
+func (s *stallSink) Beat(ctx context.Context, _ time.Time) error {
s.beats.Add(1)
<-ctx.Done()
return ctx.Err()
diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go
index 52b00ee..8fc2aff 100644
--- a/internal/progress/tracker_test.go
+++ b/internal/progress/tracker_test.go
@@ -17,11 +17,12 @@ import (
// fakeSink records every saved snapshot and every heartbeat.
type fakeSink struct {
- mu sync.Mutex
- saves [][]domain.ProgressStep
- at []time.Time
- beats int
- err error
+ mu sync.Mutex
+ saves [][]domain.ProgressStep
+ at []time.Time
+ beats int
+ beatAt []time.Time
+ err error
}
func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep, at time.Time) error {
@@ -32,10 +33,11 @@ func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep,
return f.err
}
-func (f *fakeSink) Beat(context.Context) error {
+func (f *fakeSink) Beat(_ context.Context, at time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
f.beats++
+ f.beatAt = append(f.beatAt, at)
return f.err
}
@@ -297,7 +299,7 @@ func (r *retainingSink) SaveProgress(_ context.Context, steps []domain.ProgressS
r.got = append(r.got, steps)
return nil
}
-func (r *retainingSink) Beat(context.Context) error { return nil }
+func (r *retainingSink) Beat(context.Context, time.Time) error { return nil }
func TestTracker_ASnapshotHandedToTheSinkIsNotChangedByLaterTransitions(t *testing.T) {
sink := &retainingSink{}
@@ -400,3 +402,20 @@ func TestTracker_AnnotateAttachesANoteToAStepAndSavesItAtOnce(t *testing.T) {
require.Equal(t, "20 min of the 79 the file claimed", sink.last(t)[0].Note)
require.NotPanics(t, func() { tr.Annotate("no-such-step", "x") })
}
+
+func TestTracker_BeatIsStampedWithTheTrackersClock(t *testing.T) {
+ // alive_at used the database's NOW() while the page compared it with the application
+ // clock; a skewed database clock showed a healthy job as lost (#51).
+ tr, sink, clk, _ := newTracker(t)
+ clk.tick(42 * time.Second)
+ want := clk.now()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
+
+ require.Eventually(t, func() bool { return sink.beatCount() >= 1 }, 2*time.Second, 5*time.Millisecond)
+ sink.mu.Lock()
+ defer sink.mu.Unlock()
+ require.True(t, sink.beatAt[0].Equal(want), "beat stamped %v, the tracker's clock says %v", sink.beatAt[0], want)
+}
diff --git a/internal/uploads/worker_test.go b/internal/uploads/worker_test.go
index 8fac296..728e404 100644
--- a/internal/uploads/worker_test.go
+++ b/internal/uploads/worker_test.go
@@ -70,7 +70,7 @@ func (s *wStore) SaveProgress(_ context.Context, _, _ string, steps []domain.Pro
return nil
}
-func (s *wStore) Heartbeat(context.Context, string, string) error {
+func (s *wStore) Heartbeat(context.Context, string, string, time.Time) error {
time.Sleep(s.beatDelay)
s.mu.Lock()
defer s.mu.Unlock()
--
2.54.0
From b8af7f32c79f0b8f62a2f19029fd5a34d13171a0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:04:42 +0200
Subject: [PATCH 10/27] fix(progress): stamp the heartbeat with the application
clock the page compares it with (#51)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/store/uploaded_transcripts.go | 12 +++++++-----
internal/progress/tracker.go | 4 ++--
internal/uploads/worker.go | 6 ++++--
3 files changed, 13 insertions(+), 9 deletions(-)
diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go
index a77d160..c62fb5a 100644
--- a/internal/adapters/store/uploaded_transcripts.go
+++ b/internal/adapters/store/uploaded_transcripts.go
@@ -80,12 +80,14 @@ func (s *Store) SaveProgress(ctx context.Context, userID, id string, steps []dom
WHERE id = $1 AND user_id = $2`, b, at)
}
-// Heartbeat stamps the upload as alive now. It does not touch progress_at: alive says
-// the service is still reporting, not that work is moving (ADR-035).
-func (s *Store) Heartbeat(ctx context.Context, userID, id string) error {
+// Heartbeat stamps the upload as alive at the given time. It does not touch progress_at:
+// alive says the service is still reporting, not that work is moving (ADR-035). The time
+// comes from the application clock, the one progress_at and the page's health check use,
+// so a database clock that disagrees cannot make a healthy job look lost (#51).
+func (s *Store) Heartbeat(ctx context.Context, userID, id string, at time.Time) error {
return s.updateUpload(ctx, userID, id, "heartbeat",
- `UPDATE uploaded_transcripts SET alive_at = NOW()
- WHERE id = $1 AND user_id = $2`)
+ `UPDATE uploaded_transcripts SET alive_at = $3
+ WHERE id = $1 AND user_id = $2`, at)
}
// FailUpload moves a queued or transcribing upload to failed with a typed reason. A
diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go
index d8878b4..c365d58 100644
--- a/internal/progress/tracker.go
+++ b/internal/progress/tracker.go
@@ -37,7 +37,7 @@ type Reporter interface {
// is for people, so a failed write is logged and never stops the job.
type Sink interface {
SaveProgress(ctx context.Context, steps []domain.ProgressStep, at time.Time) error
- Beat(ctx context.Context) error
+ Beat(ctx context.Context, at time.Time) error
}
const (
@@ -221,7 +221,7 @@ func (t *Tracker) Heartbeat(ctx context.Context, beatEvery, stillEvery time.Dura
return
case <-beat.C:
beatCtx, cancel := context.WithTimeout(ctx, t.saveTimeout)
- err := t.sink.Beat(beatCtx)
+ err := t.sink.Beat(beatCtx, t.now())
cancel()
if err != nil && ctx.Err() == nil {
t.log.Warn("progress: heartbeat not saved", "err", err)
diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go
index 9fa2958..a29fecf 100644
--- a/internal/uploads/worker.go
+++ b/internal/uploads/worker.go
@@ -47,7 +47,7 @@ type WorkerStore interface {
CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error
- Heartbeat(ctx context.Context, userID, id string) error
+ Heartbeat(ctx context.Context, userID, id string, at time.Time) error
}
// WorkerOption tunes a Worker.
@@ -91,7 +91,9 @@ func (s jobSink) SaveProgress(ctx context.Context, steps []domain.ProgressStep,
return s.st.SaveProgress(ctx, s.userID, s.id, steps, at)
}
-func (s jobSink) Beat(ctx context.Context) error { return s.st.Heartbeat(ctx, s.userID, s.id) }
+func (s jobSink) Beat(ctx context.Context, at time.Time) error {
+ return s.st.Heartbeat(ctx, s.userID, s.id, at)
+}
// Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is
// not a second deployable: it shares the web process's lifetime, which is why it
--
2.54.0
From 4ef3e85d16879587edb5b2f548fc168a1aa64041 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:05:16 +0200
Subject: [PATCH 11/27] test(web): the home list links to recordings only when
uploads are mounted (#37), red
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_nav_test.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
create mode 100644 internal/web/uploads_nav_test.go
diff --git a/internal/web/uploads_nav_test.go b/internal/web/uploads_nav_test.go
new file mode 100644
index 0000000..bf62bdd
--- /dev/null
+++ b/internal/web/uploads_nav_test.go
@@ -0,0 +1,19 @@
+package web_test
+
+// /uploads was mounted but nothing linked to it (#37). The shared Layout has no access to
+// the App, so the entry point is the home list, shown only when uploads are mounted.
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHomeList_LinksToRecordingsOnlyWhenUploadsAreMounted(t *testing.T) {
+ with, _ := newUploadApp(t, audioOK, 200<<20)
+ without := newApp(t)
+
+ assert.Contains(t, body(t, do(t, with, get("/"))), `href="/uploads"`)
+ assert.NotContains(t, body(t, do(t, without, get("/"))), `href="/uploads"`,
+ "a link to a route that is not mounted would be a dead end")
+}
--
2.54.0
From 453464a22b8a0d238f13ffc4a1a2835b4f3f291e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:05:28 +0200
Subject: [PATCH 12/27] feat(web): the home list links to recordings when
uploads are mounted (#37)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/handlers.go | 2 +-
internal/web/paste_test.go | 2 +-
internal/web/views.templ | 5 +-
internal/web/views_templ.go | 706 ++++++++++++++++++------------------
4 files changed, 364 insertions(+), 351 deletions(-)
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index 2e06cb5..141120a 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -309,7 +309,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
a.render(w, r, summaryList(buckets, hasConnected, autoSummarize))
return
}
- a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize))
+ a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize, a.Uploads != nil))
}
// handleDetail renders one summary in full (highlights, takeaways, action group).
diff --git a/internal/web/paste_test.go b/internal/web/paste_test.go
index ee92feb..6ca9a54 100644
--- a/internal/web/paste_test.go
+++ b/internal/web/paste_test.go
@@ -66,7 +66,7 @@ func TestParseYouTubeVideoID(t *testing.T) {
func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) {
render := func(connected bool) string {
var buf bytes.Buffer
- if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil, true).Render(context.Background(), &buf); err != nil {
+ if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil, true, false).Render(context.Background(), &buf); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
diff --git a/internal/web/views.templ b/internal/web/views.templ
index 55d9c9b..d137c44 100644
--- a/internal/web/views.templ
+++ b/internal/web/views.templ
@@ -107,9 +107,12 @@ templ flashBanner(code string) {
// #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect.
-templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool) {
+templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool, uploadsEnabled bool) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
+ if uploadsEnabled {
+
Recordings: upload audio and get a private transcript.
+ }
if hasConnected {
@pasteForm()
}
diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go
index 8f2732f..2943941 100644
--- a/internal/web/views_templ.go
+++ b/internal/web/views_templ.go
@@ -294,7 +294,7 @@ func flashBanner(code string) templ.Component {
}
ctx = templ.ClearChildren(ctx)
if f, ok := flashFor(code); ok {
- templ_7745c5c3_Var9 := []any{"flash", "flash-" + f.Kind}
+ var templ_7745c5c3_Var9 = []any{"flash", "flash-" + f.Kind}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -338,7 +338,7 @@ func flashBanner(code string) templ.Component {
// #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect.
-func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool) templ.Component {
+func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool, uploadsEnabled bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -379,13 +379,23 @@ func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasCon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ if uploadsEnabled {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
Recordings: upload audio and get a private transcript.
Your account is connected ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
Your account is connected ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if autoSummarize {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Tapir is finding your subscriptions. You are in Manual mode, so videos appear here with a Summarize button — pick the ones you want, or switch to Automatic in your account.")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "Tapir is finding your subscriptions. You are in Manual mode, so videos appear here with a Summarize button — pick the ones you want, or switch to Automatic in your account.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !v.Available {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "
Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.
Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.
Working from a bounded portion of a long transcript — answers about the end of the ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "
Working from a bounded portion of a long transcript — answers about the end of the ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var88 string
templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(v.subject())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 586, Col: 129}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 589, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " may be incomplete.
Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.
Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.
This permanently deletes the recording and its transcript. Are you sure?
+
+
+
+}
+
// UploadChatPage is the no-JS full page for chatting with an uploaded transcript:
// the same chat panel as a video's (ADR-027), scoped to this recording.
templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index 44cff63..b2d3bf3 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -386,12 +386,20 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
Two transcripts were made but could not be reconciled (")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var42 string
- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 101}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "); the text shown is ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
Only one engine produced this transcript (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 132}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 211, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "'s, unreconciled.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "), so there was no second opinion to reconcile it with.")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case domain.ReconcileSkipped:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
Two transcripts were made but could not be reconciled (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var44 string
+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 213, Col: 101}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "); the text shown is ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var45 string
+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 213, Col: 132}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "'s, unreconciled.
The service stopped reporting ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
The service stopped reporting ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var56 string
- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
+ var templ_7745c5c3_Var58 string
+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 245, Col: 54}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 261, Col: 54}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case healthQuiet:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
Nothing new for ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
Nothing new for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var57 string
- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
+ var templ_7745c5c3_Var59 string
+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 250, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 266, Col: 40}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
--
2.54.0
From a6691ac46bf01aeb710ae597b47fd504b1ecd385 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:07:11 +0200
Subject: [PATCH 18/27] docs: per-recording delete and the recordings link are
built; feature scenarios and coverage map (#37)
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 8 ++++++--
docs/use-cases/audio_upload.feature | 13 +++++++++++++
test/acceptance/scenario_coverage_test.go | 4 ++++
3 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index 86a60be..01f0dc4 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1551,8 +1551,12 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
disabled by default, a failed iguana job fails honestly and the user retries. *Blocks:*
the production default only; the adapter ships either way.
- ~~Verify Tapir's ingress `proxy-body-size` against the 200 MB cap (`infra`).~~ **Resolved 2026-10-03:** real unauthenticated bodies of 2, 30, 120 and 210 MiB all pass NPM (TLS, piguard) and Traefik to tapir (a 302 to login); there is no body limit in that range. Uploads were enabled in production the same day, local engines only (infra#520).
-- Per-transcript deletion is not built; account deletion cascades. A private-content store
- without a delete button is a gap worth a ticket, not a silent omission.
+- ~~Per-transcript deletion is not built; account deletion cascades.~~ **Built 2026-10-03
+ (#37):** `POST /uploads/{id}/delete`, owner-scoped (a non-owner gets 404 by the same RLS
+ mechanism), confirm-then-delete on the recording's page. A recording that is **queued or
+ transcribing is refused (409) until it ends**, because the worker would otherwise write to a
+ row that is gone; a job whose service was lost stays undeletable until the next start's sweep
+ marks it failed. The home list now links to `/uploads` when uploads are mounted.
**Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload
surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos`
diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature
index cd3ed30..d591ed7 100644
--- a/docs/use-cases/audio_upload.feature
+++ b/docs/use-cases/audio_upload.feature
@@ -245,6 +245,19 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Then its page says in plain words that the audio left the homelab and names the provider
And a locally transcribed recording carries no such note
+ Scenario: I can delete a finished recording
+ When I confirm deleting a done or failed recording
+ Then the recording and its transcript are gone and I am back on my list
+ And another user's recording is a 404 and is untouched
+
+ Scenario: A recording still being transcribed cannot be deleted
+ When I try to delete a recording that is queued or transcribing
+ Then I am told it is still being transcribed and nothing is removed
+
+ Scenario: The home list links to my recordings when uploads are on
+ Given audio upload is enabled
+ Then the home list links to the recordings page
+
Scenario: A full queue is a retry, not an error
When too many recordings are already waiting
Then the upload is answered as busy, its row is failed honestly and its audio is deleted
diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go
index 0dfcbba..bc16562 100644
--- a/test/acceptance/scenario_coverage_test.go
+++ b/test/acceptance/scenario_coverage_test.go
@@ -124,6 +124,10 @@ var scenarioCoverage = map[string]string{
"A transcript made by the cloud fallback says the audio left the homelab": "TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab",
+ "I can delete a finished recording": "TestUploadDelete_AnOwnerRemovesAFinishedOrFailedRecordingAndLandsOnTheList",
+ "A recording still being transcribed cannot be deleted": "TestUploadDelete_ARecordingStillBeingTranscribedIsRefusedWith409AndKept",
+ "The home list links to my recordings when uploads are on": "TestHomeList_LinksToRecordingsOnlyWhenUploadsAreMounted",
+
// audio_upload.feature, ADR-034
"Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled",
"Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel",
--
2.54.0
From 25628a999499845527bcd9ca6a3045686b595fc0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:22:24 +0200
Subject: [PATCH 19/27] test(web): the per-recording delete route is covered by
the cross-origin gate (#37, #48)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/crossorigin_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/web/crossorigin_test.go b/internal/web/crossorigin_test.go
index 0d5e915..b75d832 100644
--- a/internal/web/crossorigin_test.go
+++ b/internal/web/crossorigin_test.go
@@ -113,6 +113,7 @@ func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testi
"/v/abc/summarize",
"/v/abc/retry-now",
"/v/abc/chat",
+ "/uploads/00000000-0000-0000-0000-000000000000/delete",
}
for _, path := range routes {
t.Run(path, func(t *testing.T) {
--
2.54.0
From 9aa01468cef3948c35721b04cacc8d7b009111b6 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:30:44 +0200
Subject: [PATCH 20/27] fix(web): judge each alias of a reconciled endpoint, so
an all-local transcript is not called cloud-served (#52)
The two-engine path writes ' + , reconciled by '. IsLocalModel is a
single-alias match, so the composite was 'not local' and the note claimed the audio left
the homelab, naming a local host as the provider. Found by the PR #58 review.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 24 +++++++++++++++++---
internal/web/uploads_cloud_note_test.go | 29 +++++++++++++++++++++++++
2 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index f4638f6..8ef6a43 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -273,12 +273,30 @@ func titleFromFilename(name string) string {
// cloudProvider names the cloud service that produced a transcript, or false when it was
// served locally (or by an unknown endpoint). "Local" is config.IsLocalModel: the same
// definition the start-up validation uses, not a second string match here (#52).
+//
+// The endpoint is one alias, except for the two-engine path (ADR-034), which writes
+// " + , reconciled by ". Every alias in it is judged on its own, so a
+// composite of local models is local and one naming a cloud alias names that provider.
func cloudProvider(endpoint string) (string, bool) {
- if endpoint == "" || config.IsLocalModel(endpoint) {
+ if strings.TrimSpace(endpoint) == "" {
return "", false
}
- provider, _, _ := strings.Cut(endpoint, "/")
- return provider, true
+ aliases := []string{}
+ for _, tok := range strings.Fields(endpoint) {
+ if tok = strings.Trim(tok, ","); strings.Contains(tok, "/") {
+ aliases = append(aliases, tok)
+ }
+ }
+ if len(aliases) == 0 {
+ aliases = append(aliases, strings.TrimSpace(endpoint))
+ }
+ for _, a := range aliases {
+ if !config.IsLocalModel(a) {
+ provider, _, _ := strings.Cut(a, "/")
+ return provider, true
+ }
+ }
+ return "", false
}
// hasNote reports whether any phase carries a note. A note is the only warning for a
diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go
index f6ebf76..7e68187 100644
--- a/internal/web/uploads_cloud_note_test.go
+++ b/internal/web/uploads_cloud_note_test.go
@@ -53,3 +53,32 @@ func TestUploadStatus_AnEndpointThatOnlyLooksLocalIsStillNamedAsCloud(t *testing
assert.Contains(t, b, "left the homelab")
}
+
+// The two-engine path (ADR-034) writes one composite endpoint string. Every name in it is
+// a local model, so the audio never left the homelab and the note must not say it did.
+func TestUploadStatus_AReconciledAllLocalEndpointCarriesNoCloudNote(t *testing.T) {
+ for _, endpoint := range []string{
+ "iguana/kb-whisper + koala/whisper, reconciled by iguana/gemma4-26b",
+ "iguana/kb-whisper + koala/whisper, reconciled by iguana/gemma4-26b\n",
+ } {
+ t.Run(endpoint, func(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+ setEndpoint(t, id, endpoint)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.NotContains(t, b, "left the homelab")
+ })
+ }
+}
+
+func TestUploadStatus_AReconciledEndpointNamingACloudEngineStillNamesTheCloudProvider(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+ setEndpoint(t, id, "iguana/kb-whisper + berget/kb-whisper, reconciled by iguana/gemma4-26b")
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "cloud service (berget)")
+}
--
2.54.0
From 01c1606caa7387cd5df15f4ffcd2d872e1a18198 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:30:58 +0200
Subject: [PATCH 21/27] test(progress): the default save and heartbeat deadline
is pinned, not only the override (#51)
Every earlier test passed WithSaveTimeout; the worker passes nothing, so the 5 s default
was the only production value and a flip to 0, 1ms or 1h stayed green. Found by the PR #58
review; each flip is now red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker_stall_test.go | 56 +++++++++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/internal/progress/tracker_stall_test.go b/internal/progress/tracker_stall_test.go
index f31f425..6aec38b 100644
--- a/internal/progress/tracker_stall_test.go
+++ b/internal/progress/tracker_stall_test.go
@@ -96,3 +96,59 @@ func TestTracker_TheStillRunningLineIsNotHeldUpBehindAHungSave(t *testing.T) {
require.Eventually(t, func() bool { return strings.Contains(buf.String(), "still running") }, 3*time.Second, 5*time.Millisecond)
}
+
+// deadlineSink records how long each write was given, and answers at once.
+type deadlineSink struct {
+ mu sync.Mutex
+ saves []time.Duration
+ beats []time.Duration
+ noDLOK bool
+}
+
+func (d *deadlineSink) record(ctx context.Context, into *[]time.Duration) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ dl, ok := ctx.Deadline()
+ if !ok {
+ d.noDLOK = true
+ return
+ }
+ *into = append(*into, time.Until(dl))
+}
+
+func (d *deadlineSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error {
+ d.record(ctx, &d.saves)
+ return nil
+}
+
+func (d *deadlineSink) Beat(ctx context.Context, _ time.Time) error {
+ d.record(ctx, &d.beats)
+ return nil
+}
+
+// The production value is the one the worker gets: it builds a Tracker without options.
+// A zero or tiny default would drop every write, a huge one would bound nothing.
+func TestTracker_TheDefaultSaveAndBeatDeadlineIsAFewSecondsAway(t *testing.T) {
+ sink := &deadlineSink{}
+ tr := progress.NewTracker(context.Background(), sink, slog.New(slog.NewTextHandler(&lockedBuf{}, nil)),
+ []progress.Planned{{Key: "a", Label: "A"}})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
+
+ tr.Begin("a", 0)
+ require.Eventually(t, func() bool {
+ sink.mu.Lock()
+ defer sink.mu.Unlock()
+ return len(sink.beats) > 0
+ }, 3*time.Second, 5*time.Millisecond)
+
+ sink.mu.Lock()
+ defer sink.mu.Unlock()
+ require.False(t, sink.noDLOK, "a write was given no deadline at all")
+ require.NotEmpty(t, sink.saves)
+ for _, d := range append(append([]time.Duration{}, sink.saves...), sink.beats...) {
+ require.Greater(t, d, time.Second, "the deadline is already (nearly) spent when the write starts")
+ require.LessOrEqual(t, d, 5*time.Second, "the default bound is 5 s")
+ }
+}
--
2.54.0
From 2e54d80a7817b3ee5cd7edd22cc1eb50c2f2a95c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:31:14 +0200
Subject: [PATCH 22/27] test(uploads): jobSink forwards the tracker's timestamp
to the store (#51)
The worker fake ignored the heartbeat time, so the store-boundary half of the app-clock
fix had no assertion. Found by the PR #58 review.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/jobsink_test.go | 48 ++++++++++++++++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 internal/uploads/jobsink_test.go
diff --git a/internal/uploads/jobsink_test.go b/internal/uploads/jobsink_test.go
new file mode 100644
index 0000000..79da468
--- /dev/null
+++ b/internal/uploads/jobsink_test.go
@@ -0,0 +1,48 @@
+package uploads
+
+// Observes what jobSink hands the store: the heartbeat's timestamp is the one the tracker
+// stamped (its application clock), not a second reading taken here (#51).
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+type atStore struct{ beatAt, saveAt time.Time }
+
+func (*atStore) StartTranscribing(context.Context, string, string) error { return nil }
+func (*atStore) CompleteUpload(context.Context, string, string, domain.Transcription) error {
+ return nil
+}
+func (*atStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error { return nil }
+func (s *atStore) SaveProgress(_ context.Context, _, _ string, _ []domain.ProgressStep, at time.Time) error {
+ s.saveAt = at
+ return nil
+}
+func (s *atStore) Heartbeat(_ context.Context, _, _ string, at time.Time) error {
+ s.beatAt = at
+ return nil
+}
+
+func TestJobSink_ForwardsTheTrackersTimestampToTheStoreUnchanged(t *testing.T) {
+ st := &atStore{}
+ sink := jobSink{st: st, userID: "u", id: "i"}
+ stamp := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
+
+ if err := sink.Beat(context.Background(), stamp); err != nil {
+ t.Fatal(err)
+ }
+ if err := sink.SaveProgress(context.Background(), nil, stamp); err != nil {
+ t.Fatal(err)
+ }
+
+ if !st.beatAt.Equal(stamp) {
+ t.Errorf("heartbeat reached the store stamped %v, want the tracker's %v", st.beatAt, stamp)
+ }
+ if !st.saveAt.Equal(stamp) {
+ t.Errorf("progress save reached the store stamped %v, want the tracker's %v", st.saveAt, stamp)
+ }
+}
--
2.54.0
From 4a0226d8bd61847dd465ad4f5eed5e102329e148 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:31:30 +0200
Subject: [PATCH 23/27] test(llm): the response cap is pinned at its boundary
and as the production default (#53)
Only the 8-byte override was exercised; >= vs >, a huge default and a non-positive option
all stayed green. Also says why the error-body read ignores its error. Found by the PR #58
review.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/llm/bodycap_test.go | 75 +++++++++++++++++++++++++++
internal/adapters/llm/client.go | 1 +
2 files changed, 76 insertions(+)
diff --git a/internal/adapters/llm/bodycap_test.go b/internal/adapters/llm/bodycap_test.go
index 293381f..d56e3d7 100644
--- a/internal/adapters/llm/bodycap_test.go
+++ b/internal/adapters/llm/bodycap_test.go
@@ -79,3 +79,78 @@ func TestClient_AFailingBodyIsReadOnlyUpToTheErrorBound(t *testing.T) {
t.Fatalf("the client kept reading an endless error body: took %s", time.Since(start))
}
}
+
+// sizedGateway answers 200 with a valid completion padded with trailing whitespace to
+// exactly size bytes, so the boundary of the cap can be probed to the byte.
+func sizedGateway(t *testing.T, size int) *httptest.Server {
+ t.Helper()
+ const base = `{"choices":[{"message":{"content":"ok"}}]}`
+ payload := base + strings.Repeat(" ", size-len(base))
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(payload))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestClient_ABodyOfExactlyTheCapIsReadAndOneByteMoreIsRefused(t *testing.T) {
+ const capBytes = 4096
+ for _, tt := range []struct {
+ name string
+ size int
+ wantErr bool
+ }{
+ {"one under", capBytes - 1, false},
+ {"exactly the cap", capBytes, false},
+ {"one over", capBytes + 1, true},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ c := New(sizedGateway(t, tt.size).URL, "", "m", 5*time.Second, WithMaxResponseBytes(capBytes))
+
+ got, err := c.Complete(context.Background(), "s", "u")
+
+ if tt.wantErr {
+ if err == nil || !strings.Contains(err.Error(), "exceeds") {
+ t.Fatalf("want a response-too-large error, got %q, %v", got, err)
+ }
+ return
+ }
+ if err != nil || got != "ok" {
+ t.Fatalf("want ok, got %q, %v", got, err)
+ }
+ })
+ }
+}
+
+// New is what production calls: no option. The default is therefore the live bound.
+func TestClient_TheDefaultCapIsEightMiBWhenNoOptionIsGiven(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ size int
+ wantErr bool
+ }{
+ {"exactly 8 MiB", 8 << 20, false},
+ {"8 MiB plus one", 8<<20 + 1, true},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ c := New(sizedGateway(t, tt.size).URL, "", "m", 10*time.Second)
+
+ _, err := c.Complete(context.Background(), "s", "u")
+
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("size %d: err = %v, wantErr %v", tt.size, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestClient_ANonPositiveCapOptionKeepsTheDefault(t *testing.T) {
+ for _, n := range []int64{0, -1} {
+ c := New(sizedGateway(t, 1024).URL, "", "m", 5*time.Second, WithMaxResponseBytes(n))
+
+ if got, err := c.Complete(context.Background(), "s", "u"); err != nil || got != "ok" {
+ t.Fatalf("WithMaxResponseBytes(%d): want ok from the default cap, got %q, %v", n, got, err)
+ }
+ }
+}
diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go
index 1c5a92f..b9d5cca 100644
--- a/internal/adapters/llm/client.go
+++ b/internal/adapters/llm/client.go
@@ -202,6 +202,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
if resp.StatusCode != http.StatusOK {
// Only the bounded head is ever used (HTTPError.Error truncates to it), so read no more.
+ // A read error here only shortens a diagnostic; the status code below is what callers act on.
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
return "", &HTTPError{Code: resp.StatusCode, Body: string(head)}
}
--
2.54.0
From fc1e8e7ba8e9e9c0fd11954522ff4a1e9ca93282 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:32:14 +0200
Subject: [PATCH 24/27] test(web): a foreign post to the recording delete route
leaves the recording, on an app that mounts uploads (#37, #48)
The earlier entry posted to an app without uploads, where the route does not exist, and
stayed green with the route removed. This one seeds a finished row and goes red without the
gate. Found by the PR #58 review.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/crossorigin_test.go | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/internal/web/crossorigin_test.go b/internal/web/crossorigin_test.go
index b75d832..df39745 100644
--- a/internal/web/crossorigin_test.go
+++ b/internal/web/crossorigin_test.go
@@ -113,7 +113,6 @@ func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testi
"/v/abc/summarize",
"/v/abc/retry-now",
"/v/abc/chat",
- "/uploads/00000000-0000-0000-0000-000000000000/delete",
}
for _, path := range routes {
t.Run(path, func(t *testing.T) {
@@ -126,6 +125,20 @@ func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testi
}
}
+func TestCrossOrigin_ARefusedRecordingDeletePostLeavesTheRecordingInPlace(t *testing.T) {
+ for name, h := range foreign {
+ t.Run(name, func(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+
+ rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/uploads/"+id+"/delete", nil), h))
+
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ assert.True(t, uploadExists(t, id), "a refused post must not delete the recording")
+ })
+ }
+}
+
// A login redirect comes back to /auth/callback as a cross-site GET navigation, and
// health probes carry no headers: safe methods must never be refused on origin grounds.
func TestCrossOrigin_ACrossSiteGETIsNotRefused(t *testing.T) {
--
2.54.0
From c24573bd9f2f2d7bac4621e1d49e4634c69df600 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sat, 3 Oct 2026 21:32:14 +0200
Subject: [PATCH 25/27] docs: state what the save timeout and a racing delete
actually guarantee (#51, #37)
The tracker comment said 'never a stalled job'; each transition can wait up to the timeout.
The delete refusal is judged at the moment of the attempt. Found by the PR #58 review.
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 4 +++-
internal/progress/tracker.go | 7 +++++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index 01f0dc4..69786f3 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1556,7 +1556,9 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
mechanism), confirm-then-delete on the recording's page. A recording that is **queued or
transcribing is refused (409) until it ends**, because the worker would otherwise write to a
row that is gone; a job whose service was lost stays undeletable until the next start's sweep
- marks it failed. The home list now links to `/uploads` when uploads are mounted.
+ marks it failed. The refusal reflects the row's state when the delete was attempted: a
+ recording that finishes in the same instant still gets a 409 once, and a retry deletes it.
+ The home list now links to `/uploads` when uploads are mounted.
**Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload
surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos`
diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go
index c365d58..deaca50 100644
--- a/internal/progress/tracker.go
+++ b/internal/progress/tracker.go
@@ -57,8 +57,11 @@ func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now =
func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } }
// WithSaveTimeout bounds one save or heartbeat write. Progress is cosmetic, so a database
-// that stops answering costs a dropped write, never a stalled job: saves run under the
-// tracker's lock, which Begin and Advance (called by the engines) need (#51).
+// that stops answering costs a dropped write rather than an unbounded stall. It is still a
+// delay: saves run under the tracker's lock, which Begin and Advance (called by the engines)
+// need, so each transition can wait up to this long, and a hung database slows a job by
+// that much per call, out of the job's own time budget (#51). A dropped final save is not
+// retried; the row keeps its last saved step.
func WithSaveTimeout(d time.Duration) Option { return func(t *Tracker) { t.saveTimeout = d } }
// Tracker implements Reporter for one job.
--
2.54.0
From 67bd0350a3b06d404d6bf504a48e8518cc586215 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sun, 4 Oct 2026 00:22:41 +0200
Subject: [PATCH 26/27] fix(web): judge every word of a reconciled endpoint
that is not a connector, so an unprefixed alias is not called local (#52)
The previous version kept only words containing '/' and dropped the rest, so a bare
TAPIR_TRANSCRIBE_MODEL alias in the composite produced a false 'all local' result. Found
by the PR #58 re-review; red tests for a bare alias, an alias with a space and a split
'berget / x' first.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 27 +++++++++++--------------
internal/web/uploads_cloud_note_test.go | 21 +++++++++++++++++++
2 files changed, 33 insertions(+), 15 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 8ef6a43..836b493 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -275,24 +275,21 @@ func titleFromFilename(name string) string {
// definition the start-up validation uses, not a second string match here (#52).
//
// The endpoint is one alias, except for the two-engine path (ADR-034), which writes
-// " + , reconciled by ". Every alias in it is judged on its own, so a
-// composite of local models is local and one naming a cloud alias names that provider.
+// " + , reconciled by ". Every word of it that is not that format's own
+// connector is judged as an alias, so a composite of local models is local, and anything
+// else (a cloud alias, one with no provider prefix, one with a space in it) is not.
func cloudProvider(endpoint string) (string, bool) {
- if strings.TrimSpace(endpoint) == "" {
- return "", false
- }
- aliases := []string{}
for _, tok := range strings.Fields(endpoint) {
- if tok = strings.Trim(tok, ","); strings.Contains(tok, "/") {
- aliases = append(aliases, tok)
+ tok = strings.Trim(tok, ",")
+ switch tok {
+ case "", "+", "reconciled", "by":
+ continue
}
- }
- if len(aliases) == 0 {
- aliases = append(aliases, strings.TrimSpace(endpoint))
- }
- for _, a := range aliases {
- if !config.IsLocalModel(a) {
- provider, _, _ := strings.Cut(a, "/")
+ if !config.IsLocalModel(tok) {
+ provider, _, _ := strings.Cut(tok, "/")
+ if provider == "" {
+ provider = tok
+ }
return provider, true
}
}
diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go
index 7e68187..f703e76 100644
--- a/internal/web/uploads_cloud_note_test.go
+++ b/internal/web/uploads_cloud_note_test.go
@@ -82,3 +82,24 @@ func TestUploadStatus_AReconciledEndpointNamingACloudEngineStillNamesTheCloudPro
assert.Contains(t, b, "cloud service (berget)")
}
+
+// An alias without a provider prefix is not a local model, wherever it sits in the
+// composite: it must never be dropped from the judgement (PR #58 re-review).
+func TestUploadStatus_AReconciledEndpointWithAnUnprefixedAliasIsNotCalledLocal(t *testing.T) {
+ for _, endpoint := range []string{
+ "iguana/kb-whisper + whisper-1, reconciled by iguana/gemma4-26b",
+ "iguana/kb-whisper + whisper 1, reconciled by iguana/gemma4-26b",
+ "iguana/kb-whisper + berget / x, reconciled by iguana/gemma4-26b",
+ } {
+ t.Run(endpoint, func(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+ setEndpoint(t, id, endpoint)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "left the homelab")
+ assert.NotContains(t, b, "cloud service ()", "the note names a provider")
+ })
+ }
+}
--
2.54.0
From 41ab1f5d501ca45b05634c0f6ede2ac3b3ff8d92 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Sun, 4 Oct 2026 00:28:09 +0200
Subject: [PATCH 27/27] fix(web): take the reconciled endpoint apart exactly,
so a connector word used as a model name is still judged (#52)
Skipping 'by', 'reconciled' and '+' as words everywhere let an alias with one of those
names pass as local. The two-engine shape is now split on its own separators and each alias
judged whole; a reconciler that is not local is pinned too. Hardening suggested by the third
PR #58 review.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 30 ++++++++++++---------
internal/web/uploads_cloud_note_test.go | 35 +++++++++++++++++++++++++
2 files changed, 52 insertions(+), 13 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 836b493..921a42f 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -275,20 +275,24 @@ func titleFromFilename(name string) string {
// definition the start-up validation uses, not a second string match here (#52).
//
// The endpoint is one alias, except for the two-engine path (ADR-034), which writes
-// " + , reconciled by ". Every word of it that is not that format's own
-// connector is judged as an alias, so a composite of local models is local, and anything
-// else (a cloud alias, one with no provider prefix, one with a space in it) is not.
+// " + , reconciled by " (ensemble.go). That shape is taken apart exactly
+// and each alias is judged whole, so a composite of local models is local, and anything
+// else (a cloud alias, one with no provider prefix, one with a space or a connector word in
+// it) is not. A string that is not that shape is one alias.
func cloudProvider(endpoint string) (string, bool) {
- for _, tok := range strings.Fields(endpoint) {
- tok = strings.Trim(tok, ",")
- switch tok {
- case "", "+", "reconciled", "by":
- continue
- }
- if !config.IsLocalModel(tok) {
- provider, _, _ := strings.Cut(tok, "/")
- if provider == "" {
- provider = tok
+ endpoint = strings.TrimSpace(endpoint)
+ if endpoint == "" {
+ return "", false
+ }
+ aliases := []string{endpoint}
+ if engines, reconciler, ok := strings.Cut(endpoint, ", reconciled by "); ok {
+ aliases = append(strings.Split(engines, " + "), reconciler)
+ }
+ for _, a := range aliases {
+ if a = strings.TrimSpace(a); !config.IsLocalModel(a) {
+ provider, _, _ := strings.Cut(a, "/")
+ if provider = strings.TrimSpace(provider); provider == "" {
+ provider = a
}
return provider, true
}
diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go
index f703e76..82fcd42 100644
--- a/internal/web/uploads_cloud_note_test.go
+++ b/internal/web/uploads_cloud_note_test.go
@@ -103,3 +103,38 @@ func TestUploadStatus_AReconciledEndpointWithAnUnprefixedAliasIsNotCalledLocal(t
})
}
}
+
+// "by", "reconciled" and "+" are connectors only inside the composite format. As a whole
+// endpoint, or as an alias within the composite, they are model names and are judged.
+func TestUploadStatus_AConnectorWordUsedAsAModelNameIsStillJudged(t *testing.T) {
+ for _, endpoint := range []string{
+ "by",
+ "reconciled",
+ "+",
+ "iguana/kb-whisper + by, reconciled by iguana/gemma4-26b",
+ "iguana/kb-whisper + reconciled, reconciled by iguana/gemma4-26b",
+ "iguana/kb-whisper + +, reconciled by iguana/gemma4-26b",
+ "/x", // no provider at all: the note must still name something
+ } {
+ t.Run(endpoint, func(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+ setEndpoint(t, id, endpoint)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "left the homelab")
+ assert.NotContains(t, b, "cloud service ()")
+ })
+ }
+}
+
+func TestUploadStatus_AReconcilerThatIsNotLocalNamesTheCloudProvider(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+ setEndpoint(t, id, "iguana/kb-whisper + koala/whisper, reconciled by berget/mistral-large")
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "cloud service (berget)")
+}
--
2.54.0