[1.29.2] — 2026-05-14
Hot-fix: 🌐 Scan with source=both only ran the EN phase. RU phase was silently dropped.
🚑 Critical hot-fix
User-reported symptom: clicking the unified 🌐 Scan button (which calls runScanAll() → GET /api/stream/scan?source=both) returned only the EN ATS phase. The console showed ▶ ATS scan (Greenhouse + Ashby + Lever) … ✓ ATS done · NEW=0 and then stopped. No RU phase output ever appeared, even with all 5 RU adapters listed in russian_portals.sources.
Root cause — public/js/api.js:156 closed the EventSource on the first done event:
if (ev === 'done' || ev === 'error') es.close();But server/lib/routes/scan.mjs::driveOne emits done once per phase, and the source=both branch drives two phases sequentially. The client closed after the EN done, the server detected res.on('close') → AbortController.abort() → the RU phase started but was immediately cancelled.
Fix — multi-phase SSE contract:
- Server (
server/lib/routes/scan.mjs):driveOnenow accepts afinalparam (defaulttrue). Thedonepayload carriesfinal: <bool>. Thesource=bothbranch passesfinal: falseto the first phase,final: trueto the second. - Client (
public/js/api.js:148-172):stream(...)closes theEventSourceondoneonly whendata.final !== false. Backward-compatible: legacy single-phase producers (/api/stream/batch,/api/stream/pdf*, etc.) don't setfinal, so the behaviour is unchanged. Close onerrorremains unconditional.
🧪 Tests
test(scan): tests/scan-stream-multi-phase.test.mjs— 11 cases covering both server-emitted SSE contract and client decision logic:- SSE contract (6 cases):
source=ats→ 1done(final:true);source=regional→ 1done(final:true);source=both→ 2doneevents withfinal:falsethenfinal:true;source=both→ 2startevents inen-scanner/ru-scannerorder; static canary that the pre-v1.29.2 unconditional close pattern is gone; static canary that the v1.29.2data.final !== falseguard is present. - Functional proof of fix (3 cases):
source=bothactually emits the RU-phase banner line (proves the body runs, not just empty shells);ru-scannerstart arrives AFTERen-scannerdone (ordering);dryRun=1does NOT modifydata/pipeline.md(no phase secretly flipswriteFiles). - Pure-logic close-decision table (1 case, parametrized over 11 inputs): mirrors the api.js branch in JS — covers
donewithfinal:false / true / undefined / null / 0 / 'false', pluserrorwith payload/null, plusstart/log(never close). - Bug-forensics (1 case): simulates the pre-v1.29.2 client by cancelling the response stream after the first
doneand verifies the server'sres.on('close')abort handler is still intact (documents the pre-fix mechanism for future readers).
- SSE contract (6 cases):
- 547 → 558 unit + acceptance (+11).
🔄 Migration
No user action needed beyond updating to v1.29.2. The next 🌐 Scan will run both phases.
Verification
$ npm run test:ci
# 553 / 553
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.29.2
# Manual smoke — both phases should now emit:
$ curl -sN "http://127.0.0.1:4317/api/stream/scan?source=both&dryRun=1" \
| grep -E '^event:|"final":'
# event: start ← en-scanner
# ...
# event: done ← phase 1 of 2
# data: {"code":0,"counts":{...},"errors":0,"final":false}
# event: start ← ru-scanner
# ...
# event: done ← phase 2 of 2 (final)
# data: {"code":0,"counts":{...},"errors":0,"final":true}