Skip to main content

forge_workspace/
slack.rs

1//! The Slack seam on [`Workspace`]: one client per configured workspace,
2//! the subscription records sessions create, and the boot verification
3//! that proves each token.
4//!
5//! Everything here stays on `Workspace` as a second `impl` block, the
6//! way [`crate::gotify`] does, so the boot path and the `mcp::slack`
7//! facade keep their paths. The client itself lives in
8//! `forge_connectors::slack`; this module holds the workspace state.
9
10use 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
26/// What ended a held draft, as the registry resolves it: the reader's
27/// answer, or nothing at all when the asking session went away.
28pub(crate) enum DraftResolution {
29    Answered(SlackDraftAnswer),
30    Abandoned,
31}
32
33/// How long a delivered message stays remembered. Long enough to cover a
34/// sweep that re-runs after a 429 or a restart, short enough that the map
35/// stays small.
36const DELIVERY_REMEMBER: Duration = Duration::from_secs(300);
37
38const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
39
40/// The age of a Slack `ts` in whole days, read from its seconds prefix.
41/// The fraction is cursor precision, not calendar precision; the coarse
42/// prefix is all the idle check needs. An unparseable cursor reads as
43/// ancient and drops the row.
44fn 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/// One client per configured workspace, keyed by its label.
54///
55/// Held as [`SlackApi`] rather than the concrete client so a test can
56/// substitute a double and drive a real facade against it. `Debug` is
57/// hand-written because a trait object is not, and it prints only the
58/// labels: a client is not a thing worth printing.
59#[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    /// Labels key the clients a project's binding resolves to and name the
72    /// workspace in the Inspector, so they must be distinct and the token
73    /// must be present.
74    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    /// A set built from caller-supplied APIs, so a test can drive a real
87    /// facade against a double instead of a live workspace.
88    #[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    /// Every configured label, sorted (the map is ordered).
98    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    /// Prove every token at boot. A failure is reported, never fatal:
107    /// one dead workspace must not stop forge from booting.
108    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
118/// The semantic checks a `[[slack]]` list must pass. Shared by the boot's
119/// client build and the config loader, so a reload refuses what a restart
120/// refuses.
121pub(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
138/// The distinct projects a set of subscriptions belongs to, in the order the
139/// subscriptions carry them: one announcement per project a write moved.
140fn 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    /// Register a Slack subscription in the active set. Durable ones also
152    /// persist to the redb store; ephemeral ad-hoc-worker ones stay in
153    /// memory only and drop on restart.
154    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    /// Whether this exact message has already been handed to its
175    /// destination. The owner is part of the key: a lead and a worker in
176    /// one project are two destinations, and one key would starve the
177    /// second as "already delivered". The edit ts is part of it too: a
178    /// correction is a new delivery, while a sweep re-run is not.
179    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    /// Record a hand-off so a sweep re-run drops it. Committed only after
198    /// a successful hand-off: a failed dispatch leaves the keys
199    /// unrecorded, so the next sweep re-delivers instead of losing the
200    /// messages behind a cursor that moved on.
201    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    /// Hold a composed draft and hand back its id plus the receiver the
222    /// caller awaits. The draft is addressed to `caller`, so its prompt
223    /// surfaces in the session that will read the reply rather than in
224    /// whichever session happens to be focused.
225    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            // No subscriber can answer this draft - either none is
240            // attached or none renders one - so holding the caller
241            // would park it forever. Drop the draft; the awaiting
242            // caller sees the dropped receiver and fails closed.
243            self.slack_drafts.lock().remove(&id);
244            let (_ignored_sender, dead_receiver) = tokio::sync::oneshot::channel();
245            return (id, dead_receiver);
246        }
247        // **A second draft is a second ask.** The number is the ask's identity
248        // on the wire, so a draft that never moves it is never announced.
249        self.note_ask(caller);
250        (id, receiver)
251    }
252
253    /// Whether a draft with this id is still registered to `caller`, without
254    /// removing it - the read the dispatch guard makes before an answer.
255    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    /// Remove every Slack subscription the worker `label` owns in
260    /// `project_key`, from the active set and the store. Worker teardown
261    /// calls this so a despawned worker cannot strand records - and so a
262    /// delivery for it does not fall through to the lead.
263    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        // The worker's subscriptions are all gone by now, so the
313        // alive-set prune drops exactly its ownership, and the cursor
314        // clear drops the cursors nothing covers any more.
315        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    /// Answer a held draft, returning whether one was waiting for THIS
323    /// caller. That return is also how a caller asks whether a draft is
324    /// still outstanding: it is true exactly once, for the entry that was
325    /// there. A draft held for another session is refused rather than
326    /// answered, and a caller that has gone away is not an error.
327    ///
328    /// The removal is the one point every removal a view could have parked
329    /// on funnels through - an answer, or a dead waiter - so the stand-down
330    /// is emitted here. The one other removal,
331    /// `register_slack_draft`'s no-answerable-subscriber path, drops a draft
332    /// the update may already have reached an observer with: a subscriber
333    /// that could ANSWER is what makes a draft answerable at all, and an
334    /// observer draws no dock to stand down.
335    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        // A dead waiter is the abandoned path, and it has nothing to receive.
360        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    /// Record the last `ts` delivered for a conversation in a workspace,
372    /// through the store so it survives a restart.
373    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    /// Record a message forge posted, so the sweep refuses it by identity
386    /// rather than by author. forge posts as the user, so this is the only
387    /// thing that tells its own words from the ones Ved typed.
388    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    /// Whether forge posted this message itself. A ledger that cannot be
409    /// read answers `false`: a self-post delivered once costs what a lost
410    /// ledger costs, while refusing would drop what the user wrote. The
411    /// failure is worth a line once, not once per message, because a
412    /// persistent fault would otherwise echo every post back to the agent.
413    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                // A read that works again ends the failure, so a later
419                // fault is a new line rather than silence.
420                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    /// Mark one call's post in flight until the guard drops, so a sweep
454    /// that runs mid-post skips the conversation instead of delivering a
455    /// message whose ts is not ledgered yet. A guard covers ONE call and
456    /// its ledger write: an operation of several calls takes one per call,
457    /// or a slow but healthy Slack would retire the mark mid-operation.
458    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    /// How long the oldest live post call in `conversation` has been in
474    /// flight, or `None`. A stamp past the request timeout is expired
475    /// rather than obeyed, so a stalled call cannot silence the
476    /// conversation. A stamp crossing half the budget says so once, on the
477    /// transition; both are forge's own problems, so both are warnings.
478    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    /// Fill in the display name on this workspace's conversation
533    /// subscriptions that have none. A durable record is written through
534    /// the store; an ephemeral one is named in memory only. Only unnamed
535    /// records are touched: a conversation renamed since it was
536    /// subscribed keeps the stored name.
537    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        // The name is what the section draws, and a watched conversation
559        // starts unnamed and heals on its first message - so the heal is the
560        // common way a set moves. Announced before the store work, which a
561        // run with no store skips: the set a client draws is the one in
562        // memory, and a project's records may heal in the same sweep.
563        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        // An ephemeral ad-hoc-worker subscription lives in memory only by
569        // design, and inserting it here would promote it to durable.
570        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    /// Whether the conversation (or the `__mentions__` stream) has a sweep
596    /// cursor yet. The cursor belongs to the conversation and is shared by
597    /// every owner watching it, so a second subscriber seeds only when
598    /// this is false.
599    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    /// Track a thread because a delivered message anchors it, owned by the
606    /// matching subscription. A new row's cursor starts at `since` - the
607    /// delivered message's own ts, not the parent's: the parent of a
608    /// mention can be weeks old, and a cursor seeded from it would
609    /// idle-drop the thread before its first reply walk. A row that
610    /// exists keeps its cursor and gains the owner if it was not there.
611    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                // A thread whose newest reply is already older than the idle
629                // window would be dropped the moment the sweep listed it, and
630                // the sweep anchors every parent it can see on every tick -
631                // so writing the row would rewrite it forever for nothing.
632                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            // The edits this row must judge start at its seed: anything older
669            // was the previous row's, and the first walk is ts-only anyway.
670            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    /// Drop a removed thread's in-memory clocks, so a thread re-followed
676    /// under the same parent starts clean.
677    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    /// Record that the user is in a thread, so every later walk reads past the
685    /// cursor rather than the whole thread.
686    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    /// The idle window this workspace drops a thread after, from its
715    /// `[[slack]]` entry: the followed set is per workspace, so its window
716    /// is too. A workspace whose entry is gone keeps the default.
717    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    /// The age a delivered block reaches before its header says so, from its
726    /// `[[slack]]` entry. A workspace whose entry is gone keeps the default.
727    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    /// The threads tracked for a conversation, pruned while listed: an
738    /// owner with no remaining subscription in the workspace is dropped
739    /// from the record, a row whose last owner goes is deleted, and a row
740    /// idle past the workspace's window is dropped with a debug log.
741    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    /// A thread's reply cursor, or `None` when the thread has none yet.
799    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    /// Advance a thread's reply cursor, through the store so it survives a
812    /// restart.
813    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    /// A thread's newest judged edit stamp. The map is a cache; a miss reads
857    /// the row, so a restart judges edits against what the last process
858    /// delivered instead of re-offering the thread's whole edit history.
859    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        // A row written before the stamp was stored has none. Its cursor
888        // approximates it, and both costs are bounded to the upgrade: an
889        // edit that landed before it and sits below the cursor but was never
890        // judged is dropped, and an already-handed correction above the
891        // cursor is re-offered once.
892        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    /// Advance a thread's judged edit stamp, through the store so a restart
917    /// does not re-offer the correction it clears. The mark only moves
918    /// forward: a caller that read a stale value must not roll a newer one
919    /// back.
920    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            // No row: there is nothing durable to stamp, and a row created
936            // later seeds its own.
937            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    /// Cache a thread's edit stamp, moving it forward only.
978    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    /// Watch `conversation` because a mention arrived in it, owned by
986    /// whoever subscribes to mentions in that workspace - not the lead,
987    /// and not whichever session happens to be active. `All` mode is
988    /// deliberate: pulled into a conversation by a mention, the agent
989    /// should see what is said next rather than only the next mention.
990    /// The cursor starts at `since` (the mention that pulled us in), so
991    /// the channel's history is not swept. Returns whether a record was
992    /// added. A label that is empty or the conversation id itself (the
993    /// search path's fallback) stores as no name.
994    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        // The cursor starts at the mention that pulled us in - but only when
1028        // the conversation has no cursor yet. The cursor is shared by
1029        // every owner watching the conversation, and another owner's
1030        // pending window must survive this auto-subscribe; the mention
1031        // itself was delivered through search, not this cursor.
1032        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    /// Per-workspace pump liveness, for the Inspector's SLACK section.
1057    /// One entry per workspace a pump has reported on.
1058    pub fn slack_connected_workspaces(&self) -> std::collections::BTreeMap<String, bool> {
1059        self.slack_connected.lock().clone()
1060    }
1061
1062    /// Watch a conversation for this owner, handing back the record that
1063    /// already exists when there is one - a second record renders as a second
1064    /// Inspector row for one channel. A watch whose mode differs updates that
1065    /// record, since the caller asked for something it did not have and there
1066    /// is no other route to change a mode. The check and the write share one
1067    /// guard, so two subscribes racing cannot both create.
1068    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        // The announcement is made after the guards drop: it reads both
1078        // connectors' sets, and a second lock on the one held here is a
1079        // deadlock rather than a warning.
1080        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                    // A same-mode watch writes the record it already holds,
1097                    // so the section moved for neither.
1098                    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    /// Every Slack subscription owned in `project`, whichever session
1146    /// owns it. Backs the pump, which needs the whole set.
1147    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    /// Remove the subscription `id` in `project` only when its owner
1155    /// matches `owner` (`None` = a lead subscription, `Some(label)` =
1156    /// that worker's), from both the active set and the redb store.
1157    /// Returns the row as it stood (`None` when nothing matched). Backs
1158    /// the owner-scoped `slack__unsubscribe` so a caller removes only
1159    /// what it subscribed.
1160    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    /// Clear the sweep cursors the removed subscription was the last
1193    /// cover for. "Has cursor" must mean a live pending window: a cursor
1194    /// left behind by the last unsubscribe replays the whole gap to a
1195    /// later subscriber as fresh deliveries, and the mention cursor has
1196    /// no baseline arm at all, so its clearing is what makes the
1197    /// re-subscribe seed fresh.
1198    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                // The class covered every DM cursor the sweep baselined;
1241                // the ones no remaining subscription names are stale now.
1242                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    /// Prune every followed thread in `workspace` against the same
1262    /// predicate the sweep's listing uses: an owner survives only while
1263    /// it holds ANY remaining subscription in the workspace. Runs when a
1264    /// subscription is removed, so a thread whose conversation is no
1265    /// longer swept does not freeze until some sweep happens to list it -
1266    /// [`Self::slack_followed_threads`] re-prunes as a backstop. The
1267    /// surviving set decides because an owner key removed with one
1268    /// subscription may still hold others: a Mentions target anchors
1269    /// threads in every conversation its mentions touched.
1270    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    /// Prove each workspace's token once, logging the team it belongs to
1322    /// or the failure. Idempotent, and a no-op with no `[[slack]]` entry.
1323    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                        // Kept because the pump needs the id to recognise
1337                        // `<@U...>` mentions, and `auth.test` is the only
1338                        // call that reports it.
1339                        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    /// Whether boot could not read the durable Slack subscriptions.
1360    pub fn slack_subscription_load_failed(&self) -> bool {
1361        self.slack_load_failed.load(std::sync::atomic::Ordering::SeqCst)
1362    }
1363
1364    /// Re-prove one workspace's token in the background, so a boot-time
1365    /// auth failure heals itself instead of leaving the mention stream
1366    /// down until restart. Rate-limited to one attempt a minute per
1367    /// workspace; the caller is the host's user-id miss.
1368    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    /// Start one pump per configured workspace that has at least one
1407    /// subscription. Idempotent per workspace, and a no-op with no
1408    /// `[[slack]]` entry.
1409    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            // Clamped because the two clocks share one token's allowance: a
1430            // value below the floor is the overrun the key exists to prevent.
1431            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    /// Stop the Slack pump for any workspace that no longer has a
1446    /// subscription, and mark it disconnected. Pumps are per workspace, so
1447    /// one workspace losing its subscriptions must not keep another's
1448    /// pump alive - nor leave its own running with nothing to watch.
1449    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
1478/// One live post call in a conversation: its identity, when it started,
1479/// and whether the stall line has been said for it.
1480pub(crate) struct SlackPostStamp {
1481    pub(crate) id: u64,
1482    pub(crate) since: std::time::Instant,
1483    pub(crate) warned: bool,
1484}
1485
1486/// Stamps are identified by a process-wide counter, so a guard's `Drop`
1487/// removes its own stamp even after its entry was expired and its key
1488/// taken again by a later post.
1489static NEXT_POST_STAMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1490
1491/// Clears one call's in-flight stamp however it ends, a failed call or a
1492/// panic included. It removes only its own stamp, never a later call's.
1493pub(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
1511/// The [`SlackHost`] one pump drives: a `Weak<Workspace>` behind the port,
1512/// so the workspace can drop while a pump runs and every port call then
1513/// degrades to a no-op.
1514pub(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        // A boot-time auth failure would otherwise keep the mention
1545        // stream down until restart; the retry heals it in the
1546        // background.
1547        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        // Direct, not via the command bus: the pump needs the delivery's
1723        // own outcome, and a bus round-trip reports only that the command
1724        // was accepted - a mid-delivery failure would read as success and
1725        // advance the cursor past an undelivered message.
1726        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    /// A workspace whose `[[slack]]` holds exactly `label`. The tempdir
1766    /// is returned so it outlives the workspace it names.
1767    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    /// An answer as the registry takes it. Every call site here is a view
1780    /// answering; the drop is reached through the paths under test rather
1781    /// than through this.
1782    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    /// A worker's draft must not surface in the lead's dock prompt: the
1809    /// approval is answered by whoever will read the reply, and the agent
1810    /// waiting on the oneshot is the one that asked.
1811    #[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    /// No UI to answer the draft: the registry must not hold it, and the
1840    /// awaiting caller sees a dead receiver - the fail-closed read.
1841    #[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    /// An observer attached to the stream cannot answer a draft, so the
1855    /// guard must fail it closed the way it does with no subscriber at
1856    /// all. `a_draft_with_no_ui_to_answer_it_fails_closed` cannot tell
1857    /// `send` from `send_answering` - with nobody attached both report
1858    /// false - so this is the test that catches pointing the guard back
1859    /// at `send`.
1860    #[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    /// The draft leaving the registry is the one moment every view has to
1895    /// hear about: each view keeps its own copy of the draft, and no
1896    /// other update clears it. The ending rides it, because a view that
1897    /// did not answer has nothing else to say what happened.
1898    #[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    /// A send-back is an answer the waiting call has to read: the note is
1923    /// what it revises from, so the registry hands it through whole and
1924    /// tells every view the draft went back rather than out.
1925    #[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    /// A send-back with nothing in it has no words to hand the agent, and
1958    /// resolving it would drop the reader's card for an answer that says
1959    /// nothing. The draft stays waiting so the field can be sent again.
1960    #[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    /// An answer is only applied by the session the draft was addressed
1983    /// to; another session naming the id is refused, and the draft stays
1984    /// waiting for its owner.
1985    #[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    /// Through `Workspace::dispatch`, the route a view's dock answer
2003    /// takes - the direct `resolve_slack_draft` tests above never
2004    /// exercise routing.
2005    #[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    /// A click on a dock the core has already resolved - answered in some
2026    /// other view - is refused with the reason, the same way the permission
2027    /// and question answers are, rather than dropped: the reader learns why
2028    /// the click did nothing.
2029    #[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    /// The guard's owner half: a draft is answered by the session it was
2049    /// addressed to, so another session's dispatch is refused rather than
2050    /// reporting an answer that never landed - and the draft stays waiting
2051    /// for the one that asked.
2052    #[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`: starting a subsystem spawns a pump, which needs a
2076    /// runtime.
2077    #[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        // Not on the lead, and not on whichever session happens to be
2110        // active: the conversation belongs to whoever asked to be told.
2111        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    /// The mention sweep's label falls back to the conversation id when
2130    /// Slack omits the name; storing it would render `#C1` and read as
2131    /// a channel that does not exist.
2132    #[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    /// The delivered message's label is the only name source for a
2157    /// conversation a mention pulled us into but the user never joined:
2158    /// `users.conversations` is membership-scoped, so the sweep can never
2159    /// name that one. Drives the trait seam rather than the helper, so a
2160    /// `None` handed to it cannot pass unnoticed.
2161    #[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    /// The sweep's backfill writes through to the store, so the name
2200    /// survives the restart that produced the unnamed record.
2201    #[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    /// The guard the doc comment promises: a conversation renamed since it
2227    /// was subscribed keeps the stored name rather than picking up the
2228    /// directory's current one.
2229    #[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    /// The same conversation id can be watched in two workspaces, so the
2259    /// heal is scoped to the workspace it was called for.
2260    #[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        // Keyed, not ordered: the store iterates by uuid bytes.
2277        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    /// An ephemeral ad-hoc-worker subscription is in memory only by design,
2295    /// so healing its name must not write it to the store and resurrect it
2296    /// on the next boot under a worker label that is gone.
2297    #[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    /// The auto-subscribe seeds only when no cursor exists: the cursor is
2329    /// shared by every owner watching the conversation, and another
2330    /// owner's pending window must survive the mention that pulled us in.
2331    #[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    /// A ts shaped like Slack's and set to now, because a followed thread
2370    /// starts at a parent that was just delivered - an epoch-era fixture
2371    /// would read as idle and be dropped.
2372    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    /// A host over a real workspace and store, with one C1 subscription
2380    /// owned by `project`/`team_role` already in place.
2381    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    /// As [`host_with_c1_subscriber`], with the workspace's own idle
2394    /// window, so a test can show the prune reads it rather than a
2395    /// compiled default.
2396    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    /// The idle window is the workspace's own. Both sides of it are
2414    /// exercised at a distance the 14-day default would get wrong in
2415    /// opposite directions.
2416    #[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    /// The stale threshold is the workspace's own, and a workspace with no
2437    /// entry keeps the default.
2438    #[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    /// The host hop is the whole read path for the ledger: a harness that
2455    /// swapped the workspace and the conversation would leave every
2456    /// connector test green.
2457    #[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    /// A post is addressed by whichever name the caller had: a DM by user
2474    /// id, a channel by name. The sweep holds the conversation and asks
2475    /// under each of its names.
2476    #[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        // Two calls into one conversation at once: the first to finish
2482        // does not clear the other's stamp.
2483        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    /// A mark past the request timeout is expired, not obeyed: a stalled
2501    /// call must not silence the conversation, and the expired row is
2502    /// dropped rather than re-checked forever.
2503    #[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    /// An old guard that outlived its stamp must not clear a newer call's
2527    /// mark: `Drop` removes only the stamp it took.
2528    #[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    /// The workspace label the live test configures, and the project whose
2561    /// running lead receives the block.
2562    const LIVE_WORKSPACE: &str = "live-test";
2563    const LIVE_PROJECT_PATH: &str = "/tmp/forge-slack-live";
2564
2565    /// Layer-below integration test: a real Slack workspace, a real sweep,
2566    /// and the block the session is handed.
2567    ///
2568    /// Opt-in, because it needs three things a CI machine has not - a user
2569    /// token, a bot token, and a channel the bot can post into. Absent any
2570    /// of them it skips, so the suite stays green without them:
2571    ///
2572    /// ```text
2573    /// SLACK_TEST_USER_TOKEN=... SLACK_TEST_BOT_TOKEN=... SLACK_TEST_CHANNEL=... \
2574    ///     cargo nextest run -p forge-workspace slack_live_bundle
2575    /// ```
2576    ///
2577    /// The bot posts and the user reads, so the reader never filters the
2578    /// poster's own messages out of the sweep.
2579    #[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        // The sweep's own log is the only place a live failure says why, so
2593        // run this test with the log visible: `--no-capture`.
2594        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        // Either the id or the name, since Slack hands out the first and a
2604        // person reads the second.
2605        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        // The name the header should carry, which is not the id: asserting
2617        // the id would pass on the header's `id` field alone.
2618        let channel_label = channel.name.clone().unwrap_or_else(|| conversation_id.clone());
2619
2620        // Everything at or below this is old news, so the sweep's news is
2621        // exactly what is posted below.
2622        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        // The own id is what boot's auth probe caches; without it the sweep
2660        // refuses the tick rather than echo the user's own posts back.
2661        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        // Checked before the pump starts: a subscription filed under another
2670        // workspace leaves the sweep with nothing to do, which it does in
2671        // silence and which reads from the outside as a connector that never
2672        // delivers.
2673        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        // A running lead is where the block lands. The dispatch is
2680        // intercepted so the test never spawns a child.
2681        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        // The shape, line by line: a collapse that delivered only the newest
2731        // message would still carry its own text and ts, so the count is
2732        // what says all three arrived.
2733        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    /// A thread a delivered message anchors is tracked from the caller's
2745    /// `since`, owned once per session even when both followed it.
2746    #[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        // A second session following the same thread joins the record
2765        // rather than resetting it.
2766        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    /// The pinned split: two owners on one thread, one subscription
2778    /// removed - the remaining owner still receives and the removed one
2779    /// stops, and the row goes when the last owner does.
2780    #[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        // A stamp above the row's cursor seed, so the assertion below cannot
2788        // pass on a cursor-derived value.
2789        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        // The prune rewrites the row; the edit stamp rides it, and the
2812        // cache is dropped so only the store can answer.
2813        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    /// A thread with no reply newer than its cursor for the drop age is
2839    /// deleted; one inside the window survives. Both sides of the 14-day
2840    /// boundary are exercised so the constant's value is pinned, not just
2841    /// its existence.
2842    #[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    /// The documented fail-closed arm: a cursor that does not parse reads
2865    /// as ancient, so the row drops rather than living forever.
2866    #[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    /// A mention dug up a weeks-old thread: the row's idle clock runs
2877    /// from the follow (the mention's ts), never from the parent, or the
2878    /// thread would be dropped before its first reply walk.
2879    #[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    /// Pruning runs at REMOVAL time, not only inside the sweep's listing:
2899    /// a thread whose only owner is removed is gone from the store
2900    /// before any sweep happens to list it.
2901    #[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    /// Pruning keys on the SURVIVING subscription set, not on the removed
2923    /// owner identity: an owner whose C1 subscription is removed may
2924    /// still hold a Mentions subscription anchoring threads elsewhere.
2925    #[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    /// Worker teardown removes that worker's subscriptions - and only
2955    /// its own - from the active set, and prunes its thread rows in the
2956    /// same pass, so a delivery for it cannot fall through to the lead.
2957    #[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    /// The delivery memory makes a sweep re-run idempotent, and an edit is
3039    /// not a re-run: a correction keeps the message's ts, so the edit ts
3040    /// is part of the identity or the correction is swallowed as already
3041    /// delivered.
3042    #[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    /// A lead and a worker in one project are two destinations: one
3078    /// delivery must never mark the other as already done.
3079    #[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    /// Unsubscribing the last subscription covering a conversation
3169    /// clears its cursor, so "has cursor" means a live pending window
3170    /// and a later subscriber starts fresh instead of replaying the gap.
3171    #[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    /// **The real host is what seeds the clocks**, and nothing else drives
3193    /// it: a new row is a first walk, its edit watermark starts at the seed,
3194    /// and the mark is cleared only by the walk that read it.
3195    #[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        // A seed inside the idle window, or the host refuses to write the row
3202        // at all.
3203        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    /// **A pruned thread forgets its clocks**, so the maps do not leak a row
3240    /// that no longer exists and a re-follow starts clean.
3241    #[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    /// **A correction already delivered must not come back after a restart.**
3273    /// The thread's edit stamp lives on its durable row; a process-lifetime
3274    /// map holding it empties on restart, and the next whole read then judges
3275    /// every edited reply against nothing and hands them all over again -
3276    /// "5d old ... edited" for a correction the session already had.
3277    #[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        // The pump hands over a reply's correction and records its stamp; a
3295        // newer reply then moves the reply cursor past it.
3296        host.set_thread_edit_watermark("acme", "C1", "100.0", "990.0");
3297        host.set_thread_watermark("acme", "C1", "100.0", "995.0");
3298
3299        // A restart keeps the store and drops every process-lifetime clock.
3300        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    /// A row written before the edit stamp was stored has none. Its cursor is
3311    /// the last position the row judged, so it reads as its stamp: without
3312    /// the fallback the first restart after an upgrade re-offers every
3313    /// correction the thread ever had.
3314    #[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        // The shape a row written before the field existed has: no key at
3321        // all, not a null. Serde reads both as no stamp, but only the absent
3322        // key pins the migration this row is.
3323        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    /// **The stamp only moves forward.** A caller that read a stale value -
3364    /// a store read that failed, say - must not roll a newer stamp back.
3365    #[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        // With the cache empty the guard has only the row to compare
3388        // against: a stale caller arriving after a restart must not roll it
3389        // back.
3390        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    /// A cursor survives while ANY subscription still covers its
3399    /// conversation: one owner leaving must not reset the other's window.
3400    #[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    /// The mention cursor follows the same rule: with the last Mentions
3428    /// subscription gone it is cleared, because the mention sweep has no
3429    /// baseline arm and a stale cursor would replay the whole stream.
3430    #[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    /// The one `ConnectorSubscriptionsChanged` on a test's update stream, as
3456    /// its key and the two sets it announces.
3457    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    /// A slack subscribe, a watch that moves a mode, an unsubscribe and a
3473    /// worker teardown each announce the project's connector sets on its
3474    /// lead seat - both connectors, because the section a client patches is
3475    /// one - and a watch that moves nothing announces nothing.
3476    ///
3477    /// Mutants: drop any one emission; announce a set taken before the
3478    /// write; announce a same-mode watch, which is a write the section did
3479    /// not move for.
3480    #[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        // A watch of a conversation lands as a new record.
3493        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        // The same watch again moves nothing, so it announces nothing.
3500        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        // A watch whose mode differs is the one write that changes an
3510        // existing record, and it is announced as the moved set.
3511        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    /// A name heal is the COMMON way a set moves: `watch_slack_conversation`
3555    /// writes `name: None` and the pump heals it on the first message, and
3556    /// the client draws the name - so a heal that announces nothing leaves the
3557    /// row unnamed until some unrelated write.
3558    ///
3559    /// Mutants: drop the heal's announcement; announce every heal, including
3560    /// one that named nothing new.
3561    #[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        // A second heal with the same name touches no record - only unnamed
3592        // ones are healed - so it announces nothing.
3593        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    /// A worker teardown announces the subscriptions it left, which is the
3602    /// other door the section moves through - and one that matched nothing
3603    /// announces nothing.
3604    #[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        // A teardown matching no subscription of that label is not a write,
3627        // so it announces nothing.
3628        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}