feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)

Câble un canal attachable au flux de sortie d'une tâche de fond (runner
infrastructure + commande app-tauri + port/adaptateurs frontend) et le
panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un
état figé au dernier snapshot.

Validations obtenues avant commit :
- cargo test -p infrastructure --test background_task_runner : vert
- cargo check -p backend -p app-tauri : vert
- npx vitest run src/features/workstate/workstate.test.tsx : vert
- npm run typecheck : vert
- verdict QA #58 : vert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:48:29 +02:00
parent 6f532d7434
commit b734c237d3
17 changed files with 528 additions and 23 deletions

View File

@ -53,15 +53,6 @@ impl FakePty {
self.state.lock().unwrap().scrollback = bytes.to_vec();
}
fn last_handle(&self) -> PtyHandle {
self.state
.lock()
.unwrap()
.handle
.clone()
.expect("spawned handle")
}
fn kill_count(&self) -> usize {
self.state.lock().unwrap().kills
}
@ -205,7 +196,9 @@ async fn ui_subscriber_does_not_interfere_with_runner_completion() {
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
let handle = pty.last_handle();
let handle = runner
.pty_handle_for(task_id)
.expect("runner exposes live PTY handle");
let _ui_stream = pty.subscribe_output(&handle).expect("ui subscribes");
pty.complete(Some(0));
@ -229,6 +222,68 @@ async fn ui_subscriber_does_not_interfere_with_runner_completion() {
);
}
#[tokio::test]
async fn double_ui_subscriber_uses_independent_pty_streams() {
let pty = Arc::new(FakePty::default());
let clock = Arc::new(FakeClock::default());
let (runner, _completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(14));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
let handle = runner
.pty_handle_for(task_id)
.expect("runner exposes live PTY handle");
let _first = pty.subscribe_output(&handle).expect("first ui subscribes");
let _second = pty.subscribe_output(&handle).expect("second ui subscribes");
assert_eq!(
pty.subscribe_count(),
2,
"each UI attach must get its own PTY subscription"
);
assert_eq!(
runner.pty_handle_for(task_id),
Some(handle),
"UI subscribers must not remove the runner's live handle"
);
}
#[tokio::test]
async fn completed_task_no_longer_exposes_live_pty_handle_for_attach() {
let pty = Arc::new(FakePty::default());
pty.set_scrollback(b"final tail");
let clock = Arc::new(FakeClock::default());
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(15));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
assert!(runner.pty_handle_for(task_id).is_some());
pty.complete(Some(0));
let completion = tokio::time::timeout(
Duration::from_secs(1),
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
)
.await
.expect("completion arrives")
.expect("completion thread joins");
assert_eq!(completion.task_id, task_id);
assert_eq!(runner.pty_handle_for(task_id), None);
assert_eq!(
pty.subscribe_count(),
0,
"attach after completion must not subscribe to a dead PTY handle"
);
}
#[tokio::test]
async fn deadline_expires_and_kills_when_wait_never_resolves() {
let pty = Arc::new(FakePty::default());