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..2e1a35a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## v2.4.0 - 2026-07-13 + +BookForge v2.4.0 closes the local review loop and makes safe runtime tuning +genuinely live while strengthening the release gates around both changes. + +- 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 ordering, adaptive batch override propagation, stale + override cleanup, stop/resume lifecycle timing races (including scheduler- + delayed CI child startup), 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. +- Split the largest dashboard, batch, store, PDF-conversion, and translate + modules behind behavior-preserving seams while retaining the embedded + single-binary dashboard and existing public APIs. +- Added an explicit pre-v2.4 database migration regression proving existing + translations and blocks survive the new human-correction audit columns. +- Exercised the generated installers on native x86_64/aarch64 macOS and Linux + plus Windows MSVC, and completed a selected DeepSeek pause, live-reconfigure, + correction, guided-retry, replacement-worker, persistence, and output- + validation acceptance run. + +## 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..05b3276 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" @@ -260,7 +260,7 @@ dependencies = [ [[package]] name = "bookforge-cli" -version = "2.3.0" +version = "2.4.0" 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" dependencies = [ "clap", "quick-xml", @@ -307,7 +308,7 @@ dependencies = [ [[package]] name = "bookforge-epub" -version = "2.3.0" +version = "2.4.0" dependencies = [ "bookforge-core", "quick-xml", @@ -319,7 +320,7 @@ dependencies = [ [[package]] name = "bookforge-llm" -version = "2.3.0" +version = "2.4.0" dependencies = [ "bookforge-core", "reqwest", @@ -333,7 +334,7 @@ dependencies = [ [[package]] name = "bookforge-pdf" -version = "2.3.0" +version = "2.4.0" dependencies = [ "bookforge-core", "bookforge-epub", @@ -350,7 +351,7 @@ dependencies = [ [[package]] name = "bookforge-store" -version = "2.3.0" +version = "2.4.0" 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]] @@ -1687,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", ] @@ -1716,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", @@ -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/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-cli/Cargo.toml b/crates/bookforge-cli/Cargo.toml index 3241a1a..2ab296f 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" 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", path = "../bookforge-core", features = ["cli"] } +bookforge-epub = { version = "2.4.0", path = "../bookforge-epub" } +bookforge-llm = { version = "2.4.0", path = "../bookforge-llm" } +bookforge-pdf = { version = "2.4.0", path = "../bookforge-pdf" } +bookforge-store = { version = "2.4.0", 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/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 1409cef..72172ca 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::{ @@ -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); } @@ -315,7 +308,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 +329,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)); @@ -351,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(), @@ -377,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 { @@ -520,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(), ) @@ -566,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(), ) @@ -591,7 +596,7 @@ async fn run_inner( persist_corrected_translations( &store, &job.id, - &run_config, + &double_check_run_config, &translations, &changed_segment_ids, )?; @@ -611,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 { @@ -651,18 +657,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()); @@ -1254,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 @@ -1268,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"); } @@ -1808,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/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..d297958 100644 --- a/crates/bookforge-cli/src/commands/serve.rs +++ b/crates/bookforge-cli/src/commands/serve.rs @@ -140,6 +140,23 @@ 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, + #[cfg(test)] + resume_launches: Option>, +} + +/// 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 +175,9 @@ 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(), + #[cfg(test)] + resume_launches: None, }; let app = dashboard_router(state); @@ -185,8 +205,24 @@ 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( + "/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 +307,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 +321,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() { @@ -312,12 +353,73 @@ async fn job_detail(AxumPath(id): AxumPath) -> Result, + 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, ) -> 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 +455,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 +476,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 +581,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 +620,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??; @@ -443,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( @@ -463,27 +766,38 @@ 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); } + 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())), } } @@ -801,9 +1115,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 +1210,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 +1227,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 +1409,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) @@ -1163,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], @@ -1278,980 +1773,23 @@ 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
- - -
- -
-
-
-
- - - - -"##; + template + .replace("{{BOOKFORGE_DASHBOARD_CSS}}", &css) + .replace("{{BOOKFORGE_DASHBOARD_JS}}", &js) +} + +static DASHBOARD_HTML: std::sync::LazyLock = std::sync::LazyLock::new(|| { + assemble_dashboard_html(DASHBOARD_HTML_TEMPLATE, DASHBOARD_CSS, DASHBOARD_JS) +}); #[cfg(test)] mod tests { @@ -2266,6 +1804,20 @@ mod tests { csrf_token: token.to_string(), host_port: 8765, keys: Arc::new(Mutex::new(HashMap::new())), + store_path: default_store_path(), + resume_launches: None, + } + } + + /// 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) } } @@ -2464,23 +2016,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] @@ -2611,6 +2167,47 @@ 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" + ); + + let crlf = |asset: &str| asset.replace("\r\n", "\n").replace('\n', "\r\n"); + assert_eq!( + assemble_dashboard_html( + &crlf(DASHBOARD_HTML_TEMPLATE), + &crlf(DASHBOARD_CSS), + &crlf(DASHBOARD_JS), + ), + *DASHBOARD_HTML, + ); + } + + #[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)); @@ -2623,4 +2220,916 @@ 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(), + qa_mode: "off".to_string(), + validate_output: false, + 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") + } + + 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(); + 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/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 => `