feat(web): tell running from quiet from lost; poll every two seconds (ADR-035)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 09:00:23 +02:00
co-authored by Claude Sonnet 5.5
parent d1dcb131f2
commit 89c9dd6705
+100 -3
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
"github.com/a-h/templ" "github.com/a-h/templ"
@@ -32,7 +33,15 @@ const (
uploadOverhead = 1 << 20 uploadOverhead = 1 << 20
maxTitleRunes = 200 maxTitleRunes = 200
// uploadPollEvery is how often an unfinished upload re-asks for its status. // uploadPollEvery is how often an unfinished upload re-asks for its status.
uploadPollEvery = "5s" uploadPollEvery = "2s"
// serviceSilentAfter is how long a job may go without any signal (heartbeat or
// progress) before the page calls the service lost. The heartbeat is every 10 s, so
// 45 s is four missed beats (ADR-035).
serviceSilentAfter = 45 * time.Second
// countedQuietAfter is how long a COUNTED phase (the deciding one) may go without
// advancing before the page says it is quiet. Each decision has a two-minute timeout,
// so five minutes is more than two in a row. A phase with no count is never quiet.
countedQuietAfter = 5 * time.Minute
) )
// uploadsView is what the /uploads page renders. // uploadsView is what the /uploads page renders.
@@ -137,7 +146,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.render(w, r, UploadPage(*u, a.Chat != nil)) a.render(w, r, UploadPage(*u, a.Chat != nil, a.now()))
} }
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states // handleUploadStatus renders the status fragment HTMX polls. Unfinished states
@@ -148,7 +157,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.render(w, r, uploadStatus(*u, a.Chat != nil)) a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now()))
} }
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) { func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
@@ -296,3 +305,91 @@ func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot {
func moreSpots(r *domain.Reconciliation) int { func moreSpots(r *domain.Reconciliation) int {
return max(0, r.Changed+r.Unsure-len(shownSpots(r))) return max(0, r.Changed+r.Unsure-len(shownSpots(r)))
} }
// jobHealth is what the page can honestly say about a job it is still polling.
type jobHealth string
const (
// healthRunning: the service is reporting and work is moving, or there is nothing yet
// to say otherwise.
healthRunning jobHealth = "running"
// healthQuiet: the service is alive but a counted phase has not advanced for a while.
healthQuiet jobHealth = "quiet"
// healthLost: nothing has been heard from the service for too long. It may have
// restarted or crashed; the start-up sweep fails the row once it is back.
healthLost jobHealth = "lost"
)
// uploadHealth tells running from quiet from lost for an upload being transcribed (the
// page calls it for no other state: a queued or finished job has no heartbeat to judge). The
// two timestamps mean different things (ADR-035): alive_at is the heartbeat, which says
// the service is still reporting; progress_at moves only when work really moved. A
// single blocking engine call has no sub-progress, so only a counted phase can be quiet.
// silent is how long since the signal that decided it.
func uploadHealth(u domain.UploadedTranscript, now time.Time) (health jobHealth, silent time.Duration) {
last := u.AliveAt
if u.ProgressAt.After(last) {
last = u.ProgressAt
}
if last.IsZero() {
return healthRunning, 0
}
if d := now.Sub(last); d > serviceSilentAfter {
return healthLost, d
}
for _, s := range u.Progress {
if s.State == domain.StepRunning && s.Total > 0 && !u.ProgressAt.IsZero() {
if d := now.Sub(u.ProgressAt); d > countedQuietAfter {
return healthQuiet, d
}
}
}
return healthRunning, 0
}
// stepDuration renders how long a phase took or has run: "<1s", "42s", "3m 12s", "1h 02m".
func stepDuration(d time.Duration) string {
switch {
case d < time.Second:
return "<1s"
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
default:
return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
}
}
// stepTime is the time shown beside a phase: how long it has run, or how long it took.
func stepTime(s domain.ProgressStep, now time.Time) string {
switch s.State {
case domain.StepRunning:
return stepDuration(now.Sub(s.StartedAt))
case domain.StepDone, domain.StepFailed:
return stepDuration(s.FinishedAt.Sub(s.StartedAt))
}
return ""
}
// stepMark is the glyph at the head of a phase line.
func stepMark(s domain.ProgressStep) string {
switch s.State {
case domain.StepDone:
return "✓"
case domain.StepFailed:
return "✗"
case domain.StepSkipped:
return "–"
}
return "·"
}
// minutesAgo is a whole number of minutes for the health notes: "6 min", or "under a
// minute" when it is less.
func minutesAgo(d time.Duration) string {
if m := int(d.Minutes()); m >= 1 {
return fmt.Sprintf("%d min", m)
}
return "under a minute"
}