Skip to content

WW-sync — Live sync progress + hang fix

Two "sync" flows hang forever with a blind "Syncing…" spinner. Root cause: long per-item loops with no timeout and no per-item try/catch — one hung HTTP/LLM call never returns, the controller never responds, the frontend spinner never clears. Live log proof: two POST /api/jobs/resync calls logged "Request starting" + "Scraped 60 job postings" then never "Request finished".

Deliver: (1) both loops hardened so they always return, (2) a live N/60 progress counter, (3) the counter drives an outline that traces the perimeter of the "Syncing…" box and closes the loop at 100%.


BACKEND (agent owns src/** only — do NOT touch frontend/**)

New: src/WorkWingman.Core/Models/SyncProgress.cs

namespace WorkWingman.Core.Models;

public enum SyncStatus { Running, Completed, PartialSuccess, Failed }

public class SyncProgress
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");
    public string Kind { get; set; } = "";     // "linkedin-resync" | "drive-backup"
    public string Label { get; set; } = "";    // human header, e.g. "Re-syncing LinkedIn"
    public string Phase { get; set; } = "";     // "Scraping" | "Analyzing fit" | "Enriching" | "Uploading"
    public int Current { get; set; }
    public int Total { get; set; }
    public int Failed { get; set; }
    public SyncStatus Status { get; set; } = SyncStatus.Running;
    public string Message { get; set; } = "";
    public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? FinishedAt { get; set; }
}

New: src/WorkWingman.Infrastructure/Services/SyncProgressTracker.cs

In-memory, mirrors the existing ApplicationRunService.ActiveRuns pattern. Thread-safe (ConcurrentDictionary + lock (progress) on mutation). Register as singleton.

public interface ISyncProgressTracker
{
    SyncProgress Begin(string kind, string label, string phase, int total);
    void SetTotal(string id, int total);
    void SetPhase(string id, string phase);
    void Advance(string id, int by = 1);
    void RecordFailure(string id);
    void Complete(string id, string message, SyncStatus status = SyncStatus.Completed);
    SyncProgress? GetActive();
}
- GetActive() returns the running progress, OR a just-finished one within a 20s "linger" window (so the frontend gets one poll showing Completed/PartialSuccess before it vanishes). Lazily drop finished entries older than the linger window on each call. - Complete sets Status, Message, FinishedAt. Idempotent.

New: src/WorkWingman.Api/Controllers/SyncController.cs

[ApiController]
[Route("api/sync")]
public class SyncController(ISyncProgressTracker tracker) : ControllerBase
{
    [HttpGet("progress")]
    public ActionResult<SyncProgress> Progress()
        => tracker.GetActive() is { } p ? Ok(p) : NoContent();
}

Edit: JobQueueService.ResyncFromLinkedInAsync (src/WorkWingman.Infrastructure/Services/JobQueueService.cs:74)

Inject ISyncProgressTracker tracker via primary ctor. Then: 1. var p = tracker.Begin("linkedin-resync", "Re-syncing LinkedIn", "Scraping", 0); 2. Wrap the entire body in try / catch / finally. The finally must call tracker.Complete(...) exactly once — this is the guarantee the request always resolves. 3. Overall timeout: build a linked token — using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromMinutes(8)); var lct = cts.Token; and pass lct to the scrape + enrichment calls. 4. After scrape+add, tracker.SetTotal(p.Id, added.Count). 5. Parallel narrative loop (:189): tracker.SetPhase(p.Id, "Analyzing fit") before it. 6. Sequential enrichment loop (:224): tracker.SetPhase(p.Id, "Enriching"). Wrap each job's body in try { … } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "enrich failed for {Job}", job.Id); tracker.RecordFailure(p.Id); } then tracker.Advance(p.Id) in a finally per job. One bad job must not abort the sync. 7. finally: Failed>0 ? PartialSuccess : Completed; message e.g. "Pulled {added.Count} jobs — {added.Count-Failed} enriched, {Failed} skipped." On OperationCanceledException from the timeout: Complete(Failed, "Timed out after 8 min…"). Return value stays int (added.Count).

Edit: DataPortabilityService.SyncToDriveAsync (src/WorkWingman.Infrastructure/Services/DataPortabilityService.cs:183)

Inject tracker. Begin("drive-backup", $"Syncing {domain.DisplayName} to Drive", "Uploading", rows.Count). Same shape: linked-token timeout (5 min), per-row try/catchRecordFailure+continue (do NOT abort the whole upload on one row), Advance per row in finally, Complete in an outer finally. Return DriveSyncResult with Synced: Failed==0, message includes failed count if any.

DI: src/WorkWingman.Api/Program.cs

builder.Services.AddSingleton<ISyncProgressTracker, SyncProgressTracker>();

Verify: dotnet build the API project green. Add/adjust unit tests for the tracker

(Begin/Advance/Complete/GetActive linger) + a JobQueueService test proving one throwing job still completes the run (Failed==1, Status==PartialSuccess). Match existing test style (Bogus).


FRONTEND (agent owns frontend/** only — do NOT touch src/**)

New standalone component: frontend/src/app/shared/progress-ring/progress-ring.{ts,html,scss}

Wraps arbitrary content; draws an animated outline around the content box's perimeter. - Inputs: percent: number (0–100), active: boolean. - Template:

<div class="ring-host" [class.active]="active()">
  <ng-content />
  <svg class="ring-outline" preserveAspectRatio="none" aria-hidden="true">
    <rect class="track" x="1" y="1" pathLength="100" vector-effect="non-scaling-stroke" />
    <rect class="fill"  x="1" y="1" pathLength="100" vector-effect="non-scaling-stroke"
          [style.stroke-dashoffset]="100 - clamped()" />
  </svg>
</div>
- SCSS: .ring-host{position:relative}. .ring-outline{position:absolute; inset:0; width:100%; height:100%; pointer-events:none}. Both rects width:calc(100% - 2px); height:calc(100% - 2px); rx: <match the box radius, use the WW radius token>; fill:none. .track faint (var(--border) low alpha). .fill stroke: var(--accent); stroke-dasharray:100; stroke-linecap:round; transition: stroke-dashoffset .4s ease. Start the trace at top-center via transform: rotate(...) or by leaving default (top-left is acceptable — pick top-center if clean). Respect prefers-reduced-motion (drop the transition). - clamped() = Math.max(0, Math.min(100, percent())). Theme-aware (accent token works light+dark).

Models: frontend/src/app/core/models.ts

Add SyncProgress interface mirroring the backend record (kind, label, phase, current, total, failed, status, message).

API: frontend/src/app/core/api.service.ts

getSyncProgress(): Observable<SyncProgress | null> → GET /api/sync/progress, map 204→null (catchError(() => of(null))).

Wire frontend/src/app/features/queue/ (the Resync box)

  • Add signals syncCurrent, syncTotal, syncPhase.
  • In resync(): on start, kick a setInterval(~1000ms) polling getSyncProgress() → set the signals. Clear the interval in the subscribe's complete/error (and ngOnDestroy).
  • Template: wrap the "Re-sync LinkedIn / Syncing…" button-box in <app-progress-ring [active]="syncing()" [percent]="syncTotal() ? syncCurrent()/syncTotal()*100 : 0">. When syncing, show {{ syncCurrent() }}/{{ syncTotal() }} + syncPhase() next to the label.

Wire frontend/src/app/features/data-backup/ (the per-domain Sync box)

  • Same: while a domain is 'syncing', poll getSyncProgress(); wrap that domain's status box in the progress-ring; show N/total. (Only one sync runs at a time — match on kind === 'drive-backup'.)

Verify: frontend build/tsc green + existing Vitest suite passes. Add a small spec for

progress-ring rendering the fill offset for a given percent.


Gate

After both sides land + build green: /council-code-review, then FF-push to master, rebuild installer, redeploy to fleet. Do NOT push from the builder agents — leave that to the reviewer.