feat(domain): modèle de tâches de fond de 1re classe et mailbox (B1)
Entités BackgroundTask et Inbox, identifiants, événements et ports du domaine pour la récupération de complétion post-tour et la réception de messages concurrents. Fondations des lots infra/application suivants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
302
crates/domain/tests/background_task.rs
Normal file
302
crates/domain/tests/background_task.rs
Normal file
@ -0,0 +1,302 @@
|
||||
//! Pure invariants for first-class background tasks.
|
||||
|
||||
use domain::{
|
||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
||||
BackgroundTaskState, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn task_id(n: u128) -> domain::TaskId {
|
||||
domain::TaskId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project_id(n: u128) -> domain::ProjectId {
|
||||
domain::ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn agent_id(n: u128) -> domain::AgentId {
|
||||
domain::AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn command_task() -> BackgroundTask {
|
||||
BackgroundTask::new(
|
||||
task_id(1),
|
||||
project_id(2),
|
||||
agent_id(3),
|
||||
BackgroundTaskKind::Command {
|
||||
label: "build".into(),
|
||||
},
|
||||
BackgroundTaskWakePolicy::WakeOwner,
|
||||
100,
|
||||
Some(200),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn success_result(at: u64) -> BackgroundTaskResult {
|
||||
BackgroundTaskResult::Success {
|
||||
finished_at_ms: at,
|
||||
exit_code: Some(0),
|
||||
summary: "ok".into(),
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_result(at: u64) -> BackgroundTaskResult {
|
||||
BackgroundTaskResult::Failure {
|
||||
finished_at_ms: at,
|
||||
exit_code: Some(1),
|
||||
error: "failed".into(),
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_task_is_queued_without_result_or_delivery() {
|
||||
let task = command_task();
|
||||
|
||||
assert_eq!(task.state, BackgroundTaskState::Queued);
|
||||
assert_eq!(task.result, None);
|
||||
assert!(!task.completion_delivered);
|
||||
assert!(!task.is_terminal());
|
||||
assert!(!task.has_pending_completion_delivery());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_must_not_precede_creation() {
|
||||
let err = BackgroundTask::new(
|
||||
task_id(1),
|
||||
project_id(2),
|
||||
agent_id(3),
|
||||
BackgroundTaskKind::Command {
|
||||
label: "build".into(),
|
||||
},
|
||||
BackgroundTaskWakePolicy::WakeOwner,
|
||||
100,
|
||||
Some(99),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, BackgroundTaskError::InvalidTimestamps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_must_be_non_empty_and_bounded() {
|
||||
let empty = BackgroundTaskKind::Command { label: " ".into() };
|
||||
assert_eq!(empty.validate(), Err(BackgroundTaskError::EmptyLabel));
|
||||
|
||||
let oversized = BackgroundTaskKind::Maintenance {
|
||||
label: "x".repeat(domain::BACKGROUND_TASK_LABEL_MAX_CHARS + 1),
|
||||
};
|
||||
assert_eq!(
|
||||
oversized.validate(),
|
||||
Err(BackgroundTaskError::FieldTooLarge {
|
||||
field: "label",
|
||||
max: domain::BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_non_terminal_transitions_update_timestamp() {
|
||||
let running = command_task()
|
||||
.transition(BackgroundTaskState::Running, 120)
|
||||
.unwrap();
|
||||
assert_eq!(running.state, BackgroundTaskState::Running);
|
||||
assert_eq!(running.updated_at_ms, 120);
|
||||
|
||||
let waiting = running
|
||||
.transition(BackgroundTaskState::Waiting, 130)
|
||||
.unwrap();
|
||||
assert_eq!(waiting.state, BackgroundTaskState::Waiting);
|
||||
|
||||
let running_again = waiting
|
||||
.transition(BackgroundTaskState::Running, 140)
|
||||
.unwrap();
|
||||
assert_eq!(running_again.state, BackgroundTaskState::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_transitions_are_rejected() {
|
||||
let task = command_task();
|
||||
let err = task
|
||||
.transition(BackgroundTaskState::Waiting, 120)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
BackgroundTaskError::InvalidTransition {
|
||||
from: BackgroundTaskState::Queued,
|
||||
to: BackgroundTaskState::Waiting,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_timestamps_must_not_regress() {
|
||||
let running = command_task()
|
||||
.transition(BackgroundTaskState::Running, 120)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
running.transition(BackgroundTaskState::Waiting, 119),
|
||||
Err(BackgroundTaskError::InvalidTimestamps)
|
||||
);
|
||||
assert_eq!(
|
||||
running.complete(success_result(119)),
|
||||
Err(BackgroundTaskError::InvalidTimestamps)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_transition_requires_complete_with_matching_result() {
|
||||
let running = command_task()
|
||||
.transition(BackgroundTaskState::Running, 120)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
running.transition(BackgroundTaskState::Completed, 130),
|
||||
Err(BackgroundTaskError::MissingResult)
|
||||
);
|
||||
|
||||
let completed = running.complete(success_result(130)).unwrap();
|
||||
assert_eq!(completed.state, BackgroundTaskState::Completed);
|
||||
assert_eq!(completed.updated_at_ms, 130);
|
||||
assert!(completed.is_terminal());
|
||||
assert!(completed.has_pending_completion_delivery());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states_are_immutable() {
|
||||
let completed = command_task()
|
||||
.transition(BackgroundTaskState::Running, 120)
|
||||
.unwrap()
|
||||
.complete(success_result(130))
|
||||
.unwrap();
|
||||
|
||||
let err = completed
|
||||
.transition(BackgroundTaskState::Running, 140)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
BackgroundTaskError::InvalidTransition {
|
||||
from: BackgroundTaskState::Completed,
|
||||
to: BackgroundTaskState::Running,
|
||||
}
|
||||
);
|
||||
|
||||
let err = completed.complete(failure_result(140)).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
BackgroundTaskError::InvalidTransition {
|
||||
from: BackgroundTaskState::Completed,
|
||||
to: BackgroundTaskState::Failed,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_variants_determine_terminal_state() {
|
||||
let running = command_task()
|
||||
.transition(BackgroundTaskState::Running, 120)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
running.complete(failure_result(130)).unwrap().state,
|
||||
BackgroundTaskState::Failed
|
||||
);
|
||||
assert_eq!(
|
||||
running
|
||||
.complete(BackgroundTaskResult::Cancelled {
|
||||
finished_at_ms: 130,
|
||||
reason: "user".into(),
|
||||
})
|
||||
.unwrap()
|
||||
.state,
|
||||
BackgroundTaskState::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
running
|
||||
.complete(BackgroundTaskResult::Expired {
|
||||
finished_at_ms: 130,
|
||||
reason: "deadline".into(),
|
||||
})
|
||||
.unwrap()
|
||||
.state,
|
||||
BackgroundTaskState::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_delivery_only_once_after_terminal_state() {
|
||||
let running = command_task()
|
||||
.transition(BackgroundTaskState::Running, 120)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
running.mark_completion_delivered(),
|
||||
Err(BackgroundTaskError::MissingResult)
|
||||
);
|
||||
|
||||
let delivered = running
|
||||
.complete(success_result(130))
|
||||
.unwrap()
|
||||
.mark_completion_delivered()
|
||||
.unwrap();
|
||||
|
||||
assert!(delivered.completion_delivered);
|
||||
assert!(!delivered.has_pending_completion_delivery());
|
||||
assert_eq!(
|
||||
delivered.mark_completion_delivered(),
|
||||
Err(BackgroundTaskError::CompletionAlreadyDelivered)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_text_fields_are_bounded() {
|
||||
let summary_too_large = BackgroundTaskResult::Success {
|
||||
finished_at_ms: 1,
|
||||
exit_code: None,
|
||||
summary: "x".repeat(domain::BACKGROUND_TASK_TEXT_MAX_BYTES + 1),
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
};
|
||||
assert_eq!(
|
||||
summary_too_large.validate(),
|
||||
Err(BackgroundTaskError::FieldTooLarge {
|
||||
field: "summary",
|
||||
max: domain::BACKGROUND_TASK_TEXT_MAX_BYTES,
|
||||
})
|
||||
);
|
||||
|
||||
let tail_too_large = BackgroundTaskResult::Failure {
|
||||
finished_at_ms: 1,
|
||||
exit_code: None,
|
||||
error: "failed".into(),
|
||||
stdout_tail: Some("x".repeat(domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES + 1)),
|
||||
stderr_tail: None,
|
||||
};
|
||||
assert_eq!(
|
||||
tail_too_large.validate(),
|
||||
Err(BackgroundTaskError::FieldTooLarge {
|
||||
field: "stdout_tail",
|
||||
max: domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_task_serializes_with_camel_case_fields() {
|
||||
let task = command_task();
|
||||
let json = serde_json::to_string(&task).unwrap();
|
||||
|
||||
assert!(json.contains("\"ownerAgentId\""), "json was {json}");
|
||||
assert!(
|
||||
json.contains("\"wakePolicy\":\"wakeOwner\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(json.contains("\"completionDelivered\""), "json was {json}");
|
||||
assert_eq!(serde_json::from_str::<BackgroundTask>(&json).unwrap(), task);
|
||||
}
|
||||
Reference in New Issue
Block a user