Run classification previews in the background with live progress

The preview endpoint held one HTTP request open while classifying
serially at three-second pacing, so any real range meant minutes of a
grayed-out button and per-row errors were invisible until the loop
ended. Analyse now starts a single background run against its own
snapshot; a progress endpoint reports analysed counts, proposed
changes and errors as they happen, and the page polls it with a
progress bar, pace-based estimate and a Stop button. Navigating away
no longer orphans the run: the page re-attaches to it on return.

A run that has produced no successful proposal and fails three times
in a row with the identical error stops early and reports that error,
so a wrong key or unsupported model surfaces in seconds instead of
repeating across the whole paced range.

Also normalize a null settings.private_names, which crashed the whole
UI on a workspace that had never saved preferences.
This commit is contained in:
Lars Nolden
2026-09-12 12:24:50 +02:00
parent 635c11be56
commit 9092c5721d
10 changed files with 449 additions and 91 deletions
+51 -10
View File
@@ -129,6 +129,32 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
}
}
// runPreview drives the background preview job to completion the way the UI
// does: start the run, then poll progress until it reports done.
func runPreview(t *testing.T, a *App, r PreviewRequest) (Preview, error) {
t.Helper()
start, err := a.StartPreview(context.Background(), r)
if err != nil {
return Preview{}, err
}
deadline := time.Now().Add(15 * time.Second)
for {
p, err := a.PreviewProgress(start.ID)
if err != nil {
return Preview{}, err
}
if p.Done {
if p.Error != "" {
return Preview{}, errors.New(p.Error)
}
return *p.Preview, nil
}
if time.Now().After(deadline) {
t.Fatal("preview run did not finish")
}
time.Sleep(5 * time.Millisecond)
}
}
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
@@ -145,7 +171,7 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
defer cancel()
for _, model := range []string{"test/model", "test/another-model"} {
preview, err := a.Preview(ctx, PreviewRequest{
preview, err := runPreview(t, a, PreviewRequest{
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true},
})
@@ -185,24 +211,39 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
}
}
func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ids := make(chan string, 1)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Stop the run from within its first provider call, as the UI's Stop
// button would mid-request.
a.CancelPreview(<-ids)
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
cancel()
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
p, err := a.Preview(ctx, PreviewRequest{
start, err := a.StartPreview(context.Background(), PreviewRequest{
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true},
})
if !errors.Is(err, context.Canceled) || p.ID != "" {
t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
if err != nil {
t.Fatal(err)
}
ids <- start.ID
deadline := time.Now().Add(10 * time.Second)
for {
if _, err := a.PreviewProgress(start.ID); err != nil {
break // the cancelled run is gone, never a finished preview
}
if time.Now().After(deadline) {
t.Fatal("cancelled preview run still reports progress")
}
time.Sleep(5 * time.Millisecond)
}
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
t.Fatal("cancelled run produced an applicable preview")
}
after, err := a.Snapshot(context.Background())
if err != nil {
@@ -261,7 +302,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
}
mockClassifier(t, a)
before := domain.Clone(s.Data)
preview, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
preview, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -306,7 +347,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}