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/// `events.watch`: a runner waits, with the human session it started its
250/// agents with, for something it should wake one of them for. It marks
251/// nothing as told: the agent still hears it through `inbox`. Keep the
252/// answer's `next` and `reserved` for the next call ([`Watch::after`]).
253#[derive(Clone, Debug, Default, Serialize, Deserialize)]
254pub struct Watch {
255    /// `next` of the last answer. `None`: from now on, nothing older.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub from: Option<u64>,
258    /// Only these sessions, each one this human started. `None`: every
259    /// live session this human started, as it is when the daemon looks.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub sessions: Option<Vec<String>>,
262    /// `reserved` of the last answer: reservations already reported.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub reserved: Vec<String>,
265    /// With nothing new, hold the request this long (the daemon caps it at
266    /// 30 seconds) for something to happen.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub wait_ms: Option<i64>,
269}
270
271impl Watch {
272    /// The call that goes on from `answer` with the same sessions and wait.
273    pub fn after(&self, answer: &Watched) -> Self {
274        Self {
275            from: Some(answer.next),
276            reserved: answer.reserved.clone(),
277            ..self.clone()
278        }
279    }
280}
281
282/// The answer to `events.watch`.
283#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
284pub struct Watched {
285    pub events: Vec<WatchedEvent>,
286    /// The `from` of the next call.
287    pub next: u64,
288    /// Every reservation the watched sessions hold now, reported or not:
289    /// the `reserved` of the next call.
290    #[serde(default)]
291    pub reserved: Vec<String>,
292    /// Sessions named in `sessions` that can not act any more. The wait
293    /// ends at once while there is one: stop watching it.
294    #[serde(default)]
295    pub ended: Vec<EndedSession>,
296}
297
298/// One thing that happened to a watched session. Never what another agent
299/// wrote: the agent reads that itself, with `inbox` or `events`.
300#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
301pub struct WatchedEvent {
302    pub session: String,
303    /// `integrated`, `conflicted`, `failed`, `approved`, `rejected`,
304    /// `message`, `lease_received`, `symbols_changed` or `reserved`; more
305    /// may come.
306    pub kind: String,
307    /// The log entry it comes from; `None` for `reserved`, which is the
308    /// state now.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub seq: Option<u64>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub change: Option<ChangeId>,
313    /// The session that sent a message or handed a lease over.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub from: Option<String>,
316    /// A reservation's name, as it appears in `reserved`.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub reservation: Option<String>,
319    /// When a reservation runs out unless the session leases it.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub until_ms: Option<i64>,
322}
323
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
325pub struct EndedSession {
326    pub session: String,
327    /// Why: the reason it, or the session it came from, was ended with, or
328    /// `expired`.
329    pub reason: String,
330}
331
332#[derive(Clone, Debug, Serialize, Deserialize)]
333pub struct FactsPreview {
334    pub base: GitId,
335    pub files: Vec<FileChange>,
336}
337
338#[derive(Clone, Debug, Serialize, Deserialize)]
339pub struct TreeQuery {
340    pub commit: GitId,
341    #[serde(default)]
342    pub with_content: bool,
343    #[serde(default)]
344    pub paths: Option<Vec<String>>,
345}
346
347#[derive(Clone, Debug, Serialize, Deserialize)]
348pub struct TreeFile {
349    pub path: String,
350    pub mode: FileMode,
351    pub blob: GitId,
352    /// Blob size in bytes, so a client can page its fetches.
353    #[serde(default)]
354    pub size: u64,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub content_b64: Option<String>,
357}
358
359/// `blobs`: the contents of some files of one commit, one page. The
360/// client sizes a page from `tree`'s sizes to stay under the line cap.
361#[derive(Clone, Debug, Serialize, Deserialize)]
362pub struct BlobsQuery {
363    pub commit: GitId,
364    pub paths: Vec<String>,
365}
366
367#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct LogQuery {
369    #[serde(default)]
370    pub from: u64,
371    #[serde(default)]
372    pub limit: Option<u64>,
373    /// With nothing past `from`, hold the request this long (the daemon caps
374    /// it at 30 seconds) for an entry to arrive.
375    #[serde(default)]
376    pub wait_ms: Option<i64>,
377}
378
379/// A harness hook event, reduced to what Lonca uses. `hook.input` makes one
380/// from what a harness sent; tool names stay the harness's own.
381#[derive(Clone, Debug, Default, Serialize, Deserialize)]
382pub struct HookEvent {
383    pub hook_event_name: String,
384    #[serde(default)]
385    pub tool_name: Option<String>,
386    #[serde(default)]
387    pub tool_input: Value,
388    #[serde(default)]
389    pub cwd: Option<String>,
390    /// Absolute path of the workspace root the hook resolved.
391    pub workspace: String,
392    /// The harness profile to read it with; none means `claude-code`.
393    #[serde(default)]
394    pub harness: Option<String>,
395}
396
397/// `harness.setup`: which harness's setup to read from the policy.
398#[derive(Clone, Debug, Serialize, Deserialize)]
399pub struct HarnessSetup {
400    pub harness: String,
401}
402
403/// What a hook received from its harness, untouched: the daemon reads it with
404/// the harness's profile from the policy.
405#[derive(Clone, Debug, Serialize, Deserialize)]
406pub struct HookInput {
407    pub harness: String,
408    pub input: Value,
409    /// Absolute path of the workspace root the hook resolved.
410    pub workspace: String,
411}
412
413/// One file a workspace differs in from its base, as it would be in a tree.
414#[derive(Clone, Debug, Serialize, Deserialize)]
415pub struct LocalFile {
416    pub path: String,
417    /// `None`: the file is deleted.
418    #[serde(default)]
419    pub entry: Option<FileEntry>,
420}
421
422/// `hook.stop`: the agent is about to finish its turn, with these changes.
423#[derive(Clone, Debug, Serialize, Deserialize)]
424pub struct HookStop {
425    pub harness: String,
426    pub files: Vec<LocalFile>,
427}
428
429/// `mcp.call`: `lonca mcp` is about to run one of its tools, by the name it
430/// has in `tools/list`.
431#[derive(Clone, Debug, Serialize, Deserialize)]
432pub struct McpCall {
433    pub tool: String,
434}
435
436/// `session.end`: what the workspace held, when the client knows.
437#[derive(Clone, Debug, Default, Serialize, Deserialize)]
438pub struct SessionEnd {
439    #[serde(default)]
440    pub files: Option<Vec<LocalFile>>,
441}
442
443#[derive(Clone, Debug, Serialize, Deserialize)]
444pub struct HookBind {
445    pub harness: String,
446    pub harness_session: String,
447    pub sudo_without_password: bool,
448    /// Workspace root; lets the network proxy attribute connections.
449    #[serde(default)]
450    pub workspace: Option<String>,
451}
452
453#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
454pub struct HookVerdict {
455    /// `allow`, `deny` or `ask`.
456    pub decision: String,
457    #[serde(default)]
458    pub reason: String,
459}
460
461/// Reads one line of at most `max` bytes. `Ok(None)` at end of stream.
462pub fn read_line_limited(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<String>> {
463    let mut buf = Vec::new();
464    loop {
465        let chunk = r.fill_buf()?;
466        if chunk.is_empty() {
467            return Ok(if buf.is_empty() {
468                None
469            } else {
470                Some(String::from_utf8_lossy(&buf).into_owned())
471            });
472        }
473        match chunk.iter().position(|&b| b == b'\n') {
474            Some(i) => {
475                buf.extend_from_slice(&chunk[..i]);
476                r.consume(i + 1);
477                return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
478            }
479            None => {
480                let n = chunk.len();
481                buf.extend_from_slice(chunk);
482                r.consume(n);
483                if buf.len() > max {
484                    return Err(std::io::Error::new(
485                        std::io::ErrorKind::InvalidData,
486                        "request too large",
487                    ));
488                }
489            }
490        }
491    }
492}
493
494pub fn write_line<T: Serialize>(w: &mut impl Write, v: &T) -> std::io::Result<()> {
495    let mut bytes = serde_json::to_vec(v)?;
496    bytes.push(b'\n');
497    w.write_all(&bytes)?;
498    w.flush()
499}