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:
+51
-10
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user