Skip to main content

a3s_code_core/evaluation/
auxiliary_run.rs

1//! Isolated, host-supplied auxiliary evaluation runs.
2
3use super::evidence::EvidenceSnapshotV1;
4use super::identity::{digest_bytes, digest_json, validate_digest, ExecutionFrameV1};
5use async_trait::async_trait;
6use futures::FutureExt;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, VecDeque};
9use std::sync::Arc;
10use std::time::Duration;
11use thiserror::Error;
12use tokio::sync::{Mutex, Notify, RwLock};
13use tokio_util::sync::CancellationToken;
14
15pub const AUXILIARY_RUN_SCHEMA_V1: &str = "a3s.code.auxiliary-run.v1";
16pub const AUXILIARY_OUTPUT_SCHEMA_V1: &str = "a3s.code.auxiliary-output.v1";
17pub const AUXILIARY_SNAPSHOT_SCHEMA_V1: &str = "a3s.code.auxiliary-run-snapshot.v1";
18pub const AUXILIARY_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
19pub const AUXILIARY_MAX_STEPS: u32 = 1_000_000;
20const MAX_PURPOSE_BYTES: usize = 256;
21const MAX_INSTRUCTION_BYTES: usize = 128 * 1024;
22const MAX_MODEL_REF_BYTES: usize = 512;
23const MAX_SCHEMA_BYTES: usize = 128 * 1024;
24const MAX_ERROR_BYTES: usize = 16 * 1024;
25const MAX_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1000;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AuxiliaryModeV1 {
30    Detached,
31    Advisory,
32    Gate,
33}
34
35/// Explicit capability profile for an auxiliary run.  The service never
36/// grants capabilities implicitly; a host can additionally provide a parent
37/// ceiling and the requested profile must be a subset of it.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct AuxiliaryCapabilityProfileV1 {
41    pub read_workspace: bool,
42    pub write_workspace: bool,
43    pub execute_commands: bool,
44    pub network: bool,
45    pub spawn_children: bool,
46    pub max_output_bytes: usize,
47}
48
49impl AuxiliaryCapabilityProfileV1 {
50    pub const fn tool_free() -> Self {
51        Self {
52            read_workspace: false,
53            write_workspace: false,
54            execute_commands: false,
55            network: false,
56            spawn_children: false,
57            max_output_bytes: 64 * 1024,
58        }
59    }
60
61    pub const fn read_only(max_output_bytes: usize) -> Self {
62        Self {
63            read_workspace: true,
64            write_workspace: false,
65            execute_commands: false,
66            network: false,
67            spawn_children: false,
68            max_output_bytes,
69        }
70    }
71
72    pub fn validate(&self) -> Result<(), AuxiliaryRunError> {
73        if self.max_output_bytes == 0 || self.max_output_bytes > AUXILIARY_MAX_OUTPUT_BYTES {
74            return Err(AuxiliaryRunError::InvalidField(
75                "capabilities.max_output_bytes",
76            ));
77        }
78        Ok(())
79    }
80
81    pub const fn is_within(self, ceiling: Self) -> bool {
82        (!self.read_workspace || ceiling.read_workspace)
83            && (!self.write_workspace || ceiling.write_workspace)
84            && (!self.execute_commands || ceiling.execute_commands)
85            && (!self.network || ceiling.network)
86            && (!self.spawn_children || ceiling.spawn_children)
87            && self.max_output_bytes <= ceiling.max_output_bytes
88    }
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct AuxiliaryRunSpecV1 {
94    pub schema: String,
95    pub id: String,
96    pub parent: ExecutionFrameV1,
97    pub purpose: String,
98    pub mode: AuxiliaryModeV1,
99    pub instruction: String,
100    pub evidence_digest: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub model_ref: Option<String>,
103    pub capabilities: AuxiliaryCapabilityProfileV1,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub parent_ceiling: Option<AuxiliaryCapabilityProfileV1>,
106    pub max_steps: u32,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub timeout_ms: Option<u64>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub output_schema: Option<serde_json::Value>,
111}
112
113impl AuxiliaryRunSpecV1 {
114    pub fn new(
115        parent: ExecutionFrameV1,
116        purpose: impl Into<String>,
117        instruction: impl Into<String>,
118        evidence_digest: impl Into<String>,
119    ) -> Self {
120        Self {
121            schema: AUXILIARY_RUN_SCHEMA_V1.to_string(),
122            id: format!("aux-{}", uuid::Uuid::new_v4()),
123            parent,
124            purpose: purpose.into(),
125            mode: AuxiliaryModeV1::Detached,
126            instruction: instruction.into(),
127            evidence_digest: evidence_digest.into(),
128            model_ref: None,
129            capabilities: AuxiliaryCapabilityProfileV1::tool_free(),
130            parent_ceiling: None,
131            max_steps: 1,
132            timeout_ms: None,
133            output_schema: None,
134        }
135    }
136
137    pub fn with_id(mut self, id: impl Into<String>) -> Self {
138        self.id = id.into();
139        self
140    }
141
142    pub fn with_mode(mut self, mode: AuxiliaryModeV1) -> Self {
143        self.mode = mode;
144        self
145    }
146
147    pub fn with_model_ref(mut self, model_ref: impl Into<String>) -> Self {
148        self.model_ref = Some(model_ref.into());
149        self
150    }
151
152    pub fn with_capabilities(mut self, capabilities: AuxiliaryCapabilityProfileV1) -> Self {
153        self.capabilities = capabilities;
154        self
155    }
156
157    pub fn with_parent_ceiling(mut self, ceiling: AuxiliaryCapabilityProfileV1) -> Self {
158        self.parent_ceiling = Some(ceiling);
159        self
160    }
161
162    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
163        self.timeout_ms = Some(timeout_ms);
164        self
165    }
166
167    pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
168        self.output_schema = Some(schema);
169        self
170    }
171
172    pub fn validate(&self, evidence_digest: &str) -> Result<(), AuxiliaryRunError> {
173        if self.schema != AUXILIARY_RUN_SCHEMA_V1 {
174            return Err(AuxiliaryRunError::UnsupportedSchema);
175        }
176        self.parent
177            .validate()
178            .map_err(|_| AuxiliaryRunError::InvalidField("parent"))?;
179        validate_text("id", &self.id, MAX_PURPOSE_BYTES)?;
180        validate_text("purpose", &self.purpose, MAX_PURPOSE_BYTES)?;
181        if self.instruction.is_empty()
182            || self.instruction.len() > MAX_INSTRUCTION_BYTES
183            || self.instruction.contains('\0')
184        {
185            return Err(AuxiliaryRunError::InvalidField("instruction"));
186        }
187        validate_digest(&self.evidence_digest)
188            .map_err(|_| AuxiliaryRunError::InvalidField("evidence_digest"))?;
189        if self.evidence_digest != evidence_digest {
190            return Err(AuxiliaryRunError::EvidenceMismatch);
191        }
192        if let Some(model_ref) = &self.model_ref {
193            validate_text("model_ref", model_ref, MAX_MODEL_REF_BYTES)?;
194        }
195        self.capabilities.validate()?;
196        if let Some(ceiling) = self.parent_ceiling {
197            ceiling.validate()?;
198            if !self.capabilities.is_within(ceiling) {
199                return Err(AuxiliaryRunError::CapabilityEscalation);
200            }
201        }
202        if self.max_steps == 0 || self.max_steps > AUXILIARY_MAX_STEPS {
203            return Err(AuxiliaryRunError::InvalidField("max_steps"));
204        }
205        if self
206            .timeout_ms
207            .is_some_and(|timeout| timeout == 0 || timeout > MAX_TIMEOUT_MS)
208        {
209            return Err(AuxiliaryRunError::InvalidField("timeout_ms"));
210        }
211        if let Some(schema) = &self.output_schema {
212            let bytes = serde_json::to_vec(schema)
213                .map_err(|error| AuxiliaryRunError::Serialization(error.to_string()))?;
214            if bytes.len() > MAX_SCHEMA_BYTES {
215                return Err(AuxiliaryRunError::InvalidField("output_schema"));
216            }
217            jsonschema::draft202012::options()
218                .build(schema)
219                .map_err(|_| AuxiliaryRunError::InvalidField("output_schema"))?;
220        }
221        Ok(())
222    }
223
224    pub fn digest(&self) -> Result<String, AuxiliaryRunError> {
225        self.validate(&self.evidence_digest)?;
226        digest_json("a3s.code.auxiliary-run.identity.v1", self)
227            .map_err(|error| AuxiliaryRunError::Serialization(error.to_string()))
228    }
229}
230
231#[derive(Clone)]
232pub struct AuxiliaryRunContextV1 {
233    pub spec: AuxiliaryRunSpecV1,
234    pub evidence: EvidenceSnapshotV1,
235    pub cancellation: CancellationToken,
236}
237
238impl std::fmt::Debug for AuxiliaryRunContextV1 {
239    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        formatter
241            .debug_struct("AuxiliaryRunContextV1")
242            .field("spec", &self.spec)
243            .field("evidence_digest", &self.evidence.snapshot_digest)
244            .field("cancelled", &self.cancellation.is_cancelled())
245            .finish()
246    }
247}
248
249#[async_trait]
250pub trait AuxiliaryExecutor: Send + Sync {
251    async fn execute(
252        &self,
253        context: AuxiliaryRunContextV1,
254    ) -> Result<serde_json::Value, AuxiliaryRunError>;
255}
256
257/// Adapter for the provider-neutral structured-output engine.  The request
258/// factory is host-owned so Core does not prescribe a rubric, prompt, or
259/// result vocabulary.
260pub struct StructuredAuxiliaryExecutor {
261    client: Arc<dyn crate::llm::LlmClient>,
262    request_factory: Arc<
263        dyn Fn(&AuxiliaryRunContextV1) -> crate::llm::structured::StructuredRequest + Send + Sync,
264    >,
265}
266
267impl std::fmt::Debug for StructuredAuxiliaryExecutor {
268    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        formatter
270            .debug_struct("StructuredAuxiliaryExecutor")
271            .finish_non_exhaustive()
272    }
273}
274
275impl StructuredAuxiliaryExecutor {
276    pub fn new<F>(client: Arc<dyn crate::llm::LlmClient>, request_factory: F) -> Self
277    where
278        F: Fn(&AuxiliaryRunContextV1) -> crate::llm::structured::StructuredRequest
279            + Send
280            + Sync
281            + 'static,
282    {
283        Self {
284            client,
285            request_factory: Arc::new(request_factory),
286        }
287    }
288}
289
290#[async_trait]
291impl AuxiliaryExecutor for StructuredAuxiliaryExecutor {
292    async fn execute(
293        &self,
294        context: AuxiliaryRunContextV1,
295    ) -> Result<serde_json::Value, AuxiliaryRunError> {
296        if context.cancellation.is_cancelled() {
297            return Err(AuxiliaryRunError::Cancelled);
298        }
299        let request = (self.request_factory)(&context);
300        let result = crate::llm::structured::generate_blocking_with_cancellation(
301            self.client.as_ref(),
302            &request,
303            context.cancellation.clone(),
304        )
305        .await
306        .map_err(|error| AuxiliaryRunError::Executor(error.to_string()))?;
307        if context.cancellation.is_cancelled() {
308            return Err(AuxiliaryRunError::Cancelled);
309        }
310        Ok(result.object)
311    }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum AuxiliaryRunStateV1 {
317    Queued,
318    Running,
319    Completed,
320    Failed,
321    Cancelled,
322    TimedOut,
323}
324
325impl AuxiliaryRunStateV1 {
326    pub const fn is_terminal(self) -> bool {
327        matches!(
328            self,
329            Self::Completed | Self::Failed | Self::Cancelled | Self::TimedOut
330        )
331    }
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct AuxiliaryRunOutputV1 {
337    pub schema: String,
338    pub value: serde_json::Value,
339    pub output_bytes: u64,
340    pub output_digest: String,
341}
342
343impl AuxiliaryRunOutputV1 {
344    fn from_value(value: serde_json::Value) -> Result<Self, AuxiliaryRunError> {
345        let encoded = serde_json::to_vec(&value)
346            .map_err(|error| AuxiliaryRunError::Serialization(error.to_string()))?;
347        Ok(Self {
348            schema: AUXILIARY_OUTPUT_SCHEMA_V1.to_string(),
349            output_bytes: u64::try_from(encoded.len())
350                .map_err(|_| AuxiliaryRunError::NumericOverflow)?,
351            output_digest: digest_bytes("a3s.code.auxiliary-output.value.v1", &encoded),
352            value,
353        })
354    }
355
356    /// Validate a serialized output at a host/process boundary.
357    pub fn validate(
358        &self,
359        max_bytes: usize,
360        schema: Option<&serde_json::Value>,
361    ) -> Result<(), AuxiliaryRunError> {
362        if self.schema != AUXILIARY_OUTPUT_SCHEMA_V1 {
363            return Err(AuxiliaryRunError::UnsupportedSchema);
364        }
365        let encoded = serde_json::to_vec(&self.value)
366            .map_err(|error| AuxiliaryRunError::Serialization(error.to_string()))?;
367        let encoded_bytes =
368            u64::try_from(encoded.len()).map_err(|_| AuxiliaryRunError::NumericOverflow)?;
369        if encoded.len() > max_bytes || self.output_bytes != encoded_bytes {
370            return Err(AuxiliaryRunError::OutputLimit);
371        }
372        validate_digest(&self.output_digest)
373            .map_err(|_| AuxiliaryRunError::InvalidField("output_digest"))?;
374        if self.output_digest != digest_bytes("a3s.code.auxiliary-output.value.v1", &encoded) {
375            return Err(AuxiliaryRunError::DigestMismatch("output_digest"));
376        }
377        if let Some(schema) = schema {
378            let validator = jsonschema::draft202012::options()
379                .build(schema)
380                .map_err(|_| AuxiliaryRunError::InvalidField("output_schema"))?;
381            if validator.iter_errors(&self.value).next().is_some() {
382                return Err(AuxiliaryRunError::OutputSchemaMismatch);
383            }
384        }
385        Ok(())
386    }
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct AuxiliaryRunSnapshotV1 {
392    pub schema: String,
393    pub id: String,
394    pub parent: ExecutionFrameV1,
395    pub mode: AuxiliaryModeV1,
396    pub state: AuxiliaryRunStateV1,
397    pub spec_digest: String,
398    pub created_at_ms: u64,
399    pub updated_at_ms: u64,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub output_digest: Option<String>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub error: Option<String>,
404}
405
406impl AuxiliaryRunSnapshotV1 {
407    pub fn validate(&self) -> Result<(), AuxiliaryRunError> {
408        if self.schema != AUXILIARY_SNAPSHOT_SCHEMA_V1 {
409            return Err(AuxiliaryRunError::UnsupportedSchema);
410        }
411        validate_text("id", &self.id, MAX_PURPOSE_BYTES)?;
412        self.parent
413            .validate()
414            .map_err(|_| AuxiliaryRunError::InvalidField("parent"))?;
415        validate_digest(&self.spec_digest)
416            .map_err(|_| AuxiliaryRunError::InvalidField("spec_digest"))?;
417        if self.output_digest.is_some() {
418            validate_digest(
419                self.output_digest
420                    .as_deref()
421                    .ok_or(AuxiliaryRunError::InvalidField("output_digest"))?,
422            )
423            .map_err(|_| AuxiliaryRunError::InvalidField("output_digest"))?;
424        }
425        if self
426            .error
427            .as_ref()
428            .is_some_and(|error| error.len() > MAX_ERROR_BYTES)
429        {
430            return Err(AuxiliaryRunError::InvalidField("error"));
431        }
432        if self.updated_at_ms < self.created_at_ms {
433            return Err(AuxiliaryRunError::InvalidField("updated_at_ms"));
434        }
435        match self.state {
436            AuxiliaryRunStateV1::Completed
437                if self.output_digest.is_none() || self.error.is_some() =>
438            {
439                return Err(AuxiliaryRunError::InvalidField("state"));
440            }
441            AuxiliaryRunStateV1::Queued | AuxiliaryRunStateV1::Running
442                if self.output_digest.is_some() || self.error.is_some() =>
443            {
444                return Err(AuxiliaryRunError::InvalidField("state"));
445            }
446            AuxiliaryRunStateV1::Failed
447            | AuxiliaryRunStateV1::Cancelled
448            | AuxiliaryRunStateV1::TimedOut
449                if self.output_digest.is_some() || self.error.is_none() =>
450            {
451                return Err(AuxiliaryRunError::InvalidField("state"));
452            }
453            _ => {}
454        }
455        Ok(())
456    }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Error)]
460pub enum AuxiliaryRunError {
461    #[error("auxiliary run schema is unsupported")]
462    UnsupportedSchema,
463    #[error("auxiliary run field `{0}` is invalid")]
464    InvalidField(&'static str),
465    #[error("auxiliary run evidence does not match the spec")]
466    EvidenceMismatch,
467    #[error("auxiliary run parent target does not match its evidence target")]
468    TargetMismatch,
469    #[error("auxiliary run would exceed its parent capability ceiling")]
470    CapabilityEscalation,
471    #[error("auxiliary run output exceeds its bounded contract")]
472    OutputLimit,
473    #[error("auxiliary run output does not match its schema")]
474    OutputSchemaMismatch,
475    #[error("auxiliary run digest for `{0}` does not match")]
476    DigestMismatch(&'static str),
477    #[error("auxiliary run is already registered with a different spec")]
478    Conflict,
479    #[error("auxiliary run was not found")]
480    NotFound,
481    #[error("auxiliary run was cancelled")]
482    Cancelled,
483    #[error("auxiliary run timed out")]
484    TimedOut,
485    #[error("auxiliary run executor failed: {0}")]
486    Executor(String),
487    #[error("auxiliary run numeric value does not fit the wire type")]
488    NumericOverflow,
489    #[error("auxiliary run serialization failed: {0}")]
490    Serialization(String),
491}
492
493#[derive(Debug)]
494struct AuxiliaryEntry {
495    spec: AuxiliaryRunSpecV1,
496    evidence: EvidenceSnapshotV1,
497    snapshot: Mutex<AuxiliaryRunSnapshotV1>,
498    output: Mutex<Option<Result<AuxiliaryRunOutputV1, AuxiliaryRunError>>>,
499    notify: Notify,
500    cancellation: CancellationToken,
501}
502
503/// A cloneable capability to observe or cancel exactly one auxiliary run.
504#[derive(Clone)]
505pub struct AuxiliaryRunHandle {
506    entry: Arc<AuxiliaryEntry>,
507}
508
509impl std::fmt::Debug for AuxiliaryRunHandle {
510    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511        formatter
512            .debug_struct("AuxiliaryRunHandle")
513            .field("id", &self.entry.spec.id)
514            .finish()
515    }
516}
517
518impl AuxiliaryRunHandle {
519    pub fn id(&self) -> &str {
520        &self.entry.spec.id
521    }
522
523    pub async fn snapshot(&self) -> AuxiliaryRunSnapshotV1 {
524        self.entry.snapshot.lock().await.clone()
525    }
526
527    pub async fn cancel(&self) -> bool {
528        let snapshot = self.entry.snapshot.lock().await;
529        if snapshot.state.is_terminal() {
530            return false;
531        }
532        drop(snapshot);
533        self.entry.cancellation.cancel();
534        true
535    }
536
537    pub async fn wait(&self) -> Result<AuxiliaryRunOutputV1, AuxiliaryRunError> {
538        loop {
539            // Enable the notification before checking the output. Merely
540            // constructing `Notified` does not register a waiter; `enable`
541            // does. Without it, `notify_waiters` can run between the check
542            // and the await and leave a waiter asleep forever.
543            let notified = self.entry.notify.notified();
544            tokio::pin!(notified);
545            notified.as_mut().enable();
546            if let Some(result) = self.entry.output.lock().await.clone() {
547                // The worker publishes the terminal snapshot before the
548                // output. Keep `wait` linearizable for callers that observe
549                // both handles independently.
550                if self.entry.snapshot.lock().await.state.is_terminal() {
551                    return result;
552                }
553            }
554            notified.await;
555        }
556    }
557}
558
559#[async_trait]
560pub trait AuxiliaryRunService: Send + Sync {
561    async fn spawn(
562        &self,
563        spec: AuxiliaryRunSpecV1,
564        evidence: EvidenceSnapshotV1,
565        parent_cancellation: Option<CancellationToken>,
566    ) -> Result<AuxiliaryRunHandle, AuxiliaryRunError>;
567    async fn get(&self, id: &str) -> Option<AuxiliaryRunSnapshotV1>;
568
569    async fn list(&self) -> Vec<AuxiliaryRunSnapshotV1> {
570        Vec::new()
571    }
572
573    async fn cancel(&self, _id: &str) -> bool {
574        false
575    }
576}
577
578#[derive(Clone)]
579pub struct InMemoryAuxiliaryRunService {
580    entries: Arc<RwLock<HashMap<String, Arc<AuxiliaryEntry>>>>,
581    order: Arc<RwLock<VecDeque<String>>>,
582    executor: Arc<dyn AuxiliaryExecutor>,
583    max_terminal_runs: Option<usize>,
584}
585
586impl std::fmt::Debug for InMemoryAuxiliaryRunService {
587    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588        formatter
589            .debug_struct("InMemoryAuxiliaryRunService")
590            .field(
591                "entry_count",
592                &self.entries.try_read().map(|entries| entries.len()).ok(),
593            )
594            .field("max_terminal_runs", &self.max_terminal_runs)
595            .finish()
596    }
597}
598
599impl InMemoryAuxiliaryRunService {
600    pub fn new(executor: Arc<dyn AuxiliaryExecutor>) -> Self {
601        Self::with_max_terminal_runs(executor, None)
602    }
603
604    pub fn with_max_terminal_runs(
605        executor: Arc<dyn AuxiliaryExecutor>,
606        max_terminal_runs: Option<usize>,
607    ) -> Self {
608        Self {
609            entries: Arc::new(RwLock::new(HashMap::new())),
610            order: Arc::new(RwLock::new(VecDeque::new())),
611            executor,
612            max_terminal_runs,
613        }
614    }
615
616    async fn prune_terminal(&self) {
617        let Some(limit) = self.max_terminal_runs else {
618            return;
619        };
620        loop {
621            let id = {
622                let order = self.order.read().await;
623                if order.len() <= limit {
624                    return;
625                }
626                order.front().cloned()
627            };
628            let Some(id) = id else {
629                return;
630            };
631            let terminal = self
632                .entries
633                .read()
634                .await
635                .get(&id)
636                .cloned()
637                .map(|entry| async move { entry.snapshot.lock().await.state.is_terminal() });
638            let Some(terminal) = terminal else {
639                let mut order = self.order.write().await;
640                order.pop_front();
641                continue;
642            };
643            if !terminal.await {
644                return;
645            }
646            {
647                let mut order = self.order.write().await;
648                if order.front().is_some_and(|front| front == &id) {
649                    order.pop_front();
650                }
651            }
652            self.entries.write().await.remove(&id);
653        }
654    }
655
656    async fn run_entry(self, entry: Arc<AuxiliaryEntry>) {
657        {
658            let mut snapshot = entry.snapshot.lock().await;
659            if snapshot.state == AuxiliaryRunStateV1::Queued {
660                snapshot.state = AuxiliaryRunStateV1::Running;
661                snapshot.updated_at_ms = now_ms().max(snapshot.updated_at_ms);
662            }
663        }
664        let context = AuxiliaryRunContextV1 {
665            spec: entry.spec.clone(),
666            evidence: entry.evidence.clone(),
667            cancellation: entry.cancellation.clone(),
668        };
669        let execution = self.execute_with_deadline(context, &entry);
670        let result = execution.await;
671        let (state, stored_result, error_text) = match result {
672            Ok(output) => (AuxiliaryRunStateV1::Completed, Ok(output), None),
673            Err(error @ AuxiliaryRunError::Cancelled) => (
674                AuxiliaryRunStateV1::Cancelled,
675                Err(error.clone()),
676                Some(bound_error(&error.to_string())),
677            ),
678            Err(error @ AuxiliaryRunError::TimedOut) => (
679                AuxiliaryRunStateV1::TimedOut,
680                Err(error.clone()),
681                Some(bound_error(&error.to_string())),
682            ),
683            Err(error) => (
684                AuxiliaryRunStateV1::Failed,
685                Err(error.clone()),
686                Some(bound_error(&error.to_string())),
687            ),
688        };
689        let output_digest = match &stored_result {
690            Ok(output) => Some(output.output_digest.clone()),
691            Err(_) => None,
692        };
693        {
694            let mut snapshot = entry.snapshot.lock().await;
695            snapshot.state = state;
696            snapshot.updated_at_ms = now_ms().max(snapshot.updated_at_ms);
697            snapshot.error = error_text;
698            snapshot.output_digest = output_digest;
699        }
700        {
701            let mut output = entry.output.lock().await;
702            *output = Some(stored_result);
703        }
704        entry.notify.notify_waiters();
705        self.order.write().await.push_back(entry.spec.id.clone());
706        self.prune_terminal().await;
707    }
708
709    async fn execute_with_deadline(
710        &self,
711        context: AuxiliaryRunContextV1,
712        entry: &AuxiliaryEntry,
713    ) -> Result<AuxiliaryRunOutputV1, AuxiliaryRunError> {
714        let cancellation = entry.cancellation.clone();
715        let executor = Arc::clone(&self.executor);
716        let run = async move {
717            tokio::select! {
718                result = executor.execute(context) => result,
719                _ = cancellation.cancelled() => Err(AuxiliaryRunError::Cancelled),
720            }
721        };
722        let run = std::panic::AssertUnwindSafe(run).catch_unwind();
723        let value = if let Some(timeout_ms) = entry.spec.timeout_ms {
724            tokio::select! {
725                result = tokio::time::timeout(Duration::from_millis(timeout_ms), run) => {
726                    result
727                        .map_err(|_| AuxiliaryRunError::TimedOut)?
728                        .map_err(|_| AuxiliaryRunError::Executor("executor panicked".to_string()))?
729                }
730                _ = entry.cancellation.cancelled() => return Err(AuxiliaryRunError::Cancelled),
731            }
732        } else {
733            run.await
734                .map_err(|_| AuxiliaryRunError::Executor("executor panicked".to_string()))?
735        }?;
736        let output = AuxiliaryRunOutputV1::from_value(value)?;
737        output.validate(
738            entry.spec.capabilities.max_output_bytes,
739            entry.spec.output_schema.as_ref(),
740        )?;
741        Ok(output)
742    }
743}
744
745#[async_trait]
746impl AuxiliaryRunService for InMemoryAuxiliaryRunService {
747    async fn spawn(
748        &self,
749        spec: AuxiliaryRunSpecV1,
750        evidence: EvidenceSnapshotV1,
751        parent_cancellation: Option<CancellationToken>,
752    ) -> Result<AuxiliaryRunHandle, AuxiliaryRunError> {
753        evidence
754            .validate()
755            .map_err(|_| AuxiliaryRunError::EvidenceMismatch)?;
756        if spec.parent.target != evidence.target {
757            return Err(AuxiliaryRunError::TargetMismatch);
758        }
759        spec.validate(&evidence.snapshot_digest)?;
760        let spec_digest = spec.digest()?;
761        let id = spec.id.clone();
762        if let Some(existing) = self.entries.read().await.get(&id) {
763            if existing.spec == spec
764                && existing.evidence.snapshot_digest == evidence.snapshot_digest
765            {
766                return Ok(AuxiliaryRunHandle {
767                    entry: Arc::clone(existing),
768                });
769            }
770            return Err(AuxiliaryRunError::Conflict);
771        }
772        let now = now_ms();
773        let cancellation = parent_cancellation
774            .map(|parent| parent.child_token())
775            .unwrap_or_default();
776        let entry = Arc::new(AuxiliaryEntry {
777            spec: spec.clone(),
778            evidence,
779            snapshot: Mutex::new(AuxiliaryRunSnapshotV1 {
780                schema: AUXILIARY_SNAPSHOT_SCHEMA_V1.to_string(),
781                id: id.clone(),
782                parent: spec.parent.clone(),
783                mode: spec.mode,
784                state: AuxiliaryRunStateV1::Queued,
785                spec_digest,
786                created_at_ms: now,
787                updated_at_ms: now,
788                output_digest: None,
789                error: None,
790            }),
791            output: Mutex::new(None),
792            notify: Notify::new(),
793            cancellation,
794        });
795        let mut entries = self.entries.write().await;
796        if let Some(existing) = entries.get(&id) {
797            if existing.spec == spec {
798                return Ok(AuxiliaryRunHandle {
799                    entry: Arc::clone(existing),
800                });
801            }
802            return Err(AuxiliaryRunError::Conflict);
803        }
804        entries.insert(id, Arc::clone(&entry));
805        drop(entries);
806        let service = self.clone();
807        let task_entry = Arc::clone(&entry);
808        tokio::spawn(async move { service.run_entry(task_entry).await });
809        Ok(AuxiliaryRunHandle { entry })
810    }
811
812    async fn get(&self, id: &str) -> Option<AuxiliaryRunSnapshotV1> {
813        let entry = self.entries.read().await.get(id).cloned()?;
814        let snapshot = entry.snapshot.lock().await.clone();
815        if snapshot.validate().is_err() {
816            return None;
817        }
818        Some(snapshot)
819    }
820
821    async fn list(&self) -> Vec<AuxiliaryRunSnapshotV1> {
822        let entries = self
823            .entries
824            .read()
825            .await
826            .values()
827            .cloned()
828            .collect::<Vec<_>>();
829        let mut snapshots = Vec::with_capacity(entries.len());
830        for entry in entries {
831            snapshots.push(entry.snapshot.lock().await.clone());
832        }
833        snapshots.sort_by_key(|snapshot| snapshot.created_at_ms);
834        snapshots
835    }
836
837    async fn cancel(&self, id: &str) -> bool {
838        let Some(entry) = self.entries.read().await.get(id).cloned() else {
839            return false;
840        };
841        let handle = AuxiliaryRunHandle { entry };
842        handle.cancel().await
843    }
844}
845
846fn validate_text(
847    field: &'static str,
848    value: &str,
849    max_bytes: usize,
850) -> Result<(), AuxiliaryRunError> {
851    if value.is_empty()
852        || value.len() > max_bytes
853        || value.contains('\0')
854        || value.lines().count() != 1
855    {
856        return Err(AuxiliaryRunError::InvalidField(field));
857    }
858    Ok(())
859}
860
861fn now_ms() -> u64 {
862    std::time::SystemTime::now()
863        .duration_since(std::time::UNIX_EPOCH)
864        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
865        .unwrap_or(0)
866}
867
868fn bound_error(error: &str) -> String {
869    if error.len() <= MAX_ERROR_BYTES {
870        return error.to_string();
871    }
872    let mut end = MAX_ERROR_BYTES;
873    while !error.is_char_boundary(end) {
874        end = end.saturating_sub(1);
875    }
876    error[..end].to_string()
877}
878
879#[cfg(test)]
880mod tests;