1use 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
14pub 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 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 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 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 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 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 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
154fn plain(e: ApiError) -> anyhow::Error {
158 anyhow::anyhow!("{}", e.message)
159}
160
161fn 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}