1use std::collections::{BTreeMap, BTreeSet};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use starweaver_core::{Metadata, RunId, SessionId};
9
10use crate::{
11 ApprovalDecision, ApprovalRecord, ApprovalStatus, DeferredToolRecord, ExecutionStatus,
12 MutationReceipt, PendingHostEventPublication, SessionStoreError, SessionStoreResult,
13};
14
15pub const APPROVAL_DECIDE_OPERATION: &str = "approval.decide";
17pub const DEFERRED_COMPLETE_OPERATION: &str = "deferred.complete";
19pub const DEFERRED_FAIL_OPERATION: &str = "deferred.fail";
21pub const CLARIFICATION_RESOLVE_OPERATION: &str = "clarification.resolve";
23pub const ASK_USER_QUESTION_ACTION: &str = "ask_user_question";
25pub const CLARIFICATION_ANSWERS_METADATA_KEY: &str = "clarification_answers";
27pub const CLARIFICATION_RESPONSE_METADATA_KEY: &str = "clarification_response";
29
30#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct InteractionMutationContext {
33 pub authority_binding: String,
35 pub expected_revision: u64,
37 pub idempotency_key: String,
39 pub command_fingerprint: String,
41 pub occurred_at: DateTime<Utc>,
43 pub host_event_publication: Option<PendingHostEventPublication>,
45}
46
47impl InteractionMutationContext {
48 pub fn validate(&self) -> SessionStoreResult<()> {
55 require_non_empty("interaction authority binding", &self.authority_binding)?;
56 require_non_empty("interaction idempotency key", &self.idempotency_key)?;
57 require_non_empty("interaction command fingerprint", &self.command_fingerprint)?;
58 if self.expected_revision == 0 {
59 return Err(SessionStoreError::Failed(
60 "interaction expected revision must be greater than zero".to_string(),
61 ));
62 }
63 if let Some(publication) = &self.host_event_publication {
64 publication.validate()?;
65 }
66 Ok(())
67 }
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct DecideApproval {
73 pub context: InteractionMutationContext,
75 pub session_id: SessionId,
77 pub run_id: RunId,
79 pub approval_id: String,
81 pub decision: ApprovalDecision,
83}
84
85impl DecideApproval {
86 pub fn validate(&self) -> SessionStoreResult<()> {
93 self.context.validate()?;
94 require_non_empty("approval id", &self.approval_id)?;
95 if !matches!(
96 self.decision.status,
97 ApprovalStatus::Approved | ApprovalStatus::Denied
98 ) {
99 return Err(SessionStoreError::Failed(
100 "approval decision must be approved or denied".to_string(),
101 ));
102 }
103 if self.decision.decided_at != self.context.occurred_at {
104 return Err(SessionStoreError::Failed(
105 "approval decision time must match mutation time".to_string(),
106 ));
107 }
108 Ok(())
109 }
110}
111
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114pub struct ApprovalMutationResult {
115 pub approval: ApprovalRecord,
117 pub receipt: MutationReceipt,
119}
120
121impl starweaver_core::VersionedRecord for ApprovalMutationResult {
122 const SCHEMA: &'static str = "starweaver.session.approval_mutation_result";
123}
124
125impl ApprovalMutationResult {
126 #[must_use]
128 pub fn replayed_projection(&self) -> Self {
129 Self {
130 approval: self.approval.clone(),
131 receipt: self.receipt.replayed_projection(),
132 }
133 }
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138#[serde(tag = "status", rename_all = "snake_case")]
139pub enum DeferredMutationOutcome {
140 Completed {
142 response: Value,
144 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
146 metadata: Metadata,
147 },
148 Failed {
150 response: Value,
152 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
154 metadata: Metadata,
155 },
156}
157
158impl DeferredMutationOutcome {
159 #[must_use]
161 pub const fn operation(&self) -> &'static str {
162 match self {
163 Self::Completed { .. } => DEFERRED_COMPLETE_OPERATION,
164 Self::Failed { .. } => DEFERRED_FAIL_OPERATION,
165 }
166 }
167
168 #[must_use]
170 pub const fn status(&self) -> ExecutionStatus {
171 match self {
172 Self::Completed { .. } => ExecutionStatus::Completed,
173 Self::Failed { .. } => ExecutionStatus::Failed,
174 }
175 }
176
177 #[must_use]
179 pub const fn parts(&self) -> (&Value, &Metadata) {
180 match self {
181 Self::Completed { response, metadata } | Self::Failed { response, metadata } => {
182 (response, metadata)
183 }
184 }
185 }
186}
187
188#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct ResolveDeferredTool {
191 pub context: InteractionMutationContext,
193 pub session_id: SessionId,
195 pub run_id: RunId,
197 pub deferred_id: String,
199 pub outcome: DeferredMutationOutcome,
201}
202
203impl ResolveDeferredTool {
204 pub fn validate(&self) -> SessionStoreResult<()> {
210 self.context.validate()?;
211 require_non_empty("deferred id", &self.deferred_id)
212 }
213}
214
215#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub struct DeferredMutationResult {
218 pub deferred: DeferredToolRecord,
220 pub receipt: MutationReceipt,
222}
223
224impl starweaver_core::VersionedRecord for DeferredMutationResult {
225 const SCHEMA: &'static str = "starweaver.session.deferred_mutation_result";
226}
227
228impl DeferredMutationResult {
229 #[must_use]
231 pub fn replayed_projection(&self) -> Self {
232 Self {
233 deferred: self.deferred.clone(),
234 receipt: self.receipt.replayed_projection(),
235 }
236 }
237}
238
239#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
241pub struct ClarificationOption {
242 pub label: String,
244 #[serde(default)]
246 pub description: String,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub preview: Option<String>,
250}
251
252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254pub struct ClarificationQuestion {
255 #[serde(default)]
257 pub header: String,
258 pub question: String,
260 #[serde(default, alias = "multiSelect")]
262 pub multi_select: bool,
263 #[serde(default)]
265 pub options: Vec<ClarificationOption>,
266}
267
268#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
270pub struct ClarificationAnswer {
271 pub question: String,
273 #[serde(default, alias = "selectedOptions")]
275 pub selected_options: Vec<String>,
276 #[serde(default, alias = "freeText", skip_serializing_if = "Option::is_none")]
278 pub free_text: Option<String>,
279}
280
281#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct ResolveClarification {
284 pub context: InteractionMutationContext,
286 pub session_id: SessionId,
288 pub run_id: RunId,
290 pub clarification_id: String,
292 pub answers: Vec<ClarificationAnswer>,
294 pub response: Option<String>,
296 pub resolved_by: Option<String>,
298}
299
300impl ResolveClarification {
301 pub fn validate(&self) -> SessionStoreResult<()> {
308 self.context.validate()?;
309 require_non_empty("clarification id", &self.clarification_id)?;
310 if self.response.as_deref().is_some_and(str::is_empty) {
311 return Err(SessionStoreError::Failed(
312 "clarification response cannot be empty".to_string(),
313 ));
314 }
315 if self.resolved_by.as_deref().is_some_and(str::is_empty) {
316 return Err(SessionStoreError::Failed(
317 "clarification resolver cannot be empty".to_string(),
318 ));
319 }
320 Ok(())
321 }
322}
323
324#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326pub struct ClarificationResolution {
327 pub clarification_id: String,
329 pub session_id: SessionId,
331 pub run_id: RunId,
333 pub questions: Vec<ClarificationQuestion>,
335 pub answers: Vec<ClarificationAnswer>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub response: Option<String>,
340 pub revision: u64,
342 pub resolved_at: DateTime<Utc>,
344}
345
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
348pub struct ClarificationMutationResult {
349 pub clarification: ClarificationResolution,
351 pub approval: ApprovalRecord,
353 pub receipt: MutationReceipt,
355}
356
357impl starweaver_core::VersionedRecord for ClarificationMutationResult {
358 const SCHEMA: &'static str = "starweaver.session.clarification_mutation_result";
359}
360
361impl ClarificationMutationResult {
362 #[must_use]
364 pub fn replayed_projection(&self) -> Self {
365 Self {
366 clarification: self.clarification.clone(),
367 approval: self.approval.clone(),
368 receipt: self.receipt.replayed_projection(),
369 }
370 }
371}
372
373#[allow(clippy::too_many_lines)]
385pub fn validate_clarification_answers(
386 request: &Value,
387 answers: &[ClarificationAnswer],
388) -> SessionStoreResult<(Vec<ClarificationQuestion>, Vec<ClarificationAnswer>)> {
389 let questions_value = request
390 .as_object()
391 .and_then(|object| object.get("questions"))
392 .ok_or_else(|| {
393 SessionStoreError::Failed(
394 "clarification request must contain a questions array".to_string(),
395 )
396 })?;
397 let questions = serde_json::from_value::<Vec<ClarificationQuestion>>(questions_value.clone())
398 .map_err(|error| {
399 SessionStoreError::Failed(format!("malformed clarification questions: {error}"))
400 })?;
401 if questions.is_empty() {
402 return Err(SessionStoreError::Failed(
403 "clarification request must contain at least one question".to_string(),
404 ));
405 }
406
407 let mut by_question = BTreeMap::new();
408 for question in &questions {
409 require_non_empty("clarification question", &question.question)?;
410 let mut labels = BTreeSet::new();
411 for option in &question.options {
412 require_non_empty("clarification option label", &option.label)?;
413 if !labels.insert(option.label.as_str()) {
414 return Err(SessionStoreError::Failed(format!(
415 "duplicate clarification option {} for question {}",
416 option.label, question.question
417 )));
418 }
419 }
420 if by_question
421 .insert(question.question.as_str(), question)
422 .is_some()
423 {
424 return Err(SessionStoreError::Failed(format!(
425 "duplicate clarification question {}",
426 question.question
427 )));
428 }
429 }
430
431 if answers.len() != questions.len() {
432 return Err(SessionStoreError::Failed(
433 "clarification answers must match every durable question exactly once".to_string(),
434 ));
435 }
436 let mut answer_by_question = BTreeMap::new();
437 for answer in answers {
438 let question = by_question.get(answer.question.as_str()).ok_or_else(|| {
439 SessionStoreError::Failed(format!(
440 "clarification answer does not match durable question {}",
441 answer.question
442 ))
443 })?;
444 if answer_by_question
445 .insert(answer.question.as_str(), answer)
446 .is_some()
447 {
448 return Err(SessionStoreError::Failed(format!(
449 "duplicate clarification answer for {}",
450 answer.question
451 )));
452 }
453 if answer.free_text.as_deref().is_some_and(str::is_empty) {
454 return Err(SessionStoreError::Failed(format!(
455 "clarification free text cannot be empty for {}",
456 answer.question
457 )));
458 }
459 if answer.selected_options.is_empty() && answer.free_text.is_none() {
460 return Err(SessionStoreError::Failed(format!(
461 "clarification answer is empty for {}",
462 answer.question
463 )));
464 }
465 if !question.multi_select && answer.selected_options.len() > 1 {
466 return Err(SessionStoreError::Failed(format!(
467 "clarification question {} does not allow multiple selections",
468 answer.question
469 )));
470 }
471 let allowed = question
472 .options
473 .iter()
474 .map(|option| option.label.as_str())
475 .collect::<BTreeSet<_>>();
476 let mut selected = BTreeSet::new();
477 for label in &answer.selected_options {
478 if !selected.insert(label.as_str()) {
479 return Err(SessionStoreError::Failed(format!(
480 "duplicate clarification selection {label} for {}",
481 answer.question
482 )));
483 }
484 if !allowed.contains(label.as_str()) {
485 return Err(SessionStoreError::Failed(format!(
486 "clarification selection {label} is not an option for {}",
487 answer.question
488 )));
489 }
490 }
491 }
492
493 let ordered_answers = questions
494 .iter()
495 .map(|question| {
496 answer_by_question
497 .get(question.question.as_str())
498 .map(|answer| (*answer).clone())
499 .ok_or_else(|| {
500 SessionStoreError::Failed(format!(
501 "missing clarification answer for {}",
502 question.question
503 ))
504 })
505 })
506 .collect::<SessionStoreResult<Vec<_>>>()?;
507 Ok((questions, ordered_answers))
508}
509
510fn require_non_empty(label: &str, value: &str) -> SessionStoreResult<()> {
511 if value.is_empty() {
512 return Err(SessionStoreError::Failed(format!(
513 "{label} cannot be empty"
514 )));
515 }
516 Ok(())
517}