A partial warm session (e.g. Rpartstore DOWN → only Fiat+Dialogys) is proven
unstable: re-warm cycles + the missing catalog's launch-error keep knocking the
Fiat window off its VIN panel, so warm Fiat decodes thrash to the 180s budget →
not_found. Require all three windows before this.warm=true; otherwise stay on the
reliable cold path (which decodes Fiat/Renault + cross-brand cleanly). The daemon
backs off + retries, so warm auto-resumes once Rpartstore recovers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review finding: warmDecode's genuine-not-found decision used VINPIN_OCR.notFound
(/…|Catalogue/i) against the FULL frame, where the 'Spare Parts Catalogue' header
always matches → every on-panel garble was flagged a genuine miss and null'd out
instead of falling back to the cold retry. Add notFoundStrict (no Catalogue token)
for the full-frame check so a transient on-panel garble (VIN exists) recovers via
the cold path; keep notFound for the runVinFlow modal-region settle poll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the warm daemon is up and Rpartstore is DOWN, a partial-warm session
(Fiat opened, Rpartstore launch-error, Dialogys opened last → foreground)
made a Fiat warm decode fail silently: the Fiat foreground regex shared the
`Поиск` token with the Dialogys "ПОИСК" button, so raiseWarmWindow reported
success without raising Fiat and the VIN was typed into Dialogys → garbage;
warmDecode's Fiat branch then did a bare `return null` (no log, no fallback).
- FIX 1: drop the ambiguous `Поиск` from VINPIN_WINDOW_FOREGROUND.fiat; keep
Fiat-only chrome (Dealer/ePER) + VIN-panel model tokens.
- FIX 2: warmDecode Fiat unusable-parse no longer returns a silent null —
OCR the frame; on-panel + genuine not-found → null (real miss), otherwise
warn (cold-path parity) and fall back to the proven cold decodeFiatLocked.
- FIX 3: ensureWarmWindow panel-verifies a raised Fiat window (catalogueReady);
if up but off the VIN panel, re-navigate via establishSession (bounded/never-throw).
- FIX 4: _warmUp records per-window availability (warmWindows) so a Fiat VIN
routes straight to cold when no Fiat window opened; and dismisses a leftover
Rpartstore launch-error modal before opening Dialogys so it can't dirty the
desktop / drive the wrong-window state.
Adds ocrFrame() test seam + 4 unit tests. tsc clean; 114 vinpin tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An AMBIGUOUS Renault outcome is transient/state-dependent, but decodeRenaultLocked
retried it with a full browser teardown (this.close()). The next attempt then
re-launched chromium + re-did the web login + re-established from scratch
(~60-90s each), and each re-establish re-hit the seat's dirty-resume ("catalog
window resumed open" -> closeStrayRunningApps), so 3 attempts blew the 180s budget
-> not_found. Proven live: VF1RFE00653633190 decoded cleanly to KADJAR earlier
when the desktop state was favorable, then thrashed to not_found on relaunch.
Fix: on the ambiguous path, reset to a clean VinPower brand grid on the SAME live
session via ensureBrandGrid (closeStrayRunningApps DOM recovery first, canvas
tab-X fallback) instead of tearing the browser down. Keep the browser + authed so
the next iteration's ensureAuthenticated is a no-op (no relaunch, no web login),
and re-run runRenaultFlow from the clean grid (~30-40s). Graduated safety: if the
cheap reset can't confirm a clean grid or the session is broken (page
closed/disconnected), fall back to the old close() + cold re-establish. The reset
runs under the wall-clock deadline so an overrun still routes to the existing
VinpinBudgetError teardown+poison path. never-throw + budget/poison paths
unchanged; maxAttempts semantics unchanged.
Tests: +3 (ambiguous -> in-session ensureBrandGrid reset re-runs runRenaultFlow
with NO close(); graduated fallback close()s when the reset can't reach a grid;
broken session skips straight to close()). 110 vinpin tests green; tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review finding: closeStrayRunningApps classified an unreadable/empty VinPower
running-app row as a stray and closed it (`/VinPower/i.test('')` is false),
needlessly killing+relaunching a healthy VinPower on a transient name-read miss.
Only close rows POSITIVELY identified as non-VinPower (non-empty name that fails
the VinPower match); treat unreadable names as keep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On login the RDS seat resumes DIRTY (e.g. a Renault Rpartstore launch-error
modal + its taskbar window over the brand grid). The old recovery failed: the
canvas tab-✕ (93,45) only hits a TABBED catalog window's ✕, which a resumed
stray doesn't have, so 6 tries did nothing and escalated to logout+relogin —
counterproductive, since the seat publishes apps-only and the RDS session ends
only on server-side idle timeout, so a Connection-Server logout+relogin PROVABLY
resumes the same dirty window.
New state-agnostic recovery `closeStrayRunningApps`: reveal the Horizon sidebar
(#sidebar-toggler), enumerate ul.running-app rows, and terminate every app whose
name != VinPower via its per-app ✕ (li.icon-close-app-image) — real DOM outside
the Blast canvas, so it closes a window regardless of its modal/spinner/loading
state. Collapse the sidebar, OCR-confirm the brand grid; relaunch VinPower via
#available-VINPIN (or the vinpinApp canvas coord) if the app itself was gone.
Wired as the PRIMARY recovery in ensureBrandGrid — both the resumed-catalog
branch (before the canvas tab-✕ fallback) and the end-of-loop escalation, which
NO LONGER calls logout+relogin (method retired). Every DOM op is guarded
(try/catch + presence check) so a missing selector / canvas-only render degrades
gracefully to the existing dismissBlockingModal + tab-✕ / OCR path instead of
throwing. Bounded loop; the launch-error modal dismissal (OK 868,530 / Escape)
is kept as a fast pre-step and fallback.
Preserves the never-throw contract, 180s budget/poison, spinner-guard, acquire
cap, launch-error cooldown, ensureRpartstore fast-bail, and the Fiat ePER path.
tsc clean; 107 vinpin tests green (adds closeStrayRunningApps close/degrade tests
and the ensureBrandGrid-uses-closeStrayRunningApps escalation tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When Rpartstore is DOWN, its hard launch-error modal ("Ошибка запуска каталога",
title "Renault Rpartstore") renders OVER the brand grid. The grid tiles stay
OCR-visible behind the small centered modal, so the full-frame read matches both
`brandGrid` and `renaultSubmenu` — making ensureRpartstore's flyout branch fire on
EVERY iteration, re-clicking renaultRpartstore(584,779) + langOk + a 12s full-frame
poll for all ~6 iterations (~72s) before finally returning false. That wastes ~60s on
the first cold DOWN decode AND repeatedly actuates coordinates on a wedged desktop.
Detect the launch-error modal (upscaled crop via the existing
`rpartstoreLaunchErrorPresent`) at the top of the per-iteration loop, BEFORE the
flyout branch: if present, return false on iteration 1 so the acquire loop's
`!present` path dismisses it, sets the down-cooldown, and routes straight to Dialogys.
Depends on the crop OCR being legible (ffmpeg upscale, added in 281c54a); when
illegible it's false and behaviour is exactly as before.
Adds two robustness tests: (1) modal-over-grid → false on the first iteration with no
flyout re-clicks; (2) illegible crop → flyout path still runs (unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two review follow-ups to 281c54a:
1. born-stuck cooldown regression: acquireLoadedRpartstoreInner set the 10m
down-cooldown when maxOpens was exhausted by a born-stuck-spinner streak — a
TRANSIENT, reopen-recoverable blip, not a server outage. Because the cooldown
can only self-clear from INSIDE the acquire loop (skipped while cooling down),
one spinner streak suppressed the richer Rpartstore catalog for every Renault
decode for 10m. Reserve the cooldown for the confirmed launch-error DOWN signal
(unchanged at the two launch-error sites); the born-stuck give-up now just falls
back to Dialogys for that one VIN and retries Rpartstore fresh next VIN.
2. clean-logout false disconnect-recovery: cleanTeardown's disconnect recovery
gated on VINPIN_OCR.sessionDropped, whose broad "HTML Access" token also matches
the clean-logout Horizon HTML-Access launcher. A clean log-off could then click
disconnectedClose + RELAUNCH VinPower right before close(), leaving the exact
dirty resumed session the teardown prevents (+~17s wasted). Veto the recovery
with !VINPIN_OCR.launcher so it fires only on a real Disconnected drop.
Keeps never-throw, budget, spinner-guard, Fiat/Dialogys fallbacks intact. Adds a
born-stuck-no-cooldown test and a clean-logout-launcher-no-relaunch test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: ffmpeg is absent in the prod worker, so vinpin.ocr cropScale silently
returns the full 1600x900 frame — the tiny centered Rpartstore launch-error modal
is illegible, so a DOWN Rpartstore is misread as a spinner and thrashes the seat.
- Dockerfile: install ffmpeg so cropScale actually crops+3x-upscales (restores all
clipped OCR: modal-crop error detect, Fiat modal, Renault header).
- ensureRpartstore: gate the "already open" short-circuit on a CONTENT token
(rpartstoreLoaded), not rpartstoreOpen which false-matches the flyout/title word
"Rpartstore"; click the flyout entry when the submenu is up over the grid; keep a
late open-window branch so a spinner/vehicle-page window still counts as present.
- closeRpartstoreTab: never click windowClose(1298,14) (it hits the language
selector and wedges the grid); gate the retry on a content token; Escape after.
- cleanTeardown: dismiss any blocking modal BEFORE "Çıkış yap" so logout is a clean
RDS log-off (not a dirty channel disconnect); recover a Disconnected dialog via
disconnectedClose(953,505) + relaunch VINPIN app for a fresh grid.
- Rpartstore-down cooldown (10m): a launch-error / repeated load-failure routes
Renault decodes straight to Dialogys (skip reopening a down catalog); a confirmed
load clears it.
- ensureBrandGrid: dismiss a wedging launch-error modal + short-retry instead of
burning the 84s dead-wait.
- parseRenaultHeader: ignore the status-bar license-expiry date when reading the
model year; sessionAlive matches RDST01/RDST02 (seat load-balances).
- Keeps never-throw, VINPIN_DECODE_BUDGET_MS, sessionPoisoned, the acquire cap,
spinner-guard, relogin-cap and the Fiat/Dialogys fallbacks intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Renault decode fell to not_found when Rpartstore is DOWN (hard
launch-error modal) even though Dialogys should take over. Root cause:
- acquireLoadedRpartstore's budget-exhausted / never-loaded return-false
paths left the centered launch-error modal on screen (only the flaky
OCR error-detect branch dismissed it). The modal then blocked the
Dialogys grid-return.
- returnToBrandGrid's tab-✕ can't close a centered dialog, so every
"not on brand grid (try N/4)" wedged and re-entered ensureBrandGrid,
which escalated to logoutAndRelogin → close()+launch() ("browser
disconnected — will relaunch") on EVERY iteration, thrashing on a stale
page ref until the 180s budget → not_found.
Fixes (conservative, all safety nets intact):
1. Wrap acquireLoadedRpartstore so EVERY false return runs a best-effort
defensive dismiss (Escape → click launch-error OK 868,530 → Escape),
unconditional of the OCR read. Harmless when no modal is up.
2. returnToBrandGrid + ensureBrandGrid dismiss a possible centered modal
before the tab-✕ close so a leftover dialog can't wedge the loop.
3. Cap the logout+relogin escalation to ONE attempt per decode/warm-up
(reloginUsedThisDecode) — a capped exhaustion poisons the seat for a
clean cold restart instead of looping close()+launch() until budget.
Keeps the OCR fast-path branch, maxOpens/acquireBudgetMs=14s, fcc0298,
61b5769, Fiat path, never-throw/budget/poison all intact.
Tests: +3 (budget-exhausted defensive dismiss; grid-return modal-clear
before tab-✕; relogin capped to one attempt) — 88 vinpin tests green,
tsc + biome clean. Needs prod validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rpartstore's launch-error modal OCR-detection is unreliable across renderings,
so a DOWN Rpartstore was grinding all 3 reopens (~54s) + dirtying the seat →
Dialogys fallback couldn't finish inside the decode budget → not_found.
Lower acquireBudgetMs 90s→14s so the loop bails after the first open+poll (~22s)
straight to Dialogys on a still-clean seat. Healthy Rpartstore loads on the first
open and is used as before; maxOpens kept so tests still exercise the reopen path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prod Rpartstore→Dialogys bail regressed: a DOWN Rpartstore was still
misclassified as a "born-stuck spinner", burning 3 reopens (~54s) and pushing
the decode past the 180s budget → not_found. The rpartstoreLaunchError regex
never matched because acquireLoadedRpartstore's load-poll OCR'd the FULL
1600x900 frame at 1x — at which the small centered Cyrillic modal ("Ошибка
запуска каталога") is illegible to tesseract (it returns the surrounding
brand-grid tiles and drops the modal text). The regex text was actually fine;
the modal was never fed to it.
Root cause (verified live 2026-07-15, seat trvinpin47828): wrong OCR
resolution/region, not wrong regex.
Fix:
- New VINPIN_RPARTSTORE_ERROR_REGION (centered modal crop); OCR it UPSCALED (3x)
so "Ошибка запуска каталога" reads as "Owwu6ka 3anycka KaTanora" and matches.
- pollRpartstoreState(): each poll reads LOADED off the full frame (large Latin
text, unchanged) AND the launch-error off the upscaled modal crop → bail on the
FIRST open, no wasted reopens. Genuine-spinner reopen path preserved.
- Also catch the launch error when ensureRpartstore/raiseWarmWindow can't confirm
a window (modal is over the grid, no catalog chrome) → dismiss + bail.
- rpartstoreLaunchError regex: add the verbatim live transliterations
(Owwu6ka/OwwbKa); 3anycka+KaTanora remain the stable anchors.
Live verification (seat trvinpin47828, exclusive night access):
- Rpartstore is DOWN server-side (hard launch error, NOT a spinner).
- New detection returns launchError on open 1 → bail, no reopens.
- Dialogys fallback decoded VF1RFE00653633190 → RENAULT Kadjar (HFE) in 52.2s
(well under the 180s budget).
Budget/poison/livelock/Fiat paths untouched. 85 vinpin unit tests green;
typecheck + biome clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rpartstore currently throws an immediate hard launch-error modal ("Ошибка
запуска каталога" / title "Renault Rpartstore") within ~8-10s of every open —
a server-side/entitlement failure that reopening never fixes. The old code
misclassified it: the error dialog's title matched rpartstoreOpen so
ensureRpartstore returned true, but it lacked the rpartstoreLoaded content
markers, so acquireLoadedRpartstore judged it a born-stuck spinner and burned
all 3 reopens (~54s) before falling back to Dialogys — which then ran out of
the 150s decode budget → not_found. Dialogys itself decodes correctly (~25s).
Fix (detect-and-bail on the FIRST open, no wasted reopens):
- constants: add VINPIN_COORDS.rpartstoreLaunchErrorOk (868,530) + the
VINPIN_OCR.rpartstoreLaunchError pattern (matches the real Cyrillic AND its
stable eng-OCR transliteration "Owwnbka 3anycka KaTanora").
- acquireLoadedRpartstore step 2: poll now stops on loaded OR launch-error and
classifies via the returned OCR text; a launch error dismisses the modal and
returns false immediately → straight to Dialogys (reopen loop untouched for
genuine spinners).
- ensureRpartstore: submenu-open poll also stops fast on the launch error
instead of dead-waiting afterRenaultCatalogOpen.
- bump VINPIN_DECODE_BUDGET_MS 150s→180s (cheap safety margin).
- tests: launch-error → false after ONE open (no reopens, dismiss clicked);
genuine spinner still reopens; OCR-pattern matches Cyrillic + transliteration.
Never-throw contract, sessionPoisoned, budget teardown, cold/Dialogys
fallbacks, the fcc0298 spinner-guard, the 61b5769 establish/teardown fix and
the Fiat ePER path are all intact. Skipped the warm-path Dialogys field-clear
tweak — runDialogysSearch is shared with the cold path and switching its clear
step would change cold behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of warm-establish failures was resume-into-dirty-session +
broken window-close + no backoff, not OCR/detection. Four composing fixes:
A. Clean teardown (cleanTeardown): before the browser close(), close each
open catalog window via the corrected tab-✕ then click "Çıkış yap" logout
to END the RDS session, so the next warm-up starts from a fresh login/grid
instead of resuming into the last-open 3-window desktop. Wired into
teardownWarm() and both failed-warmUp exits. Bounded + never-throw.
B. Fix close coords + tab-✕ primary. catalogTabClose {135,45}→{93,45}
(validated live). In ensureBrandGrid/returnToBrandGrid the tab-✕ is now the
PRIMARY close; the windowClose {1298,14} click (which opens the HTML-Access
language dropdown) is no longer used there. Escape pressed after each close
to dismiss an accidental dropdown before re-OCR. After N failed closes,
ensureBrandGrid escalates to logout+relogin (one-shot, no recursion) instead
of limping into the ePER-open loop.
C. Realistic grid wait. afterLogin 20_000→45_000 (real grid render ~32-46s).
D. Backoff between re-warm attempts. A failed warmUp sets a cooldown (60s,
exponential to 5min) that BOTH reconcile() and decode()'s warm-on-demand
honour; a successful warm resets it — so a failing seat is no longer
hammered every ~60s leaving fresh dirty windows.
Safety nets preserved: never-throw contract, VINPIN_DECODE_BUDGET_MS,
sessionPoisoned, cold/Dialogys fallbacks; fcc0298 Rpartstore spinner-guard,
Fiat ePER path, and Russian-dialog dismissal (746,454) untouched. Cannot be
exercised in dev (single seat on prod) — needs prod validation on a rested seat.
Tests: +8 unit tests (clean-teardown ordering, tab-✕ primary + relogin
escalation, warm-up backoff respected by reconcile + warm-on-demand + reset).
81 vinpin tests green; tsc + biome clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unsubscribe confirmations (and any stale links) used the path form which
had no route and fell to the SPA not-found screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- inject signed unsubscribeUrl into every optional-workflow Novu payload
(templates render it via {{#if unsubscribeUrl}} footer)
- add 'conversion' campaign workflow to OPTIONAL_WORKFLOWS + email_marketing
category so its one-click tokens validate and opt-outs suppress it
- declare UNSUBSCRIBE_SECRET/URL_BASE/EMAIL in env schema (""→undefined
preprocess against the url().optional() boot-crash trap), .env.example and
both compose env blocks
- fix confirmation-page settings link (?tab=notifications)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kullanıcı düzeltmesi: kokpit tur ham araç-marka etiketlerini "yansanayi"
sayıyordu — yanlış. FORD/GM/BMW/Mercedes/Volkswagen/Renault… OE etiketidir.
Orijinal tespiti artık kokpit tur='orjinal' setine değil, OE aile üyeliğine
dayanır: OE_SUPPLY_LABELS OE_FAMILIES'ten TÜRETİLİR (+ jenerik ORJINAL), böylece
OE-tespiti ile aile-filtresi drift etmez ve tüm araç markaları orijinal sayılır.
Gerçek yan sanayi PARÇA markaları (Bosch/Febi/Valeo/TRW) hiçbir ailede yok →
orijinal değil. GM Opel ailesine eklendi (TR'de Opel OE'si). Redis v3→v4 (eski
"miss" değerleri bayat). kokpit'e DOKUNULMADI.
Ölçülen etki (dev): grafikli kod %0,77→%0,84; artış sase kataloğu ile takip
beslemesinin bu markalardaki kod kesişimiyle sınırlı (ör. takip'te 4.859 Ford-
stokta koddan 335'i sase kataloğunda).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A freshly-opened Rpartstore instance sometimes gets "born stuck" on an
infinite spinner (survives raise/maximize). The only reliable fix, proven
in a live spike, is to CLOSE the stuck instance and reopen a FRESH one.
Replace the old "raise Rpartstore → if not focusable reopen → else fall to
Dialogys" with a bounded load-verify-else-reopen loop applied to BOTH the
warm-daemon Renault path (warmRenaultDecode) and the cold per-decode path
(runRenaultFlow):
- acquireLoadedRpartstore(): bring a Rpartstore window forward, OCR-verify it
actually rendered its search-home landing markers (new rpartstoreLoaded set —
content tokens the spinner lacks), and if still spinning close the tab and
reopen a fresh instance. Retries up to VINPIN_RPARTSTORE.maxOpens (3) times.
- Bounded inside the decode wall-clock budget AND a tighter acquireBudgetMs
(90s) sub-cap, so a permanently-stuck Rpartstore still leaves headroom to
fall back to Dialogys — never a livelock.
- Rpartstore stays PRIMARY (richer Turkish catalog); Dialogys only as a
last resort once reopen attempts are exhausted or it genuinely misses.
- Fiat ePER path unchanged; sessionPoisoned / never-throw contract preserved.
Adds unit tests for reuse / spinner→reopen→loaded / exhausted→false /
budget-bail / Rpartstore-primary-on-hit / exhausted→Dialogys-fallback.
Could not live-test (single Vinpin seat is held on prod) — needs prod
validation after promote.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Aynı OEM koduna farklı OE dağıtıcıları düşebiliyor (Stellantis: PSA ₺5.531 vs
OPAR ₺3.459) → OE-only havuz bile iki dağıtıcıyı medyanlayıp hayalet fiyat
veriyordu. Artık kart, kodun kendi araç markasına göre tek OE ailesine süzer.
- part-prices.logic: marka→OE-dağıtıcı-ailesi haritası (OE_FAMILIES: PSA=
Peugeot/Citroen/DS, FIAT=Fiat/OPAR/Tofaş, RENAULT=Renault/Dacia/MAIS, ...).
filterOffersForBrand: araç markası → yalnız o ailenin orijinal teklifleri
(jenerik ORJINAL aile-belirsiz olduğu için elenir); yan sanayi markası çipi
→ yalnız etiket-uyumlular; markasız → tüm OE aileleri (taban).
- Web: OEM kartı kodun araç markasını oem-vehicles'tan (en çok geçen brandName)
türetip geçirir; başlık "Orijinal (OE) fiyat analizi · <Marka>". Kart marka
çözümü için oem-vehicles'ı bekler.
- vehiclesByOem NORMALIZE eşleşmeye geçti + parts_oem_code_norm_idx functional
index (migration 0033): PSA kodları boşluklu saklandığından ("9827 622 780")
exact match onları kaçırıyordu — marka bu yüzden çözülemiyordu. Reverse
"kataloğunuzda" bölümü de artık reformatlı kodları buluyor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the per-decode "launch browser + login + open catalog" model with a
persistent warm Vinpin seat that eliminates cold-start, brand-switch cost and the
seat livelock. Additive + fail-safe: every warm operation degrades to the proven
cold per-decode path, so behaviour never regresses.
Warm daemon (VinpinDaemonService, worker-process singleton):
- Scheduler warms the seat at 08:00 and tears it down at 21:00 Europe/Istanbul
(proper TZ via Intl, no hardcoded offset); warms on worker start if inside hours;
reconcile() every 60s with a reentrancy guard.
- Keepalive nudges the RDS session (mouse.move) every ~75s while warm+idle; it
SKIPS during any active seat op (busy flag) and never takes a lock that blocks a
decode.
- decode(vin): inside hours ensure warm (warm-on-demand once) then delegate to the
driver; off-hours delegate straight to the cold path. Never throws.
Driver warm path (VinpinDriverService):
- warmUp() launches+logs in ONCE and opens Fiat ePER + Renault Rpartstore + Renault
Dialogys windows without closing each other, then OCR-binds each taskbar button
(order read via OCR, not hardcoded; raise self-heals by probing slots + OCR
verify). isWarm()/teardownWarm()/keepalivePing() added.
- warmDecode(): taskbar-raise the brand window (Fiat→ePER, Renault→Rpartstore w/
Dialogys fallback), run the EXISTING in-catalog decode on the warm window, OCR
the modal, parse, then Escape to ready the field for the next VIN. Runs under the
wall-clock budget; a hang still aborts.
- Health-recovery: a dropped seat (Disconnected/no-free-sessions OCR marker) →
teardown + re-warm ONCE, then retry the decode once.
- Refactored runDialogys into openDialogysSubmenu + runDialogysSearch so the warm
path searches without a window-closing reopen; cold Dialogys flow unchanged.
Safety nets retained: VINPIN_DECODE_BUDGET_MS + sessionPoisoned breaker (warm
budget abort → teardown+poison+null), single-seat serialization (runExclusive),
decode() never throws. Gated by VINPIN_ENABLED; VINPIN_WARM_DAEMON=false forces the
legacy cold path (kill-switch). Widened VINPIN_MODAL_REGION to ~900px.
Wiring: processor calls getVinpinDaemon().decode(); worker starts the daemon on
boot and stops it (releasing the seat) on shutdown. BullMQ concurrency 1 +
attempts:1 unchanged.
Tests: business-hours warm/teardown scheduling (injected clock/TZ), brand→taskbar
routing, taskbar OCR-order binding, keepalive-skips-during-decode, and
session-drop→re-warm→retry recovery. All existing vinpin/queue tests stay green.
NOTE: un-dev-testable (prod holds the single seat) — pixel/taskbar coords are
OCR-verified + marked TUNE and need live prod validation; warm falls back to cold
until confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A bad/unresolvable VIN could leave a catalog window open on the shared Vinpin
seat; the next decode's ensureBrandGrid found it "resumed open" and looped
(close→brand-grid-not-confirmed→ePER-open→browser-disconnect→relaunch) forever.
BullMQ attempts:2 + 30s backoff auto-re-fed every failure straight back into the
stuck seat, starving all real decodes for minutes.
- queue: attempts:1, drop the 30s exponential backoff (extract VINPIN_DECODE_JOB_
OPTIONS). The driver already runs its own bounded internal retries; a BullMQ
retry on top is what compounded the livelock. Null decode still persists as
not_found; a hard infra throw stays user-retriable (failed).
- driver: hard per-decode wall-clock budget (VINPIN_DECODE_BUDGET_MS, 150s) via
withDeadline() racing each attempt; on abort → close() + poison seat + return
null (no retry into the stuck state).
- driver: sessionPoisoned flag — set at nav-loop exhaustion (ensureBrandGrid /
establishSession), budget abort, and failed post-decode cleanup; the NEXT
decode forces a full cold re-establish instead of reconnecting to the resumed
desktop. Cleared on any confirmed-clean grid/catalogue.
- driver: finally-cleanup after every decode — on failure/not-found return the
seat to a clean brand grid; if that can't reach the grid, poison + tear down.
- driver: basic VIN sanity (17 alphanumerics) before touching the seat.
Healthy Fiat/Renault happy paths are byte-identical when nothing is stuck.
Cannot be live-tested (seat is on prod) — needs prod validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Vinpin catalog matcher could route a European Renault VIN
(VF1RFE00653633190, decoded "KADJAR") onto the China-market catalog
"KADJAR ÇİN" (source XZH, 22 categories) instead of the correct European
"KADJAR" (XFE, 44 categories), serving wrong parts. Root cause: the
Turkish "ÇİN" lost its Ç/İ to the ASCII token strip and collapsed to a
dropped 1-char "N", so "KADJAR ÇİN" tokenized identically to "KADJAR" —
the market qualifier was invisible to the scorer.
Fixes:
- modelTokens now folds diacritics (NFD + combining-mark strip) so
"ÇİN" survives as the ASCII token "CIN".
- Scorer prefers an EXACT normalized model match (no extra tokens) over
a superset, via a bonus kept smaller than the year-range swing so
multi-generation routing (2022 TIPO-EGEA → MCA) is unaffected.
- New MARKET_QUALIFIERS set (ÇİN/CIN, CHINA, CHINE, RUSYA, ... grounded
in the real Renault/Dacia catalog rows) heavily penalizes candidates
whose EXTRA tokens are region qualifiers; generation tokens are not
penalized, so generations stay matchable.
- Richer-catalog (categoryCount) tiebreak among equal-score candidates;
match() query selects the category count via a correlated subquery.
Adds unit tests incl. the KADJAR case, CLIO/DUSTER generation cases, and
diacritic-folding. Fiat behavior unchanged; all 39 vinpin tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two Vinpin decode-flow UX bugs in the search page:
1. Poll window (VINPIN_MAX_POLLS 6→30, still 3s interval) now covers a
cold Vinpin VDI decode (~60-100s) instead of bailing at 18s. On
poll-exhaust we no longer fall through to the no-catalog dead-end;
instead a reassuring brand-neutral "still working, ready shortly"
card (decodePendingHint) — the decode caches server-side so a later
re-submit returns instantly.
2. The poll-exhaust fallback previously forced brandName:"Fiat" onto the
no-catalog prompt, so a Renault VIN read "Fiat". Removed. The
no-catalog copy is brand-aware via the API's noCatalog.brandName, with
brand-neutral fallback keys (noCatalogHintNeutral / browseCatalogCtaNeutral)
when no brand is available. A Renault VIN never says "Fiat".
Web-only, no flag. New i18n keys added to tr.json + en.json. No API change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Latency optimizations in the flag-gated (VINPIN_ENABLED) Vinpin decode driver.
Every change keeps the existing fixed-wait value as a fallback cap, so
worst-case behaviour and robustness are unchanged — only the common case is
faster. No live seat session was run (prod holds the single Horizon seat).
#2 Renault→Fiat symmetric swap: ensureReady no longer tears the whole browser
down on a Renault→Fiat flow switch (was a ~60-80s cold restart). It now keeps
the authenticated Horizon/VinPower session, returns to the brand grid via the
existing ensureBrandGrid (which closes a resumed foreign catalog window) and
reopens Fiat via the normal tile flow — symmetric with the Fiat→Renault
direction. Guarded fallback: if the in-session swap can't reach the grid /
throws, it falls back to close()+cold re-establish.
#3 OCR-poll-until-ready: replaced big fixed post-action sleeps whose completion
is OCR-detectable with a capped poll (screenshot→ocrRegion→regex every ~700ms,
return on match, cap == old fixed wait). Converted: afterFiatOpen(12s),
afterVinSubmit(7s, Fiat modal), afterRpartstoreSubmit(8s),
afterRenaultCatalogOpen(12s, ×2), plus cold-path afterLogin(20s) and
afterVinPowerLogin(18s). Left afterVinpinLaunch(14s) fixed — the VinPower login
dialog has no reliable OCR marker (detected via DOM), and it doubles as a
generic connecting-screen settle. Left afterDialogysSubmit fixed (out of scope).
#4 Micro-trims: field-clear Backspace burst 40→5 (VINPIN_FIELD_CLEAR_BACKSPACES),
key-type delay 45-50ms→20ms (VINPIN_TYPE_DELAY_MS, 17-char VIN ~850→~340ms),
afterAlertDismiss 700→250ms.
New pure/injectable pollForText helper in vinpin.ocr.ts (unit-tested:
early-return, cap-never-exceeded, first-read match). typecheck + biome clean;
31 vinpin unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VINPIN köprüsüne Fiat'ın yanına Renault/Dacia desteği eklendi.
- Marka yönlendirme: selectVinpinBrandFlow(vin) WMI'den akışı seçer
(Renault/Dacia → Rpartstore/Dialogys, geri kalan → mevcut Fiat ePER,
Fiat davranışı byte-identical). isVinpinBrandAllowed allowlist'i
fiat + renault + dacia'ya genişletildi.
- Renault akışı durum-toleranslı: koltuk son katalogu (Rpartstore)
sunucu-taraflı hatırlayıp açık resume ediyor; ensureRpartstore grid /
submenu / açık-pencere / yükleniyor durumlarını tanıyıp kendini
toparlıyor. Rpartstore ana akış, Dialogys best-effort fallback.
- Ortak ensureBrandGrid artık resume olmuş yabancı katalog penceresini
(Rpartstore/Dialogys/ePER) kapatıp grid'e dönüyor → Renault↔Fiat
ardışık decode'ları (tek koltuk) artık kırılmıyor.
- parseRenaultHeader/isUsableRenaultParse: OCR başlığından model+marka
(RENAULT/DACIA) + yıl; bilinen-token allowlist ile contiguous model
koşusu. Matcher + processor kararlaştırılan markaya göre PL24
catalog_vehicle eşliyor (Fiat varsayılan korunur).
- Testler: Renault parser + marka-yönlendirme/allowlist birim testleri.
Flag-gated (VINPIN_ENABLED). Canlı doğrulama: 5/5 Renault/Dacia VIN
(Kadjar, Clio II, Clio IV, Dacia Duster, Latitude) doğru decode; Fiat
Egea spot-check korunuyor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SOURCE_COMMIT isn't substituted into docker-compose build args by Coolify
(resolved to a constant "unknown"), so the cache-bust never fired. Removing the
dead ARG/RUN + compose arg. Web-only deploys use a Coolify force-rebuild instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-deploys (docker build with cache) were reusing a stale build layer, so
web-only commits shipped an old bundle — the switcher removal (#216) and the
canonical fix (#217) only appeared after a manual --no-cache/force rebuild.
Reference SOURCE_COMMIT (substituted by Coolify) right before `COPY . .`, so the
build layer's cache key changes every commit and COPY + `pnpm build` re-run. The
deps stage stays cached on the lockfile, so installs aren't repeated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>