1use serde::{Deserialize, Serialize};
2
3pub const DEFAULT_TCP_PORT: u16 = 7422;
5
6fn default_branch_fallback() -> String {
9 "main".to_string()
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Repository {
14 pub id: String,
15 pub name: String,
16 pub path: String,
18 #[serde(default = "default_branch_fallback")]
21 pub default_branch: String,
22 pub registered_at: u64,
23 pub workstreams: Vec<Workstream>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Workstream {
28 pub id: String,
29 pub name: String,
30 pub repo_id: String,
31 pub branch: String,
32 pub worktree_path: String,
34 pub tmux_session: String,
36 pub status: WorkstreamStatus,
37 pub agents: Vec<Agent>,
38 #[serde(default)]
40 pub shells: Vec<ShellSession>,
41 pub created_at: u64,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ShellSession {
48 pub id: String,
49 pub workstream_id: String,
50 pub tmux_window: u32,
52 pub status: ShellStatus,
53 pub started_at: u64,
54 pub exited_at: Option<u64>,
55 pub exit_code: Option<i32>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub enum ShellStatus {
60 Active,
62 Detached,
64 Exited,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub enum WorkstreamStatus {
70 Idle,
71 Running,
72 Stopped,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Agent {
77 pub id: String,
78 pub workstream_id: String,
79 pub tmux_window: u32,
81 pub prompt: String,
82 pub status: AgentStatus,
83 pub exit_code: Option<i32>,
84 pub spawned_at: u64,
85 pub exited_at: Option<u64>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub enum AgentStatus {
90 Running,
91 Exited,
92 Failed,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(tag = "type", content = "data")]
99pub enum Command {
100 Status,
102 Whoami,
103 PairCreate {
104 label: Option<String>,
105 expire_secs: Option<u64>,
107 },
108 PairList,
109 PairRevoke {
110 id: String,
111 },
112 PairRevokeAll,
113
114 RepoRegister {
117 path: String,
118 },
119 RepoList,
120 RepoUnregister {
121 repo_id: String,
122 },
123 RepoSetDefaultBranch {
125 repo_id: String,
126 branch: String,
127 },
128
129 WorkstreamCreate {
131 repo_id: String,
132 name: Option<String>,
134 branch: Option<String>,
136 fetch_latest: bool,
139 },
140 WorkstreamList {
142 repo_id: Option<String>,
143 },
144 WorkstreamDelete {
145 workstream_id: String,
146 },
147
148 AgentSpawn {
150 workstream_id: String,
151 prompt: String,
152 },
153 AgentSpawnInPlace {
156 workstream_id: String,
157 tmux_window: u32,
159 prompt: Option<String>,
161 },
162 AgentKill {
163 agent_id: String,
164 },
165 AgentList {
166 workstream_id: String,
167 },
168
169 ShellSpawn {
172 workstream_id: String,
173 },
174 ShellKill {
176 shell_id: String,
177 },
178 ShellList {
180 workstream_id: String,
181 },
182 ShellRegister {
184 workstream_id: String,
185 tmux_window: u32,
186 },
187 AttachShell {
192 shell_id: String,
193 },
194 DetachShell {
196 shell_id: String,
197 },
198 PtyInput {
200 shell_id: String,
201 data: String,
203 },
204 PtyResize {
206 shell_id: String,
207 cols: u16,
208 rows: u16,
209 },
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type", content = "data")]
214pub enum Response {
215 Pong,
217 Ok,
218 DaemonStatus(DaemonStatus),
219 ClientInfo(ClientInfo),
220 Pair(PairPayload),
222 PairedClient(PairedClient),
223 PairedClients(Vec<PairedClient>),
224 Revoked(u32),
226 Error(VexProtoError),
227
228 RepoRegistered(Repository),
230 RepoList(Vec<Repository>),
231 RepoUnregistered,
232 RepoDefaultBranchSet,
233
234 WorkstreamCreated(Workstream),
236 WorkstreamList(Vec<Repository>),
238 WorkstreamDeleted,
239
240 AgentSpawned(Agent),
242 AgentSpawnedInPlace {
244 agent: Agent,
245 exec_cmd: String,
247 },
248 AgentKilled,
249 AgentList(Vec<Agent>),
250
251 ShellSpawned(ShellSession),
253 ShellKilled,
254 ShellList(Vec<ShellSession>),
255 ShellRegistered {
257 shell_id: String,
258 },
259 ShellAttached,
261 ShellDetached,
262 PtyOutput {
264 shell_id: String,
265 data: String,
267 },
268 ShellExited {
270 shell_id: String,
271 code: Option<i32>,
272 },
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(tag = "type", content = "data")]
285pub enum ShellMsg {
286 Out { data: String },
288 In { data: String },
290 Resize { cols: u16, rows: u16 },
292 Exited { code: Option<i32> },
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct DaemonStatus {
300 pub uptime_secs: u64,
301 pub connected_clients: u32,
302 pub version: String,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct PairPayload {
308 pub token_id: String,
309 pub token_secret: String,
310 pub host: Option<String>,
312}
313
314impl PairPayload {
315 pub fn pairing_string(&self) -> String {
317 format!("{}:{}", self.token_id, self.token_secret)
318 }
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct PairedClient {
323 pub token_id: String,
324 pub label: Option<String>,
325 pub created_at: String,
326 pub expires_at: Option<String>,
327 pub last_seen: Option<String>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct ClientInfo {
332 pub token_id: Option<String>,
333 pub is_local: bool,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum Transport {
339 Unix,
340 Tcp,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(tag = "code", content = "message")]
345pub enum VexProtoError {
346 Unauthorized,
347 LocalOnly,
348 NotFound,
349 Internal(String),
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct AuthToken {
355 pub token_id: String,
356 pub token_secret: String,
358}
359
360pub mod framing {
363 use serde::{Deserialize, Serialize};
364 use std::io;
365 use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
366
367 #[derive(Debug)]
368 pub enum VexFrameError {
369 Io(io::Error),
370 Json(serde_json::Error),
371 }
372
373 impl std::fmt::Display for VexFrameError {
374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375 match self {
376 VexFrameError::Io(e) => write!(f, "IO error: {e}"),
377 VexFrameError::Json(e) => write!(f, "JSON error: {e}"),
378 }
379 }
380 }
381
382 impl std::error::Error for VexFrameError {}
383
384 impl From<io::Error> for VexFrameError {
385 fn from(e: io::Error) -> Self {
386 VexFrameError::Io(e)
387 }
388 }
389
390 impl From<serde_json::Error> for VexFrameError {
391 fn from(e: serde_json::Error) -> Self {
392 VexFrameError::Json(e)
393 }
394 }
395
396 pub async fn send<W, T>(w: &mut W, msg: &T) -> Result<(), VexFrameError>
398 where
399 W: AsyncWrite + Unpin,
400 T: Serialize,
401 {
402 let body = serde_json::to_vec(msg)?;
403 w.write_u32(body.len() as u32).await?;
404 w.write_all(&body).await?;
405 Ok(())
406 }
407
408 const MAX_FRAME_SIZE: u32 = 16 * 1024 * 1024;
410
411 pub async fn recv<R, T>(r: &mut R) -> Result<T, VexFrameError>
413 where
414 R: AsyncRead + Unpin,
415 T: for<'de> Deserialize<'de>,
416 {
417 let len = r.read_u32().await?;
418 if len > MAX_FRAME_SIZE {
419 return Err(VexFrameError::Io(io::Error::new(
420 io::ErrorKind::InvalidData,
421 format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
422 )));
423 }
424 let mut buf = vec![0u8; len as usize];
425 r.read_exact(&mut buf).await?;
426 Ok(serde_json::from_slice(&buf)?)
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::{Command, Response, ShellMsg, VexProtoError, framing};
433
434 #[tokio::test]
435 async fn framing_command_roundtrip() {
436 let (mut a, mut b) = tokio::io::duplex(4096);
437 framing::send(&mut a, &Command::Status).await.unwrap();
438 let recv: Command = framing::recv(&mut b).await.unwrap();
439 assert!(matches!(recv, Command::Status));
440 }
441
442 #[tokio::test]
443 async fn framing_shell_register_roundtrip() {
444 let (mut a, mut b) = tokio::io::duplex(4096);
445 let cmd = Command::ShellRegister {
446 workstream_id: "ws_abc123".to_string(),
447 tmux_window: 7,
448 };
449 framing::send(&mut a, &cmd).await.unwrap();
450 let recv: Command = framing::recv(&mut b).await.unwrap();
451 match recv {
452 Command::ShellRegister {
453 workstream_id,
454 tmux_window,
455 } => {
456 assert_eq!(workstream_id, "ws_abc123");
457 assert_eq!(tmux_window, 7);
458 }
459 other => panic!("unexpected: {other:?}"),
460 }
461 }
462
463 #[tokio::test]
464 async fn framing_shell_msg_out() {
465 let (mut a, mut b) = tokio::io::duplex(4096);
466 let msg = ShellMsg::Out {
467 data: "aGVsbG8=".to_string(),
468 };
469 framing::send(&mut a, &msg).await.unwrap();
470 let recv: ShellMsg = framing::recv(&mut b).await.unwrap();
471 match recv {
472 ShellMsg::Out { data } => assert_eq!(data, "aGVsbG8="),
473 other => panic!("unexpected: {other:?}"),
474 }
475 }
476
477 #[tokio::test]
478 async fn framing_shell_msg_resize() {
479 let (mut a, mut b) = tokio::io::duplex(4096);
480 framing::send(
481 &mut a,
482 &ShellMsg::Resize {
483 cols: 120,
484 rows: 40,
485 },
486 )
487 .await
488 .unwrap();
489 let recv: ShellMsg = framing::recv(&mut b).await.unwrap();
490 assert!(matches!(
491 recv,
492 ShellMsg::Resize {
493 cols: 120,
494 rows: 40
495 }
496 ));
497 }
498
499 #[tokio::test]
500 async fn framing_shell_msg_exited() {
501 let (mut a, mut b) = tokio::io::duplex(4096);
502 framing::send(&mut a, &ShellMsg::Exited { code: Some(1) })
503 .await
504 .unwrap();
505 let recv: ShellMsg = framing::recv(&mut b).await.unwrap();
506 assert!(matches!(recv, ShellMsg::Exited { code: Some(1) }));
507 }
508
509 #[tokio::test]
510 async fn framing_response_registered() {
511 let (mut a, mut b) = tokio::io::duplex(4096);
512 let resp = Response::ShellRegistered {
513 shell_id: "sh_aabbcc".to_string(),
514 };
515 framing::send(&mut a, &resp).await.unwrap();
516 let recv: Response = framing::recv(&mut b).await.unwrap();
517 match recv {
518 Response::ShellRegistered { shell_id } => assert_eq!(shell_id, "sh_aabbcc"),
519 other => panic!("unexpected: {other:?}"),
520 }
521 }
522
523 #[tokio::test]
524 async fn framing_response_error() {
525 let (mut a, mut b) = tokio::io::duplex(4096);
526 framing::send(&mut a, &Response::Error(VexProtoError::NotFound))
527 .await
528 .unwrap();
529 let recv: Response = framing::recv(&mut b).await.unwrap();
530 assert!(matches!(recv, Response::Error(VexProtoError::NotFound)));
531 }
532}