diff options
95 files changed, 18567 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca3cd38 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Docker context guard for the worker image build +# (`docker build -f worker/Dockerfile -t strategy-lab-worker:local .` from the +# project root). The Dockerfile only COPYs requirements-worker.txt and worker/, +# so everything else stays out of the build context. Rationale: a full-context +# build previously pushed ~2.5 GB into the builder, including private QA +# evidence artifacts/, server sources, docs and caches that must never enter +# an image layer derived context. +# +# Whitelist semantics (moby patternmatcher, last matching pattern wins): +# `**/*` excludes every path (including dot entries; `**` also matches zero +# directories), and the negations re-include exactly what worker/Dockerfile +# COPYs: requirements-worker.txt and the worker/ tree. + +**/* +!requirements-worker.txt +!worker +!worker/** + +# never ship caches inside the worker tree even if created locally +worker/__pycache__ +worker/**/__pycache__ +worker/*.pyc +worker/**/*.pyc +worker/.pytest_cache +worker/**/.pytest_cache +worker/.venv +worker/**/.venv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7112985 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +.env +.env.* +!.env.example +.venv/ +node_modules/ +frontend/node_modules/ +frontend/dist/ +server/target/ +data/ +*.sqlite +*.sqlite-wal +*.sqlite-shm +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm +server-data/ +*.private.json +*.private.env +__pycache__/ +.pytest_cache/ +*.pyc +artifacts/**/request.json +artifacts/**/response.json +playwright-report/ +test-results/ +# Local evidence, third-party skills, and machine-specific agent settings +artifacts/ +.opencode/ +opencode.json +.tmp* +*.log +*.pem +*.key +*.p12 +*.pfx +coverage/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2f5857 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# 策研 Strategy Lab + +研究标的、采集行情、编写 Python 策略并执行回测的内部研究项目。 + +## 当前状态 + +开发中,尚未通过完整产品验收。当前源码包含尚未部署的 ETF 数据源修复;源码 main 分支不代表线上版本。行情源可能不可用,跨来源成交量单位仍需核对,UI 与完整浏览器流程正在整改。不可据此声称策略盈利或数据完整。 + +## 结构 + +- `frontend/`:Svelte 5 + TypeScript、CodeMirror、ECharts。 +- `server/`:Rust API、账户权限、数据集及任务管理。 +- `worker/`:行情采集、标准化与 Backtrader 回测。 +- `tests/`:Python 测试;前端及后端另有各自测试。 + +## 开发检查 + +```sh +python -m venv .venv +.venv/bin/pip install -r requirements-worker.txt +.venv/bin/python -m pytest tests/worker -q +npm --prefix frontend ci +npm --prefix frontend run check +npm --prefix frontend test +npm --prefix frontend run build +cargo test --manifest-path server/Cargo.toml +``` + +运行服务需要独立配置与隔离执行环境。不要把生产数据库、账户凭据或 Docker 管理接口暴露给公开开发会话。 + +## 协作 + +源码托管在 https://git.somhairle.bid/strategy-lab.git/ 。开发使用独立工作副本及功能分支;每个工作副本仅一个写入会话。OpenHands 开发环境与 OpenCode 编码任务应遵循这一约束。合并前提交测试证据;上线前单独完成真实数据、浏览器流程、视觉和失败恢复验收。禁止自动将提交部署到生产。 + +密钥、本地模型配置、数据库、行情缓存、构建产物、第三方技能副本和内部操作记录不纳入公开仓库。公开可读不构成对行情数据、第三方依赖或项目源码的额外再许可授权。 diff --git a/RELEASE_SCOPE.md b/RELEASE_SCOPE.md new file mode 100644 index 0000000..373d263 --- /dev/null +++ b/RELEASE_SCOPE.md @@ -0,0 +1,38 @@ +# Release scope amendment — supersedes conflicting SPEC.md clauses + +User explicitly authorized renewed full development and deployment after stop. Final target is https://fin.somhairle.bid (the supplied on-host Cloudflare guide confirms .bid; earlier .bin failed public DNS). Parent handles Tunnel edits only after QA. Do NOT touch Cloudflare, hosts, proxy, Docker daemon or unrelated services. Working directory MUST be /home/somhairle/projects/strategy-lab, use absolute file paths and explicit cwd for tool commands. Earlier jobs were interrupted; inspect partial files and continue. + +## Full user and permission model +Deliver real multi-user system; default invitation-based registration so code-running accounts are trusted during POC. No open self-signup in POC. Roles admin and member, explicit active/disabled account state. Object-owner checks apply to every project/draft/version/data request/manifest/run/result/AI conversation. Admin manages users and quotas; admin does NOT automatically access private strategy source or payloads. Admin may inspect aggregate ops/errors safely. Shared public market objects never imply shared project visibility. + +Add API (compatible with prior contract): +- POST /auth/register {invite_token,name,email,password}: consume single-use expiring invitation transactionally; Argon2 password; return user and session. Role assigned by invite/admin, never client role input. +- GET /auth/me user includes role,active; login checks disabled and throttled failed attempts. Secure cookie in production, HttpOnly, SameSite, hashed session storage and expiration. +- PATCH /auth/profile {name}; POST /auth/password {current_password,new_password}: verify current then revoke other sessions. +- GET /auth/sessions returns own sessions sanitized; DELETE /auth/sessions/:id revokes own session. Logout invalidates server token. +- GET /admin/users; PATCH /admin/users/:id {active?,role?,daily_run_limit?,ai_enabled?}; prevent last active admin demotion/disable; disable revokes sessions and blocks jobs/AI. +- POST /admin/invitations {email?,role:'member',expires_hours?} -> {token,expires_at} display token once; hash in DB. GET /admin/invitations sanitized list; DELETE /admin/invitations/:id revoke. +- POST /admin/users/:id/reset-password -> {reset_token,expires_at} single-use short-lived hash; POST /auth/reset-password {token,new_password}; reset revokes sessions. No fake email delivery; UI says admin-issued recovery link. Optional SMTP can be future configured, not prerequisite for honest recovery UI. +- GET /admin/audit => sanitized security audit events (actor/action/target/time/status; no password/key/code). +- AI permission is explicit per account, default admin-only internal POC until upstream third-party service permission. No payments/resale in this release; ledger is metering, not claim real charges. +- quotas enforced transactionally server-side for data requests/run concurrency/model request budget, not merely disabled frontend controls. + +## UI additions +Login/register-by-invite/password-reset routes. Account profile/security page, password change and own session management. Admin-only users/invitations/recovery/audit page. Consistent loading/empty/error states, no hidden dead ends. Allow invite token from URL, but never put passwords in URLs or logs. + +## API/worker contract clarifications +Cache share only exact canonical source/market/asset/symbol/frequency/adjustment/params/normalization version and immutable payload. Store interval coverage; exact duplicate reuse required; overlapping coverage reuse where API/data adapter supports it, limitations documented. New user dataset has own id but references same raw/normalized object. Dataset manifest hash stable content identity independent of request/user IDs and fetch timestamp if data content unchanged. Source revision changes create new objects. Previous runs pin existing objects. +Backend must transform worker relative object paths into immutable stored paths and mount only files selected for that run; prohibit symlinks/path traversal. Data worker and backtest worker differ in network permission. No fake catalog; data endpoint failures visible. Complete natural workflow with chosen instruments and fields, not a hardcoded demo. + +Store strategy source hash, source version, parameters, data manifest hash, engine/dependency versions, execution assumptions and seed where relevant. Re-run original MUST use same source/config/dataset, not current draft. Data warnings acknowledged explicitly. One run worker initially and separate bounded data fetch concurrency, to protect host. + +## Production delivery +Local service should bind loopback:8787 behind existing Tunnel HTTPS. Canonical origin env https://fin.somhairle.bid, secure cookies when set, enforce exact trusted origin for writes (no broad trust of arbitrary X-Forwarded headers). Health public contains no paths/secrets. Frontend built static served same origin. +Backtest isolation is defense-in-depth Docker for trusted invited accounts, not claim hostile public sandbox; no register-without-invite. Cannot mount host secrets, Docker socket, arbitrary paths, or network in user-code runner. CPU/memory/pids/runtime/output limits and cancel/restart cleanup tested. Do not run user code directly on host to pass tests. +Provide production build, install/run script and systemd --user service instructions (parent integrates). Runtime credentials outside repo mode0600, .env.example names only. Respect current service ports. Application startup cannot fail merely because provider API is temporarily unavailable; AI surfaces bounded errors. + +## Context efficiency +OpenCode uses model strategy-go/glm-5.3-flash, model context/input 1,000,000, automatic compaction reserve 744,000 (effective threshold 256,000). Use short build outputs, read relevant files only, keep docs/<area>.md handoff before long pauses. Parent may invoke /session/:id/summarize (same compaction as /compact) around 256K. No broad home scans. Do not install other agents or change model. + +## Evidence +TDD first; independently runnable tests per area. Full real browser check and real data/model call required before completion. No fake data except clearly labeled tests. Primary output must be WORKING CODE, not further plans. @@ -0,0 +1,53 @@ +# 策研 Strategy Lab — internal proof of concept + +User approved implementation, not further brainstorming. Deliver working end-to-end web product, real data and model integrations, tests, local running service. No public deployment or paid resale in this phase. No fake market/AI/backtest output. Synthetic test fixtures must carry _synthetic=true and never be production fallback. + +## Chosen architecture +Rust/Axum server, SQLite WAL, immutable on-disk data objects, Svelte 5 + TypeScript + Vite static UI, Python AKShare data worker and Backtrader strategy worker. No Node/Python HTTP service at runtime; one active worker task initially. Docker execution isolation (internal trusted-user POC only, not claim public hostile-code safety). Server binds 127.0.0.1:8787. Server serves frontend/dist. All work under this project. No global Hermes/config changes. Do not print credentials/env or inspect home secrets. All model requests must use provided OPENCODE_GO_API_KEY environment only. Never commit secrets, create public GitHub repos, or upload user code elsewhere except explicitly requested provider call. + +## Product flow +User logs in -> creates project -> chooses instruments, range, fields/frequency/adjustment -> previews prepared data and provenance -> edits Backtrader Python Strategy -> saves immutable revisions -> runs experiment -> inspects equity/trades/logs -> compares previous runs -> optionally asks AI for a proposed full source and unified diff -> accepts using optimistic draft revision guard. Every historical result ties to immutable code, dataset manifest, engine/config versions. Recovery from old revision creates a new draft, never rewrites history. Data requests are user-visible, public underlying market data deduplicated, each user's project/code/private objects ownership checked. + +## Scope +Daily bars only with visible capabilities, stock/ETF/index (index explicitly nontradable proxy and rejected for direct orders or labeled index-proxy research). User can search actual instrument identity; manually enter exact symbol with explicit market/type if catalog unavailable, never invented identities. One or multiple selected instruments stored in dataset; strategy receives named feeds, prices never substituted across symbols. Max 5 symbols, max 15 years for internal POC; backend validates. OHLCV required for engine, optional raw fields preserved; frontend explains derived indicators (SMA/RSI etc) are calculated in code with warmup, not fetched. Unsupported frequencies/fields rejected. Date coverage differences surfaced before running. No portfolio rankings/live trading/payments. Model usage ledger and budget/quotas present but no payment processor. + +## HTTP contract (backend owns; all routes prefix /api) +JSON errors {error:{code,message,details?}}. IDs UUID strings; timestamps ISO UTC. Server uses cookie session HttpOnly SameSite=Strict, bounded lifetime; writes require same-origin check where Origin supplied, reject cross-site Sec-Fetch-Site, JSON content type. No open registration; bootstrap user via CLI/env, passwords Argon2, random session token hashed in DB. API response objects no secret fields. +- GET /health public => {status,version,worker_available,ai_configured} +- POST /auth/login {email,password} => {user:{id,email,name}} and cookie +- POST /auth/logout; GET /auth/me => {user} +- GET /capabilities => {frequencies:['daily'],asset_types,adjustments,fields,limits,internal_only:true} +- GET /instruments?q=... => {items:[{symbol,market,asset_type,name,currency}],source,status}; query cache via AKShare catalog optional; explicit symbol entry fallback UI. +- GET /projects => {items:[Project]}; POST /projects {name,description?} => Project +- GET /projects/:id => Project; PATCH /projects/:id {name?,description?} +Project {id,name,description,draft_code,draft_generation,created_at,updated_at} +- PUT /projects/:id/draft {code,expected_generation} => Project; stale generation 409 +- GET /projects/:id/versions => {items:[Version]}; POST same {message} => Version (snapshots draft) +Version {id,project_id,code,hash,message,created_at,source:'manual'|'run'|'ai'|'restore'} +- POST /projects/:id/restore {version_id,expected_generation} => Project +- GET /datasets => {items:[Dataset]}; POST /datasets {name,instruments:[{symbol,market,asset_type,name?}],start_date,end_date,frequency:'daily',adjustment:'none'|'qfq'|'hfq',fields:[...]} => Dataset, async pending +Dataset {id,name,request,status:'pending'|'running'|'ready'|'failed',manifest?,error?,created_at,cache_hit?} +- GET /datasets/:id => Dataset; GET /datasets/:id/preview => {columns,rows,coverage,warnings}; ownership enforced. +Manifest {id,hash,objects:[{instrument,object_hash,path internal-only,provider,endpoint,params,akshare_version,fetched_at,schema_version,normalization_version,adjustment,requested_start,requested_end,actual_start,actual_end,row_count,columns,raw_object_hash,warnings}], immutable:true}; don't expose host paths to clients. +- GET /runs[?project_id] => {items:[Run]}; POST /runs {project_id,dataset_id,capital,commission,slippage,benchmark_symbol?,parameters?:{},acknowledge_warnings?:bool} => Run. Snapshots draft, links version id and manifest hash. Fail if dataset not ready. Defaults commission .0003/slippage .001 but user sees. Validate bounded numeric settings and code lengths. +Run {id,project_id,version_id,dataset_id,status:'queued'|'running'|'succeeded'|'failed'|'cancelled',config,created_at,started_at?,finished_at?,error?,result?} +- GET /runs/:id => Run incl result; POST /runs/:id/cancel => Run; POST /runs/:id/rerun {use_original_data:true} => new Run pinned original code+data+config; no secret fetching latest. +Result {equity:[{date,equity,cash,benchmark?}],orders:[...],trades:[{date,symbol,side,quantity,price,commission,value}],metrics:{total_return,annual_return,max_drawdown,sharpe?,trade_count,final_equity},logs:[string],engine:{name:'backtrader',version},warnings,elapsed_ms,peak_rss_kb?,data_manifest_hash}. Return undefined metrics as null, never NaN/Inf. Trade_count semantics documented. Next-bar fills. Record source model limitations e.g no liquidity/price limits fully modeled; don't call production investment simulator. +- POST /ai/assist {project_id,instruction,expected_generation} => {id,model,explanation,proposed_code,diff,base_generation,usage:{...},status}. Server calls https://opencode.ai/zen/go/v1/chat/completions model glm-5.3-flash, configurable base/model. Send only scoped code, data schema and trusted framework docs prompt. No auto-exec/provider tool execution. Parse fenced full python or JSON output robustly, retain failure ledger. No hardcoded fake suggestion. +- POST /ai/:id/accept {expected_generation} => Project and new version source ai; check owner/project/base generation, reject stale. +- GET /ai/usage => {items:[...],totals:{...},internal_poc:true}; measure real usage, do not invent price/cost. Budget per user request/day cap and input/output caps. Key server-only environment, no config endpoint exposes it. + +## Worker filesystem protocol +Docker image strategy-lab-worker:local built from worker/Dockerfile. Root project requirements/scripts local venv for tests/data probes. Python executable entry worker/main.py subcommands: +`python -m worker.main fetch --request /input/request.json --output /output` network enabled for data adapter only. Output /output/result.json {status:'ready',manifest,preview,cache_key}; data CSV/Parquet plus raw JSON actual source response. Main source current AKShare APIs, configurable request details; no silent different provider/adjustment fallback. AKShare failures reported. Precision/source units kept. Shared cache outside fetch jobs: backend content hashes output directory artifacts and copies/moves into data/objects; exact-key cache and overlap reuse where supported. Concurrent same-key work serialized/rechecked. Immutable old snapshots not overwritten. Raw data stored before normalized transform. Cold storage retention documented. +`python -m worker.main backtest --request /input/request.json --output /output` input {code,config,dataset_manifest,data_root:'/data'}; only dataset files mounted read-only. Output result.json above. Validate Strategy class presence with syntax check; exec ONLY in isolated strategy worker, never API. Backtrader mature engine. Record open orders/fills and cash correctly, no same-bar lookahead. `Strategy` receives feeds ._name instrument canonical identity. Minimum lot/T+1/suspension capabilities explicitly stated and enforced for supported stock/ETF where applicable or reject unsupported; don't silently imply fidelity. Synthetic deterministic accounting fixtures for tests. Runtime record real process RSS and elapsed. No fabricated real market data. +Docker runner: no Docker socket in worker, no provider/auth env, non-root uid, cap-drop=ALL, no-new-privileges, read-only root, tmpfs /tmp, pids-limit, memory/cpu limit, backtest --network none; write output only and read input/data only. Kill task by specific container id on timeout/cancel, no global prune. Restart running tasks become failed/interrupted, queued resumable. Control host API may use Docker socket; local/internal security limitation explicit. + +## Visual design +Chinese primary UI, product name 策研 / Strategy Lab. Modern calm light research workspace, near-white, slate text, restrained teal highlights, good whitespace, no noisy gradients/glass, no marketing dashboard filler. Sidebar + clear project tabs 数据 / 策略 / 回测 / 结果, global 数据集 / 实验记录 / AI用量. CodeMirror editor, ECharts line charts lazy loaded, real tables, contextual empty/loading/error states. Inputs labeled, keyboard focus, responsive 375px viewing and desktop editing. SF Pro system font then Noto Sans CJK SC fallback, avoid copying restricted font files. AI diff accept/reject and version compare readable (can use diff package). Autosave debounce with visible status, no stale response overwrite. Selection multiple prior runs shows aligned comparison and explicit differing conditions. No dead buttons, placeholder fake metrics or fake model content. Login first run should work via supplied credentials; never embed real passwords. + +## Acceptance +Tests first RED then GREEN per component. Backend ownership/auth/CSRF/bounds/versions/restore/run lifecycle/shared-cache/AI stale acceptance tests. Worker synthetic (_synthetic true) accounting, dates, adjustment/unit/raw retention and error-path tests. Frontend checks/build and Playwright real browser happy path plus error/version/diff tests. Live AKShare at least one real instrument fetched and inspected; use exact identifier/name verify. Complete real backtest (not fixture) displayed in browser. AI real call yields proposed code accepted as new version without overwrite. At least two user accounts cannot read each others objects. Cache repeated dataset proves shared physical object reused. Restart persistence and failed runs retained. No secret values in logs/source/build. Measure idle process RSS and one run peak/time. Document limitations honestly, no public-readiness claim. All acceptance evidence in artifacts/qa with actual output. + +## Work boundaries +backend implement server/ plus docs/backend.md only; worker implement worker/ tests/worker/ requirements-worker.txt docs/worker.md only; frontend implement frontend/ docs/frontend.md only. Common interface is this file. Any cross-contract concern write docs/<area>-questions.md. Other agents concurrently working: never modify their files. Root README/deploy scripts/integration fixed later. Do not use git commits or modify global system services. Avoid broad recursive reading outside project. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f394960 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="color-scheme" content="light only" /> + <title>策研 Strategy Lab</title> + </head> + <body> + <div id="app"></div> + <script type="module" src="/src/main.ts"></script> + </body> +</html> diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d2f0b45 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2979 @@ +{ + "name": "strategy-lab-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "strategy-lab-frontend", + "version": "0.1.0", + "dependencies": { + "@codemirror/lang-python": "^6.1.7", + "@codemirror/merge": "^6.7.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "codemirror": "^6.0.1", + "diff": "^7.0.0", + "echarts": "^5.6.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/svelte": "^5.2.7", + "@tsconfig/svelte": "^5.0.8", + "@types/diff": "^7.0.2", + "jsdom": "^26.1.0", + "playwright": "^1.63.0", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.3.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmmirror.com/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.5", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.5.tgz", + "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.12", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.12.tgz", + "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmmirror.com/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmmirror.com/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmmirror.com/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.57.0", + "resolved": "https://registry.npmmirror.com/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a60fa56 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "strategy-lab-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/svelte": "^5.2.7", + "@tsconfig/svelte": "^5.0.8", + "@types/diff": "^7.0.2", + "jsdom": "^26.1.0", + "playwright": "^1.63.0", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.3.5", + "vitest": "^3.2.4" + }, + "dependencies": { + "@codemirror/lang-python": "^6.1.7", + "@codemirror/merge": "^6.7.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "codemirror": "^6.0.1", + "diff": "^7.0.0", + "echarts": "^5.6.0" + }, + "allowScripts": { + "[email protected]": true + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9f6109a --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0f766e"/><path d="M8 22V13M13.5 22V9M19 22v-8M24.5 22v-5" stroke="#f0fdfa" stroke-width="2.6" stroke-linecap="round"/></svg> diff --git a/frontend/scripts/browser-smoke.mjs b/frontend/scripts/browser-smoke.mjs new file mode 100644 index 0000000..6afc2f4 --- /dev/null +++ b/frontend/scripts/browser-smoke.mjs @@ -0,0 +1,49 @@ +/** + * Browser smoke for 策研 Strategy Lab frontend. + * Usage: PLAYWRIGHT_BROWSERS_PATH=... node scripts/browser-smoke.mjs [baseURL] + * Requires the Vite dev server (frontend/ npm run dev, /api proxying to backend). + */ +import { chromium } from 'playwright'; + +const base = process.argv[2] ?? 'http://127.0.0.1:8788'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(String(e))); +page.on('response', (r) => { + if (r.status() >= 500) pageErrors.push(`${r.status} ${r.url()}`); +}); + +await page.goto(`${base}/#/login`, { waitUntil: 'networkidle' }); +const t1 = await page.evaluate(() => document.body.innerText); +if (!t1.includes('邮箱') || !t1.includes('登录')) throw new Error(`login page missing content: ${t1.slice(0, 100)}`); + +await page.goto(`${base}/#/register`, { waitUntil: 'networkidle' }); +if (!(await page.$('form'))) throw new Error('register form missing'); + +await page.goto(`${base}/#/reset`, { waitUntil: 'networkidle' }); +if ((await page.$$eval('input', (els) => els.length)) < 3) throw new Error('reset inputs missing'); + +// wrong password flow must NOT navigate to app, must render error banner +await page.goto(`${base}/#/login`, { waitUntil: 'networkidle' }); +await page.fill('#le', '[email protected]'); +await page.fill('#lp', 'wrong-password'); +await page.click('button[type="submit"]'); +await page.waitForTimeout(1200); +const t2 = await page.evaluate(() => document.body.innerText); +if (!t2.includes('不正确') && !t2.includes('HTTP') && !t2.includes('出错')) { + throw new Error(`expected visible login error, got: ${t2.slice(0, 160)}`); +} + +for (const h of ['projects', 'datasets', 'runs', 'usage', 'account', 'admin']) { + await page.goto(`${base}/#/${h}`, { waitUntil: 'networkidle' }); + const has = await page.evaluate(() => document.body.innerText.length > 50); + if (!has) throw new Error(`route ${h} rendered empty`); +} + +if (pageErrors.length > 0) { + throw new Error(`page errors: ${pageErrors.slice(0, 5).join('; ')}`); +} +console.log('BROWSER SMOKE OK', { pageErrors: pageErrors.length }); +await browser.close(); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..ae70ba4 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,71 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import type { Route } from './lib/router'; + import { routeFromHash, replace } from './lib/router'; + import { session } from './lib/session.svelte'; + import Layout from './components/Layout.svelte'; + import AuthPages from './pages/AuthPages.svelte'; + import ProjectsPage from './pages/ProjectsPage.svelte'; + import ProjectPage from './pages/ProjectPage.svelte'; + import DatasetsPage from './pages/DatasetsPage.svelte'; + import RunsPage from './pages/RunsPage.svelte'; + import UsagePage from './pages/UsagePage.svelte'; + import AccountPage from './pages/AccountPage.svelte'; + import AdminPage from './pages/AdminPage.svelte'; + import Loading from './components/Loading.svelte'; + + let route = $state<Route>(routeFromHash()); + + window.addEventListener('hashchange', () => { + route = routeFromHash(); + }); + + const publicRoutes = new Set(['login', 'register', 'reset']); + const projectRoutes = new Set(['project-data', 'project-strategy', 'project-backtest', 'project-results']); + + onMount(() => { + void session.refresh(); + }); + + $effect(() => { + if (publicRoutes.has(route.name)) return; + if (session.status === 'anon') { + replace('/login'); + return; + } + if (session.me && route.name === 'admin' && session.me.role !== 'admin') { + replace('/projects'); + } + }); + + const showAuth = $derived(publicRoutes.has(route.name) || session.status === 'anon'); + const booting = $derived(session.status === 'loading' && !publicRoutes.has(route.name)); +</script> + +<div class="app-root"> + {#if booting} + <Loading /> + {:else if showAuth} + <AuthPages {route} /> + {:else} + <Layout {route}> + {#if route.name === 'projects'} + <ProjectsPage /> + {:else if projectRoutes.has(route.name)} + <ProjectPage {route} /> + {:else if route.name === 'datasets'} + <DatasetsPage /> + {:else if route.name === 'runs'} + <RunsPage /> + {:else if route.name === 'usage'} + <UsagePage /> + {:else if route.name === 'account'} + <AccountPage /> + {:else if route.name === 'admin'} + <AdminPage /> + {:else} + <ProjectsPage /> + {/if} + </Layout> + {/if} +</div> diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..c9ed4be --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,249 @@ +:root { + --bg: #f7fafb; + --surface: #ffffff; + --surface-2: #f1f5f6; + --border: #e2e8ea; + --border-strong: #cbd5d8; + --text: #1e2a30; + --text-2: #4b5a62; + --text-3: #7b8a92; + --teal: #0d9488; + --teal-strong: #0f766e; + --teal-soft: #e6f4f2; + --ink: #12312d; + --danger: #b91c1c; + --danger-soft: #fdf2f2; + --warn: #92400e; + --warn-soft: #fff9ec; + --ok: #15803d; + --ok-soft: #ecfdf3; + --radius: 10px; + --shadow: 0 1px 2px rgba(16, 42, 49, 0.05), 0 2px 8px rgba(16, 42, 49, 0.05); + --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace; + --font: -apple-system, 'SF Pro Text', BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + 'Noto Sans CJK SC', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14.5px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4 { font-weight: 600; margin: 0; } +h1 { font-size: 1.35rem; } +h2 { font-size: 1.1rem; } +h3 { font-size: 1rem; } +a { color: var(--teal-strong); text-decoration: none; } +a:hover { text-decoration: underline; } +p { margin: 0.4rem 0; } + +button, input, select, textarea { font: inherit; color: inherit; } + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.95rem; + border-radius: var(--radius); + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + cursor: pointer; + font-size: 0.92rem; + transition: background 0.12s, border-color 0.12s, box-shadow 0.12s; + white-space: nowrap; +} +.btn:hover:not(:disabled) { background: var(--surface-2); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 1px; +} +.btn.primary { + background: var(--teal-strong); + border-color: var(--teal-strong); + color: #fff; +} +.btn.primary:hover:not(:disabled) { background: #115e59; } +.btn.danger { color: var(--danger); border-color: #f2c7c7; } +.btn.danger:hover:not(:disabled) { background: var(--danger-soft); } +.btn.ghost { border-color: transparent; background: transparent; } +.btn.ghost:hover:not(:disabled) { background: var(--surface-2); } +.btn.small { padding: 0.25rem 0.6rem; font-size: 0.83rem; } +.btn[aria-pressed='true'] { background: var(--teal-soft); border-color: var(--teal); color: var(--ink); } + +label { display: block; font-size: 0.84rem; color: var(--text-2); font-weight: 500; margin-bottom: 0.25rem; } +input, select, textarea.plain { + width: 100%; + padding: 0.45rem 0.65rem; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + font-size: 0.92rem; +} +input[type='checkbox'], input[type='radio'] { width: auto; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.15rem; + box-shadow: var(--shadow); +} +.card + .card { margin-top: 0.9rem; } + +.hint { color: var(--text-3); font-size: 0.83rem; } + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} +table.data th { + text-align: left; + font-weight: 600; + color: var(--text-2); + border-bottom: 1px solid var(--border-strong); + padding: 0.45rem 0.6rem; + white-space: nowrap; +} +table.data td { + border-bottom: 1px solid var(--border); + padding: 0.42rem 0.6rem; + vertical-align: top; +} +table.data tr:hover td { background: var(--surface-2); } +.num { font-family: var(--mono); font-variant-numeric: tabular-nums; } + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 600; + border: 1px solid transparent; +} +.badge.teal { background: var(--teal-soft); color: var(--teal-strong); } +.badge.grey { background: var(--surface-2); color: var(--text-2); border-color: var(--border); } +.badge.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0ddb4; } +.badge.danger { background: var(--danger-soft); color: var(--danger); border-color: #f2c7c7; } +.badge.ok { background: var(--ok-soft); color: var(--ok); border-color: #c3ecd0; } + +.banner { + border-radius: var(--radius); + padding: 0.65rem 0.9rem; + font-size: 0.9rem; + border: 1px solid; + display: flex; + gap: 0.6rem; + align-items: flex-start; +} +.banner.error { background: var(--danger-soft); border-color: #f2c7c7; color: var(--danger); } +.banner.warn { background: var(--warn-soft); border-color: #f0ddb4; color: var(--warn); } +.banner.info { background: var(--teal-soft); border-color: #c8eae6; color: var(--teal-strong); } + +.stack { display: flex; flex-direction: column; gap: 0.75rem; } +.row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } +.spread { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } + +.tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border); +} +.tabs button { + padding: 0.55rem 1rem; + border: none; + background: none; + color: var(--text-2); + font-size: 0.95rem; + cursor: pointer; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; +} +.tabs button:hover { background: var(--surface-2); } +.tabs button[aria-selected='true'] { + color: var(--teal-strong); + font-weight: 600; + border-bottom-color: var(--teal); +} + +/* metric tiles shared by the results card and other metric rows; without + these the six result metrics stacked vertically with large blank space */ +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.7rem; +} +.metric { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.85rem; + min-width: 0; +} +.mlabel { color: var(--text-3); font-size: 0.8rem; } +.mvalue { + font-size: 1.15rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.cols-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; align-items: start; } +.table-wrap { overflow-x: auto; min-width: 0; } + +@media (max-width: 900px) { + .cols-2 { grid-template-columns: minmax(0, 1fr); } + .metric-grid { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } +} + +code, pre { + font-family: var(--mono); + font-size: 0.85rem; +} +pre { margin: 0; white-space: pre-wrap; word-break: break-all; } + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); + border: 0; +} + +/* ---- narrow-viewport wrapping (no global content hiding) ---- + Real provider error strings contain long unbreakable URLs; .spread/.stack + children would otherwise propagate min-content outwards and inflate the + document width on mobile. `overflow-wrap` keeps every word reachable while + wrapping; nothing is hidden — long tables get a local scroll container. */ +.card, .stack, .row, .spread, .banner { min-width: 0; } +.banner { overflow-wrap: anywhere; } +.banner .err-list { overflow-wrap: anywhere; } +.card table.data { min-width: 0; } + +@media (max-width: 900px) { + /* collapse multi-column forms: the dataset wizard grid3 + label min-widths + measured 523px min-content at 375px viewport (mobile-overflow-probe) */ + .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); } + /* wrap table cells instead of nowrap inflating min-content; a truly wide + tabular block still gets its own local scrollbar (.scrollx), content + remains fully accessible without a document-level horizontal overflow */ + table.data th, table.data td { + white-space: normal; + overflow-wrap: anywhere; + } + .btn { white-space: normal; } +} + + diff --git a/frontend/src/components/Chart.svelte b/frontend/src/components/Chart.svelte new file mode 100644 index 0000000..ca8f4f8 --- /dev/null +++ b/frontend/src/components/Chart.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; + import type * as echartsNS from 'echarts'; + + interface Props { + option: echartsNS.EChartsOption; + height?: number; + ariaLabel?: string; + } + let { option, height = 360, ariaLabel = '图表' }: Props = $props(); + + let host: HTMLDivElement; + let chart: echartsNS.ECharts | null = null; + let observer: ResizeObserver | null = null; + + onMount(async () => { + // ECharts chunk loads lazily when a result/comparison view is opened. + const echarts = (await import('echarts')) as typeof echartsNS; + if (!host) return; + // Pass explicit sizes when the host has no layout yet (jsdom regression + // tests report 0×0 and ECharts warns "Can't get DOM width or height"). + // In a real browser clientWidth/clientHeight are non-zero, so the measured + // size is used exactly as before; responsive resizing still works through + // the ResizeObserver below. + chart = echarts.init(host, undefined, { + renderer: 'svg', + width: host.clientWidth || 640, + height: host.clientHeight || height + }); + observer = new ResizeObserver(() => { + if (!chart) return; + // re-measure on EVERY resize callback and pass the measured size + // explicitly: echarts keeps the width given at init/resize, so a + // desktop->mobile viewport change would otherwise leave the SVG at the + // old width and overflow the page (real, re-verified live bug). + const w = host.clientWidth || undefined; + const h = host.clientHeight || undefined; + if (w === undefined && h === undefined) return; + chart.resize({ width: w, height: h }); + }); + observer.observe(host); + chart?.setOption(option, { notMerge: true }); + }); + + onDestroy(() => { + observer?.disconnect(); + chart?.dispose(); + chart = null; + }); + + $effect(() => { + chart?.setOption(option, { notMerge: true }); + }); +</script> + +<div + bind:this={host} + style="height:{height}px; width:100%; min-width:0;" + role="img" + aria-label={ariaLabel} +></div> + +<style> + div { + display: block; + } +</style> diff --git a/frontend/src/components/CodeEditor.svelte b/frontend/src/components/CodeEditor.svelte new file mode 100644 index 0000000..544a6a4 --- /dev/null +++ b/frontend/src/components/CodeEditor.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; + import { EditorView, keymap, drawSelection } from '@codemirror/view'; + import { EditorState, StateEffect } from '@codemirror/state'; + import { python } from '@codemirror/lang-python'; + import { indentWithTab } from '@codemirror/commands'; + import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; + + interface Props { + value: string; + onInput: (v: string) => void; + height?: number; + readOnly?: boolean; + label?: string; + } + let { value, onInput, height = 420, readOnly = false, label }: Props = $props(); + + let host: HTMLDivElement; + let view: EditorView | null = null; + let applyingExternal = false; + // svelte-ignore state_referenced_locally + let handler = onInput; + $effect(() => { + handler = onInput; + }); + + const setDoc = StateEffect.define<string>(); + + onMount(() => { + view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: value ?? '', + extensions: [ + readOnly ? EditorView.editable.of(false) : [], + history(), + drawSelection(), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + python(), + EditorView.lineWrapping, + EditorView.theme({ + '&': { + fontSize: '13px', + background: 'var(--surface)', + color: 'var(--text)', + fontFamily: + "ui-monospace, 'SF Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace" + }, + '.cm-content': { padding: '10px 0', minHeight: `${height}px` }, + '.cm-scroller': { lineHeight: '1.55' }, + '&.cm-focused': { outline: 'none' }, + '.cm-gutters': { + background: 'var(--surface)', + border: 'none', + color: 'var(--text-3)' + }, + '.cm-activeLine': { background: 'var(--surface-2)' }, + '.cm-activeLineGutter': { background: 'var(--surface-2)' } + }), + EditorView.updateListener.of((u) => { + if (applyingExternal) return; + if (u.docChanged) handler(u.state.doc.toString()); + }) + ] + }) + }); + return () => view?.destroy(); + }); + + $effect(() => { + if (!view) return; + const current = view.state.doc.toString(); + if (value !== current) { + applyingExternal = true; + view.dispatch({ changes: { from: 0, to: current.length, insert: value ?? '' } }); + applyingExternal = false; + } + }); + + $effect(() => { + if (view) { + if (readOnly) view.contentDOM.setAttribute('aria-readonly', 'true'); + else view.contentDOM.removeAttribute('aria-readonly'); + } + }); + + onDestroy(() => view?.destroy()); +</script> + +<div> + {#if label}<span class="hint editor-label">{label}</span>{/if} + <div bind:this={host} class="cm-host"></div> +</div> + +<style> + .cm-host { + border: 1px solid var(--border-strong); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); + } + .cm-host :global(.cm-editor) { min-height: 60px; } + .editor-label { display: block; margin-bottom: 0.25rem; } +</style> diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte new file mode 100644 index 0000000..7b38874 --- /dev/null +++ b/frontend/src/components/DatasetCard.svelte @@ -0,0 +1,157 @@ +<script lang="ts"> + import Status from './Status.svelte'; + import { getDataset, getDatasetPreview } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { adjustmentLabel, assetTypeLabel, instrumentLabel, datasetStatusLabel, fmtDate } from '../lib/format'; + import { summarizeCoverage } from '../lib/coverage'; + import type { Dataset, DatasetPreview } from '../lib/types'; + + interface Props { + dsId: string; + } + let { dsId }: Props = $props(); + + let ds = $state<Dataset | null>(null); + let error: string | null = $state(null); + let busy = $state(true); + let showPreview = $state(false); + let preview = $state<DatasetPreview | null>(null); + let previewBusy = $state(false); + let previewError: string | null = $state(null); + let pollTimer: ReturnType<typeof setInterval> | null = null; + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + $effect(() => { + void load(); + return stopPolling; + }); + + async function load() { + error = null; + try { + ds = await getDataset(dsId); + if (ds.status === 'pending' || ds.status === 'running') { + if (!pollTimer) { + pollTimer = setInterval(() => { + void load(); + }, 2000); + } + } else { + stopPolling(); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + async function togglePreview() { + showPreview = !showPreview; + if (showPreview && !preview && ds?.status === 'ready') { + previewBusy = true; + previewError = null; + try { + preview = await getDatasetPreview(dsId); + } catch (e) { + previewError = `预览加载失败:${extractStatusMessage(e)}`; + } finally { + previewBusy = false; + } + } + } +</script> + +<div class="card"> + {#if ds} + <div class="spread"> + <div> + <strong>{ds.name ?? '未命名数据集'}</strong> + {#if ds.manifest_hash}<span class="hint mono">manifest {ds.manifest_hash.slice(0, 10)}…</span>{/if} + {#if ds.cache_hit}<span class="badge teal">缓存命中 · 复用现有物化对象</span>{/if} + </div> + <span class="badge {ds.status === 'ready' ? 'ok' : ds.status === 'failed' ? 'danger' : 'warn'}"> + {datasetStatusLabel[ds.status]} + </span> + </div> + <table class="data kv"> + <tbody> + <tr><th>请求标的</th><td>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</td></tr> + <tr><th>范围</th><td>{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}</td></tr> + <tr><th>字段</th><td>{(ds.request.fields ?? []).join(', ')}</td></tr> + <tr><th>创建时间</th><td>{fmtDate(ds.created_at)}</td></tr> + </tbody> + </table> + {#if ds.status === 'failed'} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></div> + {/if} + {#if ds.status !== 'ready'} + <p class="hint">{ds.status === 'pending' || ds.status === 'running' ? '获取中,完成后可预览覆盖情况' : ''}</p> + {/if} + {#if ds.status === 'ready'} + <div> + <button class="btn small" aria-expanded={showPreview} onclick={togglePreview}> + {showPreview ? '收起预览' : '查看数据覆盖预览'} + </button> + </div> + {#if showPreview} + <div class="preview-box"> + <Status busy={previewBusy} busyText="加载预览…" error={previewError} empty={null} /> + {#if preview} + {@const cov = summarizeCoverage(preview.coverage ?? [])} + {#if cov.warnings.length > 0} + <div class="banner warn"><span aria-hidden="true">⚠</span><div> + <div class="cov-caption">覆盖差异(共有区间 {cov.common.start ?? '—'} ~ {cov.common.end ?? '—'}):</div> + <ul class="cov-list"> + {#each cov.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + {#if (preview.coverage ?? []).some((c) => c.warnings)} + <ul class="cov-list"> + {#each preview.coverage.filter((c) => c.warnings) as c (c.instrument)} + <li>{c.instrument}:{(c.warnings ?? []).join(';')}</li> + {/each} + </ul> + {/if} + </div></div> + {:else if cov.common.start} + <div class="banner info"> + <span aria-hidden="true">✓</span> + <span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span> + </div> + {/if} + <table class="data scrollx"> + <thead> + <tr>{#each preview.columns as c (c)}<th>{c}</th>{/each}</tr> + </thead> + <tbody> + {#each preview.rows as row, i (i)} + <tr>{#each preview.columns as c (c)}<td class="num">{row[c]}</td>{/each}</tr> + {/each} + </tbody> + </table> + {#if preview.truncated} + <p class="hint">预览仅展示部分行</p> + {/if} + {/if} + </div> + {/if} + {/if} + {:else} + <Status busy={busy} error={error} busyText="载入数据集…" empty={null} /> + {/if} +</div> + +<style> + .kv th { width: 110px; color: var(--text-2); font-weight: 500; min-width: 110px; } + .mono { font-family: var(--mono); margin-left: 0.5rem; } + .preview-box { margin-top: 0.5rem; display: grid; gap: 0.5rem; } + .scrollx { display: block; overflow-x: auto; } + .cov-caption { font-weight: 600; } + .cov-list { margin: 0.2rem 0 0 1.1rem; padding: 0; } +</style> diff --git a/frontend/src/components/DatasetWizard.svelte b/frontend/src/components/DatasetWizard.svelte new file mode 100644 index 0000000..1e61d92 --- /dev/null +++ b/frontend/src/components/DatasetWizard.svelte @@ -0,0 +1,374 @@ +<script lang="ts"> + import Status from './Status.svelte'; + import { getCapabilities, searchInstruments, createDataset } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { adjustmentLabel, assetTypeLabel, itemSourceLabel } from '../lib/format'; + import { createSearchRunner, SEARCH_EMPTY_MSG } from '../lib/instrumentSearch'; + import type { Capabilities, Dataset, Instrument } from '../lib/types'; + + interface Props { + onCreated?: (d: Dataset) => void; + } + let { onCreated }: Props = $props(); + + let caps = $state<Capabilities | null>(null); + let loadErr: string | null = $state(null); + + let query = $state(''); + let searchBusy = $state(false); + let searchItems: Instrument[] = $state([]); + let searchSource = $state(''); + let searchErr: string | null = $state(null); + + const searchRunner = createSearchRunner(searchInstruments, { + onBusy: () => { + searchBusy = true; + searchErr = null; + searchItems = []; + }, + onResult: (res) => { + searchItems = res.items; + searchSource = res.source; + if (res.items.length === 0 && !searchErr) { + searchErr = SEARCH_EMPTY_MSG; + } + }, + onError: (message) => { + searchItems = []; + searchErr = message; + }, + onFinish: () => { + searchBusy = false; + } + }); + + function doSearch() { + searchRunner.runNow(query); + } + + function onSearchInput() { + searchRunner.requestDebounced(query); + } + + let selected: Instrument[] = $state([]); + let symbol = $state(''); + let market = $state('SH'); + let assetType = $state('stock'); + let manualName = $state(''); + + let startDate = $state(''); + let endDate = $state(''); + let adjustment: 'none' | 'qfq' | 'hfq' = $state('none'); + let chosenRaw: string[] = $state([]); + let dsName = $state(''); + + let submitErr: string | null = $state(null); + let submitting = $state(false); + let formErr: string[] = $state([]); + let formOk: string | null = $state(null); + + const maxYears = $derived(caps?.limits.max_years ?? 15); + const maxSymbols = $derived(caps?.limits.max_symbols ?? 5); + const rawFieldChoices = $derived(caps?.fields.filter((f) => f.raw) ?? []); + const baseRequired = $derived(caps?.fields.filter((f) => !f.raw) ?? []); + + $effect(() => { + (async () => { + try { + caps = await getCapabilities(); + } catch (e) { + loadErr = extractStatusMessage(e); + } + })(); + }); + + function isRaw(code: string) { + if (!caps) return false; + return !caps.fields.some((f) => f.code === code && !f.raw); + } + + function toggleRawField(code: string) { + chosenRaw = chosenRaw.includes(code) ? chosenRaw.filter((c) => c !== code) : [...chosenRaw, code]; + } + + function sameInstrument(a: Instrument, b: Instrument) { + return a.market === b.market && a.symbol === b.symbol && a.asset_type === b.asset_type; + } + + function addSelection(inst: Instrument) { + if (selected.some((s) => sameInstrument(s, inst))) { + formErr = [`${inst.market}.${inst.symbol} 已在列表中`]; + return; + } + if (selected.length >= maxSymbols) { + formErr = [`最多选择 ${maxSymbols} 个标的(能力上限)`]; + return; + } + formErr = []; + selected = [...selected, inst]; + } + + function removeSelection(target: Instrument) { + selected = selected.filter((s) => !sameInstrument(s, target)); + } + + function addManual() { + const sym = symbol.trim(); + if (!sym) { + formErr = ['请输入确切的代码,例如 600000 或 510300']; + return; + } + const inst: Instrument = { + symbol: sym, + market: market.trim().toUpperCase(), + asset_type: assetType as Instrument['asset_type'], + name: manualName.trim() || null, + currency: null + }; + addSelection(inst); + symbol = ''; + manualName = ''; + } + + async function submit() { + const errs: string[] = []; + if (selected.length === 0) errs.push('至少选择一个标的'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) errs.push('请选择开始日期'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) errs.push('请选择结束日期'); + if (/^\d{4}-\d{2}-\d{2}$/.test(startDate) && /^\d{4}-\d{2}-\d{2}$/.test(endDate)) { + const years = (new Date(endDate).getTime() - new Date(startDate).getTime()) / (365.25 * 86400000); + if (!(endDate > startDate)) errs.push('结束日期须晚于开始日期'); + else if (years > maxYears) errs.push(`范围不能超过 ${maxYears} 年(当前约 ${years.toFixed(1)} 年)`); + } + if (errs.length > 0) { + formErr = errs; + return; + } + formErr = []; + formOk = null; + submitting = true; + submitErr = null; + try { + const fields = ['open', 'high', 'low', 'close', 'volume'].concat(chosenRaw); + const ds = await createDataset({ + name: dsName.trim() || undefined, + instruments: selected, + start_date: startDate, + end_date: endDate, + frequency: 'daily', + adjustment, + fields + }); + formOk = `数据请求已提交,编号 ${ds.id.slice(0, 8)}…,可在下方列表查看获取进度`; + selected = []; + startDate = ''; + endDate = ''; + dsName = ''; + chosenRaw = []; + onCreated?.(ds); + } catch (e) { + submitErr = extractStatusMessage(e); + if ((e as { details?: { errors?: string[] } }).details?.errors) { + formErr = (e as { details: { errors: string[] } }).details.errors; + } + } finally { + submitting = false; + } + } +</script> + +<div class="card stack"> + <h2>新建数据集</h2> + + <Status busy={!caps && !loadErr} busyText="加载能力配置…" error={loadErr} empty={null} /> + + {#if caps} + <section class="stack"> + <h3 class="step-h">1) 选择标的 <span class="hint">最多 {maxSymbols} 个 · 日频 POC</span></h3> + <div class="row"> + <input + style="flex:2 1 200px" + placeholder="搜索代码或名称,如 600000、沪深300ETF" + bind:value={query} + oninput={onSearchInput} + onkeydown={(ev) => { + if (ev.key === 'Enter') doSearch(); + }} + aria-label="标的搜索" + /> + <button class="btn" disabled={searchBusy || !query.trim()} onclick={() => doSearch()}> + {searchBusy ? '搜索中…' : '搜索'} + </button> + </div> + <Status busy={searchBusy} busyText="搜索中…" error={searchErr} empty={null} /> + {#if searchErr && !searchBusy} + <button class="btn small" onclick={() => doSearch()}>重试</button> + {/if} + {#if searchItems.length > 0} + <table class="data"> + <thead> + <tr><th>代码</th><th>类型</th><th>名称</th><th>来源</th><th></th></tr> + </thead> + <tbody> + {#each searchItems as inst (inst.market + '.' + inst.symbol + inst.asset_type + '.' + (inst.source ?? 'unknown'))} + <tr> + <td class="num">{inst.market}.<strong>{inst.symbol}</strong></td> + <td>{assetTypeLabel[inst.asset_type] ?? inst.asset_type}</td> + <td>{inst.name ?? '—'}</td> + <td class="hint">{itemSourceLabel(inst.source, searchSource)}</td> + <td> + <button class="btn small" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}> + {inst.asset_type === 'index' ? '加入(基准研究)' : '加入'} + </button> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + + <details> + <summary>手动录入标的(目录未收录时)</summary> + <div class="card stack manual"> + <p class="hint">仅接受确切标识,不会推断市场或类型。请与供应商页面核对后再录入。</p> + <div class="row wrap"> + <label style="flex:1 1 130px"> + 代码 + <input bind:value={symbol} placeholder="600000 / 510300 / 000300" /> + </label> + <label style="flex:0 0 110px"> + 市场 + <select bind:value={market}> + {#each ['SH', 'SZ', 'BJ'] as m (m)} + <option value={m}>{m}</option> + {/each} + </select> + </label> + <label style="flex:0 0 120px"> + 类型 + <select bind:value={assetType}> + {#each caps.asset_types as t (t)} + <option value={t}>{assetTypeLabel[t] ?? t}</option> + {/each} + </select> + </label> + <label style="flex:1 1 160px"> + 名称(可选) + <input bind:value={manualName} placeholder="与供应商页面一致" /> + </label> + </div> + <div class="row"> + <button class="btn" onclick={addManual}>加入所选</button> + </div> + </div> + </details> + </section> + + <section> + <h3 class="step-h">2) 已选标的 <span class="hint">{selected.length}/{maxSymbols}</span></h3> + {#if selected.length === 0} + <p class="hint">尚未选择标的。指数仅可作研究中枢,不可直接交易。</p> + {:else} + <ul class="chip-list"> + {#each selected as s, i (s.market + '.' + s.symbol + s.asset_type)} + <li> + <span class="num">{s.market}.{s.symbol}</span> + {#if s.name}<span class="hint">{s.name}</span>{/if} + <span class="badge grey">{assetTypeLabel[s.asset_type] ?? s.asset_type}</span> + <button class="btn small ghost" aria-label={`移除 ${s.symbol}`} onclick={() => selected.splice(i, 1)}>✕</button> + </li> + {/each} + </ul> + {/if} + </section> + + <section class="grid3 wrap-3"> + <label> + 起始日期 + <input type="date" bind:value={startDate} /> + </label> + <label> + 结束日期 + <input type="date" bind:value={endDate} /> + </label> + <label style="min-width:160px"> + 复权 + <select bind:value={adjustment}> + {#each caps.adjustments as a (a.code)} + <option value={a.code}>{a.label}</option> + {/each} + </select> + {#if adjustment !== 'none'} + <p class="hint">{adjustmentLabel[adjustment]}:以复权口径返回</p> + {/if} + <p class="hint">日频 · 周期上限 {maxYears} 年</p> + </label> + </section> + + <section> + <label for="ds-name">数据集名称(可选,留空自动生成)</label> + <input id="ds-name" bind:value={dsName} placeholder="例如:沪深300 2019-2024 前复权" /> + </section> + + <details> + <summary>附加原始字段(可选)</summary> + <p class="hint">OHLCV 必需字段由引擎要求固定获取;附加字段保持原始口径,未请求的字段不会返回。</p> + <ul class="field-list"> + {#each rawFieldChoices as f (f.code)} + <li> + <label class="check"> + <input type="checkbox" checked={chosenRaw.includes(f.code)} onchange={() => toggleRawField(f.code)} /> + {f.label} <span class="hint">(未请求不返回)</span> + </label> + </li> + {/each} + </ul> + {#if caps.fields.some((f) => !f.raw)} + <p class="hint">必需字段:{caps.fields.filter((f) => !f.raw).map((f) => f.label).join(' / ')}</p> + {/if} + </details> + + {#if formErr.length > 0} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <ul class="err-list"> + {#each formErr as msg, i (i)}<li>{msg}</li>{/each} + </ul> + </div> + {/if} + {#if submitErr} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>数据请求提交失败:{submitErr}</span> + </div> + {/if} + <p class="hint formhint">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p> + <div> + <button class="btn primary" disabled={submitting || selected.length === 0} onclick={submit}> + {submitting ? '提交中…' : '提交数据请求'} + </button> + </div> + {/if} + + <Status busy={false} error={null} empty={formOk} /> +</div> + +<style> + .manual { padding: 0.8rem; margin: 0.4rem 0; } + .manual input, .manual select { margin-top: 0.25rem; } + .chip-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.4rem; } + .chip-list li { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--border-strong); + padding: 0.25rem 0.5rem; + border-radius: 999px; + background: var(--surface-2); + } + .field-list { list-style: none; padding: 0; margin: 0.3rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.2rem; } + .check { font-size: 0.88rem; margin: 0; display: flex; gap: 0.35rem; align-items: center; } + .step-h { font-size: 0.95rem; } + .err-list { padding-left: 1.1rem; margin: 0; list-style: disc; } + .wrap-3 { align-items: start; } +</style> diff --git a/frontend/src/components/DiffView.svelte b/frontend/src/components/DiffView.svelte new file mode 100644 index 0000000..cf0c1be --- /dev/null +++ b/frontend/src/components/DiffView.svelte @@ -0,0 +1,71 @@ +<script lang="ts"> + import { diffLines } from 'diff'; + + interface Props { + before: string; + after: string; + beforeLabel?: string; + afterLabel?: string; + maxHeight?: number; + } + let { before, after, beforeLabel = '原版本', afterLabel = '新版本', maxHeight = 480 }: Props = $props(); + + const parts = $derived(diffLines(before ?? '', after ?? '')); + + const lines = $derived.by(() => { + const out: { t: 'a' | 'd' | 'c'; text: string }[] = []; + for (const p of parts) { + const pieces = p.value.replace(/\n$/, '').split('\n'); + for (const piece of pieces) { + if (p.added) out.push({ t: 'a', text: piece }); + else if (p.removed) out.push({ t: 'd', text: piece }); + else out.push({ t: 'c', text: piece }); + } + } + return out; + }); +</script> + +<div class="diff"> + <div class="legend"> + <span><i class="sw a"></i>新增({afterLabel})</span> + <span><i class="sw d"></i>删除({beforeLabel})</span> + </div> + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> + <div class="body" style="max-height:{maxHeight}px" tabindex="0" aria-label="代码差异" role="region"> + {#each lines as l, i (i)} + {#if l.t === 'a'} + <span class="ln add" aria-hidden="true"></span><span class="code add">+{l.text}</span> + {:else if l.t === 'd'} + <span class="ln del" aria-hidden="true"></span><span class="code del">−{l.text}</span> + {:else} + <span class="ln" aria-hidden="true"> </span><span class="code">{l.text}</span> + {/if} + {/each} + </div> +</div> + +<style> + .diff .legend { + color: var(--text-3); + font-size: 0.8rem; + margin-bottom: 0.4rem; + display: flex; + gap: 1rem; + } + .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 4px; } + .sw.a { background: #c7f0e6; } + .sw.d { background: #fbd4d0; } + .body { + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.7rem; + } + .body .code { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-all; display: inline-block; width: 100%; } + .add { background: #eafaf5; color: #14532d; } + .del { background: #fdf0ee; color: #7f1d1d; } + .ln { user-select: none; } + .code.add::before, .code.del::before { opacity: 0.6; } +</style> diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte new file mode 100644 index 0000000..78cee45 --- /dev/null +++ b/frontend/src/components/Layout.svelte @@ -0,0 +1,241 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import { go } from '../lib/router'; + import { session } from '../lib/session.svelte'; + import { postLogout } from '../lib/client'; + import { projectOf, projectInvisible, projectsStore } from '../lib/listsStore.svelte'; + + interface Props { + route: Route; + children?: import('svelte').Snippet; + } + let { route, children }: Props = $props(); + + const topLeft: { name: string; label: string }[] = [ + { name: 'projects', label: '我的项目' }, + { name: 'datasets', label: '数据集' }, + { name: 'runs', label: '实验记录' }, + { name: 'usage', label: 'AI 用量' } + ]; + + const bottom: { name: string; label: string }[] = + session.me?.role === 'admin' + ? [ + { name: 'account', label: '账户与安全' }, + { name: 'admin', label: '管理员' } + ] + : [{ name: 'account', label: '账户与安全' }]; + + async function logout() { + await postLogout().catch(() => undefined); + session.set(null); + // drop cached projects/failure marks: the next session on this browser + // must not see (or be marked invisible by) the previous user's projects + projectsStore.clear(); + go('/login'); + } + + const isProject = $derived(route.name.startsWith('project-')); + const projectTab = $derived(isProject ? route.name.replace('project-', '') : ''); + const projectNav: [string, string][] = [ + ['data', '数据'], + ['strategy', '策略'], + ['backtest', '回测'], + ['results', '结果'] + ]; + const activeProject = $derived(isProject ? projectOf(route.params.id) : null); + const projectHidden = + $derived(isProject && !activeProject && projectInvisible(route.params.id)); + // Deep link / first navigation into a project tab: the sidebar store may not + // contain the project yet (ProjectsPage was never visited). Fetch it once so + // the 当前项目 name resolves without waiting for the tab's own lifecycle. + $effect(() => { + const id = isProject ? route.params.id : null; + if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined); + }); +</script> + +<div class="layout"> + <aside class="sidebar" aria-label="全局导航"> + <a href="#/projects" class="brand"> + <span class="brand-mark" aria-hidden="true"></span> + <span class="brand-text">策研 <span class="brand-sub">Strategy Lab</span></span> + </a> + + {#if isProject} + <div class="projctx"> + <div class="nav-title">当前项目</div> + <div class="projname">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div> + {#each projectNav as [t, label] (t)} + <a + class="navlink" + href={`#/projects/${route.params.id}/${t}`} + aria-current={projectTab === t ? 'page' : undefined} + >{label}</a> + {/each} + <div class="divider" aria-hidden="true"></div> + </div> + {/if} + + <div class="nav-title">工作台</div> + {#each topLeft as item (item.name)} + <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}> + {item.label} + </a> + {/each} + + <div class="push"></div> + <div class="nav-title">账户</div> + {#each bottom as item (item.name)} + <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}> + {item.label} + </a> + {/each} + {#if session.me} + <div class="me"> + <span class="me-name">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span> + <button class="btn small ghost" onclick={() => logout()}>退出</button> + </div> + {/if} + </aside> + + <main class="content" aria-label="主内容"> + {@render children?.()} + </main> +</div> + +<style> + .layout { + display: grid; + grid-template-columns: 240px 1fr; + min-height: 100vh; + } + .sidebar { + background: var(--surface); + border-right: 1px solid var(--border); + padding: 1rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + } + .brand { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.4rem; + color: var(--text); + } + .brand:hover { text-decoration: none; } + .brand-mark { + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--teal-strong); + flex-shrink: 0; + position: relative; + overflow: hidden; + } + .brand-mark::after { + content: ''; + position: absolute; + inset: 5px; + background: linear-gradient( + to top, + transparent 4px, + #f0fdfa 4px, + #f0fdfa 8px, + transparent 8px, + transparent 13px, + #f0fdfa 13px, + #f0fdfa 19px, + transparent 19px + ) left/ 4px 100% no-repeat; + opacity: 0.85; + } + .brand-text { font-weight: 700; font-size: 1.02rem; letter-spacing: 0.02em; } + .brand-sub { color: var(--text-3); font-weight: 500; font-size: 0.72rem; display: block; } + .nav-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + padding: 0.4rem 0.5rem 0.1rem; + font-weight: 700; + } + .navlink { + display: block; + padding: 0.4rem 0.6rem; + border-radius: 8px; + color: var(--text-2); + font-size: 0.92rem; + } + .navlink:hover { background: var(--surface-2); text-decoration: none; } + .navlink[aria-current='page'] { + background: var(--teal-soft); + color: var(--teal-strong); + font-weight: 600; + } + .projctx .navlink { font-size: 0.88rem; } + .projname { + font-weight: 600; + padding: 0 0.6rem 0.25rem; + font-size: 0.94rem; + } + .divider { + height: 1px; + background: var(--border); + margin: 0.7rem 0.3rem; + } + .push { flex: 1; min-height: 1rem; } + .me { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + padding: 0.5rem; + background: var(--surface-2); + border-radius: 8px; + font-size: 0.86rem; + } + .me-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); + } + .content { + padding: 1.6rem 2.2rem 3rem; + max-width: 1200px; + width: 100%; + /* grid/flex children default to min-content sizing; without min-width:0 a + wide table cell propagates min-content into the column and inflates the + whole document (375px probe measured 552px). */ + min-width: 0; + } + @media (max-width: 900px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .sidebar { + position: static; + height: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-bottom: 1px solid var(--border); + border-right: none; + gap: 0.3rem; + min-width: 0; + } + .nav-title, .divider, .push { display: none; } + .navlink { padding: 0.35rem 0.7rem; } + /* the project tab row must wrap, not force one long line */ + .projctx { display: flex; flex-wrap: wrap; gap: 0.3rem; min-width: 0; } + .projname { max-width: 100%; overflow-wrap: anywhere; } + .content { padding: 1.1rem 1rem 3rem; max-width: 100%; } + } +</style> diff --git a/frontend/src/components/Loading.svelte b/frontend/src/components/Loading.svelte new file mode 100644 index 0000000..6de2ea2 --- /dev/null +++ b/frontend/src/components/Loading.svelte @@ -0,0 +1,24 @@ +<div class="loading-page" role="status" aria-live="polite"> + <span class="spinner" aria-hidden="true"></span> + <p>加载中…</p> +</div> + +<style> + .loading-page { + min-height: 100vh; + display: grid; + place-items: center; + color: var(--text-3); + } + .spinner { + width: 22px; + height: 22px; + border: 2.5px solid var(--teal); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } +</style> diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte new file mode 100644 index 0000000..c9f334c --- /dev/null +++ b/frontend/src/components/Modal.svelte @@ -0,0 +1,63 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + title: string; + onClose: () => void; + wide?: boolean; + children?: Snippet; + } + let { title, onClose, wide = false, children }: Props = $props(); + + function onBackdrop(e: MouseEvent) { + if (e.target === e.currentTarget) onClose(); + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + + let container: HTMLDivElement; + $effect(() => { + container?.focus(); + }); +</script> + +<div + class="overlay" + onclick={onBackdrop} + role="presentation" + onkeydown={onKeydown} + tabindex="-1" + bind:this={container} +> + <div class="dialog card" role="dialog" aria-modal="true" aria-label={title}> + <div class="spread"> + <h2>{title}</h2> + <button class="btn ghost" onclick={onClose} aria-label="关闭">✕</button> + </div> + <div class="content"> + {@render children?.()} + </div> + </div> +</div> + +<style> + .overlay { + position: fixed; + inset: 0; + background: rgba(15, 30, 35, 0.35); + display: grid; + place-items: center; + z-index: 50; + padding: 1rem; + } + .dialog { + width: min(760px, 100%); + max-height: 88vh; + overflow: auto; + padding: 1.1rem 1.2rem; + } + + .content { margin-top: 0.6rem; } +</style> diff --git a/frontend/src/components/Page.svelte b/frontend/src/components/Page.svelte new file mode 100644 index 0000000..a9549bb --- /dev/null +++ b/frontend/src/components/Page.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + title: string; + subtitle?: string; + actions?: Snippet; + children?: Snippet; + wide?: boolean; + } + let { title, subtitle, actions, children, wide }: Props = $props(); +</script> + +<header class="page-head"> + <div> + <h1>{title}</h1> + {#if subtitle}<p class="hint">{subtitle}</p>{/if} + </div> + {#if actions}{@render actions?.()}{/if} +</header> +<div class="page-body" class:wide> + {@render children?.()} +</div> + +<style> + .page-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.1rem; + flex-wrap: wrap; + } + .page-head .hint { margin-top: 0.2rem; } + .page-body { max-width: 780px; } + .page-body.wide { max-width: none; } +</style> diff --git a/frontend/src/components/Status.svelte b/frontend/src/components/Status.svelte new file mode 100644 index 0000000..019c224 --- /dev/null +++ b/frontend/src/components/Status.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + interface Props { + error?: string | null; + busy?: boolean; + busyText?: string; + empty?: string | boolean | null; + emptyHint?: string; + warnings?: string[]; + } + let { error, busy, busyText = '加载中…', empty, emptyHint, warnings }: Props = $props(); +</script> + +{#if busy} + <div class="status" role="status"> + <span class="spinner" aria-hidden="true"></span> + {busyText} + </div> +{:else if error} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>{error}</span> + </div> +{:else if empty !== null && empty !== undefined && empty !== false} + <div class="state empty"> + <p class="empty-title">{empty}</p> + {#if emptyHint}<p class="hint">{emptyHint}</p>{/if} + </div> +{/if} +{#if warnings && warnings.length > 0} + <div class="stack"> + {#each warnings as w, i (i)} + <div class="banner warn"><span aria-hidden="true">△</span><span>{w}</span></div> + {/each} + </div> +{/if} + +<style> + .status { + color: var(--text-3); + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.9rem 0; + font-size: 0.9rem; + } + .spinner { + width: 15px; + height: 15px; + border: 2px solid var(--teal); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + .empty { + text-align: center; + padding: 2.2rem 1rem; + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + color: var(--text-2); + background: var(--surface); + } + .empty-title { font-weight: 600; margin-bottom: 0.2rem; } +</style> diff --git a/frontend/src/lib/__tests__/state.test.ts b/frontend/src/lib/__tests__/state.test.ts new file mode 100644 index 0000000..245c38c --- /dev/null +++ b/frontend/src/lib/__tests__/state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { extractStatusMessage } from '../state'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('extractStatusMessage', () => { + it('prefers ApiError.code-mapped Chinese text', () => { + const msg = extractStatusMessage({ + status: 409, + code: 'stale_generation', + message: '草稿已被其他修改更新' + }); + expect(msg).toContain('409'); + expect(msg).toContain('草稿已被其他修改更新'); + }); + + it('handles unknown errors without leaking internals', () => { + const msg = extractStatusMessage({ message: 'boom' }); + expect(msg).toContain('出错'); + }); +}); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..b66d9ca --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { apiFetch, ApiError } from './api'; +import type { ApiErrorBody } from './api'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('apiFetch', () => { + it('parses JSON success responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } })) + ); + const res = await apiFetch<void, { ok: number }>('/api/health'); + expect(res).toEqual({ ok: 1 }); + }); + + it('returns undefined for empty body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })) + ); + const res = await apiFetch<void, undefined>('/api/auth/logout', { method: 'POST' }); + expect(res).toBeUndefined(); + }); + + it('raises ApiError with structured error body', async () => { + const body: ApiErrorBody = { + error: { code: 'stale_generation', message: '草稿已被其他修改更新', details: { expected: 'g1' } } + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(body), { status: 409, headers: { 'content-type': 'application/json' } })) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(409); + expect(err.code).toBe('stale_generation'); + expect(err.message).toBe('草稿已被其他修改更新'); + expect(err.details).toEqual({ expected: 'g1' }); + }); + + it('raises ApiError for non-JSON error body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('oops', { status: 500 })) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(500); + expect(err.code).toBe('server_error'); + }); + + it('raises network ApiError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('failed'); + }) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(0); + expect(err.code).toBe('network'); + }); + + it('sends JSON content type for body requests and does not for bodyless', async () => { + const f = vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', f); + await apiFetch('/api/projects', { method: 'POST', body: { name: 'x' } }); + expect(f).toHaveBeenCalledWith( + '/api/projects', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'content-type': 'application/json' }), + credentials: 'same-origin' + }) + ); + await apiFetch('/api/projects'); + expect(f).toHaveBeenLastCalledWith( + '/api/projects', + expect.not.objectContaining({ headers: expect.objectContaining({ 'content-type': 'application/json' }) }) + ); + }); + + it('rejects a 200 without JSON body per API contract', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('<html>oops</html>', { status: 200, headers: { 'content-type': 'text/html' } })) + ); + const err = await apiFetch<void>('/api/projects').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.code).toBe('bad_response'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..923a6e8 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,70 @@ +export interface ApiErrorShape { + code: string; + message: string; + details?: unknown; +} + +export interface ApiErrorBody { + error: ApiErrorShape; +} + +export class ApiError extends Error { + status: number; + code: string; + details?: unknown; + + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +interface Options { + method?: string; + body?: unknown; + signal?: AbortSignal; +} + +export async function apiFetch<T = unknown, R = T>(url: string, options: Options = {}): Promise<R> { + const headers: Record<string, string> = { accept: 'application/json' }; + let bodyStr: string | undefined; + if (options.body !== undefined) { + headers['content-type'] = 'application/json'; + bodyStr = JSON.stringify(options.body); + } + let res: Response; + try { + res = await fetch(url, { + method: options.method ?? 'GET', + headers, + body: bodyStr, + credentials: 'same-origin', + signal: options.signal + }); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') throw e; + throw new ApiError(0, 'network', '网络请求失败,请确认服务已启动后重试'); + } + if (res.status === 204) { + return undefined as R; + } + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + if (!res.ok) { + throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`); + } + throw new ApiError(res.status, 'bad_response', '服务返回了非预期的非 JSON 响应(接口约定为 JSON)'); + } + const parsed: unknown = await res.json().catch(() => null); + if (!res.ok) { + const errBody = parsed as ApiErrorBody | null; + if (errBody?.error) { + throw new ApiError(res.status, errBody.error.code, errBody.error.message, errBody.error.details); + } + throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`); + } + return parsed as R; +} diff --git a/frontend/src/lib/chartResize.test.ts b/frontend/src/lib/chartResize.test.ts new file mode 100644 index 0000000..95beaad --- /dev/null +++ b/frontend/src/lib/chartResize.test.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { mount, unmount } from 'svelte'; +import Chart from '../components/Chart.svelte'; + +// Bounded chart-overflow regression (parent live QA: viewport 375, +// document.scrollWidth 1133 after desktop->mobile resize): +// +// echarts.init previously received an explicit width, and ECharts KEEPS the +// explicitly passed size on chart.resize(); a desktop->mobile viewport change +// therefore left the rendered SVG at the desktop width, overflowing the page. +// The ResizeObserver handler must now re-measure the host and pass the +// measured size into chart.resize() so the SVG actually shrinks with the +// viewport. The chart output (SVG renderer) itself must stay fully real — +// this test drives a genuine echarts SVG instance in jsdom (only zrender's +// canvas measureText is shimmed) and asserts the SVG element's width follows +// the host size on a simulated resize (fresh 375 deep links were already +// correct; this covers the persistent resize path). + +interface CapturedRO { + callback: () => void; + el: Element; +} +let captured: CapturedRO[] = []; + +function makeResizeObserverCtor() { + return class FakeResizeObserver { + cb: () => void; + el: Element | null = null; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never }); + } + observe(el: Element) { + const capture = captured[captured.length - 1]; + if (capture) capture.el = el; + } + unobserve() {} + disconnect() {} + }; +} + +const baseOption = { + grid: { top: 24, left: 60, right: 24, bottom: 54 }, + xAxis: { type: 'category', data: ['a', 'b'] }, + yAxis: { type: 'value' }, + series: [{ name: 'x', type: 'line' as const, data: [1, 2], showSymbol: false }] +}; + +function hostSvgWidth(host: HTMLElement): number | null { + const svg = host.querySelector('svg'); + if (!svg) return null; + const attr = svg.getAttribute('width'); + if (attr) return parseFloat(attr); + const style = svg.getAttribute('style') ?? ''; + const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style); + return m ? parseFloat(m[1]) : null; +} + +describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => { + beforeEach(() => { + captured = []; + vi.stubGlobal('ResizeObserver', makeResizeObserverCtor()); + }); + + it('init uses measured/fallback size, then resizes the SVG when the host shrinks', async () => { + const host = document.createElement('div'); + host.style.width = '900px'; + document.body.appendChild(host); + const originalW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + Object.defineProperty(host, 'clientWidth', { configurable: true, get() { return 900; } }); + try { + const inst = mount(Chart, { + target: host, + props: { option: baseOption, height: 340, ariaLabel: '曲线' } + }); + // let the lazy echarts import mount + await new Promise((r) => setTimeout(r, 120)); + const ro = captured[captured.length - 1]; + expect(ro).toBeTruthy(); + expect(ro.el).toBe(host.firstElementChild); // observer watches the bind host + const inner = ro.el as HTMLElement; + + const svg = inner.querySelector('svg'); + expect(svg, 'echarts SVG renderer must be real in tests').toBeTruthy(); + // initial size: measured/fallback (target has no clientWidth -> 640) + expect(parseFloat(svg!.getAttribute('width')!)).toBe(640); + + // seed the chart at a "desktop" width, then shrink the host (viewport + // resize) and fire the observer: svg must follow, not stay sticky + const setW = (v: number) => Object.defineProperty(inner, 'clientWidth', { configurable: true, get() { return v; } }); + setW(900); + ro.callback(); + await new Promise((r) => setTimeout(r, 40)); + expect(parseFloat(inner.querySelector('svg')!.getAttribute('width')!)).toBe(900); + + // now the actual overflow scenario: shrink to 375-device width + setW(340); + ro.callback(); + await new Promise((r) => setTimeout(r, 40)); + + const svgAfter = inner.querySelector('svg')!; + const wAfter = svgAfter!.getAttribute('width'); + expect(wAfter, 'svg must shrink on resize, not keep desktop width').toBeTruthy(); + expect(parseFloat(wAfter!)).toBe(340); + + unmount(inst); + host.remove(); + vi.unstubAllGlobals(); + } finally { + Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 }); + } + }); + + it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(Chart, { + target: host, + props: { option: baseOption, height: 340, ariaLabel: '曲线' } + }); + await new Promise((r) => setTimeout(r, 120)); + const ro = captured[captured.length - 1]; + expect(ro?.el).toBeTruthy(); + // host with zero layout: callback must be a no-op (no degenerate 0-width resize) + expect(() => ro.callback()).not.toThrow(); + unmount(inst); + host.remove(); + vi.unstubAllGlobals(); + }); +}); diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts new file mode 100644 index 0000000..4937902 --- /dev/null +++ b/frontend/src/lib/client.ts @@ -0,0 +1,118 @@ +// Central typed binding layer for the 策研 Strategy Lab HTTP contract. +import { apiFetch } from './api'; +import type { + AIAssist, + AIUsage, + AdminUserUpdate, + BacktestResult, + Capabilities, + Dataset, + DatasetPreview, + DatasetRequest, + InstrumentSearchResponse, + Invitation, + Project, + Run, + SessionInfo, + User, + Version +} from './types'; + +export const getHealth = () => + apiFetch<{ status: string; version: string; worker_available: boolean; ai_configured: boolean }>('/api/health'); + +// ---- auth ---- +export const postLogin = (email: string, password: string) => + apiFetch<{ user: User }>('/api/auth/login', { method: 'POST', body: { email, password } }); +export const postLogout = () => apiFetch<void>('/api/auth/logout', { method: 'POST', body: {} }); +export const getMe = () => apiFetch<{ user: User }>('/api/auth/me'); +export const postRegister = (invite_token: string, name: string, email: string, password: string) => + apiFetch<{ user: User }>('/api/auth/register', { method: 'POST', body: { invite_token, name, email, password } }); +export const patchProfile = (name: string) => apiFetch<{ user: User }>('/api/auth/profile', { method: 'PATCH', body: { name } }); +export const postPassword = (current_password: string, new_password: string) => + apiFetch<void>('/api/auth/password', { method: 'POST', body: { current_password, new_password } }); +export const getSessions = () => apiFetch<{ items: SessionInfo[] }>('/api/auth/sessions'); +export const deleteSession = (id: string) => apiFetch<void>(`/api/auth/sessions/${id}`, { method: 'DELETE', body: {} }); +export const postResetPassword = (token: string, new_password: string) => + apiFetch<{ user?: User }>('/api/auth/reset-password', { method: 'POST', body: { token, new_password } }); + +// ---- capabilities ---- +export const getCapabilities = () => apiFetch<Capabilities>('/api/capabilities'); + +// ---- instruments ---- +export const searchInstruments = (q: string, signal?: AbortSignal) => + apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal }); + +// ---- projects ---- +export const listProjects = () => apiFetch<{ items: Project[] }>('/api/projects'); +export const createProject = (name: string, description?: string) => + apiFetch<Project>('/api/projects', { method: 'POST', body: { name, description } }); +export const getProject = (id: string) => apiFetch<Project>(`/api/projects/${id}`); +export const patchProject = (id: string, body: { name?: string; description?: string }) => + apiFetch<Project>(`/api/projects/${id}`, { method: 'PATCH', body }); +export const putDraft = (id: string, code: string, expected_generation: number) => + apiFetch<Project>(`/api/projects/${id}/draft`, { method: 'PUT', body: { code, expected_generation } }); +export const listVersions = (id: string) => apiFetch<{ items: Version[] }>(`/api/projects/${id}/versions`); +export const postVersion = (id: string, message: string) => + apiFetch<Version>(`/api/projects/${id}/versions`, { method: 'POST', body: { message } }); +export const getVersionCode = (id: string, versionId: string) => apiFetch<{ code: string }>(`/api/projects/${id}/versions/${versionId}`); +export const postRestore = (id: string, version_id: string, expected_generation: number) => + apiFetch<Project>(`/api/projects/${id}/restore`, { method: 'POST', body: { version_id, expected_generation } }); + +// ---- datasets ---- +export const listDatasets = () => apiFetch<{ items: Dataset[] }>('/api/datasets'); +export const createDataset = (body: DatasetRequest) => apiFetch<Dataset>('/api/datasets', { method: 'POST', body }); +export const getDataset = (id: string) => apiFetch<Dataset>(`/api/datasets/${id}`); +export const getDatasetPreview = (id: string) => apiFetch<DatasetPreview>(`/api/datasets/${id}/preview`); + +// ---- runs ---- +export const listRuns = (project_id?: string) => + apiFetch<{ items: Run[] }>(project_id ? `/api/runs?project_id=${encodeURIComponent(project_id)}` : '/api/runs'); +export interface RunCreateBody { + project_id: string; + dataset_id: string; + capital: number; + commission: number; + slippage: number; + benchmark_symbol?: string | null; + parameters?: Record<string, unknown>; + acknowledge_warnings?: boolean; +} +export const createRun = (body: RunCreateBody) => apiFetch<Run>('/api/runs', { method: 'POST', body }); +export const getRun = (id: string) => apiFetch<Run>(`/api/runs/${id}`); +export const cancelRun = (id: string) => apiFetch<Run>(`/api/runs/${id}/cancel`, { method: 'POST', body: {} }); +export const rerunRun = (id: string, use_original_data = true) => + apiFetch<Run>(`/api/runs/${id}/rerun`, { method: 'POST', body: { use_original_data } }); + +// ---- AI ---- +export const postAIAssist = (project_id: string, instruction: string, expected_generation: number) => + apiFetch<AIAssist>('/api/ai/assist', { method: 'POST', body: { project_id, instruction, expected_generation } }); +export const postAIAccept = (aiId: string, expected_generation: number) => + apiFetch<Project>(`/api/ai/${aiId}/accept`, { method: 'POST', body: { expected_generation } }); +export const getAIUsage = () => apiFetch<AIUsage>('/api/ai/usage'); + +// ---- admin ---- +export const listAdminUsers = () => apiFetch<{ items: User[] }>('/api/admin/users'); +export const patchAdminUser = (id: string, body: AdminUserUpdate) => + apiFetch<User>(`/api/admin/users/${id}`, { method: 'PATCH', body }); +export const postAdminUserPassword = (id: string) => + apiFetch<{ reset_token: string; expires_at: string }>(`/api/admin/users/${id}/reset-password`, { method: 'POST', body: {} }); +export const createInvitation = (body: { email?: string; role?: string; expires_hours?: number }) => + apiFetch<{ token: string; expires_at: string }>('/api/admin/invitations', { method: 'POST', body }); +export const listInvitations = () => apiFetch<{ items: Invitation[] }>('/api/admin/invitations'); +export const deleteInvitation = (id: string) => apiFetch<void>(`/api/admin/invitations/${id}`, { method: 'DELETE', body: {} }); +export const getAdminAudit = () => + apiFetch<{ items: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] }>( + '/api/admin/audit' + ); + +// helpers +export async function poll<T>(fn: () => Promise<T>, until: (v: T) => boolean, intervalMs = 1500): Promise<T> { + for (;;) { + const v = await fn(); + if (until(v)) return v; + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +export type { BacktestResult }; diff --git a/frontend/src/lib/coverage.test.ts b/frontend/src/lib/coverage.test.ts new file mode 100644 index 0000000..cf59d54 --- /dev/null +++ b/frontend/src/lib/coverage.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { summarizeCoverage, type CoverageEntry } from './coverage'; + +describe('summarizeCoverage', () => { + const entry = (o: Partial<CoverageEntry>): CoverageEntry => ({ + instrument: '600000', + market: 'SH', + asset_type: 'stock', + requested_start: '', + requested_end: '', + actual_start: '', + actual_end: '', + row_count: 1, + ...o + }); + + it('lists unaligned starts without invented dates', () => { + const rows = [ + entry({ instrument: 'A', actual_start: '2018-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' }), + entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' }) + ]; + const s = summarizeCoverage(rows); + expect(s.common.start).toBe('2020-01-02'); + expect(s.common.end).toBe('2024-12-31'); + expect(s.warnings.includes('B 起始数据较其他标的更晚(2020-01-02)')).toBe(true); + }); + + it('reports missing rows', () => { + const rows = [entry({ instrument: 'C', row_count: 0, actual_start: '', actual_end: '' })]; + const s = summarizeCoverage(rows); + expect(s.warnings.includes('C 没有返回任何数据行')).toBe(true); + expect(s.common.start).toBeNull(); + }); + + it('reports different end dates', () => { + const rows = [ + entry({ instrument: 'A', actual_start: '2020-01-02', actual_end: '2024-12-31' }), + entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-11-29' }) + ]; + const s = summarizeCoverage(rows); + expect(s.warnings.some((w) => w.includes('B 较早结束'))).toBe(true); + }); +}); diff --git a/frontend/src/lib/coverage.ts b/frontend/src/lib/coverage.ts new file mode 100644 index 0000000..c9a8447 --- /dev/null +++ b/frontend/src/lib/coverage.ts @@ -0,0 +1,52 @@ +export interface CoverageEntry { + instrument: string; + market?: string; + asset_type?: string; + requested_start?: string | null; + requested_end?: string | null; + actual_start?: string | null; + actual_end?: string | null; + row_count?: number; + warnings?: string[]; +} + +export interface CoverageSummary { + common: { start: string | null; end: string | null }; + warnings: string[]; +} + +export function summarizeCoverage(entries: CoverageEntry[]): CoverageSummary { + const warnings: string[] = []; + const withData = entries.filter((e) => (e.row_count ?? 0) > 0 && e.actual_start && e.actual_end); + for (const e of entries) { + if ((e.row_count ?? 0) === 0) { + warnings.push(`${e.instrument} 没有返回任何数据行`); + } else if (!e.actual_start || !e.actual_end) { + warnings.push(`${e.instrument} 数据区间不完整`); + } + } + let common: { start: string | null; end: string | null } = { start: null, end: null }; + if (withData.length > 0) { + const starts = withData.map((e) => e.actual_start as string).sort(); + const startsAsc = [...starts]; + const earliest = startsAsc[0]; + const lateStart = starts[starts.length - 1]; + if (lateStart > earliest) { + const owner = withData.find((e) => e.actual_start === lateStart); + if (owner) { + warnings.push(`${owner.instrument} 起始数据较其他标的更晚(${lateStart})`); + } + } + const ends = withData.map((e) => e.actual_end as string); + const commonEnd = ends.sort()[0]; + const earlyOwners = withData.filter((e) => e.actual_end === commonEnd); + const otherEnds = withData.filter((e) => (e.actual_end as string) > commonEnd); + if (otherEnds.length > 0) { + for (const e of earlyOwners) { + warnings.push(`${e.instrument} 较早结束(${commonEnd}),造成共有区间缺失`); + } + } + common = { start: lateStart, end: commonEnd }; + } + return { common, warnings }; +} diff --git a/frontend/src/lib/draftGuard.test.ts b/frontend/src/lib/draftGuard.test.ts new file mode 100644 index 0000000..a18de48 --- /dev/null +++ b/frontend/src/lib/draftGuard.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { unifiedDiffStats, ConflictResolver } from './draftGuard'; + +describe('unifiedDiffStats', () => { + it('counts added and removed lines', () => { + const s = unifiedDiffStats('a\nb\n', 'a\nc\nd\n'); + expect(s.added).toBe(2); + expect(s.removed).toBe(1); + }); + + it('is zero for identical inputs', () => { + const s = unifiedDiffStats('same', 'same'); + expect(s.added).toBe(0); + expect(s.removed).toBe(0); + }); +}); + +describe('ConflictResolver', () => { + const local = { generation: 1, code: 'x = 1\n' }; + + it('flags stale response: server generation ahead of local base', () => { + const r = new ConflictResolver(local); + const verdict = r.onServerState({ generation: 2, code: 'x = 2\n' }, local.generation); + expect(verdict.stale).toBe(true); + }); + + it('not stale when server generation equals local base generation', () => { + const r = new ConflictResolver(local); + const verdict = r.onServerState({ generation: 1, code: 'x = 1\n' }, local.generation); + expect(verdict.stale).toBe(false); + }); + + it('force-overwrite plan uses server generation to retry save', () => { + const r = new ConflictResolver(local); + const plan = r.retryPlan({ generation: 2, code: 'x = 2\n' } as const); + expect(plan).toEqual({ generation: 2, code: 'x = 1\n' }); + }); +}); diff --git a/frontend/src/lib/draftGuard.ts b/frontend/src/lib/draftGuard.ts new file mode 100644 index 0000000..d6a5222 --- /dev/null +++ b/frontend/src/lib/draftGuard.ts @@ -0,0 +1,46 @@ +import { diffLines, diffWordsWithSpace } from 'diff'; + +export interface DiffStats { + added: number; + removed: number; +} + +export function unifiedDiffStats(before: string, after: string): DiffStats { + const parts = diffLines(before, after); + let added = 0; + let removed = 0; + for (const p of parts) { + if (p.added) added += p.value.replace(/\n$/, '').split('\n').length; + else if (p.removed) removed += p.value.replace(/\n$/, '').split('\n').length; + } + return { added, removed }; +} + +export interface ServerDraft { + generation: number; + code: string; +} + +export interface ConflictVerdict { + stale: boolean; + serverEqualsLocal: boolean; + localDirty: boolean; +} + +export class ConflictResolver { + constructor(private local: { generation: number; code: string }) {} + + onServerState(server: ServerDraft, lastKnownGeneration: number): ConflictVerdict { + const stale = server.generation !== lastKnownGeneration && lastKnownGeneration !== null; + const serverEqualsLocal = server.generation === this.local.generation && server.code === this.local.code; + return { stale, serverEqualsLocal, localDirty: true }; + } + + retryPlan(server: ServerDraft): { generation: number; code: string } { + return { generation: server.generation, code: this.local.code }; + } +} + +export function wordDiff(before: string, after: string) { + return diffWordsWithSpace(before, after); +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..13e9d1e --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,118 @@ +import type { + BacktestMetrics, + Dataset, + Instrument, + Run +} from './types'; + +export function uuidLike(id: string): boolean { + return /^[0-9a-f-]+$/i.test(id); +} + +export function fmtDateTime(iso?: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString('zh-CN', { hour12: false }); +} + +export function fmtDate(iso?: string | null): string { + if (!iso) return '—'; + return iso.replace('T', ' ').replace(/Z$|\+08:00$/i, ''); +} + +export function fmtPct(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—'; + return `${(v * 100).toFixed(digits)}%`; +} + +export function fmtNum(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—'; + if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)} 亿`; + if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)} 万`; + let r = v.toFixed(digits); + if (r === '0.00' && v !== 0) r = '0'; + return r; +} + +export function itemSourceLabel(itemSource?: string | null, fallback = ''): string { + const s = itemSource || fallback; + if (!s) return '—'; + if (s === 'catalog') return '目录'; + if (s === 'provider_suggest') return '供应商'; + if (s === 'eastmoney') return '东财'; + if (s === 'tencent') return '腾讯'; + if (s === 'sina') return '新浪'; + return s; +} + +export const runStatusLabel: Record<Run['status'], string> = { + queued: '排队中', + running: '执行中', + succeeded: '已完成', + failed: '失败', + cancelled: '已取消' +}; + +export const runStatusBadge: Record<Run['status'], 'grey' | 'warn' | 'ok' | 'danger' | 'teal'> = { + queued: 'grey', + running: 'warn', + succeeded: 'ok', + failed: 'danger', + cancelled: 'grey' +}; + +export const datasetStatusLabel: Record<Dataset['status'], string> = { + pending: '等待中', + running: '获取数据', + ready: '就绪', + failed: '失败' +}; + +export const adjustmentLabel: Record<string, string> = { + none: '不复权', + qfq: '前复权', + hfq: '后复权' +}; + +export const assetTypeLabel: Record<string, string> = { + stock: '股票', + etf: 'ETF', + index: '指数' +}; + +export function instrumentLabel(i: Instrument): string { + const name = i.name ? ` ${i.name}` : ''; + return `${i.market}.${i.symbol}${name}`; +} + +export function formatMoney(v: number | null | undefined): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—'; + return `¥ ${fmtNum(v)}`; +} + +export const versionSourceLabel: Record<string, string> = { + manual: '手动快照', + run: '回测快照', + ai: 'AI 采用', + restore: '恢复产生' +}; + +export function metricsLabels(): string[] { + // trade_count semantics (worker/backtest.py): COUNT of closed round trips — + // an open that is fully closed afterwards counts as 1; fills/orders and + // cancellations do NOT count. Consistent label for the result card AND the + // run comparison tables. + return ['总收益率', '年化收益率', '最大回撤', '夏普比率', '平仓回合数', '期末权益']; +} + +export function metricsRow(m: BacktestMetrics): string[] { + return [ + fmtPct(m.total_return), + fmtPct(m.annual_return), + fmtPct(m.max_drawdown), + m.sharpe === null || m.sharpe === undefined ? '—' : m.sharpe.toFixed(2), + m.trade_count === null || m.trade_count === undefined ? '—' : String(m.trade_count), + m.final_equity === null || m.final_equity === undefined ? '—' : fmtNum(m.final_equity) + ]; +} diff --git a/frontend/src/lib/instrumentSearch.test.ts b/frontend/src/lib/instrumentSearch.test.ts new file mode 100644 index 0000000..15636be --- /dev/null +++ b/frontend/src/lib/instrumentSearch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createSearchRunner, + SEARCH_TIMEOUT_MSG, + SEARCH_UNAVAILABLE_MSG, + type SearchFetcher, + type SearchHandlers +} from './instrumentSearch'; +import type { Instrument, InstrumentSearchResponse } from './types'; + +function inst(symbol: string): Instrument { + return { symbol, market: 'SH', asset_type: 'stock', name: symbol, currency: 'CNY', source: 'eastmoney' }; +} + +function ok(items: Instrument[]): InstrumentSearchResponse { + return { items, source: 'provider_suggest', status: 'ok' }; +} + +function trackedHandlers(): SearchHandlers & { events: string[] } { + const events: string[] = []; + return { + events, + onBusy: () => events.push('busy'), + onResult: (res) => events.push(`result:${res.items.length}:${res.status}`), + onError: (m) => events.push(`error:${m}`), + onFinish: () => events.push('finish') + }; +} + +const flush = () => new Promise<void>((r) => setTimeout(r, 0)); + +describe('instrument search coordinator', () => { + it('successful provider response shows items and clears the spinner', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = vi.fn(async () => ok([inst('600000')])); + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + expect(h.events).toEqual(['busy', 'result:1:ok', 'finish']); + }); + + it('unavailable status shows an explicit Chinese message and clears items', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = async () => ({ items: [], source: 'none', status: 'unavailable: providers down' }); + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + expect(h.events).toContain(`error:${SEARCH_UNAVAILABLE_MSG}`); + expect(h.events[h.events.length - 1]).toBe('finish'); + }); + + it('provider rejection surfaces a visible failure and the spinner is cleared in finally', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = async () => { throw { status: 502, message: 'bad gateway' }; }; + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + expect(h.events.some((e) => e.startsWith('error:'))).toBe(true); + expect(h.events[h.events.length - 1]).toBe('finish'); + }); + + it('a hard timer aborts the hung request and shows the timeout wording', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = vi.fn(async (_q: string, signal?: AbortSignal) => + new Promise<InstrumentSearchResponse>((_, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('x', 'AbortError'))); + })); + const runner = createSearchRunner(fetch, h, { timeoutMs: 30 }); + runner.runNow('600000'); + await flush(); + await new Promise((r) => setTimeout(r, 60)); + expect(h.events).toContain(`error:${SEARCH_TIMEOUT_MSG}`); + expect(h.events[h.events.length - 1]).toBe('finish'); // spinner never hangs + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('stale prior query: its late result never repaints and never hangs the spinner', async () => { + const h = trackedHandlers(); + let releaseA!: (v: InstrumentSearchResponse) => void; + let calls = 0; + const fetch: SearchFetcher = vi.fn(async () => { + calls += 1; + if (calls === 1) { + return new Promise<InstrumentSearchResponse>((resolve) => { releaseA = resolve; }); + } + return ok([inst('600000')]); + }); + const runner = createSearchRunner(fetch, h); + runner.runNow('old'); + await flush(); + const busyCount = h.events.filter((e) => e === 'busy').length; + runner.runNow('new'); + await flush(); + // first request aborted; the slow promise is released AFTER the swap and + // resolves into a stale seq → no UI effect, no extra finish + releaseA(ok([inst('000001')])); + await flush(); + const busyAfter = h.events.filter((e) => e === 'busy').length; + expect(busyAfter).toBe(2); + expect(h.events[h.events.length - 1]).toBe('finish'); + expect(h.events[busyCount - 1]).toBe('busy'); + expect(h.events.join('|')).not.toContain('result:1:ok|finish|result'); + expect(h.events.filter((e) => e.startsWith('result')).length).toBe(1); + }); + + it('dispose invalidates in-flight callbacks entirely', async () => { + const h = trackedHandlers(); + let pendings = 0; + const fetch: SearchFetcher = async () => { + pendings += 1; + return new Promise<InstrumentSearchResponse>(() => { /* pending */ }); + }; + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + const eventsAtDispose = h.events.length; + runner.dispose(); + await flush(); + expect(h.events.length).toBe(eventsAtDispose); // nothing further fired + }); +}); diff --git a/frontend/src/lib/instrumentSearch.ts b/frontend/src/lib/instrumentSearch.ts new file mode 100644 index 0000000..8a83a47 --- /dev/null +++ b/frontend/src/lib/instrumentSearch.ts @@ -0,0 +1,120 @@ +// Production search bug fix /instrument-search coordinator. +// +// Root cause of the hung spinner: the API previously did a full catalog fetch +// server-side. Client-side we now keep the round trip bounded end to end: +// debounce + abort of prior stale queries + a hard AbortController timer, and +// explicit handling of the honest failure statuses the backend now returns. +// Pure dependency injection makes stale-query cleanup unit testable. +import { apiFetch } from './api'; +import { extractStatusMessage } from './state'; +import type { InstrumentSearchResponse } from './types'; + +export const SEARCH_TIMEOUT_MS = 12_000; +export const SEARCH_DEBOUNCE_MS = 350; + +export const SEARCH_UNAVAILABLE_MSG = '搜索数据源暂时不可用,请稍后重试'; +export const SEARCH_TIMEOUT_MSG = '搜索超时,请重试'; +export const SEARCH_EMPTY_MSG = '未找到匹配标的,可使用下方手动录入并核对身份'; + +export type SearchFetcher = ( + q: string, + signal?: AbortSignal +) => Promise<InstrumentSearchResponse>; + +export function fetchSearch(q: string, signal?: AbortSignal): Promise<InstrumentSearchResponse> { + return apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal }); +} + +export interface SearchHandlers { + onBusy(): void; + onResult(res: InstrumentSearchResponse): void; + onError(message: string): void; + onFinish(): void; +} + +export interface SearchRunner { + runNow(q: string): void; + requestDebounced(q: string): void; + dispose(): void; +} + +export interface SearchRunnerOptions { + timeoutMs?: number; + debounceMs?: number; +} + +export function createSearchRunner( + doFetch: SearchFetcher = fetchSearch, + handlers: SearchHandlers, + options: SearchRunnerOptions = {}, +): SearchRunner { + const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS; + const debounceMs = options.debounceMs ?? SEARCH_DEBOUNCE_MS; + let seq = 0; + let controller: AbortController | null = null; + let timer: ReturnType<typeof setTimeout> | null = null; + + function clearTimer() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + + async function run(q: string) { + const mySeq = ++seq; + // abort the previous in-flight request first: its callbacks all become + // stale (mySeq !== seq) and must not touch the UI + controller?.abort(); + controller = new AbortController(); + const ctl = controller; + const timeout = setTimeout(() => ctl.abort(), timeoutMs); + timer = timeout; + handlers.onBusy(); + try { + const res = await doFetch(q, ctl.signal); + if (mySeq !== seq) return; + if (res.status === 'ok') { + handlers.onResult(res); + } else { + handlers.onResult({ items: [], source: 'none', status: res.status }); + handlers.onError(SEARCH_UNAVAILABLE_MSG); + } + } catch (e) { + if (mySeq !== seq) return; + const signalAborted = ctl.signal.aborted; + const abortError = (e as { name?: string }).name === 'AbortError'; + handlers.onError( + signalAborted || abortError ? SEARCH_TIMEOUT_MSG : extractStatusMessage(e), + ); + } finally { + if (timer === timeout) timer = null; + clearTimeout(timeout); + // stale requests never touch busy state (their handler may already have + // been superseded); only the still-current query may reset the spinner + if (mySeq === seq) handlers.onFinish(); + } + } + + return { + runNow: (q: string) => { + clearTimer(); + if (!q.trim()) return; + void run(q.trim()); + }, + requestDebounced: (q: string) => { + clearTimer(); + if (!q.trim()) return; + timer = setTimeout(() => { + timer = null; + void run(q.trim()); + }, debounceMs); + }, + dispose: () => { + clearTimer(); + seq += 1; // invalidate any in-flight callbacks + controller?.abort(); + controller = null; + }, + }; +} diff --git a/frontend/src/lib/layout.test.ts b/frontend/src/lib/layout.test.ts new file mode 100644 index 0000000..99d3c47 --- /dev/null +++ b/frontend/src/lib/layout.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +// `?raw` gives us the exact Svelte source for a real regression check +// (ProjectPage used to wrap a second <Layout>, doubling the sidebar). +// Type support comes from vite/client's `*?raw` module declaration. +import projectPageSource from '../pages/ProjectPage.svelte?raw'; + +describe('single layout shell', () => { + it('ProjectPage does not nest its own Layout', () => { + const src: string = projectPageSource as unknown as string; + expect(src).not.toMatch(/import\s+Layout/); + expect(src).not.toMatch(/<Layout/); + // page props still passed through on each tab + expect(src).toMatch(/ProjectDataTab/); + expect(src).toMatch(/ProjectStrategyTab/); + expect(src).toMatch(/ProjectBacktestTab/); + expect(src).toMatch(/ProjectResultsTab/); + }); +}); diff --git a/frontend/src/lib/listsStore.svelte.ts b/frontend/src/lib/listsStore.svelte.ts new file mode 100644 index 0000000..102543f --- /dev/null +++ b/frontend/src/lib/listsStore.svelte.ts @@ -0,0 +1,92 @@ +import type { Project } from './types'; +import { getProject, listProjects } from './client'; +import { ApiError } from './api'; + +let projects: Project[] = $state([]); + +// ids that failed to fetch because the project is not visible to this session +// (404 not found / 403 not owner). Remembered so the sidebar can show an honest +// 不可见项目 state instead of an endless 加载中… retry loop. Cleared on logout / +// session switch so cross-user stale names or failure marks never leak. +// $state map so derived views (sidebar) re-react when a mark is added/cleared. +let invisibleIds: Record<string, boolean> = $state({}); +let inflight = new Map<string, Promise<Project | null>>(); + +export interface ProjectsStore { + get all(): Project[]; + set(items: Project[]): void; + /** fetch + cache a project the sidebar/derived views cannot see yet (deep link) */ + ensure(id: string): Promise<Project | null>; + bump(p: Project): void; + /** reactive lookup: reads only the $state projects array */ + get(id: string): Project | undefined; + load(): Promise<Project[]>; + /** drop all cached projects + failure marks (must be called on logout so a + * future session on the same browser cannot see the previous user's names) */ + clear(): void; +} + +function upsert(p: Project) { + const idx = projects.findIndex((x) => x.id === p.id); + if (idx >= 0) projects[idx] = p; + else projects = [...projects, p]; +} + +export const projectsStore: ProjectsStore = { + get all() { + return projects; + }, + set(items: Project[]) { + projects = items; + }, + get(id: string) { + return projects.find((p) => p.id === id); + }, + bump(p: Project) { + upsert(p); + }, + async ensure(id: string) { + const known = projects.find((p) => p.id === id); + if (known) return known; + if (invisibleIds[id]) return null; + const pending = inflight.get(id); + if (pending) return pending; + const fetchPromise = (async () => { + try { + const p = await getProject(id); + upsert(p); + return p; + } catch (e) { + if (e instanceof ApiError && (e.status === 403 || e.status === 404)) { + invisibleIds = { ...invisibleIds, [id]: true }; + return null; + } + throw e; + } finally { + inflight.delete(id); + } + })(); + inflight.set(id, fetchPromise); + return fetchPromise; + }, + async load() { + const { items } = await listProjects(); + projects = items; + return items; + }, + clear() { + projects = []; + invisibleIds = {}; + inflight.clear(); + } +}; + +/** reactive derived read for components */ +export function projectOf(id: string): Project | undefined { + return projectsStore.get(id); +} + +/** true when a previous ensure() proved the id is not visible for this session */ +export function projectInvisible(id: string): boolean { + return !!invisibleIds[id]; +} diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts new file mode 100644 index 0000000..c4d1efb --- /dev/null +++ b/frontend/src/lib/mobileCss.test.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +import { describe, expect, it } from 'vitest'; +// Live 375px browser overflow regression (artifacts/qa/supervisor-live/ +// mobile-overflow-probe.json): document scrollWidth measured 532 → 552 at a +// 375px viewport, sidebar/main 552px, cards 523px; DOM authoritative (a +// full-page screenshot expands to the overflow width, so source/DOM checks +// are the honest regression). Root causes are real CSS: grid min-content +// blow-up (layout/grid3), nowrap table cells, and unbreakable provider error +// URLs. CSS `?raw` imports are stubbed empty by vitest, so the real source +// files are read from disk instead. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src'); +const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8'); +const layoutSource = readFileSync(path.join(srcRoot, 'components', 'Layout.svelte'), 'utf8'); +const datasetCard = readFileSync(path.join(srcRoot, 'components', 'DatasetCard.svelte'), 'utf8'); + +describe('375px responsive sizing (no global content hiding)', () => { + it('layout columns shrink below min-content (no document-level overflow)', () => { + // the mobile layout must be allowed to shrink (minmax(0,1fr), not bare 1fr) + expect(layoutSource).toMatch(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.layout\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/); + // the content column opts out of min-content propagation + expect(layoutSource).toMatch(/\.content\s*\{[^}]*min-width:\s*0/); + // project tab context wraps instead of forcing one long line + expect(layoutSource).toMatch(/\.projctx\s*\{[^}]*flex-wrap:\s*wrap/); + }); + + it('multi-column forms and tables collapse/wrap on narrow view', () => { + expect(appCss).toContain('@media (max-width: 900px)'); + expect(appCss).toContain('.grid2, .grid3 { grid-template-columns: minmax(0, 1fr); }'); + expect(appCss).toContain('table.data th, table.data td {'); + expect(appCss).toContain('white-space: normal'); + }); + + it('long real provider errors wrap instead of inflating width', () => { + // `overflow-wrap: anywhere` wraps even long URL payloads; NOT + // overflow-x:hidden on body/document (that would hide content). + expect(appCss).toMatch(/\.banner\s*\{[^}]*overflow-wrap:\s*anywhere/); + expect(appCss.includes('body { overflow-x: hidden')).toBe(false); + expect(appCss.includes('html { overflow-x: hidden')).toBe(false); + }); + + it('dataset card keeps a local scroll container for tabular data', () => { + expect(datasetCard).toContain('scrollx'); + }); +}); + +function getMediaBlock(css, query) { + const idx = css.indexOf(`@media ${query}`); + if (idx < 0) return null; + const open = css.indexOf('{', idx); + let depth = 1; + for (let i = open + 1; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { + depth--; + if (depth === 0) return css.slice(idx, i + 1); + } + } + return null; +} diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts new file mode 100644 index 0000000..226e1d0 --- /dev/null +++ b/frontend/src/lib/resultsAccounting.test.ts @@ -0,0 +1,99 @@ +// @ts-nocheck +import { describe, expect, it, vi } from 'vitest'; +import { mount, unmount } from 'svelte'; +import { metricsLabels, metricsRow } from '../lib/format'; +import resultsSource from '../pages/ProjectResultsTab.svelte?raw'; + +// Bounded accounting regression (parent live findings): +// 1) trade_count semantics: worker/backtest.py counts CLOSED ROUND TRIPS while +// the results card called it 成交次数 (fills). Label + definition must be the +// honest closed-round-trip semantics consistently, including comparisons +// (metricsLabels/metricsRow feed the run comparison tables too). +// 2) Fill value now = actual turnover quantity*price (worker regression has its +// own RED/GREEN accounting test over the real engine). +// 3) metric tiles had no CSS classes at all -> six stacked metrics with large +// blank space; global stylesheet must contain real layout classes. + +const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b'; +const RUN_ID = '569b23d0-1111-4222-8333-4444444444442'; + +function resultBody() { + return { + id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', + status: 'succeeded', + config: { capital: 100000, commission: 0.0003, slippage: 0.001 }, + created_at: '2026-01-01T00:00:00Z', + result: { + status: 'succeeded', + engine: { name: 'backtrader', version: '1.9' }, + metrics: { total_return: 0.0123, annual_return: 0.05, max_drawdown: -0.033, sharpe: 0.9, trade_count: 1, final_equity: 101234.5 }, + equity: [{ date: '2024-01-02', equity: 100000, cash: 100000 }, { date: '2024-01-03', equity: 101234.5, cash: 98967.9 }], + orders: [], trades: [ + // the accounting shape the parent verified live: different buy/sell + // prices -> different turnover (abs(quantity*price)), NOT cost basis + { date: '2024-01-02', symbol: 'SH#600000', side: 'buy', quantity: 100, price: 10.2, commission: 0.30606, value: 1020.0 }, + { date: '2024-01-03', symbol: 'SH#600000', side: 'sell', quantity: 100, price: 10.4, commission: 0.32, value: 1040.0 }, + ], closed_trades: [], + logs: ['Engine is required', 'Final writing'], warnings: [], + elapsed_ms: 1234, peak_rss_kb: 8192, data_manifest_hash: 'deadbeefdeadbeef1234', + }, + }; +} + +function runBody() { + return { id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', status: 'succeeded', config: {}, created_at: '2026-01-01T00:00:00Z' }; +} + +const fetchMockOnce = () => (url) => { + if (/\/api\/runs\/[0-9a-f-]+$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify(resultBody()), { status: 200, headers: { 'content-type': 'application/json' } })); + if (/\/api\/runs\?|\/api\/runs$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify({ items: [runBody()] }), { status: 200, headers: { 'content-type': 'application/json' } })); + if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url))) + return Promise.resolve(new Response(JSON.stringify({ id: PROJECT_ID, name: '端到端验收项目', draft_code: 'x=1', draft_generation: 2 }), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); +}; + +describe('results accounting labels + values (live browser accounting)', () => { + it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', async () => { + // jsdom lacks ResizeObserver; Chart.svelte needs it only for responsive + // canvas resize, which jsdom never triggers. Narrow environment shim. + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + vi.stubGlobal('fetch', fetchMockOnce()); + const ProjectResultsTab = (await import('../pages/ProjectResultsTab.svelte')).default; + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(ProjectResultsTab, { target: host, props: { projectId: PROJECT_ID, runId: RUN_ID } }); + await new Promise((r) => setTimeout(r, 80)); + try { + const text = host.textContent; + // closed round trip semantics (not fill count) + expect(text).toContain('平仓回合数'); + expect(text).toContain('已完成的开仓并全部平仓的完整回合'); + expect(text).not.toContain('成交次数(不含撤单)'); + // numeric trade_count shows through: equity div reached rendering + expect(host.querySelectorAll('.metric').length).toBe(6); + // turnover: two fills with distinct values (buy vs sell prices differ) + const rows = [...host.querySelectorAll('td.num')].map((td) => td.textContent.trim()); + const hasSell = [...host.children].join('').includes('卖出'); + // the trades table renders the sell row with its OWN value (1040), not the buy's cost basis + expect(rows.filter((v) => v === '1020.00' || v === '1040.00').length).toBe(2); + // trade_count metric value renders + expect([...host.querySelectorAll('.mvalue')].some((n) => n.textContent === '1')).toBe(true); + } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); } }); + + it('comparison labels use the same closed-round-trip semantics', () => { + const labels = metricsLabels(); + expect(labels).toContain('平仓回合数'); + const m = metricsRow({ total_return: 0.1, annual_return: 0.2, max_drawdown: -0.1, sharpe: 1, trade_count: 1, final_equity: 110000 }); + const label = labels[m.findIndex((_, i) => i === labels.indexOf('平仓回合数'))]; + // the value for trade_count in the comparison row is the numeric string + expect(m[labels.indexOf('平仓回合数')]).toBe('1'); + expect(labels).not.toContain('交易次数'); // old misleading '交易次数' gone + }); + + it('stylesheet defines the shared metric tile layout (no stacking blank space)', () => { + const template: string = resultsSource as unknown as string; + // keep the editor honest: no leftover 成交次数 wording in the metrics card + expect(template).toContain('平仓回合数'); + expect(template).not.toContain('成交次数'); + }); +}); diff --git a/frontend/src/lib/router.test.ts b/frontend/src/lib/router.test.ts new file mode 100644 index 0000000..944e342 --- /dev/null +++ b/frontend/src/lib/router.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { routeFromHash, projectPath } from './router'; + +function setHash(h: string) { + window.location.hash = h; +} + +describe('router', () => { + it('routes auth pages', () => { + setHash('#/login'); + expect(routeFromHash().name).toBe('login'); + setHash('#/register'); + expect(routeFromHash().name).toBe('register'); + setHash('#/reset'); + expect(routeFromHash().name).toBe('reset'); + }); + + it('routes protected areas', () => { + setHash('#/projects'); + expect(routeFromHash().name).toBe('projects'); + setHash('#/datasets'); + expect(routeFromHash().name).toBe('datasets'); + setHash('#/runs'); + expect(routeFromHash().name).toBe('runs'); + setHash('#/usage'); + expect(routeFromHash().name).toBe('usage'); + setHash('#/account'); + expect(routeFromHash().name).toBe('account'); + setHash('#/admin'); + expect(routeFromHash().name).toBe('admin'); + }); + + it('routes project tabs', () => { + setHash('#/projects/abc-1/strategy'); + const r = routeFromHash(); + expect(r.name).toBe('project-strategy'); + expect(r.params.id).toBe('abc-1'); + setHash('#/projects/abc-1'); + expect(routeFromHash().name).toBe('project-data'); + setHash('#/projects/abc-1/backtest'); + expect(routeFromHash().name).toBe('project-backtest'); + setHash('#/projects/abc-1/results?run=r9'); + const rr = routeFromHash(); + expect(rr.name).toBe('project-results'); + expect(rr.query.get('run')).toBe('r9'); + }); + + it('projects deep path builder', () => { + expect(projectPath('id1', 'strategy')).toBe('/projects/id1/strategy'); + }); +}); diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts new file mode 100644 index 0000000..3c2631a --- /dev/null +++ b/frontend/src/lib/router.ts @@ -0,0 +1,43 @@ +export interface Route { + name: string; + params: Record<string, string>; + query: URLSearchParams; +} + +export function routeFromHash(): Route { + const raw = window.location.hash.replace(/^#/, '') || '/login'; + const [pathPart, queryPart] = raw.split('?'); + const segments = pathPart.split('/').filter(Boolean); + const query = new URLSearchParams(queryPart ?? ''); + if (segments[0] === 'login' || segments.length === 0) return { name: 'login', params: {}, query }; + if (segments[0] === 'register') return { name: 'register', params: {}, query }; + if (segments[0] === 'reset') return { name: 'reset', params: {}, query }; + if (segments[0] === 'account') return { name: 'account', params: {}, query }; + if (segments[0] === 'admin') return { name: 'admin', params: {}, query }; + if (segments[0] === 'datasets') return { name: 'datasets', params: {}, query }; + if (segments[0] === 'runs') return { name: 'runs', params: {}, query }; + if (segments[0] === 'usage') return { name: 'usage', params: {}, query }; + if (segments[0] === 'projects') { + if (!segments[1]) return { name: 'projects', params: {}, query }; + const id = segments[1]; + const tab = segments[2] ?? 'data'; + if (['data', 'strategy', 'backtest', 'results'].includes(tab)) { + return { name: `project-${tab}`, params: { id }, query }; + } + return { name: 'project-data', params: { id }, query }; + } + return { name: 'login', params: {}, query }; +} + +export function go(path: string) { + if (!path.startsWith('#')) path = `#${path.startsWith('/') ? path : `/${path}`}`; + window.location.hash = path; +} + +export function replace(path: string) { + go(path); +} + +export function projectPath(id: string, tab: 'data' | 'strategy' | 'backtest' | 'results') { + return `/projects/${id}/${tab}`; +} diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts new file mode 100644 index 0000000..47b452f --- /dev/null +++ b/frontend/src/lib/session.svelte.ts @@ -0,0 +1,45 @@ +import type { User } from './types'; +import { getMe } from './client'; + +class SessionStore { + me = $state<User | null>(null); + status = $state<'loading' | 'anon' | 'authed'>('loading'); + + async refresh(): Promise<User | null> { + try { + const { user } = await getMe(); + if (!user || typeof user.id !== 'string' || user.id.length === 0) { + this.clear(); + return null; + } + this.me = user; + this.status = 'authed'; + return user; + } catch (e) { + const status = (e as { status?: number }).status ?? 0; + if (status === 0) { + // network/protocol error: keep previous state so UI can show retry instead + if (this.status === 'loading') this.clear(); + return null; + } + this.clear(); + return null; + } + } + + clear() { + this.me = null; + this.status = 'anon'; + } + + set(u: User | null) { + if (u && typeof u.id === 'string' && u.id.length > 0) { + this.me = u; + this.status = 'authed'; + } else { + this.clear(); + } + } +} + +export const session: SessionStore = new SessionStore(); diff --git a/frontend/src/lib/sidebarCurrentProject.test.ts b/frontend/src/lib/sidebarCurrentProject.test.ts new file mode 100644 index 0000000..5dc9b9d --- /dev/null +++ b/frontend/src/lib/sidebarCurrentProject.test.ts @@ -0,0 +1,186 @@ +// @ts-nocheck +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { mount, unmount, flushSync } from 'svelte'; +import Layout from '../components/Layout.svelte'; +import { session } from '../lib/session.svelte'; +import { projectsStore, projectInvisible, projectOf } from '../lib/listsStore.svelte'; +import { ApiError } from '../lib/api'; +import type { Route } from '../lib/router'; + +// Bounded sidebar regression (parent live finding): on a deep link or first +// navigation into a project tab the sidebar showed an endless 加载中… because +// projectOf() only read the (empty) list store and nothing fetched the missing +// project. Fix: Layout triggers projectsStore.ensure() for unknown ids; the +// name then resolves reactively, updates on in-tab changes (bump), and a +// non-visible project (404/403) shows an honest 不可见项目 state instead of a +// retry loop. Failure marks + cache are cleared on logout so no cross-user +// stale name (or invisible mark from another user's project) can leak. + +const PID = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc01'; +const PID_OTHER = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc02'; +const route = (name: string, id?: string): Route => + id + ? { name, params: { id }, query: new URLSearchParams() } + : { name: 'projects', params: {}, query: new URLSearchParams() }; + +const USER = { id: 'u1', name: '测试用户', role: 'user', session_id: 's1' }; + +function projectBody(name = '回测验收项目') { + return { + id: PID, + name, + owner_id: 'u1', + draft_code: 'x=1', + draft_generation: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }; +} + +async function mountLayout(r: Route) { + const el = document.createElement('div'); + document.body.appendChild(el); + const inst = mount(Layout, { target: el, props: { route: r } }); + flushSync(); + await new Promise((res) => setTimeout(res, 50)); + flushSync(); + return { el, inst }; +} + +describe('sidebar current project (deep link / navigation / updates)', () => { + beforeEach(() => { + session.set(USER as never); + projectsStore.clear(); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + }); + + it('deep link into a project tab resolves 当前项目 name without another page loading first', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', (url) => { + calls.push(String(url)); + if (String(url).includes(`/api/projects/${PID}`)) + return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-strategy', PID)); + try { + expect(el.textContent).toContain('回测验收项目'); + expect(el.textContent).not.toContain('加载中'); + expect(el.textContent).not.toContain('不可见项目'); + // exactly ONE direct project fetch (deduped by ensure(); no retry loops) + const projectCalls = calls.filter((u) => u.includes(`/api/projects/${PID}`)); + expect(projectCalls.length).toBe(1); + // project is now visible through the shared reactive store + expect(projectOf(PID)?.name).toBe('回测验收项目'); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + }); + + it('reacts to in-tab updates (bump) without extra fetching, then navigates between tabs', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', (url) => { + calls.push(String(url)); + if (String(url).includes(`/api/projects/${PID}`)) + return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-data', PID)); + try { + expect(el.textContent).toContain('回测验收项目'); + + // one shared ensure cache: opening another tab of the SAME project does + // not refetch and stays showing the name + const { inst: inst2, el: el2 } = await mountLayout(route('project-backtest', PID)); + try { + expect(el2.textContent).toContain('回测验收项目'); + expect(calls.filter((u) => u.includes(`/api/projects/${PID}`)).length).toBe(1); + } finally { + unmount(inst2); + el2.remove(); + } + + // in-tab save/restore/AI bumps the store -> sidebar updates in place + projectsStore.bump({ ...projectBody('改名后的验收项目') } as never); + flushSync(); + expect(el.textContent).toContain('改名后的验收项目'); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + }); + + it('shows 不可见项目 (not an endless 加载中) for another user\'s/deleted project and clears marks on logout', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', (url) => { + calls.push(String(url)); + return Promise.resolve(new Response(JSON.stringify({ error: { code: 'not_found', message: 'no' } }), { status: 404, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-results', PID_OTHER)); + try { + expect(el.textContent).toContain('不可见项目'); + expect(el.textContent).not.toContain('回测验收项目'); + // retry guard: layout remount doesn't hammer a known-invisible project + await mountLayout(route('project-results', PID_OTHER)).then(({ inst: i2, el: e2 }) => { + unmount(i2); + e2.remove(); + }); + expect(calls.filter((u) => u.includes(`/api/projects/${PID_OTHER}`)).length).toBe(1); + expect(projectInvisible(PID_OTHER)).toBe(true); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + + // logout must clear the failure marks + cache: the NEXT session must not + // inherit invisible marks or cached project names (cross-user leakage) + projectsStore.clear(); + expect(projectInvisible(PID_OTHER)).toBe(false); + expect(projectOf(PID)).toBeUndefined(); + }); + + it('transient network errors are not remembered as invisible (honest retry)', async () => { + let fail = true; + // phase 1: the fetch to the project id rejects with a network-layer error + vi.stubGlobal('fetch', (url) => { + if (String(url).includes(`/api/projects/${PID}`)) { + if (fail) throw new TypeError('network down'); + return new Promise<Response>(() => {}); + } + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-data', PID)); + try { + // network error: not marked invisible; sidebar keeps the honest 加载中… + expect(el.textContent).toContain('加载中'); + expect(projectInvisible(PID)).toBe(false); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + // phase 2: a later retry (fresh fetch stub) succeeds and renders + vi.stubGlobal('fetch', (url) => { + if (String(url).includes(`/api/projects/${PID}`)) + return Promise.resolve(new Response(JSON.stringify(projectBody('重试后的项目')), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + projectsStore.clear(); + const once = await mountLayout(route('project-data', PID)); + // allow the ensure() fetch+reactive update to settle regardless of load + await new Promise((res) => setTimeout(res, 300)); + flushSync(); + try { + expect(projectOf(PID)?.name).toBe('重试后的项目'); + expect(once.el.textContent).toContain('重试后的项目'); + } finally { + unmount(once.inst); + once.el.remove(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts new file mode 100644 index 0000000..3beb2c0 --- /dev/null +++ b/frontend/src/lib/state.ts @@ -0,0 +1,15 @@ +import type { ApiError } from './api'; + +export function extractStatusMessage(err: unknown): string { + const e = err as Partial<ApiError>; + if (e && typeof e.status === 'number') { + return `联网出错:HTTP ${e.status} ${e.message ?? ''}`.trim(); + } + if (e && e.message) return `出错:${e.message}`; + return '出错:未知错误,请稍后再试'; +} + +export function statusByName(err: unknown): string { + const code = (err as Partial<ApiError>)?.code ?? ''; + return code; +} diff --git a/frontend/src/lib/strategyTab.test.ts b/frontend/src/lib/strategyTab.test.ts new file mode 100644 index 0000000..a5bf6d8 --- /dev/null +++ b/frontend/src/lib/strategyTab.test.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { mount, unmount } from 'svelte'; +// NOTE: `resolve.conditions: ['browser']` lets vitest resolve svelte's client +// build so actual component mounting works in jsdom. +import { putDraft } from '../lib/client'; +import { ApiError } from '../lib/api'; + +// Live regression (artifacts/qa/supervisor-live/browser-strategy.json): +// ProjectStrategyTab rendered `c(...).slice is not a function` pageerror and a +// blank main because the backend sends draft_generation as a NUMBER (i64) while +// the frontend typed it as a string and called .slice on it. Backend keeps the +// integer; these tests exercise the ACTUAL numeric API shape through the real +// fetch client and the REAL component render in jsdom (no source-string-only +// assertions). + +const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b'; + +const json = (v, status = 200) => + new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json' } }); + +function projectBody(generation) { + return { + id: PROJECT_ID, name: '端到端验收项目', description: null, + draft_code: 'import backtrader as bt\nclass Strategy(bt.Strategy):\n pass\n', + draft_generation: generation, // NUMERIC contract from the backend (i64) + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }; +} + +async function mountStrategyTab(fetchImpl) { + vi.stubGlobal('fetch', fetchImpl); + const ProjectStrategyTab = (await import('../pages/ProjectStrategyTab.svelte')).default; + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(ProjectStrategyTab, { target: host, props: { projectId: PROJECT_ID } }); + // flush effects/microtasks + await new Promise((r) => setTimeout(r, 50)); + return { host, inst, cleanup: () => { try { unmount(inst); } catch {} host.remove(); vi.unstubAllGlobals(); } }; +} + +function handler() { + return (url, _opts = {}) => { + if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url))) + return Promise.resolve(json(projectBody(3))); + if (/\/versions/.test(String(url))) + return Promise.resolve(json({ items: [] })); + return Promise.resolve(json({})); + }; +} + +describe('strategy tab numeric generation contract (pageerror regression)', () => { + beforeEach(() => { document.body.innerHTML = ''; }); + afterEach(() => vi.unstubAllGlobals()); + + it('full numeric flow renders editor + 草稿代 3 without .slice crash', async () => { + const { host, cleanup } = await mountStrategyTab(handler()); + try { + const text = host.textContent; + // editor content from the numeric flow (component reached rendering) + expect(text).toContain('class Strategy(bt.Strategy)'); + // generation renders as the actual number, never a truncated-slice crash + expect(text).toMatch(/草稿代\s*3(?:\s|$|·)/); + expect(text).not.toContain('slice is not a function'); + } finally { cleanup(); } + }); + + it('client PUT sends numeric expected_generation and accepts numeric response', async () => { + const seen = []; + vi.stubGlobal('fetch', (url, opts = {}) => { + seen.push([String(url), opts]); + return Promise.resolve(json({ id: PROJECT_ID, draft_code: 'x=1', draft_generation: 4 })); + }); + const res = await putDraft(PROJECT_ID, 'x=1', 3); + expect(res.draft_generation).toBe(4); + expect(typeof res.draft_generation).toBe('number'); + const [, sentOpts] = seen[0]; + const sentBody = typeof sentOpts.body === 'string' ? JSON.parse(sentOpts.body) : sentOpts.body; + expect(sentBody.expected_generation).toBe(3); + expect(typeof sentBody.expected_generation).toBe('number'); + }); + + it('409 conflict keeps stale_generation code (numeric retry flow preserved)', async () => { + vi.stubGlobal('fetch', (url) => { + if (/\/draft$/.test(String(url))) + return Promise.resolve(new Response(JSON.stringify({ error: { code: 'stale_generation', message: 'g3' } }), { status: 409, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(json({})); + }); + let err; + await putDraft(PROJECT_ID, 'stale', 2).catch((e) => { err = e; }); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(409); + expect(err.code).toBe('stale_generation'); + }); +}); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..db065e4 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,244 @@ +import type { CoverageEntry } from './coverage'; + +export type { CoverageEntry }; + +export interface User { + id: string; + email: string; + name: string; + role: 'admin' | 'member'; + active: boolean; + daily_run_limit?: number | null; + ai_enabled?: boolean; + created_at?: string; +} + +export interface Project { + id: string; + name: string; + description: string | null; + draft_code: string; + draft_generation: number; + draft_code_preview?: string; + created_at: string; + updated_at: string; +} + +export interface Version { + id: string; + project_id: string; + message: string; + hash: string; + source: 'manual' | 'run' | 'ai' | 'restore'; + created_at: string; + code?: string; +} + +export interface Instrument { + symbol: string; + market: string; + asset_type: 'stock' | 'etf' | 'index'; + name: string | null; + currency: string | null; + source?: string | null; +} + +export interface InstrumentSearchItem extends Instrument { + symbol_requested?: string; +} + +export interface InstrumentSearchResponse { + items: Instrument[]; + source: 'provider_suggest' | 'operator_entry' | 'none'; + status: string; +} + +export interface DatasetManifestObject { + instrument: string; + market?: string; + asset_type?: string; + provider: string; + endpoint?: string; + params?: Record<string, unknown>; + akshare_version?: string; + fetched_at?: string; + requested_start: string; + requested_end: string; + actual_start: string | null; + actual_end: string | null; + row_count: number; + columns: string[]; + adjustment: string; + frequency?: string; + warnings?: string[]; +} + +export interface DatasetRequest { + name?: string; + instruments: Instrument[]; + start_date: string; + end_date: string; + frequency: 'daily'; + adjustment: 'none' | 'qfq' | 'hfq'; + fields: string[]; +} + +export interface Dataset { + id: string; + name: string | null; + request: DatasetRequest; + status: 'pending' | 'running' | 'ready' | 'failed'; + manifest?: DatasetManifest | null; + manifest_hash?: string | null; + error?: string | null; + cache_hit?: boolean; + created_at: string; +} + +export interface DatasetManifest { + version?: string; + hash?: string; + objects: DatasetManifestObject[]; + columns?: string[]; +} + +export interface DatasetPreview { + columns: string[]; + rows: Record<string, unknown>[]; + coverage: CoverageEntry[]; + warnings: string[]; + truncated?: boolean; +} + +export interface RunConfig { + capital: number; + commission: number; + slippage: number; + benchmark_symbol?: string | null; + parameters?: Record<string, unknown>; +} + +export interface Run { + id: string; + project_id: string; + version_id: string; + dataset_id: string; + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + config: RunConfig; + created_at: string; + started_at?: string | null; + finished_at?: string | null; + error?: string | null; + result?: BacktestResult | null; +} + +export interface Trade { + date: string; + symbol: string; + side: string; + quantity: number; + price: number; + commission: number; + value: number; +} + +export interface OrderRecord { + date?: string; + symbol?: string; + side?: string; + quantity?: number; + price?: number | null; + state?: string; + value?: number | null; + commission?: number | null; +} + +export interface BacktestMetrics { + total_return?: number | null; + annual_return?: number | null; + max_drawdown?: number | null; + sharpe?: number | null; + /** closed ROUND-TRIP count (open fully closed = 1), NOT fill count */ + trade_count?: number | null; + final_equity?: number | null; +} + +export interface BacktestResult { + equity: { date: string; equity: number; cash: number; benchmark?: number | null }[]; + orders?: OrderRecord[]; + trades: Trade[]; + metrics: BacktestMetrics; + logs: string[]; + engine: { name: string; version: string }; + warnings: string[]; + elapsed_ms?: number | null; + peak_rss_kb?: number | null; + data_manifest_hash?: string; +} + +export interface RunListItem extends Run { + project_name?: string; +} + +export interface AIUsageItem { + id: string; + project_id?: string; + model: string; + input_tokens?: number; + output_tokens?: number; + status?: string; + created_at: string; +} + +export interface AIUsage { + items: AIUsageItem[]; + totals?: { input_tokens?: number; output_tokens?: number; requests?: number }; + internal_poc?: boolean; +} + +export interface AIAssist { + id: string; + model: string; + explanation: string; + proposed_code: string; + diff: string | null; + base_generation: number; + usage?: { input_tokens?: number; output_tokens?: number } | null; + status?: string; +} + +export interface Invitation { + id: string; + email?: string | null; + role?: string; + token?: string | null; + expires_at?: string; + used_at?: string | null; + revoked?: boolean; + created_by?: string; + created_at?: string; +} + +export interface AdminUserUpdate extends Partial<Omit<User, 'id' | 'email' | 'created_at'>> {} + +export interface Capabilities { + frequencies: string[]; + asset_types: string[]; + adjustments: { code: string; label: string }[]; + fields: { code: string; label: string; raw?: boolean }[]; + limits: { + max_symbols: number; + max_years: number; + max_code_length: number; + }; + internal_only: boolean; +} + +export interface SessionInfo { + id: string; + created_at?: string; + last_seen_at?: string | null; + user_agent?: string | null; + expires_at?: string | null; + current?: boolean; +} diff --git a/frontend/src/lib/wizardError.test.ts b/frontend/src/lib/wizardError.test.ts new file mode 100644 index 0000000..43a6bb0 --- /dev/null +++ b/frontend/src/lib/wizardError.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +// Live browser QA regression: a failing dataset submit (e.g. HTTP422/500 from +// the backend) was captured into `submitErr` but never rendered, so the user +// saw silently nothing. The wizard markup must surface submitErr. +// Type support comes from vite/client's `*?raw` module declaration. +import wizardSource from '../components/DatasetWizard.svelte?raw'; + +describe('dataset wizard visible submit errors', () => { + it('renders backend submit failures (submitErr in template, not script-only)', () => { + const src: string = wizardSource as unknown as string; + // captured at least once + expect(src).toMatch(/submitErr = extractStatusMessage\(e\)/); + // and rendered outside the script: a template reference (Svelte template) + const template = src.slice(src.indexOf('/script') + 7); + expect(template).toMatch(/\{submitErr\}/); + expect(template).toMatch(/role="alert"/); + }); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..d94ef99 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +const app = mount(App, { target: document.getElementById('app')! }); + +export default app; diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte new file mode 100644 index 0000000..ef6d1a3 --- /dev/null +++ b/frontend/src/pages/AccountPage.svelte @@ -0,0 +1,189 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { session } from '../lib/session.svelte'; + import { getSessions, patchProfile, postPassword, deleteSession } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime } from '../lib/format'; + import type { SessionInfo } from '../lib/types'; + + let nameInput = $state(session.me?.name ?? ''); + let profileBusy = $state(false); + let profileOk = $state<string | null>(null); + let profileErr = $state<string | null>(null); + + let curPw = $state(''); + let newPw = $state(''); + let newPw2 = $state(''); + let pwBusy = $state(false); + let pwErr = $state<string | null>(null); + let pwOk = $state<string | null>(null); + + let sessions: SessionInfo[] = $state([]); + let sessBusy = $state(true); + let sessErr = $state<string | null>(null); + let sessMsg = $state<string | null>(null); + + $effect(() => { + nameInput = session.me?.name ?? ''; + void loadSessions(); + }); + + async function loadSessions() { + sessBusy = true; + sessErr = null; + try { + const { items } = await getSessions(); + sessions = items; + } catch (e) { + sessErr = extractStatusMessage(e); + } finally { + sessBusy = false; + } + } + + async function saveProfile(e: SubmitEvent) { + e.preventDefault(); + profileBusy = true; + profileOk = null; + profileErr = null; + try { + await patchProfile(nameInput.trim()); + profileOk = '资料已更新'; + await session.refresh(); + } catch (err) { + profileErr = extractStatusMessage(err); + } finally { + profileBusy = false; + } + } + + async function changePassword(e: SubmitEvent) { + e.preventDefault(); + pwErr = null; + if (newPw.length < 8) { + pwErr = '新密码长度需至少 8 位'; + return; + } + if (newPw !== newPw2) { + pwErr = '两次输入的新密码不一致'; + return; + } + pwBusy = true; + try { + await postPassword(curPw, newPw); + pwOk = '密码已更新;其他会话已被登出,请用新密码重新登录本机外的应用。'; + curPw = ''; + newPw = ''; + newPw2 = ''; + } catch (err) { + pwErr = extractStatusMessage(err); + } finally { + pwBusy = false; + } + } + + async function revoke(s: SessionInfo) { + sessMsg = null; + sessErr = null; + try { + await deleteSession(s.id); + sessMsg = s.current ? '该会话已注销;如果你注销的是当前会话,会稍后自动退出。' : '会话已撤销'; + await loadSessions(); + await session.refresh(); + } catch (e) { + sessErr = extractStatusMessage(e); + } + } +</script> + +<Page title="账户与安全" subtitle="资料、密码与个人会话管理"> + <div class="stack"> + <section class="card stack"> + <h2>个人信息</h2> + <form onsubmit={saveProfile} class="row"> + <label style="flex:1 1 200px"> + 姓名 + <input bind:value={nameInput} autocomplete="name" /> + </label> + <div> + 邮箱 + <div class="fixed">{session.me?.email ?? '—'}</div> + </div> + <div> + 角色 + <div>{session.me?.role === 'admin' ? '管理员' : '成员'}{session.me?.active === false ? ' · 已停用' : ''}</div> + </div> + <button class="btn primary" disabled={profileBusy || nameInput.trim().length === 0}> + {profileBusy ? '保存中…' : '保存资料'} + </button> + </form> + <Status busy={false} error={profileErr} empty={profileOk} /> + </section> + + <section class="card stack"> + <h2>修改密码</h2> + <form onsubmit={changePassword} class="stack"> + <div class="grid3 wrap-3"> + <label> + 当前密码 + <input type="password" bind:value={curPw} required autocomplete="current-password" /> + </label> + <label> + 新密码 + <input type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} /> + </label> + <label> + 确认新密码 + <input type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} /> + </label> + </div> + <div class="row"> + <button class="btn primary" disabled={pwBusy}> + {pwBusy ? '更新中…' : '更新密码'} + </button> + </div> + <Status busy={false} error={pwErr} empty={pwOk} /> + </form> + </section> + + <section class="card stack"> + <h2>登录会话</h2> + <Status busy={sessBusy} error={sessErr} busyText="载入会话…" empty={null} /> + {#if !sessBusy && !sessErr && sessions.length === 0} + <Status busy={false} empty="没有其他会话记录" /> + {/if} + {#if sessions.length > 0} + <table class="data"> + <thead> + <tr> + <th>创建</th> + <th>最近活跃</th> + <th>设备/UA</th> + <th></th> + </tr> + </thead> + <tbody> + {#each sessions as s (s.id)} + <tr> + <td>{fmtDateTime(s.created_at)}</td> + <td>{fmtDateTime(s.last_seen_at)}</td> + <td class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</td> + <td> + <div class="row"> + {#if s.current}<span class="badge teal">当前会话</span>{/if} + <button class="btn small danger" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}> + {s.current ? '注销当前' : '撤销'} + </button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <Status busy={false} error={null} empty={sessMsg} /> + <p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p> + </section> + </div> +</Page> diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte new file mode 100644 index 0000000..984962d --- /dev/null +++ b/frontend/src/pages/AdminPage.svelte @@ -0,0 +1,371 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Modal from '../components/Modal.svelte'; + import { + listAdminUsers, + patchAdminUser, + createInvitation, + listInvitations, + deleteInvitation, + getAdminAudit, + postAdminUserPassword + } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime } from '../lib/format'; + import type { User, Invitation } from '../lib/types'; + + let tab = $state<'users' | 'invites' | 'audit'>('users'); + + let users: User[] = $state([]); + let usersBusy = $state(true); + let usersErr = $state<string | null>(null); + let resetFor = $state<User | null>(null); + let resetToken = $state(''); + let resetting = $state(false); + let adminOpsErr = $state<string | null>(null); + let inviteCopied = $state(false); + + let invites: Invitation[] = $state([]); + let inviteBusy = $state(true); + let inviteErr = $state<string | null>(null); + let inviteEmail = $state(''); + let inviteHours = $state(72); + let creatingInvite = $state(false); + let newInvite = $state<{ token: string; expires_at: string } | null>(null); + let gotToken = $state(false); + + let audit: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] = $state([]); + let auditBusy = $state(true); + let auditErr = $state<string | null>(null); + + $effect(() => { + void loadTab(tab); + }); + + async function loadTab(t: 'users' | 'invites' | 'audit') { + if (t === 'users') await loadUsers(); + else if (t === 'invites') await loadInvites(); + else await loadAudit(); + } + + async function loadUsers() { + usersBusy = true; + usersErr = null; + try { + const { items } = await listAdminUsers(); + users = items; + } catch (e) { + usersErr = extractStatusMessage(e); + } finally { + usersBusy = false; + } + } + + async function loadInvites() { + inviteBusy = true; + inviteErr = null; + try { + const { items } = await listInvitations(); + invites = items; + } catch (e) { + inviteErr = extractStatusMessage(e); + } finally { + inviteBusy = false; + } + } + + async function loadAudit() { + auditBusy = true; + auditErr = null; + try { + const res = await getAdminAudit(); + audit = res.items; + } catch (e) { + auditErr = extractStatusMessage(e); + } finally { + auditBusy = false; + } + } + + async function patchUser(u: User, patch: { active?: boolean; role?: 'admin' | 'member'; daily_run_limit?: number | null; ai_enabled?: boolean }) { + adminOpsErr = null; + try { + const fresh = await patchAdminUser(u.id, patch); + users = users.map((x) => (x.id === fresh.id ? fresh : x)); + } catch (e) { + adminOpsErr = extractStatusMessage(e); + } + } + + async function issueReset(u: User) { + resetFor = u; + resetToken = ''; + adminOpsErr = null; + resetting = true; + try { + const res = await postAdminUserPassword(u.id); + resetToken = res.reset_token; + } catch (e) { + adminOpsErr = extractStatusMessage(e); + resetFor = null; + } finally { + resetting = false; + } + } + + async function makeInvite(e: SubmitEvent) { + e.preventDefault(); + creatingInvite = true; + inviteErr = null; + newInvite = null; + try { + const res = await createInvitation({ + email: inviteEmail.trim() || undefined, + expires_hours: inviteHours + }); + newInvite = res; + inviteEmail = ''; + await loadInvites(); + } catch (err) { + inviteErr = extractStatusMessage(err); + } finally { + creatingInvite = false; + } + } + + async function revokeInvite(i: Invitation) { + adminOpsErr = null; + try { + await deleteInvitation(i.id); + await loadInvites(); + } catch (e) { + adminOpsErr = extractStatusMessage(e); + } + } + + async function copy(text: string) { + try { + await navigator.clipboard.writeText(text); + inviteCopied = true; + } catch { + /* clipboard unavailable; user will select manually */ + } + } + + const inviteUrl = $derived( + newInvite + ? `${window.location.origin}${window.location.pathname}#/register?invite=${encodeURIComponent(newInvite.token)}` + : '' + ); + const resetUrl = $derived( + resetToken + ? `${window.location.origin}${window.location.pathname}#/reset?token=${encodeURIComponent(resetToken)}` + : '' + ); +</script> + +<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码"> + {#snippet actions()} + <div class="tabs" role="tablist"> + <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button> + <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button> + <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button> + </div> + {/snippet} + + <div class="stack"> + <Status busy={false} error={adminOpsErr} empty={null} /> + + {#if tab === 'users'} + <section class="card stack"> + <h2>用户</h2> + <Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} /> + {#if !usersBusy && !usersErr && users.length > 0} + <table class="data"> + <thead> + <tr> + <th>邮箱</th> + <th>姓名</th> + <th>角色</th> + <th>状态</th> + <th>AI 权限</th> + <th>日回测上限</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each users as u (u.id)} + <tr> + <td>{u.email}</td> + <td>{u.name}</td> + <td> + <select + value={u.role} + aria-label={`角色 ${u.email}`} + onchange={(ev) => void patchUser(u, { role: (ev.target as HTMLSelectElement).value as 'admin' | 'member' })} + > + <option value="member" selected={u.role === 'member'}>成员</option> + <option value="admin" selected={u.role === 'admin'}>管理员</option> + </select> + </td> + <td> + <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span> + </td> + <td> + <label class="checkline"> + <input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} /> + </label> + </td> + <td> + <input + class="num" + style="width:90px" + type="number" + min="0" + max="200" + value={u.daily_run_limit ?? 5} + onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })} + /> + </td> + <td> + <div class="row"> + <button + class="btn small {u.active ? 'danger' : ''}" + onclick={() => void patchUser(u, { active: !u.active })} + > + {u.active ? '停用' : '启用'} + </button> + <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p> + </section> + {:else if tab === 'invites'} + <section class="card stack"> + <h2>签发邀请</h2> + <form onsubmit={makeInvite} class="row"> + <label style="flex:1 1 220px"> + 邮箱(可选备注) + <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" /> + </label> + <label style="flex:0 0 130px"> + 有效小时 + <input type="number" bind:value={inviteHours} min="1" max="720" /> + </label> + <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button> + </form> + <Status busy={false} error={inviteErr} empty={null} /> + {#if newInvite} + <div class="banner info"> + <div> + <div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div> + <div class="mono-invite">{newInvite.token}</div> + <div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div> + <div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div> + <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button> + </div> + </div> + {/if} + </section> + + <section class="card stack"> + <h2>历史邀请</h2> + <Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} /> + {#if !inviteBusy && !inviteErr && invites.length === 0} + <Status busy={false} empty="还没有邀请记录" /> + {/if} + {#if !inviteBusy && invites.length > 0} + <table class="data"> + <thead> + <tr> + <th>邮箱备注</th> + <th>角色</th> + <th>创建时间</th> + <th>过期</th> + <th>状态</th> + <th></th> + </tr> + </thead> + <tbody> + {#each invites as inv (inv.id)} + <tr> + <td>{inv.email ?? '—'}</td> + <td>{inv.role === 'admin' ? '管理员' : '成员'}</td> + <td>{fmtDateTime(inv.created_at)}</td> + <td>{fmtDateTime(inv.expires_at)}</td> + <td> + {#if inv.used_at}<span class="badge grey">已使用</span> + {:else if inv.revoked}<span class="badge grey">已撤销</span> + {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span> + {:else}<span class="badge teal">有效</span> + {/if} + </td> + <td> + {#if !inv.used_at && !inv.revoked} + <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button> + {/if} + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p> + </section> + {:else} + <section class="card stack"> + <h2>安全审计(收尾尾部)</h2> + <Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} /> + {#if !auditBusy && !auditErr && audit.length === 0} + <Status busy={false} empty="暂无审计事件" /> + {/if} + {#if !auditBusy && audit.length > 0} + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>行为者</th> + <th>事件</th> + <th>对象</th> + <th>结果</th> + </tr> + </thead> + <tbody> + {#each audit as e, i (i)} + <tr> + <td>{fmtDateTime(e.time)}</td> + <td>{e.actor ?? '—'}</td> + <td>{e.action}</td> + <td class="num">{e.target ?? '—'}</td> + <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p> + </section> + {/if} + </div> + + {#if resetFor} + <Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}> + <div class="stack"> + <div class="banner info"> + <div> + <div>恢复链接(管理员线下告知对方,只出现一次):</div> + <div class="mono-invite">{resetUrl}</div> + <div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div> + </div> + </div> + <p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p> + </div> + </Modal> + {/if} +</Page> diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte new file mode 100644 index 0000000..9e56be2 --- /dev/null +++ b/frontend/src/pages/AuthPages.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import { session } from '../lib/session.svelte'; + import { go } from '../lib/router'; + import { postLogin, postRegister, postResetPassword } from '../lib/client'; + import { extractStatusMessage, statusByName } from '../lib/state'; + + interface Props { + route: Route; + } + let { route }: Props = $props(); + + const mode = $derived(route.name); + + let email = $state(''); + let password = $state(''); + let name = $state(''); + // Initial route query is deliberately read once: token is consumed at page load. + // svelte-ignore state_referenced_locally + let inviteToken = $state(route.query.get('invite') ?? ''); + // svelte-ignore state_referenced_locally + let resetToken = $state(route.query.get('token') ?? ''); + let busy = $state(false); + let resultError = $state<string | null>(null); + let done = $state<string | null>(null); + let pw2 = $state(''); + let newPassword = $state(''); + + async function login(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + await postLogin(email.trim(), password); + const user = await session.refresh(); + if (user) session.set(user); + go('/projects'); + } catch (err) { + if (statusByName(err) === 'unauthorized') resultError = '邮箱或密码不正确,或账户已被停用'; + else resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } + + async function register(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + const { user } = await postRegister(inviteToken.trim(), name.trim(), email.trim(), password); + session.set(user); + go('/projects'); + } catch (err) { + resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } + + async function reset(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + if (!resetToken.trim()) { + resultError = '链接中的重置令牌未提供。请使用管理员发给您的完整恢复链接打开本页。'; + return; + } + if (newPassword.length < 8) { + resultError = '新密码长度需至少 8 位'; + return; + } + if (newPassword !== pw2) { + resultError = '两次输入的新密码不一致'; + return; + } + await postResetPassword(resetToken.trim(), newPassword); + done = '密码已重置,现在可以用新密码登录。'; + } catch (err) { + resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } +</script> + +<section class="auth-wrap"> + <div class="auth-card card" role="main"> + <div class="auth-brand"> + <span class="mark" aria-hidden="true"></span> + <h1>策研 <span class="sub">Strategy Lab</span></h1> + <p class="hint">内部策略研究平台 · 仅限受邀成员</p> + </div> + + {#if mode === 'register'} + <form onsubmit={register}> + <div> + <label for="inv">邀请令牌</label> + <input id="inv" bind:value={inviteToken} required autocomplete="off" /> + <p class="hint">单次有效,来自管理员邀请</p> + </div> + <div> + <label for="nm">姓名</label> + <input id="nm" bind:value={name} required autocomplete="name" /> + </div> + <div> + <label for="em">邮箱</label> + <input id="em" type="email" bind:value={email} required autocomplete="email" /> + </div> + <div> + <label for="pw">密码</label> + <input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} /> + <div class="hint">至少 8 位</div> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '创建中…' : '开通账户'}</button> + <p class="hint">已有账户? <a href="#/login">登录</a></p> + </form> + {:else if mode === 'reset'} + {#if done} + <div class="banner info">{done}</div> + <p><a href="#/login">前往登录</a></p> + {:else} + <form onsubmit={reset}> + <div> + <label for="rt">重置令牌</label> + <input id="rt" bind:value={resetToken} autocomplete="off" /> + <p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p> + </div> + <div> + <label for="np">新密码</label> + <input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} /> + </div> + <div> + <label for="np2">再输入一次新密码</label> + <input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} /> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '重置中…' : '重置密码'}</button> + <p class="hint">想起密码了? <a href="#/login">直接登录</a></p> + </form> + {/if} + {:else} + <form onsubmit={login}> + <div> + <label for="le">邮箱</label> + <input id="le" type="email" bind:value={email} required autocomplete="email" /> + </div> + <div> + <label for="lp">密码</label> + <input id="lp" type="password" bind:value={password} required autocomplete="current-password" /> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '登录中…' : '登录'}</button> + <p class="hint"> + 开通新账户需要管理员邀请: <a href="#/register">用邀请令牌注册</a> + {#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if} + </p> + <p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p> + </form> + {/if} + </div> +</section> + +<style> + .auth-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; + background: + radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent), + var(--bg); + } + .auth-card { width: 400px; max-width: 100%; padding: 1.6rem; } + .auth-brand { margin-bottom: 1.1rem; } + .mark { + display: inline-block; + width: 34px; + height: 34px; + border-radius: 9px; + background: var(--teal-strong); + margin-bottom: 0.6rem; + } + .sub { color: var(--text-3); font-weight: 500; font-size: 0.8rem; } +</style> diff --git a/frontend/src/pages/DatasetsPage.svelte b/frontend/src/pages/DatasetsPage.svelte new file mode 100644 index 0000000..b348ee3 --- /dev/null +++ b/frontend/src/pages/DatasetsPage.svelte @@ -0,0 +1,8 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import ProjectDataTab from './ProjectDataTab.svelte'; +</script> + +<Page title="数据集" subtitle="你的所有数据请求与物化数据(跨项目共享目录,锁定标的信息与覆盖情况)"> + <ProjectDataTab /> +</Page> diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte new file mode 100644 index 0000000..baa9e5f --- /dev/null +++ b/frontend/src/pages/ProjectBacktestTab.svelte @@ -0,0 +1,384 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import { listDatasets, createRun, getRun, cancelRun, rerunRun, listRuns, getDatasetPreview } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { + fmtDateTime, + fmtPct, + formatMoney, + datasetStatusLabel, + runStatusLabel, + runStatusBadge + } from '../lib/format'; + import { summarizeCoverage } from '../lib/coverage'; + import type { Run, Dataset } from '../lib/types'; + + interface Props { + projectId: string; + } + let { projectId }: Props = $props(); + + // config inputs + let datasetsReady: Dataset[] = $state([]); + let busyDatasets = $state(true); + let cfgErr = $state<string | null>(null); + + let datasetId = $state(''); + let capital = $state<number>(1_000_000); + let commission = $state(0.0003); + let slippage = $state(0.001); + let benchmark = $state(''); + let paramsText = $state(''); + let acknowledged = $state(false); + + // life cycle + let latest = $state<Run | null>(null); + let latestErr = $state<string | null>(null); + let cancelBusy = $state(false); + let rerunBusy = $state(false); + let actionMsg = $state<string | null>(null); + + let history: Run[] = $state([]); + let historyBusy = $state(false); + let historyErr = $state<string | null>(null); + + $effect(() => { + void loadDatasets(); + void loadLatest(true); + return () => { + if (pollTimer) clearInterval(pollTimer); + }; + }); + + let pollTimer: ReturnType<typeof setInterval> | null = null; + + function isLiveStatus(r: Run | null | undefined) { + return r?.status === 'queued' || r?.status === 'running'; + } + + async function loadDatasets() { + busyDatasets = true; + cfgErr = null; + try { + const { items } = await listDatasets(); + datasetsReady = items.filter((d) => d.status === 'ready'); + if (items.some((d) => d.status === 'pending' || d.status === 'running')) { + if (!waitTimer) { + waitTimer = setInterval(() => { + void loadDatasets(); + }, 2500); + } + } else { + stopWait(); + } + } catch (e) { + cfgErr = extractStatusMessage(e); + } finally { + busyDatasets = false; + } + } + + let waitTimer: ReturnType<typeof setInterval> | null = null; + + function stopWait() { + if (waitTimer) { + clearInterval(waitTimer); + waitTimer = null; + } + } + + async function loadLatest(startPolling: boolean) { + latestErr = null; + try { + const res = await listRuns(projectId); + history = res.items; + latest = res.items[0] ?? null; + if (startPolling && isLiveStatus(latest) && !pollTimer) { + pollTimer = setInterval(() => void loadLatest(false), 2000); + } + if (!isLiveStatus(latest) && pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + await refreshHistory(); + } + } catch (e) { + latestErr = extractStatusMessage(e); + } + } + + async function refreshHistory() { + historyBusy = true; + historyErr = null; + try { + const res = await listRuns(projectId); + history = res.items; + } catch (e) { + historyErr = extractStatusMessage(e); + } finally { + historyBusy = false; + } + } + + async function previewWarningsForDataset(id: string) { + try { + const preview = await getDatasetPreview(id); + const cov = summarizeCoverage(preview.coverage ?? []); + datasetWarnMsgs = [...cov.warnings, ...(preview.warnings ?? [])]; + } catch { + datasetWarnMsgs = []; + } + acknowledged = false; + } + + let datasetWarnMsgs: string[] = $state([]); + + const selectedDataset = $derived(datasetsReady.find((d) => d.id === datasetId) ?? null); + const needAck = $derived(datasetWarnMsgs.length > 0); + + $effect(() => { + if (datasetId) void previewWarningsForDataset(datasetId); + }); + + async function startRun() { + const errs: string[] = []; + if (!datasetId) errs.push('请选择一个就绪的数据集'); + if (!(capital > 0) || !Number.isFinite(capital)) errs.push('起始资金必须是正数'); + if (!(commission >= 0 && commission < 0.1)) errs.push('佣金率应在 0 与 0.1 之间'); + if (!(slippage >= 0 && slippage < 0.1)) errs.push('滑点应在 0 与 0.1 之间'); + let parameters: Record<string, unknown> | undefined; + if (paramsText.trim()) { + try { + parameters = JSON.parse(paramsText); + } catch { + errs.push('参数必须是合法 JSON'); + } + } + if (errs.length > 0) { + cfgErr = errs.join(';'); + return; + } + cfgErr = null; + if (needAck && !acknowledged) { + cfgErr = '数据覆盖有差异,请先勾选确认已知晓后再启动回测'; + return; + } + try { + const run = await createRun({ + project_id: projectId, + dataset_id: datasetId, + capital, + commission, + slippage, + benchmark_symbol: benchmark.trim() || undefined, + parameters, + acknowledge_warnings: acknowledged + }); + latest = run; + await loadLatest(true); + actionMsg = `回测已提交,编号 ${run.id.slice(0, 8)}…`; + void refreshHistory(); + } catch (e) { + cfgErr = extractStatusMessage(e); + const details = (e as { details?: { errors?: string[] } }).details?.errors; + if (details) cfgErr = details.join(';'); + } + } + + + + async function onCancel() { + if (!latest) return; + cancelBusy = true; + try { + latest = await cancelRun(latest.id); + actionMsg = '已请求取消'; + } catch (e) { + actionMsg = extractStatusMessage(e); + } finally { + cancelBusy = false; + } + } + + async function onRerun() { + if (!latest) return; + rerunBusy = true; + try { + const run = await rerunRun(latest.id, true); + latest = run; + await loadLatest(true); + actionMsg = `已用原始代码与数据重新提交,编号 ${run.id.slice(0, 8)}…`; + } catch (e) { + actionMsg = extractStatusMessage(e); + } finally { + rerunBusy = false; + } + } + + const canCancel = $derived(isLiveStatus(latest)); + const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled'); +</script> + +<div class="stack"> + <section class="card stack"> + <h2>发起回测</h2> + <Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} /> + {#if !busyDatasets && datasetsReady.length === 0 && !cfgErr} + <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" /> + {/if} + {#if datasetsReady.length > 0} + <div class="grid3 wrap-3"> + <label> + 数据集 + <select bind:value={datasetId}> + <option value="" disabled>选择数据集…</option> + {#each datasetsReady as d (d.id)} + <option value={d.id}> + {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')} + </option> + {/each} + </select> + </label> + <label> + 起始资金 + <input class="num" type="number" bind:value={capital} min="1" step="1000" /> + </label> + <div class="row2"> + <label> + 佣金率 + <input class="num" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" /> + </label> + <label> + 滑点率 + <input class="num" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" /> + </label> + </div> + <label> + 基准代码(可选) + <input bind:value={benchmark} placeholder="例如 000300.SH 指数代码" /> + </label> + <label> + 策略参数(JSON,可选) + <textarea rows="1" bind:value={paramsText} placeholder='输入 JSON 参数,例如 fast/slow 周期'></textarea> + </label> + </div> + {#if needAck} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <div> + 该数据集存在覆盖/预警信息,需要明确知晓后才能启动: + <ul> + {#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each} + </ul> + <label class="checkline"> + <input type="checkbox" bind:checked={acknowledged} /> + 我已知晓并用当前数据继续 + </label> + </div> + </div> + {/if} + <div class="row"> + <button class="btn primary" onclick={startRun} disabled={!datasetId}>开始回测</button> + {#if selectedDataset} + <span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span> + {/if} + </div> + <Status busy={false} error={cfgErr} empty={actionMsg} /> + {/if} + </section> + + <section class="card stack"> + <div class="spread"> + <h2>最近一次回测</h2> + {#if latest} + <span class="row"> + <span class="badge">Run 分支 {latest.version_id?.slice(0,8) ?? "—"}</span> + <span class="badge {runStatusBadge[latest.status]}">{runStatusLabel[latest.status]}</span> + </span> + {/if} + </div> + + {#if latest} + <Status + busy={isLiveStatus(latest)} + busyText="排队/执行中,页面会自动刷新状态…" + empty={null} + error={latestErr} + /> + <div class="rows-grid"> + <table class="data kv"> + <tbody> + <tr><th>启动时间</th><td>{fmtDateTime(latest.created_at)}</td></tr> + {#if latest.started_at}<tr><th>开始执行</th><td>{fmtDateTime(latest.started_at)}</td></tr>{/if} + {#if latest.finished_at}<tr><th>结束时间</th><td>{fmtDateTime(latest.finished_at)}</td></tr>{/if} + <tr><th>代码版本</th><td><span class="num">{latest.version_id.slice(0, 10)}</span></td></tr> + <tr><th>数据集</th><td><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></td></tr> + <tr><th>资金</th><td>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</td></tr> + </tbody> + </table> + </div> + {#if latest.error} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{latest.error}</span></div> + {/if} + {#if latest.result?.warnings?.length} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <ul class="plain-list"> + {#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + </div> + {/if} + {#if latest.status === 'succeeded' && latest.result} + <a class="btn" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</a> + {/if} + <div class="row"> + {#if canCancel} + <button class="btn danger" onclick={onCancel} disabled={cancelBusy}> + {cancelBusy ? '取消中…' : '取消回测'} + </button> + {/if} + {#if canRerun} + <button class="btn" onclick={onRerun} disabled={rerunBusy}> + {rerunBusy ? '重新提交…' : '按原始代码&数据重跑'} + </button> + {/if} + </div> + {:else} + <Status busy={false} empty="项目还没有回测记录" emptyHint="在上方选择数据集并启动一次回测" /> + {/if} + </section> + + <section class="stack"> + <h2>回测历史</h2> + <Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} /> + {#if history && history.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>启动时间</th> + <th>状态</th> + <th>资金/佣金/滑点</th> + <th>数据集</th> + <th>结果</th> + </tr> + </thead> + <tbody> + {#each history as h (h.id)} + <tr> + <td>{fmtDateTime(h.created_at)}</td> + <td>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</td> + <td> + {formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)} + </td> + <td class="num">{h.dataset_id?.slice(0, 8)}…</td> + <td> + <a class="btn small" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</a> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </section> +</div> diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte new file mode 100644 index 0000000..e3b1923 --- /dev/null +++ b/frontend/src/pages/ProjectDataTab.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import DatasetCard from '../components/DatasetCard.svelte'; + import DatasetWizard from '../components/DatasetWizard.svelte'; + import { listDatasets } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import type { Dataset } from '../lib/types'; + + interface Props { + projectId?: string; + showWizard?: boolean; + } + let { projectId: _projectId, showWizard = false }: Props = $props(); + + let items: Dataset[] = $state([]); + let busy = $state(true); + let error: string | null = $state(null); + // svelte-ignore state_referenced_locally + let wizardOpen = $state(showWizard); + + + let refreshTimer: ReturnType<typeof setInterval> | null = null; + + $effect(() => { + void load(); + return () => { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = null; + }; + }); + + async function load() { + error = null; + try { + const { items: got } = await listDatasets(); + items = got; + const active = got.some((d) => d.status === 'pending' || d.status === 'running'); + if (active && !refreshTimer) { + refreshTimer = setInterval(async () => { + try { + const { items: fresh } = await listDatasets(); + items = fresh; + const still = fresh.some((d) => d.status === 'pending' || d.status === 'running'); + if (!still && refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } catch { + /* transient error, keep polling */ + } + }, 2000); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } +</script> + +<div class="stack"> + {#if wizardOpen} + <DatasetWizard onCreated={() => void load()} /> + <button class="btn ghost" onclick={() => (wizardOpen = false)}>收起新建面板</button> + {:else} + <button class="btn primary" onclick={() => (wizardOpen = true)}>新建数据集</button> + {/if} + + <h2>我的数据集</h2> + <Status busy={busy} error={error} busyText="加载数据集列表…" empty={null} /> + + {#if !busy && !error && items.length === 0} + <Status + busy={false} + empty="还没有数据集" + emptyHint="先创建数据集:选择标的、日期范围与复权方式,提交后异步获取并给出覆盖预览" + /> + {/if} + + {#if !busy && !error && items.length > 0} + {#each items as ds (ds.id)} + <DatasetCard dsId={ds.id} /> + {/each} + {/if} +</div> diff --git a/frontend/src/pages/ProjectPage.svelte b/frontend/src/pages/ProjectPage.svelte new file mode 100644 index 0000000..b31caa3 --- /dev/null +++ b/frontend/src/pages/ProjectPage.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import ProjectDataTab from '../pages/ProjectDataTab.svelte'; + import ProjectStrategyTab from '../pages/ProjectStrategyTab.svelte'; + import ProjectBacktestTab from '../pages/ProjectBacktestTab.svelte'; + import ProjectResultsTab from '../pages/ProjectResultsTab.svelte'; + + interface Props { + route: Route; + } + let { route }: Props = $props(); +</script> + +<!-- Layout is provided once by App.svelte; wrapping here produced the doubled sidebar bug. --> +{#if route.name === 'project-data'} + <ProjectDataTab projectId={route.params.id} /> +{:else if route.name === 'project-strategy'} + <ProjectStrategyTab projectId={route.params.id} /> +{:else if route.name === 'project-backtest'} + <ProjectBacktestTab projectId={route.params.id} /> +{:else if route.name === 'project-results'} + <ProjectResultsTab projectId={route.params.id} runId={route.query.get('run') ?? undefined} /> +{/if} diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte new file mode 100644 index 0000000..4149213 --- /dev/null +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -0,0 +1,299 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption, LineSeriesOption } from 'echarts'; +import { listRuns, getRun } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { + fmtDateTime, + fmtPct, + fmtNum, + formatMoney, + runStatusLabel, + runStatusBadge, + metricsLabels, + metricsRow + } from '../lib/format'; + import type { Run, BacktestResult } from '../lib/types'; + + interface Props { + projectId: string; + runId?: string; + } + let { projectId, runId }: Props = $props(); + + let run = $state<Run | null>(null); + let busy = $state(true); + let error = $state<string | null>(null); + let history = $state<Run[]>([]); + let logsOpen = $state(false); + let ordersOpen = $state(false); + let chartMode = $state<'equity' | 'drawdown'>('equity'); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + error = null; + try { + if (!runId) { + const res = await listRuns(projectId); + history = res.items; + run = res.items[0] ?? null; + } else { + run = await getRun(runId); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const result = $derived<BacktestResult | null>(run?.result ?? null); + const hasEquity = $derived(!!result && result.equity.length > 0); + + const equityOption: EChartsOption = $derived.by(() => { + if (!result || !hasEquity) return {}; + const dates = result.equity.map((p) => p.date); + const series: LineSeriesOption[] = [ + { + name: "策略权益", + type: "line" as const, + data: result.equity.map((p) => p.equity), + showSymbol: false, + lineStyle: { width: 1.6 }, + itemStyle: { color: "#0f766e" } + }, + { + name: "现金", + type: "line" as const, + data: result.equity.map((p) => p.cash), + showSymbol: false, + lineStyle: { type: "dashed", opacity: 0.65 }, + itemStyle: { color: "#94a3b8" } + } + ]; + const bench = result.equity.map((p) => (p.benchmark === null || p.benchmark === undefined ? null : p.benchmark)); + if (bench.some((x) => x !== null && x !== undefined)) { + series.push({ + name: "基准", + type: "line" as const, + data: bench as number[], + showSymbol: false, + itemStyle: { color: "#eab308" } + }); + } + return { + title: { text: undefined }, + legend: { top: 0 }, + grid: { top: 32, left: 60, right: 24, bottom: 54 }, + tooltip: { trigger: "axis" }, + xAxis: { type: "category", data: dates }, + yAxis: { type: "value", scale: true, name: "金额" }, + series + }; + }); + + const drawdownOption: EChartsOption = $derived.by(() => { + if (!result || !hasEquity) return {}; + const dates = result.equity.map((p) => p.date); + let peak = -Infinity; + const dd = result.equity.map((p) => { + peak = Math.max(peak, p.equity); + return -((peak - p.equity) / peak) * 100; + }); + return { + grid: { top: 24, left: 60, right: 24, bottom: 54 }, + tooltip: { trigger: "axis", valueFormatter: (v) => `${(v as number).toFixed(2)}%` }, + xAxis: { type: "category", data: dates }, + yAxis: { type: "value", name: "回撤 %", max: 0 }, + series: [ + { + name: "回撤", + type: "line" as const, + data: dd, + showSymbol: false, + areaStyle: { color: "rgba(30, 74, 66, 0.25)" }, + lineStyle: { width: 1 }, + itemStyle: { color: "#134e4a" } + } + ] + }; + }); + + function switchToRun(id: string) { + runId = id; + void load(); + } + + const ml = $derived(metricsLabels()); + const mr = $derived(result ? metricsRow(result.metrics) : []); +</script> + +<div class="stack"> + <div class="spread"> + <h2>回测结果</h2> + {#if run} + <span class="row"> + <span class="hint">Run {run.id.slice(0, 8)}…</span> + <span class="badge {runStatusBadge[run.status]}">{runStatusLabel[run.status]}</span> + </span> + {/if} + </div> + + <Status busy={busy} error={error} busyText="加载结果…" empty={null} /> + + {#if !busy && !error && !run} + <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测" /> + {/if} + + {#if run && !busy && !error} + {#if history.length > 1} + <div class="row"> + <label for="runsel" class="hint" style="margin:0">查看其他回测:</label> + <select id="runsel" style="max-width:320px" value={run?.id ?? ""} onchange={(e) => switchToRun((e.target as HTMLSelectElement).value)}> + {#each history as h (h.id)} + <option value={h.id}>{fmtDateTime(h.created_at)} · {runStatusLabel[h.status]}</option> + {/each} + </select> + </div> + {/if} + + {#if run.error} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{run.error}</span></div> + {/if} + + {#if !result} + <Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} /> + {:else} + {#if result.warnings?.length} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <div> + <ul class="ul-plain"> + {#each result.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + </div> + </div> + {/if} + + <div class="card"> + {#if result.metrics} + {@const mrow = result.metrics} + <div class="metric-grid"> + <div class="metric"> + <div class="mlabel">总收益率</div> + <div class="mvalue">{fmtPct(mrow.total_return)}</div> + </div> + <div class="metric"> + <div class="mlabel">年化收益率</div> + <div class="mvalue">{fmtPct(mrow.annual_return)}</div> + </div> + <div class="metric"> + <div class="mlabel">最大回撤</div> + <div class="mvalue">{fmtPct(mrow.max_drawdown)}</div> + </div> + <div class="metric"> + <div class="mlabel">夏普比率</div> + <div class="mvalue">{fmtNum(mrow.sharpe)}</div> + </div> + <div class="metric"> + <div class="mlabel">平仓回合数</div> + <div class="mvalue">{mrow.trade_count ?? "—"}</div> + </div> + <div class="metric"> + <div class="mlabel">期末权益</div> + <div class="mvalue">{formatMoney(mrow.final_equity)}</div> + </div> + </div> + {/if} + <p class="hint"> + 平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。 + 引擎 {result.engine?.name} {result.engine?.version} + {#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if} + {#if result.peak_rss_kb}<span> · 峰值内存 {fmtNum(result.peak_rss_kb / 1024)} MB</span>{/if} + </p> + </div> + + <div class="card stack"> + <div class="row"> + <button class="btn small" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</button> + <button class="btn small" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</button> + </div> + {#if chartMode === "equity"} + <Chart option={equityOption} ariaLabel="策略权益曲线" height={340} /> + {:else} + <Chart option={drawdownOption} ariaLabel="回撤曲线" height={300} /> + {/if} + </div> + + <div class="card stack"> + <h2>订单与持仓记录</h2> + <div class="cols-2"> + <div> + <h3>成交记录 ({result.trades.length})</h3> + {#if result.trades.length > 0} + <div class="table-wrap"> + <table class="data"> + <thead> + <tr> + <th>日期</th> + <th>标的</th> + <th>方向</th> + <th>数量</th> + <th>价格</th> + <th>金额</th> + <th>佣金</th> + </tr> + </thead> + <tbody> + {#each result.trades.slice(0, 80) as t, i (i)} + <tr> + <td>{t.date}</td> + <td class="num">{t.symbol}</td> + <td>{t.side === "buy" ? "买入" : "卖出"}</td> + <td class="num">{t.quantity}</td> + <td class="num">{fmtNum(t.price, 3)}</td> + <td class="num">{fmtNum(t.value)}</td> + <td class="num">{fmtNum(t.commission, 4)}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {#if result.trades.length > 80} + <p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p> + {/if} + {:else} + <p class="hint">本区间未发生成交</p> + {/if} + </div> + <div> + <h3>策略日志</h3> + <button class="btn small" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}> + {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`} + </button> + {#if logsOpen} + <pre class="logs">{result.logs.join("\n")}</pre> + {/if} + </div> + </div> + <div> + <h3>数据与引擎 + <span class="hint">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span> + </h3> + <p class="hint"> + 结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。 + </p> + </div> + </div> + {/if} + {/if} + + {#if !busy && !error && history.length === 0 && run === null} + <Status busy={false} empty="还没有可展示的回测" emptyHint="在「回测」标签先启动一遍" /> + {/if} +</div> diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte new file mode 100644 index 0000000..5f2e242 --- /dev/null +++ b/frontend/src/pages/ProjectStrategyTab.svelte @@ -0,0 +1,420 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import CodeEditor from '../components/CodeEditor.svelte'; + import DiffView from '../components/DiffView.svelte'; + import Modal from '../components/Modal.svelte'; + import { projectsStore } from '../lib/listsStore.svelte'; + import { + getProject, + putDraft, + listVersions, + postVersion, + getVersionCode, + postRestore, + postAIAssist, + postAIAccept + } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, versionSourceLabel } from '../lib/format'; + import type { Project, Version, AIAssist } from '../lib/types'; + + interface Props { + projectId: string; + } + let { projectId }: Props = $props(); + + let code = $state(''); + let baseGeneration = $state(0); + let baseCode = $state(''); + let saveStatus = $state<'saved' | 'saving' | 'dirty' | 'conflict'>('saved'); + let saveMsg = $state(''); + let versions: Version[] = $state([]); + let versionsBusy = $state(false); + let versionsErr = $state<string | null>(null); + let snapshotBusy = $state(false); + let snapshotMsg = $state<string | null>(null); + let snapshotErr = $state<string | null>(null); + let snapshotMessage = $state(''); + let conflictServer: { generation: number; code: string } | null = $state(null); + let comparing: { label: string; before: string; after: string } | null = $state(null); + + let instruction = $state(''); + let aiBusy = $state(false); + let aiErr = $state<string | null>(null); + let aiResult = $state<AIAssist | null>(null); + let aiAcceptBusy = $state(false); + let aiAcceptErr = $state<string | null>(null); + let aiNote = $state<string | null>(null); + + let initBusy = $state(true); + let initErr = $state<string | null>(null); + let saveTimer: ReturnType<typeof setTimeout> | null = null; + + $effect(() => { + void init(); + return () => { + if (saveTimer) clearTimeout(saveTimer); + }; + }); + + async function init() { + initBusy = true; + initErr = null; + try { + const p: Project = await getProject(projectId); + projectsStore.bump(p); + code = p.draft_code ?? ''; + baseGeneration = p.draft_generation; + baseCode = code; + await refreshVersions(); + } catch (e) { + initErr = extractStatusMessage(e); + } finally { + initBusy = false; + } + } + + async function refreshVersions() { + versionsBusy = true; + versionsErr = null; + try { + const { items } = await listVersions(projectId); + versions = items; + } catch (e) { + versionsErr = extractStatusMessage(e); + } finally { + versionsBusy = false; + } + } + + function onEditorInput(v: string) { + if (saveStatus !== 'dirty') saveStatus = 'dirty'; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => void saveDraft(v), 800); + saveMsg = '输入暂停后自动保存'; + } + + async function saveDraft(payload?: string) { + const toSave = payload ?? code; + if (!toSave && !baseCode) return; + saveStatus = 'saving'; + saveMsg = '保存中…'; + try { + const p = await putDraft(projectId, toSave, baseGeneration); + code = toSave; + baseGeneration = p.draft_generation; + baseCode = toSave; + projectsStore.bump(p); + saveStatus = 'saved'; + saveMsg = `已保存 · 草稿代 ${baseGeneration}`; + if (aiNote) aiNote = null; + } catch (e) { + const err = e as { status?: number; code?: string }; + if (err.status === 409 && err.code === 'stale_generation') { + const fresh = await getProject(projectId); + saveStatus = 'conflict'; + conflictServer = { generation: fresh.draft_generation, code: fresh.draft_code ?? '' }; + saveMsg = '检测到冲突'; + } else { + saveStatus = 'dirty'; + saveMsg = extractStatusMessage(e); + } + } + } + + async function keepMine() { + if (!conflictServer) return; + try { + const p = await putDraft(projectId, code, conflictServer.generation); + baseGeneration = p.draft_generation; + baseCode = code; + saveStatus = 'saved'; + saveMsg = '已覆盖保存本机内容'; + conflictServer = null; + } catch (e) { + saveMsg = extractStatusMessage(e); + } + } + + function loadServer() { + if (!conflictServer) return; + code = conflictServer.code; + baseGeneration = conflictServer.generation; + baseCode = code; + saveStatus = 'saved'; + saveMsg = '已采用服务器草稿'; + conflictServer = null; + } + + async function snapshot() { + snapshotBusy = true; + snapshotErr = null; + snapshotMsg = null; + try { + await postVersion(projectId, snapshotMessage.trim() || '保存版本快照'); + snapshotMessage = ''; + snapshotMsg = '版本快照已创建'; + await refreshVersions(); + } catch (e) { + snapshotErr = extractStatusMessage(e); + } finally { + snapshotBusy = false; + } + } + + async function compareWithDraft(v: Version) { + versionsErr = null; + try { + const { code: vCode } = await getVersionCode(projectId, v.id); + comparing = { label: `版本 ${v.hash.slice(0, 10)} · ${v.message}`, before: vCode, after: code }; + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function compareTwoVersions(i: number) { + const v = versions[i]; + const prev = versions[i + 1]; + if (!prev) return; + try { + const [a, b] = await Promise.all([getVersionCode(projectId, v.id), getVersionCode(projectId, prev.id)]); + comparing = { + label: `${a.code ? '' : ''}${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`, + before: b.code, + after: a.code + }; + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function restore(v: Version) { + versionsErr = null; + try { + const p = await postRestore(projectId, v.id, baseGeneration); + code = p.draft_code ?? ''; + baseGeneration = p.draft_generation; + baseCode = code; + projectsStore.bump(p); + versionsErr = null; + aiNote = `已从该版本恢复为新草稿(代 ${baseGeneration}),历史未改写`; + await refreshVersions(); + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function askAI() { + if (!instruction.trim()) return; + aiBusy = true; + aiErr = null; + try { + const res = await postAIAssist(projectId, instruction.trim(), baseGeneration); + aiResult = res; + if (res.status && res.status !== 'ok') { + aiErr = `模型返回状态 ${res.status},未生成代码建议`; + } + } catch (e) { + aiErr = extractStatusMessage(e); + aiResult = null; + } finally { + aiBusy = false; + } + } + + async function acceptAI() { + if (!aiResult) return; + aiAcceptBusy = true; + aiAcceptErr = null; + try { + const p = await postAIAccept(aiResult.id, baseGeneration); + code = p.draft_code ?? aiResult.proposed_code; + baseGeneration = p.draft_generation; + baseCode = code; + projectsStore.bump(p); + aiNote = 'AI 建议已接受,生成新的代码版本'; + aiResult = null; + instruction = ''; + await refreshVersions(); + } catch (e) { + const err = e as { status?: number; code?: string }; + if (err.status === 409) { + aiAcceptErr = '草稿代已变化,建议可能失效。请重新发起 AI 说明或刷新草稿后重试。'; + } else { + aiAcceptErr = extractStatusMessage(e); + } + } finally { + aiAcceptBusy = false; + } + } + + function discardAI() { + aiResult = null; + aiErr = null; + aiAcceptErr = null; + } +</script> + +<div class="stack"> + <Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} /> + + {#if !initBusy && !initErr} + <div class="card stack"> + <div class="spread"> + <h2>策略代码(Backtrader Python)</h2> + <span class="save-status" role="status"> + {saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`} + {#if saveStatus === 'dirty'}<span class="badge warn">有未保存修改</span>{/if} + {#if saveStatus === 'saving'}<span class="badge teal">保存中…</span>{/if} + {#if saveStatus === 'conflict'}<span class="badge danger">冲突</span>{/if} + </span> + </div> + <p class="hint"> + 编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照 + </p> + <CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} /> + <div class="spread wrap"> + <input + bind:value={snapshotMessage} + placeholder="快照说明,例如:加入RSI过滤器(可选)" + style="flex:2 1 240px" + onkeydown={(ev) => { + if (ev.key === 'Enter') void snapshot(); + }} + /> + <button class="btn" disabled={snapshotBusy} onclick={snapshot}> + {snapshotBusy ? '创建快照…' : '保存版本快照'} + </button> + <button + class="btn" + onclick={() => versions[1] && void compareWithDraft(versions[1])} + disabled={!versions[1]} + > + 草稿与上一快照对比 + </button> + </div> + <Status busy={false} error={snapshotErr} empty={snapshotMsg} /> + + {#if conflictServer} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <span> + 服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。 + </span> + </div> + <DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" /> + <div class="row"> + <button class="btn primary" onclick={keepMine}>保留本机修改(覆盖服务器)</button> + <button class="btn" onclick={loadServer}>采用服务器版本</button> + </div> + {/if} + </div> + + <div class="stack"> + <h2>AI 助手</h2> + <div class="card stack"> + <label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label> + <textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea> + <div class="row"> + <button class="btn primary" disabled={aiBusy || !instruction.trim()} onclick={askAI}> + {aiBusy ? 'AI 处理中…' : '获取建议'} + </button> + </div> + {#if aiErr} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>{aiErr}</span> + </div> + {/if} + + {#if aiResult} + <div class="stack"> + <div class="spread"> + <h3>建议解释 <span class="hint">{aiResult.model}</span></h3> + <span class="hint"> + tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出 + </span> + </div> + <p>{aiResult.explanation}</p> + <h3>完整建议源码</h3> + <CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} /> + <h3>与当前草稿的差异</h3> + <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" /> + <div class="row"> + <button + class="btn primary" + disabled={aiAcceptBusy} + onclick={acceptAI} + title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''} + > + {aiAcceptBusy ? '接受中…' : '接受为新版本'} + </button> + <button class="btn ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</button> + {#if aiResult.base_generation !== baseGeneration} + <span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span> + {/if} + {#if aiAcceptErr} + <div class="banner error" role="alert">{aiAcceptErr}</div> + {/if} + </div> + </div> + {/if} + {#if aiNote} + <div class="banner info">{aiNote}</div> + {/if} + </div> + </div> + + <div class="stack"> + <h2>版本历史</h2> + <Status busy={versionsBusy} error={versionsErr} busyText="载入版本…" empty={null} /> + {#if !versionsBusy && versions.length === 0} + <Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" /> + {/if} + {#if !versionsBusy && versions.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>摘要</th> + <th>哈希</th> + <th>来源</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each versions as v, i (v.id)} + <tr> + <td>{fmtDateTime(v.created_at)}</td> + <td> + {v.message?.trim() || '(无说明)'} + <span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span> + </td> + <td class="num">{v.hash?.slice(0, 12)}</td> + <td><span class="badge grey">{versionSourceLabel[v.source]}</span></td> + <td> + <div class="row"> + <button class="btn small" onclick={() => void compareWithDraft(v)}>对比当前</button> + {#if i + 1 < versions.length} + <button class="btn small" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</button> + {/if} + <button class="btn small" onclick={() => void restore(v)}>恢复为新草稿</button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + <p class="hint">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p> + </div> + + {#if comparing} + <Modal title={comparing.label} onClose={() => (comparing = null)}> + <DiffView before={comparing.before} after={comparing.after} /> + </Modal> + {/if} + {/if} +</div> diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte new file mode 100644 index 0000000..b29a856 --- /dev/null +++ b/frontend/src/pages/ProjectsPage.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { projectsStore } from '../lib/listsStore.svelte'; + import { createProject } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { go } from '../lib/router'; + import { fmtDateTime } from '../lib/format'; + + let busy = $state(false); + let error = $state<string | null>(null); + let creating = $state(false); + let showCreate = $state(false); + let newName = $state(''); + let newDesc = $state(''); + let createErr = $state<string | null>(null); + + async function load() { + busy = true; + error = null; + try { + await projectsStore.load(); + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + async function create(e: SubmitEvent) { + e.preventDefault(); + if (!newName.trim()) return; + creating = true; + createErr = null; + try { + const p = await createProject(newName.trim(), newDesc.trim() || undefined); + projectsStore.bump(p); + showCreate = false; + newName = ''; + newDesc = ''; + go(`/projects/${p.id}/data`); + } catch (err) { + createErr = extractStatusMessage(err); + } finally { + creating = false; + } + } +</script> + +<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录"> + {#snippet actions()} + <button class="btn primary" onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}> + {showCreate ? '取消新建' : '新建项目'} + </button> + {/snippet} + + <div class="stack"> + <Status {busy} {error} busyText="加载项目列表…" empty={null} /> + {#if showCreate} + <form class="card stack" onsubmit={create}> + <h2>新建项目</h2> + <div> + <label for="pn">项目名称</label> + <input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" /> + </div> + <div> + <label for="pd">简介(可选)</label> + <input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" /> + </div> + {#if createErr}<div class="banner error" role="alert">{createErr}</div>{/if} + <div class="row"> + <button type="submit" class="btn primary" disabled={creating || !newName.trim()}> + {creating ? '创建中…' : '创建项目'} + </button> + </div> + </form> + {/if} + + {#if !busy && !error && projectsStore.all.length === 0} + <Status busy={false} empty="还没有项目" emptyHint="点击右上角「新建项目」开始你的第一个策略研究" /> + {/if} + + {#if projectsStore.all.length > 0} + <div class="card" style="padding:0.4rem 0.6rem;"> + <table class="data"> + <thead> + <tr> + <th>名称</th> + <th>简介</th> + <th>版本代</th> + <th>最近更新</th> + </tr> + </thead> + <tbody> + {#each projectsStore.all as p (p.id)} + <tr> + <td><a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a></td> + <td class="hint">{p.description ?? '—'}</td> + <td class="num">{p.draft_generation ?? '—'}</td> + <td>{fmtDateTime(p.updated_at)}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </div> +</Page> + +<style> + .p-name { font-weight: 600; } +</style> diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte new file mode 100644 index 0000000..bd77892 --- /dev/null +++ b/frontend/src/pages/RunsPage.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption } from 'echarts'; + import { listRuns } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format'; + import type { Run } from '../lib/types'; + + let runs: Run[] = $state([]); + let busy = $state(true); + let error = $state<string | null>(null); + let chosenIds: string[] = $state([]); + let failedLoad = $state<string[]>([]); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + try { + const { items } = await listRuns(); + runs = items; + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const selected = $derived(runs.filter((r) => chosenIds.includes(r.id))); + const comparable = $derived( + selected.filter((r) => r.status === 'succeeded' && r.result) + ); + + function toggle(id: string) { + if (chosenIds.includes(id)) chosenIds = chosenIds.filter((x) => x !== id); + else chosenIds = [...chosenIds.slice(-3), id]; + } + + const compareOption: EChartsOption = $derived.by(() => { + if (comparable.length === 0) return {}; + const series = comparable.map((r, idx) => { + const eq = r.result?.equity ?? []; + const base = eq[0]?.equity ?? 1; + const colors = ['#0f766e', '#b45309', '#1d4ed8', '#8b1d8b']; + return { + name: `Run ${r.id.slice(0, 6)}.`, + type: 'line' as const, + data: eq.map((p) => p.equity / base), + showSymbol: false, + lineStyle: { width: 1.5 }, + itemStyle: { color: colors[idx % colors.length] } + }; + }); + let xLen = Math.max(...comparable.map((r) => (r.result?.equity ?? []).length), 0); + const longest = comparable.reduce((best, r) => ((r.result?.equity ?? []).length > best.length ? r.result!.equity : best), [] as { date: string }[]); + return { + legend: { top: 0 }, + grid: { top: 32, left: 64, right: 20, bottom: 50 }, + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: longest.map((p) => p.date) }, + yAxis: { type: 'value', scale: true, name: '归一净值' }, + series + }; + }); + + const configDiffRows = $derived.by(() => { + if (selected.length < 2) return []; + const keys: (keyof Run)[] = ['project_id', 'version_id', 'dataset_id']; + const cfgKeys: (keyof NonNullable<Run['config']>)[] = ['capital', 'commission', 'slippage', 'benchmark_symbol']; + const rows: { label: string; vals: string[]; differing: boolean }[] = []; + const mk = (label: string, vals: unknown[]) => { + const valsStr = vals.map((v) => (v === null || v === undefined || v === '' ? '—' : v instanceof Date ? String(v) : String(v))); + rows.push({ label, vals: valsStr, differing: new Set(valsStr).size > 1 }); + }; + for (const k of keys) mk(k === 'project_id' ? '项目' : k === 'version_id' ? '代码版本' : '数据集', selected.map((r) => r[k])); + for (const k of cfgKeys) { + mk( + k === 'capital' ? '起始资金' : k === 'commission' ? '佣金率' : k === 'slippage' ? '滑点' : '基准', + selected.map((r) => r.config?.[k]) + ); + } + mk('状态', selected.map((r) => runStatusLabel[r.status])); + mk('时间', selected.map((r) => r.created_at)); + return rows; + }); +</script> + +<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在加载实验…" empty={null} /> + + {#if !busy && !error && runs.length === 0} + <Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" /> + {/if} + + {#if !busy && !error && runs.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th></th> + <th>时间</th> + <th>状态</th> + <th>项目</th> + <th>数据集</th> + <th>资金/佣金/滑点</th> + <th>收益</th> + <th>最大回撤</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each runs as r (r.id)} + <tr> + <td> + <input + type="checkbox" + checked={chosenIds.includes(r.id)} + onchange={() => toggle(r.id)} + aria-label={`选择 Run ${r.id.slice(0, 8)} 参与`} + disabled={r.status !== 'succeeded' && chosenIds.length >= 4 && !chosenIds.includes(r.id)} + /> + </td> + <td>{fmtDateTime(r.created_at)}</td> + <td><span class="badge {runStatusBadge[r.status]}">{runStatusLabel[r.status]}</span></td> + <td class="num">{r.project_id?.slice(0, 8)}…</td> + <td class="num">{r.dataset_id?.slice(0, 8)}…</td> + <td>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</td> + <td>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</td> + <td>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</td> + <td> + <a class="btn small" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</a> + </td> + </tr> + {/each} + </tbody> + </table> + <p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p> + </div> + + {#if selected.length > 0} + <div class="card stack"> + <h2>条件差异</h2> + <table class="data"> + <thead> + <tr> + <th>维度</th> + {#each selected as r (r.id)}<th>Run {r.id.slice(0, 6)}</th>{/each} + </tr> + </thead> + <tbody> + {#each configDiffRows as row, i (i)} + <tr class:diffrow={row.differing}> + <th>{row.label}</th> + {#each row.vals as v, j (j)} + <td class={row.differing ? 'num-diff num' : 'num'}>{v}</td> + {/each} + </tr> + {/each} + </tbody> + </table> + </div> + + {#if comparable.length > 1} + <div class="card stack"> + <h2>收益曲线对齐比较(首日归一)</h2> + <Chart option={compareOption} ariaLabel="多回测对比曲线" height={340} /> + <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p> + </div> + {:else} + <Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" /> + {/if} + {/if} + {/if} + </div> +</Page> diff --git a/frontend/src/pages/UsagePage.svelte b/frontend/src/pages/UsagePage.svelte new file mode 100644 index 0000000..88dd8a5 --- /dev/null +++ b/frontend/src/pages/UsagePage.svelte @@ -0,0 +1,84 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { getAIUsage } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, fmtNum } from '../lib/format'; + import type { AIUsage } from '../lib/types'; + + let usage = $state<AIUsage | null>(null); + let busy = $state(true); + let error = $state<string | null>(null); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + error = null; + try { + usage = await getAIUsage(); + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } +</script> + +<Page title="AI 用量" subtitle="内部 POC 阶段的用量计量与限额参考。不含报价或计费"> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在载入用量…" empty={null} /> + {#if usage} + <div class="card"> + <div class="row spaced"> + <div> + <div class="mlabel">模型请求</div> + <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div> + </div> + <div> + <div class="mlabel">输入 tokens</div> + <div class="mvalue">{usage.totals?.input_tokens ?? '—'}</div> + </div> + <div> + <div class="mlabel">输出 tokens</div> + <div class="mvalue">{usage.totals?.output_tokens ?? '—'}</div> + </div> + </div> + {#if usage.internal_poc} + <p class="hint">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p> + {/if} + </div> + + {#if usage.items.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>模型</th> + <th>输入</th> + <th>输出</th> + <th>状态</th> + </tr> + </thead> + <tbody> + {#each usage.items as it, i (it.id)} + <tr> + <td>{fmtDateTime(it.created_at)}</td> + <td>{it.model}</td> + <td class="num">{it.input_tokens ?? '—'}</td> + <td class="num">{it.output_tokens ?? '—'}</td> + <td class="badge">{it.status ?? '—'}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {:else} + <Status busy={false} empty="尚未使用 AI 建议" emptyHint="在项目「策略」标签发起一次 AI 修改,记录会出现在这里" /> + {/if} + {/if} + </div> +</Page> diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..cdd6071 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1,36 @@ + +// jsdom does not implement Range.getClientRects, which CodeMirror's +// measurement layer calls for drawing decorations (diagnostics markers). +// Without it the suite emits an unhandled +// `textRange(...).getClientRects is not a function` TypeError even though +// assertions pass. Narrow geometry shim ONLY for the missing API; it returns +// one empty rect, from which CodeMirror derives null geometry — no other +// errors are suppressed. +if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { + const EMPTY_RECT = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() { return {}; } }; + Range.prototype.getClientRects = function getClientRects() { + return [EMPTY_RECT] as unknown as DOMRectList; + }; +} + +// jsdom ships no canvas backend: HTMLCanvasElement.prototype.getContext exists +// but throws "Not implemented". Charts render with ECharts' SVG renderer; the +// only canvas user in tests is zrender's text-metrics helper (measureText +// paints into a hidden canvas). Replace getContext ONLY when the native +// implementation is the throwing stub, and provide just the measurement +// approximation — SVG chart rendering itself (the part regressions inspect) +// is untouched and fully real. +if (typeof HTMLCanvasElement !== 'undefined') { + HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement, contextId: string) { + if (contextId === '2d') { + return { + canvas: this, + font: '', + measureText(text: string) { + return { width: String(text).length * 7, actualBoundingBoxAscent: 7, actualBoundingBoxDescent: 1 }; + } + } as unknown as CanvasRenderingContext2D; + } + return null; + } as unknown as typeof HTMLCanvasElement.prototype.getContext; +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..9dea8ab --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "types": ["vite/client", "vitest/globals"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3178f19 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + // resolve svelte's browser (client) build so tests can actually mount + // components; production client bundles already resolve the same entry + resolve: { conditions: ['browser'] }, + build: { target: 'es2020' }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + // geometry shim: jsdom lacks Range.getClientRects which CodeMirror's + // measurement layer needs (narrow; see src/test-setup.ts) + setupFiles: ['./src/test-setup.ts'], + // let vitest resolve the browser (client) build of svelte so component + // mounting is possible in jsdom regression tests + resolve: { conditions: ['browser'] } + }, + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8787', + changeOrigin: true + } + } + } +}); diff --git a/requirements-worker.txt b/requirements-worker.txt new file mode 100644 index 0000000..f21298b --- /dev/null +++ b/requirements-worker.txt @@ -0,0 +1,7 @@ +# Python worker runtime dependencies (worker + backtest + data acquisition) +backtrader==1.9.78.123 +akshare>=1.16.0 +pandas>=2.0 +numpy>=1.26 +pyarrow>=15.0 +requests>=2.31 diff --git a/scripts/qa_api.py b/scripts/qa_api.py new file mode 100644 index 0000000..a97a1ce --- /dev/null +++ b/scripts/qa_api.py @@ -0,0 +1,159 @@ +"""Black-box release gates. Uses isolated QA accounts, no production credentials in output.""" +import argparse +import json +import os +import pathlib +import secrets +import time +import requests + +BASE = os.environ.get('QA_BASE', 'http://127.0.0.1:8787').rstrip('/') +OUT = pathlib.Path(os.environ.get('QA_OUTPUT', '/home/somhairle/.hermes/cache/strategy-lab-qa/results')) +OUT.mkdir(parents=True, exist_ok=True) +checks = [] + + +def check(name, condition, detail=''): + checks.append({'name': name, 'passed': bool(condition), 'detail': detail}) + print(('PASS ' if condition else 'FAIL ') + name, flush=True) + + +def session(): + s = requests.Session() + s.headers.update({'Content-Type': 'application/json', 'Origin': BASE}) + return s + + +def call(s, method, path, body=None, expected=(200, 201, 202)): + r = s.request(method, BASE + '/api' + path, json=body, timeout=150) + if r.status_code not in expected: + raise RuntimeError(f'{method} {path}: HTTP {r.status_code}: {r.text[:600]}') + return r.json() if r.content else {} + + +def denied(s, method, path, body=None, headers=None): + r = s.request(method, BASE + '/api' + path, json=body, headers=headers, timeout=20) + return r.status_code in (401, 403, 404) + + +def wait(s, path, terminal, seconds=300): + until = time.monotonic() + seconds + while time.monotonic() < until: + obj = call(s, 'GET', path) + if obj.get('status') in terminal: + return obj + time.sleep(2) + raise RuntimeError('timeout waiting for ' + path) + + +def accounts(admin): + nonce = secrets.token_hex(5) + users = [] + for label in ['a', 'b']: + email = f'qa-{label}-{nonce}@strategy-lab.invalid' + pw = secrets.token_urlsafe(22) + invite = call(admin, 'POST', '/admin/invitations', {'email': email, 'role': 'member', 'expires_hours': 1}) + s = session() + body = {'invite_token': invite['token'], 'email': email, 'password': pw, 'name': '验收账户' + label, 'role': 'admin'} + reg = call(s, 'POST', '/auth/register', body) + me = call(s, 'GET', '/auth/me')['user'] + check('register ignores supplied admin role ' + label, me['role'] == 'member') + check('member cannot enumerate users ' + label, denied(s, 'GET', '/admin/users')) + retry = session().post(BASE + '/api/auth/register', json=body, timeout=20) + check('invitation single-use ' + label, retry.status_code in (400, 401, 403, 409)) + users.append({'session': s, 'email': email, 'password': pw, 'user': me}) + a, b = users + project = call(a['session'], 'POST', '/projects', {'name': '端到端验收项目', 'description': '自动化验收;真实行情与合成测试严格分离'}) + pid = project['id'] + check('other member cannot read project', denied(b['session'], 'GET', '/projects/' + pid)) + check('admin cannot read private project', denied(admin, 'GET', '/projects/' + pid)) + check('other member cannot overwrite project', denied(b['session'], 'PUT', f'/projects/{pid}/draft', {'code': 'leak', 'expected_generation': project['draft_generation']})) + check('csrf foreign origin blocked', denied(a['session'], 'PATCH', '/projects/' + pid, {'name': 'bad'}, {'Origin': 'https://evil.invalid'})) + host = BASE.split('://', 1)[1] + check('csrf substring origin blocked', denied(a['session'], 'PATCH', '/projects/' + pid, {'name': 'bad'}, {'Origin': 'https://' + host + '.evil.invalid'})) + code = 'import backtrader as bt\nclass Strategy(bt.Strategy):\n def next(self):\n if not self.position and len(self) == 2:\n self.buy(size=100)\n' + project = call(a['session'], 'PUT', f'/projects/{pid}/draft', {'code': code, 'expected_generation': project['draft_generation']}) + stale = a['session'].put(BASE + f'/api/projects/{pid}/draft', json={'code': '# stale', 'expected_generation': project['draft_generation'] - 1}, timeout=20) + check('stale draft rejected', stale.status_code == 409) + version = call(a['session'], 'POST', f'/projects/{pid}/versions', {'message': '验收:固定源代码'}) + edited = call(a['session'], 'PUT', f'/projects/{pid}/draft', {'code': code + '# changed\n', 'expected_generation': project['draft_generation']}) + old = call(a['session'], 'GET', f'/projects/{pid}/versions')['items'] + check('immutable saved source', any(v['id'] == version['id'] and v['code'] == code for v in old)) + restored = call(a['session'], 'POST', f'/projects/{pid}/restore', {'version_id': version['id'], 'expected_generation': edited['draft_generation']}) + check('restore creates new draft generation', restored['draft_code'] == code and restored['draft_generation'] > edited['draft_generation']) + check('last admin cannot be disabled', admin.patch(BASE + '/api/admin/users/' + call(admin, 'GET', '/auth/me')['user']['id'], json={'active': False}, timeout=20).status_code in (400, 403, 409)) + second = session() + call(second, 'POST', '/auth/login', {'email': b['email'], 'password': b['password']}) + reset = call(admin, 'POST', '/admin/users/' + b['user']['id'] + '/reset-password', {}) + new_pw = secrets.token_urlsafe(22) + call(session(), 'POST', '/auth/reset-password', {'token': reset['reset_token'], 'new_password': new_pw}) + check('reset revokes existing sessions', denied(second, 'GET', '/auth/me') and denied(b['session'], 'GET', '/auth/me')) + check('reset token single-use', session().post(BASE + '/api/auth/reset-password', json={'token': reset['reset_token'], 'new_password': new_pw}, timeout=20).status_code in (400, 401, 403, 409)) + b['password'] = new_pw + call(b['session'], 'POST', '/auth/login', {'email': b['email'], 'password': new_pw}) + call(admin, 'PATCH', '/admin/users/' + b['user']['id'], {'active': False}) + check('disabled account session invalid', denied(b['session'], 'GET', '/auth/me')) + check('disabled account login denied', denied(session(), 'POST', '/auth/login', {'email': b['email'], 'password': new_pw})) + call(admin, 'PATCH', '/admin/users/' + b['user']['id'], {'active': True}) + call(b['session'], 'POST', '/auth/login', {'email': b['email'], 'password': new_pw}) + a['project'] = restored + return a, b + + +def market(a, b): + s = a['session'] + req = {'name': '真实行情验收:浦发银行', 'instruments': [{'symbol': '600000', 'market': 'cn', 'asset_type': 'stock', 'name': '浦发银行'}], 'start_date': '2024-01-01', 'end_date': '2024-06-30', 'frequency': 'daily', 'adjustment': 'none', 'fields': ['open', 'high', 'low', 'close', 'volume']} + d = call(s, 'POST', '/datasets', req) + d = wait(s, '/datasets/' + d['id'], ['ready', 'failed'], 360) + check('real dataset ready', d['status'] == 'ready', str(d.get('error', ''))[:400]) + if d['status'] != 'ready': + return + check('other account cannot read dataset', denied(b['session'], 'GET', '/datasets/' + d['id'])) + check('no server paths in manifest', '/home/' not in json.dumps(d) and '"path"' not in json.dumps(d.get('manifest', {}))) + preview = call(s, 'GET', '/datasets/' + d['id'] + '/preview') + check('real preview has rows', len(preview.get('rows', [])) > 0) + dupe = call(b['session'], 'POST', '/datasets', req) + dupe = wait(b['session'], '/datasets/' + dupe['id'], ['ready', 'failed'], 360) + check('shared cache reused across users', dupe['status'] == 'ready' and dupe.get('cache_hit') is True) + check('shared immutable data hash', dupe.get('manifest', {}).get('hash') == d.get('manifest', {}).get('hash')) + runbody = {'project_id': a['project']['id'], 'dataset_id': d['id'], 'capital': 100000, 'commission': 0.0003, 'slippage': 0.001, 'benchmark_symbol': '600000', 'parameters': {}, 'acknowledge_warnings': True} + run = call(s, 'POST', '/runs', runbody) + run = wait(s, '/runs/' + run['id'], ['succeeded', 'failed', 'cancelled'], 240) + check('real container backtest succeeded', run['status'] == 'succeeded', str(run.get('error', ''))[:400]) + check('other member cannot read run', denied(b['session'], 'GET', '/runs/' + run['id'])) + if run['status'] == 'succeeded': + result = run['result'] + check('backtest has real equity and fills', len(result.get('equity', [])) > 10 and len(result.get('trades', [])) > 0) + check('result pins data manifest', result.get('data_manifest_hash') == d['manifest']['hash']) + rerun = call(s, 'POST', '/runs/' + run['id'] + '/rerun', {'use_original_data': True}) + rerun = wait(s, '/runs/' + rerun['id'], ['succeeded', 'failed', 'cancelled'], 240) + check('original run reproducible', rerun['status'] == 'succeeded' and rerun.get('result', {}).get('equity') == result.get('equity')) + a['dataset_id'] = d['id'] + a['run_id'] = run['id'] + (OUT / 'real-backtest.json').write_text(json.dumps(run, ensure_ascii=False, indent=2)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--market', action='store_true') + args = parser.parse_args() + admin = session() + call(admin, 'POST', '/auth/login', {'email': os.environ['QA_ADMIN_EMAIL'], 'password': os.environ['QA_ADMIN_PASSWORD']}) + a, b = accounts(admin) + if args.market: + market(a, b) + safe_state = {k: v for k, v in a.items() if k != 'session'} + private = OUT / 'browser-account.private.json' + fd = os.open(private, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as f: + json.dump(safe_state, f, ensure_ascii=False) + + +if __name__ == '__main__': + try: + main() + except Exception as exc: + check('workflow precondition', False, str(exc)) + finally: + (OUT / 'api-checks.json').write_text(json.dumps(checks, ensure_ascii=False, indent=2)) + raise SystemExit(0 if checks and all(c['passed'] for c in checks) else 1) diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..41abaa4 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,1984 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strategy-lab-server" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "chrono", + "hex", + "http-body-util", + "rand 0.8.8", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..88e512f --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "strategy-lab-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "strategy-lab-server" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.8", features = ["json", "http1", "macros"] } +tokio = { version = "1", features = ["full"] } +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } +argon2 = "0.5" +rand = "0.8" +sha2 = "0.10" +hex = "0.4" +chrono = { version = "0.4", features = ["serde"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tower = "0.5" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3" +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } diff --git a/server/src/admin.rs b/server/src/admin.rs new file mode 100644 index 0000000..d6a861e --- /dev/null +++ b/server/src/admin.rs @@ -0,0 +1,387 @@ +use serde_json::json; +use axum::Json; + +use crate::auth::{audit, hash_password, make_admin_token, AuthUser}; +use crate::error::{AppError, AppResult}; +use crate::state::Cx; +use crate::util::now_iso; + +async fn assert_admin(_cx: &Cx, auth: &AuthUser) -> AppResult<()> { + if auth.role != "admin" { + return Err(AppError::forbidden("admin only")); + } + Ok(()) +} + +fn user_json(id: &str, email: &str, name: &str, role: &str, active: i64, ai: i64, limit: i64, created: String) -> serde_json::Value { + json!({ + "id": id, "email": email, "name": name, "role": role, + "active": active != 0, "ai_enabled": ai != 0, + "daily_run_limit": limit, "created_at": created, + }) +} + +pub async fn users(cx: Cx, auth: AuthUser) -> AppResult<Json<serde_json::Value>> { + assert_admin(&cx, &auth).await?; + let items: Vec<serde_json::Value> = cx.with_db(|db| { + let mut st = db.prepare("SELECT id,email,name,role,active,ai_enabled,daily_run_limit,created_at FROM users ORDER BY created_at ASC")?; + let v: Vec<serde_json::Value> = st.query_map([], |r| Ok(user_json( + &r.get::<_, String>(0)?, &r.get::<_, String>(1)?, &r.get::<_, String>(2)?, &r.get::<_, String>(3)?, + r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?, r.get::<_, String>(7)?, + )))?.collect::<Result<_, _>>()?; + Ok::<_, AppError>(v) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +#[derive(serde::Deserialize)] +pub struct AdminUserPatch { + pub active: Option<bool>, + pub role: Option<String>, + pub daily_run_limit: Option<i64>, + pub ai_enabled: Option<bool>, +} + +pub async fn patch_user(cx: Cx, auth: AuthUser, axum::extract::Path(target): axum::extract::Path<String>, body: Option<axum::Json<AdminUserPatch>>) -> AppResult<Json<serde_json::Value>> { + assert_admin(&cx, &auth).await?; + let axum::Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if let Some(role) = &r.role { + if !matches!(role.as_str(), "admin" | "member") { + return Err(AppError::bad("validation", "role must be admin|member")); + } + } + if let Some(n) = r.daily_run_limit { + if !(1..=2000).contains(&n) { + return Err(AppError::bad("validation", "daily_run_limit must be between 1 and 2000")); + } + } + cx.with_db(|db| -> AppResult<()> { + let tx = db.transaction()?; + let (trole, tactive): (String, i64) = tx.query_row( + "SELECT role, active FROM users WHERE id=?1", [&target], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|_| AppError::not_found("user not found"))?; + let demoting = r.role.as_ref().map(|new| trole == "admin" && new != "admin").unwrap_or(false); + let disabling = r.active == Some(false); + // Last active admin protection, enforced atomically with the update. + if (demoting || disabling) && trole == "admin" && tactive != 0 { + let active_admins: i64 = tx.query_row("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1", [], |row| row.get(0))?; + if active_admins <= 1 { + return Err(AppError::conflict("last_admin", "cannot demote or disable the last active admin")); + } + } + tx.execute( + "UPDATE users SET ai_enabled=COALESCE(?1,ai_enabled), daily_run_limit=COALESCE(?2,daily_run_limit), role=COALESCE(?3,role), active=COALESCE(?4,active) WHERE id=?5", + rusqlite::params![ + r.ai_enabled.map(|b| b as i64), + r.daily_run_limit, + &r.role, + r.active.map(|b| b as i64), + &target, + ])?; + // Disabling revokes every session and freezes that user's live runs, + // inside the same transaction as the account state flip. + if disabling { + tx.execute("DELETE FROM sessions WHERE user_id=?1", [&target]).ok(); + tx.execute( + "UPDATE runs SET status='cancelled', error='account disabled', finished_at=?1 WHERE user_id=?2 AND status IN ('queued','running')", + rusqlite::params![now_iso(), &target], + ).ok(); + } + tx.commit()?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "admin_user_update", &target, "ok").await; + let v = cx.with_db(|db| { + db.query_row("SELECT id,email,name,role,active,ai_enabled,daily_run_limit,created_at FROM users WHERE id=?1", [&target], |r| + Ok(user_json( + &r.get::<_, String>(0)?, &r.get::<_, String>(1)?, &r.get::<_, String>(2)?, &r.get::<_, String>(3)?, + r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?, r.get::<_, String>(7)?, + ))).map_err(|_| AppError::not_found("user not found")) + }).await?; + Ok(Json(v)) +} + +#[derive(serde::Deserialize)] +pub struct InvitationReq { + pub email: Option<String>, + pub role: Option<String>, + pub expires_hours: Option<i64>, +} + +pub async fn create_invitation(cx: Cx, auth: AuthUser, body: Option<axum::Json<InvitationReq>>) -> AppResult<(axum::http::StatusCode, Json<serde_json::Value>)> { + assert_admin(&cx, &auth).await?; + let axum::Json(r) = body.unwrap_or(axum::Json(InvitationReq { email: None, role: None, expires_hours: None })); + let role = r.role.unwrap_or_else(|| "member".to_string()); + // POC: invitations can only mint members; admins are bootstrapped offline. + // The invitation's role is stored in DB and registration ignores any + // client-supplied role input, so no escalation path exists. + if role != "member" { + return Err(AppError::bad("validation", "POC invitations can only create member role")); + } + let hours = r.expires_hours.unwrap_or(168); + if !(1..=336).contains(&hours) { + return Err(AppError::bad("validation", "expires_hours must be 1-336")); + } + let (token, expires) = make_admin_token(&cx, "invitations", None, hours, r.email.as_deref()).await?; + audit(&cx, Some(&auth.id), "invitation_issued", r.email.as_deref().unwrap_or("open-invite"), "ok").await; + Ok((axum::http::StatusCode::CREATED, Json(json!({ "token": token, "expires_at": expires })))) +} + +/// Sanitized invitations list: no token hashes, no secrets. +pub async fn list_invitations(cx: Cx, auth: AuthUser) -> AppResult<Json<serde_json::Value>> { + assert_admin(&cx, &auth).await?; + let items: Vec<serde_json::Value> = cx.with_db(|db| { + let now = now_iso(); + let mut st = db.prepare("SELECT id,email,role,expires_at,used_by,created_at FROM invitations WHERE expires_at > ?1 ORDER BY created_at DESC")?; + let v = st.query_map([&now], |r| Ok(json!({ + "id": r.get::<_, String>(0)?, + "email": r.get::<_, Option<String>>(1)?, + "role": r.get::<_, String>(2)?, + "expires_at": r.get::<_, String>(3)?, + "used_by": r.get::<_, Option<String>>(4)?, + "created_at": r.get::<_, String>(5)?, + })))?.collect::<Result<Vec<_>, _>>()?; + Ok::<_, AppError>(v) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn delete_invitation(cx: Cx, auth: AuthUser, axum::extract::Path(id): axum::extract::Path<String>) -> AppResult<Json<serde_json::Value>> { + assert_admin(&cx, &auth).await?; + cx.with_db(|db| -> AppResult<()> { + if db.execute("DELETE FROM invitations WHERE id=?1", [&id])? == 0 { + return Err(AppError::not_found("invitation not found")); + } + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "invitation_revoke", &id, "ok").await; + Ok(Json(json!({"ok": true}))) +} + +/// Admin-issued password reset token, hashed in DB, short-lived single-use. +/// The raw token is returned once for display; no fake email delivery. +pub async fn create_reset(cx: Cx, auth: AuthUser, axum::extract::Path(target): axum::extract::Path<String>, _body: Option<axum::Json<serde_json::Value>>) -> AppResult<(axum::http::StatusCode, Json<serde_json::Value>)> { + assert_admin(&cx, &auth).await?; + cx.with_db(|db| { + db.query_row("SELECT 1 FROM users WHERE id=?1", [&target], |row| row.get::<_, i64>(0)) + .map_err(|_| AppError::not_found("user not found"))?; + Ok::<_, AppError>(()) + }).await?; + let hours = 2; + let (token, expires) = make_admin_token(&cx, "password_resets", Some(target.as_str()), hours, None).await?; + audit(&cx, Some(&auth.id), "admin_password_reset_issued", &target, "ok").await; + Ok((axum::http::StatusCode::CREATED, Json(json!({"reset_token": token, "expires_at": expires})))) +} + +/// Sanitized security audit listing: actor/action/target/time/status only. +/// Sensitive material never reaches this table (see auth::audit) and stored +/// targets are rendered trimmed, never with password/key/code content. +pub async fn audit_list(cx: Cx, auth: AuthUser) -> AppResult<Json<serde_json::Value>> { + assert_admin(&cx, &auth).await?; + let items: Vec<serde_json::Value> = cx.with_db(|db| { + let mut st = db.prepare("SELECT ts,actor_id,action,target,status FROM audit ORDER BY seq DESC LIMIT 500")?; + let v = st.query_map([], |r| { + let ts: String = r.get(0)?; + let actor: Option<String> = r.get(1)?; + let action: String = r.get(2)?; + let target: Option<String> = r.get(3)?; + let status: String = r.get(4)?; + let items_json = json!({ + "ts": ts, "actor": actor, "action": action, + "target": target.unwrap_or_default(), "status": status, + }); + Ok(items_json) + })?.collect::<Result<Vec<_>, _>>()?; + Ok::<_, AppError>(v) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +/// Bootstrap the first admin from env. Idempotent: only inserts when no +/// admin account exists yet. +pub async fn bootstrap_admin(cx: &Cx) -> AppResult<()> { + let cfg = &cx.cfg; + let Some(email) = cfg.bootstrap_admin_email.clone() else { return Ok(()); }; + let Some(password) = cfg.bootstrap_admin_password.clone() else { return Ok(()); }; + let email = email.trim().to_lowercase(); + if email.is_empty() || !email.contains('@') || password.len() < 8 { + return Ok(()); + } + let exists: i64 = cx.with_db(|db| { + db.query_row("SELECT COUNT(*) FROM users WHERE role='admin'", [], |r| r.get(0)).unwrap_or(0) + }).await; + if exists > 0 { return Ok(()); } + let hash = hash_password(&password)?; + let id = crate::util::new_id(); + cx.with_db(|db| { + db.execute("INSERT OR IGNORE INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES (?1,?2,?3,'Administrator','admin',1,1,100,?4)", + rusqlite::params![&id, &email, &hash, now_iso()]).ok(); + }).await; + audit(cx, Some(&id), "bootstrap_admin", &id, "ok").await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AppState; + use axum::extract::{Path as P, State}; + use std::sync::Arc; + + fn handle(s: &Arc<AppState>) -> Cx { State(s.clone()) } + + fn admin_auth() -> AuthUser { + AuthUser { id: "admin-x".into(), email: "[email protected]".into(), role: "admin".into(), ai_enabled: true, daily_run_limit: 100, session_id: "sess-admin".into() } + } + + fn member_auth() -> AuthUser { + AuthUser { id: "member-x".into(), email: "[email protected]".into(), role: "member".into(), ai_enabled: false, daily_run_limit: 10, session_id: "sess-member".into() } + } + + fn test_state() -> Arc<AppState> { + let conn = rusqlite::Connection::open_in_memory().expect("in-memory db"); + crate::db::init_db(&conn).expect("schema"); + let cfg = crate::config::Config { + bind_addr: "127.0.0.1:0".into(), + canonical_origin: String::new(), + secure_cookies: false, + db_path: ":memory:".into(), + frontend_dir: "frontend/dist".into(), + data_dir: std::env::temp_dir().to_string_lossy().into_owned(), + worker_image: "test".into(), + fetch_timeout_secs: 1, + backtest_timeout_secs: 1, + run_concurrency: 1, + fetch_concurrency: 1, + session_hours: 24, + bootstrap_admin_email: None, + bootstrap_admin_password: None, + ai_base_url: "http://127.0.0.1:9".into(), + ai_model: "test-model".into(), + ai_daily_request_cap: 1, + ai_input_token_cap: 1, + ai_output_token_cap: 1, + ai_enabled_poc: false, + default_run_limit_per_day: 10, + version: "test".into(), + }; + Arc::new(AppState { + cfg, + db: tokio::sync::Mutex::new(conn), + run_sem: Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), + }) + } + + /// Insert a user directly and return its id. + async fn seed_user(cx: &Cx, email: &str, role: &str, password: &str) -> String { + let hash = crate::auth::hash_password(password).unwrap(); + let uid = crate::util::new_id(); + cx.with_db(|db| { + db.execute( + "INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES (?1,?2,?3,?4,?5,1,0,10,?6)", + rusqlite::params![&uid, email, &hash, "Test User", role, now_iso()]).unwrap(); + }).await; + uid + } + + #[tokio::test] + async fn member_cannot_list_users_or_audit() { + let s = test_state(); + let u = users(handle(&s), member_auth()).await; + assert_eq!(u.err().map(|e| e.status).unwrap_or_default(), axum::http::StatusCode::FORBIDDEN); + let a = audit_list(handle(&s), member_auth()).await; + assert_eq!(a.err().map(|e| e.status).unwrap_or_default(), axum::http::StatusCode::FORBIDDEN); + let inv = create_invitation(handle(&s), member_auth(), None).await; + assert_eq!(inv.err().map(|e| e.status).unwrap_or_default(), axum::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn last_active_admin_cannot_be_disabled_or_demoted() { + let s = test_state(); + let aid = seed_user(&handle(&s), "[email protected]", "admin", "an-admin-passphrase").await; + let res = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: Some(false), role: None, daily_run_limit: None, ai_enabled: None }))).await; + assert_eq!(res.err().map(|e| e.code).unwrap_or_default(), "last_admin", "disabling the last admin must be blocked"); + let res = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: None, role: Some("member".into()), daily_run_limit: None, ai_enabled: None }))).await; + assert_eq!(res.err().map(|e| e.code).unwrap_or_default(), "last_admin", "demoting the last admin must be blocked"); + // With a second active admin, disabling becomes legal. + let _ = seed_user(&handle(&s), "[email protected]", "admin", "another-passphrase-2").await; + let ok = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: Some(false), role: None, daily_run_limit: None, ai_enabled: None }))).await; + assert!(ok.is_ok()); + let out = ok.unwrap().0; + assert_eq!(out["active"], json!(false)); + } + + #[tokio::test] + async fn disabling_user_revokes_sessions_and_cancels_runs() { + let s = test_state(); + let aid = seed_user(&handle(&s), "[email protected]", "member", "a-member-passphrase").await; + handle(&s).with_db(|db| { + db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES ('sess-1',?1,?2,?3)", + rusqlite::params![&aid, now_iso(), crate::util::plus_hours(1)]).unwrap(); + db.execute( + "INSERT INTO projects (id,user_id,name,draft_code,draft_generation,created_at,updated_at) VALUES ('pid',?1,'p','',0,?2,?2)", + rusqlite::params![&aid, now_iso()]).unwrap(); + db.execute( + "INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES ('vid','pid','code','h','m','manual',?1)", + [now_iso()]).unwrap(); + db.execute( + "INSERT INTO datasets (id,user_id,name,request,status,created_at,updated_at) VALUES ('did',?1,'ds','{}','ready',?2,?2)", + rusqlite::params![&aid, now_iso()]).unwrap(); + db.execute( + "INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('rid',?1,'pid','vid','did','running','{}',?2)", + rusqlite::params![&aid, now_iso()]).unwrap(); + }).await; + let ok = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: Some(false), role: None, daily_run_limit: None, ai_enabled: None }))).await; + assert!(ok.is_ok(), "member can be disabled"); + let blocked: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM runs WHERE id='rid' AND status='cancelled'", [], |r| r.get(0)).unwrap()).await; + assert_eq!(blocked, 1, "live runs must be cancelled"); + let gone: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id=?1", [&aid], |r| r.get(0)).unwrap()).await; + assert_eq!(gone, 0, "sessions must be revoked"); + } + + #[tokio::test] + async fn invitations_only_mint_members_and_are_hashed() { + let s = test_state(); + let res = create_invitation(handle(&s), admin_auth(), None).await.unwrap(); + assert_eq!(res.0, axum::http::StatusCode::CREATED); + let token = res.1.0["token"].as_str().unwrap().to_string(); + assert!(!token.contains("password")); + // The DB must hold only the sha256 of the token, never the raw token. + let hash = crate::util::sha256_hex(token.as_bytes()); + let hashed_rows: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM invitations WHERE token_hash=?1", [&hash], |r| r.get(0)).unwrap()).await; + assert_eq!(hashed_rows, 1); + let raw_rows: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM invitations WHERE token_hash=?1", [&token], |r| r.get(0)).unwrap()).await; + assert_eq!(raw_rows, 0, "raw tokens must never be stored"); + // Admin roles via invitation are rejected. + let offered = create_invitation(handle(&s), admin_auth(), Some(axum::Json(InvitationReq { email: None, role: Some("admin".into()), expires_hours: None }))).await; + assert_eq!(offered.err().map(|e| e.code).unwrap_or_default(), "validation"); + // Sanitized listing contains no token material. + let list = list_invitations(handle(&s), admin_auth()).await.unwrap().0; + let raw = serde_json::to_string(&list).unwrap(); + assert!(!raw.contains(&hash), "listing must not leak token hashes"); + } + + #[tokio::test] + async fn bootstrap_admin_is_idempotent_and_hashes_password() { + let mut cfg_state = test_state(); + { + let cfg = &mut Arc::get_mut(&mut cfg_state).unwrap().cfg; + cfg.bootstrap_admin_email = Some("[email protected] ".into()); + cfg.bootstrap_admin_password = Some("boot-admin-passphrase".into()); + } + let cx = handle(&cfg_state); + bootstrap_admin(&cx).await.unwrap(); + bootstrap_admin(&cx).await.unwrap(); + let count: i64 = cx.with_db(|db| db.query_row( + "SELECT COUNT(*) FROM users WHERE role='admin'", [], |r| r.get(0)).unwrap()).await; + assert_eq!(count, 1, "bootstrap must not duplicate admins"); + let hash: String = cx.with_db(|db| db.query_row( + "SELECT password_hash FROM users WHERE role='admin'", [], |r| r.get(0)).unwrap()).await; + assert!(hash.starts_with("$argon2"), "bootstrap password must be Argon2 hashed"); + } +} diff --git a/server/src/ai.rs b/server/src/ai.rs new file mode 100644 index 0000000..271fa76 --- /dev/null +++ b/server/src/ai.rs @@ -0,0 +1,319 @@ +use axum::extract::Path; +use axum::http::StatusCode; +use axum::Json; +use serde_json::{json, Value}; + +use crate::auth::{audit, AuthUser}; +use crate::error::{AppError, AppResult}; +use crate::util::new_id; +use crate::util::now_iso; +use crate::util::sha256_hex; + +use crate::state::Cx; + +const ASSIST_TIMEOUT_SECS: u64 = 90; +pub const MAX_INSTRUCTION_CHARS: usize = 2000; + +pub async fn list_ai_usage(cx: Cx, auth: AuthUser) -> AppResult<Json<Value>> { + let (items, totals_in, totals_out, n) = cx.with_db(|db| -> AppResult<_> { + let mut items = Vec::new(); + { + let mut st = db.prepare("SELECT ts,kind,input_tokens,output_tokens FROM ai_usage WHERE user_id=?1 ORDER BY ts DESC LIMIT 500")?; + let mut rows = st.query([auth.id.clone()])?; + while let Some(r) = rows.next()? { items.push(json!({ + "ts": r.get::<_, String>(0)?, + "kind": r.get::<_, String>(1)?, + "input_tokens": r.get::<_, i64>(2)?, + "output_tokens": r.get::<_, i64>(3)?, + })); + } + } + let (i, o, n): (i64, i64, i64) = db.query_row( + "SELECT COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COUNT(*) FROM ai_usage WHERE user_id=?1", + [&auth.id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + Ok((items, i, o, n)) + }).await?; + Ok(Json(json!({ + "items": items, + "totals": {"requests": n, "input_tokens": totals_in, "output_tokens": totals_out, "internal_poc": true}, + }))) +} + +pub async fn assist(cx: Cx, auth: AuthUser, body: Option<Json<serde_json::Value>>) -> AppResult<Json<Value>> { + let axum::Json(j) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let project_id = j.get("project_id").and_then(|v| v.as_str()).ok_or_else(|| AppError::bad("validation", "project_id required"))?.to_string(); + let instruction = j.get("instruction").and_then(|v| v.as_str()).ok_or_else(|| AppError::bad("validation", "instruction required"))?.to_string(); + let expected = j.get("expected_generation").and_then(|v| v.as_i64()).ok_or_else(|| AppError::bad("validation", "expected_generation required"))?; + if instruction.trim().is_empty() || instruction.len() > MAX_INSTRUCTION_CHARS { + return Err(AppError::bad("validation", "instruction required (max 2000 chars)")); + } + if !cx.cfg.ai_enabled_poc { + return Err(AppError::forbidden("AI assist is not enabled (internal POC flag off)")); + } + if !auth.ai_enabled { + return Err(AppError::forbidden("AI not enabled on this account; contact the administrator")); + } + let (draft, gen): (String, i64) = cx.with_db(|db| { + db.query_row("SELECT draft_code,draft_generation FROM projects WHERE id=?1 AND user_id=?2", + rusqlite::params![&project_id, &auth.id], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|_| AppError::not_found("project not found")) + }).await?; + if gen != expected { + return Err(AppError::conflict("stale_generation", "draft changed; expected generation mismatch")); + } + // daily request budget, measured from the real ledger + let key = std::env::var("OPENCODE_GO_API_KEY").map_err(|_| { + AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "ai_not_configured", "[internal] AI provider key not configured in environment") + })?; + let (used, requests): (i64, i64) = cx.with_db(|db| { + let used: i64 = db.query_row("SELECT COUNT(*) FROM ai_requests WHERE user_id=?1 AND created_at LIKE ?2", + rusqlite::params![&auth.id, format!("{}%", chrono::Utc::now().format("%Y-%m-%d").to_string())], |r| r.get(0)).unwrap_or(0); + let reqs = db.query_row("SELECT COUNT(*) FROM ai_requests WHERE user_id=?1", [&auth.id], |r| r.get(0)).unwrap_or(0); + Ok::<_, AppError>((used, reqs)) + }).await?; + let _ = requests; + if used >= cx.cfg.ai_daily_request_cap { + return Err(AppError::new(StatusCode::TOO_MANY_REQUESTS, "ai_budget", "daily AI request budget reached")); + } + + let dataset_summary = summarize_dataset(&cx, &auth.id, &project_id).await?; + let system_prompt = "You are a strategy editor for a Backtrader POC. Propose changes to the user strategy code. Reply with one fenced ```python block containing the FULL proposed module, plus a short explanation outside the block. Never fabricate data. Do not execute anything."; + // Scope caveat (kept explicit): this integration is an internal POC providing + // strategy coding help for own use only; no production use and no commercial + // licensing grant is claimed for the upstream model service. + let user_prompt = format!("Change requested: {instruction}\n\nAvailable dataset fields (indicator warmup is your code's responsibility): {dataset_summary}\n\nCurrent full strategy source:\n{draft}"); + let body = json!({ + "model": cx.cfg.ai_model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_tokens": cx.cfg.ai_output_token_cap, + "temperature": 0.4, + }); + let id = new_id(); + let ts = now_iso(); + cx.with_db(|db| -> AppResult<()> { + db.execute("INSERT INTO ai_requests (id,user_id,project_id,instruction,status,base_generation,base_code_hash,created_at) VALUES (?1,?2,?3,?4,'pending',?5,?6,?7)", + rusqlite::params![&id, &auth.id, &project_id, &instruction, gen, sha256_hex(draft.as_bytes()), &ts])?; + Ok(()) + }).await?; + + let url = format!("{}/chat/completions", cx.cfg.ai_base_url.trim_end_matches('/')); + let client = reqwest::Client::builder().timeout(std::time::Duration::from_secs(ASSIST_TIMEOUT_SECS)).build() + .map_err(|_| AppError::internal("http client unavailable"))?; + let session_id = ai_session_id(&auth.id, &project_id); + let resp = client.post(url) + .bearer_auth(&key) + .header("Content-Type", "application/json") + // Honest app identity for the opencode go gateway (custom coding agents + // are an explicitly supported use; no other client identity is claimed). + .header("User-Agent", "strategy-lab-coding-assistant/0.1 (internal POC, strategy coding help only)") + // Stable per (account, project) conversation id so the upstream can + // reuse one session context; never random per request, never the key. + .header("x-opencode-session", session_id) + .json(&body).send().await; + + let resp: Value = match resp { + Ok(r) if r.status().is_success() => r.json().await.unwrap_or(Value::Null), + Ok(r) => { + let status = r.status(); + let text = r.text().await.unwrap_or_default(); + audit(&cx, Some(&auth.id), "ai_error", &id, "fail").await; + return Err(AppError::new(StatusCode::BAD_GATEWAY, "ai_upstream_error", format!("model call failed ({status})")) + .with_details(json!({"status": status.to_string(), "internal": text.chars().take(500).collect::<String>()}))); + } + Err(e) => { + audit(&cx, Some(&auth.id), "ai_error", &id, "error").await; + // startup must survive an unreachable provider; user sees a bounded error + return Err(AppError::new(StatusCode::BAD_GATEWAY, "ai_unreachable", format!("model endpoint unreachable: {e}"))); + } + }; + let usage_in: i64 = resp.get("usage").and_then(|u| u.get("prompt_tokens")).and_then(|v| v.as_i64()).unwrap_or(0); + let usage_out: i64 = resp.get("usage").and_then(|u| u.get("completion_tokens")).and_then(|v| v.as_i64()).unwrap_or(0); + if usage_in > cx.cfg.ai_input_token_cap { + return record_failure(&cx, &id, json!({"code": "ai_input_too_large", "input_tokens": usage_in}), "fail").await; + } + let choice_content = resp.get("choices").and_then(|c| c.get(0)).and_then(|c| c.get("message")) + .and_then(|m| m.get("content")).and_then(|c| c.as_str()).unwrap_or("").to_string(); + let proposed_opt = extract_code_block(&choice_content); + let explanation: String = { + let without = strip_code_blocks(&choice_content); + if without.is_empty() { "Model returned code without explanation.".to_string() } else { without } + }; + let Some(proposed) = proposed_opt else { + return record_failure(&cx, &id, json!({"code": "ai_no_code", "message": "model response contained no parseable full code block"}), "fail").await; + }; + let diff = crate::util::unified_diff(&draft, &proposed); + let usage = json!({"input_tokens": usage_in, "output_tokens": usage_out, "measured": true}); + cx.with_db(|db| -> AppResult<()> { + db.execute("UPDATE ai_requests SET status='succeeded', model=?1, explanation=?2, proposed_code=?3, diff=?4, usage=?5 WHERE id=?6", + rusqlite::params![cx.cfg.ai_model.clone(), explanation, &proposed, &diff, usage.to_string(), &id])?; + db.execute("INSERT INTO ai_usage (id,user_id,request_id,ts,kind,input_tokens,output_tokens) VALUES (?1,?2,?3,?4,'request',?5,?6)", + rusqlite::params![new_id(), &auth.id, &id, now_iso(), usage_in, usage_out])?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "ai_assist", &id, "ok").await; + Ok(Json(json!({ + "id": id, + "model": cx.cfg.ai_model, + "explanation": explanation, + "proposed_code": proposed, + "diff": diff, + "base_generation": gen, + "status": "succeeded", + "usage": usage, + }))) +} + +/// Stable AI gateway session id scoped to owner id + project UUID. +/// Deterministic so each (owner, project) pair always reuses one upstream +/// session; it contains no secrets (no API key material) and no email. +pub fn ai_session_id(owner_id: &str, project_id: &str) -> String { + format!("sl-strategy-lab-{}", sha256_hex(format!("{owner_id}:{project_id}").as_bytes())) +} + +async fn record_failure(cx: &Cx, id: &str, err: Value, status: &str) -> AppResult<Json<Value>> { + cx.with_db(|db| { + db.execute("UPDATE ai_requests SET status='failed', error=?1 WHERE id=?2", rusqlite::params![err.to_string(), id]).ok(); + }).await; + audit(cx, None, "ai_failure", id, status).await; + Err(AppError::bad("ai_failure", "AI request failed; see usage ledger")) +} + +fn strip_code_blocks(s: &str) -> String { + let mut out = String::new(); + let mut inn = false; + for line in s.lines() { + if line.contains("```python") || line.contains("```") { inn = !inn; continue } + if !inn { out.push_str(line); out.push('\n'); } + } + out.trim().to_string() +} + +pub fn extract_code_block(s: &str) -> Option<String> { + let mark = "```"; + let mut in_block = false; + let mut block = String::new(); + for l in s.lines() { + if !in_block && l.trim().starts_with(mark) { in_block = true; block.clear(); continue; } + if in_block && l.trim().starts_with(mark) { if !block.trim().is_empty() { return Some(block); } in_block = false; block.clear(); continue; } + if in_block { block.push_str(l); block.push('\n'); } + } + Some(block).filter(|b| !b.trim().is_empty()) +} + +/// Schema of the most recent ready dataset actually used by runs of this project. +async fn summarize_dataset(cx: &Cx, user_id: &str, project_id: &str) -> AppResult<String> { + let q: Option<String> = cx.with_db(|db| { + db.query_row("SELECT d.manifest FROM datasets d JOIN runs r ON r.dataset_id=d.id WHERE r.project_id=?1 AND d.user_id=?2 AND d.status='ready' ORDER BY r.created_at DESC LIMIT 1", + rusqlite::params![project_id, user_id], |r| r.get::<_, String>(0)).ok() + }).await; + let Some(m) = q else { return Ok("No dataset ready in this project yet: columns unknown.".to_string()); }; + let mj: Value = serde_json::from_str(&m).unwrap_or(Value::Null); + let mut cols = Vec::new(); + if let Some(objs) = mj.get("objects").and_then(|o| o.as_array()) { + for o in objs.iter() { + if let Some(c) = o.get("columns") { + if let Some(arr) = c.as_array() { + let cur: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(); + if cur.len() > cols.len() { cols = cur; } + } + } + } + } + Ok(if cols.is_empty() { "Dataset ready but columns not enumerated.".to_string() } else { cols.join(", ") }) +} + +/// Accept a proposal and create a new draft and a version based on the AI proposal. +pub async fn accept(cx: Cx, auth: AuthUser, path: Path<(String,)>, body: Option<Json<serde_json::Value>>) -> AppResult<Json<Value>> { + let (aid,) = path.0; + let axum::Json(j) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let expected = j.get("expected_generation").and_then(|v| v.as_i64()).ok_or_else(|| AppError::bad("validation", "expected_generation required"))?; + let (user_id, project_id, proposed, base_gen, base_hash): (String, String, Option<String>, i64, Option<String>) = cx.with_db(|db| { + db.query_row("SELECT user_id,project_id,proposed_code,base_generation,base_code_hash FROM ai_requests WHERE id=?1", + [&aid], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))) + .map_err(|_| AppError::not_found("AI request not found")) + }).await?; + if user_id != auth.id { return Err(AppError::not_found("AI request not found")); } + let Some(proposed) = proposed else { return Err(AppError::bad("ai_not_ready", "AI request failed or has no code to accept")); }; + let p: Value = cx.with_db(|db| -> AppResult<Value> { + let (draft, gen): (String, i64) = db.query_row("SELECT draft_code,draft_generation FROM projects WHERE id=?1 AND user_id=?2", + rusqlite::params![&project_id, &auth.id], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|_| AppError::not_found("project not found"))?; + if let Some(bh) = &base_hash { + if sha256_hex(draft.as_bytes()) != *bh { + return Err(AppError::conflict("stale_base", "current draft has changed since the proposal base; cannot auto-apply")); + } + } + if gen != base_gen { + return Err(AppError::conflict("stale_base", "proposal base does not match current generation")); + } + if gen != expected { + return Err(AppError::conflict("stale_generation", "draft changed; reload first")); + } + let hash = sha256_hex(proposed.as_bytes()); + let ts = now_iso(); + let vid = new_id(); + db.execute("UPDATE projects SET draft_code=?1, draft_generation=?2, updated_at=?3 WHERE id=?4", + rusqlite::params![&proposed, gen + 1, &ts, &project_id])?; + db.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES (?1,?2,?3,?4,'accepted AI proposal','ai',?5)", + rusqlite::params![&vid, &project_id, &proposed, &hash, &ts])?; + db.execute("UPDATE ai_requests SET status='succeeded' WHERE id=?1", [&aid]).ok(); + let mut st = db.prepare("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE id=?1")?; + let mut rows = st.query([&project_id])?; + let row = rows.next()?.ok_or_else(|| AppError::internal("project vanished"))?; + Ok(json!({ + "id": row.get::<_, String>(0)?, + "name": row.get::<_, String>(1)?, + "description": row.get::<_, String>(2)?, + "draft_code": row.get::<_, String>(3)?, + "draft_generation": row.get::<_, i64>(4)?, + "created_at": row.get::<_, String>(5)?, + "updated_at": row.get::<_, String>(6)?, + })) + }).await?; + audit(&cx, Some(&auth.id), "ai_accept", &aid, "ok").await; + Ok(Json(p)) +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_takes_first_fenced_full_python_block() { + let s = "prose\n```python\nclass Strategy(bt.Strategy):\n pass\n```\ntrail"; + assert_eq!(extract_code_block(s).unwrap(), "class Strategy(bt.Strategy):\n pass\n"); + assert!(extract_code_block("no fence at all").is_none(), "no hardcoded fake suggestion"); + assert!(extract_code_block("```\n\n```").is_none(), "empty block rejected"); + } + + #[test] + fn usage_totals_never_invent_costs() { + // usage ledger records tokens measured, never price/cost + let j = json!({"usage": {"input_tokens": 11, "output_tokens": 7}}); + assert!(j.get("usage").map(|_| true).unwrap_or(false)); + assert!(!serde_json::to_string(&j["usage"]).unwrap().contains("cost")); + } + + #[test] + fn ai_session_headers_are_stable_app_identity_without_secrets() { + // Honest app UA: claims only this internal POC coding-assistant identity. + let ua = "strategy-lab-coding-assistant/0.1 (internal POC, strategy coding help only)"; + assert!(ua.starts_with("strategy-lab-coding-assistant/0.1")); + assert!(!ua.contains("opencode") && !ua.contains("curl"), "must not impersonate another client identity"); + // Stable per owner+project: same scope -> same id, different scope -> different id. + let owner = "6f1d2b3a-1111-4aaa-9bbb-cccccccccccc"; + let p1 = "00000000-2222-4333-8444-555555555555"; + let p2 = "00000000-2222-4333-8444-666666666666"; + let s1 = ai_session_id(owner, p1); + assert_eq!(s1, ai_session_id(owner, p1), "session must be stable across requests for owner+project"); + assert_ne!(s1, ai_session_id(owner, p2), "session is scoped to the project"); + assert_ne!(s1, ai_session_id("another-owner", p1), "session is scoped to the owner id"); + // No secret material ever rides in the session header. + assert!(!s1.contains(owner) && !s1.contains(p1)); + assert_eq!(s1.len(), 16 + 64, "prefix + sha256 hex of owner:project"); + } +} diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..8942a83 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,713 @@ +use std::sync::Arc; +use axum::{ + extract::{FromRequestParts, Path}, + http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::error::{AppError, AppResult}; +use crate::state::{AppState, Cx}; +use crate::util::{gen_token, new_id, now_iso, plus_hours, sha256_hex}; + +pub const COOKIE_NAME: &str = "sl_session"; + +const USER_COLS_FULL: &str = "id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at"; + +struct RowUser { + id: String, email: String, password_hash: String, name: String, + role: String, active: i64, ai_enabled: i64, daily_run_limit: i64, created_at: String, +} + +fn row_user(r: &rusqlite::Row) -> rusqlite::Result<RowUser> { + Ok(RowUser { + id: r.get(0)?, email: r.get(1)?, password_hash: r.get(2)?, name: r.get(3)?, + role: r.get(4)?, active: r.get(5)?, ai_enabled: r.get(6)?, daily_run_limit: r.get(7)?, created_at: r.get(8)?, + }) +} + +fn user_json(u: &RowUser) -> serde_json::Value { + json!({ + "id": u.id, "email": u.email, "name": u.name, "role": u.role, + "active": u.active != 0, "ai_enabled": u.ai_enabled != 0, + "daily_run_limit": u.daily_run_limit, "created_at": u.created_at, + }) +} + +#[derive(Debug, Clone, Serialize)] +pub struct User { + pub id: String, + pub email: String, + pub name: String, + pub role: String, + pub active: bool, + pub ai_enabled: bool, + pub daily_run_limit: i64, + pub created_at: String, +} + +#[derive(Clone)] +pub struct AuthUser { + pub id: String, + pub email: String, + pub role: String, + pub ai_enabled: bool, + pub daily_run_limit: i64, + pub session_id: String, +} + +impl FromRequestParts<Arc<AppState>> for AuthUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> { + let Some(token) = cookie_token(&parts.headers) else { + return Err(AppError::unauthorized("authentication required")); + }; + if token.len() < 32 { + return Err(AppError::unauthorized("invalid or expired session")); + } + let id = sha256_hex(token.as_bytes()); + let now = now_iso(); + let user = state.with_db(|db| { + db.query_row( + "SELECT u.id,u.email,u.role,u.ai_enabled,u.daily_run_limit,s.id FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id=?1 AND s.expires_at > ?2 AND u.active=1", + [&id, &now], + |r| Ok(AuthUser { id: r.get(0)?, email: r.get(1)?, role: r.get(2)?, ai_enabled: r.get::<_, i64>(3)? != 0, daily_run_limit: r.get(4)?, session_id: r.get(5)? }), + ).ok() + }).await; + user.ok_or_else(|| AppError::unauthorized("invalid or expired session")) + } +} + +/// Argon2id password hashing with a fresh random salt per call. +pub fn hash_password(p: &str) -> AppResult<String> { + let salt = SaltString::generate(&mut rand::rngs::OsRng); + Argon2::default().hash_password(p.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::internal(format!("hash: {e}"))) +} + +pub fn verify_password(hash: &str, p: &str) -> bool { + PasswordHash::new(hash).ok() + .and_then(|h| Argon2::default().verify_password(p.as_bytes(), &h).ok()) + .is_some() +} + +pub async fn fetch_user(cx: &Cx, id: &str) -> AppResult<User> { + cx.with_db(|db| { + db.query_row( + "SELECT id,email,name,role,active,ai_enabled,daily_run_limit,created_at FROM users WHERE id=?1", + [id], + |r| Ok(User { id: r.get(0)?, email: r.get(1)?, name: r.get(2)?, role: r.get(3)?, active: r.get::<_, i64>(4)? != 0, ai_enabled: r.get::<_, i64>(5)? != 0, daily_run_limit: r.get(6)?, created_at: r.get(7)? }), + ).map_err(|_| AppError::not_found("user not found")) + }).await +} + +pub fn cookie_token(headers: &HeaderMap) -> Option<String> { + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + raw.split(';').find_map(|c| { + let c = c.trim(); + c.strip_prefix(COOKIE_NAME) + .and_then(|v| v.strip_prefix('=')) + .map(str::to_string) + .filter(|v| !v.is_empty()) + }) +} + +/// Issue a random 256-bit session token; only sha256(token) is stored. +pub async fn session_cookie(cx: &Cx, user_id: &str) -> AppResult<(String, String)> { + let token = gen_token(); + let id = sha256_hex(token.as_bytes()); + let expires = plus_hours(cx.cfg.session_hours); + cx.with_db(|db| -> AppResult<()> { + db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES (?1,?2,?3,?4)", + rusqlite::params![&id, user_id, now_iso(), &expires])?; + Ok(()) + }).await?; + let mut cookie = format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", cx.cfg.session_hours * 3600); + if cx.cfg.secure_cookies { + cookie.push_str("; Secure"); + } + Ok((cookie, expires)) +} + +pub fn clear_cookie() -> String { + format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0") +} + +pub async fn revoke_all_sessions(cx: &Cx, user_id: &str) -> AppResult<()> { + cx.with_db(|db| -> AppResult<()> { + db.execute("DELETE FROM sessions WHERE user_id=?1", [user_id])?; + Ok(()) + }).await +} + +/// Write a sanitized audit event. Callers must never pass passwords, keys, +/// tokens, strategy source or object payloads; only fixed action labels and +/// id/email targets, which sanitize_target bounds before storage. +pub async fn audit(cx: &Cx, actor: Option<&str>, action: &str, target: &str, status: &str) { + let target = sanitize_target(target); + cx.with_db(|db| { + db.execute("INSERT INTO audit (ts,actor_id,action,target,status) VALUES (?1,?2,?3,?4,?5)", + rusqlite::params![now_iso(), actor.map(str::to_string), action, target, status]).ok() + }).await; +} + +fn sanitize_target(t: &str) -> String { + if t.len() > 300 || t.lines().count() > 3 { + "redacted-oversized".to_string() + } else { + t.to_string() + } +} + +fn with_cookie(mut resp: Response, cookie: &str) -> Response { + if let Ok(hv) = HeaderValue::from_str(cookie) { + resp.headers_mut().insert(header::SET_COOKIE, hv); + } + resp +} + +async fn record_failure(cx: &Cx, email: &str) { + cx.with_db(|db| { + db.execute("INSERT INTO login_failures (email,failed_at) VALUES (?1,?2)", + rusqlite::params![email.to_string(), now_iso()]).ok() + }).await; +} + +/// Admin-issued one-time hashed token ("invitations" or "password_resets"). +/// Returns (raw_token shown once, expires_at). Only sha256 is persisted. +pub async fn make_admin_token( + cx: &Cx, + table: &str, + user_id: Option<&str>, + hours: i64, + email: Option<&str>, +) -> AppResult<(String, String)> { + let token = gen_token(); + let th = sha256_hex(token.as_bytes()); + let expires = plus_hours(hours); + let id = new_id(); + let email = email.map(|e| e.trim().to_lowercase()); + cx.with_db(|db| -> AppResult<()> { + match table { + "invitations" => { + db.execute("INSERT INTO invitations (id,email,token_hash,role,expires_at,created_at) VALUES (?1,?2,?3,'member',?4,?5)", + rusqlite::params![&id, email, &th, &expires, now_iso()])?; + } + "password_resets" => { + let uid = user_id.ok_or_else(|| AppError::bad("validation", "reset requires target user"))?; + db.execute("INSERT INTO password_resets (id,user_id,token_hash,expires_at) VALUES (?1,?2,?3,?4)", + rusqlite::params![&id, uid, &th, &expires])?; + } + other => return Err(AppError::internal(format!("unknown token table {other}"))), + } + Ok(()) + }).await?; + Ok((token, expires)) +} + +pub async fn purge_expired(cx: &Cx) { + cx.with_db(|db| { + let now = now_iso(); + db.execute("DELETE FROM sessions WHERE expires_at <= ?1", [&now]).ok(); + db.execute("DELETE FROM invitations WHERE expires_at <= ?1", [&now]).ok(); + db.execute("DELETE FROM password_resets WHERE expires_at <= ?1", [&now]).ok(); + db.execute("DELETE FROM login_failures WHERE failed_at <= ?1", [&plus_hours(-2)]).ok(); + }).await; +} + +// ---- handlers ---- + +#[derive(Deserialize)] +pub struct LoginReq { pub email: String, pub password: String } + +pub async fn login(cx: Cx, body: Option<Json<LoginReq>>) -> AppResult<Response> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let email = r.email.trim().to_lowercase(); + if email.is_empty() || r.password.is_empty() { + return Err(AppError::bad("validation", "email and password required")); + } + let failures: i64 = cx.with_db(|db| { + db.query_row("SELECT COUNT(*) FROM login_failures WHERE email=?1 AND failed_at >= ?2", + [&email, &plus_hours(-1)], |row| row.get(0)).unwrap_or(0) + }).await; + if failures > 20 { + audit(&cx, None, "login_throttled", &email, "rate_limited").await; + return Err(AppError::new(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "too many failed logins; try again later")); + } + let Some(u) = cx.with_db(|db| { + db.query_row(&format!("SELECT {USER_COLS_FULL} FROM users WHERE email=?1"), [&email], row_user).ok() + }).await else { + record_failure(&cx, &email).await; + audit(&cx, None, "login_failed", &email, "invalid_credentials").await; + return Err(AppError::unauthorized("invalid credentials")); + }; + if !verify_password(&u.password_hash, &r.password) { + record_failure(&cx, &email).await; + audit(&cx, Some(&u.id), "login_failed", &u.id, "invalid_credentials").await; + return Err(AppError::unauthorized("invalid credentials")); + } + if u.active == 0 { + audit(&cx, Some(&u.id), "login_denied", &u.email, "disabled").await; + return Err(AppError::forbidden("account is disabled")); + } + purge_expired(&cx).await; + let (cookie, _) = session_cookie(&cx, &u.id).await?; + audit(&cx, Some(&u.id), "login", &u.id, "ok").await; + let resp = Json(user_json(&u)).into_response(); + Ok(with_cookie(resp, &cookie)) +} + +/// Registration payload has NO role field: role comes only from the +/// admin-issued invitation ("member" only in this POC). serde ignores extra +/// client fields, so role escalation at registration is impossible. +#[derive(Deserialize)] +pub struct RegisterReq { + pub invite_token: String, + pub name: String, + pub email: String, + pub password: String, +} + +pub async fn register(cx: Cx, body: Option<Json<RegisterReq>>) -> AppResult<Response> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if r.name.trim().is_empty() || r.name.trim().len() > 200 { return Err(AppError::bad("validation", "name required (max 200)")); } + if r.password.len() < 8 || r.password.len() > 256 { return Err(AppError::bad("validation", "password must be 8-256 characters")); } + let email = r.email.trim().to_lowercase(); + if !email.contains('@') || email.len() < 5 || email.len() > 320 { return Err(AppError::bad("validation", "valid email required")); } + let pw = hash_password(&r.password)?; + let token_hash = sha256_hex(r.invite_token.as_bytes()); + let day_limit = cx.cfg.default_run_limit_per_day; + let user_id: String = cx.with_db(|db| -> AppResult<String> { + // One-time atomically consumed invitation with email binding: the + // select, binding/role/expiry/used checks, user insert and invite + // marking all run inside ONE transaction; the marking ( + // UPDATE ... WHERE used_by IS NULL) guarantees concurrent retries lose. + let now = now_iso(); + let tx = db.transaction()?; + let invite: Option<(String, String, Option<String>)> = tx.query_row( + "SELECT id,role,email FROM invitations WHERE token_hash=?1", + [&token_hash], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).ok(); + let Some((inv_id, role, inv_email)) = invite else { + return Err(AppError::bad("invite_invalid", "invalid, used or expired invitation token")); + }; + if role != "member" { + return Err(AppError::bad("invite_invalid", "invitation does not permit this role")); + } + // Email binding: an invitation addressed to a specific email can only + // be consumed with that exact (case-insensitive) email. + if let Some(bound) = &inv_email { + let bound = bound.trim().to_lowercase(); + if !bound.is_empty() && bound != email { + return Err(AppError::bad("invite_email_mismatch", "invitation is bound to a different email")); + } + } + let expired = tx.query_row("SELECT expires_at FROM invitations WHERE id=?1", [&inv_id], |r| r.get::<_, String>(0)) + .map(|exp| exp <= now).unwrap_or(true); + if expired { + return Err(AppError::bad("invite_invalid", "invalid, used or expired invitation token")); + } + if tx.query_row("SELECT COUNT(*) FROM users WHERE email=?1", [&email], |c| c.get::<_, i64>(0)).unwrap_or(0) > 0 { + return Err(AppError::conflict("email_taken", "an account with this email already exists")); + } + let uid = new_id(); + tx.execute( + "INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES (?1,?2,?3,?4,'member',1,0,?5,?6)", + rusqlite::params![&uid, &email, &pw, r.name.trim(), day_limit, &now])?; + let n = tx.execute("UPDATE invitations SET used_by=?1 WHERE id=?2 AND used_by IS NULL AND expires_at > ?3", + rusqlite::params![&uid, &inv_id, &now])?; + if n != 1 { + return Err(AppError::conflict("invite_used", "invitation token already consumed")); + } + tx.commit()?; + Ok(uid) + }).await?; + audit(&cx, Some(&user_id), "register", &email, "ok").await; + purge_expired(&cx).await; + let (cookie, _) = session_cookie(&cx, &user_id).await?; + let u = fetch_user(&cx, &user_id).await?; + let resp = (StatusCode::CREATED, Json(json!({ "user": u }))).into_response(); + Ok(with_cookie(resp, &cookie)) +} + +pub async fn logout(cx: Cx, auth: AuthUser, headers: HeaderMap) -> AppResult<Response> { + if let Some(t) = cookie_token(&headers) { + let id = sha256_hex(t.as_bytes()); + cx.with_db(|db| { db.execute("DELETE FROM sessions WHERE id=?1", [&id]).ok() }).await; + } + audit(&cx, Some(&auth.id), "logout", &auth.id, "ok").await; + let resp = StatusCode::NO_CONTENT.into_response(); + Ok(with_cookie(resp, &clear_cookie())) +} + +pub async fn me(cx: Cx, auth: AuthUser) -> AppResult<Json<serde_json::Value>> { + let u = fetch_user(&cx, &auth.id).await?; + Ok(Json(json!({ "user": u }))) +} + +#[derive(Deserialize)] +pub struct ProfileReq { pub name: String } + +pub async fn patch_profile(cx: Cx, auth: AuthUser, body: Option<Json<ProfileReq>>) -> AppResult<Json<serde_json::Value>> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let name = r.name.trim().to_string(); + if name.is_empty() || name.len() > 200 { return Err(AppError::bad("validation", "name required (max 200)")); } + cx.with_db(|db| -> AppResult<()> { + let n = db.execute("UPDATE users SET name=?1 WHERE id=?2", rusqlite::params![&name, &auth.id])?; + if n == 0 { return Err(AppError::not_found("user not found")); } + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "profile_update", &auth.id, "ok").await; + let u = fetch_user(&cx, &auth.id).await?; + Ok(Json(json!({ "user": u }))) +} + +#[derive(Deserialize)] +pub struct PasswordReq { pub current_password: String, pub new_password: String } + +pub async fn change_password(cx: Cx, auth: AuthUser, headers: HeaderMap, body: Option<Json<PasswordReq>>) -> AppResult<Json<serde_json::Value>> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if r.new_password.len() < 8 || r.new_password.len() > 256 { + return Err(AppError::bad("validation", "new password must be 8-256 characters")); + } + let hash: String = cx.with_db(|db| { + db.query_row("SELECT password_hash FROM users WHERE id=?1", [&auth.id], |row| row.get(0)) + .map_err(|_| AppError::unauthorized("authentication required")) + }).await?; + if !verify_password(&hash, &r.current_password) { + audit(&cx, Some(&auth.id), "password_change", &auth.id, "wrong_current_password").await; + return Err(AppError::unauthorized("current password incorrect")); + } + let new_hash = hash_password(&r.new_password)?; + // Keep the current session, revoke all others; update + revoke atomically. + let keep = cookie_token(&headers).map(|t| sha256_hex(t.as_bytes())).unwrap_or_default(); + cx.with_db(|db| -> AppResult<()> { + let tx = db.transaction()?; + tx.execute("UPDATE users SET password_hash=?1 WHERE id=?2", rusqlite::params![&new_hash, &auth.id])?; + tx.execute("DELETE FROM sessions WHERE user_id=?1 AND id != ?2", rusqlite::params![&auth.id, &keep])?; + tx.commit()?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "password_change", &auth.id, "ok").await; + Ok(Json(json!({ "ok": true, "other_sessions_revoked": true }))) +} + +/// Own-session listing, sanitized: sha256-derived ids only, never raw tokens; +/// expired sessions are never listed. `current` marks the caller's live session. +#[derive(Serialize)] +pub struct SessionInfo { pub id: String, pub created_at: String, pub expires_at: String } + +pub async fn list_sessions(cx: Cx, auth: AuthUser) -> AppResult<Json<serde_json::Value>> { + let current = auth.session_id.clone(); + let rows: Vec<(String, String, String)> = cx.with_db(|db| { + let now = now_iso(); + let mut st = db.prepare("SELECT id,created_at,expires_at FROM sessions WHERE user_id=?1 AND expires_at > ?2 ORDER BY created_at DESC")?; + let rows = st + .query_map(rusqlite::params![&auth.id, &now], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }).await.map_err(|e: rusqlite::Error| AppError::internal(e.to_string()))?; + Ok(Json(json!({ + "items": rows.into_iter().map(|(id, c, e)| json!({ + "id": id, "created_at": c, "expires_at": e, + "current": id == current, + })).collect::<Vec<_>>() + }))) +} + +pub async fn delete_session(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<serde_json::Value>> { + cx.with_db(|db| -> AppResult<()> { + if db.execute("DELETE FROM sessions WHERE id=?1 AND user_id=?2", rusqlite::params![&id, &auth.id])? == 0 { + // Never reveal another user's session ids. + return Err(AppError::not_found("session not found")); + } + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "session_revoke", &auth.id, "ok").await; + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +pub struct ResetReq { pub token: String, pub new_password: String } + +pub async fn reset_password(cx: Cx, body: Option<Json<ResetReq>>) -> AppResult<Json<serde_json::Value>> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if r.new_password.len() < 8 || r.new_password.len() > 256 { + return Err(AppError::bad("validation", "password must be 8-256 characters")); + } + let new_hash = hash_password(&r.new_password)?; + let th = sha256_hex(r.token.as_bytes()); + // Single-use consumption plus password update plus session revocation in + // ONE transaction: a second submission of the same token cannot succeed. + let uid: String = cx.with_db(|db| -> AppResult<String> { + let tx = db.transaction()?; + let (uid, used): (String, i64) = tx.query_row( + "SELECT user_id,used FROM password_resets WHERE token_hash=?1", + [&th], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|_| AppError::bad("reset_invalid", "invalid or expired reset token"))?; + if used != 0 { + return Err(AppError::conflict("reset_used", "reset token already consumed")); + } + let expired = tx.query_row("SELECT expires_at FROM password_resets WHERE token_hash=?1", [&th], |row| row.get::<_, String>(0)) + .map(|exp| exp <= now_iso()).unwrap_or(true); + if expired { + return Err(AppError::bad("reset_invalid", "invalid or expired reset token")); + } + tx.execute("UPDATE password_resets SET used=1 WHERE token_hash=?1", [&th])?; + tx.execute("UPDATE users SET password_hash=?1 WHERE id=?2", rusqlite::params![&new_hash, &uid])?; + tx.execute("DELETE FROM sessions WHERE user_id=?1", [&uid])?; + tx.commit()?; + Ok(uid) + }).await?; + audit(&cx, Some(&uid), "password_reset", &uid, "ok").await; + Ok(Json(json!({"ok": true}))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::init_db; + + fn st() -> Arc<AppState> { + let conn = rusqlite::Connection::open_in_memory().expect("in-memory db"); + init_db(&conn).expect("schema"); + let cfg = crate::config::Config { + bind_addr: "127.0.0.1:0".into(), + canonical_origin: String::new(), + secure_cookies: false, + db_path: ":memory:".into(), + frontend_dir: "frontend/dist".into(), + data_dir: std::env::temp_dir().to_string_lossy().into_owned(), + worker_image: "test".into(), + fetch_timeout_secs: 1, + backtest_timeout_secs: 1, + run_concurrency: 1, + fetch_concurrency: 1, + session_hours: 24, + bootstrap_admin_email: None, + bootstrap_admin_password: None, + ai_base_url: "http://127.0.0.1:9".into(), + ai_model: "test-model".into(), + ai_daily_request_cap: 1, + ai_input_token_cap: 1, + ai_output_token_cap: 1, + ai_enabled_poc: false, + default_run_limit_per_day: 10, + version: "test".into(), + }; + Arc::new(AppState { + cfg, + db: tokio::sync::Mutex::new(conn), + run_sem: Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), + }) + } + + fn handle(s: &Arc<AppState>) -> Cx { axum::extract::State(s.clone()) } + + fn cookie_of(token: &str) -> HeaderMap { + let mut hm = HeaderMap::new(); + hm.insert(header::COOKIE, HeaderValue::from_str(&format!("{COOKIE_NAME}={token}")).unwrap()); + hm + } + + /// Register through the real handler, then resolve the session exactly the + /// way the AuthUser extractor does, to produce a test auth context. + async fn register_ok(s: &Arc<AppState>, email: &str, name: &str, password: &str) -> (AuthUser, HeaderMap) { + let (inv, _) = make_admin_token(&handle(s), "invitations", None, 24, Some(email)).await.unwrap(); + let body = RegisterReq { + invite_token: inv, + name: name.into(), + email: email.into(), + password: password.into(), + }; + let resp = register(handle(s), Some(Json(body))).await.expect("register ok"); + let cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().to_string(); + let tok = cookie.split(';').next().unwrap() + .strip_prefix(COOKIE_NAME).and_then(|c| c.strip_prefix('=')) + .expect("cookie contains the raw session token").to_string(); + let now = now_iso(); + let au = s.with_db(|db| { + db.query_row( + "SELECT u.id,u.email,u.role,u.ai_enabled,u.daily_run_limit,s.id FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id=?1 AND s.expires_at > ?2", + [&sha256_hex(tok.as_bytes()), &now], + |r| Ok(AuthUser { id: r.get(0)?, email: r.get(1)?, role: r.get(2)?, ai_enabled: r.get::<_, i64>(3)? != 0, daily_run_limit: r.get(4)?, session_id: r.get(5)? }), + ).unwrap() + }).await; + (au, cookie_of(&tok)) + } + + #[tokio::test] + async fn password_hashes_are_argon2() { + let h = hash_password("correct horse battery").unwrap(); + assert!(h.starts_with("$argon2")); + assert!(verify_password(&h, "correct horse battery")); + assert!(!verify_password(&h, "wrong password")); + } + + #[tokio::test] + async fn register_creates_member_and_ignores_client_role() { + let s = st(); + let (auth, _) = register_ok(&s, "[email protected]", "Tester User", "a-reasonable-passphrase-1").await; + assert_eq!(auth.email, "[email protected]"); + assert_eq!(auth.role, "member", "role must never come from client input"); + assert!(!auth.ai_enabled, "ai stays off by default for members"); + // No password hash ever appears in user-facing rows' JSON contract: + // the users table keeps the hash, API payloads never include it. + let u = fetch_user(&handle(&s), &auth.id).await.unwrap(); + let payload = serde_json::to_string(&u).unwrap(); + assert!(!payload.contains("password_hash")); + assert!(!payload.contains("argon2")); + } + + #[tokio::test] + async fn invitation_is_single_use() { + let s = st(); + let (inv, _) = make_admin_token(&handle(&s), "invitations", None, 24, None).await.unwrap(); + let body = RegisterReq { + invite_token: inv, + name: "Once".into(), + email: "[email protected]".into(), + password: "a-reasonable-passphrase-1".into(), + }; + register(handle(&s), Some(Json(body))).await.expect("first use ok"); + let consumed: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM invitations WHERE used_by IS NOT NULL", [], |r| r.get(0)).unwrap()).await; + assert_eq!(consumed, 1); + let token_rows: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM invitations WHERE used_by IS NULL", [], |r| r.get(0)).unwrap()).await; + assert_eq!(token_rows, 0, "invitation must be one-time"); + } + + #[tokio::test] + async fn invitation_email_binding_enforced() { + let s = st(); + let (inv, _) = make_admin_token(&handle(&s), "invitations", None, 24, Some("[email protected]")).await.unwrap(); + let body = RegisterReq { + invite_token: inv, + name: "Bound".into(), + email: "[email protected]".into(), + password: "a-reasonable-passphrase-1".into(), + }; + let err = register(handle(&s), Some(Json(body))).await.err().expect("must reject mismatched email"); + assert_eq!(err.code, "invite_email_mismatch"); + // The token was NOT consumed by the failed attempt. + let unused: i64 = handle(&s).with_db(|db| { + db.query_row("SELECT COUNT(*) FROM invitations WHERE used_by IS NULL", [], |r| r.get(0)).unwrap() + }).await; + assert_eq!(unused, 1); + } + + #[tokio::test] + async fn reset_password_is_single_use_and_revokes_sessions() { + let s = st(); + let (auth, _) = register_ok(&s, "[email protected]", "Reset User", "original-passphrase-1").await; + handle(&s).with_db(|db| { + db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES ('dead-session',?1,?2,?3)", + rusqlite::params![&auth.id, now_iso(), plus_hours(1)]).unwrap(); + }).await; + let (token, _) = make_admin_token(&handle(&s), "password_resets", Some(auth.id.as_str()), 1, None).await.unwrap(); + reset_password(handle(&s), Some(Json(ResetReq { token: token.clone(), new_password: "new-passphrase-1".into() }))).await.unwrap(); + let sessions: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id=?1", [&auth.id], |r| r.get(0)).unwrap()).await; + assert_eq!(sessions, 0, "reset must revoke all sessions"); + let second = reset_password(handle(&s), Some(Json(ResetReq { token, new_password: "new-passphrase-2".into() }))).await; + assert!(second.is_err(), "reset token must be single-use"); + let hash: String = handle(&s).with_db(|db| db.query_row( + "SELECT password_hash FROM users WHERE id=?1", [&auth.id], |r| r.get(0)).unwrap()).await; + assert!(verify_password(&hash, "new-passphrase-1")); + assert!(!verify_password(&hash, "new-passphrase-2")); + } + + #[tokio::test] + async fn change_password_revokes_others_keeps_current() { + let s = st(); + let (auth, headers) = register_ok(&s, "[email protected]", "Change User", "original-passphrase-1").await; + let extra = gen_token(); + let extra_id = sha256_hex(extra.as_bytes()); + handle(&s).with_db(|db| { + db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES (?1,?2,?3,?4)", + rusqlite::params![&extra_id, &auth.id, now_iso(), plus_hours(1)]).unwrap(); + }).await; + let done = change_password(handle(&s), auth.clone(), headers.clone(), Some(Json(PasswordReq { + current_password: "original-passphrase-1".into(), + new_password: "brand-new-passphrase".into(), + }))).await; + done.unwrap_or_else(|e| panic!("change failed: {e}")); + let remaining: i64 = handle(&s).with_db(|db| { + let now = now_iso(); + db.query_row("SELECT COUNT(*) FROM sessions WHERE user_id=?1 AND expires_at > ?2", [&auth.id, &now], |r| r.get(0)).unwrap() + }).await; + assert_eq!(remaining, 1, "only the current session survives"); + let wrong = change_password(handle(&s), auth.clone(), headers, Some(Json(PasswordReq { + current_password: "wrong".into(), + new_password: "another-passphrase-1".into(), + }))).await; + assert!(wrong.is_err(), "wrong current password must be rejected"); + } + + #[tokio::test] + async fn audit_never_records_secrets() { + let s = st(); + let long = format!("{}password=secret-value", "x".repeat(400)); + audit(&handle(&s), None, "login_failed", &long, "fail").await; + let stored: String = handle(&s).with_db(|db| db.query_row( + "SELECT target FROM audit ORDER BY seq DESC LIMIT 1", [], |r| r.get(0)).unwrap()).await; + assert_eq!(stored, "redacted-oversized"); + let dirty: i64 = handle(&s).with_db(|db| { + db.query_row("SELECT COUNT(*) FROM audit WHERE target LIKE '%password=%'", [], |r| r.get(0)).unwrap() + }).await; + assert_eq!(dirty, 0, "secrets must never be written into the audit trail"); + } + + #[tokio::test] + async fn cookie_sessions_are_hashed_never_plaintext() { + let s = st(); + handle(&s).with_db(|db| { + db.execute("INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES ('some-user','[email protected]','h','n','member',1,0,10,?1)", + [now_iso()]).unwrap(); + }).await; + let (cookie, _) = session_cookie(&handle(&s), "some-user").await.unwrap(); + let tok = cookie.split(';').next().unwrap() + .strip_prefix(COOKIE_NAME).and_then(|c| c.strip_prefix('=')).unwrap(); + let plaintext: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM sessions WHERE id=?1", [&tok], |r| r.get(0)).unwrap()).await; + assert_eq!(plaintext, 0, "raw token must never be stored"); + let hashed: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM sessions WHERE id=?1", [&sha256_hex(tok.as_bytes())], |r| r.get(0)).unwrap()).await; + assert_eq!(hashed, 1); + assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict")); + } + + #[tokio::test] + async fn login_throttles_after_repeated_failures() { + let s = st(); + for _ in 0..25 { + record_failure(&handle(&s), "[email protected]").await; + } + let res = login(handle(&s), Some(Json(LoginReq { + email: "[email protected]".into(), + password: "whatever-passphrase-1".into(), + }))).await; + match res { + Err(e) => assert_eq!(e.status, StatusCode::TOO_MANY_REQUESTS), + Ok(_) => panic!("login must be throttled after sustained failures"), + } + } + + #[tokio::test] + async fn logout_invalidates_the_server_session() { + let s = st(); + let (auth, headers) = register_ok(&s, "[email protected]", "Logout User", "a-reasonable-passphrase-1").await; + let tok = headers.get(header::COOKIE).unwrap().to_str().unwrap() + .split(';').next().unwrap() + .strip_prefix(COOKIE_NAME).and_then(|c| c.strip_prefix('=')).unwrap().to_string(); + logout(handle(&s), auth, headers).await.unwrap(); + let left: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM sessions WHERE id=?1", [&sha256_hex(tok.as_bytes())], |r| r.get(0)).unwrap()).await; + assert_eq!(left, 0, "logout must delete the server-side session row"); + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..410cb09 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,65 @@ +use std::env; + +#[derive(Clone, Debug)] +pub struct Config { + pub bind_addr: String, + pub canonical_origin: String, + pub secure_cookies: bool, + pub db_path: String, + pub data_dir: String, + pub frontend_dir: String, + pub worker_image: String, + pub fetch_timeout_secs: u64, + pub backtest_timeout_secs: u64, + pub run_concurrency: usize, + pub fetch_concurrency: usize, + pub session_hours: i64, + pub bootstrap_admin_email: Option<String>, + pub bootstrap_admin_password: Option<String>, + pub ai_base_url: String, + pub ai_model: String, + pub ai_daily_request_cap: i64, + pub ai_input_token_cap: i64, + pub ai_output_token_cap: i64, + pub ai_enabled_poc: bool, + pub default_run_limit_per_day: i64, + pub version: String, +} + +fn envs(k: &str, d: &str) -> String { + env::var(k).ok().filter(|v| !v.is_empty()).unwrap_or_else(|| d.to_string()) +} + +impl Config { + pub fn from_env() -> Self { + let canonical_origin = envs("ORIGIN", ""); + Config { + bind_addr: envs("BIND", "127.0.0.1:8787"), + canonical_origin: canonical_origin.clone(), + secure_cookies: canonical_origin.starts_with("https://"), + db_path: envs("DB_PATH", "server-data/strategy-lab.sqlite3"), + data_dir: envs("DATA_DIR", "server-data/data"), + frontend_dir: envs("FRONTEND_DIR", "frontend/dist"), + worker_image: envs("WORKER_IMAGE", "strategy-lab-worker:local"), + fetch_timeout_secs: envs("FETCH_TIMEOUT_SECS", "1800").parse().unwrap_or(1800), + backtest_timeout_secs: envs("BACKTEST_TIMEOUT_SECS", "1800").parse().unwrap_or(1800), + run_concurrency: envs("RUN_CONCURRENCY", "1").parse().unwrap_or(1), + fetch_concurrency: envs("FETCH_CONCURRENCY", "2").parse().unwrap_or(2), + session_hours: envs("SESSION_HOURS", "336").parse().unwrap_or(336), + bootstrap_admin_email: env::var("BOOTSTRAP_ADMIN_EMAIL").ok(), + bootstrap_admin_password: env::var("BOOTSTRAP_ADMIN_PASSWORD").ok(), + ai_base_url: envs("AI_BASE_URL", "https://opencode.ai/zen/go/v1"), + ai_model: envs("AI_MODEL", "glm-5.3-flash"), + ai_daily_request_cap: envs("AI_DAILY_REQUEST_CAP", "20").parse().unwrap_or(20), + ai_input_token_cap: envs("AI_INPUT_TOKEN_CAP", "8000").parse().unwrap_or(8000), + ai_output_token_cap: envs("AI_OUTPUT_TOKEN_CAP", "6000").parse().unwrap_or(6000), + ai_enabled_poc: envs("AI_ENABLED_POC", "true") == "true", + default_run_limit_per_day: envs("DEFAULT_RUN_LIMIT_PER_DAY", "10").parse().unwrap_or(10), + version: envs("APP_VERSION", env!("CARGO_PKG_VERSION")), + } + } +} + +pub const MAX_CODE_LEN: usize = 256_000; +pub const MAX_SYMBOLS: usize = 5; +pub const MAX_RANGE_YEARS: i64 = 15; diff --git a/server/src/datasets.rs b/server/src/datasets.rs new file mode 100644 index 0000000..5b2428f --- /dev/null +++ b/server/src/datasets.rs @@ -0,0 +1,244 @@ +use axum::{extract::{Path, State}, Json}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::auth::{audit, AuthUser}; +use crate::config::{MAX_RANGE_YEARS, MAX_SYMBOLS}; +use crate::error::{AppError, AppResult}; +use crate::state::S; +use crate::util::{canonical_json, new_id, now_iso, sha256_hex}; + +/// Matches the fixed backend contract `Cx`; wired by State extractor in main.rs. +pub type Cx = State<S>; +const FIELDS: [&str; 6] = ["open", "high", "low", "close", "volume", "adj_factor"]; + + +#[derive(Deserialize, Serialize, Clone)] +pub struct InstrumentReq { + pub symbol: String, + pub market: String, + pub asset_type: String, + pub name: Option<String>, +} + +#[derive(Deserialize)] +pub struct DatasetRequest { + /// Optional: SPEC/UI permit an absent or blank name; the backend then + /// auto-generates a descriptive name and persists it. + /// Optional: SPEC/UI permit an absent or blank name; the backend then + /// auto-generates a descriptive name and persists it. + #[serde(default)] + pub name: Option<String>, + pub instruments: Vec<InstrumentReq>, + pub start_date: String, + pub end_date: String, + pub frequency: String, + pub adjustment: String, + pub fields: Vec<String>, +} + +pub fn canonical_request_value(req: &DatasetRequest) -> Value { + json!({ + "instruments": req.instruments.iter().map(|i| json!({ + "symbol": i.symbol, "market": i.market, "asset_type": i.asset_type, + "name": i.name.clone(), + })).collect::<Vec<_>>(), + "start_date": req.start_date, "end_date": req.end_date, + "frequency": req.frequency, "adjustment": req.adjustment, "fields": req.fields, + }) +} + +pub fn validate_request(req: &DatasetRequest) -> AppResult<String> { + if req.instruments.is_empty() || req.instruments.len() > MAX_SYMBOLS { + return Err(AppError::bad("validation", format!("instruments must be 1-{} items", MAX_SYMBOLS))); + } + if req.frequency != "daily" { return Err(AppError::bad("validation", "only daily frequency is supported")); } + if !matches!(req.adjustment.as_str(), "none" | "qfq" | "hfq") { + return Err(AppError::bad("validation", "adjustment must be none|qfq|hfq")); + } + if req.fields.is_empty() { return Err(AppError::bad("validation", "fields must not be empty")); } + for f in &req.fields { + if !FIELDS.contains(&f.as_str()) { return Err(AppError::bad("validation", format!("unsupported field: {f}"))); } + } + let mut seen = std::collections::HashSet::new(); + for i in &req.instruments { + if i.symbol.trim().is_empty() || i.market.trim().is_empty() { + return Err(AppError::bad("validation", "each instrument needs a symbol and a market")); + } + if !matches!(i.asset_type.as_str(), "stock" | "etf" | "index") { + return Err(AppError::bad("validation", "asset_type must be stock|etf|index")); + } + if i.asset_type == "index" && req.adjustment != "none" { + return Err(AppError::bad("validation", "index instruments support adjustment 'none' only (explicit restriction, no factors)")); + } + if !seen.insert(format!("{}|{}|{}", i.market, i.asset_type, i.symbol)) { + return Err(AppError::bad("validation", "duplicate instrument in request")); + } + } + let sd = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d").map_err(|_| AppError::bad("validation", "start_date must be YYYY-MM-DD"))?; + let ed = NaiveDate::parse_from_str(&req.end_date, "%Y-%m-%d").map_err(|_| AppError::bad("validation", "end_date must be YYYY-MM-DD"))?; + if ed < sd { return Err(AppError::bad("validation", "end_date must not precede start_date")); } + if (ed - sd).num_days() > MAX_RANGE_YEARS * 366 { + return Err(AppError::bad("validation", "range exceeds maximum of 15 years")); + } + Ok(sha256_hex(canonical_json(&canonical_request_value(req)).as_bytes())) +} + +/// Full stored manifest JSON -> client copy without host/internal paths. +pub fn client_manifest(m: &Value) -> Value { + let mut o = m.clone(); + o.as_object_mut().map(|m| m.remove("preview")); + if let Some(objs) = o.get_mut("objects").and_then(|v| v.as_array_mut()) { + for obj in objs.iter_mut() { + if let Some(map) = obj.as_object_mut() { + map.remove("path"); + } + } + } + o +} + +fn row_dataset(r: &rusqlite::Row) -> rusqlite::Result<Value> { + let manifest: Option<String> = r.get(6)?; + let manifest_v: Value = manifest.and_then(|m| serde_json::from_str::<Value>(&m).ok()).unwrap_or(Value::Null); + // The stored canonical request is persisted as a JSON string; clients get an object. + let request_s: String = r.get(2)?; + let request_v: Value = serde_json::from_str::<Value>(&request_s) + .map_err(|_| rusqlite::Error::InvalidColumnType(2, "dataset request".into(), rusqlite::types::Type::Text))?; + Ok(json!({ + "id": r.get::<_, String>(0)?, + "name": r.get::<_, String>(1)?, + "request": request_v, + "status": r.get::<_, String>(3)?, + "error": r.get::<_, Option<String>>(4)?, + "cache_hit": r.get::<_, Option<i64>>(5)?.map(|v| v != 0), + "manifest": if manifest_v.is_null() { Value::Null } else { client_manifest(&manifest_v) }, + "warnings": manifest_v.get("warnings").cloned().unwrap_or(json!([])), + "manifest_hash": manifest_v.get("hash").cloned().unwrap_or(Value::Null), + "created_at": r.get::<_, String>(7)?, + "updated_at": r.get::<_, String>(8)?, + })) +} + +pub async fn assert_owned(cx: &Cx, user_id: &str, dataset_id: &str) -> AppResult<()> { + let found: Option<String> = cx.with_db(|db| { + db.query_row("SELECT user_id FROM datasets WHERE id=?1", [dataset_id], |r| r.get(0)).ok() + }).await; + match found { + Some(o) if o == user_id => Ok(()), + _ => Err(AppError::not_found("dataset not found")), + } +} + +async fn query_dataset(cx: &Cx, sql: &str, dataset_id: &str) -> AppResult<Value> { + cx.with_db(move |db| { + db.query_row(sql, [dataset_id], row_dataset) + .map_err(|_| AppError::not_found("dataset not found")) + }).await +} + +const LIST_SQL: &str = "SELECT id,name,request,status,error,cache_hit,manifest,created_at,updated_at FROM datasets WHERE user_id=?1 ORDER BY created_at DESC LIMIT 200"; + +pub async fn list(cx: Cx, auth: AuthUser) -> AppResult<Json<Value>> { + let items: Vec<Value> = cx.with_db(|db| { + let mut st = db.prepare(LIST_SQL)?; + let mut rows = st.query([auth.id.clone()])?; + let mut out = Vec::new(); + while let Some(r) = rows.next()? { out.push(row_dataset(r)?); } + Ok::<_, AppError>(out) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn get(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<Value>> { + assert_owned(&cx, &auth.id, &id).await?; + let v = query_dataset(&cx, "SELECT id,name,request,status,error,cache_hit,manifest,created_at,updated_at FROM datasets WHERE id=?1", &id).await?; + Ok(Json(v)) +} + +/// Load the stored worker manifest (with internal paths) of a ready dataset. +pub async fn load_manifest(cx: &Cx, dataset_id: &str) -> AppResult<Value> { + let m: String = cx.with_db(|db| { + db.query_row("SELECT manifest FROM datasets WHERE id=?1 AND status='ready'", [dataset_id], |r| r.get(0)) + .map_err(|_| AppError::conflict("dataset_not_ready", "dataset not ready")) + }).await?; + serde_json::from_str(&m).map_err(|e| AppError::internal(format!("manifest corrupt: {e}"))) +} + +pub async fn create(cx: Cx, auth: AuthUser, body: Option<Json<DatasetRequest>>) -> AppResult<(axum::http::StatusCode, Json<Value>)> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + // Frontend sends name: optional/empty; default is an auto-generated descriptive name. + let name = if r.name.as_deref().map(|n| n.trim().is_empty()).unwrap_or(true) { + // Frontend allows an empty name to auto-generate: instruments + range. + let syms: Vec<String> = r.instruments.iter().map(|i| i.symbol.clone()).collect(); + format!("{} · {} ~ {}", syms.join(","), r.start_date, r.end_date) + } else { + r.name.as_deref().unwrap_or_default().trim().to_string() + }; + if name.len() > 200 { return Err(AppError::bad("validation", "name required (max 200)")); } + let _key = validate_request(&r)?; + let stored_request = canonical_request_value(&r); + let id = new_id(); + let ts = now_iso(); + let uid = auth.id.clone(); + cx.with_db(|db| -> AppResult<()> { + db.execute("INSERT INTO datasets (id,user_id,name,request,status,cache_hit,created_at,updated_at) VALUES (?1,?2,?3,?4,'pending',0,?5,?5)", + rusqlite::params![&id, &uid, &name, stored_request.to_string(), &ts])?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "dataset_create", &id, "ok").await; + let v = query_dataset(&cx, "SELECT id,name,request,status,error,cache_hit,manifest,created_at,updated_at FROM datasets WHERE id=?1", &id).await?; + Ok((axum::http::StatusCode::ACCEPTED, Json(v))) +} + +pub async fn preview(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<Value>> { + assert_owned(&cx, &auth.id, &id).await?; + let m = load_manifest(&cx, &id).await?; + let p = m.get("preview").cloned().unwrap_or(Value::Null); + if p.is_null() { return Err(AppError::not_found("preview not available yet")); } + Ok(Json(p)) +} + + +#[cfg(test)] +mod tests { + use super::*; + + fn req() -> DatasetRequest { + serde_json::from_value(json!({ + "name": "t", "instruments": [{"symbol": "600000", "market": "cn", "asset_type": "stock", "name": "浦发银行"}], + "start_date": "2024-01-01", "end_date": "2024-06-30", + "frequency": "daily", "adjustment": "none", "fields": ["open","high","low","close","volume"] + })).unwrap() + } + + #[test] + fn validate_rejects_unsupported() { + assert_eq!(validate_request(&req()).unwrap().len(), 64); + let mut r = req(); r.frequency = "hourly".into(); + assert_eq!(validate_request(&r).unwrap_err().code, "validation"); + let mut r = req(); r.adjustment = "qfq".into(); r.instruments[0].asset_type = "index".into(); + assert_eq!(validate_request(&r).unwrap_err().code, "validation", "index+adjustment must be explicit rejections"); + let mut r = req(); r.instruments.push(r.instruments[0].clone()); + assert_eq!(validate_request(&r).unwrap_err().code, "validation", "duplicate instruments rejected"); + } + + #[test] + fn cache_key_is_stable_and_shared_regardless_of_display_name() { + let other = { let mut o = req(); o.name = Some("别的名字".to_string()); o }; + assert_eq!(canonical_request_value(&req()), canonical_request_value(&other)); + assert_eq!(validate_request(&req()).unwrap(), validate_request(&other).unwrap()); + } + + #[test] + fn client_manifest_strips_internal_paths_and_preview() { + let m = json!({ + "hash": "h", "warnings": [], "preview": {"rows": [1]}, + "objects": [{"instrument": {"symbol": "SH#600000"}, "path": "objects/ab/ab12.csv"}] + }); + let c = client_manifest(&m); + assert!(serde_json::to_string(&c).unwrap().find("objects/ab").is_none(), "host paths must not leak"); + assert_eq!(c.get("hash"), Some(&json!("h"))); + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..d8b09dd --- /dev/null +++ b/server/src/db.rs @@ -0,0 +1,146 @@ +use rusqlite::Connection; + +pub fn init_db(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + r#" + PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','member')), + active INTEGER NOT NULL DEFAULT 1, + ai_enabled INTEGER NOT NULL DEFAULT 0, + daily_run_limit INTEGER NOT NULL DEFAULT 10, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- sha256(token) + user_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + origin_note TEXT + ); + CREATE TABLE IF NOT EXISTS login_failures ( + email TEXT NOT NULL, + failed_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS invitations ( + id TEXT PRIMARY KEY, + email TEXT, + token_hash TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'member', + expires_at TEXT NOT NULL, + used_by TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS password_resets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS audit ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + actor_id TEXT, + action TEXT NOT NULL, + target TEXT, + status TEXT NOT NULL, + details TEXT + ); + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + draft_code TEXT NOT NULL DEFAULT '', + draft_generation INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS project_versions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + code TEXT NOT NULL, + hash TEXT NOT NULL, + message TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('manual','run','ai','restore')), + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS datasets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + request TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('pending','running','ready','failed')), + error TEXT, + manifest_hash TEXT, + manifest TEXT, + cache_hit INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS data_objects ( + hash TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + size INTEGER NOT NULL, + fetched_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + project_id TEXT NOT NULL REFERENCES projects(id), + version_id TEXT NOT NULL REFERENCES project_versions(id), + dataset_id TEXT NOT NULL REFERENCES datasets(id), + status TEXT NOT NULL CHECK(status IN ('queued','running','succeeded','failed','cancelled')), + config TEXT NOT NULL, + manifest_hash TEXT, + container_id TEXT, + result TEXT, + error TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS runs_project ON runs(project_id, created_at DESC); + CREATE INDEX IF NOT EXISTS runs_user_daily ON runs(user_id, created_at DESC); + CREATE TABLE IF NOT EXISTS ai_requests ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + project_id TEXT NOT NULL REFERENCES projects(id), + instruction TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('pending','succeeded','failed')), + model TEXT, + explanation TEXT, + proposed_code TEXT, + diff TEXT, + base_generation INTEGER, + base_code_hash TEXT, + version_id TEXT, + usage TEXT, + error TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS fetch_cache ( + key TEXT PRIMARY KEY, + manifest_hash TEXT NOT NULL, + manifest TEXT NOT NULL, + object_count INTEGER NOT NULL, + fetched_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ai_usage ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + request_id TEXT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0 + ); + "#, + ) +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..0342afd --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,145 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::{json, Value}; + +/// Coherent error type for all handlers and helpers. Exactly one set of +/// constructors, one IntoResponse, and the three owned From conversions +/// (rusqlite, std::io, serde_json) live here and nowhere else. +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub code: &'static str, + pub message: String, + pub details: Option<Value>, +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for AppError {} + +impl AppError { + pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self { + AppError { status, code, message: message.into(), details: None } + } + pub fn bad(code: &'static str, msg: impl Into<String>) -> Self { + Self::new(StatusCode::BAD_REQUEST, code, msg) + } + pub fn unauthorized(msg: impl Into<String>) -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized", msg) + } + pub fn forbidden(msg: impl Into<String>) -> Self { + Self::new(StatusCode::FORBIDDEN, "forbidden", msg) + } + pub fn not_found(msg: impl Into<String>) -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", msg) + } + pub fn conflict(code: &'static str, msg: impl Into<String>) -> Self { + Self::new(StatusCode::CONFLICT, code, msg) + } + pub fn internal(msg: impl Into<String>) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal", msg) + } + pub fn with_details(mut self, d: Value) -> Self { + self.details = Some(d); + self + } + pub fn with_code(mut self, code: &'static str) -> Self { + self.code = code; + self + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let mut e = json!({"code": self.code, "message": self.message}); + if let Some(details) = self.details { + e["details"] = details; + } + (self.status, Json(json!({"error": e}))).into_response() + } +} + +impl From<rusqlite::Error> for AppError { + fn from(e: rusqlite::Error) -> Self { + match &e { + rusqlite::Error::QueryReturnedNoRows => AppError::not_found("resource not found"), + _ => { + tracing::error!("db error: {e}"); + AppError::internal("internal storage error") + } + } + } +} + +impl From<std::io::Error> for AppError { + fn from(e: std::io::Error) -> Self { + tracing::error!("io error: {e}"); + AppError::internal("internal filesystem error") + } +} + +impl From<serde_json::Error> for AppError { + fn from(e: serde_json::Error) -> Self { + tracing::error!("json error: {e}"); + AppError::internal("internal json error") + } +} + +pub type AppResult<T> = Result<T, AppError>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructors_map_to_expected_status_and_code() { + let e = AppError::bad("validation", "x"); + assert_eq!(e.status, StatusCode::BAD_REQUEST); + assert_eq!(e.code, "validation"); + assert_eq!(AppError::unauthorized("y").status, StatusCode::UNAUTHORIZED); + assert_eq!(AppError::forbidden("y").status, StatusCode::FORBIDDEN); + assert_eq!(AppError::not_found("y").status, StatusCode::NOT_FOUND); + assert_eq!(AppError::conflict("stale", "y").status, StatusCode::CONFLICT); + assert_eq!(AppError::internal("y").status, StatusCode::INTERNAL_SERVER_ERROR); + let v = AppError::conflict("stale", "y") + .with_code("dataset_not_ready") + .with_details(json!({"warnings": [1]})); + assert_eq!(v.code, "dataset_not_ready"); + assert_eq!(v.details.expect("details")["warnings"], json!([1])); + } + + #[tokio::test] + async fn body_shape_is_error_code_message_details() { + let mut resp = AppError::conflict("stale", "expected_generation out of date") + .with_details(json!({"expected": 3})) + .into_response(); + let bytes = axum::body::to_bytes(std::mem::take(resp.body_mut()), 64 * 1024).await.unwrap(); + let parsed: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(parsed["error"]["code"], "stale"); + assert_eq!(parsed["error"]["message"], "expected_generation out of date"); + assert_eq!(parsed["error"]["details"]["expected"], 3); + + let mut plain = AppError::internal("boom").into_response(); + let plain: Value = serde_json::from_slice( + &axum::body::to_bytes(std::mem::take(plain.body_mut()), 64 * 1024).await.unwrap(), + ).unwrap(); + assert!(plain["error"].get("details").is_none()); + } + + #[test] + fn from_impls_convert_without_duplicates() { + let e: AppError = rusqlite::Error::QueryReturnedNoRows.into(); + assert_eq!(e.status, StatusCode::NOT_FOUND); + let e: AppError = std::io::Error::new(std::io::ErrorKind::NotFound, "nope").into(); + assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR); + let e: AppError = serde_json::from_str::<Value>("{").unwrap_err().into(); + assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/server/src/jobs.rs b/server/src/jobs.rs new file mode 100644 index 0000000..a3c49eb --- /dev/null +++ b/server/src/jobs.rs @@ -0,0 +1,857 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Mutex; +use std::collections::HashSet; +use serde_json::{json, Value}; + +use crate::auth::audit; +use crate::error::AppError; +use crate::state::AppState; +use crate::store::ObjectStore; +use crate::util::{canonical_json, new_id, now_iso, sha256_hex}; + +pub struct Signals; + +impl Signals { + pub fn new() -> Self { Signals } +} + +pub async fn main_loop(cx: Arc<AppState>, _signals: Signals) { + loop { + if let Err(e) = tick(&cx).await { tracing::error!("job loop: {e}"); } + tokio::time::sleep(Duration::from_millis(700)).await; + } +} + +// ---- shared in-flight fetch serialization for the exact request cache key ---- + +fn inflight() -> &'static Mutex<HashSet<String>> { + static SET: std::sync::OnceLock<Mutex<HashSet<String>>> = std::sync::OnceLock::new(); + SET.get_or_init(|| Mutex::new(HashSet::new())) +} + +pub fn cache_key_for_request(req: &Value) -> String { + sha256_hex(canonical_json(req).as_bytes()) +} + +async fn release_key(key: &str) { + inflight().lock().await.remove(key); +} + +// ---- dispatcher ---- + +async fn tick(cx: &Arc<AppState>) -> Result<(), AppError> { + // pending datasets -> bounded fetch concurrency + let n_running: i64 = cx.with_db(|db| { + db.query_row("SELECT COUNT(*) FROM datasets WHERE status='running'", [], |r| r.get(0)) + }).await.unwrap_or(0); + if n_running < cx.cfg.fetch_concurrency as i64 { + let next: Option<(String, String)> = cx.with_db(|db| { + db.query_row("SELECT id,request FROM datasets WHERE status='pending' ORDER BY created_at LIMIT 1", [], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).ok() + }).await; + if let Some((did, request)) = next { + cx.with_db(|db| { + db.execute("UPDATE datasets SET status='running', updated_at=?1 WHERE id=?2 AND status='pending'", + rusqlite::params![now_iso(), &did]).ok(); + }).await; + let cx2 = cx.clone(); + tokio::spawn(async move { + if let Err(e) = spawn_fetch_job(&cx2, &did, &request).await { + tracing::error!("fetch {did}: {e}"); + cx2.with_db(|db| { + db.execute("UPDATE datasets SET status='failed', error=?1, updated_at=?2 WHERE id=?3 AND status IN ('pending','running')", + rusqlite::params![e.message, now_iso(), &did]).ok(); + }).await; + } + }); + } + } + + // queued runs -> single active backtest worker (POC) + let n_running_runs: i64 = cx.with_db(|db| { + db.query_row("SELECT COUNT(*) FROM runs WHERE status='running'", [], |r| r.get(0)) + }).await.unwrap_or(0); + if n_running_runs >= cx.cfg.run_concurrency as i64 { return Ok(()); } + let next: Option<(String, String)> = cx.with_db(|db| { + db.query_row("SELECT id,config FROM runs WHERE status='queued' ORDER BY created_at LIMIT 1", [], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).ok() + }).await; + let Some((run_id, config)) = next else { return Ok(()); }; + let container_name = format!("sl-run-{}", new_id()); + // Atomic claim: queued -> running WITH container_id set in the same transaction. + // A run never becomes 'running' without a container identity, so the claim + // itself proves the worker task was handed off. + let claimed = cx.with_db(|db| -> Result<bool, AppError> { + move_claim(&mut *db, &run_id, &container_name) + }).await?; + if !claimed { return Ok(()); } + let cx2 = cx.clone(); + tokio::spawn(async move { + let user_id: String = cx2.with_db(|db| { + db.query_row("SELECT user_id FROM runs WHERE id=?1", [&run_id], |r| r.get(0)).unwrap_or_default() + }).await; + let res = run_backtest(&cx2, &run_id, &config, &container_name).await; + let _ = finalize_run(&cx2, &run_id, &user_id, res).await; + }); + Ok(()) +} + +/// Recheck account/quota/dataset durability, then claim queued -> running with +/// a container id atomically inside one transaction. +/// Returns true only if this caller actually claimed the run (and must spawn the worker). +fn move_claim(db: &mut rusqlite::Connection, run_id: &str, container_id: &str) -> Result<bool, AppError> { + db.execute("BEGIN IMMEDIATE", []).ok(); + match db.query_row( + "SELECT u.active, r.user_id FROM runs r JOIN users u ON u.id=r.user_id WHERE r.id=?1", + [run_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) { + Ok((active, _uid)) if active == 1 => {}, + _ => { + db.execute("UPDATE runs SET status='failed', error='account disabled', finished_at=?1 WHERE id=?2 AND status='queued'", + rusqlite::params![now_iso(), run_id]).ok(); + db.execute("COMMIT", []).ok(); + return Ok(false); + } + } + let uid = db.query_row("SELECT user_id FROM runs WHERE id=?1", [run_id], |r| r.get::<_, String>(0)).unwrap_or_default(); + let ready_ok: Option<i64> = db.query_row( + "SELECT d.status='ready' AS ok FROM datasets d JOIN runs r ON r.dataset_id=d.id AND r.user_id=d.user_id WHERE r.id=?1", + [run_id], |r| r.get::<_, i64>(0)).ok(); + if ready_ok != Some(1) { + db.execute("UPDATE runs SET status='failed', error='dataset no longer ready at dequeue', finished_at=?1 WHERE id=?2 AND status='queued'", + rusqlite::params![now_iso(), run_id]).ok(); + db.execute("COMMIT", []).ok(); + return Ok(false); + } + let today = format!("{}%", chrono::Utc::now().format("%Y-%m-%d").to_string()); + let used: i64 = db.query_row("SELECT COUNT(*) FROM runs WHERE user_id=?1 AND created_at LIKE ?2", + rusqlite::params![&uid, &today], |r| r.get(0)).unwrap_or(0); + let limit: i64 = db.query_row("SELECT daily_run_limit FROM users WHERE id=?1", [&uid], |r| r.get(0)).unwrap_or(10); + if used > limit { + db.execute("UPDATE runs SET status='failed', error='run quota exceeded', finished_at=?1 WHERE id=?2 AND status='queued'", + rusqlite::params![now_iso(), run_id]).ok(); + db.execute("COMMIT", []).ok(); + return Ok(false); + } + // Single atomic transition: a claimed run is running WITH its container identity. + let n = db.execute( + "UPDATE runs SET status='running', started_at=?1, container_id=?2 WHERE id=?3 AND status='queued'", + rusqlite::params![now_iso(), container_id, run_id]).unwrap_or(0); + db.execute("COMMIT", []).ok(); + Ok(n == 1) +} + +async fn finalize_run(cx: &Arc<AppState>, run_id: &str, user_id: &str, res: Result<Value, AppError>) { + cx.with_db(|db| -> Result<(), AppError> { + match res { + Ok(result) => { + let n = db.execute("UPDATE runs SET status='succeeded', result=?1, error=NULL, finished_at=?2 WHERE id=?3 AND status='running'", + rusqlite::params![result.to_string(), now_iso(), run_id]).unwrap_or(0); + if n > 0 { + let cxx = cx.clone(); + let uid = user_id.to_string(); + let rid = run_id.to_string(); + tokio::spawn(async move { audit(&axum::extract::State(cxx), Some(&uid), "run_succeeded", &rid, "ok").await }); + } + } + Err(e) => { + // guaranteed non-empty terminal reason, never an empty failure + let msg = if e.message.trim().is_empty() { "worker failed without error detail".to_string() } else { e.message.clone() }; + db.execute("UPDATE runs SET status='failed', error=?1, finished_at=?2 WHERE id=?3 AND status='running'", + rusqlite::params![msg, now_iso(), run_id]).ok(); + } + } + Ok(()) + }).await.ok(); +} + +// ---- dataset fetch ---- + +async fn try_cache(cx: &Arc<AppState>, dataset_id: &str, key: &str) -> Result<bool, AppError> { + let manifest: Option<String> = cx.with_db(|db| { + db.query_row("SELECT manifest FROM fetch_cache WHERE key=?1", [key], |r| r.get(0)).ok() + }).await; + let Some(m) = manifest else { return Ok(false); }; + let mv: Value = serde_json::from_str(&m).unwrap_or(Value::Null); + let hash = mv.get("hash").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + cx.with_db(|db| { + db.execute("UPDATE datasets SET status='ready', error=NULL, manifest=?1, manifest_hash=?2, cache_hit=1, updated_at=?3 WHERE id=?4 AND status='running'", + rusqlite::params![&m, hash, now_iso(), dataset_id]).ok(); + }).await; + Ok(true) +} + +/// One dataset fetch: cache recheck or container run + artifact ingest. +pub async fn spawn_fetch_job(cx: &Arc<AppState>, dataset_id: &str, request_s: &str) -> Result<(), AppError> { + let request: Value = serde_json::from_str(request_s).map_err(|e| AppError::internal(format!("stored request invalid: {e}")))?; + let key = cache_key_for_request(&request); + + // serialize identical cache keys: second arrives late and rechecks cache + let deadline = std::time::Duration::from_secs(cx.cfg.fetch_timeout_secs.max(60)); + let started = std::time::Instant::now(); + loop { + { + let mut set = inflight().lock().await; + if set.insert(key.clone()) { break; } + + } + if try_cache(cx, dataset_id, &key).await? { return Ok(()); } + if started.elapsed() > deadline { + return Err(AppError::internal("waiting on identical in-flight fetch timed out")); + } + tokio::time::sleep(Duration::from_millis(1500)).await; + } + let result = perform_fetch(cx, dataset_id, request, &key).await; + release_key(&key).await; + result +} + +async fn perform_fetch(cx: &Arc<AppState>, dataset_id: &str, request: Value, key: &str) -> Result<(), AppError> { + // cached from a prior identical request? + if try_cache(cx, dataset_id, key).await? { return Ok(()); } + + let store = ObjectStore::new(&cx.cfg.data_dir); + let work = temp_job_dir(&cx.cfg.data_dir)?; + let input = work.join("input"); + let output = work.join("output"); + std::fs::create_dir_all(&input).map_err(io_err)?; + std::fs::create_dir_all(&output).map_err(io_err)?; + open_writable(&output)?; + + std::fs::write(input.join("request.json"), request.to_string()).map_err(io_err)?; + let mounts = vec![ + (input.display().to_string(), "/input".to_string(), true), + (output.display().to_string(), "/output".to_string(), false), + ]; + let args = vec![ + "python".into(), "-m".into(), "worker.main".into(), "fetch".into(), + "--request".into(), "/input/request.json".into(), "--output".into(), "/output".into(), + ]; + let name = format!("sl-fetch-{}", new_id()); + let res = crate::worker::run_named(&*cx, true, &mounts, &args, &name, cx.cfg.fetch_timeout_secs).await; + let out_take = match res { + Ok(r) if r.ok() => r, + Ok(r) => { + let msg = worker_error_message(&output, r.stderr.as_str()); + cleanup(&work); + return Err(AppError::bad("fetch_failed", msg)); + } + Err(e) => { cleanup(&work); return Err(e); } + }; + + let build = ingest_fetch_output(&store, &work).await; + let (manifest, _warnings) = match build { + Ok(v) => v, + Err(e) => { cleanup(&work); return Err(e); } + }; + let _ = out_take; + let manifest_hash = manifest.get("hash").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if manifest_hash.is_empty() || manifest.get("objects").and_then(|o| o.as_array()).map(|a| a.is_empty()).unwrap_or(true) { + cleanup(&work); + return Err(AppError::internal("fetch produced no usable objects")); + } + let manifest_s = manifest.to_string(); + let object_count = manifest.get("objects").and_then(|o| o.as_array()).map(|a| a.len()).unwrap_or(0) as i64; + cx.with_db(|db| -> Result<(), AppError> { + db.execute("INSERT OR REPLACE INTO fetch_cache (key,manifest_hash,manifest,object_count,fetched_at) VALUES (?1,?2,?3,?4,?5)", + rusqlite::params![&key, &manifest_hash, &manifest_s, object_count, now_iso()])?; + db.execute("UPDATE datasets SET status='ready', error=NULL, manifest=?1, manifest_hash=?2, cache_hit=0, updated_at=?3 WHERE id=?4 AND status='running'", + rusqlite::params![&manifest_s, &manifest_hash, now_iso(), dataset_id])?; + Ok(()) + }).await?; + cleanup(&work); + tracing::info!("fetch {dataset_id} cached under {key} ({object_count} objects)"); + Ok(()) +} + + +// manifest hash must be content identity: independent of user/request ids and fetch timestamp +fn manifest_content_hash(m: &Value) -> String { + let mut objects = Vec::new(); + for o in m.get("objects").and_then(|v| v.as_array()).unwrap_or(&vec![]).iter() { + let mut e = o.clone(); + if let Some(map) = e.as_object_mut() { + map.remove("fetched_at"); + map.remove("path"); + } + objects.push(e); + } + let payload = json!({ + "frequency": m.get("frequency"), + "adjustment": m.get("adjustment"), + "schema_version": m.get("schema_version"), + "normalization_version": m.get("normalization_version"), + "objects": objects, + }); + sha256_hex(canonical_json(&payload).as_bytes()) +} + + +async fn ingest_fetch_output(store: &ObjectStore, work: &Path) -> Result<(Value, Vec<String>), AppError> { + let out_dir = work.join("output"); + let result_path = out_dir.join("result.json"); + let result: Value = serde_json::from_str(&std::fs::read_to_string(&result_path).map_err(|e| AppError::internal(format!("worker result.json unreadable: {e}")))?) + .map_err(|e| AppError::internal(format!("worker result.json invalid: {e}")))?; + if result.get("status").and_then(|v| v.as_str()) != Some("ready") { + return Err(AppError::bad("fetch_failed", format!("worker reported status: {}", result.get("status").and_then(|v| v.as_str()).unwrap_or("missing")))); + } + let m = result.get("manifest").cloned().unwrap_or(Value::Null); + if !m.get("objects").and_then(|o| o.as_array()).map(|a| !a.is_empty()).unwrap_or(false) { + return Err(AppError::bad("fetch_failed", "worker manifest has no objects")); + } + // hash+ingest every worker artifact once (raw JSON and normalized CSVs) + let files: Vec<crate::store::StoredObject> = store.ingest_directory(&out_dir).map_err(io_err)?; + let find_hash = |h: &str| files.iter().find(|f| f.hash == h); + let find_rel = |rel: &str| files.iter().find(|f| f.stored_path == rel || f.mount_name == rel); + + let all_warnings: Vec<String> = result.get("warnings").and_then(|v| v.as_array()).map(|a| { + a.iter().filter_map(|w| w.as_str().map(String::from)).collect() + }).unwrap_or_default(); + let mut new_objects = Vec::new(); + for obj in m.get("objects").and_then(|v| v.as_array()).cloned().unwrap_or_default() { + let mut entry = obj.clone(); + // normalized: rewrite the internal path into immutable object storage + let rel_norm = entry.get("path").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + if rel_norm.contains("..") { + return Err(AppError::internal("worker output path rejected")); + } + let norm_obj = find_rel(&rel_norm).ok_or_else(|| AppError::internal("normalized object missing from worker output"))?; + let rel_stored = norm_obj.stored_path.clone(); + if let Some(map) = entry.as_object_mut() { + map.insert("path".into(), json!(format!("objects/{rel_stored}"))); + } + // raw: locate by content hash of the raw object (worker writes immutable raw JSON first) + let raw_hash = entry.get("raw_object_hash").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + if !raw_hash.is_empty() { + if find_hash(&raw_hash).is_none() { + return Err(AppError::internal("raw object referenced by manifest is missing")); + } + } + new_objects.push(entry); + } + + let mut manifest = m.clone(); + { + let map = manifest.as_object_mut().unwrap(); + map.insert("objects".into(), Value::Array(new_objects)); + map.insert("warnings".into(), json!(all_warnings.clone())); + map.insert("immutable".into(), json!(true)); + } + // stable content identity independent of request/user ids and fetch timestamp + { + let h = manifest_content_hash(&manifest); + if let Some(map) = manifest.as_object_mut() { + map.insert("hash".into(), json!(h)); + // Per-instrument coverage rows aligned to the client CoverageEntry shape: + // {instrument, market, asset_type, requested_start, requested_end, actual_start, actual_end, row_count, warnings} + let coverage: Vec<Value> = map.get("objects").and_then(|o| o.as_array()).map(|objs| { + objs.iter().map(|o| { + let inst = o.get("instrument").cloned().unwrap_or(Value::Null); + json!({ + "instrument": inst.get("symbol").or_else(|| inst.get("instrument")).and_then(|v| v.as_str()) + .map(String::from).unwrap_or_default(), + "market": inst.get("market").cloned().unwrap_or(Value::Null), + "asset_type": inst.get("asset_type").cloned().unwrap_or(Value::Null), + "requested_start": o.get("requested_start").cloned().unwrap_or(Value::Null), + "requested_end": o.get("requested_end").cloned().unwrap_or(Value::Null), + "actual_start": o.get("actual_start").cloned().unwrap_or(Value::Null), + "actual_end": o.get("actual_end").cloned().unwrap_or(Value::Null), + "row_count": o.get("row_count").cloned().unwrap_or(Value::Null), + "warnings": o.get("warnings").cloned().unwrap_or(json!([])), + }) + }).collect() + }).unwrap_or_default(); + let fallback_preview = json!({"columns": [], "rows": [], "coverage": coverage, "warnings": all_warnings.clone()}); + let pv = match result.get("preview") { + Some(p) => { + let mut p = p.clone(); + p["warnings"] = json!(all_warnings.clone()); + if !p.get("coverage").map(|c| c.is_array()).unwrap_or(false) { + p["coverage"] = json!(coverage); + } + p + } + None => fallback_preview, + }; + map.insert("preview".into(), pv); + } + } + Ok((manifest, all_warnings)) +} + +// ---- backtest ---- + +pub async fn run_backtest(cx: &Arc<AppState>, run_id: &str, config_s: &str, container_name: &str) -> Result<Value, AppError> { + let (code, dataset_id): (String, String) = cx.with_db(|db| { + db.query_row("SELECT pv.code, r.dataset_id FROM runs r JOIN project_versions pv ON pv.id=r.version_id WHERE r.id=?1", + [run_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) + .map_err(|_| AppError::not_found("run not found")) + }).await?; + let manifest: Value = cx.with_db(|db| { + let m: String = db.query_row("SELECT manifest FROM datasets WHERE id=?1 AND status='ready'", [&dataset_id], |r| r.get(0)) + .map_err(|_| AppError::conflict("dataset_not_ready", "dataset not ready"))?; + serde_json::from_str(&m).map_err(|e| AppError::internal(format!("manifest corrupt: {e}"))) + }).await?; + let store = ObjectStore::new(&cx.cfg.data_dir); + + let work = temp_job_dir(&cx.cfg.data_dir)?; + let input = work.join("input"); + let output = work.join("output"); + let r = (|| -> Result<(String, Vec<(String, String, bool)>), AppError> { + std::fs::create_dir_all(&input).map_err(io_err)?; + std::fs::create_dir_all(&output).map_err(io_err)?; + open_writable(&output)?; + let mut run_manifest = manifest.clone(); + let mut mounts: Vec<(String, String, bool)> = Vec::new(); + if let Some(objs) = run_manifest.get_mut("objects").and_then(|o| o.as_array_mut()) { + for obj in objs.iter_mut() { + let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + // internal layout: objects/<hash-prefix>/<object>; reject traversal/symlinks + let rel = path.strip_prefix("objects/").unwrap_or(""); + if rel.is_empty() || rel.contains("..") || rel.contains('/') && rel.len() < 4 { + return Err(AppError::internal("invalid stored object path")); + } + let abs = store.absolute(rel); + if abs.is_symlink() || !abs.is_file() { + return Err(AppError::internal("object file missing or not a regular file")); + } + let fname = abs.file_name().and_then(|f| f.to_str()).unwrap_or_default().to_string(); + let dst = format!("/data/{fname}"); + obj.as_object_mut().unwrap().insert("path".into(), json!(dst.clone())); + mounts.push((abs.display().to_string(), dst, true)); + } + } + if run_manifest.get("objects").and_then(|o| o.as_array()).map(|a| a.is_empty()).unwrap_or(true) { + return Err(AppError::internal("dataset manifest has no mountable objects")); + } + std::fs::write(input.join("request.json"), json!({ + "code": code.clone(), + "config": serde_json::from_str::<Value>(config_s).unwrap_or(Value::Null), + "dataset_manifest": run_manifest, + "data_root": "/data", + }).to_string()).map_err(io_err)?; + mounts.push((input.display().to_string(), "/input".into(), true)); + mounts.push((output.display().to_string(), "/output".into(), false)); + Ok((code, mounts)) + })(); + let (_code_owned, mounts) = match r { + Ok(v) => v, + Err(e) => { cleanup(&work); return Err(e); } + }; + let args = vec![ + "python".into(), "-m".into(), "worker.main".into(), "backtest".into(), + "--request".into(), "/input/request.json".into(), "--output".into(), "/output".into(), + ]; + let res = crate::worker::run_named(&*cx, false, &mounts, &args, container_name, cx.cfg.backtest_timeout_secs).await; + let mut result: Value = match res { + Ok(r) if r.ok() => { + match std::fs::read_to_string(output.join("result.json")) { + Ok(s) => serde_json::from_str(&s).map_err(|e| AppError::internal(format!("backtest result invalid: {e}")))?, + Err(e) => { cleanup(&work); return Err(AppError::internal(format!("backtest result unreadable: {e}"))); } + } + } + Ok(r) => { + let msg = worker_error_message(&output, r.stderr.as_str()); + cleanup(&work); + return Err(AppError::bad("backtest_failed", msg)); + } + Err(e) => { cleanup(&work); return Err(e); } + }; + if result.get("status").and_then(|v| v.as_str()) != Some("succeeded") { + let msg = result.get("error").and_then(|v| v.as_str()).map(String::from) + .unwrap_or_else(|| format!("worker status: {}", result.get("status").and_then(|v| v.as_str()).unwrap_or("missing"))); + cleanup(&work); + return Err(AppError::bad("backtest_failed", msg)); + } + let hash = manifest.get("hash").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if let Some(map) = result.as_object_mut() { + map.insert("data_manifest_hash".into(), json!(hash)); + } + sanitize_nonfinite(&mut result); + cleanup(&work); + Ok(result) +} + +/// Replace non-finite floats with null; results never carry NaN/Inf to clients. +pub fn sanitize_nonfinite(v: &mut Value) { + match v { + Value::Number(n) => { + if let Some(f) = n.as_f64() { + if !f.is_finite() { *v = Value::Null; } + } + } + Value::Array(a) => { for x in a.iter_mut() { sanitize_nonfinite(x); } } + Value::Object(o) => { for (_, x) in o.iter_mut() { sanitize_nonfinite(x); } } + _ => {} + } +} + +/// Best-effort honest error: worker JSON error first, else bounded stderr. +fn worker_error_message(output: &Path, stderr: &str) -> String { + if let Ok(s) = std::fs::read_to_string(output.join("result.json")) { + if let Ok(v) = serde_json::from_str::<Value>(&s) { + if let Some(e) = v.get("error").and_then(|v| v.as_str()) { return trunc(e, 1200); } + if let Some(err) = v.get("error").and_then(|v| v.get("message")) { return trunc(err.as_str().unwrap_or_default(), 1200); } + if let Some(errs) = v.get("errors").and_then(|v| v.as_array()) { + let msgs: Vec<String> = errs.iter().filter_map(|e| e.as_str().map(String::from)).collect(); + if !msgs.is_empty() { return trunc(&msgs.join("; "), 1200); } + } + } + } + trunc(stderr, 1200) +} + +fn io_err(e: std::io::Error) -> AppError { AppError::internal(format!("job fs: {e}")) } + + +// keeps HashSet/Mutex import used even on paths without cancel tracking + +/// Cancel a run. Cancellation intent is persisted atomically BEFORE the +/// container is killed, so a racing worker completion can never turn a +/// user-cancelled task into an empty `failed` (finalize_run only transitions +/// runs still in 'running'). Terminal states are never overwritten. +pub async fn signal_cancel(cx: &Arc<AppState>, run_id: &str) -> bool { + let (status, container): (String, Option<String>) = cx.with_db(|db| { + db.query_row("SELECT status,container_id FROM runs WHERE id=?1", [run_id], |r| + Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))) + .ok() + .unwrap_or((String::new(), None)) + }).await; + match status.as_str() { + "running" => { + // 1) atomically persist the cancel claim (single guarded transition) + let n = cx.with_db(|db| { + db.execute( + "UPDATE runs SET status='cancelled', error='cancelled by user', finished_at=?1 WHERE id=?2 AND status='running'", + rusqlite::params![now_iso(), run_id]).unwrap_or(0) + }).await; + if n == 0 { + // lost the race with a genuine worker completion; never clobber + audit(&axum::extract::State(cx.clone()), None, "run_cancel", run_id, "already").await; + return false; + } + // 2) only now kill the specific container by name/id + if let Some(name) = container { + crate::worker::cancel_container(&name).await; + } + audit(&axum::extract::State(cx.clone()), None, "run_cancel", run_id, "ok").await; + true + } + "queued" => { + let n = cx.with_db(|db| { + db.execute("UPDATE runs SET status='cancelled', error='cancelled before start', finished_at=?1 WHERE id=?2 AND status='queued'", + rusqlite::params![now_iso(), run_id]).unwrap_or(0) + }).await; + n > 0 + } + _ => false, + } +} + +fn trunc(s: &str, n: usize) -> String { + if s.len() <= n { s.into() } else { + let off = s.char_indices().nth(n).map(|i| i.0).unwrap_or(n); + s[..off].into() + } +} + +/// Job dir 0700 owned by the server; output subdir 0777 so the nonroot +/// container (uid 65534) can write artifacts while secrets stay unread. +fn temp_job_dir(data_dir: &str) -> Result<PathBuf, AppError> { + use std::os::unix::fs::PermissionsExt; + let d = Path::new(data_dir).join("jobs").join(new_id()); + std::fs::create_dir_all(&d).map_err(io_err)?; + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).map_err(io_err)?; + Ok(d) +} + +fn open_writable(p: &Path) -> Result<(), AppError> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o777)).map_err(io_err)?; + Ok(()) +} + +fn cleanup(dir: &Path) { std::fs::remove_dir_all(dir).ok(); } + + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn queued_run_claim_is_atomic_and_reaches_terminal_state() { + let tdir = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let db_path = tdir.path().join("db.sqlite3"); + let mut conn = rusqlite::Connection::open(&db_path).unwrap(); + crate::db::init_db(&conn).unwrap(); + conn.execute("INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES ('u','u@x','h','n','member',1,0,10,?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO projects (id,user_id,name,draft_code,draft_generation,created_at,updated_at) VALUES ('p','u','proj','code',0,?1,?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES ('v','p','code','h','run snap','run',?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO datasets (id,user_id,name,request,status,created_at,updated_at) VALUES ('d','u','ds','{}','ready',?1,?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('r1','u','p','v','d','queued','{}',?1)", [now_iso()]).unwrap(); + let mut cfg = crate::config::Config::from_env(); + cfg.db_path = db_path.display().to_string(); + let st = std::sync::Arc::new(crate::state::AppState { + cfg, + db: tokio::sync::Mutex::new(conn), + run_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), + }); + + // Claim: queued -> running WITH container_id in one atomic step. + let claimed = st.with_db(|db| move_claim(db, "r1", "sl-run-t1")).await.unwrap(); + assert!(claimed, "eligible queued run must be claimed"); + let (status, container, started): (String, Option<String>, Option<String>) = + st.with_db(|db| db.query_row("SELECT status,container_id,started_at FROM runs WHERE id='r1'", [], |r| + Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap()).await; + assert_eq!(status, "running"); + assert_eq!(container.as_deref(), Some("sl-run-t1"), "claimed run must carry container identity for the actual worker launch"); + assert!(started.is_some()); + // Second claim is a no-op: not queued anymore. + let again = st.with_db(|db| move_claim(db, "r1", "sl-run-t2")).await.unwrap(); + assert!(!again, "run must not be double-claimed"); + let c: Option<String> = st.with_db(|db| db.query_row("SELECT container_id FROM runs WHERE id='r1'", [], |r| r.get(0)).unwrap()).await; + assert_eq!(c.as_deref(), Some("sl-run-t1")); + + // Terminal state from a real worker result: only status 'succeeded' succeeds. + let ok_result = json!({"status":"succeeded","metrics":{"total_return":0.1,"final_equity":1.1}}); + finalize_run(&st, "r1", "u", Ok(ok_result)).await; + let (s2, res): (String, Option<String>) = st.with_db(|db| db.query_row("SELECT status,result,finished_at FROM runs WHERE id='r1'", [], |r| + Ok((r.get(0)?, r.get(1)?))).unwrap()).await; + assert_eq!(s2, "succeeded"); + let rv: Value = serde_json::from_str(&res.unwrap()).unwrap(); + assert_eq!(rv["status"], json!("succeeded"), "persisted result must be the actual worker result"); + + // A worker result NOT marked succeeded must fail the run, never fake success. + conn_reset_running(&st, "r1").await; + let bad_result = json!({"status":"failed","error":"strategy raised"}); + finalize_run(&st, "r1", "u", Ok(bad_result)).await; + // finalize_run only accepts Ok; a failed worker result arrives as Err via run_backtest, + // so ensure that path marks failed honestly. + let s3: String = st.with_db(|db| db.query_row("SELECT status FROM runs WHERE id='r1'", [], |r| r.get(0)).unwrap()).await; + assert!(matches!(s3.as_str(), "succeeded" | "running"), "unexpected state {s3}"); + if s3 == "succeeded" { + // rerun the failure path from a fresh running run + st.with_db(|db| db.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('r2','u','p','v','d','running','{}',?1)", rusqlite::params![now_iso()]).unwrap()).await; + finalize_run(&st, "r2", "u", Err(AppError::bad("backtest_failed", "worker reported status: failed"))).await; + let s4: String = st.with_db(|db| db.query_row("SELECT status FROM runs WHERE id='r2'", [], |r| r.get(0)).unwrap()).await; + assert_eq!(s4, "failed", "worker failure must propagate to terminal failed state"); + } + + // Disabled account: queued run fails at claim, never launched. + st.with_db(|db| db.execute("UPDATE users SET active=0", []).unwrap()).await; + st.with_db(|db| db.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('r3','u','p','v','d','queued','{}',?1)", rusqlite::params![now_iso()]).unwrap()).await; + let dis = st.with_db(|db| move_claim(db, "r3", "sl-run-t3")).await.unwrap(); + assert!(!dis); + let (s5, e5): (String, Option<String>) = st.with_db(|db| db.query_row("SELECT status,error,finished_at FROM runs WHERE id='r3'", [], |r| + Ok((r.get(0)?, r.get(1)?))).unwrap()).await; + assert_eq!(s5, "failed"); + assert_eq!(e5.as_deref(), Some("account disabled")); + } + + async fn conn_reset_running(st: &std::sync::Arc<AppState>, run_id: &str) { + st.with_db(|db| db.execute("UPDATE runs SET status='running', finished_at=NULL WHERE id=?1", rusqlite::params![run_id]).unwrap()).await; + } + + fn jobs_state() -> (std::sync::Arc<AppState>, tempfile::TempDir) { + let tdir = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::init_db(&conn).unwrap(); + // minimal FK parent rows for run fixtures + conn.execute("INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES ('u','[email protected]','h','n','member',1,0,10,?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO projects (id,user_id,name,draft_code,draft_generation,created_at,updated_at) VALUES ('p','u','proj','code',0,?1,?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES ('v','p','code','h','m','manual',?1)", [now_iso()]).unwrap(); + conn.execute("INSERT INTO datasets (id,user_id,name,request,status,created_at,updated_at) VALUES ('d','u','ds','{}','ready',?1,?1)", [now_iso()]).unwrap(); + let mut cfg = crate::config::Config::from_env(); + cfg.db_path = tdir.path().join("db.sqlite3").display().to_string(); + let st = std::sync::Arc::new(crate::state::AppState { + cfg, + db: tokio::sync::Mutex::new(conn), + run_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), + }); + (st, tdir) + } + + async fn seed_running_run(st: &std::sync::Arc<AppState>, id: &str) { + st.with_db(|db| db.execute( + "INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES (?1,'u','p','v','d','running','{}',?2)", + rusqlite::params![id, now_iso()]).unwrap()).await; + } + + fn raw_digest(b: &[u8]) -> String { sha256_hex(b) } + + fn write_worker_output(work: &Path, raw: &[u8], raw_hash: &str) { + let out = work.join("output"); + std::fs::create_dir_all(out.join("objects")).unwrap(); + std::fs::write(out.join("objects/600000.csv"), + "date,symbol,open,close\n2024-01-02,SH#600000,10.0,10.5\n2024-01-03,SH#600000,10.5,11.0\n").unwrap(); + std::fs::write(out.join("objects/raw_600000.json"), raw).unwrap(); + let manifest = json!({ + "schema_version": "1", "normalization_version": "1", + "fetched_at": "2024-01-01T00:00:00Z", + "frequency": "daily", "adjustment": "none", + "hash": "worker-hash", + "objects": [{ + "instrument": {"symbol": "SH#600000", "market": "cn", "asset_type": "stock"}, + "object_hash": "obj-hash", "path": "objects/600000.csv", + "raw_object_hash": raw_hash, "warnings": [], + "requested_start": "2024-01-01", "requested_end": "2024-06-30", + "actual_start": "2024-01-02", "actual_end": "2024-01-03", + "fetched_at": "2024-01-01T00:00:00Z", "row_count": 2, + }], + }); + std::fs::write(out.join("result.json"), json!({ + "status": "ready", "manifest": manifest, + "preview": {"columns": ["date","open","close"], "rows": [], "coverage": {}, "warnings": []}, + }).to_string()).unwrap(); + } + + #[tokio::test] + async fn cancel_race_never_produces_empty_failed_run() { + // Parent finding: cancel kill ran BEFORE the guarded state transition, + // so a racing worker-failure finalize could emit failed + empty error. + // New order persists the cancel intent first; the terminal transition + // stays guarded under every interleaving. + let (st, _t) = jobs_state(); + seed_running_run(&st, "rz").await; + for _ in 0..25 { + let s2 = st.clone(); + let f2 = st.clone(); + // race: user cancel vs worker failure finalize, both headings 'running' + let (a, b) = tokio::join!( + async move { signal_cancel(&s2, "rz").await }, + async move { finalize_run(&f2, "rz", "u", Err(AppError::internal("worker container killed"))).await }, + ); + let _ = a; + let _ = b; + let (status, error): (String, Option<String>) = st.with_db(|db| db.query_row( + "SELECT status,error FROM runs WHERE id='rz'", + [], |r| Ok((r.get(0)?, r.get::<_, Option<String>>(1)?))).unwrap()).await; + assert_ne!(status, "running"); + match status.as_str() { + "cancelled" => assert_eq!(error.as_deref(), Some("cancelled by user"), + "user cancellation must persist its honest reason"), + "failed" => assert!(error.as_deref().map(|e| !e.trim().is_empty()).unwrap_or(false), + "failed terminal must never be empty: {error:?}"), + other => panic!("unexpected terminal state {other}"), + } + // reset to running to race the opposite interleaving next round + st.with_db(|db| db.execute( + "UPDATE runs SET status='running', error=NULL, finished_at=NULL, container_id=NULL WHERE id='rz'", []).unwrap()).await; + } + } + + /// Terminal transitions happen ONLY in guarded single-step UPDATEs here + /// (WHERE status='running') — that is the concurrency regression itself. + + #[tokio::test] + async fn timeout_marks_failed_with_reason_not_empty() { + let (st, _t) = jobs_state(); + seed_running_run(&st, "rt").await; + // the real runner_timeout error produced by crate::worker on deadline kill + let err = AppError::internal( + "worker container timed out after 600s and was killed: sl-run-x" + ).with_code("runner_timeout"); + finalize_run(&st, "rt", "u", Err(err)).await; + let (status, error): (String, Option<String>) = st.with_db(|db| db.query_row( + "SELECT status,error FROM runs WHERE id='rt'", [], |r| Ok((r.get(0)?, r.get::<_, Option<String>>(1)?))).unwrap()).await; + assert_eq!(status, "failed"); + assert!(error.as_deref().unwrap_or_default().contains("timed out"), + "timeout must carry an honest reason, got {error:?}"); + } + + #[tokio::test] + async fn preview_coverage_rows_align_with_client_expected_shape() { + let td = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let raw = b"raw-payload".to_vec(); + write_worker_output(&td.path().to_path_buf(), &raw, &raw_digest(&raw)); + let store = ObjectStore::new(td.path().join("storage2").to_str().unwrap()); + let (manifest, _) = ingest_fetch_output(&store, &td.path().to_path_buf()).await.unwrap(); + let pv = &manifest["preview"]; + let cov = pv["coverage"].as_array().expect("coverage must be an array of per-instrument rows"); + assert_eq!(cov.len(), 1); + let c = &cov[0]; + assert_eq!(c["instrument"], json!("SH#600000")); + assert_eq!(c["market"], json!("cn")); + assert_eq!(c["asset_type"], json!("stock")); + assert_eq!(c["row_count"], json!(2)); + assert!(!c["requested_start"].is_null() && !c["actual_start"].is_null(), + "coverage rows must carry requested/actual bounds"); + assert!(pv["warnings"].as_array().unwrap().is_empty() || pv["warnings"].is_array()); + } + + #[test] + fn manifest_hash_is_content_identity_not_fetch_metadata() { + let mut m = manifest_with_rh(digest_of(b"raw-payload").as_str()); + let h1 = manifest_content_hash(&m); + if let Some(ar) = m.get_mut("objects").and_then(|v| v.as_array_mut()) { + ar[0]["fetched_at"] = json!("2099-01-01T00:00:00Z"); + ar[0]["path"] = json!("objects/elsewhere.csv"); + } + let h2 = manifest_content_hash(&m); + assert_eq!(h1, h2, "fetch timestamp/path must not enter content identity"); + } + + fn digest_of(b: &[u8]) -> String { raw_digest(b) } + + fn manifest_with_rh(rh: &str) -> Value { + json!({ + "schema_version": "1", "normalization_version": "1", + "fetched_at": "2024-01-01T00:00:00Z", + "frequency": "daily", "adjustment": "none", + "objects": [{ + "instrument": {"symbol": "SH#600000"}, "path": "objects/x.csv", + "raw_object_hash": rh, "warnings": [], + "fetched_at": "2024-01-01T00:00:00Z", "row_count": 2, + }], + }) + } + + #[tokio::test] + async fn ingest_rewrites_paths_and_hash_is_stable() { + let td = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let raw = b"raw-payload".to_vec(); + write_worker_output(&td.path().to_path_buf(), &raw, &raw_digest(&raw)); + let store = ObjectStore::new(td.path().join("storage").to_str().unwrap()); + let (manifest, _) = ingest_fetch_output(&store, &td.path().to_path_buf()).await.unwrap(); + let p = manifest["objects"][0]["path"].as_str().unwrap_or_default(); + assert!(p.starts_with("objects/") && p.ends_with(".csv"), "immutable stored path: {p}"); + assert!(store.absolute(p.trim_start_matches("objects/")).is_file()); + let td2 = tempfile::tempdir_in("/tmp/opencode").unwrap(); + write_worker_output(&td2.path().to_path_buf(), &raw, &raw_digest(&raw)); + let (again, _) = ingest_fetch_output(&store, &td2.path().to_path_buf()).await.unwrap(); + assert_eq!(again["hash"], manifest["hash"], "content identity stable across users/requests"); + assert_eq!(again["objects"][0]["path"], manifest["objects"][0]["path"]); + } + + #[test] + fn cache_key_treats_request_semantics_not_names() { + let a = json!({"instruments": [{"symbol": "600000", "market": "cn", "asset_type": "stock", "name": null}], + "start_date": "2024-01-01", "end_date": "2024-06-30", + "frequency": "daily", "adjustment": "none", + "fields": ["open","high","low","close","volume"]}); + assert_eq!(cache_key_for_request(&a).len(), 64); + } + + #[tokio::test] + async fn signal_cancel_never_rewrites_terminal_states() { + let tdir = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let mut conn = rusqlite::Connection::open(tdir.path().join("db.sqlite3")).unwrap(); + conn.execute_batch("CREATE TABLE runs (id TEXT PRIMARY KEY, user_id TEXT, project_id TEXT, version_id TEXT, dataset_id TEXT, status TEXT, config TEXT, manifest_hash TEXT, container_id TEXT, result TEXT, error TEXT, created_at TEXT, started_at TEXT, finished_at TEXT);").unwrap(); + conn.execute("INSERT INTO runs (id,user_id,status) VALUES ('r1','u','succeeded')", []).unwrap(); + let mut cfg = crate::config::Config::from_env(); + cfg.db_path = tdir.path().join("db.sqlite3").display().to_string(); + let st = std::sync::Arc::new(crate::state::AppState { + cfg, + db: tokio::sync::Mutex::new(conn), + run_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), + }); + assert!(!signal_cancel(&st, "r1").await, "terminal run must not be clobbered by cancel"); + let s: String = st.with_db(|db| db.query_row("SELECT status FROM runs WHERE id='r1'", [], |r| r.get::<_, String>(0)).unwrap()).await; + assert_eq!(s, "succeeded"); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..87f5467 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,667 @@ +mod admin; +mod ai; +mod auth; +mod config; +mod db; +mod datasets; +mod error; +mod jobs; +mod projects; +mod runs; +mod state; +mod store; +mod util; +mod worker; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::{ + body::Body, + extract::Request, + http::{header, HeaderValue, Method, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{delete, get, patch, post, put}, + Json, Router, +}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::state::{AppState, Cx}; + +const MIME_FALLBACK: &str = "application/octet-stream"; + +/// CSRF / content protections for writes. +async fn csrf_middleware(req: Request<Body>, next: Next) -> Response { + if !is_write(req.method()) { + return next.run(req).await; + } + let headers = req.headers().clone(); + if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { + let host = headers.get(header::HOST).and_then(|v| v.to_str().ok()).unwrap_or(""); + // No trust of X-Forwarded-* hosts; only exact canonical/host origin matches. + if !origin_allowed(origin, host, canonical_origin()) { + return AppErr::forbidden("cross-origin write rejected").into_response(); + } + } + if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) { + if site == "cross-site" { + return AppErr::forbidden("cross-site request rejected").into_response(); + } + } + // JSON writes only. DELETE carries no body: allow an absent content type. + let ctype = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let missing_ok = req.method() == Method::DELETE && ctype.is_empty(); + if !(missing_ok || ctype.starts_with("application/json")) { + return AppErr::forbidden("JSON Content-Type required").into_response(); + } + next.run(req).await +} + +static CANONICAL_ORIGIN: std::sync::OnceLock<String> = std::sync::OnceLock::new(); + +fn canonical_origin() -> &'static str { + CANONICAL_ORIGIN.get().map(|s| s.as_str()).unwrap_or("") +} + +/// Exact-origin check. Substring matching is exploitable: +/// an attacker-supplied Origin `https://allowed.example.evil.invalid` must fail. +/// Empty canonical origin: exact local (Host-based) origin only. +pub fn origin_allowed(origin: &str, host: &str, canonical: &str) -> bool { + let origin = origin.trim(); + if !(origin.starts_with("http://") || origin.starts_with("https://")) { + return false; + } + if !canonical.is_empty() { + // Trust only the configured canonical https origin, compared exactly. + return origin.eq_ignore_ascii_case(canonical.trim()); + } + if host.is_empty() { + // No Host and no canonical: cannot establish trust; reject. + return false; + } + origin == format!("http://{host}") || origin == format!("https://{host}") +} + +fn set_canonical(origin: &str) { + let _ = CANONICAL_ORIGIN.set(origin.trim().to_string()); +} + +fn is_write(m: &Method) -> bool { + matches!(*m, Method::POST | Method::PUT | Method::PATCH | Method::DELETE) +} + +type AppErr = error::AppError; + +async fn health(cx: Cx) -> Json<serde_json::Value> { + let docker = worker::docker_available().await; + Json(serde_json::json!({ + "status": "ok", + "version": cx.cfg.version, + "worker_available": docker, + "ai_configured": std::env::var("OPENCODE_GO_API_KEY").map(|_| true).unwrap_or(false), + })) +} + +async fn capabilities() -> Json<serde_json::Value> { + Json(serde_json::json!({ + "frequencies": ["daily"], + "asset_types": ["stock", "etf", "index"], + "adjustments": [ + {"code": "none", "label": "不复权"}, + {"code": "qfq", "label": "前复权"}, + {"code": "hfq", "label": "后复权"} + ], + "fields": [ + {"code": "open", "label": "开盘", "raw": false}, + {"code": "high", "label": "最高", "raw": false}, + {"code": "low", "label": "最低", "raw": false}, + {"code": "close", "label": "收盘", "raw": false}, + {"code": "volume", "label": "成交量", "raw": false}, + {"code": "adj_factor", "label": "复权因子", "raw": true} + ], + "limits": {"max_symbols": 5, "max_years": 15, "internal_only": true}, + })) +} + +/// Instrument search through the real worker container catalog command. +/// Failures are surfaced honestly in `status`; no fake empty success. +/// NOTE: Query deserialization: a plain `HashMap` accepts both absent and +/// present query params. `Query<Option<...>>` rejects any non-empty query with +/// `invalid type: map, expected option` (HTTP400 observed in live browser QA). +async fn instruments( + cx: Cx, + q: axum::extract::Query<HashMap<String, String>>, +) -> Json<serde_json::Value> { + let qm = q.0; + let query = qm.get("q").cloned().unwrap_or_default(); + let limit = qm + .get("limit") + .and_then(|v| v.parse::<i64>().ok()) + .unwrap_or(50); + match worker::search_instruments(&cx, &query, limit).await { + Ok(items) => { + let source = if items.is_empty() { "none" } else { "provider_suggest" }; + Json(serde_json::json!({"items": items, "source": source, "status": "ok"})) + } + Err(e) => Json(serde_json::json!({ + "items": [], + "source": "none", + "status": format!("unavailable: {}", e.message) + })), + } +} + +async fn api_fallback() -> Response { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": {"code": "not_found", "message": "unknown API route"}})), + ) + .into_response() +} + +fn mime_of(path: &std::path::Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "json" => "application/json", + "svg" => "image/svg+xml", + "png" => "image/png", + "webp" => "image/webp", + "woff2" => "font/woff2", + "woff" => "font/woff", + "ico" => "image/x-icon", + "map" => "application/json", + "txt" => "text/plain; charset=utf-8", + _ => MIME_FALLBACK, + } +} + +/// Serve the built SPA from frontend/dist. Exact files when they exist, +/// otherwise /index.html so client routes work; unknown /api is handled by the +/// inner fallback above and never falls back to the SPA. + +/// Static file resolution for the SPA (GET/HEAD only). +async fn serve_static(root: PathBuf, path: &str) -> Response { + let rel = path.trim_start_matches('/'); + if rel.contains("..") || rel.contains('\\') { + return StatusCode::NOT_FOUND.into_response(); + } + let base = root.join("index.html"); + let target = if rel.is_empty() { + base + } else { + let p = root.join(rel); + if p.is_file() { + p + } else if p.is_dir() || !p.exists() { + base + } else { + return StatusCode::NOT_FOUND.into_response(); + } + }; + match tokio::fs::read(&target).await { + Ok(bytes) => { + let mut resp = ( + StatusCode::OK, + [(header::CONTENT_TYPE, mime_of(&target))], + bytes, + ).into_response(); + if mime_of(&target) != "text/html" { + resp.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + ); + } + resp + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +pub fn build_app(cx: Arc<AppState>, frontend_dir: String) -> Router { + let api = Router::new() + .route("/health", get(health)) + .route("/capabilities", get(capabilities)) + .route("/instruments", get(instruments)) + .route("/auth/login", post(auth::login)) + .route("/auth/register", post(auth::register)) + .route("/auth/logout", post(auth::logout)) + .route("/auth/me", get(auth::me)) + .route("/auth/profile", patch(auth::patch_profile)) + .route("/auth/password", post(auth::change_password)) + .route("/auth/sessions", get(auth::list_sessions)) + .route("/auth/sessions/{id}", delete(auth::delete_session)) + .route("/auth/reset-password", post(auth::reset_password)) + .route("/projects", get(projects::list).post(projects::create)) + .route("/projects/{id}", get(projects::get).patch(projects::patch)) + .route("/projects/{id}/draft", put(projects::put_draft)) + .route("/projects/{id}/versions", get(projects::list_versions).post(projects::create_version)) + .route("/projects/{id}/versions/{vid}", get(projects::get_version)) + .route("/projects/{id}/versions/{vid}/diff", post(projects::diff_versions)) + .route("/projects/{id}/restore", post(projects::restore)) + .route("/datasets", get(datasets::list).post(datasets::create)) + .route("/datasets/{id}", get(datasets::get)) + .route("/datasets/{id}/preview", get(datasets::preview)) + .route("/runs", get(runs::list).post(runs::enqueue)) + .route("/runs/{id}", get(runs::get)) + .route("/runs/{id}/cancel", post(runs::cancel)) + .route("/runs/{id}/rerun", post(runs::rerun)) + .route("/ai/assist", post(ai::assist)) + .route("/ai/usage", get(ai::list_ai_usage)) + .route("/ai/{id}/accept", post(ai::accept)) + .route("/admin/users", get(admin::users)) + .route("/admin/users/{id}", patch(admin::patch_user)) + .route("/admin/invitations", get(admin::list_invitations).post(admin::create_invitation)) + .route("/admin/invitations/{id}", delete(admin::delete_invitation)) + .route("/admin/users/{id}/reset-password", post(admin::create_reset)) + .route("/admin/audit", get(admin::audit_list)) + .fallback(api_fallback) + .layer(middleware::from_fn(csrf_middleware)) + .with_state(cx.clone()); + + let spa = move |req: Request<Body>| { + let root = frontend_dir.clone(); + async move { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + if !matches!(method, Method::GET | Method::HEAD) { + return StatusCode::METHOD_NOT_ALLOWED.into_response(); + } + serve_static(PathBuf::from(root), &path).await + } + }; + + Router::new().nest("/api", api).fallback(spa) +} + +#[cfg(test)] +mod probe { + use super::*; + + fn probe_static(root: &str, path: &str) -> Response { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(serve_static(PathBuf::from(root), path)) + } + + #[axum::debug_handler(state = Arc<AppState>)] + async fn probe_auth( + _cx: Cx, + _auth: auth::AuthUser, + _path: axum::extract::Path<String>, + ) -> Result<Json<serde_json::Value>, error::AppError> { + Ok(Json(serde_json::json!({}))) + } + + #[axum::debug_handler(state = Arc<AppState>)] + async fn probe_query( + _cx: Cx, + _q: axum::extract::Query<HashMap<String, String>>, + ) -> Json<serde_json::Value> { + Json(serde_json::json!({})) + } + + #[test] + fn probe_router() { + let state = Arc::new(AppState { + cfg: config::Config::from_env(), + db: AsyncMutex::new(rusqlite::Connection::open_in_memory().unwrap()), + run_sem: Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), + }); + let _r: Router = Router::new() + .route("/x", get(probe_auth)) + .route("/y", get(probe_query)) + .with_state(state); + } + + /// Exact-origin CSRF: substring attacks must be rejected. + #[test] + fn csrf_rejects_malicious_substring_origin() { + const CANON: &str = "https://fin.somhairle.bid"; + assert!(origin_allowed(CANON, "fin.somhairle.bid", CANON)); + // attacker-controlled suffix + assert!(!origin_allowed("https://fin.somhairle.bid.evil.invalid", "fin.somhairle.bid", CANON)); + // attacker-controlled prefix host + assert!(!origin_allowed("https://evil.fin.somhairle.bid", "fin.somhairle.bid", CANON)); + // different scheme + assert!(!origin_allowed("http://fin.somhairle.bid", "fin.somhairle.bid", CANON)); + // different port + assert!(!origin_allowed("https://fin.somhairle.bid:8443", "fin.somhairle.bid", CANON)); + // no canonical: exact local origin only + assert!(origin_allowed("http://127.0.0.1:8787", "127.0.0.1:8787", "")); + assert!(!origin_allowed("http://127.0.0.1:8787.evil.invalid", "127.0.0.1:8787", "")); + assert!(!origin_allowed("http://127.0.0.1:8787x", "127.0.0.1:8787", "")); + // no host, no canonical: reject + assert!(!origin_allowed("http://whatever", "", "")); + assert!(!origin_allowed("javascript:alert(1)", "127.0.0.1:8787", "")); + assert!(!origin_allowed("", "127.0.0.1:8787", "")); + } + + #[test] + fn spa_serves_index_for_unknown_paths_and_sanitizes_traversal() { + let td = tempfile::tempdir_in("/tmp/opencode").unwrap(); + std::fs::write(td.path().join("index.html"), b"<html>ok</html>").unwrap(); + std::fs::write(td.path().join("assets.js"), b"console.log(1)").unwrap(); + let resp = probe_static(td.path().to_str().unwrap(), "/"); + assert_eq!(resp.status(), 200); + assert!(resp.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html")); + let resp = probe_static(td.path().to_str().unwrap(), "/assets.js"); + assert_eq!(resp.status(), 200); + let resp = probe_static(td.path().to_str().unwrap(), "/unknown/route"); + // SPA fallback serves index.html for client routes + assert_eq!(resp.status(), 200); + let resp = probe_static(td.path().to_str().unwrap(), "/../../etc/passwd"); + assert_ne!(resp.status(), 200); + } + + /// Executable-level regression: boot the real binary on an isolated + /// DB/port, assert it stays alive (>10s) WITHOUT any shutdown signal, then + /// SIGTERM must produce a bounded timely exit. This formerly caught a bug + /// where the drain deadline incorrectly started at startup and killed the + /// server at ~10s of healthy uptime (premature-exit regression). + #[tokio::test] + async fn server_survives_past_10s_then_bounds_sigterm_exit() { + let td = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let db_path = td.path().join("db.sqlite3"); + let data_dir = td.path().join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + // reserve a port using the OS + let probe_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = probe_listener.local_addr().unwrap().port(); + std::mem::drop(probe_listener); + + // a known-empty frontend dir keeps static serving honest for this probe + let fe = td.path().join("fe"); + std::fs::create_dir_all(&fe).unwrap(); + + let bin = std::env::var("CARGO_BIN_EXE_strategy-lab-server") + .unwrap_or_else(|_| "target/debug/strategy-lab-server".to_string()); + let bin_path = std::path::PathBuf::from(bin); + assert!(bin_path.is_file(), "isolated server binary not built at {}", bin_path.display()); + let mut child = tokio::process::Command::new(bin_path) + .env("BIND", format!("127.0.0.1:{port}")) + .env("DB_PATH", db_path.display().to_string()) + .env("DATA_DIR", data_dir.display().to_string()) + .env("FRONTEND_DIR", fe.display().to_string()) + .env("WORKER_IMAGE", "strategy-lab-worker-unset") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + + async fn health_ok(port: u16) -> bool { + let url = format!("http://127.0.0.1:{port}/api/health"); + match reqwest::get(&url).await { + Ok(r) => r.status().is_success(), + Err(_) => false, + } + } + // server must come up + let mut up = false; + for _ in 0..100 { + if health_ok(port).await { up = true; break; } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + assert!(up, "server did not come up in 20s"); + // CRITICAL: still alive WELL PAST 10 seconds with NO shutdown signal + tokio::time::sleep(std::time::Duration::from_secs(12)).await; + assert!(health_ok(port).await, "premature-exit regression: server died ~10s after startup without any signal"); + // now signal and require bounded exit + let pid = child.id().unwrap(); + nix_pid_kill_term(pid); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let mut exited = false; + while std::time::Instant::now() < deadline { + if child.try_wait().unwrap().is_some() { + exited = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + assert!(exited, "server did not exit in bounded time after SIGTERM"); + // give the runtime a moment to reap + let _ = child.wait().await; + let pid_gone = !std::path::Path::new(format!("/proc/{pid}").as_str()).exists(); + assert!(pid_gone, "server process still alive after bounded exit check"); + } + + fn nix_pid_kill_term(pid: u32) { + // POSIX kill of the exact PID only; no process-name scans, no group ops + std::process::Command::new("kill").args(["-TERM", &pid.to_string()]) + .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()) + .status().ok(); + } + + /// Route-level regression: `GET /api/instruments?q=600000` formerly failed + /// with HTTP400 "invalid type: map, expected option" because the query + /// extractor was `Query<Option<...>>`. Must be JSON 200 with `items`, + /// `source`, `status` (parent browser workflow live failure). + #[tokio::test] + async fn instruments_query_with_q_is_json_200() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::init_db(&conn).unwrap(); + let mut cfg = crate::config::Config { + db_path: format!("{}/nonexistent.sqlite3", std::env::temp_dir().display()), + data_dir: std::env::temp_dir().display().to_string(), + worker_image: "strategy-lab-worker-unset".into(), + ..config::Config::from_env() + }; + cfg.bind_addr = "127.0.0.1:0".into(); + let st = std::sync::Arc::new(AppState { + cfg, + db: AsyncMutex::new(conn), + run_sem: Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), + }); + let app = build_app(st, "frontend-dist-missing-for-probe".to_string()); + let req = axum::http::Request::builder() + .method("GET") + .uri("/api/instruments?q=600000") + .body(axum::body::Body::empty()) + .unwrap(); + let resp = tower::ServiceExt::oneshot(app, req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK, + "q= query must deserialize (was HTTP400 invalid type: map, expected option)"); + let body = axum::body::to_bytes(resp.into_body(), 64_000).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(v.is_object(), "structured JSON error/shape, never plain text: {v}"); + assert!(v.get("items").and_then(|i| i.as_array()).is_some(), "items array present"); + assert!(v.get("source").and_then(|s| s.as_str()).is_some(), "source present"); + // absent q must not 400 either + let app2 = { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::init_db(&conn).unwrap(); + let mut cfg = crate::config::Config::from_env(); + cfg.db_path = format!("{}/x.sqlite3", std::env::temp_dir().display()); + cfg.data_dir = std::env::temp_dir().display().to_string(); + cfg.bind_addr = "127.0.0.1:0".into(); + std::sync::Arc::new(AppState { + cfg, + db: AsyncMutex::new(conn), + run_sem: Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), + }) + }; + let app2 = build_app(app2, "missing-frontend-probe".to_string()); + let req = axum::http::Request::builder().method("GET").uri("/api/instruments?") + .body(axum::body::Body::empty()).unwrap(); + let resp = tower::ServiceExt::oneshot(app2, req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + /// Dataset creation shape: absent `name` and blank `name` must both + /// deserialize (SPEC/UI permit; backend auto-generates and persists). + #[test] + fn dataset_request_allows_missing_or_blank_name() { + let base = serde_json::json!({ + "instruments": [{"symbol": "600000", "market": "SH", "asset_type": "stock"}], + "start_date": "2024-01-01", "end_date": "2024-06-30", + "frequency": "daily", "adjustment": "none", + "fields": ["open", "high", "low", "close", "volume"] + }); + // missing `name` entirely (previously HTTP422 missing field `name`) + let r: datasets::DatasetRequest = serde_json::from_value(base.clone()).unwrap(); + assert!(r.name.is_none(), "absent name must deserialize as None"); + // blank name + let mut with_blank = base.clone(); + with_blank["name"] = serde_json::json!(" "); + let r = serde_json::from_value::<datasets::DatasetRequest>(with_blank).unwrap(); + assert!(r.name.unwrap().trim().is_empty()); + // normal name still works + let mut with_name = base; + with_name["name"] = serde_json::json!("真实行情验收:浦发银行"); + let r = serde_json::from_value::<datasets::DatasetRequest>(with_name).unwrap(); + assert_eq!(r.name.as_deref(), Some("真实行情验收:浦发银行")); + } +} + +fn make_state(cfg: config::Config) -> Arc<AppState> { + if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() { + std::fs::create_dir_all(parent).expect("create db directory"); + } + let conn = rusqlite::Connection::open(&cfg.db_path).expect("open db"); + db::init_db(&conn).expect("init schema"); + Arc::new(AppState { + run_sem: Arc::new(tokio::sync::Semaphore::new(cfg.run_concurrency)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(cfg.fetch_concurrency)), + cfg: cfg.clone(), + db: AsyncMutex::new(conn), + }) +} + +/// Create required runtime directories (databases, object store, job dirs). +fn ensure_directories(cfg: &config::Config) { + let data = std::path::Path::new(&cfg.data_dir); + for d in [data.join("objects"), data.join("jobs")] { + if let Err(e) = std::fs::create_dir_all(&d) { + tracing::error!("cannot create {d:?}: {e}"); + std::process::exit(1); + } + } +} + +/// Remove leftover containers from any earlier (crashed) server instance. +async fn cleanup_orphan_containers(cx: &Arc<AppState>) { + let names: Vec<String> = cx + .with_db(|db| { + let mut st = db.prepare("SELECT container_id FROM runs WHERE container_id IS NOT NULL")?; + let rows = st.query_map([], |r| r.get::<_, Option<String>>(0))?; + let t: Vec<Option<String>> = + rows.collect::<Result<Vec<Option<String>>, rusqlite::Error>>()?; + let out: Vec<String> = t + .into_iter() + .flatten() + .filter(|s| !s.is_empty()) + .collect(); + Ok::<Vec<String>, rusqlite::Error>(out) + }) + .await + .unwrap_or_default(); + let mut removed = 0usize; + for name in names { + if worker::cancel_container(&name).await { + removed += 1; + } + } + if removed > 0 { + tracing::info!("cleaned {removed} leftover worker container(s)"); + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + let cfg = config::Config::from_env(); + set_canonical(&cfg.canonical_origin); + let mode = std::env::args().nth(1).unwrap_or_else(|| "serve".into()); + + if mode == "bootstrap-admin" { + let state = make_state(cfg.clone()); + ensure_directories(&cfg); + let cx = axum::extract::State(state.clone()); + let _ = admin::bootstrap_admin(&cx) + .await + .map_err(|e| tracing::error!("bootstrap-admin failed: {e}")); + println!("bootstrap-admin done (if ADMIN_BOOTSTRAP env configured)"); + return; + } + + ensure_directories(&cfg); + let state = make_state(cfg.clone()); + let cx = axum::extract::State(state.clone()); + match admin::bootstrap_admin(&cx).await { + Ok(_) => {} + Err(e) => tracing::error!("bootstrap_admin: {e}"), + } + + // Restart safety: runs stuck as running become failed; stale containers are + // removed by name (never a global docker prune). + runs::cleanup_interrupted(&cx).await; + cleanup_orphan_containers(&state).await; + + let app = build_app(state.clone(), cfg.frontend_dir.clone()); + + tokio::spawn(async move { + jobs::main_loop(state, jobs::Signals::new()).await; + }); + + let addr = cfg.bind_addr.clone(); + let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind"); + tracing::info!("listening on {addr}"); + // NO startup deadline. The drain budget starts only after the shutdown + // signal: select between + // (a) the serve future completing normally / after graceful shutdown, and + // (b) signal-received AFTER which a 10s post-signal ceiling passes — + // the (b) arm cannot fire before the signal is delivered, so a healthy + // server with no signal stays up indefinitely. + tokio::select! { + drained = axum::serve(listener, app.into_make_service()) + .with_graceful_shutdown(wait_shutdown_signal()) => + { + match drained { + Ok(()) => tracing::info!("http drained, exiting"), + Err(e) => tracing::error!("http serve failed: {e}"), + } + } + _ = async { + wait_shutdown_signal().await; + tracing::info!("shutdown signal received; http draining (<=10s)"); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } => { + tracing::warn!("post-signal drain budget (10s) exceeded; exiting now"); + } + } +} + +/// Waits for SIGTERM or SIGINT (whichever arrives first). +async fn wait_shutdown_signal() { + use tokio::signal::unix::{signal, SignalKind}; + let term_fut = async { + match signal(SignalKind::terminate()) { + Ok(mut s) => { s.recv().await; } + Err(_) => std::future::pending::<()>().await, + } + }; + let int_fut = async { + match signal(SignalKind::interrupt()) { + Ok(mut s) => { s.recv().await; } + Err(_) => std::future::pending::<()>().await, + } + }; + tokio::select! { _ = term_fut => {}, _ = int_fut => {} } +} diff --git a/server/src/projects.rs b/server/src/projects.rs new file mode 100644 index 0000000..f5305da --- /dev/null +++ b/server/src/projects.rs @@ -0,0 +1,219 @@ +use axum::extract::Path; +use axum::Json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::auth::{audit, AuthUser}; +use crate::error::{AppError, AppResult}; +use crate::util::{new_id, now_iso, sha256_hex, unified_diff}; + +pub type Cx = crate::state::Cx; + +#[derive(Serialize)] +pub struct Project { + pub id: String, + pub name: String, + pub description: String, + pub draft_code: String, + pub draft_generation: i64, + pub created_at: String, + pub updated_at: String, +} + +pub async fn load_owned(cx: &Cx, user_id: &str, project_id: &str) -> AppResult<Project> { + cx.with_db(|db| { + db.query_row("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE id=?1 AND user_id=?2", + rusqlite::params![project_id, user_id], row_project) + .map_err(|_| AppError::not_found("project not found")) + }).await +} + +fn row_project(r: &rusqlite::Row) -> rusqlite::Result<Project> { + Ok(Project { + id: r.get(0)?, name: r.get(1)?, description: r.get(2)?, + draft_code: r.get(3)?, draft_generation: r.get(4)?, + created_at: r.get(5)?, updated_at: r.get(6)?, + }) +} + +#[derive(Deserialize)] +pub struct CreateProject { pub name: String, pub description: Option<String> } + +pub async fn create(cx: Cx, auth: AuthUser, body: Option<Json<CreateProject>>) -> AppResult<(axum::http::StatusCode, Json<Value>)> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let name = r.name.trim().to_string(); + if name.is_empty() || name.len() > 200 { return Err(AppError::bad("validation", "name required (max 200 chars)")); } + let id = new_id(); + let desc = r.description.unwrap_or_default(); + let ts = now_iso(); + let uid = auth.id.clone(); + cx.with_db(|db| -> AppResult<()> { + db.execute("INSERT INTO projects (id,user_id,name,description,draft_code,draft_generation,created_at,updated_at) VALUES (?1,?2,?3,?4,'',0,?5,?5)", + rusqlite::params![&id, &uid, &name, &desc, &ts])?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "project_create", &id, "ok").await; + let p = load_owned(&cx, &auth.id, &id).await?; + Ok((axum::http::StatusCode::CREATED, Json(json!(p)))) +} + +pub async fn list(cx: Cx, auth: AuthUser) -> AppResult<Json<Value>> { + let items: Vec<Project> = cx.with_db(|db| { + let mut st = db.prepare("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE user_id=?1 ORDER BY updated_at DESC")?; + let mut rows = st.query([auth.id.clone()])?; + let mut out = Vec::new(); + while let Some(r) = rows.next()? { out.push(row_project(r)?); } + Ok::<_, AppError>(out) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn get(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<Value>> { + let p = load_owned(&cx, &auth.id, &id).await?; + Ok(Json(json!(p))) +} + +#[derive(Deserialize)] +pub struct PatchProject { pub name: Option<String>, pub description: Option<String> } + +pub async fn patch(cx: Cx, auth: AuthUser, Path(id): Path<String>, body: Option<Json<PatchProject>>) -> AppResult<Json<Value>> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if let Some(n) = &r.name { + if n.trim().is_empty() { return Err(AppError::bad("validation", "name must not be empty")); } + } + load_owned(&cx, &auth.id, &id).await?; + cx.with_db(|db| -> AppResult<()> { + db.execute("UPDATE projects SET name=COALESCE(?1,name), description=COALESCE(?2,description), updated_at=?3 WHERE id=?4", + rusqlite::params![r.name, r.description, now_iso(), &id])?; + Ok(()) + }).await?; + let p = load_owned(&cx, &auth.id, &id).await?; + Ok(Json(json!(p))) +} + +#[derive(Deserialize)] +pub struct PutDraft { pub code: String, pub expected_generation: i64 } + +pub async fn put_draft(cx: Cx, auth: AuthUser, Path(id): Path<String>, body: Option<Json<PutDraft>>) -> AppResult<Json<Value>> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if r.code.len() > crate::config::MAX_CODE_LEN { return Err(AppError::bad("validation", "code too long")); } + let p: Project = cx.with_db(|db| -> AppResult<Project> { + let cur: i64 = db.query_row("SELECT draft_generation FROM projects WHERE id=?1 AND user_id=?2", + rusqlite::params![&id, &auth.id], |row| row.get(0)) + .map_err(|_| AppError::not_found("project not found"))?; + if cur != r.expected_generation { + return Err(AppError::conflict("stale_generation", "draft changed; reload first") + .with_details(json!({ "current_generation": cur }))); + } + db.execute("UPDATE projects SET draft_code=?1, draft_generation=?2, updated_at=?3 WHERE id=?4", + rusqlite::params![&r.code, cur + 1, now_iso(), &id])?; + db.query_row("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE id=?1", [&id], row_project) + .map_err(AppError::from) + }).await?; + Ok(Json(json!(p))) +} + +// ---- versions ---- + +#[derive(Serialize)] +pub struct Version { + pub id: String, + pub project_id: String, + pub code: String, + pub hash: String, + pub message: String, + pub created_at: String, + pub source: String, +} + +fn row_version(r: &rusqlite::Row) -> rusqlite::Result<Version> { + Ok(Version { id: r.get(0)?, project_id: r.get(1)?, code: r.get(2)?, hash: r.get(3)?, message: r.get(4)?, created_at: r.get(5)?, source: r.get(6)? }) +} + +fn select_version(db: &mut rusqlite::Connection, vid: &str, pid: &str) -> rusqlite::Result<Version> { + let mut st = db.prepare("SELECT id,project_id,code,hash,message,created_at,source FROM project_versions WHERE id=?1 AND project_id=?2")?; + st.query_row(rusqlite::params![vid, pid], row_version) +} + +pub async fn list_versions(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<Value>> { + load_owned(&cx, &auth.id, &id).await?; + let mut items: Vec<Version> = cx.with_db(|db| { + let mut st = db.prepare("SELECT id,project_id,code,hash,message,created_at,source FROM project_versions WHERE project_id=?1 ORDER BY created_at ASC")?; + let mut rows = st.query([id.clone()])?; + let mut out = Vec::new(); + while let Some(r) = rows.next()? { out.push(row_version(r)?); } + Ok::<_, AppError>(out) + }).await?; + items.reverse(); + Ok(Json(json!({ "items": items }))) +} + +pub async fn get_version(cx: Cx, auth: AuthUser, Path((pid, vid)): Path<(String, String)>) -> AppResult<Json<Value>> { + load_owned(&cx, &auth.id, &pid).await?; + let v: Version = cx.with_db(|db| select_version(db, &vid, &pid)) + .await + .map_err(|_| AppError::not_found("version not found"))?; + Ok(Json(json!(v))) +} + +pub async fn diff_versions(cx: Cx, auth: AuthUser, Path((pid, vid)): Path<(String, String)>, body: Option<Json<Value>>) -> AppResult<Json<Value>> { + let base: Value = body.map(|b| b.0).unwrap_or(json!({})); + load_owned(&cx, &auth.id, &pid).await?; + let v: Version = cx.with_db(|db| select_version(db, &vid, &pid)) + .await + .map_err(|_| AppError::not_found("version not found"))?; + let base_code: String = match base.get("base_version").and_then(|b| b.as_str()) { + Some(bv) => cx.with_db(|db| { + db.query_row("SELECT code FROM project_versions WHERE id=?1 AND project_id=?2", rusqlite::params![bv, &pid], |r| r.get(0)) + .map_err(|_| AppError::bad("validation", "base_version not found")) + }).await?, + None => String::new(), + }; + Ok(Json(json!({ "diff": unified_diff(&base_code, &v.code), "version": { "id": v.id, "hash": v.hash, "message": v.message } }))) +} + +pub async fn create_version(cx: Cx, auth: AuthUser, Path(id): Path<String>, body: Option<Json<Value>>) -> AppResult<(axum::http::StatusCode, Json<Value>)> { + let b = body.map(|b| b.0).unwrap_or(json!({})); + let message = b.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string(); + if message.trim().is_empty() { return Err(AppError::bad("validation", "message required")); } + let v = create_version_from_draft(&cx, &auth.id, &id, &message, "manual").await?; + Ok((axum::http::StatusCode::CREATED, Json(json!(v)))) +} + +/// Snapshot the current draft as an immutable version. +pub async fn create_version_from_draft(cx: &Cx, user_id: &str, project_id: &str, message: &str, source: &str) -> AppResult<Version> { + let p = load_owned(cx, user_id, project_id).await?; + if p.draft_code.trim().is_empty() { return Err(AppError::bad("validation", "draft is empty; nothing to version")); } + let hash = sha256_hex(p.draft_code.as_bytes()); + let draft = p.draft_code.clone(); + let v: Version = cx.with_db(|db| -> AppResult<Version> { + let id = new_id(); + let ts = now_iso(); + db.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", + rusqlite::params![&id, project_id, &draft, &hash, message, source, &ts])?; + let mut st = db.prepare("SELECT id,project_id,code,hash,message,created_at,source FROM project_versions WHERE id=?1")?; + st.query_row([&id], row_version).map_err(AppError::from) + }).await?; + audit(cx, Some(user_id), "version_create", project_id, "ok").await; + Ok(v) +} + +#[derive(Deserialize)] +pub struct RestoreReq { pub version_id: String, pub expected_generation: i64 } + +pub async fn restore(cx: Cx, auth: AuthUser, Path(id): Path<String>, body: Option<Json<RestoreReq>>) -> AppResult<Json<Value>> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + cx.with_db(|db| -> AppResult<()> { + let cur: i64 = db.query_row("SELECT draft_generation FROM projects WHERE id=?1 AND user_id=?2", rusqlite::params![&id, &auth.id], |row| row.get(0)) + .map_err(|_| AppError::not_found("project not found"))?; + if cur != r.expected_generation { return Err(AppError::conflict("stale_generation", "draft changed; reload first")); } + let code: String = db.query_row("SELECT code FROM project_versions WHERE id=?1 AND project_id=?2", rusqlite::params![&r.version_id, &id], |row| row.get(0)) + .map_err(|_| AppError::not_found("version not found"))?; + db.execute("UPDATE projects SET draft_code=?1, draft_generation=?2, updated_at=?3 WHERE id=?4", + rusqlite::params![&code, cur + 1, now_iso(), &id])?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "version_restore", &id, "ok").await; + let p = load_owned(&cx, &auth.id, &id).await?; + Ok(Json(json!(p))) +} diff --git a/server/src/runs.rs b/server/src/runs.rs new file mode 100644 index 0000000..8bb934f --- /dev/null +++ b/server/src/runs.rs @@ -0,0 +1,281 @@ +use axum::extract::{Path, Query}; +use axum::http::StatusCode; +use axum::Json; +use serde_json::{json, Value}; + +use crate::auth::{audit, AuthUser}; +use crate::config::MAX_CODE_LEN; +use crate::error::{AppError, AppResult}; +use crate::util::new_id; +use crate::util::now_iso; + +pub use crate::state::Cx; + +const RUN_COLS: &str = "id,project_id,version_id,config,dataset_id,status,error,manifest_hash,created_at,result,started_at,finished_at"; + +fn row_run(r: &rusqlite::Row) -> rusqlite::Result<Value> { + let config: String = r.get(3)?; + let result: Option<String> = r.get(9)?; + Ok(json!({ + "id": r.get::<_, String>(0)?, + "project_id": r.get::<_, String>(1)?, + "version_id": r.get::<_, String>(2)?, + "config": serde_json::from_str::<Value>(&config).unwrap_or(Value::Null), + "dataset_id": r.get::<_, String>(4)?, + "status": r.get::<_, String>(5)?, + "error": r.get::<_, Option<String>>(6)?, + "manifest_hash": r.get::<_, Option<String>>(7)?, + "data_manifest_hash": r.get::<_, Option<String>>(7)?, + "created_at": r.get::<_, String>(8)?, + "result": result.and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(Value::Null), + "started_at": r.get::<_, Option<String>>(10)?, + "finished_at": r.get::<_, Option<String>>(11)?, + })) +} + +fn select_run(db: &mut rusqlite::Connection, run_id: &str) -> AppResult<Value> { + let cols = RUN_COLS; + db.query_row(format!("SELECT {cols} FROM runs WHERE id=?1").as_str(), [run_id], row_run) + .map_err(|_| AppError::not_found("run not found")) +} + +async fn assert_run_owner(cx: &Cx, user_id: &str, run_id: &str) -> AppResult<()> { + let owner: Option<String> = cx.with_db(|db| { + db.query_row("SELECT user_id FROM runs WHERE id=?1", [run_id], |r| r.get(0)).ok() + }).await; + match owner { + Some(u) if u == user_id => Ok(()), + _ => Err(AppError::not_found("run not found")), + } +} + +pub async fn get(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<Value>> { + assert_run_owner(&cx, &auth.id, &id).await?; + let v = cx.with_db(|db| select_run(db, &id)).await?; + Ok(Json(v)) +} + +pub async fn list(cx: Cx, auth: AuthUser, q: Query<std::collections::HashMap<String, String>>) -> AppResult<Json<Value>> { + let project = q.get("project_id").cloned(); + let cols = RUN_COLS; + let items: Vec<Value> = cx.with_db(|db| -> AppResult<Vec<Value>> { + let owned = project.as_deref(); + let sql = if owned.is_some() { + format!("SELECT {cols} FROM runs WHERE user_id=?1 AND project_id=?2 ORDER BY created_at DESC LIMIT 200") + } else { + format!("SELECT {cols} FROM runs WHERE user_id=?1 ORDER BY created_at DESC LIMIT 200") + }; + let mut st = db.prepare(&sql)?; + let mut rows = if let Some(p) = owned { + st.query(rusqlite::params![auth.id, p])? + } else { + st.query([auth.id.clone()])? + }; + let mut out = Vec::new(); + while let Some(r) = rows.next()? { out.push(row_run(r)?); } + Ok(out) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +fn build_config(j: &Value) -> AppResult<Value> { + let g = |k: &str, d: f64| j.get(k).and_then(|v| v.as_f64()).unwrap_or(d); + let capital = j.get("capital").and_then(|v| v.as_f64()); + let config = json!({ + "capital": capital.unwrap_or(1_000_000.0), + "commission": g("commission", 0.0003), + "slippage": g("slippage", 0.001), + "benchmark_symbol": j.get("benchmark_symbol").cloned().unwrap_or(Value::Null), + "parameters": j.get("parameters").cloned().unwrap_or(json!({})), + "seed": j.get("seed").cloned().unwrap_or(Value::Null), + }); + let cap = config.get("capital").and_then(|v| v.as_f64()).unwrap(); + if !cap.is_finite() || !(1.0..=1e12).contains(&cap) { + return Err(AppError::bad("validation", "capital must be between 1 and 1e12")); + } + for k in ["commission", "slippage"] { + let v = config.get(k).and_then(|v| v.as_f64()).unwrap(); + if !v.is_finite() || !(0.0..=0.05).contains(&v) { + return Err(AppError::bad("validation", format!("{k} must be between 0 and 0.05"))); + } + } + if config.get("parameters").and_then(|p| p.as_object()).map(|o| !o.is_empty()).unwrap_or(false) { + if serde_json::to_string(config.get("parameters").unwrap()).unwrap_or_default().len() > MAX_CODE_LEN { + return Err(AppError::bad("validation", "parameters too large")); + } + } + Ok(config) +} + +/// Insert a queued run inside one transaction with an atomic per-user daily quota check. +async fn insert_run(cx: &Cx, auth: &AuthUser, project_id: &str, dataset_id: &str, version_id: &str, config: &Value, manifest_hash: &Option<String>) -> AppResult<String> { + let uid = auth.id.clone(); + let cfg_s = config.to_string(); + let limit = auth.daily_run_limit; + let today: String = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let pattern = format!("{today}%"); + let id = new_id(); + let now = now_iso(); + cx.with_db(|db| -> AppResult<String> { + db.execute("BEGIN IMMEDIATE", []).ok(); + let used: i64 = db.query_row( + "SELECT COUNT(*) FROM runs WHERE user_id=?1 AND created_at LIKE ?2", + rusqlite::params![&uid, &pattern], |r| r.get(0)).unwrap_or(0); + if used >= limit { + db.execute("ROLLBACK", []).ok(); + return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "run_limit", "daily run quota reached")); + } + db.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,manifest_hash,created_at) VALUES (?1,?2,?3,?4,?5,'queued',?6,?7,?8)", + rusqlite::params![&id, &uid, project_id, version_id, dataset_id, &cfg_s, manifest_hash, &now])?; + db.execute("COMMIT", []).map_err(AppError::from)?; + Ok(id) + }).await +} + +pub async fn enqueue(cx: Cx, auth: AuthUser, body: Option<Json<serde_json::Value>>) -> AppResult<(StatusCode, Json<Value>)> { + let Json(j) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let project_id = j.get("project_id").and_then(|v| v.as_str()).ok_or_else(|| AppError::bad("validation", "project_id required"))?.to_string(); + let dataset_id = j.get("dataset_id").and_then(|v| v.as_str()).ok_or_else(|| AppError::bad("validation", "dataset_id required"))?.to_string(); + + // dataset must exist, belong to the caller and be ready + let (ready, manifest_hash): (bool, Option<String>) = cx.with_db(|db| { + db.query_row("SELECT status='ready', manifest_hash FROM datasets WHERE id=?1 AND user_id=?2", + rusqlite::params![&dataset_id, &auth.id], |r| Ok((r.get::<_, i64>(0)? != 0, r.get(1)?))) + .map_err(|_| AppError::not_found("dataset not found")) + }).await?; + if !ready { return Err(AppError::conflict("dataset_not_ready", "dataset pending or failed; cannot run")); } + + // snapshot + pin draft + let draft: String = cx.with_db(|db| { + db.query_row("SELECT draft_code FROM projects WHERE id=?1 AND user_id=?2", rusqlite::params![&project_id, &auth.id], |r| r.get(0)) + .map_err(|_| AppError::not_found("project not found")) + }).await?; + if draft.trim().is_empty() { + return Err(AppError::bad("validation", "draft is empty; cannot run empty strategy")); + } + + let config = build_config(&j)?; + let version_id = push_run_version(&cx, &auth.id, &project_id, &draft).await?; + + // warnings need explicit acknowledgement + let warnings: Value = cx.with_db(|db| { + let m: String = db.query_row("SELECT manifest FROM datasets WHERE id=?1", [&dataset_id], |r| r.get(0)).unwrap_or_default(); + let mj: Value = serde_json::from_str(&m).unwrap_or(Value::Null); + Ok::<_, AppError>(mj.get("warnings").cloned().unwrap_or(json!([]))) + }).await?; + let mut warnings = warnings; + if let Some(objs) = warnings.as_array_mut() { + // explicit unsupported-restriction surfacing for index feeds + let has_index: bool = cx.with_db(|db| { + let m: String = db.query_row("SELECT manifest FROM datasets WHERE id=?1", [&dataset_id], |r| r.get(0)).unwrap_or_default(); + let mj: Value = serde_json::from_str(&m).unwrap_or(Value::Null); + Ok::<_, AppError>(mj.get("objects").and_then(|o| o.as_array()).map(|a| { + a.iter().any(|o| o.get("instrument").and_then(|i| i.get("asset_type")).and_then(|t| t.as_str()) == Some("index")) + }).unwrap_or(false)) + }).await?; + if has_index && !objs.iter().any(|w| w.as_str().map(|s| s.contains("index feeds are nontradable")).unwrap_or(false)) { + objs.push(json!("index feeds are nontradable research proxies; direct index orders are rejected by the engine")); + } + } + let has_warns = warnings.as_array().map(|a| !a.is_empty()).unwrap_or(false); + if has_warns && !j.get("acknowledge_warnings").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(AppError::conflict("warnings_unacknowledged", "dataset has warnings; acknowledge to run") + .with_details(json!({"warnings": warnings}))); + } + + let run_id = insert_run(&cx, &auth, &project_id, &dataset_id, &version_id, &config, &manifest_hash).await?; + audit(&cx, Some(&auth.id), "run_create", &run_id, "ok").await; + let v = cx.with_db(|db| select_run(db, &run_id)).await?; + Ok((StatusCode::ACCEPTED, Json(v))) +} + +/// Snapshot the draft as a pinned run version, reusing an identical hash version. +async fn push_run_version(cx: &Cx, _user_id: &str, project_id: &str, code: &str) -> AppResult<String> { + let hash = crate::util::sha256_hex(code.as_bytes()); + let code = code.to_string(); + cx.with_db(|db| -> AppResult<String> { + let vid: Option<String> = db.query_row("SELECT id FROM project_versions WHERE project_id=?1 AND hash=?2", + rusqlite::params![project_id, &hash], |r| r.get(0)).ok(); + if let Some(v) = vid { return Ok(v); } + let id = new_id(); + db.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES (?1,?2,?3,?4,'run snapshot','run',?5)", + rusqlite::params![&id, project_id, &code, &hash, now_iso()])?; + Ok(id) + }).await +} + +pub async fn cancel(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<Value>> { + assert_run_owner(&cx, &auth.id, &id).await?; + let status: Option<String> = cx.with_db(|db| { + db.query_row("SELECT status FROM runs WHERE id=?1", [&id], |r| r.get(0)).ok() + }).await; + if matches!(status.as_deref(), Some("queued") | Some("running")) { + let res = crate::jobs::signal_cancel(&cx, &id).await; + audit(&cx, Some(&auth.id), "run_cancel", &id, if res { "ok" } else { "not_running" }).await; + } + let v = cx.with_db(|db| select_run(db, &id)).await?; + Ok(Json(v)) +} + +/// Fresh run pinned to the original code+config+dataset; never refetches data. +pub async fn rerun(cx: Cx, auth: AuthUser, Path(id): Path<String>, body: Option<Json<serde_json::Value>>) -> AppResult<(StatusCode, Json<Value>)> { + assert_run_owner(&cx, &auth.id, &id).await?; + let j = body.map(|b| b.0).unwrap_or(json!({})); + if j.get("use_original_data").and_then(|v| v.as_bool()) != Some(true) { + return Err(AppError::bad("validation", "use_original_data must be true; rerun is pinned to original data")); + } + let (project_id, version_id, dataset_id, config, manifest_hash): (String, String, String, String, Option<String>) = cx.with_db(|db| { + db.query_row("SELECT project_id,version_id,dataset_id,config,manifest_hash FROM runs WHERE id=?1", [&id], |r| + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, Option<String>>(4)?))) + .map_err(|_| AppError::not_found("run not found")) + }).await?; + // dataset must still be ready and owned + let ready: bool = cx.with_db(|db| { + db.query_row("SELECT status='ready' FROM datasets WHERE id=?1 AND user_id=?2", + rusqlite::params![&dataset_id, &auth.id], |r| r.get(0)) + .map_err(|_| AppError::not_found("dataset not found")) + }).await?; + if !ready { return Err(AppError::conflict("dataset_not_ready", "original dataset is missing or not ready")); } + let cfg_val: Value = serde_json::from_str(&config).unwrap_or(json!({})); + let new_id_r = insert_run(&cx, &auth, &project_id, &dataset_id, &version_id, &cfg_val, &manifest_hash).await?; + audit(&cx, Some(&auth.id), "run_rerun", &new_id_r, "ok").await; + let v = cx.with_db(|db| select_run(db, &new_id_r)).await?; + Ok((StatusCode::ACCEPTED, Json(v))) +} + +/// Mark running runs failed after a server restart (queued stays resumable). +pub async fn cleanup_interrupted(cx: &Cx) { + cx.with_db(|db| { + db.execute("UPDATE runs SET status='failed', error='interrupted by server restart', finished_at=?1 WHERE status='running'", + rusqlite::params![now_iso()]).ok(); + }).await; +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_bounds_enforced() { + let ok = json!({"capital": 100000, "commission": 0.0003, "slippage": 0.001}); + assert!(build_config(&ok).is_ok()); + let bad = json!({"capital": 0.0}); + assert_eq!(build_config(&bad).unwrap_err().code, "validation"); + let bad = json!({"capital": 5000, "commission": 0.5}); + assert_eq!(build_config(&bad).unwrap_err().code, "validation"); + let big = json!({"capital": 100000, "parameters": {"huge": "x".repeat(MAX_CODE_LEN + 1)}}); + assert_eq!(build_config(&big).unwrap_err().code, "validation"); + let defaults = build_config(&json!({})).unwrap(); + assert_eq!(defaults["commission"], json!(0.0003), "defaults are visible to users"); + } + + #[test] + fn nonfinite_sanitized() { + let mut v = json!({"metrics": {"sharpe": f64::NAN, "total_return": 1.0}, "equity": [1.0, f64::INFINITY]}); + crate::jobs::sanitize_nonfinite(&mut v); + let s = serde_json::to_string(&v).unwrap(); + assert!(!s.contains("NaN") && !s.contains("Infinity"), "results must never carry NaN/Inf"); + assert_eq!(v["metrics"]["sharpe"], Value::Null); + } +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..512bcb3 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; +use tokio::sync::{Mutex as AsyncMutex, Semaphore}; + +use crate::config::Config; + +/// Shared application state. The single SQLite connection is serialized behind +/// an async mutex; concurrent fetch/backtest work is bounded by semaphores. +pub struct AppState { + pub cfg: Config, + pub db: AsyncMutex<rusqlite::Connection>, + pub run_sem: Arc<Semaphore>, + pub fetch_sem: Arc<Semaphore>, +} + +/// Fixed shared contract alias for handler arguments: the axum State extractor +/// over `Arc<AppState>`. Handlers take `cx: Cx`, helpers take `&Cx`. +pub type Cx = axum::extract::State<std::sync::Arc<AppState>>; + +/// Legacy alias kept for modules (auth, projects, datasets) whose handlers take +/// the bare `Arc<AppState>`; both styles are valid extractors for this state. +pub type S = Arc<AppState>; + +impl AppState { + /// Lock the connection, run the closure, return its result exactly. + /// Never `.await` inside the closure; keep transactions in ONE closure. + pub async fn with_db<R>(&self, f: impl FnOnce(&mut rusqlite::Connection) -> R) -> R { + let mut db = self.db.lock().await; + f(&mut db) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[tokio::test] + async fn with_db_returns_closure_result_and_composes() { + let cfg = Config::from_env(); + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let st = AppState { + cfg: cfg.clone(), + db: AsyncMutex::new(conn), + run_sem: Arc::new(Semaphore::new(1)), + fetch_sem: Arc::new(Semaphore::new(1)), + }; + let n: i64 = st.with_db(|db| { + db.execute("CREATE TABLE t(x INTEGER)", []).unwrap(); + db.execute("INSERT INTO t VALUES (42)", []).unwrap(); + db.query_row("SELECT SUM(x) FROM t", [], |r| r.get(0)).unwrap() + }).await; + assert_eq!(n, 42); + st.with_db(|_db| ()).await; + } +} diff --git a/server/src/store.rs b/server/src/store.rs new file mode 100644 index 0000000..f371d10 --- /dev/null +++ b/server/src/store.rs @@ -0,0 +1,206 @@ +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Immutable content-addressed object storage under {data_dir}/objects/{hash[:2]}/{hash}.{ext}. +/// Content is deduplicated: storing identical bytes twice keeps the first object. +pub struct ObjectStore { + pub root: PathBuf, +} + +/// One ingested artifact (file content hashed and copied into the object store). +#[derive(Debug, Clone)] +pub struct StoredObject { + /// sha256 of content + pub hash: String, + /// path relative to the object root (immutable stored path) + pub stored_path: String, + /// file name as produced by the worker, safe for /data mounts + pub mount_name: String, + pub size: u64, +} + +impl ObjectStore { + pub fn new(data_dir: &str) -> Self { + ObjectStore { root: Path::new(data_dir).join("objects") } + } + + /// Store raw bytes by content hash. Returns (hash, stored relative path). + pub fn store(&self, bytes: &[u8], filename: &str) -> std::io::Result<(String, String)> { + let hash = format!("{:x}", Sha256::digest(bytes)); + let dir = self.root.join(&hash[..2]); + std::fs::create_dir_all(&dir)?; + let ext = safe_ext(filename); + let path = dir.join(format!("{hash}.{ext}")); + if !path.is_file() { + // Atomic write in the final directory; suffix append (not with_extension) + // so different source extensions cannot collide on one tmp name. + let tmp = dir.join(format!("{hash}.{ext}.tmp")); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, &path)?; + } + let rel = path + .strip_prefix(&self.root) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string_lossy().into_owned()); + Ok((hash, rel)) + } + + /// Map a stored relative path (server controlled) back to an absolute path. + pub fn absolute(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + /// Hash and ingest every regular file under an output directory (worker + /// artifacts). Returns one entry per file, sorted for determinism. + /// Rejects symlinked entries. + pub fn ingest_directory(&self, dir: &Path) -> std::io::Result<Vec<StoredObject>> { + let mut files: Vec<PathBuf> = Vec::new(); + collect_files(dir, dir, &mut files)?; + files.sort(); + let mut out = Vec::with_capacity(files.len()); + for f in files { + let is_symlink = f.symlink_metadata()?.file_type().is_symlink() + || std::fs::symlink_metadata(&f)?.file_type().is_symlink(); + if is_symlink { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "symlinked artifact rejected", + )); + } + let bytes = std::fs::read(&f)?; + let mount_name = f + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("artifact.bin") + .to_string(); + let rel_path = f.strip_prefix(dir).expect("strip_prefix"); + let (hash, stored_path) = + self.store(&bytes, &mount_name)?; + out.push(StoredObject { + hash, + stored_path, + mount_name: rel_path.display().to_string(), + size: bytes.len() as u64, + }); + } + Ok(out) + } +} + +fn collect_files(_root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let p = entry?.path(); + let ty = p.symlink_metadata()?.file_type(); + if ty.is_symlink() { + // path traversal defense: no symlinked artifacts, ever + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "symlink in artifact tree rejected", + )); + } + if ty.is_dir() { + collect_files(_root, &p, out)?; + } else { + out.push(p); + } + } + Ok(()) +} + +/// Sanitize a filename for use as an object extension: only the final +/// extension survives, restricted to alphanumeric chars. +fn safe_ext(filename: &str) -> String { + let base = filename.rsplit('/').next().unwrap_or("data"); + let e = base.rsplit('.').next().unwrap_or("bin").to_string(); + let v: String = e.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); + if v.is_empty() || v.parse::<usize>().is_ok() { + "bin".into() + } else { + v.to_lowercase() + } +} + +/// Public wrapper used when a filename has no usable extension. +pub fn filename_or_bin(name: &str, fallback: &str) -> String { + let base = name.rsplit('/').next().unwrap_or(fallback); + let v: String = base + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + .collect(); + if v.is_empty() || v == "." { + fallback.into() + } else { + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_store(tag: &str) -> (tempfile::TempDir, ObjectStore) { + let t = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let store = ObjectStore::new(t.path().join(tag).to_str().unwrap()); + (t, store) + } + + #[test] + fn store_is_content_addressed_and_deduplicated() { + let (_t, s) = tmp_store("objs1"); + let (h1, p1) = s.store(b"hello world", "a.csv").unwrap(); + let (h2, p2) = s.store(b"hello world", "b.csv").unwrap(); + assert_eq!(h1, h2); + assert_eq!(p1, p2); + assert!(p1.starts_with(&h1[..2]), "{p1}"); + let abs = s.absolute(&p1); + assert_eq!(std::fs::read(&abs).unwrap(), b"hello world".to_vec()); + assert!(s.root.join(&p1) == abs, "stored path resolves under root"); + } + + #[test] + fn same_stem_different_extension_no_collision() { + let (_t, s) = tmp_store("objs2"); + let (_, a) = s.store(b"csv-bytes", "obj.csv").unwrap(); + let (_, b) = s.store(b"json-bytes", "obj.json").unwrap(); + assert_ne!(a, b); + assert!(!a.ends_with(".tmp")); + assert!(std::fs::read(s.absolute(&a)).unwrap().starts_with(b"csv")); + } + + #[test] + fn ingest_directory_walks_and_rejects_symlinks() { + let td = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let out = td.path().join("out"); + let objd = out.join("objects"); + std::fs::create_dir_all(&objd).unwrap(); + std::fs::write(objd.join("data.csv"), b"date,close\n2024-01-02,10\n").unwrap(); + std::fs::write(out.join("result.json"), b"{\"status\":\"ready\"}").unwrap(); + let (_t, s) = tmp_store("objs3"); + let stored = s.ingest_directory(&out).unwrap(); + assert_eq!(stored.len(), 2); + let names: Vec<&str> = stored.iter().map(|o| o.mount_name.as_str()).collect(); + assert!(names.contains_all(&["objects/data.csv", "result.json"]), "{names:?}"); + // same content re-ingested maps to the same stored object + let again = s.ingest_directory(&out).unwrap(); + for o in &stored { + assert!(again.iter().any(|n| n.hash == o.hash)); + } + // symlink rejection + std::os::unix::fs::symlink( + objd.join("data.csv"), + objd.join("data_link.csv"), + ) + .unwrap(); + assert!(s.ingest_directory(&out).is_err()); + } +} + +trait ContainsAll { + fn contains_all(&self, needles: &[&str]) -> bool; +} +impl ContainsAll for Vec<&str> { + fn contains_all(&self, needles: &[&str]) -> bool { + needles.iter().all(|n| self.iter().any(|m| m.contains(n))) + } +} diff --git a/server/src/util.rs b/server/src/util.rs new file mode 100644 index 0000000..5a82806 --- /dev/null +++ b/server/src/util.rs @@ -0,0 +1,138 @@ +use sha2::{Digest, Sha256}; + +pub fn sha256_hex(data: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(data); + hex::encode(h.finalize()) +} + +/// Synchronous UTC timestamp helper, ISO 8601 with millisecond precision. +pub fn now_iso() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +pub fn plus_hours(hours: i64) -> String { + (chrono::Utc::now() + chrono::Duration::hours(hours)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +pub fn new_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub fn gen_token() -> String { + use rand::RngCore; + let mut buf = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut buf); + hex::encode(buf) +} + +/// Line based unified diff (full document, no hunks) used by the AI draft diff. +pub fn unified_diff(a: &str, b: &str) -> String { + let x: Vec<&str> = a.lines().collect(); + let y: Vec<&str> = b.lines().collect(); + let n = x.len(); + let m = y.len(); + // LCS table + let mut dp = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + dp[i][j] = if x[i] == y[j] { + dp[i + 1][j + 1] + 1 + } else { + dp[i + 1][j].max(dp[i][j + 1]) + }; + } + } + let mut out = String::new(); + let mut ctx = std::collections::VecDeque::<usize>::new(); + let (mut i, mut j) = (0usize, 0usize); + while i < n || j < m { + if i < n && j < m && x[i] == y[j] { + ctx.push_back(i); + if ctx.len() > 2 { + let keep = ctx.pop_front().unwrap(); + out.push_str(&format!(" {}\n", x[keep])); + } + i += 1; + j += 1; + } else { + // entering a change: emit queued context lines first + for k in ctx.drain(..) { + out.push_str(&format!(" {}\n", x[k])); + } + if i < n && (j >= m || dp[i + 1][j] >= dp[i][j + 1]) { + out.push_str(&format!("-{}\n", x[i])); + i += 1; + } else { + out.push_str(&format!("+{}\n", y[j])); + j += 1; + } + } + } + out +} + +/// Deterministic canonical JSON string (recursively sorted keys) for hashing. +pub fn canonical_json(v: &serde_json::Value) -> String { + fn sort(v: &serde_json::Value) -> serde_json::Value { + match v { + serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()), + serde_json::Value::Object(o) => { + let mut keys: Vec<(String, serde_json::Value)> = + o.iter().map(|(k, v)| (k.clone(), sort(v))).collect(); + keys.sort_by(|a, b| a.0.cmp(&b.0)); + serde_json::Value::Object(keys.into_iter().collect()) + } + other => other.clone(), + } + } + sort(v).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn now_iso_is_synchronous_iso8601() { + let t = now_iso(); + let parsed = chrono::DateTime::parse_from_rfc3339(&t).expect("ISO timestamp"); + assert!(parsed.timestamp() > 1_700_000_000); + assert!(t.ends_with('Z')); + } + + #[test] + fn plus_hours_and_tokens() { + let e = chrono::DateTime::parse_from_rfc3339(&plus_hours(1)).unwrap(); + let n = chrono::DateTime::parse_from_rfc3339(&now_iso()).unwrap(); + assert!((e - n).num_minutes() >= 59); + let a = gen_token(); + assert_eq!(a.len(), 64); + assert_ne!(a, gen_token()); + assert_eq!(new_id().len(), 36); + } + + #[test] + fn sha256_is_stable() { + assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + #[test] + fn unified_diff_marks_changes_with_context() { + let a = "x\nkeep\ny"; + let b = "x\nkeep\nz"; + let d = unified_diff(a, b); + assert!(d.contains("-y\n+z\n"), "diff: {d}"); + assert!(d.contains(" keep")); + let same = unified_diff(a, "x\nkeep\ny"); + assert!(!same.contains('+') && !same.contains('-'), "no diff: {same}"); + } + + #[test] + fn canonical_json_sorts_recursively() { + let v: serde_json::Value = + serde_json::from_str(r#"{"b":1,"a":{"y":[3,2],"x":true}}"#).unwrap(); + assert_eq!(canonical_json(&v), r#"{"a":{"x":true,"y":[3,2]},"b":1}"#); + } +} diff --git a/server/src/worker.rs b/server/src/worker.rs new file mode 100644 index 0000000..8e28de5 --- /dev/null +++ b/server/src/worker.rs @@ -0,0 +1,482 @@ +use std::sync::Arc; + +use std::os::unix::fs::PermissionsExt; + +use tokio::process::Command; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +const NONROOT_UID: u64 = 65534; +const MAX_STDOUT: usize = 64_000; +const MAX_STDERR: usize = 16_000; + +#[derive(Debug)] +pub struct ContainerResult { + pub status: Option<i32>, + pub stdout: String, + pub stderr: String, +} + +impl ContainerResult { + pub fn ok(&self) -> bool { + self.status == Some(0) + } +} + +/// Docker run arguments (after `docker`). Isolation contract: +/// no caps, no privilege escalation, non-root uid 65534, read-only root +/// filesystem, small tmpfs, pids/memory/cpu limits, network only when the +/// task requires the data provider; backtest always runs with --network=none. +pub fn docker_args( + network: bool, + mounts: &[(String, String, bool)], + image: &str, + name: &str, + args: &[String], +) -> Vec<String> { + let mut a: Vec<String> = [ + "run", + "--rm", + // Signals reaching the runner CLI must never be proxied into the + // container: on service stop the container is expected to die via the + // app's exact-name cleanup (cancel/timeout/startup), not via a CLI + // relay; a proxying CLI was observed lingering under systemd + // final-sigterm (TimeoutStopSec exhaustion). + "--sig-proxy=false", + "--name", + name, + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + CONCAT_USER, + "--read-only", + "--tmpfs=/tmp:rw,size=256m,mode=1777", + "--pids-limit=128", + "--memory=2g", + "--cpus=2", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + // explicit network choice; only data fetch/search uses the bridge + a.push(if network { "--network=bridge".into() } else { "--network=none".into() }); + for (src, dst, ro) in mounts { + a.push("-v".into()); + a.push(format!("{}:{}{}", src, dst, if *ro { ":ro" } else { "" })); + } + a.push(image.to_string()); + a.extend_from_slice(args); + a +} + +async fn drain_bounded<R>(rd: R, max: usize) -> String +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + let mut buf = Vec::with_capacity(1024); + let mut chunk = [0u8; 8192]; + let mut reader = rd; + loop { + match reader.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + // drain everything, but keep only the tail-relevant bounded prefix + if buf.len() < max { + let take = n.min(max - buf.len()); + buf.extend_from_slice(&chunk[..take]); + } + if buf.len() >= max { + // continue draining the pipe without buffering the rest + let mut sink = [0u8; 8192]; + loop { + match reader.read(&mut sink).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + break; + } + } + Err(_) => break, + } + } + truncate(&String::from_utf8_lossy(&buf), max) +} + +async fn execute_docker(full: &[String], name: &str, timeout_secs: u64) -> AppResult<ContainerResult> { + execute_docker_named("docker", full, name, timeout_secs).await +} + +async fn execute_docker_named( + docker_bin: &str, + full: &[String], + name: &str, + timeout_secs: u64, +) -> AppResult<ContainerResult> { + let mut child = Command::new(docker_bin) + .args(full) + // Kill the runner process when the future that owns it is dropped. A + // graceful server stop drops the jobs task; without this the docker + // CLI child would linger inside the systemd cgroup and block the + // unit stop until TimeoutStopSec forced SIGKILL. Bounded lifetime. + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| AppError::internal(format!("failed to spawn worker container: {e}")))?; + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + let stdout_task = tokio::spawn(drain_bounded(stdout, MAX_STDOUT)); + let stderr_task = tokio::spawn(drain_bounded(stderr, MAX_STDERR)); + + let wait_res = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + child.wait(), + ) + .await; + + let status = match wait_res { + Ok(Ok(st)) => st.code(), + Ok(Err(e)) => { + return Err(AppError::internal(format!("worker process error: {e}")).with_code("runner_failed")) + } + Err(_) => { + // Timeout: kill the specific container by name so user code cannot + // linger; then reap the docker client process. + kill_container(name).await; + let _ = child.wait().await; + return Err(AppError::internal(format!( + "worker container timed out after {timeout_secs}s and was killed: {name}" + )) + .with_code("runner_timeout")); + } + }; + + Ok(ContainerResult { + status, + stdout: stdout_task.await.unwrap_or_default(), + stderr: stderr_task.await.unwrap_or_default(), + }) +} + +/// Kill and remove the named container. Returns true when docker succeeded. +pub async fn cancel_container(name: &str) -> bool { + kill_container(name).await +} + +async fn kill_container(name: &str) -> bool { + Command::new("docker") + .args(["kill", name]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok(); + Command::new("docker") + .args(["rm", "-f", name]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub async fn docker_available() -> bool { + tokio::process::Command::new("docker") + .args(["version", "--format", "ok"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Mounts need world permissions: the container runs as uid 65534 while host +/// ownership is the server user. Best effort only. +fn prepare_mounts(mounts: &[(String, String, bool)]) { + for (src, _dst, ro) in mounts { + let p = std::path::Path::new(src); + if !p.is_dir() { + continue; + } + let mode = if *ro { 0o755 } else { 0o777 }; + let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)); + // Files inside ro input dirs must be world readable; output files are + // written by the container with its umask. + if *ro { + if let Ok(rd) = std::fs::read_dir(p) { + for e in rd.flatten() { + let fmode = if e.path().is_file() { + std::fs::Permissions::from_mode(0o644) + } else { + std::fs::Permissions::from_mode(0o755) + }; + let _ = std::fs::set_permissions(e.path(), fmode); + } + } + } + } +} + +/// Run the worker image with a fixed container name so cancel maps to one +/// specific container id (never a global prune). +pub async fn run_named( + cx: &Arc<AppState>, + network: bool, + mounts: &[(String, String, bool)], + args: &[String], + name: &str, + timeout_secs: u64, +) -> AppResult<ContainerResult> { + let cfg = &cx.cfg; + prepare_mounts(mounts); + let full = docker_args(network, mounts, &cfg.worker_image, name, args); + execute_docker(&full, name, timeout_secs).await +} + +/// Instrument catalog search through the worker container (network enabled). +/// Returns the JSON array printed by `worker.main search`; failures are real +/// errors, never an empty fake success. Results are cached briefly per query so +/// repeated keystrokes reuse the actual provider identity (full item payloads). +pub async fn search_instruments( + cx: &Arc<AppState>, + query: &str, + limit: i64, +) -> AppResult<Vec<serde_json::Value>> { + let query = query.trim().to_string(); + if query.is_empty() { + return Ok(Vec::new()); + } + let limit = if (1..=100).contains(&limit) { limit } else { 50 }; + let cache_key = format!("q={query}&limit={limit}"); + if let Some(items) = catalog_cache_get(&cache_key) { + return Ok(items); + } + let args = vec![ + "python".into(), + "-m".into(), + "worker.main".into(), + "search".into(), + "--query".into(), + query, + "--limit".into(), + limit.to_string(), + ]; + // The worker enforces <=4s per HTTP source; the container including startup + // is bounded here. This is the outer bound for the whole search round trip. + let res = run_named(cx, true, &[], &args, &random_name(), 12).await?; + if !res.ok() { + return Err(AppError::bad("search_failed", truncate(&res.stderr, 500))); + } + // The contract is a single JSON object envelope on stdout: + // {"status":"ready"|"failed","items":[...],"error":{...},...} + // A `failed` status is surfaced as an error, never as an empty success. + let envelope: serde_json::Value = serde_json::from_str(res.stdout.trim()) + .map_err(|e| AppError::internal(format!("invalid search envelope: {e}")))?; + let status = envelope + .get("status") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::internal("invalid search envelope: missing status"))?; + if status != "ready" { + let err = envelope.get("error").cloned().unwrap_or(serde_json::Value::Null); + let code = err + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("search_unavailable"); + let message = err + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("instrument search providers unavailable"); + // provider error codes are dynamic; they ride in the message so the + // HTTP layer keeps static error codes + return Err(AppError::bad( + "search_unavailable", + format!("[{code}] {message}"), + )); + } + let items: Vec<serde_json::Value> = serde_json::from_value( + envelope.get("items").cloned().unwrap_or(serde_json::Value::Null), + ) + .map_err(|e| AppError::internal(format!("invalid search envelope items: {e}")))?; + catalog_cache_put(&cache_key, &items); + Ok(items) +} + +/// Small in-process TTL cache for catalog search results (provider identity). +const CATALOG_TTL_SECS: u64 = 300; +const CATALOG_MAX_ENTRIES: usize = 128; + +fn catalog_cache() -> &'static tokio::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, Vec<serde_json::Value>)>> { + static MAP: std::sync::OnceLock<tokio::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, Vec<serde_json::Value>)>>> = + std::sync::OnceLock::new(); + MAP.get_or_init(|| tokio::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn catalog_cache_get(key: &str) -> Option<Vec<serde_json::Value>> { + // Instant checks must not block behind stdio work; try_lock is fine here. + let map = catalog_cache().try_lock().ok()?; + let (at, items) = map.get(key)?; + if at.elapsed() < std::time::Duration::from_secs(CATALOG_TTL_SECS) { + Some(items.clone()) + } else { + None + } +} + +fn catalog_cache_put(key: &str, items: &[serde_json::Value]) { + if let Ok(mut map) = catalog_cache().try_lock() { + if map.len() >= CATALOG_MAX_ENTRIES { + map.clear(); + } + map.insert(key.to_string(), (std::time::Instant::now(), items.to_vec())); + } +} + +fn random_name() -> String { + use rand::RngCore; + let mut buf = [0u8; 8]; + rand::rngs::OsRng.fill_bytes(&mut buf); + format!("sl-run-{}", hex::encode(buf)) +} + +pub fn truncate(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +const CONCAT_USER: &str = "--user=65534:65534"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_cli_never_proxies_signals_and_names_the_worker() { + // Cause-establishing regression: systemd restart stall happened because + // the runner CLI swallowed SIGTERM while relaying to the container. + // The CLI (and every other container invokation) must use sig-proxy=false. + for network in [true, false] { + let full = docker_args( + network, + &[("/job/output".into(), "/output".into(), false)], + "strategy-lab-worker:local", + "sl-run-t", + &["python".into()], + ); + let s = full.join(" "); + assert!(s.contains("--sig-proxy=false"), "{s}"); + assert!(s.contains("--rm --sig-proxy=false --name sl-run-t")); + assert!(s.contains(format!("--user={NONROOT_UID}:").as_str())); + } + } + + #[tokio::test] + async fn runner_child_is_reaped_when_the_job_future_is_dropped() { + // Focused cause test: a runner child that ignores SIGTERM must not + // outlive its owning future (systemd restart stall cause). We emulate + // with a stub runner in a UNIQUE tempdir that ignores SIGTERM and then + // `exec`s into sleep so the stub PID IS the sleep process: kill_on_drop + // removes it entirely, leaving no grandchild orphan. Wait supervision + // uses exact PID checks (`/proc/<pid>`), never process-name scans. + let td = tempfile::tempdir_in("/tmp/opencode").unwrap(); + let stub_path = td.path().join("runner-stub.sh"); + let pid_file = td.path().join("stub.pid"); + std::fs::write(&stub_path, format!( + "#!/bin/sh\ntrap '' TERM INT\necho $$ > {}\nexec sleep 500\n", pid_file.display() + )).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&stub_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let stub = stub_path.clone(); + let task = tokio::spawn(async move { + let _res = execute_docker_named( + stub.to_str().unwrap(), + &["long-running-stub".into()], + "sl-run-stub", + 5, + ).await; + }); + // wait until the stub published its exact PID + let mut stub_pid: Option<i32> = None; + for _ in 0..50 { + if let Ok(s) = std::fs::read_to_string(&pid_file) { + stub_pid = s.trim().parse().ok(); + } + if stub_pid.is_some() { break; } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + let stub_pid: i32 = match stub_pid { + Some(p) => p, + None => panic!("stub never published its PID; test setup broken"), + }; + let proc_dir = format!("/proc/{stub_pid}"); + // sanity: stub alive; and after `exec` it IS the sleep grandchild + assert!(std::path::Path::new(&proc_dir).exists(), "stub pid {stub_pid} must be alive before the drop"); + let cmd = std::fs::read_to_string(format!("{proc_dir}/cmdline")).unwrap_or_default(); + assert!(cmd.contains("sleep"), "exec replace failed; test would leave an orphan: {cmd:?}"); + task.abort(); // drops the execute_docker future mid-flight -> kill_on_drop -> SIGKILL + // exact-PID supervision: gone == /proc/<pid> has vanished + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut gone = false; + while std::time::Instant::now() < deadline { + if !std::path::Path::new(&proc_dir).exists() { + gone = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!(gone, "runner stub pid {stub_pid} was not reaped when its future was dropped"); + // no grandchild either: the exec'd sleep adopted the same PID, then was + // SIGKILLed with the rest; nothing named-scan was used. + let _ = stub_path; // file removed with the unique tempdir at scope end + } + fn mounts() -> Vec<(String, String, bool)> { + vec![ + ("/job/input".into(), "/input".into(), true), + ("/job/output".into(), "/output".into(), false), + ("/data/objects/x.csv".into(), "/data/x.csv".into(), true), + ] + } + + #[test] + fn backtest_container_flags_are_bounded_isolated_nonroot() { + let args = vec!["python".to_string(), "-m".to_string(), "worker.main".to_string()]; + let full = docker_args(false, &mounts(), "strategy-lab-worker:local", "sl-run-x", &args); + let s = full.join(" "); + assert!(s.contains("strategy-lab-worker:local")); + assert!(s.contains("--network=none"), "runner must not use network: {s}"); + assert!(s.contains(format!("--user={NONROOT_UID}:").as_str()), "{s}"); + assert!(s.contains("--cap-drop=ALL")); + assert!(s.contains("--security-opt=no-new-privileges")); + assert!(s.contains("--read-only")); + assert!(s.contains("--pids-limit=128")); + assert!(s.contains("--memory=2g")); + assert!(s.contains("--cpus=2")); + assert!(s.contains("--tmpfs=/tmp:")); + assert!(!s.contains("/var/run/docker.sock"), "no docker socket in worker: {s}"); + // mount directions preserved + assert!(s.contains("/job/input:/input:ro")); + assert!(s.contains("/job/output:/output")); + assert!(s.contains("/data/objects/x.csv:/data/x.csv:ro")); + } + + #[test] + fn fetch_container_has_network_and_same_isolation() { + let full = docker_args(true, &[], "img", "sl-fetch-1", &["python".into()]); + let s = full.join(" "); + assert!(!s.contains("--network=none")); + assert!(s.contains("--cap-drop=ALL") && s.contains(CONCAT_USER)); + assert!(s.contains("--rm --sig-proxy=false --name sl-fetch-1")); + } + + #[test] + fn truncate_is_char_safe() { + let s = "中文内容"; + let t = truncate(s, 4); + assert!(t.chars().count() <= 4); + assert_eq!(truncate("short", 100), "short"); + } +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/fixtures_synth.py b/tests/fixtures_synth.py new file mode 100644 index 0000000..69b305b --- /dev/null +++ b/tests/fixtures_synth.py @@ -0,0 +1,39 @@ +"""Deterministic synthetic OHLCV fixtures for worker tests. + +Every object here carries ``_synthetic: true`` and must never be used as a +production fallback. Values are simple deterministic series so accounting + assertions in backtest tests can be hand-verified. +""" +import pandas as pd + +_SYNTHETIC = {"_synthetic": True} + + +def synthetic_daily(symbol: str = "SH#600000", start="2024-01-02", days=20, + base=10.0, volume=1_000_000, extra_fields=True) -> pd.DataFrame: + dates = pd.bdate_range(start, periods=days) + rows = [] + price = base + for i, d in enumerate(dates): + o = round(price, 2) + c = round(price * 1.02, 2) if i % 2 == 0 else round(price * 0.98, 2) + h = round(max(o, c) * 1.01, 2) + l = round(min(o, c) * 0.99, 2) + row = { + "date": d.strftime("%Y-%m-%d"), + "symbol": symbol, + "open": o, "high": h, "low": l, "close": c, + "volume": volume + i * 1000, + } + if extra_fields: + # raw provider-style extra fields preserved verbatim + row["amount"] = round((o + h + l + c) / 4 * (volume + i * 1000), 2) + row["turnover"] = round(0.5 + i * 0.01, 3) + rows.append(row) + price = c + df = pd.DataFrame(rows) + df.attrs["synthetic"] = True + return df + + +SYNTH = _SYNTHETIC diff --git a/tests/worker/test_backtest.py b/tests/worker/test_backtest.py new file mode 100644 index 0000000..30f8e46 --- /dev/null +++ b/tests/worker/test_backtest.py @@ -0,0 +1,270 @@ +"""Backtrader runner tests — deterministic synthetic accounting (_synthetic). + +Hand-computable expectations: price series closes 10.20, 10.00, 10.40, 10.20... +commission 0.0003, slippage 0.001 of price, next-bar-open fills. +""" +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fixtures_synth import synthetic_daily, SYNTH # noqa: E402 +from worker.backtest import run_backtest # noqa: E402 + +STRAT = ''' +import backtrader as bt + +class Strategy(bt.Strategy): + params = (("size", 100),) + + def __init__(self): + self.bought = False + + def next(self): + if not self.bought and len(self) > 2: + self.buy(data=self.getdatabyname("SH#600000"), size=self.p.size) + self.bought = True +''' + +STRAT_NAMED = ''' +import backtrader as bt + +class Strategy(bt.Strategy): + def __init__(self): + self.a = self.getdatabyname("SH#600000") + self.b = self.getdatabyname("BJ#TD001") + self.done = False + + def next(self): + if not self.done and len(self) > 2: + self.buy(data=self.a, size=100) + self.done = True +''' + + +def write_dataset(tmp_path): + d = tmp_path / "data" + d.mkdir() + dfa = synthetic_daily("SH#600000", base=10.0, extra_fields=False) + dfb = synthetic_daily("BJ#TD001", base=5.0, extra_fields=False) + for name, df in (("SH#600000", dfa), ("BJ#TD001", dfb)): + p = d / f"{name.lower().replace('#','_')}.csv" + df.to_csv(p, index=False) + manifest = { + "id": "00000000-0000-0000-0000-000000000000", + "hash": "fix-hash", + "_synthetic": True, + "objects": [ + {"instrument": {"symbol": "SH#600000", "market": "cn", "asset_type": "stock"}, + "path": f"{name}" , "object_hash": "h", "row_count": 20, "columns": ["date"]}, + ], + "warnings": [], + } + manifest["objects"] = [ + {"instrument": {"symbol": "SH#600000", "market": "cn", "asset_type": "stock"}, + "path": "sh_600000.csv", "object_hash": "h-a", "row_count": 20}, + {"instrument": {"symbol": "BJ#TD001", "market": "cn", "asset_type": "stock"}, + "path": "bj_td001.csv", "object_hash": "h-b", "row_count": 20}, + ] + return d, manifest + + +def make_request(tmp_path, code, benchmark=None, params=None): + d, manifest = write_dataset(tmp_path) + return { + "_synthetic": True, + "code": code, + "config": { + "capital": 100000.0, "commission": 0.0003, "slippage": 0.001, + "benchmark_symbol": benchmark, + "parameters": params or {}, + }, + "dataset_manifest": manifest, + "data_root": str(d), + }, d + + +def test_synthetic_labeled_runs_next_bar_fill(tmp_path): + req, _ = make_request(tmp_path, STRAT) + res = run_backtest(req) + assert res["engine"]["name"] == "backtrader" + assert res["_synthetic"] is True + assert res["trades"], "expected at least one trade" + t = res["trades"][0] + # signal bar index 3; fills at next-bar open (10.20 * 1.001 slippage), NOT signal-bar close + assert t["symbol"] == "SH#600000" + assert t["side"] == "buy" + assert t["quantity"] == 100 + assert abs(t["price"] - 10.20 * 1.001) < 1e-9 + assert abs(t["commission"] - t["value"] * 0.0003) < 1e-6 + + +def test_equity_cash_accounting(tmp_path): + req, _ = make_request(tmp_path, STRAT) + res = run_backtest(req) + eq = res["equity"] + assert len(eq) == 20 + first = eq[0] + assert first["cash"] == 100000.0 + assert first["equity"] == 100000.0 + last = eq[-1] + filled = 100 * 10.40 * 1.001 + comm = filled * 0.0003 + # after buy: cash reduced; equity = cash + 100 * final close (10.20? compute from fixture) + assert last["cash"] < 100000.0 + expected_equity = last["cash"] + 100 * last["closes"]["SH#600000"] + assert abs(last["equity"] - expected_equity) < 1e-6 + + +def test_metrics_no_nan_nulls_and_fields(tmp_path): + req, _ = make_request(tmp_path, STRAT) + res = run_backtest(req) + m = res["metrics"] + for key in ("total_return", "annual_return", "max_drawdown", "trade_count", "final_equity"): + assert key in m + assert m[key] is None or isinstance(m[key], (int, float)) + if isinstance(m[key], float): + assert not (m[key] != m[key] or m[key] in (float("inf"), float("-inf"))) + # max drawdown is a non-positive fraction or null + assert m["max_drawdown"] is None or m["max_drawdown"] <= 0 + assert m["trade_count"] >= 0 # closed round-trips; buy-alone runs have 0 + assert res["data_manifest_hash"] == "fix-hash" + assert res["elapsed_ms"] >= 0 + assert isinstance(res["logs"], list) and res["logs"] + assert len(res["equity"]) == 20 + + +def test_named_feeds_present_no_cross_lookahead(tmp_path): + req, _ = make_request(tmp_path, STRAT_NAMED) + res = run_backtest(req) + assert res["trades"] + assert res["trades"][0]["symbol"] == "SH#600000" + # the second feed was never consumed for first-symbol pricing + assert all(t["symbol"] != "BJ#TD001" for t in res["trades"]) + + +def test_benchmark_series_included(tmp_path): + req, _ = make_request(tmp_path, STRAT, benchmark="BJ#TD001") + res = run_backtest(req) + assert all("benchmark" in e and e["benchmark"] is not None for e in res["equity"]) + + +def test_missing_strategy_class_fails_clean(tmp_path): + req, _ = make_request(tmp_path, "import backtrader as bt\n\nclass Foo(bt.Strategy):\n pass\n") + res = run_backtest(req) + assert res["status"] == "failed" + assert "Strategy" in res["error"]["message"] + + +def test_syntax_error_fails_clean(tmp_path): + req, _ = make_request(tmp_path, "def broken(:\n") + res = run_backtest(req) + assert res["status"] == "failed" + assert res["error"]["code"] == "strategy_syntax" + + +def test_missing_data_object_fails(tmp_path): + req, _ = make_request(tmp_path, STRAT) + req["dataset_manifest"]["objects"][0]["path"] = "nope.csv" + res = run_backtest(req) + assert res["status"] == "failed" + assert res["error"]["code"] == "data_missing" + + +def test_lookahead_signal_uses_prior_close_not_same_day(tmp_path): + # Strategy trades only on the last bar; a legal next-bar fill must not exist yet. + strat = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def next(self): + if len(self) == 20: + self.buy(data=self.getdatabyname("SH#600000"), size=100) +''' + req, _ = make_request(tmp_path, strat) + res = run_backtest(req) + # order placed on final bar; notification/fill cannot occur after data end -> no trade + assert not res["trades"] + + +def test_strategy_module_imports_visible_in_methods(tmp_path): + # Regression: module-level imports must remain visible inside __init__ and + # next. Transport-level isolation is Docker, NOT restricted Python globals. + strat = ''' +import os +import math +import backtrader as bt + +class Strategy(bt.Strategy): + def __init__(self): + self.foo = os.sep # os imported at module level, used in a method + + def next(self): + c = self.getdatabyname("SH#600000").close + if len(self) > 2 and abs(math.copysign(1.0, c[0] - c[-1])) == 1.0: + self.foo = math.sqrt(abs(c[0])) +''' + req, _ = make_request(tmp_path, strat) + res = run_backtest(req) + assert res["status"] == "succeeded", res.get("error") + + +def test_strategy_genuine_nameerror_still_fails(tmp_path): + # a genuinely undefined name must still surface honestly as runtime_error + strat = ''' +import backtrader as bt + +class Strategy(bt.Strategy): + def next(self): + undefined_variable_xyz.bar() +''' + req, _ = make_request(tmp_path, strat) + res = run_backtest(req) + assert res["status"] == "failed" + assert res["error"]["code"] == "runtime_error" + + + +def test_fill_value_is_executed_turnover_not_cost_basis(tmp_path): + """RED/GREEN accounting regression: fills must record actual turnover + abs(ex.size * ex.price). Backtrader's ex.value for SELL orders reports the + position cost basis, NOT sale proceeds (real QA showed identical 659.66 for + a real buy and sell at different prices). Equity/cash are not affected: + broker cash and equity already use executed price and commission.""" + strat = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): + self.done = False + def next(self): + if len(self) == 3: + self.buy(data=self.getdatabyname("SH#600000"), size=100) + elif len(self) == 10: + self.sell(data=self.getdatabyname("SH#600000"), size=100) + self.done = True +''' + req, _ = make_request(tmp_path, strat) + res = run_backtest(req) + assert res["status"] == "succeeded", res.get("error") + sides = [(t["side"], t) for t in res["trades"]] + buys = [t for s, t in sides if s == "buy"] + sells = [t for s, t in sides if s == "sell"] + assert buys and sells, f"expected a buy AND a sell fill, got {sides}" + # discount-adjusted commission is charged on the turnover, not on cost basis + for t in res["trades"]: + assert abs(t["value"] - abs(t["quantity"] * t["price"])) < 1e-9, \ + f"fill value must be quantity*price turnover: {t}" + assert abs(t["commission"] - t["value"] * 0.0003) < 1e-6, \ + f"commission follows turnover: {t}" + # honest check: buy and sell execute at different prices so their turnover + # differs (cost-basis bug reported identical values for both sides) + assert buys and sells + assert abs(buys[0]["price"] - sells[0]["price"]) > 1e-9, \ + f"buy/sell fill prices must differ: {buys[0]['price']} vs {sells[0]['price']}" + assert abs(buys[0]["value"] - sells[0]["value"]) > 1e-9, \ + "distinct prices must yield distinct fill values (cost-basis bug regression)" + # trade_count remains closed round trips, not fills + assert res["metrics"]["trade_count"] == 1 diff --git a/tests/worker/test_data.py b/tests/worker/test_data.py new file mode 100644 index 0000000..d6eeae1 --- /dev/null +++ b/tests/worker/test_data.py @@ -0,0 +1,336 @@ +"""Data adapter / instrument-search tests — mocked provider transport, no network.""" +import json +import sys +from pathlib import Path + +import pandas as pd +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import worker.data as data # noqa: E402 +from fixtures_synth import synthetic_daily, SYNTH # noqa: E402 + + +# ---- live-captured provider reply shapes (see docs/search-fix.md probes) ---- + +def _em_payload(rows): + return {"QuotationCodeTable": {"Data": rows, "Status": 0, "Message": "成功", + "TotalCount": len(rows)}, + } + + +def _em_row(code, name, classify, mktnum, sec_type_name): + return {"Code": code, "Name": name, "Classify": classify, "MktNum": mktnum, + "SecurityTypeName": sec_type_name, "MarketType": mktnum, + "QuoteID": f"{mktnum}.{code}"} + + +def test_search_stock_real_shape(monkeypatch): + """q=600000 → real-shape eastmoney AStock row classifies as stock, identity validated.""" + body = _em_payload([_em_row("600000", "浦发银行", "AStock", "1", "沪A")]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: body) + + def boom(*a, **k): + raise RuntimeError("tencent not called in this test") + monkeypatch.setattr(data, "_http_text", boom) + res = data.search_instruments("600000") + assert res["status"] == "ready" + item = res["items"][0] + assert item["symbol"] == "600000" and item["name"] == "浦发银行" + assert item["asset_type"] == "stock" and item["market"] == "cn" + assert item["canonical_symbol"] == "SH#600000" + assert item["source"] == "eastmoney" + + +def test_search_etf_real_shape_with_tencent_confirmation(monkeypatch): + """q=510300: eastmoney Classify=Fund only counts as ETF when tencent confirms.""" + em = _em_payload([_em_row("510300", "沪深300ETF华泰柏瑞", "Fund", "1", "基金")]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: em) + tx = 'v_hint="sh~510300~\\u6caa\\u6df1300ETF\\u534e\\u6cf0\\u67cf\\u745e~hs300etfhtbr~ETF"' + monkeypatch.setattr(data, "_http_text", lambda *a, **k: tx) + res = data.search_instruments("510300") + etfs = [i for i in res["items"] if i["symbol"] == "510300"] + assert etfs and all(i["asset_type"] == "etf" for i in etfs) + assert {i["source"] for i in etfs} == {"eastmoney", "tencent"} + assert any(i["canonical_symbol"] == "SH#510300" for i in etfs) + + +def test_search_etf_ambiguous_fund_excluded_without_tencent_confirmation(monkeypatch): + """Eastmoney Classify=Fund alone cannot distinguish ETF from LOF (probed): + without a tencent ETF confirmation the item must not surface, ever.""" + em = _em_payload([_em_row("160706", "沪深300LOF", "Fund", "0", "基金")]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: em) + monkeypatch.setattr( + data, "_http_text", + lambda *a, **k: 'v_hint="sz~160706~\\u6caa\\u6df1300LOF~hs300lof~LOF"') + res = data.search_instruments("160706") + assert res["status"] == "ready" + assert res["items"] == [] # LOF must never surface as an etf + + +def test_search_lof_not_present_when_eastmoney_only(monkeypatch): + em = _em_payload([_em_row("160706", "沪深300LOF", "Fund", "0", "基金")]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: em) + + def boom(*a, **k): + raise RuntimeError("tencent down") + monkeypatch.setattr(data, "_http_text", boom) + res = data.search_instruments("160706") + # tencent failed but eastmoney succeeded → still ready; LOF suppressed + assert res["status"] == "ready" + assert res["items"] == [] + assert res["providers"]["tencent"].startswith("failed") + + +def test_search_index_from_chinese_query_and_class_not_code(monkeypatch): + """q=沪深300 → indexes come from provider class (Classify=Index / ZS tag); + a numeric code like 000300 is NEVER guessed to be an index by shape.""" + em = _em_payload([ + _em_row("000300", "沪深300", "Index", "1", "指数"), + _em_row("399300", "沪深300", "Index", "0", "指数"), + _em_row("510300", "沪深300ETF华泰柏瑞", "Fund", "1", "基金"), + ]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: em) + tx = ('v_hint="sh~000300~\\u6caa\\u6df1300~hs300~ZS^sz~399300~\\u6caa\\u6df1300~hs300~ZS' + '^sh~510300~\\u6caa\\u6df1300ETF\\u534e\\u6cf0\\u67cf\\u745e~hs300etfhtbr~ETF^' + 'sz~160706~\\u6caa\\u6df1300LOF~hs300lof~LOF"') + monkeypatch.setattr(data, "_http_text", lambda *a, **k: tx) + res = data.search_instruments("沪深300") + by_key = {(i["canonical_symbol"], i["asset_type"], i["source"]) for i in res["items"]} + assert ("SH#000300", "index", "eastmoney") in by_key + assert ("SZ#399300", "index", "eastmoney") in by_key + assert ("SH#000300", "index", "tencent") in by_key + assert all(i["asset_type"] in ("stock", "etf", "index") for i in res["items"]) + assert not any("160706" == i["symbol"] and i["asset_type"] == "etf" for i in res["items"]) + + +def test_search_numeric_code_not_auto_index(monkeypatch): + """000300 with a provider stock-ish class (hypothetical) must be honored as + whatever the provider states — classification is never inferred.""" + em = _em_payload([_em_row("000300", "某标的", "AStock", "0", "深A")]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: em) + monkeypatch.setattr( + data, "_http_text", + lambda *a, **k: 'v_hint="sz~000300~\\u67d0\\u6807\\u7684~m?~GP-A"') + res = data.search_instruments("000300") + assert all(i["asset_type"] == "stock" for i in res["items"]) + + +def test_search_excludes_unsupported_classes_real_rows(monkeypatch): + """Bond/HK/OTC/LOF rows and tencent LOF tags are dropped, no guessing.""" + em = _em_payload([ + _em_row("160706", "20天津38", "Bond", "1", "债券"), + _em_row("00700", "腾讯控股", "HK", "116", "港股"), + _em_row("160706", "嘉实沪深300ETF联接A", "OTCFUND", "0", "场外基金"), + _em_row("600000", "浦发银行", "AStock", "1", "沪A"), + ]) + monkeypatch.setattr(data, "_http_json", lambda *a, **k: em) + monkeypatch.setattr( + data, "_http_text", + lambda *a, **k: 'v_hint="sz~160706~\\u6caa\\u6df1300LOF~hs300lof~LOF"') + res = data.search_instruments("x") + assert res["status"] == "ready" + symbols = [i["symbol"] for i in res["items"]] + assert symbols == ["600000"] # bond/HK/OTCFUND dropped; tencent LOF tag excluded and eastmoney Fund lacks ETF confirmation + assert res["items"][0]["asset_type"] == "stock" + + +def test_search_chinese_name_query_matches_provider(monkeypatch): + em = _em_payload([_em_row("600000", "浦发银行", "AStock", "1", "沪A")]) + captured = {} + monkeypatch.setattr(data, "_http_json", lambda url, params, **k: captured.update(params) or em) + + def boom(*a, **k): + raise RuntimeError("tencent down") + monkeypatch.setattr(data, "_http_text", boom) + res = data.search_instruments("浦发银行") + assert captured["input"] == "浦发银行" # provider receives the raw CJK query + assert res["items"][0]["name"] == "浦发银行" + + +def test_search_provider_failure_is_explicit_failed_status(monkeypatch): + def boom(*a, **k): + raise RuntimeError("network down") + monkeypatch.setattr(data, "_http_json", boom) + monkeypatch.setattr(data, "_http_text", boom) + res = data.search_instruments("600000") + assert res["status"] == "failed" + assert res["items"] == [] + assert res["error"]["code"] == "providers_unavailable" + assert "eastmoney" in res["error"]["message"] + assert "tencent" in res["error"]["message"] + + +def test_search_provider_timeout_is_explicit_failed_status(monkeypatch): + import requests as _req + def timeout(*a, **k): + raise _req.exceptions.Timeout("read timed out") + monkeypatch.setattr(data, "_http_json", timeout) + monkeypatch.setattr(data, "_http_text", timeout) + res = data.search_instruments("600000") + assert res["status"] == "failed" + assert "Timeout" in res["error"]["message"] + + +def test_search_partial_failure_is_ready_with_warning(monkeypatch): + def boom(*a, **k): + raise RuntimeError("tencent down") + monkeypatch.setattr( + data, "_http_json", + lambda *a, **k: _em_payload([_em_row("600000", "浦发银行", "AStock", "1", "沪A")])) + monkeypatch.setattr(data, "_http_text", boom) + res = data.search_instruments("600000") + assert res["status"] == "ready" + assert res["providers"]["tencent"].startswith("failed") + assert res["items"][0]["symbol"] == "600000" + + +def test_suggest_limits_are_bounded(): + import inspect + src = inspect.getsource(data) + assert "SUGGEST_TIMEOUT_SECS = 4.0" in src # per-source timeout applies to the actual calls + # the catalog paths are gone: every query must be a bounded provider call + assert "_stock_catalog" not in src + assert "_catalog_frame" not in src + + +def test_limit_is_passed_to_provider_bounded(monkeypatch): + seen = {} + rows = [_em_row(str(600000 + i), f"n{i}", "AStock", "1", "沪A") for i in range(8)] + monkeypatch.setattr(data, "_http_json", + lambda url, params, **k: seen.update(params) or _em_payload(rows[:params["count"]])) + monkeypatch.setattr(data, "_http_text", lambda *a, **k: "") + res = data.search_instruments("600", limit=4) + assert seen["count"] == 5 # bounded floor 5, well under the old full catalogs + assert len(res["items"]) <= 4 + + +def test_fetch_rejects_unknown_asset_type(): + with pytest.raises(data.DataError): + data.fetch_source({"symbol": "X#1", "market": "cn", "asset_type": "crypto"}, + "2024-01-01", "2024-02-01", "daily", "none", ["open", "close"]) + + +# ---- sina listed-ETF adapter (recovery-01: eastmoney down, tencent has no +# ETF adapter; live evidence in docs/recovery-01-plan.md). The stock-like frame +# below is a SYNTHETIC test fixture mimicking the live-captured sina reply shape. ---- + +def _sina_synth_frame(): + """Synthetic fixture in the exact live-captured shape of + ak.fund_etf_hist_sina(symbol='sz159399') (recovered live 2026-09-17: + 381 rows 2025-02-27..2026-09-16, columns date/open/.../amount/postVol/postAmt).""" + rows = [ + {"date": "2025-12-30", "open": 1.001, "high": 1.006, "low": 0.999, + "close": 1.006, "volume": 695982432, "amount": 696910071.0, + "postVol": float("nan"), "postAmt": float("nan")}, + {"date": "2026-01-02", "open": 1.007, "high": 1.008, "low": 0.994, + "close": 0.997, "volume": 505082425, "amount": 506417752.0, + "postVol": float("nan"), "postAmt": float("nan")}, + {"date": "2026-09-16", "open": 1.001, "high": 1.001, "low": 0.982, + "close": 0.992, "volume": 144526400, "amount": 142914764.0, + "postVol": 300.0, "postAmt": 298.0}, + ] + df = pd.DataFrame(rows) + df.attrs["synthetic"] = True + return df + + +def test_sina_etf_serves_159399_with_honest_provenance(monkeypatch): + inst = {"symbol": "159399", "market": "cn", "asset_type": "etf"} + monkeypatch.setattr(data, "ak", + type("M", (), {"fund_etf_hist_sina": + staticmethod(lambda **kw: _sina_synth_frame())})()) + res = data.fetch_source(inst, "2025-12-31", "2026-09-17", "daily", "none") + df, endpoint, params = res + assert res.provider == "sina" + assert endpoint == "fund_etf_hist_sina" + assert params == {"symbol": "sz159399"} + assert set(df["symbol"]) == {"SZ#159399"} + dates = df["date"].tolist() + assert dates[0] >= "2025-12-31" and dates[-1] <= "2026-09-17" + assert "2026-09-17" not in dates # no fabricated end-date bar + warns = " ".join(df.attrs.get("source_warnings", [])) + assert "股" in warns and "unadjusted" in warns + assert "159399" not in warns # numbers only, identity preserved + + +def test_sina_rejects_adjustment_and_range_semantics(monkeypatch): + inst = {"symbol": "159399", "market": "cn", "asset_type": "etf"} + monkeypatch.setattr(data, "ak", + type("M", (), {"fund_etf_hist_sina": + staticmethod(lambda **kw: _sina_synth_frame())})()) + with pytest.raises(data.DataError) as e: + data.fetch_source(inst, "2025-12-31", "2026-09-17", "daily", "qfq", source="sina") + assert e.value.code == "unsupported_adjustment" + + +def test_sina_rejects_stock_and_index(): + for asset in ("stock", "index"): + inst = {"symbol": "600000" if asset == "stock" else "000300", + "market": "cn", "asset_type": asset} + with pytest.raises(data.DataError) as e: + data.fetch_source(inst, "2025-12-31", "2026-09-17", "daily", "none", + source="sina") + assert e.value.code == "source_unavailable" + + +def test_auto_etf_falls_back_from_eastmoney_to_sina_honestly(monkeypatch): + inst = {"symbol": "159399", "market": "cn", "asset_type": "etf"} + + def dis(*a, **k): + raise ConnectionError("RemoteDisconnected('Remote end closed connection')") + monkeypatch.setattr(data, "ak", + type("M", (), {"fund_etf_hist_em": staticmethod(dis), + "fund_etf_hist_sina": + staticmethod(lambda **kw: _sina_synth_frame())})()) + res, warns = data.fetch_source_with_warnings( + inst, "2025-12-31", "2026-09-17", "daily", "none", source="auto") + assert res.provider == "sina" # actual serving provider, never disguised + joined = " ".join(warns) + assert "provider_fallback" in joined and "eastmoney" in joined and "sina" in joined + assert set(res.df["symbol"]) == {"SZ#159399"} + + +def test_auto_etf_fails_when_no_provider_is_viable(monkeypatch): + inst = {"symbol": "159399", "market": "cn", "asset_type": "etf"} + + def dis(*a, **k): + raise ConnectionError("RemoteDisconnected") + monkeypatch.setattr(data, "ak", + type("M", (), {"fund_etf_hist_em": staticmethod(dis), + "fund_etf_hist_sina": staticmethod(dis)})()) + with pytest.raises(data.DataError) as e: + data.fetch_source(inst, "2025-12-31", "2026-09-17", "daily", "none", source="auto") + assert e.value.code == "provider_unavailable" + assert "eastmoney" in str(e.value) and "sina" in str(e.value) + + +def test_tencent_etf_rejection_message_unchanged(): + inst = {"symbol": "159399", "market": "cn", "asset_type": "etf"} + with pytest.raises(data.DataError) as e: + data.fetch_source(inst, "2025-12-31", "2026-09-17", "daily", "none", + source="tencent") + assert e.value.code == "source_unavailable" + assert "tencent source has no listed-ETF daily adapter" in str(e.value) + + +def test_sina_registered_in_explicit_source_surface(): + assert "sina" in data.SUPPORTED_SOURCES + + +def test_fetch_columns_subset_requested(): + req = {"symbol": "600000", "market": "cn", "asset_type": "stock"} + called = {} + + def fake_hist(symbol, **kw): + called["params"] = kw + return synthetic_daily("SH#600000") + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(data, "ak", type("M", (), {"stock_zh_a_hist": staticmethod(fake_hist)})()) + df, endpoint, params = data.fetch_source(req, "2024-01-01", "2024-02-01", + "daily", "none", ["open", "close", "volume"]) + assert endpoint == "stock_zh_a_hist" + assert all(c in df.columns for c in ("date", "symbol", "open", "close", "volume")) + monkeypatch.undo() diff --git a/tests/worker/test_fetch_cli.py b/tests/worker/test_fetch_cli.py new file mode 100644 index 0000000..11ddd11 --- /dev/null +++ b/tests/worker/test_fetch_cli.py @@ -0,0 +1,144 @@ +"""Worker CLI fetch tests: fallback path, source labeling, raw retention — no network.""" +import json +import sys +from pathlib import Path + +import pandas as pd +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from worker import data as data_mod, main as main_mod # noqa: E402 +from fixtures_synth import synthetic_daily # noqa: E402 + + +def _fake_tx_frame(): + df = pd.DataFrame({ + "date": ["2024-06-27", "2024-06-28"], + "open": [8.21, 8.22], "close": [8.22, 8.23], "high": [8.24, 8.25], + "low": [8.20, 8.21], "volume": [21000000, 22066700], + }) + return df + + +def test_auto_falls_back_to_tencent_with_honest_warning(monkeypatch, tmp_path): + def em_fail(*a, **k): + raise data_mod.DataError("provider_unavailable", "another connect error") + monkeypatch.setattr(data_mod, "_fetch_eastmoney", em_fail) + + def tx_ok(instrument, start, end, frequency, adjustment): + df = _fake_tx_frame().assign(symbol="SH#600000") + return data_mod.FetchResult(df, "stock_zh_a_hist_tx", + {"symbol": "sh600000"}, df, "tencent") + monkeypatch.setattr(data_mod, "_fetch_tencent", tx_ok) + res, warnings = data_mod.fetch_source_with_warnings( + {"symbol": "600000", "market": "cn", "asset_type": "stock"}, + "2024-01-01", "2024-06-30", "daily", "none") + assert res.provider == "tencent" + assert res[2]["symbol"] == "sh600000" # SAME symbol, no substitution + assert any("provider_fallback" in x and "eastmoney" in x for x in warnings) + + +def test_exact_source_error_not_fallback(monkeypatch): + monkeypatch.setattr(data_mod, "_fetch_eastmoney", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("timed out"))) + with pytest.raises(RuntimeError): + data_mod.fetch_source({"symbol": "600000", "market": "cn", + "asset_type": "stock"}, "2024-01-01", "2024-02-01", + "daily", "none", source="eastmoney") + + +def test_tencent_rejects_etf(monkeypatch): + with pytest.raises(data_mod.DataError): + data_mod.fetch_source({"symbol": "510300", "market": "cn", "asset_type": "etf"}, + "2024-01-01", "2024-06-30", "daily", "none", source="tencent") + + +def test_bad_source_rejected(): + with pytest.raises(data_mod.DataError): + data_mod.fetch_source({"symbol": "600000", "market": "cn", "asset_type": "stock"}, + "2024-01-01", "2024-02-01", "daily", "none", source="sina") + + +def test_fetch_cli_records_provider_and_raw(tmp_path, monkeypatch): + def fake_fetch(inst, start, end, frequency, adjustment, fields=None, source="auto"): + df = _fake_tx_frame().assign(symbol="SH#600000", 成交额=1.0) + import akshare as _ak + return (data_mod.FetchResult(df, "stock_zh_a_hist_tx", {"symbol": "sh600000"}, + df, "tencent"), + ["provider_fallback: eastmoney attempt failed; served by tencent"]) + + monkeypatch.setattr(data_mod, "fetch_source_with_warnings", fake_fetch) + req = tmp_path / "request.json" + req.write_text(json.dumps({ + "instruments": [{"symbol": "600000", "market": "cn", "asset_type": "stock", + "name": "浦发银行"}], + "start_date": "2024-06-01", "end_date": "2024-06-30", + "frequency": "daily", "adjustment": "none", + "fields": ["open", "high", "low", "close", "volume", "amount"], + "source": "auto"})) + out = tmp_path / "output" + rc = main_mod.run_fetch(str(req), str(out)) + assert rc == 0 + result = json.loads((out / "result.json").read_text()) + assert result["status"] == "ready" + o = result["manifest"]["objects"][0] + # provider is the ACTUAL serving source in manifest + cache identity + assert o["provider"] == "tencent" + assert o["endpoint"] == "stock_zh_a_hist_tx" + assert any("provider_fallback" in w for w in result["warnings"] + o["warnings"]) + # raw provider response retained BEFORE normalized transform, as an + # immutable content-addressed object next to the normalized CSV + raw_name = f"raw_600000_{o['raw_object_hash'][:12]}.json" + raw = json.loads((out / "objects" / raw_name).read_text()) + assert raw["endpoint"] == "stock_zh_a_hist_tx" and raw["data"] + assert len(o["raw_object_hash"]) == 64 + # normalized object separate from raw + norm = pd.read_csv(out / o["path"]) + assert {"open", "high", "low", "close", "volume"} <= set(norm.columns) + assert o["object_hash"] != o["raw_object_hash"] + + +def test_fetch_cli_error_path_writes_result(tmp_path): + req = tmp_path / "request.json" + req.write_text(json.dumps({"instruments": [], "frequency": "daily", + "adjustment": "none", "start_date": "2024-01-01", + "end_date": "2024-02-01"})) + out = tmp_path / "output" + rc = main_mod.run_fetch(str(req), str(out)) + assert rc == 1 + r = json.loads((out / "result.json").read_text()) + assert r["status"] == "failed" and r["errors"] + + +def test_search_cli_envelope_contract(monkeypatch): + """worker.main search prints an explicit JSON envelope; failed status is a + real failure (non-zero exit), never an empty success.""" + def boom(q, limit): + return {"source": "provider_suggest", "status": "failed", "items": [], + "error": {"code": "providers_unavailable", "message": "all providers failed"}} + monkeypatch.setattr(data_mod, "search_instruments", boom) + assert main_mod.run_search("600000", 50) == 1 + + envelope_out: dict = {} + + class _C: + def write(self, s): + envelope_out["buf"] = envelope_out.get("buf", "") + s + import sys as _sys + orig = _sys.stdout + _sys.stdout = _C() # type: ignore[assignment] + try: + monkeypatch.setattr(data_mod, "search_instruments", lambda q, limit: { + "source": "provider_suggest", "status": "ready", + "items": [{"symbol": "600000", "name": "浦发银行", "asset_type": "stock", + "market": "cn", "canonical_symbol": "SH#600000", + "currency": "CNY", "source": "eastmoney"}], + "error": None}) + assert main_mod.run_search("600000", 50) == 0 + finally: + _sys.stdout = orig + env = json.loads(envelope_out["buf"]) + assert env["status"] == "ready" + assert env["items"][0]["source"] == "eastmoney" # per-item provenance preserved diff --git a/tests/worker/test_normalize.py b/tests/worker/test_normalize.py new file mode 100644 index 0000000..a4c9a38 --- /dev/null +++ b/tests/worker/test_normalize.py @@ -0,0 +1,92 @@ +"""Normalization tests — synthetic fixtures only (_synthetic).""" +import math +import sys +import types +from pathlib import Path + +import pandas as pd +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fixtures_synth import synthetic_daily, SYNTH # noqa: E402 +from worker.normalize import normalize_frame, build_manifest_entry, \ + compute_object_hash, coverage_summary, NormalizeError # noqa: E402 + + +def test_synthetic_marker_present(): + assert SYNTH["_synthetic"] is True + df = synthetic_daily() + assert df.attrs["synthetic"] is True + + +def test_normalize_keeps_raw_extra_fields_and_precision(): + df = synthetic_daily() + out = normalize_frame(df, symbol="SH#600000") + assert "_synthetic" not in out.columns + # canonical columns present + for col in ("date", "open", "high", "low", "close", "volume"): + assert col in out.columns + # raw extra fields preserved + assert "amount" in out.columns and "turnover" in out.columns + # precision kept, no rounding drift + assert out["close"].iloc[0] == df["close"].iloc[0] + assert out["volume"].iloc[3] == df["volume"].iloc[3] + # symbol never substituted across instruments + assert out["symbol"].iloc[5] == "SH#600000" + assert out["date"].notna().all() + + +def test_normalize_rejects_missing_ohlcv(): + df = synthetic_daily() + df.loc[4, "close"] = math.nan + with pytest.raises(NormalizeError): + normalize_frame(df, symbol="SH#600000") + + +def test_normalize_rejects_bad_dates(): + df = synthetic_daily() + df["date"] = df["date"].astype(object) + df.loc[2, "date"] = "not-a-date" + with pytest.raises(NormalizeError): + normalize_frame(df, symbol="SH#600000") + + +def test_normalize_sorts_and_dedups_dates(): + df = synthetic_daily() + df = pd.concat([df.iloc[5:], df.iloc[:5]]).reset_index(drop=True) + out = normalize_frame(df, symbol="SH#600000") + assert list(out["date"]) == sorted(out["date"]) + assert len(out) == 20 + + +def test_object_hash_stable_and_content_sensitive(): + df = synthetic_daily() + h1 = compute_object_hash(df) + h2 = compute_object_hash(normalize_frame(df, symbol="SH#600000")) + assert h1 == h2 and len(h1) == 64 + df2 = synthetic_daily(base=11.0) + assert compute_object_hash(df2) != h1 + + +def test_coverage_summary_and_manifest_entry(): + df = synthetic_daily(days=10, extra_fields=False) + cov = coverage_summary(df) + assert cov["actual_start"] == df["date"].iloc[0] + entry = build_manifest_entry( + df, instrument={"symbol": "SH#600000", "market": "cn", "asset_type": "stock"}, + provider="akshare", endpoint="stock_zh_a_hist", params={"period": "daily"}, + adjustment="none", requested_start="2024-01-01", requested_end="2024-12-31", + raw_object_hash="rawhash", warnings=[], + schema_version="1", normalization_version="1", + path="objects/ab/cd.json", fetched_at="2026-09-16T00:00:00Z", + akshare_version="1.18.95", + ) + assert entry["immutable"] is True + assert entry["row_count"] == 10 + assert entry["adjustment"] == "none" + assert entry["actual_start"] <= entry["actual_end"] + assert "potential symbol substitution" not in entry["warnings"] + for internal in ("path",): + assert internal in entry # server strips internal-only fields downstream diff --git a/tests/worker/test_rules_regression.py b/tests/worker/test_rules_regression.py new file mode 100644 index 0000000..d59ca0a --- /dev/null +++ b/tests/worker/test_rules_regression.py @@ -0,0 +1,201 @@ +"""Regression tests: broker rules, benchmark normalization, drawdown zero.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fixtures_synth import synthetic_daily # noqa: E402 +from worker.backtest import run_backtest # noqa: E402 + + +def build_manifest(tmp_path, specs): + d = tmp_path / "data" + d.mkdir(exist_ok=True) + objects = [] + for sym, code, asset, base in specs: + df = synthetic_daily(sym, base=base, extra_fields=False) # code param unused + slug = sym.lower().replace("#", "_") + path = f"{slug}.csv" + df.to_csv(d / path, index=False) + objects.append({"instrument": {"symbol": sym, "market": "cn", "asset_type": asset}, + "path": path, "row_count": len(df)}) + manifest = {"hash": "fix-hash", "_synthetic": True, "objects": objects, "warnings": []} + return d, manifest + + +def make_request(tmp_path, code, specs, benchmark=None, params=None): + d, manifest = build_manifest(tmp_path, specs) + return { + "_synthetic": True, + "code": code, + "config": {"capital": 100000.0, "commission": 0.0003, "slippage": 0.001, + "benchmark_symbol": benchmark, "parameters": params or {}}, + "dataset_manifest": manifest, + "data_root": str(d), + } + + +BUY_STOCK_ONLY = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): self.done = False + def next(self): + if not self.done and len(self) > 2: + self.buy(data=self.getdatabyname("SH#600000"), size=250) + self.done = True +''' + +BUY_INDEX = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): self.done = False + def next(self): + if not self.done and len(self) > 2: + self.buy(data=self.getdatabyname("SH000300"), size=100) + self.done = True +''' + +BUY_BM = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): self.done = False + def next(self): + if not self.done and len(self) > 2: + self.buy(data=self.getdatabyname("SH#600000"), size=100) + self.done = True +''' + +SELL_OPEN = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): self.state = 0 + def next(self): + if self.state == 0 and len(self) > 2: + self.buy(data=self.getdatabyname("SH#600000"), size=200) + self.state = 1 + elif self.state == 1: + self.sell(data=self.getdatabyname("SH#600000"), size=150) + self.state = 2 +''' + +SHORT = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): self.state = 0 + def next(self): + if self.state == 0 and len(self) > 2: + self.buy(data=self.getdatabyname("SH#600000"), size=100) + self.state = 1 + elif self.state == 1: + self.sell(data=self.getdatabyname("SH#600000"), size=400) + self.state = 2 +''' + +BUY250_TEST = ''' +import backtrader as bt +class Strategy(bt.Strategy): + def __init__(self): self.done = False + def next(self): + if not self.done and len(self) > 2: + self.buy(data=self.getdatabyname("SH#600000"), size=250) + self.done = True +''' + + +def test_zero_drawdown_is_zero_not_null(tmp_path): + req = make_request(tmp_path, SELL_OPEN, [("SH#600000", "sh_600000", "stock", 10.0)]) + res = run_backtest(req) + assert res["status"] == "succeeded" + m = res["metrics"] + req2 = make_request(tmp_path / "flat" if False else tmp_path, + 'import backtrader as bt\nclass Strategy(bt.Strategy):\n pass\n', + [("SH#600000", "sh_600000", "stock", 10.0)]) + res2 = run_backtest(req2) + assert res2["metrics"]["max_drawdown"] == 0.0 + assert res2["metrics"]["total_return"] == 0.0 + assert res2["metrics"]["trade_count"] == 0 + # initial capital and per-bar positions recorded + assert res2["initial_cash"] == 100000.0 + assert all("positions" in e for e in res2["equity"]) + + +def test_benchmark_normalized_to_initial_capital(tmp_path): + # stock 10 -> wobbles; benchmark BJ feed base differs so raw price != equity scale + req = make_request( + tmp_path, SELL_OPEN, + [("SH#600000", "sh_600000", "stock", 10.0), + ("SH000300", "sh000300", "index", 3800.0)], + benchmark="SH000300") + res = run_backtest(req) + assert all(e["benchmark"] is not None for e in res["equity"]) + # raw price never mixed into the equity scale + assert res["equity"][0]["benchmark"] == res["initial_cash"] + assert res["equity"][-1]["closes"]["SH000300"] > 3000 # raw close available separately + assert any("normalized to initial capital" in w for w in res["warnings"]) + assert res["orders"] + + +def test_index_order_rejected_broker_level(tmp_path): + specs = [("SH#600000", "sh_600000", "stock", 10.0), + ("SH000300", "sh000300", "index", 3800.0)] + req = make_request(tmp_path, BUY_INDEX, specs) + res = run_backtest(req) + assert res["status"] == "succeeded" + index_fills = [t for t in res["trades"] if t["symbol"] == "SH000300"] + assert not index_fills + assert not [t for t in res["trades"] if t["symbol"] == "SH#600000"] # strategy only bought index + user_orders = [o for o in res["orders"] if o.get("date")] # real order notifications + rejected = [o for o in user_orders if o["symbol"] == "SH000300"] + assert rejected and rejected[-1]["order_state"] == "Rejected" + rejects = [o for o in res["orders"] if o.get("order_state") == "rejected"] + assert rejects and "nontradable" in rejects[0]["reject_reason"] + assert any("nontradable" in w for w in res["warnings"]) + + +def test_index_only_as_benchmark_accepted(tmp_path): + specs = [("SH#600000", "sh_600000", "stock", 10.0), + ("SH000300", "sh000300", "index", 3800.0)] + req = make_request(tmp_path, BUY_BM, specs, benchmark="SH000300") + res = run_backtest(req) + assert res["status"] == "succeeded" + assert res["trades"][0]["symbol"] == "SH#600000" + assert res["equity"][-1]["benchmark"] is not None + + +def test_lot_size_rounded_down_to_100(tmp_path): + req = make_request(tmp_path, BUY250_TEST, [("SH#600000", "sh_600000", "stock", 10.0)]) + res = run_backtest(req) + assert res["status"] == "succeeded" + assert res["trades"], "rejection would leave no fill; rounding should keep 200" + q = res["trades"][0]["quantity"] + assert q == 200, f"expected 250 -> 200 (2 lots), got {q}" + + +def test_no_naked_short_rejected(tmp_path): + req = make_request(tmp_path, SHORT, [("SH#600000", "sh_600000", "stock", 10.0)]) + res = run_backtest(req) + short_fills = [t for t in res["trades"] if t["side"] == "sell" and t["quantity"] > 100] + # position was 100 (bought) but sell attempted 400 -> trimmed or rejected, not shorted + pos_last = res["equity"][-1]["positions"]["SH#600000"] + assert pos_last >= 0 + assert any("no naked short" in w or "would exceed" in w for w in res["warnings"]) or \ + not short_fills or short_fills[0]["quantity"] <= 100 + + +def test_t1_samebar_buy_sell_guarded(tmp_path): + # sell submitted on the same bar as buy (position still 0 at submit) must be trimmed + req = make_request(tmp_path, SELL_OPEN, [("SH#600000", "sh_600000", "stock", 10.0)]) + res = run_backtest(req) + assert res["status"] == "succeeded" + positions = [e["positions"]["SH#600000"] for e in res["equity"]] + assert min(positions) >= 0 # never negative (no short anywhere in series) + + +def test_cash_plus_position_reconciles_equity(tmp_path): + req = make_request(tmp_path, BUY_STOCK_ONLY, [("SH#600000", "sh_600000", "stock", 10.0)]) + res = run_backtest(req) + last = res["equity"][-1] + held = {s: p for s, p in last["positions"].items() if p} + expected = last["cash"] + sum(p * last["closes"][s] for s, p in held.items()) + assert abs(last["equity"] - expected) < 1e-6 diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..f23bf61 --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,20 @@ +# Strategy Lab Python worker image +# - fetch: network-enabled; backtest: --network none +# - non-root uid 10001, read-only rootfs at runtime, /output writable only +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN adduser --uid 10001 --disabled-password --gecos "" worker + +COPY requirements-worker.txt /tmp/requirements-worker.txt +RUN pip install --no-cache-dir -r /tmp/requirements-worker.txt + +COPY worker /app/worker + +USER worker + +# /input request, /data dataset objects (read-only mounts); /output writable result dir +CMD ["python", "-m", "worker.main"] diff --git a/worker/__init__.py b/worker/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/worker/__init__.py diff --git a/worker/backtest.py b/worker/backtest.py new file mode 100644 index 0000000..489ee9a --- /dev/null +++ b/worker/backtest.py @@ -0,0 +1,357 @@ +"""Backtrader runner. User `Strategy` code executes with named feeds and +next-bar fills. Recording happens in two places: + - a sibling Recorder strategy (never trades) records per-bar cash/equity/closes; + - a generated subclass of the user's Strategy intercepts notify_order / + notify_trade (documented hooks) to record orders and fills, then calls the + user's own hook methods. +""" +from __future__ import annotations + +import ast +import math +import resource +import statistics +import time +from pathlib import Path + +import backtrader as bt +import pandas as pd + +ENGINE_VERSION = bt.__version__ +EXECUTION_ASSUMPTIONS = { + "fill_timing": "signal bar -> order; order fills at NEXT bar open (no same-bar lookahead)", + "slippage": "percentage of fill price (broker set_slippage_perc)", + "commission": "percentage of order value, charged at execution", + "indicator_warmup": "indicators are computed in strategy code; bars before warmup carry no signal", + "cn_stock_etf_rules": "broker enforces at submission: buys rounded down to lot=100 " + "(reject if <100), sells rejected if they would exceed the " + "current position (no naked short) or exceed position minus " + "same-symbol pending buys (no T+0 sell of same-bar buys). " + "With next-bar-open daily fills, a sell submitted on the fill " + "day executes next trading day, which is exactly the A-share " + "T+1 rule at daily granularity.", + "unmodeled": "suspension days, price-limit halts, intraday sequencing, " + "liquidity/market impact are NOT simulated; no lot-size " + "difference between odd lots of pre-held position on sell", + "etf_rules": "A-share listed ETFs follow the same lot=100 buy rule; ETF sell " + "lots in the real market can differ slightly (approximation noted)", +} + + +class CnDailyRulesBroker(bt.brokers.BackBroker): + """Broker rules for cn stock/ETF daily simulation, enforced at submission. + + - index feeds (asset_type=index) are nontradable research proxies: any + order on them is rejected at the BROKER level (they only serve as + benchmark feeds). + - buys: size rounded down to a whole lot of 100; rejected if < 100 or not + an integer number of lots after rounding. + - sells: rejected when they would exceed the tradable position + (no naked short; pending same-symbol buys are excluded to prevent T+0 + sell of shares that are only settled by a same-cycle buy). + """ + + def __init__(self, index_feeds=(), rule_assets=(), lot: int = 100): + super().__init__() + self.index_feeds = set(index_feeds) + self.rule_assets = set(rule_assets) + self.lot = int(lot) + self.rejections = [] # {symbol, reason} + + def _reject(self, order, reason: str): + self.rejections.append({"symbol": order.data._name, "reason": reason}) + order.reject() + self.notify(order) + return order + + def submit(self, order, check=True): + feed = order.data._name + if feed in self.index_feeds: + return self._reject(order, "index_order_rejected") + if feed in self.rule_assets: + size = order.size + if not float(size).is_integer(): + order.size = int(size) # floor for buys handled below + if order.isbuy(): + pending_buy = 0 + for o in (*self.submitted, *self.pending): + if o.isbuy() and o.data._name == feed and o.status not in \ + (o.Rejected, o.Cancelled, o.Completed): + pending_buy += abs(int(o.size or 0)) if not o.executed.size else 0 + base = int(size) + lots = (base + pending_buy) // self.lot * self.lot + adjusted = max(0, lots - pending_buy) + if adjusted < self.lot: + return self._reject(order, "below_min_lot_100") + if adjusted != size: + order.prereject_size = size # audit trail of requested size + order.size = adjusted + order.executed.remsize = adjusted # _execute reads remsize + elif order.issell(): + pos = self.getposition(order.data).size + pending_buy = 0 + for o in (*self.submitted, *self.pending): + if o.isbuy() and o.data._name == feed and o.status not in \ + (o.Rejected, o.Cancelled, o.Completed): + pending_buy += abs(int(o.size or 0)) + allowed = max(0, pos - pending_buy) + if allowed <= 0: + return self._reject(order, "no_position_to_sell") + if abs(order.size) > allowed: + order.prereject_size = abs(order.size) + order.size = -allowed # trim to owned shares (no short) + order.executed.remsize = order.size + return super().submit(order, check=check) + + +def fail(code: str, message: str, details=None, t0: float = 0.0) -> dict: + return { + "status": "failed", + "error": {"code": code, "message": message, "details": details}, + "engine": {"name": "backtrader", "version": ENGINE_VERSION}, + "elapsed_ms": int((time.monotonic() - t0) * 1000), + "warnings": [], + } + + +def _safe_object_path(data_root: str, relpath: str) -> Path: + root = Path(data_root).resolve() + p = (root / relpath).resolve() + if not str(p).startswith(str(root) + "/"): + raise ValueError(f"path traversal rejected: {relpath}") + if p.is_symlink(): + raise ValueError(f"symlink rejected in data root: {relpath}") + if not p.is_file(): + raise FileNotFoundError(relpath) + return p + + +def _load_frame(path: Path) -> pd.DataFrame: + df = pd.read_parquet(path) if path.suffix == ".parquet" else pd.read_csv(path) + for c in ("date", "open", "high", "low", "close", "volume"): + if c not in df.columns: + raise ValueError(f"dataset object {path.name} missing column '{c}'") + df["date"] = pd.to_datetime(df["date"]) + if df["date"].duplicated().any(): + df = df.drop_duplicates("date", keep="last") + if not df["date"].is_monotonic_increasing: + df = df.sort_values("date") + return df.set_index("date").sort_index() + + +def _compile_user_strategy(code: str): + try: + ast.parse(code) + except SyntaxError as e: + raise SyntaxError(f"strategy syntax error at line {e.lineno}: {e.msg}") + # ONE namespace serves as both exec globals and locals. With separate + # dicts, module-level imports/definitions land in the locals dict while + # method bodies resolve names via the globals dict -> NameError in methods + # (e.g. `import os` at top level, then os.path in __init__). + # The sandbox is the Docker container itself; do not fake Python globals. + ns: dict = {"__builtins__": __builtins__} + exec(compile(code, "<strategy-source>", "exec"), ns) + cls = ns.get("Strategy") + if not (isinstance(cls, type) and issubclass(cls, bt.Strategy)): + raise ValueError("source must define a class named 'Strategy' subclassing backtrader.Strategy") + return cls + + +def run_backtest(request: dict) -> dict: + t0 = time.monotonic() + cfg = request.get("config") or {} + manifest = request.get("dataset_manifest") or {} + warnings = list(manifest.get("warnings") or []) + if manifest.get("_synthetic"): + warnings.append("dataset is a synthetic fixture (_synthetic=true); result is for tests only") + + try: + user_cls = _compile_user_strategy(request["code"]) + except SyntaxError as e: + return fail("strategy_syntax", str(e), t0=t0) + except ValueError as e: + return fail("strategy_invalid", str(e), t0=t0) + + frames = {} + try: + for obj in manifest.get("objects", []): + p = _safe_object_path(request["data_root"], obj["path"]) + frames[p.resolve().name] = _load_frame(p) + if not frames: + return fail("data_missing", "dataset manifest has no objects", t0=t0) + except FileNotFoundError as e: + return fail("data_missing", f"missing dataset object: {e}", t0=t0) + except ValueError as e: + return fail("data_invalid", str(e), t0=t0) + + feed_frames = [(obj, frames[Path(obj["path"]).resolve().name]) + for obj in manifest["objects"] if Path(obj["path"]).resolve().name in frames] + symbols = [obj["instrument"]["symbol"] for obj, _ in feed_frames] + + rec = {"orders": [], "fills": [], "closed_trades": [], "rows": []} + + def RecordingStrategy(): + class R(user_cls): + plotinfo = dict(plot=False, subplot=False) + + def notify_order(self, order): + rec["orders"].append({ + "date": self.datas[0].datetime.date(0).strftime("%Y-%m-%d"), + "symbol": order.data._name, + "side": "buy" if order.isbuy() else "sell", + "order_state": order.getstatusname(), + }) + if order.status == order.Completed: + ex = order.executed + # turnover: actual executed QUANTITY x executed PRICE. + # (Backtrader's `ex.value` for sells reports cost basis, not + # executed turnover — honest accounting must use size*price.) + rec["fills"].append({ + "date": self.datas[0].datetime.date(0).strftime("%Y-%m-%d"), + "symbol": order.data._name, + "side": "buy" if order.isbuy() else "sell", + "quantity": abs(float(ex.size)), + "price": float(ex.price), + "commission": float(ex.comm), + "value": abs(float(ex.size) * float(ex.price)), + }) + user_cls.notify_order(self, order) + + def notify_trade(self, trade): + if trade.isclosed: + rec["closed_trades"].append({ + "symbol": trade.data._name, + "pnl": float(trade.pnl), + "close_date": self.datas[0].datetime.date(0).strftime("%Y-%m-%d"), + }) + user_cls.notify_trade(self, trade) + return R + + class RowsRecorder(bt.Strategy): + plotinfo = dict(plot=False, subplot=False) + + def next(self): + closes = {d._name: float(d.close[0]) for d in self.datas if len(d) > 0} + rec["rows"].append({ + "date": self.datas[0].datetime.date(0).strftime("%Y-%m-%d"), + "cash": float(self.broker.getcash()), + "equity": float(self.broker.getvalue()), + "closes": closes, + "positions": {d._name: self.getposition(d).size for d in self.datas}, + }) + + cerebro = bt.Cerebro(stdstats=False) + for obj, frame in feed_frames: + cerebro.adddata(bt.feeds.PandasData(dataname=frame, plot=False, + timeframe=bt.TimeFrame.Days), + name=obj["instrument"]["symbol"]) + asset_types = {obj["instrument"]["symbol"]: + obj["instrument"].get("asset_type") for obj, _ in feed_frames} + index_feeds = [s for s, a in asset_types.items() if a == "index"] + rule_assets = [s for s, a in asset_types.items() if a in ("stock", "etf")] + broker = CnDailyRulesBroker(index_feeds=index_feeds, rule_assets=rule_assets) + cerebro.setbroker(broker) + initial_capital = float(cfg.get("capital", 100000.0)) + cerebro.broker.setcash(initial_capital) + cerebro.broker.setcommission(commission=float(cfg.get("commission", 0.0003))) + cerebro.broker.set_slippage_perc(perc=float(cfg.get("slippage", 0.001))) + cerebro.addstrategy(RowsRecorder) + cerebro.addstrategy(RecordingStrategy(), **(cfg.get("parameters") or {})) + + try: + strat_list = cerebro.run(runonce=False, preload=False) + except Exception as exc: # user-code runtime errors surface honestly + return fail("runtime_error", f"{type(exc).__name__}: {exc}", t0=t0) + + reasons = { + "index_order_rejected": "index feeds are nontradable proxies; broker rejected the order", + "below_min_lot_100": "buy below minimum lot 100; broker rejected the order", + "no_position_to_sell": "sell would exceed owned shares (no naked short); broker rejected the order", + } + by_reason: dict[tuple[str, str], int] = {} + for rej in broker.rejections: + by_reason[(rej["symbol"], rej["reason"])] = by_reason.get((rej["symbol"], rej["reason"]), 0) + 1 + for (sym, reason), n in by_reason.items(): + rec["orders"].append({"date": None, "symbol": sym, "side": None, + "order_state": "rejected", "reject_reason": reasons.get(reason, reason), + "count": n}) + warnings.append(f"broker rejected {n} order(s) for {sym}: {reasons.get(reason, reason)}") + + warnings.append("execution assumptions: " + + "; ".join(f"{k}: {v}" for k, v in EXECUTION_ASSUMPTIONS.items())) + rows = rec["rows"] + + benchmark_symbol = cfg.get("benchmark_symbol") + bmk_rows = [r["closes"].get(benchmark_symbol) for r in rows if benchmark_symbol] + bmk_valid = [v for v in bmk_rows if v is not None] + bmk_base = bmk_valid[0] if bmk_valid else None + eq, bench_missing = [], False + for r in rows: + e = {"date": r["date"], "equity": r["equity"], "cash": r["cash"], + "closes": r["closes"], "positions": r["positions"]} + if benchmark_symbol: + close = r["closes"].get(benchmark_symbol) + if close is None: + bench_missing = True + e["benchmark"] = None + elif bmk_base: + # normalized to initial capital so equity and benchmark share a + # common start (raw price is also available in e["closes"]) + e["benchmark"] = (close / bmk_base) * initial_capital + else: + e["benchmark"] = None + eq.append(e) + if bmk_rows and benchmark_symbol and bmk_base: + warnings.append(f"benchmark '{benchmark_symbol}' series normalized to initial capital " + f"{initial_capital:g} (raw closes available in equity[].closes)") + if bench_missing and benchmark_symbol: + warnings.append(f"benchmark_symbol '{benchmark_symbol}' not present in dataset; benchmark omitted") + + equity_vals = [r["equity"] for r in rows] + total_return = annual_return = sharpe = max_dd = final = None + if len(equity_vals) >= 2 and equity_vals[0] > 0: + final = equity_vals[-1] + total_return = final / equity_vals[0] - 1.0 + n = len(equity_vals) + annual_return = (1.0 + total_return) ** (252.0 / n) - 1.0 + peak, max_dd = -float("inf"), 0.0 + for v in equity_vals: + peak = max(peak, v) + max_dd = max(max_dd, (peak - v) / peak) + rets = [b / a - 1.0 for a, b in zip(equity_vals, equity_vals[1:])] + std = statistics.pstdev(rets) + sharpe = (statistics.fmean(rets) / std) * (252 ** 0.5) if std > 1e-12 else None + elif equity_vals: + final = equity_vals[-1] + clean = lambda v: v if v is None or math.isfinite(v) else None + metrics = { + "total_return": clean(total_return), + "annual_return": clean(annual_return), + "max_drawdown": (None if max_dd is None else -max_dd), # negative loss fraction; 0.0 is valid + "sharpe": clean(sharpe), + # trade_count = number of closed round-trip trades (开了又平完成一次), not fills + "trade_count": len(rec["closed_trades"]), + "final_equity": clean(final), + } + + return { + "status": "succeeded", + "engine": {"name": "backtrader", "version": ENGINE_VERSION}, + "initial_cash": initial_capital, + "equity": eq, + "orders": rec["orders"], + "trades": rec["fills"], # executions/fills: {date,symbol,side,quantity,price,commission,value} + "closed_trades": rec["closed_trades"], + "metrics": metrics, + "logs": [ + f"named feeds: {', '.join(symbols)}", + f"bars in equity series: {len(rows)}", + f"final equity: {metrics['final_equity']}", + ], + "warnings": warnings, + "execution_assumptions": EXECUTION_ASSUMPTIONS, + "elapsed_ms": int((time.monotonic() - t0) * 1000), + "peak_rss_kb": int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss), + "data_manifest_hash": manifest.get("hash"), + "_synthetic": bool(request.get("_synthetic")) or bool(manifest.get("_synthetic")), + } diff --git a/worker/data.py b/worker/data.py new file mode 100644 index 0000000..841a1eb --- /dev/null +++ b/worker/data.py @@ -0,0 +1,405 @@ +"""AKShare data adapter plus bounded provider suggestion search. + +No silent provider/adjustment fallbacks. No synthetic/fabricated data. +""" +from __future__ import annotations + +import json +import re +from typing import Any + +import akshare as ak # verified import at module load +import pandas as pd +import requests + +from .normalize import COLUMN_MAP, map_columns + +SUPPORTED_ASSET_TYPES = {"stock", "etf", "index"} +SUPPORTED_FREQUENCIES = {"daily"} +SUPPORTED_ADJUSTMENTS = {"none", "qfq", "hfq"} +IDENTITY_EXCHANGES = {"SH", "SZ", "BJ"} + + +SUPPORTED_SOURCES = {"eastmoney", "tencent", "sina", "auto"} +_REQUEST_TIMEOUT_SECS = 15.0 + + +class DataError(ValueError): + def __init__(self, code: str, message: str, details: Any = None): + super().__init__(message) + self.code = code + self.details = details + + +class FetchResult(tuple): + """(normalized_df, endpoint, params) with .provider and .raw extras. + + Cache identity and manifest MUST use the actual provider/endpoint that + served the data (auto fallback records it here). + """ + + def __new__(cls, df, endpoint, params, raw, provider): + obj = super().__new__(cls, (df, endpoint, params)) + obj.raw = raw + obj.provider = provider + return obj + + @property + def df(self): + return self[0] + + @property + def endpoint(self): + return self[1] + + @property + def params(self): + return self[2] + + +def split_identity(instrument: dict) -> tuple[str, str]: + """-> (exchange, code). Accepts 'SH#600000' or bare code with market identity.""" + symbol = str(instrument["symbol"]) + if "#" in symbol: + exch, code = symbol.split("#", 1) + if exch not in IDENTITY_EXCHANGES: + raise DataError("bad_identity", f"unknown exchange prefix: {exch}") + else: + code = symbol + market = instrument.get("market", "cn") + if market != "cn": + raise DataError("unsupported_market", f"market not supported by this adapter: {market}") + if len(code) == 6 and code[0] in "369": + exch = "SH" + else: + exch = "SZ" + if not code.isdigit() or len(code) != 6: + raise DataError("bad_identity", "A-share code must be a 6-digit number") + return exch, code + + +def _fetch_eastmoney(instrument: dict, start: str, end: str, + frequency: str, adjustment: str) -> FetchResult: + exch, code = split_identity(instrument) + s, e = start.replace("-", ""), end.replace("-", "") + asset = instrument["asset_type"] + if asset == "stock": + endpoint = "stock_zh_a_hist" + params = {"symbol": code, "period": "daily", "start_date": s, "end_date": e, + "adjust": "" if adjustment == "none" else adjustment} + raw = ak.stock_zh_a_hist(**params) + elif asset == "etf": + endpoint = "fund_etf_hist_em" + params = {"symbol": code, "period": "daily", "start_date": s, "end_date": e, + "adjust": "" if adjustment == "none" else adjustment} + raw = ak.fund_etf_hist_em(**params) + else: + endpoint = "index_zh_a_hist" + params = {"symbol": code, "period": "daily", "start_date": s, "end_date": e} + raw = ak.index_zh_a_hist(**params) + if not isinstance(raw, pd.DataFrame): + raise DataError("bad_provider_response", f"{endpoint} did not return a DataFrame") + df = _to_canonical(raw, f"{exch}#{code}") + return FetchResult(df, endpoint, params, raw, "eastmoney") + + +def _fetch_tencent(instrument: dict, start: str, end: str, + frequency: str, adjustment: str) -> FetchResult: + exch, code = split_identity(instrument) + asset = instrument["asset_type"] + s, e = start.replace("-", ""), end.replace("-", "") + tx_symbol = f"{exch.lower()}{code}" + if asset == "stock": + endpoint = "stock_zh_a_hist_tx" + params = {"symbol": tx_symbol, "start_date": s, "end_date": e, + "adjust": "" if adjustment == "none" else adjustment} + raw = ak.stock_zh_a_hist_tx(**params, timeout=_REQUEST_TIMEOUT_SECS) + elif asset == "index": + if frequency != "daily": + raise DataError("unsupported_frequency", "tencent source supports daily only") + endpoint = "stock_zh_index_daily_tx" + params = {"symbol": tx_symbol, "start_date": s, "end_date": e} + raw = ak.stock_zh_index_daily_tx(**params) + else: + raise DataError("source_unavailable", "tencent source has no listed-ETF daily adapter") + if not isinstance(raw, pd.DataFrame): + raise DataError("bad_provider_response", f"{endpoint} did not return a DataFrame") + symbol = f"{exch}#{code}" + df = _to_canonical(raw, symbol) + warnings = [] + if asset == "index": + warnings.append("tencent index data is labeled 前复权 by the provider; " + "adjustment-factors mixed across sources are not supported") + df.attrs["source_warnings"] = warnings + return FetchResult(df, endpoint, params, raw, "tencent") + + +def _fetch_sina(instrument: dict, start: str, end: str, + frequency: str, adjustment: str) -> FetchResult: + exch, code = split_identity(instrument) + asset = instrument["asset_type"] + if frequency != "daily": + raise DataError("unsupported_frequency", "sina source supports daily only") + if asset != "etf": + raise DataError("source_unavailable", + f"sina source has no {asset} daily adapter in this worker") + if adjustment != "none": + raise DataError("unsupported_adjustment", + "sina ETF klines are unadjusted only (no adjust parameter); " + f"adjustment '{adjustment}' cannot be served by sina") + endpoint = "fund_etf_hist_sina" + params = {"symbol": f"{exch.lower()}{code}"} + raw = ak.fund_etf_hist_sina(**params) + if not isinstance(raw, pd.DataFrame): + raise DataError("bad_provider_response", f"{endpoint} did not return a DataFrame") + df = _to_canonical(raw, f"{exch}#{code}") + # sina has no date-range parameter: slice the full history locally to the + # requested window (ISO strings compare lexicographically like dates) + df = df[(df["date"] >= start) & (df["date"] <= end)].reset_index(drop=True) + df.attrs["source_warnings"] = [ + "sina returns the full trading history without date parameters; sliced " + "locally to the requested range; data is unadjusted (no adjust parameter)", + "sina volume unit is 股 (shares); verified to be 100x the 手 (lots) " + "convention used by lot-based providers on the same session " + "(2026-09-17 evidence, docs/recovery-01-plan.md)", + ] + return FetchResult(df, endpoint, params, raw, "sina") + + +def _to_canonical(raw: pd.DataFrame, canonical_symbol: str) -> pd.DataFrame: + df = map_columns(raw.copy()) + if not df.empty and "date" in df.columns: + df["date"] = pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d") + df["symbol"] = canonical_symbol + return df + + +def fetch_source(instrument: dict, start: str, end: str, frequency: str, + adjustment: str, fields: list[str] | None = None, + source: str = "auto") -> FetchResult: + if frequency not in SUPPORTED_FREQUENCIES: + raise DataError("unsupported_frequency", f"frequency '{frequency}' not supported; supported: daily") + if adjustment not in SUPPORTED_ADJUSTMENTS: + raise DataError("unsupported_adjustment", f"adjustment '{adjustment}' not supported") + if instrument.get("asset_type") not in {"stock", "etf", "index"}: + raise DataError("unsupported_asset_type", f"asset_type '{instrument.get('asset_type')}' not supported") + if source not in SUPPORTED_SOURCES: + raise DataError("bad_source", f"source '{source}' not supported; supported: {sorted(SUPPORTED_SOURCES)}") + if instrument["asset_type"] == "index" and adjustment != "none": + raise DataError("unsupported_adjustment", "indexes have no adjustment factors; adjustment must be 'none'") + + def run(provider: str) -> FetchResult: + if provider == "eastmoney": + return _fetch_eastmoney(instrument, start, end, frequency, adjustment) + if provider == "sina": + return _fetch_sina(instrument, start, end, frequency, adjustment) + return _fetch_tencent(instrument, start, end, frequency, adjustment) + + if source != "auto": + return run(source) + # auto: eastmoney first, transparent same-symbol fallback, honestly labeled. + # tencent has no listed-ETF daily adapter, so ETFs fall back to sina. + fallback = "sina" if instrument["asset_type"] == "etf" else "tencent" + try: + return run("eastmoney") + except Exception as em_err: + try: + res = run(fallback) + except Exception as fb_err: + raise DataError( + "provider_unavailable", + f"eastmoney failed ({em_err}); {fallback} fallback failed ({fb_err})") + res.df.attrs["source_warnings"] = list(getattr(res.df, "attrs", {}).get("source_warnings", [])) + [ + f"provider_fallback: eastmoney attempt failed ({type(em_err).__name__}); " + f"served by {fallback} for the SAME symbol; sources may differ in " + "adjustment method and units (check provider warnings)" + ] + return res + + +def fetch_source_with_warnings(instrument: dict, start: str, end: str, frequency: str, + adjustment: str, fields: list[str] | None = None, + source: str = "auto"): + """fetch_source plus source warnings pulled off the returned frame.""" + res = fetch_source(instrument, start, end, frequency, adjustment, fields, source) + src_warnings = list(res.df.attrs.get("source_warnings", [])) + return res, src_warnings + + +# ---- provider suggestion search (bounded, direct HTTP, no full catalogs) ---- +# +# Root-cause fix for the production search stall: the legacy path fetched the +# ENTIRE stock catalog and the ENTIRE ETF snapshot on every query. Instead we +# hit the actual suggestion endpoints of the same data providers used by +# fetch — bounded HTTP calls with short timeouts, then validated identity +# classification per item. Asset class comes ONLY from the provider's own +# classification field (Eastmoney `Classify` / Tencent hint tail token), +# never guessed from the numeric code shape. + +SUGGEST_TIMEOUT_SECS = 4.0 # per-source hard timeout, well under overall budget + +_EASTMONEY_URL = "https://searchapi.eastmoney.com/api/suggest/get" +_TENCENT_URL = "https://smartbox.gtimg.cn/s3/" + +# Live-probed provider-stated classes (2026-09, see docs/search-fix.md): +# Eastmoney Classify: AStock→stock, Index→index, Fund→fund (ambiguous: +# same SecurityType/Classify covers ETF AND LOF), Bond/HK/OTCFUND/... unsupported. +# Tencent hint tail: GP-A→stock, ETF→etf, ZS→index, LOF/others unsupported. +_EASTMONEY_CLASSIFY = {"AStock": "stock", "Index": "index", "Fund": "etf"} +_TENCENT_TAGS = {"GP-A": "stock", "ETF": "etf", "ZS": "index"} +_MKTNUM_EXCHANGE = {"1": "SH", "0": "SZ"} +_TENCENT_EXCHANGE = {"sh": "SH", "sz": "SZ"} + +_HEADERS = {"User-Agent": "Mozilla/5.0 (strategy-lab instrument search)"} + + +def _http_json(url: str, params: dict) -> dict: + r = requests.get(url, params=params, timeout=SUGGEST_TIMEOUT_SECS, headers=_HEADERS) + r.raise_for_status() + return r.json() + + +def _http_text(url: str, params: dict) -> str: + r = requests.get(url, params=params, timeout=SUGGEST_TIMEOUT_SECS, headers=_HEADERS) + r.raise_for_status() + return r.text + + +def _search_eastmoney(q: str, limit: int) -> list[dict]: + """Bounded direct eastmoney suggest call. Returns validated items only.""" + payload = _http_json( + _EASTMONEY_URL, + {"input": q, "type": 14, "count": max(5, min(limit, 100))}, + ) + table = payload.get("QuotationCodeTable") or {} + if table.get("Status") != 0: + raise RuntimeError(f"eastmoney suggest status={table.get('Status')!r}") + rows = table.get("Data") or [] + items = [] + for row in rows[:limit]: + code = str(row.get("Code", "")).strip() + name = str(row.get("Name", "")).strip() + classify = str(row.get("Classify", "")).strip() + asset = _EASTMONEY_CLASSIFY.get(classify) + exchange = _MKTNUM_EXCHANGE.get(str(row.get("MktNum", "")).strip()) + # identity must be fully validated by the provider reply itself; the + # asset class is provider-stated, never inferred from the code digits + if not code or not exchange or asset is None: + continue + items.append({ + "symbol": code, + "canonical_symbol": f"{exchange}#{code}", + "market": "cn", + "asset_type": asset, + "name": name, + "currency": "CNY", + "source": "eastmoney", + }) + return items + + +def _search_tencent(q: str, limit: int) -> list[dict]: + """Bounded direct tencent smartbox call. Returns validated items only.""" + text = _http_text(_TENCENT_URL, {"q": q, "t": "all"}) + m = re.search(r'v_hint="(.*)"', text) + if not m: + return [] # empty suggestion is a valid provider-no-match reply + body = m.group(1) + # provider encodes non-ASCII as \uXXXX escapes; decode JSON-style safely + try: + body = json.loads(f'"{body}"') + except ValueError: + body = decode_unicode_escapes(body) + items = [] + for entry in body.split("^"): + parts = entry.strip().split("~") + if len(parts) < 5: + continue + mkt, code, name, _py, tag = parts[0].lower(), parts[1], parts[2], parts[3], parts[4].strip() + asset = _TENCENT_TAGS.get(tag) + exchange = _TENCENT_EXCHANGE.get(mkt) + # class and exchange both provider-stated; LOF/bonds/bj are excluded + if not code or not exchange or asset is None: + continue + items.append({ + "symbol": code, + "canonical_symbol": f"{exchange}#{code}", + "market": "cn", + "asset_type": asset, + "name": name, + "currency": "CNY", + "source": "tencent", + }) + return items[:limit] + + +def decode_unicode_escapes(s: str) -> str: + return re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), s) + + +def search_instruments(q: str, limit: int = 50) -> dict: + """Bounded direct provider suggestion search. + + Per-source HTTP timeout 4s; one source failing is visible but not fatal if + the other serves results. Both failing -> honest failed status, never an + empty success. Each item carries its actual provenance source. + """ + out = {"source": "provider_suggest", "status": "failed", + "items": [], "error": None, "providers": {}} + errors: list[str] = [] + sources_used: list[str] = [] + results: dict[str, list[dict]] = {} + + try: + results["eastmoney"] = _search_eastmoney(q, limit) + sources_used.append("eastmoney") + out["providers"]["eastmoney"] = "ok" + except Exception as exc: + errors.append(f"eastmoney: {type(exc).__name__}: {exc}") + out["providers"]["eastmoney"] = f"failed: {type(exc).__name__}" + + try: + results["tencent"] = _search_tencent(q, limit) + sources_used.append("tencent") + out["providers"]["tencent"] = "ok" + except Exception as exc: + errors.append(f"tencent: {type(exc).__name__}: {exc}") + out["providers"]["tencent"] = f"failed: {type(exc).__name__}" + + if not sources_used: + out["error"] = {"code": "providers_unavailable", + "message": "all providers failed: " + "; ".join(errors)} + return out + + tencent_by_code = {it["symbol"]: it for it in results.get("tencent", [])} + tencent_ok = "tencent" in results + seen: set[tuple[str, str, str]] = set() + items = [] + for provider in ("eastmoney", "tencent"): + for it in results.get(provider, []): + # Eastmoney's Classify "Fund" is ambiguous (verified live: ETF and + # LOF share the same Classify/SecurityType). Only emit Eastmoney + # fund items when Tencent — whose tags distinguish ETF from LOF — + # serves the same code; otherwise the LOF guarantee cannot hold. + if provider == "eastmoney" and it["asset_type"] == "etf": + cross = tencent_by_code.get(it["symbol"]) + if not (tencent_ok and cross and cross["asset_type"] == "etf"): + continue + key = (it["canonical_symbol"], it["asset_type"], it["source"]) + if key in seen: + continue + seen.add(key) + items.append(it) + if len(items) >= limit: + break + if len(items) >= limit: + break + + out["status"] = "ready" + out["items"] = items + out["source"] = "provider_suggest" + out["error"] = ({"code": "partial_providers", + "message": "some providers failed: " + "; ".join(errors)}) if errors else None + if errors: + out["warnings"] = errors + return out diff --git a/worker/main.py b/worker/main.py new file mode 100644 index 0000000..bedeb03 --- /dev/null +++ b/worker/main.py @@ -0,0 +1,267 @@ +"""Strategy Lab Python worker CLI. + +Subcommands: + fetch --request /input/request.json --output /output (network OK) + backtest --request /input/request.json --output /output (network none) + search --query <text> [--limit 50] (network OK) + probe --output <dir> (real AKShare evidence) +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import signal +import sys +import time +from datetime import date, timedelta +from pathlib import Path + +import akshare as ak +import pandas as pd + +from . import backtest as bt_runner +from . import data as data_mod +from .normalize import (NORMALIZATION_VERSION, SCHEMA_VERSION, build_manifest_entry, + compute_object_hash, coverage_summary, normalize_frame) + +MAX_INSTRUMENTS = 5 +MAX_YEARS = 15 +BASE_FIELDS = ["open", "high", "low", "close", "volume"] +ALLOWED_FIELDS = set(BASE_FIELDS) | {"amount", "turnover"} +EXTRA_MAP = {"amount": {"成交额", "amount"}, "turnover": {"换手率", "turnover"}} +ALLOWED_SOURCES = {"eastmoney", "tencent", "sina", "auto"} +FETCH_WALL_SECS = int(os.environ.get("STRATEGY_LAB_FETCH_WALL_SECS", "180")) + + +def _load_json(path: str): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def _dump_json(path: Path, obj) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(obj, f, ensure_ascii=False, indent=1, sort_keys=True, default=str) + + +def _slug(instrument: dict) -> str: + return instrument["symbol"].lower().replace("#", "_") + + +def _validate_request(req: dict) -> list[str]: + errors = [] + instruments = req.get("instruments") or [] + if not instruments or len(instruments) > MAX_INSTRUMENTS: + errors.append(f"1..{MAX_INSTRUMENTS} instruments required") + if req.get("frequency") != "daily": + errors.append("frequency must be 'daily'") + if req.get("adjustment") not in {"none", "qfq", "hfq"}: + errors.append("adjustment must be none|qfq|hfq") + s, e = req.get("start_date"), req.get("end_date") + try: + d0, d1 = date.fromisoformat(s), date.fromisoformat(e) + if d0 > d1: + errors.append("start_date after end_date") + if d1 - d0 > timedelta(days=365 * MAX_YEARS): + errors.append(f"range exceeds {MAX_YEARS} years POC limit") + except (TypeError, ValueError): + errors.append("start_date/end_date must be ISO dates") + for f in req.get("fields", BASE_FIELDS): + if f not in ALLOWED_FIELDS: + errors.append(f"field '{f}' not supported; supported: {sorted(ALLOWED_FIELDS)}") + if req.get("source", "auto") not in ALLOWED_SOURCES: + errors.append(f"source '{req.get('source')}' not supported; supported: {sorted(ALLOWED_SOURCES)}") + return errors + + +def _fetch_timeout(signum, frame): + raise TimeoutError("fetch wall-clock limit exceeded (bounded network call)") + + +def _write_raw(out: Path, slug: str, endpoint: str, params: dict, raw, fetched_at: str) -> tuple[str, str]: + """Immutable raw provider response object. Returns (objects-relative name, content hash).""" + payload = {"endpoint": endpoint, "params": params, "fetched_at": fetched_at, + "data": json.loads(raw.to_json(orient="records", force_ascii=False))} + data = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") + digest = hashlib.sha256(data).hexdigest() + name = f"raw_{slug}_{digest[:12]}.json" + (out / "objects").mkdir(parents=True, exist_ok=True) + (out / "objects" / name).write_bytes(data) + return name, digest + + +def run_fetch(request_path: str, output_dir: str) -> int: + signal.signal(signal.SIGALRM, _fetch_timeout) + signal.alarm(FETCH_WALL_SECS) + req = _load_json(request_path) + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + t0 = time.monotonic() + errors = _validate_request(req) + result = {"status": "failed", "errors": errors, "warnings": []} if errors else None + if result: + _dump_json(out / "result.json", result) + return 1 + + instruments = req["instruments"] + objects, warnings = [], [] + ok = True + for inst in instruments: + w = list(inst.get("warnings", [])) + try: + res, src_warn = data_mod.fetch_source_with_warnings( + inst, req["start_date"], req["end_date"], req["frequency"], + req["adjustment"], req.get("fields") or BASE_FIELDS, + source=req.get("source", "auto")) + df, endpoint, params = res + raw = res.raw + w.extend(src_warn) + except data_mod.DataError as exc: + objects.append({"instrument": inst, "error": {"code": exc.code, "message": str(exc)}}) + warnings.append(f"{inst['symbol']}: {exc}") + ok = False + continue + except Exception as exc: + objects.append({"instrument": inst, + "error": {"code": "provider_error", "message": f"{type(exc).__name__}: {exc}"}}) + warnings.append(f"{inst['symbol']}: provider error {exc}") + ok = False + continue + + fetched_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + requested_flds = req.get("fields") or BASE_FIELDS + kept = [c for c in df.columns if c in ("date", "symbol", "open", "high", "low", "close", "volume") + or c in {alt for f in requested_flds if f in EXTRA_MAP for alt in EXTRA_MAP[f]}] + df = df[kept] + norm = normalize_frame(df, inst["symbol"]) + raw_name, raw_hash = _write_raw(out, _slug(inst), res.endpoint, res.params, raw, fetched_at) + csv_path = f"objects/{_slug(inst)}.csv" + (out / "objects").mkdir(exist_ok=True) + norm.to_csv(out / csv_path, index=False) + cov = coverage_summary(norm) + if cov["actual_start"] != req["start_date"] or cov["actual_end"] != req["end_date"]: + w.append(f"actual coverage {cov['actual_start']}..{cov['actual_end']} differs from request; gaps kept") + entry = build_manifest_entry( + norm, + instrument={**inst, "symbol": inst["symbol"].upper()}, + provider=res.provider, endpoint=res.endpoint, params=res.params, + adjustment=req["adjustment"], + requested_start=req["start_date"], requested_end=req["end_date"], + raw_object_hash=raw_hash, warnings=w, path=csv_path, + fetched_at=fetched_at, akshare_version=ak.__version__, + ) + objects.append(entry) + + if not ok: + result = {"status": "failed", "errors": [f"{o['instrument']['symbol']}: {o.get('error', {}).get('message')}" + for o in objects if "error" in o], + "warnings": warnings} + _dump_json(out / "result.json", result) + return 1 + + manifest = { + "schema_version": SCHEMA_VERSION, + "normalization_version": NORMALIZATION_VERSION, + "fetched_at": objects[0]["fetched_at"], + "frequency": req["frequency"], + "adjustment": req["adjustment"], + "objects": objects, + "immutable": True, + } + content = json.dumps(objects, sort_keys=True, ensure_ascii=False) + manifest["hash"] = hashlib.sha256(content.encode()).hexdigest() + + result = { + "status": "ready", + "manifest": manifest, + "preview": {"columns": [c for c in objects[0]["columns"] if c != "symbol"], + "row_count": objects[0]["row_count"], + "rows": json.loads(pd.read_csv(out / objects[0]["path"]).tail(20).to_json(orient="records")), + "coverage": coverage_summary(pd.read_csv(out / objects[0]["path"]))}, + "manifest_hash": manifest["hash"], + "cache_key": manifest["hash"], + "warnings": warnings, + "elapsed_ms": int((time.monotonic() - t0) * 1000), + "akshare_version": ak.__version__, + } + _dump_json(out / "result.json", result) + return 0 + + +def run_backtest(request_path: str, output_dir: str) -> int: + req = _load_json(request_path) + out = Path(output_dir) + res = bt_runner.run_backtest(req) + _dump_json(out / "result.json", res) + return 0 if res.get("status") == "succeeded" else 1 + + +def run_search(query: str, limit: int) -> int: + """Bounded provider suggestion search. Prints a JSON envelope with an + explicit status; failed upstream is a real error, never empty success.""" + envelope = data_mod.search_instruments(query, limit) + print(json.dumps(envelope, ensure_ascii=False)) + return 0 if envelope.get("status") == "ready" else 1 + + +def run_probe(output_dir: str) -> int: + """Real AKShare connectivity probe for qa evidence. No fabrication.""" + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + targets = [ + {"endpoint": "stock_zh_a_hist", "symbol": "600000", "asset_type": "stock", + "call": lambda: ak.stock_zh_a_hist(symbol="600000", period="daily", + start_date="20260101", end_date="20260930", adjust="qfq")}, + {"endpoint": "fund_etf_hist_em", "symbol": "510300", "asset_type": "etf", + "call": lambda: ak.fund_etf_hist_em(symbol="510300", period="daily", + start_date="20260101", end_date="20260930", adjust="")}, + {"endpoint": "stock_zh_index_daily_em", "symbol": "000300", "asset_type": "index", + "call": lambda: ak.stock_zh_index_daily_em(symbol="sh000300")}, + {"endpoint": "stock_info_a_code_name", "symbol": "<catalog>", "asset_type": "catalog", + "call": lambda: ak.stock_info_a_code_name()}, + ] + report = {"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "akshare_version": ak.__version__, "probes": []} + for t in targets: + rec = {"endpoint": t["endpoint"], "symbol": t["symbol"], "asset_type": t["asset_type"]} + try: + df = t["call"]() + rec.update(status="ok", rows=int(len(df)), columns=list(df.columns), + head=[json.loads(df.head(2).to_json(orient="records", force_ascii=False)), + ][0]) + except Exception as exc: + rec.update(status="failed", error=f"{type(exc).__name__}: {exc}") + report["probes"].append(rec) + _dump_json(out / "akshare-probe.json", report) + ok = sum(1 for p in report["probes"] if p["status"] == "ok") + print(json.dumps({"ok": ok, "total": len(targets)})) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="python -m worker.main") + sub = ap.add_subparsers(dest="cmd", required=True) + for name in ("fetch", "backtest"): + p = sub.add_parser(name) + p.add_argument("--request", required=True) + p.add_argument("--output", required=True) + ps = sub.add_parser("search") + ps.add_argument("--query", required=True) + ps.add_argument("--limit", type=int, default=50) + pp = sub.add_parser("probe") + pp.add_argument("--output", required=True) + args = ap.parse_args(argv) + if args.cmd == "fetch": + return run_fetch(args.request, args.output) + if args.cmd == "backtest": + return run_backtest(args.request, args.output) + if args.cmd == "search": + return run_search(args.query, args.limit) + if args.cmd == "probe": + return run_probe(args.output) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/worker/normalize.py b/worker/normalize.py new file mode 100644 index 0000000..4cb373d --- /dev/null +++ b/worker/normalize.py @@ -0,0 +1,160 @@ +"""Normalization: provider frames -> canonical OHLCV schema + content hashes.""" +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pandas as pd + +SCHEMA_VERSION = "1" +NORMALIZATION_VERSION = "1" + +# Canonical instrument identity format: "<EXCHANGE>#<CODE>", e.g. SH#600000. +# Prices are never substituted across canonical symbols. + +# AKShare (Chinese) -> canonical column names. Non-mapped fields are preserved +# verbatim as raw provider fields (units/precision untouched). +COLUMN_MAP = { + "日期": "date", + "开盘": "open", + "收盘": "close", + "最高": "high", + "最低": "low", + "成交量": "volume", +} + +REQUIRED = ["date", "open", "high", "low", "close", "volume"] + + +class NormalizeError(ValueError): + def __init__(self, code: str, message: str, details: Any = None): + super().__init__(message) + self.code = code + self.details = details + + +def map_columns(df: pd.DataFrame) -> pd.DataFrame: + return df.rename(columns=COLUMN_MAP) + + +def normalize_frame(df: pd.DataFrame, symbol: str) -> pd.DataFrame: + if df is None or df.empty: + raise NormalizeError("empty_response", "provider returned no rows") + missing_req = {c for c in REQUIRED if c not in df.columns} + if missing_req: + raise NormalizeError( + "missing_columns", + f"provider frame missing standard columns: {sorted(missing_req)}", + {"columns": list(df.columns)}, + ) + out = df.copy() + out["date"] = pd.to_datetime(out["date"], errors="coerce") + if out["date"].isna().any(): + bad = out.loc[out["date"].isna()].index.tolist() + raise NormalizeError( + "bad_date", "unparseable date values", {"rows": [int(i) for i in bad[:5]]} + ) + for col in ("open", "high", "low", "close", "volume"): + out[col] = pd.to_numeric(out[col], errors="coerce") + if out[col].isna().any(): + rows = out.loc[out[col].isna()].index.tolist() + first = out.loc[rows[0], "date"].date().isoformat() + raise NormalizeError( + "missing_ohlcv", + f"missing or non-numeric '{col}' on {first} — gap kept, never imputed", + {"column": col, "row_count": len(rows), "rows": [int(i) for i in rows[:5]]}, + ) + out["date"] = out["date"].dt.strftime("%Y-%m-%d") + out = out.sort_values("date", kind="mergesort") + out = out.drop_duplicates(subset="date", keep="last") + # canonical identity, prices never substituted across symbols + out["symbol"] = symbol + return out.reset_index(drop=True) + + +def frame_records(df: pd.DataFrame) -> list[dict]: + return json.loads(df.to_json(orient="records", force_ascii=False)) + + +def compute_object_hash(df: pd.DataFrame) -> str: + """Content hash over canonical records; independent of request/user ids.""" + recs = frame_records(df) + return hashlib.sha256( + json.dumps(recs, sort_keys=True, ensure_ascii=False).encode("utf-8") + ).hexdigest() + + +def coverage_summary(df_or_dates) -> dict: + if isinstance(df_or_dates, pd.DataFrame): + dates = pd.to_datetime(df_or_dates["date"]).tolist() + else: + dates = pd.to_datetime(list(df_or_dates)).tolist() + if not dates: + return {"actual_start": None, "actual_end": None, "segments": [], "gaps": []} + segs = [[dates[0], dates[0]]] + gaps: list[dict] = [] + for prev, cur in zip(dates, dates[1:]): + delta = cur - prev + if delta.days == 1: + segs[-1][1] = cur + else: + segs.append([cur, cur]) + gaps.append({"after": prev.date().isoformat(), "before": cur.date().isoformat(), + "calendar_days": delta.days - 1}) + fmt = lambda d: d.date().isoformat() + return { + "actual_start": fmt(dates[0]), + "actual_end": fmt(dates[-1]), + "segments": [[fmt(a), fmt(b)] for a, b in segs], + "gaps": gaps, + } + + +def build_manifest_entry( + df: pd.DataFrame, + *, + instrument: dict, + provider: str, + endpoint: str, + params: dict, + adjustment: str, + requested_start: str, + requested_end: str, + raw_object_hash: str, + warnings: list, + schema_version: str = SCHEMA_VERSION, + normalization_version: str = NORMALIZATION_VERSION, + path: str, + fetched_at: str, + akshare_version: str, +) -> dict: + cov = coverage_summary(df) + return { + "instrument": { + "symbol": instrument["symbol"], + "market": instrument["market"], + "asset_type": instrument["asset_type"], + **({"name": instrument["name"]} if instrument.get("name") else {}), + }, + "object_hash": compute_object_hash(df), + "path": path, # internal-only: backend rewrites before client exposure + "raw_object_hash": raw_object_hash, + "provider": provider, + "endpoint": endpoint, + "params": params, + "akshare_version": akshare_version, + "fetched_at": fetched_at, + "schema_version": schema_version, + "normalization_version": normalization_version, + "adjustment": adjustment, + "requested_start": requested_start, + "requested_end": requested_end, + "actual_start": cov["actual_start"], + "actual_end": cov["actual_end"], + "coverage": {"segments": cov["segments"], "gaps": cov["gaps"]}, + "row_count": int(len(df)), + "columns": list(df.columns), + "warnings": list(warnings), + "immutable": True, + } |
