From 088735b948d46896b8af30efcb0a2dc5d362b97f Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 08:27:41 +0800 Subject: docs(release): 全周期交接文档入库(含 ui-shadcn 迁移交付说明) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] 纯文档提交,无运行时逻辑。 [文档内容] 补齐此前各轮未入库的交接/验收文档:backend-auth/backend-domain/ domain-authorization-user(认证与授权域)、etf-recovery-release- handoff(ETF 修复 + ops 演练定稿与生产部署命令)、recovery-* 系列、 frontend/parent-ui-findings(UI 迁移上下文)、worker/integration 等, 以及本轮 docs/ui-shadcn-handoff.md(shadcn-svelte 迁移交接,含 Chart.svelte 契约、runes $state 踩坑记录与 375/768/1440 验证证据)。 [更新方案] 按主题分文;每份文档只记录可复现的命令、验证结果与语义边界, 不导出密钥或生产敏感路径。 [影响范围] 文档渠道:后续 leader/client 审阅入口;与代码提交一一对应便于回溯。 --- docs/integration-handoff.md | 163 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/integration-handoff.md (limited to 'docs/integration-handoff.md') diff --git a/docs/integration-handoff.md b/docs/integration-handoff.md new file mode 100644 index 0000000..c64a85c --- /dev/null +++ b/docs/integration-handoff.md @@ -0,0 +1,163 @@ +# docs/integration-handoff.md — integration repair handoff + +Last updated: integration repair phase completion. Ownership: backend/server + worker tests + contract alignment only. Parent owns live QA service on 8787, deployment, tunnel and service lifecycle (this agent never started/stopped/managed the service). + +## Verbatim commands + +```bash +# backend tests (all green) +cd /home/somhairle/projects/strategy-lab/server && cargo test +# -> 46 passed; 0 failed + +# frontend tests/check/build (all green) +cd /home/somhairle/projects/strategy-lab/frontend +npm run test # 21 vitest passed +npm run check # 0 errors / 0 warnings +npm run build # production build in frontend/dist + +# worker tests (all green; project venv required for backtrader/akshare) +cd /home/somhairle/projects/strategy-lab +. .venv/bin/activate && python -m pytest tests/worker -q +# -> 35 passed + +# parent black-box QA gates (needs live 8787 service) +. .venv/bin/activate && QA_ADMIN_EMAIL=... QA_ADMIN_PASSWORD=... python scripts/qa_api.py --market +``` + +## Changed contracts (this repair) + +1. **Run claim atomicity — the stall root cause (server/src/jobs.rs)** + - `move_claim` now performs the rechecks (account active, dataset readiness, quota) AND the atomic transition `queued -> running` **with `container_id` set inside the same transaction** (`UPDATE ... SET status='running', started_at=?, container_id=? WHERE id=? AND status='queued'`). A claimed run always carries container identity; the old code set `running` without a container, then separately queried `container_id IS NOT NULL` (which was still NULL) and returned early forever — runs stalled in `running` with no worker. + - Returns `bool` (`claimed`). Only the claiming caller spawns the backtest worker. + - Regression test `queued_run_claim_is_atomic_and_reaches_terminal_state` proves: claim sets `running`+`container_id`, double-claim is a no-op, only a worker result with `status:'succeeded'` persists as succeeded, worker failure → `failed` terminal, disabled account → `failed` at dequeue without launching a worker. + +2. **Dataset JSON shape (server/src/datasets.rs)** + - `Dataset.request` is now an **object** (parsed from the stored canonical JSON), matching the frontend `DatasetRequest` usage. + - Dataset `name` is optional/empty-tolerant; when blank the backend auto-generates a descriptive name (instruments + date range) and persists it. + +3. **Coverage/preview shape (jobs.rs ingest) — frontend-expected shapes** + - `GET /datasets/:id/preview.coverage` is now an **array of per-instrument rows** built from manifest objects: `{instrument, market, asset_type, requested_start, requested_end, actual_start, actual_end, row_count, warnings}` (frontend `CoverageEntry`). Works for both fresh fetch and stale/fallback worker previews. + +4. **Capabilities shape (server/src/main.rs)** + - `GET /capabilities.fields` → objects `{code,label,raw}` (raw=true only for `adj_factor`) so the field checkboxes render; `adjustments` → objects `{code,label}` (`none/qfq/hfq` → 不复权/前复权/后复权). `frequencies`/`asset_types` stay string arrays. + +5. **Sessions (server/src/auth.rs)** + - `GET /auth/sessions` items now include **`current: bool`** (caller's live session) so the UI can label 当前会话 and guard self-revoke. + - `AuthUser` carries `session_id`; both real extractor and the auth/admin test fixtures updated. + +6. **Instrument search** + - `GET /instruments` `source` values are honest and frontend-aligned: `"catalog"` (items found) or `"none"` (empty/failure with `"status":"unavailable: ..."`). No fake successes. + - Added a bounded in-process identity cache (TTL 300s, max 128 queries) for catalog search results, so repeated queries reuse the actual provider item payloads instead of spawning a container per keystroke. Cache is transparent to callers. + +7. **Network errors stay honest** — search/fetch failures surface as `status: unavailable: ` / `error` fields; nothing silently degrades to empty success. + +## Second repair round (parent-confirmed follow-ups) + +8. **Doubled Layout in project pages (frontend)** + - Root cause per parent browser findings: `App.svelte` already wraps all protected routes in ``; `ProjectPage.svelte` wrapped its tabs in a second `` → two complete sidebars. Removed the inner wrapper (page props preserved: data/strategy/backtest/results tabs render as before inside the single App shell). + - Regression check `frontend/src/lib/layout.test.ts` (`npm run test`, 22 tests) asserts ProjectPage never nests `", "exec"), ns)`. Transport-level isolation is real Docker; no fake Python sandbox. + - Regression tests (`tests/worker/test_backtest.py`): `test_strategy_module_imports_visible_in_methods` (module-level `os` used in `__init__`, `math` used in `next` — must reach actual execution); `test_strategy_genuine_nameerror_still_fails` preserves honest `runtime_error` reporting of genuine errors. +11. **Cancel race / empty `failed` after cancel (server/src/jobs.rs)** + - Root cause: kill container ran BEFORE the guarded state update, and a racing worker-failure finalize wrote `failed` with an empty error. + - New order in `signal_cancel`: persist the cancellation intent atomically first (`WHERE id=? AND status='running'` single guarded transition with `error='cancelled by user'`), only then kill the specific container by name. `finalize_run` transitions stay guarded; lost cancel races never overwrite genuine success/failure. `finalize_run` failure path now guarantees a non-empty terminal reason. + - Regression tests: `cancel_race_never_produces_empty_failed_run` (25 interleavings of concurrent signal_cancel × finalize_run — every outcome must be `cancelled` with the honest reason or `failed` with a non-empty reason, never empty), `timeout_marks_failed_with_reason_not_empty` (real `runner_timeout` message must be persisted, not emptied). `signal_cancel_never_rewrites_terminal_states` retained. +12. Parent QA shows cancel API path exercised — combined with the guard this yields HTTP-visible `cancelled` status immediately after cancel. + +13. **Bounded restart during active Docker backtest (worker.rs / main.rs)** + - Observed by parent live: restart during an active backtest blocked the systemd unit until `TimeoutStopSec=90` forced SIGKILL; journal shows the docker runner child surviving final-sigterm. + - Empirical check on this host (not the live unit): a directly signalled attached `docker run` CLI (default sig-proxy) exits quickly on this host, so the app-level lifetime of the runner child and the CLI's signal proxying were the remaining app-side causes. + - Fixes: + - `docker run` now carries `--sig-proxy=false` immediately after `--rm` (`docker_args`), so a runner CLI can never relay signals into the running container; container lifetime is governed exclusively by the app's exact-name cleanup (cancel/timeout at `kill_container`, and `cleanup_orphan_containers` at next start — unchanged semantics). + - The runner child process is created with `kill_on_drop(true)`: when the jobs task future is dropped at shutdown, the docker CLI child is reaped instead of lingering in the cgroup. Focused regression `runner_child_is_reaped_when_the_job_future_is_dropped` uses a stub runner that ignores SIGTERM, starts an execution as a detached task, aborts the task, and asserts the child is gone within 3s. + - `main()` now handles SIGTERM/SIGINT with `axum::serve(...).with_graceful_shutdown(...)` under a 10-second drain budget, then exits — bounded unit stop; the jobs loop is not aborted mid-state-machine, so terminal-state correctness (running interrupted runs → failed with reason at next startup, queued resumable, cancelled preserved) is preserved exactly as parent verified 8/8. + - Not touched: service unit, TimeoutStopSec policy, credentials, tunnel, services — parent owns live QA. Live restart timing NOT exercised by this writer; parent will time via qa_restart.py. + +14. **CRITICAL premature-exit regression fix (server/src/main.rs)** + - Parent isolated-report confirmed the previous revision wrapped the entire `axum::serve` in `timeout(10s)` → server exited ~10.0s after STARTUP without any signal (isolated probe evidence: `artifacts/qa/supervisor-live/shutdown-premature-exit.json`). Live QA was NOT restarted onto the bad binary. + - Narrow fix: the 10s drain budget now starts ONLY after the shutdown signal. `main()` uses `tokio::select!` between (a) `axum::serve(...).with_graceful_shutdown(wait_shutdown_signal())` and (b) an arm that first awaits `wait_shutdown_signal()` and THEN sleeps 10s — arm (b) cannot fire before a signal, so an un-signalled server stays up indefinitely; a signalled one exits within ≤10s afterwards. Bounded post-signal drain + exact container cleanup semantics unchanged. + - Executable regression `probe::server_survives_past_10s_then_bounds_sigterm_exit` boots the actual isolated binary with isolated tempdir DB/DATA_DIR/port and a synthetic frontend dir, asserts `/api/health` up and — critically — STILL ALIVE at 12s with no signal, then sends SIGTERM to the exact PID (`kill -TERM `, no name scans/group ops) and requires a bounded exit with `/proc/` gone. + - RED/GREEN verified: RED on the current (buggy) binary — `cargo test --bin strategy-lab-server server_survives` failed with "premature-exit regression: server died ~10s after startup without any signal"; GREEN after the fix (12.5s runtime). + - Stub-process test hardening in `worker.rs`: unique `tempfile::tempdir_in("/tmp/opencode")` stub path + unique pid file (was a fixed `/tmp` filename + process-name scan), stub ignores SIGTERM then `exec sleep 500` so the traced PID IS the sleep process (`/proc//cmdline` assert) and `kill_on_drop`'s SIGKILL reaps it — no orphan grandchild; exact-PID supervision via `/proc/` existence only (post-run check found no lingering `sleep 500`). + +15. **Browser-QA regressions round 4 (live browser findings)** + - `GET /api/instruments?q=600000` → HTTP400 plain text `invalid type: map, expected option`. Cause: the query extractor was `Query>`, which rejects any non-empty query string. Fixed to a plain `HashMap` (accepts absent and present params); adjacent extractors audited — the only other `Query>` usage was the compile-only probe handler; runs list already used plain maps. Backend route regression `probe::instruments_query_with_q_is_json_200` (RED captured on the reverted handler: HTTP400 with exactly the live message; GREEN after fix) — asserts JSON 200 `items/source/status` and no-`q` also 200. + - `POST /datasets` with absent/blank `name` → HTTP422 `missing field name`. Cause: `DatasetRequest.name: String` required. Fixed: `#[serde(default)] pub name: Option`; absent or blank auto-generates the persisted descriptive name (SPEC/UI permitted). Regression `probe::dataset_request_allows_missing_or_blank_name` (deser missing/blank/provided). Live 422 evidence from parent remained the RED receipt because `Option` is implicitly skippable in serde — the required `String` variant failed to compile against the new test, itself honest RED. + - Frontend DatasetWizard captured submit failures into `submitErr` but never rendered them. Fixed: visible `role="alert"` banner `数据请求提交失败:…`. Regression `src/lib/wizardError.test.ts` asserts the template (extracted via `?raw`) renders `{submitErr}` inside an alert — never fake data, no test weakening. + - Error contract kept structured: instruments still returns JSON `{items,source,status}`, datasets still `{error:{code,message,...}}` JSON errors. No catalog/data faked; no tests weakened. + +16. **Mobile 375px overflow repair round 5 (live browser DOM findings)** + - Parent probe (`artifacts/qa/supervisor-live/mobile-overflow-probe.json`, DOM-authoritative) measured document scrollWidth 532→552 at a 375px viewport; sidebar/main 552px; wizard cards 523px. Root causes fixed in the relevant real CSS — nothing hidden globally (`overflow-x:hidden` explicitly absent, asserted by the regression). + - `frontend/src/components/Layout.svelte`: `.layout` mobile media uses `grid-template-columns: minmax(0, 1fr)` (must be able to shrink below cell min-content); `.content` gets `min-width:0` (grid min-content propagation caused the 552px inflation); `.projctx` (project tab row) now `flex-wrap: wrap` with `.projname` `overflow-wrap: anywhere`; sidebar row stays `min-width:0`. + - `frontend/src/app.css`: ≤900px media block collapses `.grid2`/`.grid3` to `minmax(0,1fr)` — the dataset wizard grid3 + min-width labels measured min-content 523px; `table.data th/td` switch to `white-space: normal; overflow-wrap: anywhere` on narrow viewports (dataset `.kv` and provider tables stop inflating min-content; truly wide tabular blocks keep their existing local `.scrollx` scroll container so content stays fully accessible); `.btn` allows wrapping; `.banner` and err lists use `overflow-wrap: anywhere` so long real provider error messages (eastmoney/tencent URL payloads) wrap instead of inflating; `.card/.stack/.row/.spread/.banner` get `min-width:0`. + - Regression `frontend/src/lib/mobileCss.test.ts` reads the actual source files (CSS `?raw` vitest import is empty-stubbed, so disk reads with `node:fs` + `fileURLToPath(process.cwd())` are used) and asserts: minmax(0,1fr) media layout, `.content` `min-width:0`, `.projctx` wrap, `.grid2/.grid3` collapse, table wrap, banner `overflow-wrap:anywhere`, and that NO `body/html overflow-x:hidden` is introduced (accessibility guard), plus DatasetCard retains `.scrollx` local scrolling. + - Results: 27 frontend tests pass (7 files), `npm run check` 0 errors/0 warnings, `npm run build` OK; built `dist/assets/*.css` verified to contain the minmax(0,1fr) and overflow-wrap rules. Parent owns the live 375px recheck; screenshot expansion caveat recorded (DOM authoritative). + +17. **Strategy tab numeric generation contract (live pageerror regression)** + - Parent live run (`artifacts/qa/supervisor-live/browser-strategy.json`) failed immediately: pageerror `c(...).slice is not a function` and a blank main — backend `projects.rs` keeps `draft_generation` as an integer (i64) while the frontend typed it `string` and called `.slice(0,8)` on it. The sidebar stayed blank (`当前项目 加载中…`). + - Contract fixed consistently, backend integer APs **retained**: + - `frontend/src/lib/types.ts`: `Project.draft_generation: number`, `AIAssist.base_generation: number`. + - `frontend/src/lib/client.ts`: `putDraft/postRestore/postAIAssist/postAIAccept` take `expected_generation: number` (matches backend `i64` `PutDraft`, `RestoreReq`, ai assist/accept parsers — audited). + - `frontend/src/lib/draftGuard.ts`: `ServerDraft/ConflictResolver/retryPlan` generation numeric — optimistic conflict guards unchanged in behaviour (stale 409 → compare-and-choose, retry with server generation) — and `draftGuard.test.ts` numerals updated. + - `ProjectStrategyTab.svelte`: `baseGeneration` numeric; all `draft_generation.slice(0,8)`/`base_generation.slice(0,8)` removed (display full numeric 草稿代; strict numeric `!==` still drives the AI stale banner, which retains its honest warning without crashing). + - `ProjectsPage.svelte`: plain numeric rendering instead of `.slice`. + - Regression tests (`frontend/src/lib/strategyTab.test.ts`) exercise the **actual numeric API shape**, not source strings: the real component is mounted in jsdom (vite `resolve.conditions: ['browser']` enables svelte's client build in vitest) with numeric `draft_generation` and asserts editor content + `草稿代 3` render without pageerrors; the real `putDraft` client sends numeric `expected_generation` (verified over the wire body) and accepts numeric response; 409 maps to `ApiError code stale_generation` (retry flow intact). + - RED/GREEN: with one `.slice` temporarily reintroduced in the template the mounted test failed with exactly `get(...).slice is not a function` (equivalent of the live pageerror); GREEN after the numeric contract. + - Adjacent version contract audited works: `/projects/:id/versions` newest-first (ORDER BY created_at ASC + reverse) matches frontend compareTwoVersions(i=新, prev=i+1=旧); `/projects/:id/versions/:vid` returns `{code}` owner-checked so both compare buttons (draft compare and version-pair compare) function; restore passes numeric generation and backend `RestoreReq` expects i64 — consistent. + - Results: 30 frontend tests (9 files), svelte-check 0/0, `frontend/dist` rebuilt. Backend untouched (all 54 still green; debug build re-verified). NOT browser-verified by this writer — parent owns live recheck; no services/tunnel/credentials/commits. + +## Final state of this round +- frontend: 30 tests / 9 files green; svelte-check 0 errors / 0 warnings; build OK +- backend: unchanged (54/0); debug build verified +- No services, tunnel, credentials, parent QA scripts, or commits touched. +- `cargo test` (server): 54 passed / 0 failed (RED→GREEN on both new backend regressions; RED receipt for the 422 is parent's live `artifacts/qa/supervisor-live/browser-workflow.json`) +- `cargo build` (debug) + `cargo build --release`: OK, binary rebuilt, NOT launched +- frontend: 23 tests / `npm run check` 0 errors 0 warnings / `npm run build` OK +- No services, credentials, tunnel, Docker daemon, profiles, or commits touched; parent verifies live browser after builds. + +## Files touched +- server/src/jobs.rs (claim fix + preview/coverage rows + regression tests) +- server/src/datasets.rs (request object, auto name) +- server/src/main.rs (capabilities objects, instruments source; bounded SIGTERM/SIGINT graceful shutdown with 10s drain) +- server/src/worker.rs (--sig-proxy=false, kill_on_drop(true), execute_docker_named injection, focused shutdown regressions) +- server/src/auth.rs (AuthUser.session_id, sessions `current`, test fixture seed) +- server/src/admin.rs (test fixtures aligned; missing FK dataset seed) + +## Notes for parent live QA +- Restart the parent-managed 8787 service to pick up these changes (rebuild binary first: `cargo build --release --manifest-path server/Cargo.toml` as needed). +- `frontend/dist` is already rebuilt for serving static SPA. +- `scripts/qa_api.py --market` gates all verified logically consistent with the shapes above; run them against the restarted service for final evidence in `artifacts/qa`. +- No secrets read or printed; API key remains server-only env `OPENCODE_GO_API_KEY`. +18. **Results accounting repair (parent independent verification round)** + - Parent independently verified the numeric generation fix (frontend 30 tests, check 0/0, built; live authenticated browser: editor autosave/version/diff/real backtest/equity SVG/drawdown/mobile 375 all pass; evidence `artifacts/qa/supervisor-live/browser-strategy.json`). + - Real finding 1 — trade_count semantics: worker `backtest.py` counts CLOSED ROUND TRIPS while `ProjectResultsTab.svelte` labelled it 交易笔数 with a definition claiming fills removed. Fixed honest & consistent: results label is now 平仓回合数, defined as 已完成的开仓并全部平仓的完整回合(1 回合 = 1 次平仓),不含撤单与未平仓的单笔成交; `metricsLabels()` (fed to run comparison tables) renamed from 交易次数 to the same 平仓回合数 so comparisons match; `BacktestMetrics.trade_count` doc-comment states closed round-trip semantics. + - Real finding 2 — fill value cost basis: worker recorded `abs(ex.value)` per fill; Backtrader's `ex.value` for SELL orders reports the position cost basis, NOT sale proceeds (live evidence: identical 659.66 for a real buy and sell at different prices). Fixed `worker/backtest.py` to record actual turnover `abs(ex.size * ex.price)`; commission unchanged (rate × turnover stays consistent because Backtrader's comission applies per the same scheme); audit confirmed equity/cash/metrics code untouched (broker accounting uses executed price + commission independently of displayed fill value). + - Regression: `tests/worker/test_backtest.py::test_fill_value_is_executed_turnover_not_cost_basis` — buy at bar 3 + sell at bar 10; asserts each fill value == quantity×price, commission == value×rate, buy/sell prices differ so turnovers differ (RED verified: reverting to `abs(ex.value)` fails at the turnover assertion), and trade_count == 1 (still closed round trips). Full worker suite: 38 passed. + - Result metrics desktop layout: `.metric-grid`/`.metric`/`.mlabel`/`.mvalue` (plus `.cols-2`, `.table-wrap`) had NO stylesheet definitions at all — six stacked metrics with large blank space. Added shared layout CSS in `app.css` (auto-fit minmax grid, tabular-nums values) and a ≤900px `cols-2` collapse; all consumers (results card, UsagePage metric rows) inherit; no unrelated UI touched. New regression `frontend/src/lib/resultsAccounting.test.ts` mounts the real results tab (jsdom, browser build) asserting: 平仓回合数 label + honest definition rendered, trade turnover rows show two distinct values (1020.00 buy / 1040.00 sell), six `.metric` tiles, comparison labels contain 平仓回合数 and not 交易次数, and the source contains no leftover 成交次数 wording. + - CodeMirror jsdom noise: suite emitted unhandled `textRange(...).getClientRects is not a function` while passing. Narrow geometry shim `frontend/src/test-setup.ts` (wired via vite `test.setupFiles`) implements ONLY the missing `Range.prototype.getClientRects` returning one empty rect (CodeMirror derives null geometry); no arbitrary error suppression. + - Results: frontend 33 tests / 10 files green with zero unhandled errors, svelte-check 0/0, dist rebuilt; worker pytest 38/0 including the new accounting regression (RED/GREEN). Backend untouched (54/0 previously verified; no rebuild). Parent owns worker image rebuild + live recheck; no services/Docker/credentials/tunnels/commits. +19. **Sidebar current-project repair + test noise + .dockerignore** + - Parent live finding: sidebar 当前项目 stayed 加载中… even with the project tab loaded — `projectOf()` only read the (empty) projects store and nothing ever fetched for a deep link; ProjectPage/ProjectDataTab did their own loads without feeding the sidebar store; the sidebar's `singleCache` was non-reactive ($state-less) so even cache hits never updated the derived name. + - Fix (frontend only): `listsStore.svelte.ts` — single placement-only $state array; NEW `projectsStore.ensure(id)` dedupes concurrent fetches (inflight map), `upsert` writes reactively, `get/projectOf` read only the $state array so the backtick-name update is instant on navigation or in-tab bump; 404/403 marks the id invisible in a $state record (`projectInvisible`) so the sidebar turns into an honest `不可见项目` instead of an endless 加载中…, while retry-able failures (network, 500) are never remembered as invisible. Success chain preserved: any in-tab save/restore/AI bump updates the sidebar in place without extra fetches. + - Cross-user stale-leakage guard: `Layout.logout()` calls `projectsStore.clear()` dropping cached project names AND invisible marks, so the next session on the same browser starts clean (no previous user's project names, no stale "cannot see" states). + - Layout fix: `$effect` on the project route fetches the missing project once via `ensure` (deep links + first navigation, exactly one direct /projects/:id GET, deduped). Non-visible ids render `不可见项目`; retryable errors keep honest `加载中…`. + - Regression `frontend/src/lib/sidebarCurrentProject.test.ts` (mounts the real Layout, 4 tests): deep-link name resolution with exactly ONE project fetch; tab-to-tab navigation of the same project resolves from cache (no refetch) and bumps update the name in place; 404 → `不可见项目` with retry guard (exactly one fetch across remounts) plus logout clearing marks/caches (cross-user leak check); transient network error is NOT remembered invisible (honest 加载中, later retry resolves and renders). Bug found while writing these: invisible marks initially in a non-reactive Set — fixed to a $state record so the derived sidebar state updates. + - ECharts jsdom noise eliminated at the right boundaries, none suppressed: (a) `Chart.svelte` passes explicit real sizes to `echarts.init` (`host.clientWidth || 640 / host.clientHeight || height`) so jsdom's zero-layout no longer prints the `[ECharts] Can't get DOM width or height` warning — in a real browser the measured client size is used identically and ResizeObserver still handles resize; (b) `src/test-setup.ts` replaces `HTMLCanvasElement.getContext` ONLY because jsdom's native one is a throwing stub — a minimal 2d-context providing `measureText` (the single canvas usage behind zrender text metrics); SVG chart rendering itself stays real and is still asserted by regression tests; (c) previous `Range.getClientRects` shim retained for CodeMirror. + - Root `.dockerignore` added, tailored to `worker/Dockerfile` (which only COPYs `requirements-worker.txt` and `worker`): whitelist `**/*` + `!requirements-worker.txt` + `!worker` + `!worker/**` (last-match-wins), plus local-cache guards (`worker/__pycache__`, `worker/**/__pycache__`, `*.pyc`, `.pytest_cache`, `.venv`). Verified by reimplementing moby's patternmatcher semantics (per-path last-match-wins, `**` matching zero-or-more dirs incl. dot entries) walking the real tree: 24k files pruned to exactly {requirements-worker.txt, worker/*}; zero Dockerfile-needed files dropped; no build-context leakage of artifacts/, server/, docs/, frontend/, .venv, .pytest_cache. Docker build itself left to the parent as required. + - Outcomes (this round's actual results): frontend 37 tests / 11 files green with ZERO unhandled/stderr noise (先前 docs/tests 33 → +4 sidebar tests); svelte-check 0 errors / 0 warnings; `frontend/dist` rebuilt. Worker tests unchanged from item 18 (38/0). Backend untouched (54/0 previously verified). Parent owns worker image rebuild + live recheck; no services/Docker builds/credentials/tunnels/commits. +20. **Mobile results-viewport overflow (chart SVG sticky width) — live root cause + repair** + - Parent repeatable live failure reproduced and root-caused with real browser evidence (`artifacts/qa/supervisor-live-oce/overflow-probe.json` + before-gpxs): navigating desktop→results then resizing to 375 → `document.scrollWidth = 1133`; fresh 375 deep link was already clean (375). The only overflowing DOM was the ECharts chart subtree (svg/rect/g/path at width 1101px) — no table, layout or card element overflowed, so nothing was hidden; tables already keep their scroll container (`.table-wrap`, overflow-x auto). + - Root cause: `Chart.svelte` initialized echarts with an explicit measured width, and ECharts KEEPS the explicitly passed size across `chart.resize()`; the ResizeObserver handler called `chart.resize()` with no measured size, so after the desktop→mobile viewport change the SVG kept the desktop width (the desktop graph's 1101px width overflowed the 375 viewport). + - Fix (frontend only, root cause, not a mask): the ResizeObserver callback now re-measures the host on EVERY resize event and passes the measured size explicitly: `chart.resize({ width: host.clientWidth || undefined, height: host.clientHeight || undefined })`, skipping degenerate zero-layout resizes. Real charts stay fully rendered and responsive; jsdom real-SVG chart verification retained. + - Regression `frontend/src/lib/chartResize.test.ts` (2 tests) drives a real echarts SVG chart in jsdom: init/fallback width is measured (640), the SVG follows the host width through a 900px "desktop" resize AND shrinks 900→340 on the "mobile" resize (RED verified: with `chart.resize()` restored it fails on the sticky 900px SVG), and a zero-layout host callback is a harmless no-op. + - Live verification (`artifacts/qa/supervisor-live-oce/chartcam-checks.json`, verdict PASS, screenshots chartcam-*.png; used QA venv Playwright with the private account file, credentials never logged): desktop→375 resize settled: scrollWidth 375 == clientWidth 375, maxElementRight 375, chart SVG 1101→311; fresh mobile deep link: 375/375; mobile→desktop: 1440/1440 (SVG 1101); desktop→mobile again: 375/375 (SVG 311); zero pageerrors in all four cases. The earlier probe's "FAIL" verdict string was my own probe's flawed predicate (desktop SVG 1101 > 375 bench); the artifact as stored is PASS with the corrected invariant scrollWidth==clientWidth and svgWidth<=clientWidth per case. + - Outcomes: frontend **39 tests / 12 files** green (37→39, +2 chart regression), zero unhandled/stderr noise, svelte-check 0/0, `frontend/dist` rebuilt; charts resize, no overflow hiding, assertion `width == scrollWidth` preserved and now passing on the exact parent failure path. Worker/backend untouched (pytest 38/0, cargo 54/0 previously). Parent owns the live sidebar re-run + Docker rebuild; no services/ports/tunnel/production/supervisor changes; single worker, no parallel writers. -- cgit v1.2.3