diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..a8adaa6 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,40 @@ +# cargo-audit configuration for review-engine. +# Read automatically by the rustsec/audit-check@v2 GitHub Action +# (.github/workflows/audit.yml) and by `cargo audit` when run from the +# repository root. + +[advisories] +# RUSTSEC-2023-0071: rsa 0.9.10 — Marvin Attack (timing side-channel on RSA +# private-key decryption). +# +# Why this ignore is safe for review-engine: +# +# 1. The vulnerable crate is never compiled. `rsa` enters Cargo.lock only as +# an *optional* dependency of `sqlx-mysql`, which sqlx 0.8 locks into the +# lockfile regardless of feature activation. The workspace enables sqlx +# with `default-features = false` and features = ["any", "runtime-tokio", +# "postgres", "sqlite", "migrate", "macros", "chrono", "uuid", "json"] — +# no `mysql`. Verified: `cargo tree -i rsa` and +# `cargo tree --all-features --target all -i rsa` both print nothing, +# i.e. no feature combination of this workspace activates sqlx-mysql/rsa +# in the build graph, and no compilation artifact for rsa is produced. +# Removing it from Cargo.lock is impossible while sqlx is a dependency +# (confirmed by regenerating the lockfile from scratch — rsa reappears). +# +# 2. Even if it were compiled, the attack surface does not exist here. The +# Marvin attack requires the attacker to submit chosen ciphertexts to an +# RSA private-key decryption (PKCS#1 v1.5 unpadding) oracle. review-engine +# performs no RSA private-key operations of any kind: no RSA code is +# referenced in src/ or tests/ (grep-verified), and the only consumer in +# the tree would be sqlx-mysql's MySQL `caching_sha2_password` auth — +# a code path that is unreachable because no MySQL backend is configured +# or supported (SQLite/PostgreSQL only; no mysql connection string is +# accepted anywhere). +# +# 3. No fix is available upstream: RUSTSEC-2023-0071 has no patched release +# in the rsa 0.9 series; upgrading within sqlx's accepted range cannot +# resolve it. Revisit this ignore if a fixed rsa release lands or if the +# project ever enables sqlx's `mysql` feature. +ignore = [ + "RUSTSEC-2023-0071", +] diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8e567..770bc53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [0.10.0] - 2026-09-04 + +> **0.10.x is a major upgrade.** The upgrade itself is fully automatic — the first 0.10.x boot creates the database, applies the embedded schema migrations, and imports `ui-state.toml` into the database in one transaction — but it is a **one-way move: downgrading back to 0.9.x is not supported** (configuration's authoritative source becomes the database and `ui-state.toml` is renamed to `ui-state.toml.migrated`, which a 0.9.x binary cannot read). **Back up your config directory** (`~/.config/review-engine/`, including `ui-state.toml` and `secrets.key`; Docker: the `./config` and `./auth` bind mounts) **before upgrading.** Step-by-step instructions, expected logs, verification, and troubleshooting: [`docs/migration-0.10.md`](docs/migration-0.10.md). + +### Added +- **Persistent storage layer — PostgreSQL primary, embedded SQLite fallback**: a new `src/store/` module on sqlx 0.8's `Any` pool serves both backends from one code path; `DATABASE_URL` set → PostgreSQL, unset → embedded SQLite at `~/.config/review-engine/review.db`. The initial migration creates 7 tables (`reviews` / `expert_reports` / `mr_discussions` / `review_contexts` / `git_platforms` / `llm_providers` / `app_settings`), embedded via `sqlx::migrate!()` and applied at startup. Dialect rules (placeholders, JSON-as-TEXT, bool-as-INTEGER, RFC 3339 TEXT timestamps) are codified in `design/persistence.md` §3.1. (`src/store/`, `migrations/0001_init.sql`, `Cargo.toml`) +- **Review history survives restarts**: `TaskStore` now writes through to the DB, and with persistence active the History list/detail APIs read from the DB — history is no longer bounded by the 30-minute in-memory reaper window. A startup sweep flips rows still `pending`/`running` when the previous process died to `failed` with `error='interrupted: server restarted'`, so stale tasks never hang in a running state. `db=None` keeps the exact 0.9 in-memory path. (`src/server/task_queue.rs`, `src/server/api/review/handlers.rs`, `src/store/traits.rs`, `src/store/sqlx.rs`) +- **Configuration in the database**: git platforms and LLM provider configs move from `ui-state.toml` into the DB via a one-shot, single-transaction import on first boot (a mid-import failure rolls back cleanly and retries next startup); the file is renamed to `ui-state.toml.migrated` (kept, never deleted) once every table is written. All credentials — including LLM API keys, previously plaintext on disk — are stored `enc:`-encrypted (ChaCha20-Poly1305, same `secrets.key` boundary as 0.9.x). `PUT /api/v1/config` persists to the DB when attached. `REVIEW_DISABLE_DB=1` is the escape hatch restoring full 0.9 behaviour (in-memory + file); a `DATABASE_URL` pointing at an unreachable PostgreSQL is a hard startup error, never a silent SQLite fallback. (`src/server/api/config/persist.rs`, `src/store/`, `src/cli/app.rs`) +- **MR discussion context**: GitLab Note webhook payloads are ingested into `mr_discussions` in real time — idempotent upsert on `(platform, project, mr_iid, note_id)`, with a self-echo guard so our own published review reports are never re-ingested — and the pre-review flow additionally pulls discussions via the API and injects them into expert prompts, so follow-up reviews see prior human comments and review conclusions. (`src/server/gitlab/hooks.rs`, `src/server/api/review/discussion.rs`, `src/prompt/engine.rs`, `src/store/sqlx.rs`) +- **Storage backend visibility**: `GET /api/v1/system/health` gains `storage_backend` (`postgresql` / `sqlite` / `disabled`); the Configuration page Advanced card shows it as a permanently-disabled read-only row (hidden fail-silent on health-check failure or an older server). (`src/server/api/system.rs`, `frontend/src/views/Configuration.vue`, `frontend/src/services/health.ts`, `frontend/src/i18n/locales/*` ×6) +- **Review detail「完整评论」fallback**: when the aggregator produced no output (team reviews with `aggregated=None`), the full-comment tab falls back to the Lead consolidation TL;DR instead of rendering empty. (`src/server/api/review/task.rs`) + +### Fixed +- **PostgreSQL placeholder rewriting (E2E-found release blocker)**: sqlx's `Any` driver passes SQL through verbatim — it does NOT translate `?` placeholders to `$n`, so every bound-parameter statement failed on PG with `42601 syntax error`. The store layer now rewrites top-level `?` to `$1..$n` (correctly skipping `?` inside string literals, quoted identifiers, and comments) at a single choke point (`SqlxStore::sql` / `adapt_sql`); SQLite passes through unchanged. (`src/store/placeholders.rs`, `src/store/sqlx.rs`) +- **`llm_providers.temperature` declared `DOUBLE PRECISION`**: PG parses `REAL` as float4 while the store binds/decodes f64, so config read-back after restart failed with `mismatched types: f64 is not compatible with SQL type REAL` — silently leaving `GET /config` empty while `/health` stayed green (SQLite's 8-byte REAL is why tests never saw it). The column is now float8 on PG, same 8 bytes on SQLite. (`migrations/0001_init.sql`, `src/store/sqlx.rs`) +- **`durationMs` wrap-around guard**: inverted timestamps (completed before started) now clamp to 0 instead of wrapping the u64 duration. (`src/server/task_queue.rs`) +- **History list projection self-consistency**: rows whose materialized `project`/`repository` columns drifted from `source_meta` (failed back-fill, hand-seeded or legacy rows) are re-filled from the columns at read time, so a row matched by a `?project=X` filter no longer displays `project: null`. (`src/store/rows.rs`) +- **SPA history-mode deep links no longer 404; static-dir miss is now diagnosable**: directly opening or refreshing a client-side route (`/history`, `/config`, `/reviews/42`) returned ServeDir's bare 404 — a `ServeDir::fallback` handler now serves `index.html` for extension-less non-`/api/` GET paths (200 + `no-cache, must-revalidate`, re-read from disk per request so an in-place upgrade never serves a stale copy), while unmatched `/api/` routes and file-like requests (extension in the last segment) keep their explicit 404 — serving HTML for a missing hashed asset would mask deploy breakage. Separately, `static_dir()` resolving `./frontend/dist` against the CWD silently degraded to the "Dashboard coming soon" placeholder when started outside the repository root; it now logs a WARN with the CWD and both checked paths. (`src/server/router.rs`, `tests/server/frontend.rs`) +- **Project context for webhook-triggered first reviews (RENG-25)**: the lead-overview context gather treated `MRInfo.project_path` as a local filesystem path, but for webhook/API-triggered reviews it is the provider slug (`group/project`) and the server never clones the repository, so every first review of a repo with no local cache logged `failed to gather project context: Repository path does not exist` and degraded to an empty `ProjectContext`. The gatherer now only invokes the git-backed path when the path is an existing directory; otherwise (and on gather failure) it falls back to a partial context built from the reviewed diff's file list — first-time reviews get a real file tree without new network I/O. (`src/team/orchestrator/pipeline.rs`, `src/context/gather.rs`) +- **Tolerant `GlobalReviewContext` YAML parsing (RENG-26)**: the lead-overview response was parsed with strict `serde_yaml_ng` on the raw LLM output — a ```` ```yaml ```` fence (backtick is a reserved YAML indicator) or tab indentation aborted the scanner with `found character that cannot start any token`, silently dropping the global context for the whole expert pass. Parsing is now a layered fallback: strict parse → parse after stripping code fences and normalizing tab indentation → parse of the first fenced YAML block only (reusing the shared output-parser helpers `clean_yaml` / `extract_first_fenced_yaml`). Total parse failure still degrades to no global context, unchanged. (`src/team/orchestrator/pipeline.rs`) +- **History author column shows the commit author (RENG-27)**: the author column always showed the MR creator (e.g. the GitLab root account `Administrator`) instead of the person who wrote the commits. Author resolution now prefers the head commit's author and falls back to the MR creator: webhook parse lets `object_attributes.last_commit.author.name` win over `object_attributes.author.name`; GitLab `fetch_mr_info` does a best-effort `GET /repository/commits/` into the new `MRInfo.commit_author` (any failure degrades to `None`, never fails the review; GitHub path unchanged); `source_meta_from_mr_info` uses `commit_author` falling back to `pr_author`, blank treated as absent. No schema change — `author_name` lives in the `reviews.source_meta` JSON column, so existing history rows are untouched. (`src/git_provider/gitlab/client.rs`, `src/git_provider/github/client.rs`, `src/models/mod.rs`, `src/server/gitlab/hooks.rs`, `src/server/task_queue.rs`) +- **Adjudication pass skips loudly when no local checkout exists (RENG-25)**: the adjudication pass assumed `MRInfo.project_path` is a local filesystem path, but for webhook/API-triggered reviews it is the provider slug and the server never clones the repository — every file load failed with `not readable from the local checkout` (INFO, one per file), no finding was actually adjudicated, yet the pipeline summary still logged `examined N findings`. Patch-only adjudication is unsafe (a unified diff carries only ±3 context lines, so the full-file ground-truth check is unsatisfiable and judging against it risks fail-closed drops), so the pass now skips explicitly: with no local checkout and candidates at or above the threshold it emits one WARN naming the reason and the number of findings passed through unadjudicated (kept unchanged, fail-open) and makes no LLM calls; the per-file skip inside a real checkout is elevated from INFO to WARN with the kept-finding count, and the pipeline summary says `candidates` instead of the misleading `examined`. CLI local reviews (real checkout) are unchanged. (`src/team/adjudicator.rs`, `src/team/orchestrator/pipeline.rs`) +- **`/api/v1/reviews` pinned as the sole history list endpoint (RENG-29)**: `GET /api/v1/reviews/history` returned `400 Cannot parse task_id` because no such route exists — the request is captured by `/{task_id}` and fails UUID path-parameter validation. Routing is correct and every in-tree caller already uses `GET /reviews`, so the contract is pinned instead of expanding the API surface: `docs/rest-api.md` now documents `GET /reviews` as the only history list endpoint and `GET /reviews/:task_id` documents the 400 for non-UUID `task_id` alongside the existing 404; the regression test `reviews_history_subpath_is_not_a_route` locks the semantics end-to-end (list 200 envelope; `/reviews/history` 400 naming `task_id`). (`docs/rest-api.md`, `tests/server/reviews.rs`) +- **Dashboard recent-reviews status stays on one line (RENG-30)**: the status column was 100px wide, but cell padding stacks (16px from `cellStyle` on the td plus Element Plus' default 12px on `.cell`), leaving ~44px of content width, and the default `.cell` `word-break: break-all` split `已完成` into `已完/成`. The column is widened to 108px to match the history table, the badge + label are wrapped in a flex cell, and the same truncation recipe is applied so over-long labels (e.g. ja `キャンセル済み`) ellipsize instead of wrapping. (`frontend/src/views/Dashboard.vue`) + +- **Store timestamp round-trip test is precision-aware and deterministic**: `review_row_codec_round_trip` previously asserted exact `DateTime` equality, which flaked on backends whose TEXT timestamp storage truncates sub-second precision. The test now compares with a precision-aware tolerance and fixed inputs, keeping the round-trip guarantee stable across SQLite/PostgreSQL. (`src/store/sqlx.rs`) +- **Test assert messages no longer interpolate `api_key` (CodeQL)**: assertion failure messages in `persist.rs` and `sqlx.rs` tests embedded the API key value, tripping CodeQL's clear-text-logging rule; the messages now refer to the key without printing it. Behaviour of the assertions is unchanged. (`src/server/api/config/persist.rs`, `src/store/sqlx.rs`) +- **`cargo audit` ignores RUSTSEC-2023-0071 with justification**: the rsa 0.9 Marvin-attack advisory fires on `sqlx-mysql`'s optional `rsa` dependency, which is pinned in Cargo.lock but never compiled (the workspace enables sqlx without `mysql`; verified via `cargo tree -i rsa`) and no patched rsa release exists. The ignore is scoped in `.cargo/audit.toml` with the full reasoning and a revisit condition. (`.cargo/audit.toml`) + +### Known issues +- GitLab 19.x system hooks do not deliver MR note events (even with `note_events=true`): real-time comment ingestion requires a project-level webhook; under system-hook-only deployments the pre-review API pull covers the gap. +- Deferred to 0.10.x: config-directory isolation is incomplete, the legacy `webhookSecret` masking policy needs alignment, and `/system/health` has no deep DB probe yet. + ## [0.9.50] - 2026-09-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index c51d8fa..fed4f10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -131,6 +137,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -223,6 +238,12 @@ 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 = "bit-set" version = "0.5.3" @@ -243,6 +264,9 @@ name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -270,6 +294,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.0" @@ -393,6 +423,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -417,6 +453,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -426,6 +477,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -488,6 +548,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -534,6 +605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -558,6 +630,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -569,6 +647,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "equivalent" @@ -586,6 +667,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.13.0" @@ -629,12 +731,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -705,6 +824,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -827,12 +957,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -851,6 +1001,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1115,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1215,6 +1374,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1222,6 +1384,35 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[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.4.15" @@ -1282,6 +1473,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.2" @@ -1378,12 +1579,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1391,6 +1627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1421,6 +1658,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1439,7 +1682,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1454,6 +1697,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1466,6 +1718,39 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "poly1305" version = "0.8.0" @@ -1768,6 +2053,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.4" @@ -1840,7 +2134,7 @@ dependencies = [ [[package]] name = "review-engine" -version = "0.9.50" +version = "0.10.0" dependencies = [ "anyhow", "async-trait", @@ -1871,6 +2165,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "sha2", + "sqlx", "subtle", "tar", "tempfile", @@ -1903,6 +2198,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2141,6 +2456,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2198,6 +2524,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2215,6 +2551,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -2226,12 +2565,238 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2644,12 +3209,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -2726,6 +3312,12 @@ 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" @@ -2766,6 +3358,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2863,6 +3461,16 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2953,6 +3561,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2989,6 +3606,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3022,6 +3654,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3034,6 +3672,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3046,6 +3690,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3070,6 +3720,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3082,6 +3738,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3094,6 +3756,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3106,6 +3774,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 420a226..39d760a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "review-engine" -version = "0.9.50" +version = "0.10.0" license = "Apache-2.0" edition = "2021" @@ -102,6 +102,15 @@ futures = "0.3" # Async trait support async-trait = "0.1" +# Database access: sqlx 0.8 with the Any pool so one code path serves both +# PostgreSQL (primary, via DATABASE_URL) and embedded SQLite (fallback). +# Placeholders are written as `?` everywhere; the Any driver translates them +# for Postgres. See design/persistence.md §3.1 for the dialect rules. +# Two additions on top of the design-doc list, both because of +# `default-features = false`: `any` (the Any driver is otherwise not compiled +# in) and `macros` (`sqlx::migrate!` is gated on it). +sqlx = { version = "0.8", default-features = false, features = ["any", "runtime-tokio", "postgres", "sqlite", "migrate", "macros", "chrono", "uuid", "json"] } + # Token counting for LLM context window management tiktoken-rs = "0.7" diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..54276c2 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,120 @@ +# ═══════════════════════════════════════════════════════════════════════ +# Dockerfile.dev — local/dev preview image built from source +# For contributors (e.g. on macOS arm64) who want to build a Linux image +# from the current checkout and preview branch changes locally. +# +# Differences from the root Dockerfile (zero-build, downloads GitHub +# Release assets): +# - adds a builder stage: compiles this checkout inside the container +# with rust:1.96-bookworm (macOS arm64 has no cross toolchain, so the +# Linux binary must be produced by the in-container builder) +# - binary: COPY --from=builder (replaces the release-download RUN block) +# - frontend dist: COPY the locally built frontend/dist (contains the +# latest changes of this branch) +# The runtime stage is aligned section-by-section with the root Dockerfile +# to keep deployment semantics identical. +# Note: requires the companion Dockerfile.dev.dockerignore (the root +# .dockerignore excludes frontend/dist; without the override the COPY of +# the frontend artifacts would fail). +# ═══════════════════════════════════════════════════════════════════════ + +# ═══════════════════════════════════════════════════════════════════════ +# Stage 0: Builder (compile the Linux binary in-container) +# bookworm glibc 2.36, forward-compatible with the ubuntu 24.04 runtime +# (glibc 2.39). +# ═══════════════════════════════════════════════════════════════════════ +FROM rust:1.96-bookworm AS builder + +WORKDIR /build + +# Single crate, no build.rs, no workspace members: manifest + src/ suffice +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +# Referenced by src/config/defaults.rs via include_str! — must exist at +# compile time (the root .dockerignore already exempts this file) +COPY docs/code-audit-default.toml ./docs/code-audit-default.toml +# Since v0.10.0, src/store/mod.rs uses sqlx::migrate!("./migrations") — +# must exist at compile time +COPY migrations ./migrations + +RUN cargo build --release --locked + +# ═══════════════════════════════════════════════════════════════════════ +# Stage 1: Runtime (aligned section-by-section with the root Dockerfile) +# ═══════════════════════════════════════════════════════════════════════ +FROM ubuntu:24.04 + +# Optional apt mirror for contributors with poor connectivity to the +# official Ubuntu archives (e.g. --build-arg APT_MIRROR=mirrors.aliyun.com). +# Empty (default) = official sources. When set, the archive/security hosts +# (x86_64) and the ports host (aarch64, ubuntu-ports) are rewritten in both +# the legacy sources.list and the deb822 ubuntu.sources — Ubuntu 24.04 +# keeps the real entries in the latter. +ARG APT_MIRROR="" +RUN if [ -n "${APT_MIRROR}" ]; then \ + echo ">> APT_MIRROR set: rewriting apt sources to ${APT_MIRROR}"; \ + for f in /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources; do \ + [ -f "$f" ] || continue; \ + sed -i -e "s|archive.ubuntu.com|${APT_MIRROR}|g" \ + -e "s|security.ubuntu.com|${APT_MIRROR}|g" \ + -e "s|ports.ubuntu.com|${APT_MIRROR}|g" "$f"; \ + done; \ + fi + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + openssh-client \ + curl \ + tar \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Create non-root user (fixed UID/GID 9001, same as the root Dockerfile) +RUN groupadd -r -g 9001 review-engine && useradd -r -u 9001 -g review-engine -d /app -s /sbin/nologin review-engine + +WORKDIR /app + +# ── Local build artifact: binary (replaces the release-download RUN block) ── +COPY --from=builder /build/target/release/review-engine /usr/local/bin/review-engine +RUN /usr/local/bin/review-engine --version + +# ── Local build artifact: frontend dist (replaces the frontend-dist.tar.gz +# download RUN block) ── +# IMAGE_DIST=/app/frontend-dist-image in entrypoint.sh: on first start the +# contents are synced from here into the /app/frontend/dist volume; the +# image keeps a copy as the sync source. +COPY frontend/dist /app/frontend-dist-image +RUN ls -la /app/frontend-dist-image + +# reng alias (dynamic command name via argv[0]; a symlink is enough) +RUN ln -s /usr/local/bin/review-engine /usr/local/bin/reng + +# Copy the entrypoint script; kept as the container entrypoint for future +# extension +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Create config and report directories +RUN mkdir -p /app/config /app/reports /app/.ssh /app/bin /app/frontend/dist && \ + chown -R review-engine:review-engine /app + +# Switch to non-root user +USER review-engine + +# Expose ports +EXPOSE 443 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Default environment variables +ENV REVIEW_ENGINE_CONFIG_DIR=/app/config +ENV REVIEW_ENGINE_REPORT_DIR=/app/reports +ENV RUST_LOG=info + +# Entry: start the service +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["serve", "--bind", "0.0.0.0", "--port", "8080"] diff --git a/Dockerfile.dev.dockerignore b/Dockerfile.dev.dockerignore new file mode 100644 index 0000000..31ff88d --- /dev/null +++ b/Dockerfile.dev.dockerignore @@ -0,0 +1,87 @@ +# Companion to Dockerfile.dev: root .dockerignore minus the frontend/dist +# exclusion (the dev preview image COPYs the locally built frontend). +# migrations/ is not excluded here, satisfying the builder-stage +# `COPY migrations ./migrations` (sqlx::migrate! compile-time embed). +# Git ignore patterns for Docker build context +# This prevents unnecessary files from being copied into the Docker build + +# Rust build artifacts +target/ +**/*.rs.bk +Cargo.lock.bak + +# Git +.git/ +.gitignore +.github/ + +# IDE and editors +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Documentation (not needed at runtime) +docs/*.md +!docs/code-audit-default.toml + +# Review reports and runtime data +reports/ +*.log +review-engine*.log + +# Docker files themselves (avoid recursion issues) +Dockerfile +docker-compose.yml +docker-compose.*.yml +.dockerignore +.env +.env.* +!.env.example + +# CI/CD +.github/workflows/ + +# Test data and artifacts +test_data/ +tmp/ +temp/ + +# Python bindings (not included in SaaS build) +python/ +*.py +*.pyc +__pycache__/ + +# Node.js (if any frontend exists) +# A bare `node_modules/` only matches the context root (BuildKit verified) and +# would not exclude the nested frontend/node_modules: after a local npm install +# it would be copied into the image by `COPY frontend/ ./`, clobbering the +# in-container linux-musl dependencies (@rolldown/binding-* platform mismatch +# breaks the build). `**/node_modules` covers both root and any depth; +# node_modules is a pure build artifact and must never enter the image. +**/node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Backup files +*.bak +*.backup +*.old + +# Local development configs +.code-audit-config.toml +.pr-agent.toml + +# Misc +*.md +!README.md +CHANGELOG.md +LICENSE + +# Plan and review artifacts +plan.md +review-feedback-*.md diff --git a/README.md b/README.md index 2545f73..dfa8906 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,9 @@ binary; pulling a newer image also works, the entrypoint syncs it to the volumes on next start), and plain binary installs are replaced atomically (backup + smoke test + rollback on failure). +> **Upgrading to 0.10.x?** It is a one-way, automatic migration — back up your +> config directory first: [`docs/migration-0.10.md`](docs/migration-0.10.md). + For a detailed walkthrough, see [`docs/getting-started.md`](docs/getting-started.md). For full CLI options, environment variables, LLM providers, and config reference, see [`docs/configuration.md`](docs/configuration.md), [`docs/integrations/`](docs/integrations/), and [`docs/rest-api.md`](docs/rest-api.md). diff --git a/design/persistence.md b/design/persistence.md new file mode 100644 index 0000000..0f51a8e --- /dev/null +++ b/design/persistence.md @@ -0,0 +1,378 @@ +# 持久化设计(0.10.0) + +> 状态:草案 v1(杜衡,2026-09-03) +> 范围:ReviewEngine 0.10.0 数据库持久化。本文只做设计,不含实现代码;SQL 为建表草案,可直接誊入 `migrations/`。 + +## 1. 目标与已拍板决策 + +以下决策已拍板,本文不重新论证,只在既有约束内做落地设计: + +1. **部署形态**:PG 为主、SQLite 兜底。有 `DATABASE_URL` 走 PostgreSQL;无则内嵌 SQLite,默认路径 `~/.config/review-engine/review.db`。 +2. **访问层**:sqlx 0.8,运行时 `Any` 池(features: `runtime-tokio`, `postgres`, `sqlite`, `migrate`, `chrono`, `uuid`, `json`)。 +3. **评论回流**:GitLab Note webhook 实时入库为主,评审前主动拉取 notes API 兜底。 +4. **配置入库**:git 平台 / LLM 实例配置从 `ui-state.toml` 搬进数据库,含一次性透明迁移。 + +0.10.0 要解决的具体问题: + +- 重启后评审历史丢失(TaskStore 纯内存)。 +- 配置持久化依赖单个 TOML 文件,无并发写保护、无历史语义。 +- MR 讨论(人类评论 + 历史评审结论)不进评审上下文,二次评审重复劳动。 +- LLM API key 目前明文落盘(`persist.rs:27-29` 明写的威胁模型例外),借入库一并收进加密边界。 + +## 2. 现状结论(已核实) + +### 2.1 任务存储 + +- `src/server/task_queue.rs:116` `TaskStore`:`HashMap` + broadcast SSE;`TaskEntry` 字段见 68-84 行(`task_id/state/created_at/started_at/completed_at/result/error/request/source_meta/progress/expert_name`)。 +- reaper:每 300 s 清 `completed_at` 超 30 分钟的条目(135-149 行),手动路径 `cleanup_expired()`(160-167 行)。 +- 状态机:`Pending → Running → (Completed | Failed)`,`Cancelled` 终态且 `update` 对其早退(256-261 行)。`retry` 仅允许 `Failed → Pending`(451-475 行)。 +- 结果以 `serde_json::Value`(序列化的 `ReviewOutput`,`src/models/finding.rs:143`)挂 `result`;`ReviewOutput` 含 `reports: Vec`、`aggregated: Option`、`consolidated: Option`。 + +### 2.2 API 投影 + +- `src/server/api/review/task.rs:24-46` `task_to_status`、48-104 `build_review_detail`、106-124 `build_review_list_item`:`TaskEntry → API 响应` 的唯一转换点,入库后改造面集中在这三个函数与 `handlers.rs` 的 `list_reviews`(298 行)/`get_review`(207 行)。 +- 分页参数结构 `ListParams` 已含 `status/page/per_page/q/project/repository/date_from/date_to`(task.rs:155-165),0.10.0 不需要新增参数,只需要换数据源。 +- **搭车 bug 确认**:task.rs:75 `raw_comment` 只取 `output.aggregated.markdown`;团队评审 `aggregated=None` 时详情「完整评论」tab 空态。可用的 fallback 是 `output.consolidated.assessment.tl_dr`(`src/models/mod.rs:102`,`ConsolidatedReport` 结构见 `src/team/lead_consolidator.rs:62-80`)。 + +### 2.3 配置持久化 + +- `src/server/api/config/persist.rs`:`UiStateFile` 四区段(`ui` 投影 / `llm: Vec` / `git_platforms: Vec` / `gitlab: PersistedGitlabConfig`,52-78 行)。`PUT /api/v1/config` 热生效 + 落盘;启动经同一 `apply_ui_config` 回放(366-375 行)。 +- **env/CLI 来源值永不落盘**:`UiStateEnvOverrides`(346-361 行)+ `from_applied` 的 `is_env_derived_llm` / `strip_env_value` 过滤(93-178 行)。此原则入库后必须原样保留——入库只是换 `save_ui_state` 的落点,过滤逻辑不动。 +- git 凭据已加密(`encrypt_ui_state`,231-242 行);**LLM API key 明文**(`llm` 区段不在加密范围,27-29 行注释明写)。 + +### 2.4 加密边界 + +- `src/config/secrets.rs`:ChaCha20-Poly1305,`enc:` 前缀 + 配置目录 `secrets.key`(32 字节,0600,原子写)。`decrypt_secret` 对无 `enc:` 前缀的值透传(126-128 行),天然兼容遗留明文。 +- 入库后加解密仍只在持久化边界发生,密钥文件位置不变(沿用 `key_path_for`,40-45 行)。 + +### 2.5 Webhook + +- **更正任务描述的一处事实**:Note Hook 处理器已存在——`handle_note_hook`(`src/server/gitlab/hooks.rs:408`)目前用于 `/review`、`/describe` 命令触发评审,含 allowlist 门禁与 URL 重写。0.10.0 的新工作不是"新增 Note 事件类型",而是**在既有处理器里加 note 入库**,并在评审 worker 侧消费。 +- notes API 能力已具备:`list_discussions`(`src/git_provider/gitlab/client.rs:587`)、`post_note`(595)、`get_current_user_id`(144,回流自噬过滤要用)。 + +### 2.6 其他挂点 + +- `AppState`(`src/server/state.rs:208`)已有 `Option>` 挂可插拔组件的先例(`task_store: Option>` 216 行、`feedback_store` 239 行)。DB handle 沿用同一模式:`pub db: Option>`。 +- `TaskStore::new()` 会被无 tokio runtime 的同步单测经 `AppState::new()` 触达(task_queue.rs:129-134 注释),DB 注入不能破坏这条路径——用 `Option` + setter,默认 `None` 即 0.9 行为。 +- `Cargo.toml` 当前无 sqlx 依赖;`async-trait`、`chrono`、`uuid`、`serde_json` 均已在依赖树中。 + +## 3. Schema 定稿 + +单目录 `migrations/`,首版一个文件 `0001_init.sql` 建全部 7 表。sqlx `migrate!()` 宏内嵌,`Migrator::run(&pool)` 启动时执行。 + +### 3.1 方言差异点(设计约束,先于 DDL) + +`Any` 池双后端共用同一套 SQL,以下约束逐条对应后面的 DDL 写法: + +| 主题 | PG | SQLite | 本文的取舍 | +|---|---|---|---| +| 占位符 | 原生 `$1..$n` | `?` | **统一写 `?`**,执行前由 store 层改写。**更正(0.10.0 E2E 实证)**:「Any 驱动内部为 PG 做翻译」的假设被证伪——sqlx 0.8.6 的 Any 驱动把 SQL 原样透传给 PG 解析器,`?` 直接 `42601 syntax error`(`sqlx-core-0.8.6/src/any/` 无任何 placeholder/rewrite 逻辑;`migrate` 走底层真实驱动的 ledger,不受影响)。因此 store 层自带重写器 `src/store/placeholders.rs`:PG 把顶层 `?` 依次改写为 `$1..$n`(正确跳过 `'...'` 字符串字面量含 `''` 转义、`"..."` 标识符、`--` / `/* */` 注释内的 `?`),SQLite 原样透传;所有语句经 `SqlxStore::sql` / `adapt_sql` 收口一次,不逐条手改 | +| upsert | `ON CONFLICT ... DO UPDATE/NOTHING` | 同语法(≥3.24) | 两端一致,直接用;sqlx 内置 libsqlite3 版本远高于此 | +| `RETURNING` | 支持 | ≥3.35 支持 | **一律不用**。主键全部由 Rust 侧生成(UUID v4),写后无需回读;避免 Any 下两端 decode 行为差异 | +| JSON 列 | 原生 JSONB | TEXT | **DDL 用 TEXT,绑定用 `String`**:store 层 `serde_json::to_string` 后按 TEXT 绑定,读出再 `from_str`。若声明 PG JSONB 列而 SQLite 是 TEXT,`serde_json::Value` 在 PG 端会按 JSONB 编码、绑到 TEXT 列报类型错——应用层序列化是唯一两头都稳的做法 | +| 布尔 | 原生 BOOL | 0/1 | **DDL 用 `INTEGER` 存 0/1**,绑定侧转 `bool`。原方案 `BOOLEAN` 被证伪:Any 驱动对 SQLite 只认 Null/Int4/Integer/Float/Blob/Text 五类声明类型,`BOOLEAN` 列读出直接报错(验证点 A 实测) | +| 时间戳 | TEXT | TEXT | **DDL 用 TEXT**,存 Rust 侧 chrono 生成的固定宽度 RFC 3339 UTC 串(如 `2026-09-03T10:00:00.000000Z`),**字典序 == 时间序**。原因(验证点 A/D 落地结论):sqlx 0.8 Any 驱动没有 chrono 的 `Type` 实现,且 SQLite 端拒绝对声明类型为 `TIMESTAMP` 的列做 String 解码(smoke test 实测);PG 端 TEXT 列无需 CAST | +| 模糊搜索 | `ILIKE` | `LIKE` 仅 ASCII 不敏感 | 统一 `LOWER(col) LIKE LOWER(?)`,行为两端一致 | +| 外键 | 默认启用 | 需 `PRAGMA foreign_keys=ON` | SQLite 连接串带 `?...` 参数或建池后执行 PRAGMA(见 §4.3) | +| 自增主键 | SERIAL/IDENTITY | AUTOINCREMENT | **都不用**:全部自然键/UUID 文本主键,绕开方言差异 | + +补充:`uuid` 与 `chrono` 同样无 `Type` 实现,UUID 主键按 TEXT 绑定(值仍由 Rust 侧生成,同 `RETURNING` 行约定),无影响。 + +### 3.2 建表 SQL 草案(`migrations/0001_init.sql`) + +```sql +-- ── 评审任务(TaskEntry 的持久投影)── +CREATE TABLE reviews ( + task_id TEXT PRIMARY KEY, -- UUID v4, Rust 侧生成 + state TEXT NOT NULL, -- pending|running|completed|failed|cancelled + source_meta TEXT NOT NULL DEFAULT '{}', -- SourceMeta JSON + -- 从 source_meta 物化的过滤列:分页过滤要走索引,JSON 文本抽取两端写法不同, + -- 写穿时由 Rust 同步维护,读路径不碰 JSON 抽取函数。 + project TEXT, + repository TEXT, + request TEXT, -- 序列化 ReviewRequest(无凭据,见 task.rs:175-178) + result TEXT, -- ReviewOutput JSON + error TEXT, + progress INTEGER, -- 0-100,仅终态时快照;进行中的实时进度不入库 + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT +); +CREATE INDEX idx_reviews_created_at ON reviews (created_at DESC); +CREATE INDEX idx_reviews_state ON reviews (state); +CREATE INDEX idx_reviews_project ON reviews (project); + +-- ── 专家子报告(从 ReviewOutput.reports 拆行,便于按专家查询)── +CREATE TABLE expert_reports ( + task_id TEXT NOT NULL REFERENCES reviews(task_id) ON DELETE CASCADE, + expert_name TEXT NOT NULL, + report TEXT NOT NULL, -- ExpertReport JSON + duration_ms INTEGER, -- 首版可为 NULL:TaskEntry 目前不记 per-expert 耗时, + -- 需执行器补计时后再填充(见 §5.4 注意点) + created_at TEXT NOT NULL, + PRIMARY KEY (task_id, expert_name) +); + +-- ── MR 讨论(Note webhook + notes API 兜底共用的幂等存储)── +CREATE TABLE mr_discussions ( + platform TEXT NOT NULL, -- GitPlatformConfig.name(实例级隔离) + project TEXT NOT NULL, -- path_with_namespace + mr_iid BIGINT NOT NULL, + note_id BIGINT NOT NULL, + author TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + created_at TEXT NOT NULL, -- note 的创建时间,非入库时间 + ingested_at TEXT NOT NULL, -- 入库时间,排序兜底 + PRIMARY KEY (platform, project, mr_iid, note_id) -- 幂等键 +); +CREATE INDEX idx_mr_discussions_mr ON mr_discussions (platform, project, mr_iid, created_at); + +-- ── 注入上下文(支撑 LLM 前缀缓存复用)── +CREATE TABLE review_contexts ( + task_id TEXT NOT NULL REFERENCES reviews(task_id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'mr_discussions' | 未来扩展 + content TEXT NOT NULL, -- 渲染后的上下文本(前缀稳定) + content_hash TEXT NOT NULL, -- sha256 hex;同 MR 二次评审 hash 相同即复用 + token_estimate INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (task_id, kind) +); +CREATE INDEX idx_review_contexts_hash ON review_contexts (content_hash); + +-- ── git 平台实例(ui-state.toml 的 [[git_platforms]] 区段入库)── +CREATE TABLE git_platforms ( + id TEXT PRIMARY KEY, -- UUID v4;业务合并键仍是 name(与内存模型一致) + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL DEFAULT 'gitlab', + base_url TEXT NOT NULL DEFAULT '', + internal_base_url TEXT NOT NULL DEFAULT '', + token TEXT NOT NULL DEFAULT '', -- enc: 加密 + webhook_secret TEXT NOT NULL DEFAULT '', -- enc: 加密 + webhook_signing_secret TEXT NOT NULL DEFAULT '', -- enc: 加密 + enabled INTEGER NOT NULL DEFAULT 1, -- 布尔列用 INTEGER 0/1,见 §3.1 布尔行 + raw TEXT NOT NULL DEFAULT '{}', -- 扩展兜底:allowed_projects 等未列化字段 + updated_at TEXT NOT NULL +); + +-- ── LLM 实例([[llm]] 区段入库;api_key 顺带收进加密边界)── +CREATE TABLE llm_providers ( + id TEXT PRIMARY KEY, -- UUID v4 + provider TEXT NOT NULL, -- 对齐 LLMConfig.provider(brief 中的 "name") + model TEXT NOT NULL DEFAULT '', + api_base TEXT NOT NULL DEFAULT '', + api_key TEXT NOT NULL DEFAULT '', -- enc: 加密(新增:0.9 明文落盘) + max_tokens INTEGER NOT NULL DEFAULT 4096, + temperature DOUBLE PRECISION NOT NULL DEFAULT 0.7, -- 必须 float8:PG 的 REAL 是 float4,store 层按 f64 解码会 mismatched types(PG E2E 实测);SQLite 的 REAL affinity 同为 8 字节,两端兼容 + raw TEXT NOT NULL DEFAULT '{}', -- 扩展兜底:disable_thinking 等 + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX idx_llm_providers_provider ON llm_providers (provider); + +-- ── 应用设置(ui 投影 / legacy gitlab 字段 / rules / advanced 等)── +CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, -- 如 'ui'、'gitlab'、'rules'、'advanced' + value TEXT NOT NULL, -- JSON + updated_at TEXT NOT NULL +); +``` + +说明: + +- legacy `gitlab` 三个凭据(`PersistedGitlabConfig`)进 `app_settings`(key=`gitlab`,值 JSON,三个字段均 `enc:`),不开新表——它是遗留域,未来会被 `git_platforms` 吸收。 +- `git_platforms.id` / `llm_providers.id` 用 UUID 而非自增,原因见 §3.1 自增主键行。 +- `reviews.request` 沿用现有约定:序列化的是无凭据 `ReviewRequest`,token 永不入库(task.rs:175-178 注释承诺的语义,入库后不变)。 + +## 4. Rust 抽象层设计 + +### 4.1 模块结构 + +``` +src/store/ + mod.rs — SqlxStore::connect(url) / ::connect_default()、方言探测、测试 helper(new_in_memory) + traits.rs — ReviewStore / ConfigStore / DiscussionStore 三个 trait + sqlx.rs — SqlxStore { pool: AnyPool } 及三个 trait 的实现;所有 SQL 集中在此文件 + rows.rs — 行结构 ⇄ 领域结构(TaskEntry/UiStateFile/…)的编解码;enc: 加解密边界在此 +migrations/ + 0001_init.sql +``` + +`src/lib.rs` 加 `pub mod store;`。`AppState` 加 `pub db: Option>`(沿用 `task_store` 的 Option 先例,state.rs:216)。 + +### 4.2 trait 取舍:三个域 trait,一个实现 + +**推荐**:`ReviewStore`(reviews / expert_reports / review_contexts)、`ConfigStore`(git_platforms / llm_providers / app_settings)、`DiscussionStore`(mr_discussions)三个 trait,由同一个 `SqlxStore` 实现,共享一个 `AnyPool` 和 §3.1 的方言封装。 + +理由: + +- 三类调用方天然不相交:task_queue 只碰评审域、config put/replay 只碰配置域、note hook / worker 只碰讨论域。按域拆分后每个调用方只见自己的方法面,单测 mock 面最小。 +- 一个 `SqlxStore` 实现三者,避免了"每表一个 Repo"的样板爆炸(7 表 7 trait 没有收益)。 +- 项目已有 `async-trait` 依赖(Cargo.toml:103),trait object 的装箱开销不在热路径上(热路径仍是内存 HashMap + SSE,见 §5)。 + +**否决的备选**: + +- **单一大 `Store` trait**:任何一域加方法都动全局接口;mock 一个域要实现全部方法,测试成本高。否决。 +- **不用 trait、调用方直接依赖具体 `SqlxStore`**:这是最简方案,差点入选。否决原因是两处调用方(`PUT /config` 持久化、note hook 幂等入库)的单测需要注入假实现来断言"写库被调用且内容正确",若绑死具体类型就只能起真 DB。In-memory SQLite 能缓解但消不掉(连接池时序、加解密边界都要真跑),保留 trait 的成本很低。 +- **每表一个 Repo trait**:过度碎片化,否决。 + +### 4.3 `sqlx::Any` 双后端可行性 + +结论:可行,但必须守住 §3.1 的封装纪律。落地要点: + +- 连接:`AnyPoolOptions` + `sqlx::any::install_default_drivers()`;`DATABASE_URL` 存在且以 `postgres://`/`postgresql://` 开头 → PG;否则 SQLite。**`DATABASE_URL` 设置了但连接失败 → 启动显式报错退出,绝不静默落 SQLite**(数据写到意外的地方比启动失败更难收拾,见 §9)。 +- SQLite URL 组装:默认 `sqlite://{config_dir}/review.db?mode=rwc`,`config_dir` 沿用 `resolve_ui_state_path` 的同套解析(persist.rs:214-225);建池后执行 `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`。 +- 迁移:`sqlx::migrate!("./migrations")` + `Migrator::run(&pool)`。**AnyPool 上的 migrate 支持需要落地时先跑 smoke test 确认**(验证点 A):建空库跑 `0001_init.sql`,再跑一次确认幂等跳过。 +- 测试 helper:`SqlxStore::new_in_memory()` 用 `sqlite::memory:` + `max_connections(1)`(连接池 >1 时每个连接是独立的内存库,这是 SQLite 内存模式的经典坑),供不写文件的单元测试使用。 + +## 5. TaskStore 写穿方案 + +### 5.1 原则 + +内存仍是热路径与 SSE 的唯一来源;DB 是历史的唯一来源。状态迁移**同步 await 写库**(保证重启恢复语义正确),写库失败不阻塞评审。 + +### 5.2 逐方法写穿点 + +`TaskStore` 新增 `db: Option>`(构造后 setter 注入,`None` 即 0.9 纯内存行为,同步单测路径不受影响): + +| 方法(task_queue.rs 行号) | 写库动作 | 说明 | +|---|---|---| +| `create_with_request`(179) | INSERT reviews(state=pending) | | +| `start`(212) | UPDATE state=running, started_at | | +| `set_progress`(229) | **不写库** | 高频事件,进度对历史无价值;终态写时快照一次 `progress` 即可 | +| `fill_source_meta`(310) | UPDATE source_meta + 物化 project/repository | 每任务至多一次,值得写 | +| `update` 终态分支(249) | UPDATE state/result/error/completed_at/progress + 逐条 INSERT expert_reports | Cancelled 早退分支(259-261)不写 | +| `delete`(428,cancel 语义) | UPDATE state=cancelled, completed_at | | +| `retry`(451) | UPDATE state=pending, error=NULL, completed_at=NULL | | + +写库失败处理:记 `tracing::error!` + 继续。终态写失败做一次立即重试,仍失败则放过——历史页少一条可接受,评审本身不能死。 + +### 5.3 重启恢复语义 + +- 启动时(migrate 之后、HTTP 监听之前)执行:`UPDATE reviews SET state='failed', error='interrupted: server restarted', completed_at=? WHERE state IN ('pending','running')`。 +- **取舍:复用 `failed` 而非新增 `interrupted` 状态**。新增 `TaskState::Interrupted` 会涟漪到 `task_status_str`、SSE 事件映射、前端 StatusBadge 颜色表(design.md §6.1),而收益只是列表上一个标签差异;`error` 文案已能表达原因。前端若想区分,可读 `error` 前缀。 +- 中断任务**不自动重新入队**:自动重跑会消耗 LLM 配额且可能重复评论 MR;由用户在历史页手工 retry(`retry` 允许 `Failed → Pending`,interrupted 落库为 failed,天然可 retry)。 +- 终态从库读:历史列表/详情直接查 DB(写穿保证 DB 含进行中任务),内存从空启动,无需回填。 + +### 5.4 reaper 与持久化的关系 + +- 30 分钟 reaper **保持原样、只清内存**(task_queue.rs:135-149),不删库。队列监控/SSE 的视图不变。 +- 注意点:`expert_reports.duration_ms` 首版允许 NULL——`TaskEntry` 不记 per-expert 耗时,执行器补计时是独立小改动,不阻塞本方案。 + +## 6. 配置迁移方案 + +### 6.1 启动序列(严格按序) + +1. 解析 DB URL(§4.3)→ 建池 → `Migrator::run` → **失败即退出非零**(§9)。 +2. 恢复语义扫尾(§5.3 的 interrupted UPDATE)。 +3. **一次性导入**:`git_platforms`、`llm_providers`、`app_settings` 三表合计为空 且 `ui-state.toml` 存在 → 走现有 `load_ui_state`(persist.rs:310,含解密)读入 → 经 `rows.rs` 加密边界写库(git 凭据 + LLM key 全部 `enc:`)→ `std::fs::rename("ui-state.toml", "ui-state.toml.migrated")`。**备份不删**。 + - 导入失败:记 error、**不改名原文件**、回退到现有 `load_and_apply_ui_state` 文件回放路径继续启动——迁移失败不能让用户丢配置。 + - 导入成功的判定要保守:三表全部写入完成才 rename;任何一步失败整体回滚(单事务包裹整个导入)。 +4. 之后经同一 `apply_ui_config` 路径从 DB 回放(替换 `load_and_apply_ui_state` 的数据源,回放逻辑本身不动——热/冷语义一致性是现有设计的优点,保留)。 + +### 6.2 PUT /config 语义 + +- 热生效路径(`apply_ui_config`)完全不变。 +- 持久化落点从 `save_ui_state`(文件)换成 `ConfigStore::save_*`(库)。**`UiStateFile::from_applied` 的 env 过滤逻辑原样复用**(persist.rs:93-178):env/CLI 来源值永不入库,与永不落盘同一原则。 +- 落库失败:返回 500(与今天 `save_ui_state` 失败一致),不静默吞掉。 +- `secrets.key` 位置不变(配置目录下);`rows.rs` 用 `load_or_create_key` 拿同一把钥匙。PG 部署时 key 文件仍在 server 本地配置目录——这是本地对称加密的既有威胁模型,本文不扩大也不缩小它。 + +### 6.3 优先级矩阵(逐配置域) + +| 配置域 | config.toml | DB(ui-state 迁入) | env/CLI | +|---|---|---|---| +| legacy gitlab 凭据(token/webhook_secret/signing_secret) | 仅作初始种子 | **权威源**;空时才用 env 兜底并记 deprecation warn | fallback-only(语义同今天,persist.rs:390-436) | +| LLM provider 列表 | 初始种子 | 覆盖 config.toml | **整体胜出**(`llm_from_env` 时 DB 的 llm 区段不回放,同 persist.rs:443-472) | +| git_platforms | 无来源(待核实:`config/resolver/` 是否承载 platforms,实现前确认) | **唯一权威** | 无来源 | +| ui 投影(rules / advanced / URL / 模型选择) | 初始种子 | 回放覆盖种子 | 无 | + +迁移完成后 `UiStateEnvOverrides` 机制保留原名原义,只是过滤的落点从文件换成库。 + +## 7. Note webhook 入库 + 评审前注入 + +### 7.1 入库(挂在既有 `handle_note_hook`,hooks.rs:408) + +在解析之后、命令判断之前插入入库逻辑(命令评论也是讨论历史的一部分,同样入库): + +- **payload 关键字段**:`object_kind`(须为 `"note"`)、`object_attributes.id`(note_id)、`object_attributes.noteable_type`(须为 `"MergeRequest"`,Commit/Issue/Snippet note 忽略)、`object_attributes.note`(body)、`object_attributes.created_at`、`user.username`/`user.name`(author)、`merge_request.iid`(缺失时回退 `object_attributes.url` 尾部解析,复用 `mr_iid_from_url`,hooks.rs:394)、`project.path_with_namespace`。platform 取匹配到的 `GitPlatformConfig.name`,未匹配用 `"default"`。 +- **幂等**:主键 `(platform, project, mr_iid, note_id)`,`ON CONFLICT DO UPDATE SET body=excluded.body, author=excluded.author`——webhook 重投自然去重,note 被编辑则更新。 +- **回流自噬防护(必须做)**:本服务自己 `post_note`/`post_comment` 发的评审报告也会触发 Note hook。不入库规则:(a) `object_attributes.note` 以本服务报告固定前缀开头;(b) 或 author id 等于 `get_current_user_id()`(client.rs:144)的结果(启动时解析一次并缓存)。两条件任一命中即跳过入库(命令触发的 `/review` note 除外——那是用户意图)。实现时确认 (a) 的报告前缀常量位置。 +- 系统 note(`object_attributes.system=true`,如 "added 1 commit")**入库但打标意义不大**——按已拍板 schema 无 `system` 列,决策:**跳过 system note**,它们是噪音不是讨论。 + +### 7.2 评审前注入(worker 侧) + +在评审流水线取 diff 之后、专家执行之前(`resolve.rs` / `run_review_common` 路径): + +1. 按 `(platform, project, mr_iid)` 查 `mr_discussions`。 +2. **兜底**:查询结果为空(或该 MR 从未见过)→ 调 notes API(`list_discussions`,client.rs:587)拉全量 → upsert 入库 → 用拉取结果。 +3. **组织成追加式上下文**:按 `(created_at, note_id)` 升序渲染确定性模板,固定头部(如 `## MR Discussion History`)+ 逐条 `- [author @ created_at]: body`;body 截断上限(建议 2000 字符/条)防爆 context。**前缀稳定是硬要求**:同一 MR 历史不变时渲染输出逐字节相同,`content_hash` 相同 → LLM 前缀缓存命中;新评论只追加在尾部。 +4. 渲染结果 + hash 写入 `review_contexts`(`ON CONFLICT (task_id, kind) DO UPDATE`);hash 相同的后续任务可直接复用渲染文本。 +5. **降级**:DB 不可用、notes API 失败、渲染超限——全部只记 warn,评审继续,不带讨论上下文。评论注入是增强,不是评审的前置条件。 + +## 8. API / 前端影响面 + +### 8.1 后端 + +- `list_reviews`(handlers.rs:298):数据源从 `TaskStore.list`(内存)换为 DB 查询。**参数与响应 shape 不变**(`ListParams` 已齐,task.rs:155-165):`page` 默认 1、`per_page` 默认 20、上限 100;`q` 用 `LOWER(source_meta) LIKE LOWER(?)`(§3.1);`project`/`repository` 走物化列等值;`date_from/to` 走 `created_at` 范围;`COUNT(*)` 出 total。进行中任务 DB 已有(写穿),无需内存合并。 +- `get_review`(handlers.rs:207):改读 DB;若该 task_id 恰在内存中(进行中),叠加实时 `progress`/`expert_name` 两个字段后返回。`task_to_status`/`build_review_detail`/`build_review_list_item` 三个投影函数改为接受"DB 行结构",签名变化收敛在 `api/review/task.rs` 一个文件。 +- 新增(如评审前注入需要暴露):无。Note 数据 0.10.0 不开查询 API。 + +### 8.2 前端 + +- 历史页(`/history`,design.md §2):沿用服务端分页 + `ElPagination`(total 已有),每页 20。首版不做无限滚动。 +- 若要滚动加载(可选增强):IntersectionObserver 哨兵 div + page 累加 append 到列表;filter/q 变化时重置 page=1 并清空已加载;SSE 的 `review.completed` 事件触发第一页刷新而非整表重载(配合 design.md §7.5 的 flash-border)。 +- 详情页:无结构变化(字段不变),但历史记录现在重启后仍在,注意加载态/404 处理走既有约定(design.md §10)。 + +### 8.3 搭车修复:团队评审详情空态 + +`build_review_detail`(task.rs:75)`raw_comment` fallback 链改为: + +``` +output.aggregated.map(|a| a.markdown) + .or_else(|| output.consolidated.map(|c| c.assessment.tl_dr)) + .filter(|s| !s.is_empty()) +``` + +`tl_dr` 字段已确认存在(`src/models/mod.rs:102`)。加一条 `aggregated=None + consolidated=Some` 的单测。 + +## 9. 风险与回退 + +| 风险 | 行为 | 回退 | +|---|---|---| +| `DATABASE_URL` 已设但 PG 连不上 | **启动显式报错退出**,绝不静默落 SQLite(数据写到意外的库比不起服务更糟) | 修好连接或显式去掉 `DATABASE_URL` 走 SQLite | +| SQLite 文件不可写(权限/只读盘) | 同样显式报错退出;提供逃生门 `REVIEW_DISABLE_DB=1`(或 `--no-db`)降级为 0.9 纯内存模式,启动时 warn 一条"持久化已禁用" | 设逃生门即回到 0.9 行为 | +| migrate 失败(SQL 写错、库损坏) | 退出非零,不启动 HTTP;DB 未被业务写入 | 0.9.x 二进制不读库,直接回退部署无副作用 | +| ui-state 导入中途失败 | 单事务回滚,原文件**不改名**,回退文件回放路径继续启动 | 下次启动重试导入(幂等:三表为空才触发) | +| `secrets.key` 丢失 | DB 中 `enc:` 值无法解,启动报错指明重录(沿用 secrets.rs 现有错误文案风格) | Web UI 重新录入凭据保存即可 | +| 写穿失败(内存成功、库失败) | error 日志 + 终态一次重试;评审不阻塞 | 历史页少记录,无其他影响 | +| 回滚到 0.9.x | — | DB 文件/PG 表原样保留无害;`ui-state.toml.migrated` 手工改回 `ui-state.toml` 即恢复旧配置源 | + +## 10. 实施清单(依赖序,可逐项验收) + +1. **[祁远]** `Cargo.toml` 加 sqlx 0.8(指定 features);`src/store/` 骨架 + `migrations/0001_init.sql`;`SqlxStore::connect/new_in_memory` + migrate 接线。**验收**:验证点 A(占位符改写 + AnyPool migrate smoke test)通过,SQLite 内存库建表成功。 +2. **[祁远]** `rows.rs` 加密边界 + `ConfigStore` 实现(§3.2 三张配置表 + §6.2 保存路径)。**验收**:配置 PUT→库→重启回放 round-trip 单测绿;LLM key 在库里是 `enc:`。 +3. **[祁远]** 一次性导入(§6.1 第 3 步,单事务 + rename 备份 + 失败回退)。**验收**:老 `ui-state.toml`(含明文 LLM key)启动一次后:库里有数据、文件改名、GET /config 行为不变、env 覆盖矩阵(§6.3)逐行单测。 +4. **[梁序]** `ReviewStore` + TaskStore 写穿(§5.2)+ 重启恢复(§5.3)。**验收**:跑一个评审 → kill -9 → 重启 → 该任务在库里是 failed/interrupted 文案;完成的评审重启后历史可查。 +5. **[梁序]** `list_reviews`/`get_review` 读库 + 投影函数签名收敛(§8.1)。**验收**:分页/过滤参数行为与 0.9 一致(同参数响应 shape 不变)。 +6. **[梁序]** Note hook 入库(§7.1,含自噬防护)+ worker 注入(§7.2)。**验收**:发 note webhook → 库里有行;重投不重复;编辑则更新;二次评审的 prompt 前缀逐字节稳定(hash 相同)。 +7. **[沈一帆]** 前端历史页适配(§8.2)+ 详情空态修复的 UI 确认。**验收**:重启后历史页有数据;团队评审详情「完整评论」tab 非空。 +8. **[梁序]** `build_review_detail` fallback 链(§8.3)+ 单测。 +9. 全量:fmt / clippy / test 绿;PG 与 SQLite 双后端各跑一遍验收清单。 + +依赖关系:1 → 2,3,4;4 → 5,6;2,3 与 4,5 可并行;6 依赖 1 即可起步(`DiscussionStore` 独立),注入部分依赖 4 的 worker 改造对齐。 + +## 11. 待验证点(实现前确认,不确定处不猜) + +- **验证点 A**(✅ 已验证,sqlx 0.8.6;⚠️ 占位符结论已由 0.10.0 E2E 更正):`Migrator` 在 `AnyPool` 上的行为正常(Any 端委托底层真实驱动,PG ledger 不受影响);但「Any 驱动为 PG 翻译 `?` 占位符」的假设在真实 PG E2E 上被证伪(带绑定参数的 DML 全部 `42601`),落地方案改为 store 层自行重写(`placeholders::rewrite`,见 §3.1 占位符行)。附带结论仍然有效:Any 驱动无 chrono/uuid 的 `Type` 实现,SQLite 拒绝对 `TIMESTAMP` 声明列做 String 解码——时间戳/uuid 一律 TEXT 绑定(§3.1 已按此定稿)。 +- **验证点 B**:`config/resolver/` 是否从 config.toml 承载 `git_platforms`(§6.3 表中标注待核实)。方法:`Grep "git_platforms" src/config/`。 +- **验证点 C**:评审报告的固定前缀常量位置(§7.1 自噬防护条件 a)。方法:`Grep` publisher/output 模块的报告头部模板。 +- **验证点 D**(✅ 已验证):通过。固定宽度 RFC 3339 UTC 串按 TEXT 存储,字典序 == 时间序,`ORDER BY created_at` 排序正确(分页前提成立)。 + +## 12. 验收标准清单 + +- [ ] `cargo fmt --check` / `cargo clippy` / `cargo test` 全绿 +- [ ] PG 与 SQLite 双后端:评审完成后历史落库、可查 +- [ ] 重启 server 后历史列表/详情仍可查(§5.3) +- [ ] `ui-state.toml` 迁移后:配置热生效不变、密钥可用(git token 解密、LLM key 解密且库里为 `enc:`)、原文件备份为 `.migrated` +- [ ] Note Hook 入库:重投幂等、编辑更新、自身评论不入库 +- [ ] 二次评审注入:prompt 中含讨论历史前缀,同 MR 无新评论时 `content_hash` 相同(前缀缓存可命中) +- [ ] 团队评审(`aggregated=null`)详情「完整评论」tab 不再空态 +- [ ] `DATABASE_URL` 指向不可达 PG 时启动显式报错(不静默落 SQLite) diff --git a/docs/README.md b/docs/README.md index 81dcc6d..bccd929 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ This directory contains the user-facing documentation for review-engine. | Document | What you'll learn | |---|---| | [Getting Started](getting-started.md) | Install review-engine, configure an LLM provider, and run your first local or remote review. | +| [Migrating to 0.10](migration-0.10.md) | Upgrade to 0.10.x: backup, the automatic database migration, verification, and troubleshooting. | | [FAQ / Troubleshooting](faq.md) | API token setup, 401 errors, forgotten-token recovery, and container bind-volume pitfalls. | | [Configuration](configuration.md) | How config files are merged, command enablement, expert teams, validation, and the web Configuration UI. | diff --git a/docs/migration-0.10.md b/docs/migration-0.10.md new file mode 100644 index 0000000..5a3d096 --- /dev/null +++ b/docs/migration-0.10.md @@ -0,0 +1,129 @@ +# Migrating to review-engine 0.10 + +0.10.0 adds a persistent storage layer: review history, git platform configs, and LLM provider configs now live in a database (PostgreSQL when `DATABASE_URL` is set, otherwise an embedded SQLite file) instead of only in memory and `ui-state.toml`. + +**Read this first:** + +- The upgrade itself is **fully automatic** — the first 0.10.x boot migrates everything for you. No manual data migration is needed. +- The upgrade is **one-way**. Downgrading back to 0.9.x is **not supported** (see [Downgrading is not supported](#downgrading-is-not-supported)). +- **Back up your config directory before upgrading.** It is your only way back if anything goes wrong. + +--- + +## Before you upgrade + +### 1. Back up the config directory + +Back up the **entire config directory**, not just one file: + +- Plain binary / Homebrew install: `~/.config/review-engine/` (or `$REVIEW_ENGINE_CONFIG_DIR` if you override it). +- Docker (standalone compose): the `./config` and `./auth` bind-mount directories next to your `docker-compose.yml` (`deploy/standalone-compose.yml` mounts them at `/app/config` and `/app/auth`). + +```bash +cp -a ~/.config/review-engine ~/.config/review-engine.backup-0.9 +``` + +The directory holds everything 0.9.x needs to reconstruct your setup: + +| File | What it is | +|---|---| +| `ui-state.toml` | Web-UI managed config: git platforms, LLM providers, rules, UI projection. | +| `secrets.key` | The ChaCha20-Poly1305 key that encrypts `enc:` credentials. **Without it, encrypted credentials are unrecoverable** — never exclude it from the backup. | +| `auth.toml` | SHA-256 digest of your API token (in Docker deployments this lives in the `./auth` volume). | +| `.code-audit-config.toml` | The static CLI/server config file. | +| `reports/` | Saved review reports (untouched by the migration, but back them up anyway). | + +> 0.9.x never created a database, so there is no old database to preserve and no schema conflict — the backup above is all you need. + +### 2. If you set `DATABASE_URL`, check it now + +With 0.10.0, a `DATABASE_URL` that points at an unreachable PostgreSQL is a **hard startup error** — the server refuses to boot rather than silently fall back to SQLite (your data must never land in an unexpected place). Before restarting onto 0.10.x, confirm the database is reachable from the server host. If you previously had a stray `DATABASE_URL` in the environment that you never used, unset it or expect startup to fail until you do (see [Troubleshooting](#troubleshooting)). + +--- + +## Upgrade + +Use whichever path matches your install (all of them are the same binary; the migration runs on the first 0.10.x boot, not during install): + +- **Homebrew**: `brew upgrade review-engine`, then restart the server. +- **Docker**: `docker pull ghcr.io/liewzheng/review-engine:latest` (mainland China: pull from the `ghcr.nju.edu.cn` mirror and re-tag, see [`getting-started.md`](getting-started.md#docker含国内加速)) and recreate the container (`docker compose up -d`). The in-container self-upgrade (web UI **Upgrade** button, or `POST /api/v1/system/upgrade`) works too — the container restarts itself with the new binary. +- **Plain binary**: `reng upgrade` (or re-run `install.sh`). + +Nothing else changes: ports, auth, webhooks, and your `.code-audit-config.toml` all carry over. + +--- + +## What happens on the first 0.10.x boot + +All four steps run automatically at startup, in this order. There is nothing for you to trigger. + +1. **Connect + create the database.** `DATABASE_URL` set (and starting with `postgres://`/`postgresql://`) → PostgreSQL; unset → an embedded SQLite database created at `/review.db` (e.g. `~/.config/review-engine/review.db`). A set-but-unreachable `DATABASE_URL` aborts startup with an explicit error — this step never silently falls back. +2. **Apply schema migrations.** The migration that creates the 7 tables (`reviews`, `expert_reports`, `mr_discussions`, `review_contexts`, `git_platforms`, `llm_providers`, `app_settings`) is compiled into the binary and applied here. Migrations are idempotent: first boot creates the tables, every later boot skips them. A migration failure aborts startup before HTTP comes up. +3. **One-shot import of `ui-state.toml`.** If — and only if — the three config tables are completely empty and `ui-state.toml` exists, its contents are imported into the database in a single transaction. On success the file is renamed to `ui-state.toml.migrated` (kept as a backup, never deleted) and you will see: + + ```text + INFO imported ui-state.toml into the database; backup at /ui-state.toml.migrated + ``` + + All credentials — including LLM API keys, which 0.9.x stored in plaintext — are written to the database `enc:`-encrypted with the same `secrets.key` as before. +4. **Replay config from the database.** Configuration is applied from the database through the same code path the web UI has always used, and you will see `INFO applied UI state from the database`. From now on the database is the authoritative source and `PUT /api/v1/config` persists to it. + +Two other log lines you may see on that first boot: + +- `WARN marked N interrupted review task(s) as failed (server restarted); they can be retried manually from the history page` — reviews that were pending/running when the old process stopped are closed as `failed` with `error='interrupted: server restarted'`. They are **not** re-run automatically (that would burn LLM quota and could double-post MR comments); retry them manually from the History page. +- `WARN persistence disabled via REVIEW_DISABLE_DB — running with 0.9 in-memory + file behaviour` — only if you set the escape hatch (see [Troubleshooting](#troubleshooting)). + +--- + +## Verify the upgrade + +1. **Storage backend is active.** The health endpoint now reports which backend is in use: + + ```bash + curl -s http://:/api/v1/system/health | jq .storage_backend + ``` + + Expect `"postgresql"` (with `DATABASE_URL`) or `"sqlite"` (embedded). `"disabled"` means the server is running in 0.9 mode — check whether `REVIEW_DISABLE_DB` is set or the config directory could not be resolved. The Configuration page's Advanced card shows the same value as a read-only row. + +2. **Configuration survived.** Open the web UI Configuration page: your git platforms and LLM providers should be exactly as before, and webhooks/reviews should work without re-entering anything. + +3. **History persists.** Run any review, restart the server, and confirm the entry is still on the History page. (Under 0.9.x, history vanished on restart and was bounded by a 30-minute in-memory window; it is now served from the database.) + +4. **The file was archived.** `ui-state.toml` should now be `ui-state.toml.migrated` in the config directory, alongside the new `review.db` (SQLite installs). + +--- + +## Troubleshooting + +**Startup fails with "DATABASE_URL is set but the database is unreachable"** +This is deliberate fail-fast behaviour — the server refuses to boot rather than write your data into an unexpected embedded SQLite file. Three ways out: + +1. Fix the PostgreSQL connection (host, credentials, network) and start again — preferred. +2. Unset `DATABASE_URL` to use the embedded SQLite database instead. +3. Set `REVIEW_DISABLE_DB=1` to bypass persistence entirely and run with exact 0.9 behaviour (in-memory history, config in `ui-state.toml`). Accepted values are `1`, `true`, or `yes` (case-insensitive). Use this only as a temporary escape hatch — review history will not survive restarts while it is set. + +**The log shows "ui-state.toml import failed … the file is untouched"** +The import is a single transaction: a mid-import failure rolls everything back, leaves `ui-state.toml` exactly where it was, and the server keeps starting by replaying the file (0.9 behaviour) so you never lose your configuration. Fix the cause shown in the error and restart — the import retries automatically, because it only runs while the config tables are still empty. + +**`secrets.key` was lost** +Credentials stored `enc:`-encrypted in the database (git tokens, webhook secrets, LLM API keys) cannot be decrypted without it. Re-enter the credentials in the web UI Configuration page and save — new values are encrypted under a fresh key. (This is the same threat model as 0.9.x, which is why the backup above must include `secrets.key`.) + +**A review was interrupted by the upgrade restart** +It appears on the History page as `failed` with `error='interrupted: server restarted'`. Use retry from the History page to re-run it. + +--- + +## Downgrading is not supported + +0.10.x is a one-way move: after the first boot, your configuration's authoritative source is the database and `ui-state.toml` has been renamed to `ui-state.toml.migrated`. A 0.9.x binary does not read the database, so rolling the binary back would start 0.9.x with **no configuration** — do not treat a version rollback as a supported operation. + +If a genuine disaster forces you back to 0.9.x, the pieces for a *manual* recovery are the config-directory backup you took before upgrading and the untouched `ui-state.toml.migrated` (renaming it back to `ui-state.toml` restores the old file-based config source). The database itself is inert for 0.9.x and can be left in place or deleted. This is a last-resort recovery procedure, not a supported downgrade path — and it only works if you made the backup. + +--- + +## Related reading + +- [`CHANGELOG.md`](../CHANGELOG.md) — full 0.10.0 release notes. +- [`design/persistence.md`](../design/persistence.md) — the persistence design (schema, startup sequence, risk table) this guide is based on. +- [`configuration.md`](configuration.md) — full configuration reference. +- [`faq.md`](faq.md) — API token and deployment troubleshooting. diff --git a/docs/rest-api.md b/docs/rest-api.md index d04be58..7d8769c 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -117,7 +117,9 @@ Response 202: #### `GET /api/v1/reviews/:task_id` -返回单个任务详情。snake_case `TaskStatus` 字段全部保留,之上合并 camelCase 结构化字段(`ReviewDetail`):`id` / `mrTitle` / `project` / `repository` / `branch` / `targetBranch` / `author{name, avatarUrl}` / `status` / `durationMs` / `createdAt` / `completedAt` / `commitSha` / `experts[{expertId, expertName, status, score, summary, details}]` / `rawComment` / `rawApiResponse` / `gitlabMrUrl`。 +返回单个任务详情。`task_id` 必须是合法 UUID:非 UUID 值在路径参数解析阶段即失败,返回 `400`(如误请求 `/api/v1/reviews/history`——历史列表端点是下方单独的 `GET /api/v1/reviews`,不存在 `history` 子路径);任务不存在返回 `404 { "error": "task not found" }`。 + +snake_case `TaskStatus` 字段全部保留,之上合并 camelCase 结构化字段(`ReviewDetail`):`id` / `mrTitle` / `project` / `repository` / `branch` / `targetBranch` / `author{name, avatarUrl}` / `status` / `durationMs` / `createdAt` / `completedAt` / `commitSha` / `experts[{expertId, expertName, status, score, summary, details}]` / `rawComment` / `rawApiResponse` / `gitlabMrUrl`。 ``` Response 200: @@ -185,7 +187,7 @@ Response 200: #### `GET /api/v1/reviews` -分页列出历史 reviews。 +分页列出历史 reviews。这是唯一的 review 历史列表端点(前端 History 页的数据源);不存在 `/api/v1/reviews/history` 子路径——该请求会命中 `/:task_id` 路由并因 `history` 不是 UUID 而返回 400。 ``` Query: diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9ac3c21..a7b496a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -474,6 +474,12 @@ export default { requestTimeout: 'Request timeout (seconds)', enableMetrics: 'Enable metrics', debugMode: 'Debug mode', + storageBackend: 'Storage backend', + storageBackendKind: { + postgresql: 'PostgreSQL', + sqlite: 'SQLite', + disabled: 'Disabled', + }, }, }, llm: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index cf677ac..18d2875 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -470,6 +470,12 @@ export default { requestTimeout: "Délai d'expiration de la requête (secondes)", enableMetrics: 'Activer les métriques', debugMode: 'Mode débogage', + storageBackend: 'Backend de stockage', + storageBackendKind: { + postgresql: 'PostgreSQL', + sqlite: 'SQLite', + disabled: 'Désactivé', + }, }, }, llm: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 065ac09..344c1a3 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -465,6 +465,12 @@ export default { requestTimeout: 'リクエストタイムアウト(秒)', enableMetrics: 'メトリクスを有効化', debugMode: 'デバッグモード', + storageBackend: 'ストレージバックエンド', + storageBackendKind: { + postgresql: 'PostgreSQL', + sqlite: 'SQLite', + disabled: '無効', + }, }, }, llm: { diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 3f9def8..d731193 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -464,6 +464,12 @@ export default { requestTimeout: '요청 시간 초과(초)', enableMetrics: '메트릭 활성화', debugMode: '디버그 모드', + storageBackend: '스토리지 백엔드', + storageBackendKind: { + postgresql: 'PostgreSQL', + sqlite: 'SQLite', + disabled: '비활성화됨', + }, }, }, llm: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 0829315..646305b 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -459,6 +459,12 @@ export default { requestTimeout: '请求超时(秒)', enableMetrics: '启用指标', debugMode: '调试模式', + storageBackend: '存储后端', + storageBackendKind: { + postgresql: 'PostgreSQL', + sqlite: 'SQLite', + disabled: '已禁用', + }, }, }, llm: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 042558c..daeff4d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -459,6 +459,12 @@ export default { requestTimeout: '請求逾時(秒)', enableMetrics: '啟用指標', debugMode: '偵錯模式', + storageBackend: '儲存後端', + storageBackendKind: { + postgresql: 'PostgreSQL', + sqlite: 'SQLite', + disabled: '已停用', + }, }, }, llm: { diff --git a/frontend/src/services/health.ts b/frontend/src/services/health.ts index bae3c6b..d954f87 100644 --- a/frontend/src/services/health.ts +++ b/frontend/src/services/health.ts @@ -1,10 +1,19 @@ import { request } from './api'; -import type { SystemHealth } from '../types/dashboard'; +import type { StorageBackendKind, SystemHealth } from '../types/dashboard'; + +const STORAGE_BACKENDS: readonly StorageBackendKind[] = ['postgresql', 'sqlite', 'disabled']; /** * Fetch the server's system health status. * @returns System health information (uptime, memory, version, etc.). */ export async function getSystemHealth(): Promise { - return request('/system/health'); + // `storage_backend` (0.10.0) is the one snake_case key on this otherwise + // camelCase payload; normalize it here so consumers see `storageBackend`. + // Unknown/absent values degrade to undefined (the caller hides the row). + const raw = await request('/system/health'); + return { + ...raw, + storageBackend: STORAGE_BACKENDS.find((k) => k === raw.storage_backend), + }; } diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 21ae7c5..d90fe64 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -24,6 +24,12 @@ export interface HealthStatus { message?: string; } +/** + * Persistence backend in use, reported by `/system/health` (0.10.0). + * Absent when the server predates the field. + */ +export type StorageBackendKind = 'postgresql' | 'sqlite' | 'disabled'; + export interface SystemHealth { integrations: HealthStatus[]; llmProviders: HealthStatus[]; @@ -31,6 +37,8 @@ export interface SystemHealth { lastChecked: string; /** False when the server has no usable LLM configured (reviews cannot run). */ llmConfigured: boolean; + /** Persistence backend kind; normalized from the raw `storage_backend` key. */ + storageBackend?: StorageBackendKind; } // Display-facing status for recent reviews. The backend reports the real task diff --git a/frontend/src/views/Configuration.vue b/frontend/src/views/Configuration.vue index f0201e2..c072f11 100644 --- a/frontend/src/views/Configuration.vue +++ b/frontend/src/views/Configuration.vue @@ -235,6 +235,14 @@ + + + + + + @@ -274,7 +282,9 @@ import { ElMessageBox, ElNotification } from 'element-plus' import { useI18n } from 'vue-i18n' import { useConfig } from '../composables/useConfig' import { useConfigForm } from '../composables/useConfigForm' +import { getSystemHealth } from '../services/health' import type { AppConfig, GitPlatformConfig } from '../types/config' +import type { StorageBackendKind } from '../types/dashboard' import GitPlatformsSection from '../components/Config/GitPlatformsSection.vue' // --- Composables --- @@ -306,6 +316,23 @@ const loadError = computed(() => !!cfg.error.value) const saving = cfg.saving const showAdvanced = ref(false) +/* Read-only runtime info: the persistence backend in use, from + * GET /system/health (`storage_backend`, 0.10.0). Fail-silent — a health + * check error or an older server simply leaves the row hidden. */ +const storageBackend = ref(null) + +const storageBackendLabel = computed(() => + storageBackend.value ? t(`config.advanced.storageBackendKind.${storageBackend.value}`) : '' +) + +function loadStorageBackend() { + getSystemHealth() + .then((health) => { + storageBackend.value = health.storageBackend ?? null + }) + .catch(() => {}) +} + // Card refs for flash animation const gitPlatformsCardRef = ref() const rulesCardRef = ref() @@ -400,7 +427,7 @@ async function saveChanges() { setTimeout(() => el.classList.remove('flash-success'), 600) } }) - } catch (e) { + } catch { ElNotification({ title: t('common.error'), message: t('config.saveFailed'), @@ -450,6 +477,7 @@ onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) window.addEventListener('resize', handleResize) loadConfig() + loadStorageBackend() }) // --- Error handling --- diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index a3de8aa..d12de0f 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -458,10 +458,17 @@ onUnmounted(() => { - + @@ -689,6 +696,28 @@ onUnmounted(() => { max-width: 100%; } +/* Status cell: badge dot + label must stay on one line; Element Plus' + default .cell has word-break: break-all, which split "已完成" into + "已完/成" once the stacked cell padding squeezed the content width. */ +.status-cell { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; +} + +.status-label { + font-size: 12px; + color: var(--text-primary); + /* Same truncation recipe as the history table (8aa1740): labels that + still don't fit (e.g. ja "キャンセル済み") ellipsize instead of + wrapping or being hard-clipped. */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .author-cell { display: flex; align-items: center; diff --git a/frontend/src/views/ReviewHistory.vue b/frontend/src/views/ReviewHistory.vue index 4313a5d..0dd812c 100644 --- a/frontend/src/views/ReviewHistory.vue +++ b/frontend/src/views/ReviewHistory.vue @@ -563,7 +563,7 @@ watch(() => route.query, () => { :border="false" :highlight-current-row="false" > - + - + @@ -595,13 +601,13 @@ watch(() => route.query, () => { - + - +