1use 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#[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
257pub 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 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#[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 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 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;