Skip to main content

vex_cli/proto/
mod.rs

1use serde::{Deserialize, Serialize};
2
3/// Default port vexd listens on for TLS TCP connections.
4pub const DEFAULT_TCP_PORT: u16 = 7422;
5
6// ── Domain types ──────────────────────────────────────────────────────────────
7
8fn 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    /// Absolute path to the git repository on disk
17    pub path: String,
18    /// Default branch used when creating a workstream without an explicit branch.
19    /// Falls back to "main" for repos persisted before this field was added.
20    #[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    /// Absolute path: `$VEX_HOME/worktrees/<workstream_id>`
33    pub worktree_path: String,
34    /// Always `"vex-<workstream_id>"`
35    pub tmux_session: String,
36    pub status: WorkstreamStatus,
37    pub agents: Vec<Agent>,
38    pub created_at: u64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub enum WorkstreamStatus {
43    Idle,
44    Running,
45    Stopped,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Agent {
50    pub id: String,
51    pub workstream_id: String,
52    /// Window index in the tmux session
53    pub tmux_window: u32,
54    pub prompt: String,
55    pub status: AgentStatus,
56    pub exit_code: Option<i32>,
57    pub spawned_at: u64,
58    pub exited_at: Option<u64>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub enum AgentStatus {
63    Running,
64    Exited,
65    Failed,
66}
67
68// ── Wire types ────────────────────────────────────────────────────────────────
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(tag = "type", content = "data")]
72pub enum Command {
73    // ── Existing ──────────────────────────────────────────────────────────────
74    Status,
75    Whoami,
76    PairCreate {
77        label: Option<String>,
78        /// Expiry in seconds from now
79        expire_secs: Option<u64>,
80    },
81    PairList,
82    PairRevoke {
83        id: String,
84    },
85    PairRevokeAll,
86
87    // ── Repos (LocalOnly) ─────────────────────────────────────────────────────
88    /// Register a git repository. Unix-socket only (LocalOnly on TCP).
89    RepoRegister {
90        path: String,
91    },
92    RepoList,
93    RepoUnregister {
94        repo_id: String,
95    },
96    /// Update the default branch stored for a repo. Unix-socket only.
97    RepoSetDefaultBranch {
98        repo_id: String,
99        branch: String,
100    },
101
102    // ── Workstreams ───────────────────────────────────────────────────────────
103    WorkstreamCreate {
104        repo_id: String,
105        /// Workstream name. `None` = use the resolved branch name.
106        name: Option<String>,
107        /// Branch to check out. `None` = use the repo's `default_branch`.
108        branch: Option<String>,
109        /// If true, fetch `origin/<branch>` and fast-forward the local branch
110        /// before creating the worktree.
111        fetch_latest: bool,
112    },
113    /// `repo_id = None` means all repos
114    WorkstreamList {
115        repo_id: Option<String>,
116    },
117    WorkstreamDelete {
118        workstream_id: String,
119    },
120
121    // ── Agents ────────────────────────────────────────────────────────────────
122    AgentSpawn {
123        workstream_id: String,
124        prompt: String,
125    },
126    /// Claim the caller's current tmux window as an agent (in-place conversion).
127    /// The daemon registers the agent and returns the command to exec.
128    AgentSpawnInPlace {
129        workstream_id: String,
130        /// The existing tmux window index (the caller's current pane)
131        tmux_window: u32,
132        /// Optional task description; `None` means run the agent interactively
133        prompt: Option<String>,
134    },
135    AgentKill {
136        agent_id: String,
137    },
138    AgentList {
139        workstream_id: String,
140    },
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(tag = "type", content = "data")]
145pub enum Response {
146    // ── Existing ──────────────────────────────────────────────────────────────
147    Pong,
148    Ok,
149    DaemonStatus(DaemonStatus),
150    ClientInfo(ClientInfo),
151    /// Returned after PairCreate; contains the plaintext secret (one-time)
152    Pair(PairPayload),
153    PairedClient(PairedClient),
154    PairedClients(Vec<PairedClient>),
155    /// Returned by PairRevoke / PairRevokeAll, carrying the revoked count.
156    Revoked(u32),
157    Error(VexProtoError),
158
159    // ── Repos ─────────────────────────────────────────────────────────────────
160    RepoRegistered(Repository),
161    RepoList(Vec<Repository>),
162    RepoUnregistered,
163    RepoDefaultBranchSet,
164
165    // ── Workstreams ───────────────────────────────────────────────────────────
166    WorkstreamCreated(Workstream),
167    /// Full tree: repos → workstreams → agents
168    WorkstreamList(Vec<Repository>),
169    WorkstreamDeleted,
170
171    // ── Agents ────────────────────────────────────────────────────────────────
172    AgentSpawned(Agent),
173    /// Returned by `AgentSpawnInPlace`; client should `exec` the given command.
174    AgentSpawnedInPlace {
175        agent: Agent,
176        /// Shell command string to exec (replaces the caller's current process)
177        exec_cmd: String,
178    },
179    AgentKilled,
180    AgentList(Vec<Agent>),
181}
182
183// ── Existing helper types ─────────────────────────────────────────────────────
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct DaemonStatus {
187    pub uptime_secs: u64,
188    pub connected_clients: u32,
189    pub version: String,
190}
191
192/// Returned by PairCreate — contains the plaintext secret for the new token.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct PairPayload {
195    pub token_id: String,
196    pub token_secret: String,
197    /// Optional TCP host for encoding into a QR pairing string
198    pub host: Option<String>,
199}
200
201impl PairPayload {
202    /// Returns the pairing string in `<token_id>:<token_secret>` format.
203    pub fn pairing_string(&self) -> String {
204        format!("{}:{}", self.token_id, self.token_secret)
205    }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PairedClient {
210    pub token_id: String,
211    pub label: Option<String>,
212    pub created_at: String,
213    pub expires_at: Option<String>,
214    pub last_seen: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct ClientInfo {
219    pub token_id: Option<String>,
220    pub is_local: bool,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum Transport {
226    Unix,
227    Tcp,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(tag = "code", content = "message")]
232pub enum VexProtoError {
233    Unauthorized,
234    LocalOnly,
235    NotFound,
236    Internal(String),
237}
238
239/// Sent by the client at the start of every TCP connection before any Command.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct AuthToken {
242    pub token_id: String,
243    /// Plaintext hex-encoded 32-byte secret
244    pub token_secret: String,
245}
246
247// ── Framing ───────────────────────────────────────────────────────────────────
248
249pub mod framing {
250    use serde::{Deserialize, Serialize};
251    use std::io;
252    use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
253
254    #[derive(Debug)]
255    pub enum VexFrameError {
256        Io(io::Error),
257        Json(serde_json::Error),
258    }
259
260    impl std::fmt::Display for VexFrameError {
261        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262            match self {
263                VexFrameError::Io(e) => write!(f, "IO error: {e}"),
264                VexFrameError::Json(e) => write!(f, "JSON error: {e}"),
265            }
266        }
267    }
268
269    impl std::error::Error for VexFrameError {}
270
271    impl From<io::Error> for VexFrameError {
272        fn from(e: io::Error) -> Self {
273            VexFrameError::Io(e)
274        }
275    }
276
277    impl From<serde_json::Error> for VexFrameError {
278        fn from(e: serde_json::Error) -> Self {
279            VexFrameError::Json(e)
280        }
281    }
282
283    /// Write a length-prefixed JSON frame to `w`.
284    pub async fn send<W, T>(w: &mut W, msg: &T) -> Result<(), VexFrameError>
285    where
286        W: AsyncWrite + Unpin,
287        T: Serialize,
288    {
289        let body = serde_json::to_vec(msg)?;
290        w.write_u32(body.len() as u32).await?;
291        w.write_all(&body).await?;
292        Ok(())
293    }
294
295    /// Read a length-prefixed JSON frame from `r`.
296    pub async fn recv<R, T>(r: &mut R) -> Result<T, VexFrameError>
297    where
298        R: AsyncRead + Unpin,
299        T: for<'de> Deserialize<'de>,
300    {
301        let len = r.read_u32().await?;
302        let mut buf = vec![0u8; len as usize];
303        r.read_exact(&mut buf).await?;
304        Ok(serde_json::from_slice(&buf)?)
305    }
306}