From 50542786709513c0c4aa85fa94d400f63cdd5603 Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Sat, 11 Jul 2026 19:32:01 +0200 Subject: [PATCH 01/33] feat: durable manual corrections, dashboard review loop, and remediation groundwork Continues the project-remediation plan (see docs/codex-handoff-project-remediation-2026-07-10.md): - Release/docs truth: 2.4.0-dev across workspace, CHANGELOG v2.3.0 + Unreleased, CONTRIBUTING CI commands - Windows CI job, cfg(unix) PDF tests, dependabot, RustSec + CodeQL workflows (not yet run on GitHub) - Migration 0007: translations origin/human_corrected/corrected_at; save_manual_correction freezes segments, excluded from cross-job cache - New `bookforge correct` CLI command; CSRF-protected dashboard save/flag/retry endpoints with durable segment_flags - Retry guidance persisted, survives resume, rendered into single + batch prompts; batch templates bumped v2->v3 with new PromptVersion::BatchV3 cache tag - Correction atomicity: staged rebuild -> validate -> DB persist -> atomic rename, with recovery path on rename failure - QA report gains corrected_segments and regenerates after corrections - AppState carries store_path (removes per-request cwd resolution) - request_segment_retry now rejects running/paused jobs - Tests: store flag/retry/guidance, CSRF + isolated-store e2e dashboard, retry-guidance lifecycle (548 workspace tests green) Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 24 + .github/workflows/ci.yml | 17 +- .github/workflows/security.yml | 44 + CHANGELOG.md | 31 + CONTRIBUTING.md | 19 +- Cargo.lock | 43 +- crates/bookforge-cli/Cargo.toml | 13 +- crates/bookforge-cli/src/commands/correct.rs | 319 ++++++ crates/bookforge-cli/src/commands/mod.rs | 1 + crates/bookforge-cli/src/commands/resume.rs | 27 +- crates/bookforge-cli/src/commands/review.rs | 54 +- crates/bookforge-cli/src/commands/serve.rs | 779 +++++++++++++- .../src/commands/translate/mod.rs | 5 +- .../src/commands/translate/reporting.rs | 85 +- crates/bookforge-cli/src/main.rs | 7 +- crates/bookforge-cli/src/report.rs | 80 +- crates/bookforge-cli/tests/lifecycle.rs | 540 +++++++++- crates/bookforge-core/Cargo.toml | 2 +- crates/bookforge-core/src/config.rs | 10 + crates/bookforge-epub/Cargo.toml | 4 +- crates/bookforge-llm/Cargo.toml | 4 +- ...2.md => translate_batch_marker_safe.v3.md} | 3 +- ...translate_batch_marker_safe_compact.v3.md} | 2 +- ...lain.v2.md => translate_batch_plain.v3.md} | 4 +- ...md => translate_batch_plain_compact.v3.md} | 2 +- ...d => translate_batch_run_preserving.v3.md} | 3 +- ...nslate_batch_run_preserving_compact.v3.md} | 2 +- crates/bookforge-llm/src/batch.rs | 39 +- crates/bookforge-llm/src/prompt.rs | 49 +- crates/bookforge-llm/src/scheduler.rs | 29 +- crates/bookforge-pdf/Cargo.toml | 6 +- crates/bookforge-pdf/src/convert.rs | 17 + crates/bookforge-store/Cargo.toml | 4 +- .../0007_v2_4_human_corrections.sql | 5 + crates/bookforge-store/src/db.rs | 952 +++++++++++++++++- crates/bookforge-store/src/lib.rs | 7 +- docs/ROADMAP.md | 9 + docs/codex-handoff-pause-stop.md | 7 +- ...-handoff-project-remediation-2026-07-10.md | 370 +++++++ 39 files changed, 3485 insertions(+), 133 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security.yml create mode 100644 crates/bookforge-cli/src/commands/correct.rs rename crates/bookforge-llm/prompts/{translate_batch_marker_safe.v2.md => translate_batch_marker_safe.v3.md} (94%) rename crates/bookforge-llm/prompts/{translate_batch_marker_safe_compact.v2.md => translate_batch_marker_safe_compact.v3.md} (88%) rename crates/bookforge-llm/prompts/{translate_batch_plain.v2.md => translate_batch_plain.v3.md} (83%) rename crates/bookforge-llm/prompts/{translate_batch_plain_compact.v2.md => translate_batch_plain_compact.v3.md} (83%) rename crates/bookforge-llm/prompts/{translate_batch_run_preserving.v2.md => translate_batch_run_preserving.v3.md} (87%) rename crates/bookforge-llm/prompts/{translate_batch_run_preserving_compact.v2.md => translate_batch_run_preserving_compact.v3.md} (86%) create mode 100644 crates/bookforge-store/migrations/0007_v2_4_human_corrections.sql create mode 100644 docs/codex-handoff-project-remediation-2026-07-10.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..98de736 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/Rome + open-pull-requests-limit: 5 + groups: + rust-patch-and-minor: + update-types: + - patch + - minor + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:30" + timezone: Europe/Rome + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b16222e..99cb568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,9 +53,20 @@ jobs: persist-credentials: false - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - # Lifecycle integration tests under crates/bookforge-cli/tests/ require - # test/test.epub, which is gitignored. They will be skipped in CI; that - # is intentional. The remaining workspace tests still run. + # Lifecycle tests create synthetic EPUB fixtures and run in clean clones. + - run: cargo test --workspace --locked + + test-windows: + name: test (windows-msvc) + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: windows-msvc - run: cargo test --workspace --locked msrv: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..c011119 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,44 @@ +name: Security + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "41 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + rustsec: + name: RustSec advisory audit + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: rustsec/audit-check@v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + codeql: + name: CodeQL (Rust) + runs-on: ubuntu-22.04 + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: github/codeql-action/init@v4 + with: + languages: rust + build-mode: none + queries: security-extended + - uses: github/codeql-action/analyze@v4 + with: + category: /language:rust diff --git a/CHANGELOG.md b/CHANGELOG.md index 717f0ed..994a122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased (2.4.0-dev) + +BookForge main now carries a distinct development version so source builds +cannot be confused with the published v2.3.0 artifacts. + +- Added cache-safe job reconfiguration through `bookforge reconfigure`, with + auditable overrides that apply when a stopped or dead paused job resumes. +- Added escalation-first handling for truncated batch responses and prominent + systemic-truncation alerts across CLI, watch, and dashboard surfaces. +- Fixed resume override handling, adaptive batch override propagation, stale + override cleanup, and dashboard wizard API-key retention. +- Bumped the batch translate prompts (plain, marker-safe, run-preserving, and + their compact variants) from v2 to v3 to teach models about a per-item + `retry_guidance` field; the batch prompt cache tag moved from `batch_v2` + to `batch_v3` so translations cached under the old prompt text are not + reused. + +## v2.3.0 - 2026-07-05 + +BookForge v2.3.0 added source-EPUB reflow and cooperative job controls. + +- Added `bookforge reflow`, including the opt-in `--aggressive` level, while + preserving the rule that source EPUBs are never silently rewritten during + translation. +- Added pause, resume, and stop controls to the engine, CLI, terminal watcher, + and browser dashboard, with completed segments checkpointed before parking. +- Made finalize-stage QA and double-check requests honor pause/stop controls; + stopped jobs remain resumable without retranslating completed segments. +- Hardened reflow boundaries around letterless paragraphs, inline whitespace, + dehyphenation, and class-based merge guards. + ## v2.2.0 - 2026-07-04 BookForge v2.2.0 ships the v1.7 roadmap milestone: bilingual output. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e657bd4..570946a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,13 +50,19 @@ usually won't move. Before submitting: ```bash -cargo fmt -cargo clippy --all-targets --all-features -cargo test +cargo fmt --all --check +cargo clippy --all-targets --all-features -- -A clippy::too_many_arguments -D warnings +cargo test --workspace --locked ``` All three should pass cleanly. CI runs the same set. +On Windows, use the MSVC Rust host (`stable-x86_64-pc-windows-msvc`) and +install the Visual Studio Build Tools C++ workload. The GNU host requires a +separate MinGW toolchain including `dlltool.exe` and is not the contributor +baseline. Verify the active host with `rustc -vV` before diagnosing a project +build failure. + Add tests for any behaviour change, especially around: - EPUB segmentation and rebuild (`bookforge-epub`). @@ -65,10 +71,9 @@ Add tests for any behaviour change, especially around: - Anything that touches the `ProgressSink` event schema or the JSONL field set (these are semver-stable for v1.x; see ROADMAP §1.5). -Some lifecycle integration tests under `crates/bookforge-cli/tests/` -read `test/test.epub`, which is gitignored. They will be skipped or -fail on a fresh clone unless you drop your own fixture at that path. -A real-but-small public-domain EPUB works fine. +Lifecycle integration tests build synthetic EPUB fixtures and run in a fresh +clone. The separate pinned Standard Ebooks corpus is exercised by CI through +`scripts/corpus-smoke.sh`; local corpus downloads remain gitignored. ## Commit and PR style diff --git a/Cargo.lock b/Cargo.lock index 400a2ea..7264786 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,7 +260,7 @@ dependencies = [ [[package]] name = "bookforge-cli" -version = "2.3.0" +version = "2.4.0-dev" dependencies = [ "anyhow", "assert_cmd", @@ -276,6 +276,7 @@ dependencies = [ "futures-core", "getrandom 0.3.4", "indicatif", + "predicates", "quick-xml", "ratatui", "reqwest", @@ -294,7 +295,7 @@ dependencies = [ [[package]] name = "bookforge-core" -version = "2.3.0" +version = "2.4.0-dev" dependencies = [ "clap", "quick-xml", @@ -307,7 +308,7 @@ dependencies = [ [[package]] name = "bookforge-epub" -version = "2.3.0" +version = "2.4.0-dev" dependencies = [ "bookforge-core", "quick-xml", @@ -319,7 +320,7 @@ dependencies = [ [[package]] name = "bookforge-llm" -version = "2.3.0" +version = "2.4.0-dev" dependencies = [ "bookforge-core", "reqwest", @@ -333,7 +334,7 @@ dependencies = [ [[package]] name = "bookforge-pdf" -version = "2.3.0" +version = "2.4.0-dev" dependencies = [ "bookforge-core", "bookforge-epub", @@ -350,7 +351,7 @@ dependencies = [ [[package]] name = "bookforge-store" -version = "2.3.0" +version = "2.4.0-dev" dependencies = [ "bookforge-core", "rusqlite", @@ -835,6 +836,15 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -1493,6 +1503,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1657,7 +1673,10 @@ checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -1868,6 +1887,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" diff --git a/crates/bookforge-cli/Cargo.toml b/crates/bookforge-cli/Cargo.toml index 3241a1a..f3e74c7 100644 --- a/crates/bookforge-cli/Cargo.toml +++ b/crates/bookforge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bookforge-cli" -version = "2.3.0" +version = "2.4.0-dev" description = "CLI-first EPUB translation engine with deterministic structure rebuild and review loop." readme = "../../README.md" edition.workspace = true @@ -16,11 +16,11 @@ path = "src/main.rs" anyhow.workspace = true async-stream = { version = "0.3", optional = true } axum = { version = "0.8", optional = true, features = ["multipart"] } -bookforge-core = { version = "2.3.0", path = "../bookforge-core", features = ["cli"] } -bookforge-epub = { version = "2.3.0", path = "../bookforge-epub" } -bookforge-llm = { version = "2.3.0", path = "../bookforge-llm" } -bookforge-pdf = { version = "2.3.0", path = "../bookforge-pdf" } -bookforge-store = { version = "2.3.0", path = "../bookforge-store" } +bookforge-core = { version = "2.4.0-dev", path = "../bookforge-core", features = ["cli"] } +bookforge-epub = { version = "2.4.0-dev", path = "../bookforge-epub" } +bookforge-llm = { version = "2.4.0-dev", path = "../bookforge-llm" } +bookforge-pdf = { version = "2.4.0-dev", path = "../bookforge-pdf" } +bookforge-store = { version = "2.4.0-dev", path = "../bookforge-store" } clap.workspace = true console.workspace = true futures-core = { version = "0.3", optional = true } @@ -55,6 +55,7 @@ serve = [ [dev-dependencies] assert_cmd = "2" +predicates = "3" quick-xml.workspace = true tempfile = "3" tower = "0.5" diff --git a/crates/bookforge-cli/src/commands/correct.rs b/crates/bookforge-cli/src/commands/correct.rs new file mode 100644 index 0000000..32d6051 --- /dev/null +++ b/crates/bookforge-cli/src/commands/correct.rs @@ -0,0 +1,319 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result}; +use bookforge_core::segment::{BlockTranslation, Segment, build_segments}; +use bookforge_epub::{ + rebuild_epub_with_options, validate_block_translations, validate_translated_epub, +}; +use bookforge_store::{JobStore, SaveManualCorrection}; +use clap::Args; +use serde::{Deserialize, Serialize}; + +use super::{ + review::resolve_review_input, + translate::{rebuild_options_from_snapshot, regenerate_report_after_correction}, +}; + +#[derive(Debug, Args)] +pub struct CorrectArgs { + pub job_id: String, + + #[arg(long)] + pub segment: String, + + #[arg( + long, + conflicts_with = "from_file", + required_unless_present = "from_file" + )] + pub text: Option, + + #[arg(long, value_name = "PATH", conflicts_with = "text")] + pub from_file: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct CorrectionBlock { + pub block_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct CorrectionFile { + blocks: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) enum CorrectionPayload { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct CorrectionOutcome { + pub job_id: String, + pub segment_id: String, + pub corrected_blocks: usize, + pub output_path: String, + pub job_status: String, +} + +pub async fn run(args: CorrectArgs) -> Result<()> { + let payload = if let Some(text) = args.text { + CorrectionPayload::Text(text) + } else { + let path = args.from_file.expect("clap requires one correction source"); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read correction file {}", path.display()))?; + match serde_json::from_str::(&raw) { + Ok(file) => CorrectionPayload::Blocks(file.blocks), + Err(_) => CorrectionPayload::Text(raw), + } + }; + + let store = JobStore::open_default()?; + let outcome = correct_job_segment(&store, &args.job_id, &args.segment, payload)?; + println!( + "Corrected {} block(s) in segment {}", + outcome.corrected_blocks, outcome.segment_id + ); + println!("Output: {}", outcome.output_path); + println!("Job status: {}", outcome.job_status); + Ok(()) +} + +pub(crate) fn correct_job_segment( + store: &JobStore, + job_id: &str, + segment_id: &str, + payload: CorrectionPayload, +) -> Result { + let job = store + .get_job(job_id)? + .ok_or_else(|| anyhow::anyhow!("job '{job_id}' was not found"))?; + let snapshot = store + .load_job_config_snapshot(job_id)? + .ok_or_else(|| anyhow::anyhow!("job '{job_id}' has no run configuration snapshot"))?; + let input = resolve_review_input(&job, &snapshot)?; + let book = bookforge_epub::read_epub(&input)?; + let segments = build_segments(&book, &snapshot.settings.to_settings().segmentation)?; + let segment = segments + .iter() + .find(|segment| segment.id.0 == segment_id) + .ok_or_else(|| anyhow::anyhow!("segment '{segment_id}' was not found in job '{job_id}'"))?; + let corrected = normalize_payload(segment, payload)?; + let corrected_ids = corrected + .iter() + .map(|block| block.block_id.0.as_str()) + .collect::>(); + + // Merge the pending correction over the segment's other block translations + // in-memory. This merged view — not a reload from the store — is what the + // staged rebuild below renders, so the rebuild always sees the corrected + // text even though nothing has been persisted to the database yet. + let mut all_blocks = store + .load_block_translations(job_id)? + .into_iter() + .map(|block| { + let block_id = bookforge_core::ir::BlockId(block.block_id); + ( + block_id.clone(), + BlockTranslation { + block_id, + text: block.text, + }, + ) + }) + .collect::>(); + for block in &corrected { + all_blocks.insert(block.block_id.clone(), block.clone()); + } + let all_blocks = all_blocks.into_values().collect::>(); + let blocking_issues = validate_block_translations(&segments, &all_blocks) + .into_iter() + .filter(|issue| { + issue.severity == bookforge_epub::ValidationSeverity::Error + && issue + .block_id + .as_deref() + .is_some_and(|id| corrected_ids.contains(id)) + }) + .map(|issue| issue.message) + .collect::>(); + if !blocking_issues.is_empty() { + anyhow::bail!( + "manual correction violates EPUB marker constraints: {}", + blocking_issues.join("; ") + ); + } + + // Rebuild to a staged sibling of the final output *before* touching the + // database or the real output file. Only once the staged EPUB passes + // structural validation do we persist the correction and atomically swap + // the staged file into place, so a failed rebuild/validation can never + // leave the DB and the on-disk output disagreeing about a correction. + let staged_path = staged_output_path(&job.output_path); + let rebuild_options = rebuild_options_from_snapshot(&snapshot); + if let Err(error) = + rebuild_epub_with_options(&book, &all_blocks, &staged_path, &rebuild_options) + { + let _ = fs::remove_file(&staged_path); + return Err(error.into()); + } + let validation = validate_translated_epub(&staged_path, &segments, &all_blocks); + if !validation.xml_valid + || validation + .issues + .iter() + .any(|issue| issue.severity == bookforge_epub::ValidationSeverity::Error) + { + let _ = fs::remove_file(&staged_path); + anyhow::bail!( + "corrected EPUB failed structural validation; the correction was not saved and the \ + existing output at {} is unchanged", + job.output_path.display() + ); + } + + let translated_text = corrected + .iter() + .map(|block| block.text.as_str()) + .collect::>() + .join("\n\n"); + if let Err(error) = store.save_manual_correction(SaveManualCorrection { + job_id, + segment_id, + translated_text: &translated_text, + blocks: &corrected, + }) { + let _ = fs::remove_file(&staged_path); + return Err(error.into()); + } + + // The correction is durably persisted now. If the atomic swap below fails, + // we must NOT delete the staged file: it is the only place the corrected + // EPUB content exists on disk, and the DB already claims the correction is + // saved. + if let Err(error) = fs::rename(&staged_path, &job.output_path) { + anyhow::bail!( + "manual correction for segment '{segment_id}' in job '{job_id}' was saved to the \ + database, but replacing the output EPUB failed: {error}. The rebuilt EPUB is staged \ + at {} and the previous output at {} was left untouched. Retry `bookforge correct` \ + for this segment (it will rebuild and swap again), run `bookforge resume {job_id}` \ + to regenerate the output from the saved corrections, or manually move the staged \ + file over the output to recover.", + staged_path.display(), + job.output_path.display() + ); + } + + let refreshed_job = store.get_job(job_id)?; + let status = refreshed_job + .as_ref() + .map(|job| job.status.clone()) + .unwrap_or_else(|| "unknown".to_string()); + + // The correction (DB + swapped-in EPUB) is already durable at this point. + // Refreshing the QA report is a best-effort convenience — if it fails we + // log and move on rather than reporting the whole `correct` invocation as + // failed, since the actual correction succeeded. Only bother if the job + // has ever had a report written (auto-written at translate/resume + // finalization); jobs that predate that or never finalized have nothing + // to keep in sync. + if let Some(current_job) = refreshed_job.as_ref() + && (current_job.report_json_path.is_some() || current_job.report_markdown_path.is_some()) + && let Err(error) = regenerate_report_after_correction(store, current_job, &segments) + { + tracing::warn!( + "failed to refresh QA report for job '{job_id}' after correction: {error:#}" + ); + } + + Ok(CorrectionOutcome { + job_id: job_id.to_string(), + segment_id: segment_id.to_string(), + corrected_blocks: corrected.len(), + output_path: job.output_path.display().to_string(), + job_status: status, + }) +} + +/// Builds a same-directory sibling path for staging a rebuilt EPUB before it +/// is atomically swapped into place over `output`. Living next to `output` +/// keeps the eventual `fs::rename` on the same volume, which is required for +/// the rename to be atomic (on Windows this maps to `MoveFileExW` with +/// `MOVEFILE_REPLACE_EXISTING`). +fn staged_output_path(output: &Path) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let stem = output + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("output"); + let extension = output + .extension() + .and_then(|value| value.to_str()) + .map(|ext| format!(".{ext}")) + .unwrap_or_default(); + output.with_file_name(format!( + "{stem}.staged-{}-{nonce}{extension}", + std::process::id() + )) +} + +fn normalize_payload( + segment: &Segment, + payload: CorrectionPayload, +) -> Result> { + match payload { + CorrectionPayload::Text(text) => { + if segment.block_ids.len() != 1 { + anyhow::bail!( + "segment '{}' contains {} blocks; use --from-file with JSON {{\"blocks\":[{{\"block_id\":\"...\",\"text\":\"...\"}}]}}", + segment.id.0, + segment.block_ids.len() + ); + } + Ok(vec![BlockTranslation { + block_id: segment.block_ids[0].clone(), + text, + }]) + } + CorrectionPayload::Blocks(blocks) => { + let mut by_id = HashMap::new(); + for block in blocks { + if by_id.insert(block.block_id.clone(), block.text).is_some() { + anyhow::bail!( + "correction contains duplicate block id '{}'", + block.block_id + ); + } + } + let mut corrected = Vec::with_capacity(segment.block_ids.len()); + for id in &segment.block_ids { + let text = by_id + .remove(&id.0) + .ok_or_else(|| anyhow::anyhow!("correction is missing block '{}'", id.0))?; + corrected.push(BlockTranslation { + block_id: id.clone(), + text, + }); + } + if !by_id.is_empty() { + anyhow::bail!( + "correction contains block ids not present in segment '{}': {}", + segment.id.0, + by_id.keys().cloned().collect::>().join(", ") + ); + } + Ok(corrected) + } + } +} diff --git a/crates/bookforge-cli/src/commands/mod.rs b/crates/bookforge-cli/src/commands/mod.rs index ebab198..0f89c65 100644 --- a/crates/bookforge-cli/src/commands/mod.rs +++ b/crates/bookforge-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod control; pub mod convert; +pub mod correct; pub mod doctor; pub mod entity; pub mod estimate; diff --git a/crates/bookforge-cli/src/commands/resume.rs b/crates/bookforge-cli/src/commands/resume.rs index 1409cef..05b179c 100644 --- a/crates/bookforge-cli/src/commands/resume.rs +++ b/crates/bookforge-cli/src/commands/resume.rs @@ -33,7 +33,7 @@ use crate::{ QaMode, commands::{reconfigure, validate}, performance::performance_summary_from_events, - report::{ReportInput, write_report}, + report::{ReportInput, TranslationQaInput, write_report}, }; use super::translate::{ @@ -315,7 +315,7 @@ async fn run_inner( let pending_segments = select_pending_segments(&segments, &pending_ids)?; let prompt_version = snapshot.prompt_version.as_str(); let legacy_cache_namespace = snapshot.glossary_fingerprint.is_empty(); - let glossary = if legacy_cache_namespace { + let mut glossary = if legacy_cache_namespace { crate::commands::translate::PreparedGlossary { run_config: GlossaryRunConfig::default(), fingerprint: String::new(), @@ -336,11 +336,13 @@ async fn run_inner( format: snapshot.glossary_format, entries_by_segment: selected.entries_by_segment, prompt_extra: snapshot.prompt_extra.clone(), + guidance_by_segment: std::collections::HashMap::new(), }, fingerprint, active_terms, } }; + glossary.run_config.guidance_by_segment = store.load_retry_guidance(&job.id)?; let context_cfg = snapshot_context_run_config(snapshot); let context_registry: Option> = if context_cfg.enabled() { let registry = Arc::new(ContextRegistry::new(&segments)); @@ -651,18 +653,37 @@ async fn run_inner( let summary = store .summary(&job.id)? .ok_or_else(|| anyhow::anyhow!("job '{}' was not found after resume", job.id))?; + let qa_inputs = translations + .iter() + .map(|translation| { + TranslationQaInput::new( + translation.segment_id.0.clone(), + matches!( + translation.status, + SegmentStatus::Succeeded | SegmentStatus::SkippedCached + ), + translation.joined_text(), + ) + }) + .collect::>(); + let corrected_segments = store + .load_terminal_segment_translations(&job.id)? + .iter() + .filter(|translation| translation.human_corrected) + .count(); let report = write_report(ReportInput { job: &job, summary: &summary, segments: &segments, segment_records: &segment_records, - translations: &translations, + translations: &qa_inputs, qa_reviews: &qa_reviews, performance: snapshot .events_path .as_ref() .and_then(|path| performance_summary_from_events(path).ok().flatten()), output: &output, + corrected_segments, })?; store.update_job_report_paths(&job.id, &report.json, &report.markdown)?; snapshot.report_json_path = Some(report.json.clone()); diff --git a/crates/bookforge-cli/src/commands/review.rs b/crates/bookforge-cli/src/commands/review.rs index c089573..74c1c8e 100644 --- a/crates/bookforge-cli/src/commands/review.rs +++ b/crates/bookforge-cli/src/commands/review.rs @@ -65,11 +65,22 @@ pub(crate) struct ReviewSegment { ordinal: usize, source_text: String, target_text: String, + blocks: Vec, + human_corrected: bool, + corrected_at: Option, + flagged: bool, soft_warnings: Vec, tokens: ReviewTokens, status: String, } +#[derive(Debug, Serialize)] +pub(crate) struct ReviewBlock { + block_id: String, + source_text: String, + target_text: String, +} + #[derive(Debug, Serialize)] pub(crate) struct ReviewWarning { kind: String, @@ -124,6 +135,10 @@ pub(crate) fn generate_review_document(store: &JobStore, job_id: &str) -> Result .ok_or_else(|| anyhow::anyhow!("job '{}' summary unavailable", job.id))?; let records = store.segment_records(&job.id)?; let translations = store.load_terminal_segment_translations(&job.id)?; + let flagged_segment_ids = store + .dashboard_flagged_segment_ids(&job.id)? + .into_iter() + .collect::>(); let glossary_terms = if snapshot.glossary_fingerprint.is_empty() { store.load_active_glossary_terms( job.source_lang.as_deref().unwrap_or("auto"), @@ -142,6 +157,7 @@ pub(crate) fn generate_review_document(store: &JobStore, job_id: &str) -> Result &segments, &records, &translations, + &flagged_segment_ids, &summary, &glossary_terms, )) @@ -175,7 +191,10 @@ pub async fn run(args: ReviewArgs) -> Result<()> { Ok(()) } -fn resolve_review_input(job: &JobRecord, snapshot: &RunConfigSnapshot) -> Result { +pub(crate) fn resolve_review_input( + job: &JobRecord, + snapshot: &RunConfigSnapshot, +) -> Result { if let Some(path) = snapshot .input_snapshot_path .as_ref() @@ -221,6 +240,7 @@ fn build_review_document( segments: &[bookforge_core::segment::Segment], records: &[bookforge_store::SegmentRecord], translations: &[bookforge_store::StoredSegmentTranslation], + flagged_segment_ids: &std::collections::HashSet, summary: &bookforge_store::JobSummary, glossary_terms: &[GlossaryTerm], ) -> ReviewDocument { @@ -242,10 +262,19 @@ fn build_review_document( .iter() .filter_map(|segment| { let record = records_by_id.get(segment.id.0.as_str())?; - let target_text = translations_by_id - .get(segment.id.0.as_str()) + let stored_translation = translations_by_id.get(segment.id.0.as_str()).copied(); + let target_text = stored_translation .map(|translation| translation.translated_text.clone()) .unwrap_or_default(); + let translated_blocks = stored_translation + .map(|translation| { + translation + .blocks + .iter() + .map(|block| (block.block_id.0.as_str(), block.text.as_str())) + .collect::>() + }) + .unwrap_or_default(); Some(ReviewSegment { segment_id: segment.id.0.clone(), chapter_id: segment.section_id.0.clone(), @@ -255,6 +284,25 @@ fn build_review_document( .flatten(), ordinal: segment.ordinal + 1, source_text: segment.source.text.clone(), + blocks: segment + .source + .blocks + .iter() + .map(|block| ReviewBlock { + block_id: block.block_id.0.clone(), + source_text: block.text.clone(), + target_text: translated_blocks + .get(block.block_id.0.as_str()) + .copied() + .unwrap_or_default() + .to_string(), + }) + .collect(), + human_corrected: stored_translation + .is_some_and(|translation| translation.human_corrected), + corrected_at: stored_translation + .and_then(|translation| translation.corrected_at.clone()), + flagged: flagged_segment_ids.contains(&segment.id.0), soft_warnings: soft_warnings( record, &segment.source.text, diff --git a/crates/bookforge-cli/src/commands/serve.rs b/crates/bookforge-cli/src/commands/serve.rs index 170c457..ab2073a 100644 --- a/crates/bookforge-cli/src/commands/serve.rs +++ b/crates/bookforge-cli/src/commands/serve.rs @@ -140,6 +140,21 @@ struct AppState { /// the lifetime of the server: never written to disk, never logged, and /// only injected into spawned runs through the child's environment. keys: Arc>>, + /// Path to the job store's sqlite database, resolved once when the server + /// (or, in tests, a router) is constructed. Handlers open a fresh + /// [`JobStore`] against this path per request rather than calling + /// [`JobStore::open_default`] directly, so the resolved path doesn't + /// depend on the process-global current directory at request time — this + /// keeps production behavior identical (same default relative path, + /// resolved once at startup instead of per-request) while letting tests + /// point a router at an isolated temp-dir store without touching CWD. + store_path: PathBuf, +} + +/// The default job store path, relative to the current directory — identical +/// to what [`JobStore::open_default`] uses internally. +fn default_store_path() -> PathBuf { + PathBuf::from(".bookforge/jobs.sqlite") } pub async fn run(args: ServeArgs) -> Result<()> { @@ -158,6 +173,7 @@ pub async fn run(args: ServeArgs) -> Result<()> { csrf_token: generate_csrf_token()?, host_port: local.port(), keys: Arc::new(Mutex::new(HashMap::new())), + store_path: default_store_path(), }; let app = dashboard_router(state); @@ -187,6 +203,18 @@ fn dashboard_router(state: AppState) -> Router { .route("/api/jobs/{id}", get(job_detail)) .route("/api/jobs/{id}/events", get(job_events)) .route("/api/jobs/{id}/review", get(job_review)) + .route( + "/api/jobs/{id}/segments/{segment_id}/translation", + post(save_manual_translation), + ) + .route( + "/api/jobs/{id}/segments/{segment_id}/flag", + post(set_segment_flag), + ) + .route( + "/api/jobs/{id}/segments/{segment_id}/retry", + post(retry_segment_with_guidance), + ) .route("/api/jobs/{id}/validate", post(job_validate)) .route("/api/jobs/{id}/retry", post(retry_job)) .route("/api/jobs/{id}/pause", post(pause_job)) @@ -271,9 +299,10 @@ async fn index(State(state): State) -> Html { Html(DASHBOARD_HTML.replace(CSRF_TOKEN_PLACEHOLDER, &state.csrf_token)) } -async fn list_jobs() -> Result>, AppError> { - let items = tokio::task::spawn_blocking(|| -> Result> { - let store = JobStore::open_default()?; +async fn list_jobs(State(state): State) -> Result>, AppError> { + let store_path = state.store_path.clone(); + let items = tokio::task::spawn_blocking(move || -> Result> { + let store = JobStore::open(store_path)?; Ok(store .list_job_summaries()? .into_iter() @@ -284,10 +313,14 @@ async fn list_jobs() -> Result>, AppError> { Ok(Json(items)) } -async fn job_detail(AxumPath(id): AxumPath) -> Result { +async fn job_detail( + AxumPath(id): AxumPath, + State(state): State, +) -> Result { let lookup = id.clone(); + let store_path = state.store_path.clone(); let detail = tokio::task::spawn_blocking(move || -> Result> { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; let job = store.get_job(&lookup)?; let events_path = events_path_for(job.as_ref(), &lookup); if job.is_none() && !events_path.exists() { @@ -317,7 +350,7 @@ async fn job_events( State(state): State, ) -> Sse>> { let refresh = state.refresh; - let path = resolve_events_path(id).await; + let path = resolve_events_path(id, state.store_path.clone()).await; let stream = async_stream::stream! { let mut tailer = EventLogTailer::new(path); @@ -353,9 +386,13 @@ async fn job_events( /// [`generate_review_document`](super::review::generate_review_document) with the /// CLI `review` command; the browser renders it into the Review screen. Errors /// (unknown job, or a job that predates run-config snapshots) become 404s. -async fn job_review(AxumPath(id): AxumPath) -> Result { +async fn job_review( + AxumPath(id): AxumPath, + State(state): State, +) -> Result { + let store_path = state.store_path.clone(); let outcome = tokio::task::spawn_blocking(move || { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; super::review::generate_review_document(&store, &id) }) .await?; @@ -370,6 +407,97 @@ async fn job_review(AxumPath(id): AxumPath) -> Result, +} + +async fn save_manual_translation( + AxumPath((id, segment_id)): AxumPath<(String, String)>, + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + if let Some(response) = reject_mutation(&headers, &state) { + return Ok(response); + } + if request.blocks.is_empty() { + return Ok(bad_request("at least one corrected block is required")); + } + + let store_path = state.store_path.clone(); + let outcome = tokio::task::spawn_blocking(move || -> Result<_> { + let store = JobStore::open(store_path)?; + super::correct::correct_job_segment( + &store, + &id, + &segment_id, + super::correct::CorrectionPayload::Blocks(request.blocks), + ) + }) + .await?; + + match outcome { + Ok(outcome) => Ok(Json(outcome).into_response()), + Err(err) => Ok(bad_request(&err.to_string())), + } +} + +#[derive(Debug, Deserialize)] +struct SegmentFlagRequest { + flagged: bool, +} + +async fn set_segment_flag( + AxumPath((id, segment_id)): AxumPath<(String, String)>, + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + if let Some(response) = reject_mutation(&headers, &state) { + return Ok(response); + } + let store_path = state.store_path.clone(); + let outcome = tokio::task::spawn_blocking(move || -> Result<()> { + let store = JobStore::open(store_path)?; + store.set_dashboard_segment_flag(&id, &segment_id, request.flagged)?; + Ok(()) + }) + .await?; + match outcome { + Ok(()) => Ok(Json(json!({ "flagged": request.flagged })).into_response()), + Err(err) => Ok(bad_request(&err.to_string())), + } +} + +#[derive(Debug, Deserialize)] +struct SegmentRetryRequest { + #[serde(default)] + guidance: Option, +} + +async fn retry_segment_with_guidance( + AxumPath((id, segment_id)): AxumPath<(String, String)>, + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + if let Some(response) = reject_mutation(&headers, &state) { + return Ok(response); + } + let store_path = state.store_path.clone(); + let outcome = tokio::task::spawn_blocking(move || -> Result<()> { + let store = JobStore::open(store_path)?; + store.request_segment_retry(&id, &segment_id, request.guidance.as_deref())?; + Ok(()) + }) + .await?; + match outcome { + Ok(()) => Ok(Json(json!({ "retry_pending": true })).into_response()), + Err(err) => Ok(bad_request(&err.to_string())), + } +} + /// Validate a job's translated EPUB (BookForge structural validators + EPUBCheck) /// and return the report. Shares [`validate_path`](super::validate::validate_path) /// with the CLI `validate` command. EPUBCheck may be `unavailable` (no external @@ -384,9 +512,10 @@ async fn job_validate( return Ok(response); } + let store_path = state.store_path.clone(); let outcome = tokio::task::spawn_blocking( move || -> Result> { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; let Some(job) = store.get_job(&id)? else { return Ok(None); }; @@ -422,8 +551,9 @@ async fn retry_job( if let Some(response) = reject_mutation(&headers, &state) { return Ok(response); } + let store_path = state.store_path.clone(); let retried = tokio::task::spawn_blocking(move || -> Result { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; Ok(store.retry_segments(&id, RetryScope::All)?) }) .await??; @@ -463,8 +593,9 @@ async fn control_job( if let Some(response) = reject_mutation(&headers, &state) { return Ok(response); } + let store_path = state.store_path.clone(); let outcome = tokio::task::spawn_blocking(move || -> Result> { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; if store.get_job(&id)?.is_none() { return Ok(None); } @@ -801,9 +932,11 @@ fn glossary_term_json(term: &GlossaryTerm) -> serde_json::Value { /// `scope` (global/series/book) and `scope_id`. async fn list_glossary( Query(params): Query>, + State(state): State, ) -> Result>, AppError> { + let store_path = state.store_path.clone(); let items = tokio::task::spawn_blocking(move || -> Result> { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; let terms = store.list_glossary_terms(GlossaryFilter { scope_kind: params.get("scope").map(|value| parse_glossary_scope(value)), scope_id: params @@ -894,8 +1027,9 @@ async fn add_glossary( source_count: 0, }; + let store_path = state.store_path.clone(); let id = tokio::task::spawn_blocking(move || -> Result { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; Ok(store.add_glossary_term(&term)?) }) .await??; @@ -910,8 +1044,9 @@ async fn remove_glossary( if let Some(response) = reject_mutation(&headers, &state) { return Ok(response); } + let store_path = state.store_path.clone(); let removed = tokio::task::spawn_blocking(move || -> Result { - let store = JobStore::open_default()?; + let store = JobStore::open(store_path)?; Ok(store.remove_glossary_term(id)?) }) .await??; @@ -1091,11 +1226,11 @@ fn hex_bytes(bytes: &[u8]) -> String { } /// Resolve a job's event-log path off the async runtime (sqlite is blocking). -async fn resolve_events_path(id: String) -> PathBuf { +async fn resolve_events_path(id: String, store_path: PathBuf) -> PathBuf { let fallback = PathBuf::from(format!(".bookforge/runs/{id}/events.jsonl")); let lookup = id.clone(); tokio::task::spawn_blocking(move || { - let job = JobStore::open_default() + let job = JobStore::open(store_path) .ok() .and_then(|store| store.get_job(&lookup).ok().flatten()); events_path_for(job.as_ref(), &lookup) @@ -1497,6 +1632,11 @@ main{min-height:calc(100vh - 60px)} .rev-col .cl{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.07em;color:var(--faint);margin-bottom:14px} .rev-col.tgt .cl{color:var(--accent)} .rev-text{font:400 16px/1.7 var(--serif);color:var(--ink);white-space:pre-wrap} +.rev-block{margin-bottom:16px}.rev-block:last-child{margin-bottom:0} +.rev-block-id{font:500 9px var(--mono);color:var(--faint);margin-bottom:5px} +.rev-edit{width:100%;min-height:120px;resize:vertical;border:1px solid var(--line);border-radius:9px;padding:12px;background:var(--card);color:var(--ink);font:400 15px/1.6 var(--serif)} +.rev-edit:focus{outline:none;border-color:var(--accent)} +.rev-save-row{display:flex;align-items:center;gap:10px;margin-top:14px}.rev-save-status{font:400 11px var(--sans);color:var(--muted)} .rev-note{margin-top:14px;padding:11px 13px;border-radius:9px;background:var(--card);border:1px solid var(--accentline);font:400 12.5px/1.5 var(--sans);color:var(--muted)} .rev-note b{color:var(--warn)} .rev-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);width:100%} @@ -2035,11 +2175,9 @@ function placeholder(stage, title, note) { stage.innerHTML = `

${title}

${note}

${App.selected ? "Loading…" : "Open a job from the library first."}
`; } -function flagKey(id) { return `bookforge.review.flags.${id}`; } -function loadFlags(id) { try { return JSON.parse(localStorage.getItem(flagKey(id)) || "{}"); } catch (e) { return {}; } } -function saveFlags(id, flags) { try { localStorage.setItem(flagKey(id), JSON.stringify(flags)); } catch (e) {} } function segTag(seg, flagged) { if (flagged) return { label:"Flagged", cls:"bad" }; + if (seg.human_corrected) return { label:"corrected", cls:"" }; if (seg.status === "failed") return { label:"failed", cls:"bad" }; if (seg.status === "needs_review") return { label:"review", cls:"warn" }; if ((seg.soft_warnings || []).length) return { label:"check", cls:"warn" }; @@ -2055,28 +2193,65 @@ async function renderReview(stage) { doc = await r.json(); if (!r.ok) { stage.innerHTML = `
${esc(doc.error || "Review is not available for this job.")}
`; return; } } catch (e) { stage.innerHTML = `
Could not load review.
`; return; } - App.review = { doc, idx: 0, filter: "all", flags: loadFlags(id) }; + App.review = { doc, idx: 0, filter: "all" }; drawReview(); } function bfReviewPick(i) { App.review.idx = i; drawReview(); } function bfReviewNav(d) { const n = (App.review.doc.segments || []).length; App.review.idx = Math.max(0, Math.min(n - 1, App.review.idx + d)); drawReview(); } function bfReviewFilter(f) { App.review.filter = f; drawReview(); } -function bfReviewFlag() { +async function bfReviewFlag() { const R = App.review, seg = R.doc.segments[R.idx]; if (!seg) return; - if (R.flags[seg.segment_id]) delete R.flags[seg.segment_id]; else R.flags[seg.segment_id] = { kind: "flagged" }; - saveFlags(App.selected, R.flags); drawReview(); + const next = !seg.flagged; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/flag`, { + method:"POST", headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN}, body:JSON.stringify({flagged:next}) + }); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "flag update failed"); + seg.flagged = next; drawReview(); + } catch (e) { window.alert(e.message || "flag update failed"); } +} +async function bfReviewSave() { + const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg) return; + const status = $("#rev-save-status"), button = $("#rev-save"); + const blocks = Array.from(document.querySelectorAll(".rev-edit")).map(el => ({ block_id: el.dataset.blockId, text: el.value })); + if (blocks.some(block => !block.text.trim())) { if (status) status.textContent = "every block needs translation text"; return; } + if (button) button.disabled = true; if (status) status.textContent = "saving and rebuilding…"; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/translation`, { + method: "POST", headers: { "Content-Type":"application/json", [CSRF_HEADER]: CSRF_TOKEN }, body: JSON.stringify({ blocks }) + }); + const body = await r.json(); + if (!r.ok) throw new Error(body.error || "correction failed"); + if (status) status.textContent = `saved · ${body.job_status}`; + const refreshed = await fetch("/api/jobs/" + encodeURIComponent(App.selected) + "/review"); + R.doc = await refreshed.json(); drawReview(); + } catch (e) { if (status) status.textContent = e.message || "correction failed"; } + finally { if (button) button.disabled = false; } +} +async function bfReviewRetry() { + const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg) return; + const guidance = window.prompt("Optional guidance for the next translation attempt:", "") ?? null; + if (guidance === null) return; + const status = $("#rev-save-status"); if (status) status.textContent = "marking segment for retry…"; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/retry`, { + method:"POST", headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN}, body:JSON.stringify({guidance}) + }); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "retry request failed"); + seg.status = "retry_pending"; if (status) status.textContent = `retry queued · resume ${App.selected}`; drawReview(); + } catch (e) { if (status) status.textContent = e.message || "retry request failed"; } } function drawReview() { const R = App.review, doc = R.doc, segs = doc.segments || []; - const flaggedCount = Object.keys(R.flags).length; + const flaggedCount = segs.filter(seg => seg.flagged).length; const visible = segs.map((s, i) => ({ s, i })).filter(({ s }) => { - if (R.filter === "flagged") return !!R.flags[s.segment_id]; + if (R.filter === "flagged") return !!s.flagged; if (R.filter === "warnings") return (s.soft_warnings || []).length || (s.status !== "succeeded" && s.status !== "skipped_cached"); return true; }); const filters = [["all", `All ${segs.length}`], ["warnings", "To check"], ["flagged", `Flagged ${flaggedCount}`]]; const rows = visible.map(({ s, i }) => { - const flagged = !!R.flags[s.segment_id]; + const flagged = !!s.flagged; const tag = segTag(s, flagged); const ref = `${s.chapter_title || s.chapter_id} ¶${s.ordinal}`; return `
@@ -2090,16 +2265,17 @@ function drawReview() { if (!cur) { main = `
No translated segments yet.
`; } else { - const flagged = !!R.flags[cur.segment_id]; + const flagged = !!cur.flagged; const ref = `${cur.chapter_title || cur.chapter_id} ¶${cur.ordinal}`; const notes = (cur.soft_warnings || []).map(w => `
⚑ ${esc((w.kind || "note").replace(/_/g, " "))} — ${esc(w.message || "")}
`).join(""); - main = `
${esc(ref)} · ${esc(cur.status)} + const blockEditors = (cur.blocks || []).map(block => `
${esc(block.block_id)}
`).join(""); + main = `
${esc(ref)} · ${esc(cur.status)}${cur.human_corrected ? " · manual" : ""}
Source · ${esc(doc.source_language || "auto")}
${esc(cur.source_text)}
-
Translation · ${esc(doc.target_language)}
${esc(cur.target_text || "—")}
${notes}
+
Translation · ${esc(doc.target_language)}
${blockEditors}
${cur.human_corrected ? "human correction saved" : ""}
${notes}
`; } $("#stage").innerHTML = `
@@ -2266,6 +2442,19 @@ mod tests { csrf_token: token.to_string(), host_port: 8765, keys: Arc::new(Mutex::new(HashMap::new())), + store_path: default_store_path(), + } + } + + /// Like [`test_state`], but pointed at an isolated store path instead of + /// the process-relative default — lets tests exercise the store-backed + /// mutation endpoints against a temp-dir database without chdir'ing the + /// (shared, per-process) current directory, which would race across + /// parallel test threads. + fn test_state_with_store(token: &str, store_path: PathBuf) -> AppState { + AppState { + store_path, + ..test_state(token) } } @@ -2623,4 +2812,536 @@ mod tests { assert_eq!(token.len(), 32); assert!(token.chars().all(|ch| ch.is_ascii_hexdigit())); } + + // ----------------------------------------------------------------------- + // Segment mutation endpoints: CSRF rejection + isolated-store end-to-end. + // + // These build a real completed job (real EPUB parsed with `read_epub`, + // real `Segment`s from `build_segments`, real store rows) in a per-test + // temp directory so `save_manual_translation` / `set_segment_flag` / + // `retry_segment_with_guidance` exercise the same code paths production + // traffic does. Every test gets its own `tempfile::TempDir` and its own + // `AppState::store_path` (via `test_state_with_store`), so nothing here + // touches the process's current directory and tests are parallel-safe. + // ----------------------------------------------------------------------- + + use bookforge_core::segment::BlockTranslation; + use bookforge_store::{CreateJob, SaveTranslation}; + use std::io::Write as _; + + const FIXTURE_CONTAINER_XML: &str = r#" + + + + +"#; + + const FIXTURE_OPF: &str = r#" + + + serve-fixture + Serve Fixture + en + + + + + + + + + +"#; + + const FIXTURE_CHAPTER_ONE: &str = r#" + +Fixture Chapter One + +

The lantern flickered in the old library.

+ +"#; + + const FIXTURE_CHAPTER_TWO: &str = r#" + +Fixture Chapter Two + +

Rain tapped steadily against the windowpane.

+ +"#; + + fn build_fixture_epub(path: &std::path::Path) { + use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; + + let file = std::fs::File::create(path).expect("fixture EPUB should be creatable"); + let mut zip = ZipWriter::new(file); + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + + zip.start_file("mimetype", stored).unwrap(); + zip.write_all(b"application/epub+zip").unwrap(); + zip.start_file("META-INF/container.xml", deflated).unwrap(); + zip.write_all(FIXTURE_CONTAINER_XML.as_bytes()).unwrap(); + zip.start_file("content.opf", deflated).unwrap(); + zip.write_all(FIXTURE_OPF.as_bytes()).unwrap(); + zip.start_file("chapter1.xhtml", deflated).unwrap(); + zip.write_all(FIXTURE_CHAPTER_ONE.as_bytes()).unwrap(); + zip.start_file("chapter2.xhtml", deflated).unwrap(); + zip.write_all(FIXTURE_CHAPTER_TWO.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + /// A completed two-segment job (one segment per chapter, one block per + /// segment) backed by an isolated temp-dir store and a real rebuildable + /// output path — everything the mutation endpoints under test touch. + struct MutationFixture { + // Held only to keep the temp directory alive for the fixture's + // lifetime; never read directly. + _temp: tempfile::TempDir, + store_path: PathBuf, + output_path: PathBuf, + job_id: String, + segment_a: String, + segment_b: String, + csrf: String, + } + + fn build_mutation_fixture() -> MutationFixture { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let input_path = temp.path().join("input.epub"); + build_fixture_epub(&input_path); + let output_path = temp.path().join("output.epub"); + let store_path = temp.path().join("jobs.sqlite"); + + let store = JobStore::open(&store_path).expect("store should open"); + let job = store + .create_job(CreateJob { + input: &input_path, + output: &output_path, + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-identity", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job should be created"); + + let book = bookforge_epub::read_epub(&input_path).expect("fixture EPUB should parse"); + let settings = bookforge_core::TranslationProfile::Balanced.resolve(); + let segments = bookforge_core::segment::build_segments(&book, &settings.segmentation) + .expect("segments should build"); + // `read_epub`/`build_segments` also synthesize a segment for the OPF + // `dc:title` metadata (its own section, ahead of the spine chapters), + // so a two-chapter book yields three segments total. Only the two + // chapter segments are used as `segment_a`/`segment_b` below; the + // metadata segment is still inserted and translated like any other + // so the fixture matches what a real job actually persists. + let chapter_segments = segments + .iter() + .filter(|segment| segment.section_id.0 != "sec_metadata_opf") + .collect::>(); + assert_eq!( + chapter_segments.len(), + 2, + "fixture EPUB (one paragraph per chapter) should yield exactly two chapter segments" + ); + + store + .insert_segments(&job.id, &segments, "v1", "mock", "mock-identity", "test_ns") + .expect("segments should insert"); + + for segment in &segments { + let blocks = segment + .source + .blocks + .iter() + .map(|block| BlockTranslation { + block_id: block.block_id.clone(), + text: format!("[IT] {}", block.text), + }) + .collect::>(); + let translated_text = blocks + .iter() + .map(|block| block.text.as_str()) + .collect::>() + .join("\n\n"); + store + .save_translation(SaveTranslation { + job_id: &job.id, + segment_id: &segment.id.0, + translated_text: &translated_text, + blocks: &blocks, + input_tokens: Some(10), + input_cached_tokens: Some(0), + output_tokens: Some(12), + tokens_estimated: false, + provider: "mock", + model: "mock-identity", + prompt_version: "v1", + }) + .expect("translation should save"); + } + store + .mark_job_complete(&job.id) + .expect("job should complete"); + + let snapshot = bookforge_core::RunConfigSnapshot { + input_path: input_path.clone(), + input_snapshot_path: Some(input_path.clone()), + input_sha256: Some("test-sha".to_string()), + output_path: output_path.clone(), + events_path: None, + report_json_path: None, + report_markdown_path: None, + source_language: Some("English".to_string()), + target_language: "Italian".to_string(), + provider: "mock".to_string(), + model: "mock-identity".to_string(), + base_url: None, + api_key_env: None, + profile: settings.profile, + provider_preset: None, + prompt_version: "v1".to_string(), + cache_namespace: "test_ns".to_string(), + book_id: None, + series_id: None, + glossary_budget_tokens: 800, + glossary_format: bookforge_core::GlossaryFormat::Json, + prompt_extra: None, + glossary_fingerprint: String::new(), + glossary_terms: Vec::new(), + context_window: 0, + context_budget_tokens: 1200, + context_scope: bookforge_core::config::ContextScope::Chapter, + style_fingerprint: String::new(), + style_rendered_block: String::new(), + entities_fingerprint: String::new(), + entities_rendered_block: String::new(), + bilingual_mode: bookforge_core::BilingualMode::Replace, + bilingual_separator: " / ".to_string(), + bilingual_style: bookforge_core::BilingualStyle::Minimal, + bilingual_css: None, + fallback: None, + finalize: bookforge_core::run_snapshot::FinalizeCheckpointSnapshot::default(), + settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), + }; + store + .update_job_config_snapshot(&job.id, &snapshot) + .expect("snapshot should persist"); + + MutationFixture { + store_path, + output_path, + job_id: job.id, + segment_a: chapter_segments[0].id.0.clone(), + segment_b: chapter_segments[1].id.0.clone(), + csrf: "fixture-csrf-token".to_string(), + _temp: temp, + } + } + + /// Sends `body` to `uri` with the given CSRF header value (or none), and + /// returns the response. + async fn post_json( + router: &Router, + uri: &str, + csrf: Option<&str>, + body: serde_json::Value, + ) -> Response { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let mut builder = Request::builder() + .method("POST") + .uri(uri) + .header("host", TEST_HOST) + .header("content-type", "application/json"); + if let Some(token) = csrf { + builder = builder.header(CSRF_HEADER, token); + } + router + .clone() + .oneshot( + builder + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("route should respond") + } + + #[tokio::test] + async fn save_manual_translation_rejects_missing_or_wrong_csrf_without_mutating_store() { + let fixture = build_mutation_fixture(); + let router = dashboard_router(test_state_with_store( + &fixture.csrf, + fixture.store_path.clone(), + )); + let uri = format!( + "/api/jobs/{}/segments/{}/translation", + fixture.job_id, fixture.segment_a + ); + let body = json!({ "blocks": [{ "block_id": "whatever", "text": "corrupted" }] }); + + let missing = post_json(&router, &uri, None, body.clone()).await; + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + + let wrong = post_json(&router, &uri, Some("wrong-token"), body).await; + assert_eq!(wrong.status(), StatusCode::FORBIDDEN); + + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + assert!( + !store + .translation_is_human_corrected(&fixture.job_id, &fixture.segment_a) + .expect("lookup should succeed"), + "a rejected request must not human-correct the segment" + ); + } + + #[tokio::test] + async fn set_segment_flag_rejects_missing_or_wrong_csrf_without_mutating_store() { + let fixture = build_mutation_fixture(); + let router = dashboard_router(test_state_with_store( + &fixture.csrf, + fixture.store_path.clone(), + )); + let uri = format!( + "/api/jobs/{}/segments/{}/flag", + fixture.job_id, fixture.segment_b + ); + let body = json!({ "flagged": true }); + + let missing = post_json(&router, &uri, None, body.clone()).await; + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + + let wrong = post_json(&router, &uri, Some("wrong-token"), body).await; + assert_eq!(wrong.status(), StatusCode::FORBIDDEN); + + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + let flagged = store + .dashboard_flagged_segment_ids(&fixture.job_id) + .expect("flags should load"); + assert!( + !flagged.contains(&fixture.segment_b), + "a rejected request must not persist a flag" + ); + } + + #[tokio::test] + async fn retry_segment_rejects_missing_or_wrong_csrf_without_mutating_store() { + let fixture = build_mutation_fixture(); + let router = dashboard_router(test_state_with_store( + &fixture.csrf, + fixture.store_path.clone(), + )); + let uri = format!( + "/api/jobs/{}/segments/{}/retry", + fixture.job_id, fixture.segment_b + ); + let body = json!({ "guidance": "please redo" }); + + let missing = post_json(&router, &uri, None, body.clone()).await; + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + + let wrong = post_json(&router, &uri, Some("wrong-token"), body).await; + assert_eq!(wrong.status(), StatusCode::FORBIDDEN); + + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + let guidance = store + .load_retry_guidance(&fixture.job_id) + .expect("guidance should load"); + assert!( + !guidance.contains_key(&fixture.segment_b), + "a rejected request must not persist retry guidance" + ); + let records = store + .segment_records(&fixture.job_id) + .expect("records should load"); + let segment_b = records + .iter() + .find(|record| record.id == fixture.segment_b) + .expect("segment_b should exist"); + assert_eq!( + segment_b.status, "succeeded", + "a rejected retry must not move the segment to retry_pending" + ); + } + + #[tokio::test] + async fn dashboard_review_and_mutation_endpoints_end_to_end() { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let fixture = build_mutation_fixture(); + let router = dashboard_router(test_state_with_store( + &fixture.csrf, + fixture.store_path.clone(), + )); + + // 1. Review data: per-block source/target text is present for both segments. + let review_uri = format!("/api/jobs/{}/review", fixture.job_id); + let response = router + .clone() + .oneshot( + Request::builder() + .uri(&review_uri) + .header("host", TEST_HOST) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("route should respond"); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let review: serde_json::Value = + serde_json::from_slice(&bytes).expect("review should be json"); + let segments = review["segments"].as_array().expect("segments array"); + // Two chapter segments plus the synthesized OPF `dc:title` metadata + // segment (see `build_mutation_fixture`). + assert_eq!(segments.len(), 3); + for segment in segments { + let blocks = segment["blocks"].as_array().expect("blocks array"); + assert!(!blocks.is_empty(), "each segment should have blocks"); + for block in blocks { + assert!( + !block["target_text"].as_str().unwrap_or_default().is_empty(), + "each block should carry non-empty target text" + ); + } + } + + // 2. Save a corrected translation for segment_a. + let segment_a_json = segments + .iter() + .find(|segment| segment["segment_id"] == fixture.segment_a) + .expect("segment_a should appear in the review"); + let block_ids = segment_a_json["blocks"] + .as_array() + .expect("blocks array") + .iter() + .map(|block| { + block["block_id"] + .as_str() + .expect("block_id should be a string") + .to_string() + }) + .collect::>(); + let correction_body = json!({ + "blocks": block_ids + .iter() + .map(|id| json!({ "block_id": id, "text": "Corrected by reviewer." })) + .collect::>(), + }); + let translation_uri = format!( + "/api/jobs/{}/segments/{}/translation", + fixture.job_id, fixture.segment_a + ); + let response = post_json( + &router, + &translation_uri, + Some(&fixture.csrf), + correction_body, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + assert!( + store + .translation_is_human_corrected(&fixture.job_id, &fixture.segment_a) + .expect("lookup should succeed"), + "segment_a should be marked human_corrected" + ); + assert!( + fixture.output_path.exists(), + "the rebuilt output EPUB should exist after the correction" + ); + + // 3. Flag, then clear, segment_b. + let flag_uri = format!( + "/api/jobs/{}/segments/{}/flag", + fixture.job_id, fixture.segment_b + ); + for flagged in [true, false] { + let response = post_json( + &router, + &flag_uri, + Some(&fixture.csrf), + json!({ "flagged": flagged }), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let flagged_ids = store + .dashboard_flagged_segment_ids(&fixture.job_id) + .expect("flags should load"); + assert_eq!(flagged_ids.contains(&fixture.segment_b), flagged); + } + + // 4. Request a retry with guidance for segment_b. + let retry_uri = format!( + "/api/jobs/{}/segments/{}/retry", + fixture.job_id, fixture.segment_b + ); + let response = post_json( + &router, + &retry_uri, + Some(&fixture.csrf), + json!({ "guidance": "Please redo more literally." }), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let guidance = store + .load_retry_guidance(&fixture.job_id) + .expect("guidance should load"); + assert_eq!( + guidance.get(&fixture.segment_b).map(String::as_str), + Some("Please redo more literally.") + ); + let records = store + .segment_records(&fixture.job_id) + .expect("records should load"); + let segment_b_record = records + .iter() + .find(|record| record.id == fixture.segment_b) + .expect("segment_b should exist"); + assert_eq!(segment_b_record.status, "retry_pending"); + + // 5. A retry request against the now-frozen (human-corrected) segment_a + // is rejected, and does not disturb its correction. + let retry_a_uri = format!( + "/api/jobs/{}/segments/{}/retry", + fixture.job_id, fixture.segment_a + ); + let response = post_json( + &router, + &retry_a_uri, + Some(&fixture.csrf), + json!({ "guidance": "try again" }), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let payload: serde_json::Value = + serde_json::from_slice(&bytes).expect("error body should be json"); + assert!( + payload["error"] + .as_str() + .unwrap_or_default() + .contains("frozen human correction"), + "rejection should explain the segment is frozen: {payload}" + ); + assert!( + store + .translation_is_human_corrected(&fixture.job_id, &fixture.segment_a) + .expect("lookup should succeed"), + "the rejected retry must not un-freeze the human correction" + ); + } } diff --git a/crates/bookforge-cli/src/commands/translate/mod.rs b/crates/bookforge-cli/src/commands/translate/mod.rs index 2ef8675..ce702f3 100644 --- a/crates/bookforge-cli/src/commands/translate/mod.rs +++ b/crates/bookforge-cli/src/commands/translate/mod.rs @@ -59,7 +59,7 @@ pub(crate) use cache::{CacheContext, apply_cached_translations, pending_segments use checkpointing::finalize_writer; pub(crate) use engine::{CheckpointRunContext, run_checkpointed_translation}; use reporting::print_summary_rebuild_and_report; -pub(crate) use reporting::rebuild_options_from_snapshot; +pub(crate) use reporting::{rebuild_options_from_snapshot, regenerate_report_after_correction}; use settings::{apply_provider_preset, resolve_settings, retry_amplification_warning}; use snapshot::persist_snapshot; @@ -392,6 +392,7 @@ pub(crate) fn prepare_glossary_run_config( format, entries_by_segment: selected.entries_by_segment, prompt_extra, + guidance_by_segment: std::collections::HashMap::new(), }, fingerprint, active_terms, @@ -886,7 +887,7 @@ async fn run_openai_compatible_translation( timestamp_ms: bookforge_core::progress::now_ms(), }); let run_prompt_version = if settings.batch.enabled { - PromptVersion::BatchV2.as_str() + PromptVersion::BatchV3.as_str() } else { PromptVersion::V2.as_str() }; diff --git a/crates/bookforge-cli/src/commands/translate/reporting.rs b/crates/bookforge-cli/src/commands/translate/reporting.rs index b7283e5..ce33c64 100644 --- a/crates/bookforge-cli/src/commands/translate/reporting.rs +++ b/crates/bookforge-cli/src/commands/translate/reporting.rs @@ -2,7 +2,7 @@ use anyhow::Result; use bookforge_core::{ RunConfigSnapshot, config::TranslationConfig, - segment::{BlockTranslation, Segment}, + segment::{BlockTranslation, Segment, SegmentStatus}, }; use bookforge_epub::{RebuildOptions, rebuild_epub_with_options}; use bookforge_llm::{QaSegmentReview, SegmentTranslation}; @@ -12,7 +12,7 @@ use crate::{ commands::validate, cost::estimate_cost_usd_with_cached, performance::performance_summary_from_events, - report::{ReportInput, write_report}, + report::{ReportFiles, ReportInput, TranslationQaInput, write_report}, }; pub fn block_translations(translations: &[SegmentTranslation]) -> Vec { @@ -63,15 +63,34 @@ pub fn print_summary_rebuild_and_report( .events_path .as_ref() .and_then(|path| performance_summary_from_events(path).ok().flatten()); + let qa_inputs = translations + .iter() + .map(|translation| { + TranslationQaInput::new( + translation.segment_id.0.clone(), + matches!( + translation.status, + SegmentStatus::Succeeded | SegmentStatus::SkippedCached + ), + translation.joined_text(), + ) + }) + .collect::>(); + let corrected_segments = store + .load_terminal_segment_translations(&job.id)? + .iter() + .filter(|translation| translation.human_corrected) + .count(); let report = write_report(ReportInput { job: &report_job, summary: &summary, segments, segment_records: &segment_records, - translations, + translations: &qa_inputs, qa_reviews, performance, output: &config.output, + corrected_segments, })?; store.update_job_report_paths(&job.id, &report.json, &report.markdown)?; @@ -110,6 +129,66 @@ pub fn print_summary_rebuild_and_report( Ok(()) } +/// Refreshes the on-disk QA report (`{stem}.report.json` / `.report.md`) for +/// a job entirely from store-backed data, without requiring the in-memory +/// run results (`SegmentTranslation`, `QaSegmentReview`) that only exist +/// during a live translate/resume run. Used after a manual correction, whose +/// invocation is a separate process from the original run — the correction +/// itself is already durably persisted by the time this runs, so this is a +/// best-effort refresh of a secondary artifact, not part of the correction's +/// atomicity contract. +/// +/// QA-review-derived warnings (from an optional double-check pass) cannot be +/// reconstructed here since they are never persisted to the store; the +/// refreshed report simply omits them, which is correct because a segment +/// that was just manually corrected has no meaningful stale AI QA verdict to +/// preserve anyway. +pub(crate) fn regenerate_report_after_correction( + store: &JobStore, + job: &JobRecord, + segments: &[Segment], +) -> Result { + let summary = store + .summary(&job.id)? + .ok_or_else(|| anyhow::anyhow!("job '{}' was not found after correction", job.id))?; + let report_job = store + .get_job(&job.id)? + .ok_or_else(|| anyhow::anyhow!("job '{}' was not found after correction", job.id))?; + let segment_records = store.segment_records(&job.id)?; + let stored_translations = store.load_terminal_segment_translations(&job.id)?; + let corrected_segments = stored_translations + .iter() + .filter(|translation| translation.human_corrected) + .count(); + let qa_inputs = stored_translations + .iter() + .map(|translation| { + TranslationQaInput::new( + translation.segment_id.clone(), + matches!(translation.status.as_str(), "succeeded" | "skipped_cached"), + translation.translated_text.clone(), + ) + }) + .collect::>(); + let performance = report_job + .events_path + .as_ref() + .and_then(|path| performance_summary_from_events(path).ok().flatten()); + let report = write_report(ReportInput { + job: &report_job, + summary: &summary, + segments, + segment_records: &segment_records, + translations: &qa_inputs, + qa_reviews: &[], + performance, + output: &report_job.output_path, + corrected_segments, + })?; + store.update_job_report_paths(&job.id, &report.json, &report.markdown)?; + Ok(report) +} + pub fn rebuild_options_from_snapshot(snapshot: &RunConfigSnapshot) -> RebuildOptions { RebuildOptions { target_language: Some(snapshot.target_language.clone()), diff --git a/crates/bookforge-cli/src/main.rs b/crates/bookforge-cli/src/main.rs index 4b91297..c2d2b09 100644 --- a/crates/bookforge-cli/src/main.rs +++ b/crates/bookforge-cli/src/main.rs @@ -19,8 +19,9 @@ use commands::serve; #[cfg(feature = "tui")] use commands::watch; use commands::{ - control as control_commands, convert, doctor, entity, estimate, glossary, ingest_flags, - inspect, reconfigure, reflow, resume, retry, review, status, style, tail, translate, validate, + control as control_commands, convert, correct, doctor, entity, estimate, glossary, + ingest_flags, inspect, reconfigure, reflow, resume, retry, review, status, style, tail, + translate, validate, }; #[cfg(any(test, not(feature = "serve")))] use std::io::Write; @@ -61,6 +62,7 @@ enum Command { Reconfigure(reconfigure::ReconfigureArgs), Resume(resume::ResumeArgs), Stop(control_commands::StopArgs), + Correct(correct::CorrectArgs), Review(review::ReviewArgs), IngestFlags(ingest_flags::IngestFlagsArgs), Glossary(glossary::GlossaryArgs), @@ -122,6 +124,7 @@ async fn run_command(command: Command, cancel_token: CancellationToken) -> Resul Command::Reconfigure(args) => reconfigure::run(args).await, Command::Resume(args) => resume::run(args).await, Command::Stop(args) => control_commands::stop(args).await, + Command::Correct(args) => correct::run(args).await, Command::Review(args) => review::run(args).await, Command::IngestFlags(args) => ingest_flags::run(args).await, Command::Glossary(args) => glossary::run(args).await, diff --git a/crates/bookforge-cli/src/report.rs b/crates/bookforge-cli/src/report.rs index 0f381d9..aec0967 100644 --- a/crates/bookforge-cli/src/report.rs +++ b/crates/bookforge-cli/src/report.rs @@ -5,8 +5,8 @@ use std::{ }; use anyhow::Result; -use bookforge_core::segment::{Segment, SegmentStatus}; -use bookforge_llm::{QaSegmentReview, SegmentTranslation}; +use bookforge_core::segment::Segment; +use bookforge_llm::QaSegmentReview; use bookforge_store::{JobRecord, JobSummary, SegmentRecord}; use serde::Serialize; @@ -19,16 +19,51 @@ pub(crate) struct ReportFiles { pub markdown: PathBuf, } +/// Decoupled view of a segment's translated text used to drive the QA +/// heuristics in [`qa_warnings`]. Kept independent of both +/// `bookforge_llm::SegmentTranslation` (live in-memory run results) and +/// `bookforge_store::StoredSegmentTranslation` (reloaded from the database) +/// so the report can be built fresh from either source — in particular, so a +/// manual correction (which only has store-backed data available) can +/// regenerate the report without pulling in a full in-memory run result. +#[derive(Debug, Clone)] +pub(crate) struct TranslationQaInput { + pub segment_id: String, + /// Whether this translation is in a terminal "counts as translated" + /// state (mirrors `SegmentStatus::Succeeded | SegmentStatus::SkippedCached`). + pub counts_for_warnings: bool, + pub joined_text: String, +} + +impl TranslationQaInput { + pub(crate) fn new( + segment_id: impl Into, + counts_for_warnings: bool, + joined_text: impl Into, + ) -> Self { + Self { + segment_id: segment_id.into(), + counts_for_warnings, + joined_text: joined_text.into(), + } + } +} + #[derive(Debug)] pub(crate) struct ReportInput<'a> { pub job: &'a JobRecord, pub summary: &'a JobSummary, pub segments: &'a [Segment], pub segment_records: &'a [SegmentRecord], - pub translations: &'a [SegmentTranslation], + pub translations: &'a [TranslationQaInput], pub qa_reviews: &'a [QaSegmentReview], pub performance: Option, pub output: &'a Path, + /// Count of segments whose stored translation carries a human + /// (manual-correction) origin. Additive counterpart to the other + /// aggregate segment counts below — kept in sync with `review.rs`'s + /// per-segment `human_corrected` field. + pub corrected_segments: usize, } #[derive(Debug, Serialize)] @@ -47,6 +82,7 @@ struct QaReport { failed_segments: usize, needs_review_segments: usize, retry_pending_segments: usize, + corrected_segments: usize, input_tokens: u64, input_cached_tokens: u64, output_tokens: u64, @@ -81,6 +117,7 @@ pub(crate) fn write_report(input: ReportInput<'_>) -> Result { failed_segments: input.summary.failed, needs_review_segments: input.summary.needs_review, retry_pending_segments: input.summary.retry_pending, + corrected_segments: input.corrected_segments, input_tokens: input.summary.input_tokens, input_cached_tokens: input.summary.input_cached_tokens, output_tokens: input.summary.output_tokens, @@ -157,27 +194,24 @@ fn qa_warnings(input: &ReportInput<'_>) -> Vec { .collect::>(); for translation in input.translations { - if !matches!( - translation.status, - SegmentStatus::Succeeded | SegmentStatus::SkippedCached - ) { + if !translation.counts_for_warnings { continue; } - let Some(source) = source_by_segment.get(translation.segment_id.0.as_str()) else { + let Some(source) = source_by_segment.get(translation.segment_id.as_str()) else { continue; }; - let translated = translation.joined_text(); + let translated = translation.joined_text.clone(); let source_len = source.chars().count().max(1); let translated_len = translated.chars().count(); if source_len >= 40 { let ratio = translated_len as f64 / source_len as f64; if !(0.33..=3.0).contains(&ratio) - && seen.insert((translation.segment_id.0.clone(), "length_ratio")) + && seen.insert((translation.segment_id.clone(), "length_ratio")) { warnings.push(QaWarning { severity: "warning", kind: "length_ratio", - segment_id: Some(translation.segment_id.0.clone()), + segment_id: Some(translation.segment_id.clone()), message: format!( "translated length ratio is suspicious: {ratio:.2} ({source_len} source chars, {translated_len} target chars)" ), @@ -187,57 +221,57 @@ fn qa_warnings(input: &ReportInput<'_>) -> Vec { if source_len >= 40 && source.trim() == translated.trim() - && seen.insert((translation.segment_id.0.clone(), "untranslated")) + && seen.insert((translation.segment_id.clone(), "untranslated")) { warnings.push(QaWarning { severity: "warning", kind: "untranslated", - segment_id: Some(translation.segment_id.0.clone()), + segment_id: Some(translation.segment_id.clone()), message: "translation is identical to the source text".to_string(), }); } if let Some(message) = missing_tokens_message("URL", &urls(source), &urls(&translated)) - && seen.insert((translation.segment_id.0.clone(), "url_changed")) + && seen.insert((translation.segment_id.clone(), "url_changed")) { warnings.push(QaWarning { severity: "warning", kind: "url_changed", - segment_id: Some(translation.segment_id.0.clone()), + segment_id: Some(translation.segment_id.clone()), message, }); } if let Some(message) = missing_tokens_message("number", &numbers(source), &numbers(&translated)) - && seen.insert((translation.segment_id.0.clone(), "number_changed")) + && seen.insert((translation.segment_id.clone(), "number_changed")) { warnings.push(QaWarning { severity: "warning", kind: "number_changed", - segment_id: Some(translation.segment_id.0.clone()), + segment_id: Some(translation.segment_id.clone()), message, }); } if looks_like_model_commentary(&translated) - && seen.insert((translation.segment_id.0.clone(), "model_commentary")) + && seen.insert((translation.segment_id.clone(), "model_commentary")) { warnings.push(QaWarning { severity: "warning", kind: "model_commentary", - segment_id: Some(translation.segment_id.0.clone()), + segment_id: Some(translation.segment_id.clone()), message: "translation appears to include model commentary".to_string(), }); } if has_repetition(&translated) - && seen.insert((translation.segment_id.0.clone(), "repetition")) + && seen.insert((translation.segment_id.clone(), "repetition")) { warnings.push(QaWarning { severity: "warning", kind: "repetition", - segment_id: Some(translation.segment_id.0.clone()), + segment_id: Some(translation.segment_id.clone()), message: "translation contains suspicious repeated words".to_string(), }); } @@ -560,6 +594,10 @@ fn render_markdown(report: &QaReport) -> String { "- Retry pending: {}\n", report.retry_pending_segments )); + output.push_str(&format!( + "- Manually corrected: {}\n", + report.corrected_segments + )); output.push_str(&format!("- Input tokens: {}\n", report.input_tokens)); output.push_str(&format!( "- Cached input tokens: {}\n", diff --git a/crates/bookforge-cli/tests/lifecycle.rs b/crates/bookforge-cli/tests/lifecycle.rs index 5fa52e2..8397ad7 100644 --- a/crates/bookforge-cli/tests/lifecycle.rs +++ b/crates/bookforge-cli/tests/lifecycle.rs @@ -11,7 +11,7 @@ use bookforge_core::{ ControlCommand, GlossaryCategory, GlossaryStatus, GlossaryTerm, read_control_file, write_control_file, }; -use bookforge_store::{GlossaryFilter, JobStore, NewGlossaryCandidate}; +use bookforge_store::{GlossaryFilter, JobStore, NewGlossaryCandidate, StoreError}; use sha2::{Digest, Sha256}; use tempfile::TempDir; use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; @@ -1304,6 +1304,234 @@ fn cli_status_after_translate_reports_succeeded_job() { assert!(stdout.contains("Performance:")); } +#[test] +fn cli_correct_persists_manual_blocks_and_rebuilds_output() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let run = translate_quiet(&temp, "mock-prefix-target"); + let store = JobStore::open(temp.path().join(".bookforge/jobs.sqlite")).expect("store opens"); + let segment = store + .load_terminal_segment_translations(&run.job_id) + .expect("translations should load") + .into_iter() + .next() + .expect("fixture should produce a translated segment"); + let corrected_blocks = segment + .blocks + .iter() + .map(|block| { + serde_json::json!({ + "block_id": block.block_id.0, + "text": format!("MANUAL {}", block.text), + }) + }) + .collect::>(); + let correction_path = temp.path().join("correction.json"); + fs::write( + &correction_path, + serde_json::to_vec_pretty(&serde_json::json!({ "blocks": corrected_blocks })) + .expect("correction should serialize"), + ) + .expect("correction file should write"); + + let report_json_path = run.report.with_extension("json"); + let report_before: serde_json::Value = serde_json::from_slice( + &fs::read(&report_json_path).expect("QA report should exist after translate"), + ) + .expect("QA report should parse before correction"); + assert_eq!( + report_before["corrected_segments"], 0, + "no segment should be marked corrected before the `correct` run" + ); + + bookforge() + .current_dir(temp.path()) + .args([ + "correct", + &run.job_id, + "--segment", + &segment.segment_id, + "--from-file", + correction_path.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicates::str::contains("Job status: succeeded")); + + let corrected = store + .load_terminal_segment_translations(&run.job_id) + .expect("corrected translations should load") + .into_iter() + .find(|translation| translation.segment_id == segment.segment_id) + .expect("corrected segment should remain present"); + assert!(corrected.human_corrected); + assert_eq!(corrected.provider, "manual"); + assert!( + corrected + .blocks + .iter() + .all(|block| block.text.starts_with("MANUAL ")) + ); + assert!( + run.output.exists(), + "correct should rebuild the translated EPUB" + ); + + // The QA report artifact (report.rs's `write_report`, auto-written at + // translate/resume finalization) is regenerated in place by + // `correct_job_segment` so it does not go stale after a manual + // correction — see `regenerate_report_after_correction` in + // `commands/translate/reporting.rs`. + let report_after: serde_json::Value = serde_json::from_slice( + &fs::read(&report_json_path).expect("QA report should still exist after correction"), + ) + .expect("QA report should parse after correction"); + assert_eq!( + report_after["corrected_segments"], 1, + "QA report should be refreshed to reflect the manual correction" + ); + let report_markdown_after = fs::read_to_string(&run.report) + .expect("QA report markdown should still exist after correction"); + assert!( + report_markdown_after.contains("Manually corrected: 1"), + "QA report markdown should be refreshed with the corrected-segment count: {report_markdown_after}" + ); + + let review_dir = temp.path().join("corrected-review"); + bookforge() + .current_dir(temp.path()) + .args(["review", &run.job_id, "--out", review_dir.to_str().unwrap()]) + .assert() + .success(); + let review: serde_json::Value = serde_json::from_slice( + &fs::read(review_dir.join("review.json")).expect("review JSON should exist"), + ) + .expect("review should parse"); + let reviewed = review["segments"] + .as_array() + .unwrap() + .iter() + .find(|item| item["segment_id"] == segment.segment_id) + .expect("corrected segment should be in review"); + assert_eq!(reviewed["human_corrected"], true); + assert!(reviewed["corrected_at"].is_string()); + + assert_no_staged_output_files(&run.output); +} + +/// Lists the output directory and fails if any staged rebuild artifact (the +/// `.staged--` sibling that `correct_job_segment` +/// rebuilds into before atomically swapping it over the real output) was left +/// behind. A leftover staged file would mean the atomic swap either never ran +/// or failed to clean up after itself. +fn assert_no_staged_output_files(output: &Path) { + let stem = output + .file_stem() + .and_then(|value| value.to_str()) + .expect("output should have a file stem"); + let dir = output + .parent() + .expect("output should have a parent directory"); + let stray = fs::read_dir(dir) + .expect("output directory should be readable") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with(&format!("{stem}.staged-"))) + .collect::>(); + assert!( + stray.is_empty(), + "no staged correction rebuild artifacts should remain in {}, found: {:?}", + dir.display(), + stray + ); +} + +#[test] +fn cli_correct_with_marker_violation_leaves_db_and_output_unchanged() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let run = translate_quiet(&temp, "mock-prefix-target"); + let store = JobStore::open(temp.path().join(".bookforge/jobs.sqlite")).expect("store opens"); + let segment = store + .load_terminal_segment_translations(&run.job_id) + .expect("translations should load") + .into_iter() + .next() + .expect("fixture should produce a translated segment"); + let original_output_bytes = fs::read(&run.output).expect("output should exist before correct"); + + // Blanking a block's translation while its source text is non-empty trips + // the "empty_translation" structural-validation error, which is a + // deterministic way to exercise the failure path without mocking the + // filesystem. + let corrected_blocks = segment + .blocks + .iter() + .enumerate() + .map(|(index, block)| { + let text = if index == 0 { + String::new() + } else { + block.text.clone() + }; + serde_json::json!({ + "block_id": block.block_id.0, + "text": text, + }) + }) + .collect::>(); + let correction_path = temp.path().join("bad-correction.json"); + fs::write( + &correction_path, + serde_json::to_vec_pretty(&serde_json::json!({ "blocks": corrected_blocks })) + .expect("correction should serialize"), + ) + .expect("correction file should write"); + + let assert = bookforge() + .current_dir(temp.path()) + .args([ + "correct", + &run.job_id, + "--segment", + &segment.segment_id, + "--from-file", + correction_path.to_str().unwrap(), + ]) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + stderr.contains("manual correction violates EPUB marker constraints"), + "unexpected stderr: {stderr}" + ); + + let unchanged = store + .load_terminal_segment_translations(&run.job_id) + .expect("translations should still load") + .into_iter() + .find(|translation| translation.segment_id == segment.segment_id) + .expect("segment should remain present"); + assert!( + !unchanged.human_corrected, + "failed correction must not be recorded as human-corrected" + ); + assert_eq!( + unchanged.provider, segment.provider, + "failed correction must not overwrite the original provider" + ); + assert_eq!( + unchanged.blocks, segment.blocks, + "failed correction must not change stored block translations" + ); + + let output_bytes_after = fs::read(&run.output).expect("output should still exist"); + assert_eq!( + output_bytes_after, original_output_bytes, + "failed correction must leave the existing output EPUB byte-identical" + ); + + assert_no_staged_output_files(&run.output); +} + #[test] fn cli_tail_after_translate_prints_recent_events() { let temp = tempfile::tempdir().expect("temp dir should be created"); @@ -1963,6 +2191,316 @@ fn cli_resume_uses_input_snapshot_after_original_is_moved() { .success(); } +// --- Dashboard-driven single-segment retry with guidance --------------- +// +// `JobStore::request_segment_retry` stores optional guidance text in +// `segment_flags` (kind = 'dashboard_retry') and marks the segment /job +// `retry_pending`. `resume` reloads that guidance fresh via +// `JobStore::load_retry_guidance` (see commands/resume.rs) and wires it into +// `TranslationRunConfig.guidance_by_segment`, which is rendered into the +// prompt (`prompt_extra_for_segment` in single-segment mode, +// `render_batch_items`'s `retry_guidance` field in batch mode) and is +// consumed only once a terminal provider result is saved +// (`consume_dashboard_retry_guidance`, called from `save_translation`, +// `save_needs_review`, and `save_cached_translation`). +// +// IMPORTANT LIMITATION: the compiled `mock` provider used by these +// subprocess-driven lifecycle tests (`bookforge_llm::provider::MockProvider`) +// never observes or logs the rendered `request.user` prompt text it +// receives — it only ever echoes back a transform of the *source* text, and +// nothing persists the raw prompt anywhere a subprocess-based integration +// test can read it back (no db column, no progress-jsonl event field, no +// env-var-gated dump). So these lifecycle tests cannot assert "the prompt +// text contains the guidance string" the way `bookforge-llm`'s in-process +// unit tests can (see `prompt_renders_glossary_json_prose_and_prompt_extra` +// in crates/bookforge-llm/src/scheduler.rs and the analogous +// `render_batch_items`-guidance test in crates/bookforge-llm/src/batch.rs, +// which assert the rendered prompt/JSON item literally contains +// `retry_guidance`/the guidance text). At the lifecycle level we instead +// assert the strongest observable proxy: guidance is present in the store +// before resume, the flagged segment is provably re-translated by resume +// (not served from cache, not skipped), and guidance is gone afterward. +// A production hook that would make the prompt text itself observable here +// would be an env-var-gated capture in `MockProvider::complete` (e.g. +// `BOOKFORGE_MOCK_PROMPT_LOG=` appending +// `{request_id/segment_id, template, user}` as JSONL) — deliberately not +// added here since this pass is test-only. + +#[test] +fn cli_resume_after_dashboard_retry_guidance_retranslates_single_segment_mode() { + // `--profile safe` disables batching, so this exercises the + // single-segment prompt path (`prompt_extra_for_segment`) for guidance. + let temp = tempfile::tempdir().expect("temp dir should be created"); + let input = fixture_input(); + let output = temp.path().join("out.epub"); + let events = temp.path().join("events.jsonl"); + bookforge() + .current_dir(temp.path()) + .args([ + "translate", + input.to_str().unwrap(), + "--target", + "Italian", + "--provider", + "mock", + "--model", + "mock-prefix-target", + "--profile", + "safe", + "--ui", + "quiet", + "--progress-jsonl", + events.to_str().unwrap(), + "--out", + output.to_str().unwrap(), + ]) + .assert() + .success(); + let job_id = job_id_from_events(&events); + + let db_path = temp.path().join(".bookforge/jobs.sqlite"); + let store = JobStore::open(&db_path).expect("store should open"); + let before = store + .segment_records(&job_id) + .expect("segments should load"); + assert!(!before.is_empty(), "fixture should produce segments"); + let retry_id = before[0].id.clone(); + let attempts_before = before[0].attempts; + + store + .request_segment_retry( + &job_id, + &retry_id, + Some("Use a more formal register for this paragraph."), + ) + .expect("retry request should succeed"); + let guidance_before_resume = store + .load_retry_guidance(&job_id) + .expect("guidance should load"); + assert_eq!( + guidance_before_resume + .get(retry_id.as_str()) + .map(String::as_str), + Some("Use a more formal register for this paragraph."), + "guidance stored by request_segment_retry should be readable back from the store \ + (i.e. it survives independent of any in-process state)" + ); + // Drop this handle and open a brand-new one after `resume` runs as a + // fresh subprocess below, so nothing about this test relies on shared + // in-memory state surviving a "restart". + drop(store); + + let resume_events = temp.path().join("resume-events.jsonl"); + bookforge() + .current_dir(temp.path()) + .args([ + "resume", + &job_id, + "--ui", + "quiet", + "--progress-jsonl", + resume_events.to_str().unwrap(), + ]) + .assert() + .success(); + + let events_after = read_jsonl(&resume_events); + assert_eq!( + batch_request_started_count(&events_after), + 0, + "safe profile should stay on the single-segment prompt path" + ); + assert_eq!( + segment_finished_ids(&events_after), + vec![retry_id.clone()], + "resume should retranslate exactly the segment flagged via dashboard retry" + ); + + let store = JobStore::open(&db_path).expect("store should open"); + let after = store + .segment_records(&job_id) + .expect("segments should load after resume"); + let retried = after + .iter() + .find(|record| record.id == retry_id) + .expect("retried segment should still be present"); + assert_eq!( + retried.status, "succeeded", + "retried segment should reach a terminal status" + ); + assert!( + retried.attempts > attempts_before, + "retried segment should have actually been re-translated (attempts increased from {attempts_before} to {})", + retried.attempts + ); + + let guidance_after_resume = store + .load_retry_guidance(&job_id) + .expect("guidance should load after resume"); + assert!( + guidance_after_resume.get(retry_id.as_str()).is_none(), + "guidance should be consumed once the retried segment reaches a terminal (succeeded) result" + ); +} + +#[test] +fn cli_resume_after_dashboard_retry_guidance_retranslates_batch_mode() { + // Default profile (v1-fast) keeps batching enabled, so this exercises + // the batch prompt path where guidance is serialized per-item as + // `retry_guidance` by `render_batch_items` (bookforge-llm/src/batch.rs). + let temp = tempfile::tempdir().expect("temp dir should be created"); + let run = translate_quiet(&temp, "mock-prefix-target"); + + let db_path = temp.path().join(".bookforge/jobs.sqlite"); + let store = JobStore::open(&db_path).expect("store should open"); + let before = store + .segment_records(&run.job_id) + .expect("segments should load"); + assert!(!before.is_empty(), "fixture should produce segments"); + let retry_id = before[0].id.clone(); + let attempts_before = before[0].attempts; + + store + .request_segment_retry( + &run.job_id, + &retry_id, + Some("Tighten the dialogue tag here."), + ) + .expect("retry request should succeed"); + assert_eq!( + store + .load_retry_guidance(&run.job_id) + .expect("guidance should load") + .get(retry_id.as_str()) + .map(String::as_str), + Some("Tighten the dialogue tag here.") + ); + drop(store); + + let resume_events = temp.path().join("resume-events.jsonl"); + bookforge() + .current_dir(temp.path()) + .args([ + "resume", + &run.job_id, + "--ui", + "quiet", + "--progress-jsonl", + resume_events.to_str().unwrap(), + ]) + .assert() + .success(); + + let events_after = read_jsonl(&resume_events); + assert!( + batch_request_started_count(&events_after) >= 1, + "v1-fast profile should route the retry through the batch prompt path" + ); + assert_eq!( + segment_finished_ids(&events_after), + vec![retry_id.clone()], + "resume should retranslate exactly the segment flagged via dashboard retry" + ); + + let store = JobStore::open(&db_path).expect("store should open"); + let after = store + .segment_records(&run.job_id) + .expect("segments should load after resume"); + let retried = after + .iter() + .find(|record| record.id == retry_id) + .expect("retried segment should still be present"); + assert_eq!(retried.status, "succeeded"); + assert!( + retried.attempts > attempts_before, + "retried segment should have actually been re-translated (attempts increased from {attempts_before} to {})", + retried.attempts + ); + assert!( + store + .load_retry_guidance(&run.job_id) + .expect("guidance should load after resume") + .get(retry_id.as_str()) + .is_none(), + "guidance should be consumed once the retried segment reaches a terminal (succeeded) result in batch mode too" + ); +} + +#[test] +fn cli_request_segment_retry_rejects_segment_frozen_by_correct_command() { + // Store-level coverage for this rejection already exists (see + // `request_segment_retry_rejects_human_corrected_segment` in + // crates/bookforge-store/src/db.rs). This lifecycle-level test is cheap + // to add on top of the existing `correct`-flow fixture and exercises the + // rejection through the real `correct` CLI command (which calls + // `save_manual_correction`) rather than a direct store call, proving the + // freeze is honored end-to-end. + let temp = tempfile::tempdir().expect("temp dir should be created"); + let run = translate_quiet(&temp, "mock-prefix-target"); + let store = JobStore::open(temp.path().join(".bookforge/jobs.sqlite")).expect("store opens"); + let segment = store + .load_terminal_segment_translations(&run.job_id) + .expect("translations should load") + .into_iter() + .next() + .expect("fixture should produce a translated segment"); + let corrected_blocks = segment + .blocks + .iter() + .map(|block| { + serde_json::json!({ + "block_id": block.block_id.0, + "text": format!("MANUAL {}", block.text), + }) + }) + .collect::>(); + let correction_path = temp.path().join("correction.json"); + fs::write( + &correction_path, + serde_json::to_vec_pretty(&serde_json::json!({ "blocks": corrected_blocks })) + .expect("correction should serialize"), + ) + .expect("correction file should write"); + + bookforge() + .current_dir(temp.path()) + .args([ + "correct", + &run.job_id, + "--segment", + &segment.segment_id, + "--from-file", + correction_path.to_str().unwrap(), + ]) + .assert() + .success(); + + let result = store.request_segment_retry(&run.job_id, &segment.segment_id, Some("try again")); + assert!( + matches!(result, Err(StoreError::InvalidCorrection(_))), + "retrying a segment frozen by a human correction must be rejected, got: {result:?}" + ); + + let guidance = store + .load_retry_guidance(&run.job_id) + .expect("guidance should load"); + assert!( + guidance.get(segment.segment_id.as_str()).is_none(), + "rejected retry must not record guidance for the frozen segment" + ); + let records = store + .segment_records(&run.job_id) + .expect("segment records should load"); + let frozen = records + .iter() + .find(|record| record.id == segment.segment_id) + .expect("frozen segment should remain present"); + assert_eq!( + frozen.status, "succeeded", + "rejected retry must not disturb the frozen segment's status" + ); +} + #[test] fn cli_review_generates_artifacts_and_ingest_flags_marks_retry() { let temp = tempfile::tempdir().expect("temp dir should be created"); diff --git a/crates/bookforge-core/Cargo.toml b/crates/bookforge-core/Cargo.toml index c71a1b1..6976901 100644 --- a/crates/bookforge-core/Cargo.toml +++ b/crates/bookforge-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bookforge-core" -version = "2.3.0" +version = "2.4.0-dev" description = "Core IR, segmentation, configuration, and progress types for BookForge." edition.workspace = true license.workspace = true diff --git a/crates/bookforge-core/src/config.rs b/crates/bookforge-core/src/config.rs index 0e70b87..b73dd6a 100644 --- a/crates/bookforge-core/src/config.rs +++ b/crates/bookforge-core/src/config.rs @@ -78,6 +78,15 @@ pub enum PromptVersion { BatchV1, V2, BatchV2, + /// Batch prompts (plain / marker-safe / run-preserving, and their + /// compact variants) gained a per-item `retry_guidance` field. The + /// underlying `bookforge-llm` templates moved from v2 to v3; this tag + /// must move in lockstep so `segments.prompt_version` (the + /// cross-job translation cache key — see + /// `bookforge_store::JobStore::find_cached_translation`) stops + /// matching translations produced under the old v2-era batch prompt + /// text. + BatchV3, } impl PromptVersion { @@ -87,6 +96,7 @@ impl PromptVersion { PromptVersion::BatchV1 => "batch_v1", PromptVersion::V2 => "v2", PromptVersion::BatchV2 => "batch_v2", + PromptVersion::BatchV3 => "batch_v3", } } } diff --git a/crates/bookforge-epub/Cargo.toml b/crates/bookforge-epub/Cargo.toml index 2ae220a..c54b340 100644 --- a/crates/bookforge-epub/Cargo.toml +++ b/crates/bookforge-epub/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bookforge-epub" -version = "2.3.0" +version = "2.4.0-dev" description = "EPUB reading, validation, and deterministic rebuild support for BookForge." edition.workspace = true license.workspace = true @@ -8,7 +8,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -bookforge-core = { version = "2.3.0", path = "../bookforge-core" } +bookforge-core = { version = "2.4.0-dev", path = "../bookforge-core" } quick-xml.workspace = true serde.workspace = true tracing.workspace = true diff --git a/crates/bookforge-llm/Cargo.toml b/crates/bookforge-llm/Cargo.toml index 3e93b59..84acc45 100644 --- a/crates/bookforge-llm/Cargo.toml +++ b/crates/bookforge-llm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bookforge-llm" -version = "2.3.0" +version = "2.4.0-dev" description = "Prompting, providers, batching, and validation for BookForge translation runs." edition.workspace = true license.workspace = true @@ -8,7 +8,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -bookforge-core = { version = "2.3.0", path = "../bookforge-core" } +bookforge-core = { version = "2.4.0-dev", path = "../bookforge-core" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/bookforge-llm/prompts/translate_batch_marker_safe.v2.md b/crates/bookforge-llm/prompts/translate_batch_marker_safe.v3.md similarity index 94% rename from crates/bookforge-llm/prompts/translate_batch_marker_safe.v2.md rename to crates/bookforge-llm/prompts/translate_batch_marker_safe.v3.md index 9717273..7e596d9 100644 --- a/crates/bookforge-llm/prompts/translate_batch_marker_safe.v2.md +++ b/crates/bookforge-llm/prompts/translate_batch_marker_safe.v3.md @@ -55,7 +55,8 @@ Return exactly: Translate every structured item. Preserve ALL markers exactly. -Each input item may include `glossary` or `glossary_prose`; honor those constraints for that item. +Each input item may include `glossary`, `glossary_prose`, or `retry_guidance`; +honor those constraints for that item. Retry guidance applies only to that item. Active style guide (apply consistently to every item): diff --git a/crates/bookforge-llm/prompts/translate_batch_marker_safe_compact.v2.md b/crates/bookforge-llm/prompts/translate_batch_marker_safe_compact.v3.md similarity index 88% rename from crates/bookforge-llm/prompts/translate_batch_marker_safe_compact.v2.md rename to crates/bookforge-llm/prompts/translate_batch_marker_safe_compact.v3.md index 72c46f2..06d4f4a 100644 --- a/crates/bookforge-llm/prompts/translate_batch_marker_safe_compact.v2.md +++ b/crates/bookforge-llm/prompts/translate_batch_marker_safe_compact.v3.md @@ -17,7 +17,7 @@ Return exactly: ## User Translate every item, preserving all markers. -Honor item `glossary`/`glossary_prose` constraints. Style: {{style_guide_block}} +Honor item `glossary`/`glossary_prose`/`retry_guidance` constraints. Style: {{style_guide_block}} Entities: {{entity_agreement_block}} Prior context: {{context_translation_pairs}} Extra: {{prompt_extra}} diff --git a/crates/bookforge-llm/prompts/translate_batch_plain.v2.md b/crates/bookforge-llm/prompts/translate_batch_plain.v3.md similarity index 83% rename from crates/bookforge-llm/prompts/translate_batch_plain.v2.md rename to crates/bookforge-llm/prompts/translate_batch_plain.v3.md index 9f7fc55..862c51e 100644 --- a/crates/bookforge-llm/prompts/translate_batch_plain.v2.md +++ b/crates/bookforge-llm/prompts/translate_batch_plain.v3.md @@ -21,7 +21,9 @@ Return exactly: Translate every item. -Each input item may include `glossary` or `glossary_prose`; honor those constraints for that item. +Each input item may include `glossary`, `glossary_prose`, or `retry_guidance`; +honor those constraints for that item. Retry guidance is operator feedback +about a previous translation and applies only to that item. Active style guide (apply consistently to every item): diff --git a/crates/bookforge-llm/prompts/translate_batch_plain_compact.v2.md b/crates/bookforge-llm/prompts/translate_batch_plain_compact.v3.md similarity index 83% rename from crates/bookforge-llm/prompts/translate_batch_plain_compact.v2.md rename to crates/bookforge-llm/prompts/translate_batch_plain_compact.v3.md index f9217a3..16a802b 100644 --- a/crates/bookforge-llm/prompts/translate_batch_plain_compact.v2.md +++ b/crates/bookforge-llm/prompts/translate_batch_plain_compact.v3.md @@ -11,7 +11,7 @@ Return exactly: ## User Translate every item. -Honor item `glossary`/`glossary_prose` constraints. Style: {{style_guide_block}} +Honor item `glossary`/`glossary_prose`/`retry_guidance` constraints. Style: {{style_guide_block}} Entities: {{entity_agreement_block}} Prior context: {{context_translation_pairs}} Extra: {{prompt_extra}} diff --git a/crates/bookforge-llm/prompts/translate_batch_run_preserving.v2.md b/crates/bookforge-llm/prompts/translate_batch_run_preserving.v3.md similarity index 87% rename from crates/bookforge-llm/prompts/translate_batch_run_preserving.v2.md rename to crates/bookforge-llm/prompts/translate_batch_run_preserving.v3.md index b28fc51..79a4017 100644 --- a/crates/bookforge-llm/prompts/translate_batch_run_preserving.v2.md +++ b/crates/bookforge-llm/prompts/translate_batch_run_preserving.v3.md @@ -22,7 +22,8 @@ Return exactly: Translate every item. -Each input item may include `glossary` or `glossary_prose`; honor those constraints for that item. +Each input item may include `glossary`, `glossary_prose`, or `retry_guidance`; +honor those constraints for that item. Retry guidance applies only to that item. Active style guide (apply consistently to every item): diff --git a/crates/bookforge-llm/prompts/translate_batch_run_preserving_compact.v2.md b/crates/bookforge-llm/prompts/translate_batch_run_preserving_compact.v3.md similarity index 86% rename from crates/bookforge-llm/prompts/translate_batch_run_preserving_compact.v2.md rename to crates/bookforge-llm/prompts/translate_batch_run_preserving_compact.v3.md index c6a0293..73a5ffc 100644 --- a/crates/bookforge-llm/prompts/translate_batch_run_preserving_compact.v2.md +++ b/crates/bookforge-llm/prompts/translate_batch_run_preserving_compact.v3.md @@ -17,7 +17,7 @@ Return exactly: ## User Translate every item. -Honor item `glossary`/`glossary_prose` constraints. Style: {{style_guide_block}} +Honor item `glossary`/`glossary_prose`/`retry_guidance` constraints. Style: {{style_guide_block}} Entities: {{entity_agreement_block}} Prior context: {{context_translation_pairs}} Extra: {{prompt_extra}} diff --git a/crates/bookforge-llm/src/batch.rs b/crates/bookforge-llm/src/batch.rs index 10d3170..efa6676 100644 --- a/crates/bookforge-llm/src/batch.rs +++ b/crates/bookforge-llm/src/batch.rs @@ -767,19 +767,21 @@ fn item_token_estimate( .get(&item.segment_id.0) .map(Vec::as_slice) .unwrap_or(&[]); - if entries.is_empty() { - return estimate; + if !entries.is_empty() { + estimate += match config.glossary.format { + GlossaryFormat::Json => { + let rendered = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string()); + token_estimate("glossary") + token_estimate(&rendered) + } + GlossaryFormat::Prose => { + let rendered = crate::scheduler::render_glossary_prose(entries); + token_estimate("glossary_prose") + token_estimate(&rendered) + } + }; + } + if let Some(guidance) = config.glossary.guidance_by_segment.get(&item.segment_id.0) { + estimate += token_estimate("retry_guidance") + token_estimate(guidance); } - estimate += match config.glossary.format { - GlossaryFormat::Json => { - let rendered = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string()); - token_estimate("glossary") + token_estimate(&rendered) - } - GlossaryFormat::Prose => { - let rendered = crate::scheduler::render_glossary_prose(entries); - token_estimate("glossary_prose") + token_estimate(&rendered) - } - }; estimate } @@ -2868,6 +2870,13 @@ fn render_batch_items(batch: &TranslationBatch, config: &TranslationRunConfig) - } } + if let Some(guidance) = config.glossary.guidance_by_segment.get(&item.segment_id.0) { + obj.insert( + "retry_guidance".to_string(), + serde_json::Value::String(guidance.clone()), + ); + } + if batch.mode == BatchMode::RunPreserving { let runs: Vec = item .text_runs @@ -4186,9 +4195,15 @@ mod tests { }], ); config.glossary.prompt_extra = Some("Use informal register.".to_string()); + config.glossary.guidance_by_segment.insert( + "seg1".to_string(), + "Translate the greeting less literally.".to_string(), + ); let rendered = render_batch_items(&batch, &config); assert!(rendered.contains("\"glossary\"")); + assert!(rendered.contains("\"retry_guidance\"")); + assert!(rendered.contains("Translate the greeting less literally.")); assert!(rendered.contains("\"source\":\"Hello\"")); assert!(!rendered.contains("Use informal register.")); } diff --git a/crates/bookforge-llm/src/prompt.rs b/crates/bookforge-llm/src/prompt.rs index c268f25..ea92322 100644 --- a/crates/bookforge-llm/src/prompt.rs +++ b/crates/bookforge-llm/src/prompt.rs @@ -180,18 +180,18 @@ const RUN_PRESERVING_TEMPLATE_SOURCE: &str = include_str!("../prompts/translate_run_preserving.v2.md"); const QA_TEMPLATE_SOURCE: &str = include_str!("../prompts/qa_segment.v1.md"); -const BATCH_PLAIN_TEMPLATE_SOURCE: &str = include_str!("../prompts/translate_batch_plain.v2.md"); +const BATCH_PLAIN_TEMPLATE_SOURCE: &str = include_str!("../prompts/translate_batch_plain.v3.md"); const BATCH_MARKER_SAFE_TEMPLATE_SOURCE: &str = - include_str!("../prompts/translate_batch_marker_safe.v2.md"); + include_str!("../prompts/translate_batch_marker_safe.v3.md"); const BATCH_RUN_PRESERVING_TEMPLATE_SOURCE: &str = - include_str!("../prompts/translate_batch_run_preserving.v2.md"); + include_str!("../prompts/translate_batch_run_preserving.v3.md"); const BATCH_REPAIR_TEMPLATE_SOURCE: &str = include_str!("../prompts/translate_batch_repair.v2.md"); const BATCH_PLAIN_COMPACT_TEMPLATE_SOURCE: &str = - include_str!("../prompts/translate_batch_plain_compact.v2.md"); + include_str!("../prompts/translate_batch_plain_compact.v3.md"); const BATCH_MARKER_SAFE_COMPACT_TEMPLATE_SOURCE: &str = - include_str!("../prompts/translate_batch_marker_safe_compact.v2.md"); + include_str!("../prompts/translate_batch_marker_safe_compact.v3.md"); const BATCH_RUN_PRESERVING_COMPACT_TEMPLATE_SOURCE: &str = - include_str!("../prompts/translate_batch_run_preserving_compact.v2.md"); + include_str!("../prompts/translate_batch_run_preserving_compact.v3.md"); const BATCH_REPAIR_COMPACT_TEMPLATE_SOURCE: &str = include_str!("../prompts/translate_batch_repair_compact.v2.md"); const QA_BATCH_TEMPLATE_SOURCE: &str = include_str!("../prompts/qa_batch.v1.md"); @@ -241,17 +241,17 @@ impl PromptLibrary { .expect("embedded QA template must parse"); let batch_plain = - PromptTemplate::parse("translate_batch_plain", "v2", BATCH_PLAIN_TEMPLATE_SOURCE) + PromptTemplate::parse("translate_batch_plain", "v3", BATCH_PLAIN_TEMPLATE_SOURCE) .expect("embedded batch plain template must parse"); let batch_marker_safe = PromptTemplate::parse( "translate_batch_marker_safe", - "v2", + "v3", BATCH_MARKER_SAFE_TEMPLATE_SOURCE, ) .expect("embedded batch marker-safe template must parse"); let batch_run_preserving = PromptTemplate::parse( "translate_batch_run_preserving", - "v2", + "v3", BATCH_RUN_PRESERVING_TEMPLATE_SOURCE, ) .expect("embedded batch run-preserving template must parse"); @@ -260,19 +260,19 @@ impl PromptLibrary { .expect("embedded batch repair template must parse"); let batch_plain_compact = PromptTemplate::parse( "translate_batch_plain_compact", - "v2", + "v3", BATCH_PLAIN_COMPACT_TEMPLATE_SOURCE, ) .expect("embedded batch plain compact template must parse"); let batch_marker_safe_compact = PromptTemplate::parse( "translate_batch_marker_safe_compact", - "v2", + "v3", BATCH_MARKER_SAFE_COMPACT_TEMPLATE_SOURCE, ) .expect("embedded batch marker-safe compact template must parse"); let batch_run_preserving_compact = PromptTemplate::parse( "translate_batch_run_preserving_compact", - "v2", + "v3", BATCH_RUN_PRESERVING_COMPACT_TEMPLATE_SOURCE, ) .expect("embedded batch run-preserving compact template must parse"); @@ -426,6 +426,31 @@ mod tests { assert_eq!(library.correct_batch.name, "correct_batch"); } + #[test] + fn batch_prompt_templates_are_versioned_v3_for_retry_guidance() { + // The six batch translate templates (plain / marker-safe / + // run-preserving, and their compact variants) gained a per-item + // `retry_guidance` field and moved from v2 to v3 so the cross-job + // translation cache (keyed on segments.prompt_version) does not + // serve up translations produced under the old prompt text. + // translate_batch_repair(_compact) and the single-segment / QA + // templates were not touched and must stay at their prior versions. + let library = PromptLibrary::embedded(); + assert_eq!(library.batch_plain.version, "v3"); + assert_eq!(library.batch_marker_safe.version, "v3"); + assert_eq!(library.batch_run_preserving.version, "v3"); + assert_eq!(library.batch_plain_compact.version, "v3"); + assert_eq!(library.batch_marker_safe_compact.version, "v3"); + assert_eq!(library.batch_run_preserving_compact.version, "v3"); + + assert_eq!(library.batch_repair.version, "v2"); + assert_eq!(library.batch_repair_compact.version, "v2"); + assert_eq!(library.plain.version, "v2"); + assert_eq!(library.marker_safe.version, "v2"); + assert_eq!(library.run_preserving.version, "v2"); + assert_eq!(library.qa.version, "v1"); + } + #[test] fn prompt_library_global_returns_same_instance() { let first = PromptLibrary::global() as *const PromptLibrary; diff --git a/crates/bookforge-llm/src/scheduler.rs b/crates/bookforge-llm/src/scheduler.rs index 11dc3a2..d02c315 100644 --- a/crates/bookforge-llm/src/scheduler.rs +++ b/crates/bookforge-llm/src/scheduler.rs @@ -365,6 +365,8 @@ pub struct GlossaryRunConfig { pub format: GlossaryFormat, pub entries_by_segment: HashMap>, pub prompt_extra: Option, + /// Operator guidance for an explicit single-segment retry. + pub guidance_by_segment: HashMap, } impl Default for GlossaryRunConfig { @@ -373,6 +375,7 @@ impl Default for GlossaryRunConfig { format: GlossaryFormat::Json, entries_by_segment: HashMap::new(), prompt_extra: None, + guidance_by_segment: HashMap::new(), } } } @@ -1105,7 +1108,7 @@ fn render_prompt( ) .raw( "prompt_extra", - config.glossary.prompt_extra.clone().unwrap_or_default(), + prompt_extra_for_segment(config, &segment.id.0), ) .json( "protected_spans_json", @@ -1167,6 +1170,20 @@ fn render_prompt( .map_err(|err| LlmError::Provider(format!("prompt render failed: {err}"))) } +pub(crate) fn prompt_extra_for_segment(config: &TranslationRunConfig, segment_id: &str) -> String { + match ( + config.glossary.prompt_extra.as_deref(), + config.glossary.guidance_by_segment.get(segment_id), + ) { + (Some(global), Some(guidance)) if !global.trim().is_empty() => { + format!("{global}\n\nRetry guidance for this segment:\n{guidance}") + } + (_, Some(guidance)) => format!("Retry guidance for this segment:\n{guidance}"), + (Some(global), None) => global.to_string(), + (None, None) => String::new(), + } +} + pub(crate) fn render_context_pairs(pairs: &[CompletedContext]) -> String { if pairs.is_empty() { return String::new(); @@ -2168,6 +2185,10 @@ mod tests { }], ); json_config.glossary.prompt_extra = Some("Maintain a literary register.".to_string()); + json_config.glossary.guidance_by_segment.insert( + "seg_a".to_string(), + "Keep the quoted phrase in Italian this time.".to_string(), + ); let rendered = render_prompt( &PromptLibrary::global().plain, @@ -2179,6 +2200,12 @@ mod tests { .expect("prompt should render"); assert!(rendered.user.contains("\"source\": \"Aragorn\"")); assert!(rendered.user.contains("Maintain a literary register.")); + assert!(rendered.user.contains("Retry guidance for this segment")); + assert!( + rendered + .user + .contains("Keep the quoted phrase in Italian this time.") + ); let mut prose_config = json_config.clone(); prose_config.glossary.format = bookforge_core::GlossaryFormat::Prose; diff --git a/crates/bookforge-pdf/Cargo.toml b/crates/bookforge-pdf/Cargo.toml index db92317..752ca27 100644 --- a/crates/bookforge-pdf/Cargo.toml +++ b/crates/bookforge-pdf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bookforge-pdf" -version = "2.3.0" +version = "2.4.0-dev" description = "PDF ingestion for BookForge: poppler-based layout extraction and deterministic reconstruction into a translatable EPUB." edition.workspace = true license.workspace = true @@ -8,7 +8,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -bookforge-core = { version = "2.3.0", path = "../bookforge-core" } +bookforge-core = { version = "2.4.0-dev", path = "../bookforge-core" } quick-xml.workspace = true crc32fast = "1.5.0" flate2 = "1.1.9" @@ -19,5 +19,5 @@ tracing.workspace = true zip.workspace = true [dev-dependencies] -bookforge-epub = { version = "2.3.0", path = "../bookforge-epub" } +bookforge-epub = { version = "2.4.0-dev", path = "../bookforge-epub" } tempfile = "3" diff --git a/crates/bookforge-pdf/src/convert.rs b/crates/bookforge-pdf/src/convert.rs index 3715e43..96e3735 100644 --- a/crates/bookforge-pdf/src/convert.rs +++ b/crates/bookforge-pdf/src/convert.rs @@ -2102,16 +2102,20 @@ fn baseline_page_char_counts(text: &str, pages: usize) -> Vec { mod tests { use super::*; + #[cfg(unix)] const BERT_FIGURE1_CAPTION_BOUNDARY_XML: &str = include_str!("../fixtures/bert_figure1_caption_boundary.xml"); + #[cfg(unix)] const BERT_FIGURE4_MULTIPANEL_XML: &str = include_str!("../fixtures/bert_figure4_multipanel.xml"); + #[cfg(unix)] const BERT_FIGURE5_VECTOR_CHART_XML: &str = include_str!("../fixtures/bert_figure5_vector_chart.xml"); const BERT_PAGE16_VECTOR_CHART_TWOCOL_XML: &str = include_str!("../fixtures/bert_page16_vector_chart_twocol.xml"); const BERT_FIGURE1_TOKEN_STRIP_XML: &str = include_str!("../fixtures/bert_figure1_token_strip.xml"); + #[cfg(unix)] const BERT_MODEL_PARAMETER_FALSE_POSITIVE_XML: &str = include_str!("../fixtures/bert_model_parameter_false_positive.xml"); @@ -2818,6 +2822,7 @@ cp "$script_dir/pdftoppm.fixture.png" "$last.png" assert_eq!(cluster, vec![0, 1, 2, 3]); } + #[cfg(unix)] #[test] fn nearby_raster_subimages_cluster_into_one_figure() { let dir = tempfile::tempdir().expect("temp dir"); @@ -2911,6 +2916,7 @@ cp "$script_dir/pdftoppm.fixture.png" "$last.png" assert_eq!(spans_text(&caption.spans), "Figure 2. True caption."); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_does_not_write_epub_when_baseline_fails() { @@ -3021,6 +3027,7 @@ XML assert!(content.contains("Text only PDF.")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_embeds_extracted_image_with_translatable_caption() { @@ -3104,6 +3111,7 @@ printf 'Paper Title\nFigure 1. A test image.\nBody text after the figure.\n' ); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_snaps_figure_crop_above_caption_boundary_fixture() { @@ -3172,6 +3180,7 @@ printf 'Paper Title\nFigure 1. A test image.\nBody text after the figure.\n' assert!(image.starts_with(b"\x89PNG\r\n\x1a\n")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_groups_multipanel_figure_fixture_as_one_captioned_crop() { @@ -3227,6 +3236,7 @@ printf 'Paper Title\nFigure 1. A test image.\nBody text after the figure.\n' assert!(content.contains("Discussion resumes after the figure.")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_preserves_vector_chart_fixture_as_captioned_crop() { @@ -3284,6 +3294,7 @@ printf 'Paper Title\nFigure 1. A test image.\nBody text after the figure.\n' assert!(!content.contains("Epoch")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_warns_on_lowercase_continuation_after_media() { @@ -3343,6 +3354,7 @@ printf 'This paragraph\ncontinues after the figure.\n' ); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_preserves_detected_table_as_crop_with_caption() { @@ -3446,6 +3458,7 @@ printf 'Body before table.\nTable 1. Scores.\nMetric 2019 2020\nA 0.91 0.93\nB 0 assert!(image.starts_with(b"\x89PNG\r\n\x1a\n")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_preserves_display_equation_as_crop() { @@ -3522,6 +3535,7 @@ printf 'Body before equation.\nE = mc^2\nBody after equation.\n' assert!(image.starts_with(b"\x89PNG\r\n\x1a\n")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_marks_inline_math_as_protected_span() { @@ -3580,6 +3594,7 @@ printf 'The energy term E = mc^2 appears inline.\n' ); } + #[cfg(unix)] #[cfg(unix)] #[test] fn convert_pdf_does_not_crop_model_parameter_prose_as_equation() { @@ -3629,6 +3644,7 @@ printf 'The energy term E = mc^2 appears inline.\n' assert!(!content.contains("pdf-equation-0001.png")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn low_confidence_pages_linearize_by_default() { @@ -3704,6 +3720,7 @@ printf 'Tiny plus many baseline characters that the XML reconstruction did not r assert!(!content.contains("pdf-page-0001.png")); } + #[cfg(unix)] #[cfg(unix)] #[test] fn low_confidence_pages_can_be_preserved_as_page_images() { diff --git a/crates/bookforge-store/Cargo.toml b/crates/bookforge-store/Cargo.toml index c838748..7915eaa 100644 --- a/crates/bookforge-store/Cargo.toml +++ b/crates/bookforge-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bookforge-store" -version = "2.3.0" +version = "2.4.0-dev" description = "SQLite checkpoint and job store for BookForge." edition.workspace = true license.workspace = true @@ -8,7 +8,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -bookforge-core = { version = "2.3.0", path = "../bookforge-core" } +bookforge-core = { version = "2.4.0-dev", path = "../bookforge-core" } rusqlite.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/bookforge-store/migrations/0007_v2_4_human_corrections.sql b/crates/bookforge-store/migrations/0007_v2_4_human_corrections.sql new file mode 100644 index 0000000..d6f33e9 --- /dev/null +++ b/crates/bookforge-store/migrations/0007_v2_4_human_corrections.sql @@ -0,0 +1,5 @@ +-- v2.4: durable, auditable human translation overrides. +ALTER TABLE translations ADD COLUMN origin TEXT NOT NULL DEFAULT 'model'; +ALTER TABLE translations ADD COLUMN human_corrected INTEGER NOT NULL DEFAULT 0; +ALTER TABLE translations ADD COLUMN corrected_at TEXT; + diff --git a/crates/bookforge-store/src/db.rs b/crates/bookforge-store/src/db.rs index 0bf9e2c..5c4f5fb 100644 --- a/crates/bookforge-store/src/db.rs +++ b/crates/bookforge-store/src/db.rs @@ -33,6 +33,9 @@ pub enum StoreError { #[error("serialization error: {0}")] Serialization(String), + + #[error("manual correction rejected: {0}")] + InvalidCorrection(String), } pub struct JobStore { @@ -119,6 +122,10 @@ pub struct StoredSegmentTranslation { pub error: Option, pub translated_text: String, pub blocks: Vec, + pub provider: String, + pub model: String, + pub human_corrected: bool, + pub corrected_at: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -142,6 +149,14 @@ pub struct SaveTranslation<'a> { pub tokens_estimated: bool, } +#[derive(Debug, Clone, Copy)] +pub struct SaveManualCorrection<'a> { + pub job_id: &'a str, + pub segment_id: &'a str, + pub translated_text: &'a str, + pub blocks: &'a [BlockTranslation], +} + #[derive(Debug, Clone, Copy)] pub struct SaveNeedsReview<'a> { pub job_id: &'a str, @@ -573,6 +588,9 @@ impl JobStore { } pub fn save_translation(&self, request: SaveTranslation<'_>) -> Result<()> { + if self.translation_is_human_corrected(request.job_id, request.segment_id)? { + return Ok(()); + } let now = timestamp_string(); let translated_hash = stable_hash(request.translated_text); { @@ -618,11 +636,15 @@ impl JobStore { ], )?; } + self.consume_dashboard_retry_guidance(request.job_id, request.segment_id)?; self.touch_job_unless_status(request.job_id, "running", &["paused", "stopped"])?; Ok(()) } pub fn save_needs_review(&self, request: SaveNeedsReview<'_>) -> Result<()> { + if self.translation_is_human_corrected(request.job_id, request.segment_id)? { + return Ok(()); + } let now = timestamp_string(); let translated_hash = stable_hash(request.preserved_text); { @@ -669,11 +691,15 @@ impl JobStore { ], )?; } + self.consume_dashboard_retry_guidance(request.job_id, request.segment_id)?; self.touch_job_unless_status(request.job_id, "needs_review", &["paused", "stopped"])?; Ok(()) } pub fn save_cached_translation(&self, request: SaveCachedTranslation<'_>) -> Result<()> { + if self.translation_is_human_corrected(request.job_id, request.segment_id)? { + return Ok(()); + } let now = timestamp_string(); let translated_hash = stable_hash(request.translated_text); { @@ -706,10 +732,149 @@ impl JobStore { params![translated_hash, request.job_id, request.segment_id], )?; } + self.consume_dashboard_retry_guidance(request.job_id, request.segment_id)?; self.touch_job_unless_status(request.job_id, "running", &["paused", "stopped"])?; Ok(()) } + pub fn save_manual_correction(&self, request: SaveManualCorrection<'_>) -> Result<()> { + if request.translated_text.trim().is_empty() { + return Err(StoreError::InvalidCorrection( + "translation text cannot be empty".to_string(), + )); + } + if request.blocks.is_empty() + || request + .blocks + .iter() + .any(|block| block.text.trim().is_empty()) + { + return Err(StoreError::InvalidCorrection( + "every corrected block must contain text".to_string(), + )); + } + + let now = timestamp_string(); + let translated_hash = stable_hash(request.translated_text); + let mut conn = self.conn.borrow_mut(); + let tx = conn.transaction()?; + let job_status = tx + .query_row( + "SELECT status FROM jobs WHERE id = ?1", + params![request.job_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let Some(job_status) = job_status else { + return Err(StoreError::InvalidCorrection(format!( + "job '{}' was not found", + request.job_id + ))); + }; + if matches!(job_status.as_str(), "running" | "paused") { + return Err(StoreError::InvalidCorrection(format!( + "job '{}' is {}; stop it before applying a manual correction", + request.job_id, job_status + ))); + } + + let prompt_version = tx + .query_row( + "SELECT prompt_version FROM segments WHERE job_id = ?1 AND id = ?2", + params![request.job_id, request.segment_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let Some(prompt_version) = prompt_version else { + return Err(StoreError::InvalidCorrection(format!( + "segment '{}' was not found in job '{}'", + request.segment_id, request.job_id + ))); + }; + + tx.execute( + "INSERT INTO translations + (segment_id, job_id, translated_text, provider, model, prompt_version, created_at, + origin, human_corrected, corrected_at) + VALUES (?1, ?2, ?3, 'manual', 'manual', ?4, ?5, 'manual', 1, ?5) + ON CONFLICT(job_id, segment_id) DO UPDATE SET + translated_text = excluded.translated_text, + provider = 'manual', + model = 'manual', + prompt_version = excluded.prompt_version, + created_at = excluded.created_at, + origin = 'manual', + human_corrected = 1, + corrected_at = excluded.corrected_at", + params![ + request.segment_id, + request.job_id, + request.translated_text, + prompt_version, + now, + ], + )?; + replace_block_translations(&tx, request.job_id, request.segment_id, request.blocks)?; + tx.execute( + "UPDATE segments + SET status = 'succeeded', translated_hash = ?1, error = NULL + WHERE job_id = ?2 AND id = ?3", + params![translated_hash, request.job_id, request.segment_id], + )?; + tx.execute( + "DELETE FROM qa_findings WHERE job_id = ?1 AND segment_id = ?2", + params![request.job_id, request.segment_id], + )?; + tx.execute( + "UPDATE segment_flags SET consumed = 1 WHERE job_id = ?1 AND segment_id = ?2", + params![request.job_id, request.segment_id], + )?; + tx.commit()?; + drop(conn); + + self.recompute_job_status(request.job_id)?; + Ok(()) + } + + pub fn translation_is_human_corrected(&self, job_id: &str, segment_id: &str) -> Result { + let conn = self.conn.borrow(); + Ok(conn + .query_row( + "SELECT human_corrected FROM translations WHERE job_id = ?1 AND segment_id = ?2", + params![job_id, segment_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_some_and(|value| value != 0)) + } + + fn consume_dashboard_retry_guidance(&self, job_id: &str, segment_id: &str) -> Result<()> { + let conn = self.conn.borrow(); + conn.execute( + "UPDATE segment_flags SET consumed = 1 + WHERE job_id = ?1 AND segment_id = ?2 AND kind = 'dashboard_retry'", + params![job_id, segment_id], + )?; + Ok(()) + } + + pub fn recompute_job_status(&self, job_id: &str) -> Result<()> { + let conn = self.conn.borrow(); + let (total, unresolved) = conn.query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN status IN ('succeeded', 'skipped_cached') THEN 0 ELSE 1 END), 0) + FROM segments WHERE job_id = ?1", + params![job_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + )?; + drop(conn); + if total > 0 && unresolved == 0 { + self.touch_job(job_id, "succeeded") + } else { + self.touch_job(job_id, "needs_review") + } + } + pub fn mark_job_complete(&self, job_id: &str) -> Result<()> { self.touch_job_unless_status(job_id, "succeeded", &["stopped"]) } @@ -1020,6 +1185,126 @@ impl JobStore { Ok(count) } + pub fn request_segment_retry( + &self, + job_id: &str, + segment_id: &str, + guidance: Option<&str>, + ) -> Result<()> { + if self.translation_is_human_corrected(job_id, segment_id)? { + return Err(StoreError::InvalidCorrection(format!( + "segment '{segment_id}' has a frozen human correction" + ))); + } + let mut conn = self.conn.borrow_mut(); + let tx = conn.transaction()?; + let job_status = tx + .query_row( + "SELECT status FROM jobs WHERE id = ?1", + params![job_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(job_status) = &job_status { + if matches!(job_status.as_str(), "running" | "paused") { + return Err(StoreError::InvalidCorrection(format!( + "job '{job_id}' is {job_status}; stop it before requesting a retry" + ))); + } + } + let updated = tx.execute( + "UPDATE segments SET status = 'retry_pending', error = NULL + WHERE job_id = ?1 AND id = ?2", + params![job_id, segment_id], + )?; + if updated == 0 { + return Err(StoreError::InvalidCorrection(format!( + "segment '{segment_id}' was not found in job '{job_id}'" + ))); + } + tx.execute( + "DELETE FROM segment_flags + WHERE job_id = ?1 AND segment_id = ?2 AND kind = 'dashboard_retry'", + params![job_id, segment_id], + )?; + if let Some(guidance) = guidance.filter(|value| !value.trim().is_empty()) { + tx.execute( + "INSERT INTO segment_flags + (job_id, segment_id, kind, note, ingested_at, consumed) + VALUES (?1, ?2, 'dashboard_retry', ?3, ?4, 0)", + params![job_id, segment_id, guidance.trim(), timestamp_string()], + )?; + } + tx.commit()?; + drop(conn); + self.touch_job_unless_status(job_id, "retry_pending", &["stopped"])?; + Ok(()) + } + + pub fn load_retry_guidance(&self, job_id: &str) -> Result> { + let conn = self.conn.borrow(); + let mut stmt = conn.prepare( + "SELECT segment_id, note FROM segment_flags + WHERE job_id = ?1 AND kind = 'dashboard_retry' AND consumed = 0 + AND note IS NOT NULL AND TRIM(note) <> '' + ORDER BY id", + )?; + let rows = stmt.query_map(params![job_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + let mut guidance = HashMap::new(); + for row in rows { + let (segment_id, note) = row?; + guidance.insert(segment_id, note); + } + Ok(guidance) + } + + pub fn set_dashboard_segment_flag( + &self, + job_id: &str, + segment_id: &str, + flagged: bool, + ) -> Result<()> { + let conn = self.conn.borrow(); + let exists = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM segments WHERE job_id = ?1 AND id = ?2)", + params![job_id, segment_id], + |row| row.get::<_, i64>(0), + )? != 0; + if !exists { + return Err(StoreError::InvalidCorrection(format!( + "segment '{segment_id}' was not found in job '{job_id}'" + ))); + } + conn.execute( + "DELETE FROM segment_flags + WHERE job_id = ?1 AND segment_id = ?2 AND kind = 'dashboard_flag'", + params![job_id, segment_id], + )?; + if flagged { + conn.execute( + "INSERT INTO segment_flags + (job_id, segment_id, kind, ingested_at, consumed) + VALUES (?1, ?2, 'dashboard_flag', ?3, 0)", + params![job_id, segment_id, timestamp_string()], + )?; + } + Ok(()) + } + + pub fn dashboard_flagged_segment_ids(&self, job_id: &str) -> Result> { + let conn = self.conn.borrow(); + let mut stmt = conn.prepare( + "SELECT DISTINCT segment_id FROM segment_flags + WHERE job_id = ?1 AND kind = 'dashboard_flag' AND consumed = 0 + ORDER BY segment_id", + )?; + let rows = stmt.query_map(params![job_id], |row| row.get::<_, String>(0))?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_segment_flags(&self, flags: &[NewSegmentFlag<'_>]) -> Result { let mut conn = self.conn.borrow_mut(); let tx = conn.transaction()?; @@ -1847,7 +2132,8 @@ impl JobStore { ) -> Result> { let conn = self.conn.borrow(); let mut stmt = conn.prepare( - "SELECT s.id, s.ordinal, s.status, s.error, t.translated_text + "SELECT s.id, s.ordinal, s.status, s.error, t.translated_text, + t.provider, t.model, t.human_corrected, t.corrected_at FROM segments s JOIN translations t ON t.job_id = s.job_id AND t.segment_id = s.id WHERE s.job_id = ?1 AND s.status IN ('succeeded', 'skipped_cached', 'needs_review') @@ -1860,12 +2146,26 @@ impl JobStore { row.get::<_, String>(2)?, row.get::<_, Option>(3)?, row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)? != 0, + row.get::<_, Option>(8)?, )) })?; let mut records = Vec::new(); for row in rows { - let (segment_id, ordinal, status, error, translated_text) = row?; + let ( + segment_id, + ordinal, + status, + error, + translated_text, + provider, + model, + human_corrected, + corrected_at, + ) = row?; let mut block_stmt = conn.prepare( "SELECT block_id, translated_text FROM translation_blocks @@ -1887,6 +2187,10 @@ impl JobStore { error, translated_text, blocks, + provider, + model, + human_corrected, + corrected_at, }); } @@ -1931,6 +2235,7 @@ impl JobStore { AND j.target_lang = ?6 AND s.cache_namespace = ?7 AND s.status IN ('succeeded', 'skipped_cached') + AND t.human_corrected = 0 ORDER BY CASE s.status WHEN 'succeeded' THEN 0 ELSE 1 END, CAST(t.created_at AS INTEGER) DESC, t.rowid DESC @@ -2025,6 +2330,7 @@ impl JobStore { AND j.target_lang = ?{} AND s.cache_namespace = ?{} AND s.status IN ('succeeded', 'skipped_cached') + AND t.human_corrected = 0 ORDER BY CASE s.status WHEN 'succeeded' THEN 0 ELSE 1 END, CAST(t.created_at AS INTEGER) DESC, t.rowid DESC", @@ -2198,6 +2504,9 @@ impl JobStore { model TEXT NOT NULL, prompt_version TEXT NOT NULL, created_at TEXT NOT NULL, + origin TEXT NOT NULL DEFAULT 'model', + human_corrected INTEGER NOT NULL DEFAULT 0, + corrected_at TEXT, PRIMARY KEY (job_id, segment_id), FOREIGN KEY(job_id, segment_id) REFERENCES segments(job_id, id) ); @@ -2314,6 +2623,19 @@ impl JobStore { "tokens_estimated", "INTEGER NOT NULL DEFAULT 0", )?; + ensure_column( + &conn, + "translations", + "origin", + "TEXT NOT NULL DEFAULT 'model'", + )?; + ensure_column( + &conn, + "translations", + "human_corrected", + "INTEGER NOT NULL DEFAULT 0", + )?; + ensure_column(&conn, "translations", "corrected_at", "TEXT")?; conn.execute_batch( "CREATE INDEX IF NOT EXISTS idx_segments_cache_lookup ON segments(source_hash, cache_namespace, prompt_version, provider, model, status); @@ -2332,6 +2654,7 @@ impl JobStore { record_migration(&conn, 4, "v1_2_glossary_terms")?; record_migration(&conn, 5, "v1_2_1_nullable_glossary_candidate_targets")?; record_migration(&conn, 6, "v1_3_context_styles_entities")?; + record_migration(&conn, 7, "v2_4_human_corrections")?; Ok(()) } @@ -3595,6 +3918,132 @@ mod tests { let _ = fs::remove_file(db_path); } + #[test] + fn cached_translation_rejects_mismatched_prompt_version() { + // Regression test for the batch prompt v2 -> v3 bump (retry_guidance + // field added to translate_batch_plain/marker_safe/run_preserving and + // their compact variants). segments.prompt_version is the + // cross-job translation cache key (see find_cached_translation and + // find_cached_translations_batch below); a translation cached under + // the old "v2" tag must not be served back out when the current + // binary queries under the bumped "v3" tag, since the v2-era row was + // produced from prompt text that never mentioned retry_guidance. + let db_path = temp_path("prompt_version_bump.sqlite"); + let input_path = temp_path("input.epub"); + fs::write(&input_path, b"epub bytes").expect("input fixture should be writable"); + + let store = JobStore::open(&db_path).expect("store should open"); + let job = store + .create_job(CreateJob { + input: &input_path, + output: &temp_path("output.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job should be created"); + + let mut seg = segment("seg_a", 0); + seg.block_ids = vec![BlockId("b_000000".to_string())]; + + store + .insert_segments( + &job.id, + std::slice::from_ref(&seg), + "v2", + "mock", + "mock-prefix", + "ns_bump", + ) + .expect("segments should insert"); + store + .save_translation(SaveTranslation { + job_id: &job.id, + segment_id: "seg_a", + translated_text: "Tradotto v2", + blocks: &[BlockTranslation { + block_id: BlockId("b_000000".to_string()), + text: "Tradotto v2".to_string(), + }], + provider: "mock", + model: "mock-prefix", + prompt_version: "v2", + input_tokens: Some(11), + input_cached_tokens: Some(0), + output_tokens: Some(7), + tokens_estimated: false, + }) + .expect("translation should save"); + + let hit_v2 = store + .find_cached_translation( + &seg, + "v2", + "mock", + "mock-prefix", + Some("English"), + "Italian", + "ns_bump", + ) + .expect("query ok"); + assert!( + hit_v2.is_some(), + "row stored under v2 must still be visible to a v2 query" + ); + + let miss_v3 = store + .find_cached_translation( + &seg, + "v3", + "mock", + "mock-prefix", + Some("English"), + "Italian", + "ns_bump", + ) + .expect("query ok"); + assert!( + miss_v3.is_none(), + "row stored under v2 must not be served back for a v3 (retry_guidance) query" + ); + + let batch_request_v3 = CacheLookupRequest { + prompt_version: "v3", + provider: "mock", + model: "mock-prefix", + source_lang: Some("English"), + target_lang: "Italian", + cache_namespace: "ns_bump", + }; + let batch_miss = store + .find_cached_translations_batch(std::slice::from_ref(&seg), batch_request_v3) + .expect("batch query ok"); + assert!( + batch_miss.is_empty(), + "batch lookup must not return v2-era rows for a v3 query" + ); + + let batch_request_v2 = CacheLookupRequest { + prompt_version: "v2", + ..batch_request_v3 + }; + let batch_hit = store + .find_cached_translations_batch(std::slice::from_ref(&seg), batch_request_v2) + .expect("batch query ok"); + assert!( + batch_hit.contains_key(&seg.id.0), + "batch lookup must still return the row for a matching v2 query" + ); + + let _ = fs::remove_file(db_path); + let _ = fs::remove_file(input_path); + } + #[test] fn cached_translation_rejects_mismatched_block_ids() { let db_path = temp_path("blockid_match.sqlite"); @@ -4290,6 +4739,505 @@ mod tests { let _ = fs::remove_file(input_path); } + #[test] + fn manual_correction_is_auditable_frozen_and_not_cacheable() { + let db_path = temp_path("manual_correction.sqlite"); + let (store, job, segment) = + build_seeded_store_with_translation(&db_path, "manual_ns", &["b_000000"]); + store + .mark_job_needs_review(&job.id) + .expect("job should become reviewable"); + + let manual_blocks = [BlockTranslation { + block_id: BlockId("b_000000".to_string()), + text: "Correzione umana".to_string(), + }]; + store + .save_manual_correction(SaveManualCorrection { + job_id: &job.id, + segment_id: "seg_a", + translated_text: "Correzione umana", + blocks: &manual_blocks, + }) + .expect("manual correction should save"); + + store + .save_translation(SaveTranslation { + job_id: &job.id, + segment_id: "seg_a", + translated_text: "MODEL OVERWRITE", + blocks: &[BlockTranslation { + block_id: BlockId("b_000000".to_string()), + text: "MODEL OVERWRITE".to_string(), + }], + provider: "mock", + model: "mock-prefix", + prompt_version: "v1", + input_tokens: Some(1), + input_cached_tokens: Some(0), + output_tokens: Some(1), + tokens_estimated: false, + }) + .expect("model write should be ignored rather than fail"); + + let translations = store + .load_terminal_segment_translations(&job.id) + .expect("translation should load"); + assert_eq!(translations.len(), 1); + assert_eq!(translations[0].translated_text, "Correzione umana"); + assert_eq!(translations[0].provider, "manual"); + assert_eq!(translations[0].model, "manual"); + assert!(translations[0].human_corrected); + assert!(translations[0].corrected_at.is_some()); + assert_eq!(store.get_job(&job.id).unwrap().unwrap().status, "succeeded"); + + let cached = store + .find_cached_translation( + &segment, + "v1", + "mock", + "mock-prefix", + Some("English"), + "Italian", + "manual_ns", + ) + .expect("cache lookup should succeed"); + assert!(cached.is_none(), "manual corrections must remain job-local"); + + let _ = fs::remove_file(db_path); + } + + #[test] + fn manual_correction_rejects_active_jobs() { + let db_path = temp_path("manual_correction_running.sqlite"); + let (store, job, _segment) = + build_seeded_store_with_translation(&db_path, "manual_running_ns", &["b_000000"]); + let result = store.save_manual_correction(SaveManualCorrection { + job_id: &job.id, + segment_id: "seg_a", + translated_text: "Correzione", + blocks: &[BlockTranslation { + block_id: BlockId("b_000000".to_string()), + text: "Correzione".to_string(), + }], + }); + assert!(matches!(result, Err(StoreError::InvalidCorrection(_)))); + + let _ = fs::remove_file(db_path); + } + + #[test] + fn dashboard_segment_flag_set_and_clear_is_job_scoped() { + let db_path = temp_path("dashboard_flag.sqlite"); + let input_a = temp_path("flag_input_a.epub"); + let input_b = temp_path("flag_input_b.epub"); + fs::write(&input_a, b"epub bytes flag a").expect("input a fixture should be writable"); + fs::write(&input_b, b"epub bytes flag b").expect("input b fixture should be writable"); + + let store = JobStore::open(&db_path).expect("store should open"); + let job_a = store + .create_job(CreateJob { + input: &input_a, + output: &temp_path("flag_output_a.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job a should be created"); + let job_b = store + .create_job(CreateJob { + input: &input_b, + output: &temp_path("flag_output_b.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job b should be created"); + + let segments = vec![segment("seg_a", 0), segment("seg_b", 1)]; + store + .insert_segments(&job_a.id, &segments, "v1", "mock", "mock-prefix", "flag_ns") + .expect("job a segments should insert"); + store + .insert_segments(&job_b.id, &segments, "v1", "mock", "mock-prefix", "flag_ns") + .expect("job b segments should insert"); + + assert!( + store + .dashboard_flagged_segment_ids(&job_a.id) + .unwrap() + .is_empty() + ); + + store + .set_dashboard_segment_flag(&job_a.id, "seg_a", true) + .expect("flag should set"); + assert_eq!( + store.dashboard_flagged_segment_ids(&job_a.id).unwrap(), + vec!["seg_a".to_string()] + ); + // Flags are job-scoped: the same segment id in a different job is unaffected. + assert!( + store + .dashboard_flagged_segment_ids(&job_b.id) + .unwrap() + .is_empty() + ); + + // Clearing the flag removes it from the read path. + store + .set_dashboard_segment_flag(&job_a.id, "seg_a", false) + .expect("flag should clear"); + assert!( + store + .dashboard_flagged_segment_ids(&job_a.id) + .unwrap() + .is_empty() + ); + + let _ = fs::remove_file(db_path); + let _ = fs::remove_file(input_a); + let _ = fs::remove_file(input_b); + } + + #[test] + fn request_segment_retry_stores_guidance_and_transitions_state() { + let db_path = temp_path("retry_guidance.sqlite"); + let (store, job, _segment) = + build_seeded_store_with_translation(&db_path, "retry_ns", &["b_000000"]); + + // A retry request is only accepted once the job is out of "running"/ + // "paused" (see `request_segment_retry_rejects_running_and_paused_jobs`), + // so move it to "needs_review" first, as a real dashboard-driven retry + // would only be offered once the job has stopped actively translating. + store + .mark_job_needs_review(&job.id) + .expect("job should become reviewable"); + assert_eq!( + store.get_job(&job.id).unwrap().unwrap().status, + "needs_review" + ); + + store + .request_segment_retry(&job.id, "seg_a", Some("check the idiom in paragraph 2")) + .expect("retry request should succeed"); + + let guidance = store + .load_retry_guidance(&job.id) + .expect("guidance should load"); + assert_eq!( + guidance.get("seg_a").map(String::as_str), + Some("check the idiom in paragraph 2") + ); + + let records = store + .segment_records(&job.id) + .expect("segment records should load"); + let seg_a = records + .iter() + .find(|record| record.id == "seg_a") + .expect("segment should be present"); + assert_eq!(seg_a.status, "retry_pending"); + assert!(seg_a.error.is_none()); + + assert_eq!( + store.get_job(&job.id).unwrap().unwrap().status, + "retry_pending" + ); + + let _ = fs::remove_file(db_path); + } + + #[test] + fn request_segment_retry_rejects_human_corrected_segment() { + let db_path = temp_path("retry_frozen.sqlite"); + let (store, job, _segment) = + build_seeded_store_with_translation(&db_path, "retry_frozen_ns", &["b_000000"]); + store + .mark_job_needs_review(&job.id) + .expect("job should become reviewable"); + store + .save_manual_correction(SaveManualCorrection { + job_id: &job.id, + segment_id: "seg_a", + translated_text: "Correzione umana", + blocks: &[BlockTranslation { + block_id: BlockId("b_000000".to_string()), + text: "Correzione umana".to_string(), + }], + }) + .expect("manual correction should save"); + + let result = store.request_segment_retry(&job.id, "seg_a", Some("try again")); + assert!(matches!(result, Err(StoreError::InvalidCorrection(_)))); + + // The rejected retry must not have recorded guidance or disturbed the frozen segment. + let guidance = store + .load_retry_guidance(&job.id) + .expect("guidance should load"); + assert!(guidance.get("seg_a").is_none()); + let records = store + .segment_records(&job.id) + .expect("segment records should load"); + let seg_a = records + .iter() + .find(|record| record.id == "seg_a") + .expect("segment should be present"); + assert_eq!(seg_a.status, "succeeded"); + + let _ = fs::remove_file(db_path); + } + + #[test] + fn request_segment_retry_rejects_running_and_paused_jobs() { + // Like save_manual_correction, request_segment_retry must reject retry + // requests while the job is "running" or "paused": accepting one would + // force-transition an in-flight job to "retry_pending" out from under + // whatever is currently driving it. Neither guidance nor segment/job + // state may change when the request is rejected. + let db_path = temp_path("retry_no_job_guard.sqlite"); + let (store, job, _segment) = + build_seeded_store_with_translation(&db_path, "retry_no_guard_ns", &["b_000000"]); + + store + .mark_job_running(&job.id) + .expect("job should be running"); + assert_eq!(store.get_job(&job.id).unwrap().unwrap().status, "running"); + let result = store.request_segment_retry(&job.id, "seg_a", Some("try again")); + assert!( + matches!(result, Err(StoreError::InvalidCorrection(_))), + "retry must be rejected while the job is running, got: {result:?}" + ); + assert_eq!(store.get_job(&job.id).unwrap().unwrap().status, "running"); + let guidance = store + .load_retry_guidance(&job.id) + .expect("guidance should load"); + assert!( + guidance.get("seg_a").is_none(), + "a rejected retry must not record guidance" + ); + let records = store + .segment_records(&job.id) + .expect("segment records should load"); + let seg_a = records + .iter() + .find(|record| record.id == "seg_a") + .expect("segment should be present"); + assert_eq!( + seg_a.status, "succeeded", + "a rejected retry must not move the segment to retry_pending" + ); + + store + .mark_job_paused(&job.id) + .expect("job should be paused"); + assert_eq!(store.get_job(&job.id).unwrap().unwrap().status, "paused"); + let result = store.request_segment_retry(&job.id, "seg_a", Some("try again")); + assert!( + matches!(result, Err(StoreError::InvalidCorrection(_))), + "retry must be rejected while the job is paused, got: {result:?}" + ); + assert_eq!(store.get_job(&job.id).unwrap().unwrap().status, "paused"); + let guidance = store + .load_retry_guidance(&job.id) + .expect("guidance should load"); + assert!( + guidance.get("seg_a").is_none(), + "a rejected retry must not record guidance" + ); + let records = store + .segment_records(&job.id) + .expect("segment records should load"); + let seg_a = records + .iter() + .find(|record| record.id == "seg_a") + .expect("segment should be present"); + assert_eq!( + seg_a.status, "succeeded", + "a rejected retry must not move the segment to retry_pending" + ); + + let _ = fs::remove_file(db_path); + } + + #[test] + fn terminal_save_translation_consumes_only_its_segment_guidance() { + let db_path = temp_path("retry_consume.sqlite"); + let input_path = temp_path("consume_input.epub"); + fs::write(&input_path, b"epub bytes consume").expect("input fixture should be writable"); + + let store = JobStore::open(&db_path).expect("store should open"); + let job = store + .create_job(CreateJob { + input: &input_path, + output: &temp_path("consume_output.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job should be created"); + let segments = vec![segment("seg_a", 0), segment("seg_b", 1)]; + store + .insert_segments( + &job.id, + &segments, + "v1", + "mock", + "mock-prefix", + "consume_ns", + ) + .expect("segments should insert"); + // Jobs are created "running"; request_segment_retry now rejects retries + // while running/paused, so move it to "needs_review" first. + store + .mark_job_needs_review(&job.id) + .expect("job should become reviewable"); + + store + .request_segment_retry(&job.id, "seg_a", Some("guidance for a")) + .expect("retry a should succeed"); + store + .request_segment_retry(&job.id, "seg_b", Some("guidance for b")) + .expect("retry b should succeed"); + + let guidance = store.load_retry_guidance(&job.id).unwrap(); + assert_eq!(guidance.len(), 2); + + store + .save_translation(SaveTranslation { + job_id: &job.id, + segment_id: "seg_a", + translated_text: "Tradotto A", + blocks: &[BlockTranslation { + block_id: BlockId("b_000000".to_string()), + text: "Tradotto A".to_string(), + }], + provider: "mock", + model: "mock-prefix", + prompt_version: "v1", + input_tokens: Some(1), + input_cached_tokens: Some(0), + output_tokens: Some(1), + tokens_estimated: false, + }) + .expect("translation should save"); + + let guidance_after = store.load_retry_guidance(&job.id).unwrap(); + assert!( + guidance_after.get("seg_a").is_none(), + "the consumed segment's guidance should be gone" + ); + assert_eq!( + guidance_after.get("seg_b").map(String::as_str), + Some("guidance for b"), + "another segment's guidance should survive" + ); + + let _ = fs::remove_file(db_path); + let _ = fs::remove_file(input_path); + } + + #[test] + fn load_retry_guidance_is_job_scoped() { + let db_path = temp_path("retry_job_scope.sqlite"); + let input_a = temp_path("scope_input_a.epub"); + let input_b = temp_path("scope_input_b.epub"); + fs::write(&input_a, b"epub bytes scope a").expect("input a fixture should be writable"); + fs::write(&input_b, b"epub bytes scope b").expect("input b fixture should be writable"); + + let store = JobStore::open(&db_path).expect("store should open"); + let job_a = store + .create_job(CreateJob { + input: &input_a, + output: &temp_path("scope_output_a.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job a should be created"); + let job_b = store + .create_job(CreateJob { + input: &input_b, + output: &temp_path("scope_output_b.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .expect("job b should be created"); + + let segments = vec![segment("seg_a", 0)]; + store + .insert_segments( + &job_a.id, + &segments, + "v1", + "mock", + "mock-prefix", + "scope_ns", + ) + .expect("job a segments should insert"); + store + .insert_segments( + &job_b.id, + &segments, + "v1", + "mock", + "mock-prefix", + "scope_ns", + ) + .expect("job b segments should insert"); + // Jobs are created "running"; request_segment_retry now rejects retries + // while running/paused, so move job a to "needs_review" first. + store + .mark_job_needs_review(&job_a.id) + .expect("job a should become reviewable"); + + store + .request_segment_retry(&job_a.id, "seg_a", Some("only for job a")) + .expect("retry should succeed"); + + let guidance_a = store.load_retry_guidance(&job_a.id).unwrap(); + assert_eq!( + guidance_a.get("seg_a").map(String::as_str), + Some("only for job a") + ); + + let guidance_b = store.load_retry_guidance(&job_b.id).unwrap(); + assert!( + guidance_b.is_empty(), + "job b should not see job a's retry guidance" + ); + + let _ = fs::remove_file(db_path); + let _ = fs::remove_file(input_a); + let _ = fs::remove_file(input_b); + } + fn glossary_term( scope_kind: bookforge_core::GlossaryScopeKind, scope_id: Option<&str>, diff --git a/crates/bookforge-store/src/lib.rs b/crates/bookforge-store/src/lib.rs index 3411e29..56de28e 100644 --- a/crates/bookforge-store/src/lib.rs +++ b/crates/bookforge-store/src/lib.rs @@ -3,7 +3,8 @@ pub mod db; pub use db::{ CacheLookupRequest, CachedTranslation, CreateJob, GlossaryCandidateUpsertResult, GlossaryFilter, JobRecord, JobStore, JobSummary, NewEntity, NewGlossaryCandidate, - NewSegmentFlag, NewStyleSheet, RetryScope, SaveCachedTranslation, SaveNeedsReview, - SaveTranslation, SegmentRecord, StorageDoctor, StoreError, StoredBlockTranslation, - StoredEntity, StoredGlossaryCandidate, StoredSegmentTranslation, StoredStyleSheet, run_doctor, + NewSegmentFlag, NewStyleSheet, RetryScope, SaveCachedTranslation, SaveManualCorrection, + SaveNeedsReview, SaveTranslation, SegmentRecord, StorageDoctor, StoreError, + StoredBlockTranslation, StoredEntity, StoredGlossaryCandidate, StoredSegmentTranslation, + StoredStyleSheet, run_doctor, }; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c053fba..193be76 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2540,6 +2540,11 @@ blocker for the non-developer audience. #### 10.1.3 Mini-spec: On-the-fly settings reconfiguration (added 2026-07-06, owner-approved) +> **Implemented on main after v2.3.0; scheduled for v2.4.0.** The current +> implementation is CLI-first and deliberately retains the stopped/dead-paused +> resume boundary described below. True in-process application is follow-up +> work, together with the dashboard surface. + **Goal.** Adjust *cache-safe* run settings on an existing (initially: paused) job and have `resume` apply them — without starting a fresh run and losing progress. Motivated directly by real use: a run hit repeated output @@ -2592,6 +2597,10 @@ removes a real "lost all my progress to change one number" cliff. #### 10.1.4 Mini-spec: Truncation handling + fail-fast alert (added 2026-07-06, owner-approved) +> **Implemented on main after v2.3.0; scheduled for v2.4.0.** Truncated batches +> escalate their output budget before splitting, and systemic exhaustion emits +> an additive alert surfaced by CLI, watch, and the browser dashboard. + **Goal.** Handle `max_output_tokens` truncation intelligently, and surface a prominent, actionable **alert** when truncation is *systemic* instead of spiraling silently. Surfaced by the Toki Pona limit test: a minimal-vocabulary diff --git a/docs/codex-handoff-pause-stop.md b/docs/codex-handoff-pause-stop.md index ed867f6..6fd5166 100644 --- a/docs/codex-handoff-pause-stop.md +++ b/docs/codex-handoff-pause-stop.md @@ -146,7 +146,12 @@ Early-pause (during the in-flight translation batch), concurrency=1: resume-while-parked works), job completed, NO duplicate segments. Acceptance §10.1.1.1-4 confirmed live. Good work. -### Remaining bug — finalize passes ignore pause (must fix for §10.1.1.5) +### Historical finding — finalize passes ignored pause (fixed before v2.3.0) + +Resolution: commits `a5e30027` and `e46eb398` threaded the shared pause signal +through finalize stages, added stage-boundary checks, and preserved resumable +stop semantics. The text below is retained as the review record that drove the +fix; it does not describe the current implementation. The pause gate covers ONLY the primary translation loop. The post-translation passes are not pause-aware: diff --git a/docs/codex-handoff-project-remediation-2026-07-10.md b/docs/codex-handoff-project-remediation-2026-07-10.md new file mode 100644 index 0000000..e1a37db --- /dev/null +++ b/docs/codex-handoff-project-remediation-2026-07-10.md @@ -0,0 +1,370 @@ +# Coding-agent handoff — project remediation (2026-07-10) + +## Objective + +Finish the full remediation plan agreed with the owner: + +1. restore release/version/documentation truth; +2. add Windows and security validation; +3. add durable human corrections and an editable dashboard review loop; +4. make runtime reconfiguration genuinely live and dashboard-accessible; +5. split the largest modules behind behavior-preserving seams; +6. run the full cross-platform, migration, corpus, and release gate. + +The owner asked the current agent to stop at the first useful checkpoint and +leave this handoff. Do not discard or rewrite the existing worktree: it is a +useful, compiling checkpoint, but it is intentionally not committed yet. + +## Git/worktree state + +- Repository: `C:\Users\gangi\Desktop\bookforge` +- Branch: `codex/project-remediation` +- Base: `a94c8a8665c9e96f0af16c3b1bb5c8cdcf926cc6` (`origin/main` when work began) +- Worktree: dirty, about 900 inserted lines across 30 tracked/new files. +- Nothing has been committed, staged, pushed, or opened as a PR. +- The pre-sync local v1.8.2 tip is separately preserved on + `codex/pre-sync-main-20260710`. + +Start with: + +```powershell +git status --short --branch +git diff --stat +git diff --check +``` + +## Completed and verified + +### Release and documentation truth + +- All workspace crates and path dependencies now use `2.4.0-dev`; `Cargo.lock` + matches. +- `CHANGELOG.md` now contains v2.3.0 and Unreleased/2.4.0-dev entries. +- `CONTRIBUTING.md` uses the real CI commands, explains the Windows MSVC + baseline, and no longer claims lifecycle tests need `test/test.epub`. +- The stale CI fixture comment, roadmap status notes, and historical pause bug + wording were corrected. + +### Windows and security foundation + +- `.github/workflows/ci.yml` has a `windows-2022` workspace-test job. +- Unix-shell-dependent PDF tests are now `#[cfg(unix)]`, fixing a real Windows + compilation failure exposed by the new gate. +- `.github/dependabot.yml` covers Cargo and GitHub Actions. +- `.github/workflows/security.yml` adds RustSec (`rustsec/audit-check@v2.0.0`) + and Rust CodeQL (`github/codeql-action@v4`, `build-mode: none`). +- The security YAML has not run on GitHub yet; validate with actionlint/CI before + treating it as complete. + +### Durable manual correction engine + +- New additive migration: + `crates/bookforge-store/migrations/0007_v2_4_human_corrections.sql`. +- `translations` now records `origin`, `human_corrected`, and `corrected_at`. +- `JobStore::save_manual_correction` validates state, records `manual` + provenance, clears QA errors/flags, marks the segment succeeded, and + recomputes the job status. +- Model, QA, and cache writes refuse to overwrite a human-corrected segment. +- Human corrections are excluded from cross-job translation-cache lookups. +- Store tests prove provenance, freeze behavior, job-local cache behavior, and + rejection while a job is running/paused. + +### CLI correction path + +- New command: `bookforge correct --segment + (--text ... | --from-file ...)`. +- Plain text is accepted for a single-block segment. Multi-block corrections + use JSON: + + ```json + {"blocks":[{"block_id":"b_000000","text":"..."}]} + ``` + +- The command reconstructs the source snapshot, validates marker constraints, + persists the manual correction, rebuilds the EPUB with the original bilingual + settings, and runs structural validation. +- Lifecycle coverage proves persistence, rebuild, status recomputation, and + audit fields in regenerated review JSON. + +### Dashboard correction checkpoint + +The following is implemented and type-checks, but needs endpoint/UI tests: + +- Review JSON includes per-block source/target text, manual provenance, + correction timestamp, and durable flag state. +- CSRF-protected routes exist for saving a translation, flagging a segment, and + requesting a single-segment retry with optional guidance. +- Review UI renders editable per-block textareas, Save & rebuild, durable Flag, + and Re-translate with hint controls. +- Dashboard flags use `segment_flags` instead of browser `localStorage`. +- Retry guidance is persisted in `segment_flags`, loaded during resume, rendered + into single-segment prompts, and serialized per item as `retry_guidance` in + batch prompts. + +## Verification already run + +The original Windows GNU host was incomplete. LLVM-MinGW UCRT was installed via +Winget and the existing Rust gnullvm toolchain was used. Its bin directory is: + +```text +C:\Users\gangi\AppData\Local\Microsoft\WinGet\Packages\MartinStorsjo.LLVM-MinGW.UCRT_Microsoft.Winget.Source_8wekyb3d8bbwe\llvm-mingw-20260616-ucrt-x86_64\bin +``` + +Passing checks: + +```powershell +$env:PATH=';' + $env:PATH +$env:RUSTFLAGS='-D warnings' +cargo +stable-x86_64-pc-windows-gnullvm check --workspace --all-targets --locked + +cargo +stable-x86_64-pc-windows-gnullvm test -p bookforge-store manual_correction --locked +cargo +stable-x86_64-pc-windows-gnullvm test -p bookforge-llm prompt_renders_glossary_json_prose_and_prompt_extra --locked +cargo +stable-x86_64-pc-windows-gnullvm test -p bookforge-llm batch_items_include_segment_glossary --locked +cargo +stable-x86_64-pc-windows-gnullvm test -p bookforge-cli --test lifecycle cli_correct_persists_manual_blocks_and_rebuilds_output --locked +cargo fmt --all +git diff --check +``` + +The full workspace test suite, clippy, MSRV, corpus, GitHub workflows, macOS, +and real-provider scenarios have not yet been run for this branch. + +## Claude continuation log (2026-07-10/11) + +Claude Code took over from this handoff and is executing the "Immediate next +work" items below as narrow-scope subagent work packages (WP). Status is kept +current here; if this session also ends, resume from the first non-done WP. +The worktree remains intentionally uncommitted. + +| WP | Scope | Status | +| --- | --- | --- | +| WP1 | Prompt version bump v2→v3 for the six `retry_guidance` batch templates (critical item below); cache non-reuse test | DONE — see notes below the table | +| WP2 | Store tests: `set_dashboard_segment_flag`, `request_segment_retry`, guidance load/consume, frozen-segment rejection (item 1) | DONE — 6 tests added in `db.rs` tests module, 33/33 store tests pass. See finding below. | +| WP5 | Correction atomicity: staged rebuild → DB persist → atomic replace (item 5) | DONE — `correct_job_segment` now rebuilds from the pre-persist in-memory merged blocks to `.staged--`, validates the staged file, persists the DB, then `fs::rename`s atomically. Rename failure after persist keeps the staged file and the error names both paths + recovery options. Covers CLI and dashboard (both call `correct_job_segment`). New test `cli_correct_with_marker_violation_leaves_db_and_output_unchanged`; success test now asserts no stray staged files. 37/37 lifecycle tests pass. | +| WP6 | Report artifacts record manual provenance and refresh after correction (item 6) | DONE — QA report (`{stem}.report.json`/`.md`, the only auto-written per-segment artifact) gains additive `corrected_segments` count and is regenerated from store data after each correction via new `regenerate_report_after_correction` (best-effort, logged on failure since DB+EPUB are already durable). Review JSON is on-demand and already covered; `.validation.json` has no translation content. Note: review.rs emits `human_corrected`/`corrected_at` but no `origin` field, contrary to earlier wording above. Lifecycle test extended to prove report refresh (0→1). | +| WP3 | Router CSRF tests + isolated-store e2e dashboard test; store path into `AppState` (item 2) | DONE — `AppState` now carries `store_path` resolved once at server construction (was per-request cwd-based `open_default()`, 13 call sites); 3 CSRF tests (no-token + wrong-token → 403 + no store mutation, per endpoint) and `dashboard_review_and_mutation_endpoints_end_to_end` (isolated temp store: review fetch, save correction, flag set/clear, guided retry, frozen-segment 400) in `serve.rs`'s test module (crate has no lib target, so router tests live there). No new deps. | +| WP4 | Lifecycle test: retry guidance survives restart/resume, reaches single+batch prompts, consumed only on terminal result (item 3) | DONE — 3 tests added to lifecycle.rs (single-segment mode, batch mode, frozen-segment rejection through the real `correct` CLI), 40/40 pass. Two documented gaps (see notes below the table). | +| — | Manual dashboard exercise on a mock job (item 4) | not started; needs the user or a driven session | + +Planned order: WP1+WP2 (parallel, disjoint crates), then WP5, WP6, WP3, WP4 +sequentially (all touch `bookforge-cli`). Verification per WP uses the +gnullvm toolchain commands from "Verification already run". + +### Milestone status after the continuation (dashboard correction milestone) + +Items 1, 2, 3, 5, and 6 of "Immediate next work" are DONE and verified; the +critical prompt-versioning follow-up is RESOLVED. The only open item from +that list is item 4 (manually exercising the dashboard on a mock job in a +browser), which needs a human or a driven live session. + +Final composed verification on this Windows gnullvm host (2026-07-11, after +all work packages merged in the shared worktree; the retry guard landed after +this pass, followed by green re-runs of the full store and cli suites): + +- `cargo check --workspace --all-targets --locked` with `-D warnings` — clean +- `cargo test --workspace --locked` — 548 passed, 0 failed + (bookforge-cli: 142 unit + 40 lifecycle + 16 roundtrip; core 57; epub 76; + pdf 8; llm 144; llm integration 31; store 34) +- `cargo fmt --all --check` — clean +- `git diff --check` — clean (only benign autocrlf warnings) + +On 2026-07-11 the owner approved committing this checkpoint as a single +commit on `codex/project-remediation` (supersedes the "nothing has been +committed" note in "Git/worktree state" above), and approved adding the +`request_segment_retry` running/paused guard (see the resolved WP2 finding +below). Nothing has been pushed and no PR exists. + +### Next steps for the next agent (written 2026-07-11) + +1. Manual dashboard exercise (the only open "Immediate next work" item, + item 4): create a mock-provider job the way `tests/lifecycle.rs` fixtures + do, run `bookforge serve`, and in a browser: flag → refresh (flag + survives) → edit/save a segment (expect 200, output EPUB replaced, + `{stem}.report.md` shows "Manually corrected: 1") → request a guided + retry (note: now rejected while the job is running/paused — stop first) → + resume → verify the retry consumed the guidance and events/report look + right. +2. Push the branch so the new Windows CI job and + `.github/workflows/security.yml` (RustSec + CodeQL) actually run on + GitHub — they have never executed. Fix YAML per CI feedback. Opening a PR + is the owner's call. +3. Cheap local release-gate de-risking not yet done on this branch: the + exact CI clippy command with warnings denied, and the MSRV 1.88 check. +4. Live reconfiguration milestone: follow the recommended design under + "True live reconfiguration" below. Write a short design doc first + (snapshot/watch-channel shape, which settings are boundary-applied, + event additions, dashboard exposure, race-test matrix) before coding. +5. Modularization only after live reconfig is behavior-locked; then the + final release gate (both unchanged below). + +Known flake: `cli_stop_then_resume_mock_run` occasionally fails with "stop +test should checkpoint at least one segment" (a ~50 ms sleep race in the +fixture); it passes on re-run and is unrelated to this branch's changes. +Worth deflaking when lifecycle tests are next touched. + +### WP1 notes (prompt version bump — the critical item is resolved) + +- The six changed batch templates were renamed `.v2.md` → `.v3.md` (`git mv`, + so these renames are the only staged entries) and `prompt.rs` now parses + them as `"v3"`. `translate_batch_repair(_compact)`, single-segment, and QA + templates were untouched. +- Key finding: `segments.prompt_version` (the actual cache-key column) is NOT + fed by `PromptTemplate.version`; it comes from `PromptVersion` in + `bookforge-core/src/config.rs` via `translate/mod.rs` (~line 890). A + file rename alone would have left the cache key at `"batch_v2"` and stale + pre-retry_guidance cache entries reusable. Fixed by adding + `PromptVersion::BatchV3` and switching real batch runs to it (mirrors the + historical `BatchV1`→`BatchV2` bump). `BatchV2` variant kept for existing + DB rows. +- Old v2 template files are safely deleted: templates are compile-time + `include_str!`, and resume renders with the current binary's templates while + keeping the job's original prompt_version tag purely as a cache/provenance + string (`resume.rs` ~280–410). Known nuance: a pre-bump job resumed under + the new binary renders v3 prompt text but keeps its `"batch_v2"` cache tag — + job-internal cache continuity is preserved by design. +- New tests: `batch_prompt_templates_are_versioned_v3_for_retry_guidance` + (bookforge-llm) and `cached_translation_rejects_mismatched_prompt_version` + (bookforge-store, covers single + batch lookup). CHANGELOG Unreleased bullet + added. +- Verified: workspace check clean (`-D warnings`), bookforge-llm 144/144, + bookforge-store 34/34, fmt, `git diff --check`. + +### WP4 coverage gaps (documented, deliberate) + +- Lifecycle tests cannot assert the literal prompt text contains the guidance: + `MockProvider` transforms source text only and exposes no prompt capture to + the test subprocess. Prompt content IS asserted at unit level + (`bookforge-llm` scheduler test and batch.rs ~4205). Lifecycle tests assert + the strongest observable proxy (guidance present pre-resume, attempts++, + correct single/batch code path via progress events, guidance consumed + post-resume). If prompt-level lifecycle coverage is ever wanted, add an + env-var-gated prompt log to `MockProvider::complete`. +- "Guidance survives a non-terminal failure" cannot be scripted: the mock + provider never returns a retryable error (its only failure mode routes to + the terminal `needs_review` path). Documented as a gap, not force-tested. + +### WP2 finding — RESOLVED (owner decided 2026-07-11) + +`request_segment_retry` originally had no job-state guard. The owner chose +rejection: it now rejects `running`/`paused` jobs with +`StoreError::InvalidCorrection`, mirroring `save_manual_correction`. Test +renamed to `request_segment_retry_rejects_running_and_paused_jobs`; three +sibling store tests updated to mark jobs `needs_review` before retrying. The +dashboard endpoint already maps the error to a 400. If "queue a retry against +a live job" is ever wanted as UX, design it as part of the live-reconfig +milestone (apply at batch boundaries), not by removing this guard. + +## Immediate next work (finish dashboard milestone) + +1. Add store tests for `set_dashboard_segment_flag`, + `request_segment_retry`, guidance loading/consumption, and rejection of retry + for frozen manual segments. +2. Add router tests proving all three new mutation endpoints reject missing or + cross-site CSRF requests. Add an end-to-end dashboard/API test using an + isolated store; avoid process-global current-directory races in parallel + tests (consider making the store path part of `AppState`). +3. Add a lifecycle test showing retry guidance survives process restart/resume, + reaches the prompt for both single and batch modes, and is consumed only + after a terminal provider result. +4. Manually exercise the dashboard on a mock job: flag, refresh, edit/save, + confirm output rebuild, queue a guided retry, stop/resume, and inspect the + prompt/event/report state. +5. Improve correction atomicity. Current order is DB save then EPUB rebuild; a + rebuild failure can leave the DB corrected while the output file is stale. + Preferred fix: validate/rebuild to a staged sibling path, persist the DB + transaction, then atomically replace the output, with a clear recovery path + if the final rename fails. +6. Ensure generated report artifacts (not only on-demand review JSON) record + manual provenance and are refreshed after a correction. + +### Critical prompt-versioning follow-up — RESOLVED (see WP1 notes above) + +Batch prompt files were changed to teach models about `retry_guidance`, but the +prompt/cache version has not yet been bumped. This violates the repository's +prompt-versioning invariant if left as-is. Before committing: + +- inspect `PromptVersion` and the cache namespace rules; +- bump the appropriate prompt minor/major version; +- preserve old templates if resume/backward compatibility requires them; +- add tests proving old cache entries are not reused under changed prompt text. + +Do not skip this item. + +## Remaining remediation milestones + +### True live reconfiguration + +Not started in this branch. Current behavior remains sidecar + stop/resume. + +Recommended design: + +- create a shared runtime-settings snapshot/channel (Tokio `watch` is already + available; avoid a new dependency); +- have the long-lived control watcher reload validated `overrides.json` and + publish immutable snapshots; +- apply allowed scheduling/budget settings only at segment/batch boundaries, + never mutate in-flight requests; +- keep provider/model/language/prompt/cache identity immutable; +- add additive runtime-config events and expose the editable settings on the + dashboard Progress screen; +- test pause/reconfigure/resume races, adaptive batch renaming, finalize stages, + and cleanup after success/stop/crash. + +Authoritative starting points: + +- `crates/bookforge-cli/src/commands/reconfigure.rs` +- `crates/bookforge-cli/src/commands/resume.rs` +- `crates/bookforge-cli/src/control.rs` +- `crates/bookforge-llm/src/scheduler.rs` +- `crates/bookforge-llm/src/batch.rs` +- ROADMAP sections 10.1.3 and 10.1.4. + +### Modularization + +Not started. Do it only after dashboard correction and live reconfiguration are +behavior-locked by tests. + +Suggested seams: + +- split `bookforge-llm/src/batch.rs` into planning, rendering, execution, + truncation/escalation, and tests; +- split store migrations/schema, jobs, translations/cache, flags, and glossary + out of `bookforge-store/src/db.rs` while preserving `JobStore`'s public API; +- split PDF detection/rendering/reporting from `bookforge-pdf/src/convert.rs`; +- split translate orchestration/finalization from + `bookforge-cli/src/commands/translate/mod.rs`; +- move dashboard CSS/JS/HTML to separate source files embedded with + `include_str!`, retaining the single-binary invariant. + +Each extraction should be its own behavior-neutral commit with byte-stability or +equivalent regression evidence. + +### Final release gate + +Still required: + +- `cargo fmt --all --check`; +- exact CI clippy command with warnings denied; +- full workspace tests on Linux and Windows MSVC; +- MSRV 1.88 check; +- small and full Standard Ebooks corpus; +- migration tests from pre-0007 databases; +- macOS/Windows/Linux release builds and installer smoke tests; +- mock dashboard correction/reconfiguration runs; +- selected real-provider correction, guided retry, pause, and live-reconfigure + scenarios; +- RustSec and CodeQL green runs; +- complete v2.4.0 changelog/release notes and final version bump from + `2.4.0-dev` to `2.4.0`. + +## Constraints to preserve + +- The program owns EPUB structure; the model never emits/repairs XHTML. +- Rebuild remains deterministic and structurally validated. +- Human corrections are frozen, auditable, job-local, and never cached across + jobs. +- Runtime reconfiguration cannot change cache-affecting identity. +- Event and CLI changes are additive/semver-safe. +- No hosted/multi-user expansion; dashboard remains loopback-only and + CSRF/Host protected. +- Single-binary distribution remains intact. + From ef08182ad4d941ea7acbbfbeb9b8790c2ead3c55 Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Sat, 11 Jul 2026 19:49:34 +0200 Subject: [PATCH 02/33] chore: record dashboard validation and release checks --- crates/bookforge-cli/tests/lifecycle.rs | 9 +- crates/bookforge-store/src/db.rs | 20 ++--- ...-handoff-project-remediation-2026-07-10.md | 82 +++++++++++++------ 3 files changed, 70 insertions(+), 41 deletions(-) diff --git a/crates/bookforge-cli/tests/lifecycle.rs b/crates/bookforge-cli/tests/lifecycle.rs index 8397ad7..e5f1ebb 100644 --- a/crates/bookforge-cli/tests/lifecycle.rs +++ b/crates/bookforge-cli/tests/lifecycle.rs @@ -2338,7 +2338,7 @@ fn cli_resume_after_dashboard_retry_guidance_retranslates_single_segment_mode() .load_retry_guidance(&job_id) .expect("guidance should load after resume"); assert!( - guidance_after_resume.get(retry_id.as_str()).is_none(), + !guidance_after_resume.contains_key(retry_id.as_str()), "guidance should be consumed once the retried segment reaches a terminal (succeeded) result" ); } @@ -2417,11 +2417,10 @@ fn cli_resume_after_dashboard_retry_guidance_retranslates_batch_mode() { retried.attempts ); assert!( - store + !store .load_retry_guidance(&run.job_id) .expect("guidance should load after resume") - .get(retry_id.as_str()) - .is_none(), + .contains_key(retry_id.as_str()), "guidance should be consumed once the retried segment reaches a terminal (succeeded) result in batch mode too" ); } @@ -2485,7 +2484,7 @@ fn cli_request_segment_retry_rejects_segment_frozen_by_correct_command() { .load_retry_guidance(&run.job_id) .expect("guidance should load"); assert!( - guidance.get(segment.segment_id.as_str()).is_none(), + !guidance.contains_key(segment.segment_id.as_str()), "rejected retry must not record guidance for the frozen segment" ); let records = store diff --git a/crates/bookforge-store/src/db.rs b/crates/bookforge-store/src/db.rs index 5c4f5fb..3a5ab4f 100644 --- a/crates/bookforge-store/src/db.rs +++ b/crates/bookforge-store/src/db.rs @@ -1205,12 +1205,12 @@ impl JobStore { |row| row.get::<_, String>(0), ) .optional()?; - if let Some(job_status) = &job_status { - if matches!(job_status.as_str(), "running" | "paused") { - return Err(StoreError::InvalidCorrection(format!( - "job '{job_id}' is {job_status}; stop it before requesting a retry" - ))); - } + if let Some(job_status) = &job_status + && matches!(job_status.as_str(), "running" | "paused") + { + return Err(StoreError::InvalidCorrection(format!( + "job '{job_id}' is {job_status}; stop it before requesting a retry" + ))); } let updated = tx.execute( "UPDATE segments SET status = 'retry_pending', error = NULL @@ -4985,7 +4985,7 @@ mod tests { let guidance = store .load_retry_guidance(&job.id) .expect("guidance should load"); - assert!(guidance.get("seg_a").is_none()); + assert!(!guidance.contains_key("seg_a")); let records = store .segment_records(&job.id) .expect("segment records should load"); @@ -5023,7 +5023,7 @@ mod tests { .load_retry_guidance(&job.id) .expect("guidance should load"); assert!( - guidance.get("seg_a").is_none(), + !guidance.contains_key("seg_a"), "a rejected retry must not record guidance" ); let records = store @@ -5052,7 +5052,7 @@ mod tests { .load_retry_guidance(&job.id) .expect("guidance should load"); assert!( - guidance.get("seg_a").is_none(), + !guidance.contains_key("seg_a"), "a rejected retry must not record guidance" ); let records = store @@ -5139,7 +5139,7 @@ mod tests { let guidance_after = store.load_retry_guidance(&job.id).unwrap(); assert!( - guidance_after.get("seg_a").is_none(), + !guidance_after.contains_key("seg_a"), "the consumed segment's guidance should be gone" ); assert_eq!( diff --git a/docs/codex-handoff-project-remediation-2026-07-10.md b/docs/codex-handoff-project-remediation-2026-07-10.md index e1a37db..f75ef5d 100644 --- a/docs/codex-handoff-project-remediation-2026-07-10.md +++ b/docs/codex-handoff-project-remediation-2026-07-10.md @@ -125,15 +125,17 @@ cargo fmt --all git diff --check ``` -The full workspace test suite, clippy, MSRV, corpus, GitHub workflows, macOS, -and real-provider scenarios have not yet been run for this branch. +The corpus, macOS, release-build, installer, and real-provider scenarios have +not yet been run for this branch. The continuation log below records the full +workspace suite, exact CI clippy command, MSRV check, and GitHub publication. ## Claude continuation log (2026-07-10/11) Claude Code took over from this handoff and is executing the "Immediate next work" items below as narrow-scope subagent work packages (WP). Status is kept current here; if this session also ends, resume from the first non-done WP. -The worktree remains intentionally uncommitted. +The completed work packages were committed as `50542786`; later validation +notes and clippy cleanups are recorded in the continuation below. | WP | Scope | Status | | --- | --- | --- | @@ -143,7 +145,7 @@ The worktree remains intentionally uncommitted. | WP6 | Report artifacts record manual provenance and refresh after correction (item 6) | DONE — QA report (`{stem}.report.json`/`.md`, the only auto-written per-segment artifact) gains additive `corrected_segments` count and is regenerated from store data after each correction via new `regenerate_report_after_correction` (best-effort, logged on failure since DB+EPUB are already durable). Review JSON is on-demand and already covered; `.validation.json` has no translation content. Note: review.rs emits `human_corrected`/`corrected_at` but no `origin` field, contrary to earlier wording above. Lifecycle test extended to prove report refresh (0→1). | | WP3 | Router CSRF tests + isolated-store e2e dashboard test; store path into `AppState` (item 2) | DONE — `AppState` now carries `store_path` resolved once at server construction (was per-request cwd-based `open_default()`, 13 call sites); 3 CSRF tests (no-token + wrong-token → 403 + no store mutation, per endpoint) and `dashboard_review_and_mutation_endpoints_end_to_end` (isolated temp store: review fetch, save correction, flag set/clear, guided retry, frozen-segment 400) in `serve.rs`'s test module (crate has no lib target, so router tests live there). No new deps. | | WP4 | Lifecycle test: retry guidance survives restart/resume, reaches single+batch prompts, consumed only on terminal result (item 3) | DONE — 3 tests added to lifecycle.rs (single-segment mode, batch mode, frozen-segment rejection through the real `correct` CLI), 40/40 pass. Two documented gaps (see notes below the table). | -| — | Manual dashboard exercise on a mock job (item 4) | not started; needs the user or a driven session | +| — | Manual dashboard exercise on a mock job (item 4) | DONE — owner completed the click-path and the store, events, report, logs, and retry-guidance consumption were independently checked; see below | Planned order: WP1+WP2 (parallel, disjoint crates), then WP5, WP6, WP3, WP4 sequentially (all touch `bookforge-cli`). Verification per WP uses the @@ -151,10 +153,9 @@ gnullvm toolchain commands from "Verification already run". ### Milestone status after the continuation (dashboard correction milestone) -Items 1, 2, 3, 5, and 6 of "Immediate next work" are DONE and verified; the -critical prompt-versioning follow-up is RESOLVED. The only open item from -that list is item 4 (manually exercising the dashboard on a mock job in a -browser), which needs a human or a driven live session. +All six items in "Immediate next work" are DONE and verified; the critical +prompt-versioning follow-up is RESOLVED. The manual dashboard exercise closed +the final open item on 2026-07-11. Final composed verification on this Windows gnullvm host (2026-07-11, after all work packages merged in the shared worktree; the retry guard landed after @@ -171,29 +172,59 @@ On 2026-07-11 the owner approved committing this checkpoint as a single commit on `codex/project-remediation` (supersedes the "nothing has been committed" note in "Git/worktree state" above), and approved adding the `request_segment_retry` running/paused guard (see the resolved WP2 finding -below). Nothing has been pushed and no PR exists. +below). The branch was pushed and draft PR #27 was opened: +. + +### Manual dashboard exercise — DONE (2026-07-11) + +The exercise used isolated workspace +`tmp/manual-dashboard-20260711`, mock job +`job_1783791354858557100_52d94333cfc2`, and dashboard +`http://127.0.0.1:8876/`. The owner completed the full click-path: + +1. Library → completed mock job → Review. +2. Flag a segment → refresh → confirm the flag persists. +3. Edit translation → Save & rebuild → confirm "human correction saved" is + shown next to Re-translate. +4. Stop before requesting a guided retry (running/paused jobs are rejected). +5. Re-translate with hint → enter guidance → Resume. + +The owner corrected two different segments, so the final report correctly +shows `Manually corrected: 2`. Independent inspection found no server errors, +two durable `manual`/`human_corrected` translations, one pending retry with +the supplied `dashboard_retry` hint, and the expected control signal. A CLI +`resume` then completed the pending segment: its attempts increased from 1 to +2, the hint became consumed, the job returned to `succeeded`, the report +showed `Retried: 1` and `Retry pending: 0`, and the expected resume/request/ +checkpoint/artifact/finished events were appended. + +UX finding: dashboard Resume writes a control signal for an existing process; +it cannot restart a translation process that has already exited. The hint UI +also currently uses a prompt dialog. Both should be addressed alongside the +live-reconfiguration/dashboard work: make the stopped-process action explicit +(spawn/offer `bookforge resume`, or clearly present the command) and replace +the prompt with inline hint controls. + +### Cheap local release-gate checks — DONE (2026-07-11) + +- Exact CI lint command passed: + `cargo +stable-x86_64-pc-windows-gnullvm clippy --all-targets --all-features -- -A clippy::too_many_arguments -D warnings`. +- MSRV passed with Rust 1.88 GNU and WinLibs on PATH: + `cargo +1.88.0-x86_64-pc-windows-gnu check --workspace --all-targets --locked` + (`CARGO_TARGET_DIR=target/msrv-1.88`). Rust 1.88 did not publish a Windows + gnullvm host toolchain, which is why this check uses the GNU host. ### Next steps for the next agent (written 2026-07-11) -1. Manual dashboard exercise (the only open "Immediate next work" item, - item 4): create a mock-provider job the way `tests/lifecycle.rs` fixtures - do, run `bookforge serve`, and in a browser: flag → refresh (flag - survives) → edit/save a segment (expect 200, output EPUB replaced, - `{stem}.report.md` shows "Manually corrected: 1") → request a guided - retry (note: now rejected while the job is running/paused — stop first) → - resume → verify the retry consumed the guidance and events/report look - right. -2. Push the branch so the new Windows CI job and - `.github/workflows/security.yml` (RustSec + CodeQL) actually run on - GitHub — they have never executed. Fix YAML per CI feedback. Opening a PR - is the owner's call. -3. Cheap local release-gate de-risking not yet done on this branch: the - exact CI clippy command with warnings denied, and the MSRV 1.88 check. -4. Live reconfiguration milestone: follow the recommended design under +1. Watch draft PR #27's first Windows CI and security runs and fix any real + workflow or code failures they expose. +2. Live reconfiguration milestone: follow the recommended design under "True live reconfiguration" below. Write a short design doc first (snapshot/watch-channel shape, which settings are boundary-applied, event additions, dashboard exposure, race-test matrix) before coding. -5. Modularization only after live reconfig is behavior-locked; then the +3. Fold the dashboard UX findings above into that design: inline retry-hint + controls and an actionable stopped-process resume path. +4. Modularization only after live reconfig is behavior-locked; then the final release gate (both unchanged below). Known flake: `cli_stop_then_resume_mock_run` occasionally fails with "stop @@ -367,4 +398,3 @@ Still required: - No hosted/multi-user expansion; dashboard remains loopback-only and CSRF/Host protected. - Single-binary distribution remains intact. - From 5b38680f49d4b942033780343c5620889fc08d9d Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Sat, 11 Jul 2026 19:53:35 +0200 Subject: [PATCH 03/33] docs: hand off first workflow findings --- ...-handoff-project-remediation-2026-07-10.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/codex-handoff-project-remediation-2026-07-10.md b/docs/codex-handoff-project-remediation-2026-07-10.md index f75ef5d..d18873f 100644 --- a/docs/codex-handoff-project-remediation-2026-07-10.md +++ b/docs/codex-handoff-project-remediation-2026-07-10.md @@ -214,10 +214,43 @@ the prompt with inline hint controls. (`CARGO_TARGET_DIR=target/msrv-1.88`). Rust 1.88 did not publish a Windows gnullvm host toolchain, which is why this check uses the GNU host. +### First GitHub workflow exercise — ACTION REQUIRED (2026-07-11) + +Draft PR #27 successfully exercised the previously unrun workflows. At commit +`ef08182a`, the following checks are green: + +- Linux workspace tests, Windows MSVC workspace tests, and MSRV 1.88; +- exact CI clippy and formatting; +- small Standard Ebooks corpus smoke test; +- release planning (artifact jobs correctly skip on a pull request); +- CodeQL Rust analysis. + +The only failing check is RustSec. Its log reports three vulnerable locked +dependencies and one warning: + +- `quick-xml 0.38.4`: RUSTSEC-2026-0194 and RUSTSEC-2026-0195, patched in + `>=0.41.0`. This is a direct workspace dependency (`Cargo.toml` currently + requests `0.38`), so it needs a manifest bump plus any API adaptation. +- `quinn-proto 0.11.14`: RUSTSEC-2026-0185, patched in `>=0.11.15`. It is a + locked transitive dependency from the HTTP stack; first try a precise + lockfile update to `0.11.15` or newer allowed by its parent. +- `anyhow 1.0.102`: RUSTSEC-2026-0190 unsoundness warning. The advisory output + names the fixing upstream commit but no patched-version range. Update to the + first published release containing that fix after verifying its advisory + metadata; do not add an ignore merely to make the check green. + +Focused next-agent fix: update those dependencies, run the full workspace +tests, exact clippy, MSRV 1.88, `cargo audit` (or push to rerun RustSec), and +the XML-heavy core/EPUB/PDF tests in particular. Then commit and push to PR +#27. The GitHub logs also contain non-blocking Node 20 deprecation warnings for +`actions/checkout@v4`, `actions/setup-python@v5`, `actions/setup-java@v4`, and +`rustsec/audit-check@v2.0.0`; update actions where supported, but keep that +separate from the security dependency fix. + ### Next steps for the next agent (written 2026-07-11) -1. Watch draft PR #27's first Windows CI and security runs and fix any real - workflow or code failures they expose. +1. Fix the RustSec dependency findings documented immediately above, verify, + and push them to draft PR #27. All other first-run workflow checks passed. 2. Live reconfiguration milestone: follow the recommended design under "True live reconfiguration" below. Write a short design doc first (snapshot/watch-channel shape, which settings are boundary-applied, From 5ad38b351dd8d492b12bf3728cb2f75b2778ad9b Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Sat, 11 Jul 2026 20:07:32 +0200 Subject: [PATCH 04/33] fix: update dependencies flagged by RustSec --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- crates/bookforge-epub/src/reader.rs | 11 ++++++++--- crates/bookforge-epub/src/reflow.rs | 5 ++++- crates/bookforge-epub/src/writer.rs | 5 ++++- crates/bookforge-pdf/src/parse.rs | 20 ++++++++++++++++---- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7264786..3b45094 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -1706,9 +1706,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -1735,9 +1735,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index b7e6e3b..a4511f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ console = "0.16" indicatif = "0.18" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -quick-xml = { version = "0.38", features = ["escape-html"] } +quick-xml = { version = "0.41", features = ["escape-html"] } zip = "6.0" rusqlite = { version = "0.37", features = ["bundled"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "gzip", "brotli"], default-features = false } diff --git a/crates/bookforge-epub/src/reader.rs b/crates/bookforge-epub/src/reader.rs index 2ac3bc1..496adfd 100644 --- a/crates/bookforge-epub/src/reader.rs +++ b/crates/bookforge-epub/src/reader.rs @@ -593,8 +593,11 @@ fn attr_value( let attr = attr.map_err(|err| BookforgeError::InvalidInput(err.to_string()))?; if local_name(attr.key.as_ref()) == attr_name { return Ok(Some( - attr.decode_and_unescape_value(reader.decoder())? - .into_owned(), + attr.decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + )? + .into_owned(), )); } } @@ -1150,7 +1153,9 @@ fn has_epub_type(element: &BytesStart<'_>, expected: &[u8]) -> Result { for attr in element.attributes() { let attr = attr.map_err(|err| BookforgeError::InvalidInput(err.to_string()))?; if local_name(attr.key.as_ref()) == b"type" { - let value = attr.unescape_value()?.into_owned(); + let value = attr + .normalized_value(quick_xml::XmlVersion::Implicit1_0)? + .into_owned(); return Ok(value .split_ascii_whitespace() .any(|item| item.as_bytes() == expected)); diff --git a/crates/bookforge-epub/src/reflow.rs b/crates/bookforge-epub/src/reflow.rs index cd44a5e..bd3158c 100644 --- a/crates/bookforge-epub/src/reflow.rs +++ b/crates/bookforge-epub/src/reflow.rs @@ -1024,7 +1024,10 @@ fn attr_value_unescaped(element: &BytesStart<'_>, attr_name: &[u8]) -> Result Result> { for attr in element.attributes() { let attr = attr.map_err(|err| PdfError::InvalidInput(err.to_string()))?; let value = attr - .decode_and_unescape_value(reader.decoder()) + .decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + ) .map_err(|err| PdfError::InvalidInput(err.to_string()))?; match local(attr.key.as_ref()) { b"number" => number = value.parse().unwrap_or(0), @@ -73,7 +76,10 @@ pub fn parse_pdf2xml(xml: &str) -> Result> { for attr in element.attributes() { let attr = attr.map_err(|err| PdfError::InvalidInput(err.to_string()))?; let value = attr - .decode_and_unescape_value(reader.decoder()) + .decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + ) .map_err(|err| PdfError::InvalidInput(err.to_string()))?; match local(attr.key.as_ref()) { b"id" => id = value.parse::().ok(), @@ -97,7 +103,10 @@ pub fn parse_pdf2xml(xml: &str) -> Result> { for attr in element.attributes() { let attr = attr.map_err(|err| PdfError::InvalidInput(err.to_string()))?; let value = attr - .decode_and_unescape_value(reader.decoder()) + .decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + ) .map_err(|err| PdfError::InvalidInput(err.to_string()))?; match local(attr.key.as_ref()) { b"top" => fragment.top = parse_coord(&value), @@ -128,7 +137,10 @@ pub fn parse_pdf2xml(xml: &str) -> Result> { for attr in element.attributes() { let attr = attr.map_err(|err| PdfError::InvalidInput(err.to_string()))?; let value = attr - .decode_and_unescape_value(reader.decoder()) + .decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + ) .map_err(|err| PdfError::InvalidInput(err.to_string()))?; match local(attr.key.as_ref()) { b"top" => region.top = parse_coord(&value), From b809e59897bc8ca6cdd566bb5462ad19d836c281 Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Sat, 11 Jul 2026 20:11:59 +0200 Subject: [PATCH 05/33] docs: design live runtime reconfiguration --- ...-handoff-project-remediation-2026-07-10.md | 35 ++- docs/design-live-reconfiguration.md | 266 ++++++++++++++++++ 2 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 docs/design-live-reconfiguration.md diff --git a/docs/codex-handoff-project-remediation-2026-07-10.md b/docs/codex-handoff-project-remediation-2026-07-10.md index d18873f..29ae744 100644 --- a/docs/codex-handoff-project-remediation-2026-07-10.md +++ b/docs/codex-handoff-project-remediation-2026-07-10.md @@ -53,8 +53,8 @@ git diff --check - `.github/dependabot.yml` covers Cargo and GitHub Actions. - `.github/workflows/security.yml` adds RustSec (`rustsec/audit-check@v2.0.0`) and Rust CodeQL (`github/codeql-action@v4`, `build-mode: none`). -- The security YAML has not run on GitHub yet; validate with actionlint/CI before - treating it as complete. +- The security workflow is proven on draft PR #27: RustSec and Rust CodeQL are + green after the dependency remediation recorded below. ### Durable manual correction engine @@ -214,7 +214,7 @@ the prompt with inline hint controls. (`CARGO_TARGET_DIR=target/msrv-1.88`). Rust 1.88 did not publish a Windows gnullvm host toolchain, which is why this check uses the GNU host. -### First GitHub workflow exercise — ACTION REQUIRED (2026-07-11) +### First GitHub workflow exercise — DONE (2026-07-11) Draft PR #27 successfully exercised the previously unrun workflows. At commit `ef08182a`, the following checks are green: @@ -225,7 +225,7 @@ Draft PR #27 successfully exercised the previously unrun workflows. At commit - release planning (artifact jobs correctly skip on a pull request); - CodeQL Rust analysis. -The only failing check is RustSec. Its log reports three vulnerable locked +The initial failing check was RustSec. Its log reported three vulnerable locked dependencies and one warning: - `quick-xml 0.38.4`: RUSTSEC-2026-0194 and RUSTSEC-2026-0195, patched in @@ -239,25 +239,23 @@ dependencies and one warning: first published release containing that fix after verifying its advisory metadata; do not add an ignore merely to make the check green. -Focused next-agent fix: update those dependencies, run the full workspace -tests, exact clippy, MSRV 1.88, `cargo audit` (or push to rerun RustSec), and -the XML-heavy core/EPUB/PDF tests in particular. Then commit and push to PR -#27. The GitHub logs also contain non-blocking Node 20 deprecation warnings for +Resolved in `5ad38b35`: `quick-xml` was upgraded to 0.41.0 and its deprecated +attribute APIs were migrated to equivalent explicit XML 1.0 normalization; +`quinn-proto` was locked to 0.11.15 and `anyhow` to 1.0.103. XML-heavy tests, +CLI round trips, the full workspace suite, exact clippy, and MSRV 1.88 passed +locally. PR #27 then passed RustSec, CodeQL, Linux and Windows tests, clippy, +format, MSRV, small corpus, and release planning. The GitHub logs retain +non-blocking Node 20 deprecation warnings for `actions/checkout@v4`, `actions/setup-python@v5`, `actions/setup-java@v4`, and `rustsec/audit-check@v2.0.0`; update actions where supported, but keep that separate from the security dependency fix. ### Next steps for the next agent (written 2026-07-11) -1. Fix the RustSec dependency findings documented immediately above, verify, - and push them to draft PR #27. All other first-run workflow checks passed. -2. Live reconfiguration milestone: follow the recommended design under - "True live reconfiguration" below. Write a short design doc first - (snapshot/watch-channel shape, which settings are boundary-applied, - event additions, dashboard exposure, race-test matrix) before coding. -3. Fold the dashboard UX findings above into that design: inline retry-hint - controls and an actionable stopped-process resume path. -4. Modularization only after live reconfig is behavior-locked; then the +1. Implement the accepted live-reconfiguration design in + `docs/design-live-reconfiguration.md`, including inline retry-hint controls + and an actionable stopped-process resume path. +2. Modularization only after live reconfig is behavior-locked; then the final release gate (both unchanged below). Known flake: `cli_stop_then_resume_mock_run` occasionally fails with "stop @@ -357,7 +355,8 @@ Do not skip this item. ### True live reconfiguration -Not started in this branch. Current behavior remains sidecar + stop/resume. +Design complete in `docs/design-live-reconfiguration.md`; implementation has +not started. Current behavior remains sidecar + stop/resume. Recommended design: diff --git a/docs/design-live-reconfiguration.md b/docs/design-live-reconfiguration.md new file mode 100644 index 0000000..cb1f31f --- /dev/null +++ b/docs/design-live-reconfiguration.md @@ -0,0 +1,266 @@ +# Live reconfiguration design + +Status: accepted implementation plan for the v2.4 remediation milestone +Date: 2026-07-11 +Source: `docs/codex-handoff-project-remediation-2026-07-10.md` and ROADMAP +§§10.1.3–10.1.4 + +## Goal + +Apply cache-safe scheduling, request-budget, and finalize settings to the +remaining work of an existing job without restarting a live translation +process. A setting change must never mutate an in-flight provider request, +change prompt/cache identity, or retranslate a succeeded segment. + +The existing `overrides.json` stop/resume path remains the crash-safe fallback. +Live application is additive: a job with no overrides behaves exactly as it +does today, and an old flat overrides sidecar remains readable. + +This design also closes two findings from the manual dashboard exercise: + +- retry guidance uses an inline editor instead of `window.prompt()`; and +- Resume signals a live worker, but starts `bookforge resume ` when the + original worker has exited. + +## Invariants + +1. Provider, model, source/target language, profile, segmentation, context, + prompt version, glossary, style, entities, and cache namespace are immutable. +2. Every provider request uses one immutable runtime snapshot from start through + all of that request's internal attempts. A newer revision affects only a + later request. +3. Succeeded/cached/manual segments and manual-correction freezes are untouched. +4. Revisions are monotonic. Readers retain the last valid snapshot if a newer + sidecar is partial, invalid, or unsupported. +5. The sidecar is the durable source of pending/effective overrides. It remains + after stop/crash and is cleared only after successful terminal finalization. +6. Existing event variants and JSON fields remain compatible; all event/state + additions are additive. +7. No new dependency or database migration is required. + +## Architecture + +```mermaid +flowchart LR + UI["CLI or dashboard reconfigure"] --> Sidecar["atomic overrides.json revision"] + Sidecar --> Watcher["job runtime watcher"] + Control["pause / resume / stop"] --> Watcher + Watcher --> Channel["Tokio watch: immutable runtime snapshot"] + Channel --> Single["single-segment dispatch boundary"] + Channel --> Batch["batch planning / request boundary"] + Channel --> Finalize["QA / double-check / validation boundary"] + Watcher --> Lease["runtime lease + applied revision"] + Lease --> Dashboard["signal live worker or spawn CLI resume"] +``` + +### Durable sidecar + +Replace direct `fs::write` with staged-sibling write, flush, and atomic rename. +The new envelope is: + +```json +{ + "schema_version": 1, + "revision": 3, + "updated_at_ms": 1783790000000, + "overrides": { + "batch_max_output_tokens": 12000, + "batch_max_items": 3, + "concurrency": 2 + } +} +``` + +The loader accepts both this envelope and the current flat +`RunConfigOverrides` object. A flat object is revision 0 and is rewritten as an +envelope on the next edit. Writers serialize across CLI/dashboard processes +with an adjacent `overrides.lock` acquired through `create_new`; a bounded stale +lock lease recovers from a crashed writer. While holding it, the writer reloads +the current revision, merges the typed partial update, validates it, increments +the revision, and atomically replaces the sidecar. The watcher detects +content-hash changes rather than depending on filesystem timestamp granularity. + +Validation rejects zero numeric limits and immutable fields instead of silently +clamping them. The same parser/validator is used by CLI, dashboard, live watcher, +and resume. + +### Runtime snapshots and channels + +Add a CLI-owned `JobRuntimeSettings` containing: + +- revision and the complete effective `ResolvedRunSettings`; +- QA mode and `validate_output`, which currently live outside that structure; +- the validated override field names for audit/UI display. + +Derive an engine-facing `EngineRuntimeSettings` containing only batch config, +target concurrency, provider attempt limit, and adaptive toggles. Add an +optional `tokio::sync::watch::Receiver` to +`TranslationRunConfig`. `None` preserves current behavior for library callers +and existing tests. + +Refactor `ControlFileWatcher` into a long-lived `JobRuntimeWatcher`. It keeps one +store/poller instance, watches both control and override sidecars, publishes +only validated immutable snapshots, and emits a rejection once per invalid +content hash. At each control boundary it reloads overrides before applying a +Resume command, so a sequential reconfigure-then-resume cannot dispatch under +the previous revision. + +### Boundary semantics + +| Setting | First legal application point | Required behavior | +| --- | --- | --- | +| `concurrency` | next dispatch/acquire | Existing requests finish; later dispatch observes the new target. Use the existing burn-permit limiter mechanism so shrinking never blocks the control loop. | +| `batch_max_output_tokens` | next provider request | Snapshot once before request attempts. An already-running request keeps its old budget. Truncation escalation for later attempts uses the newest baseline. | +| `batch_max_items`, `batch_target_tokens` | next unstarted batch | Repartition only unstarted items within the same section/mode. Never merge or split an in-flight batch. | +| `adaptive_batch_sizing` | next unstarted batch | Enabling creates sizing state from the new baseline; disabling stops adaptation without rewriting in-flight work. | +| `adaptive_concurrency` | next request completion/acquire | Preserve current limiter target, then enable/disable automatic feedback; explicit concurrency remains the upper target. | +| `provider_max_attempts` | next provider call | The provider snapshots the limit before its attempt loop; do not change a call already retrying. | +| `qa` | QA stage entry | Snapshot once for the stage. A change after QA starts applies on a later resume, not halfway through reviews. | +| `double_check` | double-check stage entry | Snapshot once for the whole stage. | +| `validate_output` | validation stage entry | Snapshot once immediately before deciding whether to validate. | + +The single-segment scheduler replaces its fixed `concurrency` local with the +latest snapshot at every dispatch loop. The batch scheduler replaces its fixed +semaphore with an adjustable limiter and reads a snapshot before normalizing and +spawning each batch. + +Batch retry/escalation bookkeeping must no longer rely solely on mutable batch +IDs. Key it by stable ordered item IDs (a `BatchWorkKey`) so repartitioning and +existing adaptive renaming cannot lose attempt counts or escalated budgets. +When a revision changes, rebuild only the pending queue from its remaining item +set; completed and in-flight item IDs are excluded. + +### Provider attempts + +`OpenAiCompatibleProvider` currently owns a fixed `provider_max_attempts`. +Give it an optional runtime receiver (or an `Arc` fed by the same +publisher) and snapshot the value at the beginning of `complete`. Mock and +other providers retain their current fixed behavior unless they implement an +internal retry loop. The request event records the effective revision and +attempt limit for auditability. + +### Runtime lease and dashboard Resume + +The translation process writes +`.bookforge/runs//runtime.json` atomically with: + +- a random run-instance ID, PID, and process start time; +- last heartbeat time; +- last loaded and last applied override revision. + +The runtime watcher refreshes it at most once per second and removes it on clean +exit. A lease older than three heartbeat intervals is stale; the random +instance ID prevents a stale PID from being treated as the current worker. + +`POST /api/jobs/{id}/resume` then behaves as follows: + +- fresh lease: write the Resume control command and return `mode: "signaled"`; +- stale/missing lease plus resumable segments: spawn the current executable as + `bookforge resume --ui quiet`, return `mode: "spawned"`, and surface + startup failure; +- no resumable work: return an actionable 400 instead of pretending the job is + running. + +Pause and Stop require a fresh lease; otherwise they return a message explaining +that no worker is alive. All endpoints remain loopback-only and CSRF protected. + +### Dashboard surface + +Add `GET` and `POST /api/jobs/{id}/reconfigure`. The GET response contains the +effective values, durable overrides, revision, applied revision, and lease +state. The POST accepts only the typed mutable fields and returns the new +revision plus `live`, `next_boundary`, or `resume_required` application state. + +The Progress screen gets an inline “Runtime settings” panel. It is editable for +running, paused, or stopped jobs with resumable work; immutable identity is +display-only. Save feedback names the revision and boundary. Runtime events +update the panel without a full refresh. + +On Review, “Re-translate with hint” expands an inline textarea with Cancel and +Queue retry buttons. Because retrying running/paused jobs remains rejected, the +panel explicitly says “Stop the job before queuing a retry” and offers Stop. +After queuing, Resume uses the lease-aware behavior above. + +## Events and replay state + +Add: + +- `RuntimeConfigChanged { revision, changed_fields, application, timestamp_ms }`; +- `RuntimeConfigRejected { revision, message, timestamp_ms }`. + +`application` is `next_request`, `next_batch`, `next_stage`, or +`resume_required`; multiple affected boundaries may be represented as a list. +Extend `RequestStarted` with optional `runtime_config_revision` and +`provider_max_attempts` fields. Extend `RunState` with the latest valid revision, +changed field list, and last rejection. Old event logs deserialize through +defaults, and all renderers show a compact change/rejection line. + +The existing `RuntimeConfigResolved` remains the initial full snapshot event. + +## Race and regression tests + +### Unit tests + +- legacy flat sidecar loads; envelope revision increments; atomic writer leaves + no partial target; +- immutable/zero/unknown settings reject and leave the last valid snapshot; +- watcher publishes once per revision and once per invalid content hash; +- adjustable limiter grows and shrinks without cancelling held permits; +- provider attempt count is fixed for one call and changes on the next; +- pending batch repartition preserves item IDs, retry counters, truncation + escalation, section/mode boundaries, and adaptive batch renaming. + +### Scheduler tests + +- reconfigure while requests are in flight: old requests report revision N and + later requests report N+1; +- pause → reconfigure → resume dispatches no post-resume request under N; +- concurrency shrink waits for natural completion and bounds later activity; +- stop wins over a simultaneous reconfiguration and dispatches nothing new; +- channel closure retains the last snapshot and does not fail the run. + +### Lifecycle tests + +- single and batch mock jobs apply live budgets/concurrency without + retranslating succeeded segments; +- batch target/item changes survive adaptive renaming and crash/resume; +- QA/double-check/validation changes apply only at their stage boundary; +- valid overrides survive crash and are consumed after successful resume; +- successful completion clears the sidecar and lease; stop/crash preserves + overrides and leaves a stale/recoverable lease; +- dashboard reconfigure, hint, control, and spawn-resume routes enforce Host and + CSRF protections and use an isolated store; +- a missing/stale worker makes dashboard Resume spawn one process exactly once + (concurrent clicks are deduplicated by an atomic launch claim). + +### Manual acceptance + +1. Start a real-provider batch run and pause it with requests in flight. +2. Lower items/concurrency, raise output tokens, and resume from the dashboard. +3. Confirm in-flight requests retain the old revision and every later request + reports the new revision/budget. +4. Kill the worker, queue a retry hint, and confirm dashboard Resume launches a + new worker, consumes the hint, and does not retranslate succeeded segments. +5. Confirm token spend stops while paused and remains bounded after shrinking + concurrency. + +## Implementation order + +1. Sidecar envelope, atomic persistence, shared validation, snapshots, events, + and replay state. +2. Runtime watcher/channel and process lease, preserving the old `None` channel + behavior. +3. Single-segment dynamic dispatch and provider attempt snapshots. +4. Batch adjustable limiter, pending-item repartition, and stable work keys. +5. Finalize-stage snapshots and terminal cleanup. +6. Dashboard reconfigure API/panel, lease-aware controls, and inline hint UI. +7. Full tests, exact clippy, MSRV 1.88, small/full corpus as appropriate, and a + real-provider manual acceptance run before release. + +## Non-goals + +- Changing cache/prompt identity or segmentation in an existing job. +- Cancelling or rewriting an in-flight provider request. +- Multi-user/remote dashboard operation or a general job queue. +- Reconfiguring a successfully completed job. +- Moving configuration state into SQLite in this milestone. From 29ee6077e7abd0006cee6c4edec93086a9cc0c82 Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Mon, 13 Jul 2026 20:53:11 +0200 Subject: [PATCH 06/33] feat: add live job reconfiguration --- CHANGELOG.md | 21 +- .../bookforge-cli/src/commands/reconfigure.rs | 415 +++++++- crates/bookforge-cli/src/commands/resume.rs | 72 +- crates/bookforge-cli/src/commands/serve.rs | 969 +++++++++++++++++- crates/bookforge-cli/src/commands/status.rs | 2 + crates/bookforge-cli/src/commands/tail.rs | 2 + .../src/commands/translate/engine.rs | 7 +- .../src/commands/translate/mod.rs | 74 +- .../src/commands/translate/snapshot.rs | 2 + crates/bookforge-cli/src/control.rs | 652 +++++++++++- crates/bookforge-cli/src/main.rs | 18 + crates/bookforge-cli/src/progress.rs | 35 + crates/bookforge-cli/src/tui/mod.rs | 40 + crates/bookforge-cli/tests/lifecycle.rs | 388 ++++++- crates/bookforge-core/src/progress.rs | 84 ++ crates/bookforge-core/src/run_snapshot.rs | 11 + crates/bookforge-llm/src/batch.rs | 797 +++++++++++++- crates/bookforge-llm/src/concurrency.rs | 49 +- crates/bookforge-llm/src/double_check.rs | 7 + crates/bookforge-llm/src/lib.rs | 6 +- crates/bookforge-llm/src/provider.rs | 92 +- crates/bookforge-llm/src/qa_batch.rs | 4 + crates/bookforge-llm/src/scheduler.rs | 299 +++++- crates/bookforge-store/src/db.rs | 4 + docs/ROADMAP.md | 33 +- ...-handoff-project-remediation-2026-07-10.md | 183 +++- docs/design-live-reconfiguration.md | 52 +- 27 files changed, 4119 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 994a122..eea247b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,25 @@ BookForge main now carries a distinct development version so source builds cannot be confused with the published v2.3.0 artifacts. -- Added cache-safe job reconfiguration through `bookforge reconfigure`, with - auditable overrides that apply when a stopped or dead paused job resumes. +- Added revisioned, atomic live job reconfiguration through the CLI and the + dashboard. Cache-safe concurrency, request-budget, retry, adaptive sizing, + QA, double-check, and validation settings now apply at explicit request, + batch, or finalize-stage boundaries without changing in-flight requests or + retranslating completed segments. +- Added worker leases and lease-aware dashboard controls: Resume signals a + live worker or safely launches one replacement for a stopped, crashed, or + dead-paused job, with concurrent launch attempts deduplicated. +- Added durable, auditable human corrections and flags to the CLI and Review + dashboard. Corrected segments are frozen against model/QA/cache overwrites, + guided retries persist across resume, and retry guidance now uses an inline + editor with explicit Stop/Resume workflow. +- Added Windows workspace CI, Dependabot coverage, RustSec auditing, and Rust + CodeQL analysis; the new workflows have been exercised on the v2.4 draft PR. - Added escalation-first handling for truncated batch responses and prominent systemic-truncation alerts across CLI, watch, and dashboard surfaces. -- Fixed resume override handling, adaptive batch override propagation, stale - override cleanup, and dashboard wizard API-key retention. +- Fixed resume override ordering, adaptive batch override propagation, stale + override cleanup, a stop/resume lifecycle timing race, and dashboard wizard + API-key retention. - Bumped the batch translate prompts (plain, marker-safe, run-preserving, and their compact variants) from v2 to v3 to teach models about a per-item `retry_guidance` field; the batch prompt cache tag moved from `batch_v2` diff --git a/crates/bookforge-cli/src/commands/reconfigure.rs b/crates/bookforge-cli/src/commands/reconfigure.rs index 40deabc..b65085c 100644 --- a/crates/bookforge-cli/src/commands/reconfigure.rs +++ b/crates/bookforge-cli/src/commands/reconfigure.rs @@ -1,13 +1,17 @@ use std::{ - fs, + fs::{self, File, OpenOptions}, + io::Write, path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + thread, + time::{Duration, Instant, SystemTime}, }; use anyhow::{Context, Result}; use bookforge_core::{ GlossaryFormat, ProviderPreset, ResolvedRunSettings, config::{ContextScope, DoubleCheckMode, TranslationProfile}, - run_dir_for_job, + now_ms, run_dir_for_job, }; use bookforge_store::JobStore; use clap::Args; @@ -103,8 +107,13 @@ pub struct ReconfigureArgs { provider_preset: Option, } +const OVERRIDES_SCHEMA_VERSION: u32 = 1; +const OVERRIDES_LOCK_WAIT: Duration = Duration::from_secs(5); +const OVERRIDES_STALE_LOCK_AGE: Duration = Duration::from_secs(30); +static OVERRIDES_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub(crate) struct RunConfigOverrides { pub batch_max_output_tokens: Option, pub batch_max_items: Option, @@ -155,7 +164,7 @@ impl RunConfigOverrides { } } - fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.batch_max_output_tokens.is_none() && self.batch_max_items.is_none() && self.batch_target_tokens.is_none() @@ -167,6 +176,114 @@ impl RunConfigOverrides { && self.adaptive_concurrency.is_none() && self.adaptive_batch_sizing.is_none() } + + fn validate(&self) -> Result<()> { + let mut zero_fields = Vec::new(); + if self.batch_max_output_tokens == Some(0) { + zero_fields.push("batch-max-output-tokens"); + } + if self.batch_max_items == Some(0) { + zero_fields.push("batch-max-items"); + } + if self.batch_target_tokens == Some(0) { + zero_fields.push("batch-target-tokens"); + } + if self.concurrency == Some(0) { + zero_fields.push("concurrency"); + } + if self.provider_max_attempts == Some(0) { + zero_fields.push("provider-max-attempts"); + } + if zero_fields.is_empty() { + return Ok(()); + } + anyhow::bail!( + "runtime settings must be greater than zero: {}", + zero_fields.join(", ") + ) + } + + pub(crate) fn changed_fields(&self) -> Vec { + let mut fields = Vec::new(); + if self.batch_max_output_tokens.is_some() { + fields.push("batch-max-output-tokens".to_string()); + } + if self.batch_max_items.is_some() { + fields.push("batch-max-items".to_string()); + } + if self.batch_target_tokens.is_some() { + fields.push("batch-target-tokens".to_string()); + } + if self.concurrency.is_some() { + fields.push("concurrency".to_string()); + } + if self.qa.is_some() { + fields.push("qa".to_string()); + } + if self.double_check.is_some() { + fields.push("double-check".to_string()); + } + if self.validate_output.is_some() { + fields.push("validate-output".to_string()); + } + if self.provider_max_attempts.is_some() { + fields.push("provider-max-attempts".to_string()); + } + if self.adaptive_concurrency.is_some() { + fields.push("adaptive-concurrency".to_string()); + } + if self.adaptive_batch_sizing.is_some() { + fields.push("adaptive-batch-sizing".to_string()); + } + fields + } + + pub(crate) fn application_boundaries(&self) -> Vec { + let mut boundaries = Vec::new(); + if self.concurrency.is_some() + || self.provider_max_attempts.is_some() + || self.adaptive_concurrency.is_some() + || self.batch_max_output_tokens.is_some() + { + boundaries.push("next_request".to_string()); + } + if self.batch_max_items.is_some() + || self.batch_target_tokens.is_some() + || self.adaptive_batch_sizing.is_some() + { + boundaries.push("next_batch".to_string()); + } + if self.qa.is_some() || self.double_check.is_some() || self.validate_output.is_some() { + boundaries.push("next_stage".to_string()); + } + boundaries + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct OverridesEnvelope { + schema_version: u32, + revision: u64, + updated_at_ms: u64, + overrides: RunConfigOverrides, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedOverrides { + pub revision: u64, + pub updated_at_ms: u64, + pub overrides: RunConfigOverrides, +} + +impl LoadedOverrides { + fn legacy(overrides: RunConfigOverrides) -> Self { + Self { + revision: 0, + updated_at_ms: 0, + overrides, + } + } } pub async fn run(args: ReconfigureArgs) -> Result<()> { @@ -177,17 +294,17 @@ pub async fn run(args: ReconfigureArgs) -> Result<()> { "no mutable settings provided; reconfigure accepts cache-safe scheduling, budget, QA, double-check, validation, provider-attempt, and adaptive flags" ); } + incoming.validate()?; let store = JobStore::open_default()?; let Some(job) = store.get_job(&args.job_id)? else { anyhow::bail!("job '{}' was not found", args.job_id); }; - if job.status != "paused" { + if !matches!(job.status.as_str(), "running" | "paused" | "stopped") { anyhow::bail!( - "job '{}' is '{}'; reconfigure only applies to paused jobs. Run `bookforge pause {}` first, or start a fresh run for immutable settings.", + "job '{}' is '{}'; reconfigure applies only to running, paused, or stopped jobs with remaining work", args.job_id, - job.status, - args.job_id + job.status ); } if store.load_job_config_snapshot(&args.job_id)?.is_none() { @@ -197,12 +314,11 @@ pub async fn run(args: ReconfigureArgs) -> Result<()> { ); } - let existing = load_overrides_for_job(&args.job_id)?; - let merged = incoming.merge(existing.unwrap_or_default()); - let path = write_overrides_for_job(&args.job_id, &merged)?; + let (path, loaded) = write_merged_overrides_for_job(&args.job_id, incoming)?; println!("Reconfigured: {}", args.job_id); println!("Overrides: {}", path.display()); - for line in describe_overrides(&merged) { + println!("Revision: {}", loaded.revision); + for line in describe_overrides(&loaded.overrides) { println!(" {line}"); } println!("Apply: {}", apply_instructions(&args.job_id)); @@ -240,6 +356,10 @@ pub(crate) fn apply_overrides_to_settings( } pub(crate) fn load_overrides_for_job(job_id: &str) -> Result> { + Ok(load_overrides_document_for_job(job_id)?.map(|loaded| loaded.overrides)) +} + +pub(crate) fn load_overrides_document_for_job(job_id: &str) -> Result> { load_overrides_from_path(&overrides_path_for_job(job_id)) } @@ -258,7 +378,7 @@ pub(crate) fn overrides_path_for_job(job_id: &str) -> PathBuf { pub(crate) fn apply_instructions(job_id: &str) -> String { format!( - "Stop the paused run first: `bookforge stop {job_id}`, then `bookforge resume {job_id}` to apply overrides. If the paused process is already gone, use `bookforge resume {job_id} --force`." + "A live worker applies this revision at the next safe boundary. If no worker is alive, run `bookforge resume {job_id}` (or `bookforge resume {job_id} --force` for a stale paused status)." ) } @@ -311,26 +431,178 @@ fn push_opt(lines: &mut Vec, name: &str, value: Option) } } -fn load_overrides_from_path(path: &Path) -> Result> { +fn load_overrides_from_path(path: &Path) -> Result> { match fs::read_to_string(path) { - Ok(contents) => serde_json::from_str(&contents) - .map(Some) - .with_context(|| format!("failed to parse {}", path.display())), + Ok(contents) => { + let value: serde_json::Value = serde_json::from_str(&contents) + .with_context(|| format!("failed to parse {}", path.display()))?; + let loaded = + if value.get("schema_version").is_some() || value.get("overrides").is_some() { + let envelope: OverridesEnvelope = serde_json::from_value(value) + .with_context(|| format!("failed to parse {}", path.display()))?; + if envelope.schema_version != OVERRIDES_SCHEMA_VERSION { + anyhow::bail!( + "unsupported runtime override schema {} in {}; expected {}", + envelope.schema_version, + path.display(), + OVERRIDES_SCHEMA_VERSION + ); + } + LoadedOverrides { + revision: envelope.revision, + updated_at_ms: envelope.updated_at_ms, + overrides: envelope.overrides, + } + } else { + LoadedOverrides::legacy( + serde_json::from_value(value) + .with_context(|| format!("failed to parse {}", path.display()))?, + ) + }; + loaded.overrides.validate()?; + Ok(Some(loaded)) + } Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())), } } -fn write_overrides_for_job(job_id: &str, overrides: &RunConfigOverrides) -> Result { +pub(crate) fn write_merged_overrides_for_job( + job_id: &str, + incoming: RunConfigOverrides, +) -> Result<(PathBuf, LoadedOverrides)> { let path = overrides_path_for_job(job_id); + let loaded = write_merged_overrides_at_path(&path, incoming)?; + Ok((path, loaded)) +} + +fn write_merged_overrides_at_path( + path: &Path, + incoming: RunConfigOverrides, +) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - let json = serde_json::to_string_pretty(overrides)?; - fs::write(&path, format!("{json}\n")) - .with_context(|| format!("failed to write {}", path.display()))?; - Ok(path) + let _lock = OverridesFileLock::acquire(path)?; + // A reader must retain its last valid in-memory snapshot when the durable + // document is corrupt. A writer, however, is the recovery mechanism: once + // it owns the cross-process lock it may replace an unreadable document with + // a fresh revision-1 envelope instead of making every future edit fail. + let existing = load_overrides_from_path(path) + .ok() + .flatten() + .unwrap_or_else(|| LoadedOverrides { + revision: 0, + updated_at_ms: 0, + overrides: RunConfigOverrides::default(), + }); + let overrides = incoming.merge(existing.overrides); + overrides.validate()?; + let loaded = LoadedOverrides { + revision: existing.revision.saturating_add(1), + updated_at_ms: now_ms(), + overrides, + }; + write_overrides_atomically(path, &loaded)?; + Ok(loaded) +} + +fn write_overrides_atomically(path: &Path, loaded: &LoadedOverrides) -> Result<()> { + let envelope = OverridesEnvelope { + schema_version: OVERRIDES_SCHEMA_VERSION, + revision: loaded.revision, + updated_at_ms: loaded.updated_at_ms, + overrides: loaded.overrides.clone(), + }; + let json = serde_json::to_string_pretty(&envelope)?; + let suffix = OVERRIDES_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("overrides.json"); + let staged = path.with_file_name(format!( + ".{file_name}.staged-{}-{suffix}", + std::process::id() + )); + let write_result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&staged) + .with_context(|| format!("failed to create {}", staged.display()))?; + file.write_all(format!("{json}\n").as_bytes())?; + file.sync_all()?; + fs::rename(&staged, path).with_context(|| { + format!( + "failed to atomically replace {} with {}", + path.display(), + staged.display() + ) + })?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&staged); + } + write_result +} + +struct OverridesFileLock { + path: PathBuf, + _file: File, +} + +impl OverridesFileLock { + fn acquire(overrides_path: &Path) -> Result { + let path = overrides_path.with_extension("lock"); + let deadline = Instant::now() + OVERRIDES_LOCK_WAIT; + loop { + match OpenOptions::new().create_new(true).write(true).open(&path) { + Ok(mut file) => { + writeln!( + file, + "pid={} acquired_at_ms={}", + std::process::id(), + now_ms() + )?; + file.sync_all()?; + return Ok(Self { path, _file: file }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if lock_is_stale(&path) { + let _ = fs::remove_file(&path); + continue; + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for runtime override lock {}", + path.display() + ); + } + thread::sleep(Duration::from_millis(25)); + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to acquire {}", path.display())); + } + } + } + } +} + +impl Drop for OverridesFileLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn lock_is_stale(path: &Path) -> bool { + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= OVERRIDES_STALE_LOCK_AGE) } fn reject_immutable_changes(args: &ReconfigureArgs) -> Result<()> { @@ -480,4 +752,103 @@ mod tests { assert!(!settings.batch.adaptive_sizing); assert_eq!(settings.double_check.mode, DoubleCheckMode::Semantic); } + + #[test] + fn legacy_flat_sidecar_loads_as_revision_zero() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("overrides.json"); + fs::write( + &path, + serde_json::to_string_pretty(&RunConfigOverrides { + concurrency: Some(3), + ..RunConfigOverrides::default() + }) + .unwrap(), + ) + .unwrap(); + + let loaded = load_overrides_from_path(&path) + .unwrap() + .expect("legacy sidecar should load"); + + assert_eq!(loaded.revision, 0); + assert_eq!(loaded.updated_at_ms, 0); + assert_eq!(loaded.overrides.concurrency, Some(3)); + } + + #[test] + fn revisioned_sidecar_merges_and_atomically_replaces_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("overrides.json"); + + let first = write_merged_overrides_at_path( + &path, + RunConfigOverrides { + concurrency: Some(2), + ..RunConfigOverrides::default() + }, + ) + .unwrap(); + let second = write_merged_overrides_at_path( + &path, + RunConfigOverrides { + batch_max_items: Some(4), + ..RunConfigOverrides::default() + }, + ) + .unwrap(); + + assert_eq!(first.revision, 1); + assert_eq!(second.revision, 2); + assert_eq!(second.overrides.concurrency, Some(2)); + assert_eq!(second.overrides.batch_max_items, Some(4)); + assert_eq!(load_overrides_from_path(&path).unwrap().unwrap(), second); + assert!(!path.with_extension("lock").exists()); + let leftovers = fs::read_dir(dir.path()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().contains(".staged-")) + .count(); + assert_eq!(leftovers, 0, "atomic writer must clean staged siblings"); + } + + #[test] + fn sidecar_rejects_zero_and_unknown_settings() { + let zero = RunConfigOverrides { + concurrency: Some(0), + ..RunConfigOverrides::default() + }; + assert!( + zero.validate() + .unwrap_err() + .to_string() + .contains("concurrency") + ); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("overrides.json"); + fs::write(&path, r#"{"concurrency":2,"surprise":true}"#).unwrap(); + let error = load_overrides_from_path(&path).expect_err("unknown field must reject"); + assert!(error.to_string().contains("failed to parse")); + } + + #[test] + fn writer_recovers_an_unreadable_sidecar_with_a_fresh_envelope() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("overrides.json"); + fs::write(&path, r#"{"concurrency":0,"surprise":true}"#).unwrap(); + + let recovered = write_merged_overrides_at_path( + &path, + RunConfigOverrides { + concurrency: Some(3), + ..RunConfigOverrides::default() + }, + ) + .expect("a locked writer should recover a corrupt sidecar"); + + assert_eq!(recovered.revision, 1); + assert_eq!(recovered.overrides.concurrency, Some(3)); + assert_eq!(load_overrides_from_path(&path).unwrap(), Some(recovered)); + } } diff --git a/crates/bookforge-cli/src/commands/resume.rs b/crates/bookforge-cli/src/commands/resume.rs index 05b179c..72172ca 100644 --- a/crates/bookforge-cli/src/commands/resume.rs +++ b/crates/bookforge-cli/src/commands/resume.rs @@ -119,13 +119,6 @@ pub async fn run(args: ResumeArgs) -> Result<()> { } async fn live_fast_resume_paused_job(store: &JobStore, args: &ResumeArgs) -> Result<()> { - if reconfigure::load_overrides_for_job(&args.job_id)?.is_some() { - anyhow::bail!( - "job '{}' has pending reconfigure overrides that a live fast-resume cannot apply. {}", - args.job_id, - reconfigure::apply_instructions(&args.job_id) - ); - } let path = crate::control::request_job_control(&args.job_id, ControlCommand::Resume)?; if human_stdout_enabled(args.ui) { println!("resume requested for {} ({})", args.job_id, path.display()); @@ -243,11 +236,11 @@ async fn run_inner( let qa_mode = args .qa .or_else(|| overrides.as_ref().and_then(|overrides| overrides.qa)) - .unwrap_or(QaMode::Off); + .unwrap_or_else(|| QaMode::from_snapshot(&snapshot.qa_mode)); let validate_output = overrides .as_ref() .and_then(|overrides| overrides.validate_output) - .unwrap_or(false); + .unwrap_or(snapshot.validate_output); if let Some(overrides) = overrides.as_ref() { reconfigure::apply_overrides_to_settings(&mut settings, overrides); } @@ -353,13 +346,17 @@ async fn run_inner( }; let pause_signal = bookforge_llm::PauseSignal::new(); let stop_cancel_token = tokio_util::sync::CancellationToken::new(); - let _control_watcher = crate::control::ControlFileWatcher::spawn_with_stop_cancel( + let control_watcher = crate::control::ControlFileWatcher::spawn_with_stop_cancel( store.path().to_path_buf(), job.id.clone(), progress.clone(), pause_signal.clone(), stop_cancel_token.clone(), + settings.clone(), + qa_mode, + validate_output, ); + let job_runtime_settings = control_watcher.job_runtime_settings(); let run_config = TranslationRunConfig { source_language: snapshot.source_language.clone(), target_language: snapshot.target_language.clone(), @@ -379,6 +376,7 @@ async fn run_inner( style: style_run_config_from_snapshot(snapshot), entities: entities_run_config_from_snapshot(snapshot), pause_signal: Some(pause_signal.clone()), + runtime_settings: Some(control_watcher.runtime_settings()), }; let cache_namespace = if legacy_cache_namespace { @@ -522,14 +520,16 @@ async fn run_inner( print_stopped_resume_hint(&job.id, print_stdout); return Ok(()); } + let qa_runtime = job_runtime_settings.borrow().clone(); + let qa_run_config = crate::control::freeze_run_config_for_stage(&run_config, &qa_runtime); let qa_reviews = qa_after_resume( &job, &segments, &translations, - &run_config, + &qa_run_config, snapshot, - &settings, - qa_mode, + &qa_runtime.settings, + qa_runtime.qa, progress.clone(), stop_cancel_token.clone(), ) @@ -568,16 +568,19 @@ async fn run_inner( print_stopped_resume_hint(&job.id, print_stdout); return Ok(()); } - if settings.double_check.mode != DoubleCheckMode::Off + let double_check_runtime = job_runtime_settings.borrow().clone(); + let double_check_run_config = + crate::control::freeze_run_config_for_stage(&run_config, &double_check_runtime); + if double_check_runtime.settings.double_check.mode != DoubleCheckMode::Off && !snapshot.finalize.double_check_complete { let changed_segment_ids = match double_check_after_resume( &job, &segments, &mut translations, - &run_config, + &double_check_run_config, snapshot, - &settings, + &double_check_runtime.settings, progress.clone(), stop_cancel_token.clone(), ) @@ -593,7 +596,7 @@ async fn run_inner( persist_corrected_translations( &store, &job.id, - &run_config, + &double_check_run_config, &translations, &changed_segment_ids, )?; @@ -613,7 +616,8 @@ async fn run_inner( let block_translations = rebuild_block_translations(&segments, &stored_blocks, &translations); let rebuild_options = super::translate::rebuild_options_from_snapshot(snapshot); rebuild_epub_with_options(&book, &block_translations, &output, &rebuild_options)?; - if validate_output { + let validation_runtime = job_runtime_settings.borrow().clone(); + if validation_runtime.validate_output { let validation_path = validate::default_report_path(&output); let validation = validate::validate_and_write(&output, &validation_path, false)?; if print_stdout { @@ -1275,7 +1279,7 @@ mod tests { } #[tokio::test] - async fn paused_fast_resume_with_overrides_errors_with_apply_guidance() { + async fn paused_fast_resume_with_overrides_signals_the_live_watcher() { let fixture = resume_fixture(TranslationProfile::V1Fast.resolve(), 1); fixture .store @@ -1289,22 +1293,26 @@ mod tests { }, ); let args = resume_args(&fixture.job.id); + let store_path = fixture.store.path().to_path_buf(); + let job_id = fixture.job.id.clone(); + let wake_id = job_id.clone(); + + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(5)).await; + let store = JobStore::open(store_path).expect("store should reopen"); + store + .mark_job_running(&wake_id) + .expect("live watcher should leave paused"); + }); - let error = live_fast_resume_paused_job(&fixture.store, &args) + live_fast_resume_paused_job(&fixture.store, &args) .await - .expect_err("pending overrides must block live fast-resume"); - let message = error.to_string(); + .expect("pending overrides should be applied by the live watcher"); - assert!(message.contains("live fast-resume cannot apply")); - assert!(message.contains(&format!("bookforge stop {}", fixture.job.id))); - assert!(message.contains(&format!("bookforge resume {}", fixture.job.id))); - assert!(message.contains("--force")); assert_eq!( - bookforge_core::read_control_file(&bookforge_core::control_path_for_job( - &fixture.job.id - )) - .expect("control file should read"), - ControlCommand::Run + bookforge_core::read_control_file(&bookforge_core::control_path_for_job(&job_id)) + .expect("control file should read"), + ControlCommand::Resume ); reconfigure::clear_overrides_for_job(&fixture.job.id).expect("sidecar should clear"); } @@ -1829,6 +1837,8 @@ mod tests { bilingual_css: None, fallback: None, finalize: bookforge_core::run_snapshot::FinalizeCheckpointSnapshot::default(), + qa_mode: "off".to_string(), + validate_output: false, settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), }; store diff --git a/crates/bookforge-cli/src/commands/serve.rs b/crates/bookforge-cli/src/commands/serve.rs index ab2073a..a13ba0f 100644 --- a/crates/bookforge-cli/src/commands/serve.rs +++ b/crates/bookforge-cli/src/commands/serve.rs @@ -149,6 +149,8 @@ struct AppState { /// resolved once at startup instead of per-request) while letting tests /// point a router at an isolated temp-dir store without touching CWD. store_path: PathBuf, + #[cfg(test)] + resume_launches: Option>, } /// The default job store path, relative to the current directory — identical @@ -174,6 +176,8 @@ pub async fn run(args: ServeArgs) -> Result<()> { host_port: local.port(), keys: Arc::new(Mutex::new(HashMap::new())), store_path: default_store_path(), + #[cfg(test)] + resume_launches: None, }; let app = dashboard_router(state); @@ -201,6 +205,10 @@ fn dashboard_router(state: AppState) -> Router { .route("/", get(index)) .route("/api/jobs", get(list_jobs)) .route("/api/jobs/{id}", get(job_detail)) + .route( + "/api/jobs/{id}/reconfigure", + get(job_reconfigure).post(update_job_reconfigure), + ) .route("/api/jobs/{id}/events", get(job_events)) .route("/api/jobs/{id}/review", get(job_review)) .route( @@ -345,6 +353,67 @@ async fn job_detail( } } +async fn job_reconfigure( + AxumPath(id): AxumPath, + State(state): State, +) -> Result { + let store_path = state.store_path.clone(); + let outcome = + tokio::task::spawn_blocking(move || runtime_settings_view(&store_path, &id)).await?; + match outcome { + Ok(Some(view)) => Ok(Json(view).into_response()), + Ok(None) => Ok(( + StatusCode::NOT_FOUND, + Json(json!({ "error": "no such job or run snapshot" })), + ) + .into_response()), + Err(error) => Ok(bad_request(&error.to_string())), + } +} + +async fn update_job_reconfigure( + AxumPath(id): AxumPath, + State(state): State, + headers: HeaderMap, + Json(incoming): Json, +) -> Result { + if let Some(response) = reject_mutation(&headers, &state) { + return Ok(response); + } + if incoming.is_empty() { + return Ok(bad_request("select at least one runtime setting")); + } + let store_path = state.store_path.clone(); + let outcome = tokio::task::spawn_blocking(move || -> Result { + let store = JobStore::open(&store_path)?; + let Some(job) = store.get_job(&id)? else { + anyhow::bail!("no such job"); + }; + if !matches!(job.status.as_str(), "running" | "paused" | "stopped") { + anyhow::bail!( + "job '{}' is {}; runtime settings are editable only while running, paused, or stopped", + id, + job.status + ); + } + if store.load_job_config_snapshot(&id)?.is_none() { + anyhow::bail!("job '{}' has no resumable run snapshot", id); + } + let (_path, written) = + super::reconfigure::write_merged_overrides_for_job(&id, incoming)?; + let mut view = runtime_settings_view(&store_path, &id)? + .ok_or_else(|| anyhow::anyhow!("job disappeared after reconfiguration"))?; + view.revision = written.revision; + Ok(view) + }) + .await?; + + match outcome { + Ok(view) => Ok(Json(view).into_response()), + Err(error) => Ok(bad_request(&error.to_string())), + } +} + async fn job_events( AxumPath(id): AxumPath, State(state): State, @@ -573,7 +642,111 @@ async fn resume_job( State(state): State, headers: HeaderMap, ) -> Result { - control_job(id, state, headers, ControlCommand::Resume).await + if let Some(response) = reject_mutation(&headers, &state) { + return Ok(response); + } + let store_path = state.store_path.clone(); + let lookup = id.clone(); + let action = tokio::task::spawn_blocking(move || -> Result> { + let store = JobStore::open(&store_path)?; + let Some(job) = store.get_job(&lookup)? else { + return Ok(None); + }; + let live = matches!( + crate::control::runtime_lease_state(&lookup), + crate::control::RuntimeLeaseState::Fresh(_) + ); + let resumable = !store.resumable_segment_ids(&lookup)?.is_empty() + || (job_status_has_unfinished_pipeline_work(&job.status) + && store.load_job_config_snapshot(&lookup)?.is_some()); + let force = !live && job.status == "paused"; + Ok(Some((live, resumable, force))) + }) + .await??; + let Some((live, resumable, force)) = action else { + return Ok(( + StatusCode::NOT_FOUND, + Json(json!({ "error": "no such job" })), + ) + .into_response()); + }; + if live { + let path = crate::control::request_job_control(&id, ControlCommand::Resume)?; + return Ok(Json(json!({ + "command": "resume", + "mode": "signaled", + "control_path": path, + })) + .into_response()); + } + if !resumable { + return Ok(bad_request( + "the worker is not alive and this job has no resumable work", + )); + } + + let Some(mut launch_claim) = crate::control::RuntimeLaunchClaim::acquire(&id)? else { + return Ok(Json(json!({ + "command": "resume", + "mode": "launching", + })) + .into_response()); + }; + #[cfg(test)] + if let Some(launches) = &state.resume_launches { + launches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + launch_claim.persist_until_worker(); + return Ok(Json(json!({ + "command": "resume", + "mode": "spawned", + "pid": 0, + "forced": force, + })) + .into_response()); + } + let executable = + std::env::current_exe().context("failed to locate the BookForge executable")?; + let mut command = tokio::process::Command::new(executable); + command + .arg("resume") + .arg(&id) + .arg("--ui") + .arg("quiet") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if force { + // A paused job normally expects to signal its original process. A + // missing/stale lease proves that process is unavailable, so the + // replacement must use the CLI's explicit dead-worker escape hatch. + command.arg("--force"); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.as_std_mut().creation_flags(0x0800_0000); + } + let mut child = command.spawn().context("failed to launch resume worker")?; + let pid = child.id(); + if let Some(status) = child_exit_status_after(&mut child, CHILD_STARTUP_CHECK).await? + && !status.success() + { + return Ok(bad_request(&format!( + "resume worker exited immediately with {status}" + ))); + } + launch_claim.persist_until_worker(); + Ok(Json(json!({ + "command": "resume", + "mode": "spawned", + "pid": pid, + "forced": force, + })) + .into_response()) +} + +fn job_status_has_unfinished_pipeline_work(status: &str) -> bool { + matches!(status, "running" | "paused" | "stopped") } async fn stop_job( @@ -599,22 +772,32 @@ async fn control_job( if store.get_job(&id)?.is_none() { return Ok(None); } + if !matches!( + crate::control::runtime_lease_state(&id), + crate::control::RuntimeLeaseState::Fresh(_) + ) { + anyhow::bail!( + "no live worker is available for {}; refresh the job and use Resume to launch one", + command.as_str() + ); + } let path = crate::control::request_job_control(&id, command)?; Ok(Some(path.display().to_string())) }) - .await??; + .await?; match outcome { - Some(path) => Ok(Json(json!({ + Ok(Some(path)) => Ok(Json(json!({ "command": command.as_str(), "control_path": path, })) .into_response()), - None => Ok(( + Ok(None) => Ok(( StatusCode::NOT_FOUND, Json(json!({ "error": "no such job" })), ) .into_response()), + Err(error) => Ok(bad_request(&error.to_string())), } } @@ -1298,6 +1481,183 @@ struct JobDetail { state: RunState, } +#[derive(Debug, Serialize)] +struct RuntimeMutableSettings { + batch_max_output_tokens: Option, + batch_max_items: usize, + batch_target_tokens: usize, + concurrency: usize, + qa: crate::QaMode, + double_check: bookforge_core::DoubleCheckMode, + validate_output: bool, + provider_max_attempts: usize, + adaptive_concurrency: bool, + adaptive_batch_sizing: bool, +} + +#[derive(Debug, Serialize)] +struct RuntimeIdentity { + provider: String, + model: String, + source_language: Option, + target_language: String, + profile: String, + prompt_version: String, +} + +#[derive(Debug, Serialize)] +struct RuntimeLeaseView { + state: &'static str, + pid: Option, + instance_id: Option, + heartbeat_at_ms: Option, + last_loaded_revision: Option, + last_applied_revision: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +struct RuntimeSettingsView { + effective: RuntimeMutableSettings, + overrides: super::reconfigure::RunConfigOverrides, + revision: u64, + applied_revision: u64, + changed_fields: Vec, + next_boundary: Vec, + application_state: &'static str, + live: bool, + editable: bool, + resumable_work: bool, + lease: RuntimeLeaseView, + identity: RuntimeIdentity, +} + +fn runtime_settings_view( + store_path: &std::path::Path, + id: &str, +) -> Result> { + let store = JobStore::open(store_path)?; + let Some(job) = store.get_job(id)? else { + return Ok(None); + }; + let Some(snapshot) = store.load_job_config_snapshot(id)? else { + return Ok(None); + }; + let mut settings = snapshot.settings.to_settings(); + let loaded = super::reconfigure::load_overrides_document_for_job(id)?; + let (revision, overrides) = loaded + .map(|loaded| (loaded.revision, loaded.overrides)) + .unwrap_or_default(); + super::reconfigure::apply_overrides_to_settings(&mut settings, &overrides); + let qa = overrides + .qa + .unwrap_or_else(|| crate::QaMode::from_snapshot(&snapshot.qa_mode)); + let validate_output = overrides + .validate_output + .unwrap_or(snapshot.validate_output); + let changed_fields = overrides.changed_fields(); + let next_boundary = overrides.application_boundaries(); + let (lease, live, applied_revision) = match crate::control::runtime_lease_state(id) { + crate::control::RuntimeLeaseState::Fresh(lease) => ( + RuntimeLeaseView { + state: "fresh", + pid: Some(lease.pid), + instance_id: Some(lease.instance_id.clone()), + heartbeat_at_ms: Some(lease.heartbeat_at_ms), + last_loaded_revision: Some(lease.last_loaded_revision), + last_applied_revision: Some(lease.last_applied_revision), + error: None, + }, + true, + lease.last_applied_revision, + ), + crate::control::RuntimeLeaseState::Stale(lease) => ( + RuntimeLeaseView { + state: "stale", + pid: Some(lease.pid), + instance_id: Some(lease.instance_id.clone()), + heartbeat_at_ms: Some(lease.heartbeat_at_ms), + last_loaded_revision: Some(lease.last_loaded_revision), + last_applied_revision: Some(lease.last_applied_revision), + error: None, + }, + false, + lease.last_applied_revision, + ), + crate::control::RuntimeLeaseState::Missing => ( + RuntimeLeaseView { + state: "missing", + pid: None, + instance_id: None, + heartbeat_at_ms: None, + last_loaded_revision: None, + last_applied_revision: None, + error: None, + }, + false, + 0, + ), + crate::control::RuntimeLeaseState::Invalid(error) => ( + RuntimeLeaseView { + state: "invalid", + pid: None, + instance_id: None, + heartbeat_at_ms: None, + last_loaded_revision: None, + last_applied_revision: None, + error: Some(error), + }, + false, + 0, + ), + }; + // Translation is only one part of the resumable pipeline. A stopped, + // paused, or orphaned-running job may have no pending segments while QA, + // double-check, rebuild, validation, or reporting still remains. + let resumable_work = !store.resumable_segment_ids(id)?.is_empty() + || job_status_has_unfinished_pipeline_work(&job.status); + let editable = job_status_has_unfinished_pipeline_work(&job.status) && resumable_work; + let application_state = if !live { + "resume_required" + } else if revision > applied_revision { + "next_boundary" + } else { + "live" + }; + Ok(Some(RuntimeSettingsView { + effective: RuntimeMutableSettings { + batch_max_output_tokens: settings.provider.batch_max_output_tokens, + batch_max_items: settings.batch.max_items, + batch_target_tokens: settings.batch.target_tokens, + concurrency: settings.scheduler.concurrency, + qa, + double_check: settings.double_check.mode, + validate_output, + provider_max_attempts: settings.provider.provider_max_attempts, + adaptive_concurrency: settings.adaptive_concurrency, + adaptive_batch_sizing: settings.batch.adaptive_sizing, + }, + overrides, + revision, + applied_revision, + changed_fields, + next_boundary, + application_state, + live, + editable, + resumable_work, + lease, + identity: RuntimeIdentity { + provider: snapshot.provider, + model: snapshot.model, + source_language: snapshot.source_language, + target_language: snapshot.target_language, + profile: format!("{:?}", snapshot.profile), + prompt_version: snapshot.prompt_version, + }, + })) +} + #[derive(Serialize)] struct DashboardOptions { languages: &'static [&'static str], @@ -1597,6 +1957,21 @@ main{min-height:calc(100vh - 60px)} .live{display:flex;align-items:center;gap:6px;font:400 12px var(--sans);color:var(--muted)} .dot{width:8px;height:8px;border-radius:50%;background:var(--faint)} .dot.on{background:var(--good);box-shadow:0 0 8px var(--good)} +.runtime-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px} +.runtime-head .title{font:600 17px var(--serif);color:var(--ink)} +.runtime-head .meta{font:400 11.5px/1.5 var(--mono);color:var(--muted);margin-top:3px} +.runtime-state{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.05em;padding:5px 9px;border-radius:12px;color:var(--muted);background:var(--chip)} +.runtime-state.live{color:var(--good);background:var(--goodbg)} +.runtime-state.stale,.runtime-state.invalid{color:var(--warn);background:var(--chip)} +.runtime-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(185px,1fr));gap:11px} +.runtime-field{display:flex;flex-direction:column;gap:6px;font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.04em;color:var(--faint)} +.runtime-field .inp{padding:9px 11px;font:500 12.5px var(--mono);text-transform:none;letter-spacing:0} +.runtime-check{display:flex;align-items:center;gap:8px;min-height:38px;padding:9px 11px;border:1px solid var(--line);border-radius:10px;background:var(--card);font:500 12px var(--sans);text-transform:none;letter-spacing:0;color:var(--muted)} +.runtime-check input{accent-color:var(--accent)} +.runtime-identity{margin-top:15px;padding-top:13px;border-top:1px solid var(--line);font:400 11px/1.6 var(--mono);color:var(--faint);word-break:break-word} +.runtime-foot{display:flex;align-items:center;gap:11px;margin-top:15px;flex-wrap:wrap} +.runtime-feedback{font:400 11.5px var(--sans);color:var(--muted)} +.runtime-feedback.bad{color:var(--danger)} @media (max-width:720px){.wrap{padding:28px 18px 70px}.book-grid{grid-template-columns:1fr}.wiz{display:block;min-height:0}.rail{width:auto;border-right:none;border-bottom:1px solid var(--line);padding:18px 20px}.steps{flex-direction:row;overflow:auto}.step{flex:none}.wizsummary{display:none}.wizpanel{padding:24px 20px 34px}.lang-row{flex-wrap:wrap}.swap{margin-bottom:0}.tiers{flex-direction:column}.prog-top{display:block}.prog-eta{text-align:left;margin-top:8px}.prog-actions{flex-wrap:wrap}} @@ -1637,6 +2012,13 @@ main{min-height:calc(100vh - 60px)} .rev-edit{width:100%;min-height:120px;resize:vertical;border:1px solid var(--line);border-radius:9px;padding:12px;background:var(--card);color:var(--ink);font:400 15px/1.6 var(--serif)} .rev-edit:focus{outline:none;border-color:var(--accent)} .rev-save-row{display:flex;align-items:center;gap:10px;margin-top:14px}.rev-save-status{font:400 11px var(--sans);color:var(--muted)} +.rev-hint{margin-top:14px;padding:14px;border:1px solid var(--accentline);border-radius:11px;background:var(--card)} +.rev-hint[hidden]{display:none} +.rev-hint label{display:block;font:600 11px var(--sans);color:var(--ink);margin-bottom:7px} +.rev-hint textarea{width:100%;min-height:82px;resize:vertical;border:1px solid var(--line);border-radius:9px;padding:10px 11px;background:var(--soft);color:var(--ink);font:400 13px/1.5 var(--sans)} +.rev-hint textarea:focus{outline:none;border-color:var(--accent)} +.rev-hint .note{font:500 11.5px/1.5 var(--sans);color:var(--warn);margin:8px 0 10px} +.rev-hint .actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .rev-note{margin-top:14px;padding:11px 13px;border-radius:9px;background:var(--card);border:1px solid var(--accentline);font:400 12.5px/1.5 var(--sans);color:var(--muted)} .rev-note b{color:var(--warn)} .rev-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);width:100%} @@ -1746,6 +2128,9 @@ const App = { options: { languages: ["English","Italian","Spanish","French","German"], providers: [] }, providerKeys: {}, wizard: null, + runtimeSettings: null, + runtimeJob: null, + runtimeRefreshPending: false, }; function freshWizard() { @@ -2046,9 +2431,19 @@ async function renderProgress(stage) { const id = App.selected; if (!id) { stage.innerHTML = `
Open a translation from the library to watch its progress.
`; return; } stage.innerHTML = `
Loading job…
`; - let d; - try { const r = await fetch("/api/jobs/" + encodeURIComponent(id)); if (!r.ok) throw new Error(); d = await r.json(); } + let d, runtime = null; + try { + const [jobResponse, runtimeResponse] = await Promise.all([ + fetch("/api/jobs/" + encodeURIComponent(id)), + fetch("/api/jobs/" + encodeURIComponent(id) + "/reconfigure"), + ]); + if (!jobResponse.ok) throw new Error(); + d = await jobResponse.json(); + if (runtimeResponse.ok) runtime = await runtimeResponse.json(); + } catch (e) { stage.innerHTML = `
Could not load this job.
`; return; } + App.runtimeSettings = runtime; + App.runtimeJob = id; const title = titleFromPath(d.input_path); stage.innerHTML = `
${esc(title.charAt(0))}
@@ -2067,14 +2462,91 @@ async function renderProgress(stage) {
+

Live activity

waiting…

Issues

none
`; + drawRuntimeSettings(runtime); updateState(d.state || {}); openStream(id); } +function runtimeOption(value, current, label) { return ``; } +function drawRuntimeSettings(view) { + const panel = $("#runtime-panel"); if (!panel) return; + if (!view) { panel.innerHTML = `
Runtime settings
No resumable run snapshot is available.
`; return; } + const e = view.effective || {}, ident = view.identity || {}, lease = view.lease || {}; + const disabled = view.editable ? "" : "disabled"; + const boundaries = (view.next_boundary || []).map(v => v.replace(/_/g," ")).join(", ") || "none pending"; + const leaseNote = lease.state === "fresh" ? `worker ${lease.pid || "?"} - applied r${view.applied_revision || 0}` : `${lease.state || "missing"} worker - Resume required`; + panel.innerHTML = `
+
Runtime settings
revision r${view.revision || 0} - ${esc(leaseNote)} - boundary: ${esc(boundaries)}
${esc(view.application_state || "resume_required")}
+
+ + + + + + + + + + +
+
Immutable identity: ${esc(ident.provider || "-")} / ${esc(ident.model || "-")} - ${esc(ident.source_language || "auto")} to ${esc(ident.target_language || "-")} - ${esc(ident.profile || "-")} - prompt ${esc(ident.prompt_version || "-")}
+
${view.editable ? "Changes apply at the named request, batch, or stage boundary." : (view.resumable_work ? "This job is not in an editable state." : "No resumable work remains.")}
+
`; +} +function runtimePositiveInt(id, label, optional) { + const el = $(id), raw = el ? el.value.trim() : ""; + if (optional && raw === "") return null; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) throw new Error(`${label} must be a positive integer`); + return value; +} +async function bfSaveRuntimeSettings() { + const view = App.runtimeSettings, feedback = $("#runtime-feedback"), button = $("#runtime-save"); + if (!view || !view.editable) return; + let values; + try { + values = { + batch_max_output_tokens: runtimePositiveInt("#rt-output","batch output tokens",true), + batch_max_items: runtimePositiveInt("#rt-items","batch max items",false), + batch_target_tokens: runtimePositiveInt("#rt-target","batch target tokens",false), + concurrency: runtimePositiveInt("#rt-concurrency","concurrency",false), + provider_max_attempts: runtimePositiveInt("#rt-attempts","provider attempts",false), + qa: $("#rt-qa").value, + double_check: $("#rt-double").value, + validate_output: $("#rt-validate").checked, + adaptive_concurrency: $("#rt-adaptive-concurrency").checked, + adaptive_batch_sizing: $("#rt-adaptive-batch").checked, + }; + } catch (e) { if (feedback) { feedback.textContent=e.message; feedback.classList.add("bad"); } return; } + const payload = {}, previous = view.effective || {}; + Object.keys(values).forEach(key => { if (values[key] !== previous[key] && !(key === "batch_max_output_tokens" && values[key] === null)) payload[key] = values[key]; }); + if (!Object.keys(payload).length) { if (feedback) { feedback.textContent="No runtime settings changed."; feedback.classList.remove("bad"); } return; } + if (button) button.disabled = true; + if (feedback) { feedback.textContent="Saving..."; feedback.classList.remove("bad"); } + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/reconfigure`, {method:"POST",headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN},body:JSON.stringify(payload)}); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "runtime update failed"); + App.runtimeSettings = body; drawRuntimeSettings(body); + const next = (body.next_boundary || []).map(v => v.replace(/_/g," ")).join(", "); + const out = $("#runtime-feedback"); if (out) out.textContent = `Saved revision r${body.revision} - ${body.live ? (next || "live") : "Resume required"}`; + } catch (e) { if (feedback) { feedback.textContent=e.message || "runtime update failed"; feedback.classList.add("bad"); } } + finally { const current=$("#runtime-save"); if (current) current.disabled=false; } +} +async function refreshRuntimeSettings() { + const id = App.selected; + if (!id || App.runtimeRefreshPending || App.screen !== "progress") return; + App.runtimeRefreshPending = true; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(id)}/reconfigure`); + if (r.ok && App.screen === "progress" && App.selected === id) { App.runtimeSettings = await r.json(); App.runtimeJob=id; drawRuntimeSettings(App.runtimeSettings); } + } catch (_) {} + finally { App.runtimeRefreshPending=false; } +} function setLive(on, txt) { const dt = $("#livedot"), tx = $("#livetxt"); if (dt) dt.classList.toggle("on", on); if (tx) tx.textContent = txt; } function updateState(s) { const total = s.total_segments || 0, done = s.done_segments || 0, p = pct(done, total); @@ -2101,13 +2573,19 @@ function updateState(s) { if (ebox) { const evs = s.recent_events || []; ebox.innerHTML = evs.length ? evs.slice().reverse().map(fmtEvent).join("") : `
waiting…
`; } + const runtimeRevision = Number(s.runtime_config_revision || 0); + if (runtimeRevision && (!App.runtimeSettings || runtimeRevision > Number(App.runtimeSettings.applied_revision || 0))) refreshRuntimeSettings(); + if (s.runtime_config_rejection) { + const feedback = $("#runtime-feedback"); + if (feedback) { feedback.textContent = `Rejected: ${s.runtime_config_rejection}`; feedback.classList.add("bad"); } + } } function fmtEvent(ev) { const key = Object.keys(ev)[0]; const v = ev[key] || {}; let cls = "", body = key; switch (key) { case "SegmentFinished": body = `segment ${shorten(v.segment_id,18)} → ${v.status}`; if (v.status==="failed") cls="bad"; else if (v.status==="needs_review") cls="warn"; break; case "SegmentStarted": body = `segment ${shorten(v.segment_id,18)} started`; break; - case "RequestStarted": body = `request started (${v.active_requests}/${v.target_concurrency})`; break; + case "RequestStarted": { const audit = [v.runtime_config_revision == null ? null : `r${v.runtime_config_revision}`, v.provider_max_attempts == null ? null : `${v.provider_max_attempts} attempts`].filter(Boolean).join(" - "); body = `request started (${v.active_requests}/${v.target_concurrency})${audit ? " - " + audit : ""}`; break; } case "RequestFinished": body = `request ${v.status} · ${v.latency_ms}ms`; if (v.status!=="ok"&&v.status!=="succeeded") cls="warn"; break; case "StageStarted": body = `stage: ${v.stage}`; break; case "StageFinished": body = `stage complete: ${v.stage}`; break; @@ -2117,6 +2595,8 @@ function fmtEvent(ev) { case "JobResumed": body = `resumed`; cls="good"; break; case "CheckpointFlushed": body = `checkpoint flushed (${v.flushed_count})`; break; case "ConcurrencyChanged": body = `concurrency ${v.previous} → ${v.current} (${v.reason})`; break; + case "RuntimeConfigChanged": body = `runtime r${v.revision}: ${(v.changed_fields || []).join(", ") || "updated"} -> ${(v.application || []).join(", ") || "next boundary"}`; cls="good"; break; + case "RuntimeConfigRejected": body = `runtime config rejected${v.revision == null ? "" : " r" + v.revision}: ${shorten(v.message || "invalid settings",90)}`; cls="bad"; break; case "Warning": body = `⚠ ${v.kind}: ${shorten(v.message,90)}`; cls="warn"; break; case "Error": body = `✗ ${v.kind}: ${shorten(v.message,90)}`; cls="bad"; break; case "TranslationFinished": body = `finished: ${v.succeeded} ok, ${v.cached} cached, ${v.needs_review} review, ${v.failed} failed`; cls="good"; break; @@ -2160,7 +2640,12 @@ async function bfJobControl(id, command) { if (command === "pause") setProgressStatus("paused"); if (command === "resume") setProgressStatus("running"); if (command === "stop") setProgressStatus("stopped"); - if (toast) toast.textContent = `${command} requested`; + let message = `${command} requested`; + if (command === "resume" && j.mode === "signaled") message = "Resume signaled to the live worker."; + if (command === "resume" && j.mode === "spawned") message = `Resume worker started${j.pid ? " (PID " + j.pid + ")" : ""}.`; + if (command === "resume" && j.mode === "launching") message = "A resume worker is already starting."; + if (toast) toast.textContent = message; + setTimeout(refreshRuntimeSettings, 350); } else { if (toast) toast.textContent = j.error || `${command} failed`; } @@ -2193,11 +2678,11 @@ async function renderReview(stage) { doc = await r.json(); if (!r.ok) { stage.innerHTML = `
${esc(doc.error || "Review is not available for this job.")}
`; return; } } catch (e) { stage.innerHTML = `
Could not load review.
`; return; } - App.review = { doc, idx: 0, filter: "all" }; + App.review = { doc, idx: 0, filter: "all", hintOpen: false, hintText: "", notice: "" }; drawReview(); } -function bfReviewPick(i) { App.review.idx = i; drawReview(); } -function bfReviewNav(d) { const n = (App.review.doc.segments || []).length; App.review.idx = Math.max(0, Math.min(n - 1, App.review.idx + d)); drawReview(); } +function bfReviewPick(i) { App.review.idx = i; App.review.hintOpen=false; App.review.hintText=""; App.review.notice=""; drawReview(); } +function bfReviewNav(d) { const n = (App.review.doc.segments || []).length; App.review.idx = Math.max(0, Math.min(n - 1, App.review.idx + d)); App.review.hintOpen=false; App.review.hintText=""; App.review.notice=""; drawReview(); } function bfReviewFilter(f) { App.review.filter = f; drawReview(); } async function bfReviewFlag() { const R = App.review, seg = R.doc.segments[R.idx]; if (!seg) return; @@ -2228,17 +2713,37 @@ async function bfReviewSave() { } catch (e) { if (status) status.textContent = e.message || "correction failed"; } finally { if (button) button.disabled = false; } } -async function bfReviewRetry() { +function bfReviewRetry() { + const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg || seg.human_corrected) return; + R.hintOpen = true; + const panel = $("#rev-hint-panel"), input = $("#rev-hint-text"); + if (panel) panel.hidden = false; + if (input) { input.value = R.hintText || ""; input.focus(); } +} +function bfReviewRetryCancel() { + const R = App.review; if (!R) return; + R.hintOpen = false; R.hintText = ""; + const panel = $("#rev-hint-panel"); if (panel) panel.hidden = true; +} +async function bfReviewStopForRetry() { + const status = $("#rev-save-status"); if (status) status.textContent = "requesting stop…"; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/stop`, {method:"POST",headers:{[CSRF_HEADER]:CSRF_TOKEN}}); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "stop failed"); + if (status) status.textContent = "Stop requested. Wait for the worker to stop, then queue the retry."; + } catch (e) { if (status) status.textContent = e.message || "stop failed"; } +} +async function bfReviewRetrySubmit() { const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg) return; - const guidance = window.prompt("Optional guidance for the next translation attempt:", "") ?? null; - if (guidance === null) return; - const status = $("#rev-save-status"); if (status) status.textContent = "marking segment for retry…"; + const input = $("#rev-hint-text"), guidance = input ? input.value.trim() : ""; + R.hintText = guidance; + const status = $("#rev-save-status"); if (status) status.textContent = "queuing segment retry…"; try { const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/retry`, { method:"POST", headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN}, body:JSON.stringify({guidance}) }); const body = await r.json(); if (!r.ok) throw new Error(body.error || "retry request failed"); - seg.status = "retry_pending"; if (status) status.textContent = `retry queued · resume ${App.selected}`; drawReview(); + seg.status = "retry_pending"; R.hintOpen=false; R.hintText=""; R.notice=`Retry queued. Resume ${App.selected}.`; drawReview(); } catch (e) { if (status) status.textContent = e.message || "retry request failed"; } } function drawReview() { @@ -2270,12 +2775,13 @@ function drawReview() { const notes = (cur.soft_warnings || []).map(w => `
⚑ ${esc((w.kind || "note").replace(/_/g, " "))} — ${esc(w.message || "")}
`).join(""); const blockEditors = (cur.blocks || []).map(block => `
${esc(block.block_id)}
`).join(""); + const hintPanel = `
Stop the job before queuing a retry. Running and paused jobs reject retry changes.
`; main = `
${esc(ref)} · ${esc(cur.status)}${cur.human_corrected ? " · manual" : ""}
Source · ${esc(doc.source_language || "auto")}
${esc(cur.source_text)}
-
Translation · ${esc(doc.target_language)}
${blockEditors}
${cur.human_corrected ? "human correction saved" : ""}
${notes}
+
Translation · ${esc(doc.target_language)}
${blockEditors}
${R.notice || (cur.human_corrected ? "human correction saved" : "")}
${hintPanel}${notes}
`; } $("#stage").innerHTML = `
@@ -2443,6 +2949,7 @@ mod tests { host_port: 8765, keys: Arc::new(Mutex::new(HashMap::new())), store_path: default_store_path(), + resume_launches: None, } } @@ -2653,23 +3160,27 @@ mod tests { } #[tokio::test] - async fn control_endpoint_rejects_missing_dashboard_token() { + async fn control_endpoints_reject_missing_dashboard_token() { use axum::{body::Body, http::Request}; use tower::ServiceExt; - let response = dashboard_router(test_state("token-123")) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/jobs/not-real/pause") - .header("host", TEST_HOST) - .body(Body::empty()) - .expect("request should build"), - ) - .await - .expect("route should respond"); + let router = dashboard_router(test_state("token-123")); + for command in ["pause", "resume", "stop"] { + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/jobs/not-real/{command}")) + .header("host", TEST_HOST) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("route should respond"); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{command}"); + } } #[tokio::test] @@ -2800,6 +3311,24 @@ mod tests { } } + #[test] + fn dashboard_ships_runtime_editor_and_inline_retry_guidance() { + for marker in [ + "function drawRuntimeSettings", + "function bfSaveRuntimeSettings", + "RuntimeConfigChanged", + "function bfReviewRetrySubmit", + "Stop the job before queuing a retry", + "function bfReviewStopForRetry", + ] { + assert!(DASHBOARD_HTML.contains(marker), "missing {marker}"); + } + assert!( + !DASHBOARD_HTML.contains("window.prompt"), + "retry guidance must use the inline editor" + ); + } + #[test] fn dashboard_posts_include_csrf_header() { assert!(DASHBOARD_HTML.contains(CSRF_TOKEN_PLACEHOLDER)); @@ -3025,6 +3554,8 @@ mod tests { bilingual_css: None, fallback: None, finalize: bookforge_core::run_snapshot::FinalizeCheckpointSnapshot::default(), + qa_mode: "off".to_string(), + validate_output: false, settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), }; store @@ -3072,6 +3603,384 @@ mod tests { .expect("route should respond") } + async fn get_route(router: &Router, uri: &str) -> Response { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + router + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header("host", TEST_HOST) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("route should respond") + } + + async fn response_json(response: Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should read"); + serde_json::from_slice(&bytes).expect("response should be JSON") + } + + fn make_stopped_fixture_resumable(fixture: &MutationFixture) { + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + store + .request_segment_retry(&fixture.job_id, &fixture.segment_b, None) + .expect("completed segment should become retry-pending"); + store + .mark_job_stopped(&fixture.job_id) + .expect("job should become stopped"); + } + + fn clean_runtime_files(job_id: &str) { + let _ = std::fs::remove_dir_all(bookforge_core::run_dir_for_job(job_id)); + } + + #[tokio::test] + async fn dashboard_reconfigure_is_typed_revisioned_and_csrf_protected() { + let fixture = build_mutation_fixture(); + make_stopped_fixture_resumable(&fixture); + clean_runtime_files(&fixture.job_id); + let router = dashboard_router(test_state_with_store( + &fixture.csrf, + fixture.store_path.clone(), + )); + let uri = format!("/api/jobs/{}/reconfigure", fixture.job_id); + + let initial = get_route(&router, &uri).await; + assert_eq!(initial.status(), StatusCode::OK); + let initial = response_json(initial).await; + assert_eq!(initial["revision"], 0); + assert_eq!(initial["applied_revision"], 0); + assert_eq!(initial["application_state"], "resume_required"); + assert_eq!(initial["lease"]["state"], "missing"); + assert_eq!(initial["identity"]["provider"], "mock"); + assert_eq!(initial["identity"]["model"], "mock-identity"); + assert_eq!(initial["editable"], true); + + let body = json!({ "concurrency": 2 }); + let missing = post_json(&router, &uri, None, body.clone()).await; + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + let wrong = post_json(&router, &uri, Some("wrong-token"), body).await; + assert_eq!(wrong.status(), StatusCode::FORBIDDEN); + assert!( + !crate::commands::reconfigure::overrides_path_for_job(&fixture.job_id).exists(), + "rejected mutations must not create a sidecar" + ); + + let unknown = post_json( + &router, + &uri, + Some(&fixture.csrf), + json!({ "model": "immutable-model" }), + ) + .await; + assert!(unknown.status().is_client_error()); + assert!( + !crate::commands::reconfigure::overrides_path_for_job(&fixture.job_id).exists(), + "unknown or immutable fields must not create a sidecar" + ); + + let first = post_json( + &router, + &uri, + Some(&fixture.csrf), + json!({ + "batch_max_output_tokens": 12000, + "batch_max_items": 2, + "batch_target_tokens": 3000, + "concurrency": 2, + "qa": "all", + "double_check": "Formatting", + "validate_output": true, + "provider_max_attempts": 5, + "adaptive_concurrency": false, + "adaptive_batch_sizing": false + }), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + let first = response_json(first).await; + assert_eq!(first["revision"], 1); + assert_eq!(first["effective"]["concurrency"], 2); + assert_eq!(first["effective"]["qa"], "all"); + assert_eq!(first["effective"]["double_check"], "Formatting"); + assert_eq!(first["effective"]["validate_output"], true); + assert_eq!(first["application_state"], "resume_required"); + let fields = first["changed_fields"] + .as_array() + .expect("changed fields should be an array"); + assert!(fields.iter().any(|field| field == "concurrency")); + assert!(fields.iter().any(|field| field == "qa")); + let boundaries = first["next_boundary"] + .as_array() + .expect("boundaries should be an array"); + for boundary in ["next_request", "next_batch", "next_stage"] { + assert!(boundaries.iter().any(|value| value == boundary)); + } + + let second = post_json( + &router, + &uri, + Some(&fixture.csrf), + json!({ "concurrency": 3 }), + ) + .await; + assert_eq!(second.status(), StatusCode::OK); + let second = response_json(second).await; + assert_eq!(second["revision"], 2); + assert_eq!(second["effective"]["concurrency"], 3); + assert_eq!(second["effective"]["batch_max_items"], 2); + + let replayed = response_json(get_route(&router, &uri).await).await; + assert_eq!(replayed["revision"], 2); + assert_eq!(replayed["effective"]["concurrency"], 3); + + clean_runtime_files(&fixture.job_id); + } + + #[tokio::test] + async fn dashboard_controls_require_a_fresh_lease_and_signal_one_when_present() { + let fixture = build_mutation_fixture(); + make_stopped_fixture_resumable(&fixture); + clean_runtime_files(&fixture.job_id); + let router = dashboard_router(test_state_with_store( + &fixture.csrf, + fixture.store_path.clone(), + )); + + for command in ["pause", "stop"] { + let response = post_json( + &router, + &format!("/api/jobs/{}/{}", fixture.job_id, command), + Some(&fixture.csrf), + json!({}), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload = response_json(response).await; + assert!( + payload["error"] + .as_str() + .unwrap_or_default() + .contains("no live worker") + ); + } + + let lease = crate::control::RuntimeLease { + schema_version: 1, + instance_id: "dashboard-test-worker".to_string(), + pid: std::process::id(), + process_started_at_ms: bookforge_core::now_ms(), + heartbeat_at_ms: bookforge_core::now_ms(), + last_loaded_revision: 4, + last_applied_revision: 4, + }; + let runtime_path = crate::control::runtime_path_for_job(&fixture.job_id); + std::fs::create_dir_all(runtime_path.parent().expect("runtime parent")) + .expect("runtime directory should exist"); + std::fs::write( + &runtime_path, + serde_json::to_vec_pretty(&lease).expect("lease should serialize"), + ) + .expect("lease should write"); + + let view = response_json( + get_route( + &router, + &format!("/api/jobs/{}/reconfigure", fixture.job_id), + ) + .await, + ) + .await; + assert_eq!(view["lease"]["state"], "fresh"); + assert_eq!(view["live"], true); + assert_eq!(view["applied_revision"], 4); + + let resume = post_json( + &router, + &format!("/api/jobs/{}/resume", fixture.job_id), + Some(&fixture.csrf), + json!({}), + ) + .await; + assert_eq!(resume.status(), StatusCode::OK); + let resume = response_json(resume).await; + assert_eq!(resume["mode"], "signaled"); + assert_eq!( + bookforge_core::read_control_file(&bookforge_core::control_path_for_job( + &fixture.job_id + )) + .expect("control file should read"), + bookforge_core::ControlCommand::Resume + ); + + clean_runtime_files(&fixture.job_id); + } + + #[tokio::test] + async fn dashboard_missing_worker_resume_launches_exactly_once() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fixture = build_mutation_fixture(); + make_stopped_fixture_resumable(&fixture); + clean_runtime_files(&fixture.job_id); + let launches = Arc::new(AtomicUsize::new(0)); + let mut state = test_state_with_store(&fixture.csrf, fixture.store_path.clone()); + state.resume_launches = Some(launches.clone()); + let router = dashboard_router(state); + let uri = format!("/api/jobs/{}/resume", fixture.job_id); + + let (first, second) = tokio::join!( + post_json(&router, &uri, Some(&fixture.csrf), json!({})), + post_json(&router, &uri, Some(&fixture.csrf), json!({})) + ); + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + let first = response_json(first).await; + let second = response_json(second).await; + let modes = [ + first["mode"].as_str().unwrap_or_default(), + second["mode"].as_str().unwrap_or_default(), + ]; + assert!(modes.contains(&"spawned")); + assert!(modes.contains(&"launching")); + assert_eq!( + launches.load(Ordering::SeqCst), + 1, + "the atomic launch claim must deduplicate concurrent Resume clicks" + ); + + clean_runtime_files(&fixture.job_id); + } + + #[tokio::test] + async fn dashboard_resume_recognizes_finalize_only_work() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fixture = build_mutation_fixture(); + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + assert!( + store + .resumable_segment_ids(&fixture.job_id) + .expect("segment lookup should succeed") + .is_empty(), + "the fixture should have no translation work left" + ); + store + .mark_job_stopped(&fixture.job_id) + .expect("job should become stopped during finalization"); + clean_runtime_files(&fixture.job_id); + + let launches = Arc::new(AtomicUsize::new(0)); + let mut state = test_state_with_store(&fixture.csrf, fixture.store_path.clone()); + state.resume_launches = Some(launches.clone()); + let router = dashboard_router(state); + + let view = response_json( + get_route( + &router, + &format!("/api/jobs/{}/reconfigure", fixture.job_id), + ) + .await, + ) + .await; + assert_eq!(view["resumable_work"], true); + assert_eq!(view["editable"], true); + + let response = post_json( + &router, + &format!("/api/jobs/{}/resume", fixture.job_id), + Some(&fixture.csrf), + json!({}), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = response_json(response).await; + assert_eq!(response["mode"], "spawned"); + assert_eq!(response["forced"], false); + assert_eq!(launches.load(Ordering::SeqCst), 1); + + clean_runtime_files(&fixture.job_id); + } + + #[tokio::test] + async fn dashboard_resume_forces_relaunch_of_a_dead_paused_worker() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fixture = build_mutation_fixture(); + let store = JobStore::open(&fixture.store_path).expect("store should reopen"); + store + .mark_job_paused(&fixture.job_id) + .expect("job should become paused"); + clean_runtime_files(&fixture.job_id); + + let launches = Arc::new(AtomicUsize::new(0)); + let mut state = test_state_with_store(&fixture.csrf, fixture.store_path.clone()); + state.resume_launches = Some(launches.clone()); + let router = dashboard_router(state); + let response = post_json( + &router, + &format!("/api/jobs/{}/resume", fixture.job_id), + Some(&fixture.csrf), + json!({}), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = response_json(response).await; + assert_eq!(response["mode"], "spawned"); + assert_eq!(response["forced"], true); + assert_eq!(launches.load(Ordering::SeqCst), 1); + + clean_runtime_files(&fixture.job_id); + } + + #[tokio::test] + async fn dashboard_resume_rejects_a_completed_job_without_work() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fixture = build_mutation_fixture(); + clean_runtime_files(&fixture.job_id); + let launches = Arc::new(AtomicUsize::new(0)); + let mut state = test_state_with_store(&fixture.csrf, fixture.store_path.clone()); + state.resume_launches = Some(launches.clone()); + let router = dashboard_router(state); + + let view = response_json( + get_route( + &router, + &format!("/api/jobs/{}/reconfigure", fixture.job_id), + ) + .await, + ) + .await; + assert_eq!(view["resumable_work"], false); + assert_eq!(view["editable"], false); + + let response = post_json( + &router, + &format!("/api/jobs/{}/resume", fixture.job_id), + Some(&fixture.csrf), + json!({}), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!( + response_json(response).await["error"] + .as_str() + .unwrap_or_default() + .contains("no resumable work") + ); + assert_eq!(launches.load(Ordering::SeqCst), 0); + + clean_runtime_files(&fixture.job_id); + } + #[tokio::test] async fn save_manual_translation_rejects_missing_or_wrong_csrf_without_mutating_store() { let fixture = build_mutation_fixture(); diff --git a/crates/bookforge-cli/src/commands/status.rs b/crates/bookforge-cli/src/commands/status.rs index cb5ec55..d6624c4 100644 --- a/crates/bookforge-cli/src/commands/status.rs +++ b/crates/bookforge-cli/src/commands/status.rs @@ -232,6 +232,8 @@ mod tests { bilingual_css: None, fallback: None, finalize: bookforge_core::FinalizeCheckpointSnapshot::default(), + qa_mode: "off".to_string(), + validate_output: false, settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), } } diff --git a/crates/bookforge-cli/src/commands/tail.rs b/crates/bookforge-cli/src/commands/tail.rs index efeb9e7..ec18f5a 100644 --- a/crates/bookforge-cli/src/commands/tail.rs +++ b/crates/bookforge-cli/src/commands/tail.rs @@ -240,6 +240,8 @@ mod tests { bilingual_css: None, fallback: None, finalize: bookforge_core::FinalizeCheckpointSnapshot::default(), + qa_mode: "off".to_string(), + validate_output: false, settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), } } diff --git a/crates/bookforge-cli/src/commands/translate/engine.rs b/crates/bookforge-cli/src/commands/translate/engine.rs index 2a4faed..f2b6f58 100644 --- a/crates/bookforge-cli/src/commands/translate/engine.rs +++ b/crates/bookforge-cli/src/commands/translate/engine.rs @@ -8,8 +8,8 @@ use bookforge_store::JobStore; use crate::checkpoint::CheckpointWriter; use super::{ - CheckpointContext, checkpointing::finalize_writer, translate_and_checkpoint, - translate_and_checkpoint_batch, + CheckpointContext, ProgressRequestProvider, checkpointing::finalize_writer, + translate_and_checkpoint, translate_and_checkpoint_batch, }; pub(crate) struct CheckpointRunContext<'a> { @@ -46,6 +46,7 @@ pub(crate) fn batch_run_config( style: run_config.style.clone(), entities: run_config.entities.clone(), pause_signal: run_config.pause_signal.clone(), + runtime_settings: run_config.runtime_settings.clone(), } } @@ -97,7 +98,7 @@ where .await } else { translate_and_checkpoint( - provider, + ProgressRequestProvider::new(provider, progress), pending_segments, &controlled_config, checkpoint_context, diff --git a/crates/bookforge-cli/src/commands/translate/mod.rs b/crates/bookforge-cli/src/commands/translate/mod.rs index ce702f3..323fd26 100644 --- a/crates/bookforge-cli/src/commands/translate/mod.rs +++ b/crates/bookforge-cli/src/commands/translate/mod.rs @@ -42,7 +42,7 @@ use crate::LanguageArgs; use crate::{ ProviderArgs as CliProviderArgs, QaMode, checkpoint::{CheckpointCommand, CheckpointSender, CheckpointWriter}, - commands::glossary::read_glossary_file, + commands::{glossary::read_glossary_file, reconfigure}, default_output_path, }; @@ -600,13 +600,17 @@ async fn run_mock_translation( )?; let pause_signal = bookforge_llm::PauseSignal::new(); let stop_cancel_token = tokio_util::sync::CancellationToken::new(); - let _control_watcher = crate::control::ControlFileWatcher::spawn_with_stop_cancel( + let control_watcher = crate::control::ControlFileWatcher::spawn_with_stop_cancel( store.path().to_path_buf(), job.id.clone(), progress.clone(), pause_signal.clone(), stop_cancel_token.clone(), + settings.clone(), + cli_args.qa, + cli_args.validate_output, ); + let job_runtime_settings = control_watcher.job_runtime_settings(); let run_config = TranslationRunConfig { source_language: config.source_language.clone(), target_language: config.target_language.clone(), @@ -626,6 +630,7 @@ async fn run_mock_translation( style: style.run_config.clone(), entities: entities.run_config.clone(), pause_signal: Some(pause_signal.clone()), + runtime_settings: Some(control_watcher.runtime_settings()), }; // mock let provider = MockProvider::new(mock_mode(&model), &config.target_language); let mut translations = apply_cached_translations( @@ -680,13 +685,15 @@ async fn run_mock_translation( print_stopped_resume_hint(&job.id, human_stdout_enabled(cli_args.ui)); return Ok(()); } + let qa_runtime = job_runtime_settings.borrow().clone(); + let qa_run_config = crate::control::freeze_run_config_for_stage(&run_config, &qa_runtime); let qa_reviews = qa_reviews_for_mode( ProgressRequestProvider::new(provider.clone(), progress.clone()), &segments, &translations, - &run_config, - &settings.qa, - cli_args.qa, + &qa_run_config, + &qa_runtime.settings.qa, + qa_runtime.qa, ) .await; if !wait_for_finalize_stage_control(&mut control_poller, &pause_signal).await? { @@ -717,7 +724,10 @@ async fn run_mock_translation( print_stopped_resume_hint(&job.id, human_stdout_enabled(cli_args.ui)); return Ok(()); } - if settings.double_check.mode != DoubleCheckMode::Off + let double_check_runtime = job_runtime_settings.borrow().clone(); + let double_check_run_config = + crate::control::freeze_run_config_for_stage(&run_config, &double_check_runtime); + if double_check_runtime.settings.double_check.mode != DoubleCheckMode::Off && !snapshot.finalize.double_check_complete { println!("Double-check: auditing translations..."); @@ -725,8 +735,8 @@ async fn run_mock_translation( ProgressRequestProvider::new(provider.clone(), progress.clone()), &segments, &translations, - &run_config, - &settings.double_check, + &double_check_run_config, + &double_check_runtime.settings.double_check, ) .await { @@ -746,7 +756,7 @@ async fn run_mock_translation( persist_corrected_translations( &store, &job.id, - &run_config, + &double_check_run_config, &translations, &changed_segment_ids, )?; @@ -770,6 +780,7 @@ async fn run_mock_translation( return Ok(()); } } + let validation_runtime = job_runtime_settings.borrow().clone(); print_summary_rebuild_and_report( &store, &job, @@ -779,13 +790,14 @@ async fn run_mock_translation( &qa_reviews, config, &rebuild_options, - cli_args.validate_output, + validation_runtime.validate_output, cli_args.strict_epubcheck, human_stdout_enabled(cli_args.ui), )?; let summary = store .summary(&job.id)? .ok_or_else(|| anyhow::anyhow!("job '{}' summary unavailable", job.id))?; + reconfigure::clear_overrides_for_job(&job.id)?; progress.emit(bookforge_core::ProgressEvent::ArtifactWritten { path: config.output.display().to_string(), timestamp_ms: bookforge_core::progress::now_ms(), @@ -995,13 +1007,17 @@ async fn run_openai_compatible_translation( &cache_namespace, )?; let pause_signal = bookforge_llm::PauseSignal::new(); - let _control_watcher = crate::control::ControlFileWatcher::spawn_with_stop_cancel( + let control_watcher = crate::control::ControlFileWatcher::spawn_with_stop_cancel( store.path().to_path_buf(), job.id.clone(), progress.clone(), pause_signal.clone(), cancel_token.clone(), + settings.clone(), + cli_args.qa, + cli_args.validate_output, ); + let job_runtime_settings = control_watcher.job_runtime_settings(); let run_config = TranslationRunConfig { source_language: config.source_language.clone(), target_language: config.target_language.clone(), @@ -1021,6 +1037,7 @@ async fn run_openai_compatible_translation( style: style.run_config.clone(), entities: entities.run_config.clone(), pause_signal: Some(pause_signal.clone()), + runtime_settings: Some(control_watcher.runtime_settings()), }; let mut translations = apply_cached_translations( &segments, @@ -1083,6 +1100,7 @@ async fn run_openai_compatible_translation( progress.clone(), started, &mut snapshot, + &job_runtime_settings, ) .await?; @@ -1120,6 +1138,7 @@ async fn finish_translation_pipeline( progress: Arc, started: std::time::Instant, snapshot: &mut RunConfigSnapshot, + job_runtime_settings: &tokio::sync::watch::Receiver, ) -> Result<()> { translations.sort_by_key(|t| t.ordinal); @@ -1137,13 +1156,16 @@ async fn finish_translation_pipeline( print_stopped_resume_hint(&job.id, human_stdout_enabled(cli_args.ui)); return Ok(()); } + let qa_runtime = job_runtime_settings.borrow().clone(); + let qa_run_config = + crate::control::freeze_run_config_for_stage(&controlled_run_config, &qa_runtime); let qa_reviews = qa_reviews_for_mode( ProgressRequestProvider::new(provider.clone(), progress.clone()), segments, translations, - &controlled_run_config, - &settings.qa, - cli_args.qa, + &qa_run_config, + &qa_runtime.settings.qa, + qa_runtime.qa, ) .await; @@ -1176,6 +1198,9 @@ async fn finish_translation_pipeline( print_stopped_resume_hint(&job.id, human_stdout_enabled(cli_args.ui)); return Ok(()); } + let double_check_runtime = job_runtime_settings.borrow().clone(); + let double_check_run_config = + crate::control::freeze_run_config_for_stage(&controlled_run_config, &double_check_runtime); if !snapshot.finalize.double_check_complete && run_double_check_pass(DoubleCheckPass { provider, @@ -1185,8 +1210,8 @@ async fn finish_translation_pipeline( translations, store, job_id: &job.id, - config: &controlled_run_config, - settings, + config: &double_check_run_config, + settings: &double_check_runtime.settings, progress: progress.clone(), }) .await? @@ -1212,6 +1237,7 @@ async fn finish_translation_pipeline( return Ok(()); } } + let validation_runtime = job_runtime_settings.borrow().clone(); print_summary_rebuild_and_report( store, job, @@ -1221,13 +1247,14 @@ async fn finish_translation_pipeline( &qa_reviews, config, rebuild_options, - cli_args.validate_output, + validation_runtime.validate_output, cli_args.strict_epubcheck, human_stdout_enabled(cli_args.ui), )?; let summary = store .summary(&job.id)? .ok_or_else(|| anyhow::anyhow!("job '{}' summary unavailable", job.id))?; + reconfigure::clear_overrides_for_job(&job.id)?; progress.emit(bookforge_core::ProgressEvent::ArtifactWritten { path: config.output.display().to_string(), timestamp_ms: bookforge_core::progress::now_ms(), @@ -1319,6 +1346,8 @@ where max_output_tokens, active_requests: 1, target_concurrency: 1, + runtime_config_revision: metadata.runtime_config_revision, + provider_max_attempts: metadata.provider_max_attempts, timestamp_ms: bookforge_core::progress::now_ms(), }); @@ -1465,7 +1494,11 @@ where use std::sync::Arc; let telemetry = Arc::new(TelemetryLog::new()); - let rate_controller = if settings.adaptive_concurrency { + // Keep the adaptive controller alive even while disabled so a live + // revision can enable it at the next request boundary without rebuilding + // or losing its limiter state. The batch engine bypasses it while the + // current runtime snapshot has adaptive concurrency disabled. + let rate_controller = { let limiter = Arc::new(AdaptiveLimiter::new_with_bounds( settings.scheduler.concurrency.max(1), 1, @@ -1477,8 +1510,6 @@ where limiter, RateControllerConfig::for_target(settings.scheduler.concurrency.max(1)), ))) - } else { - None }; let mut batch_sizer = settings.batch.adaptive_sizing.then(|| { @@ -1696,6 +1727,7 @@ pub(crate) async fn run_fallback_pass( style: primary_run_config.style.clone(), entities: primary_run_config.entities.clone(), pause_signal: Some(primary_run_config.pause_signal.clone().unwrap_or_default()), + runtime_settings: primary_run_config.runtime_settings.clone(), }; // fallback_run_config let writer = CheckpointWriter::spawn(store.path().to_path_buf(), Arc::new(NullProgressSink)); @@ -2560,6 +2592,7 @@ mod tests { style: None, entities: None, pause_signal: None, + runtime_settings: None, }; let error = translate_with_scheduler_guard( @@ -2668,6 +2701,7 @@ mod tests { style: None, entities: None, pause_signal: None, + runtime_settings: None, }; let mut control = crate::control::ControlFilePoller::new_with_path( &store, diff --git a/crates/bookforge-cli/src/commands/translate/snapshot.rs b/crates/bookforge-cli/src/commands/translate/snapshot.rs index 027939a..62b77e7 100644 --- a/crates/bookforge-cli/src/commands/translate/snapshot.rs +++ b/crates/bookforge-cli/src/commands/translate/snapshot.rs @@ -87,6 +87,8 @@ pub(crate) fn persist_snapshot( bilingual_css, fallback: fallback_snapshot(cli_args, model), finalize: FinalizeCheckpointSnapshot::default(), + qa_mode: cli_args.qa.as_str().to_string(), + validate_output: cli_args.validate_output, settings: ResolvedRunSettingsSnapshot::from_settings(settings), }; store.update_job_config_snapshot(&job.id, &snapshot)?; diff --git a/crates/bookforge-cli/src/control.rs b/crates/bookforge-cli/src/control.rs index 7ca4f26..0c0e2c0 100644 --- a/crates/bookforge-cli/src/control.rs +++ b/crates/bookforge-cli/src/control.rs @@ -1,15 +1,193 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; use anyhow::Result; use bookforge_core::{ - ControlCommand, ProgressEvent, ProgressSink, clear_control_file, control_path_for_job, now_ms, - read_control_file, write_control_file, + ControlCommand, ProgressEvent, ProgressSink, ResolvedRunSettings, clear_control_file, + control_path_for_job, now_ms, read_control_file, write_control_file, }; -use bookforge_llm::{PauseSignal, PauseState}; +use bookforge_llm::{EngineRuntimeSettings, PauseSignal, PauseState, TranslationRunConfig}; use bookforge_store::JobStore; +use tokio::sync::watch; use tokio_util::sync::CancellationToken; +use crate::QaMode; + const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(100); +pub(crate) const RUNTIME_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1); +pub(crate) const RUNTIME_LEASE_STALE_AFTER: Duration = Duration::from_secs(3); +const RUNTIME_LAUNCH_CLAIM_STALE_AFTER: Duration = Duration::from_secs(10); +static RUNTIME_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct RuntimeLease { + pub schema_version: u32, + pub instance_id: String, + pub pid: u32, + pub process_started_at_ms: u64, + pub heartbeat_at_ms: u64, + pub last_loaded_revision: u64, + pub last_applied_revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RuntimeLeaseState { + Missing, + Fresh(RuntimeLease), + Stale(RuntimeLease), + Invalid(String), +} + +pub(crate) fn runtime_path_for_job(job_id: &str) -> PathBuf { + bookforge_core::run_dir_for_job(job_id).join("runtime.json") +} + +pub(crate) fn runtime_lease_state(job_id: &str) -> RuntimeLeaseState { + let path = runtime_path_for_job(job_id); + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return RuntimeLeaseState::Missing; + } + Err(error) => return RuntimeLeaseState::Invalid(error.to_string()), + }; + let lease = match serde_json::from_str::(&contents) { + Ok(lease) if lease.schema_version == 1 => lease, + Ok(lease) => { + return RuntimeLeaseState::Invalid(format!( + "unsupported runtime lease schema {}", + lease.schema_version + )); + } + Err(error) => return RuntimeLeaseState::Invalid(error.to_string()), + }; + let age_ms = now_ms().saturating_sub(lease.heartbeat_at_ms); + if age_ms <= RUNTIME_LEASE_STALE_AFTER.as_millis() as u64 { + RuntimeLeaseState::Fresh(lease) + } else { + RuntimeLeaseState::Stale(lease) + } +} + +fn write_runtime_lease(path: &Path, lease: &RuntimeLease) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let suffix = RUNTIME_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let staged = path.with_file_name(format!( + ".runtime.json.staged-{}-{suffix}", + std::process::id() + )); + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&staged)?; + let json = serde_json::to_string_pretty(lease)?; + file.write_all(format!("{json}\n").as_bytes())?; + file.sync_all()?; + fs::rename(&staged, path)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&staged); + } + result +} + +fn remove_runtime_lease_if_owned(path: &Path, instance_id: &str) { + let owned = fs::read_to_string(path) + .ok() + .and_then(|contents| serde_json::from_str::(&contents).ok()) + .is_some_and(|lease| lease.instance_id == instance_id); + if owned { + let _ = fs::remove_file(path); + } +} + +pub(crate) struct RuntimeLaunchClaim { + path: PathBuf, + remove_on_drop: bool, +} + +impl RuntimeLaunchClaim { + pub(crate) fn acquire(job_id: &str) -> Result> { + let path = bookforge_core::run_dir_for_job(job_id).join("resume.launch"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + for _ in 0..2 { + match OpenOptions::new().create_new(true).write(true).open(&path) { + Ok(mut file) => { + writeln!(file, "{} {}", std::process::id(), now_ms())?; + file.sync_all()?; + return Ok(Some(Self { + path, + remove_on_drop: true, + })); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let stale = fs::metadata(&path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age >= RUNTIME_LAUNCH_CLAIM_STALE_AFTER); + if stale { + let _ = fs::remove_file(&path); + continue; + } + return Ok(None); + } + Err(error) => return Err(error.into()), + } + } + Ok(None) + } + + pub(crate) fn persist_until_worker(&mut self) { + self.remove_on_drop = false; + } +} + +impl Drop for RuntimeLaunchClaim { + fn drop(&mut self) { + if self.remove_on_drop { + let _ = fs::remove_file(&self.path); + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct JobRuntimeSettings { + pub revision: u64, + pub settings: ResolvedRunSettings, + pub qa: QaMode, + pub validate_output: bool, +} + +pub(crate) fn freeze_run_config_for_stage( + base: &TranslationRunConfig, + runtime: &JobRuntimeSettings, +) -> TranslationRunConfig { + let mut frozen = base.clone(); + frozen.scheduler.concurrency = runtime.settings.scheduler.concurrency.max(1); + frozen.batch_max_output_tokens = runtime.settings.provider.batch_max_output_tokens; + let (_sender, receiver) = watch::channel(EngineRuntimeSettings::from_resolved( + runtime.revision, + &runtime.settings, + )); + frozen.runtime_settings = Some(receiver); + frozen +} pub(crate) fn request_job_control(job_id: &str, command: ControlCommand) -> Result { let path = control_path_for_job(job_id); @@ -175,6 +353,10 @@ impl<'a> ControlFilePoller<'a> { pub(crate) struct ControlFileWatcher { cancel: CancellationToken, handle: tokio::task::JoinHandle<()>, + runtime_settings: watch::Receiver, + job_runtime_settings: watch::Receiver, + lease_path: PathBuf, + lease_instance_id: String, } impl ControlFileWatcher { @@ -184,6 +366,9 @@ impl ControlFileWatcher { progress: Arc, signal: PauseSignal, stop_cancel_token: CancellationToken, + baseline_settings: ResolvedRunSettings, + baseline_qa: QaMode, + baseline_validate_output: bool, ) -> Self { Self::spawn_inner( store_path, @@ -191,6 +376,9 @@ impl ControlFileWatcher { progress, signal, Some(stop_cancel_token), + baseline_settings, + baseline_qa, + baseline_validate_output, ) } @@ -200,11 +388,81 @@ impl ControlFileWatcher { progress: Arc, signal: PauseSignal, stop_cancel_token: Option, + baseline_settings: ResolvedRunSettings, + baseline_qa: QaMode, + baseline_validate_output: bool, ) -> Self { let cancel = CancellationToken::new(); let task_cancel = cancel.clone(); let job_id = job_id.into(); + // A resumed process may already have a durable sidecar. Load it before + // returning the receivers so the very first dispatch cannot race the + // watcher's asynchronous poll and be mislabeled as revision zero. + let initial_loaded = crate::commands::reconfigure::load_overrides_document_for_job(&job_id) + .ok() + .flatten(); + let initial_revision = initial_loaded.as_ref().map_or(0, |loaded| loaded.revision); + let mut initial_settings = baseline_settings.clone(); + let mut initial_qa = baseline_qa; + let mut initial_validate_output = baseline_validate_output; + if let Some(loaded) = initial_loaded.as_ref() { + crate::commands::reconfigure::apply_overrides_to_settings( + &mut initial_settings, + &loaded.overrides, + ); + initial_qa = loaded.overrides.qa.unwrap_or(baseline_qa); + initial_validate_output = loaded + .overrides + .validate_output + .unwrap_or(baseline_validate_output); + } + let (runtime_sender, runtime_settings) = watch::channel( + EngineRuntimeSettings::from_resolved(initial_revision, &initial_settings), + ); + let (job_runtime_sender, job_runtime_settings) = watch::channel(JobRuntimeSettings { + revision: initial_revision, + settings: initial_settings, + qa: initial_qa, + validate_output: initial_validate_output, + }); + let process_started_at_ms = now_ms(); + let lease_path = runtime_path_for_job(&job_id); + let lease_instance_id = format!( + "{}-{process_started_at_ms}-{}", + std::process::id(), + RUNTIME_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let mut lease = RuntimeLease { + schema_version: 1, + instance_id: lease_instance_id.clone(), + pid: std::process::id(), + process_started_at_ms, + heartbeat_at_ms: process_started_at_ms, + last_loaded_revision: initial_revision, + last_applied_revision: initial_revision, + }; + if let Err(error) = write_runtime_lease(&lease_path, &lease) { + progress.emit(ProgressEvent::Error { + kind: "runtime_lease".to_string(), + message: format!("failed to create runtime lease: {error}"), + timestamp_ms: now_ms(), + }); + } + if let Some(loaded) = initial_loaded.as_ref() { + progress.emit(ProgressEvent::RuntimeConfigChanged { + revision: loaded.revision, + changed_fields: loaded.overrides.changed_fields(), + application: loaded.overrides.application_boundaries(), + timestamp_ms: now_ms(), + }); + } + let _ = fs::remove_file(bookforge_core::run_dir_for_job(&job_id).join("resume.launch")); + let task_lease_path = lease_path.clone(); + let task_lease_instance_id = lease_instance_id.clone(); let handle = tokio::spawn(async move { + let mut last_override_revision = initial_loaded.as_ref().map(|loaded| loaded.revision); + let mut last_override_error = None; + let mut last_heartbeat_write = Instant::now(); loop { { match JobStore::open(store_path.clone()) { @@ -216,6 +474,62 @@ impl ControlFileWatcher { progress.clone(), stop_cancel_token.clone(), ); + match crate::commands::reconfigure::load_overrides_document_for_job( + &job_id, + ) { + Ok(Some(loaded)) + if last_override_revision != Some(loaded.revision) => + { + let mut effective = baseline_settings.clone(); + crate::commands::reconfigure::apply_overrides_to_settings( + &mut effective, + &loaded.overrides, + ); + let changed_fields = loaded.overrides.changed_fields(); + let effective_qa = loaded.overrides.qa.unwrap_or(baseline_qa); + let effective_validate_output = loaded + .overrides + .validate_output + .unwrap_or(baseline_validate_output); + job_runtime_sender.send_replace(JobRuntimeSettings { + revision: loaded.revision, + settings: effective.clone(), + qa: effective_qa, + validate_output: effective_validate_output, + }); + runtime_sender.send_replace( + EngineRuntimeSettings::from_resolved( + loaded.revision, + &effective, + ), + ); + lease.last_loaded_revision = loaded.revision; + lease.last_applied_revision = loaded.revision; + progress.emit(ProgressEvent::RuntimeConfigChanged { + revision: loaded.revision, + changed_fields, + application: loaded.overrides.application_boundaries(), + timestamp_ms: now_ms(), + }); + last_override_revision = Some(loaded.revision); + last_override_error = None; + } + Ok(_) => {} + Err(error) => { + let message = error.to_string(); + if last_override_error.as_deref() != Some(message.as_str()) { + progress.emit(ProgressEvent::RuntimeConfigRejected { + revision: None, + message: message.clone(), + timestamp_ms: now_ms(), + }); + last_override_error = Some(message); + } + } + } + // Publish a newly durable override revision before a Resume + // command can release paused dispatchers. This preserves the + // reconfigure-then-resume ordering guarantee across processes. if let Err(error) = poller.poll(&signal) { progress.emit(ProgressEvent::Error { kind: "control_file_watcher".to_string(), @@ -235,13 +549,40 @@ impl ControlFileWatcher { } } } + if last_heartbeat_write.elapsed() >= RUNTIME_HEARTBEAT_INTERVAL { + lease.heartbeat_at_ms = now_ms(); + if let Err(error) = write_runtime_lease(&task_lease_path, &lease) { + progress.emit(ProgressEvent::Error { + kind: "runtime_lease".to_string(), + message: format!("failed to refresh runtime lease: {error}"), + timestamp_ms: now_ms(), + }); + } + last_heartbeat_write = Instant::now(); + } tokio::select! { _ = task_cancel.cancelled() => break, _ = tokio::time::sleep(CONTROL_POLL_INTERVAL) => {} } } + remove_runtime_lease_if_owned(&task_lease_path, &task_lease_instance_id); }); - Self { cancel, handle } + Self { + cancel, + handle, + runtime_settings, + job_runtime_settings, + lease_path, + lease_instance_id, + } + } + + pub(crate) fn runtime_settings(&self) -> watch::Receiver { + self.runtime_settings.clone() + } + + pub(crate) fn job_runtime_settings(&self) -> watch::Receiver { + self.job_runtime_settings.clone() } #[allow(dead_code)] @@ -254,13 +595,25 @@ impl Drop for ControlFileWatcher { fn drop(&mut self) { self.cancel.cancel(); self.handle.abort(); + remove_runtime_lease_if_owned(&self.lease_path, &self.lease_instance_id); } } #[cfg(test)] mod tests { use super::*; - use bookforge_core::NullProgressSink; + use bookforge_core::{NullProgressSink, TranslationProfile}; + use std::sync::Mutex; + + struct RecordingSink { + events: Arc>>, + } + + impl ProgressSink for RecordingSink { + fn emit(&self, event: ProgressEvent) { + self.events.lock().expect("events lock").push(event); + } + } #[test] fn request_and_clear_job_control_use_conventional_path() { @@ -321,4 +674,291 @@ mod tests { poller.poll(&signal).unwrap(); assert_eq!(signal.state(), PauseState::Running); } + + #[tokio::test] + async fn watcher_publishes_revisioned_runtime_overrides() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("jobs.sqlite"); + let input = dir.path().join("input.epub"); + std::fs::write(&input, b"epub").unwrap(); + let store = JobStore::open(&db).unwrap(); + let job = store + .create_job(bookforge_store::CreateJob { + input: &input, + output: &dir.path().join("out.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .unwrap(); + let run_dir = bookforge_core::run_dir_for_job(&job.id); + std::fs::create_dir_all(&run_dir).unwrap(); + let overrides_path = run_dir.join("overrides.json"); + std::fs::write( + &overrides_path, + r#"{ + "schema_version": 1, + "revision": 7, + "updated_at_ms": 123, + "overrides": { + "concurrency": 2, + "batch_max_output_tokens": 12000, + "qa": "all", + "validate_output": true + } +}"#, + ) + .unwrap(); + + let baseline = TranslationProfile::V1Fast.resolve(); + let watcher = ControlFileWatcher::spawn_with_stop_cancel( + db, + job.id.clone(), + Arc::new(NullProgressSink), + PauseSignal::new(), + CancellationToken::new(), + baseline, + QaMode::Off, + false, + ); + let mut receiver = watcher.runtime_settings(); + if receiver.borrow().revision != 7 { + tokio::time::timeout(Duration::from_secs(2), receiver.changed()) + .await + .expect("watcher should publish the sidecar") + .expect("runtime channel should stay open"); + } + let applied = receiver.borrow().clone(); + assert_eq!(applied.revision, 7); + assert_eq!(applied.concurrency, 2); + assert_eq!(applied.batch_max_output_tokens, Some(12_000)); + let job_runtime = watcher.job_runtime_settings(); + let job_runtime = job_runtime.borrow(); + assert_eq!(job_runtime.revision, 7); + assert_eq!(job_runtime.qa, QaMode::All); + assert!(job_runtime.validate_output); + + drop(watcher); + let _ = std::fs::remove_dir_all(run_dir); + } + + #[tokio::test] + async fn watcher_runtime_lease_heartbeats_and_is_removed_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("jobs.sqlite"); + let input = dir.path().join("input.epub"); + std::fs::write(&input, b"epub").unwrap(); + let store = JobStore::open(&db).unwrap(); + let job = store + .create_job(bookforge_store::CreateJob { + input: &input, + output: &dir.path().join("out.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .unwrap(); + let run_dir = bookforge_core::run_dir_for_job(&job.id); + let watcher = ControlFileWatcher::spawn_with_stop_cancel( + db, + job.id.clone(), + Arc::new(NullProgressSink), + PauseSignal::new(), + CancellationToken::new(), + TranslationProfile::V1Fast.resolve(), + QaMode::Off, + false, + ); + + let first = match runtime_lease_state(&job.id) { + RuntimeLeaseState::Fresh(lease) => lease, + state => panic!("expected a fresh runtime lease, got {state:?}"), + }; + tokio::time::sleep(RUNTIME_HEARTBEAT_INTERVAL + Duration::from_millis(250)).await; + let refreshed = match runtime_lease_state(&job.id) { + RuntimeLeaseState::Fresh(lease) => lease, + state => panic!("expected a refreshed runtime lease, got {state:?}"), + }; + assert_eq!(refreshed.instance_id, first.instance_id); + assert!(refreshed.heartbeat_at_ms > first.heartbeat_at_ms); + + drop(watcher); + assert_eq!(runtime_lease_state(&job.id), RuntimeLeaseState::Missing); + let _ = std::fs::remove_dir_all(run_dir); + } + + #[tokio::test] + async fn watcher_publishes_overrides_before_applying_resume() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("jobs.sqlite"); + let input = dir.path().join("input.epub"); + std::fs::write(&input, b"epub").unwrap(); + let store = JobStore::open(&db).unwrap(); + let job = store + .create_job(bookforge_store::CreateJob { + input: &input, + output: &dir.path().join("out.epub"), + source_lang: Some("English"), + target_lang: "Italian", + provider: "mock", + model: "mock-prefix", + base_url: None, + api_key_env: None, + book_id: None, + series_id: None, + }) + .unwrap(); + store.mark_job_paused(&job.id).unwrap(); + + let run_dir = bookforge_core::run_dir_for_job(&job.id); + std::fs::create_dir_all(&run_dir).unwrap(); + std::fs::write( + run_dir.join("overrides.json"), + r#"{ + "schema_version": 1, + "revision": 9, + "updated_at_ms": 123, + "overrides": { "concurrency": 2 } +}"#, + ) + .unwrap(); + request_job_control(&job.id, ControlCommand::Resume).unwrap(); + + let events = Arc::new(Mutex::new(Vec::new())); + let signal = PauseSignal::new(); + signal.pause(); + let watcher = ControlFileWatcher::spawn_with_stop_cancel( + db, + job.id.clone(), + Arc::new(RecordingSink { + events: events.clone(), + }), + signal, + CancellationToken::new(), + TranslationProfile::V1Fast.resolve(), + QaMode::Off, + false, + ); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let saw_both = { + let events = events.lock().expect("events lock"); + events.iter().any(|event| { + matches!( + event, + ProgressEvent::RuntimeConfigChanged { revision: 9, .. } + ) + }) && events + .iter() + .any(|event| matches!(event, ProgressEvent::JobResumed { .. })) + }; + if saw_both { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("watcher should publish and resume"); + + let events = events.lock().expect("events lock"); + let config_index = events + .iter() + .position(|event| { + matches!( + event, + ProgressEvent::RuntimeConfigChanged { revision: 9, .. } + ) + }) + .expect("runtime change should be recorded"); + let resume_index = events + .iter() + .position(|event| matches!(event, ProgressEvent::JobResumed { .. })) + .expect("resume should be recorded"); + assert!( + config_index < resume_index, + "override revision must publish before Resume releases work" + ); + drop(events); + assert_eq!(watcher.runtime_settings().borrow().revision, 9); + + drop(watcher); + let _ = std::fs::remove_dir_all(run_dir); + } + + #[test] + fn runtime_launch_claim_deduplicates_concurrent_resume_attempts() { + let job_id = format!("launch-claim-test-{}", now_ms()); + let run_dir = bookforge_core::run_dir_for_job(&job_id); + let _ = std::fs::remove_dir_all(&run_dir); + + let first = RuntimeLaunchClaim::acquire(&job_id) + .expect("first claim should succeed") + .expect("first caller should own the claim"); + assert!( + RuntimeLaunchClaim::acquire(&job_id) + .expect("second acquire should be readable") + .is_none(), + "a concurrent caller must not launch another worker" + ); + + drop(first); + let mut persisted = RuntimeLaunchClaim::acquire(&job_id) + .expect("claim should be reusable after an unlaunched owner drops") + .expect("new caller should own the released claim"); + persisted.persist_until_worker(); + drop(persisted); + assert!( + RuntimeLaunchClaim::acquire(&job_id) + .expect("persisted claim should remain readable") + .is_none(), + "a launched worker's claim must remain until its watcher clears it" + ); + + let _ = std::fs::remove_dir_all(run_dir); + } + + #[test] + fn runtime_lease_reader_reports_stale_and_invalid_files() { + let job_id = format!("runtime-lease-state-test-{}", now_ms()); + let run_dir = bookforge_core::run_dir_for_job(&job_id); + let path = runtime_path_for_job(&job_id); + let _ = std::fs::remove_dir_all(&run_dir); + std::fs::create_dir_all(&run_dir).unwrap(); + + let stale = RuntimeLease { + schema_version: 1, + instance_id: "stale-worker".to_string(), + pid: 123, + process_started_at_ms: 1, + heartbeat_at_ms: now_ms() + .saturating_sub(RUNTIME_LEASE_STALE_AFTER.as_millis() as u64 + 1), + last_loaded_revision: 2, + last_applied_revision: 2, + }; + std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap(); + assert!(matches!( + runtime_lease_state(&job_id), + RuntimeLeaseState::Stale(lease) if lease.instance_id == "stale-worker" + )); + + std::fs::write(&path, b"not-json").unwrap(); + assert!(matches!( + runtime_lease_state(&job_id), + RuntimeLeaseState::Invalid(_) + )); + + let _ = std::fs::remove_dir_all(run_dir); + } } diff --git a/crates/bookforge-cli/src/main.rs b/crates/bookforge-cli/src/main.rs index c2d2b09..0d48b0e 100644 --- a/crates/bookforge-cli/src/main.rs +++ b/crates/bookforge-cli/src/main.rs @@ -230,6 +230,24 @@ pub(crate) enum QaMode { All, } +impl QaMode { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::Suspicious => "suspicious", + Self::All => "all", + } + } + + pub(crate) fn from_snapshot(value: &str) -> Self { + match value { + "suspicious" => Self::Suspicious, + "all" => Self::All, + _ => Self::Off, + } + } +} + fn default_output_path(input: &std::path::Path, target: &str) -> PathBuf { let stem = input .file_stem() diff --git a/crates/bookforge-cli/src/progress.rs b/crates/bookforge-cli/src/progress.rs index eacf78e..da941c6 100644 --- a/crates/bookforge-cli/src/progress.rs +++ b/crates/bookforge-cli/src/progress.rs @@ -547,6 +547,28 @@ impl ProgressBars { self.batch_bar .set_message(format!("batch {batch_id} split")); } + ProgressEvent::RuntimeConfigChanged { + revision, + changed_fields, + application, + .. + } => { + self.multi + .println(format!( + " [runtime r{revision}] {} -> {}", + changed_fields.join(", "), + application.join(", ") + )) + .ok(); + } + ProgressEvent::RuntimeConfigRejected { + revision, message, .. + } => { + let revision = revision.map_or_else(String::new, |value| format!(" r{value}")); + self.multi + .println(format!(" [runtime{revision} rejected] {message}")) + .ok(); + } ProgressEvent::Warning { message, .. } => { self.multi.println(format!(" [warn] {message}")).ok(); } @@ -594,6 +616,8 @@ fn is_important_event(event: &ProgressEvent) -> bool { ProgressEvent::Error { .. } | ProgressEvent::JobPaused { .. } | ProgressEvent::JobResumed { .. } + | ProgressEvent::RuntimeConfigChanged { .. } + | ProgressEvent::RuntimeConfigRejected { .. } | ProgressEvent::RequestStarted { .. } | ProgressEvent::Warning { .. } | ProgressEvent::BatchRepairFinished { .. } @@ -838,6 +862,17 @@ mod tests { message: "test".into(), timestamp_ms: 0, })); + assert!(is_important_event(&ProgressEvent::RuntimeConfigChanged { + revision: 1, + changed_fields: vec!["concurrency".to_string()], + application: vec!["next_request".to_string()], + timestamp_ms: 0, + })); + assert!(is_important_event(&ProgressEvent::RuntimeConfigRejected { + revision: Some(2), + message: "invalid".to_string(), + timestamp_ms: 0, + })); // BatchRepairFinished is critical assert!(is_important_event(&ProgressEvent::BatchRepairFinished { repaired_items: 0, diff --git a/crates/bookforge-cli/src/tui/mod.rs b/crates/bookforge-cli/src/tui/mod.rs index 2949478..ea22257 100644 --- a/crates/bookforge-cli/src/tui/mod.rs +++ b/crates/bookforge-cli/src/tui/mod.rs @@ -514,6 +514,28 @@ fn format_event_line(event: &ProgressEvent) -> Line<'static> { format!("concurrency → {current} ({reason})"), Style::new().fg(Color::Gray), ), + ProgressEvent::RuntimeConfigChanged { + revision, + changed_fields, + application, + .. + } => ( + format!( + "runtime r{revision}: {} → {}", + changed_fields.join(", "), + application.join(", ") + ), + Style::new().fg(Color::Cyan), + ), + ProgressEvent::RuntimeConfigRejected { + revision, message, .. + } => ( + format!( + "runtime{} rejected: {message}", + revision.map_or_else(String::new, |value| format!(" r{value}")) + ), + Style::new().fg(Color::Red), + ), ProgressEvent::Warning { message, .. } => { (format!("[warn] {message}"), Style::new().fg(Color::Yellow)) } @@ -650,4 +672,22 @@ mod tests { assert!(rendered.contains("3 ok")); assert!(rendered.contains("2 review")); } + + #[test] + fn runtime_change_event_renders_revision_fields_and_boundary() { + let line = format_event_line(&ProgressEvent::RuntimeConfigChanged { + revision: 4, + changed_fields: vec!["concurrency".to_string(), "qa".to_string()], + application: vec!["next_request".to_string(), "next_stage".to_string()], + timestamp_ms: 0, + }); + let rendered: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(rendered.contains("runtime r4")); + assert!(rendered.contains("concurrency, qa")); + assert!(rendered.contains("next_request, next_stage")); + } } diff --git a/crates/bookforge-cli/tests/lifecycle.rs b/crates/bookforge-cli/tests/lifecycle.rs index e5f1ebb..701a5d0 100644 --- a/crates/bookforge-cli/tests/lifecycle.rs +++ b/crates/bookforge-cli/tests/lifecycle.rs @@ -1756,6 +1756,330 @@ fn cli_pause_and_resume_live_mock_run() { assert!(output.exists(), "resumed live run should write output"); } +#[test] +fn cli_live_reconfigure_updates_later_single_requests_and_cleans_runtime_files() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let events = temp.path().join("single-live-reconfigure-events.jsonl"); + let output = temp.path().join("single-live-reconfigure.epub"); + let mut child = spawn_controlled_single_mock_translate(&temp, &events, &output); + let job_id = wait_for_job_id_in_store(&temp); + wait_for_events(&events, |events| { + !request_started_payloads(events).is_empty() + }); + + bookforge() + .current_dir(temp.path()) + .args([ + "reconfigure", + &job_id, + "--concurrency", + "2", + "--provider-max-attempts", + "3", + "--batch-max-output-tokens", + "1024", + ]) + .assert() + .success(); + + let status = child.wait().expect("translate child should exit"); + assert!(status.success(), "translate child failed: {status}"); + let final_events = wait_for_event_count(&events, "TranslationFinished", 1); + let requests = request_started_payloads(&final_events); + assert!(requests.len() > 1, "fixture should make several requests"); + assert_eq!( + requests[0] + .get("runtime_config_revision") + .and_then(serde_json::Value::as_u64), + Some(0), + "the in-flight request must retain the baseline revision" + ); + assert!(requests.iter().skip(1).any(|request| { + request + .get("runtime_config_revision") + .and_then(serde_json::Value::as_u64) + == Some(1) + && request + .get("provider_max_attempts") + .and_then(serde_json::Value::as_u64) + == Some(3) + })); + assert_no_duplicate_segments(&segment_finished_ids(&final_events)); + assert!(output.exists()); + assert!( + !overrides_path(&temp, &job_id).exists(), + "successful completion should consume the sidecar" + ); + assert!( + !runtime_path(&temp, &job_id).exists(), + "clean worker exit should remove its lease" + ); +} + +#[test] +fn cli_live_reconfigure_repartitions_pending_batch_work() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let events = temp.path().join("batch-live-reconfigure-events.jsonl"); + let output = temp.path().join("batch-live-reconfigure.epub"); + let mut child = spawn_controlled_mock_translate(&temp, &events, &output); + let job_id = wait_for_job_id_in_store(&temp); + wait_for_events(&events, |events| { + !request_started_payloads(events).is_empty() + }); + + bookforge() + .current_dir(temp.path()) + .args([ + "reconfigure", + &job_id, + "--batch-max-items", + "4", + "--batch-target-tokens", + "100000", + "--concurrency", + "2", + "--provider-max-attempts", + "4", + "--batch-max-output-tokens", + "1024", + "--adaptive-concurrency", + "false", + "--adaptive-batch-sizing", + "false", + ]) + .assert() + .success(); + + let status = child.wait().expect("translate child should exit"); + assert!(status.success(), "translate child failed: {status}"); + let final_events = wait_for_event_count(&events, "TranslationFinished", 1); + let requests = request_started_payloads(&final_events); + assert!(requests.len() > 1); + assert_eq!( + requests[0] + .get("runtime_config_revision") + .and_then(serde_json::Value::as_u64), + Some(0) + ); + assert!(requests.iter().skip(1).any(|request| { + request + .get("runtime_config_revision") + .and_then(serde_json::Value::as_u64) + == Some(1) + && request + .get("provider_max_attempts") + .and_then(serde_json::Value::as_u64) + == Some(4) + && request + .get("items") + .and_then(serde_json::Value::as_u64) + .is_some_and(|items| items > 1) + })); + assert_no_duplicate_segments(&segment_finished_ids(&final_events)); + assert!(!overrides_path(&temp, &job_id).exists()); + assert!(!runtime_path(&temp, &job_id).exists()); +} + +#[test] +fn cli_stop_preserves_runtime_overrides_and_resume_consumes_them() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let events = temp.path().join("stop-runtime-reconfigure-events.jsonl"); + let output = temp.path().join("stop-runtime-reconfigure.epub"); + let mut child = spawn_controlled_mock_translate(&temp, &events, &output); + let job_id = wait_for_job_id_in_store(&temp); + wait_for_events(&events, |events| { + !request_started_payloads(events).is_empty() + }); + + bookforge() + .current_dir(temp.path()) + .args([ + "reconfigure", + &job_id, + "--concurrency", + "2", + "--provider-max-attempts", + "4", + "--batch-max-items", + "3", + ]) + .assert() + .success(); + wait_for_event_count(&events, "RuntimeConfigChanged", 1); + write_control_file(&control_path(&temp, &job_id), ControlCommand::Stop) + .expect("stop control should write"); + + let status = child.wait().expect("translate child should exit"); + assert!(status.success(), "translate child failed: {status}"); + wait_for_job_status(&temp, &job_id, "stopped"); + let stopped_events = read_jsonl(&events); + assert_eq!(event_count(&stopped_events, "TranslationFinished"), 0); + let initially_finished = segment_finished_ids(&stopped_events); + assert!( + overrides_path(&temp, &job_id).exists(), + "Stop must preserve durable runtime overrides for resume" + ); + assert!( + !runtime_path(&temp, &job_id).exists(), + "the stopped worker exited cleanly and should release its lease" + ); + + let resume_events = temp.path().join("stop-runtime-resume-events.jsonl"); + bookforge() + .current_dir(temp.path()) + .args([ + "resume", + &job_id, + "--ui", + "quiet", + "--progress-jsonl", + resume_events.to_str().unwrap(), + ]) + .assert() + .success(); + + let resumed = wait_for_event_count(&resume_events, "TranslationFinished", 1); + assert!(request_started_payloads(&resumed).iter().any(|request| { + request + .get("runtime_config_revision") + .and_then(serde_json::Value::as_u64) + == Some(1) + && request + .get("provider_max_attempts") + .and_then(serde_json::Value::as_u64) + == Some(4) + })); + let resumed_finished = segment_finished_ids(&resumed); + for id in initially_finished { + assert!( + !resumed_finished.contains(&id), + "resume retranslated already checkpointed segment {id}" + ); + } + assert!( + !overrides_path(&temp, &job_id).exists(), + "successful resume should consume the sidecar" + ); + assert!(!runtime_path(&temp, &job_id).exists()); +} + +#[test] +fn killed_worker_leaves_a_stale_recoverable_runtime_lease() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let events = temp.path().join("crash-runtime-lease-events.jsonl"); + let output = temp.path().join("crash-runtime-lease.epub"); + let mut child = spawn_controlled_mock_translate(&temp, &events, &output); + let job_id = wait_for_job_id_in_store(&temp); + let lease_path = runtime_path(&temp, &job_id); + let deadline = Instant::now() + Duration::from_secs(5); + while !lease_path.exists() { + assert!(Instant::now() < deadline, "runtime lease should appear"); + thread::sleep(Duration::from_millis(20)); + } + + child.kill().expect("worker should be killable"); + let _ = child.wait().expect("killed worker should reap"); + assert!( + lease_path.exists(), + "a process crash cannot clean its lease; the stale file enables recovery" + ); + thread::sleep(Duration::from_millis(3_150)); + let lease: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&lease_path).expect("stale lease should remain readable"), + ) + .expect("runtime lease should remain valid JSON"); + let heartbeat = lease["heartbeat_at_ms"] + .as_u64() + .expect("lease heartbeat should be numeric"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_millis() as u64; + assert!( + now.saturating_sub(heartbeat) > 3_000, + "killed worker lease should age beyond the dashboard stale threshold" + ); +} + +#[test] +fn cli_finalize_stages_snapshot_runtime_settings_at_stage_boundaries() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let events = temp.path().join("stage-live-reconfigure-events.jsonl"); + let output = temp.path().join("stage-live-reconfigure.epub"); + let mut child = spawn_finalize_stage_delay_mock_translate( + &temp, + &events, + &output, + &[ + ("BOOKFORGE_MOCK_QA_DELAY_MS", "1500"), + ("BOOKFORGE_TEST_FINALIZE_BOUNDARY_DELAY_MS", "200"), + ], + ); + let job_id = wait_for_job_id_in_store(&temp); + wait_for_request_started_prefix(&events, "qa_"); + + bookforge() + .current_dir(temp.path()) + .args([ + "reconfigure", + &job_id, + "--qa", + "off", + "--double-check", + "off", + "--validate-output", + "true", + "--provider-max-attempts", + "4", + ]) + .assert() + .success(); + + let status = child.wait().expect("translate child should exit"); + assert!(status.success(), "translate child failed: {status}"); + let final_events = wait_for_event_count(&events, "TranslationFinished", 1); + let qa_started = final_events + .iter() + .position(|event| { + request_started_id(event) + .as_deref() + .is_some_and(|id| id.starts_with("qa_")) + }) + .expect("QA request should start under the baseline stage snapshot"); + let changed = final_events + .iter() + .position(|event| event.get("RuntimeConfigChanged").is_some()) + .expect("runtime change should be recorded"); + let qa_finished = final_events + .iter() + .position(|event| { + event + .get("RequestFinished") + .and_then(|payload| payload.get("request_id")) + .and_then(serde_json::Value::as_str) + .is_some_and(|id| id.starts_with("qa_")) + }) + .expect("in-flight QA request should finish"); + assert!( + qa_started < changed && changed < qa_finished, + "the runtime edit should land while the frozen QA stage is in flight" + ); + assert!( + request_started_ids(&final_events) + .iter() + .all(|id| !id.starts_with("double_check_") && !id.starts_with("repair_")), + "double-check disabled at its later stage boundary must not dispatch" + ); + assert!( + temp.path() + .join("stage-live-reconfigure.validation.json") + .exists(), + "validation enabled at its stage boundary should write a report" + ); + assert!(!overrides_path(&temp, &job_id).exists()); + assert!(!runtime_path(&temp, &job_id).exists()); +} + #[test] fn cli_stop_then_resume_mock_run() { let temp = tempfile::tempdir().expect("temp dir should be created"); @@ -1763,12 +2087,16 @@ fn cli_stop_then_resume_mock_run() { let output = temp.path().join("out.epub"); let mut child = spawn_controlled_mock_translate(&temp, &events, &output); let job_id = wait_for_job_id_in_store(&temp); - thread::sleep(Duration::from_millis(50)); let control_path = temp .path() .join(".bookforge/runs") .join(&job_id) .join("control"); + // Stop only after the first provider request is observably in flight. The + // former fixed 50 ms sleep could fire before dispatch on a fast machine, + // leaving no completed request to checkpoint. Stop still lets that + // in-flight mock request finish while preventing the next dispatch. + wait_for_events(&events, |events| batch_request_started_count(events) == 1); write_control_file(&control_path, ControlCommand::Stop).expect("stop control should write"); let status = child.wait().expect("translate child should exit"); @@ -2692,6 +3020,43 @@ fn spawn_controlled_mock_translate(temp: &TempDir, events: &Path, output: &Path) cmd.spawn().expect("controlled translate should spawn") } +fn spawn_controlled_single_mock_translate( + temp: &TempDir, + events: &Path, + output: &Path, +) -> process::Child { + let input = fixture_input(); + let mut cmd = process::Command::new(assert_cmd::cargo::cargo_bin("bookforge")); + cmd.current_dir(temp.path()) + .env("BOOKFORGE_MOCK_DELAY_MS", "400") + .args([ + "translate", + input.to_str().unwrap(), + "--target", + "Italian", + "--provider", + "mock", + "--model", + "mock-prefix-target", + "--profile", + "safe", + "--max-segment-tokens", + "1", + "--context-window", + "0", + "--concurrency", + "1", + "--ui", + "quiet", + "--progress-jsonl", + events.to_str().unwrap(), + "--out", + output.to_str().unwrap(), + ]); + cmd.spawn() + .expect("controlled single-segment translate should spawn") +} + fn spawn_finalize_controlled_mock_translate( temp: &TempDir, events: &Path, @@ -2860,6 +3225,20 @@ fn control_path(temp: &TempDir, job_id: &str) -> PathBuf { .join("control") } +fn overrides_path(temp: &TempDir, job_id: &str) -> PathBuf { + temp.path() + .join(".bookforge/runs") + .join(job_id) + .join("overrides.json") +} + +fn runtime_path(temp: &TempDir, job_id: &str) -> PathBuf { + temp.path() + .join(".bookforge/runs") + .join(job_id) + .join("runtime.json") +} + fn wait_for_event_count(path: &Path, key: &str, min_count: usize) -> Vec { wait_for_events(path, |events| event_count(events, key) >= min_count) } @@ -3017,6 +3396,13 @@ fn request_started_ids(events: &[serde_json::Value]) -> Vec { events.iter().filter_map(request_started_id).collect() } +fn request_started_payloads(events: &[serde_json::Value]) -> Vec<&serde_json::Value> { + events + .iter() + .filter_map(|event| event.get("RequestStarted")) + .collect() +} + fn finalize_request_started_between_pause_and_resume(events: &[serde_json::Value]) -> Vec { let mut paused = false; let mut ids = Vec::new(); diff --git a/crates/bookforge-core/src/progress.rs b/crates/bookforge-core/src/progress.rs index 01ec8de..2e868e0 100644 --- a/crates/bookforge-core/src/progress.rs +++ b/crates/bookforge-core/src/progress.rs @@ -68,6 +68,17 @@ pub enum ProgressEvent { batch_max_output_tokens: Option, timestamp_ms: u64, }, + RuntimeConfigChanged { + revision: u64, + changed_fields: Vec, + application: Vec, + timestamp_ms: u64, + }, + RuntimeConfigRejected { + revision: Option, + message: String, + timestamp_ms: u64, + }, SegmentationFinished { segment_count: usize, timestamp_ms: u64, @@ -109,6 +120,10 @@ pub enum ProgressEvent { max_output_tokens: Option, active_requests: usize, target_concurrency: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime_config_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_max_attempts: Option, timestamp_ms: u64, }, RequestFinished { @@ -202,6 +217,8 @@ pub fn event_timestamp_ms(event: &ProgressEvent) -> u64 { | StageStarted { timestamp_ms, .. } | StageFinished { timestamp_ms, .. } | RuntimeConfigResolved { timestamp_ms, .. } + | RuntimeConfigChanged { timestamp_ms, .. } + | RuntimeConfigRejected { timestamp_ms, .. } | SegmentationFinished { timestamp_ms, .. } | CacheScanFinished { timestamp_ms, .. } | BatchQueued { timestamp_ms, .. } @@ -260,6 +277,12 @@ pub struct RunState { pub provider: Option, pub model: Option, pub configured_concurrency: usize, + #[serde(default)] + pub runtime_config_revision: u64, + #[serde(default)] + pub runtime_config_changed_fields: Vec, + #[serde(default)] + pub runtime_config_rejection: Option, // Progress. pub stage: Option, @@ -348,6 +371,18 @@ impl RunState { self.configured_concurrency = *concurrency; self.target_concurrency = *concurrency; } + ProgressEvent::RuntimeConfigChanged { + revision, + changed_fields, + .. + } => { + self.runtime_config_revision = *revision; + self.runtime_config_changed_fields = changed_fields.clone(); + self.runtime_config_rejection = None; + } + ProgressEvent::RuntimeConfigRejected { message, .. } => { + self.runtime_config_rejection = Some(message.clone()); + } ProgressEvent::StageStarted { stage, .. } => { self.stage = Some(stage.clone()); } @@ -624,6 +659,37 @@ mod tests { assert!((state.eta_secs() - 60.0).abs() < 1e-9); } + #[test] + fn runtime_config_events_fold_into_replayable_state() { + let mut state = RunState::default(); + state.fold(&ProgressEvent::RuntimeConfigRejected { + revision: Some(2), + message: "invalid concurrency".to_string(), + timestamp_ms: 10, + }); + assert_eq!( + state.runtime_config_rejection.as_deref(), + Some("invalid concurrency") + ); + + state.fold(&ProgressEvent::RuntimeConfigChanged { + revision: 3, + changed_fields: vec!["concurrency".to_string(), "batch-max-items".to_string()], + application: vec!["next_request".to_string(), "next_batch".to_string()], + timestamp_ms: 20, + }); + + assert_eq!(state.runtime_config_revision, 3); + assert_eq!( + state.runtime_config_changed_fields, + vec!["concurrency".to_string(), "batch-max-items".to_string()] + ); + assert!(state.runtime_config_rejection.is_none()); + let json = serde_json::to_string(&state).unwrap(); + let replayed: RunState = serde_json::from_str(&json).unwrap(); + assert_eq!(replayed.runtime_config_revision, 3); + } + #[test] fn active_requests_tracks_start_and_finish() { let mut state = RunState::default(); @@ -639,6 +705,8 @@ mod tests { max_output_tokens: None, active_requests: 1, target_concurrency: 4, + runtime_config_revision: None, + provider_max_attempts: None, timestamp_ms: 1, }; state.fold(&started); @@ -689,6 +757,20 @@ mod tests { assert_eq!(state.succeeded, 1); } + #[test] + fn older_request_started_events_default_runtime_audit_fields() { + let json = r#"{"RequestStarted":{"request_id":"r1","batch_id":null,"segment_id":null,"provider":null,"model":null,"prompt_template":null,"items":1,"estimated_input_tokens":2,"max_output_tokens":256,"active_requests":1,"target_concurrency":2,"timestamp_ms":3}}"#; + let event: ProgressEvent = serde_json::from_str(json).unwrap(); + assert!(matches!( + event, + ProgressEvent::RequestStarted { + runtime_config_revision: None, + provider_max_attempts: None, + .. + } + )); + } + #[test] fn job_pause_events_fold_into_state() { let mut state = RunState::default(); @@ -741,6 +823,8 @@ mod tests { max_output_tokens: None, active_requests: active, target_concurrency: 4, + runtime_config_revision: None, + provider_max_attempts: None, timestamp_ms: ts, }; state.fold(&started(3, 1)); diff --git a/crates/bookforge-core/src/run_snapshot.rs b/crates/bookforge-core/src/run_snapshot.rs index 5cf7d2b..0a89491 100644 --- a/crates/bookforge-core/src/run_snapshot.rs +++ b/crates/bookforge-core/src/run_snapshot.rs @@ -78,9 +78,20 @@ pub struct RunConfigSnapshot { pub fallback: Option, #[serde(default)] pub finalize: FinalizeCheckpointSnapshot, + /// CLI QA scope captured outside `ResolvedRunSettings` (off, suspicious, + /// or all). Kept as a string so the core snapshot does not depend on the + /// CLI's clap enum. + #[serde(default = "default_qa_mode")] + pub qa_mode: String, + #[serde(default)] + pub validate_output: bool, pub settings: ResolvedRunSettingsSnapshot, } +fn default_qa_mode() -> String { + "off".to_string() +} + fn default_context_budget_tokens() -> usize { 1200 } diff --git a/crates/bookforge-llm/src/batch.rs b/crates/bookforge-llm/src/batch.rs index efa6676..9b941ca 100644 --- a/crates/bookforge-llm/src/batch.rs +++ b/crates/bookforge-llm/src/batch.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, VecDeque, hash_map::Entry}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::{ - sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError, mpsc}, + sync::{Semaphore, TryAcquireError, mpsc}, task::JoinSet, }; @@ -17,7 +17,7 @@ use crate::{ CompletionRequest, FinishReason, LlmError, LlmProvider, PromptLibrary, ProviderRateController, RequestMetadata, RequestStatus, ResponseFormat, SegmentTranslation, Substitutions, TelemetryLog, TranslationRunConfig, - concurrency::{PauseSignal, PauseState}, + concurrency::{AdaptiveLimiter, AdaptivePermit, PauseSignal, PauseState}, }; enum BatchWorkerResult { @@ -33,7 +33,7 @@ struct BatchWorkerOutput { max_output_tokens: u32, output_escalated: bool, next_max_output_tokens: Option, - request_permit: Option, + request_permit: Option, } struct RepairWorkerOutput { @@ -396,9 +396,13 @@ impl BatchModeSizing { max_items: initial_max_items, initial_target_tokens, initial_max_items, - min_tokens: mode.min_tokens(), + // Explicit user/runtime limits below the adaptive defaults are + // still authoritative. They become the floor for this sizing + // epoch; adaptation may grow from them but must not silently + // clamp them upward on construction. + min_tokens: initial_target_tokens.min(mode.min_tokens()).max(1), max_tokens: mode.max_tokens(), - min_items: mode.min_items(), + min_items: initial_max_items.min(mode.min_items()).max(1), max_items_cap: mode.max_items_cap(), recent: VecDeque::new(), last_increase: None, @@ -1261,6 +1265,55 @@ fn split_batch_with_config( batches } +fn take_batch_output_override( + overrides_by_item: &mut HashMap, + batch: &TranslationBatch, +) -> Option { + batch + .items + .iter() + .filter_map(|item| overrides_by_item.remove(&item.item_id)) + .max() +} + +fn set_batch_output_override( + overrides_by_item: &mut HashMap, + batch: &TranslationBatch, + max_output_tokens: u32, +) { + for item in &batch.items { + overrides_by_item.insert(item.item_id.clone(), max_output_tokens); + } +} + +fn increment_batch_item_attempts( + attempts_by_item: &mut HashMap, + batch: &TranslationBatch, +) -> usize { + let next = batch + .items + .iter() + .filter_map(|item| attempts_by_item.get(&item.item_id).copied()) + .max() + .unwrap_or(0) + .saturating_add(1); + for item in &batch.items { + attempts_by_item.insert(item.item_id.clone(), next); + } + next +} + +fn adaptive_sizer_mut<'a>( + runtime_sizer: &'a mut Option<(u64, bool, BatchSizer)>, + fallback: Option<&'a mut BatchSizer>, +) -> Option<&'a mut BatchSizer> { + match runtime_sizer { + Some((_, true, sizer)) => Some(sizer), + Some((_, false, _)) => None, + None => fallback, + } +} + fn normalize_batch_for_current_sizer( batch: TranslationBatch, sizer: Option<&BatchSizer>, @@ -1278,6 +1331,46 @@ fn normalize_batch_for_current_sizer( repack_batch_with_config(batch, target_tokens, max_items, config) } +fn repartition_pending_batches( + pending: &mut VecDeque, + sizer: &BatchSizer, + config: Option<&TranslationRunConfig>, + revision: u64, +) { + if pending.is_empty() { + return; + } + + let mut groups = Vec::::new(); + for batch in pending.drain(..) { + if let Some(group) = groups.last_mut() + && group.mode == batch.mode + && group.kind == batch.kind + && group.section_id == batch.section_id + { + group.items.extend(batch.items); + group.token_estimate = batch_token_estimate(&group.items, config); + continue; + } + groups.push(batch); + } + + let mut rebuilt = VecDeque::new(); + for (group_index, mut group) in groups.into_iter().enumerate() { + group.id = format!("runtime_r{revision}_{group_index}"); + group.token_estimate = batch_token_estimate(&group.items, config); + let target_tokens = sizer.target_tokens_for_mode(group.mode); + let max_items = sizer.max_items_for_mode(group.mode); + rebuilt.extend(repack_batch_with_config( + group, + target_tokens, + max_items, + config, + )); + } + *pending = rebuilt; +} + #[cfg(test)] fn repack_batch( batch: TranslationBatch, @@ -1467,9 +1560,15 @@ where .collect::>(), ); let config = Arc::new(config.clone()); - let concurrency = config.scheduler.concurrency.max(1); let pause_signal = config.pause_signal.clone(); - let request_semaphore = Arc::new(Semaphore::new(concurrency)); + let initial_concurrency = config.scheduler.concurrency.max(1); + let request_limiter = Arc::new(AdaptiveLimiter::new_with_bounds( + initial_concurrency, + 1, + Semaphore::MAX_PERMITS, + Duration::ZERO, + Some(progress.clone()), + )); let all_items: HashMap = batches .iter() @@ -1495,11 +1594,13 @@ where let mut all_results: Vec = Vec::new(); let mut pending: Vec = batches; let max_rounds = 3usize; - let mut single_invalid_attempts: HashMap = HashMap::new(); - let mut transient_attempts: HashMap = HashMap::new(); - let mut escalated_output_tokens: HashMap = HashMap::new(); + let mut single_invalid_attempts_by_item: HashMap = HashMap::new(); + let mut transient_attempts_by_item: HashMap = HashMap::new(); + let mut escalated_output_tokens_by_item: HashMap = HashMap::new(); let mut truncation_alert = TruncationAlertState::default(); let mut stop_dispatch = false; + let mut runtime_sizer: Option<(u64, bool, BatchSizer)> = None; + let mut repartitioned_revision: Option = None; for _round in 0..max_rounds { if pending.is_empty() || stop_dispatch { @@ -1517,22 +1618,85 @@ where while !pending_queue.is_empty() && !stop_dispatch { if let Some(signal) = pause_signal.as_ref() { on_control_boundary(signal)?; - if signal.state() == PauseState::Stopped { - stop_dispatch = true; - break; + match signal.state() { + PauseState::Running => {} + PauseState::Paused => break, + PauseState::Stopped => { + stop_dispatch = true; + break; + } } } + let runtime_snapshot = config + .runtime_settings + .as_ref() + .map(|receiver| receiver.borrow().clone()); + if let Some(runtime) = runtime_snapshot.as_ref() { + if request_limiter.current() != runtime.concurrency { + request_limiter.set_target(runtime.concurrency.max(1), "runtime_config"); + } + if runtime.revision > 0 + && runtime_sizer + .as_ref() + .is_none_or(|(revision, _, _)| *revision != runtime.revision) + { + runtime_sizer = Some(( + runtime.revision, + runtime.batch.adaptive_sizing, + BatchSizer::with_progress( + runtime.batch.target_tokens, + runtime.batch.max_items, + progress.clone(), + ), + )); + } + if runtime.revision > 0 + && repartitioned_revision != Some(runtime.revision) + && let Some((_, _, sizer)) = runtime_sizer.as_ref() + { + repartition_pending_batches( + &mut pending_queue, + sizer, + Some(config.as_ref()), + runtime.revision, + ); + repartitioned_revision = Some(runtime.revision); + } + } + + let dispatch_concurrency = runtime_snapshot + .as_ref() + .map(|runtime| runtime.concurrency) + .unwrap_or(config.scheduler.concurrency) + .max(1); + if tasks.len() >= dispatch_concurrency { + break; + } + let Some(batch) = pending_queue.pop_front() else { break; }; - let output_override = escalated_output_tokens.remove(&batch.id); - let mut normalized = normalize_batch_for_current_sizer( - batch, - batch_sizer.as_deref(), - Some(config.as_ref()), - ); + let pending_output_override = + take_batch_output_override(&mut escalated_output_tokens_by_item, &batch); + let active_sizer = runtime_sizer + .as_ref() + .map(|(_, _, sizer)| sizer) + .or(batch_sizer.as_deref()); + let mut normalized = + normalize_batch_for_current_sizer(batch, active_sizer, Some(config.as_ref())); + if let Some(output_override) = pending_output_override { + for part in &normalized { + set_batch_output_override( + &mut escalated_output_tokens_by_item, + part, + output_override, + ); + } + } let batch = normalized.remove(0); + let output_override = + take_batch_output_override(&mut escalated_output_tokens_by_item, &batch); for extra in normalized.into_iter().rev() { pending_queue.push_front(extra); } @@ -1545,9 +1709,10 @@ where let provider = provider.clone(); let library = library.clone(); let config = config.clone(); + let runtime_settings = config.runtime_settings.clone(); let rate_controller = rate_controller.clone(); let progress = progress.clone(); - let request_semaphore = request_semaphore.clone(); + let request_limiter = request_limiter.clone(); let section_titles = section_titles.clone(); let pause_signal = pause_signal.clone(); @@ -1577,6 +1742,12 @@ where }; } let request_permit = loop { + if let Some(receiver) = runtime_settings.as_ref() { + let target = receiver.borrow().concurrency.max(1); + if request_limiter.current() != target { + request_limiter.set_target(target, "runtime_config"); + } + } if let Some(signal) = pause_signal.as_ref() && signal.wait_until_running_or_stopped().await == PauseState::Stopped { @@ -1591,7 +1762,7 @@ where request_permit: None, }; } - match request_semaphore.clone().try_acquire_owned() { + match request_limiter.try_acquire() { Ok(permit) => break permit, Err(TryAcquireError::NoPermits) => { tokio::time::sleep(Duration::from_millis(25)).await; @@ -1613,7 +1784,13 @@ where } }; - let permit = match rate_controller.as_ref() { + let adaptive_concurrency = runtime_settings + .as_ref() + .map(|receiver| receiver.borrow().adaptive_concurrency) + // Existing library callers express the enabled state by + // supplying a controller and no runtime receiver. + .unwrap_or_else(|| rate_controller.is_some()); + let permit = match rate_controller.as_ref().filter(|_| adaptive_concurrency) { Some(controller) => match controller.acquire().await { Ok(permit) => Some(permit), Err(_) => { @@ -1634,6 +1811,15 @@ where None => None, }; + let mut effective_config = config.as_ref().clone(); + if let Some(receiver) = runtime_settings.as_ref() { + let runtime = receiver.borrow().clone(); + effective_config.scheduler.concurrency = runtime.concurrency.max(1); + effective_config.batch_max_output_tokens = runtime.batch_max_output_tokens; + effective_config.runtime_settings = Some(runtime.frozen_receiver()); + } + let config = Arc::new(effective_config); + let context_pairs = match strict_context_pairs { Some(pairs) => pairs, None => context_pairs_for_batch(&batch, &config).await, @@ -1656,6 +1842,8 @@ where .flatten(); let request_id = format!("batch_{}", batch.id); + let (runtime_config_revision, provider_max_attempts) = + config.request_runtime_metadata(); progress.emit(bookforge_core::ProgressEvent::RequestStarted { request_id: request_id.clone(), batch_id: Some(batch.id.clone()), @@ -1668,6 +1856,8 @@ where max_output_tokens: Some(max_output_tokens), active_requests: 0, target_concurrency: config.scheduler.concurrency, + runtime_config_revision, + provider_max_attempts, timestamp_ms: bookforge_core::progress::now_ms(), }); @@ -1700,6 +1890,20 @@ where }); } + if tasks.is_empty() { + if stop_dispatch || pending_queue.is_empty() { + continue; + } + if let Some(signal) = pause_signal.as_ref() + && signal.state() == PauseState::Paused + && wait_for_batch_resume_or_stop(signal, &mut on_control_boundary).await? + == PauseState::Stopped + { + stop_dispatch = true; + } + continue; + } + let joined = match pause_signal.as_ref() { Some(signal) if signal.state() == PauseState::Paused => { tokio::select! { @@ -1792,14 +1996,21 @@ where error_kind: None, }); - if let Some(ref controller) = rate_controller { + let adaptive_concurrency = config + .runtime_settings + .as_ref() + .map(|receiver| receiver.borrow().adaptive_concurrency) + .unwrap_or_else(|| rate_controller.is_some()); + if let Some(controller) = rate_controller.as_ref().filter(|_| adaptive_concurrency) { controller.observe(request_status, latency_ms); } match result { Ok(batch_result) => { truncation_alert.observe_resolved(); - if let Some(ref mut sizer) = batch_sizer { + if let Some(sizer) = + adaptive_sizer_mut(&mut runtime_sizer, batch_sizer.as_deref_mut()) + { sizer.on_success_for_mode(batch.mode, latency_ms); } // Publish completed segments to the context registry as @@ -1872,7 +2083,11 @@ where ), timestamp_ms: bookforge_core::progress::now_ms(), }); - escalated_output_tokens.insert(batch.id.clone(), next_max_output_tokens); + set_batch_output_override( + &mut escalated_output_tokens_by_item, + &batch, + next_max_output_tokens, + ); pending_queue.push_back(batch); } Err(LlmError::InvalidResponse(_)) if batch.kind == BatchKind::Repair => { @@ -1918,7 +2133,9 @@ where Err(error @ LlmError::InvalidResponse(_)) if request_status == RequestStatus::Truncated && batch.items.len() == 1 => { - if let Some(ref mut sizer) = batch_sizer { + if let Some(sizer) = + adaptive_sizer_mut(&mut runtime_sizer, batch_sizer.as_deref_mut()) + { sizer.on_truncation_for_mode(batch.mode); } truncation_alert.observe_unresolved(&progress); @@ -1958,11 +2175,9 @@ where } Err(error @ LlmError::InvalidResponse(_)) if batch.items.len() == 1 => { truncation_alert.observe_resolved(); - let attempts = single_invalid_attempts - .entry(batch.id.clone()) - .and_modify(|count| *count += 1) - .or_insert(1); - if *attempts < config.scheduler.max_attempts.max(1) { + let attempts = + increment_batch_item_attempts(&mut single_invalid_attempts_by_item, &batch); + if attempts < config.scheduler.max_attempts.max(1) { progress.emit(bookforge_core::ProgressEvent::Warning { kind: "single_item_batch_invalid_response_retry".to_string(), message: format!( @@ -2009,7 +2224,9 @@ where } } Err(LlmError::InvalidResponse(_)) if batch.items.len() > 1 => { - if let Some(ref mut sizer) = batch_sizer { + if let Some(sizer) = + adaptive_sizer_mut(&mut runtime_sizer, batch_sizer.as_deref_mut()) + { if request_status == RequestStatus::Truncated { sizer.on_truncation_for_mode(batch.mode); } else { @@ -2052,11 +2269,9 @@ where } Err(ref error) if is_transient(error) && batch.kind == BatchKind::Translation => { truncation_alert.observe_resolved(); - let attempts = transient_attempts - .entry(batch.id.clone()) - .and_modify(|count| *count += 1) - .or_insert(1); - if *attempts < config.scheduler.max_attempts.max(1) { + let attempts = + increment_batch_item_attempts(&mut transient_attempts_by_item, &batch); + if attempts < config.scheduler.max_attempts.max(1) { progress.emit(bookforge_core::ProgressEvent::Warning { kind: "batch_transient_retry".to_string(), message: format!( @@ -2319,7 +2534,19 @@ where let mut repair_tasks = JoinSet::::new(); while !repair_batches.is_empty() || !repair_tasks.is_empty() { - while repair_tasks.len() < concurrency { + loop { + let runtime_snapshot = config + .runtime_settings + .as_ref() + .map(|receiver| receiver.borrow().clone()); + let concurrency = runtime_snapshot + .as_ref() + .map(|runtime| runtime.concurrency) + .unwrap_or(config.scheduler.concurrency) + .max(1); + if repair_tasks.len() >= concurrency { + break; + } let Some(repair_batch) = repair_batches.pop_front() else { break; }; @@ -2331,7 +2558,13 @@ where let provider = provider.clone(); let library = library.clone(); - let config = config.clone(); + let mut task_config = config.as_ref().clone(); + if let Some(runtime) = runtime_snapshot { + task_config.scheduler.concurrency = runtime.concurrency.max(1); + task_config.batch_max_output_tokens = runtime.batch_max_output_tokens; + task_config.runtime_settings = Some(runtime.frozen_receiver()); + } + let config = Arc::new(task_config); let repair_errors = repair_errors.clone(); let progress = progress.clone(); let section_titles = section_titles.clone(); @@ -2342,6 +2575,8 @@ where let max_output_tokens = capped_batch_max_output_tokens(&repair_batch, &config, is_reasoning); let request_id = format!("batch_{}", repair_batch.id); + let (runtime_config_revision, provider_max_attempts) = + config.request_runtime_metadata(); progress.emit(bookforge_core::ProgressEvent::RequestStarted { request_id: request_id.clone(), @@ -2355,6 +2590,8 @@ where max_output_tokens: Some(max_output_tokens), active_requests: 0, target_concurrency: config.scheduler.concurrency, + runtime_config_revision, + provider_max_attempts, timestamp_ms: bookforge_core::progress::now_ms(), }); @@ -2410,7 +2647,21 @@ where response_format: ResponseFormat::Json, temperature: 0.1, max_output_tokens: Some(max_output_tokens), - metadata: RequestMetadata::default(), + metadata: RequestMetadata { + segment_id: Some(format!("batch_{}", repair_batch.id)), + block_ids: repair_batch + .items + .iter() + .map(|item| item.block_id.0.clone()) + .collect(), + prompt_template: Some(repair_template.name.clone()), + prompt_version: Some(repair_template.version.clone()), + provider: Some(config.provider.clone()), + model: Some(config.model.clone()), + source_checksum: None, + runtime_config_revision, + provider_max_attempts, + }, }) .await { @@ -2753,6 +3004,7 @@ async fn translate_one_batch( let max_tokens = max_output_tokens_override .unwrap_or_else(|| capped_batch_max_output_tokens(&batch, config, provider.is_reasoning())); + let (runtime_config_revision, provider_max_attempts) = config.request_runtime_metadata(); let response = provider .complete(CompletionRequest { @@ -2769,6 +3021,8 @@ async fn translate_one_batch( provider: Some(config.provider.clone()), model: Some(config.model.clone()), source_checksum: None, + runtime_config_revision, + provider_max_attempts, }, }) .await; @@ -3980,6 +4234,7 @@ mod tests { assert_eq!(split[1].items.len(), 2); } + use crate::EngineRuntimeSettings; use crate::provider::{ CompletionRequest, CompletionResponse, LlmProvider as LlmProviderTrait, ProviderCapabilities, Result as ProviderResult, @@ -4159,6 +4414,7 @@ mod tests { style: None, entities: None, pause_signal: None, + runtime_settings: None, } } @@ -4732,6 +4988,66 @@ mod tests { } } + type CapturedRequestBudgets = Arc)>>>; + + #[derive(Clone)] + struct GatedPromptEchoProvider { + started: Arc, + release: Arc, + budgets: CapturedRequestBudgets, + } + + impl GatedPromptEchoProvider { + fn new() -> Self { + Self { + started: Arc::new(AtomicUsize::new(0)), + release: Arc::new(tokio::sync::Semaphore::new(0)), + budgets: Arc::new(Mutex::new(Vec::new())), + } + } + } + + impl LlmProviderTrait for GatedPromptEchoProvider { + async fn complete(&self, request: CompletionRequest) -> ProviderResult { + let request_index = self.started.fetch_add(1, Ordering::AcqRel); + self.budgets + .lock() + .unwrap() + .push((request_index, request.max_output_tokens)); + self.release + .acquire() + .await + .expect("test gate should remain open") + .forget(); + let item_ids = item_ids_from_batch_prompt(&request.user); + Ok(CompletionResponse { + content: serde_json::json!({ + "items": item_ids + .into_iter() + .map(|id| serde_json::json!({ + "id": id, + "translation": format!("[it] {id}"), + })) + .collect::>(), + }) + .to_string(), + input_tokens: Some(1), + input_cached_tokens: Some(0), + output_tokens: Some(1), + finish_reason: FinishReason::Stop, + provider_latency_ms: 0, + raw: serde_json::json!({}), + }) + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + supports_json_response_format: true, + supports_usage_tokens: true, + } + } + } + fn item_ids_from_batch_prompt(user_prompt: &str) -> Vec { let Some(after_input) = user_prompt.split("Input:\n").nth(1) else { return Vec::new(); @@ -5071,4 +5387,405 @@ mod tests { .await .expect("batch scheduler must not deadlock"); } + + #[tokio::test] + async fn live_batch_settings_bound_dispatch_and_update_later_request_budget() { + let segment = make_segment( + "seg_live", + (0..4) + .map(|index| plain_block(&format!("text_{index}"))) + .collect(), + vec![], + ); + let segments = vec![segment]; + let batch_config = BatchConfig { + enabled: true, + target_tokens: 16_000, + max_items: 1, + adaptive_sizing: false, + split_on_json_failure: true, + repair_invalid_items: true, + }; + let batches = + build_translation_batches(&segments, &batch_config, TranslationProfile::Balanced); + assert_eq!(batches.len(), 4); + + let provider = GatedPromptEchoProvider::new(); + let events = Arc::new(Mutex::new(Vec::new())); + let mut config = test_run_config(); + config.scheduler.concurrency = 2; + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: batch_config, + batch_max_output_tokens: Some(256), + concurrency: 2, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + config.runtime_settings = Some(receiver); + + let run_provider = provider.clone(); + let run_segments = segments.clone(); + let run_events = events.clone(); + let run = tokio::spawn(async move { + translate_batches_with_callback( + run_provider, + batches, + &run_segments, + &config, + Arc::new(TelemetryLog::new()), + None, + None, + Arc::new(RecordingProgress { events: run_events }), + None, + |_| Ok(()), + ) + .await + .expect("batch translation should finish") + }); + + wait_for_atomic_count(&provider.started, 2).await; + runtime.revision = 2; + runtime.concurrency = 1; + runtime.batch_max_output_tokens = Some(1_024); + sender.send_replace(runtime); + provider.release.add_permits(2); + + wait_for_atomic_count(&provider.started, 3).await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!( + provider.started.load(Ordering::Acquire), + 3, + "fourth batch must wait after the live concurrency shrink" + ); + + provider.release.add_permits(1); + wait_for_atomic_count(&provider.started, 4).await; + provider.release.add_permits(1); + let translations = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("run should finish") + .expect("task should join"); + assert_eq!(translations.len(), 1); + let mut indexed_budgets = provider.budgets.lock().unwrap().clone(); + indexed_budgets.sort_unstable_by_key(|(request_index, _)| *request_index); + let budgets = indexed_budgets + .into_iter() + .map(|(_, budget)| budget) + .collect::>(); + assert_eq!(budgets.len(), 4); + assert_eq!(budgets[..2], [Some(256), Some(256)]); + assert_eq!(budgets[2..], [Some(512), Some(512)]); + let request_revisions = events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + bookforge_core::ProgressEvent::RequestStarted { + runtime_config_revision, + provider_max_attempts, + .. + } => Some((*runtime_config_revision, *provider_max_attempts)), + _ => None, + }) + .collect::>(); + assert_eq!( + request_revisions, + vec![ + (Some(1), Some(1)), + (Some(1), Some(1)), + (Some(2), Some(1)), + (Some(2), Some(1)), + ] + ); + } + + #[tokio::test] + async fn live_batch_revision_merges_only_unstarted_items() { + let segment = make_segment( + "seg_repartition", + (0..4) + .map(|index| plain_block(&format!("text_{index}"))) + .collect(), + vec![], + ); + let segments = vec![segment]; + let batch_config = BatchConfig { + enabled: true, + target_tokens: 16_000, + max_items: 1, + adaptive_sizing: false, + split_on_json_failure: true, + repair_invalid_items: true, + }; + let batches = + build_translation_batches(&segments, &batch_config, TranslationProfile::Balanced); + assert_eq!(batches.len(), 4); + + let provider = GatedPromptEchoProvider::new(); + let events = Arc::new(Mutex::new(Vec::new())); + let mut config = test_run_config(); + config.scheduler.concurrency = 1; + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: batch_config, + batch_max_output_tokens: None, + concurrency: 1, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + config.runtime_settings = Some(receiver); + + let run_provider = provider.clone(); + let run_segments = segments.clone(); + let run_events = events.clone(); + let run = tokio::spawn(async move { + translate_batches_with_callback( + run_provider, + batches, + &run_segments, + &config, + Arc::new(TelemetryLog::new()), + None, + None, + Arc::new(RecordingProgress { events: run_events }), + None, + |_| Ok(()), + ) + .await + .expect("batch translation should finish") + }); + + wait_for_atomic_count(&provider.started, 1).await; + runtime.revision = 2; + runtime.batch.max_items = 3; + sender.send_replace(runtime); + provider.release.add_permits(1); + + wait_for_atomic_count(&provider.started, 2).await; + provider.release.add_permits(1); + let translations = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("run should finish") + .expect("task should join"); + assert_eq!(translations.len(), 1); + assert_eq!( + provider.started.load(Ordering::Acquire), + 2, + "the three unstarted one-item batches should merge into one request" + ); + let request_shapes = events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + bookforge_core::ProgressEvent::RequestStarted { + items, + runtime_config_revision, + .. + } => Some((*items, *runtime_config_revision)), + _ => None, + }) + .collect::>(); + assert_eq!(request_shapes, vec![(1, Some(1)), (3, Some(2))]); + } + + #[tokio::test] + async fn paused_batch_reconfigure_repartitions_before_resume_dispatch() { + let segment = make_segment( + "seg_paused_repartition", + (0..4) + .map(|index| plain_block(&format!("text_{index}"))) + .collect(), + vec![], + ); + let segments = vec![segment]; + let batch_config = BatchConfig { + enabled: true, + target_tokens: 16_000, + max_items: 1, + adaptive_sizing: false, + split_on_json_failure: true, + repair_invalid_items: true, + }; + let batches = + build_translation_batches(&segments, &batch_config, TranslationProfile::Balanced); + assert_eq!(batches.len(), 4); + + let provider = GatedPromptEchoProvider::new(); + let events = Arc::new(Mutex::new(Vec::new())); + let signal = crate::PauseSignal::new(); + signal.pause(); + let mut config = test_run_config(); + config.scheduler.concurrency = 1; + config.pause_signal = Some(signal.clone()); + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: batch_config, + batch_max_output_tokens: None, + concurrency: 1, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + config.runtime_settings = Some(receiver); + + let run_provider = provider.clone(); + let run_segments = segments.clone(); + let run_events = events.clone(); + let run = tokio::spawn(async move { + translate_batches_with_callback( + run_provider, + batches, + &run_segments, + &config, + Arc::new(TelemetryLog::new()), + None, + None, + Arc::new(RecordingProgress { events: run_events }), + None, + |_| Ok(()), + ) + .await + .expect("batch translation should finish") + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(provider.started.load(Ordering::Acquire), 0); + runtime.revision = 2; + runtime.batch.max_items = 4; + runtime.provider_max_attempts = 3; + sender.send_replace(runtime); + signal.resume(); + + wait_for_atomic_count(&provider.started, 1).await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!( + provider.started.load(Ordering::Acquire), + 1, + "all pending items should repartition into the new one-request shape" + ); + provider.release.add_permits(1); + let translations = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("run should finish") + .expect("task should join"); + assert_eq!(translations.len(), 1); + let request_shapes = events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + bookforge_core::ProgressEvent::RequestStarted { + items, + runtime_config_revision, + provider_max_attempts, + .. + } => Some((*items, *runtime_config_revision, *provider_max_attempts)), + _ => None, + }) + .collect::>(); + assert_eq!(request_shapes, vec![(4, Some(2), Some(3))]); + } + + #[tokio::test] + async fn live_adaptive_concurrency_enable_gates_later_requests() { + let segment = make_segment( + "seg_adaptive_live", + (0..4) + .map(|index| plain_block(&format!("text_{index}"))) + .collect(), + vec![], + ); + let segments = vec![segment]; + let batch_config = BatchConfig { + enabled: true, + target_tokens: 16_000, + max_items: 1, + adaptive_sizing: false, + split_on_json_failure: true, + repair_invalid_items: true, + }; + let batches = + build_translation_batches(&segments, &batch_config, TranslationProfile::Balanced); + + let provider = GatedPromptEchoProvider::new(); + let mut config = test_run_config(); + config.scheduler.concurrency = 2; + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: batch_config, + batch_max_output_tokens: None, + concurrency: 2, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + config.runtime_settings = Some(receiver); + let adaptive_limiter = Arc::new(AdaptiveLimiter::new_with_bounds( + 1, + 1, + 4, + Duration::ZERO, + None, + )); + let controller = Arc::new(ProviderRateController::new( + adaptive_limiter, + crate::RateControllerConfig::for_target(1), + )); + + let run_provider = provider.clone(); + let run_segments = segments.clone(); + let run = tokio::spawn(async move { + translate_batches_with_callback( + run_provider, + batches, + &run_segments, + &config, + Arc::new(TelemetryLog::new()), + Some(controller), + None, + Arc::new(bookforge_core::NullProgressSink), + None, + |_| Ok(()), + ) + .await + .expect("batch translation should finish") + }); + + wait_for_atomic_count(&provider.started, 2).await; + runtime.revision = 2; + runtime.adaptive_concurrency = true; + sender.send_replace(runtime); + provider.release.add_permits(2); + + wait_for_atomic_count(&provider.started, 3).await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!( + provider.started.load(Ordering::Acquire), + 3, + "enabling the one-permit adaptive gate must hold the fourth request" + ); + provider.release.add_permits(1); + wait_for_atomic_count(&provider.started, 4).await; + provider.release.add_permits(1); + + let translations = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("run should finish") + .expect("task should join"); + assert_eq!(translations.len(), 1); + } + + async fn wait_for_atomic_count(value: &AtomicUsize, expected: usize) { + tokio::time::timeout(Duration::from_secs(2), async { + while value.load(Ordering::Acquire) < expected { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("request count should be reached"); + } } diff --git a/crates/bookforge-llm/src/concurrency.rs b/crates/bookforge-llm/src/concurrency.rs index 362823f..b15816a 100644 --- a/crates/bookforge-llm/src/concurrency.rs +++ b/crates/bookforge-llm/src/concurrency.rs @@ -3,7 +3,7 @@ use std::sync::{ atomic::{AtomicU8, AtomicUsize, Ordering}, }; use std::time::{Duration, Instant}; -use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore}; +use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore, TryAcquireError}; use bookforge_core::{ProgressEvent, ProgressSink}; @@ -182,6 +182,14 @@ impl AdaptiveLimiter { }) } + pub fn try_acquire(&self) -> Result { + let permit = self.semaphore.clone().try_acquire_owned()?; + Ok(AdaptivePermit { + permit: Some(permit), + permits_to_burn: self.permits_to_burn.clone(), + }) + } + pub fn on_success(&self) { let now = Instant::now(); let mut last = self.last_grow.lock().unwrap(); @@ -252,7 +260,24 @@ impl AdaptiveLimiter { } else { let delta = previous - new; *state = new; - self.permits_to_burn.fetch_add(delta, Ordering::AcqRel); + // Remove already-idle permits immediately. A drop-only burn + // counter is insufficient: waiters can otherwise acquire free + // permits after a shrink and temporarily exceed the new target. + // Any permit racing with this loop is held by a caller and is + // accounted for by the remaining deferred burns. + let mut deferred = delta; + while deferred > 0 { + match self.semaphore.clone().try_acquire_owned() { + Ok(permit) => { + permit.forget(); + deferred -= 1; + } + Err(_) => break, + } + } + if deferred > 0 { + self.permits_to_burn.fetch_add(deferred, Ordering::AcqRel); + } } if let Some(ref progress) = self.progress { @@ -367,6 +392,23 @@ mod tests { drop(p1); } + #[tokio::test] + async fn shrink_removes_idle_permits_before_waiters_can_acquire_them() { + let limiter = AdaptiveLimiter::new_with_bounds(2, 1, 8, Duration::ZERO, None); + assert_eq!(limiter.semaphore.available_permits(), 2); + + limiter.set_target(1, "test"); + + assert_eq!(limiter.current(), 1); + assert_eq!(limiter.semaphore.available_permits(), 1); + let held = limiter.try_acquire().expect("one permit should remain"); + assert!( + limiter.try_acquire().is_err(), + "a second permit must not remain available after shrinking" + ); + drop(held); + } + #[tokio::test] async fn drop_does_not_underflow_when_no_burn_pending() { let limiter = AdaptiveLimiter::new(2, 4); @@ -385,6 +427,7 @@ mod tests { limiter.on_success(); limiter.on_success(); limiter.on_success(); + let held = acquire_n(&limiter, 4).await; // Shrink 4 -> 1: burn counter = 3 limiter.on_rate_limit(); limiter.on_rate_limit(); @@ -394,5 +437,7 @@ mod tests { limiter.on_success(); limiter.on_success(); assert_eq!(limiter.permits_to_burn.load(Ordering::Acquire), 0); + drop(held); + assert_eq!(limiter.semaphore.available_permits(), 4); } } diff --git a/crates/bookforge-llm/src/double_check.rs b/crates/bookforge-llm/src/double_check.rs index ee5968c..8dd564f 100644 --- a/crates/bookforge-llm/src/double_check.rs +++ b/crates/bookforge-llm/src/double_check.rs @@ -472,6 +472,7 @@ where .map_err(|e| LlmError::Provider(e.to_string()))?; wait_for_double_check_pause(config, "double-check audit").await?; + let (runtime_config_revision, provider_max_attempts) = config.request_runtime_metadata(); let response = provider .complete(CompletionRequest { @@ -485,6 +486,8 @@ where prompt_version: Some(library.double_check_batch.version.clone()), provider: Some(config.provider.clone()), model: Some(config.model.clone()), + runtime_config_revision, + provider_max_attempts, ..RequestMetadata::default() }, }) @@ -606,6 +609,7 @@ where .map_err(|e| LlmError::Provider(e.to_string()))?; wait_for_double_check_pause(config, "double-check correction").await?; + let (runtime_config_revision, provider_max_attempts) = config.request_runtime_metadata(); let response = provider .complete(CompletionRequest { @@ -619,6 +623,8 @@ where prompt_version: Some(library.correct_batch.version.clone()), provider: Some(config.provider.clone()), model: Some(config.model.clone()), + runtime_config_revision, + provider_max_attempts, ..RequestMetadata::default() }, }) @@ -904,6 +910,7 @@ mod tests { style: None, entities: None, pause_signal: None, + runtime_settings: None, } } diff --git a/crates/bookforge-llm/src/lib.rs b/crates/bookforge-llm/src/lib.rs index 7651fd3..f2ca235 100644 --- a/crates/bookforge-llm/src/lib.rs +++ b/crates/bookforge-llm/src/lib.rs @@ -31,9 +31,9 @@ pub use rate_controller::{ ProviderRateController, RateControllerConfig, RequestObservation, RequestStatus, }; pub use scheduler::{ - CompletedContext, ContextRegistry, ContextRunConfig, EntityRunConfig, GlossaryRunConfig, - QaIssue, QaSegmentReview, SegmentTranslation, StyleRunConfig, TranslationRunConfig, - qa_segments, translate_segments, translate_segments_with_callback, + CompletedContext, ContextRegistry, ContextRunConfig, EngineRuntimeSettings, EntityRunConfig, + GlossaryRunConfig, QaIssue, QaSegmentReview, SegmentTranslation, StyleRunConfig, + TranslationRunConfig, qa_segments, translate_segments, translate_segments_with_callback, translate_segments_with_control, }; pub use telemetry::{TelemetryLog, telemetry_summary}; diff --git a/crates/bookforge-llm/src/provider.rs b/crates/bookforge-llm/src/provider.rs index c686b7d..6c04baa 100644 --- a/crates/bookforge-llm/src/provider.rs +++ b/crates/bookforge-llm/src/provider.rs @@ -75,6 +75,13 @@ pub struct RequestMetadata { pub provider: Option, pub model: Option, pub source_checksum: Option, + /// Runtime override revision frozen at the request boundary. `None` is + /// retained for library callers that do not opt into live settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_config_revision: Option, + /// Provider-internal attempt limit frozen for this complete call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_max_attempts: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -771,7 +778,11 @@ impl LlmProvider for OpenAiCompatibleProvider { body["response_format"] = json!({"type": "json_object"}); } - let max_attempts = self.config.provider_max_attempts.max(1); + let max_attempts = request + .metadata + .provider_max_attempts + .unwrap_or(self.config.provider_max_attempts) + .max(1); let body_len = serde_json::to_string(&body).map(|s| s.len()).unwrap_or(0); let max_backoff = Duration::from_secs(self.config.max_backoff_seconds); let policy = self.config.retry_after_policy; @@ -1339,4 +1350,83 @@ mod tests { server_handle.abort(); } + + #[tokio::test] + async fn request_metadata_freezes_provider_attempts_per_call() { + use std::sync::{Arc, atomic::AtomicUsize}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let request_count = Arc::new(AtomicUsize::new(0)); + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return, + Err(err) => panic!("test listener should bind: {err}"), + }; + let addr = listener.local_addr().unwrap(); + let server_count = request_count.clone(); + let server_handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + server_count.fetch_add(1, Ordering::SeqCst); + let mut buf = vec![0u8; 8_192]; + let _ = stream.read(&mut buf).await; + let body = br#"{"error":{"message":"retry me"}}"#; + let header = format!( + "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()).await; + let _ = stream.write_all(body).await; + let _ = stream.shutdown().await; + } + }); + + let provider = OpenAiCompatibleProvider::new(OpenAiCompatibleConfig { + base_url: format!("http://{addr}"), + // Local providers intentionally permit an absent API key. + api_key_env: "OLLAMA_API_KEY".to_string(), + model: "test-model".to_string(), + timeout_seconds: 10, + provider_max_attempts: 6, + thinking_disabled: true, + retry_after_policy: RetryAfterPolicy::RespectHeader, + max_backoff_seconds: 1, + max_idle_per_host: 4, + json_mode: bookforge_core::JsonMode::PromptOnly, + }) + .unwrap(); + let request = |revision, attempts| CompletionRequest { + system: "translate".to_string(), + user: "hello".to_string(), + response_format: ResponseFormat::Json, + temperature: 0.2, + max_output_tokens: Some(256), + metadata: RequestMetadata { + runtime_config_revision: Some(revision), + provider_max_attempts: Some(attempts), + ..RequestMetadata::default() + }, + }; + + provider + .complete(request(1, 2)) + .await + .expect_err("the test server always fails"); + assert_eq!(request_count.load(Ordering::SeqCst), 2); + + provider + .complete(request(2, 4)) + .await + .expect_err("the test server always fails"); + assert_eq!( + request_count.load(Ordering::SeqCst), + 6, + "the later call must use its new frozen attempt limit" + ); + + server_handle.abort(); + } } diff --git a/crates/bookforge-llm/src/qa_batch.rs b/crates/bookforge-llm/src/qa_batch.rs index f0e2923..c3bd65c 100644 --- a/crates/bookforge-llm/src/qa_batch.rs +++ b/crates/bookforge-llm/src/qa_batch.rs @@ -238,6 +238,7 @@ where let rendered = template .render(&vars) .map_err(|e| LlmError::Provider(e.to_string()))?; + let (runtime_config_revision, provider_max_attempts) = config.request_runtime_metadata(); let response = provider .complete(CompletionRequest { system: rendered.system, @@ -256,6 +257,8 @@ where provider: Some(config.provider.clone()), model: Some(config.model.clone()), source_checksum: None, + runtime_config_revision, + provider_max_attempts, }, }) .await?; @@ -487,6 +490,7 @@ mod tests { style: None, entities: None, pause_signal: None, + runtime_settings: None, } } diff --git a/crates/bookforge-llm/src/scheduler.rs b/crates/bookforge-llm/src/scheduler.rs index d02c315..07d83f4 100644 --- a/crates/bookforge-llm/src/scheduler.rs +++ b/crates/bookforge-llm/src/scheduler.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; use bookforge_core::{ - config::{ContextScope, TranslationProfile}, + config::{BatchConfig, ContextScope, ResolvedRunSettings, TranslationProfile}, glossary::{GlossaryFormat, GlossaryPromptTerm}, ir::BlockId, scheduler::SchedulerConfig, @@ -11,7 +11,10 @@ use bookforge_core::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use tokio::{sync::mpsc, task::JoinSet}; +use tokio::{ + sync::{mpsc, watch}, + task::JoinSet, +}; use crate::{ concurrency::{PauseSignal, PauseState}, @@ -55,6 +58,52 @@ pub struct TranslationRunConfig { pub entities: Option, /// Cooperative control signal checked before dispatching each new segment. pub pause_signal: Option, + /// Optional live, cache-safe runtime settings. Each dispatcher snapshots + /// this receiver only at a documented request/batch boundary. + pub runtime_settings: Option>, +} + +impl TranslationRunConfig { + pub(crate) fn request_runtime_metadata(&self) -> (Option, Option) { + self.runtime_settings + .as_ref() + .map(|receiver| { + let runtime = receiver.borrow(); + ( + Some(runtime.revision), + Some(runtime.provider_max_attempts.max(1)), + ) + }) + .unwrap_or((None, None)) + } +} + +#[derive(Debug, Clone)] +pub struct EngineRuntimeSettings { + pub revision: u64, + pub batch: BatchConfig, + pub batch_max_output_tokens: Option, + pub concurrency: usize, + pub provider_max_attempts: usize, + pub adaptive_concurrency: bool, +} + +impl EngineRuntimeSettings { + pub fn from_resolved(revision: u64, settings: &ResolvedRunSettings) -> Self { + Self { + revision, + batch: settings.batch.clone(), + batch_max_output_tokens: settings.provider.batch_max_output_tokens, + concurrency: settings.scheduler.concurrency.max(1), + provider_max_attempts: settings.provider.provider_max_attempts.max(1), + adaptive_concurrency: settings.adaptive_concurrency, + } + } + + pub(crate) fn frozen_receiver(self) -> watch::Receiver { + let (_sender, receiver) = watch::channel(self); + receiver + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -541,7 +590,6 @@ where let library = Arc::new(PromptLibrary::global().clone()); let provider = Arc::new(provider); let config = Arc::new(config.clone()); - let concurrency = config.scheduler.concurrency; let pause_signal = config.pause_signal.clone(); let mut dispatch_segments = segments.to_vec(); dispatch_segments.sort_by_key(|segment| segment.ordinal); @@ -551,7 +599,7 @@ where let mut translations = Vec::with_capacity(segments.len()); loop { - while !stop_dispatch && next_index < dispatch_segments.len() && tasks.len() < concurrency { + while !stop_dispatch && next_index < dispatch_segments.len() { if let Some(signal) = pause_signal.as_ref() { on_control_boundary(signal)?; match signal.state() { @@ -571,11 +619,32 @@ where PauseState::Paused => break, } } + // A paused dispatcher may have waited while the watcher published + // a newer revision. Snapshot only after the control boundary so a + // Resume can never release work under the pre-pause settings. + let runtime_snapshot = config + .runtime_settings + .as_ref() + .map(|receiver| receiver.borrow().clone()); + let concurrency = runtime_snapshot + .as_ref() + .map(|runtime| runtime.concurrency) + .unwrap_or(config.scheduler.concurrency) + .max(1); + if tasks.len() >= concurrency { + break; + } let segment = dispatch_segments[next_index].clone(); next_index += 1; let provider = provider.clone(); - let config = config.clone(); + let mut task_config = config.as_ref().clone(); + if let Some(runtime) = runtime_snapshot { + task_config.scheduler.concurrency = runtime.concurrency.max(1); + task_config.batch_max_output_tokens = runtime.batch_max_output_tokens; + task_config.runtime_settings = Some(runtime.frozen_receiver()); + } + let config = Arc::new(task_config); let library = library.clone(); tasks.spawn(async move { @@ -700,6 +769,7 @@ where P: LlmProvider, { let rendered = render_qa_prompt(&library.qa, segment, translation, config)?; + let (runtime_config_revision, provider_max_attempts) = config.request_runtime_metadata(); let response = provider .complete(CompletionRequest { system: rendered.system, @@ -715,6 +785,8 @@ where provider: Some(config.provider.clone()), model: Some(config.model.clone()), source_checksum: Some(segment.checksum.clone()), + runtime_config_revision, + provider_max_attempts, }, }) .await?; @@ -976,6 +1048,7 @@ where let max_output_tokens = config .max_output_tokens .unwrap_or_else(|| max_output_tokens(segment, mode, provider.is_reasoning())); + let (runtime_config_revision, provider_max_attempts) = config.request_runtime_metadata(); let request = CompletionRequest { system: rendered.system, user: rendered.user, @@ -990,6 +1063,8 @@ where provider: Some(config.provider.clone()), model: Some(config.model.clone()), source_checksum: Some(segment.checksum.clone()), + runtime_config_revision, + provider_max_attempts, }, }; let response = provider.complete(request).await?; @@ -1778,6 +1853,167 @@ mod tests { assert_eq!(translations[1].segment_id.0, "seg_b"); } + #[tokio::test] + async fn live_concurrency_shrink_bounds_later_segment_dispatch() { + let segments = (0..4) + .map(|ordinal| segment(&format!("seg_{ordinal}"), ordinal, vec![("b0", "Text")])) + .collect::>(); + let provider = GatedProvider::new(); + let mut cfg = config(); + cfg.scheduler.concurrency = 2; + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: TranslationProfile::V1Fast.resolve().batch, + batch_max_output_tokens: None, + concurrency: 2, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + cfg.runtime_settings = Some(receiver); + + let run_provider = provider.clone(); + let run = tokio::spawn(async move { + translate_segments(run_provider, &segments, &cfg) + .await + .expect("translation should finish") + }); + + wait_for_request_count(&provider.started, 2).await; + runtime.revision = 2; + runtime.concurrency = 1; + sender.send_replace(runtime); + provider.release.add_permits(2); + + wait_for_request_count(&provider.started, 3).await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!( + provider.started.load(Ordering::Acquire), + 3, + "after shrinking to one, the fourth request must wait for the third" + ); + + provider.release.add_permits(1); + wait_for_request_count(&provider.started, 4).await; + provider.release.add_permits(1); + let translations = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("run should finish") + .expect("task should join"); + assert_eq!(translations.len(), 4); + } + + #[tokio::test] + async fn pause_reconfigure_resume_dispatches_only_the_new_revision() { + let segments = (0..2) + .map(|ordinal| segment(&format!("seg_{ordinal}"), ordinal, vec![("b0", "Text")])) + .collect::>(); + let provider = GatedProvider::new(); + let signal = PauseSignal::new(); + signal.pause(); + let mut cfg = config(); + cfg.scheduler.concurrency = 1; + cfg.pause_signal = Some(signal.clone()); + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: TranslationProfile::V1Fast.resolve().batch, + batch_max_output_tokens: Some(512), + concurrency: 1, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + cfg.runtime_settings = Some(receiver); + + let run_provider = provider.clone(); + let run = tokio::spawn(async move { + translate_segments(run_provider, &segments, &cfg) + .await + .expect("translation should finish") + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(provider.started.load(Ordering::Acquire), 0); + + runtime.revision = 2; + runtime.provider_max_attempts = 4; + sender.send_replace(runtime); + signal.resume(); + + wait_for_request_count(&provider.started, 1).await; + provider.release.add_permits(1); + wait_for_request_count(&provider.started, 2).await; + provider.release.add_permits(1); + let translations = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("run should finish") + .expect("task should join"); + assert_eq!(translations.len(), 2); + let metadata = provider.metadata.lock().expect("metadata log mutex"); + assert_eq!(metadata.len(), 2); + assert!(metadata.iter().all(|entry| { + entry.runtime_config_revision == Some(2) && entry.provider_max_attempts == Some(4) + })); + } + + #[tokio::test] + async fn closed_runtime_channel_retains_its_last_snapshot() { + let mut cfg = config(); + let runtime = EngineRuntimeSettings { + revision: 5, + batch: TranslationProfile::V1Fast.resolve().batch, + batch_max_output_tokens: Some(768), + concurrency: 1, + provider_max_attempts: 3, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime); + cfg.runtime_settings = Some(receiver); + drop(sender); + + assert_eq!(cfg.request_runtime_metadata(), (Some(5), Some(3))); + let translations = translate_segments( + MockProvider::new(MockMode::PrefixTarget, "Italian"), + &[segment("seg_a", 0, vec![("b0", "First")])], + &cfg, + ) + .await + .expect("a closed sender must not fail an active run"); + assert_eq!(translations.len(), 1); + assert_eq!(translations[0].status, SegmentStatus::Succeeded); + } + + #[tokio::test] + async fn stop_wins_over_a_runtime_revision_before_dispatch() { + let provider = GatedProvider::new(); + let signal = PauseSignal::new(); + signal.stop(); + let mut cfg = config(); + cfg.pause_signal = Some(signal); + let mut runtime = EngineRuntimeSettings { + revision: 1, + batch: TranslationProfile::V1Fast.resolve().batch, + batch_max_output_tokens: None, + concurrency: 2, + provider_max_attempts: 1, + adaptive_concurrency: false, + }; + let (sender, receiver) = tokio::sync::watch::channel(runtime.clone()); + cfg.runtime_settings = Some(receiver); + runtime.revision = 2; + runtime.concurrency = 4; + sender.send_replace(runtime); + + let translations = translate_segments( + provider.clone(), + &[segment("seg_a", 0, vec![("b0", "First")])], + &cfg, + ) + .await + .expect("stop should terminate dispatch cleanly"); + assert!(translations.is_empty()); + assert_eq!(provider.started.load(Ordering::Acquire), 0); + } + #[tokio::test] async fn non_batch_context_waiters_do_not_consume_scheduler_permits() { let segments = vec![ @@ -2244,6 +2480,7 @@ mod tests { style: None, entities: None, pause_signal: None, + runtime_settings: None, } } @@ -2319,6 +2556,58 @@ mod tests { release: Arc, } + #[derive(Debug, Clone)] + struct GatedProvider { + started: Arc, + release: Arc, + metadata: Arc>>, + } + + impl GatedProvider { + fn new() -> Self { + Self { + started: Arc::new(AtomicUsize::new(0)), + release: Arc::new(tokio::sync::Semaphore::new(0)), + metadata: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } + } + + impl LlmProvider for GatedProvider { + async fn complete(&self, request: CompletionRequest) -> Result { + self.started.fetch_add(1, Ordering::AcqRel); + self.metadata + .lock() + .expect("metadata log mutex") + .push(request.metadata.clone()); + self.release + .acquire() + .await + .expect("test gate should remain open") + .forget(); + Ok(CompletionResponse { + content: serde_json::json!({ + "segment_id": request.metadata.segment_id.unwrap_or_default(), + "translation": "Tradotto" + }) + .to_string(), + input_tokens: Some(1), + input_cached_tokens: Some(0), + output_tokens: Some(1), + finish_reason: FinishReason::Stop, + provider_latency_ms: 0, + raw: serde_json::json!({}), + }) + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + supports_json_response_format: true, + supports_usage_tokens: true, + } + } + } + impl BlockingProvider { fn new() -> Self { Self { diff --git a/crates/bookforge-store/src/db.rs b/crates/bookforge-store/src/db.rs index 3a5ab4f..e2f446c 100644 --- a/crates/bookforge-store/src/db.rs +++ b/crates/bookforge-store/src/db.rs @@ -3363,6 +3363,8 @@ mod tests { bilingual_css: None, fallback: None, finalize: bookforge_core::run_snapshot::FinalizeCheckpointSnapshot::default(), + qa_mode: "off".to_string(), + validate_output: false, settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), }; @@ -3457,6 +3459,8 @@ mod tests { bilingual_css: None, fallback: None, finalize: bookforge_core::run_snapshot::FinalizeCheckpointSnapshot::default(), + qa_mode: "off".to_string(), + validate_output: false, settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings), }; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 193be76..be77f4e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2540,10 +2540,12 @@ blocker for the non-developer audience. #### 10.1.3 Mini-spec: On-the-fly settings reconfiguration (added 2026-07-06, owner-approved) -> **Implemented on main after v2.3.0; scheduled for v2.4.0.** The current -> implementation is CLI-first and deliberately retains the stopped/dead-paused -> resume boundary described below. True in-process application is follow-up -> work, together with the dashboard surface. +> **Implemented for v2.4.0 on the project-remediation branch.** Revisioned +> overrides now apply in-process at request, unstarted-batch, and finalize-stage +> boundaries. The CLI and dashboard share validation and persistence; a runtime +> lease lets dashboard Resume signal a live worker or launch one replacement +> after stop/crash. Automated acceptance is complete; the selected real-provider +> run remains part of the final release gate. **Goal.** Adjust *cache-safe* run settings on an existing (initially: paused) job and have `resume` apply them — without starting a fresh run and losing @@ -2563,21 +2565,20 @@ seam to amend them. toggles. 2. **Persistence: an overrides sidecar.** Write `.bookforge/runs//overrides.json` (additive, no migration — matches - the control-file architecture from §10.1.1). On `resume` (and on a live - run's next batch boundary, if feasible later), merge the overrides over the - loaded `RunConfigSnapshot`. - For v1, live fast-resume of an already-paused process cannot apply pending - overrides because that process keeps its in-memory scheduler settings. Stop - the paused run first and then `resume`, or use `resume --force` only when the - paused process is already gone; full live application remains deferred. + the control-file architecture from §10.1.1). A long-lived runtime watcher + validates and publishes immutable snapshots to the live scheduler. Resume + preloads the same durable document before its first dispatch, so stopped and + crashed workers use identical effective settings without a startup race. 3. **Guardrails — reject cache-affecting settings.** Only settings that change *how remaining work is scheduled/budgeted* are mutable. Immutable (would make partial output inconsistent or invalidate the cache): provider, model, source/target language, profile, context window/scope, prompt version, glossary/style/entity inputs. Attempting to change these fails with a clear message explaining a fresh run is required and why. -4. **Surfaces.** CLI now. Dashboard later: the Advanced panel, editable on a - paused job's Progress screen (pairs with §10.1.2). +4. **Surfaces.** The CLI, status output, additive progress events, terminal UI, + and the dashboard Progress screen expose the effective revision. The + dashboard editor is available for running, paused, and stopped jobs with + unfinished translation or finalize work. **Acceptance.** 1. Reconfigure a paused job's `--batch-max-output-tokens` + `--batch-max-items`, @@ -2588,9 +2589,9 @@ seam to amend them. 3. Overrides are auditable (visible in `status`), additive, and a job with no overrides resumes exactly as today. -**Out of scope (for v1):** live reconfiguration of a *running* (non-paused) -job mid-batch; changing the model/provider mid-job; reconfiguring completed -jobs. +**Out of scope:** mutating an in-flight provider request or an already-started +finalize stage; changing model/provider or any cache-affecting identity; +reconfiguring completed jobs. **Effort:** ~1 day CLI + snapshot-merge. **Priority:** high — small, and it removes a real "lost all my progress to change one number" cliff. diff --git a/docs/codex-handoff-project-remediation-2026-07-10.md b/docs/codex-handoff-project-remediation-2026-07-10.md index 29ae744..bf3c804 100644 --- a/docs/codex-handoff-project-remediation-2026-07-10.md +++ b/docs/codex-handoff-project-remediation-2026-07-10.md @@ -355,8 +355,187 @@ Do not skip this item. ### True live reconfiguration -Design complete in `docs/design-live-reconfiguration.md`; implementation has -not started. Current behavior remains sidecar + stop/resume. +Design complete in `docs/design-live-reconfiguration.md`. Implementation is +AUTOMATED-COMPLETE in the uncommitted worktree as of 2026-07-13. Commit/push +and the selected real-provider acceptance run remain; the historical restart +and WIP-review notes below are retained only as provenance. + +#### Live implementation checkpoint (Codex, 2026-07-13) + +This checkpoint supersedes both historical subsections below. + +- Atomic revisioned overrides, shared validation, last-valid recovery, + runtime channels/events/replay state, provider-attempt snapshots, dynamic + single and batch dispatch, adjustable concurrency, stable batch work keys, + pending repartition, and finalize-stage snapshots are implemented. +- Runtime leases and launch claims make dashboard controls worker-aware. + Resume signals a fresh worker, launches one replacement for stopped/crashed + work, adds `--force` for a dead paused worker, deduplicates concurrent clicks, + recognizes finalize-only work, and rejects completed jobs. +- The Progress screen contains the typed ten-field Runtime settings editor and + immutable identity/revision/lease state. Runtime events refresh it without a + page reload. Review retry guidance now uses an inline textarea with + Cancel/Queue/Stop and the explicit stop-before-retry instruction. +- Lifecycle coverage proves live single/batch changes, request revision and + attempt metadata, finalize-stage freezing, stop/crash preservation, resume + consumption without retranslating checkpoints, successful sidecar/lease + cleanup, and stale crash leases. The old fixed-sleep stop/resume test was + made event-driven and passes repeatedly. +- Focused and full engine/CLI suites pass (154 engine; 159 CLI unit; 45 CLI + lifecycle; 16 round-trip). Formatting, exact CI clippy with warnings denied, + and the Rust 1.88 all-target workspace check pass on Windows. After replacing + the old fixed-sleep lifecycle flake with an event-driven boundary, the clean + linked workspace suite passes in full: 159 CLI unit, 45 lifecycle, 16 + round-trip, 59 core, 154 LLM, 31 PDF, and 34 store tests, plus EPUB and + documentation tests. + +Next: review/clean generated files, update the draft PR with the +live-reconfiguration commit, confirm Linux/Windows CI, then begin +behavior-neutral modularization. + +#### Historical restart checkpoint (superseded) + +This subsection supersedes the older WIP review immediately below it. The app +had to be restarted because its approval state was malfunctioning. Stop here: +the worktree is intentionally dirty and must be continued in place. + +- Branch/HEAD: `codex/project-remediation` at `b809e598`. +- Do not discard, reset, or broadly rewrite the dirty tree. There are 18 + modified Rust files plus this handoff. +- `crates/bookforge-cli/.bookforge/` is an untracked generated test artifact; + inspect it before removing it and never commit it. +- The last fully verified checkpoint predates the newest lease/dashboard + edits. Run formatting and a targeted CLI check before making further edits. + +Use the Windows gnullvm toolchain because this host's default GNU linker is +missing `libgcc_eh`: + +```powershell +$llvm='C:\Users\gangi\AppData\Local\Microsoft\WinGet\Packages\MartinStorsjo.LLVM-MinGW.UCRT_Microsoft.Winget.Source_8wekyb3d8bbwe\llvm-mingw-20260616-ucrt-x86_64\bin' +$env:PATH="$llvm;$env:PATH" +cargo fmt --all +cargo +stable-x86_64-pc-windows-gnullvm check -p bookforge-cli --all-targets --locked +``` + +Implemented in the current uncommitted tree: + +- removed the obsolete paused-resume override rejection; overrides are loaded + and published before Resume is applied, with focused tests; +- corrupt sidecars can be recovered by the next atomic writer while readers + keep last-valid behavior; +- provider attempt limits and runtime revision are frozen per request and + recorded additively in request metadata/events; +- batch dispatch now leaves genuinely pending work available for revision-time + repartition, with per-item retry/escalation bookkeeping and live adaptive + concurrency behavior; +- CLI-owned `JobRuntimeSettings` snapshots QA, double-check, validation, and + engine settings at stage/request boundaries; +- persisted run snapshots now include additive QA mode and output-validation + fields, and successful finalization clears overrides; +- `runtime.json` worker leases, heartbeats, owner-safe cleanup, stale detection, + and deduplicating `resume.launch` claims are implemented in `control.rs`; +- dashboard GET/POST `/api/jobs/{id}/reconfigure` handlers and lease-aware + Pause/Stop/Resume behavior are largely implemented in `serve.rs`. Resume now + signals a fresh worker or spawns `bookforge resume --ui quiet` when no + worker is live. + +Focused tests that passed before the newest lease/dashboard edits cover sidecar +recovery/order, provider-attempt freezing, additive event compatibility, +single and batch runtime boundaries, pending-batch merging, adaptive-concurrency +enablement, and limiter shrink. A targeted CLI all-target check also passed at +that earlier point. The lease tests and newest dashboard handlers have not yet +been compiled or run. + +First continuation task: + +1. Run the formatting/check commands above and fix only errors from the current + WIP (likely around the new lease-aware dashboard handlers or new + `RunConfigSnapshot` fields). +2. Finish the Progress-screen runtime settings panel and event rendering in + `serve.rs`. +3. Replace `window.prompt` in `bfReviewRetry` with an inline textarea plus + Cancel/Queue controls and the explicit instruction that a running/paused job + must be stopped before a retry is queued. This is the owner's requested UI + follow-up; the underlying retry behavior already works. +4. Add dashboard API/CSRF tests, fresh/stale lease and launch-dedup tests, then + stage-boundary lifecycle/race coverage. Run the full llm/cli suites. +5. Update this subsection with exact commands/results, commit the coherent live + reconfiguration milestone, and push draft PR #27. Only then begin the + modularization milestone and final release gate. + +Potential review points, not established bugs: verify Windows +`CommandExt::creation_flags` compiles through Tokio's command wrapper; ensure +isolated dashboard tests do not leak global `.bookforge` state; verify a newly +written sidecar is observed before a stage boundary; and confirm launch claims +remain until the new worker clears them or they become stale. + +#### Historical review of the work-in-progress (superseded) + +Implemented and looking correct (roughly steps 1–4 of the design's +implementation order): + +- versioned `overrides.json` envelope with schema version, monotonic + revision, zero-value rejection, legacy flat fallback, atomic staged write, + and an `overrides.lock` writer lock with stale-lease recovery; +- `ControlFileWatcher` publishes immutable `EngineRuntimeSettings` snapshots + through a Tokio `watch` channel, emitting additive `RuntimeConfigChanged`/ + `RuntimeConfigRejected` events (deduped per revision / per error); +- `RunState` gains revision/changed-fields/rejection (serde-defaulted); +- single-segment scheduler snapshots concurrency + output budget per + dispatch; batch scheduler uses an `AdaptiveLimiter` (including a real + shrink fix: idle permits are consumed immediately so waiters cannot exceed + a lowered target — with test) and rebuilds sizing per revision; +- escalation bookkeeping is now keyed by ordered item IDs + (`batch_work_key`), surviving repartition, and carried onto split parts; +- all four run entry points (translate real/mock, resume, fallback pass) + wire the watcher channel through `TranslationRunConfig.runtime_settings` + (`None` preserves old behavior); +- race tests exist with gated providers for concurrency shrink (single + + batch) and budget change on later requests. + +Findings to fix before continuing: + +1. FAILING TEST: `paused_fast_resume_with_overrides_errors_with_apply_guidance` + (resume.rs:1301) — `apply_instructions()` was reworded for the live-worker + model and no longer contains "bookforge stop ". Decide the real fix: + under this design a live fast-resume of a paused worker should be ALLOWED + with pending overrides (the watcher applies them), so the guard in + `live_fast_resume_paused_job` likely becomes obsolete rather than the + message being patched. Workspace test run stops at this failure, so + lifecycle/roundtrip suites have not run against the WIP. +2. ORDERING BUG (design invariant): in `control.rs` the watcher loop runs + `poller.poll(&signal)` (applying Pause/Resume/Stop) BEFORE reloading the + overrides sidecar. The design requires reloading overrides before applying + Resume so reconfigure-then-resume never dispatches under the previous + revision. Swap the order (load/publish overrides first, then poll) and add + the planned pause→reconfigure→resume scheduler test. +3. Recovery nit: `write_merged_overrides_at_path` reloads the existing + sidecar through the validating loader, so a corrupt/invalid sidecar makes + every future reconfigure fail until the file is hand-deleted. Writers + should treat unreadable existing content as revision-0 default (or surface + an explicit `--reset`), while readers keep last-valid behavior. + +Not yet implemented (matches design steps 3b, 5, 6, 7): + +- provider-side `provider_max_attempts` snapshot in + `OpenAiCompatibleProvider` and `adaptive_concurrency` application — both + fields currently ride in `EngineRuntimeSettings` but nothing consumes them; +- `RequestStarted` extensions (`runtime_config_revision`, + `provider_max_attempts`); +- runtime lease `runtime.json` + lease-aware dashboard Resume/spawn; +- QA/double-check/validate stage-boundary snapshots and terminal + sidecar/lease cleanup; +- dashboard GET/POST reconfigure API, Progress-screen panel, inline + retry-hint UI; +- most of the design's unit/scheduler/lifecycle test matrix. + +Minor observations (acceptable, just be aware): the watcher re-emits +`RuntimeConfigChanged` once at every process start when a sidecar already +exists (informative, additive); a legacy revision-0 sidecar intentionally +does not instantiate a runtime batch sizer. + +Current behavior for jobs without overrides remains unchanged +(sidecar + stop/resume fallback preserved). Recommended design: diff --git a/docs/design-live-reconfiguration.md b/docs/design-live-reconfiguration.md index cb1f31f..ad37c95 100644 --- a/docs/design-live-reconfiguration.md +++ b/docs/design-live-reconfiguration.md @@ -1,7 +1,7 @@ # Live reconfiguration design -Status: accepted implementation plan for the v2.4 remediation milestone -Date: 2026-07-11 +Status: implemented on `codex/project-remediation`; real-provider acceptance remains a release gate +Date: accepted 2026-07-11; implementation verified 2026-07-13 Source: `docs/codex-handoff-project-remediation-2026-07-10.md` and ROADMAP §§10.1.3–10.1.4 @@ -89,8 +89,10 @@ and resume. Add a CLI-owned `JobRuntimeSettings` containing: - revision and the complete effective `ResolvedRunSettings`; -- QA mode and `validate_output`, which currently live outside that structure; -- the validated override field names for audit/UI display. +- QA mode and `validate_output`, which live outside that structure. + +The durable override document remains the source for validated changed-field +names used by events and the dashboard. Derive an engine-facing `EngineRuntimeSettings` containing only batch config, target concurrency, provider attempt limit, and adaptive toggles. Add an @@ -98,12 +100,12 @@ optional `tokio::sync::watch::Receiver` to `TranslationRunConfig`. `None` preserves current behavior for library callers and existing tests. -Refactor `ControlFileWatcher` into a long-lived `JobRuntimeWatcher`. It keeps one -store/poller instance, watches both control and override sidecars, publishes -only validated immutable snapshots, and emits a rejection once per invalid -content hash. At each control boundary it reloads overrides before applying a -Resume command, so a sequential reconfigure-then-resume cannot dispatch under -the previous revision. +Extend the existing `ControlFileWatcher` into the long-lived job runtime +watcher. It keeps one store/poller instance, watches both control and override +sidecars, publishes only validated immutable snapshots, and emits a rejection +once per invalid content hash. At each control boundary it reloads overrides +before applying a Resume command, so a sequential reconfigure-then-resume +cannot dispatch under the previous revision. ### Boundary semantics @@ -155,9 +157,10 @@ instance ID prevents a stale PID from being treated as the current worker. `POST /api/jobs/{id}/resume` then behaves as follows: - fresh lease: write the Resume control command and return `mode: "signaled"`; -- stale/missing lease plus resumable segments: spawn the current executable as - `bookforge resume --ui quiet`, return `mode: "spawned"`, and surface - startup failure; +- stale/missing lease plus resumable translation or finalize work: spawn the + current executable as `bookforge resume --ui quiet`, adding `--force` + for a dead paused worker, return `mode: "spawned"`, and surface startup + failure; - no resumable work: return an actionable 400 instead of pretending the job is running. @@ -233,6 +236,29 @@ The existing `RuntimeConfigResolved` remains the initial full snapshot event. - a missing/stale worker makes dashboard Resume spawn one process exactly once (concurrent clicks are deduplicated by an atomic launch claim). +### Automated implementation evidence (2026-07-13) + +- The engine suite passes 154 tests, including request snapshotting, live + single/batch concurrency and budget changes, pause/reconfigure/resume + ordering, stop precedence, channel closure, pending repartition, adaptive + naming, escalation, and limiter growth/shrink. +- The CLI suite passes 159 unit, 45 lifecycle, and 16 round-trip tests. The + lifecycle coverage exercises live single/batch runs, finalize-stage + snapshots, stop/resume sidecar persistence, successful cleanup, and stale + crash leases. Dashboard route tests use an isolated store and cover typed + revisioned edits, CSRF/Host protection, lease-aware controls, finalize-only + work, dead-paused forced relaunch, launch deduplication, and completed-job + rejection. +- `cargo fmt --all --check`, the exact all-target/all-feature CI clippy command + with warnings denied, and the Rust 1.88 workspace all-target check pass on + Windows. After replacing the documented fixed-sleep stop/resume flake with + an event-driven boundary, the clean linked workspace run passes in full: + 159 CLI unit, 45 lifecycle, 16 round-trip, 59 core, 154 LLM, 31 PDF, and 34 + store tests, plus EPUB and documentation tests. +- The embedded dashboard JavaScript passes a Node syntax check. The earlier + manual correction/dashboard exercise remains green; the real-provider steps + below are intentionally deferred to the final release gate. + ### Manual acceptance 1. Start a real-provider batch run and pause it with requests in flight. From bb76b94c96ae88fce337b93de958592d193c497f Mon Sep 17 00:00:00 2001 From: JunjoSick Date: Mon, 13 Jul 2026 21:05:17 +0200 Subject: [PATCH 07/33] refactor: extract dashboard assets --- crates/bookforge-cli/src/commands/serve.rs | 1181 +---------------- .../src/commands/serve/dashboard.css | 285 ++++ .../src/commands/serve/dashboard.html | 30 + .../src/commands/serve/dashboard.js | 847 ++++++++++++ 4 files changed, 1183 insertions(+), 1160 deletions(-) create mode 100644 crates/bookforge-cli/src/commands/serve/dashboard.css create mode 100644 crates/bookforge-cli/src/commands/serve/dashboard.html create mode 100644 crates/bookforge-cli/src/commands/serve/dashboard.js diff --git a/crates/bookforge-cli/src/commands/serve.rs b/crates/bookforge-cli/src/commands/serve.rs index a13ba0f..7b53979 100644 --- a/crates/bookforge-cli/src/commands/serve.rs +++ b/crates/bookforge-cli/src/commands/serve.rs @@ -1773,1167 +1773,15 @@ fn open_in_browser(url: &str) -> Result<()> { // Inline frontend (no build step — vanilla JS, mirrors the review.rs pattern) // --------------------------------------------------------------------------- -const DASHBOARD_HTML: &str = r##" - - - - -BookForge - - - - - - -
-
BookForgev2
- - -
- -
-
-
-
- - - - -"##; +static DASHBOARD_HTML: std::sync::LazyLock = std::sync::LazyLock::new(|| { + DASHBOARD_HTML_TEMPLATE + .replace("{{BOOKFORGE_DASHBOARD_CSS}}", DASHBOARD_CSS) + .replace("{{BOOKFORGE_DASHBOARD_JS}}", DASHBOARD_JS) +}); #[cfg(test)] mod tests { @@ -3311,6 +2159,19 @@ mod tests { } } + #[test] + fn dashboard_assets_reassemble_byte_stably() { + use sha2::{Digest, Sha256}; + + assert_eq!(DASHBOARD_HTML.len(), 82_407); + assert!(!DASHBOARD_HTML.contains("{{BOOKFORGE_DASHBOARD_CSS}}")); + assert!(!DASHBOARD_HTML.contains("{{BOOKFORGE_DASHBOARD_JS}}")); + assert_eq!( + format!("{:x}", Sha256::digest(DASHBOARD_HTML.as_bytes())), + "7a37e7095182825d2f63afec9776214ce7f99ea33464ad1e86ea43342767ce9b" + ); + } + #[test] fn dashboard_ships_runtime_editor_and_inline_retry_guidance() { for marker in [ diff --git a/crates/bookforge-cli/src/commands/serve/dashboard.css b/crates/bookforge-cli/src/commands/serve/dashboard.css new file mode 100644 index 0000000..d038ebb --- /dev/null +++ b/crates/bookforge-cli/src/commands/serve/dashboard.css @@ -0,0 +1,285 @@ +:root{ + --bg:#f4f1ea; --panel:#fcfbf8; --card:#ffffff; --line:#e6dfd0; --soft:#f6f2ea; + --ink:#2a2521; --muted:#7a7066; --faint:#a89e90; --accent:#cf8a2d; --accentink:#ffffff; + --chip:#efe9dd; --good:#5b8c5a; --goodbg:#eef3ec; --warn:#c98a2d; --danger:#c2502f; + --accentsoft:#faf3e6; --accentline:#ecd9b3; + --sans:'IBM Plex Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; + --serif:'Spectral',Georgia,'Times New Roman',serif; + --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace; +} +:root[data-theme="dark"]{ + --bg:#1b1814; --panel:#211d18; --card:#2a251e; --line:#3a332a; --soft:#241f19; + --ink:#ece5da; --muted:#b3a99b; --faint:#7d7468; --accent:#d97a52; --accentink:#1a1713; + --chip:#2c2720; --good:#6aa765; --goodbg:#23291f; --warn:#d6a24a; --danger:#e07a55; + --accentsoft:#2a221c; --accentline:#473829; +} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--sans)} +::selection{background:rgba(207,138,45,.25)} +.scr::-webkit-scrollbar{width:9px;height:9px} +.scr::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px} +@keyframes bf-fade{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} +@keyframes bf-stripe{from{background-position:0 0}to{background-position:40px 0}} +input,textarea,select,button{font-family:inherit} +a{color:var(--accent)} + +.appbar{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:16px;padding:0 26px;height:60px;background:var(--panel);border-bottom:1px solid var(--line)} +.brand{display:flex;align-items:baseline;gap:9px;cursor:pointer} +.brand b{font:600 21px var(--serif);letter-spacing:-.01em;color:var(--ink)} +.brand small{font:400 11px var(--mono);color:var(--faint)} +.nav{display:flex;gap:3px;margin-left:12px} +.tab{padding:8px 12px;border-radius:8px;cursor:pointer;font:500 13px var(--sans);color:var(--muted)} +.tab:hover{color:var(--ink)} +.tab.active{font-weight:600;color:var(--ink);background:var(--chip)} +.spacer{margin-left:auto} +.appbar .right{display:flex;align-items:center;gap:13px} +.btn{border:none;border-radius:9px;cursor:pointer;font:600 13px var(--sans)} +.btn-primary{background:var(--accent);color:var(--accentink);padding:8px 15px} +.btn-ghost{background:transparent;border:1px solid var(--line);color:var(--ink);padding:10px 18px;border-radius:10px} +.btn-danger{background:transparent;border:1px solid var(--line);color:var(--danger);padding:12px 18px;border-radius:10px} +.themetoggle{display:flex;background:var(--chip);border-radius:20px;padding:3px;gap:2px;cursor:pointer;user-select:none} +.themetoggle span{font:600 11px var(--sans);padding:5px 11px;border-radius:16px;color:var(--faint)} +.themetoggle span.on{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px rgba(0,0,0,.12)} + +main{min-height:calc(100vh - 60px)} +.wrap{max-width:1000px;margin:0 auto;padding:38px 26px 80px;animation:bf-fade .4s ease both} +.pagehead{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;margin-bottom:24px;gap:16px} +.pagehead h1{font:600 28px/1.1 var(--serif);margin:0;color:var(--ink)} +.pagehead p{font:400 13.5px var(--sans);color:var(--muted);margin:5px 0 0} +.empty{color:var(--muted);text-align:center;margin-top:60px;font-size:14px} + +/* library */ +.book-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px} +.book-card{display:flex;gap:15px;padding:16px;background:var(--card);border:1px solid var(--line);border-radius:14px;cursor:pointer} +.book-card:hover{border-color:var(--accentline)} +.cover{width:56px;height:80px;flex:none;border-radius:6px;display:flex;align-items:center;justify-content:center;font:600 24px var(--serif);color:var(--accentink);background:linear-gradient(150deg,var(--accent),var(--danger));box-shadow:0 6px 14px -6px rgba(0,0,0,.4)} +.book-main{flex:1;min-width:0} +.book-title{font:600 16px/1.2 var(--serif);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.book-sub{font:italic 400 12.5px var(--serif);color:var(--muted);margin:2px 0 9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.book-meta{display:flex;align-items:center;gap:8px;margin-bottom:11px} +.book-meta .mono{font:400 11.5px var(--mono);color:var(--faint)} +.bar-track{height:5px;border-radius:3px;background:var(--chip);overflow:hidden} +.bar-fill{height:100%;border-radius:3px;background:var(--accent)} +.book-action{font:500 12.5px var(--sans);color:var(--accent);white-space:nowrap;align-self:flex-start} +.add-card{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:22px;border:1.5px dashed var(--line);border-radius:14px;cursor:pointer;background:var(--soft);min-height:112px;text-align:center} +.add-card .plus{font-size:26px;line-height:1;color:var(--accent)} +.add-card b{font:600 14px var(--sans);color:var(--ink);margin-top:8px} +.add-card span{font:400 12px var(--sans);color:var(--muted);margin-top:3px} + +.badge{display:inline-block;font:600 10.5px var(--sans);padding:3px 9px;border-radius:20px;color:var(--muted);background:var(--chip)} +.badge.running,.badge.translating{color:var(--accent);background:var(--accentsoft)} +.badge.paused{color:var(--warn);background:var(--chip)} +.badge.stopped{color:var(--danger);background:var(--accentsoft)} +.badge.done,.badge.completed,.badge.succeeded{color:var(--good);background:var(--goodbg)} +.badge.failed,.badge.error{color:var(--danger);background:var(--accentsoft)} + +/* wizard */ +.wiz{display:flex;max-width:1040px;margin:0 auto;min-height:calc(100vh - 60px)} +.rail{width:236px;flex:none;padding:34px 24px;border-right:1px solid var(--line)} +.rail .kicker{font:500 10px var(--sans);text-transform:uppercase;letter-spacing:.07em;color:var(--faint);margin-bottom:16px} +.steps{display:flex;flex-direction:column;gap:4px} +.step{display:flex;align-items:center;gap:11px;padding:9px 10px;border-radius:10px;cursor:pointer} +.step.current{background:var(--card);box-shadow:0 1px 3px rgba(0,0,0,.07)} +.step .dot{width:23px;height:23px;flex:none;border-radius:50%;display:flex;align-items:center;justify-content:center;font:600 11px var(--mono);color:#fff;background:var(--faint)} +.step.current .dot{background:var(--accent)} +.step.done .dot{background:var(--good)} +.step .lbl{font:500 13px var(--sans);color:var(--muted)} +.step.current .lbl{font-weight:600;color:var(--ink)} +.step .hint{font:400 11px var(--sans);color:var(--faint)} +.wizsummary{margin-top:26px;padding:14px;border-radius:12px;background:var(--soft);border:1px solid var(--line)} +.wizsummary .kicker{margin-bottom:6px} +.wizsummary .t{font:600 14px/1.2 var(--serif);color:var(--ink)} +.wizsummary .m{font:400 11px var(--mono);color:var(--muted);margin-top:4px} +.wizpanel{flex:1;padding:34px 38px 40px;display:flex;flex-direction:column;animation:bf-fade .35s ease both} +.wizpanel .kicker{font:500 11px var(--sans);text-transform:uppercase;letter-spacing:.07em;color:var(--accent);margin-bottom:6px} +.wizpanel h2{font:600 25px/1.1 var(--serif);color:var(--ink);margin:0 0 5px} +.wizpanel .sub{font:400 13.5px var(--sans);color:var(--muted);margin-bottom:26px;max-width:560px} +.wizbody{flex:1} +.field-label{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin-bottom:7px} +.drop{margin-top:6px;padding:26px 20px;border:1.5px dashed var(--line);border-radius:14px;background:var(--soft);text-align:center;cursor:pointer} +.drop.has{border-style:solid;border-color:var(--accentline);background:var(--card)} +.drop b{color:var(--accent)} +.drop .fname{font:600 14px var(--serif);color:var(--ink)} +.inp{width:100%;border-radius:10px;padding:12px 14px;background:var(--card);border:1px solid var(--line);font:500 14px var(--sans);color:var(--ink)} +.inp:focus{outline:none;border-color:var(--accent)} +.lang-row{display:flex;align-items:flex-end;gap:14px;margin-bottom:24px} +.lang-row .col{flex:1} +.swap{width:40px;height:40px;flex:none;border-radius:50%;background:var(--chip);display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:16px;cursor:pointer;margin-bottom:4px} +.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:9px} +.chip{padding:8px 14px;border-radius:20px;cursor:pointer;font:500 12.5px var(--sans);color:var(--muted);background:var(--chip);border:1px solid transparent} +.chip.on{font-weight:600;color:var(--accentink);background:var(--accent);border-color:var(--accent)} +.tiers{display:flex;gap:12px;margin-bottom:20px} +.tier{flex:1;position:relative;padding:16px 15px;border-radius:13px;cursor:pointer;background:var(--card);border:1.5px solid var(--line)} +.tier.on{background:var(--accentsoft);border-color:var(--accent)} +.tier .tbadge{position:absolute;top:-9px;left:13px;font:600 9px var(--sans);text-transform:uppercase;letter-spacing:.05em;color:#fff;background:var(--good);padding:3px 8px;border-radius:5px;display:none} +.tier.rec .tbadge,.tier.on .tbadge{display:block} +.tier.on .tbadge{background:var(--accent)} +.tier .tn{font:600 14px var(--sans);color:var(--ink);margin-bottom:4px} +.tier .td{font:400 11.5px/1.4 var(--sans);color:var(--muted);margin-bottom:13px;min-height:48px} +.tier .tm{font:400 10.5px var(--mono);color:var(--faint);margin-top:4px} +.facts{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:11px;margin-bottom:13px} +.fact{padding:14px 15px;border:1px solid var(--line);border-radius:11px;background:var(--card)} +.fact .k{font:500 10px var(--sans);text-transform:uppercase;letter-spacing:.05em;color:var(--faint);margin-bottom:6px} +.fact .v{font:600 15px var(--sans);color:var(--ink)} +.fact .v.mono{font:600 13px var(--mono)} +.costbox{display:flex;align-items:center;justify-content:space-between;padding:17px 19px;background:var(--accentsoft);border:1px solid var(--accentline);border-radius:13px;margin-bottom:13px} +.costbox .ck{font:400 11px var(--sans);color:var(--muted);margin-bottom:3px} +.costbox .cv{font:600 28px var(--serif);color:var(--ink)} +.costbox .cm{text-align:right;font:400 12px/1.7 var(--mono);color:var(--muted)} +.advtoggle{display:flex;align-items:center;justify-content:space-between;padding:13px 4px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);cursor:pointer;font:500 13px var(--sans);color:var(--muted)} +.advbody{padding:16px 2px} +.adv-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px} +.adv-cell{display:flex;align-items:center;justify-content:space-between;padding:11px 13px;border:1px solid var(--line);border-radius:10px} +.adv-cell span{font:500 12.5px var(--sans);color:var(--muted)} +.adv-cell .val{font:600 12.5px var(--mono);color:var(--accent);cursor:pointer} +.stepper{display:flex;align-items:center;gap:6px} +.stepbtn{width:24px;height:24px;border-radius:6px;background:var(--chip);display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink)} +.numin{width:44px;text-align:center;border:1px solid var(--line);border-radius:7px;padding:4px;background:var(--card);color:var(--ink);font:600 12.5px var(--mono)} +.modelcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin-bottom:9px} +.modelcard{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:11px 13px;border-radius:11px;cursor:pointer;background:var(--card);border:1.5px solid var(--line)} +.modelcard.on{background:var(--accentsoft);border-color:var(--accent)} +.modelcard .ml{font:600 13px var(--sans);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.modelcard .mi{font:400 10.5px var(--mono);color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.wizfoot{display:flex;gap:11px;margin-top:30px;padding-top:22px;border-top:1px solid var(--line);align-items:center} +.wizfoot .grow{flex:1} +.keyline{font:400 10px var(--sans);color:var(--faint);margin-top:4px} +.launchstatus{font:400 12px var(--sans);color:var(--muted);min-height:16px} + +/* progress */ +.prog-hero{display:flex;align-items:center;gap:14px;margin-bottom:22px} +.prog-cover{width:44px;height:62px;flex:none;border-radius:5px;display:flex;align-items:center;justify-content:center;font:600 20px var(--serif);color:var(--accentink);background:linear-gradient(150deg,var(--accent),var(--danger))} +.prog-hero .h{flex:1;min-width:0} +.prog-hero .h .t{font:600 22px/1.1 var(--serif);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.prog-hero .h .m{font:400 12.5px var(--mono);color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.prog-card{padding:22px 24px;background:var(--card);border:1px solid var(--line);border-radius:16px;margin-bottom:16px} +.prog-top{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:13px} +.prog-pct{font:600 40px/1 var(--serif);color:var(--ink)} +.prog-pct small{font-size:20px;color:var(--muted)} +.prog-eta{text-align:right;font:400 12.5px/1.6 var(--sans);color:var(--muted)} +.prog-bar{height:10px;border-radius:6px;background:var(--chip);overflow:hidden} +.prog-bar > i{display:block;height:100%;border-radius:6px;background:repeating-linear-gradient(45deg,var(--accent) 0 10px,color-mix(in srgb,var(--accent) 78%,#000) 10px 20px);background-size:40px 40px;animation:bf-stripe 1s linear infinite;transition:width .3s ease} +.prog-bar.done > i{background:var(--good);animation:none} +.prog-actions{display:flex;gap:10px;margin-top:18px;align-items:center} +.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:11px;margin-bottom:16px} +.stat{padding:14px 15px;border:1px solid var(--line);border-radius:12px;background:var(--card)} +.stat .k{font:500 10px var(--sans);text-transform:uppercase;letter-spacing:.05em;color:var(--faint);margin-bottom:6px} +.stat .v{font:600 17px var(--mono);color:var(--ink)} +.stat .v.good{color:var(--good)} .stat .v.warn{color:var(--warn)} .stat .v.bad{color:var(--danger)} +.sectlabel{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin:0 0 10px} +.logbox{background:var(--soft);border:1px solid var(--line);border-radius:12px;padding:8px 4px;max-height:230px;overflow:auto} +.logline{font:400 12px var(--mono);color:var(--muted);padding:6px 14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.logline.good{color:var(--good)} .logline.warn{color:var(--warn)} .logline.bad{color:var(--danger)} +.live{display:flex;align-items:center;gap:6px;font:400 12px var(--sans);color:var(--muted)} +.dot{width:8px;height:8px;border-radius:50%;background:var(--faint)} +.dot.on{background:var(--good);box-shadow:0 0 8px var(--good)} +.runtime-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px} +.runtime-head .title{font:600 17px var(--serif);color:var(--ink)} +.runtime-head .meta{font:400 11.5px/1.5 var(--mono);color:var(--muted);margin-top:3px} +.runtime-state{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.05em;padding:5px 9px;border-radius:12px;color:var(--muted);background:var(--chip)} +.runtime-state.live{color:var(--good);background:var(--goodbg)} +.runtime-state.stale,.runtime-state.invalid{color:var(--warn);background:var(--chip)} +.runtime-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(185px,1fr));gap:11px} +.runtime-field{display:flex;flex-direction:column;gap:6px;font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.04em;color:var(--faint)} +.runtime-field .inp{padding:9px 11px;font:500 12.5px var(--mono);text-transform:none;letter-spacing:0} +.runtime-check{display:flex;align-items:center;gap:8px;min-height:38px;padding:9px 11px;border:1px solid var(--line);border-radius:10px;background:var(--card);font:500 12px var(--sans);text-transform:none;letter-spacing:0;color:var(--muted)} +.runtime-check input{accent-color:var(--accent)} +.runtime-identity{margin-top:15px;padding-top:13px;border-top:1px solid var(--line);font:400 11px/1.6 var(--mono);color:var(--faint);word-break:break-word} +.runtime-foot{display:flex;align-items:center;gap:11px;margin-top:15px;flex-wrap:wrap} +.runtime-feedback{font:400 11.5px var(--sans);color:var(--muted)} +.runtime-feedback.bad{color:var(--danger)} + +@media (max-width:720px){.wrap{padding:28px 18px 70px}.book-grid{grid-template-columns:1fr}.wiz{display:block;min-height:0}.rail{width:auto;border-right:none;border-bottom:1px solid var(--line);padding:18px 20px}.steps{flex-direction:row;overflow:auto}.step{flex:none}.wizsummary{display:none}.wizpanel{padding:24px 20px 34px}.lang-row{flex-wrap:wrap}.swap{margin-bottom:0}.tiers{flex-direction:column}.prog-top{display:block}.prog-eta{text-align:left;margin-top:8px}.prog-actions{flex-wrap:wrap}} + +/* review */ +.review{display:flex;height:calc(100vh - 60px)} +.rev-list{width:300px;flex:none;border-right:1px solid var(--line);display:flex;flex-direction:column} +.rev-list .lh{padding:18px 18px 12px} +.rev-list .lh .t{font:600 17px var(--serif);color:var(--ink)} +.rev-list .lh .m{font:400 11.5px var(--mono);color:var(--muted);margin-top:3px} +.rev-filters{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap} +.rev-filter{padding:5px 11px;border-radius:16px;cursor:pointer;font:500 11.5px var(--sans);color:var(--muted);background:var(--chip)} +.rev-filter.on{font-weight:600;color:var(--accentink);background:var(--accent)} +.rev-rows{flex:1;overflow:auto} +.rev-row{padding:13px 18px;cursor:pointer;border-bottom:1px solid var(--line);border-left:3px solid transparent} +.rev-row.on{background:var(--soft);border-left-color:var(--accent)} +.rev-row .r{display:flex;align-items:center;gap:8px;margin-bottom:4px} +.rev-row .ref{font:600 10.5px var(--mono);color:var(--faint)} +.rev-tag{font:600 9px var(--sans);text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:10px;color:var(--good);background:var(--goodbg)} +.rev-tag.warn{color:var(--warn);background:var(--chip)} +.rev-tag.bad{color:var(--danger);background:var(--accentsoft)} +.rev-row .prev{font:400 12px/1.4 var(--sans);color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.rev-row.on .prev{color:var(--ink)} +.rev-main{flex:1;display:flex;flex-direction:column;animation:bf-fade .35s ease both;min-width:0} +.rev-bar{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid var(--line)} +.rev-bar .ref{font:600 13px var(--mono);color:var(--muted)} +.rev-nav{display:flex;gap:8px} +.rev-btn{width:34px;height:34px;border-radius:9px;border:1px solid var(--line);background:transparent;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:15px;cursor:pointer} +.rev-flag{padding:8px 14px;border-radius:9px;border:1px solid var(--line);background:transparent;color:var(--muted);font:600 12px var(--sans);cursor:pointer} +.rev-flag.on{border-color:var(--danger);color:var(--danger);background:var(--accentsoft)} +.rev-cols{flex:1;display:grid;grid-template-columns:1fr 1fr;overflow:auto} +.rev-col{padding:26px 28px;border-right:1px solid var(--line)} +.rev-col.tgt{background:var(--soft);border-right:none} +.rev-col .cl{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.07em;color:var(--faint);margin-bottom:14px} +.rev-col.tgt .cl{color:var(--accent)} +.rev-text{font:400 16px/1.7 var(--serif);color:var(--ink);white-space:pre-wrap} +.rev-block{margin-bottom:16px}.rev-block:last-child{margin-bottom:0} +.rev-block-id{font:500 9px var(--mono);color:var(--faint);margin-bottom:5px} +.rev-edit{width:100%;min-height:120px;resize:vertical;border:1px solid var(--line);border-radius:9px;padding:12px;background:var(--card);color:var(--ink);font:400 15px/1.6 var(--serif)} +.rev-edit:focus{outline:none;border-color:var(--accent)} +.rev-save-row{display:flex;align-items:center;gap:10px;margin-top:14px}.rev-save-status{font:400 11px var(--sans);color:var(--muted)} +.rev-hint{margin-top:14px;padding:14px;border:1px solid var(--accentline);border-radius:11px;background:var(--card)} +.rev-hint[hidden]{display:none} +.rev-hint label{display:block;font:600 11px var(--sans);color:var(--ink);margin-bottom:7px} +.rev-hint textarea{width:100%;min-height:82px;resize:vertical;border:1px solid var(--line);border-radius:9px;padding:10px 11px;background:var(--soft);color:var(--ink);font:400 13px/1.5 var(--sans)} +.rev-hint textarea:focus{outline:none;border-color:var(--accent)} +.rev-hint .note{font:500 11.5px/1.5 var(--sans);color:var(--warn);margin:8px 0 10px} +.rev-hint .actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.rev-note{margin-top:14px;padding:11px 13px;border-radius:9px;background:var(--card);border:1px solid var(--accentline);font:400 12.5px/1.5 var(--sans);color:var(--muted)} +.rev-note b{color:var(--warn)} +.rev-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);width:100%} + +/* validation */ +.val-hero{display:flex;align-items:center;gap:14px;margin-bottom:22px} +.val-icon{width:46px;height:46px;flex:none;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:22px} +.val-icon.good{color:var(--good);background:var(--goodbg)} +.val-icon.warn{color:var(--warn);background:var(--chip)} +.val-icon.bad{color:var(--danger);background:var(--accentsoft)} +.val-hero .h{flex:1;min-width:0} +.val-hero .h .t{font:600 24px/1.1 var(--serif);color:var(--ink)} +.val-hero .h .s{font:400 13px var(--sans);color:var(--muted);margin-top:4px} +.val-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:11px;margin-bottom:20px} +.val-stat{padding:16px;border:1px solid var(--line);border-radius:13px;background:var(--card)} +.val-stat .v{font:600 26px var(--serif);color:var(--ink)} +.val-stat .v.good{color:var(--good)} .val-stat .v.warn{color:var(--warn)} .val-stat .v.bad{color:var(--danger)} +.val-stat .l{font:500 11.5px var(--sans);color:var(--muted);margin-top:3px} +.val-note{padding:12px 15px;border:1px solid var(--accentline);background:var(--accentsoft);border-radius:11px;margin-bottom:16px;font:400 12.5px var(--sans);color:var(--muted)} +.val-list{border:1px solid var(--line);border-radius:13px;overflow:hidden;background:var(--card)} +.val-item{display:flex;gap:13px;padding:14px 17px;border-bottom:1px solid var(--line)} +.val-item:last-child{border-bottom:none} +.val-dot{width:24px;height:24px;flex:none;border-radius:50%;display:flex;align-items:center;justify-content:center;font:600 12px var(--mono)} +.val-dot.good{color:var(--good);background:var(--goodbg)} +.val-dot.warn{color:var(--warn);background:var(--chip)} +.val-dot.bad{color:var(--danger);background:var(--accentsoft)} +.val-dot.info{color:var(--muted);background:var(--chip)} +.val-item .m{flex:1;min-width:0} +.val-item .mt{font:500 13.5px var(--sans);color:var(--ink)} +.val-item .ml{font:400 11.5px var(--mono);color:var(--faint);margin-top:3px;word-break:break-all} +.val-item .mc{font:500 11px var(--mono);color:var(--faint);white-space:nowrap} + +/* glossary */ +.gl-langs{display:flex;gap:12px;align-items:flex-end;margin:6px 0 18px} +.gl-langs > div{flex:1;max-width:220px} +.gl-add{display:flex;gap:9px;align-items:center;margin-bottom:12px} +.gl-status{font:400 12px var(--sans);color:var(--muted);min-height:16px;margin-bottom:6px} +.gl-table{border:1px solid var(--line);border-radius:13px;overflow:hidden;background:var(--card)} +.gl-head,.gl-row{display:grid;grid-template-columns:1fr 1fr 120px 40px;align-items:center;padding:12px 17px;gap:10px} +.gl-head{background:var(--soft);border-bottom:1px solid var(--line);font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.05em;color:var(--faint)} +.gl-row{border-bottom:1px solid var(--line)} +.gl-row:last-of-type{border-bottom:none} +.gl-c.s{font:500 14px var(--sans);color:var(--ink)} +.gl-c.t{font:400 14px var(--serif);color:var(--ink)} +.gl-c.cat{font:400 11.5px var(--mono);color:var(--muted)} +.gl-c.x{text-align:right;color:var(--faint);cursor:pointer;font-size:16px} +.gl-c.x:hover{color:var(--danger)} +.gl-foot{padding:11px 17px;font:400 12px var(--sans);color:var(--faint);border-top:1px solid var(--line)} +[hidden]{display:none !important} \ No newline at end of file diff --git a/crates/bookforge-cli/src/commands/serve/dashboard.html b/crates/bookforge-cli/src/commands/serve/dashboard.html new file mode 100644 index 0000000..754d51c --- /dev/null +++ b/crates/bookforge-cli/src/commands/serve/dashboard.html @@ -0,0 +1,30 @@ + + + + + +BookForge + + + + + + +
+
BookForgev2
+ + +
+ +
+
+
+
+ + + + diff --git a/crates/bookforge-cli/src/commands/serve/dashboard.js b/crates/bookforge-cli/src/commands/serve/dashboard.js new file mode 100644 index 0000000..81c38f6 --- /dev/null +++ b/crates/bookforge-cli/src/commands/serve/dashboard.js @@ -0,0 +1,847 @@ +const CSRF_HEADER = "x-bookforge-csrf"; +const CSRF_TOKEN = "__BOOKFORGE_CSRF_TOKEN__"; +const $ = (sel, el) => (el || document).querySelector(sel); +const ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; +function esc(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, ch => ESC[ch]); } +function num(n) { return (n || 0).toLocaleString(); } +function pct(done, total) { return total > 0 ? Math.min(100, Math.round(done / total * 100)) : 0; } +function shorten(s, n) { s = s || ""; return s.length > n ? s.slice(0, n - 1) + "…" : s; } +function badgeClass(status) { return (status || "").toLowerCase().replace(/[^a-z]/g, ""); } +function titleFromPath(path) { + let base = String(path || "").split(/[\\/]/).pop() || "book"; + base = base.replace(/\.epub$/i, "").replace(/^\d{6,}-/, "").replace(/[._-]+/g, " ").trim(); + return base ? base.charAt(0).toUpperCase() + base.slice(1) : "Untitled"; +} +function fmtDur(secs) { + secs = Math.round(secs); + if (secs <= 0) return "—"; + const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60; + return h > 0 ? `${h}h${String(m).padStart(2,"0")}m` : (m > 0 ? `${m}m${String(s).padStart(2,"0")}s` : `${s}s`); +} +function elapsedSecs(s) { + if (s.finished && s.finished_elapsed_ms != null) return s.finished_elapsed_ms / 1000; + if (s.first_timestamp_ms != null && s.last_timestamp_ms != null && s.last_timestamp_ms >= s.first_timestamp_ms) + return (s.last_timestamp_ms - s.first_timestamp_ms) / 1000; + return 0; +} +function segPerMin(s) { const e = elapsedSecs(s); return e > 0 ? (s.done_segments || 0) / e * 60 : 0; } +function etaSecs(s) { const r = Math.max(0, (s.total_segments || 0) - (s.done_segments || 0)); const pm = segPerMin(s); return pm > 0 ? r / (pm / 60) : 0; } +function fmtCost(v) { return v == null ? "n/a" : "$" + Number(v).toFixed(2); } + +const QUALITY = [ + { id:"economy", name:"Economy", desc:"Fast, very cheap. Good for drafts.", profile:"fastest", provider:"deepseek", model:"deepseek-v4-flash" }, + { id:"balanced", name:"Balanced", desc:"Strong quality, sensible price.", profile:"balanced", provider:"deepseek", model:"deepseek-v4-flash", rec:true }, + { id:"finest", name:"Finest", desc:"Top models, literary register.", profile:"safe", provider:"openrouter", model:"openrouter/auto" }, +]; + +const App = { + screen: "library", + theme: localStorage.getItem("bf-theme") || "light", + jobs: [], + selected: null, + es: null, + options: { languages: ["English","Italian","Spanish","French","German"], providers: [] }, + providerKeys: {}, + wizard: null, + runtimeSettings: null, + runtimeJob: null, + runtimeRefreshPending: false, +}; + +function freshWizard() { + return { step:0, file:null, fileName:"", from:"", to:"Italian", quality:"balanced", + provider:"deepseek", model:"deepseek-v4-flash", profile:"balanced", + advancedOpen:false, concurrency:4, qa:"suspicious", context:3, validate:false, + apiKey:"", baseUrl:"", estimate:null, status:"" }; +} + +function applyTheme() { + document.documentElement.setAttribute("data-theme", App.theme); + $("#sun").classList.toggle("on", App.theme === "light"); + $("#moon").classList.toggle("on", App.theme === "dark"); +} +function bfTheme() { App.theme = App.theme === "dark" ? "light" : "dark"; localStorage.setItem("bf-theme", App.theme); applyTheme(); } + +function bfGo(screen, opts) { Object.assign(App, opts || {}); App.screen = screen; if (screen !== "progress") closeStream(); render(); } +function bfStartNew() { App.wizard = freshWizard(); App.screen = "wizard"; closeStream(); render(); } + +const NAV = [["library","Library"],["progress","Progress"],["review","Review"],["validation","Validation"],["glossary","Glossary"]]; +function renderNav() { + const active = App.screen === "wizard" ? "library" : App.screen; + $("#nav").innerHTML = NAV.map(([id,label]) => + `
${label}
`).join(""); +} + +function render() { + renderNav(); + const stage = $("#stage"); + switch (App.screen) { + case "library": return renderLibrary(stage); + case "wizard": return renderWizard(stage); + case "progress": return renderProgress(stage); + case "review": return renderReview(stage); + case "validation": return renderValidation(stage); + case "glossary": return renderGlossary(stage); + default: return renderLibrary(stage); + } +} + +/* ---------------- Library ---------------- */ +function jobDone(st) { return st === "succeeded" || st === "done" || st === "completed"; } +async function renderLibrary(stage) { + stage.innerHTML = `
+

Your library

Pick up a translation, review a finished book, or start a new one.

+
+
Loading…
`; + loadLibraryJobs(); +} +async function loadLibraryJobs() { + let jobs = []; + try { jobs = await (await fetch("/api/jobs")).json(); } catch (e) { jobs = []; } + App.jobs = jobs; + const grid = $("#grid"); + if (!grid || App.screen !== "library") return; + const cards = jobs.map(j => { + const p = pct(j.done, j.total_segments); + const st = badgeClass(j.status); + const done = jobDone(st); + const action = done ? "Review →" : (st === "failed" || st === "error") ? "Inspect →" : (p > 0 ? "View progress →" : "Open →"); + const title = titleFromPath(j.input_path); + return `
+
${esc(title.charAt(0))}
+
+
${esc(title)}
+
${esc(j.provider)} / ${esc(j.model)}
+
${esc(j.status)}${j.done}/${j.total_segments} · ${esc(j.target_lang)}
+
+
+
${action}
`; + }).join(""); + grid.innerHTML = cards + `
+
Translate a new bookDrop an EPUB to begin
`; +} +function bfOpenJob(id, st) { + if (jobDone(st)) bfGo("review", { selected: id }); + else bfGo("progress", { selected: id }); +} + +/* ---------------- Wizard ---------------- */ +const WIZ_STEPS = [ + { label:"Book", hint:"Source file" }, + { label:"Languages", hint:"Pair" }, + { label:"Quality", hint:"Tier" }, + { label:"Review & start", hint:"Confirm plan" }, +]; +const WIZ_META = [ + ["Step 1 · Your book","Pick the source file","This is the EPUB BookForge will translate. Structure, footnotes and code blocks are protected."], + ["Step 2 · Languages","Choose the pair","Pick the source (or leave it to auto-detect) and the target language."], + ["Step 3 · Quality","How good, how cheap","Sets the model and pricing tier. Fine-tune the exact model under Advanced on the next step."], + ["Step 4 of 4 · Review","Ready when you are","Review the plan, then start. The job is checkpointed every chapter, so you can resume or retry anytime."], +]; +function providerOption(id) { return (App.options.providers || []).find(p => p.id === id) || (App.options.providers || [])[0] || { id:"mock", models:[], requires_key:false, requires_base_url:false }; } + +function renderWizard(stage) { + const w = App.wizard || (App.wizard = freshWizard()); + const meta = WIZ_META[w.step]; + const rail = WIZ_STEPS.map((st,i) => { + const cls = i < w.step ? "done" : i === w.step ? "current" : ""; + return `
${i +
${st.label}
${st.hint}
`; + }).join(""); + stage.innerHTML = `
+
New translation
${rail}
+
Translating
+
${esc(w.fileName ? titleFromPath(w.fileName) : "No file yet")}
+
${esc((w.from||"auto"))} → ${esc(w.to||"?")} · ${esc(qualityName(w.quality))}
+
${meta[0]}

${meta[1]}

${meta[2]}
+
+
+ + ${esc(w.status||"")} + +
`; + renderWizBody(); +} +function qualityName(id) { const q = QUALITY.find(q => q.id === id); return q ? q.name : id; } +function bfWizGo(i) { syncWizInputs(); App.wizard.step = Math.max(0, Math.min(3, i)); renderWizard($("#stage")); } +function bfWizBack() { syncWizInputs(); if (App.wizard.step > 0) { App.wizard.step--; renderWizard($("#stage")); } } + +function renderWizBody() { + const w = App.wizard, body = $("#wizbody"); if (!body) return; + if (w.step === 0) { + body.innerHTML = `
+ ${w.file ? `
${esc(w.fileName)}
Click to choose a different file
` + : `
Drop an EPUB here or click to browse.
`} +
`; + } else if (w.step === 1) { + const chips = ["Italian","Spanish","French","German","Japanese","Korean","Portuguese","Chinese (Simplified)"]; + body.innerHTML = `
+
Translate from
+
+
+
Into
+
+
+ ${(App.options.languages||[]).map(l=>` +
Quick pick
+
${chips.map(n=>`
${esc(n)}
`).join("")}
`; + } else if (w.step === 2) { + body.innerHTML = `
${QUALITY.map(q=>` +
+
${w.quality===q.id?"Selected":q.rec?"Recommended":""}
+
${q.name}
${q.desc}
+
${esc(q.provider)} · ${esc(q.model)}
`).join("")}
+

You can override the provider and exact model under Advanced on the next step — including the offline mock provider for a dry run.

`; + } else { + renderReviewStep(body); + } +} +function syncWizInputs() { + const w = App.wizard; if (!w) return; + const from = $("#w_from"); if (from) w.from = from.value.trim(); + const to = $("#w_to"); if (to) w.to = to.value.trim(); + const key = $("#w_key"); if (key) w.apiKey = key.value; + const base = $("#w_base"); if (base) w.baseUrl = base.value.trim(); + const conc = $("#w_conc"); if (conc) w.concurrency = Math.max(1, Math.min(16, parseInt(conc.value,10) || 1)); + const mid = $("#w_modelid"); if (mid && mid.value.trim()) w.model = mid.value.trim(); +} +function bfPickFile(input) { + const f = input.files && input.files[0]; + if (!f) return; + App.wizard.file = f; App.wizard.fileName = f.name; App.wizard.estimate = null; + renderWizard($("#stage")); +} +function bfSwapLangs() { syncWizInputs(); const w = App.wizard; const t = w.from; w.from = w.to; w.to = t; renderWizBody(); } +function bfPickTo(name) { syncWizInputs(); App.wizard.to = name; renderWizBody(); } +function bfPickTier(id) { + const q = QUALITY.find(q => q.id === id); if (!q) return; + const w = App.wizard; w.quality = id; w.profile = q.profile; w.provider = q.provider; w.model = q.model; + w.estimate = null; renderWizBody(); +} + +function renderReviewStep(body) { + const w = App.wizard; + const opt = providerOption(w.provider); + const needsKey = opt.requires_key === true && App.providerKeys[w.provider] !== true; + const needsBase = opt.requires_base_url === true; + const facts = [ + { k:"Languages", v:`${w.from||"auto"} → ${w.to||"?"}` }, + { k:"Quality", v:qualityName(w.quality) }, + { k:"Model", v:`${esc(w.provider)} · ${esc(w.model)}`, mono:true }, + { k:"Profile", v:esc(w.profile) }, + ]; + const est = w.estimate; + const costLabel = est ? fmtCost(est.cost_usd) : (w.file ? "…" : "add a file"); + const tokens = est ? num(est.input_tokens + est.output_tokens) : "—"; + const providerChips = (App.options.providers||[]).map(p => + `
${esc(p.label||p.id)}
`).join(""); + const models = (opt.models||[]).map(m => + `
${esc(m)}
`).join(""); + body.innerHTML = ` +
${facts.map(f=>`
${f.k}
${f.v}
`).join("")}
+
Estimated cost
${costLabel}
+
${tokens} tokens
${est?"priced from catalog":"parses your EPUB"}
+
Advanced — provider, model, concurrency, QA, context, validation${w.advancedOpen?"▾":"▸"}
+
+
Provider
+
${providerChips}
+ ${needsBase?`
Base URL
`:""} + ${needsKey?`
API key
Read from the server environment or remembered for this server session.
`:""} +
Model · ${esc((opt.label||opt.id))}
+
${models||`
No preset models
`}
+
+ Or type any ID +
+
+
Concurrency
+
+ +
+
+
QA pass${esc(w.qa)}
+
Context window${w.context}
+
Validate output${w.validate?"On":"Off"}
+
+
`; + if (w.file && !w.estimate) requestEstimate(); +} +function bfToggleAdvanced() { syncWizInputs(); App.wizard.advancedOpen = !App.wizard.advancedOpen; renderReviewStep($("#wizbody")); } +function bfPickProvider(id) { syncWizInputs(); const w = App.wizard; w.provider = id; const opt = providerOption(id); w.model = opt.default_model || (opt.models||[])[0] || w.model; w.estimate = null; renderReviewStep($("#wizbody")); } +function bfPickModel(m) { syncWizInputs(); App.wizard.model = m; App.wizard.estimate = null; renderReviewStep($("#wizbody")); } +function bfConc(d) { const el = $("#w_conc"); if (!el) return; let v = (parseInt(el.value,10)||1) + d; v = Math.max(1, Math.min(16, v)); el.value = v; App.wizard.concurrency = v; } +function bfCycleQa() { syncWizInputs(); const w = App.wizard; w.qa = w.qa === "off" ? "suspicious" : w.qa === "suspicious" ? "all" : "off"; renderReviewStep($("#wizbody")); } +function bfCycleContext() { syncWizInputs(); const w = App.wizard; w.context = w.context >= 6 ? 0 : w.context + 1; renderReviewStep($("#wizbody")); } +function bfToggleValidate() { syncWizInputs(); App.wizard.validate = !App.wizard.validate; renderReviewStep($("#wizbody")); } + +async function requestEstimate() { + const w = App.wizard; if (!w.file) return; + const fd = new FormData(); + fd.append("file", w.file); fd.append("provider", w.provider); + if (w.model) fd.append("model", w.model); + if (w.to) fd.append("target", w.to); + try { + const r = await fetch("/api/estimate", { method: "POST", headers: { [CSRF_HEADER]: CSRF_TOKEN }, body: fd }); + const j = await r.json(); + if (!r.ok) return; + if (App.screen === "wizard" && App.wizard === w) { + w.estimate = j; + const cv = $("#costv"); if (cv) cv.textContent = fmtCost(j.cost_usd); + const et = $("#esttokens"); if (et) et.textContent = num(j.input_tokens + j.output_tokens); + } + } catch (e) {} +} + +async function bfWizNext() { + syncWizInputs(); + const w = App.wizard; + if (w.step === 0) { if (!w.file) { toastWiz("choose an EPUB file"); return; } w.step = 1; return renderWizard($("#stage")); } + if (w.step === 1) { if (!w.to) { toastWiz("choose a target language"); return; } w.step = 2; return renderWizard($("#stage")); } + if (w.step === 2) { w.step = 3; return renderWizard($("#stage")); } + return launchTranslation(); +} +function toastWiz(msg) { const el = $("#launchstatus"); if (el) el.textContent = msg; if (App.wizard) App.wizard.status = msg; } + +async function launchTranslation() { + const w = App.wizard; + const opt = providerOption(w.provider); + if (opt.requires_base_url && !w.baseUrl) { w.advancedOpen = true; renderWizBody(); return toastWiz("base URL is required for this provider"); } + if (w.launching) return; + w.launching = true; + const btn = $("#wiznext"); + const reenable = () => { w.launching = false; if (btn) { btn.disabled = false; btn.style.opacity = ""; btn.textContent = "Start translation"; } }; + if (btn) { btn.disabled = true; btn.style.opacity = ".6"; btn.textContent = "Starting…"; } + toastWiz("uploading…"); + const fd = new FormData(); + fd.append("file", w.file); + fd.append("target", w.to); + if (w.from) fd.append("source", w.from); + fd.append("provider", w.provider); + if (w.model) fd.append("model", w.model); + fd.append("profile", w.profile); + fd.append("concurrency", String(w.concurrency)); + fd.append("qa", w.qa); + fd.append("context_window", String(w.context)); + if (w.validate) fd.append("validate_output", "true"); + if (w.apiKey) fd.append("api_key", w.apiKey); + if (w.baseUrl) fd.append("base_url", w.baseUrl); + try { + const r = await fetch("/api/translate", { method: "POST", headers: { [CSRF_HEADER]: CSRF_TOKEN }, body: fd }); + const j = await r.json(); + if (!r.ok) { reenable(); toastWiz(j.error || "launch failed"); return; } + toastWiz("started — locating job…"); + await loadProviderStatus(); + trySelectPending(j.input_path, 0); + } catch (e) { reenable(); toastWiz("launch failed"); } +} +async function trySelectPending(inputPath, attempt) { + if (attempt > 25) return; + let jobs = []; + try { jobs = await (await fetch("/api/jobs")).json(); } catch (e) {} + const match = jobs.find(j => j.input_path === inputPath); + if (match) { bfGo("progress", { selected: match.id }); return; } + setTimeout(() => trySelectPending(inputPath, attempt + 1), 900); +} + +/* ---------------- Progress ---------------- */ +async function renderProgress(stage) { + const id = App.selected; + if (!id) { stage.innerHTML = `
Open a translation from the library to watch its progress.
`; return; } + stage.innerHTML = `
Loading job…
`; + let d, runtime = null; + try { + const [jobResponse, runtimeResponse] = await Promise.all([ + fetch("/api/jobs/" + encodeURIComponent(id)), + fetch("/api/jobs/" + encodeURIComponent(id) + "/reconfigure"), + ]); + if (!jobResponse.ok) throw new Error(); + d = await jobResponse.json(); + if (runtimeResponse.ok) runtime = await runtimeResponse.json(); + } + catch (e) { stage.innerHTML = `
Could not load this job.
`; return; } + App.runtimeSettings = runtime; + App.runtimeJob = id; + const title = titleFromPath(d.input_path); + stage.innerHTML = `
+
${esc(title.charAt(0))}
+
${esc(d.id)}
+
${esc(d.provider)} / ${esc(d.model)} · ${esc(d.source_lang||"auto")} → ${esc(d.target_lang)}
+ ${esc(d.status)}
+
+
0%
+
+
+
connecting… + + + + + +
+
+
+

Live activity

+
waiting…
+

Issues

none
+ +
`; + drawRuntimeSettings(runtime); + updateState(d.state || {}); + openStream(id); +} +function runtimeOption(value, current, label) { return ``; } +function drawRuntimeSettings(view) { + const panel = $("#runtime-panel"); if (!panel) return; + if (!view) { panel.innerHTML = `
Runtime settings
No resumable run snapshot is available.
`; return; } + const e = view.effective || {}, ident = view.identity || {}, lease = view.lease || {}; + const disabled = view.editable ? "" : "disabled"; + const boundaries = (view.next_boundary || []).map(v => v.replace(/_/g," ")).join(", ") || "none pending"; + const leaseNote = lease.state === "fresh" ? `worker ${lease.pid || "?"} - applied r${view.applied_revision || 0}` : `${lease.state || "missing"} worker - Resume required`; + panel.innerHTML = `
+
Runtime settings
revision r${view.revision || 0} - ${esc(leaseNote)} - boundary: ${esc(boundaries)}
${esc(view.application_state || "resume_required")}
+
+ + + + + + + + + + +
+
Immutable identity: ${esc(ident.provider || "-")} / ${esc(ident.model || "-")} - ${esc(ident.source_language || "auto")} to ${esc(ident.target_language || "-")} - ${esc(ident.profile || "-")} - prompt ${esc(ident.prompt_version || "-")}
+
${view.editable ? "Changes apply at the named request, batch, or stage boundary." : (view.resumable_work ? "This job is not in an editable state." : "No resumable work remains.")}
+
`; +} +function runtimePositiveInt(id, label, optional) { + const el = $(id), raw = el ? el.value.trim() : ""; + if (optional && raw === "") return null; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) throw new Error(`${label} must be a positive integer`); + return value; +} +async function bfSaveRuntimeSettings() { + const view = App.runtimeSettings, feedback = $("#runtime-feedback"), button = $("#runtime-save"); + if (!view || !view.editable) return; + let values; + try { + values = { + batch_max_output_tokens: runtimePositiveInt("#rt-output","batch output tokens",true), + batch_max_items: runtimePositiveInt("#rt-items","batch max items",false), + batch_target_tokens: runtimePositiveInt("#rt-target","batch target tokens",false), + concurrency: runtimePositiveInt("#rt-concurrency","concurrency",false), + provider_max_attempts: runtimePositiveInt("#rt-attempts","provider attempts",false), + qa: $("#rt-qa").value, + double_check: $("#rt-double").value, + validate_output: $("#rt-validate").checked, + adaptive_concurrency: $("#rt-adaptive-concurrency").checked, + adaptive_batch_sizing: $("#rt-adaptive-batch").checked, + }; + } catch (e) { if (feedback) { feedback.textContent=e.message; feedback.classList.add("bad"); } return; } + const payload = {}, previous = view.effective || {}; + Object.keys(values).forEach(key => { if (values[key] !== previous[key] && !(key === "batch_max_output_tokens" && values[key] === null)) payload[key] = values[key]; }); + if (!Object.keys(payload).length) { if (feedback) { feedback.textContent="No runtime settings changed."; feedback.classList.remove("bad"); } return; } + if (button) button.disabled = true; + if (feedback) { feedback.textContent="Saving..."; feedback.classList.remove("bad"); } + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/reconfigure`, {method:"POST",headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN},body:JSON.stringify(payload)}); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "runtime update failed"); + App.runtimeSettings = body; drawRuntimeSettings(body); + const next = (body.next_boundary || []).map(v => v.replace(/_/g," ")).join(", "); + const out = $("#runtime-feedback"); if (out) out.textContent = `Saved revision r${body.revision} - ${body.live ? (next || "live") : "Resume required"}`; + } catch (e) { if (feedback) { feedback.textContent=e.message || "runtime update failed"; feedback.classList.add("bad"); } } + finally { const current=$("#runtime-save"); if (current) current.disabled=false; } +} +async function refreshRuntimeSettings() { + const id = App.selected; + if (!id || App.runtimeRefreshPending || App.screen !== "progress") return; + App.runtimeRefreshPending = true; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(id)}/reconfigure`); + if (r.ok && App.screen === "progress" && App.selected === id) { App.runtimeSettings = await r.json(); App.runtimeJob=id; drawRuntimeSettings(App.runtimeSettings); } + } catch (_) {} + finally { App.runtimeRefreshPending=false; } +} +function setLive(on, txt) { const dt = $("#livedot"), tx = $("#livetxt"); if (dt) dt.classList.toggle("on", on); if (tx) tx.textContent = txt; } +function updateState(s) { + const total = s.total_segments || 0, done = s.done_segments || 0, p = pct(done, total); + const liveStatus = s.paused ? "paused" : (s.finished ? "done" : null); + if (liveStatus) setProgressStatus(liveStatus); + const fill = $("#barfill"); if (fill) fill.style.width = p + "%"; + const pv = $("#pctv"); if (pv) pv.textContent = p; + const bar = $("#progbar"); if (bar) bar.classList.toggle("done", !!s.finished); + const etav = $("#etav"); if (etav) etav.innerHTML = `${done} / ${total} segments
${s.finished ? "Finished" : "about " + fmtDur(etaSecs(s)) + " remaining"}`; + const stats = [ + ["done", num(done), ""], ["succeeded", num(s.succeeded || 0), "good"], ["cached", num(s.cached || 0), ""], + ["needs review", num(s.needs_review || 0), s.needs_review ? "warn" : ""], + ["failed", num(s.failed || 0), s.failed ? "bad" : ""], + ["active", `${s.active_requests || 0}/${s.target_concurrency || 0}`, ""], + ["seg/min", segPerMin(s).toFixed(1), ""], ["elapsed", fmtDur(elapsedSecs(s)), ""], + ["tokens in", num(s.input_tokens), ""], ["tokens out", num(s.output_tokens), ""], + ]; + const box = $("#stats"); if (box) box.innerHTML = stats.map(([k,v,c]) => `
${esc(k)}
${esc(v)}
`).join(""); + const ibox = $("#issues"); + if (ibox) { const issues = s.recent_issues || []; + ibox.innerHTML = issues.length ? issues.slice().reverse().map(i => `
${i.level==="Error"?"✗":"⚠"} ${esc(i.kind)}: ${esc(shorten(i.message,120))}
`).join("") : `
none
`; + } + const ebox = $("#events"); + if (ebox) { const evs = s.recent_events || []; + ebox.innerHTML = evs.length ? evs.slice().reverse().map(fmtEvent).join("") : `
waiting…
`; + } + const runtimeRevision = Number(s.runtime_config_revision || 0); + if (runtimeRevision && (!App.runtimeSettings || runtimeRevision > Number(App.runtimeSettings.applied_revision || 0))) refreshRuntimeSettings(); + if (s.runtime_config_rejection) { + const feedback = $("#runtime-feedback"); + if (feedback) { feedback.textContent = `Rejected: ${s.runtime_config_rejection}`; feedback.classList.add("bad"); } + } +} +function fmtEvent(ev) { + const key = Object.keys(ev)[0]; const v = ev[key] || {}; let cls = "", body = key; + switch (key) { + case "SegmentFinished": body = `segment ${shorten(v.segment_id,18)} → ${v.status}`; if (v.status==="failed") cls="bad"; else if (v.status==="needs_review") cls="warn"; break; + case "SegmentStarted": body = `segment ${shorten(v.segment_id,18)} started`; break; + case "RequestStarted": { const audit = [v.runtime_config_revision == null ? null : `r${v.runtime_config_revision}`, v.provider_max_attempts == null ? null : `${v.provider_max_attempts} attempts`].filter(Boolean).join(" - "); body = `request started (${v.active_requests}/${v.target_concurrency})${audit ? " - " + audit : ""}`; break; } + case "RequestFinished": body = `request ${v.status} · ${v.latency_ms}ms`; if (v.status!=="ok"&&v.status!=="succeeded") cls="warn"; break; + case "StageStarted": body = `stage: ${v.stage}`; break; + case "StageFinished": body = `stage complete: ${v.stage}`; break; + case "SegmentationFinished": body = `segmented into ${v.segment_count} segments`; break; + case "CacheScanFinished": body = `cache scan: ${v.hits} hits / ${v.misses} misses`; break; + case "JobPaused": body = `paused`; cls="warn"; break; + case "JobResumed": body = `resumed`; cls="good"; break; + case "CheckpointFlushed": body = `checkpoint flushed (${v.flushed_count})`; break; + case "ConcurrencyChanged": body = `concurrency ${v.previous} → ${v.current} (${v.reason})`; break; + case "RuntimeConfigChanged": body = `runtime r${v.revision}: ${(v.changed_fields || []).join(", ") || "updated"} -> ${(v.application || []).join(", ") || "next boundary"}`; cls="good"; break; + case "RuntimeConfigRejected": body = `runtime config rejected${v.revision == null ? "" : " r" + v.revision}: ${shorten(v.message || "invalid settings",90)}`; cls="bad"; break; + case "Warning": body = `⚠ ${v.kind}: ${shorten(v.message,90)}`; cls="warn"; break; + case "Error": body = `✗ ${v.kind}: ${shorten(v.message,90)}`; cls="bad"; break; + case "TranslationFinished": body = `finished: ${v.succeeded} ok, ${v.cached} cached, ${v.needs_review} review, ${v.failed} failed`; cls="good"; break; + } + return `
${esc(body)}
`; +} +function openStream(id) { + closeStream(); + App.es = new EventSource("/api/jobs/" + encodeURIComponent(id) + "/events"); + setLive(true, "live"); + App.es.addEventListener("state", (e) => { if (App.selected === id && App.screen === "progress") { try { updateState(JSON.parse(e.data)); } catch (_) {} } }); + App.es.addEventListener("done", () => { setLive(false, "finished"); closeStream(); }); + App.es.onerror = () => setLive(false, "reconnecting…"); +} +function closeStream() { if (App.es) { App.es.close(); App.es = null; } } +async function bfRetry(id) { + const btn = $("#retrybtn"), toast = $("#toast"); + if (btn) btn.disabled = true; if (toast) toast.textContent = "submitting…"; + try { + const r = await fetch("/api/jobs/" + encodeURIComponent(id) + "/retry", { method: "POST", headers: { [CSRF_HEADER]: CSRF_TOKEN } }); + const j = await r.json(); + if (toast) toast.textContent = r.ok ? `marked ${j.retried} segment(s) — run: bookforge resume ${id}` : (j.error || "retry failed"); + } catch (e) { if (toast) toast.textContent = "retry failed"; } + if (btn) btn.disabled = false; +} +function setProgressStatus(status) { + const pill = $("#progpill"); + if (!pill) return; + pill.textContent = status; + pill.className = "badge " + badgeClass(status); +} +async function bfJobControl(id, command) { + const toast = $("#toast"); + const buttons = ["#pausebtn","#resumebtn","#stopbtn"].map(id => $(id)).filter(Boolean); + buttons.forEach(b => b.disabled = true); + if (toast) toast.textContent = command + " requested…"; + try { + const r = await fetch("/api/jobs/" + encodeURIComponent(id) + "/" + command, { method: "POST", headers: { [CSRF_HEADER]: CSRF_TOKEN } }); + const j = await r.json(); + if (r.ok) { + if (command === "pause") setProgressStatus("paused"); + if (command === "resume") setProgressStatus("running"); + if (command === "stop") setProgressStatus("stopped"); + let message = `${command} requested`; + if (command === "resume" && j.mode === "signaled") message = "Resume signaled to the live worker."; + if (command === "resume" && j.mode === "spawned") message = `Resume worker started${j.pid ? " (PID " + j.pid + ")" : ""}.`; + if (command === "resume" && j.mode === "launching") message = "A resume worker is already starting."; + if (toast) toast.textContent = message; + setTimeout(refreshRuntimeSettings, 350); + } else { + if (toast) toast.textContent = j.error || `${command} failed`; + } + } catch (e) { + if (toast) toast.textContent = `${command} failed`; + } + buttons.forEach(b => b.disabled = false); +} + +/* ---------------- Review / Validation / Glossary (wired in later milestones) ---------------- */ +function placeholder(stage, title, note) { + stage.innerHTML = `

${title}

${note}

+
${App.selected ? "Loading…" : "Open a job from the library first."}
`; +} +function segTag(seg, flagged) { + if (flagged) return { label:"Flagged", cls:"bad" }; + if (seg.human_corrected) return { label:"corrected", cls:"" }; + if (seg.status === "failed") return { label:"failed", cls:"bad" }; + if (seg.status === "needs_review") return { label:"review", cls:"warn" }; + if ((seg.soft_warnings || []).length) return { label:"check", cls:"warn" }; + return { label:"ok", cls:"" }; +} +async function renderReview(stage) { + const id = App.selected; + if (!id) { placeholder(stage, "Review", "Side-by-side source and translation."); return; } + stage.innerHTML = `
Loading review…
`; + let doc; + try { + const r = await fetch("/api/jobs/" + encodeURIComponent(id) + "/review"); + doc = await r.json(); + if (!r.ok) { stage.innerHTML = `
${esc(doc.error || "Review is not available for this job.")}
`; return; } + } catch (e) { stage.innerHTML = `
Could not load review.
`; return; } + App.review = { doc, idx: 0, filter: "all", hintOpen: false, hintText: "", notice: "" }; + drawReview(); +} +function bfReviewPick(i) { App.review.idx = i; App.review.hintOpen=false; App.review.hintText=""; App.review.notice=""; drawReview(); } +function bfReviewNav(d) { const n = (App.review.doc.segments || []).length; App.review.idx = Math.max(0, Math.min(n - 1, App.review.idx + d)); App.review.hintOpen=false; App.review.hintText=""; App.review.notice=""; drawReview(); } +function bfReviewFilter(f) { App.review.filter = f; drawReview(); } +async function bfReviewFlag() { + const R = App.review, seg = R.doc.segments[R.idx]; if (!seg) return; + const next = !seg.flagged; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/flag`, { + method:"POST", headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN}, body:JSON.stringify({flagged:next}) + }); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "flag update failed"); + seg.flagged = next; drawReview(); + } catch (e) { window.alert(e.message || "flag update failed"); } +} +async function bfReviewSave() { + const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg) return; + const status = $("#rev-save-status"), button = $("#rev-save"); + const blocks = Array.from(document.querySelectorAll(".rev-edit")).map(el => ({ block_id: el.dataset.blockId, text: el.value })); + if (blocks.some(block => !block.text.trim())) { if (status) status.textContent = "every block needs translation text"; return; } + if (button) button.disabled = true; if (status) status.textContent = "saving and rebuilding…"; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/translation`, { + method: "POST", headers: { "Content-Type":"application/json", [CSRF_HEADER]: CSRF_TOKEN }, body: JSON.stringify({ blocks }) + }); + const body = await r.json(); + if (!r.ok) throw new Error(body.error || "correction failed"); + if (status) status.textContent = `saved · ${body.job_status}`; + const refreshed = await fetch("/api/jobs/" + encodeURIComponent(App.selected) + "/review"); + R.doc = await refreshed.json(); drawReview(); + } catch (e) { if (status) status.textContent = e.message || "correction failed"; } + finally { if (button) button.disabled = false; } +} +function bfReviewRetry() { + const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg || seg.human_corrected) return; + R.hintOpen = true; + const panel = $("#rev-hint-panel"), input = $("#rev-hint-text"); + if (panel) panel.hidden = false; + if (input) { input.value = R.hintText || ""; input.focus(); } +} +function bfReviewRetryCancel() { + const R = App.review; if (!R) return; + R.hintOpen = false; R.hintText = ""; + const panel = $("#rev-hint-panel"); if (panel) panel.hidden = true; +} +async function bfReviewStopForRetry() { + const status = $("#rev-save-status"); if (status) status.textContent = "requesting stop…"; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/stop`, {method:"POST",headers:{[CSRF_HEADER]:CSRF_TOKEN}}); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "stop failed"); + if (status) status.textContent = "Stop requested. Wait for the worker to stop, then queue the retry."; + } catch (e) { if (status) status.textContent = e.message || "stop failed"; } +} +async function bfReviewRetrySubmit() { + const R = App.review, seg = R && R.doc.segments[R.idx]; if (!seg) return; + const input = $("#rev-hint-text"), guidance = input ? input.value.trim() : ""; + R.hintText = guidance; + const status = $("#rev-save-status"); if (status) status.textContent = "queuing segment retry…"; + try { + const r = await fetch(`/api/jobs/${encodeURIComponent(App.selected)}/segments/${encodeURIComponent(seg.segment_id)}/retry`, { + method:"POST", headers:{"Content-Type":"application/json",[CSRF_HEADER]:CSRF_TOKEN}, body:JSON.stringify({guidance}) + }); + const body = await r.json(); if (!r.ok) throw new Error(body.error || "retry request failed"); + seg.status = "retry_pending"; R.hintOpen=false; R.hintText=""; R.notice=`Retry queued. Resume ${App.selected}.`; drawReview(); + } catch (e) { if (status) status.textContent = e.message || "retry request failed"; } +} +function drawReview() { + const R = App.review, doc = R.doc, segs = doc.segments || []; + const flaggedCount = segs.filter(seg => seg.flagged).length; + const visible = segs.map((s, i) => ({ s, i })).filter(({ s }) => { + if (R.filter === "flagged") return !!s.flagged; + if (R.filter === "warnings") return (s.soft_warnings || []).length || (s.status !== "succeeded" && s.status !== "skipped_cached"); + return true; + }); + const filters = [["all", `All ${segs.length}`], ["warnings", "To check"], ["flagged", `Flagged ${flaggedCount}`]]; + const rows = visible.map(({ s, i }) => { + const flagged = !!s.flagged; + const tag = segTag(s, flagged); + const ref = `${s.chapter_title || s.chapter_id} ¶${s.ordinal}`; + return `
+
${esc(shorten(ref, 24))}${tag.label}
+
`; + }).join("") || `
Nothing here.
`; + const cur = segs[R.idx]; + const title = doc.source_book_title || titleFromPath(App.selected); + const langs = `${esc(doc.source_language || "auto")} → ${esc(doc.target_language)}`; + let main; + if (!cur) { + main = `
No translated segments yet.
`; + } else { + const flagged = !!cur.flagged; + const ref = `${cur.chapter_title || cur.chapter_id} ¶${cur.ordinal}`; + const notes = (cur.soft_warnings || []).map(w => + `
⚑ ${esc((w.kind || "note").replace(/_/g, " "))} — ${esc(w.message || "")}
`).join(""); + const blockEditors = (cur.blocks || []).map(block => `
${esc(block.block_id)}
`).join(""); + const hintPanel = `
Stop the job before queuing a retry. Running and paused jobs reject retry changes.
`; + main = `
${esc(ref)} · ${esc(cur.status)}${cur.human_corrected ? " · manual" : ""} +
+
+
+
Source · ${esc(doc.source_language || "auto")}
${esc(cur.source_text)}
+
Translation · ${esc(doc.target_language)}
${blockEditors}
${R.notice || (cur.human_corrected ? "human correction saved" : "")}
${hintPanel}${notes}
+
`; + } + $("#stage").innerHTML = `
+
${esc(shorten(title, 32))}
+
${langs} · ${segs.length} segments · ${fmtCost(doc.totals && doc.totals.estimated_cost_usd)}
+
${filters.map(([f, l]) => `
${esc(l)}
`).join("")}
+
${rows}
+
${main}
`; +} +async function renderValidation(stage) { + const id = App.selected; + if (!id) { placeholder(stage, "Validation", "EPUBCheck and structural validators."); return; } + App.validation = App.validation || {}; + if (App.validation[id]) { drawValidation(App.validation[id]); } else { runValidation(); } +} +async function runValidation() { + const id = App.selected; + $("#stage").innerHTML = `
Running validators…
`; + try { + const r = await fetch("/api/jobs/" + encodeURIComponent(id) + "/validate", { method: "POST", headers: { [CSRF_HEADER]: CSRF_TOKEN } }); + const j = await r.json(); + if (!r.ok) { $("#stage").innerHTML = `
${esc(j.error || "Validation could not run.")}
`; return; } + (App.validation = App.validation || {})[id] = j; + if (App.screen === "validation" && App.selected === id) drawValidation(j); + } catch (e) { $("#stage").innerHTML = `
Could not run validation.
`; } +} +function bfRevalidate() { runValidation(); } +function sevRank(s) { return s === "fatal" || s === "error" ? "bad" : s === "warning" ? "warn" : s === "info" ? "info" : "good"; } +function sevGlyph(cls) { return cls === "bad" ? "✗" : cls === "warn" ? "!" : cls === "info" ? "i" : "✓"; } +function drawValidation(rep) { + const ec = rep.epubcheck || {}, bf = rep.bookforge_validators || {}; + const msgs = [...(bf.messages || []).map(m => ({ ...m, src: "BookForge" })), ...(ec.messages || []).map(m => ({ ...m, src: "EPUBCheck" }))]; + const errors = msgs.filter(m => m.severity === "error" || m.severity === "fatal").length; + const warnings = msgs.filter(m => m.severity === "warning").length; + const overall = errors ? "bad" : warnings ? "warn" : "good"; + const title = errors ? "Validation failed" : warnings ? `Passed with ${warnings} warning${warnings === 1 ? "" : "s"}` : "Passed"; + const ecUnavailable = ec.status === "unavailable"; + const sub = ec.ran ? `EPUBCheck ${esc(ec.version || "")} · ${esc(ec.status)}. BookForge validators: ${esc(bf.status || "-")}.` + : `EPUBCheck not run. BookForge validators: ${esc(bf.status || "-")}.`; + const note = ecUnavailable ? `
EPUBCheck is unavailable — install epubcheck on PATH or set BOOKFORGE_EPUBCHECK to include the reader-compatibility pass. BookForge's own structural validators still ran.
` : ""; + const rows = msgs.length ? msgs.map(m => { + const cls = sevRank(m.severity); + return `
${sevGlyph(cls)} +
${esc(m.text || m.code || "message")}
+
${esc(m.src)} · ${esc(m.code || "")}${m.location ? " · " + esc(m.location) : ""}
`; + }).join("") : `
No issues reported.
`; + $("#stage").innerHTML = `
+
${overall === "bad" ? "✗" : overall === "warn" ? "!" : "✓"}
+
${title}
${sub}
+
+ ${note} +
+
${errors}
Errors
+
${warnings}
Warnings
+
${bf.xml_valid ? "OK" : "—"}
Structure${bf.files_checked != null ? " · " + bf.files_checked + " files" : ""}
+
+

Validator messages

+
${rows}
`; +} +const GL_CATEGORIES = ["person","place","object","invented","style","phrase","other"]; +function renderGlossary(stage) { + if (!App.glossary) { + const langs = App.options.languages || []; + const to = langs.includes("Italian") ? "Italian" : (langs.find(l => l !== "English") || "Italian"); + App.glossary = { from: "English", to, terms: [] }; + } + const g = App.glossary; + const langOpts = (App.options.languages || []).map(l => `