From 312e8acce5aaa0b1d150ae0bd1605b9d6178b0f5 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sat, 26 Sep 2026 12:09:08 -0400 Subject: [PATCH 1/9] feat(dgw): add a background task API DVLS can start a background task with a TASK token (POST /jet/tasks) and poll its state with a gateway.tasks.read scope token (GET /jet/tasks/{id}). Tasks run in memory with a concurrency limit and a timeout, and report not-started, running, success or failed. The first kind, ai-log, validates its AI settings and then fails as not implemented yet. Both endpoints are behind enable_unstable. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + devolutions-gateway/Cargo.toml | 1 + devolutions-gateway/src/api/mod.rs | 2 + devolutions-gateway/src/api/tasks.rs | 183 +++++++++ devolutions-gateway/src/extract.rs | 38 +- devolutions-gateway/src/lib.rs | 3 + devolutions-gateway/src/openapi.rs | 21 ++ devolutions-gateway/src/recording.rs | 2 +- devolutions-gateway/src/service.rs | 1 + devolutions-gateway/src/tasks/ai_log.rs | 212 +++++++++++ devolutions-gateway/src/tasks/mod.rs | 471 ++++++++++++++++++++++++ devolutions-gateway/tests/tasks.rs | 320 ++++++++++++++++ 12 files changed, 1253 insertions(+), 2 deletions(-) create mode 100644 devolutions-gateway/src/api/tasks.rs create mode 100644 devolutions-gateway/src/tasks/ai_log.rs create mode 100644 devolutions-gateway/src/tasks/mod.rs create mode 100644 devolutions-gateway/tests/tasks.rs diff --git a/Cargo.lock b/Cargo.lock index cdc90721d..a7826f0f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,7 @@ dependencies = [ "cfg-if", "chacha20poly1305", "devolutions-agent-shared", + "devolutions-gateway-ai", "devolutions-gateway-generators", "devolutions-gateway-task", "devolutions-log", diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 6d68ec6e8..d1e51555a 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -24,6 +24,7 @@ transport.path = "../crates/transport" jmux-proxy.path = "../crates/jmux-proxy" devolutions-agent-shared.path = "../crates/devolutions-agent-shared" devolutions-gateway-task.path = "../crates/devolutions-gateway-task" +devolutions-gateway-ai.path = "../crates/devolutions-gateway-ai" devolutions-log.path = "../crates/devolutions-log" job-queue.path = "../crates/job-queue" job-queue-libsql.path = "../crates/job-queue-libsql" diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index c7b222ebc..a77608164 100644 --- a/devolutions-gateway/src/api/mod.rs +++ b/devolutions-gateway/src/api/mod.rs @@ -13,6 +13,7 @@ pub mod preflight; pub mod rdp; pub mod session; pub mod sessions; +pub mod tasks; pub mod traffic; pub mod tunnel; pub mod update; @@ -47,6 +48,7 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { if state.conf_handle.get_conf().debug.enable_unstable { router = router.nest("/jet/net/monitor", monitoring::make_router(state.clone())); + router = router.nest("/jet/tasks", tasks::make_router(state.clone())); } router.with_state(state) diff --git a/devolutions-gateway/src/api/tasks.rs b/devolutions-gateway/src/api/tasks.rs new file mode 100644 index 000000000..21032daf9 --- /dev/null +++ b/devolutions-gateway/src/api/tasks.rs @@ -0,0 +1,183 @@ +use axum::body::Bytes; +use axum::extract::{self, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, routing}; +use uuid::Uuid; + +use crate::DgwState; +use crate::extract::{TaskToken, TasksReadScope}; +use crate::http::HttpError; +#[cfg(feature = "openapi")] +#[expect(unused_imports, reason = "utoipa refers to the request body schema by its name only")] +use crate::tasks::ai_log::AiLogParams; +use crate::tasks::ai_log::{AiLogTarget, AiLogTask}; +use crate::tasks::{StartError, TaskSnapshot, TaskStatus}; +use crate::token::TaskKind; + +pub fn make_router(state: DgwState) -> Router { + Router::new() + .route("/", routing::post(start_task)) + .route("/{id}", routing::get(get_task)) + .with_state(state) +} + +/// Starts a background task. +/// +/// The task kind and its target come from the TASK token. +/// The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. +/// +/// This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "StartTask", + tag = "Tasks", + path = "/jet/tasks", + request_body(content = AiLogParams, description = "Kind-specific task parameters", content_type = "application/json"), + responses( + (status = 202, description = "Task was accepted and runs in the background", body = TaskInfo), + (status = 400, description = "Invalid task parameters", body = TaskErrorResponse), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 409, description = "The task target is busy, such as a session that is still recording", body = TaskErrorResponse), + (status = 500, description = "Unexpected server error"), + ), + security(("task_token" = [])), +))] +pub(crate) async fn start_task( + State(state): State, + TaskToken(claims): TaskToken, + body: Bytes, +) -> Result<(StatusCode, Json), StartTaskError> { + let snapshot = match claims.kind { + TaskKind::AiLog { jet_aid } => { + state + .tasks + .start::(AiLogTarget { session_id: jet_aid }, &body, &state)? + } + }; + + Ok((StatusCode::ACCEPTED, Json(TaskInfo::from(snapshot)))) +} + +/// Gets the status of a background task. +/// +/// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. +/// +/// This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +#[cfg_attr(feature = "openapi", utoipa::path( + get, + operation_id = "GetTask", + tag = "Tasks", + path = "/jet/tasks/{id}", + params( + ("id" = Uuid, Path, description = "Task ID"), + ), + responses( + (status = 200, description = "Task status", body = TaskInfo), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "No task with this ID"), + ), + security(("scope_token" = ["gateway.tasks.read"])), +))] +pub(crate) async fn get_task( + State(state): State, + _scope: TasksReadScope, + extract::Path(id): extract::Path, +) -> Result, HttpError> { + state + .tasks + .get(id) + .map(|snapshot| Json(TaskInfo::from(snapshot))) + .ok_or_else(|| HttpError::not_found().msg("task not found")) +} + +/// A background task and its status. +/// +/// `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. +/// Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TaskInfo { + /// Task ID. + id: Uuid, + /// Task kind, as in the `jet_tk` claim of the TASK token. + kind: String, + state: TaskState, + /// Progress of a running task. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde(skip_serializing_if = "Option::is_none")] + substate: Option, + /// Result of a successful task. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + /// Why the task failed. + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum TaskState { + /// The task waits for a free slot. + NotStarted, + Running, + Success, + Failed, +} + +impl From for TaskInfo { + fn from(snapshot: TaskSnapshot) -> Self { + let (state, substate, result, error) = match snapshot.status { + TaskStatus::NotStarted => (TaskState::NotStarted, None, None, None), + TaskStatus::Running { substate } => (TaskState::Running, Some(substate), None, None), + TaskStatus::Success { result } => (TaskState::Success, None, Some(result), None), + TaskStatus::Failed { error } => (TaskState::Failed, None, None, Some(error)), + }; + + Self { + id: snapshot.id, + kind: snapshot.kind.to_owned(), + state, + substate, + result, + error, + } + } +} + +/// Why a task was not started. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +pub(crate) struct TaskErrorResponse { + /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, + /// `invalid_ai_settings` or `recording_active`. + error: &'static str, +} + +pub(crate) struct StartTaskError(StartError); + +impl From for StartTaskError { + fn from(error: StartError) -> Self { + Self(error) + } +} + +impl IntoResponse for StartTaskError { + fn into_response(self) -> Response { + let (status, error) = match self.0 { + StartError::InvalidParams(code) => (StatusCode::BAD_REQUEST, code), + StartError::TargetBusy(code) => (StatusCode::CONFLICT, code), + StartError::Internal => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + warn!(%status, error, "Task rejected"); + + (status, Json(TaskErrorResponse { error })).into_response() + } +} diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index b22ee0461..cfb620c54 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -8,7 +8,7 @@ use crate::DgwState; use crate::http::HttpError; use crate::token::{ AccessScope, AccessTokenClaims, AssociationTokenClaims, BridgeTokenClaims, EnrollmentTokenClaims, JmuxTokenClaims, - JrecTokenClaims, JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, WebAppTokenClaims, + JrecTokenClaims, JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, TaskTokenClaims, WebAppTokenClaims, }; #[derive(Clone)] @@ -449,6 +449,24 @@ where } } +#[derive(Clone, Copy)] +pub struct TasksReadScope; + +impl FromRequestParts for TasksReadScope +where + S: Send + Sync, +{ + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match ScopeToken::from_request_parts(parts, state).await?.0.scope { + AccessScope::Wildcard => Ok(Self), + AccessScope::TasksRead => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + /// Grants read access to agent management endpoints. /// /// Accepts a scope token with `AgentRead` or `Wildcard` scope. @@ -580,6 +598,24 @@ where } } +#[derive(Clone)] +pub struct TaskToken(pub TaskTokenClaims); + +impl FromRequestParts for TaskToken +where + S: Send + Sync, +{ + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + if let AccessTokenClaims::Task(claims) = AccessToken::from_request_parts(parts, state).await?.0 { + Ok(Self(claims)) + } else { + Err(HttpError::forbidden().msg("token not allowed (expected TASK)")) + } + } +} + pub struct RepeatQuery(pub(crate) T); impl FromRequest for RepeatQuery diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index cd53900b2..0fc9df332 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -41,6 +41,7 @@ pub mod streaming; pub mod subscriber; pub mod target_addr; pub(crate) mod target_connection_options; +pub mod tasks; pub mod tls; pub mod token; pub mod traffic_audit; @@ -68,6 +69,7 @@ pub struct DgwState { pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, + pub tasks: tasks::TaskRegistry, } #[doc(hidden)] @@ -110,6 +112,7 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, + tasks: tasks::TaskRegistry::new(), }; let handles = MockHandles { diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 5052f6704..527605edf 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -42,6 +42,8 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::tunnel::list_agents, crate::api::tunnel::get_agent, crate::api::tunnel::delete_agent, + crate::api::tasks::start_task, + crate::api::tasks::get_task, ), components(schemas( crate::api::health::Identity, @@ -108,6 +110,12 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::tunnel::AgentDomainAdvertisement, crate::api::tunnel::AgentStatus, crate::api::tunnel::AgentInfo, + crate::api::tasks::TaskInfo, + crate::api::tasks::TaskState, + crate::api::tasks::TaskErrorResponse, + crate::tasks::ai_log::AiLogParams, + crate::tasks::ai_log::AiProvider, + crate::tasks::ai_log::AiLogSubstate, )), modifiers(&SecurityAddon), )] @@ -237,6 +245,19 @@ impl Modify for SecurityAddon { .build(), ), ); + + components.add_security_scheme( + "task_token", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "Token authorizing one kind of background task on a specific target".to_owned(), + )) + .build(), + ), + ); } } diff --git a/devolutions-gateway/src/recording.rs b/devolutions-gateway/src/recording.rs index 59905df5b..f57061dfa 100644 --- a/devolutions-gateway/src/recording.rs +++ b/devolutions-gateway/src/recording.rs @@ -220,7 +220,7 @@ impl ActiveRecordings { self.0.lock().clone() } - fn insert(&self, id: Uuid) -> usize { + pub(crate) fn insert(&self, id: Uuid) -> usize { let mut guard = self.0.lock(); guard.insert(id); guard.len() diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 97b733f17..a4edc4b62 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -340,6 +340,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, + tasks: devolutions_gateway::tasks::TaskRegistry::new(), }; for listener in &conf.listeners { diff --git a/devolutions-gateway/src/tasks/ai_log.rs b/devolutions-gateway/src/tasks/ai_log.rs new file mode 100644 index 000000000..1ad46058c --- /dev/null +++ b/devolutions-gateway/src/tasks/ai_log.rs @@ -0,0 +1,212 @@ +//! `ai-log` task: describes what the user did in one session and stores the result as a new log of that session. + +use devolutions_gateway_ai::{AiClient, BuildError, Provider}; +use secrecy::SecretString; +use url::Url; +use uuid::Uuid; + +use super::{BackgroundTask, Persistence, Progress, StartError}; +use crate::DgwState; + +pub struct AiLogTarget { + pub session_id: Uuid, +} + +/// AI settings used by an `ai-log` task. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AiLogParams { + pub provider: AiProvider, + /// Model identifier, passed to the provider as is. + pub model: String, + /// Required by every provider; kept in memory for this task only. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub api_key: Option, + /// Overrides the provider default; required for `openai-compatible`. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub base_url: Option, + /// Upper bound of tokens in each AI answer. + pub max_output_tokens: Option, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum AiProvider { + #[serde(rename = "openai")] + OpenAi, + #[serde(rename = "anthropic")] + Anthropic, + #[serde(rename = "mistral")] + Mistral, + #[serde(rename = "openai-compatible")] + OpenAiCompatible, +} + +impl From for Provider { + fn from(provider: AiProvider) -> Self { + match provider { + AiProvider::OpenAi => Provider::OpenAi, + AiProvider::Anthropic => Provider::Anthropic, + AiProvider::Mistral => Provider::Mistral, + AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, + } + } +} + +/// Progress of a running `ai-log` task. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "kebab-case", tag = "step")] +pub enum AiLogSubstate { + #[default] + Preparing, +} + +#[derive(Debug, Serialize)] +pub enum AiLogOutput {} + +#[derive(Debug)] +pub struct AiLogTask { + #[expect(dead_code, reason = "read by the ai-log runner, which comes in a later change")] + session_id: Uuid, + #[expect(dead_code, reason = "read by the ai-log runner, which comes in a later change")] + client: AiClient, + #[expect(dead_code, reason = "read by the ai-log runner, which comes in a later change")] + max_output_tokens: Option, +} + +impl BackgroundTask for AiLogTask { + const KIND: &'static str = "ai-log"; + const PERSISTENCE: Persistence = Persistence::InMemory; + + type Params = AiLogParams; + type Target = AiLogTarget; + type Substate = AiLogSubstate; + type Output = AiLogOutput; + + fn prepare(target: AiLogTarget, params: AiLogParams, state: &DgwState) -> Result { + if state.recordings.active_recordings.contains(target.session_id) { + return Err(StartError::TargetBusy("recording_active")); + } + + let provider = Provider::from(params.provider); + + let mut builder = AiClient::builder().provider(provider).model(params.model); + + if let Some(api_key) = params.api_key { + builder = builder.api_key(api_key); + } + + let endpoint = params.base_url.clone().or_else(|| provider.default_base_url()); + + if let Some(base_url) = params.base_url { + builder = builder.base_url(base_url); + } + + // Without an endpoint, `build` reports the missing base URL before it needs the HTTP client. + if let Some(endpoint) = endpoint { + let proxy_config = state.conf_handle.get_conf().proxy.to_proxy_config(); + + let http_client = + http_client_proxy::get_or_create_cached_client(reqwest::Client::builder(), &endpoint, &proxy_config) + .map_err(|error| { + error!(%error, "Failed to build the HTTP client for the AI provider"); + StartError::Internal + })?; + + builder = builder.http_client(http_client); + } + + let client = builder.build().map_err(|error| { + let code = build_error_code(&error); + warn!(%error, code, "Invalid AI settings"); + StartError::InvalidParams(code) + })?; + + Ok(Self { + session_id: target.session_id, + client, + max_output_tokens: params.max_output_tokens, + }) + } + + async fn run(self, _progress: Progress) -> anyhow::Result { + anyhow::bail!("ai-log task not implemented yet") + } +} + +fn build_error_code(error: &BuildError) -> &'static str { + match error { + BuildError::MissingModel => "missing_model", + BuildError::MissingApiKey(_) => "missing_api_key", + BuildError::MissingBaseUrl(_) => "missing_base_url", + _ => "invalid_ai_settings", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const API_KEY: &str = "sk-ai-log-test-secret"; + + const CONFIG: &str = r#"{ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [{ "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" }], + "Proxy": { "Mode": "Off" } + }"#; + + fn params() -> AiLogParams { + serde_json::from_value(serde_json::json!({ + "provider": "openai", + "model": "gpt-test", + "apiKey": API_KEY, + })) + .expect("valid params") + } + + #[tokio::test] + async fn refuses_a_session_that_is_still_recording() { + let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); + let session_id = Uuid::new_v4(); + state.recordings.active_recordings.insert(session_id); + + let error = AiLogTask::prepare(AiLogTarget { session_id }, params(), &state).expect_err("session is busy"); + + assert_eq!(error, StartError::TargetBusy("recording_active")); + } + + #[tokio::test] + async fn debug_never_shows_the_api_key() { + let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); + + let params = params(); + assert!(!format!("{params:?}").contains(API_KEY)); + + let task = AiLogTask::prepare( + AiLogTarget { + session_id: Uuid::new_v4(), + }, + params, + &state, + ) + .expect("valid task"); + assert!(!format!("{task:?}").contains(API_KEY)); + } + + #[test] + fn build_errors_map_to_stable_codes() { + assert_eq!(build_error_code(&BuildError::MissingModel), "missing_model"); + assert_eq!( + build_error_code(&BuildError::MissingApiKey(Provider::OpenAi)), + "missing_api_key" + ); + assert_eq!( + build_error_code(&BuildError::MissingBaseUrl(Provider::OpenAiCompatible)), + "missing_base_url" + ); + } +} diff --git a/devolutions-gateway/src/tasks/mod.rs b/devolutions-gateway/src/tasks/mod.rs new file mode 100644 index 000000000..5f56bf2ff --- /dev/null +++ b/devolutions-gateway/src/tasks/mod.rs @@ -0,0 +1,471 @@ +//! One-shot background tasks, started through `POST /jet/tasks` and polled through `GET /jet/tasks/{id}`. +//! +//! Tasks are kept in memory only, so they are lost when Gateway restarts. + +pub mod ai_log; + +use core::marker::PhantomData; +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::Semaphore; +use uuid::Uuid; + +use crate::DgwState; + +/// Number of tasks running at the same time; other tasks wait in the `NotStarted` state. +pub const MAX_CONCURRENT_TASKS: usize = 2; + +/// Longest time a task may run, not counting the time it waits for a free slot. +pub const TASK_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +/// How long the status of a finished task can still be read. +pub const FINISHED_TASK_RETENTION: Duration = Duration::from_secs(60 * 60); + +/// Where the tasks of one kind are stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Persistence { + /// Tasks are dropped when Gateway restarts. + InMemory, +} + +/// A kind of one-shot background task. +pub trait BackgroundTask: Sized + Send + 'static { + /// Value of the TASK token `jet_tk` claim. + const KIND: &'static str; + + const PERSISTENCE: Persistence; + + /// Kind-specific request body of `POST /jet/tasks`. + type Params: DeserializeOwned; + + /// What the task works on, taken from the TASK token. + type Target; + + /// Progress reported while the task is running. + type Substate: Serialize + Default + Send; + + type Output: Serialize + Send; + + /// Checks the request and builds the task, before it is registered. + fn prepare(target: Self::Target, params: Self::Params, state: &DgwState) -> Result; + + fn run(self, progress: Progress) -> impl Future> + Send; +} + +/// Reason why a task was not started. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartError { + /// The request parameters are invalid; the code is stable and safe to show. + InvalidParams(&'static str), + /// The target cannot be worked on right now; the code is stable and safe to show. + TargetBusy(&'static str), + Internal, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TaskStatus { + NotStarted, + Running { substate: serde_json::Value }, + Success { result: serde_json::Value }, + Failed { error: String }, +} + +#[derive(Debug, Clone)] +pub struct TaskSnapshot { + pub id: Uuid, + pub kind: &'static str, + pub status: TaskStatus, +} + +struct TaskEntry { + kind: &'static str, + status: TaskStatus, +} + +type TaskMap = Arc>>; + +/// Lets a running task update its substate. +pub struct Progress { + id: Uuid, + tasks: TaskMap, + _substate: PhantomData, +} + +impl Progress { + pub fn set(&self, substate: &S) { + let substate = to_json_value(substate); + + if let Some(entry) = self.tasks.lock().get_mut(&self.id) { + entry.status = TaskStatus::Running { substate }; + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Limits { + max_concurrent: usize, + timeout: Duration, + retention: Duration, +} + +/// In-memory registry of the background tasks, keyed by task ID. +#[derive(Clone)] +pub struct TaskRegistry { + tasks: TaskMap, + slots: Arc, + limits: Limits, +} + +impl Default for TaskRegistry { + fn default() -> Self { + Self::new() + } +} + +impl TaskRegistry { + pub fn new() -> Self { + Self::with_limits(Limits { + max_concurrent: MAX_CONCURRENT_TASKS, + timeout: TASK_TIMEOUT, + retention: FINISHED_TASK_RETENTION, + }) + } + + fn with_limits(limits: Limits) -> Self { + Self { + tasks: Arc::new(Mutex::new(HashMap::new())), + slots: Arc::new(Semaphore::new(limits.max_concurrent)), + limits, + } + } + + pub fn get(&self, id: Uuid) -> Option { + self.tasks.lock().get(&id).map(|entry| TaskSnapshot { + id, + kind: entry.kind, + status: entry.status.clone(), + }) + } + + /// Parses the kind-specific parameters, prepares the task and runs it in the background. + pub fn start( + &self, + target: K::Target, + params: &[u8], + state: &DgwState, + ) -> Result { + match K::PERSISTENCE { + Persistence::InMemory => {} + } + + // The serde error is not logged because it may quote the rejected value, which could be the API key. + let params = serde_json::from_slice::(params).map_err(|error| { + warn!( + task.kind = K::KIND, + category = ?error.classify(), + line = error.line(), + column = error.column(), + "Invalid task parameters" + ); + StartError::InvalidParams("invalid_params") + })?; + + let task = K::prepare(target, params, state)?; + + Ok(self.spawn(task)) + } + + fn spawn(&self, task: K) -> TaskSnapshot { + let id = Uuid::new_v4(); + + self.tasks.lock().insert( + id, + TaskEntry { + kind: K::KIND, + status: TaskStatus::NotStarted, + }, + ); + + info!(task.id = %id, task.kind = K::KIND, "Background task created"); + + let tasks = Arc::clone(&self.tasks); + let slots = Arc::clone(&self.slots); + let limits = self.limits; + + tokio::spawn(async move { + let status = match Arc::clone(&slots).acquire_owned().await { + Ok(permit) => { + let status = run_task(id, task, &tasks, limits.timeout).await; + drop(permit); + status + } + Err(_) => TaskStatus::Failed { + error: "task slots are closed".to_owned(), + }, + }; + + match &status { + TaskStatus::Failed { error } => { + warn!(task.id = %id, task.kind = K::KIND, %error, "Background task failed"); + } + _ => info!(task.id = %id, task.kind = K::KIND, "Background task succeeded"), + } + + if let Some(entry) = tasks.lock().get_mut(&id) { + entry.status = status; + } + + tokio::time::sleep(limits.retention).await; + tasks.lock().remove(&id); + }); + + TaskSnapshot { + id, + kind: K::KIND, + status: TaskStatus::NotStarted, + } + } +} + +async fn run_task(id: Uuid, task: K, tasks: &TaskMap, timeout: Duration) -> TaskStatus { + if let Some(entry) = tasks.lock().get_mut(&id) { + entry.status = TaskStatus::Running { + substate: to_json_value(&K::Substate::default()), + }; + } + + info!(task.id = %id, task.kind = K::KIND, "Background task running"); + + let progress = Progress { + id, + tasks: Arc::clone(tasks), + _substate: PhantomData, + }; + + // The task runs on its own Tokio task so a panic ends as a failure instead of a task stuck in `Running`. + let mut handle = tokio::spawn(task.run(progress)); + + match tokio::time::timeout(timeout, &mut handle).await { + Ok(Ok(Ok(output))) => TaskStatus::Success { + result: to_json_value(&output), + }, + Ok(Ok(Err(error))) => TaskStatus::Failed { + error: format!("{error:#}"), + }, + Ok(Err(_)) => TaskStatus::Failed { + error: "task panicked".to_owned(), + }, + Err(_) => { + handle.abort(); + TaskStatus::Failed { + error: "task timed out".to_owned(), + } + } + } +} + +fn to_json_value(value: &T) -> serde_json::Value { + serde_json::to_value(value).unwrap_or_else(|error| { + error!(%error, "Failed to serialize a task value"); + serde_json::Value::Null + }) +} + +#[cfg(test)] +mod tests { + use tokio::sync::oneshot; + + use super::*; + + #[derive(Default, Serialize)] + struct TestSubstate { + step: u32, + } + + struct TestTask { + started: Option>, + finish: oneshot::Receiver>, + } + + impl BackgroundTask for TestTask { + const KIND: &'static str = "test"; + const PERSISTENCE: Persistence = Persistence::InMemory; + + type Params = (); + type Target = (); + type Substate = TestSubstate; + type Output = u32; + + fn prepare(_: (), (): (), _: &DgwState) -> Result { + unreachable!("tests spawn test tasks directly") + } + + async fn run(mut self, progress: Progress) -> anyhow::Result { + progress.set(&TestSubstate { step: 1 }); + + if let Some(started) = self.started.take() { + let _ = started.send(()); + } + + self.finish.await? + } + } + + struct Controls { + started: oneshot::Receiver<()>, + finish: oneshot::Sender>, + } + + fn test_task() -> (TestTask, Controls) { + let (started_tx, started_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + + ( + TestTask { + started: Some(started_tx), + finish: finish_rx, + }, + Controls { + started: started_rx, + finish: finish_tx, + }, + ) + } + + fn registry(max_concurrent: usize, timeout: Duration) -> TaskRegistry { + TaskRegistry::with_limits(Limits { + max_concurrent, + timeout, + retention: Duration::from_secs(3600), + }) + } + + async fn wait_for_final_status(registry: &TaskRegistry, id: Uuid) -> TaskStatus { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let status = registry.get(id).expect("task is registered").status; + + if matches!(status, TaskStatus::Success { .. } | TaskStatus::Failed { .. }) { + return status; + } + + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("task finishes") + } + + #[tokio::test] + async fn status_goes_from_not_started_to_running_to_failed() { + let registry = registry(1, Duration::from_secs(60)); + let (task, controls) = test_task(); + + let snapshot = registry.spawn(task); + assert_eq!(snapshot.status, TaskStatus::NotStarted); + assert_eq!(snapshot.kind, "test"); + + controls.started.await.expect("task starts"); + assert_eq!( + registry.get(snapshot.id).expect("task").status, + TaskStatus::Running { + substate: serde_json::json!({ "step": 1 }) + } + ); + + let _ = controls.finish.send(Err(anyhow::anyhow!("boom"))); + assert_eq!( + wait_for_final_status(®istry, snapshot.id).await, + TaskStatus::Failed { + error: "boom".to_owned() + } + ); + } + + #[tokio::test] + async fn success_carries_the_result() { + let registry = registry(1, Duration::from_secs(60)); + let (task, controls) = test_task(); + + let id = registry.spawn(task).id; + let _ = controls.finish.send(Ok(42)); + + assert_eq!( + wait_for_final_status(®istry, id).await, + TaskStatus::Success { + result: serde_json::json!(42) + } + ); + } + + #[tokio::test] + async fn concurrency_limit_keeps_extra_tasks_not_started() { + let registry = registry(2, Duration::from_secs(60)); + let (first, first_controls) = test_task(); + let (second, second_controls) = test_task(); + let (third, mut third_controls) = test_task(); + + let first_id = registry.spawn(first).id; + let _second_id = registry.spawn(second).id; + let third_id = registry.spawn(third).id; + + first_controls.started.await.expect("first starts"); + second_controls.started.await.expect("second starts"); + + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(third_controls.started.try_recv().is_err()); + assert_eq!(registry.get(third_id).expect("task").status, TaskStatus::NotStarted); + + let _ = first_controls.finish.send(Ok(1)); + wait_for_final_status(®istry, first_id).await; + + third_controls.started.await.expect("third starts once a slot is free"); + } + + #[tokio::test] + async fn task_times_out() { + let registry = registry(1, Duration::from_millis(20)); + let (task, _controls) = test_task(); + + let id = registry.spawn(task).id; + + assert_eq!( + wait_for_final_status(®istry, id).await, + TaskStatus::Failed { + error: "task timed out".to_owned() + } + ); + } + + #[tokio::test] + async fn finished_task_is_removed_after_retention() { + let registry = TaskRegistry::with_limits(Limits { + max_concurrent: 1, + timeout: Duration::from_secs(60), + retention: Duration::from_millis(10), + }); + let (task, controls) = test_task(); + + let id = registry.spawn(task).id; + let _ = controls.finish.send(Ok(1)); + + tokio::time::timeout(Duration::from_secs(10), async { + while registry.get(id).is_some() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("task is removed"); + } + + #[test] + fn unknown_task_is_none() { + assert!(TaskRegistry::new().get(Uuid::new_v4()).is_none()); + } +} diff --git a/devolutions-gateway/tests/tasks.rs b/devolutions-gateway/tests/tasks.rs new file mode 100644 index 000000000..04f13b3b3 --- /dev/null +++ b/devolutions-gateway/tests/tasks.rs @@ -0,0 +1,320 @@ +#![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used)] + +use std::io; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; +use axum::body::Body; +use axum::extract::connect_info::MockConnectInfo; +use axum::http::{self, Request, StatusCode}; +use base64::Engine as _; +use devolutions_gateway::{DgwState, MockHandles}; +use http_body_util::BodyExt as _; +use serde_json::{Value, json}; +use tower::ServiceExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; +use uuid::Uuid; + +const API_KEY: &str = "sk-task-api-test-secret"; + +const CONFIG: &str = r#"{ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [ + { + "InternalUrl": "http://*:7171", + "ExternalUrl": "https://*:7171" + } + ], + "Proxy": { "Mode": "Off" }, + "__debug__": { + "disable_token_validation": true, + "enable_unstable": true + } +}"#; + +fn make_router(config: &str) -> anyhow::Result<(Router, impl Sized)> { + let (state, handles) = DgwState::mock(config)?; + let MockHandles { + session_manager_rx, + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + shutdown_handle, + } = handles; + + // The auth middleware asks the session manager about any token carrying `jet_aid`; nothing answers in the mock. + drop(session_manager_rx); + + let app = + devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); + Ok(( + app, + ( + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + shutdown_handle, + ), + )) +} + +fn unsigned_jws(cty: &str, payload: &Value) -> String { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(json!({ "alg": "RS256", "cty": cty }).to_string()); + let payload = engine.encode(payload.to_string()); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") +} + +fn now() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp() +} + +fn task_token() -> String { + unsigned_jws( + "TASK", + &json!({ + "jet_tk": "ai-log", + "jet_aid": Uuid::new_v4(), + "nbf": now(), + "exp": now() + 600, + "jti": Uuid::new_v4(), + }), + ) +} + +fn scope_token(scope: &str) -> String { + unsigned_jws( + "SCOPE", + &json!({ "scope": scope, "exp": now() + 600, "jti": Uuid::new_v4() }), + ) +} + +fn ai_params() -> Value { + json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }) +} + +fn start_request(token: Option<&str>, params: &Value) -> Request { + let mut request = Request::builder() + .method("POST") + .uri("/jet/tasks") + .header(http::header::CONTENT_TYPE, "application/json"); + + if let Some(token) = token { + request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}")); + } + + request.body(Body::from(params.to_string())).unwrap() +} + +fn status_request(token: Option<&str>, id: Uuid) -> Request { + let mut request = Request::builder().method("GET").uri(format!("/jet/tasks/{id}")); + + if let Some(token) = token { + request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}")); + } + + request.body(Body::empty()).unwrap() +} + +async fn send(app: &Router, request: Request) -> (StatusCode, String) { + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(body.to_vec()).unwrap()) +} + +async fn start_task(app: &Router) -> Value { + let (status, body) = send(app, start_request(Some(&task_token()), &ai_params())).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + serde_json::from_str(&body).unwrap() +} + +async fn wait_until_finished(app: &Router, id: Uuid) -> Value { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let (status, body) = send(app, status_request(Some(&scope_token("gateway.tasks.read")), id)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let info: Value = serde_json::from_str(&body).unwrap(); + if info["state"] == "success" || info["state"] == "failed" { + return info; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("task finishes") +} + +#[derive(Clone, Default)] +struct CapturedLogs(Arc>>); + +impl io::Write for CapturedLogs { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl CapturedLogs { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +fn capture_logs() -> (CapturedLogs, tracing::subscriber::DefaultGuard) { + let logs = CapturedLogs::default(); + let writer = logs.clone(); + let guard = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .set_default(); + (logs, guard) +} + +#[tokio::test] +async fn ai_log_task_is_accepted_then_fails_as_not_implemented() { + let (app, _handles) = make_router(CONFIG).unwrap(); + + let started = start_task(&app).await; + assert_eq!(started["kind"], "ai-log"); + assert_eq!(started["state"], "not-started"); + + let id = started["id"].as_str().unwrap().parse::().unwrap(); + let finished = wait_until_finished(&app, id).await; + + assert_eq!( + finished, + json!({ + "id": id, + "kind": "ai-log", + "state": "failed", + "error": "ai-log task not implemented yet", + }) + ); +} + +#[tokio::test] +async fn start_requires_a_task_token() { + let (app, _handles) = make_router(CONFIG).unwrap(); + + let (status, _) = send(&app, start_request(None, &ai_params())).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let (status, _) = send(&app, start_request(Some(&scope_token("*")), &ai_params())).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn status_requires_the_tasks_read_scope() { + let (app, _handles) = make_router(CONFIG).unwrap(); + let id = start_task(&app).await["id"].as_str().unwrap().parse::().unwrap(); + + let (status, _) = send(&app, status_request(None, id)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let (status, _) = send(&app, status_request(Some(&task_token()), id)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, _) = send(&app, status_request(Some(&scope_token("gateway.sessions.read")), id)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, _) = send(&app, status_request(Some(&scope_token("*")), id)).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn unknown_task_is_not_found() { + let (app, _handles) = make_router(CONFIG).unwrap(); + + let (status, _) = send( + &app, + status_request(Some(&scope_token("gateway.tasks.read")), Uuid::new_v4()), + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn invalid_ai_settings_are_typed_bad_requests() { + let (app, _handles) = make_router(CONFIG).unwrap(); + + for (params, expected) in [ + (json!({ "provider": "openai", "model": "gpt-test" }), "missing_api_key"), + ( + json!({ "provider": "openai", "model": " ", "apiKey": API_KEY }), + "missing_model", + ), + ( + json!({ "provider": "openai-compatible", "model": "gpt-test", "apiKey": API_KEY }), + "missing_base_url", + ), + ( + json!({ "provider": "ollama", "model": "llama", "baseUrl": "http://localhost:11434/" }), + "invalid_params", + ), + (json!({ "model": "gpt-test", "apiKey": API_KEY }), "invalid_params"), + ] { + let (status, body) = send(&app, start_request(Some(&task_token()), ¶ms)).await; + + assert_eq!(status, StatusCode::BAD_REQUEST, "{params}"); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + json!({ "error": expected }) + ); + } +} + +#[tokio::test] +async fn api_key_never_appears_in_responses_or_logs() { + let (logs, _guard) = capture_logs(); + let (app, _handles) = make_router(CONFIG).unwrap(); + + let started = start_task(&app).await; + assert!(!started.to_string().contains(API_KEY)); + + let id = started["id"].as_str().unwrap().parse::().unwrap(); + let finished = wait_until_finished(&app, id).await; + assert!(!finished.to_string().contains(API_KEY)); + + // A key sent in the wrong field must not be echoed by the parameter error either. + let misplaced = json!({ "provider": "openai", "model": "gpt-test", "maxOutputTokens": API_KEY }); + let (status, body) = send(&app, start_request(Some(&task_token()), &misplaced)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!body.contains(API_KEY)); + + let logs = logs.text(); + assert!(logs.contains("Background task failed"), "{logs}"); + assert!(!logs.contains(API_KEY), "{logs}"); +} + +#[tokio::test] +async fn endpoints_are_hidden_when_unstable_is_disabled() { + let config = CONFIG.replace("\"enable_unstable\": true", "\"enable_unstable\": false"); + let (app, _handles) = make_router(&config).unwrap(); + + let (status, _) = send(&app, start_request(Some(&task_token()), &ai_params())).await; + assert_eq!(status, StatusCode::NOT_FOUND); + + let (status, _) = send( + &app, + status_request(Some(&scope_token("gateway.tasks.read")), Uuid::new_v4()), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} From bebd36624f7321b672477d457e0e5e59f805b16b Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sat, 26 Sep 2026 12:13:49 -0400 Subject: [PATCH 2/9] chore(openapi): regenerate clients for the task API Adds the /jet/tasks endpoints, their schemas, the task_token scheme and the gateway.tasks.read scope to gateway-api.yaml, and regenerates the dotnet and TypeScript clients and the docs. Only the task-related output is kept; the existing version and content-type drift of the committed clients is left as is. Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/openapi/doc/index.adoc | 439 +++++++++++++ .../dotnet-client/.openapi-generator/FILES | 16 + .../openapi/dotnet-client/README.md | 14 + .../openapi/dotnet-client/docs/AiLogParams.md | 15 + .../dotnet-client/docs/AiLogSubstate.md | 11 + .../dotnet-client/docs/AiLogSubstateOneOf.md | 10 + .../openapi/dotnet-client/docs/AiProvider.md | 9 + .../dotnet-client/docs/TaskErrorResponse.md | 11 + .../openapi/dotnet-client/docs/TaskInfo.md | 16 + .../openapi/dotnet-client/docs/TaskState.md | 9 + .../openapi/dotnet-client/docs/TasksApi.md | 214 +++++++ .../Api/TasksApi.cs | 590 ++++++++++++++++++ .../Model/AccessScope.cs | 10 +- .../Model/AiLogParams.cs | 140 +++++ .../Model/AiLogSubstate.cs | 216 +++++++ .../Model/AiLogSubstateOneOf.cs | 102 +++ .../Model/AiProvider.cs | 84 +++ .../Model/TaskErrorResponse.cs | 95 +++ .../Model/TaskInfo.cs | 144 +++++ .../Model/TaskState.cs | 84 +++ devolutions-gateway/openapi/gateway-api.yaml | 183 ++++++ .../.openapi-generator/FILES | 8 + .../openapi/ts-angular-client/api/api.ts | 4 +- .../ts-angular-client/api/tasks.service.ts | 251 ++++++++ .../ts-angular-client/configuration.ts | 9 + .../ts-angular-client/model/accessScope.ts | 5 +- .../ts-angular-client/model/aiLogParams.ts | 38 ++ .../ts-angular-client/model/aiLogSubstate.ts | 22 + .../model/aiLogSubstateOneOf.ts | 22 + .../ts-angular-client/model/aiProvider.ts | 20 + .../openapi/ts-angular-client/model/models.ts | 7 + .../model/taskErrorResponse.ts | 21 + .../ts-angular-client/model/taskInfo.ts | 42 ++ .../ts-angular-client/model/taskState.ts | 20 + 34 files changed, 2877 insertions(+), 4 deletions(-) create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TaskState.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs create mode 100644 devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/taskState.ts diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 04e0934d0..cf2bd281f 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -47,6 +47,11 @@ Protocol-aware fine-grained relay server + +* *Bearer* Authentication `task_token` + + + * *HTTP Basic* Authentication `web_app_custom_auth` @@ -1278,6 +1283,10 @@ Retrieves a recording file for a given session ===== Content Type +* video/webm +* application/x-asciicast +* application/x-ndjson +* application/json * application/octet-stream ===== Responses @@ -2517,6 +2526,217 @@ ifdef::internal-generation[] endif::internal-generation[] +[.Tasks] +=== Tasks + + +[.getTask] +==== getTask + +`GET /jet/tasks/{id}` + +Gets the status of a background task. + +===== Description + +Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + + +// markup not found, no include::{specDir}jet/tasks/\{id\}/GET/spec.adoc[opts=optional] + + + +===== Security + +[cols="2,1,1"] +|=== +| Name | Type | Scheme + +| `scope_token` +| http +| bearer +|=== + +===== Parameters + +====== Path Parameters + +[cols="2,3,1,1,1"] +|=== +|Name| Description| Required| Default| Pattern + +| id +| Task ID +| X +| null +| + +|=== + + + + + + +===== Return Type + +<> + + +===== Content Type + +* application/json + +===== Responses + +.HTTP Response Codes +[cols="2,3,1"] +|=== +| Code | Message | Datatype + + +| 200 +| Task status +| <> + + +| 400 +| Bad request +| <<>> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 404 +| No task with this ID +| <<>> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/tasks/\{id\}/GET/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + +[.startTask] +==== startTask + +`POST /jet/tasks` + +Starts a background task. + +===== Description + +The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + + +// markup not found, no include::{specDir}jet/tasks/POST/spec.adoc[opts=optional] + + + +===== Security + +[cols="2,1,1"] +|=== +| Name | Type | Scheme + +| `task_token` +| http +| bearer +|=== + +===== Parameters + + +====== Body Parameter + +[cols="2,3,1,1,1"] +|=== +|Name| Description| Required| Default| Pattern + +| AiLogParams +| Kind-specific task parameters <> +| X +| +| + +|=== + + + + + +===== Return Type + +<> + + +===== Content Type + +* application/json + +===== Responses + +.HTTP Response Codes +[cols="2,3,1"] +|=== +| Code | Message | Datatype + + +| 202 +| Task was accepted and runs in the background +| <> + + +| 400 +| Invalid task parameters +| <> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 409 +| The task target is busy, such as a session that is still recording +| <> + + +| 500 +| Unexpected server error +| <<>> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/tasks/POST/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + [.Traffic] === Traffic @@ -3371,6 +3591,7 @@ endif::internal-generation[] | gateway.net.monitor.drain | gateway.agent.delete | gateway.agent.read +| gateway.tasks.read |=== @@ -3544,6 +3765,121 @@ endif::internal-generation[] |=== +[#AiLogParams] +=== _AiLogParams_ + +AI settings used by an `ai-log` task. + + +[.fields-AiLogParams] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| apiKey +| +| X +| String +| Required by every provider; kept in memory for this task only. +| + +| baseUrl +| +| X +| String +| Overrides the provider default; required for `openai-compatible`. +| + +| maxOutputTokens +| +| X +| Integer +| Upper bound of tokens in each AI answer. +| int32 + +| model +| X +| +| String +| Model identifier, passed to the provider as is. +| + +| provider +| X +| +| <> +| +| openai, anthropic, mistral, openai-compatible, + +|=== + + + +[#AiLogSubstate] +=== _AiLogSubstate_ + +Progress of a running `ai-log` task. + + +[.fields-AiLogSubstate] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| step +| X +| +| <> +| +| _Enum:_ preparing, + +|=== + + + +[#AiLogSubstateOneOf] +=== _AiLogSubstateOneOf_ + + + + +[.fields-AiLogSubstateOneOf] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| step +| X +| +| <> +| +| _Enum:_ preparing, + +|=== + + + +[#AiProvider] +=== _AiProvider_ + + + + + + +[.fields-AiProvider] +[cols="1"] +|=== +| Enum Values + +| openai +| anthropic +| mistral +| openai-compatible + +|=== + + [#AppCredential] === _AppCredential_ @@ -5564,6 +5900,109 @@ Subscriber configuration +[#TaskErrorResponse] +=== _TaskErrorResponse_ + +Why a task was not started. + + +[.fields-TaskErrorResponse] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| error +| X +| +| String +| Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. +| + +|=== + + + +[#TaskInfo] +=== _TaskInfo_ + +A background task and its status. + +`substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. +Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + + +[.fields-TaskInfo] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| error +| +| X +| String +| Why the task failed. +| + +| id +| X +| +| UUID +| Task ID. +| uuid + +| kind +| X +| +| String +| Task kind, as in the `jet_tk` claim of the TASK token. +| + +| result +| +| X +| Object +| Result of a successful task. +| + +| state +| X +| +| <> +| +| not-started, running, success, failed, + +| substate +| +| X +| Object +| Progress of a running task. +| + +|=== + + + +[#TaskState] +=== _TaskState_ + + + + + + +[.fields-TaskState] +[cols="1"] +|=== +| Enum Values + +| not-started +| running +| success +| failed + +|=== + + [#TrafficEventResponse] === _TrafficEventResponse_ diff --git a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES index b49a09154..73de0d573 100644 --- a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES @@ -9,6 +9,10 @@ docs/AgentApi.md docs/AgentDomainAdvertisement.md docs/AgentInfo.md docs/AgentStatus.md +docs/AiLogParams.md +docs/AiLogSubstate.md +docs/AiLogSubstateOneOf.md +docs/AiProvider.md docs/AppCredential.md docs/AppCredentialKind.md docs/AppTokenContentType.md @@ -70,6 +74,10 @@ docs/SetUpdateScheduleRequest.md docs/SubProvisionerKey.md docs/Subscriber.md docs/TargetConnectionOptions.md +docs/TaskErrorResponse.md +docs/TaskInfo.md +docs/TaskState.md +docs/TasksApi.md docs/TrafficApi.md docs/TrafficEventResponse.md docs/TransportProtocolResponse.md @@ -88,6 +96,7 @@ src/Devolutions.Gateway.Client/Api/NetApi.cs src/Devolutions.Gateway.Client/Api/NetworkMonitoringApi.cs src/Devolutions.Gateway.Client/Api/PreflightApi.cs src/Devolutions.Gateway.Client/Api/SessionsApi.cs +src/Devolutions.Gateway.Client/Api/TasksApi.cs src/Devolutions.Gateway.Client/Api/TrafficApi.cs src/Devolutions.Gateway.Client/Api/UpdateApi.cs src/Devolutions.Gateway.Client/Api/WebAppApi.cs @@ -117,6 +126,10 @@ src/Devolutions.Gateway.Client/Model/AddressFamily.cs src/Devolutions.Gateway.Client/Model/AgentDomainAdvertisement.cs src/Devolutions.Gateway.Client/Model/AgentInfo.cs src/Devolutions.Gateway.Client/Model/AgentStatus.cs +src/Devolutions.Gateway.Client/Model/AiLogParams.cs +src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs +src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs +src/Devolutions.Gateway.Client/Model/AiProvider.cs src/Devolutions.Gateway.Client/Model/AppCredential.cs src/Devolutions.Gateway.Client/Model/AppCredentialKind.cs src/Devolutions.Gateway.Client/Model/AppTokenContentType.cs @@ -168,6 +181,9 @@ src/Devolutions.Gateway.Client/Model/SetUpdateScheduleRequest.cs src/Devolutions.Gateway.Client/Model/SubProvisionerKey.cs src/Devolutions.Gateway.Client/Model/Subscriber.cs src/Devolutions.Gateway.Client/Model/TargetConnectionOptions.cs +src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs +src/Devolutions.Gateway.Client/Model/TaskInfo.cs +src/Devolutions.Gateway.Client/Model/TaskState.cs src/Devolutions.Gateway.Client/Model/TrafficEventResponse.cs src/Devolutions.Gateway.Client/Model/TransportProtocolResponse.cs src/Devolutions.Gateway.Client/Model/UpdateProductInfo.cs diff --git a/devolutions-gateway/openapi/dotnet-client/README.md b/devolutions-gateway/openapi/dotnet-client/README.md index c60cd47e2..e24e4f2d3 100644 --- a/devolutions-gateway/openapi/dotnet-client/README.md +++ b/devolutions-gateway/openapi/dotnet-client/README.md @@ -166,6 +166,8 @@ Class | Method | HTTP request | Description *PreflightApi* | [**PostPreflight**](docs/PreflightApi.md#postpreflight) | **POST** /jet/preflight | Performs a batch of preflight operations *SessionsApi* | [**GetSessions**](docs/SessionsApi.md#getsessions) | **GET** /jet/sessions | Lists running sessions *SessionsApi* | [**TerminateSession**](docs/SessionsApi.md#terminatesession) | **POST** /jet/session/{id}/terminate | Terminate forcefully a running session +*TasksApi* | [**GetTask**](docs/TasksApi.md#gettask) | **GET** /jet/tasks/{id} | Gets the status of a background task. +*TasksApi* | [**StartTask**](docs/TasksApi.md#starttask) | **POST** /jet/tasks | Starts a background task. *TrafficApi* | [**AckTrafficEvents**](docs/TrafficApi.md#acktrafficevents) | **POST** /jet/traffic/ack | Acknowledge traffic audit events and remove them from the queue *TrafficApi* | [**ClaimTrafficEvents**](docs/TrafficApi.md#claimtrafficevents) | **POST** /jet/traffic/claim | Claim traffic audit events for processing *UpdateApi* | [**GetUpdateProducts**](docs/UpdateApi.md#getupdateproducts) | **GET** /jet/update | Retrieve the currently installed version of each Devolutions product. @@ -186,6 +188,10 @@ Class | Method | HTTP request | Description - [Model.AgentDomainAdvertisement](docs/AgentDomainAdvertisement.md) - [Model.AgentInfo](docs/AgentInfo.md) - [Model.AgentStatus](docs/AgentStatus.md) + - [Model.AiLogParams](docs/AiLogParams.md) + - [Model.AiLogSubstate](docs/AiLogSubstate.md) + - [Model.AiLogSubstateOneOf](docs/AiLogSubstateOneOf.md) + - [Model.AiProvider](docs/AiProvider.md) - [Model.AppCredential](docs/AppCredential.md) - [Model.AppCredentialKind](docs/AppCredentialKind.md) - [Model.AppTokenContentType](docs/AppTokenContentType.md) @@ -237,6 +243,9 @@ Class | Method | HTTP request | Description - [Model.SubProvisionerKey](docs/SubProvisionerKey.md) - [Model.Subscriber](docs/Subscriber.md) - [Model.TargetConnectionOptions](docs/TargetConnectionOptions.md) + - [Model.TaskErrorResponse](docs/TaskErrorResponse.md) + - [Model.TaskInfo](docs/TaskInfo.md) + - [Model.TaskState](docs/TaskState.md) - [Model.TrafficEventResponse](docs/TrafficEventResponse.md) - [Model.TransportProtocolResponse](docs/TransportProtocolResponse.md) - [Model.UpdateProductInfo](docs/UpdateProductInfo.md) @@ -273,6 +282,11 @@ Authentication schemes defined for the API: - **Type**: Bearer Authentication + +### task_token + +- **Type**: Bearer Authentication + ### web_app_custom_auth diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md new file mode 100644 index 000000000..37992c1a5 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md @@ -0,0 +1,15 @@ +# Devolutions.Gateway.Client.Model.AiLogParams +AI settings used by an `ai-log` task. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**ApiKey** | **string** | Required by every provider; kept in memory for this task only. | [optional] +**BaseUrl** | **string** | Overrides the provider default; required for `openai-compatible`. | [optional] +**MaxOutputTokens** | **int?** | Upper bound of tokens in each AI answer. | [optional] +**Model** | **string** | Model identifier, passed to the provider as is. | +**Provider** | **AiProvider** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md new file mode 100644 index 000000000..ec54cc1b0 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md @@ -0,0 +1,11 @@ +# Devolutions.Gateway.Client.Model.AiLogSubstate +Progress of a running `ai-log` task. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Step** | **string** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md new file mode 100644 index 000000000..5a6951a48 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.AiLogSubstateOneOf + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Step** | **string** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md b/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md new file mode 100644 index 000000000..18c707aca --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md @@ -0,0 +1,9 @@ +# Devolutions.Gateway.Client.Model.AiProvider + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md new file mode 100644 index 000000000..dcde635ec --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md @@ -0,0 +1,11 @@ +# Devolutions.Gateway.Client.Model.TaskErrorResponse +Why a task was not started. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Error** | **string** | Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md new file mode 100644 index 000000000..39b90c3f7 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md @@ -0,0 +1,16 @@ +# Devolutions.Gateway.Client.Model.TaskInfo +A background task and its status. `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Error** | **string** | Why the task failed. | [optional] +**Id** | **Guid** | Task ID. | +**Kind** | **string** | Task kind, as in the `jet_tk` claim of the TASK token. | +**Result** | **Object** | Result of a successful task. | [optional] +**State** | **TaskState** | | +**Substate** | **Object** | Progress of a running task. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md new file mode 100644 index 000000000..40328b8ec --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md @@ -0,0 +1,9 @@ +# Devolutions.Gateway.Client.Model.TaskState + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md new file mode 100644 index 000000000..3479fa573 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md @@ -0,0 +1,214 @@ +# Devolutions.Gateway.Client.Api.TasksApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|--------|--------------|-------------| +| [**GetTask**](TasksApi.md#gettask) | **GET** /jet/tasks/{id} | Gets the status of a background task. | +| [**StartTask**](TasksApi.md#starttask) | **POST** /jet/tasks | Starts a background task. | + + +# **GetTask** +> TaskInfo GetTask (Guid id) + +Gets the status of a background task. + +Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + +### Example +```csharp +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using Devolutions.Gateway.Client.Api; +using Devolutions.Gateway.Client.Client; +using Devolutions.Gateway.Client.Model; + +namespace Example +{ + public class GetTaskExample + { + public static void Main() + { + Configuration config = new Configuration(); + config.BasePath = "http://localhost"; + // Configure Bearer token for authorization: scope_token + config.AccessToken = "YOUR_BEARER_TOKEN"; + + // create instances of HttpClient, HttpClientHandler to be reused later with different Api classes + HttpClient httpClient = new HttpClient(); + HttpClientHandler httpClientHandler = new HttpClientHandler(); + var apiInstance = new TasksApi(httpClient, config, httpClientHandler); + var id = "id_example"; // Guid | Task ID + + try + { + // Gets the status of a background task. + TaskInfo result = apiInstance.GetTask(id); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling TasksApi.GetTask: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); + } + } + } +} +``` + +#### Using the GetTaskWithHttpInfo variant +This returns an ApiResponse object which contains the response data, status code and headers. + +```csharp +try +{ + // Gets the status of a background task. + ApiResponse response = apiInstance.GetTaskWithHttpInfo(id); + Debug.Write("Status Code: " + response.StatusCode); + Debug.Write("Response Headers: " + response.Headers); + Debug.Write("Response Body: " + response.Data); +} +catch (ApiException e) +{ + Debug.Print("Exception when calling TasksApi.GetTaskWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **id** | **Guid** | Task ID | | + +### Return type + +[**TaskInfo**](TaskInfo.md) + +### Authorization + +[scope_token](../README.md#scope_token) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | Task status | - | +| **400** | Bad request | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **404** | No task with this ID | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +# **StartTask** +> TaskInfo StartTask (AiLogParams aiLogParams) + +Starts a background task. + +The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + +### Example +```csharp +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using Devolutions.Gateway.Client.Api; +using Devolutions.Gateway.Client.Client; +using Devolutions.Gateway.Client.Model; + +namespace Example +{ + public class StartTaskExample + { + public static void Main() + { + Configuration config = new Configuration(); + config.BasePath = "http://localhost"; + // Configure Bearer token for authorization: task_token + config.AccessToken = "YOUR_BEARER_TOKEN"; + + // create instances of HttpClient, HttpClientHandler to be reused later with different Api classes + HttpClient httpClient = new HttpClient(); + HttpClientHandler httpClientHandler = new HttpClientHandler(); + var apiInstance = new TasksApi(httpClient, config, httpClientHandler); + var aiLogParams = new AiLogParams(); // AiLogParams | Kind-specific task parameters + + try + { + // Starts a background task. + TaskInfo result = apiInstance.StartTask(aiLogParams); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling TasksApi.StartTask: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); + } + } + } +} +``` + +#### Using the StartTaskWithHttpInfo variant +This returns an ApiResponse object which contains the response data, status code and headers. + +```csharp +try +{ + // Starts a background task. + ApiResponse response = apiInstance.StartTaskWithHttpInfo(aiLogParams); + Debug.Write("Status Code: " + response.StatusCode); + Debug.Write("Response Headers: " + response.Headers); + Debug.Write("Response Body: " + response.Data); +} +catch (ApiException e) +{ + Debug.Print("Exception when calling TasksApi.StartTaskWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **aiLogParams** | [**AiLogParams**](AiLogParams.md) | Kind-specific task parameters | | + +### Return type + +[**TaskInfo**](TaskInfo.md) + +### Authorization + +[task_token](../README.md#task_token) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **202** | Task was accepted and runs in the background | - | +| **400** | Invalid task parameters | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **409** | The task target is busy, such as a session that is still recording | - | +| **500** | Unexpected server error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs new file mode 100644 index 000000000..094bf23b1 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs @@ -0,0 +1,590 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Mime; +using Devolutions.Gateway.Client.Client; +using Devolutions.Gateway.Client.Model; + +namespace Devolutions.Gateway.Client.Api +{ + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApiSync : IApiAccessor + { + #region Synchronous Operations + /// + /// Gets the status of a background task. + /// + /// + /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// TaskInfo + TaskInfo GetTask(Guid id); + + /// + /// Gets the status of a background task. + /// + /// + /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// ApiResponse of TaskInfo + ApiResponse GetTaskWithHttpInfo(Guid id); + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// TaskInfo + TaskInfo StartTask(AiLogParams aiLogParams); + + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// ApiResponse of TaskInfo + ApiResponse StartTaskWithHttpInfo(AiLogParams aiLogParams); + #endregion Synchronous Operations + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApiAsync : IApiAccessor + { + #region Asynchronous Operations + /// + /// Gets the status of a background task. + /// + /// + /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + System.Threading.Tasks.Task GetTaskAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + + /// + /// Gets the status of a background task. + /// + /// + /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + System.Threading.Tasks.Task> GetTaskWithHttpInfoAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + System.Threading.Tasks.Task StartTaskAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + #endregion Asynchronous Operations + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApi : ITasksApiSync, ITasksApiAsync + { + + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public partial class TasksApi : IDisposable, ITasksApi + { + private Devolutions.Gateway.Client.Client.ExceptionFactory _exceptionFactory = (name, response) => null; + + /// + /// Initializes a new instance of the class. + /// **IMPORTANT** This will also create an instance of HttpClient, which is less than ideal. + /// It's better to reuse the HttpClient and HttpClientHandler. + /// + /// + public TasksApi() : this((string)null) + { + } + + /// + /// Initializes a new instance of the class. + /// **IMPORTANT** This will also create an instance of HttpClient, which is less than ideal. + /// It's better to reuse the HttpClient and HttpClientHandler. + /// + /// The target service's base path in URL format. + /// + /// + public TasksApi(string basePath) + { + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + new Devolutions.Gateway.Client.Client.Configuration { BasePath = basePath } + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(this.Configuration.BasePath); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + this.ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class using Configuration object. + /// **IMPORTANT** This will also create an instance of HttpClient, which is less than ideal. + /// It's better to reuse the HttpClient and HttpClientHandler. + /// + /// An instance of Configuration. + /// + /// + public TasksApi(Devolutions.Gateway.Client.Client.Configuration configuration) + { + if (configuration == null) throw new ArgumentNullException("configuration"); + + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + configuration + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(this.Configuration.BasePath); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance of HttpClient. + /// An optional instance of HttpClientHandler that is used by HttpClient. + /// + /// + /// + /// Some configuration settings will not be applied without passing an HttpClientHandler. + /// The features affected are: Setting and Retrieving Cookies, Client Certificates, Proxy settings. + /// + public TasksApi(HttpClient client, HttpClientHandler handler = null) : this(client, (string)null, handler) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance of HttpClient. + /// The target service's base path in URL format. + /// An optional instance of HttpClientHandler that is used by HttpClient. + /// + /// + /// + /// + /// Some configuration settings will not be applied without passing an HttpClientHandler. + /// The features affected are: Setting and Retrieving Cookies, Client Certificates, Proxy settings. + /// + public TasksApi(HttpClient client, string basePath, HttpClientHandler handler = null) + { + if (client == null) throw new ArgumentNullException("client"); + + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + new Devolutions.Gateway.Client.Client.Configuration { BasePath = basePath } + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(client, this.Configuration.BasePath, handler); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + this.ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class using Configuration object. + /// + /// An instance of HttpClient. + /// An instance of Configuration. + /// An optional instance of HttpClientHandler that is used by HttpClient. + /// + /// + /// + /// Some configuration settings will not be applied without passing an HttpClientHandler. + /// The features affected are: Setting and Retrieving Cookies, Client Certificates, Proxy settings. + /// + public TasksApi(HttpClient client, Devolutions.Gateway.Client.Client.Configuration configuration, HttpClientHandler handler = null) + { + if (configuration == null) throw new ArgumentNullException("configuration"); + if (client == null) throw new ArgumentNullException("client"); + + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + configuration + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(client, this.Configuration.BasePath, handler); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class + /// using a Configuration object and client instance. + /// + /// The client interface for synchronous API access. + /// The client interface for asynchronous API access. + /// The configuration object. + /// + public TasksApi(Devolutions.Gateway.Client.Client.ISynchronousClient client, Devolutions.Gateway.Client.Client.IAsynchronousClient asyncClient, Devolutions.Gateway.Client.Client.IReadableConfiguration configuration) + { + if (client == null) throw new ArgumentNullException("client"); + if (asyncClient == null) throw new ArgumentNullException("asyncClient"); + if (configuration == null) throw new ArgumentNullException("configuration"); + + this.Client = client; + this.AsynchronousClient = asyncClient; + this.Configuration = configuration; + this.ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Disposes resources if they were created by us + /// + public void Dispose() + { + this.ApiClient?.Dispose(); + } + + /// + /// Holds the ApiClient if created + /// + public Devolutions.Gateway.Client.Client.ApiClient ApiClient { get; set; } = null; + + /// + /// The client for accessing this underlying API asynchronously. + /// + public Devolutions.Gateway.Client.Client.IAsynchronousClient AsynchronousClient { get; set; } + + /// + /// The client for accessing this underlying API synchronously. + /// + public Devolutions.Gateway.Client.Client.ISynchronousClient Client { get; set; } + + /// + /// Gets the base path of the API client. + /// + /// The base path + public string GetBasePath() + { + return this.Configuration.BasePath; + } + + /// + /// Gets or sets the configuration object + /// + /// An instance of the Configuration + public Devolutions.Gateway.Client.Client.IReadableConfiguration Configuration { get; set; } + + /// + /// Provides a factory method hook for the creation of exceptions. + /// + public Devolutions.Gateway.Client.Client.ExceptionFactory ExceptionFactory + { + get + { + if (_exceptionFactory != null && _exceptionFactory.GetInvocationList().Length > 1) + { + throw new InvalidOperationException("Multicast delegate for ExceptionFactory is unsupported."); + } + return _exceptionFactory; + } + set { _exceptionFactory = value; } + } + + /// + /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// TaskInfo + public TaskInfo GetTask(Guid id) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = GetTaskWithHttpInfo(id); + return localVarResponse.Data; + } + + /// + /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// ApiResponse of TaskInfo + public Devolutions.Gateway.Client.Client.ApiResponse GetTaskWithHttpInfo(Guid id) + { + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.PathParameters.Add("id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(id)); // path parameter + + // authentication (scope_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + var localVarResponse = this.Client.Get("/jet/tasks/{id}", localVarRequestOptions, this.Configuration); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("GetTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + public async System.Threading.Tasks.Task GetTaskAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await GetTaskWithHttpInfoAsync(id, cancellationToken).ConfigureAwait(false); + return localVarResponse.Data; + } + + /// + /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + public async System.Threading.Tasks.Task> GetTaskWithHttpInfoAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.PathParameters.Add("id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(id)); // path parameter + + // authentication (scope_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + + var localVarResponse = await this.AsynchronousClient.GetAsync("/jet/tasks/{id}", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("GetTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// TaskInfo + public TaskInfo StartTask(AiLogParams aiLogParams) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = StartTaskWithHttpInfo(aiLogParams); + return localVarResponse.Data; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// ApiResponse of TaskInfo + public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttpInfo(AiLogParams aiLogParams) + { + // verify the required parameter 'aiLogParams' is set + if (aiLogParams == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'aiLogParams' when calling TasksApi->StartTask"); + + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + "application/json" + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.Data = aiLogParams; + + // authentication (task_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + var localVarResponse = this.Client.Post("/jet/tasks", localVarRequestOptions, this.Configuration); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("StartTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + public async System.Threading.Tasks.Task StartTaskAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await StartTaskWithHttpInfoAsync(aiLogParams, cancellationToken).ConfigureAwait(false); + return localVarResponse.Data; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + public async System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + // verify the required parameter 'aiLogParams' is set + if (aiLogParams == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'aiLogParams' when calling TasksApi->StartTask"); + + + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + "application/json" + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.Data = aiLogParams; + + // authentication (task_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + + var localVarResponse = await this.AsynchronousClient.PostAsync("/jet/tasks", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("StartTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + } +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs index 98c7ac937..ccebaafa4 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs @@ -145,7 +145,13 @@ public enum AccessScope /// Enum GatewayAgentRead for value: gateway.agent.read /// [EnumMember(Value = "gateway.agent.read")] - GatewayAgentRead = 19 + GatewayAgentRead = 19, + + /// + /// Enum GatewayTasksRead for value: gateway.tasks.read + /// + [EnumMember(Value = "gateway.tasks.read")] + GatewayTasksRead = 20 } public static class AccessScopeExtensions @@ -195,6 +201,8 @@ public static string ToValue(this AccessScope variant) return "gateway.agent.delete"; case AccessScope.GatewayAgentRead: return "gateway.agent.read"; + case AccessScope.GatewayTasksRead: + return "gateway.tasks.read"; default: throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); } diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs new file mode 100644 index 000000000..7c17d70fb --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs @@ -0,0 +1,140 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// AI settings used by an `ai-log` task. + /// + [DataContract(Name = "AiLogParams")] + public partial class AiLogParams : IValidatableObject + { + + /// + /// Gets or Sets Provider + /// + [DataMember(Name = "provider", IsRequired = true, EmitDefaultValue = true)] + public AiProvider Provider { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiLogParams() { } + /// + /// Initializes a new instance of the class. + /// + /// Required by every provider; kept in memory for this task only.. + /// Overrides the provider default; required for `openai-compatible`.. + /// Upper bound of tokens in each AI answer.. + /// Model identifier, passed to the provider as is. (required). + /// provider (required). + public AiLogParams(string apiKey = default(string), string baseUrl = default(string), int? maxOutputTokens = default(int?), string model = default(string), AiProvider provider = default(AiProvider)) + { + // to ensure "model" is required (not null) + if (model == null) + { + throw new ArgumentNullException("model is a required property for AiLogParams and cannot be null"); + } + this.Model = model; + this.Provider = provider; + this.ApiKey = apiKey; + this.BaseUrl = baseUrl; + this.MaxOutputTokens = maxOutputTokens; + } + + /// + /// Required by every provider; kept in memory for this task only. + /// + /// Required by every provider; kept in memory for this task only. + [DataMember(Name = "apiKey", EmitDefaultValue = true)] + public string ApiKey { get; set; } + + /// + /// Overrides the provider default; required for `openai-compatible`. + /// + /// Overrides the provider default; required for `openai-compatible`. + [DataMember(Name = "baseUrl", EmitDefaultValue = true)] + public string BaseUrl { get; set; } + + /// + /// Upper bound of tokens in each AI answer. + /// + /// Upper bound of tokens in each AI answer. + [DataMember(Name = "maxOutputTokens", EmitDefaultValue = true)] + public int? MaxOutputTokens { get; set; } + + /// + /// Model identifier, passed to the provider as is. + /// + /// Model identifier, passed to the provider as is. + [DataMember(Name = "model", IsRequired = true, EmitDefaultValue = true)] + public string Model { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class AiLogParams {\n"); + sb.Append(" ApiKey: ").Append(ApiKey).Append("\n"); + sb.Append(" BaseUrl: ").Append(BaseUrl).Append("\n"); + sb.Append(" MaxOutputTokens: ").Append(MaxOutputTokens).Append("\n"); + sb.Append(" Model: ").Append(Model).Append("\n"); + sb.Append(" Provider: ").Append(Provider).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + // MaxOutputTokens (int?) minimum + if (this.MaxOutputTokens < (int?)0) + { + yield return new ValidationResult("Invalid value for MaxOutputTokens, must be a value greater than or equal to 0.", new [] { "MaxOutputTokens" }); + } + + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs new file mode 100644 index 000000000..dd005cf5b --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs @@ -0,0 +1,216 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; +using System.Reflection; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Progress of a running `ai-log` task. + /// + [JsonConverter(typeof(AiLogSubstateJsonConverter))] + [DataContract(Name = "AiLogSubstate")] + public partial class AiLogSubstate : AbstractOpenAPISchema, IValidatableObject + { + /// + /// Initializes a new instance of the class + /// with the class + /// + /// An instance of AiLogSubstateOneOf. + public AiLogSubstate(AiLogSubstateOneOf actualInstance) + { + this.IsNullable = false; + this.SchemaType= "oneOf"; + this.ActualInstance = actualInstance ?? throw new ArgumentException("Invalid instance found. Must not be null."); + } + + + private Object _actualInstance; + + /// + /// Gets or Sets ActualInstance + /// + public override Object ActualInstance + { + get + { + return _actualInstance; + } + set + { + if (value.GetType() == typeof(AiLogSubstateOneOf) || value is AiLogSubstateOneOf) + { + this._actualInstance = value; + } + else + { + throw new ArgumentException("Invalid instance found. Must be the following types: AiLogSubstateOneOf"); + } + } + } + + /// + /// Get the actual instance of `AiLogSubstateOneOf`. If the actual instance is not `AiLogSubstateOneOf`, + /// the InvalidClassException will be thrown + /// + /// An instance of AiLogSubstateOneOf + public AiLogSubstateOneOf GetAiLogSubstateOneOf() + { + return (AiLogSubstateOneOf)this.ActualInstance; + } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class AiLogSubstate {\n"); + sb.Append(" ActualInstance: ").Append(this.ActualInstance).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public override string ToJson() + { + return JsonConvert.SerializeObject(this.ActualInstance, AiLogSubstate.SerializerSettings); + } + + /// + /// Converts the JSON string into an instance of AiLogSubstate + /// + /// JSON string + /// An instance of AiLogSubstate + public static AiLogSubstate FromJson(string jsonString) + { + AiLogSubstate newAiLogSubstate = null; + + if (string.IsNullOrEmpty(jsonString)) + { + return newAiLogSubstate; + } + int match = 0; + List matchedTypes = new List(); + + try + { + // if it does not contains "AdditionalProperties", use SerializerSettings to deserialize + if (typeof(AiLogSubstateOneOf).GetProperty("AdditionalProperties") == null) + { + newAiLogSubstate = new AiLogSubstate(JsonConvert.DeserializeObject(jsonString, AiLogSubstate.SerializerSettings)); + } + else + { + newAiLogSubstate = new AiLogSubstate(JsonConvert.DeserializeObject(jsonString, AiLogSubstate.AdditionalPropertiesSerializerSettings)); + } + matchedTypes.Add("AiLogSubstateOneOf"); + match++; + } + catch (Exception exception) + { + // deserialization failed, try the next one + System.Diagnostics.Debug.WriteLine(string.Format("Failed to deserialize `{0}` into AiLogSubstateOneOf: {1}", jsonString, exception.ToString())); + } + + if (match == 0) + { + throw new InvalidDataException("The JSON string `" + jsonString + "` cannot be deserialized into any schema defined."); + } + else if (match > 1) + { + throw new InvalidDataException("The JSON string `" + jsonString + "` incorrectly matches more than one schema (should be exactly one match): " + String.Join(",", matchedTypes)); + } + + // deserialization is considered successful at this point if no exception has been thrown. + return newAiLogSubstate; + } + + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + + /// + /// Custom JSON converter for AiLogSubstate + /// + public class AiLogSubstateJsonConverter : JsonConverter + { + /// + /// To write the JSON string + /// + /// JSON writer + /// Object to be converted into a JSON string + /// JSON Serializer + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteRawValue((string)(typeof(AiLogSubstate).GetMethod("ToJson").Invoke(value, null))); + } + + /// + /// To convert a JSON string into an object + /// + /// JSON reader + /// Object type + /// Existing value + /// JSON Serializer + /// The object converted from the JSON string + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + switch(reader.TokenType) + { + case JsonToken.StartObject: + return AiLogSubstate.FromJson(JObject.Load(reader).ToString(Formatting.None)); + case JsonToken.StartArray: + return AiLogSubstate.FromJson(JArray.Load(reader).ToString(Formatting.None)); + default: + return null; + } + } + + /// + /// Check if the object can be converted + /// + /// Object type + /// True if the object can be converted + public override bool CanConvert(Type objectType) + { + return false; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs new file mode 100644 index 000000000..f0019c489 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs @@ -0,0 +1,102 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// AiLogSubstateOneOf + /// + [DataContract(Name = "AiLogSubstate_oneOf")] + public partial class AiLogSubstateOneOf : IValidatableObject + { + /// + /// Defines Step + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum StepEnum + { + /// + /// Enum Preparing for value: preparing + /// + [EnumMember(Value = "preparing")] + Preparing = 1 + } + + + /// + /// Gets or Sets Step + /// + [DataMember(Name = "step", IsRequired = true, EmitDefaultValue = true)] + public StepEnum Step { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiLogSubstateOneOf() { } + /// + /// Initializes a new instance of the class. + /// + /// step (required). + public AiLogSubstateOneOf(StepEnum step = default(StepEnum)) + { + this.Step = step; + } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class AiLogSubstateOneOf {\n"); + sb.Append(" Step: ").Append(Step).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs new file mode 100644 index 000000000..e895d216e --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs @@ -0,0 +1,84 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Defines AiProvider + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum AiProvider + { + /// + /// Enum Openai for value: openai + /// + [EnumMember(Value = "openai")] + Openai = 1, + + /// + /// Enum Anthropic for value: anthropic + /// + [EnumMember(Value = "anthropic")] + Anthropic = 2, + + /// + /// Enum Mistral for value: mistral + /// + [EnumMember(Value = "mistral")] + Mistral = 3, + + /// + /// Enum OpenaiCompatible for value: openai-compatible + /// + [EnumMember(Value = "openai-compatible")] + OpenaiCompatible = 4 + } + + public static class AiProviderExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this AiProvider variant) + { + switch (variant) + { + case AiProvider.Openai: + return "openai"; + case AiProvider.Anthropic: + return "anthropic"; + case AiProvider.Mistral: + return "mistral"; + case AiProvider.OpenaiCompatible: + return "openai-compatible"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs new file mode 100644 index 000000000..50e30871c --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs @@ -0,0 +1,95 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Why a task was not started. + /// + [DataContract(Name = "TaskErrorResponse")] + public partial class TaskErrorResponse : IValidatableObject + { + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected TaskErrorResponse() { } + /// + /// Initializes a new instance of the class. + /// + /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. (required). + public TaskErrorResponse(string error = default(string)) + { + // to ensure "error" is required (not null) + if (error == null) + { + throw new ArgumentNullException("error is a required property for TaskErrorResponse and cannot be null"); + } + this.Error = error; + } + + /// + /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. + /// + /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. + [DataMember(Name = "error", IsRequired = true, EmitDefaultValue = true)] + public string Error { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class TaskErrorResponse {\n"); + sb.Append(" Error: ").Append(Error).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs new file mode 100644 index 000000000..bf47d9540 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs @@ -0,0 +1,144 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// A background task and its status. `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + /// + [DataContract(Name = "TaskInfo")] + public partial class TaskInfo : IValidatableObject + { + + /// + /// Gets or Sets State + /// + [DataMember(Name = "state", IsRequired = true, EmitDefaultValue = true)] + public TaskState State { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected TaskInfo() { } + /// + /// Initializes a new instance of the class. + /// + /// Why the task failed.. + /// Task ID. (required). + /// Task kind, as in the `jet_tk` claim of the TASK token. (required). + /// Result of a successful task.. + /// state (required). + /// Progress of a running task.. + public TaskInfo(string error = default(string), Guid id = default(Guid), string kind = default(string), Object result = default(Object), TaskState state = default(TaskState), Object substate = default(Object)) + { + this.Id = id; + // to ensure "kind" is required (not null) + if (kind == null) + { + throw new ArgumentNullException("kind is a required property for TaskInfo and cannot be null"); + } + this.Kind = kind; + this.State = state; + this.Error = error; + this.Result = result; + this.Substate = substate; + } + + /// + /// Why the task failed. + /// + /// Why the task failed. + [DataMember(Name = "error", EmitDefaultValue = true)] + public string Error { get; set; } + + /// + /// Task ID. + /// + /// Task ID. + [DataMember(Name = "id", IsRequired = true, EmitDefaultValue = true)] + public Guid Id { get; set; } + + /// + /// Task kind, as in the `jet_tk` claim of the TASK token. + /// + /// Task kind, as in the `jet_tk` claim of the TASK token. + [DataMember(Name = "kind", IsRequired = true, EmitDefaultValue = true)] + public string Kind { get; set; } + + /// + /// Result of a successful task. + /// + /// Result of a successful task. + [DataMember(Name = "result", EmitDefaultValue = true)] + public Object Result { get; set; } + + /// + /// Progress of a running task. + /// + /// Progress of a running task. + [DataMember(Name = "substate", EmitDefaultValue = true)] + public Object Substate { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class TaskInfo {\n"); + sb.Append(" Error: ").Append(Error).Append("\n"); + sb.Append(" Id: ").Append(Id).Append("\n"); + sb.Append(" Kind: ").Append(Kind).Append("\n"); + sb.Append(" Result: ").Append(Result).Append("\n"); + sb.Append(" State: ").Append(State).Append("\n"); + sb.Append(" Substate: ").Append(Substate).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs new file mode 100644 index 000000000..795cbe379 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs @@ -0,0 +1,84 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Defines TaskState + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum TaskState + { + /// + /// Enum NotStarted for value: not-started + /// + [EnumMember(Value = "not-started")] + NotStarted = 1, + + /// + /// Enum Running for value: running + /// + [EnumMember(Value = "running")] + Running = 2, + + /// + /// Enum Success for value: success + /// + [EnumMember(Value = "success")] + Success = 3, + + /// + /// Enum Failed for value: failed + /// + [EnumMember(Value = "failed")] + Failed = 4 + } + + public static class TaskStateExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this TaskState variant) + { + switch (variant) + { + case TaskState.NotStarted: + return "not-started"; + case TaskState.Running: + return "running"; + case TaskState.Success: + return "success"; + case TaskState.Failed: + return "failed"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index a2c1b1b2b..099f73390 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -899,6 +899,87 @@ paths: security: - scope_token: - gateway.sessions.read + /jet/tasks: + post: + tags: + - Tasks + summary: Starts a background task. + description: |- + The task kind and its target come from the TASK token. + The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. + + This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + operationId: StartTask + requestBody: + description: Kind-specific task parameters + content: + application/json: + schema: + $ref: '#/components/schemas/AiLogParams' + required: true + responses: + '202': + description: Task was accepted and runs in the background + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInfo' + '400': + description: Invalid task parameters + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '409': + description: The task target is busy, such as a session that is still recording + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + '500': + description: Unexpected server error + security: + - task_token: [] + /jet/tasks/{id}: + get: + tags: + - Tasks + summary: Gets the status of a background task. + description: |- + Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. + + This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + operationId: GetTask + parameters: + - name: id + in: path + description: Task ID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Task status + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInfo' + '400': + description: Bad request + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '404': + description: No task with this ID + security: + - scope_token: + - gateway.tasks.read /jet/traffic/ack: post: tags: @@ -1326,6 +1407,7 @@ components: - gateway.net.monitor.drain - gateway.agent.delete - gateway.agent.read + - gateway.tasks.read AckRequest: type: object required: @@ -1402,6 +1484,51 @@ components: - offline - online - unresponsive + AiLogParams: + type: object + description: AI settings used by an `ai-log` task. + required: + - provider + - model + properties: + apiKey: + type: string + description: Required by every provider; kept in memory for this task only. + nullable: true + baseUrl: + type: string + description: Overrides the provider default; required for `openai-compatible`. + nullable: true + maxOutputTokens: + type: integer + format: int32 + description: Upper bound of tokens in each AI answer. + nullable: true + minimum: 0 + model: + type: string + description: Model identifier, passed to the provider as is. + provider: + $ref: '#/components/schemas/AiProvider' + additionalProperties: false + AiLogSubstate: + oneOf: + - type: object + required: + - step + properties: + step: + type: string + enum: + - preparing + description: Progress of a running `ai-log` task. + AiProvider: + type: string + enum: + - openai + - anthropic + - mistral + - openai-compatible AppCredential: type: object required: @@ -2392,6 +2519,57 @@ components: Format: `://:` (port is required). Supported schemes are `tcp` and `udp`. nullable: true + TaskErrorResponse: + type: object + description: Why a task was not started. + required: + - error + properties: + error: + type: string + description: |- + Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, + `invalid_ai_settings` or `recording_active`. + TaskInfo: + type: object + description: |- + A background task and its status. + + `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. + Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + required: + - id + - kind + - state + properties: + error: + type: string + description: Why the task failed. + nullable: true + id: + type: string + format: uuid + description: Task ID. + kind: + type: string + description: Task kind, as in the `jet_tk` claim of the TASK token. + result: + type: object + description: Result of a successful task. + nullable: true + state: + $ref: '#/components/schemas/TaskState' + substate: + type: object + description: Progress of a running task. + nullable: true + TaskState: + type: string + enum: + - not-started + - running + - success + - failed TrafficEventResponse: type: object required: @@ -2510,6 +2688,11 @@ components: scheme: bearer bearerFormat: JWT description: Token allowing a single HTTP request for a specific scope + task_token: + type: http + scheme: bearer + bearerFormat: JWT + description: Token authorizing one kind of background task on a specific target web_app_custom_auth: type: http scheme: basic diff --git a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES index 84eba61ed..97195badb 100644 --- a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES @@ -13,6 +13,7 @@ api/net.service.ts api/networkMonitoring.service.ts api/preflight.service.ts api/sessions.service.ts +api/tasks.service.ts api/traffic.service.ts api/update.service.ts api/webApp.service.ts @@ -26,6 +27,10 @@ model/addressFamily.ts model/agentDomainAdvertisement.ts model/agentInfo.ts model/agentStatus.ts +model/aiLogParams.ts +model/aiLogSubstate.ts +model/aiLogSubstateOneOf.ts +model/aiProvider.ts model/appCredential.ts model/appCredentialKind.ts model/appTokenContentType.ts @@ -78,6 +83,9 @@ model/setUpdateScheduleRequest.ts model/subProvisionerKey.ts model/subscriber.ts model/targetConnectionOptions.ts +model/taskErrorResponse.ts +model/taskInfo.ts +model/taskState.ts model/trafficEventResponse.ts model/transportProtocolResponse.ts model/updateProductInfo.ts diff --git a/devolutions-gateway/openapi/ts-angular-client/api/api.ts b/devolutions-gateway/openapi/ts-angular-client/api/api.ts index a7eb029c9..97736f498 100644 --- a/devolutions-gateway/openapi/ts-angular-client/api/api.ts +++ b/devolutions-gateway/openapi/ts-angular-client/api/api.ts @@ -20,10 +20,12 @@ export * from './preflight.service'; import { PreflightService } from './preflight.service'; export * from './sessions.service'; import { SessionsService } from './sessions.service'; +export * from './tasks.service'; +import { TasksService } from './tasks.service'; export * from './traffic.service'; import { TrafficService } from './traffic.service'; export * from './update.service'; import { UpdateService } from './update.service'; export * from './webApp.service'; import { WebAppService } from './webApp.service'; -export const APIS = [AgentService, ConfigService, DiagnosticsService, HealthService, HeartbeatService, JrecService, JrlService, NetService, NetworkMonitoringService, PreflightService, SessionsService, TrafficService, UpdateService, WebAppService]; +export const APIS = [AgentService, ConfigService, DiagnosticsService, HealthService, HeartbeatService, JrecService, JrlService, NetService, NetworkMonitoringService, PreflightService, SessionsService, TasksService, TrafficService, UpdateService, WebAppService]; diff --git a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts new file mode 100644 index 000000000..bdcdc82bd --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts @@ -0,0 +1,251 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { AiLogParams } from '../model/aiLogParams'; +// @ts-ignore +import { TaskErrorResponse } from '../model/taskErrorResponse'; +// @ts-ignore +import { TaskInfo } from '../model/taskInfo'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; + + + +@Injectable({ + providedIn: 'root' +}) +export class TasksService { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * Gets the status of a background task. + * Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + * @param id Task ID + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getTask(id: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getTask(id: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getTask(id: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getTask(id: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling getTask.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (scope_token) required + localVarCredential = this.configuration.lookupCredential('scope_token'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jet/tasks/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * Starts a background task. + * The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + * @param aiLogParams Kind-specific task parameters + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public startTask(aiLogParams: AiLogParams, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public startTask(aiLogParams: AiLogParams, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startTask(aiLogParams: AiLogParams, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startTask(aiLogParams: AiLogParams, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (aiLogParams === null || aiLogParams === undefined) { + throw new Error('Required parameter aiLogParams was null or undefined when calling startTask.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (task_token) required + localVarCredential = this.configuration.lookupCredential('task_token'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jet/tasks`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: aiLogParams, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/devolutions-gateway/openapi/ts-angular-client/configuration.ts b/devolutions-gateway/openapi/ts-angular-client/configuration.ts index f11d4c083..ddb356684 100644 --- a/devolutions-gateway/openapi/ts-angular-client/configuration.ts +++ b/devolutions-gateway/openapi/ts-angular-client/configuration.ts @@ -132,6 +132,15 @@ export class Configuration { }; } + // init default task_token credential + if (!this.credentials['task_token']) { + this.credentials['task_token'] = () => { + return typeof this.accessToken === 'function' + ? this.accessToken() + : this.accessToken; + }; + } + // init default web_app_custom_auth credential if (!this.credentials['web_app_custom_auth']) { this.credentials['web_app_custom_auth'] = () => { diff --git a/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts b/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts index 0827c35b7..58da5f6a9 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts @@ -9,7 +9,7 @@ */ -export type AccessScope = '*' | 'gateway.sessions.read' | 'gateway.session.terminate' | 'gateway.associations.read' | 'gateway.diagnostics.read' | 'gateway.jrl.read' | 'gateway.config.write' | 'gateway.heartbeat.read' | 'gateway.recording.delete' | 'gateway.recordings.read' | 'gateway.update' | 'gateway.update.read' | 'gateway.preflight' | 'gateway.traffic.claim' | 'gateway.traffic.ack' | 'gateway.net.monitor.config' | 'gateway.net.monitor.drain' | 'gateway.agent.delete' | 'gateway.agent.read'; +export type AccessScope = '*' | 'gateway.sessions.read' | 'gateway.session.terminate' | 'gateway.associations.read' | 'gateway.diagnostics.read' | 'gateway.jrl.read' | 'gateway.config.write' | 'gateway.heartbeat.read' | 'gateway.recording.delete' | 'gateway.recordings.read' | 'gateway.update' | 'gateway.update.read' | 'gateway.preflight' | 'gateway.traffic.claim' | 'gateway.traffic.ack' | 'gateway.net.monitor.config' | 'gateway.net.monitor.drain' | 'gateway.agent.delete' | 'gateway.agent.read' | 'gateway.tasks.read'; export const AccessScope = { Star: '*' as AccessScope, @@ -30,6 +30,7 @@ export const AccessScope = { GatewayNetMonitorConfig: 'gateway.net.monitor.config' as AccessScope, GatewayNetMonitorDrain: 'gateway.net.monitor.drain' as AccessScope, GatewayAgentDelete: 'gateway.agent.delete' as AccessScope, - GatewayAgentRead: 'gateway.agent.read' as AccessScope + GatewayAgentRead: 'gateway.agent.read' as AccessScope, + GatewayTasksRead: 'gateway.tasks.read' as AccessScope }; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts new file mode 100644 index 000000000..5448a9eb9 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts @@ -0,0 +1,38 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { AiProvider } from './aiProvider'; + + +/** + * AI settings used by an `ai-log` task. + */ +export interface AiLogParams { + /** + * Required by every provider; kept in memory for this task only. + */ + apiKey?: string | null; + /** + * Overrides the provider default; required for `openai-compatible`. + */ + baseUrl?: string | null; + /** + * Upper bound of tokens in each AI answer. + */ + maxOutputTokens?: number | null; + /** + * Model identifier, passed to the provider as is. + */ + model: string; + provider: AiProvider; +} +export namespace AiLogParams { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts new file mode 100644 index 000000000..9e4f39ec7 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts @@ -0,0 +1,22 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { AiLogSubstateOneOf } from './aiLogSubstateOneOf'; + + +/** + * Progress of a running `ai-log` task. + */ +/** + * @type AiLogSubstate + * Progress of a running `ai-log` task. + * @export + */ +export type AiLogSubstate = AiLogSubstateOneOf; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts new file mode 100644 index 000000000..baa68006c --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts @@ -0,0 +1,22 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface AiLogSubstateOneOf { + step: AiLogSubstateOneOf.Step; +} +export namespace AiLogSubstateOneOf { + export type Step = 'preparing'; + export const Step = { + Preparing: 'preparing' as Step + }; +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts new file mode 100644 index 000000000..da93793a9 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts @@ -0,0 +1,20 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export type AiProvider = 'openai' | 'anthropic' | 'mistral' | 'openai-compatible'; + +export const AiProvider = { + Openai: 'openai' as AiProvider, + Anthropic: 'anthropic' as AiProvider, + Mistral: 'mistral' as AiProvider, + OpenaiCompatible: 'openai-compatible' as AiProvider +}; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/models.ts b/devolutions-gateway/openapi/ts-angular-client/model/models.ts index 7d7eb4f0f..591a1d095 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/models.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/models.ts @@ -5,6 +5,10 @@ export * from './addressFamily'; export * from './agentDomainAdvertisement'; export * from './agentInfo'; export * from './agentStatus'; +export * from './aiLogParams'; +export * from './aiLogSubstate'; +export * from './aiLogSubstateOneOf'; +export * from './aiProvider'; export * from './appCredential'; export * from './appCredentialKind'; export * from './appTokenContentType'; @@ -56,6 +60,9 @@ export * from './setUpdateScheduleRequest'; export * from './subProvisionerKey'; export * from './subscriber'; export * from './targetConnectionOptions'; +export * from './taskErrorResponse'; +export * from './taskInfo'; +export * from './taskState'; export * from './trafficEventResponse'; export * from './transportProtocolResponse'; export * from './updateProductInfo'; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts new file mode 100644 index 000000000..aa6ec36ed --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts @@ -0,0 +1,21 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * Why a task was not started. + */ +export interface TaskErrorResponse { + /** + * Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. + */ + error: string; +} + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts new file mode 100644 index 000000000..e2d2c14ae --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts @@ -0,0 +1,42 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { TaskState } from './taskState'; + + +/** + * A background task and its status. `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + */ +export interface TaskInfo { + /** + * Why the task failed. + */ + error?: string | null; + /** + * Task ID. + */ + id: string; + /** + * Task kind, as in the `jet_tk` claim of the TASK token. + */ + kind: string; + /** + * Result of a successful task. + */ + result?: object | null; + state: TaskState; + /** + * Progress of a running task. + */ + substate?: object | null; +} +export namespace TaskInfo { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts new file mode 100644 index 000000000..7cadf44e1 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts @@ -0,0 +1,20 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export type TaskState = 'not-started' | 'running' | 'success' | 'failed'; + +export const TaskState = { + NotStarted: 'not-started' as TaskState, + Running: 'running' as TaskState, + Success: 'success' as TaskState, + Failed: 'failed' as TaskState +}; + From cb920cb7b781290771a63e8509d6053c665df63a Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sat, 26 Sep 2026 13:55:50 -0400 Subject: [PATCH 3/9] feat(dgw): persist background task records in provisioner_tasks.db Every task started through POST /jet/tasks now has a record in its own database (provisioner_tasks.db, config key ProvisionerTasksDatabase) that is never deleted, so GET /jet/tasks/{id} keeps answering after a restart. Tasks run as job queue jobs and reuse its backoff and attempt limit. Durable tasks resume after a restart; ephemeral tasks such as ai-log keep their secrets (the AI API key) in memory only, so after a restart they fail with "gateway restarted, API key no longer available" instead of retrying. Neither database ever holds a secret. Transient errors are retried, permanent ones fail right away. At startup, unfinished tasks with no job left are marked as failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 13 + crates/job-queue-libsql/src/lib.rs | 21 + crates/job-queue/src/lib.rs | 3 + .../provisioner-task-store-libsql/Cargo.toml | 20 + .../provisioner-task-store-libsql/src/lib.rs | 415 ++++++++++ .../tests/store.rs | 182 +++++ devolutions-gateway/Cargo.toml | 1 + devolutions-gateway/src/api/tasks.rs | 11 +- devolutions-gateway/src/config.rs | 13 + devolutions-gateway/src/job_queue.rs | 24 +- devolutions-gateway/src/lib.rs | 4 +- devolutions-gateway/src/service.rs | 16 +- devolutions-gateway/src/tasks/ai_log.rs | 230 ++++-- devolutions-gateway/src/tasks/mod.rs | 736 ++++++++++-------- devolutions-gateway/src/tasks/tests.rs | 344 ++++++++ devolutions-gateway/tests/config.rs | 6 + devolutions-gateway/tests/tasks.rs | 265 ++++++- 17 files changed, 1888 insertions(+), 416 deletions(-) create mode 100644 crates/provisioner-task-store-libsql/Cargo.toml create mode 100644 crates/provisioner-task-store-libsql/src/lib.rs create mode 100644 crates/provisioner-task-store-libsql/tests/store.rs create mode 100644 devolutions-gateway/src/tasks/tests.rs diff --git a/Cargo.lock b/Cargo.lock index a7826f0f9..834da196c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,6 +1881,7 @@ dependencies = [ "picky-krb", "pin-project-lite 0.2.17", "proptest", + "provisioner-task-store-libsql", "rand 0.10.2", "reqwest", "rstest", @@ -5992,6 +5993,18 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "provisioner-task-store-libsql" +version = "0.0.0" +dependencies = [ + "anyhow", + "libsql", + "tempfile", + "tokio 1.52.3", + "tracing", + "uuid", +] + [[package]] name = "proxy-generators" version = "0.0.0" diff --git a/crates/job-queue-libsql/src/lib.rs b/crates/job-queue-libsql/src/lib.rs index 0efbfd23f..ff62e7da3 100644 --- a/crates/job-queue-libsql/src/lib.rs +++ b/crates/job-queue-libsql/src/lib.rs @@ -402,6 +402,27 @@ impl JobQueue for LibSqlJobQueue { Ok(Some(scheduled_for)) } + + async fn job_defs(&self, name: &str) -> anyhow::Result> { + let sql_query = "SELECT json(def) FROM job_queue WHERE name = $1"; + let params = [name]; + + trace!(%sql_query, ?params, "Listing job definitions"); + + let mut rows = self + .conn + .query(sql_query, params) + .await + .context("failed to execute SQL query")?; + + let mut defs = Vec::new(); + + while let Some(row) = rows.next().await.context("failed to read the row")? { + defs.push(row.get::(0).context("failed to read def value")?); + } + + Ok(defs) + } } // Typically, migrations should not be modified once released, and we should only be appending to this list. diff --git a/crates/job-queue/src/lib.rs b/crates/job-queue/src/lib.rs index 93232bd86..1f1e42111 100644 --- a/crates/job-queue/src/lib.rs +++ b/crates/job-queue/src/lib.rs @@ -62,6 +62,9 @@ pub trait JobQueue: Send + Sync { /// Retrieves the closest future scheduled date async fn next_scheduled_date(&self) -> anyhow::Result>; + + /// Returns the JSON definition of every job with this name still in the queue + async fn job_defs(&self, name: &str) -> anyhow::Result>; } pub struct JobCtx { diff --git a/crates/provisioner-task-store-libsql/Cargo.toml b/crates/provisioner-task-store-libsql/Cargo.toml new file mode 100644 index 000000000..defbe6e19 --- /dev/null +++ b/crates/provisioner-task-store-libsql/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "provisioner-task-store-libsql" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +publish = false + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +libsql = { version = "0.9", default-features = false, features = ["core"] } +tracing = "0.1" +uuid = "1.23" + +[dev-dependencies] +tempfile = "3.24" +tokio = { version = "1", features = ["time", "macros", "rt", "rt-multi-thread"] } +uuid = { version = "1.23", features = ["v4"] } diff --git a/crates/provisioner-task-store-libsql/src/lib.rs b/crates/provisioner-task-store-libsql/src/lib.rs new file mode 100644 index 000000000..bb75075a5 --- /dev/null +++ b/crates/provisioner-task-store-libsql/src/lib.rs @@ -0,0 +1,415 @@ +//! Records of the background tasks started by the provisioner (DVLS), stored in a libSQL database. +//! +//! Rows are never deleted, so the records can be audited later. + +#[macro_use] +extern crate tracing; + +use anyhow::Context as _; +use libsql::{Connection, Row}; +use uuid::Uuid; + +#[rustfmt::skip] +pub use libsql; + +// Released migrations are never modified; new ones are appended. +const MIGRATIONS: &[&str] = &[ + // Migration 0 + "CREATE TABLE task ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + target BLOB NOT NULL, + params BLOB NOT NULL, + state INT NOT NULL CHECK (state IN (0, 1, 2, 3)), + substate BLOB NULL, + result BLOB NULL, + error TEXT NULL, + attempts INT NOT NULL DEFAULT 0, + token_jti TEXT NOT NULL, + created_at INT NOT NULL DEFAULT (unixepoch()), + started_at INT NULL, + finished_at INT NULL, + updated_at INT NOT NULL DEFAULT (unixepoch()) + ) STRICT; + + CREATE TRIGGER update_task_updated_at_on_update AFTER UPDATE ON task + BEGIN + UPDATE task SET updated_at = unixepoch() WHERE id == NEW.id; + END; + + CREATE INDEX idx_task_kind_created_at ON task(kind, created_at);", +]; + +const SELECT_COLUMNS: &str = "id, kind, json(target), json(params), state, json(substate), json(result), error, \ + attempts, token_jti, created_at, started_at, finished_at, updated_at"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + NotStarted, + Running, + Success, + Failed, +} + +impl TaskState { + fn to_db(self) -> i64 { + match self { + TaskState::NotStarted => 0, + TaskState::Running => 1, + TaskState::Success => 2, + TaskState::Failed => 3, + } + } + + fn from_db(value: i64) -> anyhow::Result { + match value { + 0 => Ok(TaskState::NotStarted), + 1 => Ok(TaskState::Running), + 2 => Ok(TaskState::Success), + 3 => Ok(TaskState::Failed), + _ => anyhow::bail!("unknown task state {value}"), + } + } +} + +/// A task to record; `target` and `params` are JSON documents. +#[derive(Debug, Clone, Copy)] +pub struct NewTask<'a> { + pub id: Uuid, + pub kind: &'a str, + pub target: &'a str, + pub params: &'a str, + pub token_jti: Uuid, +} + +/// A stored task; JSON columns are returned as JSON text, timestamps as UNIX seconds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskRecord { + pub id: Uuid, + pub kind: String, + pub target: String, + pub params: String, + pub state: TaskState, + pub substate: Option, + pub result: Option, + pub error: Option, + pub attempts: u32, + pub token_jti: Uuid, + pub created_at: i64, + pub started_at: Option, + pub finished_at: Option, + pub updated_at: i64, +} + +pub struct LibSqlProvisionerTaskStore { + conn: Connection, +} + +impl LibSqlProvisionerTaskStore { + /// Opens the database at `path` (or `:memory:`) and applies the pending migrations. + pub async fn open(path: &str) -> anyhow::Result { + let conn = libsql::Builder::new_local(path) + .build() + .await + .context("failed to open libSQL database")? + .connect() + .context("failed to connect to libSQL")?; + + let store = Self { conn }; + store.apply_pragmas().await?; + store.migrate().await?; + + Ok(store) + } + + pub async fn insert(&self, task: NewTask<'_>) -> anyhow::Result<()> { + let sql_query = "INSERT INTO task (id, kind, target, params, state, token_jti) + VALUES (:id, :kind, jsonb(:target), jsonb(:params), :state, :token_jti)"; + + let params = ( + (":id", task.id.to_string()), + (":kind", task.kind), + (":target", task.target), + (":params", task.params), + (":state", TaskState::NotStarted.to_db()), + (":token_jti", task.token_jti.to_string()), + ); + + trace!(%sql_query, task.id = %task.id, task.kind = task.kind, "Insert task"); + + self.conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } + + pub async fn get(&self, id: Uuid) -> anyhow::Result> { + let sql_query = format!("SELECT {SELECT_COLUMNS} FROM task WHERE id = :id"); + + let mut rows = self + .conn + .query(&sql_query, [(":id", id.to_string())]) + .await + .context("failed to execute SQL query")?; + + match rows.next().await.context("failed to read the row")? { + Some(row) => read_record(&row).map(Some), + None => Ok(None), + } + } + + /// Starts a new attempt of an unfinished task and returns its attempt number, or `None` if the task is finished. + pub async fn start_attempt(&self, id: Uuid, substate: &str) -> anyhow::Result> { + let sql_query = "UPDATE task + SET + state = :running, + substate = jsonb(:substate), + attempts = attempts + 1, + started_at = coalesce(started_at, unixepoch()) + WHERE id = :id AND state IN (:not_started, :running) + RETURNING attempts"; + + let params = ( + (":running", TaskState::Running.to_db()), + (":substate", substate), + (":id", id.to_string()), + (":not_started", TaskState::NotStarted.to_db()), + ); + + let mut rows = self + .conn + .query(sql_query, params) + .await + .context("failed to execute SQL query")?; + + match rows.next().await.context("failed to read the row")? { + Some(row) => Ok(Some(row.get::(0).context("failed to read attempts")?)), + None => Ok(None), + } + } + + /// Updates the substate of a running task. + pub async fn set_substate(&self, id: Uuid, substate: &str) -> anyhow::Result<()> { + let sql_query = "UPDATE task SET substate = jsonb(:substate) WHERE id = :id AND state = :running"; + + let params = ( + (":substate", substate), + (":id", id.to_string()), + (":running", TaskState::Running.to_db()), + ); + + self.conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } + + /// Puts a running task back to `NotStarted` until its next attempt, keeping the error of the failed attempt. + pub async fn retry_later(&self, id: Uuid, error: &str) -> anyhow::Result<()> { + let sql_query = "UPDATE task + SET state = :not_started, substate = NULL, error = :error + WHERE id = :id AND state = :running"; + + let params = ( + (":not_started", TaskState::NotStarted.to_db()), + (":error", error), + (":id", id.to_string()), + (":running", TaskState::Running.to_db()), + ); + + self.conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } + + /// Marks an unfinished task as successful; returns `false` if it was already finished. + pub async fn succeed(&self, id: Uuid, result: &str) -> anyhow::Result { + self.finish(id, TaskState::Success, Some(result), None).await + } + + /// Marks an unfinished task as failed; returns `false` if it was already finished. + pub async fn fail(&self, id: Uuid, error: &str) -> anyhow::Result { + self.finish(id, TaskState::Failed, None, Some(error)).await + } + + async fn finish( + &self, + id: Uuid, + state: TaskState, + result: Option<&str>, + error: Option<&str>, + ) -> anyhow::Result { + let sql_query = "UPDATE task + SET + state = :state, + substate = NULL, + result = jsonb(:result), + error = :error, + finished_at = unixepoch() + WHERE id = :id AND state IN (:not_started, :running)"; + + let params = ( + (":state", state.to_db()), + (":result", result), + (":error", error), + (":id", id.to_string()), + (":not_started", TaskState::NotStarted.to_db()), + (":running", TaskState::Running.to_db()), + ); + + let changed = self + .conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(changed > 0) + } + + /// IDs of the tasks that are not finished yet. + pub async fn unfinished(&self) -> anyhow::Result> { + let sql_query = "SELECT id FROM task WHERE state IN (:not_started, :running) ORDER BY created_at"; + + let params = ( + (":not_started", TaskState::NotStarted.to_db()), + (":running", TaskState::Running.to_db()), + ); + + let mut rows = self + .conn + .query(sql_query, params) + .await + .context("failed to execute SQL query")?; + + let mut ids = Vec::new(); + + while let Some(row) = rows.next().await.context("failed to read the row")? { + ids.push(read_uuid(&row, 0)?); + } + + Ok(ids) + } + + async fn apply_pragmas(&self) -> anyhow::Result<()> { + const PRAGMAS: &str = " + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA busy_timeout = 15000; + PRAGMA cache_size = -3000; + PRAGMA temp_store = MEMORY; + "; + + let mut batch_rows = self + .conn + .execute_batch(PRAGMAS) + .await + .context("failed to batch execute SQL query")?; + + while let Some(rows) = batch_rows.next_stmt_row() { + let Some(mut rows) = rows else { + continue; + }; + + while let Ok(Some(row)) = rows.next().await { + trace!(?row, "PRAGMA row"); + } + } + + Ok(()) + } + + async fn migrate(&self) -> anyhow::Result<()> { + let user_version = self.query_user_version().await?; + + match MIGRATIONS.get(user_version..) { + Some(remaining) if !remaining.is_empty() => { + info!( + user_version, + migration_count = MIGRATIONS.len() - user_version, + "Start migration" + ); + + for (sql_query, migration_id) in remaining.iter().zip(user_version..MIGRATIONS.len()) { + trace!(migration_id, %sql_query, "Apply migration"); + + self.conn + .execute_batch(sql_query) + .await + .with_context(|| format!("failed to execute migration {migration_id}"))?; + + self.update_user_version(migration_id + 1) + .await + .context("failed to update user version")?; + } + + info!("Migration complete"); + } + None => { + warn!(user_version, "user_version is set to an unexpected value"); + } + _ => { + debug!(user_version, "Database is already up to date"); + } + } + + Ok(()) + } + + async fn query_user_version(&self) -> anyhow::Result { + let row = self + .conn + .query("PRAGMA user_version", ()) + .await + .context("failed to execute SQL query")? + .next() + .await + .context("failed to read the row")? + .context("no row returned")?; + + let value = row.get::(0).context("failed to read user_version value")?; + + usize::try_from(value).context("user_version is too big") + } + + async fn update_user_version(&self, value: usize) -> anyhow::Result<()> { + let sql_query = format!("PRAGMA user_version = {value}"); + + self.conn + .execute(&sql_query, ()) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } +} + +fn read_uuid(row: &Row, idx: i32) -> anyhow::Result { + let text = row.get::(idx).context("failed to read UUID column")?; + Uuid::parse_str(&text).context("invalid UUID") +} + +fn read_record(row: &Row) -> anyhow::Result { + Ok(TaskRecord { + id: read_uuid(row, 0)?, + kind: row.get(1).context("failed to read kind")?, + target: row.get(2).context("failed to read target")?, + params: row.get(3).context("failed to read params")?, + state: TaskState::from_db(row.get(4).context("failed to read state")?)?, + substate: row.get(5).context("failed to read substate")?, + result: row.get(6).context("failed to read result")?, + error: row.get(7).context("failed to read error")?, + attempts: row.get(8).context("failed to read attempts")?, + token_jti: read_uuid(row, 9)?, + created_at: row.get(10).context("failed to read created_at")?, + started_at: row.get(11).context("failed to read started_at")?, + finished_at: row.get(12).context("failed to read finished_at")?, + updated_at: row.get(13).context("failed to read updated_at")?, + }) +} diff --git a/crates/provisioner-task-store-libsql/tests/store.rs b/crates/provisioner-task-store-libsql/tests/store.rs new file mode 100644 index 000000000..b594cd85d --- /dev/null +++ b/crates/provisioner-task-store-libsql/tests/store.rs @@ -0,0 +1,182 @@ +#![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used)] + +use provisioner_task_store_libsql::{LibSqlProvisionerTaskStore, NewTask, TaskState}; +use uuid::Uuid; + +fn new_task<'a>(id: Uuid, token_jti: Uuid) -> NewTask<'a> { + NewTask { + id, + kind: "ai-log", + target: r#"{"sessionId":"3e2b1d6c-5d1a-4a8c-9a8c-1d6f9b2a4c11"}"#, + params: r#"{"provider":"openai","model":"gpt-test"}"#, + token_jti, + } +} + +async fn memory_store() -> LibSqlProvisionerTaskStore { + LibSqlProvisionerTaskStore::open(":memory:").await.unwrap() +} + +#[tokio::test] +async fn migrations_set_user_version_and_are_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("provisioner_tasks.db"); + let path = path.to_str().unwrap(); + + let id = Uuid::new_v4(); + { + let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + } + + let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + assert!(store.get(id).await.unwrap().is_some()); + + let conn = libsql::Builder::new_local(path) + .build() + .await + .unwrap() + .connect() + .unwrap(); + let row = conn + .query("PRAGMA user_version", ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap(); + assert_eq!(row.get::(0).unwrap(), 1); +} + +#[tokio::test] +async fn insert_then_read_back() { + let store = memory_store().await; + let id = Uuid::new_v4(); + let jti = Uuid::new_v4(); + + store.insert(new_task(id, jti)).await.unwrap(); + + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.id, id); + assert_eq!(record.kind, "ai-log"); + assert_eq!(record.target, r#"{"sessionId":"3e2b1d6c-5d1a-4a8c-9a8c-1d6f9b2a4c11"}"#); + assert_eq!(record.params, r#"{"provider":"openai","model":"gpt-test"}"#); + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.attempts, 0); + assert_eq!(record.token_jti, jti); + assert!(record.substate.is_none() && record.result.is_none() && record.error.is_none()); + assert!(record.started_at.is_none() && record.finished_at.is_none()); + assert!(record.created_at > 0); + + assert!(store.get(Uuid::new_v4()).await.unwrap().is_none()); +} + +#[tokio::test] +async fn attempts_substate_retry_and_success() { + let store = memory_store().await; + let id = Uuid::new_v4(); + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + + assert_eq!( + store.start_attempt(id, r#"{"step":"preparing"}"#).await.unwrap(), + Some(1) + ); + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::Running); + assert_eq!(record.substate.as_deref(), Some(r#"{"step":"preparing"}"#)); + assert!(record.started_at.is_some()); + + store.set_substate(id, r#"{"step":"reading"}"#).await.unwrap(); + assert_eq!( + store.get(id).await.unwrap().unwrap().substate.as_deref(), + Some(r#"{"step":"reading"}"#) + ); + + store.retry_later(id, "rate limited").await.unwrap(); + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.error.as_deref(), Some("rate limited")); + assert!(record.substate.is_none()); + + assert_eq!(store.start_attempt(id, "null").await.unwrap(), Some(2)); + assert!(store.succeed(id, r#"{"log":"log-1.slog"}"#).await.unwrap()); + + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::Success); + assert_eq!(record.result.as_deref(), Some(r#"{"log":"log-1.slog"}"#)); + assert!(record.error.is_none()); + assert!(record.finished_at.is_some()); + assert_eq!(record.attempts, 2); +} + +#[tokio::test] +async fn finished_tasks_are_not_changed_again() { + let store = memory_store().await; + let id = Uuid::new_v4(); + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + + assert!(store.fail(id, "boom").await.unwrap()); + assert!(!store.fail(id, "again").await.unwrap()); + assert!(!store.succeed(id, "1").await.unwrap()); + assert_eq!(store.start_attempt(id, "null").await.unwrap(), None); + store.set_substate(id, "2").await.unwrap(); + + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some("boom")); + assert!(record.substate.is_none() && record.result.is_none()); +} + +#[tokio::test] +async fn unfinished_lists_not_started_and_running_tasks() { + let store = memory_store().await; + let [not_started, running, succeeded, failed] = [(); 4].map(|()| Uuid::new_v4()); + + for id in [not_started, running, succeeded, failed] { + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + } + + store.start_attempt(running, "null").await.unwrap(); + store.succeed(succeeded, "null").await.unwrap(); + store.fail(failed, "boom").await.unwrap(); + + let mut unfinished = store.unfinished().await.unwrap(); + unfinished.sort(); + let mut expected = vec![not_started, running]; + expected.sort(); + + assert_eq!(unfinished, expected); +} + +#[tokio::test] +async fn rows_are_never_deleted() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("provisioner_tasks.db"); + let path = path.to_str().unwrap(); + + let ids = [(); 3].map(|()| Uuid::new_v4()); + { + let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + for id in ids { + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + } + store.succeed(ids[0], "null").await.unwrap(); + store.fail(ids[1], "boom").await.unwrap(); + } + + let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + for id in ids { + assert!(store.get(id).await.unwrap().is_some(), "{id}"); + } +} + +#[tokio::test] +async fn duplicate_id_is_rejected() { + let store = memory_store().await; + let id = Uuid::new_v4(); + + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + assert!(store.insert(new_task(id, Uuid::new_v4())).await.is_err()); +} diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index d1e51555a..1d3adb78e 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -28,6 +28,7 @@ devolutions-gateway-ai.path = "../crates/devolutions-gateway-ai" devolutions-log.path = "../crates/devolutions-log" job-queue.path = "../crates/job-queue" job-queue-libsql.path = "../crates/job-queue-libsql" +provisioner-task-store-libsql.path = "../crates/provisioner-task-store-libsql" traffic-audit.path = "../crates/traffic-audit" traffic-audit-libsql.path = "../crates/traffic-audit-libsql" network-scanner.path = "../crates/network-scanner" diff --git a/devolutions-gateway/src/api/tasks.rs b/devolutions-gateway/src/api/tasks.rs index 21032daf9..4f02b8ba7 100644 --- a/devolutions-gateway/src/api/tasks.rs +++ b/devolutions-gateway/src/api/tasks.rs @@ -53,7 +53,8 @@ pub(crate) async fn start_task( TaskKind::AiLog { jet_aid } => { state .tasks - .start::(AiLogTarget { session_id: jet_aid }, &body, &state)? + .start_ephemeral::(AiLogTarget { session_id: jet_aid }, &body, claims.jti, &state) + .await? } }; @@ -62,7 +63,7 @@ pub(crate) async fn start_task( /// Gets the status of a background task. /// -/// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. +/// Task records are kept forever, including across Gateway restarts. /// /// This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. #[cfg_attr(feature = "openapi", utoipa::path( @@ -90,6 +91,8 @@ pub(crate) async fn get_task( state .tasks .get(id) + .await + .map_err(HttpError::internal().with_msg("failed to read the task").err())? .map(|snapshot| Json(TaskInfo::from(snapshot))) .ok_or_else(|| HttpError::not_found().msg("task not found")) } @@ -124,7 +127,7 @@ pub(crate) struct TaskInfo { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum TaskState { - /// The task waits for a free slot. + /// The task waits for a free slot or for its next attempt. NotStarted, Running, Success, @@ -142,7 +145,7 @@ impl From for TaskInfo { Self { id: snapshot.id, - kind: snapshot.kind.to_owned(), + kind: snapshot.kind, state, substate, result, diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index 98810bd8a..0c142401e 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -162,6 +162,7 @@ pub struct Conf { pub log_file: Utf8PathBuf, pub job_queue_database: Utf8PathBuf, pub traffic_audit_database: Utf8PathBuf, + pub provisioner_tasks_database: Utf8PathBuf, pub tls: Option, pub credssp_tls: CredsspTls, pub provisioner_public_key: PublicKey, @@ -371,6 +372,12 @@ impl Conf { .unwrap_or_else(|| Utf8PathBuf::from("traffic_audit.db")) .pipe_ref(|path| normalize_data_path(path, &data_dir)); + let provisioner_tasks_database = conf_file + .provisioner_tasks_database + .clone() + .unwrap_or_else(|| Utf8PathBuf::from("provisioner_tasks.db")) + .pipe_ref(|path| normalize_data_path(path, &data_dir)); + let jrl_file = conf_file .jrl_file .clone() @@ -429,6 +436,7 @@ impl Conf { log_file, job_queue_database, traffic_audit_database, + provisioner_tasks_database, tls, credssp_tls, provisioner_public_key, @@ -1248,6 +1256,10 @@ pub mod dto { #[serde(skip_serializing_if = "Option::is_none")] pub traffic_audit_database: Option, + /// (Unstable) Path to the SQLite database file for the records of the tasks started by the provisioner + #[serde(skip_serializing_if = "Option::is_none")] + pub provisioner_tasks_database: Option, + /// HTTP/SOCKS proxy configuration for outbound requests #[serde(skip_serializing_if = "Option::is_none")] pub proxy: Option, @@ -1311,6 +1323,7 @@ pub mod dto { web_app: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, agent_tunnel: None, proxy: None, debug: None, diff --git a/devolutions-gateway/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 8985f3184..173644034 100644 --- a/devolutions-gateway/src/job_queue.rs +++ b/devolutions-gateway/src/job_queue.rs @@ -11,6 +11,11 @@ use job_queue_libsql::libsql; use time::OffsetDateTime; use tokio::sync::{Notify, mpsc}; +use crate::DgwState; + +/// Attempts of a job before the queue gives up on it. +pub const JOB_MAX_ATTEMPTS: u32 = 5; + pub struct JobQueueCtx { notify_runner: Arc, runner_waker: RunnerWaker, @@ -38,6 +43,7 @@ pub struct JobRunnerTask { notify_runner: Arc, runner_waker: RunnerWaker, queue: DynJobQueue, + state: DgwState, } impl JobQueueCtx { @@ -59,6 +65,7 @@ impl JobQueueCtx { let queue = job_queue_libsql::LibSqlJobQueue::builder() .runner_waker(runner_waker.clone()) .conn(conn) + .max_attempts(JOB_MAX_ATTEMPTS) .build(); let queue = Arc::new(queue); @@ -82,6 +89,11 @@ impl JobQueueCtx { job_queue_handle: handle, }) } + + /// Returns the JSON definition of every queued job with this name. + pub async fn job_defs(&self, name: &str) -> anyhow::Result> { + self.queue.job_defs(name).await + } } impl JobQueueHandle { @@ -197,11 +209,12 @@ async fn job_queue_task(ctx: JobQueueTask, mut shutdown_signal: ShutdownSignal) } impl JobRunnerTask { - pub fn new(ctx: &JobQueueCtx) -> Self { + pub fn new(ctx: &JobQueueCtx, state: DgwState) -> Self { Self { notify_runner: Arc::clone(&ctx.notify_runner), runner_waker: RunnerWaker::clone(&ctx.runner_waker), queue: Arc::clone(&ctx.queue), + state, } } } @@ -225,9 +238,10 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal notify_runner, runner_waker, queue, + state, } = ctx; - let reader = DgwJobReader; + let reader = DgwJobReader { state }; let spawn = |mut ctx: JobCtx, callback: job_queue::SpawnCallback| { tokio::spawn(async move { @@ -279,12 +293,15 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal Ok(()) } -struct DgwJobReader; +struct DgwJobReader { + state: DgwState, +} impl JobReader for DgwJobReader { fn read_json(&self, name: &str, json: &str) -> anyhow::Result { use crate::api::jrec::DeleteRecordingsJob; use crate::recording::RemuxJob; + use crate::tasks::TaskJob; match name { RemuxJob::NAME => { @@ -296,6 +313,7 @@ impl JobReader for DgwJobReader { serde_json::from_str(json).context("failed to deserialize DeleteRecordingsJob")?; Ok(Box::new(job)) } + TaskJob::NAME => Ok(Box::new(TaskJob::read_json(json, self.state.clone())?)), _ => anyhow::bail!("unknown job name: {name}"), } } diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index 0fc9df332..abeee4292 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -69,7 +69,7 @@ pub struct DgwState { pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, - pub tasks: tasks::TaskRegistry, + pub tasks: tasks::TaskService, } #[doc(hidden)] @@ -112,7 +112,7 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, - tasks: tasks::TaskRegistry::new(), + tasks: tasks::TaskService::mock(), }; let handles = MockHandles { diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index a4edc4b62..8153fc9e6 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -267,6 +267,15 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .await .context("failed to initialize traffic audit manager")?; + let provisioner_tasks = devolutions_gateway::tasks::TaskService::open(conf.provisioner_tasks_database.as_str()) + .await + .context("failed to initialize provisioner tasks")?; + + provisioner_tasks + .reconcile(&job_queue_ctx) + .await + .context("failed to reconcile provisioner tasks")?; + let provisioning = devolutions_gateway::provisioning::ProvisioningStore::new(); let synthetic_kdc_registry = devolutions_gateway::credential_injection::SyntheticKdcRegistry::new(); @@ -340,7 +349,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, - tasks: devolutions_gateway::tasks::TaskRegistry::new(), + tasks: provisioner_tasks, }; for listener in &conf.listeners { @@ -405,7 +414,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { job_queue_ctx.job_queue_handle.clone(), )); - tasks.register(devolutions_gateway::job_queue::JobRunnerTask::new(&job_queue_ctx)); + tasks.register(devolutions_gateway::job_queue::JobRunnerTask::new( + &job_queue_ctx, + state.clone(), + )); tasks.register(devolutions_gateway::job_queue::JobQueueTask::new(job_queue_ctx)); tasks.register(traffic_audit_task); diff --git a/devolutions-gateway/src/tasks/ai_log.rs b/devolutions-gateway/src/tasks/ai_log.rs index 1ad46058c..4da5b3f03 100644 --- a/devolutions-gateway/src/tasks/ai_log.rs +++ b/devolutions-gateway/src/tasks/ai_log.rs @@ -5,9 +5,11 @@ use secrecy::SecretString; use url::Url; use uuid::Uuid; -use super::{BackgroundTask, Persistence, Progress, StartError}; +use super::{EphemeralTask, RetryPolicy, SECRETS_LOST_ERROR, StartError, TaskCtx, TaskError, TaskKind}; use crate::DgwState; +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AiLogTarget { pub session_id: Uuid, } @@ -30,8 +32,18 @@ pub struct AiLogParams { pub max_output_tokens: Option, } +/// The persisted part of [`AiLogParams`]: everything but the API key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiLogSettings { + pub provider: AiProvider, + pub model: String, + pub base_url: Option, + pub max_output_tokens: Option, +} + #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AiProvider { #[serde(rename = "openai")] OpenAi, @@ -66,74 +78,120 @@ pub enum AiLogSubstate { #[derive(Debug, Serialize)] pub enum AiLogOutput {} -#[derive(Debug)] -pub struct AiLogTask { - #[expect(dead_code, reason = "read by the ai-log runner, which comes in a later change")] - session_id: Uuid, - #[expect(dead_code, reason = "read by the ai-log runner, which comes in a later change")] - client: AiClient, - #[expect(dead_code, reason = "read by the ai-log runner, which comes in a later change")] - max_output_tokens: Option, -} +pub enum AiLogTask {} -impl BackgroundTask for AiLogTask { +impl TaskKind for AiLogTask { const KIND: &'static str = "ai-log"; - const PERSISTENCE: Persistence = Persistence::InMemory; + const RETRY: RetryPolicy = RetryPolicy::JOB_QUEUE; - type Params = AiLogParams; type Target = AiLogTarget; + type Params = AiLogSettings; type Substate = AiLogSubstate; type Output = AiLogOutput; - fn prepare(target: AiLogTarget, params: AiLogParams, state: &DgwState) -> Result { + async fn run(ctx: TaskCtx) -> Result { + let Some(api_key) = ctx.secrets() else { + return Err(TaskError::Permanent(SECRETS_LOST_ERROR.to_owned())); + }; + + let _client = build_client(&ctx.params, Some(api_key), &ctx.state) + .map_err(|error| TaskError::Permanent(error.message()))?; + + Err(TaskError::Permanent("ai-log task not implemented yet".to_owned())) + } +} + +impl EphemeralTask for AiLogTask { + type Secrets = SecretString; + type Request = AiLogParams; + + fn prepare( + target: &AiLogTarget, + request: AiLogParams, + state: &DgwState, + ) -> Result<(AiLogSettings, SecretString), StartError> { if state.recordings.active_recordings.contains(target.session_id) { return Err(StartError::TargetBusy("recording_active")); } - let provider = Provider::from(params.provider); + let AiLogParams { + provider, + model, + api_key, + base_url, + max_output_tokens, + } = request; + + let settings = AiLogSettings { + provider, + model, + base_url, + max_output_tokens, + }; + + build_client(&settings, api_key.as_ref(), state).map_err(|error| match error { + ClientError::Build(error) => { + let code = build_error_code(&error); + warn!(%error, code, "Invalid AI settings"); + StartError::InvalidParams(code) + } + ClientError::HttpClient(error) => { + error!(%error, "Failed to build the HTTP client for the AI provider"); + StartError::Internal + } + })?; - let mut builder = AiClient::builder().provider(provider).model(params.model); + let api_key = api_key.ok_or(StartError::InvalidParams("missing_api_key"))?; - if let Some(api_key) = params.api_key { - builder = builder.api_key(api_key); - } + Ok((settings, api_key)) + } +} - let endpoint = params.base_url.clone().or_else(|| provider.default_base_url()); +enum ClientError { + Build(BuildError), + HttpClient(reqwest::Error), +} - if let Some(base_url) = params.base_url { - builder = builder.base_url(base_url); +impl ClientError { + fn message(&self) -> String { + match self { + ClientError::Build(error) => error.to_string(), + ClientError::HttpClient(error) => format!("failed to build the HTTP client: {error}"), } + } +} - // Without an endpoint, `build` reports the missing base URL before it needs the HTTP client. - if let Some(endpoint) = endpoint { - let proxy_config = state.conf_handle.get_conf().proxy.to_proxy_config(); +fn build_client( + settings: &AiLogSettings, + api_key: Option<&SecretString>, + state: &DgwState, +) -> Result { + let provider = Provider::from(settings.provider); - let http_client = - http_client_proxy::get_or_create_cached_client(reqwest::Client::builder(), &endpoint, &proxy_config) - .map_err(|error| { - error!(%error, "Failed to build the HTTP client for the AI provider"); - StartError::Internal - })?; + let mut builder = AiClient::builder().provider(provider).model(settings.model.clone()); - builder = builder.http_client(http_client); - } + if let Some(api_key) = api_key { + builder = builder.api_key(api_key.clone()); + } - let client = builder.build().map_err(|error| { - let code = build_error_code(&error); - warn!(%error, code, "Invalid AI settings"); - StartError::InvalidParams(code) - })?; + let endpoint = settings.base_url.clone().or_else(|| provider.default_base_url()); - Ok(Self { - session_id: target.session_id, - client, - max_output_tokens: params.max_output_tokens, - }) + if let Some(base_url) = settings.base_url.clone() { + builder = builder.base_url(base_url); } - async fn run(self, _progress: Progress) -> anyhow::Result { - anyhow::bail!("ai-log task not implemented yet") + // Without an endpoint, `build` reports the missing base URL before it needs the HTTP client. + if let Some(endpoint) = endpoint { + let proxy_config = state.conf_handle.get_conf().proxy.to_proxy_config(); + + let http_client = + http_client_proxy::get_or_create_cached_client(reqwest::Client::builder(), &endpoint, &proxy_config) + .map_err(ClientError::HttpClient)?; + + builder = builder.http_client(http_client); } + + builder.build().map_err(ClientError::Build) } fn build_error_code(error: &BuildError) -> &'static str { @@ -145,6 +203,20 @@ fn build_error_code(error: &BuildError) -> &'static str { } } +#[cfg_attr( + not(test), + expect(dead_code, reason = "used by the ai-log runner, which comes in a later change") +)] +fn classify_ai_error(error: &devolutions_gateway_ai::Error) -> TaskError { + let message = error.to_string(); + + if error.is_transient() { + TaskError::Transient(message) + } else { + TaskError::Permanent(message) + } +} + #[cfg(test)] mod tests { use super::*; @@ -168,33 +240,39 @@ mod tests { .expect("valid params") } + fn target() -> AiLogTarget { + AiLogTarget { + session_id: Uuid::new_v4(), + } + } + #[tokio::test] async fn refuses_a_session_that_is_still_recording() { let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); - let session_id = Uuid::new_v4(); - state.recordings.active_recordings.insert(session_id); + let target = target(); + state.recordings.active_recordings.insert(target.session_id); - let error = AiLogTask::prepare(AiLogTarget { session_id }, params(), &state).expect_err("session is busy"); + let error = AiLogTask::prepare(&target, params(), &state).expect_err("session is busy"); assert_eq!(error, StartError::TargetBusy("recording_active")); } #[tokio::test] - async fn debug_never_shows_the_api_key() { + async fn persisted_settings_never_hold_the_api_key() { let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); let params = params(); assert!(!format!("{params:?}").contains(API_KEY)); - let task = AiLogTask::prepare( - AiLogTarget { - session_id: Uuid::new_v4(), - }, - params, - &state, - ) - .expect("valid task"); - assert!(!format!("{task:?}").contains(API_KEY)); + let (settings, api_key) = AiLogTask::prepare(&target(), params, &state).expect("valid task"); + + let persisted = serde_json::to_string(&settings).expect("serializable settings"); + assert_eq!( + persisted, + r#"{"provider":"openai","model":"gpt-test","baseUrl":null,"maxOutputTokens":null}"# + ); + assert!(!format!("{settings:?}").contains(API_KEY)); + assert!(!format!("{api_key:?}").contains(API_KEY)); } #[test] @@ -209,4 +287,38 @@ mod tests { "missing_base_url" ); } + + #[test] + fn rate_limits_server_and_network_errors_are_transient() { + let status = |status| devolutions_gateway_ai::Error::Status { + status, + message: "failed".to_owned(), + }; + + let network = devolutions_gateway_ai::Error::Transport { + message: "connection refused".to_owned(), + }; + assert!(matches!(classify_ai_error(&network), TaskError::Transient(_))); + + for code in [429, 500, 503] { + assert!( + matches!(classify_ai_error(&status(code)), TaskError::Transient(_)), + "{code}" + ); + } + + for code in [400, 401, 403, 404] { + assert!( + matches!(classify_ai_error(&status(code)), TaskError::Permanent(_)), + "{code}" + ); + } + + assert!(matches!( + classify_ai_error(&devolutions_gateway_ai::Error::InvalidOutput { + reason: "no valid action line".to_owned() + }), + TaskError::Permanent(_) + )); + } } diff --git a/devolutions-gateway/src/tasks/mod.rs b/devolutions-gateway/src/tasks/mod.rs index 5f56bf2ff..b208579e9 100644 --- a/devolutions-gateway/src/tasks/mod.rs +++ b/devolutions-gateway/src/tasks/mod.rs @@ -1,61 +1,105 @@ -//! One-shot background tasks, started through `POST /jet/tasks` and polled through `GET /jet/tasks/{id}`. +//! Background tasks started by the provisioner through `POST /jet/tasks` and polled through `GET /jet/tasks/{id}`. //! -//! Tasks are kept in memory only, so they are lost when Gateway restarts. +//! Every task has a record in the provisioner task database, kept forever so it can be audited. +//! Each task runs as a job of the job queue. The job definition holds only the persisted, non-secret parameters, +//! so a [`DurableTask`] resumes after a restart. The secrets of an [`EphemeralTask`] stay in memory only: +//! when Gateway restarts, the task fails instead. pub mod ai_log; use core::marker::PhantomData; -use std::collections::HashMap; +use std::any::Any; +use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::Arc; use std::time::Duration; +use anyhow::Context as _; +use async_trait::async_trait; use parking_lot::Mutex; +use provisioner_task_store_libsql::{LibSqlProvisionerTaskStore, NewTask, TaskRecord, TaskState}; use serde::Serialize; use serde::de::DeserializeOwned; -use tokio::sync::Semaphore; +use tokio::sync::{OnceCell, Semaphore}; use uuid::Uuid; use crate::DgwState; +use crate::job_queue::{JOB_MAX_ATTEMPTS, JobQueueCtx}; /// Number of tasks running at the same time; other tasks wait in the `NotStarted` state. pub const MAX_CONCURRENT_TASKS: usize = 2; -/// Longest time a task may run, not counting the time it waits for a free slot. +/// Longest time one attempt of a task may run, not counting the time it waits for a free slot. pub const TASK_TIMEOUT: Duration = Duration::from_secs(30 * 60); -/// How long the status of a finished task can still be read. -pub const FINISHED_TASK_RETENTION: Duration = Duration::from_secs(60 * 60); +pub const SECRETS_LOST_ERROR: &str = "gateway restarted, API key no longer available"; + +pub const JOB_LOST_ERROR: &str = "gateway restarted, task job no longer exists"; + +/// Why a run of a task failed; the message is stored in the task record and returned by the API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskError { + /// Worth another attempt later, such as a rate limit or a network error. + Transient(String), + /// Another attempt would fail the same way. + Permanent(String), +} -/// Where the tasks of one kind are stored. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Persistence { - /// Tasks are dropped when Gateway restarts. - InMemory, +pub struct RetryPolicy { + /// Attempts in total, capped by the job queue. + pub max_attempts: u32, +} + +impl RetryPolicy { + pub const NO_RETRY: Self = Self { max_attempts: 1 }; + + pub const JOB_QUEUE: Self = Self { + max_attempts: JOB_MAX_ATTEMPTS, + }; } /// A kind of one-shot background task. -pub trait BackgroundTask: Sized + Send + 'static { +pub trait TaskKind: Sized + Send + Sync + 'static { /// Value of the TASK token `jet_tk` claim. const KIND: &'static str; - const PERSISTENCE: Persistence; - - /// Kind-specific request body of `POST /jet/tasks`. - type Params: DeserializeOwned; + /// How many times a run ending with [`TaskError::Transient`] is attempted. + const RETRY: RetryPolicy; /// What the task works on, taken from the TASK token. - type Target; + type Target: Serialize + DeserializeOwned + Send + Sync + 'static; + + /// Persisted parameters; they must never hold a secret. + type Params: Serialize + DeserializeOwned + Send + Sync + 'static; /// Progress reported while the task is running. - type Substate: Serialize + Default + Send; + type Substate: Serialize + Default + Send + Sync + 'static; - type Output: Serialize + Send; + type Output: Serialize + Send + 'static; - /// Checks the request and builds the task, before it is registered. - fn prepare(target: Self::Target, params: Self::Params, state: &DgwState) -> Result; + fn run(ctx: TaskCtx) -> impl Future> + Send; +} - fn run(self, progress: Progress) -> impl Future> + Send; +/// A task whose inputs are all persisted, so it resumes after a restart. +pub trait DurableTask: TaskKind { + /// Checks the request before the task is recorded. + fn prepare(target: &Self::Target, params: &Self::Params, state: &DgwState) -> Result<(), StartError>; +} + +/// A task that needs secrets, kept in memory only until the task finishes. +pub trait EphemeralTask: TaskKind { + type Secrets: Send + Sync + 'static; + + /// Body of `POST /jet/tasks`, holding both the parameters and the secrets. + type Request: DeserializeOwned; + + /// Checks the request and splits it into the persisted parameters and the secrets. + fn prepare( + target: &Self::Target, + request: Self::Request, + state: &DgwState, + ) -> Result<(Self::Params, Self::Secrets), StartError>; } /// Reason why a task was not started. @@ -79,393 +123,453 @@ pub enum TaskStatus { #[derive(Debug, Clone)] pub struct TaskSnapshot { pub id: Uuid, - pub kind: &'static str, + pub kind: String, pub status: TaskStatus, } -struct TaskEntry { - kind: &'static str, - status: TaskStatus, +impl From for TaskSnapshot { + fn from(record: TaskRecord) -> Self { + let status = match record.state { + TaskState::NotStarted => TaskStatus::NotStarted, + TaskState::Running => TaskStatus::Running { + substate: parse_json(record.substate.as_deref()), + }, + TaskState::Success => TaskStatus::Success { + result: parse_json(record.result.as_deref()), + }, + TaskState::Failed => TaskStatus::Failed { + error: record.error.unwrap_or_default(), + }, + }; + + Self { + id: record.id, + kind: record.kind, + status, + } + } +} + +/// What a run of a task gets. +pub struct TaskCtx { + pub id: Uuid, + /// Starts at 1. + pub attempt: u32, + pub target: K::Target, + pub params: K::Params, + pub state: DgwState, + pub progress: Progress, + secrets: Option>, } -type TaskMap = Arc>>; +impl TaskCtx { + pub fn secrets(&self) -> Option<&K::Secrets> { + self.secrets.as_deref().and_then(|secrets| secrets.downcast_ref()) + } +} /// Lets a running task update its substate. pub struct Progress { id: Uuid, - tasks: TaskMap, + tasks: TaskService, _substate: PhantomData, } impl Progress { - pub fn set(&self, substate: &S) { - let substate = to_json_value(substate); + pub async fn set(&self, substate: &S) { + let stored = match self.tasks.store().await { + Ok(store) => store.set_substate(self.id, &to_json(substate)).await, + Err(error) => Err(error), + }; - if let Some(entry) = self.tasks.lock().get_mut(&self.id) { - entry.status = TaskStatus::Running { substate }; + if let Err(error) = stored { + warn!(task.id = %self.id, error = format!("{error:#}"), "Failed to store the task substate"); } } } -#[derive(Debug, Clone, Copy)] -struct Limits { - max_concurrent: usize, +type SecretsMap = HashMap>; + +struct TaskServiceInner { + store: OnceCell, + /// Where the store is opened on first use, when it was not opened up front. + lazy_path: &'static str, + secrets: Mutex, + slots: Semaphore, timeout: Duration, - retention: Duration, } -/// In-memory registry of the background tasks, keyed by task ID. +/// Starts background tasks, runs them as jobs and reads their records. #[derive(Clone)] -pub struct TaskRegistry { - tasks: TaskMap, - slots: Arc, - limits: Limits, +pub struct TaskService { + inner: Arc, } -impl Default for TaskRegistry { - fn default() -> Self { - Self::new() +impl TaskService { + /// Opens the task database at `path`. + pub async fn open(path: &str) -> anyhow::Result { + let store = LibSqlProvisionerTaskStore::open(path) + .await + .context("failed to open the provisioner task database")?; + + Ok(Self::with_limits( + OnceCell::new_with(Some(store)), + MAX_CONCURRENT_TASKS, + TASK_TIMEOUT, + )) } -} -impl TaskRegistry { - pub fn new() -> Self { - Self::with_limits(Limits { - max_concurrent: MAX_CONCURRENT_TASKS, - timeout: TASK_TIMEOUT, - retention: FINISHED_TASK_RETENTION, - }) + /// Opens an in-memory database on first use, so tests that never start a task never create one. + #[doc(hidden)] + pub fn mock() -> Self { + Self::with_limits(OnceCell::new(), MAX_CONCURRENT_TASKS, TASK_TIMEOUT) } - fn with_limits(limits: Limits) -> Self { + fn with_limits(store: OnceCell, max_concurrent: usize, timeout: Duration) -> Self { Self { - tasks: Arc::new(Mutex::new(HashMap::new())), - slots: Arc::new(Semaphore::new(limits.max_concurrent)), - limits, + inner: Arc::new(TaskServiceInner { + store, + lazy_path: ":memory:", + secrets: Mutex::new(HashMap::new()), + slots: Semaphore::new(max_concurrent), + timeout, + }), } } - pub fn get(&self, id: Uuid) -> Option { - self.tasks.lock().get(&id).map(|entry| TaskSnapshot { - id, - kind: entry.kind, - status: entry.status.clone(), - }) + async fn store(&self) -> anyhow::Result<&LibSqlProvisionerTaskStore> { + self.inner + .store + .get_or_try_init(|| LibSqlProvisionerTaskStore::open(self.inner.lazy_path)) + .await } - /// Parses the kind-specific parameters, prepares the task and runs it in the background. - pub fn start( + pub async fn get(&self, id: Uuid) -> anyhow::Result> { + Ok(self.store().await?.get(id).await?.map(TaskSnapshot::from)) + } + + /// Fails every unfinished task that has no job left in the queue; call it at startup, before the job runner. + pub async fn reconcile(&self, job_queue: &JobQueueCtx) -> anyhow::Result<()> { + let defs = job_queue + .job_defs(TaskJob::NAME) + .await + .context("failed to list the task jobs")?; + + self.reconcile_with_job_defs(&defs).await + } + + async fn reconcile_with_job_defs(&self, defs: &[String]) -> anyhow::Result<()> { + let queued = defs + .iter() + .filter_map(|def| serde_json::from_str::(def).ok()) + .map(|def| def.task_id) + .collect::>(); + + let store = self.store().await?; + + for id in store.unfinished().await? { + if !queued.contains(&id) && store.fail(id, JOB_LOST_ERROR).await? { + warn!(task.id = %id, "Background task has no job left; marked as failed"); + } + } + + Ok(()) + } + + /// Parses the request of an ephemeral task, records the task and queues its job. + pub async fn start_ephemeral( &self, target: K::Target, - params: &[u8], + body: &[u8], + token_jti: Uuid, state: &DgwState, ) -> Result { - match K::PERSISTENCE { - Persistence::InMemory => {} - } + let request = parse_body::(body)?; + let (params, secrets) = K::prepare(&target, request, state)?; + self.create::(&target, ¶ms, token_jti, Some(Arc::new(secrets)), state) + .await + } - // The serde error is not logged because it may quote the rejected value, which could be the API key. - let params = serde_json::from_slice::(params).map_err(|error| { - warn!( - task.kind = K::KIND, - category = ?error.classify(), - line = error.line(), - column = error.column(), - "Invalid task parameters" - ); - StartError::InvalidParams("invalid_params") - })?; - - let task = K::prepare(target, params, state)?; - - Ok(self.spawn(task)) + /// Parses the parameters of a durable task, records the task and queues its job. + pub async fn start_durable( + &self, + target: K::Target, + body: &[u8], + token_jti: Uuid, + state: &DgwState, + ) -> Result { + let params = parse_body::(body)?; + K::prepare(&target, ¶ms, state)?; + self.create::(&target, ¶ms, token_jti, None, state).await } - fn spawn(&self, task: K) -> TaskSnapshot { + async fn create( + &self, + target: &K::Target, + params: &K::Params, + token_jti: Uuid, + secrets: Option>, + state: &DgwState, + ) -> Result { let id = Uuid::new_v4(); - self.tasks.lock().insert( - id, - TaskEntry { - kind: K::KIND, - status: TaskStatus::NotStarted, - }, - ); - - info!(task.id = %id, task.kind = K::KIND, "Background task created"); - - let tasks = Arc::clone(&self.tasks); - let slots = Arc::clone(&self.slots); - let limits = self.limits; - - tokio::spawn(async move { - let status = match Arc::clone(&slots).acquire_owned().await { - Ok(permit) => { - let status = run_task(id, task, &tasks, limits.timeout).await; - drop(permit); - status - } - Err(_) => TaskStatus::Failed { - error: "task slots are closed".to_owned(), - }, - }; - - match &status { - TaskStatus::Failed { error } => { - warn!(task.id = %id, task.kind = K::KIND, %error, "Background task failed"); - } - _ => info!(task.id = %id, task.kind = K::KIND, "Background task succeeded"), - } - - if let Some(entry) = tasks.lock().get_mut(&id) { - entry.status = status; + let (target, params) = match (serde_json::to_value(target), serde_json::to_value(params)) { + (Ok(target), Ok(params)) => (target, params), + (Err(error), _) | (_, Err(error)) => { + error!(%error, task.kind = K::KIND, "Failed to serialize the task definition"); + return Err(StartError::Internal); } + }; - tokio::time::sleep(limits.retention).await; - tasks.lock().remove(&id); - }); + let def = TaskJobDef { + task_id: id, + kind: K::KIND.to_owned(), + target, + params, + }; - TaskSnapshot { - id, - kind: K::KIND, - status: TaskStatus::NotStarted, + // The job may run as soon as it is queued, so the secrets must be in place first. + if let Some(secrets) = secrets { + self.inner.secrets.lock().insert(id, secrets); } - } -} -async fn run_task(id: Uuid, task: K, tasks: &TaskMap, timeout: Duration) -> TaskStatus { - if let Some(entry) = tasks.lock().get_mut(&id) { - entry.status = TaskStatus::Running { - substate: to_json_value(&K::Substate::default()), + let inserted = match self.store().await { + Ok(store) => { + store + .insert(NewTask { + id, + kind: K::KIND, + target: &def.target.to_string(), + params: &def.params.to_string(), + token_jti, + }) + .await + } + Err(error) => Err(error), }; - } - info!(task.id = %id, task.kind = K::KIND, "Background task running"); - - let progress = Progress { - id, - tasks: Arc::clone(tasks), - _substate: PhantomData, - }; + if let Err(error) = inserted { + error!(task.id = %id, task.kind = K::KIND, error = format!("{error:#}"), "Failed to record the task"); + self.forget_secrets(id); + return Err(StartError::Internal); + } - // The task runs on its own Tokio task so a panic ends as a failure instead of a task stuck in `Running`. - let mut handle = tokio::spawn(task.run(progress)); + let job = TaskJob { + def, + state: state.clone(), + }; - match tokio::time::timeout(timeout, &mut handle).await { - Ok(Ok(Ok(output))) => TaskStatus::Success { - result: to_json_value(&output), - }, - Ok(Ok(Err(error))) => TaskStatus::Failed { - error: format!("{error:#}"), - }, - Ok(Err(_)) => TaskStatus::Failed { - error: "task panicked".to_owned(), - }, - Err(_) => { - handle.abort(); - TaskStatus::Failed { - error: "task timed out".to_owned(), - } + if let Err(error) = state.job_queue_handle.enqueue(job).await { + error!(task.id = %id, task.kind = K::KIND, error = format!("{error:#}"), "Failed to queue the task"); + self.fail(id, "failed to queue the task").await; + return Err(StartError::Internal); } - } -} -fn to_json_value(value: &T) -> serde_json::Value { - serde_json::to_value(value).unwrap_or_else(|error| { - error!(%error, "Failed to serialize a task value"); - serde_json::Value::Null - }) -} + info!(task.id = %id, task.kind = K::KIND, %token_jti, "Background task created"); -#[cfg(test)] -mod tests { - use tokio::sync::oneshot; + Ok(TaskSnapshot { + id, + kind: K::KIND.to_owned(), + status: TaskStatus::NotStarted, + }) + } - use super::*; + async fn execute_ephemeral(&self, def: TaskJobDef, state: &DgwState) -> anyhow::Result<()> { + let secrets = self.inner.secrets.lock().get(&def.task_id).cloned(); - #[derive(Default, Serialize)] - struct TestSubstate { - step: u32, + match secrets { + Some(secrets) => self.execute::(def, state, Some(secrets)).await, + None => { + warn!(task.id = %def.task_id, task.kind = K::KIND, "Background task secrets are gone"); + self.fail(def.task_id, SECRETS_LOST_ERROR).await; + Ok(()) + } + } } - struct TestTask { - started: Option>, - finish: oneshot::Receiver>, + #[cfg_attr(not(test), expect(dead_code, reason = "no durable task kind exists yet"))] + async fn execute_durable(&self, def: TaskJobDef, state: &DgwState) -> anyhow::Result<()> { + self.execute::(def, state, None).await } - impl BackgroundTask for TestTask { - const KIND: &'static str = "test"; - const PERSISTENCE: Persistence = Persistence::InMemory; - - type Params = (); - type Target = (); - type Substate = TestSubstate; - type Output = u32; - - fn prepare(_: (), (): (), _: &DgwState) -> Result { - unreachable!("tests spawn test tasks directly") - } + /// Runs one attempt; an error asks the job queue to try again later. + async fn execute( + &self, + def: TaskJobDef, + state: &DgwState, + secrets: Option>, + ) -> anyhow::Result<()> { + let id = def.task_id; + + let (target, params) = match ( + serde_json::from_value::(def.target), + serde_json::from_value::(def.params), + ) { + (Ok(target), Ok(params)) => (target, params), + (Err(error), _) | (_, Err(error)) => { + error!(task.id = %id, task.kind = K::KIND, %error, "Invalid task definition"); + self.fail(id, "invalid task definition").await; + return Ok(()); + } + }; - async fn run(mut self, progress: Progress) -> anyhow::Result { - progress.set(&TestSubstate { step: 1 }); + let _permit = self.inner.slots.acquire().await.context("task slots are closed")?; - if let Some(started) = self.started.take() { - let _ = started.send(()); - } + let substate = to_json(&K::Substate::default()); - self.finish.await? - } - } + let store = self.store().await?; - struct Controls { - started: oneshot::Receiver<()>, - finish: oneshot::Sender>, - } + let Some(attempt) = store.start_attempt(id, &substate).await? else { + debug!(task.id = %id, task.kind = K::KIND, "Background task is already finished"); + self.forget_secrets(id); + return Ok(()); + }; - fn test_task() -> (TestTask, Controls) { - let (started_tx, started_rx) = oneshot::channel(); - let (finish_tx, finish_rx) = oneshot::channel(); + info!(task.id = %id, task.kind = K::KIND, attempt, "Background task running"); - ( - TestTask { - started: Some(started_tx), - finish: finish_rx, - }, - Controls { - started: started_rx, - finish: finish_tx, + let ctx = TaskCtx { + id, + attempt, + target, + params, + state: state.clone(), + progress: Progress { + id, + tasks: self.clone(), + _substate: PhantomData, }, - ) - } + secrets, + }; - fn registry(max_concurrent: usize, timeout: Duration) -> TaskRegistry { - TaskRegistry::with_limits(Limits { - max_concurrent, - timeout, - retention: Duration::from_secs(3600), - }) - } + let max_attempts = K::RETRY.max_attempts.min(JOB_MAX_ATTEMPTS); - async fn wait_for_final_status(registry: &TaskRegistry, id: Uuid) -> TaskStatus { - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let status = registry.get(id).expect("task is registered").status; + match run_attempt::(ctx, self.inner.timeout).await { + Ok(output) => { + store.succeed(id, &to_json(&output)).await?; + info!(task.id = %id, task.kind = K::KIND, attempt, "Background task succeeded"); + } + Err(TaskError::Transient(error)) if attempt < max_attempts => { + warn!(task.id = %id, task.kind = K::KIND, attempt, max_attempts, %error, "Background task attempt failed"); + store.retry_later(id, &error).await?; + anyhow::bail!("background task attempt failed: {error}"); + } + Err(TaskError::Transient(error) | TaskError::Permanent(error)) => { + warn!(task.id = %id, task.kind = K::KIND, attempt, %error, "Background task failed"); + store.fail(id, &error).await?; + } + } - if matches!(status, TaskStatus::Success { .. } | TaskStatus::Failed { .. }) { - return status; - } + self.forget_secrets(id); - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("task finishes") + Ok(()) } - #[tokio::test] - async fn status_goes_from_not_started_to_running_to_failed() { - let registry = registry(1, Duration::from_secs(60)); - let (task, controls) = test_task(); + async fn fail(&self, id: Uuid, error: &str) { + self.forget_secrets(id); - let snapshot = registry.spawn(task); - assert_eq!(snapshot.status, TaskStatus::NotStarted); - assert_eq!(snapshot.kind, "test"); + let stored = match self.store().await { + Ok(store) => store.fail(id, error).await.map(|_| ()), + Err(store_error) => Err(store_error), + }; - controls.started.await.expect("task starts"); - assert_eq!( - registry.get(snapshot.id).expect("task").status, - TaskStatus::Running { - substate: serde_json::json!({ "step": 1 }) - } - ); + if let Err(store_error) = stored { + error!(task.id = %id, error = format!("{store_error:#}"), "Failed to record the task failure"); + } + } - let _ = controls.finish.send(Err(anyhow::anyhow!("boom"))); - assert_eq!( - wait_for_final_status(®istry, snapshot.id).await, - TaskStatus::Failed { - error: "boom".to_owned() - } - ); + fn forget_secrets(&self, id: Uuid) { + self.inner.secrets.lock().remove(&id); } +} - #[tokio::test] - async fn success_carries_the_result() { - let registry = registry(1, Duration::from_secs(60)); - let (task, controls) = test_task(); +async fn run_attempt(ctx: TaskCtx, timeout: Duration) -> Result { + // The run has its own Tokio task so a panic ends as a failure instead of a task stuck in `Running`. + let mut handle = tokio::spawn(K::run(ctx)); - let id = registry.spawn(task).id; - let _ = controls.finish.send(Ok(42)); + match tokio::time::timeout(timeout, &mut handle).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => Err(TaskError::Permanent("task panicked".to_owned())), + Err(_) => { + handle.abort(); + Err(TaskError::Permanent("task timed out".to_owned())) + } + } +} - assert_eq!( - wait_for_final_status(®istry, id).await, - TaskStatus::Success { - result: serde_json::json!(42) - } +fn parse_body(body: &[u8]) -> Result { + // The serde error is not logged because it may quote the rejected value, which could be a secret. + serde_json::from_slice::(body).map_err(|error| { + warn!( + task.kind = K::KIND, + category = ?error.classify(), + line = error.line(), + column = error.column(), + "Invalid task parameters" ); - } + StartError::InvalidParams("invalid_params") + }) +} - #[tokio::test] - async fn concurrency_limit_keeps_extra_tasks_not_started() { - let registry = registry(2, Duration::from_secs(60)); - let (first, first_controls) = test_task(); - let (second, second_controls) = test_task(); - let (third, mut third_controls) = test_task(); +fn to_json(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|error| { + error!(%error, "Failed to serialize a task value"); + "null".to_owned() + }) +} - let first_id = registry.spawn(first).id; - let _second_id = registry.spawn(second).id; - let third_id = registry.spawn(third).id; +fn parse_json(json: Option<&str>) -> serde_json::Value { + json.and_then(|json| serde_json::from_str(json).ok()) + .unwrap_or(serde_json::Value::Null) +} - first_controls.started.await.expect("first starts"); - second_controls.started.await.expect("second starts"); +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskJobDef { + task_id: Uuid, + kind: String, + target: serde_json::Value, + params: serde_json::Value, +} - tokio::time::sleep(Duration::from_millis(50)).await; - assert!(third_controls.started.try_recv().is_err()); - assert_eq!(registry.get(third_id).expect("task").status, TaskStatus::NotStarted); +/// Job running one attempt of a background task. +pub(crate) struct TaskJob { + def: TaskJobDef, + state: DgwState, +} - let _ = first_controls.finish.send(Ok(1)); - wait_for_final_status(®istry, first_id).await; +impl TaskJob { + pub(crate) const NAME: &'static str = "provisioner-task"; - third_controls.started.await.expect("third starts once a slot is free"); + pub(crate) fn read_json(json: &str, state: DgwState) -> anyhow::Result { + let def = serde_json::from_str(json).context("failed to deserialize the task job")?; + Ok(Self { def, state }) } +} - #[tokio::test] - async fn task_times_out() { - let registry = registry(1, Duration::from_millis(20)); - let (task, _controls) = test_task(); - - let id = registry.spawn(task).id; - - assert_eq!( - wait_for_final_status(®istry, id).await, - TaskStatus::Failed { - error: "task timed out".to_owned() - } - ); +#[async_trait] +impl job_queue::Job for TaskJob { + fn name(&self) -> &str { + Self::NAME } - #[tokio::test] - async fn finished_task_is_removed_after_retention() { - let registry = TaskRegistry::with_limits(Limits { - max_concurrent: 1, - timeout: Duration::from_secs(60), - retention: Duration::from_millis(10), - }); - let (task, controls) = test_task(); - - let id = registry.spawn(task).id; - let _ = controls.finish.send(Ok(1)); - - tokio::time::timeout(Duration::from_secs(10), async { - while registry.get(id).is_some() { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("task is removed"); + fn write_json(&self) -> anyhow::Result { + serde_json::to_string(&self.def).context("failed to serialize the task job") } - #[test] - fn unknown_task_is_none() { - assert!(TaskRegistry::new().get(Uuid::new_v4()).is_none()); + async fn run(&mut self) -> anyhow::Result<()> { + let tasks = self.state.tasks.clone(); + let def = self.def.clone(); + + match def.kind.as_str() { + ai_log::AiLogTask::KIND => tasks.execute_ephemeral::(def, &self.state).await, + kind => { + error!(task.id = %def.task_id, task.kind = kind, "Unknown task kind"); + tasks.fail(def.task_id, "unknown task kind").await; + Ok(()) + } + } } } + +#[cfg(test)] +mod tests; diff --git a/devolutions-gateway/src/tasks/tests.rs b/devolutions-gateway/src/tasks/tests.rs new file mode 100644 index 000000000..ab49dbfff --- /dev/null +++ b/devolutions-gateway/src/tasks/tests.rs @@ -0,0 +1,344 @@ +use job_queue::Job as _; +use provisioner_task_store_libsql::TaskState; + +use super::ai_log::{AiLogTarget, AiLogTask}; +use super::*; +use crate::MockHandles; + +const API_KEY: &str = "sk-task-unit-test-secret"; + +const CONFIG: &str = r#"{ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [{ "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" }], + "Proxy": { "Mode": "Off" } +}"#; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +enum Outcome { + Succeed, + Transient, + Permanent, + Hang, + Panic, +} + +#[derive(Default, Serialize)] +struct Step { + step: u32, +} + +/// Durable test task doing what its parameters say, attempted at most `N` times. +struct Scripted; + +impl TaskKind for Scripted { + const KIND: &'static str = "scripted"; + const RETRY: RetryPolicy = RetryPolicy { max_attempts: N }; + + type Target = (); + type Params = Outcome; + type Substate = Step; + type Output = u32; + + async fn run(ctx: TaskCtx) -> Result { + ctx.progress.set(&Step { step: ctx.attempt }).await; + + match ctx.params { + Outcome::Succeed => Ok(42), + Outcome::Transient => Err(TaskError::Transient("rate limited".to_owned())), + Outcome::Permanent => Err(TaskError::Permanent("unauthorized".to_owned())), + Outcome::Hang => std::future::pending().await, + Outcome::Panic => panic!("scripted panic"), + } + } +} + +impl DurableTask for Scripted { + fn prepare(_: &(), _: &Outcome, _: &DgwState) -> Result<(), StartError> { + Ok(()) + } +} + +struct Harness { + state: DgwState, + handles: MockHandles, + _dir: tempfile::TempDir, + db_path: String, +} + +impl Harness { + async fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let db_path = dir + .path() + .join("provisioner_tasks.db") + .to_str() + .expect("UTF-8") + .to_owned(); + + let (mut state, handles) = DgwState::mock(CONFIG).expect("mock state"); + state.tasks = TaskService::open(&db_path).await.expect("task service"); + + Self { + state, + handles, + _dir: dir, + db_path, + } + } + + fn tasks(&self) -> &TaskService { + &self.state.tasks + } + + /// Takes the job queued by the last started task, as the job queue would read it back. + async fn queued_job(&mut self) -> TaskJob { + let message = self.handles.job_queue_rx.recv().await.expect("queued job"); + assert_eq!(message.job.name(), TaskJob::NAME); + let json = message.job.write_json().expect("job JSON"); + TaskJob::read_json(&json, self.state.clone()).expect("valid job") + } + + async fn start_scripted(&mut self, outcome: Outcome) -> TaskJob { + let body = serde_json::to_vec(&outcome).expect("JSON"); + let state = self.state.clone(); + + self.tasks() + .start_durable::>((), &body, Uuid::new_v4(), &state) + .await + .expect("task starts"); + + self.queued_job().await + } + + async fn run_scripted(&self, job: &TaskJob) -> anyhow::Result<()> { + self.tasks() + .execute_durable::>(job.def.clone(), &self.state) + .await + } + + async fn record(&self, id: Uuid) -> TaskRecord { + let store = self.tasks().store().await.expect("store"); + store.get(id).await.expect("read").expect("record exists") + } +} + +#[tokio::test] +async fn success_stores_the_result() { + let mut harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Succeed).await; + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.kind, "scripted"); + + harness.run_scripted::<5>(&job).await.expect("no retry"); + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::Success); + assert_eq!(record.result.as_deref(), Some("42")); + assert_eq!(record.attempts, 1); +} + +#[tokio::test] +async fn transient_error_asks_the_job_queue_for_a_retry() { + let mut harness = Harness::new().await; + let job = harness.start_scripted::<3>(Outcome::Transient).await; + let id = job.def.task_id; + + for attempt in 1..3 { + assert!( + harness.run_scripted::<3>(&job).await.is_err(), + "attempt {attempt} is retried" + ); + + let record = harness.record(id).await; + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.attempts, attempt); + assert_eq!(record.error.as_deref(), Some("rate limited")); + } + + harness + .run_scripted::<3>(&job) + .await + .expect("last attempt is not retried"); + + let record = harness.record(id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.attempts, 3); + assert_eq!(record.error.as_deref(), Some("rate limited")); +} + +#[tokio::test] +async fn retry_policy_is_capped_by_the_job_queue() { + let mut harness = Harness::new().await; + let job = harness.start_scripted::<100>(Outcome::Transient).await; + + let mut retried = 0; + while harness.run_scripted::<100>(&job).await.is_err() { + retried += 1; + } + + assert_eq!(retried, JOB_MAX_ATTEMPTS - 1); + assert_eq!(harness.record(job.def.task_id).await.state, TaskState::Failed); +} + +#[tokio::test] +async fn permanent_error_fails_without_retry() { + let mut harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Permanent).await; + + harness.run_scripted::<5>(&job).await.expect("no retry"); + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some("unauthorized")); + assert_eq!(record.attempts, 1); + assert!(record.finished_at.is_some()); +} + +#[tokio::test] +async fn finished_task_is_not_run_again() { + let mut harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Succeed).await; + + harness.run_scripted::<5>(&job).await.expect("first run"); + harness.run_scripted::<5>(&job).await.expect("second run"); + + assert_eq!(harness.record(job.def.task_id).await.attempts, 1); +} + +#[tokio::test] +async fn timeout_and_panic_are_permanent_failures() { + let mut harness = Harness::new().await; + let store = LibSqlProvisionerTaskStore::open(&harness.db_path).await.expect("store"); + harness.state.tasks = TaskService::with_limits(OnceCell::new_with(Some(store)), 1, Duration::from_millis(20)); + + for (outcome, error) in [(Outcome::Hang, "task timed out"), (Outcome::Panic, "task panicked")] { + let job = harness.start_scripted::<5>(outcome).await; + harness.run_scripted::<5>(&job).await.expect("no retry"); + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some(error)); + } +} + +#[tokio::test] +async fn ephemeral_task_fails_without_retry_after_a_restart() { + let mut harness = Harness::new().await; + let body = serde_json::json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }).to_string(); + let state = harness.state.clone(); + + let snapshot = harness + .tasks() + .start_ephemeral::( + AiLogTarget { + session_id: Uuid::new_v4(), + }, + body.as_bytes(), + Uuid::new_v4(), + &state, + ) + .await + .expect("task starts"); + + let message = harness.handles.job_queue_rx.recv().await.expect("queued job"); + let json = message.job.write_json().expect("job JSON"); + assert!(!json.contains(API_KEY), "{json}"); + + // A restart keeps the database but loses the secrets held in memory. + harness.state.tasks = TaskService::open(&harness.db_path).await.expect("task service"); + let mut job = TaskJob::read_json(&json, harness.state.clone()).expect("valid job"); + + job.run().await.expect("no retry"); + + let record = harness.record(snapshot.id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some(SECRETS_LOST_ERROR)); + assert_eq!(record.attempts, 0); +} + +#[tokio::test] +async fn secrets_are_dropped_when_the_task_finishes() { + let mut harness = Harness::new().await; + let body = serde_json::json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }).to_string(); + let state = harness.state.clone(); + + let snapshot = harness + .tasks() + .start_ephemeral::( + AiLogTarget { + session_id: Uuid::new_v4(), + }, + body.as_bytes(), + Uuid::new_v4(), + &state, + ) + .await + .expect("task starts"); + + assert!(harness.tasks().inner.secrets.lock().contains_key(&snapshot.id)); + + let mut job = harness.queued_job().await; + job.run().await.expect("no retry"); + + assert!(harness.tasks().inner.secrets.lock().is_empty()); + + let record = harness.record(snapshot.id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some("ai-log task not implemented yet")); + assert!(!record.params.contains(API_KEY), "{}", record.params); +} + +#[tokio::test] +async fn reconcile_fails_unfinished_tasks_without_a_job() { + let mut harness = Harness::new().await; + + let queued = harness.start_scripted::<5>(Outcome::Succeed).await; + let lost = harness.start_scripted::<5>(Outcome::Succeed).await; + let running_lost = harness.start_scripted::<5>(Outcome::Succeed).await; + let finished = harness.start_scripted::<5>(Outcome::Succeed).await; + + let store = harness.tasks().store().await.expect("store"); + store + .start_attempt(running_lost.def.task_id, "null") + .await + .expect("start"); + harness.run_scripted::<5>(&finished).await.expect("run"); + + let defs = vec![queued.write_json().expect("JSON"), "not a task job".to_owned()]; + harness.tasks().reconcile_with_job_defs(&defs).await.expect("reconcile"); + + assert_eq!(harness.record(queued.def.task_id).await.state, TaskState::NotStarted); + assert_eq!(harness.record(finished.def.task_id).await.state, TaskState::Success); + + for id in [lost.def.task_id, running_lost.def.task_id] { + let record = harness.record(id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some(JOB_LOST_ERROR)); + } +} + +#[tokio::test] +async fn snapshot_reflects_the_record() { + let mut harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Succeed).await; + let id = job.def.task_id; + + let snapshot = harness.tasks().get(id).await.expect("read").expect("exists"); + assert_eq!(snapshot.kind, "scripted"); + assert_eq!(snapshot.status, TaskStatus::NotStarted); + + let store = harness.tasks().store().await.expect("store"); + store.start_attempt(id, r#"{"step":1}"#).await.expect("start"); + assert_eq!( + harness.tasks().get(id).await.expect("read").expect("exists").status, + TaskStatus::Running { + substate: serde_json::json!({ "step": 1 }) + } + ); + + assert!(harness.tasks().get(Uuid::new_v4()).await.expect("read").is_none()); +} diff --git a/devolutions-gateway/tests/config.rs b/devolutions-gateway/tests/config.rs index 4128ae6ba..dc82f9f4d 100644 --- a/devolutions-gateway/tests/config.rs +++ b/devolutions-gateway/tests/config.rs @@ -94,6 +94,7 @@ fn hub_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: Some(VerbosityProfile::Tls), web_app: None, @@ -144,6 +145,7 @@ fn legacy_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: None, @@ -193,6 +195,7 @@ fn system_store_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: None, @@ -267,6 +270,7 @@ fn standalone_custom_auth_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: Some(WebAppConf { @@ -348,6 +352,7 @@ fn standalone_no_auth_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: Some(WebAppConf { @@ -429,6 +434,7 @@ fn proxy_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: None, diff --git a/devolutions-gateway/tests/tasks.rs b/devolutions-gateway/tests/tasks.rs index 04f13b3b3..dc31c07ab 100644 --- a/devolutions-gateway/tests/tasks.rs +++ b/devolutions-gateway/tests/tasks.rs @@ -3,6 +3,7 @@ use std::io; use std::net::SocketAddr; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -11,7 +12,10 @@ use axum::body::Body; use axum::extract::connect_info::MockConnectInfo; use axum::http::{self, Request, StatusCode}; use base64::Engine as _; +use devolutions_gateway::job_queue::{JobQueueCtx, JobQueueTask, JobRunnerTask}; +use devolutions_gateway::tasks::{SECRETS_LOST_ERROR, TaskService}; use devolutions_gateway::{DgwState, MockHandles}; +use devolutions_gateway_task::{ChildTask, ShutdownHandle, Task as _}; use http_body_util::BodyExt as _; use serde_json::{Value, json}; use tower::ServiceExt as _; @@ -37,32 +41,146 @@ const CONFIG: &str = r#"{ } }"#; -fn make_router(config: &str) -> anyhow::Result<(Router, impl Sized)> { - let (state, handles) = DgwState::mock(config)?; - let MockHandles { - session_manager_rx, - recording_manager_rx, - subscriber_rx, - job_queue_rx, - traffic_audit_rx, - shutdown_handle, - } = handles; - - // The auth middleware asks the session manager about any token carrying `jet_aid`; nothing answers in the mock. - drop(session_manager_rx); - - let app = - devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); - Ok(( - app, - ( +struct Gateway { + app: Router, + shutdown_handle: ShutdownHandle, + job_tasks: Vec>>, + _mock_handles: Box, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Jobs { + Run, + QueueOnly, +} + +impl Gateway { + /// Starts a Gateway whose task and job databases live in `dir`, so a later start acts as a restart. + async fn start(config: &str, dir: &Path, jobs: Jobs) -> anyhow::Result { + let (mut state, handles) = DgwState::mock(config)?; + let MockHandles { + session_manager_rx, recording_manager_rx, subscriber_rx, job_queue_rx, traffic_audit_rx, + shutdown_handle: mock_shutdown_handle, + } = handles; + + // The auth middleware asks the session manager about any token carrying `jet_aid`; nothing answers in the mock. + drop(session_manager_rx); + + state.tasks = TaskService::open(tasks_db(dir).to_str().unwrap()).await?; + + let job_queue_ctx = JobQueueCtx::init(&job_queue_db(dir)).await?; + state.tasks.reconcile(&job_queue_ctx).await?; + state.job_queue_handle = job_queue_ctx.job_queue_handle.clone(); + + let (shutdown_handle, shutdown_signal) = ShutdownHandle::new(); + let mut job_tasks = Vec::new(); + + if jobs == Jobs::Run { + let runner = JobRunnerTask::new(&job_queue_ctx, state.clone()); + job_tasks.push(ChildTask::spawn(runner.run(shutdown_signal.clone()))); + } + + job_tasks.push(ChildTask::spawn(JobQueueTask::new(job_queue_ctx).run(shutdown_signal))); + + let app = devolutions_gateway::make_http_service(state) + .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); + + Ok(Self { + app, shutdown_handle, - ), - )) + job_tasks, + _mock_handles: Box::new(( + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + mock_shutdown_handle, + )), + }) + } + + async fn stop(self) { + self.shutdown_handle.signal(); + + for task in self.job_tasks { + task.join().await.unwrap().unwrap(); + } + } +} + +fn tasks_db(dir: &Path) -> PathBuf { + dir.join("provisioner_tasks.db") +} + +fn job_queue_db(dir: &Path) -> PathBuf { + dir.join("job_queue.db") +} + +async fn queued_job_count(dir: &Path) -> u64 { + let conn = job_queue_libsql::libsql::Builder::new_local(job_queue_db(dir)) + .build() + .await + .unwrap() + .connect() + .unwrap(); + + let row = conn + .query("SELECT count(*) FROM job_queue", ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap(); + + row.get::(0).unwrap() +} + +async fn wait_for_queued_jobs(dir: &Path, expected: u64) { + tokio::time::timeout(Duration::from_secs(10), async { + while queued_job_count(dir).await != expected { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("job queue reaches the expected size"); +} + +/// Every file of both databases, WAL included, since SQLite may not have checkpointed yet. +fn database_bytes(dir: &Path) -> Vec<(PathBuf, Vec)> { + std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.to_str().unwrap().contains(".db")) + .map(|path| { + let bytes = std::fs::read(&path).unwrap(); + (path, bytes) + }) + .collect() +} + +fn contains(haystack: &[u8], needle: &str) -> bool { + haystack.windows(needle.len()).any(|window| window == needle.as_bytes()) +} + +fn assert_databases_hold_settings_but_not_the_key(dir: &Path) { + let files = database_bytes(dir); + assert!(files.iter().any(|(path, _)| path.ends_with("job_queue.db"))); + assert!(files.iter().any(|(path, _)| path.ends_with("provisioner_tasks.db"))); + + let all = files + .iter() + .flat_map(|(_, bytes)| bytes.iter().copied()) + .collect::>(); + assert!(contains(&all, "gpt-test"), "the scan sees the persisted settings"); + + for (path, bytes) in &files { + assert!(!contains(bytes, API_KEY), "{} holds the API key", path.display()); + } } fn unsigned_jws(cty: &str, payload: &Value) -> String { @@ -175,7 +293,7 @@ impl CapturedLogs { } } -fn capture_logs() -> (CapturedLogs, tracing::subscriber::DefaultGuard) { +fn capture_logs() -> (CapturedLogs, impl Sized) { let logs = CapturedLogs::default(); let writer = logs.clone(); let guard = tracing_subscriber::fmt() @@ -183,12 +301,19 @@ fn capture_logs() -> (CapturedLogs, tracing::subscriber::DefaultGuard) { .with_max_level(tracing::Level::TRACE) .with_ansi(false) .set_default(); - (logs, guard) + + // With one registered dispatcher, tracing takes callsite interest from the thread that hits it first, + // so a parallel test thread without a subscriber would disable these events for everyone. + let second_dispatcher = tracing::Dispatch::new(tracing_subscriber::registry()); + + (logs, (guard, second_dispatcher)) } #[tokio::test] async fn ai_log_task_is_accepted_then_fails_as_not_implemented() { - let (app, _handles) = make_router(CONFIG).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); let started = start_task(&app).await; assert_eq!(started["kind"], "ai-log"); @@ -210,7 +335,9 @@ async fn ai_log_task_is_accepted_then_fails_as_not_implemented() { #[tokio::test] async fn start_requires_a_task_token() { - let (app, _handles) = make_router(CONFIG).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); let (status, _) = send(&app, start_request(None, &ai_params())).await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -221,7 +348,9 @@ async fn start_requires_a_task_token() { #[tokio::test] async fn status_requires_the_tasks_read_scope() { - let (app, _handles) = make_router(CONFIG).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); let id = start_task(&app).await["id"].as_str().unwrap().parse::().unwrap(); let (status, _) = send(&app, status_request(None, id)).await; @@ -239,7 +368,9 @@ async fn status_requires_the_tasks_read_scope() { #[tokio::test] async fn unknown_task_is_not_found() { - let (app, _handles) = make_router(CONFIG).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); let (status, _) = send( &app, @@ -252,7 +383,9 @@ async fn unknown_task_is_not_found() { #[tokio::test] async fn invalid_ai_settings_are_typed_bad_requests() { - let (app, _handles) = make_router(CONFIG).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); for (params, expected) in [ (json!({ "provider": "openai", "model": "gpt-test" }), "missing_api_key"), @@ -283,7 +416,9 @@ async fn invalid_ai_settings_are_typed_bad_requests() { #[tokio::test] async fn api_key_never_appears_in_responses_or_logs() { let (logs, _guard) = capture_logs(); - let (app, _handles) = make_router(CONFIG).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); let started = start_task(&app).await; assert!(!started.to_string().contains(API_KEY)); @@ -306,7 +441,9 @@ async fn api_key_never_appears_in_responses_or_logs() { #[tokio::test] async fn endpoints_are_hidden_when_unstable_is_disabled() { let config = CONFIG.replace("\"enable_unstable\": true", "\"enable_unstable\": false"); - let (app, _handles) = make_router(&config).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(&config, dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); let (status, _) = send(&app, start_request(Some(&task_token()), &ai_params())).await; assert_eq!(status, StatusCode::NOT_FOUND); @@ -318,3 +455,71 @@ async fn endpoints_are_hidden_when_unstable_is_disabled() { .await; assert_eq!(status, StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn neither_database_ever_holds_the_api_key() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + + let started = start_task(&gateway.app).await; + let id = started["id"].as_str().unwrap().parse::().unwrap(); + assert_eq!(wait_until_finished(&gateway.app, id).await["state"], "failed"); + wait_for_queued_jobs(dir.path(), 0).await; + + gateway.stop().await; + + assert_databases_hold_settings_but_not_the_key(dir.path()); +} + +#[tokio::test] +async fn after_a_restart_the_ephemeral_task_fails_without_retry() { + let dir = tempfile::tempdir().unwrap(); + + let before = Gateway::start(CONFIG, dir.path(), Jobs::QueueOnly).await.unwrap(); + let started = start_task(&before.app).await; + let id = started["id"].as_str().unwrap().parse::().unwrap(); + wait_for_queued_jobs(dir.path(), 1).await; + before.stop().await; + + assert_databases_hold_settings_but_not_the_key(dir.path()); + + let after = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let finished = wait_until_finished(&after.app, id).await; + + assert_eq!( + finished, + json!({ + "id": id, + "kind": "ai-log", + "state": "failed", + "error": SECRETS_LOST_ERROR, + }) + ); + + wait_for_queued_jobs(dir.path(), 0).await; + after.stop().await; + + assert_databases_hold_settings_but_not_the_key(dir.path()); +} + +#[tokio::test] +async fn task_records_survive_a_restart() { + let dir = tempfile::tempdir().unwrap(); + + let before = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let id = start_task(&before.app).await["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + let finished = wait_until_finished(&before.app, id).await; + before.stop().await; + + let after = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let (status, body) = send(&after.app, status_request(Some(&scope_token("gateway.tasks.read")), id)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(serde_json::from_str::(&body).unwrap(), finished); + + after.stop().await; +} From edefc852cf99adc05d562fecc5bf60e4b4cac7c6 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sat, 26 Sep 2026 13:55:50 -0400 Subject: [PATCH 4/9] chore(openapi): update the task status description Task records are now kept across restarts. Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/openapi/doc/index.adoc | 2 +- .../openapi/dotnet-client/docs/TasksApi.md | 2 +- .../Devolutions.Gateway.Client/Api/TasksApi.cs | 16 ++++++++-------- devolutions-gateway/openapi/gateway-api.yaml | 2 +- .../ts-angular-client/api/tasks.service.ts | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index cf2bd281f..67c12a661 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -2539,7 +2539,7 @@ Gets the status of a background task. ===== Description -Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. // markup not found, no include::{specDir}jet/tasks/\{id\}/GET/spec.adoc[opts=optional] diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md index 3479fa573..5f799454c 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md @@ -13,7 +13,7 @@ All URIs are relative to *http://localhost* Gets the status of a background task. -Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. ### Example ```csharp diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs index 094bf23b1..15e3e25d6 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs @@ -32,7 +32,7 @@ public interface ITasksApiSync : IApiAccessor /// Gets the status of a background task. /// /// - /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -43,7 +43,7 @@ public interface ITasksApiSync : IApiAccessor /// Gets the status of a background task. /// /// - /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -83,7 +83,7 @@ public interface ITasksApiAsync : IApiAccessor /// Gets the status of a background task. /// /// - /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -95,7 +95,7 @@ public interface ITasksApiAsync : IApiAccessor /// Gets the status of a background task. /// /// - /// Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -339,7 +339,7 @@ public Devolutions.Gateway.Client.Client.ExceptionFactory ExceptionFactory } /// - /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -351,7 +351,7 @@ public TaskInfo GetTask(Guid id) } /// - /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -396,7 +396,7 @@ public Devolutions.Gateway.Client.Client.ApiResponse GetTaskWithHttpIn } /// - /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID @@ -409,7 +409,7 @@ public Devolutions.Gateway.Client.Client.ApiResponse GetTaskWithHttpIn } /// - /// Gets the status of a background task. Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call /// Task ID diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 099f73390..592bd6343 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -950,7 +950,7 @@ paths: - Tasks summary: Gets the status of a background task. description: |- - Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. + Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. operationId: GetTask diff --git a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts index bdcdc82bd..539b3b26a 100644 --- a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts +++ b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts @@ -96,7 +96,7 @@ export class TasksService { /** * Gets the status of a background task. - * Finished tasks are kept for one hour, and every task is dropped when Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + * Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. * @param id Task ID * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. From 8553d89f871a22d02120faf65b808a38ca9397de Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sun, 27 Sep 2026 13:50:54 -0400 Subject: [PATCH 5/9] refactor(dgw): run provisioner tasks on their own queue, only when unstable Provisioner tasks now have their own job queue, stored in provisioner_tasks.db next to the task records, with their own runner. The runner claims at most MAX_CONCURRENT_TASKS jobs, so a claimed task never waits for a slot and never holds one of the shared job runner slots; the semaphore is gone. Retry, backoff and max attempts are those of the job queue, and startup still fails tasks whose job is gone. The task system (database, reconcile, runner) starts only when enable_unstable is set; stable installs never open the database. Also: - the task store keeps its schema version in a table, since the job queue in the same file owns PRAGMA user_version; - the ai-log API key is required; - a failure to store the result of a successful run is logged and not returned to the job queue, so the task is not run again; - both task endpoints answer errors with one shape and a typed code; - client errors are logged at debug; - the request body of POST /jet/tasks is a free-form JSON object in OpenAPI, and AiLogParams stays exported. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provisioner-task-store-libsql/src/lib.rs | 86 ++--- .../tests/store.rs | 55 +-- devolutions-gateway/src/api/mod.rs | 5 +- devolutions-gateway/src/api/tasks.rs | 84 ++--- devolutions-gateway/src/job_queue.rs | 48 ++- devolutions-gateway/src/lib.rs | 5 +- devolutions-gateway/src/openapi.rs | 1 + devolutions-gateway/src/service.rs | 22 +- devolutions-gateway/src/tasks/ai_log.rs | 61 ++-- devolutions-gateway/src/tasks/mod.rs | 325 ++++++++++++------ devolutions-gateway/src/tasks/tests.rs | 201 ++++++----- devolutions-gateway/tests/tasks.rs | 139 +++++--- 12 files changed, 597 insertions(+), 435 deletions(-) diff --git a/crates/provisioner-task-store-libsql/src/lib.rs b/crates/provisioner-task-store-libsql/src/lib.rs index bb75075a5..ff7fb64b2 100644 --- a/crates/provisioner-task-store-libsql/src/lib.rs +++ b/crates/provisioner-task-store-libsql/src/lib.rs @@ -9,10 +9,8 @@ use anyhow::Context as _; use libsql::{Connection, Row}; use uuid::Uuid; -#[rustfmt::skip] -pub use libsql; - // Released migrations are never modified; new ones are appended. +// The job queue in the same database owns `PRAGMA user_version`, so this schema keeps its version in a table. const MIGRATIONS: &[&str] = &[ // Migration 0 "CREATE TABLE task ( @@ -106,17 +104,11 @@ pub struct LibSqlProvisionerTaskStore { } impl LibSqlProvisionerTaskStore { - /// Opens the database at `path` (or `:memory:`) and applies the pending migrations. - pub async fn open(path: &str) -> anyhow::Result { - let conn = libsql::Builder::new_local(path) - .build() - .await - .context("failed to open libSQL database")? - .connect() - .context("failed to connect to libSQL")?; - + /// Applies the pending migrations on `conn`, which the caller has opened and configured. + /// + /// The database may hold other tables, such as a job queue. + pub async fn init(conn: Connection) -> anyhow::Result { let store = Self { conn }; - store.apply_pragmas().await?; store.migrate().await?; Ok(store) @@ -297,46 +289,18 @@ impl LibSqlProvisionerTaskStore { Ok(ids) } - async fn apply_pragmas(&self) -> anyhow::Result<()> { - const PRAGMAS: &str = " - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - PRAGMA busy_timeout = 15000; - PRAGMA cache_size = -3000; - PRAGMA temp_store = MEMORY; - "; - - let mut batch_rows = self - .conn - .execute_batch(PRAGMAS) - .await - .context("failed to batch execute SQL query")?; - - while let Some(rows) = batch_rows.next_stmt_row() { - let Some(mut rows) = rows else { - continue; - }; - - while let Ok(Some(row)) = rows.next().await { - trace!(?row, "PRAGMA row"); - } - } - - Ok(()) - } - async fn migrate(&self) -> anyhow::Result<()> { - let user_version = self.query_user_version().await?; + let schema_version = self.query_schema_version().await?; - match MIGRATIONS.get(user_version..) { + match MIGRATIONS.get(schema_version..) { Some(remaining) if !remaining.is_empty() => { info!( - user_version, - migration_count = MIGRATIONS.len() - user_version, + schema_version, + migration_count = MIGRATIONS.len() - schema_version, "Start migration" ); - for (sql_query, migration_id) in remaining.iter().zip(user_version..MIGRATIONS.len()) { + for (sql_query, migration_id) in remaining.iter().zip(schema_version..MIGRATIONS.len()) { trace!(migration_id, %sql_query, "Apply migration"); self.conn @@ -344,28 +308,36 @@ impl LibSqlProvisionerTaskStore { .await .with_context(|| format!("failed to execute migration {migration_id}"))?; - self.update_user_version(migration_id + 1) + self.update_schema_version(migration_id + 1) .await - .context("failed to update user version")?; + .context("failed to update the schema version")?; } info!("Migration complete"); } None => { - warn!(user_version, "user_version is set to an unexpected value"); + warn!(schema_version, "Task schema version is set to an unexpected value"); } _ => { - debug!(user_version, "Database is already up to date"); + debug!(schema_version, "Database is already up to date"); } } Ok(()) } - async fn query_user_version(&self) -> anyhow::Result { + async fn query_schema_version(&self) -> anyhow::Result { + self.conn + .execute( + "CREATE TABLE IF NOT EXISTS task_schema_version (version INT NOT NULL) STRICT", + (), + ) + .await + .context("failed to create the schema version table")?; + let row = self .conn - .query("PRAGMA user_version", ()) + .query("SELECT coalesce(max(version), 0) FROM task_schema_version", ()) .await .context("failed to execute SQL query")? .next() @@ -373,16 +345,16 @@ impl LibSqlProvisionerTaskStore { .context("failed to read the row")? .context("no row returned")?; - let value = row.get::(0).context("failed to read user_version value")?; + let value = row.get::(0).context("failed to read the schema version")?; - usize::try_from(value).context("user_version is too big") + usize::try_from(value).context("schema version is too big") } - async fn update_user_version(&self, value: usize) -> anyhow::Result<()> { - let sql_query = format!("PRAGMA user_version = {value}"); + async fn update_schema_version(&self, value: usize) -> anyhow::Result<()> { + let value = i64::try_from(value).context("schema version is too big")?; self.conn - .execute(&sql_query, ()) + .execute("INSERT INTO task_schema_version (version) VALUES (?1)", [value]) .await .context("failed to execute SQL query")?; diff --git a/crates/provisioner-task-store-libsql/tests/store.rs b/crates/provisioner-task-store-libsql/tests/store.rs index b594cd85d..6b1022c9d 100644 --- a/crates/provisioner-task-store-libsql/tests/store.rs +++ b/crates/provisioner-task-store-libsql/tests/store.rs @@ -14,40 +14,53 @@ fn new_task<'a>(id: Uuid, token_jti: Uuid) -> NewTask<'a> { } } +async fn connect(path: &str) -> libsql::Connection { + libsql::Builder::new_local(path) + .build() + .await + .unwrap() + .connect() + .unwrap() +} + +async fn open(path: &str) -> LibSqlProvisionerTaskStore { + LibSqlProvisionerTaskStore::init(connect(path).await).await.unwrap() +} + async fn memory_store() -> LibSqlProvisionerTaskStore { - LibSqlProvisionerTaskStore::open(":memory:").await.unwrap() + open(":memory:").await +} + +async fn query_u64(conn: &libsql::Connection, sql_query: &str) -> u64 { + let row = conn.query(sql_query, ()).await.unwrap().next().await.unwrap().unwrap(); + row.get::(0).unwrap() } #[tokio::test] -async fn migrations_set_user_version_and_are_idempotent() { +async fn migrations_leave_user_version_alone_and_are_idempotent() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("provisioner_tasks.db"); let path = path.to_str().unwrap(); + // Another schema in the same database, such as the job queue, owns `user_version`. + let conn = connect(path).await; + conn.execute("PRAGMA user_version = 7", ()).await.unwrap(); + let id = Uuid::new_v4(); { - let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + let store = LibSqlProvisionerTaskStore::init(conn.clone()).await.unwrap(); store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); } - let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + let store = open(path).await; assert!(store.get(id).await.unwrap().is_some()); - let conn = libsql::Builder::new_local(path) - .build() - .await - .unwrap() - .connect() - .unwrap(); - let row = conn - .query("PRAGMA user_version", ()) - .await - .unwrap() - .next() - .await - .unwrap() - .unwrap(); - assert_eq!(row.get::(0).unwrap(), 1); + assert_eq!(query_u64(&conn, "PRAGMA user_version").await, 7); + assert_eq!( + query_u64(&conn, "SELECT max(version) FROM task_schema_version").await, + 1 + ); + assert_eq!(query_u64(&conn, "SELECT count(*) FROM task_schema_version").await, 1); } #[tokio::test] @@ -158,7 +171,7 @@ async fn rows_are_never_deleted() { let ids = [(); 3].map(|()| Uuid::new_v4()); { - let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + let store = open(path).await; for id in ids { store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); } @@ -166,7 +179,7 @@ async fn rows_are_never_deleted() { store.fail(ids[1], "boom").await.unwrap(); } - let store = LibSqlProvisionerTaskStore::open(path).await.unwrap(); + let store = open(path).await; for id in ids { assert!(store.get(id).await.unwrap().is_some(), "{id}"); } diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index a77608164..d87131828 100644 --- a/devolutions-gateway/src/api/mod.rs +++ b/devolutions-gateway/src/api/mod.rs @@ -48,7 +48,10 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { if state.conf_handle.get_conf().debug.enable_unstable { router = router.nest("/jet/net/monitor", monitoring::make_router(state.clone())); - router = router.nest("/jet/tasks", tasks::make_router(state.clone())); + } + + if let Some(task_service) = state.tasks.clone() { + router = router.nest("/jet/tasks", tasks::make_router(state.clone(), task_service)); } router.with_state(state) diff --git a/devolutions-gateway/src/api/tasks.rs b/devolutions-gateway/src/api/tasks.rs index 4f02b8ba7..fc8ae7f71 100644 --- a/devolutions-gateway/src/api/tasks.rs +++ b/devolutions-gateway/src/api/tasks.rs @@ -7,25 +7,27 @@ use uuid::Uuid; use crate::DgwState; use crate::extract::{TaskToken, TasksReadScope}; -use crate::http::HttpError; -#[cfg(feature = "openapi")] -#[expect(unused_imports, reason = "utoipa refers to the request body schema by its name only")] -use crate::tasks::ai_log::AiLogParams; use crate::tasks::ai_log::{AiLogTarget, AiLogTask}; -use crate::tasks::{StartError, TaskSnapshot, TaskStatus}; +use crate::tasks::{TaskErrorCode, TaskService, TaskSnapshot, TaskStatus}; use crate::token::TaskKind; -pub fn make_router(state: DgwState) -> Router { +#[derive(Clone)] +pub(crate) struct TasksState { + gateway: DgwState, + tasks: TaskService, +} + +pub fn make_router(state: DgwState, tasks: TaskService) -> Router { Router::new() .route("/", routing::post(start_task)) .route("/{id}", routing::get(get_task)) - .with_state(state) + .with_state(TasksState { gateway: state, tasks }) } /// Starts a background task. /// /// The task kind and its target come from the TASK token. -/// The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. +/// The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. /// /// This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. #[cfg_attr(feature = "openapi", utoipa::path( @@ -33,27 +35,27 @@ pub fn make_router(state: DgwState) -> Router { operation_id = "StartTask", tag = "Tasks", path = "/jet/tasks", - request_body(content = AiLogParams, description = "Kind-specific task parameters", content_type = "application/json"), + request_body(content = Object, description = "Kind-specific task parameters, such as `AiLogParams` for `ai-log`", content_type = "application/json"), responses( (status = 202, description = "Task was accepted and runs in the background", body = TaskInfo), (status = 400, description = "Invalid task parameters", body = TaskErrorResponse), (status = 401, description = "Invalid or missing authorization token"), (status = 403, description = "Insufficient permissions"), (status = 409, description = "The task target is busy, such as a session that is still recording", body = TaskErrorResponse), - (status = 500, description = "Unexpected server error"), + (status = 500, description = "Unexpected server error", body = TaskErrorResponse), ), security(("task_token" = [])), ))] pub(crate) async fn start_task( - State(state): State, + State(state): State, TaskToken(claims): TaskToken, body: Bytes, -) -> Result<(StatusCode, Json), StartTaskError> { +) -> Result<(StatusCode, Json), TaskErrorCode> { let snapshot = match claims.kind { TaskKind::AiLog { jet_aid } => { state .tasks - .start_ephemeral::(AiLogTarget { session_id: jet_aid }, &body, claims.jti, &state) + .start_ephemeral::(AiLogTarget { session_id: jet_aid }, &body, claims.jti, &state.gateway) .await? } }; @@ -79,22 +81,24 @@ pub(crate) async fn start_task( (status = 400, description = "Bad request"), (status = 401, description = "Invalid or missing authorization token"), (status = 403, description = "Insufficient permissions"), - (status = 404, description = "No task with this ID"), + (status = 404, description = "No task with this ID", body = TaskErrorResponse), + (status = 500, description = "Unexpected server error", body = TaskErrorResponse), ), security(("scope_token" = ["gateway.tasks.read"])), ))] pub(crate) async fn get_task( - State(state): State, + State(state): State, _scope: TasksReadScope, extract::Path(id): extract::Path, -) -> Result, HttpError> { - state - .tasks - .get(id) - .await - .map_err(HttpError::internal().with_msg("failed to read the task").err())? +) -> Result, TaskErrorCode> { + let snapshot = state.tasks.get(id).await.map_err(|error| { + error!(task.id = %id, error = format!("{error:#}"), "Failed to read the task"); + TaskErrorCode::Internal + })?; + + snapshot .map(|snapshot| Json(TaskInfo::from(snapshot))) - .ok_or_else(|| HttpError::not_found().msg("task not found")) + .ok_or(TaskErrorCode::TaskNotFound) } /// A background task and its status. @@ -154,33 +158,31 @@ impl From for TaskInfo { } } -/// Why a task was not started. +/// Why a task request failed. #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Debug, Serialize)] pub(crate) struct TaskErrorResponse { - /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, - /// `invalid_ai_settings` or `recording_active`. - error: &'static str, -} - -pub(crate) struct StartTaskError(StartError); - -impl From for StartTaskError { - fn from(error: StartError) -> Self { - Self(error) - } + error: TaskErrorCode, } -impl IntoResponse for StartTaskError { +impl IntoResponse for TaskErrorCode { fn into_response(self) -> Response { - let (status, error) = match self.0 { - StartError::InvalidParams(code) => (StatusCode::BAD_REQUEST, code), - StartError::TargetBusy(code) => (StatusCode::CONFLICT, code), - StartError::Internal => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + let status = match self { + TaskErrorCode::InvalidParams + | TaskErrorCode::MissingModel + | TaskErrorCode::MissingApiKey + | TaskErrorCode::MissingBaseUrl + | TaskErrorCode::InvalidAiSettings => StatusCode::BAD_REQUEST, + TaskErrorCode::RecordingActive => StatusCode::CONFLICT, + TaskErrorCode::TaskNotFound => StatusCode::NOT_FOUND, + TaskErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, }; - warn!(%status, error, "Task rejected"); + // Server errors are logged where they happen. + if status.is_client_error() { + debug!(%status, error = ?self, "Task request rejected"); + } - (status, Json(TaskErrorResponse { error })).into_response() + (status, Json(TaskErrorResponse { error: self })).into_response() } } diff --git a/devolutions-gateway/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 173644034..e2b7875fa 100644 --- a/devolutions-gateway/src/job_queue.rs +++ b/devolutions-gateway/src/job_queue.rs @@ -11,11 +11,6 @@ use job_queue_libsql::libsql; use time::OffsetDateTime; use tokio::sync::{Notify, mpsc}; -use crate::DgwState; - -/// Attempts of a job before the queue gives up on it. -pub const JOB_MAX_ATTEMPTS: u32 = 5; - pub struct JobQueueCtx { notify_runner: Arc, runner_waker: RunnerWaker, @@ -43,7 +38,6 @@ pub struct JobRunnerTask { notify_runner: Arc, runner_waker: RunnerWaker, queue: DynJobQueue, - state: DgwState, } impl JobQueueCtx { @@ -65,7 +59,6 @@ impl JobQueueCtx { let queue = job_queue_libsql::LibSqlJobQueue::builder() .runner_waker(runner_waker.clone()) .conn(conn) - .max_attempts(JOB_MAX_ATTEMPTS) .build(); let queue = Arc::new(queue); @@ -89,11 +82,6 @@ impl JobQueueCtx { job_queue_handle: handle, }) } - - /// Returns the JSON definition of every queued job with this name. - pub async fn job_defs(&self, name: &str) -> anyhow::Result> { - self.queue.job_defs(name).await - } } impl JobQueueHandle { @@ -209,12 +197,11 @@ async fn job_queue_task(ctx: JobQueueTask, mut shutdown_signal: ShutdownSignal) } impl JobRunnerTask { - pub fn new(ctx: &JobQueueCtx, state: DgwState) -> Self { + pub fn new(ctx: &JobQueueCtx) -> Self { Self { notify_runner: Arc::clone(&ctx.notify_runner), runner_waker: RunnerWaker::clone(&ctx.runner_waker), queue: Arc::clone(&ctx.queue), - state, } } } @@ -231,18 +218,31 @@ impl Task for JobRunnerTask { } #[instrument(skip_all)] -async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { +async fn job_runner_task(ctx: JobRunnerTask, shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { debug!("Task started"); let JobRunnerTask { notify_runner, runner_waker, queue, - state, } = ctx; - let reader = DgwJobReader { state }; + run_jobs(queue, &DgwJobReader, notify_runner, runner_waker, 16, shutdown_signal).await; + + debug!("Task terminated"); + + Ok(()) +} +/// Runs the jobs of `queue`, at most `max_batch_size` at the same time, until shutdown. +pub(crate) async fn run_jobs( + queue: DynJobQueue, + reader: &dyn JobReader, + notify_runner: Arc, + runner_waker: RunnerWaker, + max_batch_size: usize, + mut shutdown_signal: ShutdownSignal, +) { let spawn = |mut ctx: JobCtx, callback: job_queue::SpawnCallback| { tokio::spawn(async move { let result = ctx.job.run().await; @@ -274,34 +274,27 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal let runner = JobRunner { queue, - reader: &reader, + reader, spawn: &spawn, sleep: &sleep, wait_notified: &wait_notified, wait_notified_timeout: &wait_notified_timeout, waker: runner_waker, - max_batch_size: 16, + max_batch_size, }; tokio::select! { () = runner.run() => {} () = shutdown_signal.wait() => {} } - - debug!("Task terminated"); - - Ok(()) } -struct DgwJobReader { - state: DgwState, -} +struct DgwJobReader; impl JobReader for DgwJobReader { fn read_json(&self, name: &str, json: &str) -> anyhow::Result { use crate::api::jrec::DeleteRecordingsJob; use crate::recording::RemuxJob; - use crate::tasks::TaskJob; match name { RemuxJob::NAME => { @@ -313,7 +306,6 @@ impl JobReader for DgwJobReader { serde_json::from_str(json).context("failed to deserialize DeleteRecordingsJob")?; Ok(Box::new(job)) } - TaskJob::NAME => Ok(Box::new(TaskJob::read_json(json, self.state.clone())?)), _ => anyhow::bail!("unknown job name: {name}"), } } diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index abeee4292..53fe395f3 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -69,7 +69,8 @@ pub struct DgwState { pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, - pub tasks: tasks::TaskService, + /// Set only when the unstable task system is enabled. + pub tasks: Option, } #[doc(hidden)] @@ -112,7 +113,7 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, - tasks: tasks::TaskService::mock(), + tasks: None, }; let handles = MockHandles { diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 527605edf..474d0b315 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -113,6 +113,7 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::tasks::TaskInfo, crate::api::tasks::TaskState, crate::api::tasks::TaskErrorResponse, + crate::tasks::TaskErrorCode, crate::tasks::ai_log::AiLogParams, crate::tasks::ai_log::AiProvider, crate::tasks::ai_log::AiLogSubstate, diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 8153fc9e6..c5da19001 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -267,15 +267,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .await .context("failed to initialize traffic audit manager")?; - let provisioner_tasks = devolutions_gateway::tasks::TaskService::open(conf.provisioner_tasks_database.as_str()) + let provisioner_tasks = devolutions_gateway::tasks::TaskService::open_if_enabled(&conf) .await .context("failed to initialize provisioner tasks")?; - provisioner_tasks - .reconcile(&job_queue_ctx) - .await - .context("failed to reconcile provisioner tasks")?; - let provisioning = devolutions_gateway::provisioning::ProvisioningStore::new(); let synthetic_kdc_registry = devolutions_gateway::credential_injection::SyntheticKdcRegistry::new(); @@ -349,7 +344,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, - tasks: provisioner_tasks, + tasks: provisioner_tasks.clone(), }; for listener in &conf.listeners { @@ -414,10 +409,15 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { job_queue_ctx.job_queue_handle.clone(), )); - tasks.register(devolutions_gateway::job_queue::JobRunnerTask::new( - &job_queue_ctx, - state.clone(), - )); + tasks.register(devolutions_gateway::job_queue::JobRunnerTask::new(&job_queue_ctx)); + + if let Some(provisioner_tasks) = provisioner_tasks { + tasks.register(devolutions_gateway::tasks::TaskRunnerTask::new( + provisioner_tasks, + state.clone(), + )); + } + tasks.register(devolutions_gateway::job_queue::JobQueueTask::new(job_queue_ctx)); tasks.register(traffic_audit_task); diff --git a/devolutions-gateway/src/tasks/ai_log.rs b/devolutions-gateway/src/tasks/ai_log.rs index 4da5b3f03..a4cc7b580 100644 --- a/devolutions-gateway/src/tasks/ai_log.rs +++ b/devolutions-gateway/src/tasks/ai_log.rs @@ -5,7 +5,7 @@ use secrecy::SecretString; use url::Url; use uuid::Uuid; -use super::{EphemeralTask, RetryPolicy, SECRETS_LOST_ERROR, StartError, TaskCtx, TaskError, TaskKind}; +use super::{EphemeralTask, RetryPolicy, SECRETS_LOST_ERROR, TaskCtx, TaskError, TaskErrorCode, TaskKind}; use crate::DgwState; #[derive(Debug, Serialize, Deserialize)] @@ -14,7 +14,7 @@ pub struct AiLogTarget { pub session_id: Uuid, } -/// AI settings used by an `ai-log` task. +/// AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -22,9 +22,9 @@ pub struct AiLogParams { pub provider: AiProvider, /// Model identifier, passed to the provider as is. pub model: String, - /// Required by every provider; kept in memory for this task only. - #[cfg_attr(feature = "openapi", schema(value_type = Option))] - pub api_key: Option, + /// Kept in memory for this task only. + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub api_key: SecretString, /// Overrides the provider default; required for `openai-compatible`. #[cfg_attr(feature = "openapi", schema(value_type = Option))] pub base_url: Option, @@ -94,8 +94,8 @@ impl TaskKind for AiLogTask { return Err(TaskError::Permanent(SECRETS_LOST_ERROR.to_owned())); }; - let _client = build_client(&ctx.params, Some(api_key), &ctx.state) - .map_err(|error| TaskError::Permanent(error.message()))?; + let _client = + build_client(&ctx.params, api_key, &ctx.state).map_err(|error| TaskError::Permanent(error.message()))?; Err(TaskError::Permanent("ai-log task not implemented yet".to_owned())) } @@ -109,9 +109,9 @@ impl EphemeralTask for AiLogTask { target: &AiLogTarget, request: AiLogParams, state: &DgwState, - ) -> Result<(AiLogSettings, SecretString), StartError> { + ) -> Result<(AiLogSettings, SecretString), TaskErrorCode> { if state.recordings.active_recordings.contains(target.session_id) { - return Err(StartError::TargetBusy("recording_active")); + return Err(TaskErrorCode::RecordingActive); } let AiLogParams { @@ -129,20 +129,18 @@ impl EphemeralTask for AiLogTask { max_output_tokens, }; - build_client(&settings, api_key.as_ref(), state).map_err(|error| match error { + build_client(&settings, &api_key, state).map_err(|error| match error { ClientError::Build(error) => { let code = build_error_code(&error); - warn!(%error, code, "Invalid AI settings"); - StartError::InvalidParams(code) + debug!(%error, ?code, "Invalid AI settings"); + code } ClientError::HttpClient(error) => { error!(%error, "Failed to build the HTTP client for the AI provider"); - StartError::Internal + TaskErrorCode::Internal } })?; - let api_key = api_key.ok_or(StartError::InvalidParams("missing_api_key"))?; - Ok((settings, api_key)) } } @@ -161,18 +159,13 @@ impl ClientError { } } -fn build_client( - settings: &AiLogSettings, - api_key: Option<&SecretString>, - state: &DgwState, -) -> Result { +fn build_client(settings: &AiLogSettings, api_key: &SecretString, state: &DgwState) -> Result { let provider = Provider::from(settings.provider); - let mut builder = AiClient::builder().provider(provider).model(settings.model.clone()); - - if let Some(api_key) = api_key { - builder = builder.api_key(api_key.clone()); - } + let mut builder = AiClient::builder() + .provider(provider) + .model(settings.model.clone()) + .api_key(api_key.clone()); let endpoint = settings.base_url.clone().or_else(|| provider.default_base_url()); @@ -194,12 +187,12 @@ fn build_client( builder.build().map_err(ClientError::Build) } -fn build_error_code(error: &BuildError) -> &'static str { +fn build_error_code(error: &BuildError) -> TaskErrorCode { match error { - BuildError::MissingModel => "missing_model", - BuildError::MissingApiKey(_) => "missing_api_key", - BuildError::MissingBaseUrl(_) => "missing_base_url", - _ => "invalid_ai_settings", + BuildError::MissingModel => TaskErrorCode::MissingModel, + BuildError::MissingApiKey(_) => TaskErrorCode::MissingApiKey, + BuildError::MissingBaseUrl(_) => TaskErrorCode::MissingBaseUrl, + _ => TaskErrorCode::InvalidAiSettings, } } @@ -254,7 +247,7 @@ mod tests { let error = AiLogTask::prepare(&target, params(), &state).expect_err("session is busy"); - assert_eq!(error, StartError::TargetBusy("recording_active")); + assert_eq!(error, TaskErrorCode::RecordingActive); } #[tokio::test] @@ -277,14 +270,14 @@ mod tests { #[test] fn build_errors_map_to_stable_codes() { - assert_eq!(build_error_code(&BuildError::MissingModel), "missing_model"); + assert_eq!(build_error_code(&BuildError::MissingModel), TaskErrorCode::MissingModel); assert_eq!( build_error_code(&BuildError::MissingApiKey(Provider::OpenAi)), - "missing_api_key" + TaskErrorCode::MissingApiKey ); assert_eq!( build_error_code(&BuildError::MissingBaseUrl(Provider::OpenAiCompatible)), - "missing_base_url" + TaskErrorCode::MissingBaseUrl ); } diff --git a/devolutions-gateway/src/tasks/mod.rs b/devolutions-gateway/src/tasks/mod.rs index b208579e9..9160b8650 100644 --- a/devolutions-gateway/src/tasks/mod.rs +++ b/devolutions-gateway/src/tasks/mod.rs @@ -1,9 +1,12 @@ //! Background tasks started by the provisioner through `POST /jet/tasks` and polled through `GET /jet/tasks/{id}`. //! //! Every task has a record in the provisioner task database, kept forever so it can be audited. -//! Each task runs as a job of the job queue. The job definition holds only the persisted, non-secret parameters, -//! so a [`DurableTask`] resumes after a restart. The secrets of an [`EphemeralTask`] stay in memory only: -//! when Gateway restarts, the task fails instead. +//! Each task runs as a job of a job queue stored in that same database, with its own runner: +//! tasks never take a slot from the other Gateway jobs, and other jobs never delay a task. +//! The job definition holds only the persisted, non-secret parameters, so a [`DurableTask`] resumes after a restart. +//! The secrets of an [`EphemeralTask`] stay in memory only: when Gateway restarts, the task fails instead. +//! +//! The task system is unstable: it starts only when `__debug__.enable_unstable` is set. pub mod ai_log; @@ -16,22 +19,28 @@ use std::time::Duration; use anyhow::Context as _; use async_trait::async_trait; +use devolutions_gateway_task::{ShutdownSignal, Task}; +use job_queue::{DynJob, DynJobQueue, JobQueue as _, JobReader, RunnerWaker}; +use job_queue_libsql::{LibSqlJobQueue, libsql}; use parking_lot::Mutex; use provisioner_task_store_libsql::{LibSqlProvisionerTaskStore, NewTask, TaskRecord, TaskState}; use serde::Serialize; use serde::de::DeserializeOwned; -use tokio::sync::{OnceCell, Semaphore}; +use tokio::sync::Notify; use uuid::Uuid; use crate::DgwState; -use crate::job_queue::{JOB_MAX_ATTEMPTS, JobQueueCtx}; +use crate::config::Conf; /// Number of tasks running at the same time; other tasks wait in the `NotStarted` state. pub const MAX_CONCURRENT_TASKS: usize = 2; -/// Longest time one attempt of a task may run, not counting the time it waits for a free slot. +/// Longest time one attempt of a task may run. pub const TASK_TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Attempts of a task job before the task job queue gives up on it. +pub const TASK_MAX_ATTEMPTS: u32 = 5; + pub const SECRETS_LOST_ERROR: &str = "gateway restarted, API key no longer available"; pub const JOB_LOST_ERROR: &str = "gateway restarted, task job no longer exists"; @@ -47,7 +56,7 @@ pub enum TaskError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RetryPolicy { - /// Attempts in total, capped by the job queue. + /// Attempts in total, capped by the task job queue. pub max_attempts: u32, } @@ -55,7 +64,7 @@ impl RetryPolicy { pub const NO_RETRY: Self = Self { max_attempts: 1 }; pub const JOB_QUEUE: Self = Self { - max_attempts: JOB_MAX_ATTEMPTS, + max_attempts: TASK_MAX_ATTEMPTS, }; } @@ -84,7 +93,7 @@ pub trait TaskKind: Sized + Send + Sync + 'static { /// A task whose inputs are all persisted, so it resumes after a restart. pub trait DurableTask: TaskKind { /// Checks the request before the task is recorded. - fn prepare(target: &Self::Target, params: &Self::Params, state: &DgwState) -> Result<(), StartError>; + fn prepare(target: &Self::Target, params: &Self::Params, state: &DgwState) -> Result<(), TaskErrorCode>; } /// A task that needs secrets, kept in memory only until the task finishes. @@ -99,16 +108,29 @@ pub trait EphemeralTask: TaskKind { target: &Self::Target, request: Self::Request, state: &DgwState, - ) -> Result<(Self::Params, Self::Secrets), StartError>; + ) -> Result<(Self::Params, Self::Secrets), TaskErrorCode>; } -/// Reason why a task was not started. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StartError { - /// The request parameters are invalid; the code is stable and safe to show. - InvalidParams(&'static str), - /// The target cannot be worked on right now; the code is stable and safe to show. - TargetBusy(&'static str), +/// Stable code telling a client why a task request failed; safe to show. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskErrorCode { + /// The body is not valid JSON or does not match the parameters of the task kind. + InvalidParams, + /// `ai-log`: the model is empty. + MissingModel, + /// `ai-log`: the API key is empty. + MissingApiKey, + /// `ai-log`: the provider has no default base URL, so the request must give one. + MissingBaseUrl, + /// `ai-log`: the AI settings are invalid for another reason. + InvalidAiSettings, + /// `ai-log`: the session is still recording. + RecordingActive, + /// No task has this ID. + TaskNotFound, + /// Unexpected server error. Internal, } @@ -177,12 +199,7 @@ pub struct Progress { impl Progress { pub async fn set(&self, substate: &S) { - let stored = match self.tasks.store().await { - Ok(store) => store.set_substate(self.id, &to_json(substate)).await, - Err(error) => Err(error), - }; - - if let Err(error) = stored { + if let Err(error) = self.tasks.store().set_substate(self.id, &to_json(substate)).await { warn!(task.id = %self.id, error = format!("{error:#}"), "Failed to store the task substate"); } } @@ -191,66 +208,105 @@ impl Progress { type SecretsMap = HashMap>; struct TaskServiceInner { - store: OnceCell, - /// Where the store is opened on first use, when it was not opened up front. - lazy_path: &'static str, + store: LibSqlProvisionerTaskStore, + /// Stored in the same database as the task records. + queue: DynJobQueue, + notify_runner: Arc, + runner_waker: RunnerWaker, secrets: Mutex, - slots: Semaphore, timeout: Duration, } -/// Starts background tasks, runs them as jobs and reads their records. +/// Starts background tasks, queues their jobs and reads their records. #[derive(Clone)] pub struct TaskService { inner: Arc, } impl TaskService { - /// Opens the task database at `path`. - pub async fn open(path: &str) -> anyhow::Result { - let store = LibSqlProvisionerTaskStore::open(path) - .await - .context("failed to open the provisioner task database")?; + /// Opens the provisioner task database when `enable_unstable` is set; otherwise, never touches it. + /// + /// Call it at startup, before the task runner starts. + pub async fn open_if_enabled(conf: &Conf) -> anyhow::Result> { + if !conf.debug.enable_unstable { + return Ok(None); + } - Ok(Self::with_limits( - OnceCell::new_with(Some(store)), - MAX_CONCURRENT_TASKS, - TASK_TIMEOUT, - )) + Self::open(conf.provisioner_tasks_database.as_str(), TASK_TIMEOUT) + .await + .map(Some) } - /// Opens an in-memory database on first use, so tests that never start a task never create one. - #[doc(hidden)] - pub fn mock() -> Self { - Self::with_limits(OnceCell::new(), MAX_CONCURRENT_TASKS, TASK_TIMEOUT) - } + /// Opens the database at `path`, then fails every unfinished task whose job is gone. + async fn open(path: &str, timeout: Duration) -> anyhow::Result { + let conn = libsql::Builder::new_local(path) + .build() + .await + .context("failed to open the provisioner task database")? + .connect() + .context("failed to connect to the provisioner task database")?; - fn with_limits(store: OnceCell, max_concurrent: usize, timeout: Duration) -> Self { - Self { + let notify_runner = Arc::new(Notify::new()); + + let runner_waker = RunnerWaker::new({ + let notify_runner = Arc::clone(¬ify_runner); + move || notify_runner.notify_one() + }); + + let queue = LibSqlJobQueue::builder() + .runner_waker(runner_waker.clone()) + .conn(conn.clone()) + .max_attempts(TASK_MAX_ATTEMPTS) + .build(); + + queue.setup().await.context("failed to set up the task job queue")?; + + queue + .reset_claimed_jobs() + .await + .context("failed to reset the claimed task jobs")?; + + queue + .clear_failed() + .await + .context("failed to clear the failed task jobs")?; + + let store = LibSqlProvisionerTaskStore::init(conn) + .await + .context("failed to set up the provisioner task records")?; + + let service = Self { inner: Arc::new(TaskServiceInner { store, - lazy_path: ":memory:", + queue: Arc::new(queue), + notify_runner, + runner_waker, secrets: Mutex::new(HashMap::new()), - slots: Semaphore::new(max_concurrent), timeout, }), - } - } + }; - async fn store(&self) -> anyhow::Result<&LibSqlProvisionerTaskStore> { - self.inner - .store - .get_or_try_init(|| LibSqlProvisionerTaskStore::open(self.inner.lazy_path)) + service + .reconcile() .await + .context("failed to reconcile the provisioner tasks")?; + + Ok(service) + } + + fn store(&self) -> &LibSqlProvisionerTaskStore { + &self.inner.store } pub async fn get(&self, id: Uuid) -> anyhow::Result> { - Ok(self.store().await?.get(id).await?.map(TaskSnapshot::from)) + Ok(self.store().get(id).await?.map(TaskSnapshot::from)) } - /// Fails every unfinished task that has no job left in the queue; call it at startup, before the job runner. - pub async fn reconcile(&self, job_queue: &JobQueueCtx) -> anyhow::Result<()> { - let defs = job_queue + /// Fails every unfinished task that has no job left in the queue. + async fn reconcile(&self) -> anyhow::Result<()> { + let defs = self + .inner + .queue .job_defs(TaskJob::NAME) .await .context("failed to list the task jobs")?; @@ -265,7 +321,7 @@ impl TaskService { .map(|def| def.task_id) .collect::>(); - let store = self.store().await?; + let store = self.store(); for id in store.unfinished().await? { if !queued.contains(&id) && store.fail(id, JOB_LOST_ERROR).await? { @@ -283,7 +339,7 @@ impl TaskService { body: &[u8], token_jti: Uuid, state: &DgwState, - ) -> Result { + ) -> Result { let request = parse_body::(body)?; let (params, secrets) = K::prepare(&target, request, state)?; self.create::(&target, ¶ms, token_jti, Some(Arc::new(secrets)), state) @@ -297,7 +353,7 @@ impl TaskService { body: &[u8], token_jti: Uuid, state: &DgwState, - ) -> Result { + ) -> Result { let params = parse_body::(body)?; K::prepare(&target, ¶ms, state)?; self.create::(&target, ¶ms, token_jti, None, state).await @@ -310,14 +366,14 @@ impl TaskService { token_jti: Uuid, secrets: Option>, state: &DgwState, - ) -> Result { + ) -> Result { let id = Uuid::new_v4(); let (target, params) = match (serde_json::to_value(target), serde_json::to_value(params)) { (Ok(target), Ok(params)) => (target, params), (Err(error), _) | (_, Err(error)) => { error!(%error, task.kind = K::KIND, "Failed to serialize the task definition"); - return Err(StartError::Internal); + return Err(TaskErrorCode::Internal); } }; @@ -333,36 +389,33 @@ impl TaskService { self.inner.secrets.lock().insert(id, secrets); } - let inserted = match self.store().await { - Ok(store) => { - store - .insert(NewTask { - id, - kind: K::KIND, - target: &def.target.to_string(), - params: &def.params.to_string(), - token_jti, - }) - .await - } - Err(error) => Err(error), - }; + let inserted = self + .store() + .insert(NewTask { + id, + kind: K::KIND, + target: &def.target.to_string(), + params: &def.params.to_string(), + token_jti, + }) + .await; if let Err(error) = inserted { error!(task.id = %id, task.kind = K::KIND, error = format!("{error:#}"), "Failed to record the task"); self.forget_secrets(id); - return Err(StartError::Internal); + return Err(TaskErrorCode::Internal); } - let job = TaskJob { + let job: DynJob = Box::new(TaskJob { def, + tasks: self.clone(), state: state.clone(), - }; + }); - if let Err(error) = state.job_queue_handle.enqueue(job).await { + if let Err(error) = self.inner.queue.push_job(&job, None).await { error!(task.id = %id, task.kind = K::KIND, error = format!("{error:#}"), "Failed to queue the task"); self.fail(id, "failed to queue the task").await; - return Err(StartError::Internal); + return Err(TaskErrorCode::Internal); } info!(task.id = %id, task.kind = K::KIND, %token_jti, "Background task created"); @@ -413,11 +466,9 @@ impl TaskService { } }; - let _permit = self.inner.slots.acquire().await.context("task slots are closed")?; - let substate = to_json(&K::Substate::default()); - let store = self.store().await?; + let store = self.store(); let Some(attempt) = store.start_attempt(id, &substate).await? else { debug!(task.id = %id, task.kind = K::KIND, "Background task is already finished"); @@ -441,13 +492,20 @@ impl TaskService { secrets, }; - let max_attempts = K::RETRY.max_attempts.min(JOB_MAX_ATTEMPTS); + let max_attempts = K::RETRY.max_attempts.min(TASK_MAX_ATTEMPTS); match run_attempt::(ctx, self.inner.timeout).await { - Ok(output) => { - store.succeed(id, &to_json(&output)).await?; - info!(task.id = %id, task.kind = K::KIND, attempt, "Background task succeeded"); - } + // An error returned to the job queue would run the task again, so a failure to store is only logged. + Ok(output) => match store.succeed(id, &to_json(&output)).await { + Ok(_) => info!(task.id = %id, task.kind = K::KIND, attempt, "Background task succeeded"), + Err(error) => error!( + task.id = %id, + task.kind = K::KIND, + attempt, + error = format!("{error:#}"), + "Background task succeeded but its result was not stored" + ), + }, Err(TaskError::Transient(error)) if attempt < max_attempts => { warn!(task.id = %id, task.kind = K::KIND, attempt, max_attempts, %error, "Background task attempt failed"); store.retry_later(id, &error).await?; @@ -467,12 +525,7 @@ impl TaskService { async fn fail(&self, id: Uuid, error: &str) { self.forget_secrets(id); - let stored = match self.store().await { - Ok(store) => store.fail(id, error).await.map(|_| ()), - Err(store_error) => Err(store_error), - }; - - if let Err(store_error) = stored { + if let Err(store_error) = self.store().fail(id, error).await { error!(task.id = %id, error = format!("{store_error:#}"), "Failed to record the task failure"); } } @@ -496,17 +549,17 @@ async fn run_attempt(ctx: TaskCtx, timeout: Duration) -> Result< } } -fn parse_body(body: &[u8]) -> Result { +fn parse_body(body: &[u8]) -> Result { // The serde error is not logged because it may quote the rejected value, which could be a secret. serde_json::from_slice::(body).map_err(|error| { - warn!( + debug!( task.kind = K::KIND, category = ?error.classify(), line = error.line(), column = error.column(), "Invalid task parameters" ); - StartError::InvalidParams("invalid_params") + TaskErrorCode::InvalidParams }) } @@ -532,17 +585,18 @@ struct TaskJobDef { } /// Job running one attempt of a background task. -pub(crate) struct TaskJob { +struct TaskJob { def: TaskJobDef, + tasks: TaskService, state: DgwState, } impl TaskJob { - pub(crate) const NAME: &'static str = "provisioner-task"; + const NAME: &'static str = "provisioner-task"; - pub(crate) fn read_json(json: &str, state: DgwState) -> anyhow::Result { + fn read_json(json: &str, tasks: TaskService, state: DgwState) -> anyhow::Result { let def = serde_json::from_str(json).context("failed to deserialize the task job")?; - Ok(Self { def, state }) + Ok(Self { def, tasks, state }) } } @@ -557,19 +611,80 @@ impl job_queue::Job for TaskJob { } async fn run(&mut self) -> anyhow::Result<()> { - let tasks = self.state.tasks.clone(); let def = self.def.clone(); match def.kind.as_str() { - ai_log::AiLogTask::KIND => tasks.execute_ephemeral::(def, &self.state).await, + ai_log::AiLogTask::KIND => { + self.tasks + .execute_ephemeral::(def, &self.state) + .await + } kind => { error!(task.id = %def.task_id, task.kind = kind, "Unknown task kind"); - tasks.fail(def.task_id, "unknown task kind").await; + self.tasks.fail(def.task_id, "unknown task kind").await; Ok(()) } } } } +struct TaskJobReader { + tasks: TaskService, + state: DgwState, +} + +impl JobReader for TaskJobReader { + fn read_json(&self, name: &str, json: &str) -> anyhow::Result { + match name { + TaskJob::NAME => { + let job = TaskJob::read_json(json, self.tasks.clone(), self.state.clone())?; + Ok(Box::new(job)) + } + _ => anyhow::bail!("unknown job name: {name}"), + } + } +} + +/// Runs the jobs of the provisioner tasks. +pub struct TaskRunnerTask { + tasks: TaskService, + state: DgwState, +} + +impl TaskRunnerTask { + pub fn new(tasks: TaskService, state: DgwState) -> Self { + Self { tasks, state } + } +} + +#[async_trait] +impl Task for TaskRunnerTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "provisioner task runner"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { + let inner = Arc::clone(&self.tasks.inner); + + let reader = TaskJobReader { + tasks: self.tasks, + state: self.state, + }; + + // The runner claims no more jobs than may run at once, so a claimed job never waits for a slot. + crate::job_queue::run_jobs( + Arc::clone(&inner.queue), + &reader, + Arc::clone(&inner.notify_runner), + inner.runner_waker.clone(), + MAX_CONCURRENT_TASKS, + shutdown_signal, + ) + .await; + + Ok(()) + } +} + #[cfg(test)] mod tests; diff --git a/devolutions-gateway/src/tasks/tests.rs b/devolutions-gateway/src/tasks/tests.rs index ab49dbfff..ba420b02b 100644 --- a/devolutions-gateway/src/tasks/tests.rs +++ b/devolutions-gateway/src/tasks/tests.rs @@ -55,20 +55,60 @@ impl TaskKind for Scripted { } impl DurableTask for Scripted { - fn prepare(_: &(), _: &Outcome, _: &DgwState) -> Result<(), StartError> { + fn prepare(_: &(), _: &Outcome, _: &DgwState) -> Result<(), TaskErrorCode> { + Ok(()) + } +} + +/// Durable test task that succeeds after making the task records unwritable. +struct LosesStore; + +impl TaskKind for LosesStore { + const KIND: &'static str = "loses-store"; + const RETRY: RetryPolicy = RetryPolicy::JOB_QUEUE; + + /// Path of the task database. + type Target = String; + type Params = (); + type Substate = Step; + type Output = u32; + + async fn run(ctx: TaskCtx) -> Result { + let conn = libsql::Builder::new_local(ctx.target.as_str()) + .build() + .await + .expect("database") + .connect() + .expect("connection"); + + conn.execute("ALTER TABLE task RENAME TO task_gone", ()) + .await + .expect("rename"); + + Ok(42) + } +} + +impl DurableTask for LosesStore { + fn prepare(_: &String, _: &(), _: &DgwState) -> Result<(), TaskErrorCode> { Ok(()) } } struct Harness { state: DgwState, - handles: MockHandles, + tasks: TaskService, + _handles: MockHandles, _dir: tempfile::TempDir, db_path: String, } impl Harness { async fn new() -> Self { + Self::with_timeout(TASK_TIMEOUT).await + } + + async fn with_timeout(timeout: Duration) -> Self { let dir = tempfile::tempdir().expect("temp dir"); let db_path = dir .path() @@ -77,56 +117,72 @@ impl Harness { .expect("UTF-8") .to_owned(); - let (mut state, handles) = DgwState::mock(CONFIG).expect("mock state"); - state.tasks = TaskService::open(&db_path).await.expect("task service"); + let (state, handles) = DgwState::mock(CONFIG).expect("mock state"); + let tasks = TaskService::open(&db_path, timeout).await.expect("task service"); Self { state, - handles, + tasks, + _handles: handles, _dir: dir, db_path, } } - fn tasks(&self) -> &TaskService { - &self.state.tasks - } + /// Reads back the job of a task from the task job queue. + async fn queued_job(&self, id: Uuid) -> TaskJob { + let json = self + .tasks + .inner + .queue + .job_defs(TaskJob::NAME) + .await + .expect("job definitions") + .into_iter() + .find(|json| serde_json::from_str::(json).is_ok_and(|def| def.task_id == id)) + .expect("queued job"); - /// Takes the job queued by the last started task, as the job queue would read it back. - async fn queued_job(&mut self) -> TaskJob { - let message = self.handles.job_queue_rx.recv().await.expect("queued job"); - assert_eq!(message.job.name(), TaskJob::NAME); - let json = message.job.write_json().expect("job JSON"); - TaskJob::read_json(&json, self.state.clone()).expect("valid job") + TaskJob::read_json(&json, self.tasks.clone(), self.state.clone()).expect("valid job") } - async fn start_scripted(&mut self, outcome: Outcome) -> TaskJob { + async fn start_scripted(&self, outcome: Outcome) -> TaskJob { let body = serde_json::to_vec(&outcome).expect("JSON"); - let state = self.state.clone(); - self.tasks() - .start_durable::>((), &body, Uuid::new_v4(), &state) + let snapshot = self + .tasks + .start_durable::>((), &body, Uuid::new_v4(), &self.state) .await .expect("task starts"); - self.queued_job().await + self.queued_job(snapshot.id).await } async fn run_scripted(&self, job: &TaskJob) -> anyhow::Result<()> { - self.tasks() + self.tasks .execute_durable::>(job.def.clone(), &self.state) .await } + async fn start_ai_log(&self) -> TaskSnapshot { + let body = serde_json::json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }).to_string(); + let target = AiLogTarget { + session_id: Uuid::new_v4(), + }; + + self.tasks + .start_ephemeral::(target, body.as_bytes(), Uuid::new_v4(), &self.state) + .await + .expect("task starts") + } + async fn record(&self, id: Uuid) -> TaskRecord { - let store = self.tasks().store().await.expect("store"); - store.get(id).await.expect("read").expect("record exists") + self.tasks.store().get(id).await.expect("read").expect("record exists") } } #[tokio::test] async fn success_stores_the_result() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let job = harness.start_scripted::<5>(Outcome::Succeed).await; let record = harness.record(job.def.task_id).await; @@ -143,7 +199,7 @@ async fn success_stores_the_result() { #[tokio::test] async fn transient_error_asks_the_job_queue_for_a_retry() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let job = harness.start_scripted::<3>(Outcome::Transient).await; let id = job.def.task_id; @@ -172,7 +228,7 @@ async fn transient_error_asks_the_job_queue_for_a_retry() { #[tokio::test] async fn retry_policy_is_capped_by_the_job_queue() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let job = harness.start_scripted::<100>(Outcome::Transient).await; let mut retried = 0; @@ -180,13 +236,13 @@ async fn retry_policy_is_capped_by_the_job_queue() { retried += 1; } - assert_eq!(retried, JOB_MAX_ATTEMPTS - 1); + assert_eq!(retried, TASK_MAX_ATTEMPTS - 1); assert_eq!(harness.record(job.def.task_id).await.state, TaskState::Failed); } #[tokio::test] async fn permanent_error_fails_without_retry() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let job = harness.start_scripted::<5>(Outcome::Permanent).await; harness.run_scripted::<5>(&job).await.expect("no retry"); @@ -200,7 +256,7 @@ async fn permanent_error_fails_without_retry() { #[tokio::test] async fn finished_task_is_not_run_again() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let job = harness.start_scripted::<5>(Outcome::Succeed).await; harness.run_scripted::<5>(&job).await.expect("first run"); @@ -211,9 +267,7 @@ async fn finished_task_is_not_run_again() { #[tokio::test] async fn timeout_and_panic_are_permanent_failures() { - let mut harness = Harness::new().await; - let store = LibSqlProvisionerTaskStore::open(&harness.db_path).await.expect("store"); - harness.state.tasks = TaskService::with_limits(OnceCell::new_with(Some(store)), 1, Duration::from_millis(20)); + let harness = Harness::with_timeout(Duration::from_millis(20)).await; for (outcome, error) in [(Outcome::Hang, "task timed out"), (Outcome::Panic, "task panicked")] { let job = harness.start_scripted::<5>(outcome).await; @@ -227,30 +281,17 @@ async fn timeout_and_panic_are_permanent_failures() { #[tokio::test] async fn ephemeral_task_fails_without_retry_after_a_restart() { - let mut harness = Harness::new().await; - let body = serde_json::json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }).to_string(); - let state = harness.state.clone(); + let harness = Harness::new().await; + let snapshot = harness.start_ai_log().await; - let snapshot = harness - .tasks() - .start_ephemeral::( - AiLogTarget { - session_id: Uuid::new_v4(), - }, - body.as_bytes(), - Uuid::new_v4(), - &state, - ) - .await - .expect("task starts"); - - let message = harness.handles.job_queue_rx.recv().await.expect("queued job"); - let json = message.job.write_json().expect("job JSON"); + let json = harness.queued_job(snapshot.id).await.write_json().expect("job JSON"); assert!(!json.contains(API_KEY), "{json}"); // A restart keeps the database but loses the secrets held in memory. - harness.state.tasks = TaskService::open(&harness.db_path).await.expect("task service"); - let mut job = TaskJob::read_json(&json, harness.state.clone()).expect("valid job"); + let restarted = TaskService::open(&harness.db_path, TASK_TIMEOUT) + .await + .expect("task service"); + let mut job = TaskJob::read_json(&json, restarted, harness.state.clone()).expect("valid job"); job.run().await.expect("no retry"); @@ -262,29 +303,15 @@ async fn ephemeral_task_fails_without_retry_after_a_restart() { #[tokio::test] async fn secrets_are_dropped_when_the_task_finishes() { - let mut harness = Harness::new().await; - let body = serde_json::json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }).to_string(); - let state = harness.state.clone(); - - let snapshot = harness - .tasks() - .start_ephemeral::( - AiLogTarget { - session_id: Uuid::new_v4(), - }, - body.as_bytes(), - Uuid::new_v4(), - &state, - ) - .await - .expect("task starts"); + let harness = Harness::new().await; + let snapshot = harness.start_ai_log().await; - assert!(harness.tasks().inner.secrets.lock().contains_key(&snapshot.id)); + assert!(harness.tasks.inner.secrets.lock().contains_key(&snapshot.id)); - let mut job = harness.queued_job().await; + let mut job = harness.queued_job(snapshot.id).await; job.run().await.expect("no retry"); - assert!(harness.tasks().inner.secrets.lock().is_empty()); + assert!(harness.tasks.inner.secrets.lock().is_empty()); let record = harness.record(snapshot.id).await; assert_eq!(record.state, TaskState::Failed); @@ -292,16 +319,34 @@ async fn secrets_are_dropped_when_the_task_finishes() { assert!(!record.params.contains(API_KEY), "{}", record.params); } +#[tokio::test] +async fn success_is_not_run_again_when_its_record_cannot_be_written() { + let harness = Harness::new().await; + + let snapshot = harness + .tasks + .start_durable::(harness.db_path.clone(), b"null", Uuid::new_v4(), &harness.state) + .await + .expect("task starts"); + let job = harness.queued_job(snapshot.id).await; + + harness + .tasks + .execute_durable::(job.def.clone(), &harness.state) + .await + .expect("a succeeded run is not retried"); +} + #[tokio::test] async fn reconcile_fails_unfinished_tasks_without_a_job() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let queued = harness.start_scripted::<5>(Outcome::Succeed).await; let lost = harness.start_scripted::<5>(Outcome::Succeed).await; let running_lost = harness.start_scripted::<5>(Outcome::Succeed).await; let finished = harness.start_scripted::<5>(Outcome::Succeed).await; - let store = harness.tasks().store().await.expect("store"); + let store = harness.tasks.store(); store .start_attempt(running_lost.def.task_id, "null") .await @@ -309,7 +354,7 @@ async fn reconcile_fails_unfinished_tasks_without_a_job() { harness.run_scripted::<5>(&finished).await.expect("run"); let defs = vec![queued.write_json().expect("JSON"), "not a task job".to_owned()]; - harness.tasks().reconcile_with_job_defs(&defs).await.expect("reconcile"); + harness.tasks.reconcile_with_job_defs(&defs).await.expect("reconcile"); assert_eq!(harness.record(queued.def.task_id).await.state, TaskState::NotStarted); assert_eq!(harness.record(finished.def.task_id).await.state, TaskState::Success); @@ -323,22 +368,22 @@ async fn reconcile_fails_unfinished_tasks_without_a_job() { #[tokio::test] async fn snapshot_reflects_the_record() { - let mut harness = Harness::new().await; + let harness = Harness::new().await; let job = harness.start_scripted::<5>(Outcome::Succeed).await; let id = job.def.task_id; - let snapshot = harness.tasks().get(id).await.expect("read").expect("exists"); + let snapshot = harness.tasks.get(id).await.expect("read").expect("exists"); assert_eq!(snapshot.kind, "scripted"); assert_eq!(snapshot.status, TaskStatus::NotStarted); - let store = harness.tasks().store().await.expect("store"); + let store = harness.tasks.store(); store.start_attempt(id, r#"{"step":1}"#).await.expect("start"); assert_eq!( - harness.tasks().get(id).await.expect("read").expect("exists").status, + harness.tasks.get(id).await.expect("read").expect("exists").status, TaskStatus::Running { substate: serde_json::json!({ "step": 1 }) } ); - assert!(harness.tasks().get(Uuid::new_v4()).await.expect("read").is_none()); + assert!(harness.tasks.get(Uuid::new_v4()).await.expect("read").is_none()); } diff --git a/devolutions-gateway/tests/tasks.rs b/devolutions-gateway/tests/tasks.rs index dc31c07ab..cc993e51a 100644 --- a/devolutions-gateway/tests/tasks.rs +++ b/devolutions-gateway/tests/tasks.rs @@ -12,8 +12,7 @@ use axum::body::Body; use axum::extract::connect_info::MockConnectInfo; use axum::http::{self, Request, StatusCode}; use base64::Engine as _; -use devolutions_gateway::job_queue::{JobQueueCtx, JobQueueTask, JobRunnerTask}; -use devolutions_gateway::tasks::{SECRETS_LOST_ERROR, TaskService}; +use devolutions_gateway::tasks::{SECRETS_LOST_ERROR, TaskRunnerTask, TaskService}; use devolutions_gateway::{DgwState, MockHandles}; use devolutions_gateway_task::{ChildTask, ShutdownHandle, Task as _}; use http_body_util::BodyExt as _; @@ -24,22 +23,27 @@ use uuid::Uuid; const API_KEY: &str = "sk-task-api-test-secret"; -const CONFIG: &str = r#"{ - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { - "InternalUrl": "http://*:7171", - "ExternalUrl": "https://*:7171" +/// Gateway configuration keeping the task database in `dir`, so a later start on the same `dir` acts as a restart. +fn config(dir: &Path, enable_unstable: bool) -> String { + json!({ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [ + { + "InternalUrl": "http://*:7171", + "ExternalUrl": "https://*:7171" + } + ], + "Proxy": { "Mode": "Off" }, + "ProvisionerTasksDatabase": tasks_db(dir), + "__debug__": { + "disable_token_validation": true, + "enable_unstable": enable_unstable } - ], - "Proxy": { "Mode": "Off" }, - "__debug__": { - "disable_token_validation": true, - "enable_unstable": true - } -}"#; + }) + .to_string() +} struct Gateway { app: Router, @@ -55,8 +59,12 @@ enum Jobs { } impl Gateway { - /// Starts a Gateway whose task and job databases live in `dir`, so a later start acts as a restart. - async fn start(config: &str, dir: &Path, jobs: Jobs) -> anyhow::Result { + /// Starts a Gateway with the task system enabled, keeping the task database in `dir`. + async fn start(dir: &Path, jobs: Jobs) -> anyhow::Result { + Self::start_with_config(&config(dir, true), jobs).await + } + + async fn start_with_config(config: &str, jobs: Jobs) -> anyhow::Result { let (mut state, handles) = DgwState::mock(config)?; let MockHandles { session_manager_rx, @@ -70,22 +78,16 @@ impl Gateway { // The auth middleware asks the session manager about any token carrying `jet_aid`; nothing answers in the mock. drop(session_manager_rx); - state.tasks = TaskService::open(tasks_db(dir).to_str().unwrap()).await?; - - let job_queue_ctx = JobQueueCtx::init(&job_queue_db(dir)).await?; - state.tasks.reconcile(&job_queue_ctx).await?; - state.job_queue_handle = job_queue_ctx.job_queue_handle.clone(); + state.tasks = TaskService::open_if_enabled(&state.conf_handle.get_conf()).await?; let (shutdown_handle, shutdown_signal) = ShutdownHandle::new(); let mut job_tasks = Vec::new(); - if jobs == Jobs::Run { - let runner = JobRunnerTask::new(&job_queue_ctx, state.clone()); - job_tasks.push(ChildTask::spawn(runner.run(shutdown_signal.clone()))); + if let (Some(tasks), Jobs::Run) = (state.tasks.clone(), jobs) { + let runner = TaskRunnerTask::new(tasks, state.clone()); + job_tasks.push(ChildTask::spawn(runner.run(shutdown_signal))); } - job_tasks.push(ChildTask::spawn(JobQueueTask::new(job_queue_ctx).run(shutdown_signal))); - let app = devolutions_gateway::make_http_service(state) .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -116,12 +118,9 @@ fn tasks_db(dir: &Path) -> PathBuf { dir.join("provisioner_tasks.db") } -fn job_queue_db(dir: &Path) -> PathBuf { - dir.join("job_queue.db") -} - +/// Jobs in the task job queue, which lives in the task database. async fn queued_job_count(dir: &Path) -> u64 { - let conn = job_queue_libsql::libsql::Builder::new_local(job_queue_db(dir)) + let conn = job_queue_libsql::libsql::Builder::new_local(tasks_db(dir)) .build() .await .unwrap() @@ -150,7 +149,7 @@ async fn wait_for_queued_jobs(dir: &Path, expected: u64) { .expect("job queue reaches the expected size"); } -/// Every file of both databases, WAL included, since SQLite may not have checkpointed yet. +/// Every file of the task database, WAL included, since SQLite may not have checkpointed yet. fn database_bytes(dir: &Path) -> Vec<(PathBuf, Vec)> { std::fs::read_dir(dir) .unwrap() @@ -167,9 +166,8 @@ fn contains(haystack: &[u8], needle: &str) -> bool { haystack.windows(needle.len()).any(|window| window == needle.as_bytes()) } -fn assert_databases_hold_settings_but_not_the_key(dir: &Path) { +fn assert_database_holds_settings_but_not_the_key(dir: &Path) { let files = database_bytes(dir); - assert!(files.iter().any(|(path, _)| path.ends_with("job_queue.db"))); assert!(files.iter().any(|(path, _)| path.ends_with("provisioner_tasks.db"))); let all = files @@ -312,7 +310,7 @@ fn capture_logs() -> (CapturedLogs, impl Sized) { #[tokio::test] async fn ai_log_task_is_accepted_then_fails_as_not_implemented() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let app = gateway.app.clone(); let started = start_task(&app).await; @@ -336,7 +334,7 @@ async fn ai_log_task_is_accepted_then_fails_as_not_implemented() { #[tokio::test] async fn start_requires_a_task_token() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let app = gateway.app.clone(); let (status, _) = send(&app, start_request(None, &ai_params())).await; @@ -349,7 +347,7 @@ async fn start_requires_a_task_token() { #[tokio::test] async fn status_requires_the_tasks_read_scope() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let app = gateway.app.clone(); let id = start_task(&app).await["id"].as_str().unwrap().parse::().unwrap(); @@ -369,26 +367,34 @@ async fn status_requires_the_tasks_read_scope() { #[tokio::test] async fn unknown_task_is_not_found() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let app = gateway.app.clone(); - let (status, _) = send( + let (status, body) = send( &app, status_request(Some(&scope_token("gateway.tasks.read")), Uuid::new_v4()), ) .await; assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + json!({ "error": "task_not_found" }) + ); } #[tokio::test] async fn invalid_ai_settings_are_typed_bad_requests() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let app = gateway.app.clone(); for (params, expected) in [ - (json!({ "provider": "openai", "model": "gpt-test" }), "missing_api_key"), + (json!({ "provider": "openai", "model": "gpt-test" }), "invalid_params"), + ( + json!({ "provider": "openai", "model": "gpt-test", "apiKey": "" }), + "missing_api_key", + ), ( json!({ "provider": "openai", "model": " ", "apiKey": API_KEY }), "missing_model", @@ -417,7 +423,7 @@ async fn invalid_ai_settings_are_typed_bad_requests() { async fn api_key_never_appears_in_responses_or_logs() { let (logs, _guard) = capture_logs(); let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let app = gateway.app.clone(); let started = start_task(&app).await; @@ -428,7 +434,7 @@ async fn api_key_never_appears_in_responses_or_logs() { assert!(!finished.to_string().contains(API_KEY)); // A key sent in the wrong field must not be echoed by the parameter error either. - let misplaced = json!({ "provider": "openai", "model": "gpt-test", "maxOutputTokens": API_KEY }); + let misplaced = json!({ "provider": "openai", "model": "gpt-test", "apiKey": "sk", "maxOutputTokens": API_KEY }); let (status, body) = send(&app, start_request(Some(&task_token()), &misplaced)).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(!body.contains(API_KEY)); @@ -439,10 +445,11 @@ async fn api_key_never_appears_in_responses_or_logs() { } #[tokio::test] -async fn endpoints_are_hidden_when_unstable_is_disabled() { - let config = CONFIG.replace("\"enable_unstable\": true", "\"enable_unstable\": false"); +async fn stable_gateway_never_touches_the_task_database() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(&config, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start_with_config(&config(dir.path(), false), Jobs::Run) + .await + .unwrap(); let app = gateway.app.clone(); let (status, _) = send(&app, start_request(Some(&task_token()), &ai_params())).await; @@ -454,12 +461,30 @@ async fn endpoints_are_hidden_when_unstable_is_disabled() { ) .await; assert_eq!(status, StatusCode::NOT_FOUND); + + gateway.stop().await; + + assert!( + database_bytes(dir.path()).is_empty(), + "no task database file is created" + ); +} + +#[tokio::test] +async fn unstable_gateway_opens_the_task_database_at_startup() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + + assert!(tasks_db(dir.path()).exists()); + assert_eq!(queued_job_count(dir.path()).await, 0); + + gateway.stop().await; } #[tokio::test] async fn neither_database_ever_holds_the_api_key() { let dir = tempfile::tempdir().unwrap(); - let gateway = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let started = start_task(&gateway.app).await; let id = started["id"].as_str().unwrap().parse::().unwrap(); @@ -468,22 +493,22 @@ async fn neither_database_ever_holds_the_api_key() { gateway.stop().await; - assert_databases_hold_settings_but_not_the_key(dir.path()); + assert_database_holds_settings_but_not_the_key(dir.path()); } #[tokio::test] async fn after_a_restart_the_ephemeral_task_fails_without_retry() { let dir = tempfile::tempdir().unwrap(); - let before = Gateway::start(CONFIG, dir.path(), Jobs::QueueOnly).await.unwrap(); + let before = Gateway::start(dir.path(), Jobs::QueueOnly).await.unwrap(); let started = start_task(&before.app).await; let id = started["id"].as_str().unwrap().parse::().unwrap(); wait_for_queued_jobs(dir.path(), 1).await; before.stop().await; - assert_databases_hold_settings_but_not_the_key(dir.path()); + assert_database_holds_settings_but_not_the_key(dir.path()); - let after = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let after = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let finished = wait_until_finished(&after.app, id).await; assert_eq!( @@ -499,14 +524,14 @@ async fn after_a_restart_the_ephemeral_task_fails_without_retry() { wait_for_queued_jobs(dir.path(), 0).await; after.stop().await; - assert_databases_hold_settings_but_not_the_key(dir.path()); + assert_database_holds_settings_but_not_the_key(dir.path()); } #[tokio::test] async fn task_records_survive_a_restart() { let dir = tempfile::tempdir().unwrap(); - let before = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let before = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let id = start_task(&before.app).await["id"] .as_str() .unwrap() @@ -515,7 +540,7 @@ async fn task_records_survive_a_restart() { let finished = wait_until_finished(&before.app, id).await; before.stop().await; - let after = Gateway::start(CONFIG, dir.path(), Jobs::Run).await.unwrap(); + let after = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); let (status, body) = send(&after.app, status_request(Some(&scope_token("gateway.tasks.read")), id)).await; assert_eq!(status, StatusCode::OK); From 8820086d6745566d0358e94c09e8b6e3bf9c99b0 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sun, 27 Sep 2026 13:51:05 -0400 Subject: [PATCH 6/9] chore(openapi): regenerate clients for the untyped task request body StartTask now takes a free-form object; AiLogParams and the new TaskErrorCode are exported as models. The API key of AiLogParams is required, and both task endpoints document the same error body. Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/openapi/doc/index.adoc | 54 ++++++-- .../dotnet-client/.openapi-generator/FILES | 2 + .../openapi/dotnet-client/README.md | 1 + .../openapi/dotnet-client/docs/AiLogParams.md | 4 +- .../dotnet-client/docs/TaskErrorCode.md | 10 ++ .../dotnet-client/docs/TaskErrorResponse.md | 4 +- .../openapi/dotnet-client/docs/TasksApi.md | 13 +- .../Api/TasksApi.cs | 68 +++++----- .../Model/AiLogParams.cs | 17 ++- .../Model/TaskErrorCode.cs | 117 ++++++++++++++++++ .../Model/TaskErrorResponse.cs | 24 ++-- devolutions-gateway/openapi/gateway-api.yaml | 45 +++++-- .../.openapi-generator/FILES | 1 + .../ts-angular-client/api/tasks.service.ts | 20 ++- .../ts-angular-client/model/aiLogParams.ts | 6 +- .../openapi/ts-angular-client/model/models.ts | 1 + .../ts-angular-client/model/taskErrorCode.ts | 27 ++++ .../model/taskErrorResponse.ts | 11 +- 18 files changed, 318 insertions(+), 107 deletions(-) create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 67c12a661..a8964cf24 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -2617,7 +2617,12 @@ Task records are kept forever, including across Gateway restarts. This endpoint | 404 | No task with this ID -| <<>> +| <> + + +| 500 +| Unexpected server error +| <> |=== @@ -2640,7 +2645,7 @@ Starts a background task. ===== Description -The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. // markup not found, no include::{specDir}jet/tasks/POST/spec.adoc[opts=optional] @@ -2667,8 +2672,8 @@ The task kind and its target come from the TASK token. The request body holds th |=== |Name| Description| Required| Default| Pattern -| AiLogParams -| Kind-specific task parameters <> +| body +| Kind-specific task parameters, such as `AiLogParams` for `ai-log` <> | X | | @@ -2723,7 +2728,7 @@ The task kind and its target come from the TASK token. The request body holds th | 500 | Unexpected server error -| <<>> +| <> |=== @@ -3768,7 +3773,7 @@ endif::internal-generation[] [#AiLogParams] === _AiLogParams_ -AI settings used by an `ai-log` task. +AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. [.fields-AiLogParams] @@ -3777,10 +3782,10 @@ AI settings used by an `ai-log` task. | Field Name| Required| Nullable | Type| Description | Format | apiKey -| | X +| | String -| Required by every provider; kept in memory for this task only. +| Kept in memory for this task only. | | baseUrl @@ -5900,10 +5905,35 @@ Subscriber configuration +[#TaskErrorCode] +=== _TaskErrorCode_ + +Stable code telling a client why a task request failed; safe to show. + + + + +[.fields-TaskErrorCode] +[cols="1"] +|=== +| Enum Values + +| invalid_params +| missing_model +| missing_api_key +| missing_base_url +| invalid_ai_settings +| recording_active +| task_not_found +| internal + +|=== + + [#TaskErrorResponse] === _TaskErrorResponse_ -Why a task was not started. +Why a task request failed. [.fields-TaskErrorResponse] @@ -5914,9 +5944,9 @@ Why a task was not started. | error | X | -| String -| Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. -| +| <> +| +| invalid_params, missing_model, missing_api_key, missing_base_url, invalid_ai_settings, recording_active, task_not_found, internal, |=== diff --git a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES index 73de0d573..bf6f2ac19 100644 --- a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES @@ -74,6 +74,7 @@ docs/SetUpdateScheduleRequest.md docs/SubProvisionerKey.md docs/Subscriber.md docs/TargetConnectionOptions.md +docs/TaskErrorCode.md docs/TaskErrorResponse.md docs/TaskInfo.md docs/TaskState.md @@ -181,6 +182,7 @@ src/Devolutions.Gateway.Client/Model/SetUpdateScheduleRequest.cs src/Devolutions.Gateway.Client/Model/SubProvisionerKey.cs src/Devolutions.Gateway.Client/Model/Subscriber.cs src/Devolutions.Gateway.Client/Model/TargetConnectionOptions.cs +src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs src/Devolutions.Gateway.Client/Model/TaskInfo.cs src/Devolutions.Gateway.Client/Model/TaskState.cs diff --git a/devolutions-gateway/openapi/dotnet-client/README.md b/devolutions-gateway/openapi/dotnet-client/README.md index e24e4f2d3..f90a874e1 100644 --- a/devolutions-gateway/openapi/dotnet-client/README.md +++ b/devolutions-gateway/openapi/dotnet-client/README.md @@ -243,6 +243,7 @@ Class | Method | HTTP request | Description - [Model.SubProvisionerKey](docs/SubProvisionerKey.md) - [Model.Subscriber](docs/Subscriber.md) - [Model.TargetConnectionOptions](docs/TargetConnectionOptions.md) + - [Model.TaskErrorCode](docs/TaskErrorCode.md) - [Model.TaskErrorResponse](docs/TaskErrorResponse.md) - [Model.TaskInfo](docs/TaskInfo.md) - [Model.TaskState](docs/TaskState.md) diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md index 37992c1a5..752eb6045 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md @@ -1,11 +1,11 @@ # Devolutions.Gateway.Client.Model.AiLogParams -AI settings used by an `ai-log` task. +AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**ApiKey** | **string** | Required by every provider; kept in memory for this task only. | [optional] +**ApiKey** | **string** | Kept in memory for this task only. | **BaseUrl** | **string** | Overrides the provider default; required for `openai-compatible`. | [optional] **MaxOutputTokens** | **int?** | Upper bound of tokens in each AI answer. | [optional] **Model** | **string** | Model identifier, passed to the provider as is. | diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md new file mode 100644 index 000000000..2151e535f --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.TaskErrorCode +Stable code telling a client why a task request failed; safe to show. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md index dcde635ec..7bd398a7b 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md @@ -1,11 +1,11 @@ # Devolutions.Gateway.Client.Model.TaskErrorResponse -Why a task was not started. +Why a task request failed. ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**Error** | **string** | Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. | +**Error** | **TaskErrorCode** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md index 5f799454c..e3644008e 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md @@ -106,16 +106,17 @@ catch (ApiException e) | **401** | Invalid or missing authorization token | - | | **403** | Insufficient permissions | - | | **404** | No task with this ID | - | +| **500** | Unexpected server error | - | [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **StartTask** -> TaskInfo StartTask (AiLogParams aiLogParams) +> TaskInfo StartTask (Object body) Starts a background task. -The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. ### Example ```csharp @@ -141,12 +142,12 @@ namespace Example HttpClient httpClient = new HttpClient(); HttpClientHandler httpClientHandler = new HttpClientHandler(); var apiInstance = new TasksApi(httpClient, config, httpClientHandler); - var aiLogParams = new AiLogParams(); // AiLogParams | Kind-specific task parameters + var body = null; // Object | Kind-specific task parameters, such as `AiLogParams` for `ai-log` try { // Starts a background task. - TaskInfo result = apiInstance.StartTask(aiLogParams); + TaskInfo result = apiInstance.StartTask(body); Debug.WriteLine(result); } catch (ApiException e) @@ -167,7 +168,7 @@ This returns an ApiResponse object which contains the response data, status code try { // Starts a background task. - ApiResponse response = apiInstance.StartTaskWithHttpInfo(aiLogParams); + ApiResponse response = apiInstance.StartTaskWithHttpInfo(body); Debug.Write("Status Code: " + response.StatusCode); Debug.Write("Response Headers: " + response.Headers); Debug.Write("Response Body: " + response.Data); @@ -184,7 +185,7 @@ catch (ApiException e) | Name | Type | Description | Notes | |------|------|-------------|-------| -| **aiLogParams** | [**AiLogParams**](AiLogParams.md) | Kind-specific task parameters | | +| **body** | **Object** | Kind-specific task parameters, such as `AiLogParams` for `ai-log` | | ### Return type diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs index 15e3e25d6..1afbd8981 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs @@ -53,23 +53,23 @@ public interface ITasksApiSync : IApiAccessor /// Starts a background task. /// /// - /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// TaskInfo - TaskInfo StartTask(AiLogParams aiLogParams); + TaskInfo StartTask(Object body); /// /// Starts a background task. /// /// - /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// ApiResponse of TaskInfo - ApiResponse StartTaskWithHttpInfo(AiLogParams aiLogParams); + ApiResponse StartTaskWithHttpInfo(Object body); #endregion Synchronous Operations } @@ -106,25 +106,25 @@ public interface ITasksApiAsync : IApiAccessor /// Starts a background task. /// /// - /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// Cancellation Token to cancel the request. /// Task of TaskInfo - System.Threading.Tasks.Task StartTaskAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + System.Threading.Tasks.Task StartTaskAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); /// /// Starts a background task. /// /// - /// The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// Cancellation Token to cancel the request. /// Task of ApiResponse (TaskInfo) - System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); #endregion Asynchronous Operations } @@ -458,28 +458,28 @@ public Devolutions.Gateway.Client.Client.ApiResponse GetTaskWithHttpIn } /// - /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// TaskInfo - public TaskInfo StartTask(AiLogParams aiLogParams) + public TaskInfo StartTask(Object body) { - Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = StartTaskWithHttpInfo(aiLogParams); + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = StartTaskWithHttpInfo(body); return localVarResponse.Data; } /// - /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// ApiResponse of TaskInfo - public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttpInfo(AiLogParams aiLogParams) + public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttpInfo(Object body) { - // verify the required parameter 'aiLogParams' is set - if (aiLogParams == null) - throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'aiLogParams' when calling TasksApi->StartTask"); + // verify the required parameter 'body' is set + if (body == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'body' when calling TasksApi->StartTask"); Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); @@ -498,7 +498,7 @@ public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttp var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); - localVarRequestOptions.Data = aiLogParams; + localVarRequestOptions.Data = body; // authentication (task_token) required // bearer authentication required @@ -520,30 +520,30 @@ public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttp } /// - /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// Cancellation Token to cancel the request. /// Task of TaskInfo - public async System.Threading.Tasks.Task StartTaskAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public async System.Threading.Tasks.Task StartTaskAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { - Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await StartTaskWithHttpInfoAsync(aiLogParams, cancellationToken).ConfigureAwait(false); + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await StartTaskWithHttpInfoAsync(body, cancellationToken).ConfigureAwait(false); return localVarResponse.Data; } /// - /// Starts a background task. The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. /// /// Thrown when fails to make API call - /// Kind-specific task parameters + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` /// Cancellation Token to cancel the request. /// Task of ApiResponse (TaskInfo) - public async System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(AiLogParams aiLogParams, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public async System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { - // verify the required parameter 'aiLogParams' is set - if (aiLogParams == null) - throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'aiLogParams' when calling TasksApi->StartTask"); + // verify the required parameter 'body' is set + if (body == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'body' when calling TasksApi->StartTask"); Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); @@ -564,7 +564,7 @@ public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttp var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); - localVarRequestOptions.Data = aiLogParams; + localVarRequestOptions.Data = body; // authentication (task_token) required // bearer authentication required diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs index 7c17d70fb..7205cd3cb 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs @@ -28,7 +28,7 @@ namespace Devolutions.Gateway.Client.Model { /// - /// AI settings used by an `ai-log` task. + /// AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. /// [DataContract(Name = "AiLogParams")] public partial class AiLogParams : IValidatableObject @@ -47,13 +47,19 @@ protected AiLogParams() { } /// /// Initializes a new instance of the class. /// - /// Required by every provider; kept in memory for this task only.. + /// Kept in memory for this task only. (required). /// Overrides the provider default; required for `openai-compatible`.. /// Upper bound of tokens in each AI answer.. /// Model identifier, passed to the provider as is. (required). /// provider (required). public AiLogParams(string apiKey = default(string), string baseUrl = default(string), int? maxOutputTokens = default(int?), string model = default(string), AiProvider provider = default(AiProvider)) { + // to ensure "apiKey" is required (not null) + if (apiKey == null) + { + throw new ArgumentNullException("apiKey is a required property for AiLogParams and cannot be null"); + } + this.ApiKey = apiKey; // to ensure "model" is required (not null) if (model == null) { @@ -61,16 +67,15 @@ protected AiLogParams() { } } this.Model = model; this.Provider = provider; - this.ApiKey = apiKey; this.BaseUrl = baseUrl; this.MaxOutputTokens = maxOutputTokens; } /// - /// Required by every provider; kept in memory for this task only. + /// Kept in memory for this task only. /// - /// Required by every provider; kept in memory for this task only. - [DataMember(Name = "apiKey", EmitDefaultValue = true)] + /// Kept in memory for this task only. + [DataMember(Name = "apiKey", IsRequired = true, EmitDefaultValue = true)] public string ApiKey { get; set; } /// diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs new file mode 100644 index 000000000..f94171e13 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs @@ -0,0 +1,117 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Stable code telling a client why a task request failed; safe to show. + /// + /// Stable code telling a client why a task request failed; safe to show. + [JsonConverter(typeof(StringEnumConverter))] + public enum TaskErrorCode + { + /// + /// Enum InvalidParams for value: invalid_params + /// + [EnumMember(Value = "invalid_params")] + InvalidParams = 1, + + /// + /// Enum MissingModel for value: missing_model + /// + [EnumMember(Value = "missing_model")] + MissingModel = 2, + + /// + /// Enum MissingApiKey for value: missing_api_key + /// + [EnumMember(Value = "missing_api_key")] + MissingApiKey = 3, + + /// + /// Enum MissingBaseUrl for value: missing_base_url + /// + [EnumMember(Value = "missing_base_url")] + MissingBaseUrl = 4, + + /// + /// Enum InvalidAiSettings for value: invalid_ai_settings + /// + [EnumMember(Value = "invalid_ai_settings")] + InvalidAiSettings = 5, + + /// + /// Enum RecordingActive for value: recording_active + /// + [EnumMember(Value = "recording_active")] + RecordingActive = 6, + + /// + /// Enum TaskNotFound for value: task_not_found + /// + [EnumMember(Value = "task_not_found")] + TaskNotFound = 7, + + /// + /// Enum Internal for value: internal + /// + [EnumMember(Value = "internal")] + Internal = 8 + } + + public static class TaskErrorCodeExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this TaskErrorCode variant) + { + switch (variant) + { + case TaskErrorCode.InvalidParams: + return "invalid_params"; + case TaskErrorCode.MissingModel: + return "missing_model"; + case TaskErrorCode.MissingApiKey: + return "missing_api_key"; + case TaskErrorCode.MissingBaseUrl: + return "missing_base_url"; + case TaskErrorCode.InvalidAiSettings: + return "invalid_ai_settings"; + case TaskErrorCode.RecordingActive: + return "recording_active"; + case TaskErrorCode.TaskNotFound: + return "task_not_found"; + case TaskErrorCode.Internal: + return "internal"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs index 50e30871c..e74f0451b 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs @@ -28,11 +28,17 @@ namespace Devolutions.Gateway.Client.Model { /// - /// Why a task was not started. + /// Why a task request failed. /// [DataContract(Name = "TaskErrorResponse")] public partial class TaskErrorResponse : IValidatableObject { + + /// + /// Gets or Sets Error + /// + [DataMember(Name = "error", IsRequired = true, EmitDefaultValue = true)] + public TaskErrorCode Error { get; set; } /// /// Initializes a new instance of the class. /// @@ -41,24 +47,12 @@ protected TaskErrorResponse() { } /// /// Initializes a new instance of the class. /// - /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. (required). - public TaskErrorResponse(string error = default(string)) + /// error (required). + public TaskErrorResponse(TaskErrorCode error = default(TaskErrorCode)) { - // to ensure "error" is required (not null) - if (error == null) - { - throw new ArgumentNullException("error is a required property for TaskErrorResponse and cannot be null"); - } this.Error = error; } - /// - /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. - /// - /// Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. - [DataMember(Name = "error", IsRequired = true, EmitDefaultValue = true)] - public string Error { get; set; } - /// /// Returns the string presentation of the object /// diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 592bd6343..8845cb308 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -906,16 +906,16 @@ paths: summary: Starts a background task. description: |- The task kind and its target come from the TASK token. - The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. + The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. operationId: StartTask requestBody: - description: Kind-specific task parameters + description: Kind-specific task parameters, such as `AiLogParams` for `ai-log` content: application/json: schema: - $ref: '#/components/schemas/AiLogParams' + type: object required: true responses: '202': @@ -942,6 +942,10 @@ paths: $ref: '#/components/schemas/TaskErrorResponse' '500': description: Unexpected server error + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' security: - task_token: [] /jet/tasks/{id}: @@ -977,6 +981,16 @@ paths: description: Insufficient permissions '404': description: No task with this ID + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + '500': + description: Unexpected server error + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' security: - scope_token: - gateway.tasks.read @@ -1486,15 +1500,15 @@ components: - unresponsive AiLogParams: type: object - description: AI settings used by an `ai-log` task. + description: 'AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`.' required: - provider - model + - apiKey properties: apiKey: type: string - description: Required by every provider; kept in memory for this task only. - nullable: true + description: Kept in memory for this task only. baseUrl: type: string description: Overrides the provider default; required for `openai-compatible`. @@ -2519,17 +2533,26 @@ components: Format: `://:` (port is required). Supported schemes are `tcp` and `udp`. nullable: true + TaskErrorCode: + type: string + description: Stable code telling a client why a task request failed; safe to show. + enum: + - invalid_params + - missing_model + - missing_api_key + - missing_base_url + - invalid_ai_settings + - recording_active + - task_not_found + - internal TaskErrorResponse: type: object - description: Why a task was not started. + description: Why a task request failed. required: - error properties: error: - type: string - description: |- - Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, - `invalid_ai_settings` or `recording_active`. + $ref: '#/components/schemas/TaskErrorCode' TaskInfo: type: object description: |- diff --git a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES index 97195badb..beb588571 100644 --- a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES @@ -83,6 +83,7 @@ model/setUpdateScheduleRequest.ts model/subProvisionerKey.ts model/subscriber.ts model/targetConnectionOptions.ts +model/taskErrorCode.ts model/taskErrorResponse.ts model/taskInfo.ts model/taskState.ts diff --git a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts index 539b3b26a..804f65e36 100644 --- a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts +++ b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts @@ -16,8 +16,6 @@ import { HttpClient, HttpHeaders, HttpParams, import { CustomHttpParameterCodec } from '../encoder'; import { Observable } from 'rxjs'; -// @ts-ignore -import { AiLogParams } from '../model/aiLogParams'; // @ts-ignore import { TaskErrorResponse } from '../model/taskErrorResponse'; // @ts-ignore @@ -168,17 +166,17 @@ export class TasksService { /** * Starts a background task. - * The task kind and its target come from the TASK token. The request body holds the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. - * @param aiLogParams Kind-specific task parameters + * The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + * @param body Kind-specific task parameters, such as `AiLogParams` for `ai-log` * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public startTask(aiLogParams: AiLogParams, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public startTask(aiLogParams: AiLogParams, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public startTask(aiLogParams: AiLogParams, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public startTask(aiLogParams: AiLogParams, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { - if (aiLogParams === null || aiLogParams === undefined) { - throw new Error('Required parameter aiLogParams was null or undefined when calling startTask.'); + public startTask(body: object, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public startTask(body: object, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startTask(body: object, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startTask(body: object, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (body === null || body === undefined) { + throw new Error('Required parameter body was null or undefined when calling startTask.'); } let localVarHeaders = this.defaultHeaders; @@ -237,7 +235,7 @@ export class TasksService { return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, - body: aiLogParams, + body: body, responseType: responseType_, withCredentials: this.configuration.withCredentials, headers: localVarHeaders, diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts index 5448a9eb9..4e7e99ba3 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts @@ -11,13 +11,13 @@ import { AiProvider } from './aiProvider'; /** - * AI settings used by an `ai-log` task. + * AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. */ export interface AiLogParams { /** - * Required by every provider; kept in memory for this task only. + * Kept in memory for this task only. */ - apiKey?: string | null; + apiKey: string; /** * Overrides the provider default; required for `openai-compatible`. */ diff --git a/devolutions-gateway/openapi/ts-angular-client/model/models.ts b/devolutions-gateway/openapi/ts-angular-client/model/models.ts index 591a1d095..2a42acc64 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/models.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/models.ts @@ -60,6 +60,7 @@ export * from './setUpdateScheduleRequest'; export * from './subProvisionerKey'; export * from './subscriber'; export * from './targetConnectionOptions'; +export * from './taskErrorCode'; export * from './taskErrorResponse'; export * from './taskInfo'; export * from './taskState'; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts new file mode 100644 index 000000000..5ea39274e --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts @@ -0,0 +1,27 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * Stable code telling a client why a task request failed; safe to show. + */ +export type TaskErrorCode = 'invalid_params' | 'missing_model' | 'missing_api_key' | 'missing_base_url' | 'invalid_ai_settings' | 'recording_active' | 'task_not_found' | 'internal'; + +export const TaskErrorCode = { + InvalidParams: 'invalid_params' as TaskErrorCode, + MissingModel: 'missing_model' as TaskErrorCode, + MissingApiKey: 'missing_api_key' as TaskErrorCode, + MissingBaseUrl: 'missing_base_url' as TaskErrorCode, + InvalidAiSettings: 'invalid_ai_settings' as TaskErrorCode, + RecordingActive: 'recording_active' as TaskErrorCode, + TaskNotFound: 'task_not_found' as TaskErrorCode, + Internal: 'internal' as TaskErrorCode +}; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts index aa6ec36ed..ffa0b559f 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts @@ -7,15 +7,16 @@ * https://openapi-generator.tech * Do not edit the class manually. */ +import { TaskErrorCode } from './taskErrorCode'; /** - * Why a task was not started. + * Why a task request failed. */ export interface TaskErrorResponse { - /** - * Stable error code, such as `invalid_params`, `missing_model`, `missing_api_key`, `missing_base_url`, `invalid_ai_settings` or `recording_active`. - */ - error: string; + error: TaskErrorCode; } +export namespace TaskErrorResponse { +} + From 96b38fbed605962d2b5a26d330a438afb84e5393 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 18:26:28 -0400 Subject: [PATCH 7/9] refactor(dgw): share AI settings between AI tasks The ai-log task held everything needed to call an AI provider: the provider enum, the persisted settings, the proxy-aware client builder, the mapping of build errors to task error codes, and the retry rule for AI errors. The next task kind that calls an AI would copy all of it. These now live in tasks::ai. AiSettings is what a task persists, check() validates it when the task starts, and client() builds the client of a run. AI errors convert into TaskError through Error::is_transient, so every AI task retries the same errors. The ai-log request body and the OpenAPI document are unchanged. --- devolutions-gateway/src/openapi.rs | 2 +- devolutions-gateway/src/tasks/ai.rs | 188 ++++++++++++++++++++++++ devolutions-gateway/src/tasks/ai_log.rs | 172 ++-------------------- devolutions-gateway/src/tasks/mod.rs | 1 + 4 files changed, 203 insertions(+), 160 deletions(-) create mode 100644 devolutions-gateway/src/tasks/ai.rs diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 474d0b315..6ca704fe2 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -115,7 +115,7 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::tasks::TaskErrorResponse, crate::tasks::TaskErrorCode, crate::tasks::ai_log::AiLogParams, - crate::tasks::ai_log::AiProvider, + crate::tasks::ai::AiProvider, crate::tasks::ai_log::AiLogSubstate, )), modifiers(&SecurityAddon), diff --git a/devolutions-gateway/src/tasks/ai.rs b/devolutions-gateway/src/tasks/ai.rs new file mode 100644 index 000000000..3caef1b24 --- /dev/null +++ b/devolutions-gateway/src/tasks/ai.rs @@ -0,0 +1,188 @@ +//! AI settings shared by the task kinds that call an AI provider. +//! +//! The provisioner sends them with the API key in the body of `POST /jet/tasks`. +//! [`AiSettings`] is the part persisted with the task; the API key stays in memory as the task secret. + +use devolutions_gateway_ai::{AiClient, BuildError, Provider}; +use secrecy::SecretString; +use url::Url; + +use super::{TaskError, TaskErrorCode}; +use crate::DgwState; + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiProvider { + #[serde(rename = "openai")] + OpenAi, + #[serde(rename = "anthropic")] + Anthropic, + #[serde(rename = "mistral")] + Mistral, + #[serde(rename = "openai-compatible")] + OpenAiCompatible, +} + +impl From for Provider { + fn from(provider: AiProvider) -> Self { + match provider { + AiProvider::OpenAi => Provider::OpenAi, + AiProvider::Anthropic => Provider::Anthropic, + AiProvider::Mistral => Provider::Mistral, + AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, + } + } +} + +/// AI settings of a task, persisted with it: everything but the API key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiSettings { + pub provider: AiProvider, + pub model: String, + pub base_url: Option, + /// Upper bound of tokens in each AI answer. + pub max_output_tokens: Option, +} + +impl AiSettings { + /// Checks the settings before the task is recorded. + pub fn check(&self, api_key: &SecretString, state: &DgwState) -> Result<(), TaskErrorCode> { + match self.build_client(api_key, state) { + Ok(_) => Ok(()), + Err(ClientError::Build(error)) => { + let code = build_error_code(&error); + debug!(%error, ?code, "Invalid AI settings"); + Err(code) + } + Err(ClientError::HttpClient(error)) => { + error!(%error, "Failed to build the HTTP client for the AI provider"); + Err(TaskErrorCode::Internal) + } + } + } + + /// Builds the client of a task run, through the proxy configured for Gateway. + pub fn client(&self, api_key: &SecretString, state: &DgwState) -> Result { + self.build_client(api_key, state) + .map_err(|error| TaskError::Permanent(error.message())) + } + + fn build_client(&self, api_key: &SecretString, state: &DgwState) -> Result { + let provider = Provider::from(self.provider); + + let mut builder = AiClient::builder() + .provider(provider) + .model(self.model.clone()) + .api_key(api_key.clone()); + + let endpoint = self.base_url.clone().or_else(|| provider.default_base_url()); + + if let Some(base_url) = self.base_url.clone() { + builder = builder.base_url(base_url); + } + + // Without an endpoint, `build` reports the missing base URL before it needs the HTTP client. + if let Some(endpoint) = endpoint { + let proxy_config = state.conf_handle.get_conf().proxy.to_proxy_config(); + + let http_client = + http_client_proxy::get_or_create_cached_client(reqwest::Client::builder(), &endpoint, &proxy_config) + .map_err(ClientError::HttpClient)?; + + builder = builder.http_client(http_client); + } + + builder.build().map_err(ClientError::Build) + } +} + +/// Retries only the AI errors that may pass later, such as a rate limit or a network error. +impl From for TaskError { + fn from(error: devolutions_gateway_ai::Error) -> Self { + if error.is_transient() { + TaskError::Transient(error.to_string()) + } else { + TaskError::Permanent(error.to_string()) + } + } +} + +enum ClientError { + Build(BuildError), + HttpClient(reqwest::Error), +} + +impl ClientError { + fn message(&self) -> String { + match self { + ClientError::Build(error) => error.to_string(), + ClientError::HttpClient(error) => format!("failed to build the HTTP client: {error}"), + } + } +} + +fn build_error_code(error: &BuildError) -> TaskErrorCode { + match error { + BuildError::MissingModel => TaskErrorCode::MissingModel, + BuildError::MissingApiKey(_) => TaskErrorCode::MissingApiKey, + BuildError::MissingBaseUrl(_) => TaskErrorCode::MissingBaseUrl, + _ => TaskErrorCode::InvalidAiSettings, + } +} + +#[cfg(test)] +mod tests { + use devolutions_gateway_ai::Error; + + use super::*; + + #[test] + fn build_errors_map_to_stable_codes() { + assert_eq!(build_error_code(&BuildError::MissingModel), TaskErrorCode::MissingModel); + assert_eq!( + build_error_code(&BuildError::MissingApiKey(Provider::OpenAi)), + TaskErrorCode::MissingApiKey + ); + assert_eq!( + build_error_code(&BuildError::MissingBaseUrl(Provider::OpenAiCompatible)), + TaskErrorCode::MissingBaseUrl + ); + assert_eq!( + build_error_code(&BuildError::InvalidApiKey), + TaskErrorCode::InvalidAiSettings + ); + } + + #[test] + fn rate_limits_server_and_network_errors_are_transient() { + let status = |status| Error::Status { + status, + message: "failed".to_owned(), + }; + + let network = Error::Transport { + message: "connection refused".to_owned(), + }; + assert!(matches!(TaskError::from(network), TaskError::Transient(_))); + + for code in [429, 500, 503] { + assert!( + matches!(TaskError::from(status(code)), TaskError::Transient(_)), + "{code}" + ); + } + + for code in [400, 401, 403, 404] { + assert!( + matches!(TaskError::from(status(code)), TaskError::Permanent(_)), + "{code}" + ); + } + + let invalid = Error::InvalidOutput { + reason: "no valid action line".to_owned(), + }; + assert!(matches!(TaskError::from(invalid), TaskError::Permanent(_))); + } +} diff --git a/devolutions-gateway/src/tasks/ai_log.rs b/devolutions-gateway/src/tasks/ai_log.rs index a4cc7b580..58e4a80af 100644 --- a/devolutions-gateway/src/tasks/ai_log.rs +++ b/devolutions-gateway/src/tasks/ai_log.rs @@ -1,10 +1,10 @@ //! `ai-log` task: describes what the user did in one session and stores the result as a new log of that session. -use devolutions_gateway_ai::{AiClient, BuildError, Provider}; use secrecy::SecretString; use url::Url; use uuid::Uuid; +use super::ai::{AiProvider, AiSettings}; use super::{EphemeralTask, RetryPolicy, SECRETS_LOST_ERROR, TaskCtx, TaskError, TaskErrorCode, TaskKind}; use crate::DgwState; @@ -32,40 +32,6 @@ pub struct AiLogParams { pub max_output_tokens: Option, } -/// The persisted part of [`AiLogParams`]: everything but the API key. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiLogSettings { - pub provider: AiProvider, - pub model: String, - pub base_url: Option, - pub max_output_tokens: Option, -} - -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AiProvider { - #[serde(rename = "openai")] - OpenAi, - #[serde(rename = "anthropic")] - Anthropic, - #[serde(rename = "mistral")] - Mistral, - #[serde(rename = "openai-compatible")] - OpenAiCompatible, -} - -impl From for Provider { - fn from(provider: AiProvider) -> Self { - match provider { - AiProvider::OpenAi => Provider::OpenAi, - AiProvider::Anthropic => Provider::Anthropic, - AiProvider::Mistral => Provider::Mistral, - AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, - } - } -} - /// Progress of a running `ai-log` task. #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Debug, Default, Serialize)] @@ -85,7 +51,7 @@ impl TaskKind for AiLogTask { const RETRY: RetryPolicy = RetryPolicy::JOB_QUEUE; type Target = AiLogTarget; - type Params = AiLogSettings; + type Params = AiSettings; type Substate = AiLogSubstate; type Output = AiLogOutput; @@ -94,8 +60,7 @@ impl TaskKind for AiLogTask { return Err(TaskError::Permanent(SECRETS_LOST_ERROR.to_owned())); }; - let _client = - build_client(&ctx.params, api_key, &ctx.state).map_err(|error| TaskError::Permanent(error.message()))?; + let _client = ctx.params.client(api_key, &ctx.state)?; Err(TaskError::Permanent("ai-log task not implemented yet".to_owned())) } @@ -109,7 +74,7 @@ impl EphemeralTask for AiLogTask { target: &AiLogTarget, request: AiLogParams, state: &DgwState, - ) -> Result<(AiLogSettings, SecretString), TaskErrorCode> { + ) -> Result<(AiSettings, SecretString), TaskErrorCode> { if state.recordings.active_recordings.contains(target.session_id) { return Err(TaskErrorCode::RecordingActive); } @@ -122,94 +87,19 @@ impl EphemeralTask for AiLogTask { max_output_tokens, } = request; - let settings = AiLogSettings { + let settings = AiSettings { provider, model, base_url, max_output_tokens, }; - build_client(&settings, &api_key, state).map_err(|error| match error { - ClientError::Build(error) => { - let code = build_error_code(&error); - debug!(%error, ?code, "Invalid AI settings"); - code - } - ClientError::HttpClient(error) => { - error!(%error, "Failed to build the HTTP client for the AI provider"); - TaskErrorCode::Internal - } - })?; + settings.check(&api_key, state)?; Ok((settings, api_key)) } } -enum ClientError { - Build(BuildError), - HttpClient(reqwest::Error), -} - -impl ClientError { - fn message(&self) -> String { - match self { - ClientError::Build(error) => error.to_string(), - ClientError::HttpClient(error) => format!("failed to build the HTTP client: {error}"), - } - } -} - -fn build_client(settings: &AiLogSettings, api_key: &SecretString, state: &DgwState) -> Result { - let provider = Provider::from(settings.provider); - - let mut builder = AiClient::builder() - .provider(provider) - .model(settings.model.clone()) - .api_key(api_key.clone()); - - let endpoint = settings.base_url.clone().or_else(|| provider.default_base_url()); - - if let Some(base_url) = settings.base_url.clone() { - builder = builder.base_url(base_url); - } - - // Without an endpoint, `build` reports the missing base URL before it needs the HTTP client. - if let Some(endpoint) = endpoint { - let proxy_config = state.conf_handle.get_conf().proxy.to_proxy_config(); - - let http_client = - http_client_proxy::get_or_create_cached_client(reqwest::Client::builder(), &endpoint, &proxy_config) - .map_err(ClientError::HttpClient)?; - - builder = builder.http_client(http_client); - } - - builder.build().map_err(ClientError::Build) -} - -fn build_error_code(error: &BuildError) -> TaskErrorCode { - match error { - BuildError::MissingModel => TaskErrorCode::MissingModel, - BuildError::MissingApiKey(_) => TaskErrorCode::MissingApiKey, - BuildError::MissingBaseUrl(_) => TaskErrorCode::MissingBaseUrl, - _ => TaskErrorCode::InvalidAiSettings, - } -} - -#[cfg_attr( - not(test), - expect(dead_code, reason = "used by the ai-log runner, which comes in a later change") -)] -fn classify_ai_error(error: &devolutions_gateway_ai::Error) -> TaskError { - let message = error.to_string(); - - if error.is_transient() { - TaskError::Transient(message) - } else { - TaskError::Permanent(message) - } -} - #[cfg(test)] mod tests { use super::*; @@ -268,50 +158,14 @@ mod tests { assert!(!format!("{api_key:?}").contains(API_KEY)); } - #[test] - fn build_errors_map_to_stable_codes() { - assert_eq!(build_error_code(&BuildError::MissingModel), TaskErrorCode::MissingModel); - assert_eq!( - build_error_code(&BuildError::MissingApiKey(Provider::OpenAi)), - TaskErrorCode::MissingApiKey - ); - assert_eq!( - build_error_code(&BuildError::MissingBaseUrl(Provider::OpenAiCompatible)), - TaskErrorCode::MissingBaseUrl - ); - } - - #[test] - fn rate_limits_server_and_network_errors_are_transient() { - let status = |status| devolutions_gateway_ai::Error::Status { - status, - message: "failed".to_owned(), - }; - - let network = devolutions_gateway_ai::Error::Transport { - message: "connection refused".to_owned(), - }; - assert!(matches!(classify_ai_error(&network), TaskError::Transient(_))); - - for code in [429, 500, 503] { - assert!( - matches!(classify_ai_error(&status(code)), TaskError::Transient(_)), - "{code}" - ); - } + #[tokio::test] + async fn invalid_ai_settings_are_refused_with_a_code() { + let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); + let mut params = params(); + params.model = " ".to_owned(); - for code in [400, 401, 403, 404] { - assert!( - matches!(classify_ai_error(&status(code)), TaskError::Permanent(_)), - "{code}" - ); - } + let error = AiLogTask::prepare(&target(), params, &state).expect_err("empty model"); - assert!(matches!( - classify_ai_error(&devolutions_gateway_ai::Error::InvalidOutput { - reason: "no valid action line".to_owned() - }), - TaskError::Permanent(_) - )); + assert_eq!(error, TaskErrorCode::MissingModel); } } diff --git a/devolutions-gateway/src/tasks/mod.rs b/devolutions-gateway/src/tasks/mod.rs index 9160b8650..90e1b1cfb 100644 --- a/devolutions-gateway/src/tasks/mod.rs +++ b/devolutions-gateway/src/tasks/mod.rs @@ -8,6 +8,7 @@ //! //! The task system is unstable: it starts only when `__debug__.enable_unstable` is set. +pub mod ai; pub mod ai_log; use core::marker::PhantomData; From cfd1097bbb8132fa727c4ea0bfe8b3fad2b09674 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 19:52:15 -0400 Subject: [PATCH 8/9] feat(dgw): accept Gemini as an AI task provider DVLS offers Google Gemini as an AI provider, but an AI task could only reach it as openai-compatible with Google's URL given by the caller. The gemini provider now uses the AI crate's default Gemini endpoint, so DVLS maps Gemini to one Gateway provider, as it does OpenAI, Claude and Mistral. --- devolutions-gateway/src/tasks/ai.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/devolutions-gateway/src/tasks/ai.rs b/devolutions-gateway/src/tasks/ai.rs index 3caef1b24..09e50d07e 100644 --- a/devolutions-gateway/src/tasks/ai.rs +++ b/devolutions-gateway/src/tasks/ai.rs @@ -19,6 +19,8 @@ pub enum AiProvider { Anthropic, #[serde(rename = "mistral")] Mistral, + #[serde(rename = "gemini")] + Gemini, #[serde(rename = "openai-compatible")] OpenAiCompatible, } @@ -29,6 +31,7 @@ impl From for Provider { AiProvider::OpenAi => Provider::OpenAi, AiProvider::Anthropic => Provider::Anthropic, AiProvider::Mistral => Provider::Mistral, + AiProvider::Gemini => Provider::Gemini, AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, } } @@ -185,4 +188,26 @@ mod tests { }; assert!(matches!(TaskError::from(invalid), TaskError::Permanent(_))); } + + #[test] + fn providers_keep_their_wire_names() { + for (provider, name) in [ + (AiProvider::OpenAi, "openai"), + (AiProvider::Anthropic, "anthropic"), + (AiProvider::Mistral, "mistral"), + (AiProvider::Gemini, "gemini"), + (AiProvider::OpenAiCompatible, "openai-compatible"), + ] { + assert_eq!(serde_json::to_value(provider).expect("serializable"), name); + assert_eq!( + serde_json::from_value::(serde_json::Value::from(name)).expect("known name"), + provider + ); + } + } + + #[test] + fn gemini_has_a_default_base_url() { + assert!(Provider::from(AiProvider::Gemini).default_base_url().is_some()); + } } From ddc4e3e907b6612131f36607bd602fde4216c44d Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 19:55:32 -0400 Subject: [PATCH 9/9] chore(openapi): regenerate clients for the Gemini provider --- devolutions-gateway/openapi/doc/index.adoc | 3 ++- .../src/Devolutions.Gateway.Client/Model/AiProvider.cs | 10 +++++++++- devolutions-gateway/openapi/gateway-api.yaml | 1 + .../openapi/ts-angular-client/model/aiProvider.ts | 3 ++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index a8964cf24..8e24cb2d0 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -3814,7 +3814,7 @@ AI settings used by an `ai-log` task: the body of `POST /jet/task | | <> | -| openai, anthropic, mistral, openai-compatible, +| openai, anthropic, mistral, gemini, openai-compatible, |=== @@ -3880,6 +3880,7 @@ Progress of a running `ai-log` task. | openai | anthropic | mistral +| gemini | openai-compatible |=== diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs index e895d216e..fe67c8516 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs @@ -51,11 +51,17 @@ public enum AiProvider [EnumMember(Value = "mistral")] Mistral = 3, + /// + /// Enum Gemini for value: gemini + /// + [EnumMember(Value = "gemini")] + Gemini = 4, + /// /// Enum OpenaiCompatible for value: openai-compatible /// [EnumMember(Value = "openai-compatible")] - OpenaiCompatible = 4 + OpenaiCompatible = 5 } public static class AiProviderExtensions @@ -73,6 +79,8 @@ public static string ToValue(this AiProvider variant) return "anthropic"; case AiProvider.Mistral: return "mistral"; + case AiProvider.Gemini: + return "gemini"; case AiProvider.OpenaiCompatible: return "openai-compatible"; default: diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 8845cb308..723c99bd6 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -1542,6 +1542,7 @@ components: - openai - anthropic - mistral + - gemini - openai-compatible AppCredential: type: object diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts index da93793a9..0d8087222 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts @@ -9,12 +9,13 @@ */ -export type AiProvider = 'openai' | 'anthropic' | 'mistral' | 'openai-compatible'; +export type AiProvider = 'openai' | 'anthropic' | 'mistral' | 'gemini' | 'openai-compatible'; export const AiProvider = { Openai: 'openai' as AiProvider, Anthropic: 'anthropic' as AiProvider, Mistral: 'mistral' as AiProvider, + Gemini: 'gemini' as AiProvider, OpenaiCompatible: 'openai-compatible' as AiProvider };