1use std::sync::{Arc, Weak};
8use std::time::SystemTime;
9
10use forge_connectors::gotify::{GotifyRecent, refresh_app_index};
11use forge_primitives::GotifySubscription;
12use uuid::Uuid;
13
14use crate::SessionSlot;
15use crate::gotify::SubsystemHost;
16use crate::mcp::caller_context::caller_context;
17use crate::workspace::Workspace;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub(crate) enum GotifySubscribeError {
22 NotConfigured,
24 UnknownCallerProject,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
36pub(crate) struct SubscribeOutcome {
37 pub id: Uuid,
38 pub applications: Vec<String>,
40 pub min_priority: Option<u8>,
42 pub names_resolve: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub(crate) enum GotifyReadError {
48 NotConfigured,
50 Fetch(String),
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
59pub(crate) enum GotifyUnsubscribeError {
60 UnknownCallerProject,
63 NoSuchSubscription,
65 NotOwnedByCaller,
67}
68
69#[async_trait::async_trait]
73pub(crate) trait GotifyFacade: Send + Sync {
74 async fn subscribe(
80 &self,
81 caller: &SessionSlot,
82 applications: Vec<String>,
83 min_priority: Option<u8>,
84 ) -> Result<SubscribeOutcome, GotifySubscribeError>;
85
86 fn list(&self, caller: &SessionSlot) -> Vec<GotifySubscription>;
89
90 fn unsubscribe(
96 &self,
97 caller: &SessionSlot,
98 id: Uuid,
99 ) -> Result<GotifySubscription, GotifyUnsubscribeError>;
100
101 async fn apps(&self) -> Result<Vec<String>, GotifyReadError>;
104
105 async fn recent(
109 &self,
110 applications: Vec<String>,
111 min_priority: Option<u8>,
112 limit: usize,
113 ) -> Result<Vec<GotifyRecent>, GotifyReadError>;
114}
115
116pub(crate) struct ProdGotifyFacade {
119 workspace: Weak<Workspace>,
120}
121
122impl ProdGotifyFacade {
123 pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn GotifyFacade> {
124 Arc::new(Self { workspace: Arc::downgrade(workspace) })
125 }
126}
127
128#[async_trait::async_trait]
129impl GotifyFacade for ProdGotifyFacade {
130 async fn subscribe(
131 &self,
132 caller: &SessionSlot,
133 applications: Vec<String>,
134 min_priority: Option<u8>,
135 ) -> Result<SubscribeOutcome, GotifySubscribeError> {
136 let ws = self.workspace.upgrade().ok_or(GotifySubscribeError::UnknownCallerProject)?;
137 let cfg = ws.gotify_config().ok_or(GotifySubscribeError::NotConfigured)?;
138 let (project, team_role, durable) =
139 resolve_identity(&ws, caller).ok_or(GotifySubscribeError::UnknownCallerProject)?;
140 let sub = GotifySubscription {
141 id: Uuid::new_v4(),
142 project,
143 team_role,
144 applications,
145 min_priority,
146 created_at: SystemTime::now(),
147 };
148 let names_applications = !sub.applications.is_empty();
149 let id = sub.id;
150 let applications = sub.applications.clone();
151 ws.add_gotify_subscription(sub, durable);
152 ws.start_gotify_subsystem();
156 let names_resolve = if names_applications {
160 refresh_app_index(&SubsystemHost::new(&ws), &cfg).await
161 } else {
162 true
163 };
164 Ok(SubscribeOutcome { id, applications, min_priority, names_resolve })
165 }
166
167 fn list(&self, caller: &SessionSlot) -> Vec<GotifySubscription> {
168 let Some(ws) = self.workspace.upgrade() else { return Vec::new() };
169 let Some(cx) = caller_context(&ws, caller) else { return Vec::new() };
170 ws.gotify_subscriptions_for_project(&cx.project_name)
174 .into_iter()
175 .filter(|s| s.team_role == cx.worker_label)
176 .collect()
177 }
178
179 fn unsubscribe(
180 &self,
181 caller: &SessionSlot,
182 id: Uuid,
183 ) -> Result<GotifySubscription, GotifyUnsubscribeError> {
184 let ws = self.workspace.upgrade().ok_or(GotifyUnsubscribeError::UnknownCallerProject)?;
185 let cx = caller_context(&ws, caller).ok_or(GotifyUnsubscribeError::UnknownCallerProject)?;
186 let removed = ws.remove_gotify_subscription_owned_by(
187 &cx.project_name,
188 id,
189 cx.worker_label.as_deref(),
190 );
191 ws.stop_gotify_subsystem_if_idle();
192 if let Some(removed) = removed {
193 return Ok(removed);
194 }
195 if ws.gotify_subscriptions_for_project(&cx.project_name).iter().any(|s| s.id == id) {
199 Err(GotifyUnsubscribeError::NotOwnedByCaller)
200 } else {
201 Err(GotifyUnsubscribeError::NoSuchSubscription)
202 }
203 }
204
205 async fn apps(&self) -> Result<Vec<String>, GotifyReadError> {
206 let ws = self.workspace.upgrade().ok_or(GotifyReadError::NotConfigured)?;
207 let cfg = ws.gotify_config().ok_or(GotifyReadError::NotConfigured)?;
208 forge_connectors::gotify::app_names(&SubsystemHost::new(&ws), &cfg)
209 .await
210 .map_err(|err| GotifyReadError::Fetch(format!("{err:#}")))
211 }
212
213 async fn recent(
214 &self,
215 applications: Vec<String>,
216 min_priority: Option<u8>,
217 limit: usize,
218 ) -> Result<Vec<GotifyRecent>, GotifyReadError> {
219 let ws = self.workspace.upgrade().ok_or(GotifyReadError::NotConfigured)?;
220 let cfg = ws.gotify_config().ok_or(GotifyReadError::NotConfigured)?;
221 forge_connectors::gotify::recent_messages(
222 &SubsystemHost::new(&ws),
223 &cfg,
224 &applications,
225 min_priority,
226 limit,
227 )
228 .await
229 .map_err(|err| GotifyReadError::Fetch(format!("{err:#}")))
230 }
231}
232
233pub(crate) fn resolve_identity(
238 ws: &Workspace,
239 caller: &SessionSlot,
240) -> Option<(String, Option<String>, bool)> {
241 let cx = caller_context(ws, caller)?;
242 let worker_label = if cx.is_lead {
243 None
244 } else {
245 ws.list_live_workers(&cx.project_key)
246 .into_iter()
247 .find(|w| w.slot == *caller)
248 .map(|w| w.label)
249 };
250 let dynamic_labels: Vec<String> =
251 ws.worker_rows_for_project(&cx.project_key).into_iter().map(|w| w.label).collect();
252 let (team_role, durable) = durable_identity(worker_label.as_deref(), &dynamic_labels);
253 Some((cx.project_name, team_role, durable))
254}
255
256fn durable_identity(
261 worker_label: Option<&str>,
262 dynamic_labels: &[String],
263) -> (Option<String>, bool) {
264 match worker_label {
265 None => (None, true),
266 Some(label) => {
267 let durable = dynamic_labels.iter().any(|t| t == label);
268 (Some(label.to_owned()), durable)
269 }
270 }
271}
272
273#[cfg(test)]
278type SubscribeCall = (SessionSlot, Vec<String>, Option<u8>);
279
280#[cfg(test)]
282type RecentCall = (Vec<String>, Option<u8>, usize);
283
284#[cfg(test)]
285#[derive(Default)]
286pub(crate) struct MockGotifyFacade {
287 pub subs: parking_lot::Mutex<Vec<GotifySubscription>>,
288 pub subscribe_calls: parking_lot::Mutex<Vec<SubscribeCall>>,
289 pub subscribe_result:
290 parking_lot::Mutex<Option<Result<SubscribeOutcome, GotifySubscribeError>>>,
291 pub unsubscribe_calls: parking_lot::Mutex<Vec<(SessionSlot, Uuid)>>,
292 pub unsubscribe_result:
293 parking_lot::Mutex<Option<Result<GotifySubscription, GotifyUnsubscribeError>>>,
294 pub apps_result: parking_lot::Mutex<Option<Result<Vec<String>, GotifyReadError>>>,
295 pub recent_calls: parking_lot::Mutex<Vec<RecentCall>>,
296 pub recent_result: parking_lot::Mutex<Option<Result<Vec<GotifyRecent>, GotifyReadError>>>,
297}
298
299#[cfg(test)]
300impl MockGotifyFacade {
301 pub(crate) fn new() -> Self {
302 Self::default()
303 }
304 pub(crate) fn into_arc(self) -> Arc<dyn GotifyFacade> {
305 Arc::new(self)
306 }
307}
308
309#[cfg(test)]
310#[async_trait::async_trait]
311impl GotifyFacade for MockGotifyFacade {
312 async fn subscribe(
313 &self,
314 caller: &SessionSlot,
315 applications: Vec<String>,
316 min_priority: Option<u8>,
317 ) -> Result<SubscribeOutcome, GotifySubscribeError> {
318 self.subscribe_calls.lock().push((caller.clone(), applications.clone(), min_priority));
319 self.subscribe_result.lock().clone().unwrap_or_else(|| {
320 Ok(SubscribeOutcome {
321 id: Uuid::nil(),
322 applications,
323 min_priority,
324 names_resolve: true,
325 })
326 })
327 }
328
329 fn list(&self, _caller: &SessionSlot) -> Vec<GotifySubscription> {
330 self.subs.lock().clone()
331 }
332
333 fn unsubscribe(
334 &self,
335 caller: &SessionSlot,
336 id: Uuid,
337 ) -> Result<GotifySubscription, GotifyUnsubscribeError> {
338 self.unsubscribe_calls.lock().push((caller.clone(), id));
339 self.unsubscribe_result
340 .lock()
341 .clone()
342 .unwrap_or(Err(GotifyUnsubscribeError::NoSuchSubscription))
343 }
344
345 async fn apps(&self) -> Result<Vec<String>, GotifyReadError> {
346 self.apps_result.lock().clone().unwrap_or_else(|| Ok(Vec::new()))
347 }
348
349 async fn recent(
350 &self,
351 applications: Vec<String>,
352 min_priority: Option<u8>,
353 limit: usize,
354 ) -> Result<Vec<GotifyRecent>, GotifyReadError> {
355 self.recent_calls.lock().push((applications, min_priority, limit));
356 self.recent_result.lock().clone().unwrap_or_else(|| Ok(Vec::new()))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::WorkerEntry;
364 use crate::mcp::gotify::Unsubscribe;
365 use crate::mcp::test_support::text_of;
366 use crate::target::ProjectKey;
367 use forge_connectors::gotify::GotifyHost as _;
368 use forge_primitives::WorkerLiveness;
369 use forge_sdk::mcp::tool::{Tool as _, ToolInput};
370 use std::collections::HashMap;
371 use std::sync::atomic::{AtomicUsize, Ordering};
372
373 struct AppStub {
377 addr: std::net::SocketAddr,
378 application_fetches: Arc<AtomicUsize>,
379 }
380
381 impl AppStub {
382 fn start(status: &'static str, body: &'static str) -> Self {
383 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind app stub");
384 let addr = listener.local_addr().expect("app stub addr");
385 let application_fetches = Arc::new(AtomicUsize::new(0));
386 let counter = Arc::clone(&application_fetches);
387 std::thread::spawn(move || {
388 for socket in listener.incoming() {
389 let Ok(mut socket) = socket else { continue };
390 let mut raw = Vec::new();
391 let mut chunk = [0u8; 1024];
392 while !raw.windows(4).any(|w| w == b"\r\n\r\n") {
393 match std::io::Read::read(&mut socket, &mut chunk) {
394 Ok(0) | Err(_) => break,
395 Ok(read) => raw.extend_from_slice(&chunk[..read]),
396 }
397 }
398 let head = String::from_utf8_lossy(&raw);
399 let (status, body) = if head.starts_with("GET /application ") {
400 counter.fetch_add(1, Ordering::SeqCst);
401 (status, body)
402 } else {
403 ("404 Not Found", "{}")
404 };
405 let response = format!(
406 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
407 body.len()
408 );
409 let _ = std::io::Write::write_all(&mut socket, response.as_bytes());
410 }
411 });
412 Self { addr, application_fetches }
413 }
414
415 fn url(&self) -> String {
416 format!("http://{}", self.addr)
417 }
418
419 fn application_fetches(&self) -> usize {
420 self.application_fetches.load(Ordering::SeqCst)
421 }
422 }
423
424 fn subscribe_fixture(
428 apps_status: &'static str,
429 apps_body: &'static str,
430 ) -> (tempfile::TempDir, Arc<Workspace>, Arc<dyn GotifyFacade>, SessionSlot, AppStub) {
431 let dir = tempfile::tempdir().expect("tempdir");
432 let stub = AppStub::start(apps_status, apps_body);
433 let forge = crate::config::ensure_forge_data_dir(dir.path()).expect("forge/ dir");
434 std::fs::write(
435 forge.join("forge.toml"),
436 format!(
437 r#"
438[[orgs]]
439name = "TestOrg"
440accounts = ["acct-a"]
441
442[[orgs.projects]]
443name = "myproj"
444path = "/tmp/gotify-subscribe-refresh"
445
446[[accounts]]
447display_name = "acct-a"
448token = "t"
449models = ["claude-sonnet-5"]
450provider = "anthropic"
451
452[gotify]
453url = "{}"
454client_token = "Ctest"
455"#,
456 stub.url(),
457 ),
458 )
459 .expect("write forge.toml");
460 let config = crate::config::load_from_dir(dir.path()).expect("load the fixture config");
461 let (ws, _rx) =
462 Workspace::testing_stub_with_config(dir.path().to_owned(), config).expect("stub");
463 let facade = ProdGotifyFacade::from_arc(&ws);
464 (dir, ws, facade, SessionSlot::lead("TestOrg", "myproj"), stub)
465 }
466
467 #[tokio::test]
472 async fn subscribe_refreshes_the_app_index_so_a_new_application_matches() {
473 let (_dir, ws, facade, caller, stub) = subscribe_fixture(
474 "200 OK",
475 r#"[{"id":7,"name":"phone-agent","token":"A.x","description":"","image":""}]"#,
476 );
477 let host = SubsystemHost::new(&ws);
480 host.store_app_index(HashMap::new());
481
482 let outcome = facade
483 .subscribe(&caller, vec!["phone-agent".to_owned()], None)
484 .await
485 .expect("subscribe to the configured server");
486
487 assert!(outcome.names_resolve, "a refreshed index leaves the name filter resolvable");
488 assert_eq!(
489 outcome.applications,
490 ["phone-agent"],
491 "the outcome carries the filter that was stored, not an empty default",
492 );
493 assert_eq!(outcome.min_priority, None, "and the priority floor it registered");
494 assert_eq!(stub.application_fetches(), 1, "the subscribe refreshed the index");
495 let resolved = host.app_name(7);
496 let subs = ws.gotify_subscriptions_for_project("myproj");
497 let matched =
498 forge_connectors::gotify::matching_subscriptions(&subs, resolved.as_deref(), 5);
499 assert_eq!(
500 matched.len(),
501 1,
502 "a message from the newly created application matches its subscription: resolved={resolved:?}",
503 );
504 }
505
506 #[tokio::test]
509 async fn subscribe_without_application_names_does_not_fetch_the_index() {
510 let (_dir, _ws, facade, caller, stub) = subscribe_fixture("200 OK", "[]");
511
512 let outcome = facade
513 .subscribe(&caller, Vec::new(), Some(5))
514 .await
515 .expect("subscribe without filters");
516
517 assert!(outcome.names_resolve, "a match-any filter needs no names, so it always resolves");
518 assert!(outcome.applications.is_empty(), "a match-any filter names no applications");
519 assert_eq!(
520 outcome.min_priority,
521 Some(5),
522 "the outcome carries the priority floor that was stored",
523 );
524 assert_eq!(
525 stub.application_fetches(),
526 0,
527 "a match-any subscribe names no applications, so it must not fetch /application",
528 );
529 }
530
531 #[tokio::test]
535 async fn subscribe_reports_a_failed_index_refresh_and_keeps_the_subscription() {
536 let (_dir, ws, facade, caller, _stub) =
537 subscribe_fixture("500 Internal Server Error", "{}");
538
539 let outcome = facade
540 .subscribe(&caller, vec!["phone-agent".to_owned()], None)
541 .await
542 .expect("the subscription itself still succeeds");
543
544 assert!(
545 !outcome.names_resolve,
546 "an unrefreshable index leaves the name filter unresolvable, and that is reported",
547 );
548 assert_eq!(
549 ws.gotify_subscriptions_for_project("myproj").len(),
550 1,
551 "the subscription is created whether or not the index refreshed",
552 );
553 }
554
555 fn worker_entry(project: &str, label: &str) -> WorkerEntry {
556 WorkerEntry {
557 label: label.to_owned(),
558 charter: "watch".to_owned(),
559 slot: SessionSlot::worker("TestOrg", project, label),
560 session_id: None,
561 status: WorkerLiveness::Running,
562 spawned_at: SystemTime::UNIX_EPOCH,
563 spawned_by: SessionSlot::lead("TestOrg", project),
564 needs_tag: false,
565 is_git_repo_at_spawn: false,
566 cwd: None,
567 diagnostic: None,
568 kick: None,
569 }
570 }
571
572 fn fixture() -> (Arc<Workspace>, Arc<dyn GotifyFacade>, ProjectKey, SessionSlot, SessionSlot) {
575 let (ws, _rx) = Workspace::testing_stub();
576 ws.seed_test_project("myproj", "/tmp/gotify-scope");
577 let key =
578 ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("seeded view").key;
579 ws.record_connected_session("/tmp/gotify-scope", "lead-uuid", None);
580 ws.insert_live_worker(&key, worker_entry("myproj", "reviewer"));
581 ws.insert_live_worker(&key, worker_entry("myproj", "analyst"));
582 let facade = ProdGotifyFacade::from_arc(&ws);
583 (
584 ws,
585 facade,
586 key,
587 SessionSlot::lead("TestOrg", "myproj"),
588 SessionSlot::worker("TestOrg", "myproj", "reviewer"),
589 )
590 }
591
592 fn seed_sub(ws: &Workspace, team_role: Option<&str>) -> Uuid {
593 let sub = GotifySubscription {
594 id: Uuid::new_v4(),
595 project: "myproj".to_owned(),
596 team_role: team_role.map(str::to_owned),
597 applications: vec!["alerts".to_owned()],
598 min_priority: None,
599 created_at: SystemTime::UNIX_EPOCH,
600 };
601 let id = sub.id;
602 ws.add_gotify_subscription(sub, true);
603 id
604 }
605
606 #[test]
607 fn list_is_scoped_to_the_callers_own_subscriptions() {
608 let (ws, facade, _key, lead, worker) = fixture();
609 let lead_id = seed_sub(&ws, None);
610 let worker_id = seed_sub(&ws, Some("reviewer"));
611 seed_sub(&ws, Some("analyst"));
612
613 let worker_list = facade.list(&worker);
614 assert_eq!(
615 worker_list.iter().map(|s| s.id).collect::<Vec<_>>(),
616 vec![worker_id],
617 "a worker sees neither the lead's subscription nor a sibling's",
618 );
619 let lead_list = facade.list(&lead);
620 assert_eq!(
621 lead_list.iter().map(|s| s.id).collect::<Vec<_>>(),
622 vec![lead_id],
623 "a lead sees only its own subscriptions",
624 );
625 }
626
627 #[test]
628 fn unsubscribe_only_removes_the_callers_own_subscription() {
629 let (ws, facade, _key, lead, worker) = fixture();
630 let lead_id = seed_sub(&ws, None);
631 let worker_id = seed_sub(&ws, Some("reviewer"));
632 let sibling_id = seed_sub(&ws, Some("analyst"));
633
634 assert_eq!(
635 facade.unsubscribe(&worker, lead_id),
636 Err(GotifyUnsubscribeError::NotOwnedByCaller),
637 "a worker cannot unsubscribe the lead's, and the refusal says which case it is",
638 );
639 assert_eq!(
640 facade.unsubscribe(&worker, sibling_id),
641 Err(GotifyUnsubscribeError::NotOwnedByCaller),
642 "a worker cannot unsubscribe a sibling worker's",
643 );
644 assert_eq!(
645 facade.unsubscribe(&lead, worker_id),
646 Err(GotifyUnsubscribeError::NotOwnedByCaller),
647 "a lead cannot unsubscribe a worker's",
648 );
649 assert_eq!(
650 ws.gotify_subscriptions_for_project("myproj").len(),
651 3,
652 "a refused unsubscribe removes nothing",
653 );
654
655 let removed = facade
656 .unsubscribe(&worker, worker_id)
657 .expect("a worker unsubscribes its own, and gets the row back");
658 assert_eq!(removed.id, worker_id, "the echoed row is the one that went");
659 assert_eq!(removed.applications, ["alerts"], "carrying its effective filter");
660 facade.unsubscribe(&lead, lead_id).expect("a lead unsubscribes its own");
661 assert_eq!(
662 ws.gotify_subscriptions_for_project("myproj").iter().map(|s| s.id).collect::<Vec<_>>(),
663 vec![sibling_id],
664 "only the untouched sibling subscription remains",
665 );
666 }
667
668 #[test]
671 fn unsubscribe_refuses_an_id_the_project_never_had() {
672 let (_ws, facade, _key, lead, _worker) = fixture();
673
674 assert_eq!(
675 facade.unsubscribe(&lead, Uuid::from_u128(0xdead)),
676 Err(GotifyUnsubscribeError::NoSuchSubscription),
677 "an id with no entry in the project is reported as absent",
678 );
679 }
680
681 #[test]
685 fn unsubscribe_refuses_an_id_from_another_project_as_absent() {
686 let (ws, facade, _key, lead, _worker) = fixture();
687 ws.seed_test_project("otherproj", "/tmp/gotify-scope-other");
688 let other = GotifySubscription {
689 id: Uuid::new_v4(),
690 project: "otherproj".to_owned(),
691 team_role: None,
692 applications: vec!["alerts".to_owned()],
693 min_priority: None,
694 created_at: SystemTime::UNIX_EPOCH,
695 };
696 let other_id = other.id;
697 ws.add_gotify_subscription(other, true);
698
699 assert_eq!(
700 facade.unsubscribe(&lead, other_id),
701 Err(GotifyUnsubscribeError::NoSuchSubscription),
702 "an id that lives in another project is absent here, not a subscription of this project's",
703 );
704 }
705
706 #[tokio::test]
712 async fn unsubscribe_refusals_name_the_case_through_the_tool() {
713 let (ws, facade, _key, _lead, worker) = fixture();
714 let lead_id = seed_sub(&ws, None);
715 let tool = Unsubscribe { facade, slot: worker };
716
717 let not_yours =
718 tool.call(ToolInput { value: serde_json::json!({ "id": lead_id.to_string() }) }).await;
719 assert!(not_yours.is_error, "a refused unsubscribe is an error");
720 assert_eq!(
721 text_of(¬_yours),
722 format!("subscription {lead_id} exists in this project but was not subscribed by you"),
723 "a live subscription owned by another session must say so, not report it missing",
724 );
725
726 let ghost = Uuid::from_u128(0xdead);
727 let not_there =
728 tool.call(ToolInput { value: serde_json::json!({ "id": ghost.to_string() }) }).await;
729 assert!(not_there.is_error, "an unknown id is an error");
730 assert_eq!(
731 text_of(¬_there),
732 format!("no subscription with id {ghost} in this project"),
733 "an id the project never had must not read as another session's subscription",
734 );
735 }
736
737 #[test]
741 fn worker_teardown_clears_subscriptions_without_going_through_the_facade() {
742 let (ws, _facade, key, _lead, _worker) = fixture();
743 let lead_id = seed_sub(&ws, None);
744 let worker_id = seed_sub(&ws, Some("reviewer"));
745
746 ws.remove_gotify_subscriptions_for_worker(&key, "reviewer");
747
748 let remaining: Vec<Uuid> =
749 ws.gotify_subscriptions_for_project("myproj").iter().map(|s| s.id).collect();
750 assert!(!remaining.contains(&worker_id), "teardown removed the departing worker's");
751 assert!(remaining.contains(&lead_id), "the lead's subscription survives the despawn");
752 }
753
754 #[test]
755 fn worker_is_durable_when_persisted() {
756 let dynamic = vec!["scratch".to_owned()];
757 assert_eq!(durable_identity(Some("scratch"), &dynamic), (Some("scratch".to_owned()), true),);
758 }
759
760 #[test]
761 fn lead_is_durable_and_targets_itself() {
762 assert_eq!(durable_identity(None, &[]), (None, true));
763 }
764
765 #[test]
766 fn worker_without_a_row_is_ephemeral() {
767 assert_eq!(
770 durable_identity(Some("scratch"), &["reviewer".to_owned()]),
771 (Some("scratch".to_owned()), false),
772 );
773 }
774}