Skip to main content

vela_protocol/
queue.rs

1//! Phase R (v0.5): a local queue of unsigned draft actions.
2//!
3//! The Workbench writes here; `vela queue sign` walks the queue, signs
4//! each action with the caller's Ed25519 key, and posts the signed
5//! action to a live `vela serve` (or applies it directly via the same
6//! `proposals::*_at_path` helpers the CLI uses).
7//!
8//! This is the v0.5 doctrine for human review actions: signing is a
9//! deliberate human act on a terminal that holds the key. The browser
10//! never sees the key. Drafts queue here; the CLI is the only signer.
11
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub const QUEUE_SCHEMA: &str = "vela.queue.v0.1";
18
19/// A queued draft action. `kind` matches a write tool name
20/// (`propose_review`, `propose_note`, `propose_revise_confidence`,
21/// `propose_retract`, `accept_proposal`, `reject_proposal`); `args` is
22/// the tool-specific argument bundle *without* the signature field —
23/// `vela queue sign` constructs the signature at sign-time.
24///
25/// `frontier` is the path to the frontier file the action targets.
26/// Multiple frontiers can be queued in a single queue file.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct QueuedAction {
29    pub kind: String,
30    pub frontier: PathBuf,
31    #[serde(default)]
32    pub args: Value,
33    pub queued_at: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Queue {
38    #[serde(default = "default_schema")]
39    pub schema: String,
40    #[serde(default)]
41    pub actions: Vec<QueuedAction>,
42}
43
44fn default_schema() -> String {
45    QUEUE_SCHEMA.to_string()
46}
47
48impl Default for Queue {
49    fn default() -> Self {
50        Self {
51            schema: QUEUE_SCHEMA.to_string(),
52            actions: Vec::new(),
53        }
54    }
55}
56
57/// Resolve the queue file path. Defaults to `~/.vela/queue.json`.
58/// Override with `VELA_QUEUE_FILE` for testing or alternate locations.
59#[must_use]
60pub fn default_queue_path() -> PathBuf {
61    if let Ok(path) = std::env::var("VELA_QUEUE_FILE") {
62        return PathBuf::from(path);
63    }
64    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
65    PathBuf::from(home).join(".vela").join("queue.json")
66}
67
68/// Load the queue from `path`. If the file does not exist, returns an
69/// empty queue (the queue is ephemeral; nonexistence is a normal state).
70pub fn load(path: &Path) -> Result<Queue, String> {
71    if !path.exists() {
72        return Ok(Queue::default());
73    }
74    let raw =
75        std::fs::read_to_string(path).map_err(|e| format!("read queue {}: {e}", path.display()))?;
76    let queue: Queue =
77        serde_json::from_str(&raw).map_err(|e| format!("parse queue {}: {e}", path.display()))?;
78    Ok(queue)
79}
80
81/// Write the queue to `path`, creating parent directories as needed.
82pub fn save(path: &Path, queue: &Queue) -> Result<(), String> {
83    if let Some(parent) = path.parent() {
84        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
85    }
86    let raw = serde_json::to_string_pretty(queue).map_err(|e| format!("serialize queue: {e}"))?;
87    std::fs::write(path, raw).map_err(|e| format!("write queue {}: {e}", path.display()))?;
88    Ok(())
89}
90
91/// Append a draft action to the queue file (creating it if absent).
92pub fn append(path: &Path, action: QueuedAction) -> Result<(), String> {
93    let mut queue = load(path)?;
94    queue.actions.push(action);
95    save(path, &queue)
96}
97
98/// Remove all actions from the queue file. Idempotent.
99pub fn clear(path: &Path) -> Result<usize, String> {
100    let queue = load(path)?;
101    let dropped = queue.actions.len();
102    save(path, &Queue::default())?;
103    Ok(dropped)
104}
105
106/// Replace the queue's action list (used by `sign` after each successful
107/// signed-and-applied action to remove the signed entry).
108pub fn replace_actions(path: &Path, actions: Vec<QueuedAction>) -> Result<(), String> {
109    save(
110        path,
111        &Queue {
112            schema: QUEUE_SCHEMA.to_string(),
113            actions,
114        },
115    )
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use serde_json::json;
122    use tempfile::TempDir;
123
124    #[test]
125    fn empty_queue_when_file_absent() {
126        let tmp = TempDir::new().unwrap();
127        let path = tmp.path().join("queue.json");
128        let q = load(&path).unwrap();
129        assert_eq!(q.actions.len(), 0);
130        assert_eq!(q.schema, QUEUE_SCHEMA);
131    }
132
133    #[test]
134    fn append_persists_and_round_trips() {
135        let tmp = TempDir::new().unwrap();
136        let path = tmp.path().join("queue.json");
137        append(
138            &path,
139            QueuedAction {
140                kind: "accept_proposal".to_string(),
141                frontier: PathBuf::from("/tmp/x.json"),
142                args: json!({"proposal_id": "vpr_x", "reviewer_id": "r:test", "reason": "ok"}),
143                queued_at: "2026-04-25T00:00:00Z".to_string(),
144            },
145        )
146        .unwrap();
147        let q = load(&path).unwrap();
148        assert_eq!(q.actions.len(), 1);
149        assert_eq!(q.actions[0].kind, "accept_proposal");
150    }
151
152    #[test]
153    fn clear_drops_all_actions() {
154        let tmp = TempDir::new().unwrap();
155        let path = tmp.path().join("queue.json");
156        for i in 0..3 {
157            append(
158                &path,
159                QueuedAction {
160                    kind: "propose_review".to_string(),
161                    frontier: PathBuf::from("/tmp/x.json"),
162                    args: json!({"i": i}),
163                    queued_at: "2026-04-25T00:00:00Z".to_string(),
164                },
165            )
166            .unwrap();
167        }
168        let dropped = clear(&path).unwrap();
169        assert_eq!(dropped, 3);
170        let q = load(&path).unwrap();
171        assert_eq!(q.actions.len(), 0);
172    }
173}