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);
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/**)¶
LinkedInLinkStatus(find the model): addpublic bool SessionLiveConfirmed { get; set; }. InLinkedInSessionConnection.GetStatusAsync, setSessionLiveConfirmed = trueONLY on the live-cookie path (whereli_at=was actually observed); leave it false on the stale-JSON fallback. Do NOT change the meaning of the existingSessionVerified.JobQueueService.ResyncFromLinkedInAsync— injectIVaultService vault(optional+defaulted like the tracker, so existingnew JobQueueService(...)test sites still compile). Strengthen the pre-flight (currentlyif (!status.SessionVerified) throw LinkedInSessionRequiredException(...)):Keep this BEFOREvar 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.");TryBeginso the tracker never starts a doomed run.- 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 theConnection/status shape the queue can read exposes LinkedIn connected + (if available) vault-unlocked. ReusegetConnections()(GET /api/connections); inspect the returnedConnectionfor 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 alinkedInReady = 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.