Skip to main content

runner_protocol/
lib.rs

1// Copyright 2026 Kotelnikovekb
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://apache.org
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14use anyhow::{Context, Result, bail};
15use chrono::Utc;
16use serde::{Deserialize, Serialize};
17use std::{fmt, fs, path::Path};
18
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct JobSpec {
21    pub api_version: String,
22    pub id: String,
23    #[serde(default)]
24    pub attempt: u32,
25    pub executor: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub execution: Option<ExecutionRequirements>,
28    pub image: String,
29    #[serde(default)]
30    pub command: Vec<String>,
31    pub working_directory: String,
32    pub workspace: WorkspaceSpec,
33    #[serde(default)]
34    pub environment_from_runner: Vec<String>,
35    #[serde(default)]
36    pub secrets: Vec<SecretSpec>,
37    pub resources: Resources,
38    pub network: NetworkSpec,
39    #[serde(default)]
40    pub artifacts: Vec<String>,
41    /// Explicit compatibility escape hatch for images that must write outside
42    /// /workspace, for example Flutter SDK cache or an agent log directory.
43    #[serde(default)]
44    pub writable_rootfs: bool,
45    #[serde(default)]
46    pub workflow: Option<WorkflowSpec>,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct JobSpecV1beta1 {
51    pub api_version: String,
52    pub id: String,
53    #[serde(default)]
54    pub attempt: u32,
55    pub image: String,
56    #[serde(default)]
57    pub command: Vec<String>,
58    pub working_directory: String,
59    pub workspace: WorkspaceSpec,
60    #[serde(default)]
61    pub environment_from_runner: Vec<String>,
62    #[serde(default)]
63    pub secrets: Vec<SecretSpec>,
64    pub resources: Resources,
65    pub network: NetworkSpec,
66    #[serde(default)]
67    pub artifacts: Vec<String>,
68    #[serde(default)]
69    pub writable_rootfs: bool,
70    #[serde(default)]
71    pub workflow: Option<WorkflowSpec>,
72    pub execution: ExecutionRequirements,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
76pub struct ExecutionRequirements {
77    pub isolation: IsolationLevel,
78    #[serde(default)]
79    pub capabilities: Vec<String>,
80}
81
82#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum IsolationLevel {
85    #[default]
86    Container,
87    Sandboxed,
88    Dedicated,
89}
90
91impl From<JobSpecV1beta1> for JobSpec {
92    fn from(spec: JobSpecV1beta1) -> Self {
93        Self {
94            api_version: spec.api_version,
95            id: spec.id,
96            attempt: spec.attempt,
97            // The adapter keeps the Community executor identity for the
98            // existing runtime while execution requirements remain explicit.
99            executor: "docker".into(),
100            execution: Some(spec.execution),
101            image: spec.image,
102            command: spec.command,
103            working_directory: spec.working_directory,
104            workspace: spec.workspace,
105            environment_from_runner: spec.environment_from_runner,
106            secrets: spec.secrets,
107            resources: spec.resources,
108            network: spec.network,
109            artifacts: spec.artifacts,
110            writable_rootfs: spec.writable_rootfs,
111            workflow: spec.workflow,
112        }
113    }
114}
115#[derive(Debug, Clone, Deserialize, Serialize)]
116#[serde(tag = "kind", rename_all = "snake_case")]
117pub enum WorkspaceSpec {
118    Local {
119        path: String,
120    },
121    ArchiveUrl {
122        url: String,
123    },
124    Git {
125        clone_url: String,
126        base_branch: String,
127        branch: String,
128        #[serde(default)]
129        base_sha: Option<String>,
130        #[serde(default)]
131        head_sha: Option<String>,
132        username: String,
133        token: String,
134        commit_message: String,
135        #[serde(default)]
136        publish_mode: GitPublishMode,
137    },
138}
139
140#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum GitPublishMode {
143    Disabled,
144    #[default]
145    IfChanged,
146    Required,
147}
148#[derive(Debug, Clone, Deserialize, Serialize)]
149pub struct Resources {
150    pub cpu: f64,
151    pub memory_mb: i64,
152    pub pids: i64,
153    pub timeout_seconds: u64,
154}
155#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct NetworkSpec {
157    pub mode: String,
158}
159#[derive(Debug, Clone, Deserialize, Serialize)]
160pub struct WorkflowSpec {
161    #[serde(default)]
162    pub setup: Vec<CommandSpec>,
163    #[serde(default)]
164    pub services: Vec<ServiceSpec>,
165    #[serde(default)]
166    pub initialize: Option<CommandSpec>,
167    pub agent: CommandSpec,
168    #[serde(default)]
169    pub verifiers: Vec<VerifierSpec>,
170    #[serde(default = "default_max_iterations")]
171    pub max_iterations: u32,
172    #[serde(default = "default_feedback_file")]
173    pub feedback_file: String,
174    #[serde(default)]
175    pub publish: Option<CommandSpec>,
176}
177#[derive(Debug, Clone, Deserialize, Serialize)]
178pub struct ServiceSpec {
179    pub name: String,
180    pub image: String,
181    #[serde(default)]
182    pub alias: Option<String>,
183    #[serde(default)]
184    pub command: Vec<String>,
185    #[serde(default)]
186    pub environment: std::collections::HashMap<String, String>,
187    #[serde(default)]
188    pub healthcheck: Option<HealthcheckSpec>,
189}
190#[derive(Debug, Clone, Deserialize, Serialize)]
191pub struct HealthcheckSpec {
192    pub command: Vec<String>,
193    #[serde(default = "default_healthcheck_timeout")]
194    pub timeout_seconds: u64,
195}
196fn default_healthcheck_timeout() -> u64 {
197    60
198}
199#[derive(Debug, Clone, Deserialize, Serialize)]
200pub struct CommandSpec {
201    pub command: Vec<String>,
202    #[serde(default)]
203    pub working_directory: Option<String>,
204}
205#[derive(Debug, Clone, Deserialize, Serialize)]
206pub struct VerifierSpec {
207    pub name: String,
208    pub command: Vec<String>,
209    #[serde(default)]
210    pub working_directory: Option<String>,
211    #[serde(default)]
212    pub required: bool,
213}
214fn default_max_iterations() -> u32 {
215    3
216}
217fn default_feedback_file() -> String {
218    "/workspace/.runner/feedback.md".into()
219}
220
221pub fn validate_feedback_file(value: &str) -> Result<std::path::PathBuf> {
222    let relative = value
223        .strip_prefix("/workspace/")
224        .or_else(|| (!value.starts_with('/')).then_some(value))
225        .ok_or_else(|| anyhow::anyhow!("feedback_file must be inside /workspace"))?;
226    if relative.is_empty()
227        || relative.contains('\\')
228        || relative.contains(':')
229        || relative == "."
230        || relative.starts_with("./")
231        || relative.contains("/./")
232        || relative.ends_with("/.")
233    {
234        bail!("invalid feedback_file path")
235    }
236    let path = Path::new(relative);
237    if path.is_absolute()
238        || path.components().any(|component| {
239            matches!(
240                component,
241                std::path::Component::CurDir
242                    | std::path::Component::ParentDir
243                    | std::path::Component::RootDir
244                    | std::path::Component::Prefix(_)
245            )
246        })
247    {
248        bail!("invalid feedback_file path")
249    }
250    Ok(path.to_path_buf())
251}
252#[derive(Clone, Deserialize, Serialize)]
253pub struct SecretSpec {
254    pub name: String,
255    #[serde(default, skip_serializing_if = "String::is_empty")]
256    pub value: String,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub secret_ref: Option<String>,
259}
260impl fmt::Debug for SecretSpec {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.debug_struct("SecretSpec")
263            .field("name", &self.name)
264            .field("value", &"[REDACTED]")
265            .field(
266                "secret_ref",
267                &self.secret_ref.as_ref().map(|_| "[REDACTED]"),
268            )
269            .finish()
270    }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "snake_case")]
275pub enum FailureKind {
276    Validation,
277    Policy,
278    Secret,
279    Execution,
280    Cancellation,
281    Timeout,
282    Infrastructure,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286pub struct FailureInfo {
287    pub kind: FailureKind,
288    pub code: String,
289    pub message: String,
290}
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct JobResult {
293    pub job_id: String,
294    pub attempt: u32,
295    pub status: String,
296    pub exit_code: Option<i64>,
297    pub started_at: String,
298    pub finished_at: String,
299    pub duration_ms: u128,
300    pub log_truncated: bool,
301    pub stdout: String,
302    pub stderr: String,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub error_summary: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub failure: Option<FailureInfo>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub failed_phase: Option<String>,
309    pub artifacts: Vec<String>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub artifact_dir: Option<String>,
312    pub sandbox: SandboxResult,
313}
314
315#[derive(Debug, Clone, Serialize)]
316pub struct LogChunk {
317    pub attempt: u32,
318    pub sequence: u64,
319    pub stream: String,
320    pub phase: String,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub level: Option<String>,
323    pub message: String,
324}
325
326pub fn error_summary(status: &str, stdout: &str, stderr: &str) -> Option<String> {
327    if status == "completed" {
328        return None;
329    }
330    let source = if stderr.trim().is_empty() {
331        stdout
332    } else {
333        stderr
334    };
335    let line = source.lines().rev().find(|line| !line.trim().is_empty())?;
336    let summary = line.trim();
337    Some(summary.chars().take(500).collect())
338}
339
340impl JobResult {
341    pub fn failed_from_error(spec: &JobSpec, error: impl std::fmt::Display) -> Self {
342        Self::failed_from_failure(
343            spec,
344            FailureInfo {
345                kind: FailureKind::Execution,
346                code: "executor_error".into(),
347                message: error.to_string(),
348            },
349        )
350    }
351
352    pub fn failed_from_failure(spec: &JobSpec, failure: FailureInfo) -> Self {
353        let now = Utc::now().to_rfc3339();
354        Self {
355            job_id: spec.id.clone(),
356            attempt: spec.attempt,
357            status: "failed".into(),
358            exit_code: None,
359            started_at: now.clone(),
360            finished_at: now,
361            duration_ms: 0,
362            log_truncated: false,
363            stdout: String::new(),
364            stderr: failure.message.clone(),
365            error_summary: Some(failure.message.clone()),
366            failure: Some(failure),
367            failed_phase: None,
368            artifacts: Vec::new(),
369            artifact_dir: None,
370            sandbox: SandboxResult {
371                executor: spec.executor.clone(),
372                container_id: String::new(),
373                image_id: None,
374            },
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::{
382        ExecutionRequirements, FailureInfo, FailureKind, IsolationLevel, JobSpec, JobSpecV1beta1,
383        SecretSpec, error_summary, validate_feedback_file,
384    };
385
386    #[test]
387    fn v1alpha1_job_spec_fixture_remains_readable_and_valid() {
388        let value: serde_json::Value =
389            serde_json::from_str(include_str!("../tests/fixtures/job-spec-v1alpha1.json")).unwrap();
390        let spec: JobSpec = serde_json::from_value(value.clone()).unwrap();
391
392        spec.validate(false).unwrap();
393        assert_eq!(spec.api_version, "ai-kodu-runner.dev/v1alpha1");
394        assert_eq!(spec.id, "fixture-job");
395        assert_eq!(serde_json::to_value(spec).unwrap(), value);
396    }
397
398    #[test]
399    fn v1alpha1_job_result_fixture_preserves_required_shape() {
400        let value: serde_json::Value =
401            serde_json::from_str(include_str!("../tests/fixtures/job-result-v1alpha1.json"))
402                .unwrap();
403
404        assert_eq!(value["status"], "completed");
405        assert_eq!(value["exit_code"], 0);
406        assert_eq!(value["sandbox"]["executor"], "docker");
407        assert!(value.get("error_summary").is_none());
408        assert!(value.get("failed_phase").is_none());
409        assert!(value.get("artifact_dir").is_none());
410    }
411
412    #[test]
413    fn feedback_file_validation_accepts_workspace_relative_path() {
414        assert_eq!(
415            validate_feedback_file("/workspace/.runner/feedback.md").unwrap(),
416            std::path::PathBuf::from(".runner/feedback.md")
417        );
418        assert_eq!(
419            validate_feedback_file(".runner/feedback.md").unwrap(),
420            std::path::PathBuf::from(".runner/feedback.md")
421        );
422    }
423
424    #[test]
425    fn feedback_file_validation_rejects_escape_and_platform_prefixes() {
426        for path in [
427            "../outside.md",
428            "/workspace/../outside.md",
429            "/tmp/outside.md",
430            "C:\\outside.md",
431            "\\\\server\\share\\outside.md",
432            ".runner/./feedback.md",
433        ] {
434            assert!(
435                validate_feedback_file(path).is_err(),
436                "path should be rejected: {path}"
437            );
438        }
439    }
440
441    #[test]
442    fn v1beta1_fixture_adapts_to_canonical_job_spec() {
443        let document = include_str!("../tests/fixtures/job-spec-v1beta1.json");
444        let value: serde_json::Value = serde_json::from_str(document).unwrap();
445        let beta: JobSpecV1beta1 = serde_json::from_value(value).unwrap();
446        let spec: JobSpec = beta.into();
447
448        spec.validate(false).unwrap();
449        assert_eq!(spec.api_version, "ai-kodu-runner.dev/v1beta1");
450        assert_eq!(spec.executor, "docker");
451        assert_eq!(
452            spec.execution,
453            Some(ExecutionRequirements {
454                isolation: IsolationLevel::Container,
455                capabilities: vec!["artifacts".into(), "streaming_logs".into()]
456            })
457        );
458    }
459
460    #[test]
461    fn loader_adapts_v1beta1_and_rejects_unknown_versions() {
462        let spec =
463            JobSpec::from_json(include_str!("../tests/fixtures/job-spec-v1beta1.json")).unwrap();
464        assert_eq!(spec.id, "fixture-beta-job");
465
466        let error = JobSpec::from_json(r#"{"api_version":"ai-kodu-runner.dev/v9"}"#).unwrap_err();
467        assert!(error.to_string().contains("unsupported api_version"));
468    }
469
470    #[test]
471    fn v1beta1_requires_execution_requirements() {
472        let mut spec = JobSpec {
473            api_version: "ai-kodu-runner.dev/v1beta1".into(),
474            id: "missing-execution".into(),
475            attempt: 1,
476            executor: "docker".into(),
477            execution: None,
478            image: "example/runner:latest".into(),
479            command: vec!["true".into()],
480            working_directory: "/workspace".into(),
481            workspace: super::WorkspaceSpec::Local { path: ".".into() },
482            environment_from_runner: Vec::new(),
483            secrets: Vec::new(),
484            resources: super::Resources {
485                cpu: 1.0,
486                memory_mb: 256,
487                pids: 64,
488                timeout_seconds: 60,
489            },
490            network: super::NetworkSpec {
491                mode: "none".into(),
492            },
493            artifacts: Vec::new(),
494            writable_rootfs: false,
495            workflow: None,
496        };
497
498        assert!(spec.validate(false).is_err());
499        spec.execution = Some(ExecutionRequirements {
500            isolation: IsolationLevel::Sandboxed,
501            capabilities: vec!["sandboxed".into()],
502        });
503        assert!(spec.validate(false).is_ok());
504    }
505
506    #[test]
507    fn secret_ref_is_serialized_without_secret_value() {
508        let spec = SecretSpec {
509            name: "MODEL_KEY".into(),
510            value: String::new(),
511            secret_ref: Some("vault://team/model-key".into()),
512        };
513        let json = serde_json::to_value(&spec).unwrap();
514
515        assert_eq!(json["secret_ref"], "vault://team/model-key");
516        assert!(json.get("value").is_none());
517        assert!(!format!("{spec:?}").contains("vault://team/model-key"));
518    }
519
520    #[test]
521    fn failed_result_contains_typed_failure() {
522        let spec =
523            JobSpec::from_json(include_str!("../tests/fixtures/job-spec-v1alpha1.json")).unwrap();
524        let result = super::JobResult::failed_from_failure(
525            &spec,
526            FailureInfo {
527                kind: FailureKind::Secret,
528                code: "secret_ref_unresolvable".into(),
529                message: "secret_ref is not resolvable by the Community executor".into(),
530            },
531        );
532        let json = serde_json::to_value(result).unwrap();
533
534        assert_eq!(json["failure"]["kind"], "secret");
535        assert_eq!(json["failure"]["code"], "secret_ref_unresolvable");
536    }
537
538    #[test]
539    fn error_summary_prefers_stderr_and_truncates() {
540        assert_eq!(
541            error_summary("failed", "agent output", "first\nUnexpected server error\n"),
542            Some("Unexpected server error".into())
543        );
544        assert_eq!(error_summary("completed", "", ""), None);
545    }
546}
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct SandboxResult {
549    pub executor: String,
550    pub container_id: String,
551    pub image_id: Option<String>,
552}
553impl JobSpec {
554    pub fn from_path(p: &Path) -> Result<Self> {
555        let document =
556            fs::read_to_string(p).with_context(|| format!("read job {}", p.display()))?;
557        Self::from_json(&document)
558    }
559
560    pub fn from_json(document: &str) -> Result<Self> {
561        let value: serde_json::Value = serde_json::from_str(document).context("parse job JSON")?;
562        match value.get("api_version").and_then(serde_json::Value::as_str) {
563            Some("ai-kodu-runner.dev/v1alpha1") => {
564                serde_json::from_value(value).context("parse v1alpha1 job JSON")
565            }
566            Some("ai-kodu-runner.dev/v1beta1") => {
567                let spec: JobSpecV1beta1 =
568                    serde_json::from_value(value).context("parse v1beta1 job JSON")?;
569                Ok(spec.into())
570            }
571            Some(version) => bail!("unsupported api_version: {version}"),
572            None => bail!("missing api_version"),
573        }
574    }
575    pub fn validate(&self, daemon: bool) -> Result<()> {
576        if !["ai-kodu-runner.dev/v1alpha1", "ai-kodu-runner.dev/v1beta1"]
577            .contains(&self.api_version.as_str())
578        {
579            bail!("unsupported api_version")
580        }
581        if self.executor != "docker" {
582            bail!("unsupported executor")
583        }
584        if self.api_version == "ai-kodu-runner.dev/v1beta1" && self.execution.is_none() {
585            bail!("v1beta1 execution requirements are required")
586        }
587        if let Some(execution) = &self.execution {
588            if execution.capabilities.len() > 32 {
589                bail!("too many execution capabilities")
590            }
591            let mut capabilities = std::collections::HashSet::new();
592            for capability in &execution.capabilities {
593                if capability.is_empty()
594                    || capability.len() > 64
595                    || !capability
596                        .bytes()
597                        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
598                    || !capabilities.insert(capability)
599                {
600                    bail!("invalid execution capability")
601                }
602            }
603        }
604        if self.workflow.is_none() && self.command.is_empty() {
605            bail!("command must not be empty")
606        }
607        if let Some(workflow) = &self.workflow {
608            if workflow
609                .initialize
610                .as_ref()
611                .is_some_and(|command| command.command.is_empty())
612            {
613                bail!("workflow initialize command must not be empty")
614            }
615            if workflow.agent.command.is_empty() {
616                bail!("workflow agent command must not be empty")
617            }
618            if workflow.max_iterations == 0 || workflow.max_iterations > 20 {
619                bail!("workflow max_iterations must be between 1 and 20")
620            }
621            validate_feedback_file(&workflow.feedback_file)?;
622            if workflow
623                .verifiers
624                .iter()
625                .any(|v| v.name.trim().is_empty() || v.command.is_empty())
626            {
627                bail!("workflow verifier names and commands must not be empty")
628            }
629            let mut service_names = std::collections::HashSet::new();
630            if workflow.services.len() > 16 {
631                bail!("too many workflow services")
632            }
633            for service in &workflow.services {
634                if service.name.trim().is_empty()
635                    || !service_names.insert(&service.name)
636                    || service.image.trim().is_empty()
637                    || service.image.contains(char::is_whitespace)
638                    || !service
639                        .name
640                        .bytes()
641                        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
642                {
643                    bail!("invalid workflow service")
644                }
645                if daemon && !service.image.contains("@sha256:") {
646                    bail!("daemon workflow services require image digests")
647                }
648                if let Some(alias) = &service.alias
649                    && (alias.trim().is_empty()
650                        || alias.contains(char::is_whitespace)
651                        || !alias.bytes().all(|b| {
652                            b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'
653                        }))
654                {
655                    bail!("invalid workflow service alias")
656                }
657                if let Some(healthcheck) = &service.healthcheck
658                    && (healthcheck.command.is_empty()
659                        || healthcheck.timeout_seconds == 0
660                        || healthcheck.timeout_seconds > 3600)
661                {
662                    bail!("invalid workflow service healthcheck")
663                }
664            }
665        }
666        if self.image.trim().is_empty() || self.image.contains(char::is_whitespace) {
667            bail!("invalid image reference")
668        }
669        if daemon && !self.image.contains("@sha256:") {
670            bail!("daemon jobs require an image digest")
671        }
672        if !["bridge", "none"].contains(&self.network.mode.as_str()) {
673            bail!("unsupported network mode")
674        }
675        if self.secrets.len() > 32 {
676            bail!("too many secrets")
677        }
678        for secret in &self.secrets {
679            if secret.name.is_empty()
680                || secret.name.len() > 128
681                || !secret
682                    .name
683                    .bytes()
684                    .all(|b| b.is_ascii_alphanumeric() || b == b'_')
685            {
686                bail!("invalid secret name")
687            }
688            if secret.secret_ref.is_some() && !secret.value.is_empty() {
689                bail!("secret must use either value or secret_ref")
690            }
691            if let Some(secret_ref) = &secret.secret_ref
692                && (secret_ref.is_empty() || secret_ref.len() > 512)
693            {
694                bail!("invalid secret_ref")
695            }
696            if secret.value.len() > 64 * 1024 {
697                bail!("secret is too large")
698            }
699        }
700        Ok(())
701    }
702}