1use std::sync::Arc;
20
21use async_trait::async_trait;
22use solti_core::{CollectionError, CoreError, SupervisorApi, WritePreconditionViolation};
23use solti_model::{
24 OutputEvent, Task, TaskFilter, TaskId, TaskManifest, TaskPage, TaskQuery, TaskRun,
25 WritePreconditions,
26};
27use tokio_stream::StreamExt;
28
29use crate::error::{ApiConflict, ApiError, ApiErrorCause};
30use crate::handler::{ApiHandler, OutputEventStream, TaskWatchEventStream};
31use crate::visibility::{manifest_is_visible, task_is_visible, workload_is_visible};
32
33pub struct SupervisorApiAdapter {
49 supervisor: Arc<SupervisorApi>,
50}
51
52impl SupervisorApiAdapter {
53 pub fn new(supervisor: Arc<SupervisorApi>) -> Self {
55 Self { supervisor }
56 }
57
58 fn event_is_from_generation(event: &OutputEvent, generation: u64) -> bool {
60 match event {
61 OutputEvent::Chunk(chunk) => chunk.generation == generation,
62 OutputEvent::RunStarted {
63 generation: event_generation,
64 ..
65 }
66 | OutputEvent::RunFinished {
67 generation: event_generation,
68 ..
69 } => *event_generation == generation,
70 OutputEvent::Lagged { .. } => true,
71 _ => false,
72 }
73 }
74}
75
76#[async_trait]
77impl ApiHandler for SupervisorApiAdapter {
78 async fn create_task(&self, manifest: TaskManifest) -> Result<Task, ApiError> {
79 if !manifest_is_visible(&manifest) {
80 return Err(ApiError::InvalidRequest(
81 "workload has no public wire representation".into(),
82 ));
83 }
84 self.supervisor
85 .create_task(manifest)
86 .await
87 .map_err(map_core_error)
88 }
89
90 async fn apply_task(
91 &self,
92 manifest: TaskManifest,
93 preconditions: WritePreconditions,
94 ) -> Result<Task, ApiError> {
95 if !manifest_is_visible(&manifest) {
96 return Err(ApiError::InvalidRequest(
97 "workload has no public wire representation".into(),
98 ));
99 }
100 self.supervisor
101 .apply_task_where(manifest, preconditions, task_is_visible)
102 .await
103 .map_err(map_core_error)
104 }
105
106 async fn get_task(&self, name: &TaskId) -> Result<Option<Task>, ApiError> {
107 Ok(self.supervisor.get_task(name).filter(task_is_visible))
108 }
109
110 async fn query_tasks(&self, query: TaskQuery) -> Result<TaskPage<Task>, ApiError> {
111 self.supervisor
112 .query_tasks_where(&query, task_is_visible)
113 .map_err(map_collection_error)
114 }
115
116 async fn watch_tasks(
117 &self,
118 filter: TaskFilter,
119 resource_version: Option<String>,
120 ) -> Result<TaskWatchEventStream, ApiError> {
121 let stream = self
122 .supervisor
123 .watch_tasks_where(&filter, resource_version.as_deref(), task_is_visible)
124 .map_err(map_collection_error)?;
125 Ok(Box::pin(
126 stream.map(|event| event.map_err(map_collection_error)),
127 ))
128 }
129
130 async fn list_task_runs(&self, id: &TaskId) -> Result<Vec<TaskRun>, ApiError> {
131 self.supervisor
132 .list_task_runs_where(id, workload_is_visible)
133 .await
134 .ok_or_else(|| ApiError::TaskNotFound(id.to_string()))
135 }
136
137 async fn delete_task(
138 &self,
139 id: &TaskId,
140 preconditions: WritePreconditions,
141 ) -> Result<(), ApiError> {
142 self.supervisor
143 .delete_task_where(id, preconditions, task_is_visible)
144 .await
145 .map_err(map_core_error)
146 }
147
148 async fn stream_task_logs(&self, id: &TaskId) -> Result<OutputEventStream, ApiError> {
149 let (generation, stream) = self
150 .supervisor
151 .subscribe_output_where(id, task_is_visible)
152 .await
153 .ok_or_else(|| ApiError::TaskNotFound(id.to_string()))?;
154 Ok(Box::pin(stream.filter(move |event| {
155 Self::event_is_from_generation(event, generation)
156 })))
157 }
158}
159
160fn map_core_error(error: CoreError) -> ApiError {
161 match error {
162 CoreError::InvalidSpec(inner) => ApiError::InvalidRequest(inner.to_string()),
163 CoreError::AlreadyExists(message) => ApiError::AlreadyExists(message),
164 CoreError::NotFound(message) => ApiError::TaskNotFound(message),
165 CoreError::Conflict(conflict) => {
166 let causes = conflict
167 .violations()
168 .iter()
169 .map(|violation| match violation {
170 WritePreconditionViolation::Uid { .. } => {
171 ApiErrorCause::new("UIDMismatch", violation.to_string())
172 .with_field("preconditions.uid")
173 }
174 WritePreconditionViolation::ResourceVersion { .. } => {
175 ApiErrorCause::new("ResourceVersionMismatch", violation.to_string())
176 .with_field("preconditions.resourceVersion")
177 }
178 _ => ApiErrorCause::new("PreconditionFailed", violation.to_string()),
179 })
180 .collect();
181 ApiError::Conflict(ApiConflict::new(conflict.name().to_string(), causes))
182 }
183 CoreError::ShuttingDown => ApiError::Unavailable("supervisor is shutting down".into()),
184 other => ApiError::Internal(other.to_string()),
185 }
186}
187
188fn map_collection_error(error: CollectionError) -> ApiError {
189 match &error {
190 CollectionError::InvalidResourceVersion { .. }
191 | CollectionError::ContinuationFilterMismatch
192 | CollectionError::ContinuationCursorNotFound { .. } => {
193 ApiError::InvalidRequest(error.to_string())
194 }
195 CollectionError::ResourceVersionExpired { .. } => {
196 ApiError::ResourceVersionExpired(error.to_string())
197 }
198 _ => ApiError::Internal(error.to_string()),
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 use std::time::UNIX_EPOCH;
207
208 use bytes::Bytes;
209 use solti_model::{
210 EmbeddedSpec, ExtensionWorkload, OutputChunk, StreamKind, TaskSpec, TaskWorkload,
211 WORKLOAD_API_VERSION, WorkloadTypeMeta,
212 };
213 use solti_runner::{BuildContext, RunId, Runner, RunnerError, RunnerRouter};
214 use taskvisor::{TaskContext, TaskError, TaskFn, TaskRef};
215
216 struct TestRunner;
217
218 impl Runner for TestRunner {
219 fn name(&self) -> &str {
220 "adapter-test"
221 }
222
223 fn workload_types(&self) -> Vec<WorkloadTypeMeta> {
224 vec![
225 WorkloadTypeMeta::new(WORKLOAD_API_VERSION, "Subprocess")
226 .expect("built-in workload GVK"),
227 WorkloadTypeMeta::new("workloads.example.io/v1", "ExampleJob")
228 .expect("extension workload GVK"),
229 ]
230 }
231
232 fn build_task(
233 &self,
234 _task: &Task,
235 run_id: &RunId,
236 _context: &BuildContext,
237 ) -> Result<TaskRef, RunnerError> {
238 Ok(TaskFn::arc(run_id.name(), |_ctx: TaskContext| async move {
239 Ok::<(), TaskError>(())
240 }))
241 }
242 }
243
244 async fn supervisor() -> SupervisorApi {
245 let mut router = RunnerRouter::new();
246 router.register(Arc::new(TestRunner)).unwrap();
247 SupervisorApi::builder(router)
248 .start()
249 .await
250 .expect("SupervisorApiBuilder::start")
251 }
252
253 #[tokio::test]
254 async fn get_task_hides_embedded_tasks() {
255 let api = supervisor().await;
256
257 let task: TaskRef = TaskFn::arc("embedded-probe", |_ctx: TaskContext| async move {
261 Ok::<(), TaskError>(())
262 });
263 let spec = TaskSpec::builder(
264 "slot-embedded",
265 TaskWorkload::Embedded(EmbeddedSpec::new("adapter-test-v1").unwrap()),
266 5_000_u64,
267 )
268 .build()
269 .expect("spec builds");
270 let manifest = TaskManifest::new("embedded-probe", spec).expect("manifest builds");
271 let created = api
272 .create_embedded_task(manifest, task)
273 .await
274 .expect("create_embedded_task");
275 let name = created.name().clone();
276
277 assert!(
278 api.get_task(&name).is_some(),
279 "supervisor state must still hold the embedded task"
280 );
281
282 let adapter = SupervisorApiAdapter::new(Arc::new(api));
283 let visible = adapter
284 .get_task(&name)
285 .await
286 .expect("get_task must not fail");
287 assert!(
288 visible.is_none(),
289 "embedded tasks must be reported as absent over the API"
290 );
291 }
292
293 #[tokio::test]
294 async fn embedded_tasks_are_absent_for_all_per_id_operations() {
295 let api = supervisor().await;
296
297 let task: TaskRef = TaskFn::arc("embedded-guard", |_ctx: TaskContext| async move {
298 Ok::<(), TaskError>(())
299 });
300 let spec = solti_model::TaskSpec::builder(
301 "slot-embedded-guard",
302 TaskWorkload::Embedded(EmbeddedSpec::new("adapter-test-v1").unwrap()),
303 5_000_u64,
304 )
305 .build()
306 .expect("spec builds");
307 let manifest = TaskManifest::new("embedded-guard", spec).expect("manifest builds");
308 let created = api
309 .create_embedded_task(manifest, task)
310 .await
311 .expect("create_embedded_task");
312 let name = created.name().clone();
313
314 let adapter = SupervisorApiAdapter::new(Arc::new(api));
315
316 let runs = adapter.list_task_runs(&name).await;
317 assert!(
318 matches!(runs, Err(ApiError::TaskNotFound(_))),
319 "embedded run history must look like an unknown id, got {runs:?}"
320 );
321
322 let deleted = adapter.delete_task(&name, WritePreconditions::new()).await;
323 assert!(
324 matches!(deleted, Err(ApiError::TaskNotFound(_))),
325 "deleting an embedded task must look like an unknown id, got {deleted:?}"
326 );
327 assert!(
328 adapter.supervisor.get_task(&name).is_some(),
329 "the embedded task must survive the delete attempt"
330 );
331
332 let stream = adapter.stream_task_logs(&name).await;
333 assert!(
334 matches!(stream, Err(ApiError::TaskNotFound(_))),
335 "embedded log streams must look like an unknown id"
336 );
337 }
338
339 #[tokio::test]
340 async fn get_task_unknown_name_is_none() {
341 let adapter = SupervisorApiAdapter::new(Arc::new(supervisor().await));
342 let visible = adapter
343 .get_task(&TaskId::new("no-such-task").unwrap())
344 .await
345 .expect("get_task must not fail");
346 assert!(visible.is_none());
347 }
348
349 #[tokio::test]
350 async fn query_filters_sdk_only_workloads_before_pagination() {
351 use solti_model::{
352 Flag, LabelSelector, Labels, SubprocessMode, SubprocessSpec, TaskEnv, TaskQuery,
353 };
354
355 let api = supervisor().await;
356 let hidden_ref: TaskRef = TaskFn::arc("hidden-runtime", |_ctx: TaskContext| async move {
357 Ok::<(), TaskError>(())
358 });
359 let hidden_spec = solti_model::TaskSpec::builder(
360 "hidden-slot",
361 TaskWorkload::Embedded(EmbeddedSpec::new("adapter-test-v1").unwrap()),
362 5_000_u64,
363 )
364 .build()
365 .unwrap();
366 let mut hidden_labels = Labels::new();
367 hidden_labels.insert("environment", "production");
368 api.create_embedded_task(
369 TaskManifest::new("aaa-hidden", hidden_spec)
370 .unwrap()
371 .with_labels(hidden_labels)
372 .unwrap(),
373 hidden_ref,
374 )
375 .await
376 .unwrap();
377
378 let visible_workload = TaskWorkload::Subprocess(SubprocessSpec::new(
379 SubprocessMode::Command {
380 command: "true".into(),
381 args: Vec::new(),
382 },
383 TaskEnv::new(),
384 None,
385 Flag::from(true),
386 ));
387 let visible_spec =
388 solti_model::TaskSpec::builder("visible-slot", visible_workload, 5_000_u64)
389 .build()
390 .unwrap();
391 let mut visible_labels = Labels::new();
392 visible_labels.insert("environment", "production");
393 api.create_task(
394 TaskManifest::new("zzz-visible", visible_spec)
395 .unwrap()
396 .with_labels(visible_labels)
397 .unwrap(),
398 )
399 .await
400 .unwrap();
401
402 let adapter = SupervisorApiAdapter::new(Arc::new(api));
403 let selector: LabelSelector = "environment=production".parse().unwrap();
404 let page = adapter
405 .query_tasks(
406 TaskQuery::new()
407 .with_label_selector(selector)
408 .unwrap()
409 .with_limit(1),
410 )
411 .await
412 .unwrap();
413
414 assert_eq!(page.items.len(), 1);
415 assert_eq!(page.items[0].name().as_str(), "zzz-visible");
416 assert!(page.continuation.is_none());
417 assert_eq!(page.remaining_item_count, 0);
418 }
419
420 #[tokio::test]
421 async fn apply_cannot_replace_an_embedded_task_through_the_public_api() {
422 use solti_model::{Flag, SubprocessMode, SubprocessSpec, TaskEnv};
423
424 let api = supervisor().await;
425 let hidden_ref: TaskRef = TaskFn::arc("hidden-runtime", |_ctx: TaskContext| async move {
426 Ok::<(), TaskError>(())
427 });
428 let hidden_spec = TaskSpec::builder(
429 "hidden-slot",
430 TaskWorkload::Embedded(EmbeddedSpec::new("adapter-test-v1").unwrap()),
431 5_000_u64,
432 )
433 .build()
434 .unwrap();
435 api.create_embedded_task(
436 TaskManifest::new("hidden-apply-target", hidden_spec).unwrap(),
437 hidden_ref,
438 )
439 .await
440 .unwrap();
441
442 let visible_spec = TaskSpec::builder(
443 "visible-slot",
444 TaskWorkload::Subprocess(SubprocessSpec::new(
445 SubprocessMode::Command {
446 command: "true".into(),
447 args: Vec::new(),
448 },
449 TaskEnv::new(),
450 None,
451 Flag::from(true),
452 )),
453 5_000_u64,
454 )
455 .build()
456 .unwrap();
457 let adapter = SupervisorApiAdapter::new(Arc::new(api));
458 let result = adapter
459 .apply_task(
460 TaskManifest::new("hidden-apply-target", visible_spec).unwrap(),
461 WritePreconditions::new(),
462 )
463 .await;
464
465 assert!(matches!(result, Err(ApiError::TaskNotFound(_))));
466 let stored = adapter
467 .supervisor
468 .get_task(&TaskId::new("hidden-apply-target").unwrap())
469 .expect("embedded task remains stored");
470 assert!(matches!(
471 stored.spec().workload(),
472 TaskWorkload::Embedded(_)
473 ));
474 }
475
476 #[tokio::test]
477 async fn extension_workloads_are_public_and_routable() {
478 let api = supervisor().await;
479 let adapter = SupervisorApiAdapter::new(Arc::new(api));
480 let workload = TaskWorkload::Extension(
481 ExtensionWorkload::new(
482 "workloads.example.io/v1",
483 "ExampleJob",
484 serde_json::json!({"value": 9_007_199_254_740_993_u64}),
485 )
486 .unwrap(),
487 );
488 let spec = TaskSpec::builder("extension-slot", workload, 5_000_u64)
489 .build()
490 .unwrap();
491
492 let created = adapter
493 .create_task(TaskManifest::new("extension-task", spec).unwrap())
494 .await
495 .expect("extension workload must be accepted");
496 assert_eq!(created.status().phase(), solti_model::TaskPhase::Pending);
497 assert_eq!(created.status().observed_generation(), 0);
498 let fetched = adapter
499 .get_task(created.name())
500 .await
501 .expect("get succeeds")
502 .expect("extension task remains visible");
503
504 assert!(matches!(
505 fetched.spec().workload(),
506 TaskWorkload::Extension(_)
507 ));
508 }
509
510 #[test]
511 fn core_errors_translate_to_api_owned_categories() {
512 let invalid = map_core_error(CoreError::InvalidSpec(solti_model::ModelError::Invalid(
513 "bad".into(),
514 )));
515 assert!(
516 matches!(invalid, ApiError::InvalidRequest(message) if message == "invalid model: bad")
517 );
518
519 let duplicate = map_core_error(CoreError::AlreadyExists("duplicate".into()));
520 assert!(matches!(duplicate, ApiError::AlreadyExists(message) if message == "duplicate"));
521
522 let missing = map_core_error(CoreError::NotFound("missing".into()));
523 assert!(matches!(missing, ApiError::TaskNotFound(message) if message == "missing"));
524
525 let shutting_down = map_core_error(CoreError::ShuttingDown);
526 assert!(matches!(shutting_down, ApiError::Unavailable(_)));
527
528 let internal = map_core_error(CoreError::Mapping("mapping".into()));
529 assert!(matches!(internal, ApiError::Internal(message) if message.contains("mapping")));
530 }
531
532 #[test]
533 fn collection_errors_translate_to_api_owned_categories() {
534 let invalid = map_collection_error(CollectionError::InvalidResourceVersion {
535 resource_version: "bad".into(),
536 });
537 assert!(matches!(invalid, ApiError::InvalidRequest(_)));
538
539 let mismatch = map_collection_error(CollectionError::ContinuationFilterMismatch);
540 assert!(matches!(mismatch, ApiError::InvalidRequest(_)));
541
542 let missing_cursor = map_collection_error(CollectionError::ContinuationCursorNotFound {
543 name: TaskId::new("missing").unwrap(),
544 });
545 assert!(matches!(missing_cursor, ApiError::InvalidRequest(_)));
546
547 let expired = map_collection_error(CollectionError::ResourceVersionExpired {
548 resource_version: "old:1".into(),
549 });
550 assert!(matches!(expired, ApiError::ResourceVersionExpired(_)));
551 }
552
553 #[test]
554 fn output_stream_is_pinned_to_the_opened_generation() {
555 let current = OutputEvent::Chunk(OutputChunk {
556 generation: 7,
557 attempt: 1,
558 stream: StreamKind::Stdout,
559 seq: 0,
560 ts: UNIX_EPOCH,
561 line: Bytes::from_static(b"current"),
562 });
563 let stale = OutputEvent::RunStarted {
564 generation: 6,
565 attempt: 1,
566 started_at: UNIX_EPOCH,
567 };
568 let future = OutputEvent::RunFinished {
569 generation: 8,
570 attempt: 1,
571 exit_code: Some(0),
572 finished_at: UNIX_EPOCH,
573 };
574 let lagged = OutputEvent::Lagged { skipped: 2 };
575
576 assert!(SupervisorApiAdapter::event_is_from_generation(¤t, 7));
577 assert!(!SupervisorApiAdapter::event_is_from_generation(&stale, 7));
578 assert!(!SupervisorApiAdapter::event_is_from_generation(&future, 7));
579 assert!(SupervisorApiAdapter::event_is_from_generation(&lagged, 7));
580 }
581}