Skip to content

WW-sync — UX hardening (follow-on to the hang fix)

Live testing surfaced 3 real gaps. Root symptoms observed: - Vault was LOCKED → scraper got no cookies → fell through to WaitForManualLoginAsync and sat there until the 8-min ceiling → 500 "Re-sync failed". The pre-check waved it through because GetStatusAsync returned SessionVerified=true from the stale JSON record.Live flag (browser closed → JSON fallback path in LinkedInSessionConnection.cs:52). - Clicking resync more than once (or remounting the page) started overlapping backend runs; the local syncing boolean can't track that, so a "failed" toast + a stuck "Syncing…" button showed at the same time. - "Scraping" has no known total, so the ring sits at 0% and looks dead for 1–2 min.


FIX 1 — No overlapping runs; button reflects the REAL backend sync

Backend (src/**)

Tracker — atomic guard. src/WorkWingman.Infrastructure/Services/SyncProgressTracker.cs Add to ISyncProgressTracker:

/// <summary>Begin a run only if no run of the same kind is currently active (FinishedAt == null).
/// Returns null if one already is — the caller should reject the request rather than start a
/// second concurrent sync. Atomic: the check + insert happen under one lock.</summary>
SyncProgress? TryBegin(string kind, string label, string phase, int total);
Impl: lock a private gate object (or lock over the dictionary) so the "any active of this kind?" check + insert are atomic; SweepExpired() first. Begin stays as-is for internal/other callers.

New exception. src/WorkWingman.Core/Interfaces/IJobQueueService.cs (next to LinkedInSessionRequiredException):

/// <summary>Thrown when a sync is requested while one of the same kind is already running — the
/// controller turns it into a 409 so the UI can say "already syncing" instead of starting a
/// second overlapping run.</summary>
public class SyncAlreadyRunningException(string message) : Exception(message);

Wire into both flows. - JobQueueService.ResyncFromLinkedInAsync: replace var p = syncTracker.Begin("linkedin-resync", …) with var p = syncTracker.TryBegin("linkedin-resync", …) ?? throw new SyncAlreadyRunningException("A LinkedIn re-sync is already running — hang tight."); Put this AFTER the pre-flight connection/vault checks below (so a rejected-for-not-connected request doesn't first get blocked by a stale running entry — but a genuine double-click does). Keep the finally/timeout structure exactly as-is otherwise. - DataPortabilityService.SyncToDriveAsync: same — TryBegin("drive-backup", …) ?? throw new SyncAlreadyRunningException("A Drive sync is already running — hang tight."). - JobsController.Resync: add catch (SyncAlreadyRunningException ex) { return Conflict(ex.Message); }. - DataBackupController.SyncToDrive: it already catches InvalidOperationException→409; add a catch (SyncAlreadyRunningException ex) { return Conflict(ex.Message); } before it.

Frontend (frontend/**) — button tracks reality, not just the click

frontend/src/app/features/queue/queue.ts: - Implement ngOnInit (or in the constructor): call getSyncProgress() ONCE; if it returns a running linkedin-resync, adopt it — syncing.set(true) + startSyncPoll(). Fixes "remounted mid-sync → button shows Re-sync". (data-backup.ts: same adoption for drive-backup.) - In startSyncPoll's handler: when the poll returns null (204 = nothing active) OR a progress whose status is terminal (Completed/PartialSuccess/Failed), call syncing.set(false) + stopSyncPoll(). This clears the button when the backend run ends even if this component's own POST was orphaned by an overlap. (Keep the existing POST-driven clear too; both are idempotent.) - A 409 from resync (already-running or not-connected) must still syncing.set(false) — it already does via the catchError→subscribe path; just confirm the adoption logic doesn't re-set it true.


FIX 2 — Real connection/vault pre-check (fail fast, don't stall)

The bug: LinkedInLinkStatus.SessionVerified is trustworthy ONLY when set from the live cookie check (browser open); when the browser is closed it's the stale JSON record.Live. A resync from a cold start can't confirm a live session without an open browser, and can't restore one without the vault — so a locked vault + no live browser session must fail fast.

Backend (src/**)

  1. LinkedInLinkStatus (find the model): add public bool SessionLiveConfirmed { get; set; }. In LinkedInSessionConnection.GetStatusAsync, set SessionLiveConfirmed = true ONLY on the live-cookie path (where li_at= was actually observed); leave it false on the stale-JSON fallback. Do NOT change the meaning of the existing SessionVerified.
  2. JobQueueService.ResyncFromLinkedInAsync — inject IVaultService vault (optional+defaulted like the tracker, so existing new JobQueueService(...) test sites still compile). Strengthen the pre-flight (currently if (!status.SessionVerified) throw LinkedInSessionRequiredException(...)):
    var status = await linkedIn.GetStatusAsync(ct);
    // A locked vault is where the LinkedIn login lives. If we can't confirm a LIVE session right
    // now (no open browser with li_at) AND the vault is locked, we'd otherwise launch a browser and
    // sit on the login wall until the 8-min ceiling. Fail fast with the actionable message instead.
    if (!status.SessionLiveConfirmed && !vault.IsUnlocked)
        throw new LinkedInSessionRequiredException(
            "Unlock your vault first — your LinkedIn login is stored there, then Re-sync.");
    if (!status.SessionVerified)
        throw new Core.Interfaces.LinkedInSessionRequiredException(
            "LinkedIn isn't signed in yet. Open Connections → LinkedIn → Connect, sign in inside the " +
            "automation browser, and let it verify — then Re-sync.");
    
    Keep this BEFORE TryBegin so the tracker never starts a doomed run.
  3. Do NOT change the scraper's login-wall handling — the fast pre-check above means we only reach the scraper when we genuinely have (or can restore) a session.

Frontend (frontend/**) — tell them BEFORE they click

  • api.service.ts/models.ts: ensure the Connection/status shape the queue can read exposes LinkedIn connected + (if available) vault-unlocked. Reuse getConnections() (GET /api/connections); inspect the returned Connection for the LinkedIn entry's connected/verified flags. If vault state isn't in that payload, it's fine to gate only on LinkedIn-connected for the pre-click hint (the backend 409 is the authoritative backstop with the exact message).
  • queue.ts: on init, load connection status into a linkedInReady = signal(false).
  • queue.html: the Re-sync button [disabled]="syncing() || !linkedInReady()", and when !linkedInReady() show a small hint next to it: "Connect LinkedIn / unlock vault to re-sync." Keep the 409 toast handling as the backstop for the race where state changes between load & click.

FIX 3 — Legible "Scraping" (indeterminate ring)

Frontend (frontend/**)

progress-ring component: - Add indeterminate = input<boolean>(false). - Template: when active() && indeterminate(), render the .fill in a pulsing/sweeping mode instead of the fixed stroke-dashoffset. Simplest: a CSS keyframe on .ring-host.indeterminate .fill that animates stroke-dashoffset continuously (e.g. a short arc sweeping around the perimeter) OR a gentle opacity pulse of the full outline. Determinate mode (total known) is unchanged. - Respect prefers-reduced-motion (no sweep/pulse; fall back to a static faint full outline). queue.html: bind [indeterminate]="syncing() && !syncTotal()" — pulse during Scraping (unknown total), determinate N/total once enrichment sets the total. (data-backup.ts: same, !syncTotal().)


Verify

Backend: dotnet build + dotnet test green; add tracker TryBegin tests (2nd same-kind begin returns null; different kind allowed; after Complete a new begin succeeds) + a Resync test that a locked vault (IVaultService.IsUnlocked == false) with an unverified/stale session throws LinkedInSessionRequiredException with the "Unlock your vault" message (no browser launched). Frontend: npm run build + Vitest green; a progress-ring spec for the indeterminate class toggle; a queue spec that a running linkedin-resync from getSyncProgress on init sets syncing true.

Gate

Council review (Codex + Gemini) → fix findings → rebuild installer → install locally + finish the 3 staged fleet boxes. Do NOT push from builder agents.