1use 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 #[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 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}