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.resolve`: the whole id of the one change whose id starts with
203/// `prefix` (at least 4 letters).
204#[derive(Clone, Debug, Serialize, Deserialize)]
205pub struct ChangeResolve {
206    pub prefix: String,
207}
208
209/// `change.diff`: the unified diff of a change, computed by the daemon
210/// in the store, with redaction applied and a size cap.
211#[derive(Clone, Debug, Serialize, Deserialize)]
212pub struct ChangeDiff {
213    pub change: ChangeId,
214    /// Only `unified` today.
215    #[serde(default)]
216    pub format: Option<String>,
217}
218
219#[derive(Clone, Debug, Serialize, Deserialize)]
220pub struct Negotiate {
221    pub thread: String,
222    pub to_session: String,
223    pub message: NegotiationMsg,
224}
225
226#[derive(Clone, Debug, Serialize, Deserialize)]
227pub struct Attenuate {
228    pub agent: String,
229    pub scope: Scope,
230    pub expires_ms: i64,
231    /// Lease sets handed to the child until it ends.
232    #[serde(default)]
233    pub leases: Vec<String>,
234}
235
236#[derive(Clone, Debug, Serialize, Deserialize)]
237pub struct LeaseInterest {
238    pub items: Vec<LeaseItem>,
239    #[serde(default)]
240    pub intent: String,
241}
242
243#[derive(Clone, Debug, Serialize, Deserialize)]
244pub struct Events {
245    #[serde(default)]
246    pub from: u64,
247}
248
249#[derive(Clone, Debug, Serialize, Deserialize)]
250pub struct FactsPreview {
251    pub base: GitId,
252    pub files: Vec<FileChange>,
253}
254
255#[derive(Clone, Debug, Serialize, Deserialize)]
256pub struct TreeQuery {
257    pub commit: GitId,
258    #[serde(default)]
259    pub with_content: bool,
260    #[serde(default)]
261    pub paths: Option<Vec<String>>,
262}
263
264#[derive(Clone, Debug, Serialize, Deserialize)]
265pub struct TreeFile {
266    pub path: String,
267    pub mode: FileMode,
268    pub blob: GitId,
269    /// Blob size in bytes, so a client can page its fetches.
270    #[serde(default)]
271    pub size: u64,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub content_b64: Option<String>,
274}
275
276/// `blobs`: the contents of some files of one commit, one page. The
277/// client sizes a page from `tree`'s sizes to stay under the line cap.
278#[derive(Clone, Debug, Serialize, Deserialize)]
279pub struct BlobsQuery {
280    pub commit: GitId,
281    pub paths: Vec<String>,
282}
283
284#[derive(Clone, Debug, Serialize, Deserialize)]
285pub struct LogQuery {
286    #[serde(default)]
287    pub from: u64,
288    #[serde(default)]
289    pub limit: Option<u64>,
290    /// With nothing past `from`, hold the request this long (the daemon caps
291    /// it at 30 seconds) for an entry to arrive.
292    #[serde(default)]
293    pub wait_ms: Option<i64>,
294}
295
296/// A harness hook event, reduced to what Lonca uses. `hook.input` makes one
297/// from what a harness sent; tool names stay the harness's own.
298#[derive(Clone, Debug, Default, Serialize, Deserialize)]
299pub struct HookEvent {
300    pub hook_event_name: String,
301    #[serde(default)]
302    pub tool_name: Option<String>,
303    #[serde(default)]
304    pub tool_input: Value,
305    #[serde(default)]
306    pub cwd: Option<String>,
307    /// Absolute path of the workspace root the hook resolved.
308    pub workspace: String,
309    /// The harness profile to read it with; none means `claude-code`.
310    #[serde(default)]
311    pub harness: Option<String>,
312}
313
314/// `harness.setup`: which harness's setup to read from the policy.
315#[derive(Clone, Debug, Serialize, Deserialize)]
316pub struct HarnessSetup {
317    pub harness: String,
318}
319
320/// What a hook received from its harness, untouched: the daemon reads it with
321/// the harness's profile from the policy.
322#[derive(Clone, Debug, Serialize, Deserialize)]
323pub struct HookInput {
324    pub harness: String,
325    pub input: Value,
326    /// Absolute path of the workspace root the hook resolved.
327    pub workspace: String,
328}
329
330/// One file a workspace differs in from its base, as it would be in a tree.
331#[derive(Clone, Debug, Serialize, Deserialize)]
332pub struct LocalFile {
333    pub path: String,
334    /// `None`: the file is deleted.
335    #[serde(default)]
336    pub entry: Option<FileEntry>,
337}
338
339/// `hook.stop`: the agent is about to finish its turn, with these changes.
340#[derive(Clone, Debug, Serialize, Deserialize)]
341pub struct HookStop {
342    pub harness: String,
343    pub files: Vec<LocalFile>,
344}
345
346/// `mcp.call`: `lonca mcp` is about to run one of its tools, by the name it
347/// has in `tools/list`.
348#[derive(Clone, Debug, Serialize, Deserialize)]
349pub struct McpCall {
350    pub tool: String,
351}
352
353/// `session.end`: what the workspace held, when the client knows.
354#[derive(Clone, Debug, Default, Serialize, Deserialize)]
355pub struct SessionEnd {
356    #[serde(default)]
357    pub files: Option<Vec<LocalFile>>,
358}
359
360#[derive(Clone, Debug, Serialize, Deserialize)]
361pub struct HookBind {
362    pub harness: String,
363    pub harness_session: String,
364    pub sudo_without_password: bool,
365    /// Workspace root; lets the network proxy attribute connections.
366    #[serde(default)]
367    pub workspace: Option<String>,
368}
369
370#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
371pub struct HookVerdict {
372    /// `allow`, `deny` or `ask`.
373    pub decision: String,
374    #[serde(default)]
375    pub reason: String,
376}
377
378/// Reads one line of at most `max` bytes. `Ok(None)` at end of stream.
379pub fn read_line_limited(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<String>> {
380    let mut buf = Vec::new();
381    loop {
382        let chunk = r.fill_buf()?;
383        if chunk.is_empty() {
384            return Ok(if buf.is_empty() {
385                None
386            } else {
387                Some(String::from_utf8_lossy(&buf).into_owned())
388            });
389        }
390        match chunk.iter().position(|&b| b == b'\n') {
391            Some(i) => {
392                buf.extend_from_slice(&chunk[..i]);
393                r.consume(i + 1);
394                return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
395            }
396            None => {
397                let n = chunk.len();
398                buf.extend_from_slice(chunk);
399                r.consume(n);
400                if buf.len() > max {
401                    return Err(std::io::Error::new(
402                        std::io::ErrorKind::InvalidData,
403                        "request too large",
404                    ));
405                }
406            }
407        }
408    }
409}
410
411pub fn write_line<T: Serialize>(w: &mut impl Write, v: &T) -> std::io::Result<()> {
412    let mut bytes = serde_json::to_vec(v)?;
413    bytes.push(b'\n');
414    w.write_all(&bytes)?;
415    w.flush()
416}