feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)

Clôt le cold-start de coordination du programme live-state :
- domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState
  (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate.
- application : section bornée « # État du projet » injectée au lancement
  (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ;
  LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch.
- infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping,
  tool_returns_reply), compteur d'outils MCP 12→14.
- app-tauri : providers câblés, garde du nombre d'outils 12→14.
- tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service,
  infrastructure mcp_server, app-tauri state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:09:57 +02:00
parent 9d46e6cd21
commit 533a9c57f3
12 changed files with 1503 additions and 68 deletions

View File

@ -48,6 +48,39 @@ pub enum WorkStatus {
Done,
}
impl WorkStatus {
/// Parses a status label (case-insensitive) into a [`WorkStatus`].
///
/// Accepts exactly the five canonical labels (`idle`, `working`, `blocked`,
/// `waiting`, `done`), ignoring surrounding whitespace and ASCII case. Returns
/// `None` for anything else, so the caller can raise a typed error rather than
/// guess a default.
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"idle" => Some(Self::Idle),
"working" => Some(Self::Working),
"blocked" => Some(Self::Blocked),
"waiting" => Some(Self::Waiting),
"done" => Some(Self::Done),
_ => None,
}
}
/// The canonical, human-readable label of this status (the inverse of
/// [`WorkStatus::parse`]): `idle`, `working`, `blocked`, `waiting` or `done`.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Working => "working",
Self::Blocked => "blocked",
Self::Waiting => "waiting",
Self::Done => "done",
}
}
}
/// A single agent's live-state row. One per [`AgentId`] in a [`LiveState`].
///
/// Construct via [`LiveEntry::new`], which enforces the field invariants
@ -180,6 +213,30 @@ mod tests {
TicketId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn work_status_parse_is_case_insensitive_and_rejects_unknown() {
assert_eq!(WorkStatus::parse("idle"), Some(WorkStatus::Idle));
assert_eq!(WorkStatus::parse(" Working "), Some(WorkStatus::Working));
assert_eq!(WorkStatus::parse("BLOCKED"), Some(WorkStatus::Blocked));
assert_eq!(WorkStatus::parse("waiting"), Some(WorkStatus::Waiting));
assert_eq!(WorkStatus::parse("Done"), Some(WorkStatus::Done));
assert_eq!(WorkStatus::parse("busy"), None);
assert_eq!(WorkStatus::parse(""), None);
}
#[test]
fn work_status_label_round_trips_through_parse() {
for status in [
WorkStatus::Idle,
WorkStatus::Working,
WorkStatus::Blocked,
WorkStatus::Waiting,
WorkStatus::Done,
] {
assert_eq!(WorkStatus::parse(status.label()), Some(status));
}
}
#[test]
fn new_truncates_intent_and_progress_at_soft_bound() {
let long = "x".repeat(FIELD_PREVIEW_MAX_CHARS + 50);