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

@ -2866,3 +2866,167 @@ mod ls6_pagination_tests {
assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none());
}
}
// ---------------------------------------------------------------------------
// Background tasks (B8 — first-class background command)
// ---------------------------------------------------------------------------
use domain::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId,
};
/// One first-class background task as seen by the frontend (camelCase wire shape).
///
/// Mirrors the persisted [`BackgroundTask`], flattening the terminal
/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the
/// UI has a single shape for every lifecycle state. Optional fields are omitted
/// from the wire when absent (`skip_serializing_if`).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTaskDto {
/// Stable task id (UUID string).
pub task_id: String,
/// Owning agent id (UUID string).
pub owner_agent_id: String,
/// Owning project id (UUID string).
pub project_id: String,
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
/// `"maintenance"`).
pub kind: String,
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
/// `"failed"`, `"cancelled"`, `"expired"`).
pub state: String,
/// Process exit code, when the terminal result carries one.
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Human-readable summary / error / reason of the terminal result.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// Bounded stdout tail (merged output for PTY-backed commands).
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout_tail: Option<String>,
/// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
pub updated_at_ms: u64,
}
/// Kind discriminant string for a [`BackgroundTaskKind`].
fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str {
match kind {
BackgroundTaskKind::Command { .. } => "command",
BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous",
BackgroundTaskKind::SessionResume { .. } => "sessionResume",
BackgroundTaskKind::Maintenance { .. } => "maintenance",
}
}
/// Lifecycle state string for a [`BackgroundTaskState`].
fn background_state_label(state: BackgroundTaskState) -> &'static str {
match state {
BackgroundTaskState::Queued => "queued",
BackgroundTaskState::Running => "running",
BackgroundTaskState::Waiting => "waiting",
BackgroundTaskState::Completed => "completed",
BackgroundTaskState::Failed => "failed",
BackgroundTaskState::Cancelled => "cancelled",
BackgroundTaskState::Expired => "expired",
}
}
impl From<BackgroundTask> for BackgroundTaskDto {
fn from(task: BackgroundTask) -> Self {
let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
Some(BackgroundTaskResult::Success {
exit_code,
summary,
stdout_tail,
stderr_tail,
..
}) => (
*exit_code,
Some(summary.clone()),
stdout_tail.clone(),
stderr_tail.clone(),
),
Some(BackgroundTaskResult::Failure {
exit_code,
error,
stdout_tail,
stderr_tail,
..
}) => (
*exit_code,
Some(error.clone()),
stdout_tail.clone(),
stderr_tail.clone(),
),
Some(BackgroundTaskResult::Cancelled { reason, .. })
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
(None, Some(reason.clone()), None, None)
}
None => (None, None, None, None),
};
Self {
task_id: task.id.to_string(),
owner_agent_id: task.owner_agent_id.to_string(),
project_id: task.project_id.to_string(),
kind: background_kind_label(&task.kind).to_owned(),
state: background_state_label(task.state).to_owned(),
exit_code,
summary,
stdout_tail,
stderr_tail,
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
}
}
/// Parses a task-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_task_id(raw: &str) -> Result<TaskId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(TaskId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid task id: {raw}"),
})
}
/// Request DTO for `spawn_background_command`.
///
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
/// project host's PTY and its completion is delivered to `ownerAgentId`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpawnBackgroundCommandRequestDto {
/// Owning project id (UUID string).
pub project_id: String,
/// Agent that owns completion delivery (UUID string).
pub owner_agent_id: String,
/// Human-facing label.
pub label: String,
/// Executable to run.
pub command: String,
/// Arguments.
#[serde(default)]
pub args: Vec<String>,
/// Working directory (absolute path).
pub cwd: String,
/// Extra environment variables.
#[serde(default)]
pub env: Vec<(String, String)>,
/// When `true` (the default), wake the owner on completion; otherwise only
/// record it.
#[serde(default)]
pub record_only: bool,
/// Optional absolute deadline, epoch milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
}