@agent-native/core 0.175.4 → 0.176.0-nightly-20260826203614
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 +1 -1
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +9 -8
- package/corpus/templates/clips/actions/import-loom-recording.ts +1 -1
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +52 -35
- package/corpus/templates/clips/app/components/clips-avatar.tsx +46 -0
- package/corpus/templates/clips/app/components/library/recording-card.tsx +8 -7
- package/corpus/templates/clips/app/components/library/space-card.tsx +8 -5
- package/corpus/templates/clips/app/components/meetings/attendee-stack.tsx +7 -8
- package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +19 -12
- package/corpus/templates/clips/app/components/workspace/members-list.tsx +8 -6
- package/corpus/templates/clips/app/components/workspace/notifications-list.tsx +8 -6
- package/corpus/templates/clips/app/components/workspace/top-creators-table.tsx +8 -6
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +8 -7
- package/corpus/templates/clips/changelog/2026-08-26-detected-calls-no-longer-duplicate-meetings-or-strand-the-prompt.md +6 -0
- package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +39 -22
- package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +1765 -219
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +4 -0
- package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +45 -30
- package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +91 -0
- package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +16 -0
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +44 -22
- package/corpus/templates/content/app/components/ui/avatar.tsx +1 -0
- package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
- package/corpus/templates/mail/actions/get-email.ts +68 -21
- package/corpus/templates/mail/actions/get-thread.ts +92 -51
- package/corpus/templates/mail/app/hooks/use-emails.ts +7 -4
- package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +7 -4
- package/corpus/templates/plan/app/pages/PlansPage.tsx +23 -15
- package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +18 -7
- package/corpus/templates/slides/app/components/ui/avatar.tsx +1 -0
- package/corpus/templates/tasks/DEVELOPING.md +8 -0
- package/corpus/templates/tasks/drizzle.config.ts +3 -1
- package/corpus/templates/tasks/drizzle.local.config.ts +6 -0
- package/corpus/templates/tasks/package.json +2 -1
- package/corpus/templates/tasks/server/db/migrations/meta/0000_snapshot.json +234 -0
- package/corpus/templates/tasks/server/db/migrations/meta/_journal.json +13 -0
- package/corpus/templates/tasks/server/db/migrations.ts +110 -0
- package/corpus/templates/tasks/server/plugins/db.ts +3 -87
- package/dist/client/review/ReviewThreadPanel.js +4 -2
- package/dist/collab/client.js +20 -5
- package/dist/collab/presence.d.ts +3 -1
- package/dist/collab/presence.js +48 -45
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/drizzle-config.d.ts +7 -0
- package/dist/db/drizzle-config.js +35 -9
- package/dist/db/drizzle-migrations.d.ts +28 -0
- package/dist/db/drizzle-migrations.js +81 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrations.d.ts +13 -1
- package/dist/db/migrations.js +60 -31
- package/dist/db/schema.js +4 -1
- package/dist/deploy/build.d.ts +6 -0
- package/dist/deploy/build.js +41 -0
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +9 -9
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.js +3 -2
- package/dist/server/builder-browser.d.ts +0 -4
- package/dist/server/builder-browser.js +23 -8
- package/dist/server/google-oauth.d.ts +18 -0
- package/dist/server/google-oauth.js +3 -3
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/short-lived-token.d.ts +1 -1
- package/dist/server/short-lived-token.js +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/google-profile-image.d.ts +2 -0
- package/dist/shared/google-profile-image.js +24 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/docs/content/creating-templates.mdx +10 -1
- package/docs/content/server-database.mdx +46 -2
- package/docs/content/server-plugins.mdx +39 -3
- package/docs/content/webmcp.mdx +2 -0
- package/package.json +3 -2
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
//! Granola-style adhoc Zoom / Teams detection.
|
|
2
2
|
//!
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
5
|
-
//!
|
|
6
|
-
//!
|
|
7
|
-
//!
|
|
3
|
+
//! Samples every known call app every few seconds and asks one question: is a
|
|
4
|
+
//! call underway? A live audio input stream held by Zoom or Teams answers yes
|
|
5
|
+
//! wherever their window sits, so detection survives joining a call and then
|
|
6
|
+
//! working in another app — the common case that foreground dwell alone missed
|
|
7
|
+
//! entirely. Foreground dwell remains the fallback for machines whose OS cannot
|
|
8
|
+
//! report input state at all. On a yes, creates a meeting row via
|
|
9
|
+
//! `create-meeting` (matched to a calendar event when one lines up) and shows
|
|
10
|
+
//! the same meeting-notification overlay used for calendar reminders.
|
|
8
11
|
//!
|
|
9
12
|
//! Reuses `MeetingsWatcherState` session (server URL + cookie + auth token)
|
|
10
13
|
//! so the popover only needs to push credentials once.
|
|
@@ -25,17 +28,73 @@ use crate::meetings_watcher::{
|
|
|
25
28
|
/// How often to sample the frontmost app.
|
|
26
29
|
const POLL_SECS: u64 = 2;
|
|
27
30
|
|
|
28
|
-
///
|
|
29
|
-
const
|
|
31
|
+
/// A live input stream held this long is a call, wherever its window sits.
|
|
32
|
+
const MIC_DWELL: Duration = Duration::from_secs(5);
|
|
30
33
|
|
|
31
|
-
///
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
/// Foreground-only dwell, used when the OS cannot report input state.
|
|
35
|
+
const FRONT_DWELL: Duration = Duration::from_secs(9);
|
|
36
|
+
|
|
37
|
+
/// A call app that just came forward gets this long to open an input stream
|
|
38
|
+
/// before a silent one counts as "not in a call". Joining muted, and the
|
|
39
|
+
/// seconds between the window appearing and the stream starting, both live
|
|
40
|
+
/// here: treating the first silent read as final is how a real call goes
|
|
41
|
+
/// undetected.
|
|
42
|
+
const JOIN_GRACE: Duration = Duration::from_secs(20);
|
|
43
|
+
|
|
44
|
+
/// A live stream that blips out for less than this is still the same call.
|
|
45
|
+
const MIC_DROP_GRACE: Duration = Duration::from_secs(8);
|
|
46
|
+
|
|
47
|
+
/// How long an unreadable input state keeps vouching for a stream that was live
|
|
48
|
+
/// a moment ago. Unreadable is not silence, so it must never end a call — but
|
|
49
|
+
/// it cannot vouch forever either, or a machine whose CoreAudio stopped
|
|
50
|
+
/// answering would hold a finished call open and never prompt again. Past this
|
|
51
|
+
/// the mic has nothing to say and the foreground fallback decides.
|
|
52
|
+
const MIC_UNREADABLE_GRACE: Duration = Duration::from_secs(45);
|
|
53
|
+
|
|
54
|
+
/// No live stream for this long ends the call, which releases the per-call
|
|
55
|
+
/// suppression so the next call gets its own prompt.
|
|
56
|
+
const CALL_END: Duration = Duration::from_secs(30);
|
|
57
|
+
|
|
58
|
+
/// Backstop for the fallback path, where there is no stream to signal the end
|
|
59
|
+
/// of a call. Short enough that a back-to-back block of calls still prompts.
|
|
60
|
+
const COOLDOWN_SECS: i64 = 8 * 60;
|
|
34
61
|
|
|
35
62
|
/// Soft guard: skip adhoc if a calendar reminder for the same platform fired
|
|
36
63
|
/// this recently.
|
|
37
64
|
const CALENDAR_SOFT_GUARD_SECS: i64 = 3 * 60;
|
|
38
65
|
|
|
66
|
+
/// How long a failed `create-meeting` waits before the same call tries again.
|
|
67
|
+
/// The evidence still says a call is underway, so without a backoff the retry
|
|
68
|
+
/// lands on the next tick and every tick after it.
|
|
69
|
+
const CREATE_RETRY_BACKOFF_SECS: i64 = 60;
|
|
70
|
+
|
|
71
|
+
/// How long an unresolved `create-meeting` attempt keeps forcing a reconcile
|
|
72
|
+
/// before the next attempt for the same call. Bounded on purpose: a doubt
|
|
73
|
+
/// pinned to a call whose end the watcher can no longer observe would
|
|
74
|
+
/// eventually let a *new* call adopt the old call's row. Long enough to cover
|
|
75
|
+
/// several backoff retries of a server outage, short enough that the window in
|
|
76
|
+
/// which that mis-adoption is possible stays narrow.
|
|
77
|
+
const CREATE_DOUBT_TTL_SECS: i64 = 5 * 60;
|
|
78
|
+
|
|
79
|
+
/// Slack on the reconcile window's edges. `scheduledStart` is stamped by this
|
|
80
|
+
/// process but read back through the server, so allow a little clock skew rather
|
|
81
|
+
/// than missing the row we are looking for by one second.
|
|
82
|
+
const RECONCILE_SKEW_SECS: i64 = 10;
|
|
83
|
+
|
|
84
|
+
/// Rows per reconcile page. The agenda view is ascending from the start of the
|
|
85
|
+
/// lookback, so the row a lost response created sits near the front of it; this
|
|
86
|
+
/// only has to exceed the number of meetings that can start inside one
|
|
87
|
+
/// reconcile window. A full page is treated as possibly truncated rather than
|
|
88
|
+
/// assumed complete, so raising this trades a bigger response for fewer
|
|
89
|
+
/// abandoned retries — it is not what makes the lookup correct.
|
|
90
|
+
const RECONCILE_PAGE_LIMIT: usize = 200;
|
|
91
|
+
|
|
92
|
+
/// How many reconcile pages to walk before giving up. The agenda view has no
|
|
93
|
+
/// upper bound, so a user with a long future calendar would otherwise have every
|
|
94
|
+
/// retry page through all of it. Running out of pages is reported as unresolved
|
|
95
|
+
/// rather than as "no such row", so the cap costs a retry, never a duplicate.
|
|
96
|
+
const RECONCILE_MAX_PAGES: usize = 5;
|
|
97
|
+
|
|
39
98
|
const STRONG_VC_BUNDLES: &[(&str, &str, &str)] = &[
|
|
40
99
|
// (bundle_id, platform, display title)
|
|
41
100
|
("us.zoom.xos", "zoom", "Zoom meeting detected"),
|
|
@@ -51,26 +110,380 @@ pub struct AdhocMeetingsWatcherState {
|
|
|
51
110
|
|
|
52
111
|
#[derive(Default)]
|
|
53
112
|
struct AdhocMeetingsWatcherInner {
|
|
54
|
-
/// platform -> unix-seconds
|
|
55
|
-
|
|
56
|
-
///
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
///
|
|
113
|
+
/// platform -> unix-seconds until which an auto-prompt is held back. This
|
|
114
|
+
/// is the backstop for the fallback path, where nothing but the foreground
|
|
115
|
+
/// can say a call is over.
|
|
116
|
+
prompt_cooldown_until: HashMap<String, i64>,
|
|
117
|
+
/// platform -> unix-seconds until which a user dismissal holds. Kept apart
|
|
118
|
+
/// from the prompt backstop because the fallback's coarse "left the window"
|
|
119
|
+
/// signal must not undo a choice the user made on purpose.
|
|
120
|
+
dismissed_until: HashMap<String, i64>,
|
|
121
|
+
/// platform -> what that platform has shown us so far.
|
|
122
|
+
evidence: HashMap<String, CallEvidence>,
|
|
123
|
+
/// Platforms already notified for the current call.
|
|
60
124
|
session_notified: HashMap<String, bool>,
|
|
125
|
+
/// platform -> unix-seconds of the earliest `create-meeting` attempt for
|
|
126
|
+
/// the current call whose outcome could not be read. A lost response is not
|
|
127
|
+
/// a failed write, so while an entry stands the next attempt must look for
|
|
128
|
+
/// the row the last one may already have committed instead of inserting a
|
|
129
|
+
/// second one. Earliest, not latest: any attempt in the run could be the
|
|
130
|
+
/// one that landed, so the reconcile window has to reach back to the first.
|
|
131
|
+
create_in_doubt_since: HashMap<String, i64>,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Why a `create-meeting` attempt failed — specifically, whether it could have
|
|
135
|
+
/// committed. Collapsing these two into one error is what turns a lost response
|
|
136
|
+
/// into a duplicate meeting row: the retry cannot tell "the write never
|
|
137
|
+
/// happened" from "the write happened and the answer went missing".
|
|
138
|
+
#[derive(Debug)]
|
|
139
|
+
enum CreateFailure {
|
|
140
|
+
/// Rejected before any insert could run, so nothing was written.
|
|
141
|
+
NotCommitted(String),
|
|
142
|
+
/// Unreadable outcome: the row may or may not exist.
|
|
143
|
+
Ambiguous(String),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
impl CreateFailure {
|
|
147
|
+
fn message(&self) -> &str {
|
|
148
|
+
match self {
|
|
149
|
+
CreateFailure::NotCommitted(m) | CreateFailure::Ambiguous(m) => m,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fn is_ambiguous(&self) -> bool {
|
|
154
|
+
matches!(self, CreateFailure::Ambiguous(_))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Whether a call is underway on one platform.
|
|
159
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
160
|
+
enum CallState {
|
|
161
|
+
/// Confirmed: prompt for it.
|
|
162
|
+
Live,
|
|
163
|
+
/// Might still become a call — hold the accumulated evidence.
|
|
164
|
+
Pending,
|
|
165
|
+
/// No call, and no reason to keep waiting.
|
|
166
|
+
Idle,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// What one platform has shown us across the current run of ticks.
|
|
170
|
+
#[derive(Debug, Default, Clone, Copy)]
|
|
171
|
+
struct CallEvidence {
|
|
172
|
+
/// When it became frontmost, in the current unbroken foreground run.
|
|
173
|
+
front_since: Option<Instant>,
|
|
174
|
+
/// When its live input stream first appeared in the current call.
|
|
175
|
+
mic_since: Option<Instant>,
|
|
176
|
+
/// The most recent tick that saw a live input stream.
|
|
177
|
+
mic_last_true: Option<Instant>,
|
|
178
|
+
/// Start of the current unbroken run of reads that say there is no stream.
|
|
179
|
+
/// Only `Some(false)` lands here. An unreadable read is not evidence of
|
|
180
|
+
/// silence, and silence is the only thing allowed to end a call.
|
|
181
|
+
mic_silent_since: Option<Instant>,
|
|
182
|
+
/// Start of the current unbroken run of unreadable (`None`) reads.
|
|
183
|
+
mic_unreadable_since: Option<Instant>,
|
|
184
|
+
/// Set at a call boundary, reported once by `take_call_ended`.
|
|
185
|
+
call_ended: bool,
|
|
186
|
+
/// Set once when a readable stream stops being able to speak for the call,
|
|
187
|
+
/// reported by `take_mic_abstained`.
|
|
188
|
+
mic_abstained: bool,
|
|
189
|
+
/// Whether the stream was the authority as of the previous tick, so the
|
|
190
|
+
/// moment it stops being one can be caught as an edge rather than a state.
|
|
191
|
+
mic_was_authoritative: bool,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
impl CallEvidence {
|
|
195
|
+
fn observe(&mut self, now: Instant, mic: Option<bool>, frontmost: bool) {
|
|
196
|
+
if frontmost {
|
|
197
|
+
self.front_since.get_or_insert(now);
|
|
198
|
+
} else {
|
|
199
|
+
self.front_since = None;
|
|
200
|
+
}
|
|
201
|
+
match mic {
|
|
202
|
+
Some(true) => {
|
|
203
|
+
// A stream returning inside `CALL_END` is the same call coming
|
|
204
|
+
// back, not the next one. `MIC_DROP_GRACE` having cleared
|
|
205
|
+
// `mic_since` only restarts the dwell; treating that as a new
|
|
206
|
+
// call would prompt a second time for one ordinary blip.
|
|
207
|
+
self.mic_last_true = Some(now);
|
|
208
|
+
self.mic_since.get_or_insert(now);
|
|
209
|
+
self.mic_silent_since = None;
|
|
210
|
+
self.mic_unreadable_since = None;
|
|
211
|
+
}
|
|
212
|
+
Some(false) => {
|
|
213
|
+
self.mic_unreadable_since = None;
|
|
214
|
+
let silent_since = *self.mic_silent_since.get_or_insert(now);
|
|
215
|
+
let silent_for = now.saturating_duration_since(silent_since);
|
|
216
|
+
if silent_for >= MIC_DROP_GRACE {
|
|
217
|
+
self.mic_since = None;
|
|
218
|
+
}
|
|
219
|
+
if self.mic_last_true.is_some() && silent_for >= CALL_END {
|
|
220
|
+
self.end_call(now);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// The OS could not answer. That is not a "no": the stream timers
|
|
224
|
+
// stay where they are so an unreadable stretch neither starts a
|
|
225
|
+
// call nor ends one. The silence run does break, though — time we
|
|
226
|
+
// could not read is not time we watched go quiet, and counting it
|
|
227
|
+
// would let one `Some(false)` afterwards satisfy `CALL_END` on its
|
|
228
|
+
// own and end a call that may never have stopped.
|
|
229
|
+
None => {
|
|
230
|
+
self.mic_unreadable_since.get_or_insert(now);
|
|
231
|
+
self.mic_silent_since = None;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let authoritative = self.mic_is_authoritative(now);
|
|
235
|
+
if self.mic_was_authoritative && !authoritative {
|
|
236
|
+
self.mic_abstained = true;
|
|
237
|
+
}
|
|
238
|
+
self.mic_was_authoritative = authoritative;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Close the current call: report the boundary once, and drop the evidence
|
|
242
|
+
/// that only describes the call that just ended.
|
|
243
|
+
fn end_call(&mut self, now: Instant) {
|
|
244
|
+
self.call_ended = true;
|
|
245
|
+
self.mic_since = None;
|
|
246
|
+
self.mic_last_true = None;
|
|
247
|
+
self.mic_silent_since = None;
|
|
248
|
+
// A call that ended on the stream's own verdict has not lost its
|
|
249
|
+
// authority — it exercised it. Only an unreadable stretch abstains.
|
|
250
|
+
self.mic_was_authoritative = false;
|
|
251
|
+
// The next call may start muted in this same still-frontmost window.
|
|
252
|
+
// An untouched foreground age would be past `JOIN_GRACE` already and
|
|
253
|
+
// read as a window merely parked open, so re-arm the grace here.
|
|
254
|
+
if self.front_since.is_some() {
|
|
255
|
+
self.front_since = Some(now);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Whether a stream has spoken for this platform at all in the current
|
|
260
|
+
/// call. While one has, the window is not evidence about anything.
|
|
261
|
+
fn mic_ever_spoke(&self) -> bool {
|
|
262
|
+
self.mic_last_true.is_some() || self.mic_since.is_some()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Whether the stream is still the authority on when this call ends. It is,
|
|
266
|
+
/// right up until an unreadable run outlives its grace — at which point we
|
|
267
|
+
/// genuinely do not know, and the coarse foreground signal is all that is
|
|
268
|
+
/// left. Holding "authoritative" through an unbounded unreadable stretch is
|
|
269
|
+
/// what would strand a platform: the flag that suppression hangs on has no
|
|
270
|
+
/// clock of its own, so nothing would ever release it.
|
|
271
|
+
fn mic_is_authoritative(&self, now: Instant) -> bool {
|
|
272
|
+
self.mic_ever_spoke()
|
|
273
|
+
&& !self
|
|
274
|
+
.mic_unreadable_since
|
|
275
|
+
.is_some_and(|since| now.saturating_duration_since(since) >= MIC_UNREADABLE_GRACE)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fn mic_live(&self, now: Instant, mic: Option<bool>) -> bool {
|
|
279
|
+
if mic == Some(true) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
// Only an established stream earns a grace period. A stream seen on one
|
|
283
|
+
// tick and gone on the next is a device probe or a notification sound;
|
|
284
|
+
// bridging that gap would let `mic_since` age past `MIC_DWELL` while
|
|
285
|
+
// nothing was running and confirm a call that never happened.
|
|
286
|
+
let (Some(since), Some(last)) = (self.mic_since, self.mic_last_true) else {
|
|
287
|
+
return false;
|
|
288
|
+
};
|
|
289
|
+
if last.saturating_duration_since(since) < MIC_DWELL {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
if self
|
|
293
|
+
.mic_silent_since
|
|
294
|
+
.is_some_and(|since| now.saturating_duration_since(since) >= MIC_DROP_GRACE)
|
|
295
|
+
{
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
!self
|
|
299
|
+
.mic_unreadable_since
|
|
300
|
+
.is_some_and(|since| now.saturating_duration_since(since) >= MIC_UNREADABLE_GRACE)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn classify(&self, now: Instant, mic: Option<bool>, frontmost: bool) -> CallState {
|
|
304
|
+
if self.mic_live(now, mic) {
|
|
305
|
+
let since = self.mic_since.unwrap_or(now);
|
|
306
|
+
return if now.saturating_duration_since(since) >= MIC_DWELL {
|
|
307
|
+
CallState::Live
|
|
308
|
+
} else {
|
|
309
|
+
CallState::Pending
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
let Some(front_since) = self.front_since.filter(|_| frontmost) else {
|
|
313
|
+
return CallState::Idle;
|
|
314
|
+
};
|
|
315
|
+
let front_for = now.saturating_duration_since(front_since);
|
|
316
|
+
match mic {
|
|
317
|
+
// The OS declined to answer — `kAudioProcessPropertyIsRunningInput`
|
|
318
|
+
// is macOS 14+ — so foreground dwell is the only evidence there is.
|
|
319
|
+
// Requiring a stream here would leave older machines detecting
|
|
320
|
+
// nothing at all.
|
|
321
|
+
None => {
|
|
322
|
+
if front_for >= FRONT_DWELL {
|
|
323
|
+
CallState::Live
|
|
324
|
+
} else {
|
|
325
|
+
CallState::Pending
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// A definite no. Wait out the join grace, then stop: past it, a
|
|
329
|
+
// silent Zoom window is Zoom parked open, which is the noise that
|
|
330
|
+
// got prompting muted in the first place.
|
|
331
|
+
_ => {
|
|
332
|
+
if front_for < JOIN_GRACE {
|
|
333
|
+
CallState::Pending
|
|
334
|
+
} else {
|
|
335
|
+
CallState::Idle
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/// True once per call boundary: either a confirmed run of silence long
|
|
342
|
+
/// enough to be the end of the call, or a fresh stream that can only be the
|
|
343
|
+
/// next one. An unreadable input state is neither, so it never fires here —
|
|
344
|
+
/// releasing the per-call suppression on a state we could not read is how
|
|
345
|
+
/// the same call gets prompted twice.
|
|
346
|
+
fn take_call_ended(&mut self) -> bool {
|
|
347
|
+
std::mem::take(&mut self.call_ended)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// True once, at the moment a readable stream becomes unreadable for longer
|
|
351
|
+
/// than its grace. Not a call end — we do not know that — but a change of
|
|
352
|
+
/// regime, after which only the foreground has anything to say.
|
|
353
|
+
fn take_mic_abstained(&mut self) -> bool {
|
|
354
|
+
std::mem::take(&mut self.mic_abstained)
|
|
355
|
+
}
|
|
61
356
|
}
|
|
62
357
|
|
|
63
358
|
impl AdhocMeetingsWatcherInner {
|
|
64
|
-
fn
|
|
359
|
+
fn note_prompted(&mut self, platform: &str, now_ts: i64) {
|
|
360
|
+
self.session_notified.insert(platform.to_string(), true);
|
|
361
|
+
self.prompt_cooldown_until
|
|
362
|
+
.insert(platform.to_string(), now_ts + COOLDOWN_SECS);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
fn note_dismissed(&mut self, platform: &str, now_ts: i64) {
|
|
65
366
|
self.session_notified.insert(platform.to_string(), true);
|
|
66
|
-
self.
|
|
367
|
+
self.dismissed_until
|
|
67
368
|
.insert(platform.to_string(), now_ts + COOLDOWN_SECS);
|
|
68
|
-
|
|
69
|
-
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// The current call is confirmed over: prompt again for the next one, even
|
|
372
|
+
/// if the user dismissed this one.
|
|
373
|
+
fn end_call_session(&mut self, platform: &str) {
|
|
374
|
+
self.session_notified.remove(platform);
|
|
375
|
+
self.prompt_cooldown_until.remove(platform);
|
|
376
|
+
self.dismissed_until.remove(platform);
|
|
377
|
+
// The doubt belonged to the call that just ended. Carrying it into the
|
|
378
|
+
// next call would make that call adopt the previous call's row.
|
|
379
|
+
self.create_in_doubt_since.remove(platform);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// A create failed. Let the same call try again, but not on the next tick:
|
|
383
|
+
/// the evidence is untouched, so it reconfirms immediately.
|
|
384
|
+
///
|
|
385
|
+
/// `ambiguous` is the part that matters. Backoff alone cannot make a
|
|
386
|
+
/// non-idempotent create safe — it only decides *when* the duplicate is
|
|
387
|
+
/// inserted — so an attempt that may have committed has to be remembered
|
|
388
|
+
/// and reconciled before the next one runs.
|
|
389
|
+
fn note_create_failed(&mut self, platform: &str, now_ts: i64, ambiguous: bool) {
|
|
390
|
+
self.session_notified.remove(platform);
|
|
391
|
+
self.prompt_cooldown_until
|
|
392
|
+
.insert(platform.to_string(), now_ts + CREATE_RETRY_BACKOFF_SECS);
|
|
393
|
+
if ambiguous {
|
|
394
|
+
self.create_in_doubt_since
|
|
395
|
+
.entry(platform.to_string())
|
|
396
|
+
.or_insert(now_ts);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/// A create resolved: there is a row, and we know its id.
|
|
401
|
+
fn note_create_resolved(&mut self, platform: &str) {
|
|
402
|
+
self.create_in_doubt_since.remove(platform);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/// Hold back the platforms that lost this poll's tie-break, now that the
|
|
406
|
+
/// selected platform has actually produced a meeting row.
|
|
407
|
+
///
|
|
408
|
+
/// Deliberately not a prompt cooldown: these platforms were never prompted
|
|
409
|
+
/// for. Giving them one would mute a live call for eight minutes on the
|
|
410
|
+
/// strength of a different call winning a coin toss, which is the same
|
|
411
|
+
/// defect one layer down. The bare flag is released by the platform's own
|
|
412
|
+
/// call end — or, on the fallback path, by the boundaries that keep it from
|
|
413
|
+
/// stranding — and either way what follows is the prompt it never got.
|
|
414
|
+
fn defer_secondary_candidates(&mut self, platforms: &[&str]) {
|
|
415
|
+
for platform in platforms {
|
|
416
|
+
self.session_notified.insert((*platform).to_string(), true);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// The attempt timestamp a retry for this platform must reconcile against,
|
|
421
|
+
/// or `None` when no attempt is outstanding.
|
|
422
|
+
fn create_doubt_since(&self, platform: &str) -> Option<i64> {
|
|
423
|
+
self.create_in_doubt_since.get(platform).copied()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/// Stop tracking calls entirely: the feature is off, or a meeting is
|
|
427
|
+
/// already being transcribed.
|
|
428
|
+
///
|
|
429
|
+
/// The evidence that would have released `session_notified` is going away,
|
|
430
|
+
/// so that flag cannot stay as it is — nothing would ever clear it. It also
|
|
431
|
+
/// cannot simply be dropped: transcription can stop while the call and its
|
|
432
|
+
/// stream keep running, and a blank slate re-detects that call within one
|
|
433
|
+
/// `MIC_DWELL` and prompts for it again. So it becomes the cooldown, which
|
|
434
|
+
/// is bounded: a call that outlives transcription stays suppressed, and a
|
|
435
|
+
/// platform is never suppressed for longer than the cooldown. A dismissal
|
|
436
|
+
/// already has its own clock and is left alone.
|
|
437
|
+
fn clear_tracking(&mut self, now_ts: i64) {
|
|
438
|
+
self.evidence.clear();
|
|
439
|
+
// Whatever call the doubt belonged to, we are no longer tracking it, so
|
|
440
|
+
// there is nothing left to tie a found row to.
|
|
441
|
+
self.create_in_doubt_since.clear();
|
|
442
|
+
let notified: Vec<String> = self.session_notified.drain().map(|(p, _)| p).collect();
|
|
443
|
+
for platform in notified {
|
|
444
|
+
let until = now_ts + COOLDOWN_SECS;
|
|
445
|
+
self.prompt_cooldown_until
|
|
446
|
+
.entry(platform)
|
|
447
|
+
.and_modify(|existing| *existing = (*existing).max(until))
|
|
448
|
+
.or_insert(until);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fn retain_live_cooldowns(&mut self, now_ts: i64) {
|
|
453
|
+
self.prompt_cooldown_until
|
|
454
|
+
.retain(|_, until| *until > now_ts);
|
|
455
|
+
self.dismissed_until.retain(|_, until| *until > now_ts);
|
|
456
|
+
|
|
457
|
+
// An attempt we never managed to settle must not quietly become a fresh
|
|
458
|
+
// insert. Dropping the marker alone would do exactly that: the next tick
|
|
459
|
+
// would find no doubt, skip the reconcile, and bare-insert a row that may
|
|
460
|
+
// already exist. So expiry hands the platform a normal cooldown instead
|
|
461
|
+
// — the write we cannot see stays un-duplicated, and the platform is
|
|
462
|
+
// picked up again after it, by which point a new attempt is far more
|
|
463
|
+
// likely to be a genuinely new call than a retry of this one.
|
|
464
|
+
let expired: Vec<String> = self
|
|
465
|
+
.create_in_doubt_since
|
|
466
|
+
.iter()
|
|
467
|
+
.filter(|(_, since)| *since + CREATE_DOUBT_TTL_SECS <= now_ts)
|
|
468
|
+
.map(|(platform, _)| platform.clone())
|
|
469
|
+
.collect();
|
|
470
|
+
for platform in expired {
|
|
471
|
+
self.create_in_doubt_since.remove(&platform);
|
|
472
|
+
let until = now_ts + COOLDOWN_SECS;
|
|
473
|
+
self.prompt_cooldown_until
|
|
474
|
+
.entry(platform)
|
|
475
|
+
.and_modify(|existing| *existing = (*existing).max(until))
|
|
476
|
+
.or_insert(until);
|
|
477
|
+
}
|
|
70
478
|
}
|
|
71
479
|
|
|
72
480
|
fn is_suppressed(&self, platform: &str, now_ts: i64) -> bool {
|
|
73
|
-
self.
|
|
481
|
+
self.prompt_cooldown_until
|
|
482
|
+
.get(platform)
|
|
483
|
+
.copied()
|
|
484
|
+
.unwrap_or(0)
|
|
485
|
+
> now_ts
|
|
486
|
+
|| self.dismissed_until.get(platform).copied().unwrap_or(0) > now_ts
|
|
74
487
|
|| self
|
|
75
488
|
.session_notified
|
|
76
489
|
.get(platform)
|
|
@@ -88,7 +501,7 @@ pub fn refresh_dismissal_suppression(app: &AppHandle, platform: &str) -> Result<
|
|
|
88
501
|
.try_state::<AdhocMeetingsWatcherState>()
|
|
89
502
|
.ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
|
|
90
503
|
let mut g = state.inner.lock().map_err(|e| e.to_string())?;
|
|
91
|
-
g.
|
|
504
|
+
g.note_dismissed(&platform, chrono::Utc::now().timestamp());
|
|
92
505
|
Ok(())
|
|
93
506
|
}
|
|
94
507
|
|
|
@@ -133,6 +546,17 @@ fn match_vc_bundle(bundle: &str) -> Option<(&'static str, &'static str)> {
|
|
|
133
546
|
.map(|(_, platform, title)| (*platform, *title))
|
|
134
547
|
}
|
|
135
548
|
|
|
549
|
+
/// One entry per platform, with the title its notification falls back to.
|
|
550
|
+
fn platform_titles() -> Vec<(&'static str, &'static str)> {
|
|
551
|
+
let mut out: Vec<(&'static str, &'static str)> = Vec::new();
|
|
552
|
+
for (_, platform, title) in STRONG_VC_BUNDLES {
|
|
553
|
+
if !out.iter().any(|(known, _)| known == platform) {
|
|
554
|
+
out.push((*platform, *title));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
out
|
|
558
|
+
}
|
|
559
|
+
|
|
136
560
|
/// Bundle ids belonging to one platform, lowercased for CoreAudio comparison.
|
|
137
561
|
///
|
|
138
562
|
/// Scoped to the frontmost platform rather than every call app: Teams sitting
|
|
@@ -145,16 +569,6 @@ fn bundles_for_platform(platform: &str) -> Vec<String> {
|
|
|
145
569
|
.collect()
|
|
146
570
|
}
|
|
147
571
|
|
|
148
|
-
/// Whether a live-input reading should veto a dwell-confirmed detection.
|
|
149
|
-
///
|
|
150
|
-
/// Only an explicit `Some(false)` blocks. `None` means the OS declined to
|
|
151
|
-
/// answer — `kAudioProcessPropertyIsRunningInput` is macOS 14+ — and treating
|
|
152
|
-
/// that as "no call" would leave every older machine detecting nothing at all,
|
|
153
|
-
/// which is the failure mode this whole branch exists to undo.
|
|
154
|
-
fn mic_vetoes_detection(mic_running: Option<bool>) -> bool {
|
|
155
|
-
mic_running == Some(false)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
572
|
/// What a dwell-confirmed detection is allowed to do under the current mode.
|
|
159
573
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
160
574
|
struct AdhocNotificationPlan {
|
|
@@ -180,7 +594,7 @@ fn adhoc_notification_plan(config: &crate::config::FeatureConfig) -> AdhocNotifi
|
|
|
180
594
|
async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), String> {
|
|
181
595
|
let config = feature_config(app);
|
|
182
596
|
if !config.meetings_enabled {
|
|
183
|
-
|
|
597
|
+
reset_evidence(app);
|
|
184
598
|
return Ok(());
|
|
185
599
|
}
|
|
186
600
|
|
|
@@ -202,45 +616,101 @@ async fn tick_macos(
|
|
|
202
616
|
client: &reqwest::Client,
|
|
203
617
|
config: &crate::config::FeatureConfig,
|
|
204
618
|
) -> Result<(), String> {
|
|
205
|
-
let front = crate::util::frontmost_bundle_id();
|
|
206
|
-
let matched = front.as_deref().and_then(match_vc_bundle);
|
|
207
|
-
|
|
208
|
-
let Some((platform, fallback_title)) = matched else {
|
|
209
|
-
// VC app left front — clear session dedupe so a later re-focus can
|
|
210
|
-
// fire again (cooldown still applies).
|
|
211
|
-
clear_session_if_left(app, None);
|
|
212
|
-
reset_dwell(app);
|
|
213
|
-
return Ok(());
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
clear_session_if_left(app, Some(platform));
|
|
217
|
-
|
|
218
619
|
// Skip while already transcribing a meeting.
|
|
219
620
|
if crate::util::is_meeting_active(app) {
|
|
220
|
-
|
|
621
|
+
reset_evidence(app);
|
|
221
622
|
return Ok(());
|
|
222
623
|
}
|
|
223
624
|
|
|
224
625
|
if config.meeting_transcription_mode == MeetingTranscriptionMode::Manual
|
|
225
626
|
&& !config.show_meeting_widget_enabled
|
|
226
627
|
{
|
|
227
|
-
|
|
628
|
+
reset_evidence(app);
|
|
228
629
|
return Ok(());
|
|
229
630
|
}
|
|
230
631
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
632
|
+
let front = crate::util::frontmost_bundle_id();
|
|
633
|
+
let front_platform = front.as_deref().and_then(match_vc_bundle).map(|(p, _)| p);
|
|
634
|
+
let now = Instant::now();
|
|
635
|
+
let now_ts = chrono::Utc::now().timestamp();
|
|
636
|
+
|
|
637
|
+
// Read every platform, not just the frontmost one. A call holds its input
|
|
638
|
+
// stream open while you take notes elsewhere, read Slack, or share a doc,
|
|
639
|
+
// so that stream is the evidence that survives leaving the meeting window.
|
|
640
|
+
let mut confirmed: Option<(&'static str, &'static str)> = None;
|
|
641
|
+
let mut deferred: Vec<&'static str> = Vec::new();
|
|
642
|
+
for (platform, fallback) in platform_titles() {
|
|
643
|
+
let mic = crate::call_activity::call_app_uses_microphone(&bundles_for_platform(platform));
|
|
644
|
+
let frontmost = front_platform == Some(platform);
|
|
645
|
+
|
|
646
|
+
let state = app
|
|
647
|
+
.try_state::<AdhocMeetingsWatcherState>()
|
|
648
|
+
.ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
|
|
649
|
+
let mut g = state.inner.lock().map_err(|e| e.to_string())?;
|
|
650
|
+
g.retain_live_cooldowns(now_ts);
|
|
651
|
+
|
|
652
|
+
let (call_state, call_ended, mic_abstained, mic_live, mic_authoritative) = {
|
|
653
|
+
let evidence = g.evidence.entry(platform.to_string()).or_default();
|
|
654
|
+
evidence.observe(now, mic, frontmost);
|
|
655
|
+
(
|
|
656
|
+
evidence.classify(now, mic, frontmost),
|
|
657
|
+
evidence.take_call_ended(),
|
|
658
|
+
evidence.take_mic_abstained(),
|
|
659
|
+
evidence.mic_live(now, mic),
|
|
660
|
+
evidence.mic_is_authoritative(now),
|
|
661
|
+
)
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// A finished call releases every suppression, so the next call in a
|
|
665
|
+
// back-to-back block gets its own prompt instead of inheriting one.
|
|
666
|
+
// This is the stream's verdict, and the only thing that clears the
|
|
667
|
+
// cooldown: nothing below can prove a call ended, only that we stopped
|
|
668
|
+
// being able to see it.
|
|
669
|
+
if call_ended {
|
|
670
|
+
g.end_call_session(platform);
|
|
671
|
+
}
|
|
672
|
+
// `session_notified` is the flag with no clock of its own, so it needs
|
|
673
|
+
// releasing on evidence weaker than a call end or it strands the
|
|
674
|
+
// platform. Both releases below leave the prompt cooldown standing,
|
|
675
|
+
// which is what bounds a duplicate prompt in the meantime.
|
|
676
|
+
//
|
|
677
|
+
// Losing the window is not proof a call ended — on the fallback path,
|
|
678
|
+
// switching to Slack mid-call looks exactly the same — so it may only
|
|
679
|
+
// release the flag while the stream is not speaking for the call.
|
|
680
|
+
if !mic_live && !frontmost && !mic_authoritative {
|
|
681
|
+
g.session_notified.remove(platform);
|
|
682
|
+
}
|
|
683
|
+
// The stream just stopped being readable. The window may never change
|
|
684
|
+
// again, so this edge is the only chance to hand the session to the
|
|
685
|
+
// fallback; without it a later call in a still-frontmost window has no
|
|
686
|
+
// boundary left that could ever release the flag.
|
|
687
|
+
if mic_abstained {
|
|
688
|
+
g.session_notified.remove(platform);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if call_state == CallState::Live && !g.is_suppressed(platform, now_ts) {
|
|
692
|
+
if confirmed.is_none() {
|
|
693
|
+
confirmed = Some((platform, fallback));
|
|
694
|
+
} else {
|
|
695
|
+
// One poll prompts for one call. A second platform holding a
|
|
696
|
+
// stream at the same moment would otherwise be untouched here
|
|
697
|
+
// and confirm on the very next tick, so a single moment of
|
|
698
|
+
// overlap becomes a second meeting two seconds later.
|
|
699
|
+
//
|
|
700
|
+
// Only note it, though — suppressing it here would spend the
|
|
701
|
+
// selected platform's failure on it. If the selected flow then
|
|
702
|
+
// errors or is skipped by the calendar guard, the platform that
|
|
703
|
+
// lost the tie is the only live call left, and marking it
|
|
704
|
+
// already would have hidden it for the rest of its run.
|
|
705
|
+
deferred.push(platform);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
242
708
|
}
|
|
243
709
|
|
|
710
|
+
let Some((platform, fallback_title)) = confirmed else {
|
|
711
|
+
return Ok(());
|
|
712
|
+
};
|
|
713
|
+
|
|
244
714
|
// Soft guard against double-prompting after a calendar reminder.
|
|
245
715
|
if let Some(state) = app.try_state::<MeetingsWatcherState>() {
|
|
246
716
|
if state.recent_calendar_notify(platform, CALENDAR_SOFT_GUARD_SECS) {
|
|
@@ -248,82 +718,93 @@ async fn tick_macos(
|
|
|
248
718
|
"[clips-tray] adhoc skip: recent calendar notify for {}",
|
|
249
719
|
platform
|
|
250
720
|
);
|
|
251
|
-
|
|
721
|
+
// The calendar reminder owns this call, so record it as handled
|
|
722
|
+
// rather than just skipping the tick. Leaving it unmarked keeps the
|
|
723
|
+
// call eligible on every later tick, and the moment the guard
|
|
724
|
+
// expires the same call prompts again — the double-prompt this
|
|
725
|
+
// guard exists to prevent, three minutes late.
|
|
726
|
+
//
|
|
727
|
+
// Recorded exactly like a prompt, cooldown included. The flag alone
|
|
728
|
+
// is released by the window changing, which on a machine with no
|
|
729
|
+
// readable stream is just the user checking Slack — so without the
|
|
730
|
+
// cooldown the guard lasts only until they come back.
|
|
731
|
+
if let Some(adhoc) = app.try_state::<AdhocMeetingsWatcherState>() {
|
|
732
|
+
if let Ok(mut g) = adhoc.inner.lock() {
|
|
733
|
+
g.note_prompted(platform, now_ts);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
252
736
|
return Ok(());
|
|
253
737
|
}
|
|
254
738
|
}
|
|
255
739
|
|
|
256
|
-
let
|
|
257
|
-
let should_fire = {
|
|
740
|
+
let reconcile_since = {
|
|
258
741
|
let state = app
|
|
259
742
|
.try_state::<AdhocMeetingsWatcherState>()
|
|
260
743
|
.ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
|
|
261
744
|
let mut g = state.inner.lock().map_err(|e| e.to_string())?;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
g.cooldown_until.retain(|_, until| *until > now_ts);
|
|
265
|
-
|
|
266
|
-
if g.is_suppressed(platform, now_ts) {
|
|
267
|
-
return Ok(());
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
match (g.dwell_platform.as_deref(), g.dwell_since) {
|
|
271
|
-
(Some(p), Some(since)) if p == platform => {
|
|
272
|
-
if since.elapsed() >= Duration::from_secs(DWELL_SECS) {
|
|
273
|
-
g.session_notified.insert(platform.to_string(), true);
|
|
274
|
-
g.cooldown_until
|
|
275
|
-
.insert(platform.to_string(), now_ts + COOLDOWN_SECS);
|
|
276
|
-
g.dwell_platform = None;
|
|
277
|
-
g.dwell_since = None;
|
|
278
|
-
true
|
|
279
|
-
} else {
|
|
280
|
-
false
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
_ => {
|
|
284
|
-
g.dwell_platform = Some(platform.to_string());
|
|
285
|
-
g.dwell_since = Some(Instant::now());
|
|
286
|
-
false
|
|
287
|
-
}
|
|
288
|
-
}
|
|
745
|
+
g.note_prompted(platform, now_ts);
|
|
746
|
+
g.create_doubt_since(platform)
|
|
289
747
|
};
|
|
290
748
|
|
|
291
|
-
if !should_fire {
|
|
292
|
-
return Ok(());
|
|
293
|
-
}
|
|
294
|
-
|
|
295
749
|
dlog!(
|
|
296
|
-
"[clips-tray] adhoc
|
|
750
|
+
"[clips-tray] adhoc call confirmed for {} — creating meeting",
|
|
297
751
|
platform
|
|
298
752
|
);
|
|
299
753
|
|
|
300
|
-
let meeting = match create_adhoc_meeting(app, client, platform).await {
|
|
754
|
+
let meeting = match create_adhoc_meeting(app, client, platform, reconcile_since).await {
|
|
301
755
|
Ok(meeting) => meeting,
|
|
302
|
-
Err(
|
|
303
|
-
//
|
|
756
|
+
Err(failure) => {
|
|
757
|
+
// Retry, but not on the very next tick. The evidence is untouched,
|
|
758
|
+
// so the call reconfirms immediately and would re-submit every two
|
|
759
|
+
// seconds for as long as the call runs. Backoff only spaces the
|
|
760
|
+
// attempts out, though — an attempt whose outcome we could not read
|
|
761
|
+
// is recorded as such so the next one reconciles instead of
|
|
762
|
+
// inserting a second row for the same call.
|
|
304
763
|
if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
|
|
305
764
|
if let Ok(mut g) = state.inner.lock() {
|
|
306
|
-
g.
|
|
307
|
-
g.cooldown_until.remove(platform);
|
|
765
|
+
g.note_create_failed(platform, now_ts, failure.is_ambiguous());
|
|
308
766
|
}
|
|
309
767
|
}
|
|
310
|
-
return Err(
|
|
768
|
+
return Err(failure.message().to_string());
|
|
311
769
|
}
|
|
312
770
|
};
|
|
313
771
|
|
|
772
|
+
// The selected flow produced a row, so the tie-break above is now settled
|
|
773
|
+
// and the platforms that lost it can be held back. Their own call end
|
|
774
|
+
// releases them again.
|
|
775
|
+
if !deferred.is_empty() {
|
|
776
|
+
if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
|
|
777
|
+
if let Ok(mut g) = state.inner.lock() {
|
|
778
|
+
g.defer_secondary_candidates(&deferred);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
{
|
|
784
|
+
let state = app
|
|
785
|
+
.try_state::<AdhocMeetingsWatcherState>()
|
|
786
|
+
.ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
|
|
787
|
+
let mut g = state.inner.lock().map_err(|e| e.to_string())?;
|
|
788
|
+
g.note_create_resolved(platform);
|
|
789
|
+
}
|
|
790
|
+
|
|
314
791
|
let AdhocNotificationPlan {
|
|
315
792
|
show_widget,
|
|
316
793
|
auto_start,
|
|
317
794
|
} = adhoc_notification_plan(config);
|
|
318
795
|
|
|
796
|
+
// Awaited, not spawned. `meetings:hide-notification` is dropped by the
|
|
797
|
+
// overlay unless it already holds the matching payload, so auto-start
|
|
798
|
+
// emitting first would race the card into existence *after* the only event
|
|
799
|
+
// that clears it — leaving a "Take notes?" prompt stuck over a meeting that
|
|
800
|
+
// is already recording. Installing the payload before startup is announced
|
|
801
|
+
// makes that ordering impossible rather than unlikely.
|
|
319
802
|
if show_widget {
|
|
320
|
-
let
|
|
321
|
-
let id_clone = meeting.id.clone();
|
|
322
|
-
let title_clone = meeting
|
|
803
|
+
let title = meeting
|
|
323
804
|
.title
|
|
324
805
|
.clone()
|
|
325
806
|
.unwrap_or_else(|| fallback_title.to_string());
|
|
326
|
-
let
|
|
807
|
+
let notify_platform = meeting
|
|
327
808
|
.platform
|
|
328
809
|
.clone()
|
|
329
810
|
.unwrap_or_else(|| platform.to_string());
|
|
@@ -331,24 +812,26 @@ async fn tick_macos(
|
|
|
331
812
|
.scheduled_start
|
|
332
813
|
.clone()
|
|
333
814
|
.or_else(|| Some(chrono::Utc::now().to_rfc3339()));
|
|
334
|
-
let
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
815
|
+
if let Err(err) = crate::notifications::notify_meeting_starting(
|
|
816
|
+
app.clone(),
|
|
817
|
+
meeting.id.clone(),
|
|
818
|
+
title,
|
|
819
|
+
0,
|
|
820
|
+
meeting.join_url.clone(),
|
|
821
|
+
scheduled_start,
|
|
822
|
+
meeting.scheduled_end.clone(),
|
|
823
|
+
Some(notify_platform),
|
|
824
|
+
Some(auto_start),
|
|
825
|
+
meeting.source.clone().or_else(|| Some("adhoc".to_string())),
|
|
826
|
+
)
|
|
827
|
+
.await
|
|
828
|
+
{
|
|
829
|
+
dlog!(
|
|
830
|
+
"[clips-tray] adhoc notification failed for {}: {}",
|
|
831
|
+
meeting.id,
|
|
832
|
+
err
|
|
833
|
+
);
|
|
834
|
+
}
|
|
352
835
|
}
|
|
353
836
|
|
|
354
837
|
if auto_start {
|
|
@@ -361,6 +844,12 @@ async fn tick_macos(
|
|
|
361
844
|
"scheduledStart": meeting.scheduled_start,
|
|
362
845
|
}),
|
|
363
846
|
);
|
|
847
|
+
// The stored payload is deliberately left alone here. Emitting only
|
|
848
|
+
// *requests* startup — audio acquisition happens afterwards and can
|
|
849
|
+
// still fail — so clearing it now would leave a cold overlay with
|
|
850
|
+
// nothing to hydrate and the user with no way back to the detected
|
|
851
|
+
// meeting. `watch_meeting_notification_acks` retires it when the
|
|
852
|
+
// frontend confirms the meeting is on screen instead.
|
|
364
853
|
}
|
|
365
854
|
|
|
366
855
|
Ok(())
|
|
@@ -395,124 +884,993 @@ mod tests {
|
|
|
395
884
|
assert!(bundles_for_platform("webex").is_empty());
|
|
396
885
|
}
|
|
397
886
|
|
|
887
|
+
/// `Instant` has no constructor, and subtracting past boot panics, so age
|
|
888
|
+
/// every fixture from a single `now` and clamp.
|
|
889
|
+
fn ago(now: Instant, secs: u64) -> Instant {
|
|
890
|
+
now.checked_sub(Duration::from_secs(secs)).unwrap_or(now)
|
|
891
|
+
}
|
|
892
|
+
|
|
398
893
|
#[test]
|
|
399
|
-
fn
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
assert!(
|
|
404
|
-
assert!(!mic_vetoes_detection(Some(true)));
|
|
405
|
-
assert!(!mic_vetoes_detection(None));
|
|
894
|
+
fn every_platform_is_sampled_once() {
|
|
895
|
+
let platforms = platform_titles();
|
|
896
|
+
assert_eq!(platforms.len(), 2);
|
|
897
|
+
assert!(platforms.iter().any(|(p, _)| *p == "zoom"));
|
|
898
|
+
assert!(platforms.iter().any(|(p, _)| *p == "teams"));
|
|
406
899
|
}
|
|
407
900
|
|
|
408
901
|
#[test]
|
|
409
|
-
fn
|
|
410
|
-
// The
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
let
|
|
414
|
-
|
|
415
|
-
|
|
902
|
+
fn a_live_stream_confirms_a_call_from_the_background() {
|
|
903
|
+
// The whole point: join a call, switch to Slack, and detection still
|
|
904
|
+
// lands. Foreground dwell alone missed this entirely.
|
|
905
|
+
let now = Instant::now();
|
|
906
|
+
let evidence = CallEvidence {
|
|
907
|
+
front_since: None,
|
|
908
|
+
mic_since: Some(ago(now, 6)),
|
|
909
|
+
mic_last_true: Some(now),
|
|
910
|
+
..Default::default()
|
|
911
|
+
};
|
|
912
|
+
assert_eq!(evidence.classify(now, Some(true), false), CallState::Live);
|
|
416
913
|
}
|
|
417
914
|
|
|
418
915
|
#[test]
|
|
419
|
-
fn
|
|
420
|
-
let
|
|
421
|
-
|
|
422
|
-
|
|
916
|
+
fn a_live_stream_still_debounces() {
|
|
917
|
+
let now = Instant::now();
|
|
918
|
+
let evidence = CallEvidence {
|
|
919
|
+
front_since: None,
|
|
920
|
+
mic_since: Some(ago(now, 2)),
|
|
921
|
+
mic_last_true: Some(now),
|
|
922
|
+
..Default::default()
|
|
923
|
+
};
|
|
924
|
+
assert_eq!(
|
|
925
|
+
evidence.classify(now, Some(true), false),
|
|
926
|
+
CallState::Pending
|
|
927
|
+
);
|
|
423
928
|
}
|
|
424
929
|
|
|
425
930
|
#[test]
|
|
426
|
-
fn
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
931
|
+
fn a_single_live_sample_is_not_a_call() {
|
|
932
|
+
// A notification sound or a device probe holds the input for one tick.
|
|
933
|
+
// The drop grace used to bridge it while `mic_since` kept ageing, so at
|
|
934
|
+
// t=6 the dwell looked satisfied and a meeting was created for a stream
|
|
935
|
+
// that ran for two seconds.
|
|
936
|
+
let now = Instant::now();
|
|
937
|
+
let mut evidence = CallEvidence::default();
|
|
938
|
+
evidence.observe(ago(now, 6), Some(true), false);
|
|
939
|
+
evidence.observe(ago(now, 4), Some(false), false);
|
|
940
|
+
evidence.observe(ago(now, 2), Some(false), false);
|
|
941
|
+
evidence.observe(now, Some(false), false);
|
|
942
|
+
|
|
943
|
+
assert!(!evidence.mic_live(now, Some(false)));
|
|
944
|
+
assert_eq!(
|
|
945
|
+
evidence.classify(now, Some(false), false),
|
|
946
|
+
CallState::Idle,
|
|
947
|
+
"one live sample never becomes a confirmed call"
|
|
948
|
+
);
|
|
949
|
+
}
|
|
430
950
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
951
|
+
#[test]
|
|
952
|
+
fn an_established_stream_still_gets_its_drop_grace() {
|
|
953
|
+
// The counterpart: once a stream has run long enough to be confirmed, a
|
|
954
|
+
// short gap must not drop it back out.
|
|
955
|
+
let now = Instant::now();
|
|
956
|
+
let mut evidence = CallEvidence::default();
|
|
957
|
+
evidence.observe(ago(now, 20), Some(true), false);
|
|
958
|
+
evidence.observe(ago(now, 14), Some(true), false);
|
|
959
|
+
evidence.observe(now, Some(false), false);
|
|
960
|
+
|
|
961
|
+
assert!(evidence.mic_live(now, Some(false)));
|
|
962
|
+
assert_eq!(evidence.classify(now, Some(false), false), CallState::Live);
|
|
434
963
|
}
|
|
435
964
|
|
|
436
965
|
#[test]
|
|
437
|
-
fn
|
|
438
|
-
let
|
|
439
|
-
let mut
|
|
440
|
-
|
|
441
|
-
|
|
966
|
+
fn a_stream_that_blips_out_does_not_restart_the_dwell() {
|
|
967
|
+
let now = Instant::now();
|
|
968
|
+
let mut evidence = CallEvidence::default();
|
|
969
|
+
evidence.observe(ago(now, 20), Some(true), false);
|
|
970
|
+
evidence.observe(ago(now, 4), Some(true), false);
|
|
971
|
+
evidence.observe(now, Some(false), false);
|
|
972
|
+
assert_eq!(
|
|
973
|
+
evidence.classify(now, Some(false), false),
|
|
974
|
+
CallState::Live,
|
|
975
|
+
"one silent read inside the drop grace is still the same call"
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
#[test]
|
|
980
|
+
fn a_freshly_opened_window_waits_out_the_join_grace() {
|
|
981
|
+
// Joining muted, and the seconds before Zoom opens its input, both read
|
|
982
|
+
// as Some(false). Treating the first one as final is a missed meeting.
|
|
983
|
+
let now = Instant::now();
|
|
984
|
+
let joining = CallEvidence {
|
|
985
|
+
front_since: Some(ago(now, 4)),
|
|
442
986
|
..Default::default()
|
|
443
987
|
};
|
|
988
|
+
assert_eq!(joining.classify(now, Some(false), true), CallState::Pending);
|
|
444
989
|
|
|
445
|
-
|
|
446
|
-
|
|
990
|
+
let parked = CallEvidence {
|
|
991
|
+
front_since: Some(ago(now, 60)),
|
|
992
|
+
..Default::default()
|
|
993
|
+
};
|
|
994
|
+
assert_eq!(
|
|
995
|
+
parked.classify(now, Some(false), true),
|
|
996
|
+
CallState::Idle,
|
|
997
|
+
"past the grace window a silent window is Zoom parked open"
|
|
998
|
+
);
|
|
999
|
+
}
|
|
447
1000
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
1001
|
+
#[test]
|
|
1002
|
+
fn an_unreadable_input_state_falls_back_to_foreground_dwell() {
|
|
1003
|
+
// `kAudioProcessPropertyIsRunningInput` is macOS 14+. Requiring a
|
|
1004
|
+
// stream would leave every older machine detecting nothing at all.
|
|
1005
|
+
let now = Instant::now();
|
|
1006
|
+
let dwelled = CallEvidence {
|
|
1007
|
+
front_since: Some(ago(now, 10)),
|
|
1008
|
+
..Default::default()
|
|
1009
|
+
};
|
|
1010
|
+
assert_eq!(dwelled.classify(now, None, true), CallState::Live);
|
|
1011
|
+
|
|
1012
|
+
let fresh = CallEvidence {
|
|
1013
|
+
front_since: Some(ago(now, 3)),
|
|
1014
|
+
..Default::default()
|
|
1015
|
+
};
|
|
1016
|
+
assert_eq!(fresh.classify(now, None, true), CallState::Pending);
|
|
1017
|
+
assert_eq!(dwelled.classify(now, None, false), CallState::Idle);
|
|
452
1018
|
}
|
|
453
1019
|
|
|
454
1020
|
#[test]
|
|
455
|
-
fn
|
|
456
|
-
let
|
|
1021
|
+
fn a_finished_call_releases_the_suppression_for_the_next_one() {
|
|
1022
|
+
let now = Instant::now();
|
|
1023
|
+
let mut evidence = CallEvidence::default();
|
|
1024
|
+
evidence.observe(ago(now, 90), Some(true), true);
|
|
1025
|
+
evidence.observe(ago(now, 60), Some(false), true);
|
|
1026
|
+
evidence.observe(now, Some(false), true);
|
|
1027
|
+
|
|
1028
|
+
assert!(
|
|
1029
|
+
evidence.take_call_ended(),
|
|
1030
|
+
"no stream for 30s ends the call even with the window still front"
|
|
1031
|
+
);
|
|
1032
|
+
assert!(
|
|
1033
|
+
!evidence.take_call_ended(),
|
|
1034
|
+
"the end of a call fires once, not on every later tick"
|
|
1035
|
+
);
|
|
1036
|
+
|
|
457
1037
|
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1038
|
+
state.note_prompted("zoom", 1_000);
|
|
1039
|
+
assert!(state.is_suppressed("zoom", 1_001));
|
|
1040
|
+
state.end_call_session("zoom");
|
|
1041
|
+
assert!(
|
|
1042
|
+
!state.is_suppressed("zoom", 1_001),
|
|
1043
|
+
"back-to-back calls each get their own prompt"
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
458
1046
|
|
|
459
|
-
|
|
1047
|
+
#[test]
|
|
1048
|
+
fn an_unreadable_read_does_not_end_a_live_call() {
|
|
1049
|
+
// CoreAudio returning `None` mid-call is a read failure, not silence.
|
|
1050
|
+
// Ending the call here releases the per-call suppression, so the same
|
|
1051
|
+
// call gets prompted a second time once readable samples resume.
|
|
1052
|
+
let now = Instant::now();
|
|
1053
|
+
let mut evidence = CallEvidence::default();
|
|
1054
|
+
evidence.observe(ago(now, 60), Some(true), false);
|
|
1055
|
+
for secs in (1..=50).rev() {
|
|
1056
|
+
evidence.observe(ago(now, secs), None, false);
|
|
1057
|
+
assert!(
|
|
1058
|
+
!evidence.take_call_ended(),
|
|
1059
|
+
"an unreadable read must never end a call"
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
evidence.observe(now, Some(true), false);
|
|
460
1063
|
|
|
461
|
-
assert!(
|
|
462
|
-
|
|
1064
|
+
assert!(
|
|
1065
|
+
!evidence.take_call_ended(),
|
|
1066
|
+
"a stream that was never confirmed gone is still the same call"
|
|
1067
|
+
);
|
|
1068
|
+
assert_eq!(
|
|
1069
|
+
evidence.mic_since,
|
|
1070
|
+
Some(ago(now, 60)),
|
|
1071
|
+
"the original dwell survives the unreadable stretch"
|
|
1072
|
+
);
|
|
1073
|
+
assert_eq!(evidence.classify(now, Some(true), false), CallState::Live);
|
|
463
1074
|
}
|
|
464
|
-
}
|
|
465
1075
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1076
|
+
#[test]
|
|
1077
|
+
fn an_unreadable_stretch_hands_back_to_the_foreground_fallback() {
|
|
1078
|
+
// Unreadable cannot vouch for a live stream forever, or a machine whose
|
|
1079
|
+
// CoreAudio stopped answering would hold a finished call open. Past the
|
|
1080
|
+
// grace the mic abstains and foreground dwell decides — but it still
|
|
1081
|
+
// reports no call end, because nothing confirmed one.
|
|
1082
|
+
let now = Instant::now();
|
|
1083
|
+
let mut evidence = CallEvidence::default();
|
|
1084
|
+
evidence.observe(ago(now, 300), Some(true), true);
|
|
1085
|
+
evidence.observe(ago(now, 200), None, true);
|
|
1086
|
+
evidence.observe(now, None, true);
|
|
1087
|
+
|
|
1088
|
+
assert!(!evidence.mic_live(now, None));
|
|
1089
|
+
assert!(
|
|
1090
|
+
!evidence.take_call_ended(),
|
|
1091
|
+
"a state we could not read is not a confirmed call end"
|
|
1092
|
+
);
|
|
1093
|
+
assert_eq!(
|
|
1094
|
+
evidence.classify(now, None, true),
|
|
1095
|
+
CallState::Live,
|
|
1096
|
+
"foreground dwell is the only evidence left"
|
|
1097
|
+
);
|
|
1098
|
+
assert_eq!(evidence.classify(now, None, false), CallState::Idle);
|
|
472
1099
|
}
|
|
473
|
-
}
|
|
474
1100
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
1101
|
+
#[test]
|
|
1102
|
+
fn a_stream_returning_inside_the_end_threshold_is_the_same_call() {
|
|
1103
|
+
// `CALL_END` is what separates two calls. A stream returning inside it
|
|
1104
|
+
// is one call blipping, so it must not report a boundary — that would
|
|
1105
|
+
// release the suppression and prompt a second time for one call.
|
|
1106
|
+
let now = Instant::now();
|
|
1107
|
+
let mut evidence = CallEvidence::default();
|
|
1108
|
+
evidence.observe(ago(now, 40), Some(true), false);
|
|
1109
|
+
evidence.observe(ago(now, 20), Some(false), false);
|
|
1110
|
+
evidence.observe(ago(now, 12), Some(false), false);
|
|
1111
|
+
evidence.observe(ago(now, 10), Some(true), false);
|
|
1112
|
+
|
|
1113
|
+
assert!(
|
|
1114
|
+
!evidence.take_call_ended(),
|
|
1115
|
+
"a 10s gap is a blip, not the end of the call"
|
|
1116
|
+
);
|
|
1117
|
+
assert!(
|
|
1118
|
+
evidence.mic_is_authoritative(now),
|
|
1119
|
+
"a readable stream still decides when this call ends"
|
|
1120
|
+
);
|
|
484
1121
|
}
|
|
485
|
-
}
|
|
486
1122
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
.
|
|
495
|
-
.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1123
|
+
#[test]
|
|
1124
|
+
fn a_backgrounded_call_keeps_its_suppression_until_the_confirmed_end() {
|
|
1125
|
+
// The window must not pre-empt the stream. `mic_live` goes false after
|
|
1126
|
+
// 8s of silence, but the call is not over until 30s — releasing
|
|
1127
|
+
// suppression in between lets one call prompt twice.
|
|
1128
|
+
let now = Instant::now();
|
|
1129
|
+
let mut evidence = CallEvidence::default();
|
|
1130
|
+
evidence.observe(ago(now, 60), Some(true), false);
|
|
1131
|
+
evidence.observe(ago(now, 12), Some(false), false);
|
|
1132
|
+
|
|
1133
|
+
assert!(!evidence.mic_live(now, Some(false)));
|
|
1134
|
+
assert!(
|
|
1135
|
+
evidence.mic_is_authoritative(now),
|
|
1136
|
+
"a readable silence run is the stream's business, not the window's"
|
|
1137
|
+
);
|
|
1138
|
+
assert!(!evidence.take_call_ended());
|
|
1139
|
+
}
|
|
499
1140
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1141
|
+
#[test]
|
|
1142
|
+
fn an_unreadable_background_call_gives_up_only_the_clockless_suppression() {
|
|
1143
|
+
// Past the unreadable grace we genuinely do not know. The prompt
|
|
1144
|
+
// cooldown expires on its own, so it can stay; `session_notified`
|
|
1145
|
+
// cannot, so it must go or the platform is stranded for good.
|
|
1146
|
+
let now = Instant::now();
|
|
1147
|
+
let mut evidence = CallEvidence::default();
|
|
1148
|
+
evidence.observe(ago(now, 300), Some(true), false);
|
|
1149
|
+
evidence.observe(ago(now, 200), None, false);
|
|
1150
|
+
evidence.observe(now, None, false);
|
|
1151
|
+
|
|
1152
|
+
assert!(evidence.mic_ever_spoke());
|
|
1153
|
+
assert!(
|
|
1154
|
+
!evidence.mic_is_authoritative(now),
|
|
1155
|
+
"an unreadable run past its grace is no longer an authority"
|
|
1156
|
+
);
|
|
1157
|
+
assert!(!evidence.take_call_ended());
|
|
1158
|
+
|
|
1159
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1160
|
+
state.note_prompted("zoom", 1_000);
|
|
1161
|
+
state.session_notified.remove("zoom");
|
|
1162
|
+
assert!(
|
|
1163
|
+
state.is_suppressed("zoom", 1_000 + COOLDOWN_SECS - 1),
|
|
1164
|
+
"the cooldown still guards against a duplicate prompt"
|
|
1165
|
+
);
|
|
1166
|
+
assert!(
|
|
1167
|
+
!state.is_suppressed("zoom", 1_000 + COOLDOWN_SECS),
|
|
1168
|
+
"and it expires, so the platform is never stranded"
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
#[test]
|
|
1173
|
+
fn a_platform_with_no_stream_evidence_is_the_only_one_the_window_ends() {
|
|
1174
|
+
let now = Instant::now();
|
|
1175
|
+
let mut fallback = CallEvidence::default();
|
|
1176
|
+
fallback.observe(ago(now, 30), None, true);
|
|
1177
|
+
fallback.observe(now, None, true);
|
|
1178
|
+
assert!(
|
|
1179
|
+
!fallback.mic_ever_spoke(),
|
|
1180
|
+
"macOS 13 never records a stream, so the window is all there is"
|
|
1181
|
+
);
|
|
1182
|
+
assert!(!fallback.mic_is_authoritative(now));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
#[test]
|
|
1186
|
+
fn a_second_muted_call_in_the_same_window_gets_a_fresh_join_grace() {
|
|
1187
|
+
// Zoom never left the foreground, so without re-arming the grace the
|
|
1188
|
+
// next call reads as a window parked open and stays Idle forever.
|
|
1189
|
+
let now = Instant::now();
|
|
1190
|
+
let mut evidence = CallEvidence::default();
|
|
1191
|
+
evidence.observe(ago(now, 600), Some(true), true);
|
|
1192
|
+
evidence.observe(ago(now, 100), Some(false), true);
|
|
1193
|
+
evidence.observe(ago(now, 10), Some(false), true);
|
|
1194
|
+
assert!(evidence.take_call_ended());
|
|
1195
|
+
|
|
1196
|
+
assert_eq!(
|
|
1197
|
+
evidence.classify(now, Some(false), true),
|
|
1198
|
+
CallState::Pending,
|
|
1199
|
+
"joining the next call muted still gets its join grace"
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
#[test]
|
|
1204
|
+
fn losing_the_window_never_releases_the_prompt_cooldown() {
|
|
1205
|
+
// On the fallback path, switching to Slack mid-call is indistinguishable
|
|
1206
|
+
// from the call ending. Releasing the cooldown there would re-prompt
|
|
1207
|
+
// the same still-running call the moment the user came back, so only a
|
|
1208
|
+
// confirmed call end may clear it.
|
|
1209
|
+
let now_ts = 1_000;
|
|
1210
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1211
|
+
state.note_prompted("zoom", now_ts);
|
|
1212
|
+
|
|
1213
|
+
state.session_notified.remove("zoom");
|
|
1214
|
+
assert!(
|
|
1215
|
+
state.is_suppressed("zoom", now_ts + 1),
|
|
1216
|
+
"the same call must not prompt again on returning to the window"
|
|
1217
|
+
);
|
|
1218
|
+
assert!(
|
|
1219
|
+
!state.is_suppressed("zoom", now_ts + COOLDOWN_SECS),
|
|
1220
|
+
"and the cooldown still expires, so the platform is never stranded"
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
state.end_call_session("zoom");
|
|
1224
|
+
assert!(
|
|
1225
|
+
!state.is_suppressed("zoom", now_ts + 1),
|
|
1226
|
+
"a confirmed call end is what frees the next call"
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
#[test]
|
|
1231
|
+
fn abandoning_evidence_converts_suppression_into_a_bounded_one() {
|
|
1232
|
+
// Transcription starts, so the watcher stops sampling. The per-call flag
|
|
1233
|
+
// cannot stay (nothing would be left to release it) and cannot simply
|
|
1234
|
+
// go (stopping notes mid-call would re-prompt the call still running),
|
|
1235
|
+
// so it becomes the cooldown, which expires on its own.
|
|
1236
|
+
let now_ts = 1_000;
|
|
1237
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1238
|
+
state.note_prompted("zoom", now_ts);
|
|
1239
|
+
state.note_dismissed("teams", now_ts);
|
|
1240
|
+
state
|
|
1241
|
+
.evidence
|
|
1242
|
+
.insert("zoom".to_string(), CallEvidence::default());
|
|
1243
|
+
|
|
1244
|
+
state.clear_tracking(now_ts + 30);
|
|
1245
|
+
|
|
1246
|
+
assert!(state.evidence.is_empty());
|
|
1247
|
+
assert!(
|
|
1248
|
+
state.session_notified.is_empty(),
|
|
1249
|
+
"the clockless flag never survives the evidence that releases it"
|
|
1250
|
+
);
|
|
1251
|
+
assert!(
|
|
1252
|
+
state.is_suppressed("zoom", now_ts + 31),
|
|
1253
|
+
"a call that outlives transcription is not prompted for twice"
|
|
1254
|
+
);
|
|
1255
|
+
assert!(
|
|
1256
|
+
!state.is_suppressed("zoom", now_ts + 30 + COOLDOWN_SECS),
|
|
1257
|
+
"and the suppression is bounded, so it can never strand"
|
|
1258
|
+
);
|
|
1259
|
+
assert!(
|
|
1260
|
+
state.is_suppressed("teams", now_ts + 31),
|
|
1261
|
+
"a dismissal has its own clock and is not ours to discard"
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
#[test]
|
|
1266
|
+
fn a_failed_create_backs_off_instead_of_retrying_every_tick() {
|
|
1267
|
+
// The evidence still says a call is underway, so the retry lands on the
|
|
1268
|
+
// next two-second tick and every tick after it — and a create that
|
|
1269
|
+
// committed but lost its response leaves a row per attempt.
|
|
1270
|
+
let now_ts = 1_000;
|
|
1271
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1272
|
+
state.note_prompted("zoom", now_ts);
|
|
1273
|
+
|
|
1274
|
+
state.note_create_failed("zoom", now_ts, false);
|
|
1275
|
+
|
|
1276
|
+
assert!(
|
|
1277
|
+
state.is_suppressed("zoom", now_ts + CREATE_RETRY_BACKOFF_SECS - 1),
|
|
1278
|
+
"the same call must not resubmit on the next tick"
|
|
1279
|
+
);
|
|
1280
|
+
assert!(
|
|
1281
|
+
!state.is_suppressed("zoom", now_ts + CREATE_RETRY_BACKOFF_SECS),
|
|
1282
|
+
"but the call is retried rather than abandoned"
|
|
1283
|
+
);
|
|
1284
|
+
assert!(
|
|
1285
|
+
CREATE_RETRY_BACKOFF_SECS < COOLDOWN_SECS,
|
|
1286
|
+
"a failed create retries sooner than a successful prompt repeats"
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
#[test]
|
|
1291
|
+
fn a_create_that_could_not_have_committed_does_not_force_a_reconcile() {
|
|
1292
|
+
let now_ts = 1_000;
|
|
1293
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1294
|
+
|
|
1295
|
+
state.note_create_failed("zoom", now_ts, false);
|
|
1296
|
+
|
|
1297
|
+
assert_eq!(
|
|
1298
|
+
state.create_doubt_since("zoom"),
|
|
1299
|
+
None,
|
|
1300
|
+
"a rejected request wrote nothing, so there is no row to look for"
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
#[test]
|
|
1305
|
+
fn an_unreadable_create_outcome_makes_the_next_attempt_reconcile() {
|
|
1306
|
+
let now_ts = 1_000;
|
|
1307
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1308
|
+
|
|
1309
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1310
|
+
|
|
1311
|
+
assert_eq!(
|
|
1312
|
+
state.create_doubt_since("zoom"),
|
|
1313
|
+
Some(now_ts),
|
|
1314
|
+
"the attempt may have committed, so the retry must look before inserting"
|
|
1315
|
+
);
|
|
1316
|
+
assert_eq!(
|
|
1317
|
+
state.create_doubt_since("teams"),
|
|
1318
|
+
None,
|
|
1319
|
+
"doubt is per platform"
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
#[test]
|
|
1324
|
+
fn a_run_of_unreadable_attempts_reconciles_against_the_first_one() {
|
|
1325
|
+
// Any attempt in the run could be the one that landed, so the window has
|
|
1326
|
+
// to reach back to the earliest — a later timestamp would look right past
|
|
1327
|
+
// the row the first attempt created.
|
|
1328
|
+
let now_ts = 1_000;
|
|
1329
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1330
|
+
|
|
1331
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1332
|
+
state.note_create_failed("zoom", now_ts + CREATE_RETRY_BACKOFF_SECS, true);
|
|
1333
|
+
|
|
1334
|
+
assert_eq!(state.create_doubt_since("zoom"), Some(now_ts));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
#[test]
|
|
1338
|
+
fn a_resolved_create_stops_reconciling() {
|
|
1339
|
+
let now_ts = 1_000;
|
|
1340
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1341
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1342
|
+
|
|
1343
|
+
state.note_create_resolved("zoom");
|
|
1344
|
+
|
|
1345
|
+
assert_eq!(state.create_doubt_since("zoom"), None);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
#[test]
|
|
1349
|
+
fn a_finished_call_drops_the_doubt_from_the_call_that_ended() {
|
|
1350
|
+
// Otherwise the next call reconciles against the previous call's row and
|
|
1351
|
+
// adopts it, attaching this call's notes to the wrong meeting.
|
|
1352
|
+
let now_ts = 1_000;
|
|
1353
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1354
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1355
|
+
|
|
1356
|
+
state.end_call_session("zoom");
|
|
1357
|
+
|
|
1358
|
+
assert_eq!(state.create_doubt_since("zoom"), None);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
#[test]
|
|
1362
|
+
fn an_unresolved_create_doubt_expires_into_a_cooldown() {
|
|
1363
|
+
// The doubt is pinned to one call. On the fallback path the watcher may
|
|
1364
|
+
// never see that call end, so the marker needs a clock of its own — but
|
|
1365
|
+
// expiring it on its own would let the next tick skip the reconcile and
|
|
1366
|
+
// bare-insert a row that may already exist.
|
|
1367
|
+
let now_ts = 1_000;
|
|
1368
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1369
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1370
|
+
|
|
1371
|
+
state.retain_live_cooldowns(now_ts + CREATE_DOUBT_TTL_SECS - 1);
|
|
1372
|
+
assert_eq!(state.create_doubt_since("zoom"), Some(now_ts));
|
|
1373
|
+
|
|
1374
|
+
let expiry = now_ts + CREATE_DOUBT_TTL_SECS;
|
|
1375
|
+
state.retain_live_cooldowns(expiry);
|
|
1376
|
+
assert_eq!(state.create_doubt_since("zoom"), None);
|
|
1377
|
+
assert!(
|
|
1378
|
+
state.is_suppressed("zoom", expiry),
|
|
1379
|
+
"an unsettled write must not fall through to a fresh insert"
|
|
1380
|
+
);
|
|
1381
|
+
assert!(
|
|
1382
|
+
state.is_suppressed("zoom", expiry + COOLDOWN_SECS - 1),
|
|
1383
|
+
"the pause runs a full cooldown"
|
|
1384
|
+
);
|
|
1385
|
+
assert!(
|
|
1386
|
+
!state.is_suppressed("zoom", expiry + COOLDOWN_SECS),
|
|
1387
|
+
"and is bounded, so the platform is never suppressed forever"
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
#[test]
|
|
1392
|
+
fn abandoning_tracking_drops_the_doubt_too() {
|
|
1393
|
+
let now_ts = 1_000;
|
|
1394
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1395
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1396
|
+
|
|
1397
|
+
state.clear_tracking(now_ts);
|
|
1398
|
+
|
|
1399
|
+
assert_eq!(
|
|
1400
|
+
state.create_doubt_since("zoom"),
|
|
1401
|
+
None,
|
|
1402
|
+
"there is no tracked call left to tie a found row to"
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
#[test]
|
|
1407
|
+
fn a_deferred_platform_is_suppressed_only_once_the_winner_has_a_row() {
|
|
1408
|
+
// Both platforms confirmed on one poll. Until the selected platform
|
|
1409
|
+
// actually produces a meeting, the loser must stay eligible — if the
|
|
1410
|
+
// selected flow errors or the calendar guard skips it, the loser is the
|
|
1411
|
+
// only live call left and it is the one the user wants.
|
|
1412
|
+
let now_ts = 1_000;
|
|
1413
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1414
|
+
state.note_prompted("zoom", now_ts);
|
|
1415
|
+
|
|
1416
|
+
assert!(
|
|
1417
|
+
!state.is_suppressed("teams", now_ts),
|
|
1418
|
+
"losing the tie-break is not grounds for suppression on its own"
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
state.defer_secondary_candidates(&["teams"]);
|
|
1422
|
+
|
|
1423
|
+
assert!(
|
|
1424
|
+
state.is_suppressed("teams", now_ts),
|
|
1425
|
+
"once the winner has a row, one poll has prompted for one call"
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
#[test]
|
|
1430
|
+
fn a_deferred_platform_survives_the_winners_failure() {
|
|
1431
|
+
let now_ts = 1_000;
|
|
1432
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1433
|
+
state.note_prompted("zoom", now_ts);
|
|
1434
|
+
|
|
1435
|
+
// The selected platform's create failed, so the deferral never happens.
|
|
1436
|
+
state.note_create_failed("zoom", now_ts, true);
|
|
1437
|
+
|
|
1438
|
+
assert!(
|
|
1439
|
+
!state.is_suppressed("teams", now_ts),
|
|
1440
|
+
"the other live call must still be promptable on the next tick"
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
#[test]
|
|
1445
|
+
fn a_calendar_guarded_platform_yields_the_next_poll_to_the_other_call() {
|
|
1446
|
+
// The guard records the skip exactly like a prompt, cooldown included,
|
|
1447
|
+
// so the guarded platform stops being first in line. A concurrent call
|
|
1448
|
+
// on the other platform is selected one tick later, not starved.
|
|
1449
|
+
let now_ts = 1_000;
|
|
1450
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1451
|
+
|
|
1452
|
+
state.note_prompted("zoom", now_ts);
|
|
1453
|
+
|
|
1454
|
+
let next_tick = now_ts + POLL_SECS as i64;
|
|
1455
|
+
assert!(
|
|
1456
|
+
state.is_suppressed("zoom", next_tick),
|
|
1457
|
+
"the guarded platform is no longer eligible to be picked first"
|
|
1458
|
+
);
|
|
1459
|
+
assert!(
|
|
1460
|
+
!state.is_suppressed("teams", next_tick),
|
|
1461
|
+
"so the other live call becomes this poll's candidate"
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
#[test]
|
|
1466
|
+
fn a_deferred_platform_is_released_by_its_own_call_end() {
|
|
1467
|
+
let now_ts = 1_000;
|
|
1468
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1469
|
+
state.defer_secondary_candidates(&["teams"]);
|
|
1470
|
+
assert!(state.is_suppressed("teams", now_ts));
|
|
1471
|
+
|
|
1472
|
+
state.end_call_session("teams");
|
|
1473
|
+
|
|
1474
|
+
assert!(
|
|
1475
|
+
!state.is_suppressed("teams", now_ts),
|
|
1476
|
+
"a deferral carries no cooldown, so the next call prompts immediately"
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
fn adhoc_row(id: &str, platform: &str, start: &str) -> serde_json::Value {
|
|
1481
|
+
serde_json::json!({
|
|
1482
|
+
"id": id,
|
|
1483
|
+
"platform": platform,
|
|
1484
|
+
"source": "adhoc",
|
|
1485
|
+
"scheduledStart": start,
|
|
1486
|
+
})
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
fn ts(rfc3339: &str) -> i64 {
|
|
1490
|
+
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
|
1491
|
+
.unwrap()
|
|
1492
|
+
.timestamp()
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/// The attempt that went unanswered, and the retry reading back a minute on.
|
|
1496
|
+
const ATTEMPT: &str = "2026-08-19T10:00:00Z";
|
|
1497
|
+
const RETRY: &str = "2026-08-19T10:01:00Z";
|
|
1498
|
+
|
|
1499
|
+
#[test]
|
|
1500
|
+
fn reconcile_adopts_the_row_a_lost_response_left_behind() {
|
|
1501
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1502
|
+
"meetings": [adhoc_row("zoom-row", "zoom", "2026-08-19T10:00:05Z")],
|
|
1503
|
+
}));
|
|
1504
|
+
|
|
1505
|
+
let found = pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY));
|
|
1506
|
+
|
|
1507
|
+
assert_eq!(found.map(|m| m.id), Some("zoom-row".to_string()));
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
#[test]
|
|
1511
|
+
fn reconcile_ignores_a_previous_calls_row() {
|
|
1512
|
+
// The row predates the attempt, so it belongs to an earlier call.
|
|
1513
|
+
// Adopting it would file this call's notes under that meeting.
|
|
1514
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1515
|
+
"meetings": [adhoc_row("old-row", "zoom", "2026-08-19T09:30:00Z")],
|
|
1516
|
+
}));
|
|
1517
|
+
|
|
1518
|
+
assert!(pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).is_none());
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
#[test]
|
|
1522
|
+
fn reconcile_ignores_a_scheduled_future_row() {
|
|
1523
|
+
// The agenda view runs from the lookback "onward" with no upper bound,
|
|
1524
|
+
// so a future ad-hoc row for this platform comes back too — and on
|
|
1525
|
+
// recency alone it would beat the row this call actually created.
|
|
1526
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1527
|
+
"meetings": [
|
|
1528
|
+
adhoc_row("this-call", "zoom", "2026-08-19T10:00:05Z"),
|
|
1529
|
+
adhoc_row("scheduled-later", "zoom", "2026-08-19T15:00:00Z"),
|
|
1530
|
+
],
|
|
1531
|
+
}));
|
|
1532
|
+
|
|
1533
|
+
assert_eq!(
|
|
1534
|
+
pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
|
|
1535
|
+
Some("this-call".to_string()),
|
|
1536
|
+
"a meeting that has not happened yet is not the row we just wrote"
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
#[test]
|
|
1541
|
+
fn reconcile_stays_on_its_own_platform_and_source() {
|
|
1542
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1543
|
+
"meetings": [
|
|
1544
|
+
adhoc_row("teams-row", "teams", "2026-08-19T10:00:05Z"),
|
|
1545
|
+
{
|
|
1546
|
+
"id": "calendar-row",
|
|
1547
|
+
"platform": "zoom",
|
|
1548
|
+
"source": "calendar",
|
|
1549
|
+
"scheduledStart": "2026-08-19T10:00:05Z",
|
|
1550
|
+
},
|
|
1551
|
+
],
|
|
1552
|
+
}));
|
|
1553
|
+
|
|
1554
|
+
assert!(
|
|
1555
|
+
pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).is_none(),
|
|
1556
|
+
"a Teams row and a calendar row are both the wrong meeting to adopt"
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
#[test]
|
|
1561
|
+
fn reconcile_takes_the_newest_row_when_attempts_already_duplicated() {
|
|
1562
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1563
|
+
"meetings": [
|
|
1564
|
+
adhoc_row("first", "zoom", "2026-08-19T10:00:05Z"),
|
|
1565
|
+
adhoc_row("second", "zoom", "2026-08-19T10:00:50Z"),
|
|
1566
|
+
],
|
|
1567
|
+
}));
|
|
1568
|
+
|
|
1569
|
+
assert_eq!(
|
|
1570
|
+
pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
|
|
1571
|
+
Some("second".to_string())
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
#[test]
|
|
1576
|
+
fn reconcile_tolerates_clock_skew_on_both_window_edges() {
|
|
1577
|
+
// `scheduledStart` is stamped here but read back through the server.
|
|
1578
|
+
let just_before = parse_meetings(&serde_json::json!({
|
|
1579
|
+
"meetings": [adhoc_row("zoom-row", "zoom", "2026-08-19T09:59:55Z")],
|
|
1580
|
+
}));
|
|
1581
|
+
assert_eq!(
|
|
1582
|
+
pick_recent_adhoc_meeting(&just_before, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
|
|
1583
|
+
Some("zoom-row".to_string()),
|
|
1584
|
+
"a few seconds of skew must not hide the row we just created"
|
|
1585
|
+
);
|
|
1586
|
+
|
|
1587
|
+
let just_after = parse_meetings(&serde_json::json!({
|
|
1588
|
+
"meetings": [adhoc_row("zoom-row", "zoom", "2026-08-19T10:01:05Z")],
|
|
1589
|
+
}));
|
|
1590
|
+
assert_eq!(
|
|
1591
|
+
pick_recent_adhoc_meeting(&just_after, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
|
|
1592
|
+
Some("zoom-row".to_string()),
|
|
1593
|
+
"nor may it hide a row stamped a moment ahead of our clock"
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
assert!(
|
|
1597
|
+
RECONCILE_SKEW_SECS < CALL_END.as_secs() as i64,
|
|
1598
|
+
"the skew allowance must not reach into a previous call"
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
#[test]
|
|
1603
|
+
fn an_unparseable_start_is_not_treated_as_a_match() {
|
|
1604
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1605
|
+
"meetings": [
|
|
1606
|
+
{ "id": "no-start", "platform": "zoom", "source": "adhoc" },
|
|
1607
|
+
adhoc_row("bad-start", "zoom", "not-a-timestamp"),
|
|
1608
|
+
],
|
|
1609
|
+
}));
|
|
1610
|
+
|
|
1611
|
+
assert!(
|
|
1612
|
+
pick_recent_adhoc_meeting(&meetings, "zoom", 0, ts(RETRY)).is_none(),
|
|
1613
|
+
"a row whose start cannot be read is not a row we can place in the window"
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
#[test]
|
|
1618
|
+
fn a_body_that_is_not_a_meetings_list_is_unreadable_not_empty() {
|
|
1619
|
+
// The defect this guards: `parse_meetings` flattens an unknown envelope
|
|
1620
|
+
// to an empty vector, so a 200 error payload would read as a checked
|
|
1621
|
+
// "no such meeting" and the retry would insert the duplicate.
|
|
1622
|
+
use crate::meetings_watcher::try_parse_meetings;
|
|
1623
|
+
|
|
1624
|
+
assert!(
|
|
1625
|
+
try_parse_meetings(&serde_json::json!({ "error": "boom" })).is_none(),
|
|
1626
|
+
"an error payload says nothing about whether a row exists"
|
|
1627
|
+
);
|
|
1628
|
+
assert!(
|
|
1629
|
+
try_parse_meetings(&serde_json::json!({ "rows": [] })).is_none(),
|
|
1630
|
+
"a changed envelope is not an empty list"
|
|
1631
|
+
);
|
|
1632
|
+
assert_eq!(
|
|
1633
|
+
try_parse_meetings(&serde_json::json!({ "meetings": [] })).map(|rows| rows.len()),
|
|
1634
|
+
Some(0),
|
|
1635
|
+
"an actual empty list is the one answer that permits a create"
|
|
1636
|
+
);
|
|
1637
|
+
assert!(
|
|
1638
|
+
parse_meetings(&serde_json::json!({ "error": "boom" })).is_empty(),
|
|
1639
|
+
"read-only callers keep their lenient behavior"
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
#[test]
|
|
1644
|
+
fn a_short_page_covers_the_reconcile_window() {
|
|
1645
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
1646
|
+
"meetings": [adhoc_row("only", "zoom", "2026-08-19T10:00:05Z")],
|
|
1647
|
+
}));
|
|
1648
|
+
|
|
1649
|
+
assert!(
|
|
1650
|
+
reconcile_window_was_covered(&meetings, ts(RETRY)),
|
|
1651
|
+
"a page shorter than the limit is the whole result"
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
#[test]
|
|
1656
|
+
fn a_full_page_ending_early_does_not_cover_the_window() {
|
|
1657
|
+
// Ascending agenda order means a truncated page proves only what it
|
|
1658
|
+
// reached. Concluding "no row" from it would insert the duplicate.
|
|
1659
|
+
let rows: Vec<serde_json::Value> = (0..RECONCILE_PAGE_LIMIT)
|
|
1660
|
+
.map(|i| adhoc_row(&format!("row-{i}"), "teams", "2026-08-19T09:55:00Z"))
|
|
1661
|
+
.collect();
|
|
1662
|
+
let meetings = parse_meetings(&serde_json::json!({ "meetings": rows }));
|
|
1663
|
+
|
|
1664
|
+
assert_eq!(meetings.len(), RECONCILE_PAGE_LIMIT);
|
|
1665
|
+
assert!(
|
|
1666
|
+
!reconcile_window_was_covered(&meetings, ts(RETRY)),
|
|
1667
|
+
"the row we want could sit past the cut"
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
#[test]
|
|
1672
|
+
fn a_full_page_reaching_past_the_window_still_covers_it() {
|
|
1673
|
+
let mut rows: Vec<serde_json::Value> = (0..RECONCILE_PAGE_LIMIT - 1)
|
|
1674
|
+
.map(|i| adhoc_row(&format!("row-{i}"), "teams", "2026-08-19T09:55:00Z"))
|
|
1675
|
+
.collect();
|
|
1676
|
+
rows.push(adhoc_row("future", "teams", "2026-08-19T18:00:00Z"));
|
|
1677
|
+
let meetings = parse_meetings(&serde_json::json!({ "meetings": rows }));
|
|
1678
|
+
|
|
1679
|
+
assert!(
|
|
1680
|
+
reconcile_window_was_covered(&meetings, ts(RETRY)),
|
|
1681
|
+
"the page ran past the window, so nothing inside it was cut off"
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
#[test]
|
|
1686
|
+
fn an_unreadable_stream_hands_the_session_over_exactly_once() {
|
|
1687
|
+
// The window may never change again, so the moment the stream stops
|
|
1688
|
+
// being readable is the only boundary left to release the flag on.
|
|
1689
|
+
let now = Instant::now();
|
|
1690
|
+
let mut evidence = CallEvidence::default();
|
|
1691
|
+
evidence.observe(ago(now, 300), Some(true), true);
|
|
1692
|
+
assert!(!evidence.take_mic_abstained());
|
|
1693
|
+
|
|
1694
|
+
evidence.observe(ago(now, 200), None, true);
|
|
1695
|
+
assert!(
|
|
1696
|
+
!evidence.take_mic_abstained(),
|
|
1697
|
+
"inside the grace the stream still speaks for the call"
|
|
1698
|
+
);
|
|
1699
|
+
|
|
1700
|
+
evidence.observe(now, None, true);
|
|
1701
|
+
assert!(
|
|
1702
|
+
evidence.take_mic_abstained(),
|
|
1703
|
+
"past the grace the session is handed to the fallback"
|
|
1704
|
+
);
|
|
1705
|
+
assert!(
|
|
1706
|
+
!evidence.take_mic_abstained(),
|
|
1707
|
+
"and handed over once, not on every later tick"
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
#[test]
|
|
1712
|
+
fn a_confirmed_call_end_is_not_an_abstention() {
|
|
1713
|
+
let now = Instant::now();
|
|
1714
|
+
let mut evidence = CallEvidence::default();
|
|
1715
|
+
evidence.observe(ago(now, 90), Some(true), true);
|
|
1716
|
+
evidence.observe(ago(now, 60), Some(false), true);
|
|
1717
|
+
evidence.observe(now, Some(false), true);
|
|
1718
|
+
|
|
1719
|
+
assert!(evidence.take_call_ended());
|
|
1720
|
+
assert!(
|
|
1721
|
+
!evidence.take_mic_abstained(),
|
|
1722
|
+
"the stream exercised its authority rather than losing it"
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
#[test]
|
|
1727
|
+
fn unreadable_time_does_not_count_as_confirmed_silence() {
|
|
1728
|
+
// The unreadable stretch may have been full of audio. Counting it would
|
|
1729
|
+
// let the first `Some(false)` afterwards satisfy CALL_END on its own and
|
|
1730
|
+
// end a call that never stopped.
|
|
1731
|
+
let now = Instant::now();
|
|
1732
|
+
let mut evidence = CallEvidence::default();
|
|
1733
|
+
evidence.observe(ago(now, 120), Some(true), false);
|
|
1734
|
+
evidence.observe(ago(now, 118), Some(false), false);
|
|
1735
|
+
evidence.observe(ago(now, 100), None, false);
|
|
1736
|
+
evidence.observe(ago(now, 10), None, false);
|
|
1737
|
+
evidence.observe(now, Some(false), false);
|
|
1738
|
+
|
|
1739
|
+
assert!(
|
|
1740
|
+
!evidence.take_call_ended(),
|
|
1741
|
+
"the silence clock restarts after time we could not read"
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
#[test]
|
|
1746
|
+
fn ask_mode_prompts_without_auto_starting() {
|
|
1747
|
+
// The b00c38db4 regression: Ask is the shipped default, so if this
|
|
1748
|
+
// stops surfacing the overlay, ad-hoc detection is dead for everyone
|
|
1749
|
+
// who never opened Settings.
|
|
1750
|
+
let plan = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Ask, false));
|
|
1751
|
+
assert!(plan.show_widget);
|
|
1752
|
+
assert!(!plan.auto_start);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
#[test]
|
|
1756
|
+
fn auto_mode_prompts_and_starts() {
|
|
1757
|
+
let plan = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Auto, false));
|
|
1758
|
+
assert!(plan.show_widget);
|
|
1759
|
+
assert!(plan.auto_start);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
#[test]
|
|
1763
|
+
fn manual_mode_stays_silent_only_when_the_widget_is_disabled() {
|
|
1764
|
+
let hidden = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Manual, false));
|
|
1765
|
+
assert!(!hidden.show_widget);
|
|
1766
|
+
assert!(!hidden.auto_start);
|
|
1767
|
+
|
|
1768
|
+
let shown = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Manual, true));
|
|
1769
|
+
assert!(shown.show_widget);
|
|
1770
|
+
assert!(!shown.auto_start);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
#[test]
|
|
1774
|
+
fn dismissal_suppresses_the_rest_of_the_call() {
|
|
1775
|
+
let now_ts = 1_000;
|
|
1776
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1777
|
+
|
|
1778
|
+
state.note_dismissed("zoom", now_ts);
|
|
1779
|
+
state.session_notified.remove("zoom");
|
|
1780
|
+
|
|
1781
|
+
assert!(state.is_suppressed("zoom", now_ts + COOLDOWN_SECS - 1));
|
|
1782
|
+
assert!(!state.is_suppressed("zoom", now_ts + COOLDOWN_SECS));
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
#[test]
|
|
1786
|
+
fn dismissal_suppression_remains_platform_scoped() {
|
|
1787
|
+
let now_ts = 1_000;
|
|
1788
|
+
let mut state = AdhocMeetingsWatcherInner::default();
|
|
1789
|
+
|
|
1790
|
+
state.note_dismissed("zoom", now_ts);
|
|
1791
|
+
|
|
1792
|
+
assert!(state.is_suppressed("zoom", now_ts));
|
|
1793
|
+
assert!(!state.is_suppressed("teams", now_ts));
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
fn reset_evidence(app: &AppHandle) {
|
|
1798
|
+
if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
|
|
1799
|
+
if let Ok(mut g) = state.inner.lock() {
|
|
1800
|
+
g.clear_tracking(chrono::Utc::now().timestamp());
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
async fn create_adhoc_meeting(
|
|
1806
|
+
app: &AppHandle,
|
|
1807
|
+
client: &reqwest::Client,
|
|
1808
|
+
platform: &str,
|
|
1809
|
+
reconcile_since: Option<i64>,
|
|
1810
|
+
) -> Result<MeetingItem, CreateFailure> {
|
|
1811
|
+
let session = app
|
|
1812
|
+
.try_state::<MeetingsWatcherState>()
|
|
1813
|
+
.map(|s| s.session_snapshot())
|
|
1814
|
+
.unwrap_or_default();
|
|
1815
|
+
let Some(server_url) = session.server_url.as_deref() else {
|
|
1816
|
+
return Err(CreateFailure::NotCommitted(
|
|
1817
|
+
"no server_url for create-meeting".to_string(),
|
|
1818
|
+
));
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
// A previous attempt for this same call may have committed before its
|
|
1822
|
+
// response went missing. `create-meeting` is only idempotent on its
|
|
1823
|
+
// `calendarEventId` path (that one claims the event row atomically); the
|
|
1824
|
+
// ad-hoc path is a bare insert, so nothing server-side collapses a second
|
|
1825
|
+
// attempt into the first. Until it has an idempotency key, the only way not
|
|
1826
|
+
// to leave one row per attempt is to go looking for the first row.
|
|
1827
|
+
//
|
|
1828
|
+
// Before the calendar lookup, not after. A retry whose calendar match has
|
|
1829
|
+
// since become resolvable would otherwise return that calendar meeting, and
|
|
1830
|
+
// the caller would clear the doubt against it — stranding the ad-hoc row the
|
|
1831
|
+
// earlier attempt actually wrote, with nothing left that would ever find it.
|
|
1832
|
+
// An outstanding write is settled first; enrichment is what happens on a
|
|
1833
|
+
// call that has no row yet.
|
|
1834
|
+
//
|
|
1835
|
+
// A lookup that fails is not a lookup that found nothing. Treating an
|
|
1836
|
+
// unreadable reconcile as "no existing row" would insert the duplicate this
|
|
1837
|
+
// whole path exists to avoid, so it abandons the attempt and stays in doubt
|
|
1838
|
+
// for the next tick instead.
|
|
1839
|
+
if let Some(since) = reconcile_since {
|
|
1840
|
+
match find_recent_adhoc_meeting(app, client, server_url, &session, platform, since).await {
|
|
1841
|
+
Ok(Some(existing)) => {
|
|
1842
|
+
dlog!(
|
|
1843
|
+
"[clips-tray] adhoc reconciled to existing meeting {} for {}",
|
|
1844
|
+
existing.id,
|
|
1845
|
+
platform
|
|
1846
|
+
);
|
|
1847
|
+
return Ok(existing);
|
|
1848
|
+
}
|
|
1849
|
+
Ok(None) => {}
|
|
1850
|
+
Err(error) => {
|
|
1851
|
+
return Err(CreateFailure::Ambiguous(format!(
|
|
1852
|
+
"adhoc reconcile unreadable for {platform}, not retrying create: {error}"
|
|
1853
|
+
)));
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// The native watcher knows which conferencing app is active, but not the
|
|
1859
|
+
// calendar event title. Resolve the nearest joinable event first so a
|
|
1860
|
+
// calendar-backed meeting keeps its title, URL, and scheduled span. A
|
|
1861
|
+
// disconnected calendar only loses that enrichment and still gets an
|
|
1862
|
+
// adhoc meeting below.
|
|
1863
|
+
match find_calendar_meeting(app, client, server_url, &session, platform).await {
|
|
1864
|
+
Ok(Some(meeting)) => {
|
|
1865
|
+
dlog!(
|
|
1866
|
+
"[clips-tray] adhoc matched calendar meeting {} for {}",
|
|
1867
|
+
meeting.id,
|
|
1868
|
+
platform
|
|
1869
|
+
);
|
|
1870
|
+
return Ok(meeting);
|
|
1871
|
+
}
|
|
1872
|
+
Ok(None) => {}
|
|
1873
|
+
Err(error) => dlog!(
|
|
516
1874
|
"[clips-tray] adhoc calendar title lookup skipped for {}: {}",
|
|
517
1875
|
platform,
|
|
518
1876
|
error
|
|
@@ -545,29 +1903,51 @@ async fn create_adhoc_meeting(
|
|
|
545
1903
|
req = req.bearer_auth(token);
|
|
546
1904
|
}
|
|
547
1905
|
|
|
548
|
-
let resp = req
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
.
|
|
1906
|
+
let resp = req.send().await.map_err(|e| {
|
|
1907
|
+
let message = format!("create-meeting fetch: {e}");
|
|
1908
|
+
// A request that never opened a connection cannot have written
|
|
1909
|
+
// anything. Everything else here — a timeout above all — may have been
|
|
1910
|
+
// answered by a handler that had already committed.
|
|
1911
|
+
if e.is_connect() || e.is_builder() {
|
|
1912
|
+
CreateFailure::NotCommitted(message)
|
|
1913
|
+
} else {
|
|
1914
|
+
CreateFailure::Ambiguous(message)
|
|
1915
|
+
}
|
|
1916
|
+
})?;
|
|
552
1917
|
let status = resp.status();
|
|
553
1918
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
554
1919
|
let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
|
|
555
|
-
return Err(
|
|
1920
|
+
return Err(CreateFailure::NotCommitted(
|
|
1921
|
+
"create-meeting http 401".to_string(),
|
|
1922
|
+
));
|
|
556
1923
|
}
|
|
557
1924
|
if !status.is_success() {
|
|
558
1925
|
let text = resp.text().await.unwrap_or_default();
|
|
559
|
-
|
|
1926
|
+
let message = format!(
|
|
560
1927
|
"create-meeting http {} — {}",
|
|
561
1928
|
status,
|
|
562
1929
|
text.chars().take(180).collect::<String>()
|
|
563
|
-
)
|
|
1930
|
+
);
|
|
1931
|
+
// A 4xx is the action refusing the request, so no row exists. A 5xx can
|
|
1932
|
+
// be raised after the insert committed — by the app-state write or the
|
|
1933
|
+
// read-back that follows it — so it has to be treated as unresolved.
|
|
1934
|
+
return Err(if status.is_client_error() {
|
|
1935
|
+
CreateFailure::NotCommitted(message)
|
|
1936
|
+
} else {
|
|
1937
|
+
CreateFailure::Ambiguous(message)
|
|
1938
|
+
});
|
|
564
1939
|
}
|
|
565
|
-
|
|
1940
|
+
// Past here the server returned success, so a row exists. Anything we then
|
|
1941
|
+
// fail to read about it leaves us holding a committed write we cannot name.
|
|
1942
|
+
let body: serde_json::Value = resp
|
|
1943
|
+
.json()
|
|
1944
|
+
.await
|
|
1945
|
+
.map_err(|e| CreateFailure::Ambiguous(format!("create-meeting response: {e}")))?;
|
|
566
1946
|
let id = extract_meeting_id(&body).ok_or_else(|| {
|
|
567
|
-
format!(
|
|
1947
|
+
CreateFailure::Ambiguous(format!(
|
|
568
1948
|
"create-meeting response missing meeting id: {}",
|
|
569
1949
|
body.to_string().chars().take(200).collect::<String>()
|
|
570
|
-
)
|
|
1950
|
+
))
|
|
571
1951
|
})?;
|
|
572
1952
|
Ok(MeetingItem {
|
|
573
1953
|
id,
|
|
@@ -580,6 +1960,172 @@ async fn create_adhoc_meeting(
|
|
|
580
1960
|
})
|
|
581
1961
|
}
|
|
582
1962
|
|
|
1963
|
+
/// Look for the ad-hoc row an earlier unanswered attempt may already have
|
|
1964
|
+
/// created for this call.
|
|
1965
|
+
///
|
|
1966
|
+
/// Reads persisted rows only: a live calendar event cannot be what a
|
|
1967
|
+
/// `create-meeting` insert left behind, and asking for them would make this
|
|
1968
|
+
/// lookup depend on the calendar being reachable.
|
|
1969
|
+
async fn find_recent_adhoc_meeting(
|
|
1970
|
+
app: &AppHandle,
|
|
1971
|
+
client: &reqwest::Client,
|
|
1972
|
+
server_url: &str,
|
|
1973
|
+
session: &crate::meetings_watcher::MeetingsSessionSnapshot,
|
|
1974
|
+
platform: &str,
|
|
1975
|
+
since_ts: i64,
|
|
1976
|
+
) -> Result<Option<MeetingItem>, String> {
|
|
1977
|
+
let now_ts = chrono::Utc::now().timestamp();
|
|
1978
|
+
// Reach back to the earliest attempt still in doubt, plus a minute so the
|
|
1979
|
+
// window cannot close on the row it is looking for.
|
|
1980
|
+
let lookback_min = (((now_ts - since_ts).max(0) / 60) + 2).to_string();
|
|
1981
|
+
|
|
1982
|
+
// Walk pages until the window is covered. The agenda view is ascending from
|
|
1983
|
+
// the start of the lookback, so the row a lost response created can sit
|
|
1984
|
+
// behind a page boundary if enough meetings start ahead of it — and
|
|
1985
|
+
// answering "no such row" from a page that stopped short is what would
|
|
1986
|
+
// insert the duplicate. Bounded, because an unbounded walk over a calendar
|
|
1987
|
+
// with no upper bound would page through every future meeting on every
|
|
1988
|
+
// retry.
|
|
1989
|
+
for page in 0..RECONCILE_MAX_PAGES {
|
|
1990
|
+
let offset = page * RECONCILE_PAGE_LIMIT;
|
|
1991
|
+
let rows = fetch_agenda_page(
|
|
1992
|
+
app,
|
|
1993
|
+
client,
|
|
1994
|
+
server_url,
|
|
1995
|
+
session,
|
|
1996
|
+
lookback_min.as_str(),
|
|
1997
|
+
offset,
|
|
1998
|
+
)
|
|
1999
|
+
.await?;
|
|
2000
|
+
|
|
2001
|
+
if let Some(found) = pick_recent_adhoc_meeting(&rows, platform, since_ts, now_ts) {
|
|
2002
|
+
return Ok(Some(found));
|
|
2003
|
+
}
|
|
2004
|
+
if reconcile_window_was_covered(&rows, now_ts) {
|
|
2005
|
+
return Ok(None);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Ran out of pages with the window still open. Unresolved, not absent.
|
|
2010
|
+
Err(format!(
|
|
2011
|
+
"list-meetings did not reach the reconcile window within {RECONCILE_MAX_PAGES} pages"
|
|
2012
|
+
))
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/// One page of persisted agenda rows.
|
|
2016
|
+
///
|
|
2017
|
+
/// Persisted only: a live calendar event cannot be what a `create-meeting`
|
|
2018
|
+
/// insert left behind, and asking for them would make this lookup depend on the
|
|
2019
|
+
/// calendar being reachable.
|
|
2020
|
+
async fn fetch_agenda_page(
|
|
2021
|
+
app: &AppHandle,
|
|
2022
|
+
client: &reqwest::Client,
|
|
2023
|
+
server_url: &str,
|
|
2024
|
+
session: &crate::meetings_watcher::MeetingsSessionSnapshot,
|
|
2025
|
+
lookback_min: &str,
|
|
2026
|
+
offset: usize,
|
|
2027
|
+
) -> Result<Vec<MeetingItem>, String> {
|
|
2028
|
+
let page_limit = RECONCILE_PAGE_LIMIT.to_string();
|
|
2029
|
+
let offset = offset.to_string();
|
|
2030
|
+
let url = format!("{server_url}/_agent-native/actions/list-meetings");
|
|
2031
|
+
let mut req = client
|
|
2032
|
+
.get(url)
|
|
2033
|
+
.query(&[
|
|
2034
|
+
("view", "agenda"),
|
|
2035
|
+
("agendaLookbackMin", lookback_min),
|
|
2036
|
+
("includeLiveCalendar", "false"),
|
|
2037
|
+
("limit", page_limit.as_str()),
|
|
2038
|
+
("offset", offset.as_str()),
|
|
2039
|
+
])
|
|
2040
|
+
.header("X-Request-Source", "clips-desktop");
|
|
2041
|
+
if let Some(cookie) = session.session_cookie.as_deref() {
|
|
2042
|
+
req = req.header("Cookie", cookie);
|
|
2043
|
+
}
|
|
2044
|
+
if let Some(token) = session.auth_token.as_deref() {
|
|
2045
|
+
req = req.bearer_auth(token);
|
|
2046
|
+
}
|
|
2047
|
+
let response = req
|
|
2048
|
+
.send()
|
|
2049
|
+
.await
|
|
2050
|
+
.map_err(|error| format!("list-meetings fetch: {error}"))?;
|
|
2051
|
+
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
|
2052
|
+
let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
|
|
2053
|
+
return Err("list-meetings http 401".to_string());
|
|
2054
|
+
}
|
|
2055
|
+
if !response.status().is_success() {
|
|
2056
|
+
return Err(format!("list-meetings http {}", response.status()));
|
|
2057
|
+
}
|
|
2058
|
+
let body: serde_json::Value = response
|
|
2059
|
+
.json()
|
|
2060
|
+
.await
|
|
2061
|
+
.map_err(|error| format!("list-meetings response: {error}"))?;
|
|
2062
|
+
// A 200 whose body is not a meetings list — an error payload, or a changed
|
|
2063
|
+
// envelope — is unreadable, not empty. `parse_meetings` flattens both to an
|
|
2064
|
+
// empty vector, and the caller is deciding whether to insert a row, so it
|
|
2065
|
+
// has to see the difference.
|
|
2066
|
+
crate::meetings_watcher::try_parse_meetings(&body).ok_or_else(|| {
|
|
2067
|
+
format!(
|
|
2068
|
+
"list-meetings response was not a meetings list: {}",
|
|
2069
|
+
body.to_string().chars().take(200).collect::<String>()
|
|
2070
|
+
)
|
|
2071
|
+
})
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/// Whether a returned page can be trusted to answer "no such row".
|
|
2075
|
+
///
|
|
2076
|
+
/// A short page is the whole result, so it can. A full page was truncated, and
|
|
2077
|
+
/// only covers up to its newest row — if that is still behind the window's
|
|
2078
|
+
/// ceiling, the row a lost response created may be one page further on, and
|
|
2079
|
+
/// concluding "no row exists" from it would insert the duplicate.
|
|
2080
|
+
fn reconcile_window_was_covered(meetings: &[MeetingItem], now_ts: i64) -> bool {
|
|
2081
|
+
if meetings.len() < RECONCILE_PAGE_LIMIT {
|
|
2082
|
+
return true;
|
|
2083
|
+
}
|
|
2084
|
+
let newest = meetings
|
|
2085
|
+
.iter()
|
|
2086
|
+
.filter_map(|m| m.scheduled_start.as_deref())
|
|
2087
|
+
.filter_map(|start| chrono::DateTime::parse_from_rfc3339(start).ok())
|
|
2088
|
+
.map(|start| start.timestamp())
|
|
2089
|
+
.max();
|
|
2090
|
+
newest.is_some_and(|newest| newest >= now_ts + RECONCILE_SKEW_SECS)
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/// The ad-hoc row a lost `create-meeting` response would have left behind: same
|
|
2094
|
+
/// platform, `source == "adhoc"`, and scheduled inside the attempt window.
|
|
2095
|
+
///
|
|
2096
|
+
/// Bounded at both ends. Rows older than the attempt belong to a previous call,
|
|
2097
|
+
/// and the agenda view runs "from the lookback onward" with no upper bound of
|
|
2098
|
+
/// its own, so it also returns *future* ad-hoc rows — a scheduled one would
|
|
2099
|
+
/// otherwise win on recency and collect this call's notes. Within the window the
|
|
2100
|
+
/// newest wins, so a run of attempts that duplicated before this guard existed
|
|
2101
|
+
/// still lands on the row the call is actually using.
|
|
2102
|
+
fn pick_recent_adhoc_meeting(
|
|
2103
|
+
meetings: &[MeetingItem],
|
|
2104
|
+
platform: &str,
|
|
2105
|
+
since_ts: i64,
|
|
2106
|
+
now_ts: i64,
|
|
2107
|
+
) -> Option<MeetingItem> {
|
|
2108
|
+
let floor = since_ts - RECONCILE_SKEW_SECS;
|
|
2109
|
+
let ceiling = now_ts + RECONCILE_SKEW_SECS;
|
|
2110
|
+
meetings
|
|
2111
|
+
.iter()
|
|
2112
|
+
.filter(|m| m.source.as_deref() == Some("adhoc"))
|
|
2113
|
+
.filter(|m| {
|
|
2114
|
+
m.platform
|
|
2115
|
+
.as_deref()
|
|
2116
|
+
.is_some_and(|p| p.eq_ignore_ascii_case(platform))
|
|
2117
|
+
})
|
|
2118
|
+
.filter_map(|m| {
|
|
2119
|
+
let start = m.scheduled_start.as_deref()?;
|
|
2120
|
+
let ts = chrono::DateTime::parse_from_rfc3339(start)
|
|
2121
|
+
.ok()?
|
|
2122
|
+
.timestamp();
|
|
2123
|
+
(ts >= floor && ts <= ceiling).then_some((ts, m))
|
|
2124
|
+
})
|
|
2125
|
+
.max_by_key(|(ts, _)| *ts)
|
|
2126
|
+
.map(|(_, m)| m.clone())
|
|
2127
|
+
}
|
|
2128
|
+
|
|
583
2129
|
async fn find_calendar_meeting(
|
|
584
2130
|
app: &AppHandle,
|
|
585
2131
|
client: &reqwest::Client,
|