Skip to main content

lonca_client/
protocol.rs

1//! Wire protocol: one JSON object per line over a Unix socket
2//! (`docs/protocol.md`). The parameters of `repo.prepare_init` are the
3//! daemon's (`lonca_daemon::protocol::PrepareInit`): they name a policy
4//! profile, and this crate knows no policy.
5
6use std::io::{BufRead, Write};
7
8use lonca_core::ops::{Claim, Decision, HumanSignature, LeaseItem, NegotiationMsg, Op, Scope};
9use lonca_core::{ChangeId, FileEntry, FileMode, GitId};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Largest accepted request line (base64 file contents included).
14pub const MAX_LINE: usize = 96 * 1024 * 1024;
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct Request {
18    pub id: u64,
19    pub method: String,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub repo: Option<String>,
22    /// Agent session token or human session token. Never logged.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub token: Option<String>,
25    #[serde(default)]
26    pub params: Value,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct ErrorBody {
31    pub code: String,
32    pub message: String,
33}
34
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct Response {
37    pub id: u64,
38    pub ok: bool,
39    #[serde(default, skip_serializing_if = "Value::is_null")]
40    pub result: Value,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub error: Option<ErrorBody>,
43}
44
45impl Response {
46    pub fn ok(id: u64, result: Value) -> Self {
47        Self {
48            id,
49            ok: true,
50            result,
51            error: None,
52        }
53    }
54
55    pub fn err(id: u64, code: &str, message: impl Into<String>) -> Self {
56        Self {
57            id,
58            ok: false,
59            result: Value::Null,
60            error: Some(ErrorBody {
61                code: code.into(),
62                message: message.into(),
63            }),
64        }
65    }
66}
67
68/// The outcome of a logged operation.
69#[derive(Clone, Debug, Serialize, Deserialize)]
70pub struct Logged {
71    pub seq: u64,
72    pub decision: Decision,
73}
74
75/// One file in a proposed change, relative to the base commit.
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct FileChange {
78    pub path: String,
79    /// `None` deletes the file.
80    #[serde(default)]
81    pub content_b64: Option<String>,
82    #[serde(default)]
83    pub mode: Option<FileMode>,
84}
85
86#[derive(Clone, Debug, Serialize, Deserialize)]
87pub struct HumanSubmit {
88    pub op: Op,
89    pub sig: HumanSignature,
90    /// `session.isolate`: the workspace, which the proxy attributes
91    /// connections to. Not logged.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub workspace: Option<String>,
94}
95
96/// `human.prepare_isolate`.
97#[derive(Clone, Debug, Serialize, Deserialize)]
98pub struct PrepareIsolate {
99    pub session: String,
100    /// The commit the workspace says it stands on.
101    pub base: GitId,
102}
103
104/// `human.login`: opens a human session. The signature is over a
105/// [`lonca_core::ops::LoginIntent`]; `repo` on the request names the
106/// repository whose enrolment verifies it, or is left out to try every
107/// repository the daemon serves.
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct HumanLogin {
110    pub sig: HumanSignature,
111    /// Session length; the daemon has a default and a ceiling.
112    #[serde(default)]
113    pub ttl_ms: Option<i64>,
114}
115
116/// `repo.restore`: a new repository from a backup bundle, a bare store or
117/// a remote Lonca pushed to.
118#[derive(Clone, Debug, Serialize, Deserialize)]
119pub struct Restore {
120    pub repo: String,
121    pub from: String,
122}
123
124#[derive(Clone, Debug, Serialize, Deserialize)]
125pub struct PrepareChange {
126    pub base: GitId,
127    pub files: Vec<FileChange>,
128    #[serde(default)]
129    pub change: Option<ChangeId>,
130    pub author: String,
131    #[serde(default)]
132    pub intent: String,
133    #[serde(default)]
134    pub revert_of: Option<ChangeId>,
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct PreparePolicy {
139    pub config: String,
140    pub policy: String,
141}
142
143#[derive(Clone, Debug, Serialize, Deserialize)]
144pub struct PrepareRemote {
145    pub remote: String,
146    #[serde(default)]
147    pub branch: Option<String>,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize)]
151pub struct LeaseAcquire {
152    #[serde(default)]
153    pub set: Option<String>,
154    pub items: Vec<LeaseItem>,
155    pub intent: String,
156    pub ttl_ms: i64,
157}
158
159#[derive(Clone, Debug, Serialize, Deserialize)]
160pub struct SetRef {
161    pub set: String,
162    #[serde(default)]
163    pub ttl_ms: Option<i64>,
164}
165
166#[derive(Clone, Debug, Serialize, Deserialize)]
167pub struct ProposeChange {
168    #[serde(default)]
169    pub change: Option<ChangeId>,
170    pub base: GitId,
171    pub files: Vec<FileChange>,
172    pub claim: Claim,
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub struct ChangeRef {
177    pub change: ChangeId,
178}
179
180/// `security.events`: what the security log holds since `since_ms`
181/// (default: the last day).
182#[derive(Clone, Debug, Default, Serialize, Deserialize)]
183pub struct SecurityEvents {
184    #[serde(default)]
185    pub since_ms: Option<i64>,
186}
187
188/// `trunk.bundle`: a Git bundle of trunk for a checkout, thin against
189/// the commits the checkout already has.
190#[derive(Clone, Debug, Serialize, Deserialize)]
191pub struct TrunkBundle {
192    #[serde(default)]
193    pub have: Vec<GitId>,
194}
195
196/// `trunk.distance`: how far a commit that once was trunk is behind.
197#[derive(Clone, Debug, Serialize, Deserialize)]
198pub struct TrunkDistance {
199    pub base: GitId,
200}
201
202/// `change.diff`: the unified diff of a change, computed by the daemon
203/// in the store, with redaction applied and a size cap.
204#[derive(Clone, Debug, Serialize, Deserialize)]
205pub struct ChangeDiff {
206    pub change: ChangeId,
207    /// Only `unified` today.
208    #[serde(default)]
209    pub format: Option<String>,
210}
211
212#[derive(Clone, Debug, Serialize, Deserialize)]
213pub struct Negotiate {
214    pub thread: String,
215    pub to_session: String,
216    pub message: NegotiationMsg,
217}
218
219#[derive(Clone, Debug, Serialize, Deserialize)]
220pub struct Attenuate {
221    pub agent: String,
222    pub scope: Scope,
223    pub expires_ms: i64,
224    /// Lease sets handed to the child until it ends.
225    #[serde(default)]
226    pub leases: Vec<String>,
227}
228
229#[derive(Clone, Debug, Serialize, Deserialize)]
230pub struct LeaseInterest {
231    pub items: Vec<LeaseItem>,
232    #[serde(default)]
233    pub intent: String,
234}
235
236#[derive(Clone, Debug, Serialize, Deserialize)]
237pub struct Events {
238    #[serde(default)]
239    pub from: u64,
240}
241
242#[derive(Clone, Debug, Serialize, Deserialize)]
243pub struct FactsPreview {
244    pub base: GitId,
245    pub files: Vec<FileChange>,
246}
247
248#[derive(Clone, Debug, Serialize, Deserialize)]
249pub struct TreeQuery {
250    pub commit: GitId,
251    #[serde(default)]
252    pub with_content: bool,
253    #[serde(default)]
254    pub paths: Option<Vec<String>>,
255}
256
257#[derive(Clone, Debug, Serialize, Deserialize)]
258pub struct TreeFile {
259    pub path: String,
260    pub mode: FileMode,
261    pub blob: GitId,
262    /// Blob size in bytes, so a client can page its fetches.
263    #[serde(default)]
264    pub size: u64,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub content_b64: Option<String>,
267}
268
269/// `blobs`: the contents of some files of one commit, one page. The
270/// client sizes a page from `tree`'s sizes to stay under the line cap.
271#[derive(Clone, Debug, Serialize, Deserialize)]
272pub struct BlobsQuery {
273    pub commit: GitId,
274    pub paths: Vec<String>,
275}
276
277#[derive(Clone, Debug, Serialize, Deserialize)]
278pub struct LogQuery {
279    #[serde(default)]
280    pub from: u64,
281    #[serde(default)]
282    pub limit: Option<u64>,
283    /// With nothing past `from`, hold the request this long (the daemon caps
284    /// it at 30 seconds) for an entry to arrive.
285    #[serde(default)]
286    pub wait_ms: Option<i64>,
287}
288
289/// A harness hook event, reduced to what Lonca uses. `hook.input` makes one
290/// from what a harness sent; tool names stay the harness's own.
291#[derive(Clone, Debug, Default, Serialize, Deserialize)]
292pub struct HookEvent {
293    pub hook_event_name: String,
294    #[serde(default)]
295    pub tool_name: Option<String>,
296    #[serde(default)]
297    pub tool_input: Value,
298    #[serde(default)]
299    pub cwd: Option<String>,
300    /// Absolute path of the workspace root the hook resolved.
301    pub workspace: String,
302    /// The harness profile to read it with; none means `claude-code`.
303    #[serde(default)]
304    pub harness: Option<String>,
305}
306
307/// `harness.setup`: which harness's setup to read from the policy.
308#[derive(Clone, Debug, Serialize, Deserialize)]
309pub struct HarnessSetup {
310    pub harness: String,
311}
312
313/// What a hook received from its harness, untouched: the daemon reads it with
314/// the harness's profile from the policy.
315#[derive(Clone, Debug, Serialize, Deserialize)]
316pub struct HookInput {
317    pub harness: String,
318    pub input: Value,
319    /// Absolute path of the workspace root the hook resolved.
320    pub workspace: String,
321}
322
323/// One file a workspace differs in from its base, as it would be in a tree.
324#[derive(Clone, Debug, Serialize, Deserialize)]
325pub struct LocalFile {
326    pub path: String,
327    /// `None`: the file is deleted.
328    #[serde(default)]
329    pub entry: Option<FileEntry>,
330}
331
332/// `hook.stop`: the agent is about to finish its turn, with these changes.
333#[derive(Clone, Debug, Serialize, Deserialize)]
334pub struct HookStop {
335    pub harness: String,
336    pub files: Vec<LocalFile>,
337}
338
339/// `mcp.call`: `lonca mcp` is about to run one of its tools, by the name it
340/// has in `tools/list`.
341#[derive(Clone, Debug, Serialize, Deserialize)]
342pub struct McpCall {
343    pub tool: String,
344}
345
346/// `session.end`: what the workspace held, when the client knows.
347#[derive(Clone, Debug, Default, Serialize, Deserialize)]
348pub struct SessionEnd {
349    #[serde(default)]
350    pub files: Option<Vec<LocalFile>>,
351}
352
353#[derive(Clone, Debug, Serialize, Deserialize)]
354pub struct HookBind {
355    pub harness: String,
356    pub harness_session: String,
357    pub sudo_without_password: bool,
358    /// Workspace root; lets the network proxy attribute connections.
359    #[serde(default)]
360    pub workspace: Option<String>,
361}
362
363#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
364pub struct HookVerdict {
365    /// `allow`, `deny` or `ask`.
366    pub decision: String,
367    #[serde(default)]
368    pub reason: String,
369}
370
371/// Reads one line of at most `max` bytes. `Ok(None)` at end of stream.
372pub fn read_line_limited(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<String>> {
373    let mut buf = Vec::new();
374    loop {
375        let chunk = r.fill_buf()?;
376        if chunk.is_empty() {
377            return Ok(if buf.is_empty() {
378                None
379            } else {
380                Some(String::from_utf8_lossy(&buf).into_owned())
381            });
382        }
383        match chunk.iter().position(|&b| b == b'\n') {
384            Some(i) => {
385                buf.extend_from_slice(&chunk[..i]);
386                r.consume(i + 1);
387                return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
388            }
389            None => {
390                let n = chunk.len();
391                buf.extend_from_slice(chunk);
392                r.consume(n);
393                if buf.len() > max {
394                    return Err(std::io::Error::new(
395                        std::io::ErrorKind::InvalidData,
396                        "request too large",
397                    ));
398                }
399            }
400        }
401    }
402}
403
404pub fn write_line<T: Serialize>(w: &mut impl Write, v: &T) -> std::io::Result<()> {
405    let mut bytes = serde_json::to_vec(v)?;
406    bytes.push(b'\n');
407    w.write_all(&bytes)?;
408    w.flush()
409}