@agent-native/core 0.114.6 → 0.114.8

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.
Files changed (155) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  11. package/corpus/core/src/mcp-client/index.ts +9 -0
  12. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  13. package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
  14. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
  15. package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
  16. package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
  17. package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
  19. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  20. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  21. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  22. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  23. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  24. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  25. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  26. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  27. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  28. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  29. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  30. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  31. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  32. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  33. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  34. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  35. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  36. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  37. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  38. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  50. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  53. package/corpus/templates/clips/desktop/package.json +1 -0
  54. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  55. package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
  56. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  57. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  58. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  59. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  60. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  61. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  62. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  63. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  64. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/toolkit/CHANGELOG.md +6 -0
  99. package/corpus/toolkit/package.json +1 -1
  100. package/dist/cli/mcp-config-writers.d.ts +2 -2
  101. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  102. package/dist/cli/mcp-config-writers.js +6 -6
  103. package/dist/cli/mcp-config-writers.js.map +1 -1
  104. package/dist/cli/mcp.d.ts +14 -0
  105. package/dist/cli/mcp.d.ts.map +1 -1
  106. package/dist/cli/mcp.js +79 -0
  107. package/dist/cli/mcp.js.map +1 -1
  108. package/dist/cli/skills-content/help.d.ts +1 -1
  109. package/dist/cli/skills-content/help.d.ts.map +1 -1
  110. package/dist/cli/skills-content/help.js +4 -3
  111. package/dist/cli/skills-content/help.js.map +1 -1
  112. package/dist/cli/skills-content/index.d.ts +1 -0
  113. package/dist/cli/skills-content/index.d.ts.map +1 -1
  114. package/dist/cli/skills-content/index.js +1 -0
  115. package/dist/cli/skills-content/index.js.map +1 -1
  116. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  117. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  118. package/dist/cli/skills-content/rewind-skill.js +53 -0
  119. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  120. package/dist/cli/skills.d.ts +6 -0
  121. package/dist/cli/skills.d.ts.map +1 -1
  122. package/dist/cli/skills.js +90 -14
  123. package/dist/cli/skills.js.map +1 -1
  124. package/dist/collab/struct-routes.d.ts +1 -1
  125. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  126. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  127. package/dist/mcp/screen-memory-stdio.js +1014 -91
  128. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  129. package/dist/mcp-client/index.d.ts +1 -1
  130. package/dist/mcp-client/index.d.ts.map +1 -1
  131. package/dist/mcp-client/index.js +1 -1
  132. package/dist/mcp-client/index.js.map +1 -1
  133. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  134. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  135. package/dist/mcp-client/screen-memory-local.js +590 -12
  136. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  137. package/dist/observability/routes.d.ts +1 -1
  138. package/dist/progress/routes.d.ts +1 -1
  139. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  140. package/dist/resources/handlers.d.ts +1 -1
  141. package/dist/server/agent-chat-plugin.d.ts +5 -0
  142. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  143. package/dist/server/agent-chat-plugin.js +10 -9
  144. package/dist/server/agent-chat-plugin.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/cli/mcp-config-writers.ts +6 -4
  147. package/src/cli/mcp.ts +124 -0
  148. package/src/cli/skills-content/help.ts +4 -3
  149. package/src/cli/skills-content/index.ts +1 -0
  150. package/src/cli/skills-content/rewind-skill.ts +52 -0
  151. package/src/cli/skills.ts +115 -13
  152. package/src/mcp/screen-memory-stdio.ts +1360 -104
  153. package/src/mcp-client/index.ts +9 -0
  154. package/src/mcp-client/screen-memory-local.ts +847 -13
  155. package/src/server/agent-chat-plugin.ts +16 -9
@@ -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
+ }