1use std::collections::BTreeMap;
11use std::sync::Arc;
12use std::time::Duration;
13
14use forge_connectors::slack::{AuthTest, MENTION_CURSOR, SlackApi, SlackClient, SlackHost};
15use forge_primitives::slack::{
16 DEFAULT_DM_POLL_SECONDS, DEFAULT_POLL_SECONDS, DEFAULT_STALE_AFTER_HOURS,
17 DEFAULT_THREAD_IDLE_DAYS, MIN_DM_POLL_SECONDS, SlackConfig, SlackDraft, SlackDraftAnswer,
18 SlackDraftEnding, SlackFollowedThread, SlackMessage, SlackSubscription,
19 SlackSubscriptionTarget, SlackThreadOwner, SlackThreadRecord, SlackWatchMode,
20};
21use uuid::Uuid;
22
23use crate::SessionSlot;
24use crate::workspace::Workspace;
25
26pub(crate) enum DraftResolution {
29 Answered(SlackDraftAnswer),
30 Abandoned,
31}
32
33const DELIVERY_REMEMBER: Duration = Duration::from_secs(300);
37
38const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
39
40fn thread_idle_days(cursor: &str) -> u64 {
45 let now = std::time::SystemTime::now()
46 .duration_since(std::time::SystemTime::UNIX_EPOCH)
47 .map(|now| now.as_secs())
48 .unwrap_or_default();
49 let cursor_secs: u64 = cursor.split('.').next().and_then(|secs| secs.parse().ok()).unwrap_or(0);
50 now.saturating_sub(cursor_secs) / SECONDS_PER_DAY
51}
52
53#[derive(Default)]
60pub struct SlackWorkspaces {
61 clients: BTreeMap<String, Arc<dyn SlackApi>>,
62}
63
64impl std::fmt::Debug for SlackWorkspaces {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("SlackWorkspaces").field("labels", &self.labels()).finish()
67 }
68}
69
70impl SlackWorkspaces {
71 pub fn from_config(configs: &[SlackConfig], http: &reqwest::Client) -> Result<Self, String> {
75 validate_configs(configs)?;
76 let clients: BTreeMap<String, Arc<dyn SlackApi>> = configs
77 .iter()
78 .map(|config| {
79 let client = Arc::new(SlackClient::new(http.clone(), config.token.clone()));
80 (config.workspace.trim().to_owned(), client as Arc<dyn SlackApi>)
81 })
82 .collect();
83 Ok(Self { clients })
84 }
85
86 #[cfg(test)]
89 pub(crate) fn from_apis(apis: BTreeMap<String, Arc<dyn SlackApi>>) -> Self {
90 Self { clients: apis }
91 }
92
93 pub fn is_empty(&self) -> bool {
94 self.clients.is_empty()
95 }
96
97 pub fn labels(&self) -> Vec<String> {
99 self.clients.keys().cloned().collect()
100 }
101
102 pub fn client(&self, label: &str) -> Option<Arc<dyn SlackApi>> {
103 self.clients.get(label).cloned()
104 }
105
106 pub async fn verify_all(&self) -> Vec<(String, Result<AuthTest, String>)> {
109 let mut out = Vec::new();
110 for (label, client) in &self.clients {
111 let result = client.auth_test().await.map_err(|err| err.to_string());
112 out.push((label.clone(), result));
113 }
114 out
115 }
116}
117
118pub(crate) fn validate_configs(configs: &[SlackConfig]) -> Result<(), String> {
122 let mut labels: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
123 for config in configs {
124 let label = config.workspace.trim();
125 if label.is_empty() {
126 return Err("a [[slack]] entry has an empty workspace label".to_owned());
127 }
128 if config.token.trim().is_empty() {
129 return Err(format!("slack workspace '{label}' has an empty token"));
130 }
131 if !labels.insert(label) {
132 return Err(format!("two [[slack]] entries share the label '{label}'"));
133 }
134 }
135 Ok(())
136}
137
138fn projects_of(subscriptions: &[SlackSubscription]) -> Vec<String> {
141 let mut projects: Vec<String> = Vec::new();
142 for sub in subscriptions {
143 if !projects.contains(&sub.project) {
144 projects.push(sub.project.clone());
145 }
146 }
147 projects
148}
149
150impl Workspace {
151 pub(crate) fn add_slack_subscription(
155 &self,
156 sub: forge_primitives::slack::SlackSubscription,
157 durable: bool,
158 ) {
159 let project = sub.project.clone();
160 if durable
161 && let Some(db) = self.db.lock().as_ref()
162 && let Err(error) = crate::store::slack::insert(db, &sub)
163 {
164 tracing::warn!(
165 target: "forge_workspace::slack",
166 %error,
167 "persisting a Slack subscription failed",
168 );
169 }
170 self.slack_subs.lock().push(sub);
171 self.announce_connector_subscriptions_changed(&project);
172 }
173
174 pub(crate) fn slack_delivery_seen(
180 &self,
181 project: &str,
182 team_role: Option<&str>,
183 message: &SlackMessage,
184 ) -> bool {
185 let key = (
186 project.to_owned(),
187 team_role.map(str::to_owned),
188 message.conversation.clone(),
189 message.ts.clone(),
190 message.edited.clone(),
191 );
192 let mut seen = self.slack_recently_delivered.lock();
193 seen.retain(|_, seen_at| seen_at.elapsed() < DELIVERY_REMEMBER);
194 seen.contains_key(&key)
195 }
196
197 pub(crate) fn slack_delivery_commit(
202 &self,
203 project: &str,
204 team_role: Option<&str>,
205 messages: &[SlackMessage],
206 ) {
207 let mut seen = self.slack_recently_delivered.lock();
208 seen.retain(|_, seen_at| seen_at.elapsed() < DELIVERY_REMEMBER);
209 for message in messages {
210 let key = (
211 project.to_owned(),
212 team_role.map(str::to_owned),
213 message.conversation.clone(),
214 message.ts.clone(),
215 message.edited.clone(),
216 );
217 seen.insert(key, std::time::Instant::now());
218 }
219 }
220
221 pub(crate) fn register_slack_draft(
226 &self,
227 caller: &SessionSlot,
228 draft: SlackDraft,
229 ) -> (Uuid, tokio::sync::oneshot::Receiver<SlackDraftAnswer>) {
230 let (sender, receiver) = tokio::sync::oneshot::channel();
231 let id = draft.id;
232 self.slack_drafts.lock().insert(id, (caller.clone(), draft.clone(), sender));
233 let answerable =
234 self.update_sender().send_answering(crate::protocol::SessionUpdate::SlackPostPending {
235 key: caller.clone(),
236 draft,
237 });
238 if !answerable {
239 self.slack_drafts.lock().remove(&id);
244 let (_ignored_sender, dead_receiver) = tokio::sync::oneshot::channel();
245 return (id, dead_receiver);
246 }
247 self.note_ask(caller);
250 (id, receiver)
251 }
252
253 pub(crate) fn slack_draft_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool {
256 self.slack_drafts.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
257 }
258
259 pub(crate) fn remove_slack_subscriptions_for_worker(
264 &self,
265 project_key: &crate::target::ProjectKey,
266 label: &str,
267 ) {
268 let Some(project_name) = self
269 .list_projects()
270 .into_iter()
271 .find(|view| view.key == *project_key)
272 .map(|view| view.name)
273 else {
274 tracing::warn!(
275 target: "forge_workspace::slack",
276 project = %project_key.as_str(),
277 label,
278 "could not resolve a project name at worker teardown; its Slack subscriptions may be stranded",
279 );
280 return;
281 };
282
283 let removed: Vec<SlackSubscription> = {
284 let mut subs = self.slack_subs.lock();
285 let mut removed = Vec::new();
286 subs.retain(|sub| {
287 let owned = sub.project == project_name && sub.team_role.as_deref() == Some(label);
288 if owned {
289 removed.push(sub.clone());
290 }
291 !owned
292 });
293 removed
294 };
295
296 if removed.is_empty() {
297 return;
298 }
299 let mut workspaces: std::collections::HashSet<String> = std::collections::HashSet::new();
300 for sub in &removed {
301 if let Some(db) = self.db.lock().as_ref()
302 && let Err(error) = crate::store::slack::remove(db, sub.id)
303 {
304 tracing::warn!(
305 target: "forge_workspace::slack",
306 %error,
307 "removing a persisted Slack subscription failed",
308 );
309 }
310 let _ = workspaces.insert(sub.workspace.clone());
311 }
312 for sub in &removed {
316 self.prune_slack_threads(&sub.workspace);
317 self.clear_slack_cursors_after_removal(sub);
318 }
319 self.announce_connector_subscriptions_changed(&project_name);
320 }
321
322 pub(crate) fn resolve_slack_draft(
336 &self,
337 id: Uuid,
338 caller: &SessionSlot,
339 resolution: DraftResolution,
340 ) -> bool {
341 let mut drafts = self.slack_drafts.lock();
342 if !drafts.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
343 return false;
344 }
345 let Some((owner, _, sender)) = drafts.remove(&id) else { return false };
346 drop(drafts);
347 let ending = match &resolution {
348 DraftResolution::Answered(SlackDraftAnswer::Approve) => {
349 SlackDraftEnding::Answered { approved: true }
350 }
351 DraftResolution::Answered(SlackDraftAnswer::Decline) => {
352 SlackDraftEnding::Answered { approved: false }
353 }
354 DraftResolution::Answered(SlackDraftAnswer::SendBack { .. }) => {
355 SlackDraftEnding::SentBack
356 }
357 DraftResolution::Abandoned => SlackDraftEnding::Abandoned,
358 };
359 if let DraftResolution::Answered(answer) = resolution {
361 let _ = sender.send(answer);
362 }
363 let _ = self.update_sender().send(crate::protocol::SessionUpdate::SlackDraftResolved {
364 key: owner,
365 id,
366 ending,
367 });
368 true
369 }
370
371 pub(crate) fn set_slack_watermark(&self, workspace: &str, conversation: &str, ts: &str) {
374 let db = self.db.lock();
375 let Some(db) = db.as_ref() else { return };
376 if let Err(error) = crate::store::slack::set_watermark(db, workspace, conversation, ts) {
377 tracing::warn!(
378 target: "forge_workspace::slack",
379 %error,
380 "writing a Slack watermark failed",
381 );
382 }
383 }
384
385 pub(crate) fn record_slack_own_post(&self, workspace: &str, conversation: &str, ts: &str) {
389 let db = self.db.lock();
390 let Some(db) = db.as_ref() else { return };
391 let now = std::time::SystemTime::now()
392 .duration_since(std::time::UNIX_EPOCH)
393 .map(|now| now.as_secs())
394 .unwrap_or_default();
395 if let Err(error) =
396 crate::store::slack::record_own_post(db, workspace, conversation, ts, now)
397 {
398 tracing::warn!(
399 target: "forge_workspace::slack",
400 workspace,
401 conversation = %conversation,
402 %error,
403 "writing a Slack own-post ledger row failed",
404 );
405 }
406 }
407
408 pub(crate) fn slack_own_post(&self, workspace: &str, conversation: &str, ts: &str) -> bool {
414 let db = self.db.lock();
415 let Some(db) = db.as_ref() else { return false };
416 match crate::store::slack::own_post(db, workspace, conversation, ts) {
417 Ok(own) => {
418 self.slack_ledger_failures
421 .lock()
422 .remove(&(workspace.to_owned(), conversation.to_owned()));
423 own
424 }
425 Err(error) => {
426 let first = self
427 .slack_ledger_failures
428 .lock()
429 .insert((workspace.to_owned(), conversation.to_owned()));
430 if first {
431 tracing::warn!(
432 target: "forge_workspace::slack",
433 event_name = "slack_own_post_ledger_unreadable",
434 workspace,
435 conversation = %conversation,
436 %error,
437 "reading the Slack own-post ledger failed; own posts are treated as not ours until it reads",
438 );
439 } else {
440 tracing::debug!(
441 target: "forge_workspace::slack",
442 workspace,
443 conversation = %conversation,
444 %error,
445 "reading the Slack own-post ledger failed again",
446 );
447 }
448 false
449 }
450 }
451 }
452
453 pub(crate) fn begin_slack_post(
459 self: &Arc<Self>,
460 workspace: &str,
461 conversation: &str,
462 ) -> SlackPostInFlight {
463 let key = (workspace.to_owned(), conversation.to_owned());
464 let id = NEXT_POST_STAMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
465 self.slack_inflight.lock().entry(key.clone()).or_default().push(SlackPostStamp {
466 id,
467 since: std::time::Instant::now(),
468 warned: false,
469 });
470 SlackPostInFlight { workspace: Arc::clone(self), key, id }
471 }
472
473 pub(crate) fn slack_post_in_flight(
479 &self,
480 workspace: &str,
481 conversation: &str,
482 ) -> Option<std::time::Duration> {
483 let mut inflight = self.slack_inflight.lock();
484 let mut empty: Vec<(String, String)> = Vec::new();
485 let mut stalled: Vec<(String, String, std::time::Duration)> = Vec::new();
486 let mut oldest: Option<std::time::Duration> = None;
487 for ((marked_workspace, marked_address), stamps) in inflight.iter_mut() {
488 stamps
489 .retain(|stamp| stamp.since.elapsed() <= forge_connectors::slack::REQUEST_TIMEOUT);
490 if stamps.is_empty() {
491 empty.push((marked_workspace.clone(), marked_address.clone()));
492 continue;
493 }
494 if marked_workspace != workspace || marked_address != conversation {
495 continue;
496 }
497 for stamp in stamps.iter_mut() {
498 let age = stamp.since.elapsed();
499 if age > forge_connectors::slack::REQUEST_TIMEOUT / 2 && !stamp.warned {
500 stamp.warned = true;
501 stalled.push((marked_workspace.clone(), marked_address.clone(), age));
502 }
503 if oldest.is_none_or(|current| age > current) {
504 oldest = Some(age);
505 }
506 }
507 }
508 for key in empty {
509 inflight.remove(&key);
510 tracing::warn!(
511 target: "forge_workspace::slack",
512 event_name = "slack_post_mark_expired",
513 workspace = %key.0,
514 conversation = %key.1,
515 "a post's in-flight mark outlived its request timeout; the conversation is swept again",
516 );
517 }
518 drop(inflight);
519 for (workspace, conversation, age) in stalled {
520 tracing::warn!(
521 target: "forge_workspace::slack",
522 event_name = "slack_post_in_flight_stalled",
523 workspace = %workspace,
524 conversation = %conversation,
525 age_ms = age.as_millis(),
526 "a post has been in flight for most of its request budget; the conversation is being skipped",
527 );
528 }
529 oldest
530 }
531
532 pub(crate) fn name_slack_conversation(&self, workspace: &str, conversation: &str, name: &str) {
538 let updated: Vec<SlackSubscription> = {
539 let mut subs = self.slack_subs.lock();
540 let mut updated = Vec::new();
541 for sub in subs.iter_mut() {
542 if sub.workspace != workspace {
543 continue;
544 }
545 if let SlackSubscriptionTarget::Conversation { id, name: stored @ None, .. } =
546 &mut sub.target
547 && id.as_str() == conversation
548 {
549 *stored = Some(name.to_owned());
550 updated.push(sub.clone());
551 }
552 }
553 updated
554 };
555 if updated.is_empty() {
556 return;
557 }
558 for project in projects_of(&updated) {
564 self.announce_connector_subscriptions_changed(&project);
565 }
566 let db = self.db.lock();
567 let Some(db) = db.as_ref() else { return };
568 let durable: Vec<Uuid> = match crate::store::slack::list(db) {
571 Ok(stored) => stored.into_iter().map(|sub| sub.id).collect(),
572 Err(error) => {
573 tracing::warn!(
574 target: "forge_workspace::slack",
575 %error,
576 "reading Slack subscriptions to heal a name failed",
577 );
578 return;
579 }
580 };
581 for sub in &updated {
582 if !durable.contains(&sub.id) {
583 continue;
584 }
585 if let Err(error) = crate::store::slack::insert(db, sub) {
586 tracing::warn!(
587 target: "forge_workspace::slack",
588 %error,
589 "writing a Slack conversation name failed",
590 );
591 }
592 }
593 }
594
595 pub(crate) fn slack_has_cursor(&self, workspace: &str, conversation: &str) -> bool {
600 let db = self.db.lock();
601 let Some(db) = db.as_ref() else { return false };
602 matches!(crate::store::slack::watermark(db, workspace, conversation), Ok(Some(_)))
603 }
604
605 pub(crate) fn follow_slack_thread(
612 &self,
613 workspace: &str,
614 conversation: &str,
615 parent_ts: &str,
616 owner: forge_primitives::slack::SlackThreadOwner,
617 since: &str,
618 ) {
619 let db = self.db.lock();
620 let Some(db) = db.as_ref() else { return };
621 let created;
622 let mut record = match crate::store::slack::thread(db, workspace, conversation, parent_ts) {
623 Ok(Some(record)) => {
624 created = false;
625 record
626 }
627 Ok(None) => {
628 if thread_idle_days(since) >= self.slack_thread_idle_days(workspace) {
633 return;
634 }
635 created = true;
636 forge_primitives::slack::SlackThreadRecord {
637 cursor: since.to_owned(),
638 edit_watermark: Some(since.to_owned()),
639 owners: Vec::new(),
640 participating: false,
641 }
642 }
643 Err(error) => {
644 tracing::warn!(
645 target: "forge_workspace::slack",
646 %error,
647 "reading a Slack thread failed; the thread stays unfollowed",
648 );
649 return;
650 }
651 };
652 if record.owners.contains(&owner) {
653 return;
654 }
655 record.owners.push(owner);
656 if let Err(error) =
657 crate::store::slack::set_thread(db, workspace, conversation, parent_ts, &record)
658 {
659 tracing::warn!(
660 target: "forge_workspace::slack",
661 %error,
662 "writing a Slack thread failed",
663 );
664 return;
665 }
666 if created {
667 let key = (workspace.to_owned(), conversation.to_owned(), parent_ts.to_owned());
668 self.slack_thread_edit_watermarks.lock().insert(key.clone(), since.to_owned());
671 self.slack_thread_first_walks.lock().insert(key);
672 }
673 }
674
675 fn forget_thread_clocks(&self, workspace: &str, conversation: &str, parent_ts: &str) {
678 let key = (workspace.to_owned(), conversation.to_owned(), parent_ts.to_owned());
679 self.slack_thread_full_reads.lock().remove(&key);
680 self.slack_thread_first_walks.lock().remove(&key);
681 self.slack_thread_edit_watermarks.lock().remove(&key);
682 }
683
684 pub(crate) fn mark_slack_thread_participating(
687 &self,
688 workspace: &str,
689 conversation: &str,
690 parent_ts: &str,
691 ) {
692 let db = self.db.lock();
693 let Some(db) = db.as_ref() else { return };
694 let Ok(Some(mut record)) =
695 crate::store::slack::thread(db, workspace, conversation, parent_ts)
696 else {
697 return;
698 };
699 if record.participating {
700 return;
701 }
702 record.participating = true;
703 if let Err(error) =
704 crate::store::slack::set_thread(db, workspace, conversation, parent_ts, &record)
705 {
706 tracing::warn!(
707 target: "forge_workspace::slack",
708 %error,
709 "writing a Slack thread failed",
710 );
711 }
712 }
713
714 fn slack_thread_idle_days(&self, workspace: &str) -> u64 {
718 self.config_arc()
719 .slack
720 .iter()
721 .find(|config| config.workspace.trim() == workspace)
722 .map_or(DEFAULT_THREAD_IDLE_DAYS, |config| config.thread_idle_days)
723 }
724
725 pub(crate) fn slack_stale_after(&self, workspace: &str) -> Duration {
728 let hours = self
729 .config_arc()
730 .slack
731 .iter()
732 .find(|config| config.workspace.trim() == workspace)
733 .map_or(DEFAULT_STALE_AFTER_HOURS, |config| config.stale_after_hours);
734 Duration::from_secs(hours.saturating_mul(3_600))
735 }
736
737 pub(crate) fn slack_followed_threads(
742 &self,
743 workspace: &str,
744 conversation: &str,
745 ) -> Vec<forge_primitives::slack::SlackFollowedThread> {
746 use forge_primitives::slack::SlackFollowedThread;
747 let db = self.db.lock();
748 let Some(db) = db.as_ref() else { return Vec::new() };
749 let rows = match crate::store::slack::threads_for(db, workspace, conversation) {
750 Ok(rows) => rows,
751 Err(error) => {
752 tracing::warn!(
753 target: "forge_workspace::slack",
754 %error,
755 "reading Slack threads failed",
756 );
757 return Vec::new();
758 }
759 };
760 let alive_owners: std::collections::HashSet<(String, Option<String>)> = self
761 .slack_subs
762 .lock()
763 .iter()
764 .filter(|sub| sub.workspace == workspace)
765 .map(|sub| (sub.project.clone(), sub.team_role.clone()))
766 .collect();
767 let mut out = Vec::new();
768 for (parent_ts, mut record) in rows {
769 record.owners.retain(|owner| {
770 alive_owners.contains(&(owner.project.clone(), owner.team_role.clone()))
771 });
772 if record.owners.is_empty() {
773 let _ = crate::store::slack::remove_thread(db, workspace, conversation, &parent_ts);
774 self.forget_thread_clocks(workspace, conversation, &parent_ts);
775 continue;
776 }
777 if thread_idle_days(&record.cursor) >= self.slack_thread_idle_days(workspace) {
778 let _ = crate::store::slack::remove_thread(db, workspace, conversation, &parent_ts);
779 self.forget_thread_clocks(workspace, conversation, &parent_ts);
780 tracing::debug!(
781 target: "forge_workspace::slack",
782 workspace,
783 conversation,
784 parent = %parent_ts,
785 "dropping an idle Slack thread",
786 );
787 continue;
788 }
789 out.push(SlackFollowedThread {
790 parent_ts,
791 owners: record.owners,
792 participating: record.participating,
793 });
794 }
795 out
796 }
797
798 pub(crate) fn slack_thread_watermark(
800 &self,
801 workspace: &str,
802 conversation: &str,
803 parent_ts: &str,
804 ) -> anyhow::Result<Option<String>> {
805 let db = self.db.lock();
806 let Some(db) = db.as_ref() else { return Ok(None) };
807 crate::store::slack::thread(db, workspace, conversation, parent_ts)
808 .map(|record| record.map(|record| record.cursor))
809 }
810
811 pub(crate) fn set_slack_thread_watermark(
814 &self,
815 workspace: &str,
816 conversation: &str,
817 parent_ts: &str,
818 ts: &str,
819 ) {
820 let db = self.db.lock();
821 let Some(db) = db.as_ref() else { return };
822 let record = match crate::store::slack::thread(db, workspace, conversation, parent_ts) {
823 Ok(Some(record)) => record,
824 Ok(None) => forge_primitives::slack::SlackThreadRecord {
825 cursor: ts.to_owned(),
826 edit_watermark: None,
827 owners: Vec::new(),
828 participating: false,
829 },
830 Err(error) => {
831 tracing::warn!(
832 target: "forge_workspace::slack",
833 %error,
834 "reading a Slack thread failed; the cursor was not advanced",
835 );
836 return;
837 }
838 };
839 let advanced = forge_primitives::slack::SlackThreadRecord {
840 cursor: ts.to_owned(),
841 edit_watermark: record.edit_watermark,
842 owners: record.owners,
843 participating: record.participating,
844 };
845 if let Err(error) =
846 crate::store::slack::set_thread(db, workspace, conversation, parent_ts, &advanced)
847 {
848 tracing::warn!(
849 target: "forge_workspace::slack",
850 %error,
851 "writing a Slack thread cursor failed",
852 );
853 }
854 }
855
856 pub(crate) fn slack_thread_edit_watermark(
860 &self,
861 workspace: &str,
862 conversation: &str,
863 parent_ts: &str,
864 ) -> Option<String> {
865 let key = (workspace.to_owned(), conversation.to_owned(), parent_ts.to_owned());
866 if let Some(mark) = self.slack_thread_edit_watermarks.lock().get(&key).cloned() {
867 return Some(mark);
868 }
869 let db = self.db.lock();
870 let db = db.as_ref()?;
871 let mut record = match crate::store::slack::thread(db, workspace, conversation, parent_ts) {
872 Ok(Some(record)) => record,
873 Ok(None) => return None,
874 Err(error) => {
875 tracing::warn!(
876 target: "forge_workspace::slack",
877 event_name = "slack_thread_edit_stamp_read_failed",
878 workspace,
879 conversation,
880 parent = %parent_ts,
881 %error,
882 "reading a Slack thread failed; its edits are judged from the beginning",
883 );
884 return None;
885 }
886 };
887 let mark = if let Some(mark) = record.edit_watermark.clone() {
893 mark
894 } else {
895 let seed = record.cursor.clone();
896 record.edit_watermark = Some(seed.clone());
897 if let Err(error) =
898 crate::store::slack::set_thread(db, workspace, conversation, parent_ts, &record)
899 {
900 tracing::warn!(
901 target: "forge_workspace::slack",
902 event_name = "slack_thread_edit_stamp_seed_failed",
903 workspace,
904 conversation,
905 parent = %parent_ts,
906 %error,
907 "writing a legacy Slack thread's edit stamp failed",
908 );
909 }
910 seed
911 };
912 self.slack_thread_edit_watermarks.lock().insert(key, mark.clone());
913 Some(mark)
914 }
915
916 pub(crate) fn set_slack_thread_edit_watermark(
921 &self,
922 workspace: &str,
923 conversation: &str,
924 parent_ts: &str,
925 ts: &str,
926 ) {
927 let key = (workspace.to_owned(), conversation.to_owned(), parent_ts.to_owned());
928 let db = self.db.lock();
929 let Some(db) = db.as_ref() else {
930 self.cache_slack_thread_edit_watermark(&key, ts);
931 return;
932 };
933 let mut record = match crate::store::slack::thread(db, workspace, conversation, parent_ts) {
934 Ok(Some(record)) => record,
935 Ok(None) => {
938 self.cache_slack_thread_edit_watermark(&key, ts);
939 return;
940 }
941 Err(error) => {
942 tracing::warn!(
943 target: "forge_workspace::slack",
944 event_name = "slack_thread_edit_stamp_advance_read_failed",
945 workspace,
946 conversation,
947 parent = %parent_ts,
948 %error,
949 "reading a Slack thread failed; its edit stamp was not advanced",
950 );
951 return;
952 }
953 };
954 if record.edit_watermark.as_deref().is_some_and(|mark| ts <= mark) {
955 if let Some(mark) = record.edit_watermark {
956 self.cache_slack_thread_edit_watermark(&key, &mark);
957 }
958 return;
959 }
960 record.edit_watermark = Some(ts.to_owned());
961 if let Err(error) =
962 crate::store::slack::set_thread(db, workspace, conversation, parent_ts, &record)
963 {
964 tracing::warn!(
965 target: "forge_workspace::slack",
966 event_name = "slack_thread_edit_stamp_advance_write_failed",
967 workspace,
968 conversation,
969 parent = %parent_ts,
970 %error,
971 "writing a Slack thread's edit stamp failed; a restart may re-offer it",
972 );
973 }
974 self.cache_slack_thread_edit_watermark(&key, ts);
975 }
976
977 fn cache_slack_thread_edit_watermark(&self, key: &(String, String, String), ts: &str) {
979 let mut marks = self.slack_thread_edit_watermarks.lock();
980 if marks.get(key).is_none_or(|mark| ts > mark.as_str()) {
981 marks.insert(key.clone(), ts.to_owned());
982 }
983 }
984
985 pub(crate) fn auto_subscribe_slack_conversation(
995 &self,
996 workspace: &str,
997 conversation: &str,
998 since: &str,
999 name: Option<&str>,
1000 ) -> bool {
1001 let owner = {
1002 let subs = self.slack_subs.lock();
1003 let owner = subs.iter().find(|sub| {
1004 sub.workspace == workspace
1005 && matches!(
1006 sub.target,
1007 forge_primitives::slack::SlackSubscriptionTarget::Mentions
1008 )
1009 });
1010 let Some(owner) = owner else { return false };
1011 let covered = subs.iter().any(|sub| {
1012 sub.workspace == workspace
1013 && sub.project == owner.project
1014 && sub.team_role == owner.team_role
1015 && matches!(
1016 &sub.target,
1017 forge_primitives::slack::SlackSubscriptionTarget::Conversation { id, .. }
1018 if id == conversation
1019 )
1020 });
1021 if covered {
1022 return false;
1023 }
1024 (owner.project.clone(), owner.team_role.clone())
1025 };
1026
1027 if !self.slack_has_cursor(workspace, conversation) {
1033 self.set_slack_watermark(workspace, conversation, since);
1034 }
1035
1036 self.add_slack_subscription(
1037 forge_primitives::slack::SlackSubscription {
1038 id: Uuid::new_v4(),
1039 workspace: workspace.to_owned(),
1040 project: owner.0,
1041 team_role: owner.1,
1042 target: forge_primitives::slack::SlackSubscriptionTarget::Conversation {
1043 id: conversation.to_owned(),
1044 name: name
1045 .filter(|label| !label.is_empty() && *label != conversation)
1046 .map(str::to_owned),
1047 mode: forge_primitives::slack::SlackWatchMode::All,
1048 },
1049 created_at: std::time::SystemTime::now(),
1050 },
1051 true,
1052 );
1053 true
1054 }
1055
1056 pub fn slack_connected_workspaces(&self) -> std::collections::BTreeMap<String, bool> {
1059 self.slack_connected.lock().clone()
1060 }
1061
1062 pub(crate) fn watch_slack_conversation(
1069 &self,
1070 workspace: &str,
1071 project: &str,
1072 team_role: Option<&str>,
1073 conversation: &str,
1074 mode: SlackWatchMode,
1075 durable: bool,
1076 ) -> SlackSubscription {
1077 let (sub, moved) = {
1081 let db = self.db.lock();
1082 let mut subs = self.slack_subs.lock();
1083 if let Some(existing) = subs.iter_mut().find(|sub| {
1084 sub.workspace == workspace
1085 && sub.project == project
1086 && sub.team_role.as_deref() == team_role
1087 && matches!(
1088 &sub.target,
1089 SlackSubscriptionTarget::Conversation { id, .. } if id == conversation
1090 )
1091 }) {
1092 let mut moved = false;
1093 if let SlackSubscriptionTarget::Conversation { mode: stored, .. } =
1094 &mut existing.target
1095 {
1096 moved = *stored != mode;
1099 *stored = mode;
1100 }
1101 if durable
1102 && let Some(db) = db.as_ref()
1103 && let Err(error) = crate::store::slack::insert(db, existing)
1104 {
1105 tracing::warn!(
1106 target: "forge_workspace::slack",
1107 %error,
1108 "persisting a Slack subscription failed",
1109 );
1110 }
1111 (existing.clone(), moved)
1112 } else {
1113 let sub = SlackSubscription {
1114 id: Uuid::new_v4(),
1115 workspace: workspace.to_owned(),
1116 project: project.to_owned(),
1117 team_role: team_role.map(str::to_owned),
1118 target: SlackSubscriptionTarget::Conversation {
1119 id: conversation.to_owned(),
1120 name: None,
1121 mode,
1122 },
1123 created_at: std::time::SystemTime::now(),
1124 };
1125 if durable
1126 && let Some(db) = db.as_ref()
1127 && let Err(error) = crate::store::slack::insert(db, &sub)
1128 {
1129 tracing::warn!(
1130 target: "forge_workspace::slack",
1131 %error,
1132 "persisting a Slack subscription failed",
1133 );
1134 }
1135 subs.push(sub.clone());
1136 (sub, true)
1137 }
1138 };
1139 if moved {
1140 self.announce_connector_subscriptions_changed(project);
1141 }
1142 sub
1143 }
1144
1145 pub fn slack_subscriptions_for_project(
1148 &self,
1149 project: &str,
1150 ) -> Vec<forge_primitives::slack::SlackSubscription> {
1151 self.slack_subs.lock().iter().filter(|s| s.project == project).cloned().collect()
1152 }
1153
1154 pub(crate) fn remove_slack_subscription_owned_by(
1161 &self,
1162 project: &str,
1163 id: Uuid,
1164 owner: Option<&str>,
1165 ) -> Option<SlackSubscription> {
1166 let removed_sub = {
1167 let subs = self.slack_subs.lock();
1168 subs.iter()
1169 .find(|s| s.id == id && s.project == project && s.team_role.as_deref() == owner)
1170 .cloned()
1171 };
1172 let removed_sub = removed_sub?;
1173 {
1174 let mut subs = self.slack_subs.lock();
1175 subs.retain(|s| s.id != id);
1176 }
1177 if let Some(db) = self.db.lock().as_ref()
1178 && let Err(error) = crate::store::slack::remove(db, id)
1179 {
1180 tracing::warn!(
1181 target: "forge_workspace::slack",
1182 %error,
1183 "removing a persisted Slack subscription failed",
1184 );
1185 }
1186 self.prune_slack_threads(&removed_sub.workspace);
1187 self.clear_slack_cursors_after_removal(&removed_sub);
1188 self.announce_connector_subscriptions_changed(project);
1189 Some(removed_sub)
1190 }
1191
1192 pub(crate) fn clear_slack_cursors_after_removal(&self, removed: &SlackSubscription) {
1199 let db_guard = self.db.lock();
1200 let Some(db) = db_guard.as_ref() else { return };
1201 let remaining = self.slack_subs.lock();
1202 let covered = |conversation: &str| {
1203 remaining.iter().any(|sub| {
1204 sub.workspace == removed.workspace
1205 && match &sub.target {
1206 SlackSubscriptionTarget::Conversation { id, .. } => id == conversation,
1207 SlackSubscriptionTarget::Mentions => conversation == MENTION_CURSOR,
1208 SlackSubscriptionTarget::DirectMessages => false,
1209 }
1210 })
1211 };
1212 let drop_cursor = |db: &crate::store::Db, conversation: &str| {
1213 match crate::store::slack::remove_watermark(db, &removed.workspace, conversation) {
1214 Ok(true) => tracing::debug!(
1215 target: "forge_workspace::slack",
1216 workspace = %removed.workspace,
1217 conversation,
1218 "cleared a sweep cursor with no watcher left",
1219 ),
1220 Ok(false) => {}
1221 Err(error) => tracing::warn!(
1222 target: "forge_workspace::slack",
1223 %error,
1224 "clearing a Slack sweep cursor failed",
1225 ),
1226 }
1227 };
1228 match &removed.target {
1229 SlackSubscriptionTarget::Conversation { id, .. } => {
1230 if !covered(id) {
1231 drop_cursor(db, id);
1232 }
1233 }
1234 SlackSubscriptionTarget::Mentions => {
1235 if !covered(MENTION_CURSOR) {
1236 drop_cursor(db, MENTION_CURSOR);
1237 }
1238 }
1239 SlackSubscriptionTarget::DirectMessages => {
1240 let Ok(cursors) =
1243 crate::store::slack::watermark_conversations(db, &removed.workspace)
1244 else {
1245 tracing::warn!(
1246 target: "forge_workspace::slack",
1247 workspace = %removed.workspace,
1248 "reading Slack cursors for cleanup failed",
1249 );
1250 return;
1251 };
1252 for conversation in cursors {
1253 if !covered(&conversation) {
1254 drop_cursor(db, &conversation);
1255 }
1256 }
1257 }
1258 }
1259 }
1260
1261 fn prune_slack_threads(&self, workspace: &str) {
1271 let db_guard = self.db.lock();
1272 let Some(db) = db_guard.as_ref() else { return };
1273 let Ok(rows) = crate::store::slack::threads_for_workspace(db, workspace) else {
1274 tracing::warn!(
1275 target: "forge_workspace::slack",
1276 workspace,
1277 "reading Slack threads for pruning failed",
1278 );
1279 return;
1280 };
1281 let alive_owners: std::collections::HashSet<(String, Option<String>)> = self
1282 .slack_subs
1283 .lock()
1284 .iter()
1285 .filter(|sub| sub.workspace == workspace)
1286 .map(|sub| (sub.project.clone(), sub.team_role.clone()))
1287 .collect();
1288 for (conversation, parent_ts, record) in rows {
1289 let kept: Vec<SlackThreadOwner> = record
1290 .owners
1291 .iter()
1292 .filter(|owner| {
1293 alive_owners.contains(&(owner.project.clone(), owner.team_role.clone()))
1294 })
1295 .cloned()
1296 .collect();
1297 if kept.len() == record.owners.len() {
1298 continue;
1299 }
1300 if kept.is_empty() {
1301 let _ =
1302 crate::store::slack::remove_thread(db, workspace, &conversation, &parent_ts);
1303 self.forget_thread_clocks(workspace, &conversation, &parent_ts);
1304 } else {
1305 let _ = crate::store::slack::set_thread(
1306 db,
1307 workspace,
1308 &conversation,
1309 &parent_ts,
1310 &SlackThreadRecord {
1311 cursor: record.cursor,
1312 edit_watermark: record.edit_watermark,
1313 owners: kept,
1314 participating: record.participating,
1315 },
1316 );
1317 }
1318 }
1319 }
1320
1321 pub fn start_slack_verification(self: &Arc<Self>) {
1324 if self.slack.is_empty() {
1325 return;
1326 }
1327 if self.slack_verification_started.swap(true, std::sync::atomic::Ordering::SeqCst) {
1328 return;
1329 }
1330 let weak = Arc::downgrade(self);
1331 tokio::spawn(async move {
1332 let Some(ws) = weak.upgrade() else { return };
1333 for (label, result) in ws.slack.verify_all().await {
1334 match result {
1335 Ok(auth) => {
1336 ws.slack_user_ids.lock().insert(label.clone(), auth.user_id.clone());
1340 tracing::info!(
1341 target: "forge_workspace::slack",
1342 workspace = %label,
1343 team = %auth.team,
1344 user = %auth.user,
1345 "slack workspace verified",
1346 );
1347 }
1348 Err(error) => tracing::warn!(
1349 target: "forge_workspace::slack",
1350 workspace = %label,
1351 %error,
1352 "slack auth.test failed; the mention stream stays down until it succeeds",
1353 ),
1354 }
1355 }
1356 });
1357 }
1358
1359 pub fn slack_subscription_load_failed(&self) -> bool {
1361 self.slack_load_failed.load(std::sync::atomic::Ordering::SeqCst)
1362 }
1363
1364 pub(crate) fn retry_slack_user_id(self: &Arc<Self>, label: &str) {
1369 const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1370 {
1371 let mut retries = self.slack_user_id_retries.lock();
1372 if retries
1373 .get(label)
1374 .is_some_and(|last| last.elapsed().is_ok_and(|elapsed| elapsed < RETRY_INTERVAL))
1375 {
1376 return;
1377 }
1378 retries.insert(label.to_owned(), std::time::SystemTime::now());
1379 }
1380 let Some(api) = self.slack.client(label) else { return };
1381 let weak = Arc::downgrade(self);
1382 let label = label.to_owned();
1383 tokio::spawn(async move {
1384 let result = api.auth_test().await;
1385 let Some(ws) = weak.upgrade() else { return };
1386 match result {
1387 Ok(auth) => {
1388 ws.slack_user_ids.lock().insert(label.clone(), auth.user_id);
1389 tracing::info!(
1390 target: "forge_workspace::slack",
1391 workspace = %label,
1392 user = %auth.user,
1393 "slack user id recovered on retry",
1394 );
1395 }
1396 Err(error) => tracing::warn!(
1397 target: "forge_workspace::slack",
1398 workspace = %label,
1399 %error,
1400 "slack user-id retry failed; the mention stream stays down",
1401 ),
1402 }
1403 });
1404 }
1405
1406 pub fn start_slack_subsystem(self: &Arc<Self>) {
1410 if self.slack.is_empty() {
1411 return;
1412 }
1413 for label in self.slack.labels() {
1414 if !self.slack_subs.lock().iter().any(|sub| sub.workspace == label) {
1415 continue;
1416 }
1417 let mut guard = self.slack_subsystem.lock();
1418 if guard.contains_key(&label) {
1419 continue;
1420 }
1421 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
1422 guard.insert(label.clone(), shutdown_tx);
1423 drop(guard);
1424
1425 let config = self.config_arc();
1426 let entry =
1427 config.slack.iter().find(|config| config.workspace.trim() == label.as_str());
1428 let poll_seconds = entry.map_or(DEFAULT_POLL_SECONDS, |config| config.poll_seconds);
1429 let dm_poll_seconds = entry
1432 .map_or(DEFAULT_DM_POLL_SECONDS, |config| config.dm_poll_seconds)
1433 .max(MIN_DM_POLL_SECONDS);
1434 let host: Arc<dyn SlackHost> = Arc::new(SlackSubsystemHost::new(self));
1435 tokio::spawn(forge_connectors::slack::run_workspace_pump(
1436 host,
1437 label,
1438 poll_seconds,
1439 dm_poll_seconds,
1440 shutdown_rx,
1441 ));
1442 }
1443 }
1444
1445 pub fn stop_slack_subsystem_if_idle(&self) {
1450 let idle_labels: Vec<String> = {
1451 let subs = self.slack_subs.lock();
1452 let watched: std::collections::HashSet<&str> =
1453 subs.iter().map(|sub| sub.workspace.as_str()).collect();
1454 self.slack_subsystem
1455 .lock()
1456 .keys()
1457 .filter(|label| !watched.contains(label.as_str()))
1458 .cloned()
1459 .collect()
1460 };
1461 if idle_labels.is_empty() {
1462 return;
1463 }
1464 let handles: Vec<_> = {
1465 let mut handles = self.slack_subsystem.lock();
1466 idle_labels.iter().filter_map(|label| handles.remove(label)).collect()
1467 };
1468 for handle in handles {
1469 let _ = handle.send(());
1470 }
1471 let mut connected = self.slack_connected.lock();
1472 for label in &idle_labels {
1473 connected.insert(label.clone(), false);
1474 }
1475 }
1476}
1477
1478pub(crate) struct SlackPostStamp {
1481 pub(crate) id: u64,
1482 pub(crate) since: std::time::Instant,
1483 pub(crate) warned: bool,
1484}
1485
1486static NEXT_POST_STAMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1490
1491pub(crate) struct SlackPostInFlight {
1494 workspace: Arc<Workspace>,
1495 key: (String, String),
1496 id: u64,
1497}
1498
1499impl Drop for SlackPostInFlight {
1500 fn drop(&mut self) {
1501 let mut inflight = self.workspace.slack_inflight.lock();
1502 if let Some(stamps) = inflight.get_mut(&self.key) {
1503 stamps.retain(|stamp| stamp.id != self.id);
1504 if stamps.is_empty() {
1505 inflight.remove(&self.key);
1506 }
1507 }
1508 }
1509}
1510
1511pub(crate) struct SlackSubsystemHost(std::sync::Weak<Workspace>);
1515
1516impl SlackSubsystemHost {
1517 pub(crate) fn new(workspace: &Arc<Workspace>) -> Self {
1518 Self(Arc::downgrade(workspace))
1519 }
1520}
1521
1522impl SlackHost for SlackSubsystemHost {
1523 fn client(&self, workspace: &str, timeout: Duration) -> Result<Arc<dyn SlackApi>, String> {
1524 let ws = self.0.upgrade().ok_or("the workspace is gone")?;
1525 let config = ws.config_arc();
1526 let entry = config
1527 .slack
1528 .iter()
1529 .find(|config| config.workspace.trim() == workspace)
1530 .ok_or_else(|| format!("no [[slack]] entry named '{workspace}'"))?;
1531 let http =
1532 forge_agent::http_trust::with_extra_roots(reqwest::Client::builder().timeout(timeout))
1533 .build()
1534 .map_err(|error| error.to_string())?;
1535 Ok(Arc::new(SlackClient::new(http, entry.token.clone())))
1536 }
1537
1538 fn user_id(&self, workspace: &str) -> Option<String> {
1539 let ws = self.0.upgrade()?;
1540 let cached = ws.slack_user_ids.lock().get(workspace).cloned();
1541 if cached.is_some() {
1542 return cached;
1543 }
1544 ws.retry_slack_user_id(workspace);
1548 None
1549 }
1550
1551 fn user_name(&self, workspace: &str, user: &str) -> Option<String> {
1552 let ws = self.0.upgrade()?;
1553 ws.slack_user_names.lock().get(&(workspace.to_owned(), user.to_owned())).cloned()
1554 }
1555
1556 fn set_user_name(&self, workspace: &str, user: &str, name: &str) {
1557 let Some(ws) = self.0.upgrade() else { return };
1558 ws.slack_user_names.lock().insert((workspace.to_owned(), user.to_owned()), name.to_owned());
1559 }
1560
1561 fn subscriptions(&self, workspace: &str) -> Vec<SlackSubscription> {
1562 let Some(ws) = self.0.upgrade() else { return Vec::new() };
1563 ws.slack_subs.lock().iter().filter(|sub| sub.workspace == workspace).cloned().collect()
1564 }
1565
1566 fn watermark(&self, workspace: &str, conversation: &str) -> Result<Option<String>, String> {
1567 let ws = self.0.upgrade().ok_or("the workspace is gone")?;
1568 let db = ws.db.lock();
1569 let Some(db) = db.as_ref() else { return Ok(None) };
1570 crate::store::slack::watermark(db, workspace, conversation)
1571 .map_err(|error| error.to_string())
1572 }
1573
1574 fn set_watermark(&self, workspace: &str, conversation: &str, ts: &str) {
1575 let Some(ws) = self.0.upgrade() else { return };
1576 ws.set_slack_watermark(workspace, conversation, ts);
1577 }
1578
1579 fn followed_threads(&self, workspace: &str, conversation: &str) -> Vec<SlackFollowedThread> {
1580 let Some(ws) = self.0.upgrade() else { return Vec::new() };
1581 ws.slack_followed_threads(workspace, conversation)
1582 }
1583
1584 fn thread_watermark(
1585 &self,
1586 workspace: &str,
1587 conversation: &str,
1588 parent_ts: &str,
1589 ) -> Result<Option<String>, String> {
1590 let ws = self.0.upgrade().ok_or("the workspace is gone")?;
1591 ws.slack_thread_watermark(workspace, conversation, parent_ts)
1592 .map_err(|error| error.to_string())
1593 }
1594
1595 fn set_thread_watermark(&self, workspace: &str, conversation: &str, parent_ts: &str, ts: &str) {
1596 let Some(ws) = self.0.upgrade() else { return };
1597 ws.set_slack_thread_watermark(workspace, conversation, parent_ts, ts);
1598 }
1599
1600 fn mark_thread_participating(&self, workspace: &str, conversation: &str, parent_ts: &str) {
1601 let Some(ws) = self.0.upgrade() else { return };
1602 ws.mark_slack_thread_participating(workspace, conversation, parent_ts);
1603 }
1604
1605 fn thread_full_read_at(
1606 &self,
1607 workspace: &str,
1608 conversation: &str,
1609 parent_ts: &str,
1610 ) -> Option<std::time::Instant> {
1611 let ws = self.0.upgrade()?;
1612 ws.slack_thread_full_reads
1613 .lock()
1614 .get(&(workspace.to_owned(), conversation.to_owned(), parent_ts.to_owned()))
1615 .copied()
1616 }
1617
1618 fn mark_thread_full_read(
1619 &self,
1620 workspace: &str,
1621 conversation: &str,
1622 parent_ts: &str,
1623 at: std::time::Instant,
1624 ) {
1625 let Some(ws) = self.0.upgrade() else { return };
1626 ws.slack_thread_full_reads
1627 .lock()
1628 .insert((workspace.to_owned(), conversation.to_owned(), parent_ts.to_owned()), at);
1629 }
1630
1631 fn thread_first_walk(&self, workspace: &str, conversation: &str, parent_ts: &str) -> bool {
1632 let Some(ws) = self.0.upgrade() else { return false };
1633 ws.slack_thread_first_walks.lock().contains(&(
1634 workspace.to_owned(),
1635 conversation.to_owned(),
1636 parent_ts.to_owned(),
1637 ))
1638 }
1639
1640 fn clear_thread_first_walk(&self, workspace: &str, conversation: &str, parent_ts: &str) {
1641 let Some(ws) = self.0.upgrade() else { return };
1642 ws.slack_thread_first_walks.lock().remove(&(
1643 workspace.to_owned(),
1644 conversation.to_owned(),
1645 parent_ts.to_owned(),
1646 ));
1647 }
1648
1649 fn thread_edit_watermark(
1650 &self,
1651 workspace: &str,
1652 conversation: &str,
1653 parent_ts: &str,
1654 ) -> Option<String> {
1655 let ws = self.0.upgrade()?;
1656 ws.slack_thread_edit_watermark(workspace, conversation, parent_ts)
1657 }
1658
1659 fn set_thread_edit_watermark(
1660 &self,
1661 workspace: &str,
1662 conversation: &str,
1663 parent_ts: &str,
1664 ts: &str,
1665 ) {
1666 let Some(ws) = self.0.upgrade() else { return };
1667 ws.set_slack_thread_edit_watermark(workspace, conversation, parent_ts, ts);
1668 }
1669
1670 fn first_author_failure(&self, workspace: &str, user: &str) -> bool {
1671 let Some(ws) = self.0.upgrade() else { return true };
1672 ws.slack_author_failures.lock().insert((workspace.to_owned(), user.to_owned()))
1673 }
1674
1675 fn first_draft_user_failure(&self, workspace: &str, user: &str) -> bool {
1676 let Some(ws) = self.0.upgrade() else { return true };
1677 ws.slack_draft_user_failures.lock().insert((workspace.to_owned(), user.to_owned()))
1678 }
1679
1680 fn follow_thread(
1681 &self,
1682 workspace: &str,
1683 conversation: &str,
1684 parent_ts: &str,
1685 owner: SlackThreadOwner,
1686 since: &str,
1687 ) {
1688 let Some(ws) = self.0.upgrade() else { return };
1689 ws.follow_slack_thread(workspace, conversation, parent_ts, owner, since);
1690 }
1691
1692 fn set_connected(&self, workspace: &str, connected: bool) {
1693 let Some(ws) = self.0.upgrade() else { return };
1694 ws.slack_connected.lock().insert(workspace.to_owned(), connected);
1695 }
1696
1697 fn name_conversation(&self, workspace: &str, conversation: &str, name: &str) {
1698 let Some(ws) = self.0.upgrade() else { return };
1699 ws.name_slack_conversation(workspace, conversation, name);
1700 }
1701
1702 fn auto_subscribe(&self, workspace: &str, message: &SlackMessage) -> bool {
1703 let Some(ws) = self.0.upgrade() else { return false };
1704 ws.auto_subscribe_slack_conversation(
1705 workspace,
1706 &message.conversation,
1707 &message.ts,
1708 Some(&message.conversation_label),
1709 )
1710 }
1711
1712 fn own_post(&self, workspace: &str, conversation: &str, ts: &str) -> bool {
1713 self.0.upgrade().is_some_and(|ws| ws.slack_own_post(workspace, conversation, ts))
1714 }
1715
1716 fn post_in_flight(&self, workspace: &str, conversation: &str) -> Option<Duration> {
1717 self.0.upgrade().and_then(|ws| ws.slack_post_in_flight(workspace, conversation))
1718 }
1719
1720 fn deliver(&self, subscription: &SlackSubscription, messages: &[SlackMessage]) -> bool {
1721 let Some(ws) = self.0.upgrade() else { return false };
1722 crate::spawn::deliver_slack_message(
1727 &ws,
1728 &subscription.project,
1729 subscription.team_role.as_deref(),
1730 messages.to_vec(),
1731 )
1732 }
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737 use super::*;
1738 use forge_primitives::slack::{
1739 SlackDraftAnswer, SlackSubscription, SlackSubscriptionTarget, SlackWatchMode,
1740 };
1741 use uuid::Uuid;
1742
1743 fn cfg(workspace: &str, token: &str) -> SlackConfig {
1744 SlackConfig {
1745 workspace: workspace.to_owned(),
1746 token: token.to_owned(),
1747 poll_seconds: 30,
1748 dm_poll_seconds: 150,
1749 thread_idle_days: 14,
1750 stale_after_hours: 24,
1751 }
1752 }
1753
1754 fn sub_for(project: &str, team_role: Option<&str>) -> SlackSubscription {
1755 SlackSubscription {
1756 id: Uuid::new_v4(),
1757 workspace: "acme".to_owned(),
1758 project: project.to_owned(),
1759 team_role: team_role.map(str::to_owned),
1760 target: SlackSubscriptionTarget::DirectMessages,
1761 created_at: std::time::SystemTime::UNIX_EPOCH,
1762 }
1763 }
1764
1765 fn workspace_with_one_slack_workspace(
1768 label: &str,
1769 ) -> (Arc<Workspace>, tempfile::TempDir, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>)
1770 {
1771 let dir = tempfile::tempdir().expect("tempdir");
1772 let mut config = crate::config::LoadedConfig::empty_for_test();
1773 config.slack = vec![cfg(label, "xoxp-test")];
1774 let (ws, rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
1775 .expect("the stub config's [[slack]] entries are well-formed");
1776 (ws, dir, rx)
1777 }
1778
1779 fn answered(approved: bool) -> DraftResolution {
1783 DraftResolution::Answered(if approved {
1784 SlackDraftAnswer::Approve
1785 } else {
1786 SlackDraftAnswer::Decline
1787 })
1788 }
1789
1790 fn sent_back(note: &str) -> DraftResolution {
1791 DraftResolution::Answered(SlackDraftAnswer::SendBack { note: note.to_owned() })
1792 }
1793
1794 fn draft(workspace: &str, conversation: &str) -> SlackDraft {
1795 SlackDraft {
1796 id: Uuid::new_v4(),
1797 workspace: workspace.to_owned(),
1798 conversation: conversation.to_owned(),
1799 conversation_label: conversation.to_owned(),
1800 thread_ts: None,
1801 text: "hello".to_owned(),
1802 operation: forge_primitives::slack::SlackDraftOperation::Post,
1803 thread: None,
1804 tagged: Vec::new(),
1805 }
1806 }
1807
1808 #[test]
1812 fn a_draft_is_addressed_to_the_session_that_asked() {
1813 let (ws, _dir, mut rx) = workspace_with_one_slack_workspace("acme");
1814 let caller = SessionSlot::from_str_for_test("worker-uuid");
1815 let (_id, _decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1816
1817 let mut addressed = Vec::new();
1818 while let Ok(frame) = rx.try_recv() {
1819 if let crate::protocol::SessionUpdate::SlackPostPending { key, .. } =
1820 std::sync::Arc::unwrap_or_clone(frame.update)
1821 {
1822 addressed.push(key);
1823 }
1824 }
1825 assert_eq!(
1826 addressed,
1827 vec![caller],
1828 "the draft is addressed to the worker that asked, never the lead",
1829 );
1830 }
1831
1832 #[test]
1833 fn answering_a_draft_that_is_not_pending_is_refused() {
1834 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
1835 let caller = SessionSlot::from_str_for_test("caller-uuid");
1836 assert!(!ws.resolve_slack_draft(Uuid::new_v4(), &caller, answered(true)));
1837 }
1838
1839 #[tokio::test]
1842 async fn a_draft_with_no_ui_to_answer_it_fails_closed() {
1843 let (ws, _dir, rx) = workspace_with_one_slack_workspace("acme");
1844 drop(rx);
1845 let caller = SessionSlot::from_str_for_test("caller-uuid");
1846 let (_id, decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1847 assert!(
1848 ws.slack_drafts.lock().is_empty(),
1849 "an unanswerable draft is dropped, not held forever",
1850 );
1851 assert!(decision.await.is_err(), "a dead receiver reads as not-approved");
1852 }
1853
1854 #[tokio::test]
1861 async fn a_draft_with_only_an_observer_fails_closed() {
1862 let (ws, _dir, rx) = workspace_with_one_slack_workspace("acme");
1863 drop(rx);
1864 let mut observer = ws.subscribe_mirror();
1865 let caller = SessionSlot::from_str_for_test("caller-uuid");
1866
1867 let (_id, decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1868
1869 assert!(
1870 ws.slack_drafts.lock().is_empty(),
1871 "a draft only an observer can see is dropped, not held forever",
1872 );
1873 assert!(
1874 matches!(
1875 observer.try_recv(),
1876 Ok(frame)
1877 if matches!(&*frame.update, crate::protocol::SessionUpdate::SlackPostPending { .. })
1878 ),
1879 "the observer is still delivered the draft it cannot answer",
1880 );
1881 assert!(decision.await.is_err(), "the awaiting caller sees a dead receiver");
1882 }
1883
1884 #[test]
1885 fn answering_a_draft_removes_it() {
1886 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
1887 let caller = SessionSlot::from_str_for_test("caller-uuid");
1888 let (id, _decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1889
1890 assert!(ws.resolve_slack_draft(id, &caller, answered(true)), "the draft was waiting");
1891 assert!(!ws.resolve_slack_draft(id, &caller, answered(true)), "and is gone once answered");
1892 }
1893
1894 #[test]
1899 fn a_resolved_draft_stands_the_views_down() {
1900 let (ws, _dir, mut rx) = workspace_with_one_slack_workspace("acme");
1901 let caller = SessionSlot::from_str_for_test("caller-uuid");
1902 let (id, _decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1903 while rx.try_recv().is_ok() {}
1904
1905 assert!(ws.resolve_slack_draft(id, &caller, answered(true)), "the draft was waiting");
1906
1907 let mut resolved = Vec::new();
1908 while let Ok(frame) = rx.try_recv() {
1909 if let crate::protocol::SessionUpdate::SlackDraftResolved { key, id, ending } =
1910 std::sync::Arc::unwrap_or_clone(frame.update)
1911 {
1912 resolved.push((key, id, ending));
1913 }
1914 }
1915 assert_eq!(
1916 resolved,
1917 vec![(caller, id, SlackDraftEnding::Answered { approved: true })],
1918 "the stand-down names the draft's own id and how it ended",
1919 );
1920 }
1921
1922 #[tokio::test]
1926 async fn a_sent_back_draft_hands_its_note_to_the_waiting_call() {
1927 let (ws, _dir, mut rx) = workspace_with_one_slack_workspace("acme");
1928 let caller = SessionSlot::from_str_for_test("caller-uuid");
1929 let (id, decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1930 while rx.try_recv().is_ok() {}
1931
1932 assert!(
1933 ws.resolve_slack_draft(id, &caller, sent_back("shorten the second line")),
1934 "the draft was waiting",
1935 );
1936
1937 assert_eq!(
1938 decision.await.expect("the held draft answers"),
1939 SlackDraftAnswer::SendBack { note: "shorten the second line".to_owned() },
1940 "the reader's words reach the blocked call whole",
1941 );
1942 let mut resolved = Vec::new();
1943 while let Ok(frame) = rx.try_recv() {
1944 if let crate::protocol::SessionUpdate::SlackDraftResolved { ending, .. } =
1945 std::sync::Arc::unwrap_or_clone(frame.update)
1946 {
1947 resolved.push(ending);
1948 }
1949 }
1950 assert_eq!(
1951 resolved,
1952 vec![SlackDraftEnding::SentBack],
1953 "and a view that did not answer hears the draft was sent back",
1954 );
1955 }
1956
1957 #[tokio::test]
1961 async fn an_empty_send_back_note_never_resolves_the_draft() {
1962 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
1963 let caller = SessionSlot::from_str_for_test("caller-uuid");
1964 let (id, _decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
1965
1966 let refused = ws.dispatch(crate::protocol::Command::RespondSlackPost {
1967 key: caller.clone(),
1968 id,
1969 answer: SlackDraftAnswer::SendBack { note: " \n ".to_owned() },
1970 });
1971
1972 assert!(
1973 matches!(refused, Err(crate::protocol::DispatchError::EmptySlackFeedback { .. })),
1974 "an empty note is refused by name: {refused:?}",
1975 );
1976 assert!(
1977 ws.resolve_slack_draft(id, &caller, answered(false)),
1978 "and the draft is still waiting for a real answer",
1979 );
1980 }
1981
1982 #[test]
1986 fn another_session_cannot_answer_a_draft_it_was_not_addressed() {
1987 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
1988 let asker = SessionSlot::from_str_for_test("worker-uuid");
1989 let other = SessionSlot::from_str_for_test("lead-uuid");
1990 let (id, _decision) = ws.register_slack_draft(&asker, draft("acme", "C1"));
1991
1992 assert!(
1993 !ws.resolve_slack_draft(id, &other, answered(true)),
1994 "an answer from another session must be refused",
1995 );
1996 assert!(
1997 ws.resolve_slack_draft(id, &asker, answered(true)),
1998 "and the draft is still waiting for the session that asked",
1999 );
2000 }
2001
2002 #[tokio::test]
2006 async fn the_dock_answer_reaches_the_registry_through_dispatch() {
2007 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2008 let caller = SessionSlot::from_str_for_test("caller-uuid");
2009 let (id, decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
2010
2011 ws.dispatch(crate::protocol::Command::RespondSlackPost {
2012 key: caller.clone(),
2013 id,
2014 answer: SlackDraftAnswer::Approve,
2015 })
2016 .expect("the dock answer routes to the workspace");
2017
2018 assert_eq!(
2019 decision.await.expect("the held draft answers"),
2020 SlackDraftAnswer::Approve,
2021 "the answer the dock gave reaches the awaiting caller",
2022 );
2023 }
2024
2025 #[tokio::test]
2030 async fn a_stale_dock_answer_is_refused_with_the_reason() {
2031 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2032 let caller = SessionSlot::from_str_for_test("caller-uuid");
2033 let (id, _decision) = ws.register_slack_draft(&caller, draft("acme", "C1"));
2034 assert!(ws.resolve_slack_draft(id, &caller, answered(true)), "the draft was waiting");
2035
2036 let refused = ws.dispatch(crate::protocol::Command::RespondSlackPost {
2037 key: caller,
2038 id,
2039 answer: SlackDraftAnswer::Approve,
2040 });
2041
2042 assert!(
2043 matches!(refused, Err(crate::protocol::DispatchError::NoDraftWaiting { .. })),
2044 "a stale dock must report why the answer did not land: {refused:?}",
2045 );
2046 }
2047
2048 #[tokio::test]
2053 async fn another_session_answering_through_dispatch_is_refused() {
2054 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2055 let asker = SessionSlot::from_str_for_test("worker-uuid");
2056 let other = SessionSlot::from_str_for_test("lead-uuid");
2057 let (id, _decision) = ws.register_slack_draft(&asker, draft("acme", "C1"));
2058
2059 let refused = ws.dispatch(crate::protocol::Command::RespondSlackPost {
2060 key: other,
2061 id,
2062 answer: SlackDraftAnswer::Approve,
2063 });
2064
2065 assert!(
2066 matches!(refused, Err(crate::protocol::DispatchError::NoDraftWaiting { .. })),
2067 "another session's answer must be refused, not reported as landed: {refused:?}",
2068 );
2069 assert!(
2070 ws.resolve_slack_draft(id, &asker, answered(true)),
2071 "and the draft is still the asking session's to answer",
2072 );
2073 }
2074
2075 #[tokio::test]
2078 async fn the_subsystem_does_not_start_without_subscriptions() {
2079 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2080 ws.start_slack_subsystem();
2081 assert!(
2082 ws.slack_subsystem.lock().is_empty(),
2083 "a pump with nothing to watch is a task that only burns a timer",
2084 );
2085 }
2086
2087 #[tokio::test]
2088 async fn the_subsystem_starts_once_a_subscription_exists() {
2089 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2090 ws.add_slack_subscription(sub_for("acme", None), true);
2091
2092 ws.start_slack_subsystem();
2093 assert_eq!(ws.slack_subsystem.lock().len(), 1, "one pump for the one configured workspace");
2094
2095 let id = ws.slack_subscriptions_for_project("acme")[0].id;
2096 ws.remove_slack_subscription_owned_by("acme", id, None).expect("the lead's own row");
2097 ws.stop_slack_subsystem_if_idle();
2098 assert!(ws.slack_subsystem.lock().is_empty(), "the pump stops with its last subscription");
2099 }
2100
2101 fn sub_mentions_for(project: &str, team_role: Option<&str>) -> SlackSubscription {
2102 let mut sub = sub_for(project, team_role);
2103 sub.target = SlackSubscriptionTarget::Mentions;
2104 sub
2105 }
2106
2107 #[test]
2108 fn the_auto_subscription_lands_on_the_mention_subscriptions_owner() {
2109 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2112 ws.add_slack_subscription(sub_mentions_for("forge", Some("tester")), true);
2113
2114 ws.auto_subscribe_slack_conversation("acme", "C1", "200.1", Some("general"));
2115
2116 let added = ws.slack_subscriptions_for_project("forge");
2117 assert!(
2118 added.iter().any(|sub| sub.team_role.as_deref() == Some("tester")
2119 && sub.target
2120 == SlackSubscriptionTarget::Conversation {
2121 id: "C1".to_owned(),
2122 name: Some("general".to_owned()),
2123 mode: SlackWatchMode::All,
2124 }),
2125 "the conversation lands on the mention subscription's owner: {added:?}",
2126 );
2127 }
2128
2129 #[test]
2133 fn an_auto_subscription_stores_no_name_when_the_label_is_not_one() {
2134 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2135 ws.add_slack_subscription(sub_mentions_for("forge", Some("tester")), true);
2136
2137 assert!(ws.auto_subscribe_slack_conversation("acme", "C1", "200.1", Some("C1")));
2138 assert!(ws.auto_subscribe_slack_conversation("acme", "C2", "200.1", Some("")));
2139 assert!(ws.auto_subscribe_slack_conversation("acme", "C3", "200.1", None));
2140
2141 let named = ws
2142 .slack_subscriptions_for_project("forge")
2143 .into_iter()
2144 .filter_map(|sub| match sub.target {
2145 SlackSubscriptionTarget::Conversation { name, .. } => Some(name),
2146 _ => None,
2147 })
2148 .collect::<Vec<_>>();
2149 assert_eq!(named.len(), 3, "every record contributed a name slot: {named:?}");
2150 assert!(
2151 named.iter().all(Option::is_none),
2152 "no record invents a name from the id or an empty label: {named:?}",
2153 );
2154 }
2155
2156 #[test]
2162 fn the_auto_subscribe_seam_names_the_pulled_in_conversation() {
2163 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2164 ws.add_slack_subscription(sub_mentions_for("forge", Some("tester")), true);
2165 let host = SlackSubsystemHost::new(&ws);
2166
2167 let message = SlackMessage {
2168 workspace: "acme".to_owned(),
2169 conversation: "C9".to_owned(),
2170 conversation_label: "granite-staging-alerts".to_owned(),
2171 ts: "200.1".to_owned(),
2172 thread_ts: None,
2173 user: Some("U9".to_owned()),
2174 author: None,
2175 text: "ping".to_owned(),
2176 parent_user_id: None,
2177 latest_reply: None,
2178 edited: None,
2179 files: Vec::new(),
2180 };
2181 assert!(host.auto_subscribe("acme", &message), "the mention subscribes the conversation");
2182
2183 let named =
2184 ws.slack_subscriptions_for_project("forge").into_iter().find_map(|sub| {
2185 match sub.target {
2186 SlackSubscriptionTarget::Conversation { id, name, .. } if id == "C9" => {
2187 Some(name)
2188 }
2189 _ => None,
2190 }
2191 });
2192 assert_eq!(
2193 named,
2194 Some(Some("granite-staging-alerts".to_owned())),
2195 "the search hit's label names the conversation at creation",
2196 );
2197 }
2198
2199 #[test]
2202 fn a_backfilled_conversation_name_reaches_the_store() {
2203 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
2204 ws.install_db_for_test(
2205 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2206 );
2207 ws.add_slack_subscription(sub_for_conversation("forge", None, "C1"), true);
2208
2209 ws.name_slack_conversation("acme", "C1", "ved-test");
2210
2211 let db = ws.db.lock();
2212 let stored = crate::store::slack::list(db.as_ref().expect("db installed")).expect("list");
2213 let named = stored.iter().find_map(|sub| match &sub.target {
2214 SlackSubscriptionTarget::Conversation { id, name, .. } if id == "C1" => {
2215 Some(name.clone())
2216 }
2217 _ => None,
2218 });
2219 assert_eq!(
2220 named,
2221 Some(Some("ved-test".to_owned())),
2222 "the backfilled name is persisted, not only held in memory",
2223 );
2224 }
2225
2226 #[test]
2230 fn naming_a_conversation_leaves_an_already_named_record_alone() {
2231 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
2232 ws.install_db_for_test(
2233 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2234 );
2235 let mut sub = sub_for_conversation("forge", None, "C1");
2236 sub.target = SlackSubscriptionTarget::Conversation {
2237 id: "C1".to_owned(),
2238 name: Some("stale-name".to_owned()),
2239 mode: SlackWatchMode::All,
2240 };
2241 ws.add_slack_subscription(sub, true);
2242
2243 ws.name_slack_conversation("acme", "C1", "renamed");
2244
2245 let db = ws.db.lock();
2246 let stored = crate::store::slack::list(db.as_ref().expect("db installed")).expect("list");
2247 let name = stored.iter().find_map(|sub| match &sub.target {
2248 SlackSubscriptionTarget::Conversation { id, name, .. } if id == "C1" => name.clone(),
2249 _ => None,
2250 });
2251 assert_eq!(
2252 name,
2253 Some("stale-name".to_owned()),
2254 "a record that already has a name is never rewritten",
2255 );
2256 }
2257
2258 #[test]
2261 fn naming_a_conversation_leaves_another_workspace_alone() {
2262 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
2263 ws.install_db_for_test(
2264 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2265 );
2266 ws.add_slack_subscription(sub_for_conversation("forge", None, "C1"), true);
2267 let mut other = sub_for_conversation("forge", None, "C1");
2268 other.id = Uuid::new_v4();
2269 other.workspace = "beta".to_owned();
2270 ws.add_slack_subscription(other, true);
2271
2272 ws.name_slack_conversation("acme", "C1", "ved-test");
2273
2274 let db = ws.db.lock();
2275 let stored = crate::store::slack::list(db.as_ref().expect("db installed")).expect("list");
2276 let named: BTreeMap<String, Option<String>> = stored
2278 .iter()
2279 .filter_map(|sub| match &sub.target {
2280 SlackSubscriptionTarget::Conversation { id, name, .. } if id == "C1" => {
2281 Some((sub.workspace.clone(), name.clone()))
2282 }
2283 _ => None,
2284 })
2285 .collect();
2286 assert_eq!(
2287 named.get("acme"),
2288 Some(&Some("ved-test".to_owned())),
2289 "the named workspace's record is healed",
2290 );
2291 assert_eq!(named.get("beta"), Some(&None), "the other workspace's record is untouched");
2292 }
2293
2294 #[test]
2298 fn naming_an_ephemeral_conversation_does_not_persist_it() {
2299 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
2300 ws.install_db_for_test(
2301 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2302 );
2303 ws.add_slack_subscription(sub_for_conversation("forge", Some("tester"), "C1"), false);
2304
2305 ws.name_slack_conversation("acme", "C1", "ved-test");
2306
2307 let db = ws.db.lock();
2308 let stored = crate::store::slack::list(db.as_ref().expect("db installed")).expect("list");
2309 assert!(stored.is_empty(), "an ephemeral record never reaches the store: {stored:?}");
2310 drop(db);
2311
2312 let named =
2313 ws.slack_subscriptions_for_project("forge").into_iter().find_map(|sub| {
2314 match sub.target {
2315 SlackSubscriptionTarget::Conversation { id, name, .. } if id == "C1" => {
2316 Some(name)
2317 }
2318 _ => None,
2319 }
2320 });
2321 assert_eq!(
2322 named,
2323 Some(Some("ved-test".to_owned())),
2324 "the in-memory record still gets its name",
2325 );
2326 }
2327
2328 #[test]
2332 fn an_auto_subscribe_never_resets_another_owners_cursor() {
2333 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
2334 ws.install_db_for_test(
2335 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2336 );
2337 ws.add_slack_subscription(sub_mentions_for("forge", Some("tester")), true);
2338 ws.set_slack_watermark("acme", "C1", "100.0");
2339
2340 assert!(
2341 ws.auto_subscribe_slack_conversation("acme", "C1", "200.1", None),
2342 "the conversation is watched by nobody yet, so a record is added",
2343 );
2344 let db = ws.db.lock();
2345 let cursor =
2346 crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
2347 .expect("read");
2348 assert_eq!(
2349 cursor,
2350 Some("100.0".to_owned()),
2351 "the auto-subscribe never moves another owner's cursor",
2352 );
2353 }
2354
2355 fn sub_for_conversation(project: &str, team_role: Option<&str>, id: &str) -> SlackSubscription {
2356 let mut sub = sub_for(project, team_role);
2357 sub.target = SlackSubscriptionTarget::Conversation {
2358 id: id.to_owned(),
2359 name: None,
2360 mode: SlackWatchMode::All,
2361 };
2362 sub
2363 }
2364
2365 fn owner(project: &str, team_role: Option<&str>) -> SlackThreadOwner {
2366 SlackThreadOwner { project: project.to_owned(), team_role: team_role.map(str::to_owned) }
2367 }
2368
2369 fn recent_ts() -> String {
2373 let now = std::time::SystemTime::now()
2374 .duration_since(std::time::SystemTime::UNIX_EPOCH)
2375 .expect("the clock is past the epoch");
2376 format!("{}.{:06}", now.as_secs(), now.subsec_micros())
2377 }
2378
2379 fn host_with_c1_subscriber(
2382 project: &str,
2383 team_role: Option<&str>,
2384 ) -> (crate::slack::SlackSubsystemHost, Arc<Workspace>, tempfile::TempDir) {
2385 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
2386 ws.install_db_for_test(
2387 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2388 );
2389 ws.add_slack_subscription(sub_for_conversation(project, team_role, "C1"), true);
2390 (crate::slack::SlackSubsystemHost::new(&ws), ws, dir)
2391 }
2392
2393 fn host_with_c1_subscriber_and_idle_days(
2397 project: &str,
2398 team_role: Option<&str>,
2399 thread_idle_days: u64,
2400 ) -> (crate::slack::SlackSubsystemHost, Arc<Workspace>, tempfile::TempDir) {
2401 let dir = tempfile::tempdir().expect("tempdir");
2402 let mut config = crate::config::LoadedConfig::empty_for_test();
2403 config.slack = vec![SlackConfig { thread_idle_days, ..cfg("acme", "xoxp-test") }];
2404 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
2405 .expect("the stub config's [[slack]] entries are well-formed");
2406 ws.install_db_for_test(
2407 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2408 );
2409 ws.add_slack_subscription(sub_for_conversation(project, team_role, "C1"), true);
2410 (crate::slack::SlackSubsystemHost::new(&ws), ws, dir)
2411 }
2412
2413 #[test]
2417 fn a_workspace_configured_idle_window_is_what_the_prune_uses() {
2418 let now = std::time::SystemTime::now()
2419 .duration_since(std::time::SystemTime::UNIX_EPOCH)
2420 .expect("the clock is past the epoch")
2421 .as_secs();
2422 let days_ago = |days: u64| format!("{}.{:06}", now - days * 24 * 60 * 60, 0);
2423
2424 for (window, kept) in [(30_u64, 1_usize), (7, 0)] {
2425 let (host, _ws, _dir) = host_with_c1_subscriber_and_idle_days("forge", None, window);
2426 host.follow_thread("acme", "C1", &days_ago(20), owner("forge", None), &days_ago(20));
2427
2428 assert_eq!(
2429 host.followed_threads("acme", "C1").len(),
2430 kept,
2431 "a thread quiet for 20 days under a {window}-day window",
2432 );
2433 }
2434 }
2435
2436 #[test]
2439 fn a_workspace_configures_its_own_stale_age() {
2440 let dir = tempfile::tempdir().expect("tempdir");
2441 let mut config = crate::config::LoadedConfig::empty_for_test();
2442 config.slack = vec![SlackConfig { stale_after_hours: 6, ..cfg("acme", "xoxp-test") }];
2443 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
2444 .expect("the stub config's [[slack]] entries are well-formed");
2445
2446 assert_eq!(ws.slack_stale_after("acme"), Duration::from_secs(6 * 3_600));
2447 assert_eq!(
2448 ws.slack_stale_after("gone"),
2449 Duration::from_secs(DEFAULT_STALE_AFTER_HOURS * 3_600),
2450 "a workspace with no entry keeps the default",
2451 );
2452 }
2453
2454 #[test]
2458 fn the_own_post_port_reads_the_ledger_by_workspace_and_conversation() {
2459 let (host, ws, _dir) = host_with_c1_subscriber("forge", None);
2460 ws.record_slack_own_post("acme", "C1", "1700000000.000100");
2461
2462 assert!(host.own_post("acme", "C1", "1700000000.000100"), "the recorded row is read back");
2463 assert!(
2464 !host.own_post("acme", "C2", "1700000000.000100"),
2465 "another conversation's ledger does not answer for it",
2466 );
2467 assert!(
2468 !host.own_post("beta", "C1", "1700000000.000100"),
2469 "another workspace's ledger does not answer for it",
2470 );
2471 }
2472
2473 #[test]
2477 fn an_in_flight_mark_is_per_call_and_per_guard() {
2478 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2479 let host = SlackSubsystemHost::new(&ws);
2480
2481 let first = ws.begin_slack_post("acme", "C789");
2484 let second = ws.begin_slack_post("acme", "C789");
2485 assert!(host.post_in_flight("acme", "C789").is_some(), "both calls are in flight");
2486 assert!(
2487 host.post_in_flight("beta", "C789").is_none(),
2488 "and only in the workspace that took the mark",
2489 );
2490 assert!(host.post_in_flight("acme", "C790").is_none(), "and only for that conversation");
2491 drop(first);
2492 assert!(
2493 host.post_in_flight("acme", "C789").is_some(),
2494 "one call finishing does not clear an overlapping one",
2495 );
2496 drop(second);
2497 assert!(host.post_in_flight("acme", "C789").is_none(), "the last one clears the mark");
2498 }
2499
2500 #[test]
2504 fn an_expired_in_flight_mark_is_not_obeyed() {
2505 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2506 let host = SlackSubsystemHost::new(&ws);
2507 let stale = std::time::Instant::now()
2508 .checked_sub(forge_connectors::slack::REQUEST_TIMEOUT)
2509 .and_then(|instant| instant.checked_sub(std::time::Duration::from_secs(1)))
2510 .expect("the clock is past the timeout");
2511 ws.slack_inflight.lock().insert(
2512 ("acme".to_owned(), "C789".to_owned()),
2513 vec![SlackPostStamp { id: 1, since: stale, warned: false }],
2514 );
2515
2516 assert!(
2517 host.post_in_flight("acme", "C789").is_none(),
2518 "an expired mark is expired, never obeyed",
2519 );
2520 assert!(
2521 ws.slack_inflight.lock().is_empty(),
2522 "and it is dropped, not left to be re-checked"
2523 );
2524 }
2525
2526 #[test]
2529 fn an_expired_guards_drop_leaves_a_newer_mark_alone() {
2530 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
2531 let host = SlackSubsystemHost::new(&ws);
2532
2533 let old = ws.begin_slack_post("acme", "C789");
2534 let stale = std::time::Instant::now()
2535 .checked_sub(forge_connectors::slack::REQUEST_TIMEOUT)
2536 .and_then(|instant| instant.checked_sub(std::time::Duration::from_secs(1)))
2537 .expect("the clock is past the timeout");
2538 ws.slack_inflight
2539 .lock()
2540 .get_mut(&("acme".to_owned(), "C789".to_owned()))
2541 .expect("the old guard's entry")
2542 .first_mut()
2543 .expect("its stamp")
2544 .since = stale;
2545 assert!(
2546 host.post_in_flight("acme", "C789").is_none(),
2547 "the old stamp is expired and pruned",
2548 );
2549
2550 let fresh = ws.begin_slack_post("acme", "C789");
2551 drop(old);
2552 assert!(
2553 host.post_in_flight("acme", "C789").is_some(),
2554 "the old guard's drop must not clear the newer call's mark",
2555 );
2556 drop(fresh);
2557 assert!(host.post_in_flight("acme", "C789").is_none(), "and the newer one clears it");
2558 }
2559
2560 const LIVE_WORKSPACE: &str = "live-test";
2563 const LIVE_PROJECT_PATH: &str = "/tmp/forge-slack-live";
2564
2565 #[tokio::test]
2580 async fn slack_live_bundle_delivers_a_conversations_news_as_one_block() {
2581 let (Ok(user_token), Ok(bot_token), Ok(channel_name)) = (
2582 std::env::var("SLACK_TEST_USER_TOKEN"),
2583 std::env::var("SLACK_TEST_BOT_TOKEN"),
2584 std::env::var("SLACK_TEST_CHANNEL"),
2585 ) else {
2586 eprintln!(
2587 "skipping slack_live_bundle: set SLACK_TEST_USER_TOKEN, SLACK_TEST_BOT_TOKEN \
2588 and SLACK_TEST_CHANNEL to run it against a real workspace",
2589 );
2590 return;
2591 };
2592 let _ = tracing_subscriber::fmt()
2595 .with_max_level(tracing::Level::DEBUG)
2596 .with_writer(std::io::stderr)
2597 .try_init();
2598
2599 let http = reqwest::Client::builder().build().expect("an http client");
2600 let bot = SlackClient::new(http.clone(), bot_token);
2601 let user = SlackClient::new(http, user_token.clone());
2602
2603 let channel = bot
2606 .list_conversations()
2607 .await
2608 .expect("the bot reads its own channels")
2609 .into_iter()
2610 .find(|conversation| {
2611 conversation.id == channel_name
2612 || conversation.name.as_deref() == Some(channel_name.as_str())
2613 })
2614 .expect("the channel SLACK_TEST_CHANNEL names is one the bot is in");
2615 let conversation_id = channel.id.clone();
2616 let channel_label = channel.name.clone().unwrap_or_else(|| conversation_id.clone());
2619
2620 let watermark = user
2623 .history(&conversation_id, None, None, 1, None)
2624 .await
2625 .expect("the user reads the channel")
2626 .messages
2627 .first()
2628 .map_or_else(|| "0".to_owned(), |message| message.ts.clone());
2629
2630 let stamp = std::time::SystemTime::now()
2631 .duration_since(std::time::SystemTime::UNIX_EPOCH)
2632 .expect("the clock is past the epoch")
2633 .as_secs();
2634 let texts: Vec<String> = (1..=3).map(|n| format!("live bundle {n} {stamp}")).collect();
2635 let mut posted = Vec::new();
2636 for text in &texts {
2637 posted.push(
2638 bot.post_message(&conversation_id, text, None).await.expect("the bot posts").ts,
2639 );
2640 }
2641
2642 let dir = tempfile::tempdir().expect("tempdir");
2643 let mut config = crate::config::LoadedConfig::empty_for_test();
2644 config.slack = vec![SlackConfig {
2645 workspace: LIVE_WORKSPACE.to_owned(),
2646 token: user_token,
2647 poll_seconds: 1,
2648 dm_poll_seconds: 1,
2649 thread_idle_days: 14,
2650 stale_after_hours: 24,
2651 }];
2652 let (ws, mut update_rx) =
2653 Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
2654 .expect("the stub config's [[slack]] entries are well-formed");
2655 ws.install_db_for_test(
2656 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2657 );
2658
2659 let own_id = user.auth_test().await.expect("the user token proves").user_id;
2662 ws.slack_user_ids.lock().insert(LIVE_WORKSPACE.to_owned(), own_id);
2663
2664 ws.seed_test_project("live", LIVE_PROJECT_PATH);
2665 let mut sub = sub_for_conversation("live", None, &conversation_id);
2666 sub.workspace = LIVE_WORKSPACE.to_owned();
2667 ws.add_slack_subscription(sub, true);
2668 ws.set_slack_watermark(LIVE_WORKSPACE, &conversation_id, &watermark);
2669 assert_eq!(
2674 ws.slack_subscriptions_for_project("live").len(),
2675 1,
2676 "the subscription has to be in the workspace the pump sweeps",
2677 );
2678
2679 ws.record_connected_session(LIVE_PROJECT_PATH, "live-lead", None);
2682 let lead_key = SessionSlot::lead("TestOrg", "live");
2683 let (handle, _agent_rx) = Workspace::testing_stub_handle();
2684 ws.pool.lock().insert(
2685 lead_key.clone(),
2686 crate::workspace::PooledAgent {
2687 handle: Arc::new(handle),
2688 account: forge_gateway::AccountKey("test".to_owned()),
2689 permission_mode: None,
2690 registration: None,
2691 session_id: "live-lead".to_owned(),
2692 },
2693 );
2694 ws.mark_session_connected_for_test(&lead_key, "live-lead");
2695 ws.enable_test_dispatch_intercept();
2696
2697 let host: Arc<dyn SlackHost> = Arc::new(SlackSubsystemHost::new(&ws));
2698 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
2699 let pump = tokio::spawn(forge_connectors::slack::run_workspace_pump(
2700 host,
2701 LIVE_WORKSPACE.to_owned(),
2702 1,
2703 1,
2704 shutdown_rx,
2705 ));
2706
2707 let mut delivered = None;
2708 for _ in 0..600 {
2709 while let Ok(frame) = update_rx.try_recv() {
2710 if let crate::protocol::SessionUpdate::SlackMessageAppended { prose, .. } =
2711 std::sync::Arc::unwrap_or_clone(frame.update)
2712 {
2713 delivered = Some(prose);
2714 }
2715 }
2716 if delivered.is_some() {
2717 break;
2718 }
2719 tokio::time::sleep(Duration::from_millis(100)).await;
2720 }
2721 let _ = shutdown_tx.send(());
2722 let _ = pump.await;
2723
2724 let prose = delivered.expect("the sweep delivered the block the session is handed");
2725 assert!(
2726 prose.contains(&format!("({} messages)", texts.len())),
2727 "one block holding all three: {prose}",
2728 );
2729 assert!(prose.contains(&channel_label), "the header names the channel: {prose}");
2730 let members = prose.lines().filter(|line| line.contains(" [ts ")).count();
2734 assert_eq!(members, texts.len(), "one member line per posted message: {prose}");
2735 for (index, text) in texts.iter().enumerate() {
2736 assert!(prose.contains(text.as_str()), "member {index} is in the block: {prose}");
2737 assert!(
2738 prose.contains(&posted[index]),
2739 "and keeps its own ts, so the agent can answer it alone: {prose}",
2740 );
2741 }
2742 }
2743
2744 #[test]
2747 fn a_followed_thread_starts_at_the_callers_since_and_gains_its_owner_once() {
2748 let (host, ws, _dir) = host_with_c1_subscriber("forge", Some("tester"));
2749 let parent = recent_ts();
2750
2751 host.follow_thread("acme", "C1", &parent, owner("forge", Some("tester")), &parent);
2752 host.follow_thread("acme", "C1", &parent, owner("forge", Some("tester")), &parent);
2753
2754 let threads = host.followed_threads("acme", "C1");
2755 assert_eq!(threads.len(), 1);
2756 assert_eq!(threads[0].parent_ts, parent);
2757 assert_eq!(threads[0].owners, vec![owner("forge", Some("tester"))]);
2758 assert_eq!(
2759 host.thread_watermark("acme", "C1", &parent).expect("read"),
2760 Some(parent.clone()),
2761 "a new thread starts at the caller's since",
2762 );
2763
2764 ws.add_slack_subscription(sub_for_conversation("other", None, "C1"), true);
2767 host.follow_thread("acme", "C1", &parent, owner("other", None), &recent_ts());
2768 let threads = host.followed_threads("acme", "C1");
2769 assert_eq!(threads[0].owners.len(), 2, "the second owner is added");
2770 assert_eq!(
2771 host.thread_watermark("acme", "C1", &parent).expect("read"),
2772 Some(parent),
2773 "joining a thread does not move its cursor",
2774 );
2775 }
2776
2777 #[test]
2781 fn a_removed_subscription_prunes_its_owner_and_the_last_one_deletes_the_row() {
2782 let (host, ws, _dir) = host_with_c1_subscriber("forge", Some("a"));
2783 ws.add_slack_subscription(sub_for_conversation("forge", Some("b"), "C1"), true);
2784 let parent = recent_ts();
2785 host.follow_thread("acme", "C1", &parent, owner("forge", Some("a")), &parent);
2786 host.follow_thread("acme", "C1", &parent, owner("forge", Some("b")), &parent);
2787 let mark = {
2790 let secs: u64 =
2791 parent.split('.').next().expect("a seconds prefix").parse().expect("numeric");
2792 format!("{}.000000", secs + 60)
2793 };
2794 host.set_thread_edit_watermark("acme", "C1", &parent, &mark);
2795
2796 let b_id = ws
2797 .slack_subscriptions_for_project("forge")
2798 .into_iter()
2799 .find(|sub| sub.team_role.as_deref() == Some("b"))
2800 .expect("b's subscription")
2801 .id;
2802 assert!(ws.remove_slack_subscription_owned_by("forge", b_id, Some("b")).is_some());
2803
2804 let threads = host.followed_threads("acme", "C1");
2805 assert_eq!(threads.len(), 1, "the thread keeps being swept");
2806 assert_eq!(
2807 threads[0].owners,
2808 vec![owner("forge", Some("a"))],
2809 "the removed owner is pruned, the remaining one keeps the thread",
2810 );
2811 ws.forget_thread_clocks("acme", "C1", &parent);
2814 assert_eq!(
2815 host.thread_edit_watermark("acme", "C1", &parent).as_deref(),
2816 Some(mark.as_str()),
2817 "the pruned row keeps its edit stamp, or the next restart re-offers it",
2818 );
2819
2820 let a_id = ws
2821 .slack_subscriptions_for_project("forge")
2822 .into_iter()
2823 .find(|sub| sub.team_role.as_deref() == Some("a"))
2824 .expect("a's subscription")
2825 .id;
2826 assert!(ws.remove_slack_subscription_owned_by("forge", a_id, Some("a")).is_some());
2827 assert!(
2828 host.followed_threads("acme", "C1").is_empty(),
2829 "a thread whose last owner goes stops being tracked",
2830 );
2831 assert_eq!(
2832 ws.slack_thread_watermark("acme", "C1", &parent).expect("read"),
2833 None,
2834 "the row is deleted, not just filtered from the listing",
2835 );
2836 }
2837
2838 #[test]
2843 fn a_thread_idle_past_fourteen_days_is_dropped() {
2844 let (host, _ws, _dir) = host_with_c1_subscriber("forge", None);
2845 let now = std::time::SystemTime::now()
2846 .duration_since(std::time::SystemTime::UNIX_EPOCH)
2847 .expect("the clock is past the epoch")
2848 .as_secs();
2849 let days_ago = |days: u64| format!("{}.{:06}", now - days * 24 * 60 * 60, 0);
2850
2851 host.follow_thread("acme", "C1", &days_ago(15), owner("forge", None), &days_ago(15));
2852 host.follow_thread("acme", "C1", &days_ago(14), owner("forge", None), &days_ago(14));
2853 host.follow_thread("acme", "C1", &days_ago(13), owner("forge", None), &days_ago(13));
2854
2855 let threads = host.followed_threads("acme", "C1");
2856 assert_eq!(
2857 threads.len(),
2858 1,
2859 "the 15- and 14-day idle threads are dropped, the 13-day one kept",
2860 );
2861 assert_eq!(threads[0].parent_ts, days_ago(13), "and the survivor is the young one");
2862 }
2863
2864 #[test]
2867 fn an_unparseable_thread_cursor_reads_as_ancient() {
2868 assert!(
2869 thread_idle_days("not-a-ts") >= DEFAULT_THREAD_IDLE_DAYS,
2870 "an unparseable cursor must drop the row, not keep it: {}",
2871 thread_idle_days("not-a-ts"),
2872 );
2873 assert_eq!(thread_idle_days(&recent_ts()), 0, "a fresh cursor is not idle");
2874 }
2875
2876 #[test]
2880 fn a_mention_in_an_old_thread_is_not_idle_dropped_by_its_parents_age() {
2881 let (host, _ws, _dir) = host_with_c1_subscriber("forge", None);
2882 let now = std::time::SystemTime::now()
2883 .duration_since(std::time::SystemTime::UNIX_EPOCH)
2884 .expect("the clock is past the epoch")
2885 .as_secs();
2886 let twenty_days_ago = format!("{}.{:06}", now - 20 * 24 * 60 * 60, 0);
2887
2888 host.follow_thread("acme", "C1", &twenty_days_ago, owner("forge", None), &recent_ts());
2889
2890 let threads = host.followed_threads("acme", "C1");
2891 assert_eq!(
2892 threads.len(),
2893 1,
2894 "a thread followed now must survive a weeks-old parent: {threads:?}",
2895 );
2896 }
2897
2898 #[test]
2902 fn removing_the_last_owners_subscription_deletes_the_thread_row_at_once() {
2903 let (host, ws, _dir) = host_with_c1_subscriber("forge", Some("tester"));
2904 let parent = recent_ts();
2905 host.follow_thread("acme", "C1", &parent, owner("forge", Some("tester")), &parent);
2906
2907 let id = ws
2908 .slack_subscriptions_for_project("forge")
2909 .into_iter()
2910 .find(|sub| sub.team_role.as_deref() == Some("tester"))
2911 .expect("the tester's subscription")
2912 .id;
2913 assert!(ws.remove_slack_subscription_owned_by("forge", id, Some("tester")).is_some());
2914
2915 assert_eq!(
2916 ws.slack_thread_watermark("acme", "C1", &parent).expect("read"),
2917 None,
2918 "the row is pruned at removal, never left for a sweep to find",
2919 );
2920 }
2921
2922 #[test]
2926 fn removing_one_subscription_keeps_the_owners_surviving_anchors() {
2927 let (host, ws, _dir) = host_with_c1_subscriber("forge", Some("tester"));
2928 ws.add_slack_subscription(sub_mentions_for("forge", Some("tester")), true);
2929 let parent = recent_ts();
2930 host.follow_thread("acme", "C2", &parent, owner("forge", Some("tester")), &parent);
2931
2932 let c1_id = ws
2933 .slack_subscriptions_for_project("forge")
2934 .into_iter()
2935 .find(|sub| {
2936 sub.target
2937 == SlackSubscriptionTarget::Conversation {
2938 id: "C1".to_owned(),
2939 name: None,
2940 mode: SlackWatchMode::All,
2941 }
2942 })
2943 .expect("the C1 subscription")
2944 .id;
2945 assert!(ws.remove_slack_subscription_owned_by("forge", c1_id, Some("tester")).is_some());
2946
2947 assert_eq!(
2948 host.followed_threads("acme", "C2").len(),
2949 1,
2950 "the owner's surviving Mentions subscription keeps its thread row",
2951 );
2952 }
2953
2954 #[test]
2958 fn worker_teardown_removes_its_subscriptions_and_threads_only() {
2959 let (host, ws, _dir) = host_with_c1_subscriber("forge", Some("a"));
2960 ws.add_slack_subscription(sub_for_conversation("forge", Some("b"), "C1"), true);
2961 let parent = recent_ts();
2962 host.follow_thread("acme", "C1", &parent, owner("forge", Some("a")), &parent);
2963 host.follow_thread("acme", "C1", &parent, owner("forge", Some("b")), &parent);
2964
2965 ws.seed_test_project("forge", "/tmp/slack-teardown");
2966 let key = ws.list_projects().into_iter().find(|v| v.name == "forge").expect("seeded").key;
2967 ws.remove_slack_subscriptions_for_worker(&key, "a");
2968
2969 let left = ws.slack_subscriptions_for_project("forge");
2970 assert_eq!(left.len(), 1, "a's subscription is gone, b's remains");
2971 assert_eq!(left[0].team_role.as_deref(), Some("b"));
2972 let threads = host.followed_threads("acme", "C1");
2973 assert_eq!(
2974 threads[0].owners,
2975 vec![owner("forge", Some("b"))],
2976 "a's ownership is pruned, b keeps the thread",
2977 );
2978 }
2979
2980 #[test]
2981 fn advancing_a_thread_cursor_keeps_its_owners() {
2982 let (host, _ws, _dir) = host_with_c1_subscriber("forge", Some("tester"));
2983 let parent = recent_ts();
2984 host.follow_thread("acme", "C1", &parent, owner("forge", Some("tester")), &parent);
2985 let advanced = recent_ts();
2986 host.set_thread_watermark("acme", "C1", &parent, &advanced);
2987 assert_eq!(
2988 host.thread_watermark("acme", "C1", &parent).expect("read"),
2989 Some(advanced),
2990 "the cursor advances to the newest delivered reply",
2991 );
2992 assert_eq!(
2993 host.followed_threads("acme", "C1")[0].owners,
2994 vec![owner("forge", Some("tester"))],
2995 "and the owners ride along untouched",
2996 );
2997 }
2998
2999 #[test]
3000 fn a_conversation_already_watched_is_not_auto_subscribed_again() {
3001 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
3002 ws.add_slack_subscription(sub_mentions_for("forge", None), true);
3003 ws.add_slack_subscription(sub_for_conversation("forge", None, "C1"), true);
3004
3005 assert!(
3006 !ws.auto_subscribe_slack_conversation("acme", "C1", "200.1", None),
3007 "a conversation that owner already watches adds nothing",
3008 );
3009 assert_eq!(ws.slack_subscriptions_for_project("forge").len(), 2, "and no third record");
3010 }
3011
3012 #[test]
3013 fn a_redelivered_message_is_dropped_once() {
3014 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
3015 let message = SlackMessage {
3016 workspace: "acme".to_owned(),
3017 conversation: "C1".to_owned(),
3018 conversation_label: "C1".to_owned(),
3019 ts: "200.1".to_owned(),
3020 thread_ts: None,
3021 user: Some("U9".to_owned()),
3022 author: None,
3023 text: "hello".to_owned(),
3024 parent_user_id: None,
3025 latest_reply: None,
3026 edited: None,
3027 files: Vec::new(),
3028 };
3029
3030 assert!(!ws.slack_delivery_seen("forge", None, &message), "the first delivery is unseen");
3031 ws.slack_delivery_commit("forge", None, std::slice::from_ref(&message));
3032 assert!(
3033 ws.slack_delivery_seen("forge", None, &message),
3034 "the same ts in the same conversation to the same owner is a re-run",
3035 );
3036 }
3037
3038 #[test]
3043 fn a_correction_is_a_new_delivery_while_a_re_run_is_not() {
3044 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
3045 let message = SlackMessage {
3046 workspace: "acme".to_owned(),
3047 conversation: "C1".to_owned(),
3048 conversation_label: "C1".to_owned(),
3049 ts: "200.1".to_owned(),
3050 thread_ts: None,
3051 user: Some("U9".to_owned()),
3052 author: None,
3053 text: "deploy is green".to_owned(),
3054 parent_user_id: None,
3055 latest_reply: None,
3056 edited: None,
3057 files: Vec::new(),
3058 };
3059 ws.slack_delivery_commit("forge", None, std::slice::from_ref(&message));
3060
3061 let corrected = SlackMessage {
3062 text: "deploy is RED".to_owned(),
3063 edited: Some("250.1".to_owned()),
3064 ..message.clone()
3065 };
3066 assert!(
3067 !ws.slack_delivery_seen("forge", None, &corrected),
3068 "an edit past the delivered version is a new delivery, not a duplicate",
3069 );
3070 ws.slack_delivery_commit("forge", None, std::slice::from_ref(&corrected));
3071 assert!(
3072 ws.slack_delivery_seen("forge", None, &corrected),
3073 "and the same edit re-run is the duplicate the memory is for",
3074 );
3075 }
3076
3077 #[test]
3080 fn a_delivery_to_one_owner_never_dedupes_another_owners() {
3081 let (ws, _dir, _rx) = workspace_with_one_slack_workspace("acme");
3082 let message = SlackMessage {
3083 workspace: "acme".to_owned(),
3084 conversation: "C1".to_owned(),
3085 conversation_label: "C1".to_owned(),
3086 ts: "200.1".to_owned(),
3087 thread_ts: None,
3088 user: Some("U9".to_owned()),
3089 author: None,
3090 text: "hello".to_owned(),
3091 parent_user_id: None,
3092 latest_reply: None,
3093 edited: None,
3094 files: Vec::new(),
3095 };
3096
3097 ws.slack_delivery_commit("forge", None, std::slice::from_ref(&message));
3098 assert!(
3099 !ws.slack_delivery_seen("forge", Some("tester"), &message),
3100 "the worker's delivery is its own, never the lead's duplicate",
3101 );
3102 }
3103
3104 #[test]
3105 fn a_subscription_is_scoped_to_its_project_and_owner() {
3106 let (ws, _rx) = Workspace::testing_stub();
3107 ws.add_slack_subscription(sub_for("forge", None), true);
3108 ws.add_slack_subscription(sub_for("forge", Some("tester")), true);
3109 ws.add_slack_subscription(sub_for("other", None), true);
3110
3111 let visible = ws.slack_subscriptions_for_project("forge");
3112 assert_eq!(visible.len(), 2, "another project's subscription must not leak in");
3113 assert!(visible.iter().any(|s| s.team_role.is_none()));
3114 assert!(visible.iter().any(|s| s.team_role.as_deref() == Some("tester")));
3115 }
3116
3117 #[test]
3118 fn a_worker_cannot_remove_another_owners_subscription() {
3119 let (ws, _rx) = Workspace::testing_stub();
3120 let lead = sub_for("forge", None);
3121 ws.add_slack_subscription(lead.clone(), true);
3122
3123 assert!(
3124 ws.remove_slack_subscription_owned_by("forge", lead.id, Some("tester")).is_none(),
3125 "a worker must not remove the lead's subscription",
3126 );
3127 assert_eq!(
3128 ws.slack_subscriptions_for_project("forge").len(),
3129 1,
3130 "a refused removal removes nothing",
3131 );
3132 assert!(ws.remove_slack_subscription_owned_by("forge", lead.id, None).is_some());
3133 assert!(ws.slack_subscriptions_for_project("forge").is_empty());
3134 }
3135
3136 #[test]
3137 fn duplicate_workspace_labels_are_refused() {
3138 let configs = vec![cfg("acme", "xoxp-test"), cfg("acme", "xoxp-other")];
3139 let err = SlackWorkspaces::from_config(&configs, &reqwest::Client::new())
3140 .expect_err("two workspaces sharing a label cannot be addressed apart");
3141 assert!(err.contains("acme"), "the error has to name the shared label, got: {err}");
3142 }
3143
3144 #[test]
3145 fn an_empty_token_is_refused() {
3146 let configs = vec![cfg("acme", "")];
3147 let err = SlackWorkspaces::from_config(&configs, &reqwest::Client::new())
3148 .expect_err("a blank token would fail on every call");
3149 assert!(
3150 err.contains("acme") && err.contains("empty token"),
3151 "the error has to name the workspace and the missing credential, got: {err}",
3152 );
3153 }
3154
3155 use forge_connectors::slack::MENTION_CURSOR;
3156
3157 #[test]
3158 fn an_empty_workspace_label_is_refused() {
3159 let configs = vec![cfg("", "xoxp-test")];
3160 let err = SlackWorkspaces::from_config(&configs, &reqwest::Client::new())
3161 .expect_err("a workspace with no label cannot be addressed at all");
3162 assert!(
3163 err.contains("empty workspace label"),
3164 "the error has to name the label, got: {err}",
3165 );
3166 }
3167
3168 #[test]
3172 fn unsubscribing_the_last_subscription_clears_the_conversation_cursor() {
3173 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3174 ws.install_db_for_test(
3175 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3176 );
3177 ws.add_slack_subscription(sub_for_conversation("forge", None, "C1"), true);
3178 ws.set_slack_watermark("acme", "C1", "100.0");
3179
3180 let id = ws.slack_subscriptions_for_project("forge")[0].id;
3181 assert!(ws.remove_slack_subscription_owned_by("forge", id, None).is_some());
3182
3183 let db = ws.db.lock();
3184 assert_eq!(
3185 crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
3186 .expect("read"),
3187 None,
3188 "a cursor with no watcher is a stale backlog waiting to replay",
3189 );
3190 }
3191
3192 #[test]
3196 fn a_new_thread_seeds_its_clocks_and_the_first_walk_mark() {
3197 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3198 ws.install_db_for_test(
3199 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3200 );
3201 let since = format!(
3204 "{}.000000",
3205 std::time::SystemTime::now()
3206 .duration_since(std::time::UNIX_EPOCH)
3207 .expect("the clock is past the epoch")
3208 .as_secs()
3209 );
3210 let owner =
3211 SlackThreadOwner { project: "forge".to_owned(), team_role: Some("tester".to_owned()) };
3212 ws.follow_slack_thread("acme", "C1", "100.0", owner.clone(), &since);
3213 let host = SlackSubsystemHost::new(&ws);
3214
3215 assert!(host.thread_first_walk("acme", "C1", "100.0"), "a new row is a first walk");
3216 assert!(host.thread_first_walk("acme", "C1", "100.0"), "a peek does not consume the mark");
3217 assert_eq!(
3218 host.thread_edit_watermark("acme", "C1", "100.0").as_deref(),
3219 Some(since.as_str()),
3220 "the edit watermark starts at the row's seed",
3221 );
3222 host.clear_thread_first_walk("acme", "C1", "100.0");
3223 assert!(!host.thread_first_walk("acme", "C1", "100.0"), "and is cleared once read");
3224
3225 host.mark_thread_full_read("acme", "C1", "100.0", std::time::Instant::now());
3226 assert!(
3227 host.thread_full_read_at("acme", "C1", "100.0").is_some(),
3228 "a whole-read attempt is stamped",
3229 );
3230
3231 let second = SlackThreadOwner { project: "forge".to_owned(), team_role: None };
3232 ws.follow_slack_thread("acme", "C1", "100.0", second, &since);
3233 assert!(
3234 !host.thread_first_walk("acme", "C1", "100.0"),
3235 "following an existing row again does not re-arm the mark",
3236 );
3237 }
3238
3239 #[test]
3242 fn a_pruned_thread_forgets_its_clocks() {
3243 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3244 ws.install_db_for_test(
3245 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3246 );
3247 let since = format!(
3248 "{}.000000",
3249 std::time::SystemTime::now()
3250 .duration_since(std::time::UNIX_EPOCH)
3251 .expect("the clock is past the epoch")
3252 .as_secs()
3253 );
3254 let owner =
3255 SlackThreadOwner { project: "forge".to_owned(), team_role: Some("gone".to_owned()) };
3256 ws.follow_slack_thread("acme", "C1", "100.0", owner, &since);
3257 let host = SlackSubsystemHost::new(&ws);
3258 assert!(host.thread_first_walk("acme", "C1", "100.0"));
3259 assert!(host.thread_edit_watermark("acme", "C1", "100.0").is_some());
3260
3261 ws.prune_slack_threads("acme");
3262
3263 assert!(!host.thread_first_walk("acme", "C1", "100.0"), "the first-walk mark is gone");
3264 assert_eq!(
3265 host.thread_edit_watermark("acme", "C1", "100.0"),
3266 None,
3267 "and the edit watermark with it",
3268 );
3269 assert_eq!(host.thread_full_read_at("acme", "C1", "100.0"), None, "as is the stamp");
3270 }
3271
3272 #[test]
3278 fn a_restart_does_not_re_offer_a_thread_edit_already_delivered() {
3279 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3280 ws.install_db_for_test(
3281 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3282 );
3283 let since = format!(
3284 "{}.000000",
3285 std::time::SystemTime::now()
3286 .duration_since(std::time::UNIX_EPOCH)
3287 .expect("the clock is past the epoch")
3288 .as_secs()
3289 );
3290 let owner =
3291 SlackThreadOwner { project: "forge".to_owned(), team_role: Some("tester".to_owned()) };
3292 ws.follow_slack_thread("acme", "C1", "100.0", owner, &since);
3293 let host = SlackSubsystemHost::new(&ws);
3294 host.set_thread_edit_watermark("acme", "C1", "100.0", "990.0");
3297 host.set_thread_watermark("acme", "C1", "100.0", "995.0");
3298
3299 ws.forget_thread_clocks("acme", "C1", "100.0");
3301
3302 assert_eq!(
3303 host.thread_edit_watermark("acme", "C1", "100.0").as_deref(),
3304 Some("990.0"),
3305 "the delivered correction's stamp has to survive the restart, or the next whole \
3306 read re-offers it",
3307 );
3308 }
3309
3310 #[test]
3315 fn a_thread_row_without_a_stored_edit_stamp_reads_its_cursor() {
3316 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3317 ws.install_db_for_test(
3318 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3319 );
3320 let legacy = r#"{"cursor":"500.0","owners":[{"project":"forge","team_role":"tester"}],"participating":true}"#;
3324 {
3325 let db = ws.db.lock();
3326 let db = db.as_ref().expect("db installed");
3327 let txn = db.database().begin_write().expect("begin a write");
3328 {
3329 let table: redb::TableDefinition<&str, &str> =
3330 redb::TableDefinition::new("slack_threads");
3331 let mut table = txn.open_table(table).expect("open the thread table");
3332 table.insert("acme\u{0}C1\u{0}100.0", legacy).expect("insert the legacy row");
3333 }
3334 txn.commit().expect("commit the legacy row");
3335 }
3336 let stored = {
3337 let db = ws.db.lock();
3338 crate::store::slack::thread(db.as_ref().expect("db installed"), "acme", "C1", "100.0")
3339 .expect("read")
3340 .expect("the legacy row decodes")
3341 };
3342 assert_eq!(stored.edit_watermark, None, "an absent key reads as no stamp");
3343
3344 let host = SlackSubsystemHost::new(&ws);
3345 assert_eq!(
3346 host.thread_edit_watermark("acme", "C1", "100.0").as_deref(),
3347 Some("500.0"),
3348 "an older row judges up to its cursor, not from the beginning",
3349 );
3350 let seeded = {
3351 let db = ws.db.lock();
3352 crate::store::slack::thread(db.as_ref().expect("db installed"), "acme", "C1", "100.0")
3353 .expect("read")
3354 .expect("the row survives")
3355 };
3356 assert_eq!(
3357 seeded.edit_watermark.as_deref(),
3358 Some("500.0"),
3359 "and the seed is persisted, so the next restart reads it back",
3360 );
3361 }
3362
3363 #[test]
3366 fn an_older_edit_stamp_does_not_roll_the_stored_one_back() {
3367 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3368 ws.install_db_for_test(
3369 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3370 );
3371 let since = recent_ts();
3372 let secs: u64 =
3373 since.split('.').next().expect("a seconds prefix").parse().expect("numeric");
3374 let newer = format!("{}.000000", secs + 120);
3375 let older = format!("{}.000000", secs + 60);
3376 ws.follow_slack_thread("acme", "C1", "100.0", owner("forge", Some("tester")), &since);
3377 let host = SlackSubsystemHost::new(&ws);
3378 host.set_thread_edit_watermark("acme", "C1", "100.0", &newer);
3379 host.set_thread_edit_watermark("acme", "C1", "100.0", &older);
3380
3381 assert_eq!(
3382 host.thread_edit_watermark("acme", "C1", "100.0").as_deref(),
3383 Some(newer.as_str()),
3384 "the cache keeps the newer stamp",
3385 );
3386 ws.forget_thread_clocks("acme", "C1", "100.0");
3387 host.set_thread_edit_watermark("acme", "C1", "100.0", &older);
3391 assert_eq!(
3392 host.thread_edit_watermark("acme", "C1", "100.0").as_deref(),
3393 Some(newer.as_str()),
3394 "and the row keeps it, so a restart cannot roll it back",
3395 );
3396 }
3397
3398 #[test]
3401 fn a_surviving_second_subscription_keeps_the_conversation_cursor() {
3402 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3403 ws.install_db_for_test(
3404 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3405 );
3406 ws.add_slack_subscription(sub_for_conversation("forge", None, "C1"), true);
3407 ws.add_slack_subscription(sub_for_conversation("forge", Some("tester"), "C1"), true);
3408 ws.set_slack_watermark("acme", "C1", "100.0");
3409
3410 let lead_id = ws
3411 .slack_subscriptions_for_project("forge")
3412 .into_iter()
3413 .find(|sub| sub.team_role.is_none())
3414 .expect("the lead's subscription")
3415 .id;
3416 assert!(ws.remove_slack_subscription_owned_by("forge", lead_id, None).is_some());
3417
3418 let db = ws.db.lock();
3419 assert_eq!(
3420 crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
3421 .expect("read"),
3422 Some("100.0".to_owned()),
3423 "the worker still watches C1, so its pending window survives",
3424 );
3425 }
3426
3427 #[test]
3431 fn removing_the_last_mentions_subscription_clears_the_mention_cursor() {
3432 let (ws, dir, _rx) = workspace_with_one_slack_workspace("acme");
3433 ws.install_db_for_test(
3434 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
3435 );
3436 ws.add_slack_subscription(sub_mentions_for("forge", None), true);
3437 ws.set_slack_watermark("acme", MENTION_CURSOR, "100.0");
3438
3439 let id = ws.slack_subscriptions_for_project("forge")[0].id;
3440 assert!(ws.remove_slack_subscription_owned_by("forge", id, None).is_some());
3441
3442 let db = ws.db.lock();
3443 assert_eq!(
3444 crate::store::slack::watermark(
3445 db.as_ref().expect("db installed"),
3446 "acme",
3447 MENTION_CURSOR
3448 )
3449 .expect("read"),
3450 None,
3451 "a stale mention cursor would replay the entire stream",
3452 );
3453 }
3454
3455 fn next_connectors_changed(
3458 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
3459 ) -> (SessionSlot, Vec<forge_primitives::GotifySubscription>, Vec<SlackSubscription>) {
3460 match rx.try_recv().map(|frame| std::sync::Arc::unwrap_or_clone(frame.update)) {
3461 Ok(crate::protocol::SessionUpdate::ConnectorSubscriptionsChanged {
3462 key,
3463 gotify,
3464 slack,
3465 }) => (key, gotify, slack),
3466 other => {
3467 panic!("expected a ConnectorSubscriptionsChanged on the stream, got {other:?}")
3468 }
3469 }
3470 }
3471
3472 #[test]
3481 fn every_slack_write_announces_the_projects_subscriptions() {
3482 let (ws, _dir, mut rx) = workspace_with_one_slack_workspace("acme");
3483 ws.seed_test_project("forge", "/tmp/tp-slack");
3484 let lead = SessionSlot::lead("TestOrg", "forge");
3485
3486 ws.add_slack_subscription(sub_for("forge", None), true);
3487 let (key, gotify, slack) = next_connectors_changed(&mut rx);
3488 assert_eq!(key, lead, "a subscribe routes on the project's lead seat");
3489 assert_eq!(slack.len(), 1, "and carries the set the core now holds");
3490 assert!(gotify.is_empty(), "beside the sibling connector's own set");
3491
3492 let watched =
3494 ws.watch_slack_conversation("acme", "forge", None, "C1", SlackWatchMode::All, true);
3495 let id = watched.id;
3496 let (_, _, slack) = next_connectors_changed(&mut rx);
3497 assert!(slack.iter().any(|sub| sub.id == id), "a new watch is announced: {slack:?}");
3498
3499 let same =
3501 ws.watch_slack_conversation("acme", "forge", None, "C1", SlackWatchMode::All, true);
3502 assert_eq!(same.id, id, "precondition: the watch found the record it had");
3503 let announced = rx.try_recv();
3504 assert!(
3505 announced.is_err(),
3506 "a watch that moved nothing announces nothing, and this was: {announced:?}",
3507 );
3508
3509 ws.watch_slack_conversation(
3512 "acme",
3513 "forge",
3514 None,
3515 "C1",
3516 SlackWatchMode::MentionsOnly,
3517 true,
3518 );
3519 let (_, _, slack) = next_connectors_changed(&mut rx);
3520 assert!(
3521 slack.iter().any(|sub| {
3522 sub.id == id
3523 && sub.target
3524 == SlackSubscriptionTarget::Conversation {
3525 id: "C1".to_owned(),
3526 name: None,
3527 mode: SlackWatchMode::MentionsOnly,
3528 }
3529 }),
3530 "the mode the watch moved to is what is announced: {slack:?}",
3531 );
3532
3533 assert!(
3534 ws.remove_slack_subscription_owned_by("forge", id, None).is_some(),
3535 "precondition: the lead removes its own subscription",
3536 );
3537 let (_, _, slack) = next_connectors_changed(&mut rx);
3538 assert!(
3539 slack.iter().all(|sub| sub.id != id),
3540 "an unsubscribe announces the set it left behind: {slack:?}",
3541 );
3542
3543 assert!(
3544 ws.remove_slack_subscription_owned_by("forge", Uuid::new_v4(), None).is_none(),
3545 "precondition: no subscription carries the id",
3546 );
3547 let announced = rx.try_recv();
3548 assert!(
3549 announced.is_err(),
3550 "a refused unsubscribe announces nothing, and this was: {announced:?}",
3551 );
3552 }
3553
3554 #[test]
3562 fn a_name_heal_announces_the_projects_subscriptions() {
3563 let (ws, _dir, mut rx) = workspace_with_one_slack_workspace("acme");
3564 ws.seed_test_project("forge", "/tmp/tp-slack-heal");
3565 let lead = SessionSlot::lead("TestOrg", "forge");
3566
3567 let id =
3568 ws.watch_slack_conversation("acme", "forge", None, "C1", SlackWatchMode::All, true).id;
3569 let (_, _, slack) = next_connectors_changed(&mut rx);
3570 assert!(
3571 slack.iter().any(|sub| sub.id == id),
3572 "precondition: the watch announced the record it created, unnamed: {slack:?}",
3573 );
3574
3575 ws.name_slack_conversation("acme", "C1", "general");
3576 let (announced_key, _, slack) = next_connectors_changed(&mut rx);
3577 assert_eq!(announced_key, lead, "a heal routes on the project's lead seat");
3578 assert!(
3579 slack.iter().any(|sub| {
3580 sub.id == id
3581 && sub.target
3582 == SlackSubscriptionTarget::Conversation {
3583 id: "C1".to_owned(),
3584 name: Some("general".to_owned()),
3585 mode: SlackWatchMode::All,
3586 }
3587 }),
3588 "and carries the name the heal wrote: {slack:?}",
3589 );
3590
3591 ws.name_slack_conversation("acme", "C1", "general");
3594 let announced = rx.try_recv();
3595 assert!(
3596 announced.is_err(),
3597 "a heal that named nothing new announces nothing, and this was: {announced:?}",
3598 );
3599 }
3600
3601 #[test]
3605 fn a_slack_teardown_announces_the_survivors() {
3606 let (ws, _dir, mut rx) = workspace_with_one_slack_workspace("acme");
3607 ws.seed_test_project("forge", "/tmp/tp-slack-teardown");
3608 let view_key = ws.project_key_for_name("forge").expect("seeded project");
3609 let lead = SessionSlot::lead("TestOrg", "forge");
3610
3611 ws.add_slack_subscription(sub_for("forge", None), true);
3612 ws.add_slack_subscription(sub_for("forge", Some("reviewer")), true);
3613 let (_, _, slack) = next_connectors_changed(&mut rx);
3614 assert_eq!(slack.len(), 1, "precondition: the lead's subscribe announced it");
3615 let (_, _, slack) = next_connectors_changed(&mut rx);
3616 assert_eq!(slack.len(), 2, "precondition: and the worker's announced the whole set");
3617
3618 ws.remove_slack_subscriptions_for_worker(&view_key, "reviewer");
3619 let (announced_key, _, slack) = next_connectors_changed(&mut rx);
3620 assert_eq!(announced_key, lead, "a teardown routes on the project's lead seat");
3621 assert!(
3622 slack.iter().all(|sub| sub.team_role.is_none()),
3623 "and announces what the teardown left: {slack:?}",
3624 );
3625
3626 ws.remove_slack_subscriptions_for_worker(&view_key, "nobody");
3629 let announced = rx.try_recv();
3630 assert!(
3631 announced.is_err(),
3632 "a teardown that took nothing announces nothing, and this was: {announced:?}",
3633 );
3634 }
3635}