Skip to main content

solti_api/
adapter.rs

1//! # Supervisor Adapter
2//!
3//! [`SupervisorApiAdapter`] connects [`solti_core::SupervisorApi`] to [`ApiHandler`].
4//!
5//! ```text
6//! wire transport
7//!      │ domain request
8//!      ▼
9//! SupervisorApiAdapter
10//!      ├── public workload guard
11//!      ├── core error mapping
12//!      └──► solti_core::SupervisorApi
13//! ```
14//!
15//! The adapter hides the built-in `Embedded` workload.
16//! It preserves extension workloads.
17//! It also pins output subscriptions to the visible task generation.
18
19use 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
33/// [`ApiHandler`] implementation backed by [`SupervisorApi`].
34///
35/// Desired writes, reads, watches, history, deletion, and output
36/// delegate to the supervisor.
37/// Core errors are converted into [`ApiError`] categories.
38///
39/// ## Visibility
40///
41/// Embedded tasks remain available through the in-process core API.
42/// They are absent through this adapter.
43///
44/// ## See Also
45///
46/// - [`ApiHandler`] defines the transport contract.
47/// - [`ApiError`] defines the mapped error categories.
48pub struct SupervisorApiAdapter {
49    supervisor: Arc<SupervisorApi>,
50}
51
52impl SupervisorApiAdapter {
53    /// Creates an adapter for a running supervisor.
54    pub fn new(supervisor: Arc<SupervisorApi>) -> Self {
55        Self { supervisor }
56    }
57
58    /// Checks whether an output event belongs to the opened generation.
59    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        // Embedded tasks enter state through the prebuilt-task SDK path; the
258        // wire path can never create them, but a point GET by name
259        // used to reach them via `state.get` and fail the proto conversion.
260        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(&current, 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}