1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use serde_json::Value;
5use soma_provider_core::{ProviderCall, ProviderSurface};
6use thiserror::Error;
7
8pub(crate) const PYTHON_WORKER_SCHEMA_VERSION: u32 = 1;
9pub(crate) const ONE_SHOT_REQUEST_ID: u64 = 0;
10pub(crate) const PYTHON_PROTOCOL_HEADROOM_BYTES: usize = 1024;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "mode", rename_all = "snake_case")]
14pub(crate) enum PythonWorkerRequest {
15 Catalog {
16 schema_version: u32,
17 request_id: u64,
18 path: PathBuf,
19 },
20 Call {
21 schema_version: u32,
22 request_id: u64,
23 path: PathBuf,
24 #[serde(default)]
25 env_keys: Vec<String>,
26 provider: String,
27 action: String,
28 params: Value,
29 surface: ProviderSurface,
30 snapshot_id: String,
31 },
32}
33
34impl PythonWorkerRequest {
35 pub(crate) fn catalog(path: &Path) -> Self {
36 Self::Catalog {
37 schema_version: PYTHON_WORKER_SCHEMA_VERSION,
38 request_id: ONE_SHOT_REQUEST_ID,
39 path: path.to_path_buf(),
40 }
41 }
42
43 pub(crate) fn call(path: &Path, call: &ProviderCall, env_keys: Vec<String>) -> Self {
44 Self::Call {
45 schema_version: PYTHON_WORKER_SCHEMA_VERSION,
46 request_id: ONE_SHOT_REQUEST_ID,
47 path: path.to_path_buf(),
48 env_keys,
49 provider: call.provider.clone(),
50 action: call.action.clone(),
51 params: call.params.clone(),
52 surface: call.surface,
53 snapshot_id: call.snapshot_id.clone(),
54 }
55 }
56
57 fn schema_version(&self) -> u32 {
58 match self {
59 Self::Catalog { schema_version, .. } | Self::Call { schema_version, .. } => {
60 *schema_version
61 }
62 }
63 }
64
65 fn request_id(&self) -> u64 {
66 match self {
67 Self::Catalog { request_id, .. } | Self::Call { request_id, .. } => *request_id,
68 }
69 }
70
71 fn mode(&self) -> &'static str {
72 match self {
73 Self::Catalog { .. } => "catalog",
74 Self::Call { .. } => "call",
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[serde(tag = "mode", rename_all = "snake_case")]
81pub(crate) enum PythonWorkerResponse {
82 Catalog {
83 schema_version: u32,
84 request_id: u64,
85 catalog: Value,
86 },
87 Call {
88 schema_version: u32,
89 request_id: u64,
90 output: Value,
91 },
92}
93
94impl PythonWorkerResponse {
95 fn schema_version(&self) -> u32 {
96 match self {
97 Self::Catalog { schema_version, .. } | Self::Call { schema_version, .. } => {
98 *schema_version
99 }
100 }
101 }
102
103 fn request_id(&self) -> u64 {
104 match self {
105 Self::Catalog { request_id, .. } | Self::Call { request_id, .. } => *request_id,
106 }
107 }
108
109 fn mode(&self) -> &'static str {
110 match self {
111 Self::Catalog { .. } => "catalog",
112 Self::Call { .. } => "call",
113 }
114 }
115}
116
117pub const PYTHON_RUNNER_PROTOCOL_MAJOR: u16 = 1;
119pub const PYTHON_RUNNER_PROTOCOL_MINOR: u16 = 0;
121pub const PYTHON_RUNNER_MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub struct PythonRunnerProtocolVersion {
127 pub major: u16,
128 pub minor: u16,
129}
130
131impl PythonRunnerProtocolVersion {
132 #[must_use]
133 pub const fn current() -> Self {
134 Self {
135 major: PYTHON_RUNNER_PROTOCOL_MAJOR,
136 minor: PYTHON_RUNNER_PROTOCOL_MINOR,
137 }
138 }
139
140 pub fn negotiate(self, peer: Self) -> Result<Self, PythonProtocolError> {
141 if self.major != peer.major {
142 return Err(PythonProtocolError::ProtocolMajorMismatch {
143 host: self.major,
144 worker: peer.major,
145 });
146 }
147 Ok(Self {
148 major: self.major,
149 minor: self.minor.min(peer.minor),
150 })
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
156#[serde(rename_all = "kebab-case")]
157pub enum PythonRunnerFeature {
158 Describe,
159 Invoke,
160 Cancel,
161 Health,
162 Drain,
163 Shutdown,
164 HostCalls,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct PythonRuntimeIdentity {
170 pub implementation: String,
171 pub version: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct PythonRunnerHello {
177 pub protocol: PythonRunnerProtocolVersion,
178 pub sdk_version: String,
179 pub python: PythonRuntimeIdentity,
180 #[serde(default)]
181 pub features: Vec<PythonRunnerFeature>,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(tag = "type", rename_all = "snake_case")]
187pub enum PythonRunnerHostMessage {
188 Initialize {
189 protocol: PythonRunnerProtocolVersion,
190 #[serde(default)]
191 features: Vec<PythonRunnerFeature>,
192 generation_id: String,
193 },
194 Request {
195 #[serde(flatten)]
196 request: PythonRunnerHostRequest,
197 },
198 HostReply {
199 request_id: u64,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 result: Option<Value>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 error: Option<PythonRunnerError>,
204 },
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(tag = "type", rename_all = "snake_case")]
210pub enum PythonRunnerWorkerMessage {
211 Hello {
212 protocol: PythonRunnerProtocolVersion,
213 sdk_version: String,
214 python: PythonRuntimeIdentity,
215 #[serde(default)]
216 features: Vec<PythonRunnerFeature>,
217 },
218 Ready {
219 protocol: PythonRunnerProtocolVersion,
220 #[serde(default)]
221 features: Vec<PythonRunnerFeature>,
222 generation_id: String,
223 },
224 Reply {
225 #[serde(flatten)]
226 reply: PythonRunnerReply,
227 },
228 HostCall {
229 #[serde(flatten)]
230 call: PythonRunnerHostCall,
231 },
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum PythonRequestState {
237 Queued,
238 Written,
239 Accepted,
240 Terminal,
241 TimedOut,
242 WorkerLost,
243}
244
245impl PythonRequestState {
246 #[must_use]
247 pub const fn is_pending(self) -> bool {
248 matches!(self, Self::Queued | Self::Written | Self::Accepted)
249 }
250}
251
252#[must_use]
254pub fn negotiate_runner_features(
255 host: &[PythonRunnerFeature],
256 worker: &[PythonRunnerFeature],
257) -> Vec<PythonRunnerFeature> {
258 host.iter()
259 .copied()
260 .filter(|feature| worker.contains(feature))
261 .collect()
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct PythonTraceContext {
267 pub traceparent: String,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub tracestate: Option<String>,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct PythonActorContext {
275 pub actor_id: String,
276 #[serde(default)]
277 pub scopes: Vec<String>,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282pub struct PythonInvocationRequest {
283 pub invocation_id: String,
284 pub request_id: String,
287 pub provider: String,
288 pub action: String,
289 pub arguments: Value,
290 pub surface: ProviderSurface,
291 pub snapshot_id: String,
292 pub deadline_unix_ms: u64,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub trace: Option<PythonTraceContext>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub actor: Option<PythonActorContext>,
297 pub cancellation_token_id: String,
298 pub generation_id: String,
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[serde(tag = "method", rename_all = "snake_case")]
304pub enum PythonRunnerHostRequest {
305 Describe {
306 request_id: u64,
307 path: PathBuf,
308 generation_id: String,
309 },
310 Invoke {
311 request_id: u64,
312 invocation: Box<PythonInvocationRequest>,
313 },
314 Cancel {
315 request_id: u64,
316 invocation_id: String,
317 cancellation_token_id: String,
318 },
319 Health {
320 request_id: u64,
321 },
322 Drain {
323 request_id: u64,
324 },
325 Shutdown {
326 request_id: u64,
327 },
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum PythonWorkerHealth {
334 Starting,
335 Ready,
336 Draining,
337 Unhealthy,
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "snake_case")]
343pub enum PythonInvocationState {
344 Pending,
345 Accepted,
346 Running,
347 Completed,
348 Cancelled,
349 Indeterminate,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354#[serde(tag = "status", rename_all = "snake_case")]
355pub enum PythonRunnerReply {
356 Ok {
357 request_id: u64,
358 result: Value,
359 },
360 Accepted {
361 request_id: u64,
362 invocation_id: String,
363 state: PythonInvocationState,
364 },
365 Health {
366 request_id: u64,
367 health: PythonWorkerHealth,
368 generation_id: String,
369 },
370 Error {
371 request_id: u64,
372 error: PythonRunnerError,
373 },
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378#[serde(tag = "method")]
379pub enum PythonRunnerHostCall {
380 #[serde(rename = "host.http")]
381 Http {
382 request_id: u64,
383 invocation_id: String,
384 request: Value,
385 },
386 #[serde(rename = "host.secret")]
387 Secret {
388 request_id: u64,
389 invocation_id: String,
390 name: String,
391 },
392 #[serde(rename = "host.state.get")]
393 StateGet {
394 request_id: u64,
395 invocation_id: String,
396 key: String,
397 },
398 #[serde(rename = "host.state.put")]
399 StatePut {
400 request_id: u64,
401 invocation_id: String,
402 key: String,
403 value: Value,
404 },
405 #[serde(rename = "host.log")]
406 Log {
407 request_id: u64,
408 invocation_id: String,
409 level: String,
410 message: String,
411 #[serde(default)]
412 fields: Value,
413 },
414 #[serde(rename = "host.metric")]
415 Metric {
416 request_id: u64,
417 invocation_id: String,
418 name: String,
419 value: serde_json::Number,
420 #[serde(default)]
421 attributes: Value,
422 },
423 #[serde(rename = "host.progress")]
424 Progress {
425 request_id: u64,
426 invocation_id: String,
427 current: u64,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 total: Option<u64>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 message: Option<String>,
432 },
433 #[serde(rename = "host.cancelled")]
434 Cancelled {
435 request_id: u64,
436 invocation_id: String,
437 },
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(rename_all = "snake_case")]
443pub enum PythonRunnerErrorCode {
444 PythonRuntimeMissing,
445 PythonVersionUnsupported,
446 PythonDependencyResolutionFailed,
447 PythonWorkerStartFailed,
448 PythonProtocolMismatch,
449 PythonCatalogTimeout,
450 PythonImportFailed,
451 PythonSchemaInvalid,
452 PythonPolicyDenied,
453 PythonCallTimeout,
454 PythonCallCancelled,
455 PythonWorkerCrashed,
456 PythonOutputTooLarge,
457 PythonInvalidOutput,
458 PythonNativeAbiMismatch,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "snake_case")]
464pub enum PythonRunnerErrorPhase {
465 Runtime,
466 DependencyResolution,
467 WorkerStartup,
468 Protocol,
469 Catalog,
470 Import,
471 Schema,
472 Policy,
473 Invocation,
474 NativeBinding,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479pub struct PythonRunnerError {
480 pub code: PythonRunnerErrorCode,
481 pub phase: PythonRunnerErrorPhase,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub provider: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub source: Option<String>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub generation_id: Option<String>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub action: Option<String>,
490 pub retryable: bool,
491 pub public_message: String,
492}
493
494#[derive(Debug, Error)]
495pub enum PythonProtocolError {
496 #[error("invalid Python worker JSON: {0}")]
497 Json(#[from] serde_json::Error),
498 #[error("unsupported Python worker schema version {actual}; expected {expected}")]
499 UnsupportedSchemaVersion { expected: u32, actual: u32 },
500 #[error("unexpected Python worker request id {actual}; expected {expected}")]
501 UnexpectedRequestId { expected: u64, actual: u64 },
502 #[error("unexpected Python worker response mode {actual}; expected {expected}")]
503 UnexpectedResponseMode {
504 expected: &'static str,
505 actual: &'static str,
506 },
507 #[error("Python runner protocol major mismatch: host {host}, worker {worker}")]
508 ProtocolMajorMismatch { host: u16, worker: u16 },
509 #[error("Python runner frame header is incomplete: got {actual} bytes; expected 4")]
510 FrameHeaderTooShort { actual: usize },
511 #[error("Python runner frame payload is {actual} bytes; limit is {limit}")]
512 FrameTooLarge { limit: usize, actual: usize },
513 #[error("Python runner frame length mismatch: declared {declared} bytes; got {actual}")]
514 FrameLengthMismatch { declared: usize, actual: usize },
515}
516
517pub fn encode_runner_frame<T: Serialize>(message: &T) -> Result<Vec<u8>, PythonProtocolError> {
519 let payload = serde_json::to_vec(message)?;
520 if payload.len() > PYTHON_RUNNER_MAX_FRAME_BYTES {
521 return Err(PythonProtocolError::FrameTooLarge {
522 limit: PYTHON_RUNNER_MAX_FRAME_BYTES,
523 actual: payload.len(),
524 });
525 }
526
527 let mut frame = Vec::with_capacity(4 + payload.len());
528 frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
529 frame.extend_from_slice(&payload);
530 Ok(frame)
531}
532
533pub fn decode_runner_frame<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, PythonProtocolError> {
535 if bytes.len() < 4 {
536 return Err(PythonProtocolError::FrameHeaderTooShort {
537 actual: bytes.len(),
538 });
539 }
540
541 let declared = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
542 if declared > PYTHON_RUNNER_MAX_FRAME_BYTES {
543 return Err(PythonProtocolError::FrameTooLarge {
544 limit: PYTHON_RUNNER_MAX_FRAME_BYTES,
545 actual: declared,
546 });
547 }
548
549 let payload = &bytes[4..];
550 if payload.len() != declared {
551 return Err(PythonProtocolError::FrameLengthMismatch {
552 declared,
553 actual: payload.len(),
554 });
555 }
556
557 Ok(serde_json::from_slice(payload)?)
558}
559
560pub(crate) fn encode_python_request(
561 request: &PythonWorkerRequest,
562) -> Result<Vec<u8>, PythonProtocolError> {
563 validate_schema_version(request.schema_version())?;
564 let mut encoded = serde_json::to_vec(request)?;
565 encoded.push(b'\n');
566 Ok(encoded)
567}
568
569pub(crate) fn decode_python_response(
570 bytes: &[u8],
571) -> Result<PythonWorkerResponse, PythonProtocolError> {
572 let response: PythonWorkerResponse = serde_json::from_slice(bytes)?;
573 validate_schema_version(response.schema_version())?;
574 Ok(response)
575}
576
577pub(crate) fn validate_python_response(
578 request: &PythonWorkerRequest,
579 response: &PythonWorkerResponse,
580) -> Result<(), PythonProtocolError> {
581 validate_schema_version(request.schema_version())?;
582 validate_schema_version(response.schema_version())?;
583 if response.request_id() != request.request_id() {
584 return Err(PythonProtocolError::UnexpectedRequestId {
585 expected: request.request_id(),
586 actual: response.request_id(),
587 });
588 }
589 if response.mode() != request.mode() {
590 return Err(PythonProtocolError::UnexpectedResponseMode {
591 expected: request.mode(),
592 actual: response.mode(),
593 });
594 }
595 Ok(())
596}
597
598fn validate_schema_version(actual: u32) -> Result<(), PythonProtocolError> {
599 if actual == PYTHON_WORKER_SCHEMA_VERSION {
600 return Ok(());
601 }
602 Err(PythonProtocolError::UnsupportedSchemaVersion {
603 expected: PYTHON_WORKER_SCHEMA_VERSION,
604 actual,
605 })
606}
607
608#[cfg(test)]
609#[path = "python_protocol_tests.rs"]
610mod tests;