Compare commits

...
14 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5.5 cace7d179d fix(deps): bump x/text past GO-2026-5970
CI / Lint / Test / Vet (push) Successful in 30s
CI / Build & Import (push) Successful in 11s
CI / Deploy via GitOps (push) Skipped
govulncheck reported GO-2026-5970 as called via golang.org/x/text v0.31.0
(reached through the DB driver). Bump to v0.39.0; x/sync moves to v0.21.0
as its required minimum. govulncheck ./...: 0 vulnerabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:52:55 +02:00
mathiasandClaude Opus 5.5 550764d43c fix(oidc): retry issuer discovery with bounded back-off at start-up
A 503 from the issuer's discovery endpoint made `serve` exit and the pod
restart. New now retries discovery 5 times (2+4+8+16 s back-off, ~30 s)
before failing as before; a cancelled ctx ends the back-off at once.
WithDiscoveryRetry lets tests shrink the schedule.

Closes #59

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:52:26 +02:00
mathiasandClaude Sonnet 5.5 4affeb12e8 docs: ADR-034 second blind check protocol; D8 decided (Berget allowed, per-recording local-only opt-out first, #60); #37 verified
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 10s
CI / Deploy via GitOps (push) Successful in 5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:47:10 +02:00
mathiasandClaude Sonnet 5.5 460e07719d docs(sprint): #50 observed live (old pod Completed); #37 and #52 wait for an authenticated look
CI / Build & Import (push) Successful in 10s
CI / Lint / Test / Vet (push) Successful in 32s
CI / Deploy via GitOps (push) Successful in 4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 00:41:07 +02:00
mathiasandClaude Sonnet 5.5 7b7da650d4 docs(sprint): v0.31.1 and v0.32.0 shipped; what is observed live and what waits for an authenticated look
CI / Deploy via GitOps (push) Successful in 5s
CI / Lint / Test / Vet (push) Successful in 31s
CI / Build & Import (push) Successful in 10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 00:34:59 +02:00
mathias 85f4cc3236 Merge pull request 'v0.32.0: per-recording delete and nav link, cloud-served note, tracker hardening, bounded LLM reads (#37 #51 #52 #53)' (#58) from sprint/v032 into main
CI / Deploy via GitOps (push) Successful in 5s
CI / Lint / Test / Vet (push) Successful in 34s
CI / Build & Import (push) Successful in 11s
2026-10-03 22:31:26 +00:00
mathiasandClaude Sonnet 5.5 41ab1f5d50 fix(web): take the reconciled endpoint apart exactly, so a connector word used as a model name is still judged (#52)
CI / Lint / Test / Vet (pull_request) Successful in 35s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-04 00:28:09 +02:00
mathiasandClaude Sonnet 5.5 67bd0350a3 fix(web): judge every word of a reconciled endpoint that is not a connector, so an unprefixed alias is not called local (#52)
CI / Lint / Test / Vet (pull_request) Successful in 33s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-04 00:22:41 +02:00
mathiasandClaude Sonnet 5.5 c24573bd9f docs: state what the save timeout and a racing delete actually guarantee (#51, #37)
CI / Lint / Test / Vet (pull_request) Successful in 29s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-03 21:32:14 +02:00
mathiasandClaude Sonnet 5.5 fc1e8e7ba8 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 <noreply@anthropic.com>
2026-10-03 21:32:14 +02:00
mathiasandClaude Sonnet 5.5 4a0226d8bd 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 <noreply@anthropic.com>
2026-10-03 21:31:30 +02:00
mathiasandClaude Sonnet 5.5 2e54d80a78 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 <noreply@anthropic.com>
2026-10-03 21:31:14 +02:00
mathiasandClaude Sonnet 5.5 01c1606caa 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 <noreply@anthropic.com>
2026-10-03 21:30:58 +02:00
mathiasandClaude Sonnet 5.5 9aa01468ce 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 '<base> + <alt>, reconciled by <model>'. 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 <noreply@anthropic.com>
2026-10-03 21:30:44 +02:00
16 changed files with 555 additions and 36 deletions
+3 -1
View File
@@ -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`
@@ -0,0 +1,69 @@
# ADR-034 second blind check: protocol (ready to run; tapir#33, D9, D10)
This repo is public and the recordings are private, so this file holds the method only. The
sheet, the key and the filled-in verdicts stay outside the repo, and the trial data is deleted
after the check. Results go back here as aggregate numbers, as in
`reconcile-blind-check-2026-10-02.md`.
## What the check decides
ADR-034 (two engines, a local model reconciles their disagreements) is `Proposed` and off in
production. Two conditions were set for keeping it. The first run answered the first one on one
reading only and did not answer the second:
1. **The reconciled reading is right at least 85% of the time.** Open flaw in the bar: it did not
say how ties count. The first run gave 95% (decided items only) or 70% (all 30), so it was met on
one reading and missed on the other.
2. **It is no worse than the better single engine.** Not answered: before ADR-035 the spots the
model *kept* as the base were not stored, so kb-whisper alone could not be compared.
ADR-035 now stores every judged spot (`reconciliation.contested`: both readings, which one was
used, unsure or not, a few words of left context, a time). That makes condition 2 measurable.
## Rules to fix BEFORE looking at any result (the owner confirms or changes them)
| Rule | Proposed |
|---|---|
| Who judges | The speaker, hearing the audio where the text cannot settle it (D9). Not Opus: it cannot hear, and its agreement with the speaker was only shown on items picked as decidable. |
| Sample | 30 spots drawn at random with a fixed seed, from ALL judged spots (changed and kept-base alike), not only the changed ones. Insertions and deletions that cannot be placed are listed separately and not sampled. |
| Verdict per spot | `x`, `y` (the two readings, shown in random order, key kept apart), `neither`, or `cannot tell`. |
| Condition 1 | Reconciled is right on at least 85% of the spots where the judge could tell (`cannot tell` excluded, reported, and `neither` counts as not right). Also report all-30. Pass needs the decided-only figure at 85% or more AND the all-30 figure above 70%. |
| Condition 2 | Over the same spots, the reconciled text is right at least as often as the better single engine (base engine alone is right where the base reading is right; the other engine alone where the alt reading is right). Ties favour the single engine. |
| If it fails | Switch reconcile off by configuration; the single-engine path is unchanged (ADR-034 reversibility). |
Why a fixed all-30 floor: the literal 70% last time sat well under the bar, and "decided only" can
hide a model that mostly lands on spelling differences.
## What a run needs (D10: the owner is present)
- The same real recording as before (about 60 minutes). Only the owner has it.
- A **throwaway** tapir instance with its own Postgres, not the production pod, so production's
key pin and deployment are untouched. Settings: `TAPIR_RECONCILE_BASE_MODEL=iguana/pianissimo`,
`TAPIR_TRANSCRIBE_MODEL=iguana/kb-whisper`, `TAPIR_RECONCILE_MODEL=iguana/gemma4-31b`, cloud
fallbacks empty. Job time was 21 min 49 s per 63.6 min last run.
- iguana with Low Power Mode off and gemma4-31b able to load (the production-iguana model load
that needs a quiet window; D6).
## Steps
1. Start the throwaway instance, upload the recording, wait for `done` (about 22 minutes).
2. Read `reconciliation.contested` from the throwaway database. From it build, **outside the repo**:
`sheet` (spot number, time, left context, reading x, reading y, random order) and `key` (which
of x/y is the base, which is the other engine, which one the reconciler used).
3. The speaker fills in the sheet (about 20 to 30 minutes for 30 spots, audio open at the times).
The agent does not see the key until the sheet is returned.
4. Score with the rules above. Write the aggregate numbers into a dated note next to this one,
and amend ADR-034's validation section (kept, or switched off, and the tie rule used).
5. Delete the throwaway instance, its database and the sheet, as last time.
6. Only if the check passes and the owner wants it in production: enabling reconcile there is
the separate D10 step (it needs `iguana/pianissimo` and the reconciler added to the tapir key
pin in the infra repo plus the env vars on the deployment, `risk/medium`, owner present).
## Open and owner-only
- Confirm or change the rules table above before the run.
- The **tool that builds the sheet and key** is the one piece not in the repo (last time it was a
one-off script). It can be built test-first on request; until then the agent builds it ad hoc
outside the repo.
- A Berget adjudicator (D8, tapir#45) is a different question with its own measurement and is
not part of this check: this check judges the local setup that ADR-034 describes.
+30 -3
View File
@@ -1,7 +1,8 @@
# Sprint: audio upload follow-ups (Sweep Sprint, 2026-10)
Method: Sweep Sprint (phase 1 parallel read-only research per issue: **done**; phase 2 sequential
execution, one issue per commit: **not started, waits for the owner's go**). Closing oath below.
execution, one issue per commit: **v0.31.1 and v0.32.0 shipped 2026-10-03/04, the owner-held items
below remain open**). Closing oath below.
Milestones: [v0.31.1 - security and robustness](https://git.d-ma.be/mathias/tapir/milestone/10),
[v0.32.0 - audio upload follow-ups](https://git.d-ma.be/mathias/tapir/milestone/11).
@@ -63,8 +64,8 @@ Release v0.31.1: one adversarial review round over 2-5, then tag, push, observe.
| D5 | #38: Berget length measurement needs a throwaway key | Yes, throwaway key | #38 |
| D6 | #38: load large models on production iguana | Yes, in a quiet window with the owner present | #38 |
| D7 | #46: where the ffmpeg tarball lives | Do nothing now; file an issue | none |
| D8 | #45: evaluate Berget as the reconciler | No; stay local-only | #45 |
| D9 | Who judges ADR-034's second blind check | The owner; do not reuse the one-off Opus authorisation | ADR-034 |
| D8 | #45: evaluate Berget as the reconciler | **Decided 2026-10-04 by the owner: yes, Berget may be used, and each user must be able to keep a recording on local models only** (reverses the recommendation). Opt-out first: #60; #45 stays as the model measurement | #60, #45 |
| D9 | Who judges ADR-034's second blind check | The owner; do not reuse the one-off Opus authorisation. Protocol: `docs/research/reconcile-second-check-protocol.md` | ADR-034 |
| D10 | When to enable reconcile in production | Needs `iguana/pianissimo` in the key pin and env vars; owner present | ADR-034 |
## Dropped or reduced, with reasons
@@ -86,3 +87,29 @@ Release v0.31.1: one adversarial review round over 2-5, then tag, push, observe.
SemVer: v0.31.1 patch (fixes, no new behaviour); v0.32.0 minor (nav link, delete, cloud labelling).
Every push to tapir main redeploys the pod and fails any job in flight: push when no upload is
running (check the pod log first).
## Status (2026-10-04)
**Shipped and deployed:** v0.31.1 (PR #55, merge e037c6b) and v0.32.0 (PR #58, merge 85f4cc3; pod
tapir-597dbfccc-ng78q 1/1, 0 restarts, image 85f4cc3). Each went through an adversarial review
round by agent with the verdicts recorded on the PR (v0.31.1: BLOCK then APPROVE; v0.32.0: BLOCK,
BLOCK, APPROVE, APPROVE; every block was a real defect, fixed test-first).
**Live evidence:** a foreign-origin `POST` to `/account/delete` and to `/uploads/{id}/delete` is
refused (403) on production, a same-origin one reaches the login redirect (302).
**Not observed live (needs an authenticated session, so it is the owner's look; closed on word or screenshot):**
- #52, cloud side: a transcript served by the Berget fallback names `berget`; a reconciled all-local
one shows no note. The local side was observed on 2026-10-04 (a new clip served by iguana: chip
`iguana/…`, no cloud note). The cloud side is carried as an acceptance item in #60.
**Observed live since:** #37 (owner, 2026-10-04: link, confirm-then-delete, no control while transcribing; closed). #50: at the 2026-10-04 rollout the old pod went `Terminating` then `Completed`
(not `Error`), 0 restarts; #50 closed. No job was in flight, so a drained job is still unobserved
(a recorded limitation, D2).
**Still open (owner's call, milestone 11):** #36, #38, #45 (now the Berget adjudicator measurement), #60 (per-recording local-only choice, 4 sub-decisions), #46 remainder, #56 (slow-drip bodies hold
a receive slot), #57 (shutdown logging, second signal). Decisions D4-D6, D9, D10 above are unchanged; D8 is decided (above).
**Deferred and filed from the reviews:** #56, #57, #59 (start-up exits when the identity provider's OIDC
discovery returns 503 (seen once at 19:40 on 2026-10-03; the pod restarted itself and recovered)).
+2 -2
View File
@@ -33,9 +33,9 @@ require (
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+4 -4
View File
@@ -113,12 +113,12 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+75
View File
@@ -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)
}
}
}
+1
View File
@@ -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)}
}
+5 -2
View File
@@ -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.
+56
View File
@@ -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")
}
}
+48
View File
@@ -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)
}
}
+14 -1
View File
@@ -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) {
+61
View File
@@ -0,0 +1,61 @@
package oidc_test
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/web/oidc"
)
func testConfig(issuer string) oidc.Config {
return oidc.Config{
Issuer: issuer,
ClientID: testClientID,
ClientSecret: "test-client-secret",
RedirectURL: "http://tapir.test/auth/callback",
SessionSecret: "test-session-secret-please-change",
}
}
// #59: a discovery 503 at start-up is retried instead of failing New.
func TestNewRetriesDiscoveryAfter503UntilIssuerAnswers(t *testing.T) {
f := newFakeIssuer(t)
f.unavailableFor = 2
auth, err := oidc.New(context.Background(), testConfig(f.server.URL),
oidc.WithDiscoveryRetry(5, time.Millisecond))
require.NoError(t, err)
require.NotNil(t, auth)
require.Equal(t, int32(3), f.discoveryHits.Load())
}
func TestNewReturnsDiscoveryErrorAfterAttemptsExhausted(t *testing.T) {
f := newFakeIssuer(t)
f.unavailableFor = 1000
_, err := oidc.New(context.Background(), testConfig(f.server.URL),
oidc.WithDiscoveryRetry(3, time.Millisecond))
require.ErrorContains(t, err, "503")
require.Equal(t, int32(3), f.discoveryHits.Load())
}
func TestNewStopsDiscoveryBackoffWhenContextCancelled(t *testing.T) {
f := newFakeIssuer(t)
f.unavailableFor = 1000
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err := oidc.New(ctx, testConfig(f.server.URL),
oidc.WithDiscoveryRetry(5, time.Hour))
require.True(t, errors.Is(err, context.DeadlineExceeded), "err = %v", err)
require.Less(t, time.Since(start), 5*time.Second)
require.Equal(t, int32(1), f.discoveryHits.Load())
}
+70 -20
View File
@@ -57,6 +57,10 @@ const (
pendingTTL = 10 * time.Minute
sessionCookie = "tapir_session"
loginPath = "/auth/login"
// Discovery retry (#59): 5 tries, 2+4+8+16 s back-off ≈ 30 s, then fail.
defaultDiscoveryAttempts = 5
defaultDiscoveryDelay = 2 * time.Second
)
// DexAuth implements web.Auth against Dex.
@@ -70,6 +74,9 @@ type DexAuth struct {
sessionTTL time.Duration
now func() time.Time
insecure bool // tests only: drop the Secure attribute so cookies flow over http
discoveryAttempts int
discoveryDelay time.Duration
}
var _ web.Auth = (*DexAuth)(nil)
@@ -96,14 +103,55 @@ func WithSessionTTL(ttl time.Duration) Option {
}
}
// WithDiscoveryRetry overrides the discovery attempt count and the initial
// back-off delay (doubled after each failed attempt).
func WithDiscoveryRetry(attempts int, delay time.Duration) Option {
return func(d *DexAuth) {
if attempts > 0 {
d.discoveryAttempts = attempts
}
if delay > 0 {
d.discoveryDelay = delay
}
}
}
// WithInsecureCookies drops the Secure cookie attribute. TEST ONLY — it lets
// the session cookie travel over plain http; never use it in production.
func WithInsecureCookies() Option {
return func(d *DexAuth) { d.insecure = true }
}
// New discovers the issuer and returns a configured DexAuth. ctx bounds the
// discovery request only.
// discover runs OIDC discovery up to attempts times, doubling delay between
// tries (#59: an issuer briefly unavailable behind its proxy answered 503 and
// serve exited). A cancelled ctx ends the back-off immediately with ctx's error.
func discover(ctx context.Context, issuer string, attempts int, delay time.Duration) (*oidc.Provider, error) {
var lastErr error
for i := range attempts {
if i > 0 {
t := time.NewTimer(delay)
select {
case <-ctx.Done():
t.Stop()
return nil, fmt.Errorf("after %d attempts (last: %v): %w", i, lastErr, ctx.Err())
case <-t.C:
}
delay *= 2
}
p, err := oidc.NewProvider(ctx, issuer)
if err == nil {
return p, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, fmt.Errorf("after %d attempts: %w", i+1, ctx.Err())
}
}
return nil, fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}
// New discovers the issuer and returns a configured DexAuth. ctx bounds discovery,
// including its retry back-off.
func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
for name, val := range map[string]string{
"issuer": cfg.Issuer,
@@ -117,29 +165,31 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
}
}
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc: discover issuer: %w", err)
}
d := &DexAuth{
cfg: cfg,
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth: &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: cfg.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
pending: newPendingStore(),
secret: []byte(cfg.SessionSecret),
sessionTTL: defaultSessionTTL,
now: time.Now,
cfg: cfg,
pending: newPendingStore(),
secret: []byte(cfg.SessionSecret),
sessionTTL: defaultSessionTTL,
now: time.Now,
discoveryAttempts: defaultDiscoveryAttempts,
discoveryDelay: defaultDiscoveryDelay,
}
for _, o := range opts {
o(d)
}
provider, err := discover(ctx, cfg.Issuer, d.discoveryAttempts, d.discoveryDelay)
if err != nil {
return nil, fmt.Errorf("oidc: discover issuer: %w", err)
}
d.verifier = provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
d.oauth = &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: cfg.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return d, nil
}
+10
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
@@ -34,6 +35,11 @@ type fakeIssuer struct {
sub string
email string
nonce string
// discoveryHits counts requests to the discovery document; the first
// unavailableFor of them answer 503 (issuer briefly down behind its proxy).
discoveryHits atomic.Int32
unavailableFor int32
}
const testKID = "test-key"
@@ -47,6 +53,10 @@ func newFakeIssuer(t *testing.T) *fakeIssuer {
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
if f.discoveryHits.Add(1) <= f.unavailableFor {
http.Error(w, "no available server", http.StatusServiceUnavailable)
return
}
writeJSON(t, w, map[string]any{
"issuer": f.server.URL,
"authorization_endpoint": f.server.URL + "/authorize",
+22 -3
View File
@@ -273,12 +273,31 @@ 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
// "<base> + <alt>, reconciled by <model>" (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) {
if endpoint == "" || config.IsLocalModel(endpoint) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", false
}
provider, _, _ := strings.Cut(endpoint, "/")
return provider, true
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
}
}
return "", false
}
// hasNote reports whether any phase carries a note. A note is the only warning for a
+85
View File
@@ -53,3 +53,88 @@ 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)")
}
// 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")
})
}
}
// "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)")
}