1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 DurableHostEventClass, DurableHostEventScope, MutationReceipt, PendingHostEventPublication,
12 SessionStoreError, SessionStoreResult,
13};
14
15pub const MAX_ENVIRONMENT_PAGE_SIZE: u32 = 200;
17pub const MAX_ENVIRONMENT_MOUNTS_PER_RUN: u32 = 128;
19pub const ENVIRONMENT_ATTACH_OPERATION: &str = "environment.attach";
21pub const ENVIRONMENT_DETACH_OPERATION: &str = "environment.detach";
23pub const ENVIRONMENT_MOUNT_OPERATION: &str = "environment.mount";
25pub const ENVIRONMENT_UNMOUNT_OPERATION: &str = "environment.unmount";
27
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum DurableEnvironmentScope {
32 Connection {
34 connection_id: String,
36 },
37 Session {
39 session_id: String,
41 },
42 Run {
44 session_id: String,
46 run_id: String,
48 },
49}
50
51impl DurableEnvironmentScope {
52 pub fn validate(&self) -> SessionStoreResult<()> {
58 match self {
59 Self::Connection { connection_id } => {
60 require_non_empty("environment scope connection", connection_id)
61 }
62 Self::Session { session_id } => {
63 require_non_empty("environment scope session", session_id)
64 }
65 Self::Run { session_id, run_id } => {
66 require_non_empty("environment scope session", session_id)?;
67 require_non_empty("environment scope run", run_id)
68 }
69 }
70 }
71
72 #[must_use]
74 pub fn permits_run(&self, connection_id: Option<&str>, session_id: &str, run_id: &str) -> bool {
75 match self {
76 Self::Connection {
77 connection_id: owner,
78 } => connection_id == Some(owner.as_str()),
79 Self::Session { session_id: owner } => owner == session_id,
80 Self::Run {
81 session_id: owner_session,
82 run_id: owner_run,
83 } => owner_session == session_id && owner_run == run_id,
84 }
85 }
86}
87
88#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(rename_all = "snake_case")]
91pub enum DurableEnvironmentStatus {
92 Attaching,
94 Ready,
96 Degraded,
98 Detached,
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
104pub struct DurableEnvironmentAttachment {
105 pub authority_binding: String,
107 pub attachment_id: String,
109 pub environment_id: String,
111 pub display_name: Option<String>,
113 pub scope: DurableEnvironmentScope,
115 pub status: DurableEnvironmentStatus,
117 pub revision: u64,
119 pub updated_at: DateTime<Utc>,
121}
122
123impl starweaver_core::VersionedRecord for DurableEnvironmentAttachment {
124 const SCHEMA: &'static str = "starweaver.session.environment_attachment";
125}
126
127impl DurableEnvironmentAttachment {
128 pub fn validate(&self) -> SessionStoreResult<()> {
134 require_non_empty("environment authority binding", &self.authority_binding)?;
135 require_non_empty("environment attachment id", &self.attachment_id)?;
136 require_non_empty("public environment id", &self.environment_id)?;
137 if self.display_name.as_ref().is_some_and(String::is_empty) {
138 return Err(SessionStoreError::Failed(
139 "environment display name cannot be empty".to_string(),
140 ));
141 }
142 self.scope.validate()?;
143 require_revision(self.revision, "environment attachment")
144 }
145}
146
147#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
149#[serde(rename_all = "snake_case")]
150pub enum DurableEnvironmentMountStatus {
151 Mounted,
153 Unmounted,
155}
156
157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159pub struct DurableEnvironmentMount {
160 pub authority_binding: String,
162 pub mount_id: String,
164 pub attachment_id: String,
166 pub session_id: String,
168 pub run_id: String,
170 pub resource_label: String,
172 pub status: DurableEnvironmentMountStatus,
174 pub revision: u64,
176 pub updated_at: DateTime<Utc>,
178}
179
180impl starweaver_core::VersionedRecord for DurableEnvironmentMount {
181 const SCHEMA: &'static str = "starweaver.session.environment_mount";
182}
183
184impl DurableEnvironmentMount {
185 pub fn validate(&self) -> SessionStoreResult<()> {
191 require_non_empty("environment authority binding", &self.authority_binding)?;
192 require_non_empty("environment mount id", &self.mount_id)?;
193 require_non_empty("environment attachment id", &self.attachment_id)?;
194 require_non_empty("environment mount session", &self.session_id)?;
195 require_non_empty("environment mount run", &self.run_id)?;
196 require_non_empty("environment resource label", &self.resource_label)?;
197 require_revision(self.revision, "environment mount")
198 }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct EnvironmentHostEventContext {
204 pub transition_identity: String,
206 pub scope: DurableHostEventScope,
208}
209
210impl EnvironmentHostEventContext {
211 pub fn validate(&self) -> SessionStoreResult<()> {
217 require_non_empty(
218 "environment host event transition identity",
219 &self.transition_identity,
220 )?;
221 match &self.scope {
222 DurableHostEventScope::Global => Ok(()),
223 DurableHostEventScope::Session { session_id } => {
224 require_non_empty("environment host event session", session_id.as_str())
225 }
226 DurableHostEventScope::Run { session_id, run_id } => {
227 require_non_empty("environment host event session", session_id.as_str())?;
228 require_non_empty("environment host event run", run_id.as_str())
229 }
230 }
231 }
232
233 pub fn publication(
239 &self,
240 attachment: &DurableEnvironmentAttachment,
241 ) -> SessionStoreResult<PendingHostEventPublication> {
242 self.validate()?;
243 attachment.validate()?;
244 let attachment_scope = match &attachment.scope {
245 DurableEnvironmentScope::Connection { .. } => {
246 serde_json::json!({"kind": "connection"})
247 }
248 DurableEnvironmentScope::Session { session_id } => {
249 serde_json::json!({"kind": "session", "sessionId": session_id})
250 }
251 DurableEnvironmentScope::Run { session_id, run_id } => serde_json::json!({
252 "kind": "run",
253 "sessionId": session_id,
254 "runId": run_id,
255 }),
256 };
257 let status = match attachment.status {
258 DurableEnvironmentStatus::Attaching => "attaching",
259 DurableEnvironmentStatus::Ready => "ready",
260 DurableEnvironmentStatus::Degraded => "degraded",
261 DurableEnvironmentStatus::Detached => "detached",
262 };
263 let mut projected_attachment = serde_json::Map::from_iter([
264 (
265 "attachmentId".to_string(),
266 serde_json::Value::String(attachment.attachment_id.clone()),
267 ),
268 (
269 "environmentId".to_string(),
270 serde_json::Value::String(attachment.environment_id.clone()),
271 ),
272 (
273 "revision".to_string(),
274 serde_json::Value::String(attachment.revision.to_string()),
275 ),
276 ("scope".to_string(), attachment_scope),
277 (
278 "status".to_string(),
279 serde_json::Value::String(status.to_string()),
280 ),
281 ]);
282 if let Some(display_name) = &attachment.display_name {
283 projected_attachment.insert(
284 "displayName".to_string(),
285 serde_json::Value::String(display_name.clone()),
286 );
287 }
288 PendingHostEventPublication::new(
289 &self.transition_identity,
290 0,
291 self.scope.clone(),
292 DurableHostEventClass::EnvironmentChanged,
293 serde_json::json!({
294 "kind": "environment_changed",
295 "attachment": serde_json::Value::Object(projected_attachment),
296 }),
297 attachment.updated_at,
298 )
299 }
300}
301
302#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct EnvironmentMutationContext {
305 pub authority_binding: String,
307 pub idempotency_key: String,
309 pub command_fingerprint: String,
311 pub occurred_at: DateTime<Utc>,
313 pub host_event: Option<EnvironmentHostEventContext>,
315}
316
317impl EnvironmentMutationContext {
318 pub fn validate(&self) -> SessionStoreResult<()> {
324 require_non_empty("environment authority binding", &self.authority_binding)?;
325 require_non_empty("environment idempotency key", &self.idempotency_key)?;
326 require_non_empty("environment command fingerprint", &self.command_fingerprint)?;
327 if let Some(host_event) = &self.host_event {
328 host_event.validate()?;
329 }
330 Ok(())
331 }
332}
333
334#[derive(Clone, Debug, Eq, PartialEq)]
336pub struct AttachEnvironment {
337 pub context: EnvironmentMutationContext,
339 pub attachment_id: String,
341 pub environment_id: String,
343 pub display_name: Option<String>,
345 pub scope: DurableEnvironmentScope,
347 pub status: DurableEnvironmentStatus,
349}
350
351impl AttachEnvironment {
352 pub fn validate(&self) -> SessionStoreResult<()> {
358 self.context.validate()?;
359 require_non_empty("environment attachment id", &self.attachment_id)?;
360 require_non_empty("public environment id", &self.environment_id)?;
361 if self.display_name.as_ref().is_some_and(String::is_empty) {
362 return Err(SessionStoreError::Failed(
363 "environment display name cannot be empty".to_string(),
364 ));
365 }
366 if self.status == DurableEnvironmentStatus::Detached {
367 return Err(SessionStoreError::Failed(
368 "new environment attachment cannot be detached".to_string(),
369 ));
370 }
371 self.scope.validate()
372 }
373}
374
375#[derive(Clone, Debug, Eq, PartialEq)]
377pub struct DetachEnvironment {
378 pub context: EnvironmentMutationContext,
380 pub attachment_id: String,
382}
383
384impl DetachEnvironment {
385 pub fn validate(&self) -> SessionStoreResult<()> {
391 self.context.validate()?;
392 require_non_empty("environment attachment id", &self.attachment_id)
393 }
394}
395
396#[derive(Clone, Debug, Eq, PartialEq)]
398pub struct MountEnvironmentResource {
399 pub context: EnvironmentMutationContext,
401 pub mount_id: String,
403 pub attachment_id: String,
405 pub session_id: String,
407 pub run_id: String,
409 pub connection_id: Option<String>,
411 pub resource_label: String,
413}
414
415impl MountEnvironmentResource {
416 pub fn validate(&self) -> SessionStoreResult<()> {
422 self.context.validate()?;
423 require_non_empty("environment mount id", &self.mount_id)?;
424 require_non_empty("environment attachment id", &self.attachment_id)?;
425 require_non_empty("environment mount session", &self.session_id)?;
426 require_non_empty("environment mount run", &self.run_id)?;
427 if self.connection_id.as_ref().is_some_and(String::is_empty) {
428 return Err(SessionStoreError::Failed(
429 "environment mount connection identity cannot be empty".to_string(),
430 ));
431 }
432 require_non_empty("environment resource label", &self.resource_label)
433 }
434}
435
436#[derive(Clone, Debug, Eq, PartialEq)]
438pub struct UnmountEnvironmentResource {
439 pub context: EnvironmentMutationContext,
441 pub mount_id: String,
443}
444
445impl UnmountEnvironmentResource {
446 pub fn validate(&self) -> SessionStoreResult<()> {
452 self.context.validate()?;
453 require_non_empty("environment mount id", &self.mount_id)
454 }
455}
456
457#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
459pub struct EnvironmentAttachmentMutationResult {
460 pub attachment: DurableEnvironmentAttachment,
462 pub receipt: MutationReceipt,
464}
465
466impl starweaver_core::VersionedRecord for EnvironmentAttachmentMutationResult {
467 const SCHEMA: &'static str = "starweaver.session.environment_attachment_mutation_result";
468}
469
470impl EnvironmentAttachmentMutationResult {
471 pub fn validate(&self) -> SessionStoreResult<()> {
477 self.attachment.validate()?;
478 self.receipt.validate()?;
479 if self.receipt.target_ref != self.attachment.attachment_id {
480 return Err(SessionStoreError::Conflict(
481 "environment receipt target does not match attachment".to_string(),
482 ));
483 }
484 Ok(())
485 }
486
487 #[must_use]
489 pub fn replayed_projection(&self) -> Self {
490 Self {
491 attachment: self.attachment.clone(),
492 receipt: self.receipt.replayed_projection(),
493 }
494 }
495}
496
497#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
499pub struct EnvironmentMountMutationResult {
500 pub mount: DurableEnvironmentMount,
502 pub receipt: MutationReceipt,
504}
505
506impl starweaver_core::VersionedRecord for EnvironmentMountMutationResult {
507 const SCHEMA: &'static str = "starweaver.session.environment_mount_mutation_result";
508}
509
510impl EnvironmentMountMutationResult {
511 pub fn validate(&self) -> SessionStoreResult<()> {
517 self.mount.validate()?;
518 self.receipt.validate()?;
519 if self.receipt.target_ref != self.mount.mount_id {
520 return Err(SessionStoreError::Conflict(
521 "environment receipt target does not match mount".to_string(),
522 ));
523 }
524 Ok(())
525 }
526
527 #[must_use]
529 pub fn replayed_projection(&self) -> Self {
530 Self {
531 mount: self.mount.clone(),
532 receipt: self.receipt.replayed_projection(),
533 }
534 }
535}
536
537#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
539#[serde(tag = "result_kind", rename_all = "snake_case")]
540pub enum EnvironmentMutationResult {
541 Attachment(EnvironmentAttachmentMutationResult),
543 Mount(EnvironmentMountMutationResult),
545}
546
547impl starweaver_core::VersionedRecord for EnvironmentMutationResult {
548 const SCHEMA: &'static str = "starweaver.session.environment_mutation_result";
549}
550
551#[derive(Clone, Debug, Eq, PartialEq)]
553pub struct EnvironmentAttachmentPageKey {
554 pub updated_at: DateTime<Utc>,
556 pub attachment_id: String,
558}
559
560#[derive(Clone, Debug, Eq, PartialEq)]
562pub struct EnvironmentAttachmentQuery {
563 pub authority_binding: String,
565 pub scope: Option<DurableEnvironmentScope>,
567 pub connection_id: Option<String>,
569 pub limit: u32,
571 pub after: Option<EnvironmentAttachmentPageKey>,
573}
574
575impl EnvironmentAttachmentQuery {
576 pub fn validate(&self) -> SessionStoreResult<()> {
582 require_non_empty("environment authority binding", &self.authority_binding)?;
583 require_page_limit(self.limit)?;
584 if let Some(scope) = &self.scope {
585 scope.validate()?;
586 if let DurableEnvironmentScope::Connection { connection_id } = scope
587 && self.connection_id.as_deref() != Some(connection_id)
588 {
589 return Err(SessionStoreError::NotFound(
590 "environment connection scope".to_string(),
591 ));
592 }
593 }
594 if self.connection_id.as_ref().is_some_and(String::is_empty) {
595 return Err(SessionStoreError::Failed(
596 "environment viewer connection identity cannot be empty".to_string(),
597 ));
598 }
599 if let Some(after) = &self.after {
600 require_non_empty("environment attachment page key", &after.attachment_id)?;
601 }
602 Ok(())
603 }
604}
605
606#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
608pub struct EnvironmentAttachmentPage {
609 pub items: Vec<DurableEnvironmentAttachment>,
611 pub next: Option<EnvironmentAttachmentPageKeyProjection>,
613}
614
615#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
617pub struct EnvironmentAttachmentPageKeyProjection {
618 pub updated_at: DateTime<Utc>,
620 pub attachment_id: String,
622}
623
624impl From<EnvironmentAttachmentPageKeyProjection> for EnvironmentAttachmentPageKey {
625 fn from(value: EnvironmentAttachmentPageKeyProjection) -> Self {
626 Self {
627 updated_at: value.updated_at,
628 attachment_id: value.attachment_id,
629 }
630 }
631}
632
633#[derive(Clone, Debug, Eq, PartialEq)]
635pub struct EnvironmentMountQuery {
636 pub authority_binding: String,
638 pub session_id: String,
640 pub run_id: String,
642 pub limit: u32,
644}
645
646impl EnvironmentMountQuery {
647 pub fn validate(&self) -> SessionStoreResult<()> {
653 require_non_empty("environment authority binding", &self.authority_binding)?;
654 require_non_empty("environment mount session", &self.session_id)?;
655 require_non_empty("environment mount run", &self.run_id)?;
656 require_page_limit(self.limit)
657 }
658}
659
660fn require_page_limit(limit: u32) -> SessionStoreResult<()> {
661 if !(1..=MAX_ENVIRONMENT_PAGE_SIZE).contains(&limit) {
662 return Err(SessionStoreError::Failed(format!(
663 "environment page limit must be between 1 and {MAX_ENVIRONMENT_PAGE_SIZE}"
664 )));
665 }
666 Ok(())
667}
668
669fn require_revision(revision: u64, label: &str) -> SessionStoreResult<()> {
670 if revision == 0 {
671 return Err(SessionStoreError::Failed(format!(
672 "{label} revision must be greater than zero"
673 )));
674 }
675 Ok(())
676}
677
678fn require_non_empty(label: &str, value: &str) -> SessionStoreResult<()> {
679 if value.is_empty() {
680 return Err(SessionStoreError::Failed(format!(
681 "{label} cannot be empty"
682 )));
683 }
684 Ok(())
685}