1use crate::RunnerSpikeArchive;
2use assay_runner_schema::{CgroupCorrelationStatus, KernelLayerStatus};
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::collections::BTreeMap;
6use std::process::{Command, ExitStatus};
7use std::time::{Duration, Instant};
8use thiserror::Error;
9use uuid::Uuid;
10
11pub const RUN_EVENT_SCHEMA: &str = "assay.runner.event.v0";
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RunSpec {
15 pub run_id: String,
16 pub platform: String,
17 pub agent_shim: String,
18 pub command: Vec<String>,
19 pub env: BTreeMap<String, String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Error)]
23pub enum RunSpecError {
24 #[error("command must not be empty")]
25 EmptyCommand,
26 #[error("run_id must not be empty")]
27 EmptyRunId,
28 #[error("run_id must not contain ':'")]
29 RunIdContainsColon,
32 #[error("run_id may only contain ASCII letters, digits, '_' and '-'")]
33 RunIdContainsUnsafeCharacter,
34 #[error("unsupported agent shim {0:?}")]
35 UnsupportedAgentShim(String),
36}
37
38#[derive(Debug, Error)]
39pub enum RunExecutionError {
40 #[error(transparent)]
41 Spec(#[from] RunSpecError),
42 #[error("failed to spawn command {command:?}: {source}")]
43 Spawn {
44 command: String,
45 source: std::io::Error,
46 },
47 #[error("failed to wait for command {command:?}: {source}")]
48 Wait {
49 command: String,
50 source: std::io::Error,
51 },
52 #[error("failed to serialize runner event: {0}")]
53 EventSerialization(#[from] serde_json::Error),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RunOutcome {
58 pub archive: RunnerSpikeArchive,
59 pub exit_code: Option<i32>,
60 pub signal: Option<i32>,
61 pub success: bool,
62}
63
64impl RunSpec {
65 pub fn new(command: Vec<String>) -> Self {
66 Self {
67 run_id: generate_run_id(),
68 platform: std::env::consts::OS.to_string(),
69 agent_shim: "none".to_string(),
70 command,
71 env: BTreeMap::new(),
72 }
73 }
74
75 pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
76 self.run_id = run_id.into();
77 self
78 }
79
80 pub fn with_platform(mut self, platform: impl Into<String>) -> Self {
81 self.platform = platform.into();
82 self
83 }
84
85 pub fn with_agent_shim(mut self, agent_shim: impl Into<String>) -> Self {
86 self.agent_shim = agent_shim.into();
87 self
88 }
89
90 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
91 self.env.insert(key.into(), value.into());
92 self
93 }
94
95 pub fn validate(&self) -> Result<(), RunSpecError> {
96 if self.command.is_empty() {
97 return Err(RunSpecError::EmptyCommand);
98 }
99 if self.run_id.is_empty() {
100 return Err(RunSpecError::EmptyRunId);
101 }
102 if self.run_id.contains(':') {
103 return Err(RunSpecError::RunIdContainsColon);
104 }
105 if !is_safe_run_id(&self.run_id) {
106 return Err(RunSpecError::RunIdContainsUnsafeCharacter);
107 }
108 if !matches!(
109 self.agent_shim.as_str(),
110 "none" | "openai-agents" | "gemini-google-genai"
111 ) {
112 return Err(RunSpecError::UnsupportedAgentShim(self.agent_shim.clone()));
113 }
114 Ok(())
115 }
116
117 pub fn skeleton_archive(&self) -> Result<RunnerSpikeArchive, RunSpecError> {
118 self.validate()?;
119 let mut archive = RunnerSpikeArchive::empty(self.run_id.clone(), self.platform.clone());
120 archive.observation_health = archive.observation_health.with_agent_shim(&self.agent_shim);
121 archive.observation_health.kernel_layer = KernelLayerStatus::Absent;
122 archive.observation_health = archive
123 .observation_health
124 .with_cgroup_correlation(CgroupCorrelationStatus::Partial);
125 archive.observation_health.notes.push(
126 "contract_only_mode: kernel and cgroup capture not wired in this revision".to_string(),
127 );
128 Ok(archive)
129 }
130
131 pub fn run_contract_only(&self) -> Result<RunOutcome, RunExecutionError> {
132 self.validate()?;
133 let mut archive = self.skeleton_archive()?;
134 let clock = Instant::now();
135
136 self.append_run_started(&mut archive, 0, Duration::ZERO)?;
137
138 let mut child = Command::new(&self.command[0])
139 .args(&self.command[1..])
140 .envs(&self.env)
141 .spawn()
142 .map_err(|source| RunExecutionError::Spawn {
143 command: self.command[0].clone(),
144 source,
145 })?;
146 let status = child.wait().map_err(|source| RunExecutionError::Wait {
147 command: self.command[0].clone(),
148 source,
149 })?;
150
151 let exit_code = status.code();
152 let signal = exit_signal(&status);
153 let success = status.success();
154 self.append_run_finished(&mut archive, 1, &status, clock.elapsed())?;
155
156 Ok(RunOutcome {
157 archive,
158 exit_code,
159 signal,
160 success,
161 })
162 }
163
164 pub fn append_run_started(
165 &self,
166 archive: &mut RunnerSpikeArchive,
167 seq: u64,
168 window_elapsed: Duration,
169 ) -> Result<(), RunExecutionError> {
170 append_event(
171 archive,
172 json!({
173 "schema": RUN_EVENT_SCHEMA,
174 "run_id": &self.run_id,
175 "seq": seq,
176 "type": "run_started",
177 "agent_shim": &self.agent_shim,
178 "command": &self.command,
179 "env_keys": self.env.keys().collect::<Vec<_>>(),
180 "window_elapsed_ms": window_elapsed.as_millis() as u64
181 }),
182 )?;
183 Ok(())
184 }
185
186 pub fn append_run_finished(
187 &self,
188 archive: &mut RunnerSpikeArchive,
189 seq: u64,
190 status: &ExitStatus,
191 window_elapsed: Duration,
192 ) -> Result<(), RunExecutionError> {
193 append_event(
194 archive,
195 json!({
196 "schema": RUN_EVENT_SCHEMA,
197 "run_id": &self.run_id,
198 "seq": seq,
199 "type": "run_finished",
200 "exit_code": status.code(),
201 "signal": exit_signal(status),
202 "success": status.success(),
203 "window_elapsed_ms": window_elapsed.as_millis() as u64
204 }),
205 )?;
206 Ok(())
207 }
208}
209
210fn generate_run_id() -> String {
211 format!("run_{}", Uuid::new_v4().simple())
212}
213
214pub(crate) fn is_safe_run_id(run_id: &str) -> bool {
215 run_id
216 .bytes()
217 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
218}
219
220fn append_event(
221 archive: &mut RunnerSpikeArchive,
222 event: serde_json::Value,
223) -> Result<(), serde_json::Error> {
224 serde_json::to_writer(&mut archive.events_ndjson, &event)?;
225 archive.events_ndjson.push(b'\n');
226 Ok(())
227}
228
229#[cfg(unix)]
230fn exit_signal(status: &std::process::ExitStatus) -> Option<i32> {
231 use std::os::unix::process::ExitStatusExt;
232 status.signal()
233}
234
235#[cfg(not(unix))]
236fn exit_signal(_status: &std::process::ExitStatus) -> Option<i32> {
237 None
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use assay_runner_schema::SdkLayerStatus;
244
245 #[test]
246 fn generated_run_id_is_stream_safe() {
247 let spec = RunSpec::new(vec!["true".to_string()]);
248
249 assert!(spec.run_id.starts_with("run_"));
250 assert!(!spec.run_id.contains(':'));
251 assert_eq!(spec.platform, std::env::consts::OS);
252 }
253
254 #[test]
255 fn run_spec_rejects_empty_command() {
256 let spec = RunSpec::new(Vec::new());
257
258 assert_eq!(spec.validate(), Err(RunSpecError::EmptyCommand));
259 }
260
261 #[test]
262 fn run_spec_rejects_colon_run_id() {
263 let spec = RunSpec::new(vec!["true".to_string()]).with_run_id("bad:run");
264
265 assert_eq!(spec.validate(), Err(RunSpecError::RunIdContainsColon));
266 }
267
268 #[test]
269 fn run_spec_rejects_path_like_run_id() {
270 let spec = RunSpec::new(vec!["true".to_string()]).with_run_id("../bad");
271
272 assert_eq!(
273 spec.validate(),
274 Err(RunSpecError::RunIdContainsUnsafeCharacter)
275 );
276 }
277
278 #[test]
279 fn run_spec_rejects_unknown_agent_shim() {
280 let spec = RunSpec::new(vec!["true".to_string()]).with_agent_shim("typo-shim");
281
282 assert_eq!(
283 spec.validate(),
284 Err(RunSpecError::UnsupportedAgentShim("typo-shim".to_string()))
285 );
286 }
287
288 #[test]
289 fn none_shim_skeleton_archive_has_absent_sdk_layer() {
290 let archive = RunSpec::new(vec!["true".to_string()])
291 .with_run_id("run_001")
292 .with_platform("linux")
293 .with_agent_shim("none")
294 .skeleton_archive()
295 .unwrap();
296
297 assert_eq!(archive.run_id, "run_001");
298 assert_eq!(archive.observation_health.sdk_layer, SdkLayerStatus::Absent);
299 }
300
301 #[test]
302 fn sdk_shim_skeleton_archive_stays_absent_until_events_are_consumed() {
303 let archive = RunSpec::new(vec!["true".to_string()])
304 .with_run_id("run_001")
305 .with_platform("linux")
306 .with_agent_shim("openai-agents")
307 .skeleton_archive()
308 .unwrap();
309
310 assert_eq!(archive.observation_health.sdk_layer, SdkLayerStatus::Absent);
311 }
312
313 #[test]
314 fn gemini_shim_is_accepted_in_allowlist() {
315 let archive = RunSpec::new(vec!["true".to_string()])
326 .with_run_id("run_001")
327 .with_platform("linux")
328 .with_agent_shim("gemini-google-genai")
329 .skeleton_archive()
330 .unwrap();
331
332 assert_eq!(archive.observation_health.sdk_layer, SdkLayerStatus::Absent);
333 }
334
335 #[test]
336 fn contract_only_run_records_lifecycle_events_and_exit_code() {
337 let outcome = RunSpec::new(vec!["true".to_string()])
338 .with_run_id("run_001")
339 .with_platform("linux")
340 .run_contract_only()
341 .unwrap();
342 let events = String::from_utf8(outcome.archive.events_ndjson).unwrap();
343
344 assert_eq!(outcome.exit_code, Some(0));
345 assert!(outcome.success);
346 assert!(events.contains("\"type\":\"run_started\""));
347 assert!(events.contains("\"type\":\"run_finished\""));
348 assert!(outcome.archive.kernel_layer_ndjson.is_empty());
349 assert!(outcome.archive.policy_layer_ndjson.is_empty());
350 assert!(outcome.archive.sdk_layer_ndjson.is_empty());
351 }
352
353 #[test]
354 fn contract_only_bundle_does_not_claim_kernel_observation() {
355 let outcome = RunSpec::new(vec!["true".to_string()])
356 .with_run_id("run_001")
357 .with_platform("linux")
358 .run_contract_only()
359 .unwrap();
360
361 assert_eq!(
362 outcome.archive.observation_health.kernel_layer,
363 KernelLayerStatus::Absent
364 );
365 assert_eq!(
366 outcome.archive.observation_health.cgroup_correlation,
367 CgroupCorrelationStatus::Partial
368 );
369 assert!(outcome
370 .archive
371 .observation_health
372 .notes
373 .iter()
374 .any(|note| note.contains("contract_only_mode")));
375 }
376}