1use crate::llm::actions::{
2 protocol_trait::{ActionResult, Protocol, Server},
3 ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct LpdProtocol;
14impl LpdProtocol {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21 Parameter {
22 name: name.into(),
23 type_hint: kind.into(),
24 description: description.into(),
25 required,
26 }
27}
28
29pub fn action(
30 name: &str,
31 description: &str,
32 parameters: Vec<Parameter>,
33 example: Value,
34) -> ActionDefinition {
35 let log_template = match name {
36 "lpd_job_reply" => LogTemplate::new().with_info("-> LPD job accept={accept}"),
37 "lpd_queue_status" => {
38 LogTemplate::new().with_info("-> LPD queue status={status} jobs={preview(jobs,120)}")
39 }
40 "lpd_remove_result" => {
41 LogTemplate::new().with_info("-> LPD removed={preview(removed,120)}")
42 }
43 "lpd_print" => {
44 LogTemplate::new().with_info("-> LPD print queue={queue} job_name={job_name}")
45 }
46 "lpd_queue" => LogTemplate::new().with_info("-> LPD queue query queue={queue} long={long}"),
47 "lpd_remove" => LogTemplate::new().with_info("-> LPD remove queue={queue} agent={agent}"),
48 "lpd_start_queue" => {
49 LogTemplate::new().with_info("-> LPD print waiting jobs queue={queue}")
50 }
51 _ => LogTemplate::new().with_info(format!("-> LPD {name}")),
52 };
53 ActionDefinition {
54 name: name.into(),
55 description: description.into(),
56 parameters,
57 example,
58 log_template: Some(log_template),
59 }
60}
61
62fn job_action() -> ActionDefinition {
63 action(
64 "lpd_job_reply",
65 "Accept or refuse the print job. Acceptance acknowledges the job's last file with 0; refusal sends a nonzero acknowledgement, which tells the client the job was not queued.",
66 vec![
67 parameter("accept", "boolean", "True to queue the job", true),
68 parameter("reason", "string", "Why, for the log; LPD has no field to carry it to the client", false),
69 ],
70 json!({"type":"lpd_job_reply","accept":true}),
71 )
72}
73
74fn queue_action() -> ActionDefinition {
75 action(
76 "lpd_queue_status",
77 "Describe the queue for lpq. Rust renders the short or long listing the client asked for.",
78 vec![
79 parameter("status", "string", "First line, such as 'raw is ready and printing'", false),
80 parameter(
81 "jobs",
82 "array",
83 "Queued jobs in order: [{owner, job_id: number, files: string, size: bytes, rank?: 'active' or a number}]",
84 false,
85 ),
86 ],
87 json!({"type":"lpd_queue_status","status":"raw is ready","jobs":[{"owner":"alice","job_id":42,"files":"report.txt","size":1024}]}),
88 )
89}
90
91fn remove_action() -> ActionDefinition {
92 action(
93 "lpd_remove_result",
94 "Report which jobs the removal request dequeued; each is listed back to lprm as dequeued.",
95 vec![parameter(
96 "removed",
97 "array",
98 "Job numbers removed, such as [42]; empty when nothing matched",
99 true,
100 )],
101 json!({"type":"lpd_remove_result","removed":[42]}),
102 )
103}
104
105pub static JOB_EVENT: LazyLock<EventType> = LazyLock::new(|| {
106 EventType::new(
107 "lpd_print_job",
108 "A complete print job: the control file and every data file it names have arrived. Decide whether to queue it.",
109 job_action().example.clone(),
110 )
111 .with_parameters(vec![
112 parameter("queue", "string", "Queue the client sent the job to", true),
113 parameter("job_id", "string", "Three-digit job number from the control file name", true),
114 parameter("host", "string", "Originating host (H line)", false),
115 parameter("user", "string", "Submitting user (P line)", false),
116 parameter("job_name", "string", "Job name (J line)", false),
117 parameter("title", "string", "Title for pr formatting (T line)", false),
118 parameter("class", "string", "Job class from the control file (C line)", false),
119 parameter("mail", "string", "Address to mail on completion (M line)", false),
120 parameter(
121 "files",
122 "array",
123 "[{name, source_name, format, size, text}] - text is the first 64 KiB when the file is text, null when binary",
124 true,
125 ),
126 ])
127 .with_actions(vec![job_action()])
128});
129
130pub static QUEUE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
131 EventType::new(
132 "lpd_queue_query",
133 "A client (lpq) asked for the state of a queue.",
134 queue_action().example.clone(),
135 )
136 .with_parameters(vec![
137 parameter(
138 "queue",
139 "string",
140 "Name of the print queue on the server",
141 true,
142 ),
143 parameter("long", "boolean", "True for the long format (lpq -l)", true),
144 parameter(
145 "list",
146 "array",
147 "Users or job numbers the listing is restricted to",
148 true,
149 ),
150 ])
151 .with_actions(vec![queue_action()])
152});
153
154pub static REMOVE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
155 EventType::new(
156 "lpd_remove_request",
157 "A client (lprm) asked to remove jobs. The agent is the user asking.",
158 remove_action().example.clone(),
159 )
160 .with_parameters(vec![
161 parameter(
162 "queue",
163 "string",
164 "Name of the print queue on the server",
165 true,
166 ),
167 parameter("agent", "string", "User requesting removal", true),
168 parameter(
169 "list",
170 "array",
171 "Users or job numbers to remove; empty means the agent's current job",
172 true,
173 ),
174 ])
175 .with_actions(vec![remove_action()])
176});
177
178impl Protocol for LpdProtocol {
179 fn protocol_name(&self) -> &'static str {
180 "LPD"
181 }
182 fn stack_name(&self) -> &'static str {
183 "ETH>IP>TCP>LPD"
184 }
185 fn description(&self) -> &'static str {
186 "LPD line printer daemon (RFC 1179): print jobs, queue listings and removals"
187 }
188 fn keywords(&self) -> Vec<&'static str> {
189 vec![
190 "lpd",
191 "lpr",
192 "lpq",
193 "lprm",
194 "rfc1179",
195 "line printer",
196 "print server",
197 ]
198 }
199 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
200 vec![]
201 }
202 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
203 vec![job_action(), queue_action(), remove_action()]
204 }
205 fn get_event_types(&self) -> Vec<EventType> {
206 vec![JOB_EVENT.clone(), QUEUE_EVENT.clone(), REMOVE_EVENT.clone()]
207 }
208 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
209 vec![
210 ParameterDefinition {
211 name: "queues".into(),
212 type_hint: "array".into(),
213 description: "Queue names to accept; a job for any other queue is refused at once. Omit to accept every queue.".into(),
214 required: false,
215 example: json!(["raw", "laser"]),
216 default: None,
217 },
218 ParameterDefinition {
219 name: "max_job_bytes".into(),
220 type_hint: "number".into(),
221 description: "Total size of the data files in one job (1..=268435456)".into(),
222 required: false,
223 example: json!(1048576),
224 default: Some(json!(super::wire::DEFAULT_MAX_JOB_BYTES)),
225 },
226 ParameterDefinition {
227 name: "idle_timeout_secs".into(),
228 type_hint: "number".into(),
229 description: "Seconds to wait for each command line or file transfer (1..=3600)".into(),
230 required: false,
231 example: json!(60),
232 default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
233 },
234 ]
235 }
236 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
237 use crate::protocol::metadata::*;
238 ProtocolMetadataV2::builder()
239 .state(DevelopmentState::Experimental)
240 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(515))
241 .well_known_port(515)
242 .implementation("RFC 1179 over Tokio TCP: receive job (control and data files in either order, abort), short and long queue state, remove jobs, print waiting jobs")
243 .llm_control("Whether each complete print job is queued, what the queue listing says, and which jobs a removal dequeues")
244 .e2e_testing("tests/server/lpd: raw-wire jobs and bounds; LPRng lpr/lpq/lprm and the CUPS lpd backend as independent clients")
245 .notes("No spool: the handler sees every job's control fields and the text of each data file (first 64 KiB, null when binary) and decides; nothing is printed or kept. Source ports are not checked against the RFC's 721-731 range. A data file must declare its exact size (no count-0 streaming). Bounds: 1024-byte command lines, 64 KiB control files, 52 data files and max_job_bytes per job, per-read idle deadline. A handler failure refuses the job (nonzero acknowledgement), answers a queue query with an unavailable line, and removes nothing.")
246 .request_only("LPD answers the one command each connection carries; the server never speaks first")
247 .max_inbound_bytes(super::wire::MAX_COMMAND_LINE)
248 .build()
249 }
250 fn example_prompt(&self) -> &'static str {
251 "LPD print server on port 5515 with a queue named raw that accepts plain-text jobs"
252 }
253 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
254 let llm = json!({"type":"open_server","base_stack":"lpd","port":5515,"instruction":"Accept text jobs on queue raw","startup_params":{"queues":["raw"]}});
255 let mut scripted = llm.clone();
256 scripted["event_handlers"] = json!([
257 {"event_pattern":"lpd_print_job","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nok=all(f['text'] is not None for f in e['files'])\nprint(json.dumps({'actions':[{'type':'lpd_job_reply','accept':ok}]}))"}},
258 {"event_pattern":"lpd_queue_query","handler":{"type":"static","actions":[{"type":"lpd_queue_status","status":"raw is ready","jobs":[]}]}},
259 {"event_pattern":"lpd_remove_request","handler":{"type":"static","actions":[{"type":"lpd_remove_result","removed":[]}]}}
260 ]);
261 let mut static_example = llm.clone();
262 static_example["event_handlers"] = json!([
263 {"event_pattern":"lpd_print_job","handler":{"type":"static","actions":[{"type":"lpd_job_reply","accept":true}]}},
264 {"event_pattern":"lpd_queue_query","handler":{"type":"static","actions":[{"type":"lpd_queue_status","status":"raw is ready","jobs":[]}]}},
265 {"event_pattern":"lpd_remove_request","handler":{"type":"static","actions":[{"type":"lpd_remove_result","removed":[]}]}}
266 ]);
267 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
268 }
269 fn group_name(&self) -> &'static str {
270 "Web & File"
271 }
272}
273
274impl Server for LpdProtocol {
275 fn spawn(
276 &self,
277 ctx: SpawnContext,
278 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
279 {
280 Box::pin(super::spawn(ctx))
281 }
282 fn execute_action(&self, v: Value) -> Result<ActionResult> {
283 match v["type"].as_str() {
284 Some("lpd_job_reply") => {
285 ensure!(v["accept"].is_boolean(), "accept must be a boolean");
286 Ok(ActionResult::Custom {
287 name: "lpd_job_reply".into(),
288 data: v,
289 })
290 }
291 Some("lpd_queue_status") => {
292 ensure!(
293 v.get("status").is_none_or(Value::is_string),
294 "status must be a string"
295 );
296 if let Some(jobs) = v.get("jobs") {
297 let jobs = jobs
298 .as_array()
299 .ok_or_else(|| anyhow::anyhow!("jobs must be an array"))?;
300 ensure!(jobs.len() <= 1000, "at most 1000 jobs in one listing");
301 for job in jobs {
302 ensure!(job.is_object(), "each job must be an object");
303 }
304 }
305 Ok(ActionResult::Custom {
306 name: "lpd_queue_status".into(),
307 data: v,
308 })
309 }
310 Some("lpd_remove_result") => {
311 let removed = v["removed"]
312 .as_array()
313 .ok_or_else(|| anyhow::anyhow!("removed must be an array"))?;
314 ensure!(removed.len() <= 1000, "at most 1000 removed jobs");
315 for id in removed {
316 ensure!(
317 id.is_u64() || id.as_str().is_some_and(super::wire::valid_token),
318 "each removed job must be a number"
319 );
320 }
321 Ok(ActionResult::Custom {
322 name: "lpd_remove_result".into(),
323 data: v,
324 })
325 }
326 _ => bail!("Unknown LPD server action"),
327 }
328 }
329}