Skip to main content

kranz_cli/
host_bridge.rs

1//! `kranz serve --slack` glue: the Slack bridge's [`PlanningHost`] implemented
2//! over the SAME [`kranz_server::MissionHost`] the web UI serves from — one
3//! hosted-engine registry, two clients (docs/slack-management.md). This is the
4//! only place the two crates meet; `kranz_slack` stays server-free and
5//! `kranz_server` stays Slack-free.
6
7use kranz_engine::draft::DraftOutcome;
8use kranz_engine::types::{Plan, TokenUsage};
9use kranz_server::{ApiError, MissionHost, PendingApproval};
10use kranz_slack::host::{ApprovePendingOutcome, AskOutcome, BoxFuture, PlanOutcome, PlanningHost};
11use serde_json::Value;
12use std::sync::Arc;
13
14/// Adapter handed to [`kranz_slack::serve_slack`] by the serve command.
15pub struct HostedPlanning(pub Arc<MissionHost>);
16
17impl PlanningHost for HostedPlanning {
18    fn create<'a>(&'a self, goal: &'a str) -> BoxFuture<'a, anyhow::Result<String>> {
19        Box::pin(async move { self.0.create(goal, None).await.map_err(plain) })
20    }
21
22    fn planning_turn<'a>(
23        &'a self,
24        id: &'a str,
25        text: &'a str,
26    ) -> BoxFuture<'a, anyhow::Result<String>> {
27        Box::pin(async move { self.0.planning_turn(id, text).await.map_err(plain) })
28    }
29
30    fn request_plan<'a>(&'a self, id: &'a str) -> BoxFuture<'a, anyhow::Result<PlanOutcome>> {
31        Box::pin(async move {
32            let value = self.0.request_plan(id).await.map_err(plain)?;
33            if value.get("ready").and_then(Value::as_bool) == Some(true) {
34                let plan: Plan =
35                    serde_json::from_value(value.get("plan").cloned().unwrap_or(Value::Null))
36                        .map_err(|e| anyhow::anyhow!("host returned an unparseable plan: {e}"))?;
37                Ok(PlanOutcome::Ready {
38                    plan,
39                    estimate: estimate_line(&value),
40                })
41            } else {
42                let reply = value
43                    .get("reply")
44                    .and_then(Value::as_str)
45                    .unwrap_or_default()
46                    .to_string();
47                Ok(PlanOutcome::NotReady(reply))
48            }
49        })
50    }
51
52    fn approve_pending<'a>(&'a self, id: &'a str) -> BoxFuture<'a, anyhow::Result<Option<String>>> {
53        Box::pin(async move { self.0.try_approve_pending(id).await.map_err(plain) })
54    }
55
56    /// The shared identity-checked approval holds the engine and pending-plan
57    /// locks through commit, so a concurrent re-plan cannot replace the plan
58    /// or be overwritten by a failed approval. Overrides the trait's default.
59    fn approve_pending_if<'a>(
60        &'a self,
61        id: &'a str,
62        expected_identity: Option<&'a str>,
63    ) -> BoxFuture<'a, anyhow::Result<ApprovePendingOutcome>> {
64        Box::pin(async move {
65            let outcome = self
66                .0
67                .try_approve_pending_matching(id, expected_identity)
68                .await
69                .map_err(plain)?;
70            Ok(match outcome {
71                PendingApproval::Approved(branch) => ApprovePendingOutcome::Approved(branch),
72                PendingApproval::NothingParked => ApprovePendingOutcome::NothingParked,
73                PendingApproval::Mismatch { parked } => ApprovePendingOutcome::StalePlan { parked },
74            })
75        })
76    }
77
78    /// Reads the parked plan without consuming it, so the bridge can refuse
79    /// an approve from a card that shows an older plan (Slack M2).
80    fn pending_plan_identity<'a>(
81        &'a self,
82        id: &'a str,
83    ) -> BoxFuture<'a, anyhow::Result<Option<String>>> {
84        Box::pin(async move {
85            Ok(self
86                .0
87                .pending_plan(id)
88                .as_ref()
89                .map(kranz_engine::planning::plan_identity))
90        })
91    }
92
93    fn start<'a>(&'a self, id: &'a str) -> BoxFuture<'a, anyhow::Result<()>> {
94        Box::pin(async move { self.0.start(id).await.map_err(plain) })
95    }
96
97    fn release<'a>(&'a self, id: &'a str) -> BoxFuture<'a, anyhow::Result<bool>> {
98        Box::pin(async move { self.0.release(id).map_err(plain) })
99    }
100
101    fn draft<'a>(&'a self, slug: &'a str) -> BoxFuture<'a, anyhow::Result<DraftOutcome>> {
102        // `then_enqueue: false` — the Slack draft verb parks for review, same
103        // as `kranz ticket draft <slug>` without `--yes`; queueing is a
104        // separate approve step.
105        Box::pin(async move { self.0.draft(slug, false).await.map_err(plain) })
106    }
107
108    fn approve_ticket<'a>(&'a self, slug: &'a str) -> BoxFuture<'a, anyhow::Result<String>> {
109        // `force: false` — `/kranz approve <slug>` runs the plain gate, same
110        // as REST's default body; a blocked-by refusal is a refusal, not a
111        // reason to silently override it from Slack.
112        Box::pin(async move {
113            self.0
114                .approve_ticket(slug, false)
115                .map(|a| a.mission_id)
116                .map_err(plain)
117        })
118    }
119
120    fn drain<'a>(&'a self) -> BoxFuture<'a, anyhow::Result<()>> {
121        // Same seam as `POST /api/queue/drain`: the host spawns the drain
122        // task and returns; this adapter never drives a mission turn itself.
123        Box::pin(async move { self.0.drain().await.map(|_| ()).map_err(plain) })
124    }
125
126    fn merge<'a>(&'a self, id: &'a str) -> BoxFuture<'a, anyhow::Result<Value>> {
127        // Same seam as `POST /api/missions/:id/merge`: `MissionHost::merge`
128        // already carries a user-presentable refusal (dirty tree / failing
129        // gate with its verbatim output / conflict) in `ApiError::message`.
130        Box::pin(async move { self.0.merge(id).await.map_err(plain) })
131    }
132
133    fn ask<'a>(&'a self, question: &'a str) -> BoxFuture<'a, anyhow::Result<AskOutcome>> {
134        Box::pin(async move {
135            let value = self.0.ask(question).await.map_err(plain)?;
136            let answer = value
137                .get("answer")
138                .and_then(Value::as_str)
139                .unwrap_or_default()
140                .to_string();
141            let cost_usd = value.get("costUsd").and_then(Value::as_f64).unwrap_or(0.0);
142            let tokens: TokenUsage =
143                serde_json::from_value(value.get("tokens").cloned().unwrap_or(Value::Null))
144                    .unwrap_or_default();
145            Ok(AskOutcome {
146                answer,
147                cost_usd,
148                tokens,
149            })
150        })
151    }
152}
153
154/// The host's errors already carry user-presentable messages (409 "a turn is
155/// in flight…", 404 "unknown mission…"); the status code adds nothing in a
156/// Slack ephemeral, so forward the message alone.
157fn plain(e: ApiError) -> anyhow::Error {
158    anyhow::anyhow!("{}", e.message)
159}
160
161/// One estimate line for the plan-review context row, from the host's
162/// `{"estimate":{"lowUsd":…,"expectedUsd":…,"highUsd":…}}` payload — the same
163/// numbers the planning TUI prints.
164fn estimate_line(value: &Value) -> Option<String> {
165    let est = value.get("estimate")?;
166    let low = est.get("lowUsd").and_then(Value::as_f64)?;
167    let expected = est.get("expectedUsd").and_then(Value::as_f64)?;
168    let high = est.get("highUsd").and_then(Value::as_f64)?;
169    let low_confidence = est.get("confidence").and_then(Value::as_str) == Some("low");
170    if low_confidence {
171        Some(format!(
172            "estimated ${low:.2}–${high:.2} (expected ~${expected:.2}; doc-heavy / \
173             judgement-heavy shape — LOW CONFIDENCE, corpus lacks a comparable mission, \
174             ${high:.2} is a soft ceiling)"
175        ))
176    } else {
177        Some(format!(
178            "estimated ${low:.2}–${high:.2} (expected ~${expected:.2})"
179        ))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use serde_json::json;
187
188    #[test]
189    fn estimate_line_renders_all_three_numbers() {
190        let value = json!({ "estimate": { "lowUsd": 6.1, "expectedUsd": 12.2, "highUsd": 30.5 } });
191        assert_eq!(
192            estimate_line(&value).unwrap(),
193            "estimated $6.10–$30.50 (expected ~$12.20)"
194        );
195    }
196
197    #[test]
198    fn estimate_line_absent_when_estimate_missing_or_partial() {
199        assert!(estimate_line(&json!({})).is_none());
200        assert!(estimate_line(&json!({ "estimate": { "lowUsd": 1.0 } })).is_none());
201    }
202}