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>
303 lines
8.1 KiB
Rust
303 lines
8.1 KiB
Rust
//! 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);
|
|
}
|