@agent-native/core 0.114.6 → 0.114.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
- package/corpus/core/src/cli/mcp.ts +124 -0
- package/corpus/core/src/cli/skills-content/help.ts +4 -3
- package/corpus/core/src/cli/skills-content/index.ts +1 -0
- package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
- package/corpus/core/src/cli/skills.ts +115 -13
- package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
- package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
- package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
- package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
- package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
- package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
- package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
- package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
- package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
- package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
- package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
- package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
- package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
- package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
- package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
- package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
- package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
- package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
- package/corpus/templates/clips/desktop/src/styles.css +956 -134
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
- package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
- package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
- package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
- package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
- package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
- package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
- package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
- package/dist/cli/mcp-config-writers.d.ts +2 -2
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +6 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +79 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills-content/help.d.ts +1 -1
- package/dist/cli/skills-content/help.d.ts.map +1 -1
- package/dist/cli/skills-content/help.js +4 -3
- package/dist/cli/skills-content/help.js.map +1 -1
- package/dist/cli/skills-content/index.d.ts +1 -0
- package/dist/cli/skills-content/index.d.ts.map +1 -1
- package/dist/cli/skills-content/index.js +1 -0
- package/dist/cli/skills-content/index.js.map +1 -1
- package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
- package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
- package/dist/cli/skills-content/rewind-skill.js +53 -0
- package/dist/cli/skills-content/rewind-skill.js.map +1 -0
- package/dist/cli/skills.d.ts +6 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +90 -14
- package/dist/cli/skills.js.map +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +365 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp/screen-memory-stdio.js +1014 -91
- package/dist/mcp/screen-memory-stdio.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +90 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
- package/dist/mcp-client/screen-memory-local.js +590 -12
- package/dist/mcp-client/screen-memory-local.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/package.json +1 -1
- package/src/cli/mcp-config-writers.ts +6 -4
- package/src/cli/mcp.ts +124 -0
- package/src/cli/skills-content/help.ts +4 -3
- package/src/cli/skills-content/index.ts +1 -0
- package/src/cli/skills-content/rewind-skill.ts +52 -0
- package/src/cli/skills.ts +115 -13
- package/src/mcp/screen-memory-stdio.ts +1360 -104
- package/src/mcp-client/index.ts +9 -0
- package/src/mcp-client/screen-memory-local.ts +847 -13
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
//! A local coordinator for capture demand.
|
|
2
|
+
//!
|
|
3
|
+
//! This module intentionally does not start a screen, audio, or camera producer.
|
|
4
|
+
//! It is the small piece of shared state those producers will eventually obey: a
|
|
5
|
+
//! single monotonic graph clock, ref-counted source demand, and auditable leases.
|
|
6
|
+
|
|
7
|
+
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
|
8
|
+
use serde::Serialize;
|
|
9
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
10
|
+
use std::fmt;
|
|
11
|
+
use std::sync::Mutex;
|
|
12
|
+
use std::time::{Duration, Instant};
|
|
13
|
+
|
|
14
|
+
/// The largest interval a caller may explicitly request from a prior buffer.
|
|
15
|
+
pub(crate) const MAX_RETROSPECTIVE_EXTENSION: Duration = Duration::from_secs(5 * 60);
|
|
16
|
+
|
|
17
|
+
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
|
18
|
+
#[serde(rename_all = "kebab-case")]
|
|
19
|
+
pub(crate) enum CaptureSource {
|
|
20
|
+
Screen,
|
|
21
|
+
SystemAudio,
|
|
22
|
+
Microphone,
|
|
23
|
+
Camera,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
|
27
|
+
#[serde(rename_all = "kebab-case")]
|
|
28
|
+
pub(crate) enum CaptureConsumer {
|
|
29
|
+
Rewind,
|
|
30
|
+
Clip,
|
|
31
|
+
Meeting,
|
|
32
|
+
Transcription,
|
|
33
|
+
VisualIndex,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
|
37
|
+
#[serde(rename_all = "kebab-case")]
|
|
38
|
+
pub(crate) enum CoverageGapReason {
|
|
39
|
+
PermissionDenied,
|
|
40
|
+
ProducerUnavailable,
|
|
41
|
+
UserPaused,
|
|
42
|
+
PrivacyExclusion,
|
|
43
|
+
SystemInterrupted,
|
|
44
|
+
Unknown,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
48
|
+
#[serde(rename_all = "camelCase")]
|
|
49
|
+
pub(crate) struct CaptureInterval {
|
|
50
|
+
pub started_at: String,
|
|
51
|
+
pub ended_at: String,
|
|
52
|
+
pub started_elapsed_ms: u64,
|
|
53
|
+
pub ended_elapsed_ms: u64,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
57
|
+
#[serde(rename_all = "camelCase")]
|
|
58
|
+
pub(crate) struct ConsumerLeaseSnapshot {
|
|
59
|
+
pub id: String,
|
|
60
|
+
pub consumer: CaptureConsumer,
|
|
61
|
+
pub sources: Vec<CaptureSource>,
|
|
62
|
+
pub interval: CaptureInterval,
|
|
63
|
+
pub retrospective_extension_ms: u64,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
67
|
+
#[serde(rename_all = "camelCase")]
|
|
68
|
+
pub(crate) struct ClosedConsumerLease {
|
|
69
|
+
pub lease: ConsumerLeaseSnapshot,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
73
|
+
#[serde(rename_all = "camelCase")]
|
|
74
|
+
pub(crate) struct PinRecord {
|
|
75
|
+
pub id: String,
|
|
76
|
+
pub interval: CaptureInterval,
|
|
77
|
+
pub label: Option<String>,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
81
|
+
#[serde(rename_all = "camelCase")]
|
|
82
|
+
pub(crate) struct CoverageGap {
|
|
83
|
+
pub id: String,
|
|
84
|
+
pub source: CaptureSource,
|
|
85
|
+
pub reason: CoverageGapReason,
|
|
86
|
+
pub interval: CaptureInterval,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
90
|
+
#[serde(rename_all = "camelCase")]
|
|
91
|
+
pub(crate) struct ActiveSourceStatus {
|
|
92
|
+
pub source: CaptureSource,
|
|
93
|
+
pub consumer_count: usize,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
97
|
+
#[serde(rename_all = "camelCase")]
|
|
98
|
+
pub(crate) struct CaptureGraphStatus {
|
|
99
|
+
pub graph_started_at: String,
|
|
100
|
+
pub graph_elapsed_ms: u64,
|
|
101
|
+
pub active_sources: Vec<ActiveSourceStatus>,
|
|
102
|
+
pub active_consumers: Vec<ConsumerLeaseSnapshot>,
|
|
103
|
+
pub coverage_gaps: Vec<CoverageGap>,
|
|
104
|
+
pub pins: Vec<PinRecord>,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
108
|
+
pub(crate) enum CaptureGraphError {
|
|
109
|
+
UnknownLease(String),
|
|
110
|
+
DuplicateLease(String),
|
|
111
|
+
EmptySources,
|
|
112
|
+
InvalidInterval,
|
|
113
|
+
BeforeGraphStart,
|
|
114
|
+
RetrospectiveExtensionTooLarge {
|
|
115
|
+
requested: Duration,
|
|
116
|
+
maximum: Duration,
|
|
117
|
+
},
|
|
118
|
+
RetrospectiveExtensionAlreadySet(String),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
impl fmt::Display for CaptureGraphError {
|
|
122
|
+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
123
|
+
match self {
|
|
124
|
+
Self::UnknownLease(id) => write!(formatter, "unknown capture lease {id}"),
|
|
125
|
+
Self::DuplicateLease(id) => write!(formatter, "capture lease {id} already exists"),
|
|
126
|
+
Self::EmptySources => write!(
|
|
127
|
+
formatter,
|
|
128
|
+
"a capture lease must request at least one source"
|
|
129
|
+
),
|
|
130
|
+
Self::InvalidInterval => write!(formatter, "capture interval ends before it starts"),
|
|
131
|
+
Self::BeforeGraphStart => write!(formatter, "capture instant predates the graph clock"),
|
|
132
|
+
Self::RetrospectiveExtensionTooLarge { requested, maximum } => write!(
|
|
133
|
+
formatter,
|
|
134
|
+
"retrospective extension of {}ms exceeds {}ms",
|
|
135
|
+
requested.as_millis(),
|
|
136
|
+
maximum.as_millis()
|
|
137
|
+
),
|
|
138
|
+
Self::RetrospectiveExtensionAlreadySet(id) => {
|
|
139
|
+
write!(
|
|
140
|
+
formatter,
|
|
141
|
+
"capture lease {id} already has a retrospective extension"
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
impl std::error::Error for CaptureGraphError {}
|
|
149
|
+
|
|
150
|
+
#[derive(Debug)]
|
|
151
|
+
struct Lease {
|
|
152
|
+
id: String,
|
|
153
|
+
consumer: CaptureConsumer,
|
|
154
|
+
sources: BTreeSet<CaptureSource>,
|
|
155
|
+
started_at: Instant,
|
|
156
|
+
retrospective_extension: Option<Duration>,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// In-memory source-demand graph. `Instant` is authoritative; wall clock values
|
|
160
|
+
/// are produced only at the serializable boundary.
|
|
161
|
+
#[derive(Debug)]
|
|
162
|
+
pub(crate) struct CaptureGraph {
|
|
163
|
+
clock_started_at: Instant,
|
|
164
|
+
epoch_started_at: DateTime<Utc>,
|
|
165
|
+
active_leases: BTreeMap<String, Lease>,
|
|
166
|
+
source_demand: BTreeMap<CaptureSource, usize>,
|
|
167
|
+
coverage_gaps: Vec<CoverageGap>,
|
|
168
|
+
pins: Vec<PinRecord>,
|
|
169
|
+
next_lease_id: u64,
|
|
170
|
+
next_gap_id: u64,
|
|
171
|
+
next_pin_id: u64,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Tauri-managed holder. No command consumes this yet; keeping it managed makes
|
|
175
|
+
/// later producer integration share one coordinator rather than inventing a
|
|
176
|
+
/// second clock in each subsystem.
|
|
177
|
+
pub(crate) struct CaptureGraphState(pub Mutex<CaptureGraph>);
|
|
178
|
+
|
|
179
|
+
impl Default for CaptureGraphState {
|
|
180
|
+
fn default() -> Self {
|
|
181
|
+
Self(Mutex::new(CaptureGraph::new()))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
impl CaptureGraph {
|
|
186
|
+
pub(crate) fn new() -> Self {
|
|
187
|
+
Self::new_at(Instant::now(), Utc::now())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// Stable identity for this in-memory monotonic clock. Persisted media may
|
|
191
|
+
/// compare elapsed offsets only when this identity also matches; elapsed
|
|
192
|
+
/// milliseconds restart from zero whenever the desktop app relaunches.
|
|
193
|
+
pub(crate) fn epoch_id(&self) -> String {
|
|
194
|
+
format_rfc3339_millis(self.epoch_started_at)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn new_at(clock_started_at: Instant, epoch_started_at: DateTime<Utc>) -> Self {
|
|
198
|
+
Self {
|
|
199
|
+
clock_started_at,
|
|
200
|
+
epoch_started_at,
|
|
201
|
+
active_leases: BTreeMap::new(),
|
|
202
|
+
source_demand: BTreeMap::new(),
|
|
203
|
+
coverage_gaps: Vec::new(),
|
|
204
|
+
pins: Vec::new(),
|
|
205
|
+
next_lease_id: 1,
|
|
206
|
+
next_gap_id: 1,
|
|
207
|
+
next_pin_id: 1,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Starts demand at `now`, never before it. Countdown handling belongs to a
|
|
212
|
+
/// caller: it must invoke this only when the countdown has completed.
|
|
213
|
+
pub(crate) fn start_consumer(
|
|
214
|
+
&mut self,
|
|
215
|
+
consumer: CaptureConsumer,
|
|
216
|
+
sources: impl IntoIterator<Item = CaptureSource>,
|
|
217
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
218
|
+
self.start_consumer_at(consumer, sources, Instant::now())
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
pub(crate) fn start_consumer_at(
|
|
222
|
+
&mut self,
|
|
223
|
+
consumer: CaptureConsumer,
|
|
224
|
+
sources: impl IntoIterator<Item = CaptureSource>,
|
|
225
|
+
now: Instant,
|
|
226
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
227
|
+
self.require_graph_instant(now)?;
|
|
228
|
+
let sources: BTreeSet<_> = sources.into_iter().collect();
|
|
229
|
+
if sources.is_empty() {
|
|
230
|
+
return Err(CaptureGraphError::EmptySources);
|
|
231
|
+
}
|
|
232
|
+
let id = format!("lease-{}", self.next_lease_id);
|
|
233
|
+
self.next_lease_id += 1;
|
|
234
|
+
self.start_consumer_with_id_at(id, consumer, sources, now)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn start_consumer_with_id_at(
|
|
238
|
+
&mut self,
|
|
239
|
+
id: String,
|
|
240
|
+
consumer: CaptureConsumer,
|
|
241
|
+
sources: BTreeSet<CaptureSource>,
|
|
242
|
+
now: Instant,
|
|
243
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
244
|
+
if self.active_leases.contains_key(&id) {
|
|
245
|
+
return Err(CaptureGraphError::DuplicateLease(id));
|
|
246
|
+
}
|
|
247
|
+
for source in &sources {
|
|
248
|
+
*self.source_demand.entry(*source).or_default() += 1;
|
|
249
|
+
}
|
|
250
|
+
let lease = Lease {
|
|
251
|
+
id: id.clone(),
|
|
252
|
+
consumer,
|
|
253
|
+
sources,
|
|
254
|
+
started_at: now,
|
|
255
|
+
retrospective_extension: None,
|
|
256
|
+
};
|
|
257
|
+
let snapshot = self.open_lease_snapshot(&lease, now)?;
|
|
258
|
+
self.active_leases.insert(id, lease);
|
|
259
|
+
Ok(snapshot)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// Explicitly asks to include bounded buffered history. Starting a lease
|
|
263
|
+
/// never calls this implicitly; actual producer coverage remains separate.
|
|
264
|
+
pub(crate) fn extend_retrospectively(
|
|
265
|
+
&mut self,
|
|
266
|
+
lease_id: &str,
|
|
267
|
+
duration: Duration,
|
|
268
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
269
|
+
if duration > MAX_RETROSPECTIVE_EXTENSION {
|
|
270
|
+
return Err(CaptureGraphError::RetrospectiveExtensionTooLarge {
|
|
271
|
+
requested: duration,
|
|
272
|
+
maximum: MAX_RETROSPECTIVE_EXTENSION,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
{
|
|
276
|
+
let lease = self
|
|
277
|
+
.active_leases
|
|
278
|
+
.get_mut(lease_id)
|
|
279
|
+
.ok_or_else(|| CaptureGraphError::UnknownLease(lease_id.to_owned()))?;
|
|
280
|
+
if lease.retrospective_extension.is_some() {
|
|
281
|
+
return Err(CaptureGraphError::RetrospectiveExtensionAlreadySet(
|
|
282
|
+
lease_id.to_owned(),
|
|
283
|
+
));
|
|
284
|
+
}
|
|
285
|
+
// Clamp at the graph epoch: a lease may not request time before its
|
|
286
|
+
// only reliable clock anchor.
|
|
287
|
+
let available = lease.started_at.duration_since(self.clock_started_at);
|
|
288
|
+
lease.retrospective_extension = Some(duration.min(available));
|
|
289
|
+
}
|
|
290
|
+
let lease = self
|
|
291
|
+
.active_leases
|
|
292
|
+
.get(lease_id)
|
|
293
|
+
.expect("lease just updated");
|
|
294
|
+
self.open_lease_snapshot(lease, lease.started_at)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// Explicitly extends a lease back to an absolute requested instant, such
|
|
298
|
+
/// as a meeting's recorded start. Unlike `extend_retrospectively`, this is
|
|
299
|
+
/// not subject to the short Rewind duration cap; it is clamped only to the
|
|
300
|
+
/// graph clock because the coordinator cannot describe coverage before it.
|
|
301
|
+
pub(crate) fn extend_retrospectively_to_start(
|
|
302
|
+
&mut self,
|
|
303
|
+
lease_id: &str,
|
|
304
|
+
requested_start: Instant,
|
|
305
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
306
|
+
let requested_start = requested_start.max(self.clock_started_at);
|
|
307
|
+
{
|
|
308
|
+
let lease = self
|
|
309
|
+
.active_leases
|
|
310
|
+
.get_mut(lease_id)
|
|
311
|
+
.ok_or_else(|| CaptureGraphError::UnknownLease(lease_id.to_owned()))?;
|
|
312
|
+
if lease.retrospective_extension.is_some() {
|
|
313
|
+
return Err(CaptureGraphError::RetrospectiveExtensionAlreadySet(
|
|
314
|
+
lease_id.to_owned(),
|
|
315
|
+
));
|
|
316
|
+
}
|
|
317
|
+
let extension = lease.started_at.saturating_duration_since(requested_start);
|
|
318
|
+
lease.retrospective_extension = Some(extension);
|
|
319
|
+
}
|
|
320
|
+
let lease = self
|
|
321
|
+
.active_leases
|
|
322
|
+
.get(lease_id)
|
|
323
|
+
.expect("lease just updated");
|
|
324
|
+
self.open_lease_snapshot(lease, lease.started_at)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
pub(crate) fn end_consumer(
|
|
328
|
+
&mut self,
|
|
329
|
+
lease_id: &str,
|
|
330
|
+
) -> Result<ClosedConsumerLease, CaptureGraphError> {
|
|
331
|
+
self.end_consumer_at(lease_id, Instant::now())
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// Ends a lease and returns its closed interval. This is the sole path that
|
|
335
|
+
/// releases its ref-counted producer demand.
|
|
336
|
+
pub(crate) fn end_consumer_at(
|
|
337
|
+
&mut self,
|
|
338
|
+
lease_id: &str,
|
|
339
|
+
now: Instant,
|
|
340
|
+
) -> Result<ClosedConsumerLease, CaptureGraphError> {
|
|
341
|
+
self.require_graph_instant(now)?;
|
|
342
|
+
let lease = self
|
|
343
|
+
.active_leases
|
|
344
|
+
.remove(lease_id)
|
|
345
|
+
.ok_or_else(|| CaptureGraphError::UnknownLease(lease_id.to_owned()))?;
|
|
346
|
+
if now < lease.started_at {
|
|
347
|
+
self.active_leases.insert(lease.id.clone(), lease);
|
|
348
|
+
return Err(CaptureGraphError::InvalidInterval);
|
|
349
|
+
}
|
|
350
|
+
for source in &lease.sources {
|
|
351
|
+
let no_remaining_demand = {
|
|
352
|
+
let demand = self
|
|
353
|
+
.source_demand
|
|
354
|
+
.get_mut(source)
|
|
355
|
+
.expect("lease demand exists");
|
|
356
|
+
*demand -= 1;
|
|
357
|
+
*demand == 0
|
|
358
|
+
};
|
|
359
|
+
if no_remaining_demand {
|
|
360
|
+
self.source_demand.remove(source);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
Ok(ClosedConsumerLease {
|
|
364
|
+
lease: self.closed_lease_snapshot(&lease, now)?,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
pub(crate) fn record_coverage_gap_at(
|
|
369
|
+
&mut self,
|
|
370
|
+
source: CaptureSource,
|
|
371
|
+
reason: CoverageGapReason,
|
|
372
|
+
started_at: Instant,
|
|
373
|
+
ended_at: Instant,
|
|
374
|
+
) -> Result<CoverageGap, CaptureGraphError> {
|
|
375
|
+
let interval = self.interval(started_at, ended_at)?;
|
|
376
|
+
let gap = CoverageGap {
|
|
377
|
+
id: format!("gap-{}", self.next_gap_id),
|
|
378
|
+
source,
|
|
379
|
+
reason,
|
|
380
|
+
interval,
|
|
381
|
+
};
|
|
382
|
+
self.next_gap_id += 1;
|
|
383
|
+
self.coverage_gaps.push(gap.clone());
|
|
384
|
+
Ok(gap)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
pub(crate) fn pin_interval_at(
|
|
388
|
+
&mut self,
|
|
389
|
+
started_at: Instant,
|
|
390
|
+
ended_at: Instant,
|
|
391
|
+
label: Option<String>,
|
|
392
|
+
) -> Result<PinRecord, CaptureGraphError> {
|
|
393
|
+
let pin = PinRecord {
|
|
394
|
+
id: format!("pin-{}", self.next_pin_id),
|
|
395
|
+
interval: self.interval(started_at, ended_at)?,
|
|
396
|
+
label,
|
|
397
|
+
};
|
|
398
|
+
self.next_pin_id += 1;
|
|
399
|
+
self.pins.push(pin.clone());
|
|
400
|
+
Ok(pin)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/// Pins an already-serialized graph interval without reconstructing it
|
|
404
|
+
/// through wall-clock time. This is the normal bridge from a closed
|
|
405
|
+
/// consumer lease to retention-aware media materialization.
|
|
406
|
+
pub(crate) fn pin_interval(
|
|
407
|
+
&mut self,
|
|
408
|
+
interval: &CaptureInterval,
|
|
409
|
+
label: Option<String>,
|
|
410
|
+
) -> Result<PinRecord, CaptureGraphError> {
|
|
411
|
+
let started_at = self
|
|
412
|
+
.clock_started_at
|
|
413
|
+
.checked_add(Duration::from_millis(interval.started_elapsed_ms))
|
|
414
|
+
.ok_or(CaptureGraphError::InvalidInterval)?;
|
|
415
|
+
let ended_at = self
|
|
416
|
+
.clock_started_at
|
|
417
|
+
.checked_add(Duration::from_millis(interval.ended_elapsed_ms))
|
|
418
|
+
.ok_or(CaptureGraphError::InvalidInterval)?;
|
|
419
|
+
self.pin_interval_at(started_at, ended_at, label)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/// Releases a retention pin after its bounded artifact has been copied to
|
|
423
|
+
/// durable pending storage. Releasing an unknown pin is deliberately a
|
|
424
|
+
/// no-op so cancellation and error cleanup can be idempotent.
|
|
425
|
+
pub(crate) fn release_pin(&mut self, pin_id: &str) -> bool {
|
|
426
|
+
let before = self.pins.len();
|
|
427
|
+
self.pins.retain(|pin| pin.id != pin_id);
|
|
428
|
+
self.pins.len() != before
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
pub(crate) fn status_at(&self, now: Instant) -> Result<CaptureGraphStatus, CaptureGraphError> {
|
|
432
|
+
self.require_graph_instant(now)?;
|
|
433
|
+
Ok(CaptureGraphStatus {
|
|
434
|
+
graph_started_at: format_rfc3339_millis(self.epoch_started_at),
|
|
435
|
+
graph_elapsed_ms: self.elapsed_ms(now)?,
|
|
436
|
+
active_sources: self
|
|
437
|
+
.source_demand
|
|
438
|
+
.iter()
|
|
439
|
+
.map(|(source, consumer_count)| ActiveSourceStatus {
|
|
440
|
+
source: *source,
|
|
441
|
+
consumer_count: *consumer_count,
|
|
442
|
+
})
|
|
443
|
+
.collect(),
|
|
444
|
+
active_consumers: self
|
|
445
|
+
.active_leases
|
|
446
|
+
.values()
|
|
447
|
+
.map(|lease| self.open_lease_snapshot(lease, now))
|
|
448
|
+
.collect::<Result<Vec<_>, _>>()?,
|
|
449
|
+
coverage_gaps: self.coverage_gaps.clone(),
|
|
450
|
+
pins: self.pins.clone(),
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fn open_lease_snapshot(
|
|
455
|
+
&self,
|
|
456
|
+
lease: &Lease,
|
|
457
|
+
now: Instant,
|
|
458
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
459
|
+
let started_at = self.effective_start(lease)?;
|
|
460
|
+
Ok(ConsumerLeaseSnapshot {
|
|
461
|
+
id: lease.id.clone(),
|
|
462
|
+
consumer: lease.consumer,
|
|
463
|
+
sources: lease.sources.iter().copied().collect(),
|
|
464
|
+
interval: self.interval(started_at, now)?,
|
|
465
|
+
retrospective_extension_ms: lease
|
|
466
|
+
.retrospective_extension
|
|
467
|
+
.map(|duration| duration.as_millis() as u64)
|
|
468
|
+
.unwrap_or_default(),
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
fn closed_lease_snapshot(
|
|
473
|
+
&self,
|
|
474
|
+
lease: &Lease,
|
|
475
|
+
ended_at: Instant,
|
|
476
|
+
) -> Result<ConsumerLeaseSnapshot, CaptureGraphError> {
|
|
477
|
+
let started_at = self.effective_start(lease)?;
|
|
478
|
+
Ok(ConsumerLeaseSnapshot {
|
|
479
|
+
id: lease.id.clone(),
|
|
480
|
+
consumer: lease.consumer,
|
|
481
|
+
sources: lease.sources.iter().copied().collect(),
|
|
482
|
+
interval: self.interval(started_at, ended_at)?,
|
|
483
|
+
retrospective_extension_ms: lease
|
|
484
|
+
.retrospective_extension
|
|
485
|
+
.map(|duration| duration.as_millis() as u64)
|
|
486
|
+
.unwrap_or_default(),
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
fn effective_start(&self, lease: &Lease) -> Result<Instant, CaptureGraphError> {
|
|
491
|
+
Ok(lease.started_at - lease.retrospective_extension.unwrap_or_default())
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
fn interval(
|
|
495
|
+
&self,
|
|
496
|
+
started_at: Instant,
|
|
497
|
+
ended_at: Instant,
|
|
498
|
+
) -> Result<CaptureInterval, CaptureGraphError> {
|
|
499
|
+
self.require_graph_instant(started_at)?;
|
|
500
|
+
self.require_graph_instant(ended_at)?;
|
|
501
|
+
if ended_at < started_at {
|
|
502
|
+
return Err(CaptureGraphError::InvalidInterval);
|
|
503
|
+
}
|
|
504
|
+
Ok(CaptureInterval {
|
|
505
|
+
started_at: self.timestamp(started_at)?,
|
|
506
|
+
ended_at: self.timestamp(ended_at)?,
|
|
507
|
+
started_elapsed_ms: self.elapsed_ms(started_at)?,
|
|
508
|
+
ended_elapsed_ms: self.elapsed_ms(ended_at)?,
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
fn require_graph_instant(&self, instant: Instant) -> Result<(), CaptureGraphError> {
|
|
513
|
+
if instant < self.clock_started_at {
|
|
514
|
+
Err(CaptureGraphError::BeforeGraphStart)
|
|
515
|
+
} else {
|
|
516
|
+
Ok(())
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
fn elapsed_ms(&self, instant: Instant) -> Result<u64, CaptureGraphError> {
|
|
521
|
+
self.require_graph_instant(instant)?;
|
|
522
|
+
Ok(instant.duration_since(self.clock_started_at).as_millis() as u64)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
fn timestamp(&self, instant: Instant) -> Result<String, CaptureGraphError> {
|
|
526
|
+
let elapsed = self.elapsed_ms(instant)?;
|
|
527
|
+
Ok(format_rfc3339_millis(
|
|
528
|
+
self.epoch_started_at + ChronoDuration::milliseconds(elapsed as i64),
|
|
529
|
+
))
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
fn format_rfc3339_millis(timestamp: DateTime<Utc>) -> String {
|
|
534
|
+
timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[cfg(test)]
|
|
538
|
+
mod tests {
|
|
539
|
+
use super::*;
|
|
540
|
+
|
|
541
|
+
fn graph() -> (CaptureGraph, Instant) {
|
|
542
|
+
let start = Instant::now();
|
|
543
|
+
(
|
|
544
|
+
CaptureGraph::new_at(
|
|
545
|
+
start,
|
|
546
|
+
DateTime::parse_from_rfc3339("2026-07-14T12:00:00Z")
|
|
547
|
+
.unwrap()
|
|
548
|
+
.with_timezone(&Utc),
|
|
549
|
+
),
|
|
550
|
+
start,
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#[test]
|
|
555
|
+
fn refcounts_shared_source_demand() {
|
|
556
|
+
let (mut graph, start) = graph();
|
|
557
|
+
let first = graph
|
|
558
|
+
.start_consumer_at(CaptureConsumer::Rewind, [CaptureSource::Screen], start)
|
|
559
|
+
.unwrap();
|
|
560
|
+
graph
|
|
561
|
+
.start_consumer_at(
|
|
562
|
+
CaptureConsumer::VisualIndex,
|
|
563
|
+
[CaptureSource::Screen, CaptureSource::SystemAudio],
|
|
564
|
+
start + Duration::from_millis(10),
|
|
565
|
+
)
|
|
566
|
+
.unwrap();
|
|
567
|
+
let status = graph.status_at(start + Duration::from_millis(10)).unwrap();
|
|
568
|
+
assert_eq!(status.active_sources[0].consumer_count, 2);
|
|
569
|
+
assert_eq!(status.active_sources[1].consumer_count, 1);
|
|
570
|
+
graph
|
|
571
|
+
.end_consumer_at(&first.id, start + Duration::from_millis(20))
|
|
572
|
+
.unwrap();
|
|
573
|
+
let status = graph.status_at(start + Duration::from_millis(20)).unwrap();
|
|
574
|
+
assert_eq!(
|
|
575
|
+
status.active_sources,
|
|
576
|
+
vec![
|
|
577
|
+
ActiveSourceStatus {
|
|
578
|
+
source: CaptureSource::Screen,
|
|
579
|
+
consumer_count: 1
|
|
580
|
+
},
|
|
581
|
+
ActiveSourceStatus {
|
|
582
|
+
source: CaptureSource::SystemAudio,
|
|
583
|
+
consumer_count: 1
|
|
584
|
+
}
|
|
585
|
+
]
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#[test]
|
|
590
|
+
fn countdown_completion_starts_without_pre_roll() {
|
|
591
|
+
let (mut graph, start) = graph();
|
|
592
|
+
let countdown_completed = start + Duration::from_secs(3);
|
|
593
|
+
let lease = graph
|
|
594
|
+
.start_consumer_at(
|
|
595
|
+
CaptureConsumer::Clip,
|
|
596
|
+
[CaptureSource::Screen],
|
|
597
|
+
countdown_completed,
|
|
598
|
+
)
|
|
599
|
+
.unwrap();
|
|
600
|
+
assert_eq!(lease.interval.started_elapsed_ms, 3_000);
|
|
601
|
+
assert_eq!(lease.retrospective_extension_ms, 0);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#[test]
|
|
605
|
+
fn retrospective_extension_is_explicit_and_bounded() {
|
|
606
|
+
let (mut graph, start) = graph();
|
|
607
|
+
let lease = graph
|
|
608
|
+
.start_consumer_at(
|
|
609
|
+
CaptureConsumer::Rewind,
|
|
610
|
+
[CaptureSource::Screen],
|
|
611
|
+
start + Duration::from_secs(10),
|
|
612
|
+
)
|
|
613
|
+
.unwrap();
|
|
614
|
+
let extended = graph
|
|
615
|
+
.extend_retrospectively(&lease.id, Duration::from_secs(4))
|
|
616
|
+
.unwrap();
|
|
617
|
+
assert_eq!(extended.interval.started_elapsed_ms, 6_000);
|
|
618
|
+
assert_eq!(extended.retrospective_extension_ms, 4_000);
|
|
619
|
+
assert!(matches!(
|
|
620
|
+
graph.extend_retrospectively(
|
|
621
|
+
&lease.id,
|
|
622
|
+
MAX_RETROSPECTIVE_EXTENSION + Duration::from_millis(1)
|
|
623
|
+
),
|
|
624
|
+
Err(CaptureGraphError::RetrospectiveExtensionTooLarge { .. })
|
|
625
|
+
));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
#[test]
|
|
629
|
+
fn absolute_retrospective_start_clamps_only_to_graph_coverage() {
|
|
630
|
+
let (mut graph, start) = graph();
|
|
631
|
+
let lease = graph
|
|
632
|
+
.start_consumer_at(
|
|
633
|
+
CaptureConsumer::Meeting,
|
|
634
|
+
[CaptureSource::Screen],
|
|
635
|
+
start + Duration::from_secs(600),
|
|
636
|
+
)
|
|
637
|
+
.unwrap();
|
|
638
|
+
let extended = graph
|
|
639
|
+
.extend_retrospectively_to_start(&lease.id, start - Duration::from_secs(1))
|
|
640
|
+
.unwrap();
|
|
641
|
+
assert_eq!(extended.interval.started_elapsed_ms, 0);
|
|
642
|
+
assert_eq!(extended.retrospective_extension_ms, 600_000);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#[test]
|
|
646
|
+
fn replacement_lease_changes_rewind_source_demand_with_capture_mode() {
|
|
647
|
+
let (mut graph, start) = graph();
|
|
648
|
+
let visuals = graph
|
|
649
|
+
.start_consumer_at(CaptureConsumer::Rewind, [CaptureSource::Screen], start)
|
|
650
|
+
.unwrap();
|
|
651
|
+
assert_eq!(
|
|
652
|
+
graph.status_at(start).unwrap().active_sources,
|
|
653
|
+
vec![ActiveSourceStatus {
|
|
654
|
+
source: CaptureSource::Screen,
|
|
655
|
+
consumer_count: 1,
|
|
656
|
+
}]
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
graph
|
|
660
|
+
.end_consumer_at(&visuals.id, start + Duration::from_millis(1))
|
|
661
|
+
.unwrap();
|
|
662
|
+
graph
|
|
663
|
+
.start_consumer_at(
|
|
664
|
+
CaptureConsumer::Rewind,
|
|
665
|
+
[
|
|
666
|
+
CaptureSource::Screen,
|
|
667
|
+
CaptureSource::SystemAudio,
|
|
668
|
+
CaptureSource::Microphone,
|
|
669
|
+
],
|
|
670
|
+
start + Duration::from_millis(1),
|
|
671
|
+
)
|
|
672
|
+
.unwrap();
|
|
673
|
+
|
|
674
|
+
assert_eq!(
|
|
675
|
+
graph
|
|
676
|
+
.status_at(start + Duration::from_millis(1))
|
|
677
|
+
.unwrap()
|
|
678
|
+
.active_sources,
|
|
679
|
+
vec![
|
|
680
|
+
ActiveSourceStatus {
|
|
681
|
+
source: CaptureSource::Screen,
|
|
682
|
+
consumer_count: 1,
|
|
683
|
+
},
|
|
684
|
+
ActiveSourceStatus {
|
|
685
|
+
source: CaptureSource::SystemAudio,
|
|
686
|
+
consumer_count: 1,
|
|
687
|
+
},
|
|
688
|
+
ActiveSourceStatus {
|
|
689
|
+
source: CaptureSource::Microphone,
|
|
690
|
+
consumer_count: 1,
|
|
691
|
+
},
|
|
692
|
+
]
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
#[test]
|
|
697
|
+
fn closing_a_lease_returns_a_closed_interval_and_releases_demand() {
|
|
698
|
+
let (mut graph, start) = graph();
|
|
699
|
+
let lease = graph
|
|
700
|
+
.start_consumer_at(CaptureConsumer::Meeting, [CaptureSource::Microphone], start)
|
|
701
|
+
.unwrap();
|
|
702
|
+
let closed = graph
|
|
703
|
+
.end_consumer_at(&lease.id, start + Duration::from_secs(2))
|
|
704
|
+
.unwrap();
|
|
705
|
+
assert_eq!(closed.lease.interval.ended_elapsed_ms, 2_000);
|
|
706
|
+
assert!(graph
|
|
707
|
+
.status_at(start + Duration::from_secs(2))
|
|
708
|
+
.unwrap()
|
|
709
|
+
.active_sources
|
|
710
|
+
.is_empty());
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#[test]
|
|
714
|
+
fn coverage_gaps_preserve_source_reason_and_interval() {
|
|
715
|
+
let (mut graph, start) = graph();
|
|
716
|
+
let gap = graph
|
|
717
|
+
.record_coverage_gap_at(
|
|
718
|
+
CaptureSource::Screen,
|
|
719
|
+
CoverageGapReason::PrivacyExclusion,
|
|
720
|
+
start + Duration::from_secs(1),
|
|
721
|
+
start + Duration::from_secs(2),
|
|
722
|
+
)
|
|
723
|
+
.unwrap();
|
|
724
|
+
assert_eq!(gap.id, "gap-1");
|
|
725
|
+
assert_eq!(gap.source, CaptureSource::Screen);
|
|
726
|
+
assert_eq!(gap.reason, CoverageGapReason::PrivacyExclusion);
|
|
727
|
+
assert_eq!(gap.interval.started_elapsed_ms, 1_000);
|
|
728
|
+
assert_eq!(
|
|
729
|
+
graph
|
|
730
|
+
.status_at(start + Duration::from_secs(2))
|
|
731
|
+
.unwrap()
|
|
732
|
+
.coverage_gaps,
|
|
733
|
+
vec![gap]
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
#[test]
|
|
738
|
+
fn pins_have_stable_ids_and_closed_intervals() {
|
|
739
|
+
let (mut graph, start) = graph();
|
|
740
|
+
let first = graph
|
|
741
|
+
.pin_interval_at(start, start + Duration::from_millis(1), Some("keep".into()))
|
|
742
|
+
.unwrap();
|
|
743
|
+
let second = graph
|
|
744
|
+
.pin_interval_at(
|
|
745
|
+
start + Duration::from_millis(2),
|
|
746
|
+
start + Duration::from_millis(3),
|
|
747
|
+
None,
|
|
748
|
+
)
|
|
749
|
+
.unwrap();
|
|
750
|
+
assert_eq!(first.id, "pin-1");
|
|
751
|
+
assert_eq!(second.id, "pin-2");
|
|
752
|
+
assert_eq!(second.interval.ended_elapsed_ms, 3);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#[test]
|
|
756
|
+
fn closed_lease_intervals_can_be_pinned_and_released_idempotently() {
|
|
757
|
+
let (mut graph, start) = graph();
|
|
758
|
+
let lease = graph
|
|
759
|
+
.start_consumer_at(CaptureConsumer::Clip, [CaptureSource::Screen], start)
|
|
760
|
+
.unwrap();
|
|
761
|
+
let closed = graph
|
|
762
|
+
.end_consumer_at(&lease.id, start + Duration::from_secs(2))
|
|
763
|
+
.unwrap();
|
|
764
|
+
let pin = graph
|
|
765
|
+
.pin_interval(&closed.lease.interval, Some("clip materialization".into()))
|
|
766
|
+
.unwrap();
|
|
767
|
+
|
|
768
|
+
assert_eq!(pin.interval, closed.lease.interval);
|
|
769
|
+
assert!(graph.release_pin(&pin.id));
|
|
770
|
+
assert!(!graph.release_pin(&pin.id));
|
|
771
|
+
assert!(graph
|
|
772
|
+
.status_at(start + Duration::from_secs(2))
|
|
773
|
+
.unwrap()
|
|
774
|
+
.pins
|
|
775
|
+
.is_empty());
|
|
776
|
+
}
|
|
777
|
+
}
|