awaken_runtime_contract/contract/
commit_coordinator.rs1use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use std::sync::Arc;
16
17use super::event_store::{AppendOptions, CanonicalEventDraft, EventStoreError};
18use super::message::Message;
19use super::outbox::OutboxError;
20use super::storage::{RunRecord, RuntimeCheckpointStore, StorageError};
21use crate::state::PersistedState;
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct TransactionScopeId(String);
35
36impl TransactionScopeId {
37 pub fn new(value: impl Into<String>) -> Result<Self, CommitError> {
39 let value = value.into();
40 if value.trim().is_empty() {
41 return Err(CommitError::Validation(
42 "transaction scope id must be non-empty".to_string(),
43 ));
44 }
45 Ok(Self(value))
46 }
47
48 #[must_use]
50 pub fn as_str(&self) -> &str {
51 &self.0
52 }
53}
54
55pub trait CanonicalEventStager: Send + Sync {
65 fn stage(&self, draft: CanonicalEventDraft);
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct StagedCanonicalEvent {
72 pub draft: CanonicalEventDraft,
73 pub append_options: AppendOptions,
74}
75
76impl StagedCanonicalEvent {
77 #[must_use]
79 pub fn new(draft: CanonicalEventDraft) -> Self {
80 Self {
81 draft,
82 append_options: AppendOptions::default(),
83 }
84 }
85
86 #[must_use]
88 pub fn with_options(mut self, options: AppendOptions) -> Self {
89 self.append_options = options;
90 self
91 }
92}
93
94#[derive(Debug, Clone)]
109pub struct ThreadCommit {
110 pub thread_id: String,
111 pub message_delta: Vec<Message>,
113 pub expected_message_count: Option<u64>,
115 pub run_projection: RunRecord,
116 pub thread_state_snapshot: Option<PersistedState>,
121}
122
123#[deprecated(since = "0.6.0", note = "Use `ThreadCommit`.")]
125pub type Checkpoint = ThreadCommit;
126
127impl ThreadCommit {
128 pub fn append_messages(
132 thread_id: impl Into<String>,
133 message_delta: Vec<Message>,
134 expected_message_count: Option<u64>,
135 run_projection: RunRecord,
136 ) -> Self {
137 Self {
138 thread_id: thread_id.into(),
139 message_delta,
140 expected_message_count,
141 run_projection,
142 thread_state_snapshot: None,
143 }
144 }
145
146 #[deprecated(since = "0.6.0", note = "Use `append_messages`.")]
148 pub fn append(
149 thread_id: impl Into<String>,
150 message_delta: Vec<Message>,
151 expected_message_count: Option<u64>,
152 run_projection: RunRecord,
153 ) -> Self {
154 Self::append_messages(
155 thread_id,
156 message_delta,
157 expected_message_count,
158 run_projection,
159 )
160 }
161
162 #[must_use]
164 pub fn with_thread_state_snapshot(mut self, thread_state: PersistedState) -> Self {
165 self.thread_state_snapshot = Some(thread_state);
166 self
167 }
168
169 #[deprecated(since = "0.6.0", note = "Use `with_thread_state_snapshot`.")]
171 #[must_use]
172 pub fn with_thread_state(self, thread_state: PersistedState) -> Self {
173 self.with_thread_state_snapshot(thread_state)
174 }
175
176 pub fn run_projection_only(thread_id: impl Into<String>, run_projection: RunRecord) -> Self {
183 Self::append_messages(thread_id, Vec::new(), None, run_projection)
184 }
185
186 #[deprecated(since = "0.6.0", note = "Use `run_projection_only`.")]
188 pub fn checkpoint_only(thread_id: impl Into<String>, run_projection: RunRecord) -> Self {
189 Self::run_projection_only(thread_id, run_projection)
190 }
191
192 pub fn validate(&self) -> Result<(), CommitError> {
194 if self.thread_id.trim().is_empty() {
195 return Err(CommitError::Validation(
196 "thread_id must be non-empty".to_string(),
197 ));
198 }
199 if self.run_projection.thread_id != self.thread_id {
200 return Err(CommitError::Validation(format!(
201 "run_projection.thread_id '{}' must match thread commit thread_id '{}'",
202 self.run_projection.thread_id, self.thread_id
203 )));
204 }
205 if self.run_projection.run_id.trim().is_empty() {
206 return Err(CommitError::Validation(
207 "run_projection.run_id must be non-empty".to_string(),
208 ));
209 }
210 if self.run_projection.agent_id.trim().is_empty() {
211 return Err(CommitError::Validation(
212 "run_projection.agent_id must be non-empty".to_string(),
213 ));
214 }
215 if !self.message_delta.is_empty() && self.expected_message_count.is_none() {
222 return Err(CommitError::Validation(
223 "append with a non-empty message delta requires an expected_message_count guard"
224 .to_string(),
225 ));
226 }
227 Ok(())
228 }
229}
230
231#[derive(Debug, Clone, Default, PartialEq)]
238pub struct ThreadCommitOutcome;
239
240#[deprecated(since = "0.6.0", note = "Use `ThreadCommitOutcome`.")]
242pub type CheckpointCommitOutcome = ThreadCommitOutcome;
243
244#[derive(Debug, Error)]
251pub enum CommitError {
252 #[error("validation error: {0}")]
254 Validation(String),
255 #[error("thread run store write failed: {0}")]
257 StoreWrite(#[from] StorageError),
258 #[error(
263 "message version conflict on thread '{thread_id}': expected {expected}, actual {actual}"
264 )]
265 MessageVersionConflict {
266 thread_id: String,
267 expected: u64,
268 actual: u64,
269 },
270 #[error("canonical event append failed: {0}")]
272 EventAppend(#[from] EventStoreError),
273 #[error("outbox insert failed: {0}")]
275 OutboxInsert(#[from] OutboxError),
276 #[error("commit failed: {0}")]
278 Commit(String),
279 #[error("transaction scope mismatch: {0}")]
281 ScopeMismatch(String),
282}
283
284impl CommitError {
285 #[must_use]
291 pub fn reclassify_append_conflict(self, thread_id: &str) -> Self {
292 match self {
293 CommitError::StoreWrite(StorageError::VersionConflict { expected, actual }) => {
294 CommitError::MessageVersionConflict {
295 thread_id: thread_id.to_string(),
296 expected,
297 actual,
298 }
299 }
300 other => other,
301 }
302 }
303}
304
305#[async_trait]
326pub trait CommitCoordinator: Send + Sync {
327 fn scope(&self) -> TransactionScopeId;
331
332 fn thread_run_storage_identity(&self) -> Option<String> {
336 None
337 }
338
339 fn reader(&self) -> Arc<dyn RuntimeCheckpointStore>;
345
346 async fn commit_checkpoint(
349 &self,
350 plan: ThreadCommit,
351 ) -> Result<ThreadCommitOutcome, CommitError>;
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::contract::event_store::{
358 CanonicalEventDraft, CanonicalEventKind, EventScope, EventVisibility,
359 };
360 use serde_json::json;
361
362 fn sample_draft(kind: &str) -> CanonicalEventDraft {
363 let mut draft = CanonicalEventDraft::new(
364 vec![EventScope::thread("t-1"), EventScope::run("run-1")],
365 CanonicalEventKind::new(kind).unwrap(),
366 json!({"kind": kind}),
367 "test",
368 )
369 .unwrap();
370 draft.visibility = EventVisibility::Public;
371 draft
372 }
373
374 fn sample_run_record() -> crate::contract::storage::RunRecord {
375 crate::contract::storage::RunRecord {
376 run_id: "run-1".to_string(),
377 thread_id: "t-1".to_string(),
378 agent_id: "agent-1".to_string(),
379 resolution_id: None,
380 activation: None,
381 ..Default::default()
382 }
383 }
384
385 #[test]
386 fn transaction_scope_id_rejects_blank() {
387 assert!(TransactionScopeId::new("").is_err());
388 assert!(TransactionScopeId::new(" ").is_err());
389 assert!(TransactionScopeId::new("pg::main").is_ok());
390 }
391
392 #[test]
393 fn staged_canonical_event_with_options_round_trip() {
394 let draft = sample_draft("RunStarted");
395 let opts = AppendOptions {
396 writer_id: Some("runtime".to_string()),
397 idempotency_key: Some("k-1".to_string()),
398 ..Default::default()
399 };
400 let staged = StagedCanonicalEvent::new(draft.clone()).with_options(opts.clone());
401 assert_eq!(staged.draft, draft);
402 assert_eq!(staged.append_options, opts);
403 }
404
405 #[test]
406 fn plan_checkpoint_only_validates() {
407 let plan = ThreadCommit::run_projection_only("t-1", sample_run_record());
408 plan.validate().unwrap();
409 }
410
411 #[test]
412 fn plan_rejects_blank_thread_id() {
413 let mut run = sample_run_record();
414 run.thread_id = String::new();
415 let plan = ThreadCommit::run_projection_only("", run);
416 let err = plan.validate().unwrap_err();
417 assert!(matches!(err, CommitError::Validation(_)));
418 }
419
420 #[test]
421 fn plan_rejects_thread_run_mismatch() {
422 let mut run = sample_run_record();
423 run.thread_id = "other-thread".to_string();
424 let plan = ThreadCommit::run_projection_only("t-1", run);
425 let err = plan.validate().unwrap_err();
426 assert!(matches!(
427 err,
428 CommitError::Validation(message) if message.contains("run_projection.thread_id")
429 ));
430 }
431
432 #[test]
433 fn plan_rejects_blank_run_id() {
434 let mut run = sample_run_record();
435 run.run_id = " ".to_string();
436 let plan = ThreadCommit::run_projection_only("t-1", run);
437 let err = plan.validate().unwrap_err();
438 assert!(matches!(
439 err,
440 CommitError::Validation(message) if message.contains("run_projection.run_id")
441 ));
442 }
443
444 #[test]
445 fn plan_rejects_blank_agent_id() {
446 let mut run = sample_run_record();
447 run.agent_id.clear();
448 let plan = ThreadCommit::run_projection_only("t-1", run);
449 let err = plan.validate().unwrap_err();
450 assert!(matches!(
451 err,
452 CommitError::Validation(message) if message.contains("run_projection.agent_id")
453 ));
454 }
455
456 #[test]
459 fn checkpoint_only_allows_empty_message_state_write() {
460 let plan = ThreadCommit::run_projection_only("t-1", sample_run_record());
462 assert_eq!(plan.expected_message_count, None);
463 assert!(plan.message_delta.is_empty());
464 plan.validate().unwrap();
465 }
466
467 #[test]
468 fn unguarded_append_of_non_empty_messages_is_rejected() {
469 let plan = ThreadCommit::append_messages(
473 "t-1",
474 vec![Message::user("a")],
475 None,
476 sample_run_record(),
477 );
478 let err = plan.validate().unwrap_err();
479 assert!(
480 matches!(&err, CommitError::Validation(message) if message.contains("expected_message_count")),
481 "expected message-count guard validation error, got {err:?}"
482 );
483 }
484
485 #[test]
486 fn append_plan_carries_delta_and_expected_version() {
487 let plan = ThreadCommit::append_messages(
488 "t-1",
489 vec![Message::user("hi")],
490 Some(3),
491 sample_run_record(),
492 );
493 assert_eq!(plan.expected_message_count, Some(3));
494 assert_eq!(plan.message_delta.len(), 1);
495 plan.validate().unwrap();
496 }
497
498 #[test]
499 fn state_only_checkpoint_accepts_none_version() {
500 let plan = ThreadCommit::append_messages("t-1", Vec::new(), None, sample_run_record());
503 assert_eq!(plan.expected_message_count, None);
504 plan.validate().unwrap();
505 }
506
507 #[test]
508 fn append_plan_still_validates_run_thread_match() {
509 let mut run = sample_run_record();
510 run.thread_id = "other-thread".to_string();
511 let plan = ThreadCommit::append_messages("t-1", Vec::new(), Some(0), run);
512 let err = plan.validate().unwrap_err();
513 assert!(matches!(
514 err,
515 CommitError::Validation(message) if message.contains("run_projection.thread_id")
516 ));
517 }
518
519 #[test]
520 fn message_version_conflict_displays_thread_expected_actual() {
521 let err = CommitError::MessageVersionConflict {
522 thread_id: "t-1".to_string(),
523 expected: 2,
524 actual: 5,
525 };
526 let msg = err.to_string();
527 assert!(msg.contains("t-1"), "missing thread_id: {msg}");
528 assert!(msg.contains('2'), "missing expected: {msg}");
529 assert!(msg.contains('5'), "missing actual: {msg}");
530 }
531}