Skip to main content

pulse_pixelstream_types/
frame_capture.rs

1use myko::prelude::*;
2use myko::TS;
3use myko_macros::myko_item;
4use serde::{Deserialize, Serialize};
5
6use crate::StoredCaptureContext;
7
8/// Contract version for the synchronized nDisplay frame-capture surface.
9/// Gated in the client exactly like the recording contract: a deployment that
10/// advertises an older version hides the capture action entirely.
11pub const FRAME_CAPTURE_CONTRACT_VERSION: &str = "0.1.0";
12
13/// One operator request for a synchronized nDisplay frame capture.
14///
15/// The browser NEVER talks to Pulse Cluster. This entity is the whole
16/// client-facing surface: the UI writes typed editorial context plus a typed
17/// target selection, and an off-browser bridge (holding the cluster
18/// credential) submits it and mirrors authoritative status back as
19/// [`FrameCaptureStatus`]. Keyed by streamer id — one in-flight capture per
20/// stream, mirroring RecordingJobRequest.
21#[myko_item]
22pub struct FrameCaptureRequest {
23    pub streamer_id: String,
24    /// Stable id for this capture across submit/status/receipt. Client-minted
25    /// (UUIDv7) so retries of the same intent are idempotent at the bridge.
26    pub capture_id: String,
27    /// Makes a re-submitted command idempotent, like RecordingJobRequest.
28    pub command_id: String,
29    /// Frozen editorial identity: typed collection ref, optional label,
30    /// optional content revision. Required — capture output is filed by
31    /// editorial context, and the collection must be explicit, never inferred.
32    #[ts(type = "unknown")]
33    pub capture_context: StoredCaptureContext,
34    /// Typed target selection. The UI picks from the summaries the bridge
35    /// mirrors; it never constructs cluster addresses.
36    pub target: FrameCaptureTarget,
37    /// Explicit operator override. Cluster's force submits despite missing or
38    /// stalled frame-progress telemetry ONLY; exact target identity,
39    /// generation, revision, and capture capability remain required. Never set
40    /// automatically — the operator asks for it after a refusal.
41    #[serde(default)]
42    pub force: bool,
43    #[serde(default)]
44    pub requested_at_ms: u64,
45}
46
47/// Which cluster the capture is fired on, plus the generation the operator
48/// believed was live when they asked. The bridge rejects a mismatch rather
49/// than capturing a different deployment than the one on screen.
50#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
51#[serde(rename_all = "camelCase")]
52pub struct FrameCaptureTarget {
53    pub cluster_name: String,
54    /// Recorded revision/generation the UI displayed at request time. Empty
55    /// means "whatever is live" and the bridge stamps what it actually used.
56    #[serde(default)]
57    pub expected_generation: String,
58}
59
60/// Lifecycle of a capture as reported by the bridge. Deliberately coarse and
61/// UI-facing: the cluster's internal step machine stays behind the bridge.
62#[derive(
63    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS, PartialOrd, Ord, Hash,
64)]
65#[serde(rename_all = "snake_case")]
66pub enum FrameCapturePhase {
67    #[default]
68    Idle,
69    /// Accepted by the bridge, not yet acknowledged by Cluster.
70    Submitted,
71    /// Cluster is preparing the nodes.
72    Arming,
73    /// The synchronized frame is being taken.
74    Firing,
75    /// Cluster is aggregating per-node artifacts.
76    Aggregating,
77    Complete,
78    Failed,
79}
80
81impl FrameCapturePhase {
82    pub fn is_running(&self) -> bool {
83        matches!(
84            self,
85            Self::Submitted | Self::Arming | Self::Firing | Self::Aggregating
86        )
87    }
88
89    pub fn is_terminal(&self) -> bool {
90        matches!(self, Self::Complete | Self::Failed)
91    }
92}
93
94/// One artifact reference exactly as Cluster reported it. Pixelstream renders
95/// these verbatim and constructs no paths of its own — the capture tree layout
96/// is PulseNode-owned implementation detail.
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
98#[serde(rename_all = "camelCase")]
99pub struct FrameCaptureReceipt {
100    /// Opaque reference string from Cluster's typed receipt (path or URI).
101    pub reference: String,
102    /// Optional node/view label for display grouping, as reported.
103    #[serde(default)]
104    pub label: String,
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn running_phases_are_not_terminal() {
113        for phase in [
114            FrameCapturePhase::Submitted,
115            FrameCapturePhase::Arming,
116            FrameCapturePhase::Firing,
117            FrameCapturePhase::Aggregating,
118        ] {
119            assert!(phase.is_running());
120            assert!(!phase.is_terminal());
121        }
122        for phase in [FrameCapturePhase::Complete, FrameCapturePhase::Failed] {
123            assert!(!phase.is_running());
124            assert!(phase.is_terminal());
125        }
126        assert!(!FrameCapturePhase::Idle.is_running());
127        assert!(!FrameCapturePhase::Idle.is_terminal());
128    }
129}