Skip to main content

opencode_ctl/tools/
hatch.rs

1//! `opencode_operations` and the gated `opencode_api` escape hatch.
2
3use serde_json::{json, Map, Value};
4
5use crate::clock::Clock;
6use crate::compact::depth_cap;
7use crate::engine::Engine;
8use crate::error::{ErrorCode, ToolError, ToolResult};
9use crate::host::Host;
10use crate::openapi::{
11    gate, validate_params, validate_session_id, GateInput, OperationTable, TableSource,
12    SNAPSHOT_VERSION,
13};
14use crate::transport::{Params, Transport};
15
16use super::{OpencodeApiArgs, OpencodeOperationsArgs};
17
18const DEPTH: usize = 6;
19
20fn source_label(source: TableSource) -> String {
21    match source {
22        TableSource::Live => "live".to_string(),
23        TableSource::Snapshot => format!("snapshot(v{SNAPSHOT_VERSION})"),
24    }
25}
26
27/// `params` must be an object of string values; anything else is `INVALID_ARGUMENT`.
28fn params_of(raw: Option<Value>) -> Result<Params, ToolError> {
29    let bad = |m: String| ToolError::new(ErrorCode::InvalidArgument, m);
30    match raw {
31        None | Some(Value::Null) => Ok(Vec::new()),
32        Some(Value::Object(map)) => map
33            .into_iter()
34            .map(|(k, v)| match v {
35                Value::String(s) => Ok((k, s)),
36                _ => Err(bad(format!("param {k} must be a string"))),
37            })
38            .collect(),
39        Some(_) => Err(bad("params must be an object of strings".to_string())),
40    }
41}
42
43impl<T: Transport, C: Clock> Engine<T, C> {
44    /// The warning for a snapshot in use against a server of another version, else the table's own note.
45    async fn hatch_warning(
46        &self,
47        source: TableSource,
48        table_warning: Option<String>,
49    ) -> Option<String> {
50        if source == TableSource::Snapshot {
51            if let Some(v) = self.observed_server_version().await {
52                if v != SNAPSHOT_VERSION {
53                    return Some(format!(
54                        "snapshot v{SNAPSHOT_VERSION} differs from server {v}"
55                    ));
56                }
57            }
58        }
59        table_warning
60    }
61
62    pub async fn opencode_operations(&self, a: OpencodeOperationsArgs) -> ToolResult {
63        let (mut table, mut source, mut warning) = self.operation_table().await;
64        if source == TableSource::Snapshot {
65            // Learn the server version for the warning; a failure only means no comparison.
66            if let Ok((info, _, _)) = self.call_json("server.info", Vec::new(), None).await {
67                self.invalidate_if_changed(&info).await;
68                (table, source, warning) = self.operation_table().await;
69            }
70        }
71        let warning = self.hatch_warning(source, warning).await;
72        let needle = a.filter.as_deref().map(str::to_lowercase);
73        let operations: Vec<Value> = table
74            .iter()
75            .filter(|o| match &needle {
76                Some(n) => o.id.to_lowercase().contains(n) || o.summary.to_lowercase().contains(n),
77                None => true,
78            })
79            .map(|o| {
80                json!({
81                    "id": o.id,
82                    "method": o.method.as_str(),
83                    "path": o.path,
84                    "summary": o.summary,
85                    "mutating": o.mutating(),
86                })
87            })
88            .collect();
89        let mut out = Map::new();
90        out.insert("source".into(), json!(source_label(source)));
91        if let Some(w) = warning {
92            out.insert("warning".into(), json!(w));
93        }
94        out.insert("operations".into(), Value::Array(operations));
95        Ok(Value::Object(out))
96    }
97
98    pub async fn opencode_api(&self, a: OpencodeApiArgs) -> ToolResult {
99        let params = params_of(a.params)?;
100        let id = a.operation.as_str();
101        let snapshot = OperationTable::snapshot();
102
103        let (mut table, mut source, mut warning) = self.operation_table().await;
104        let known_mutating = table
105            .get(id)
106            .or_else(|| snapshot.get(id))
107            .is_some_and(|o| o.mutating());
108        if known_mutating || source == TableSource::Snapshot {
109            // The one transport call allowed before the gate: re-read server.info so a changed
110            // pid or version drops the cached live table before a mutation is judged against it.
111            match self.call_json("server.info", Vec::new(), None).await {
112                Ok((info, _, _)) => {
113                    self.invalidate_if_changed(&info).await;
114                    (table, source, warning) = self.operation_table().await;
115                }
116                Err(e) if known_mutating => return Err(e),
117                Err(_) => {}
118            }
119        }
120        let warning = self.hatch_warning(source, warning).await;
121
122        let server_version = self.observed_server_version().await;
123        let live = (source == TableSource::Live).then_some(&table);
124        let (op, source) = gate(GateInput {
125            live,
126            snapshot: &snapshot,
127            id,
128            allow_mutation: a.allow_mutation.unwrap_or(false),
129            locked: self.settings.permissions_locked,
130            server_version: server_version.as_deref(),
131        })?;
132        validate_params(&op, &params)?;
133        for (name, value) in &params {
134            if name == "sessionID" {
135                validate_session_id(value)?;
136            }
137        }
138        if self.host == Host::Opencode && op.mutating() {
139            for (name, value) in &params {
140                if name == "sessionID" {
141                    self.guard_created(value)?;
142                }
143            }
144        }
145
146        let (data, cursor, meta) = self.call_json(id, params, a.body).await?;
147        let data = if op.success_status == 204 {
148            json!({ "ok": true })
149        } else {
150            data
151        };
152        let meta = match (meta, cursor) {
153            (Some(Value::Object(mut m)), Some(c)) => {
154                m.insert("cursor".into(), c);
155                Some(Value::Object(m))
156            }
157            (None, Some(c)) => Some(json!({ "cursor": c })),
158            (m, _) => m,
159        };
160
161        let mut out = Map::new();
162        out.insert("source".into(), json!(source_label(source)));
163        if let Some(w) = warning {
164            out.insert("warning".into(), json!(w));
165        }
166        if let Some(m) = meta {
167            out.insert("meta".into(), depth_cap(m, DEPTH));
168        }
169        out.insert("data".into(), depth_cap(data, DEPTH));
170        Ok(Value::Object(out))
171    }
172}