feat(background): runner PTY B8 + boucle sink fermée + refactor point-2

Livre le lot backend B8 des tâches de fond :

- infrastructure : runner de commandes concret (CommandBackgroundRunner)
  sur le port BackgroundTaskRunner, tail borné (bounded_tail/BoundedTail),
  éclatement du module background_task en sous-modules (mod/runner/tail,
  sink extrait de l'ancien background_task.rs).
- application : nouveau module background exposant les cas d'usage
  SpawnBackgroundCommand, CancelBackgroundTask, RetryBackgroundTask et le
  port BackgroundCommandArchive.
- domain : refactor point-2 de l'arbitrage Architect — sortie du trait
  BackgroundCommandArchive de la couche domaine vers application.
- app-tauri : câblage runtime (commands, dto, state, lib) des commandes
  spawn/cancel/retry et de la boucle de complétion sink fermée en
  composition root.

Build workspace + tests application/infrastructure verts (QA).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:48:46 +02:00
parent 5d88c952ec
commit 8cac1470ac
12 changed files with 1180 additions and 11 deletions

View File

@ -0,0 +1,149 @@
//! UTF-8-safe bounded output tail (B8 sub-task 1).
//!
//! A background command can print megabytes; the completion contract only keeps a
//! bounded *tail* (the most recent bytes). [`BoundedTail`] is a byte ring buffer
//! capped at construction; [`bounded_tail`] renders the retained bytes as a
//! `String`, trimming any partial UTF-8 sequence at the front so a multi-byte
//! character never gets sliced by the cap.
use std::collections::VecDeque;
use domain::background_task::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES;
/// Default tail cap when `IDEA_BG_TAIL_BYTES` is unset (8 KiB).
const DEFAULT_TAIL_BYTES: usize = 8 * 1024;
/// Floor so a hostile/typo'd env value can't disable the tail entirely.
const MIN_TAIL_BYTES: usize = 1024;
/// Resolves the tail cap in bytes from `IDEA_BG_TAIL_BYTES`.
///
/// Defaults to 8 KiB, floored at 1 KiB, and clamped to the domain maximum
/// ([`BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES`], 16 KiB) so the persisted result
/// always validates.
#[must_use]
pub fn tail_cap_bytes() -> usize {
std::env::var("IDEA_BG_TAIL_BYTES")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_TAIL_BYTES)
.clamp(MIN_TAIL_BYTES, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES)
}
/// A byte ring buffer that retains only the last `cap` bytes pushed into it.
#[derive(Debug)]
pub struct BoundedTail {
buf: VecDeque<u8>,
cap: usize,
}
impl BoundedTail {
/// Builds a tail retaining at most `cap` bytes (a `cap` of `0` retains
/// nothing but never panics).
#[must_use]
pub fn with_cap(cap: usize) -> Self {
Self {
buf: VecDeque::new(),
cap,
}
}
/// Appends a chunk, dropping the oldest bytes past the cap.
pub fn push(&mut self, chunk: &[u8]) {
if self.cap == 0 {
return;
}
// If the incoming chunk alone exceeds the cap, only its tail can survive.
let start = chunk.len().saturating_sub(self.cap);
self.buf.extend(&chunk[start..]);
let overflow = self.buf.len().saturating_sub(self.cap);
if overflow > 0 {
self.buf.drain(0..overflow);
}
}
/// Returns whether any byte is retained.
#[must_use]
pub fn is_empty(&self) -> bool {
self.buf.is_empty()
}
/// Renders the retained bytes as a UTF-8 string, dropping a leading partial
/// multi-byte sequence left dangling by the cap. Returns `None` when empty.
#[must_use]
pub fn to_tail_string(&self) -> Option<String> {
if self.buf.is_empty() {
return None;
}
let bytes: Vec<u8> = self.buf.iter().copied().collect();
Some(bounded_tail(&bytes, self.cap))
}
}
/// Renders the last `cap` bytes of `bytes` as a lossless-at-the-boundary
/// `String`.
///
/// The slice is taken from the end, then any partial UTF-8 sequence at the *front*
/// (a continuation byte with no lead byte, because the cap cut mid-character) is
/// skipped so the result is valid UTF-8 without replacement characters at the
/// seam. Invalid bytes in the interior are still replaced lossily.
#[must_use]
pub fn bounded_tail(bytes: &[u8], cap: usize) -> String {
let start = bytes.len().saturating_sub(cap);
let mut slice = &bytes[start..];
// Only realign when we actually truncated the front (start > 0): otherwise a
// legitimately leading continuation byte is just invalid input, handled below.
if start > 0 {
let mut skip = 0;
while skip < slice.len() && is_utf8_continuation(slice[skip]) {
skip += 1;
}
slice = &slice[skip..];
}
String::from_utf8_lossy(slice).into_owned()
}
/// Whether `b` is a UTF-8 continuation byte (`10xxxxxx`).
const fn is_utf8_continuation(b: u8) -> bool {
b & 0b1100_0000 == 0b1000_0000
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_only_the_last_cap_bytes() {
let mut tail = BoundedTail::with_cap(4);
tail.push(b"abcdef");
assert_eq!(tail.to_tail_string().as_deref(), Some("cdef"));
}
#[test]
fn push_bigger_than_cap_keeps_tail() {
let mut tail = BoundedTail::with_cap(3);
tail.push(b"0123456789");
assert_eq!(tail.to_tail_string().as_deref(), Some("789"));
}
#[test]
fn empty_tail_is_none() {
let tail = BoundedTail::with_cap(8);
assert!(tail.is_empty());
assert_eq!(tail.to_tail_string(), None);
}
#[test]
fn cut_multibyte_char_is_trimmed_at_front() {
// "é" is 0xC3 0xA9. Cap of 1 would keep only the trailing 0xA9 (a lone
// continuation byte); it must be dropped, yielding an empty string.
let bytes = "é".as_bytes();
assert_eq!(bounded_tail(bytes, 1), "");
// Cap of 2 keeps the whole character.
assert_eq!(bounded_tail(bytes, 2), "é");
}
#[test]
fn interior_is_preserved() {
assert_eq!(bounded_tail("héllo".as_bytes(), 100), "héllo");
}
}