algocline_app/pool/protocol.rs
1use std::path::PathBuf;
2
3use algocline_core::TokenUsage;
4use serde::{Deserialize, Serialize};
5
6// ─── Request ─────────────────────────────────────────────────────────────────
7
8/// A message sent from the MCP process (pool client) to a pool worker.
9///
10/// Serialised as a single JSON line with an `"op"` discriminant field.
11///
12/// ```json
13/// {"op":"handshake","version":"0.31.0"}
14/// {"op":"run","code":"return 1","ctx":null,"lib_paths":[]}
15/// {"op":"continue","sid":"abc","response":"yes","query_id":"q1","usage":null}
16/// {"op":"status"}
17/// {"op":"shutdown"}
18/// ```
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "op", rename_all = "snake_case")]
21pub enum PoolRequest {
22 /// Version negotiation; must be the first message sent.
23 Handshake {
24 /// Crate version of the client (e.g. `env!("CARGO_PKG_VERSION")`).
25 version: String,
26 },
27
28 /// Start a new Lua execution session.
29 Run {
30 /// Lua source code to execute.
31 code: String,
32 /// Optional JSON context passed as `alc.ctx`.
33 ctx: Option<serde_json::Value>,
34 /// Additional Lua library search paths.
35 lib_paths: Vec<PathBuf>,
36 },
37
38 /// Resume a paused session with the LLM response.
39 Continue {
40 /// Session ID originally returned in the `Run` response.
41 sid: String,
42 /// LLM response text.
43 response: String,
44 /// The specific query being answered (if multi-query).
45 query_id: Option<String>,
46 /// Token usage reported by the host alongside this response.
47 usage: Option<TokenUsage>,
48 },
49
50 /// Query the worker's current health/state (read-only).
51 Status,
52
53 /// Ask the worker to finish gracefully and exit.
54 Shutdown,
55}
56
57// ─── Response ────────────────────────────────────────────────────────────────
58
59/// Data payload carried by a successful [`PoolResponse`].
60///
61/// `FeedResult`-equivalent payloads are represented as `serde_json::Value` so
62/// that the `pool` protocol module does not introduce a hard dependency on the
63/// `algocline-engine` crate's concrete type. Subtask 5 (AppService dispatch)
64/// will deserialize the value into the appropriate engine type at the call site.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum PoolResponseData {
68 /// Reply to a `Handshake` request.
69 Handshake {
70 /// Crate version of the worker process.
71 version: String,
72 },
73
74 /// Reply to a `Run` or `Continue` request.
75 Feed {
76 /// The session ID assigned or continued by the worker.
77 session_id: String,
78 /// JSON-serialised `FeedResult` (Accepted / Paused / Finished).
79 feed_result: serde_json::Value,
80 },
81
82 /// Reply to a `Status` request.
83 Status {
84 /// Whether the worker currently has an active session.
85 has_session: bool,
86 /// Session ID of the active session, if any.
87 session_id: Option<String>,
88 },
89
90 /// Reply to a `Shutdown` request (worker will exit after sending this).
91 Shutdown,
92}
93
94/// A message sent from a pool worker back to the pool client.
95///
96/// Serialised as a single JSON line:
97///
98/// ```json
99/// {"ok":true,"data":{"kind":"handshake","version":"0.31.0"}}
100/// {"ok":false,"error":"worker handshake failed: version mismatch"}
101/// ```
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PoolResponse {
104 /// `true` if the operation succeeded.
105 pub ok: bool,
106 /// Present when `ok` is `true`.
107 pub data: Option<PoolResponseData>,
108 /// Present when `ok` is `false`.
109 pub error: Option<String>,
110}
111
112impl PoolResponse {
113 /// Construct a successful response.
114 pub fn success(data: PoolResponseData) -> Self {
115 Self {
116 ok: true,
117 data: Some(data),
118 error: None,
119 }
120 }
121
122 /// Construct an error response.
123 pub fn failure(error: impl Into<String>) -> Self {
124 Self {
125 ok: false,
126 data: None,
127 error: Some(error.into()),
128 }
129 }
130}
131
132// ─── Tests ───────────────────────────────────────────────────────────────────
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 /// Verify that a `Handshake` request round-trips through JSON without loss.
139 ///
140 /// The JSON line format must preserve the `"op"` discriminant so the worker
141 /// can dispatch on it without additional framing.
142 #[test]
143 fn handshake_request_roundtrip() {
144 let req = PoolRequest::Handshake {
145 version: "0.31.0".to_string(),
146 };
147
148 let json = serde_json::to_string(&req).expect("serialize");
149 // Must be a single line (no embedded newlines).
150 assert!(!json.contains('\n'), "JSON line must not contain newlines");
151 // Must carry the op discriminant.
152 assert!(json.contains("\"op\":\"handshake\""), "op field missing");
153 // Must carry the version field.
154 assert!(
155 json.contains("\"version\":\"0.31.0\""),
156 "version field missing"
157 );
158
159 let decoded: PoolRequest = serde_json::from_str(&json).expect("deserialize");
160 match decoded {
161 PoolRequest::Handshake { version } => {
162 assert_eq!(version, "0.31.0");
163 }
164 other => panic!("unexpected variant: {other:?}"),
165 }
166 }
167
168 /// Verify that a successful `PoolResponse` round-trips through JSON.
169 #[test]
170 fn response_success_roundtrip() {
171 let resp = PoolResponse::success(PoolResponseData::Handshake {
172 version: "0.31.0".to_string(),
173 });
174
175 let json = serde_json::to_string(&resp).expect("serialize");
176 assert!(json.contains("\"ok\":true"), "ok flag missing");
177
178 let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
179 assert!(decoded.ok);
180 assert!(decoded.error.is_none());
181 match decoded.data {
182 Some(PoolResponseData::Handshake { version }) => {
183 assert_eq!(version, "0.31.0");
184 }
185 other => panic!("unexpected data: {other:?}"),
186 }
187 }
188
189 /// Verify that an error `PoolResponse` round-trips through JSON.
190 #[test]
191 fn response_failure_roundtrip() {
192 let resp = PoolResponse::failure("version mismatch");
193
194 let json = serde_json::to_string(&resp).expect("serialize");
195 assert!(json.contains("\"ok\":false"), "ok flag missing");
196
197 let decoded: PoolResponse = serde_json::from_str(&json).expect("deserialize");
198 assert!(!decoded.ok);
199 assert!(decoded.data.is_none());
200 assert_eq!(decoded.error.as_deref(), Some("version mismatch"));
201 }
202}