diff --git a/Cargo.lock b/Cargo.lock index 725169058..e14749943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "cfg-if", "chacha20poly1305", "devolutions-agent-shared", + "devolutions-gateway-ai", "devolutions-gateway-generators", "devolutions-gateway-task", "devolutions-log", @@ -1868,6 +1869,7 @@ dependencies = [ "picky-krb", "pin-project-lite 0.2.17", "proptest", + "provisioner-task", "rand 0.10.2", "reqwest", "rstest", @@ -1895,6 +1897,7 @@ dependencies = [ "tokio-rustls", "tokio-test", "tokio-tungstenite", + "tokio-util", "tower 0.5.3", "tower-http 0.5.2", "tracing", @@ -1920,12 +1923,18 @@ dependencies = [ name = "devolutions-gateway-ai" version = "0.0.0" dependencies = [ + "anyhow", + "camino", "reqwest", "secrecy", "serde", "serde_json", + "tempfile", + "terminal-streamer", "thiserror 2.0.20", + "time", "tokio 1.52.3", + "tokio-util", "tracing", "url", ] diff --git a/crates/devolutions-gateway-ai/Cargo.toml b/crates/devolutions-gateway-ai/Cargo.toml index b6c6f2178..d46bb206e 100644 --- a/crates/devolutions-gateway-ai/Cargo.toml +++ b/crates/devolutions-gateway-ai/Cargo.toml @@ -10,13 +10,20 @@ publish = false workspace = true [dependencies] +anyhow = "1" +camino = "1.1" reqwest = { version = "0.12", default-features = false, features = ["json"] } secrecy = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" +terminal-streamer.path = "../terminal-streamer" thiserror = "2" +time = "0.3" +tokio = { version = "1.52", features = ["fs", "macros", "rt"] } +tokio-util = "0.7" tracing = "0.1" url = "2.5" [dev-dependencies] +tempfile = "3.24" tokio = { version = "1.52", features = ["io-util", "macros", "net", "rt-multi-thread"] } diff --git a/crates/devolutions-gateway-ai/src/client.rs b/crates/devolutions-gateway-ai/src/client.rs index ca15a1717..e5898fcc6 100644 --- a/crates/devolutions-gateway-ai/src/client.rs +++ b/crates/devolutions-gateway-ai/src/client.rs @@ -218,6 +218,11 @@ impl AiClient { AiClientBuilder::default() } + /// Model requested from the provider. + pub(crate) fn model(&self) -> &str { + &self.model + } + /// Sends one completion request and returns the text of the answer. /// /// An answer cut short by the output token limit or the context window is [`Error::Truncated`], because no purpose @@ -230,7 +235,6 @@ impl AiClient { debug!( provider = ?self.provider, model = %self.model, - base_url = %self.base_url, input_len = prompt.input.len(), max_output_tokens = prompt.max_output_tokens, "Send AI completion request" @@ -252,13 +256,13 @@ impl AiClient { .timeout(self.request_timeout) .send() .await - .map_err(|error| error::transport(&error, api_key))?; + .map_err(|error| error::transport(error, api_key))?; let status = response.status(); let retry_after = error::retry_after(response.headers()); let body = read_body(response) .await - .map_err(|error| error::transport(&error, api_key))?; + .map_err(|error| error::transport(error, api_key))?; if !status.is_success() { // An error body over the limit is not parsed, so the error holds the reason phrase of the status. diff --git a/crates/devolutions-gateway-ai/src/error.rs b/crates/devolutions-gateway-ai/src/error.rs index a918b59dd..f01b67661 100644 --- a/crates/devolutions-gateway-ai/src/error.rs +++ b/crates/devolutions-gateway-ai/src/error.rs @@ -77,7 +77,13 @@ const SPENT_QUOTA_CODES: [&str; 6] = [ "enforced_spend_limit_reached", ]; -pub(crate) fn transport(error: &dyn std::error::Error, api_key: &str) -> Error { +/// Builds a transport error that names neither the API key nor the URL of the request. +pub(crate) fn transport(error: reqwest::Error, api_key: &str) -> Error { + // The URL names the endpoint of the provider, which callers keep out of their logs and records. + redacted_transport(&error.without_url(), api_key) +} + +fn redacted_transport(error: &dyn std::error::Error, api_key: &str) -> Error { let mut message = error.to_string(); let mut source = error.source(); while let Some(cause) = source { @@ -131,7 +137,7 @@ mod tests { fn transport_error_redacts_api_key() { let error = std::io::Error::other(format!("invalid key {API_KEY} provided")); - let error = transport(&error, API_KEY); + let error = redacted_transport(&error, API_KEY); assert!(!error.to_string().contains(API_KEY)); assert!(!format!("{error:?}").contains(API_KEY)); diff --git a/crates/devolutions-gateway-ai/src/lib.rs b/crates/devolutions-gateway-ai/src/lib.rs index 99bf42f89..95cc14119 100644 --- a/crates/devolutions-gateway-ai/src/lib.rs +++ b/crates/devolutions-gateway-ai/src/lib.rs @@ -27,9 +27,14 @@ //! //! A new purpose is a module like [`session_actions`]: its prompt and `PROMPT_VERSION`, a request builder returned by //! a new [`AiClient`] method, and the parser of the answer. +//! +//! A purpose may also build on others and on files: [`recording_analysis`] reads the recordings of a session, +//! describes them chunk by chunk with [`session_actions`], and writes the result as a session log, keeping its working +//! files next to the recordings. mod client; mod error; +pub mod recording_analysis; mod response; pub mod session_actions; mod wire; diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs b/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs new file mode 100644 index 000000000..270b877c9 --- /dev/null +++ b/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs @@ -0,0 +1,180 @@ +//! What the AI answered for one transcript chunk, saved in the working files so analysing again does not ask the AI again. + +use std::collections::BTreeMap; +use std::io::{BufReader, BufWriter}; +use std::time::Duration; + +use anyhow::Context as _; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; + +use crate::Usage; +use crate::session_actions::Action; + +/// Tokens counted by the AI provider, as kept in checkpoints. +/// +/// It mirrors [`Usage`], so a change of [`Usage`] never changes the checkpoint format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenUsage { + input_tokens: u64, + output_tokens: u64, +} + +impl From for TokenUsage { + fn from(usage: Usage) -> Self { + Self { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + } + } +} + +impl From for Usage { + fn from(usage: TokenUsage) -> Self { + Self { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + } + } +} + +pub(super) fn path(workspace: &Utf8Path, index: usize) -> Utf8PathBuf { + workspace.join(format!("chunk-{index:04}.json")) +} + +/// Actions found in one chunk, with what the AI provider reported for the requests about it. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct DescribedChunk { + pub(super) actions: Vec, + /// Model that answered, as reported by the provider. + pub(super) model: Option, + /// Tokens of every request about the chunk, answers cut and asked again included; `None` when one was not reported. + pub(super) usage: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SavedChunk { + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + usage: Option, + actions: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SavedAction { + offset_seconds: f64, + description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + object: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + parameters: BTreeMap, +} + +/// Writes the checkpoint through a temporary file, so a crash never leaves a partial one. +pub(super) fn write(path: &Utf8Path, chunk: &DescribedChunk) -> anyhow::Result<()> { + let partial = path.with_extension("partial"); + + let saved = SavedChunk { + model: chunk.model.clone(), + usage: chunk.usage.map(TokenUsage::from), + actions: chunk + .actions + .iter() + .map(|action| SavedAction { + offset_seconds: action.offset.as_secs_f64(), + description: action.description.clone(), + object: action.object.clone(), + parameters: action.parameters.clone(), + }) + .collect(), + }; + + let mut out = BufWriter::new(std::fs::File::create(&partial).with_context(|| format!("create {partial}"))?); + serde_json::to_writer(&mut out, &saved)?; + out.into_inner()?.sync_all()?; + std::fs::rename(&partial, path).with_context(|| format!("rename {partial}"))?; + + Ok(()) +} + +pub(super) fn read(path: &Utf8Path) -> anyhow::Result { + let file = BufReader::new(std::fs::File::open(path).with_context(|| format!("open {path}"))?); + let saved: SavedChunk = serde_json::from_reader(file).with_context(|| format!("read {path}"))?; + + let actions = saved + .actions + .into_iter() + .map(|saved| { + Ok(Action { + offset: Duration::try_from_secs_f64(saved.offset_seconds)?, + description: saved.description, + object: saved.object, + parameters: saved.parameters, + }) + }) + .collect::>()?; + + Ok(DescribedChunk { + actions, + model: saved.model, + usage: saved.usage.map(Usage::from), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn described_chunk_round_trips() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = path(Utf8Path::from_path(dir.path()).expect("UTF-8"), 3); + let chunk = DescribedChunk { + actions: vec![ + Action { + offset: Duration::from_millis(1500), + description: "Listed files".to_owned(), + object: Some("/var/log".to_owned()), + parameters: BTreeMap::from([("Command".to_owned(), "ls".to_owned())]), + }, + Action { + offset: Duration::from_secs(62), + description: "Closed the shell".to_owned(), + object: None, + parameters: BTreeMap::new(), + }, + ], + model: Some("gpt-test-2026-09-30".to_owned()), + usage: Some(Usage { + input_tokens: 10, + output_tokens: 20, + }), + }; + + write(&path, &chunk).expect("written"); + + assert!(path.as_str().ends_with("chunk-0003.json")); + assert_eq!(read(&path).expect("read"), chunk); + assert!(!path.with_extension("partial").exists()); + } + + #[test] + fn unreported_model_and_usage_stay_unknown() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = path(Utf8Path::from_path(dir.path()).expect("UTF-8"), 0); + let chunk = DescribedChunk { + actions: Vec::new(), + model: None, + usage: None, + }; + + write(&path, &chunk).expect("written"); + + assert_eq!(std::fs::read_to_string(&path).expect("checkpoint"), r#"{"actions":[]}"#); + assert_eq!(read(&path).expect("read"), chunk); + } +} diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/mod.rs b/crates/devolutions-gateway-ai/src/recording_analysis/mod.rs new file mode 100644 index 000000000..7dbe4c9a7 --- /dev/null +++ b/crates/devolutions-gateway-ai/src/recording_analysis/mod.rs @@ -0,0 +1,547 @@ +//! Purpose: describe what the user did in a recorded session, with [`AiClient::analyze_recording`]. +//! +//! It builds on [`session_actions`](crate::session_actions): the terminal recordings of the session become a timed +//! transcript, the transcript is cut into chunks small enough for one request, and the actions found in every chunk +//! are merged into a Session Recording Log (`.slog`). +//! +//! The work keeps its files next to the manifest, in `.ai-analysis/`: +//! 1. `analysis.json`: the model, the prompt version and the chunk size the other files were made with; +//! 2. the transcript chunks, `chunk-NNNN.txt`, then `chunks.done` once they are all written, see [`transcript`]; +//! 3. what the AI answered for each chunk, `chunk-NNNN.json`, see [`checkpoint`], so that analysing the recording again +//! after a failure only asks about the chunks left; +//! 4. the log merged from them, see [`slog`]. +//! +//! A successful analysis moves the log next to the manifest, as `.ai-analysis.slog`, and removes the rest. + +mod checkpoint; +mod slog; +mod transcript; + +use std::collections::VecDeque; +use std::fmt; +use std::fs::File; +use std::future::Future; +use std::io::{self, BufWriter}; +use std::pin::Pin; + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use self::checkpoint::DescribedChunk; +use self::transcript::TranscriptError; +use crate::session_actions::PROMPT_VERSION; +use crate::{AiClient, Error, Usage}; + +/// Input tokens sent in one AI request, estimated at 4 characters per token. +const MAX_INPUT_TOKENS_PER_REQUEST: usize = 100_000; + +const CHARS_PER_TOKEN: usize = 4; + +/// Longest transcript chunk, in bytes; it bounds the memory an analysis uses. +const MAX_CHUNK_LEN: usize = MAX_INPUT_TOKENS_PER_REQUEST * CHARS_PER_TOKEN; + +/// A truncated AI answer for a transcript part shorter than this fails the analysis instead of splitting the part again. +const MIN_SPLIT_LEN: usize = 2_000; + +/// Folder of the working files, next to the manifest. +const WORKSPACE_DIR: &str = ".ai-analysis"; + +/// Name of the finished log, next to the manifest. +const LOG_FILE: &str = ".ai-analysis.slog"; + +const SETTINGS_FILE: &str = "analysis.json"; + +/// Written once every chunk file is complete; holds the number of chunks. +const CHUNKS_DONE_FILE: &str = "chunks.done"; + +const WORKSPACE_LOG_FILE: &str = "log.slog"; + +/// What [`AiClient::analyze_recording`] needs from the manifest of a recorded session, `recording.json`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordingManifest { + /// Start of the session, in Unix seconds. + pub start_time: i64, + /// Length of the session, in seconds. + pub duration: i64, + /// Recordings of the session, in the order of the manifest. + pub files: Vec, +} + +/// One recording of a session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordingFile { + /// Name of the file, in the folder of the manifest. + pub file_name: String, + /// Start of the recording, in Unix seconds. + pub start_time: i64, +} + +/// How far an analysis got. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Progress { + /// Transcript chunks the AI already described. + pub described_chunks: usize, + pub total_chunks: usize, +} + +/// A finished analysis. +#[derive(Debug, Clone, PartialEq)] +pub struct RecordingAnalysis { + /// The log, next to the manifest: move it where it belongs, or remove it with [`discard`]. + pub log_path: Utf8PathBuf, + /// Actions in the log. + pub actions: usize, + /// Model the log names: the first one the provider reported, or the requested one when it reported none. + pub model: String, + /// Tokens the provider counted for the whole analysis, answers cut and asked again included. + /// + /// `None` when the provider did not report them for every request. + pub usage: Option, +} + +/// Error returned when an analysis fails. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RecordingAnalysisError { + /// The session has recordings, but none that can be read as a terminal session, such as only videos. + #[error("unsupported recording type: {0}")] + Unsupported(&'static str), + /// The session has no recording at all. + #[error("session has no terminal recording")] + NoTerminalRecording, + #[error("failed to read {file_name}: {reason}")] + InvalidRecording { file_name: String, reason: String }, + /// A request to the AI failed. + #[error(transparent)] + Ai(#[from] Error), + /// The answer was cut short even for a part of the transcript too short to split again. + #[error("AI answer was cut at the output token limit, even for a short part of the transcript")] + Truncated, + /// The working files could not be written or read. + #[error("AI analysis files: {0}")] + Files(String), + /// The analysis stopped because its cancellation token was cancelled. + #[error("AI analysis cancelled")] + Cancelled, +} + +impl RecordingAnalysisError { + /// Returns `true` when analysing the recording again later may succeed, such as after a rate limit. + /// + /// The chunks already described are not asked about again. + pub fn is_transient(&self) -> bool { + matches!(self, Self::Ai(error) if error.is_transient()) + } +} + +/// Removes what [`AiClient::analyze_recording`] left next to `manifest_path`: its working files and a log nobody moved. +/// +/// Call it once the analysis of the recording is over for good; a successful analysis already removed its working +/// files. +pub async fn discard(manifest_path: &Utf8Path) -> io::Result<()> { + let dir = recording_dir(manifest_path); + + match tokio::fs::remove_dir_all(dir.join(WORKSPACE_DIR)).await { + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), + _ => {} + } + + match tokio::fs::remove_file(dir.join(LOG_FILE)).await { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +impl AiClient { + /// Describes what the user did in a recorded session, as a Session Recording Log. + /// + /// `manifest_path` is the path of the session manifest, `recording.json`, and `manifest` what it holds. + /// The recordings are read from the folder of the manifest, and the working files are kept there too. + /// Analysing a recording again after a failure resumes from the chunks already described, as long as the model and + /// the prompt are the same; otherwise it starts over. + /// Only one analysis of a recording may run at a time. + pub fn analyze_recording<'a>( + &'a self, + manifest: &'a RecordingManifest, + manifest_path: &'a Utf8Path, + ) -> AnalyzeRecording<'a> { + AnalyzeRecording { + client: self, + manifest, + manifest_path, + max_output_tokens: None, + on_progress: None, + cancel: CancellationToken::new(), + } + } +} + +type ProgressFn<'a> = Box Pin + Send + 'a>> + Send + 'a>; + +/// Request built by [`AiClient::analyze_recording`]. +#[must_use = "the analysis runs only by `send`"] +pub struct AnalyzeRecording<'a> { + client: &'a AiClient, + manifest: &'a RecordingManifest, + manifest_path: &'a Utf8Path, + max_output_tokens: Option, + on_progress: Option>, + cancel: CancellationToken, +} + +impl fmt::Debug for AnalyzeRecording<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnalyzeRecording") + .field("client", self.client) + .field("manifest_path", &self.manifest_path) + .field("max_output_tokens", &self.max_output_tokens) + .finish_non_exhaustive() + } +} + +impl<'a> AnalyzeRecording<'a> { + /// Upper bound of tokens in each AI answer, see + /// [`DescribeSessionActions::max_output_tokens`](crate::session_actions::DescribeSessionActions::max_output_tokens). + pub fn max_output_tokens(mut self, max_output_tokens: u32) -> Self { + self.max_output_tokens = Some(max_output_tokens); + self + } + + /// Called before each chunk the AI is asked about, then once all are described. + pub fn on_progress(mut self, mut on_progress: F) -> Self + where + F: FnMut(Progress) -> Fut + Send + 'a, + Fut: Future + Send + 'a, + { + self.on_progress = Some(Box::new(move |progress| Box::pin(on_progress(progress)))); + self + } + + /// Stops the analysis soon after `cancel` is cancelled, with [`RecordingAnalysisError::Cancelled`]. + /// + /// Dropping the future also stops it, but a step that writes files may still be running then; after a cancellation + /// the analysis has stopped for sure, so the next one never shares the working files with it. + pub fn cancellation(mut self, cancel: CancellationToken) -> Self { + self.cancel = cancel; + self + } + + /// Runs the analysis and returns the log. + pub async fn send(mut self) -> Result { + let dir = recording_dir(self.manifest_path).to_owned(); + let workspace = dir.join(WORKSPACE_DIR); + let cancel = self.cancel.clone(); + + let settings = WorkspaceSettings { + prompt_version: PROMPT_VERSION.to_owned(), + model: self.client.model().to_owned(), + chunk_len: MAX_CHUNK_LEN, + }; + + let total = blocking({ + let manifest = self.manifest.clone(); + let dir = dir.clone(); + let workspace = workspace.clone(); + let cancel = cancel.clone(); + move || prepare(&manifest, &dir, &workspace, &settings, &cancel) + }) + .await?; + + info!(chunks = total, "Describe the session actions"); + + for index in 0..total { + if cancel.is_cancelled() { + return Err(RecordingAnalysisError::Cancelled); + } + + let checkpoint = checkpoint::path(&workspace, index); + + if checkpoint.exists() { + debug!(index, "Chunk already described"); + continue; + } + + self.report(index, total).await; + + let chunk = tokio::fs::read_to_string(transcript::chunk_path(&workspace, index)) + .await + .map_err(|error| files_error(&error))?; + + let described = tokio::select! { + () = cancel.cancelled() => return Err(RecordingAnalysisError::Cancelled), + described = describe_chunk(self.client, self.max_output_tokens, &chunk) => described?, + }; + + debug!( + index, + actions = described.actions.len(), + model = ?described.model, + usage = ?described.usage, + "Chunk described" + ); + + blocking(move || checkpoint::write(&checkpoint, &described).map_err(|error| files_error(&error))).await?; + } + + self.report(total, total).await; + + let merged = blocking({ + let workspace = workspace.clone(); + let start_time = self.manifest.start_time; + let duration = self.manifest.duration; + let requested_model = self.client.model().to_owned(); + let cancel = cancel.clone(); + move || merge_checkpoints(&workspace, total, start_time, duration, &requested_model, &cancel) + }) + .await?; + + // Once the log leaves the working files, the analysis is done even if cancelled, so this is the last check. + if cancel.is_cancelled() { + return Err(RecordingAnalysisError::Cancelled); + } + + let log_path = dir.join(LOG_FILE); + tokio::fs::rename(workspace.join(WORKSPACE_LOG_FILE), &log_path) + .await + .map_err(|error| files_error(&error))?; + + if let Err(error) = tokio::fs::remove_dir_all(&workspace).await { + warn!(%error, path = %workspace, "Failed to remove the AI analysis working files"); + } + + Ok(RecordingAnalysis { + log_path, + actions: merged.actions, + model: merged.model, + usage: merged.usage, + }) + } + + async fn report(&mut self, described_chunks: usize, total_chunks: usize) { + if let Some(on_progress) = &mut self.on_progress { + on_progress(Progress { + described_chunks, + total_chunks, + }) + .await; + } + } +} + +/// Asks the AI about one chunk; a truncated answer is asked again as two halves. +async fn describe_chunk( + client: &AiClient, + max_output_tokens: Option, + chunk: &str, +) -> Result { + let mut parts = VecDeque::from([chunk]); + let mut described = DescribedChunk { + actions: Vec::new(), + model: None, + usage: Some(Usage::default()), + }; + + while let Some(part) = parts.pop_front() { + let mut request = client.describe_session_actions(part); + + if let Some(max_output_tokens) = max_output_tokens { + request = request.max_output_tokens(max_output_tokens); + } + + match request.send().await { + Ok(response) => { + described.actions.extend(response.output); + described.model = described.model.or(response.model); + described.usage = add_usage(described.usage, response.usage); + } + Err(Error::Truncated { usage }) => { + described.usage = add_usage(described.usage, usage); + + let Some((first, second)) = split_in_half(part) else { + return Err(RecordingAnalysisError::Truncated); + }; + + debug!(part_len = part.len(), "AI answer truncated; asking again in two halves"); + parts.push_front(second); + parts.push_front(first); + } + Err(error) => return Err(error.into()), + } + } + + Ok(described) +} + +/// What the working files were made with; files made with other settings are never reused. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceSettings { + prompt_version: String, + model: String, + chunk_len: usize, +} + +fn recording_dir(manifest_path: &Utf8Path) -> &Utf8Path { + match manifest_path.parent() { + Some(dir) if !dir.as_str().is_empty() => dir, + _ => Utf8Path::new("."), + } +} + +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(work) + .await + .map_err(|_| RecordingAnalysisError::Files("an AI analysis step panicked".to_owned()))? +} + +fn files_error(error: &dyn fmt::Display) -> RecordingAnalysisError { + RecordingAnalysisError::Files(format!("{error:#}")) +} + +/// Gets the working files ready and returns the number of chunks: the transcript is written once, and kept for +/// another analysis with the same settings. +fn prepare( + manifest: &RecordingManifest, + dir: &Utf8Path, + workspace: &Utf8Path, + settings: &WorkspaceSettings, + cancel: &CancellationToken, +) -> Result { + let saved = std::fs::read(workspace.join(SETTINGS_FILE)) + .ok() + .and_then(|saved| serde_json::from_slice::(&saved).ok()); + + if saved.as_ref() == Some(settings) { + let done = std::fs::read_to_string(workspace.join(CHUNKS_DONE_FILE)).ok(); + + if let Some(total) = done.and_then(|total| total.parse().ok()) { + return Ok(total); + } + } else if workspace.exists() { + info!(path = %workspace, "Discard the working files of another AI analysis"); + std::fs::remove_dir_all(workspace).map_err(|error| files_error(&error))?; + } + + std::fs::create_dir_all(workspace).map_err(|error| files_error(&error))?; + + let settings = serde_json::to_vec(settings).map_err(|error| files_error(&error))?; + std::fs::write(workspace.join(SETTINGS_FILE), settings).map_err(|error| files_error(&error))?; + + let total = + transcript::write_chunks(manifest, dir, workspace, MAX_CHUNK_LEN, cancel).map_err(|error| match error { + TranscriptError::Unsupported(file_type) => RecordingAnalysisError::Unsupported(file_type), + TranscriptError::NoTerminalRecording => RecordingAnalysisError::NoTerminalRecording, + TranscriptError::Invalid { file_name, reason } => { + RecordingAnalysisError::InvalidRecording { file_name, reason } + } + TranscriptError::Write(error) => files_error(&error), + TranscriptError::Cancelled => RecordingAnalysisError::Cancelled, + })?; + + std::fs::write(workspace.join(CHUNKS_DONE_FILE), total.to_string()).map_err(|error| files_error(&error))?; + + Ok(total) +} + +/// Sums token counts; the total is unknown as soon as one count is. +fn add_usage(total: Option, more: Option) -> Option { + Some(total? + more?) +} + +/// Cuts `part` on the line boundary closest to its middle, unless it is too short to split. +fn split_in_half(part: &str) -> Option<(&str, &str)> { + if part.len() < MIN_SPLIT_LEN { + return None; + } + + let bytes = part.as_bytes(); + let middle = part.len() / 2; + + let before = bytes[..middle] + .iter() + .rposition(|&byte| byte == b'\n') + .map(|end| end + 1); + let after = bytes[middle..] + .iter() + .position(|&byte| byte == b'\n') + .map(|end| middle + end + 1); + + let cut = match (before, after) { + (Some(before), Some(after)) if middle - before <= after - middle => before, + (_, Some(after)) if after < part.len() => after, + (Some(before), _) => before, + _ => return None, + }; + + Some(part.split_at(cut)) +} + +/// What [`merge_checkpoints`] wrote. +struct MergedLog { + actions: usize, + model: String, + usage: Option, +} + +/// Writes the log from the checkpoints, into the working files. +/// +/// The log names the first model reported for a chunk, or `requested_model` when the provider reported none. +fn merge_checkpoints( + workspace: &Utf8Path, + total: usize, + start_time: i64, + duration: i64, + requested_model: &str, + cancel: &CancellationToken, +) -> Result { + let log_error = |error: anyhow::Error| files_error(&format!("failed to write the log: {error:#}")); + let read = |index| checkpoint::read(&checkpoint::path(workspace, index)).map_err(|error| files_error(&error)); + + // `session.start` names the model, so it is read before the log is written. + let mut model = None; + for index in 0..total { + model = read(index)?.model; + if model.is_some() { + break; + } + } + let model = model.unwrap_or_else(|| requested_model.to_owned()); + + let out = File::create(workspace.join(WORKSPACE_LOG_FILE)).map_err(|error| files_error(&error))?; + let mut log = slog::SlogWriter::start(BufWriter::new(out), start_time, &model).map_err(log_error)?; + let mut actions = 0; + let mut usage = Some(Usage::default()); + + // Chunks follow each other in time, so only the actions of one chunk need sorting. + for index in 0..total { + if cancel.is_cancelled() { + return Err(RecordingAnalysisError::Cancelled); + } + + let mut described = read(index)?; + described.actions.sort_by_key(|action| action.offset); + + for action in &described.actions { + log.action(action).map_err(log_error)?; + } + + actions += described.actions.len(); + usage = add_usage(usage, described.usage); + } + + log.finish(duration) + .map_err(log_error)? + .into_inner() + .map_err(|error| files_error(&error.into_error()))? + .sync_all() + .map_err(|error| files_error(&error))?; + + Ok(MergedLog { actions, model, usage }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/slog.rs b/crates/devolutions-gateway-ai/src/recording_analysis/slog.rs new file mode 100644 index 000000000..27e6735f2 --- /dev/null +++ b/crates/devolutions-gateway-ai/src/recording_analysis/slog.rs @@ -0,0 +1,177 @@ +//! Writes AI actions as a Session Recording Log (`.slog`): one JSON object per line, in the shipped schema. + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde::Serialize; + +use crate::session_actions::{Action, PROMPT_VERSION}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Entry<'a> { + timestamp: String, + seq: usize, + event: &'static str, + description: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + object: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option<&'a BTreeMap>, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_version: Option<&'static str>, +} + +impl<'a> Entry<'a> { + fn new(seq: usize, timestamp: String, event: &'static str, description: &'a str) -> Self { + Self { + timestamp, + seq, + event, + description, + object: None, + parameters: None, + source: None, + model: None, + prompt_version: None, + } + } +} + +/// Writes the log of a session entry by entry, so the actions never have to be all in memory. +pub(super) struct SlogWriter { + out: W, + start_time: i64, + seq: usize, +} + +impl SlogWriter { + /// Writes `session.start` for a session that started at `start_time` (unix seconds). + pub(super) fn start(out: W, start_time: i64, model: &str) -> anyhow::Result { + let mut writer = Self { + out, + start_time, + seq: 0, + }; + + let timestamp = timestamp(start_time, Duration::ZERO)?; + writer.write(Entry { + source: Some("ai"), + model: Some(model), + prompt_version: Some(PROMPT_VERSION), + ..Entry::new(0, timestamp, "session.start", "Session started") + })?; + + Ok(writer) + } + + /// Actions must come in offset order. + pub(super) fn action(&mut self, action: &Action) -> anyhow::Result<()> { + let timestamp = timestamp(self.start_time, action.offset)?; + self.write(Entry { + object: action.object.as_deref(), + parameters: Some(&action.parameters).filter(|parameters| !parameters.is_empty()), + ..Entry::new(self.seq, timestamp, "session.action", &action.description) + }) + } + + /// Writes `session.end` for a session that lasted `duration` seconds. + pub(super) fn finish(mut self, duration: i64) -> anyhow::Result { + let end_offset = Duration::from_secs(u64::try_from(duration).unwrap_or(0)); + let timestamp = timestamp(self.start_time, end_offset)?; + self.write(Entry::new(self.seq, timestamp, "session.end", "Session ended"))?; + self.out.flush()?; + Ok(self.out) + } + + fn write(&mut self, entry: Entry<'_>) -> anyhow::Result<()> { + serde_json::to_writer(&mut self.out, &entry)?; + self.out.write_all(b"\n")?; + self.seq += 1; + Ok(()) + } +} + +/// ISO 8601 UTC time with milliseconds, like the other `.slog` writers. +fn timestamp(start_time: i64, offset: Duration) -> anyhow::Result { + let time = time::OffsetDateTime::from_unix_timestamp(start_time)? + .checked_add(time::Duration::try_from(offset)?) + .ok_or_else(|| anyhow::anyhow!("timestamp out of range"))?; + + Ok(format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", + time.year(), + u8::from(time.month()), + time.day(), + time.hour(), + time.minute(), + time.second(), + time.millisecond(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(start_time: i64, duration: i64, actions: &[Action]) -> String { + let mut writer = SlogWriter::start(Vec::new(), start_time, "gpt-test").expect("started"); + for action in actions { + writer.action(action).expect("written"); + } + String::from_utf8(writer.finish(duration).expect("finished")).expect("UTF-8") + } + + fn action(offset_ms: u64, description: &str, object: Option<&str>, parameters: &[(&str, &str)]) -> Action { + Action { + offset: Duration::from_millis(offset_ms), + description: description.to_owned(), + object: object.map(str::to_owned), + parameters: parameters + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + } + } + + #[test] + fn writes_the_shipped_schema_with_ai_fields_on_session_start() { + let actions = [ + action(1500, "Listed files", Some("/var/log"), &[("Command", "ls /var/log")]), + action(62_250, "Closed the shell", None, &[]), + ]; + + let slog = write(1_787_255_035, 90, &actions); + + let session_start = format!( + r#"{{"timestamp":"2026-08-20T19:43:55.000Z","seq":0,"event":"session.start","description":"Session started","source":"ai","model":"gpt-test","promptVersion":"{PROMPT_VERSION}"}}"# + ); + + let expected = [ + session_start.as_str(), + r#"{"timestamp":"2026-08-20T19:43:56.500Z","seq":1,"event":"session.action","description":"Listed files","object":"/var/log","parameters":{"Command":"ls /var/log"}}"#, + r#"{"timestamp":"2026-08-20T19:44:57.250Z","seq":2,"event":"session.action","description":"Closed the shell"}"#, + r#"{"timestamp":"2026-08-20T19:45:25.000Z","seq":3,"event":"session.end","description":"Session ended"}"#, + ]; + assert_eq!(slog, expected.map(|line| format!("{line}\n")).concat()); + } + + #[test] + fn a_session_without_actions_still_starts_and_ends() { + let slog = write(0, 5, &[]); + + let events: Vec = slog + .lines() + .map(|line| serde_json::from_str(line).expect("JSON line")) + .collect(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["event"], "session.start"); + assert_eq!(events[1]["event"], "session.end"); + assert_eq!(events[1]["seq"], 1); + assert_eq!(events[1]["timestamp"], "1970-01-01T00:00:05.000Z"); + } +} diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs b/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs new file mode 100644 index 000000000..d803e5adc --- /dev/null +++ b/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs @@ -0,0 +1,370 @@ +#![allow(clippy::unwrap_used, reason = "test code can panic on errors")] + +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::TcpListener; + +use super::*; +use crate::Provider; + +const MODEL: &str = "gpt-test"; +const REPORTED_MODEL: &str = "gpt-test-2026-09-30"; + +/// A mock OpenAI-compatible provider: `status` picks the answer of the n-th request, 0 being a valid answer. +/// +/// Returns a client for it and the bodies of the requests it got. +async fn spawn_provider(status: fn(usize) -> u16) -> (AiClient, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + + tokio::spawn({ + let requests = Arc::clone(&requests); + async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let requests = Arc::clone(&requests); + + tokio::spawn(async move { + let mut stream = BufReader::new(stream); + let mut content_length = 0; + + loop { + let mut line = String::new(); + stream.read_line(&mut line).await.unwrap(); + let line = line.trim_end(); + + if line.is_empty() { + break; + } + + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = value.trim().parse().unwrap(); + } + } + + let mut body = vec![0; content_length]; + stream.read_exact(&mut body).await.unwrap(); + + let index = { + let mut requests = requests.lock().unwrap(); + requests.push(String::from_utf8(body).unwrap()); + requests.len() - 1 + }; + + let (status, answer) = match status(index) { + 0 => ( + 200, + serde_json::json!({ + "model": REPORTED_MODEL, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": format!("{{\"offsetSeconds\":{index},\"description\":\"Step {index}\"}}"), + }, + "finish_reason": "stop", + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 }, + }), + ), + status => ( + status, + serde_json::json!({ "error": { "message": "scripted failure" } }), + ), + }; + + let answer = answer.to_string(); + let response = format!( + "HTTP/1.1 {status} Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}", + answer.len() + ); + stream.get_mut().write_all(response.as_bytes()).await.unwrap(); + }); + } + } + }); + + let client = AiClient::builder() + .provider(Provider::OpenAiCompatible) + .base_url(format!("http://{addr}/v1/").parse().unwrap()) + .model(MODEL) + .api_key("sk-test-secret") + .http_client(reqwest::Client::new()) + .build() + .unwrap(); + + (client, requests) +} + +/// A finished session with one terminal recording of `lines` lines of about 100 bytes each. +struct Session { + _dir: tempfile::TempDir, + manifest: RecordingManifest, + manifest_path: Utf8PathBuf, +} + +impl Session { + fn new(lines: usize) -> Self { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8Path::from_path(dir.path()).unwrap(); + + let mut cast = String::from("{\"version\": 2}\n"); + for line in 0..lines { + cast.push_str(&format!("[{line}.0,\"o\",\"line {line} {}\\r\\n\"]\n", "x".repeat(80))); + } + std::fs::write(path.join("recording-0.cast"), cast).unwrap(); + + Self { + manifest: RecordingManifest { + start_time: 1_787_255_035, + duration: i64::try_from(lines).unwrap(), + files: vec![RecordingFile { + file_name: "recording-0.cast".to_owned(), + start_time: 1_787_255_035, + }], + }, + manifest_path: path.join("recording.json"), + _dir: dir, + } + } + + fn workspace(&self) -> Utf8PathBuf { + recording_dir(&self.manifest_path).join(WORKSPACE_DIR) + } + + async fn analyze(&self, client: &AiClient) -> Result { + client + .analyze_recording(&self.manifest, &self.manifest_path) + .send() + .await + } +} + +fn descriptions(log: &Utf8Path) -> Vec { + std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| { + serde_json::from_str::(line).unwrap()["description"] + .as_str() + .unwrap() + .to_owned() + }) + .collect() +} + +#[tokio::test] +async fn the_log_is_left_next_to_the_manifest_and_the_working_files_removed() { + let session = Session::new(10); + let (client, _requests) = spawn_provider(|_| 0).await; + let mut progress = Vec::new(); + + let analysis = client + .analyze_recording(&session.manifest, &session.manifest_path) + .on_progress(|step| { + progress.push(step); + async {} + }) + .send() + .await + .unwrap(); + + assert_eq!(analysis.log_path, recording_dir(&session.manifest_path).join(LOG_FILE)); + assert_eq!(analysis.actions, 1); + assert_eq!(analysis.model, REPORTED_MODEL); + assert_eq!( + analysis.usage, + Some(Usage { + input_tokens: 10, + output_tokens: 20 + }) + ); + assert_eq!( + descriptions(&analysis.log_path), + ["Session started", "Step 0", "Session ended"] + ); + assert!(!session.workspace().exists()); + assert_eq!( + progress, + [ + Progress { + described_chunks: 0, + total_chunks: 1 + }, + Progress { + described_chunks: 1, + total_chunks: 1 + }, + ] + ); +} + +#[tokio::test] +async fn analysing_again_only_asks_about_the_chunks_left() { + // About 1 MB of transcript: three chunks. + let session = Session::new(10_000); + let (client, requests) = spawn_provider(|index| if index == 1 { 503 } else { 0 }).await; + + let error = session.analyze(&client).await.unwrap_err(); + assert!(error.is_transient(), "{error:?}"); + assert!(session.workspace().join("chunk-0000.json").exists()); + assert!(!session.workspace().join("chunk-0001.json").exists()); + + let analysis = session.analyze(&client).await.unwrap(); + + assert_eq!(requests.lock().unwrap().len(), 4, "only chunks 1 and 2 are sent again"); + assert_eq!( + descriptions(&analysis.log_path), + ["Session started", "Step 0", "Step 2", "Step 3", "Session ended"] + ); + assert_eq!( + analysis.usage, + Some(Usage { + input_tokens: 30, + output_tokens: 60 + }), + "chunk 0 counts from its checkpoint, the failed request not at all" + ); +} + +#[tokio::test] +async fn working_files_of_another_analysis_are_not_reused() { + let session = Session::new(10); + let (client, requests) = spawn_provider(|index| if index == 0 { 503 } else { 0 }).await; + session.analyze(&client).await.unwrap_err(); + + let settings = session.workspace().join(SETTINGS_FILE); + let saved = std::fs::read_to_string(&settings).unwrap(); + std::fs::write(&settings, saved.replace(MODEL, "another-model")).unwrap(); + checkpoint::write( + &checkpoint::path(&session.workspace(), 0), + &DescribedChunk { + actions: Vec::new(), + model: None, + usage: None, + }, + ) + .unwrap(); + + let analysis = session.analyze(&client).await.unwrap(); + + assert_eq!(requests.lock().unwrap().len(), 2, "the chunk is asked about again"); + assert_eq!( + descriptions(&analysis.log_path), + ["Session started", "Step 1", "Session ended"] + ); +} + +#[tokio::test] +async fn sessions_without_terminal_recording_fail_for_good() { + let mut session = Session::new(1); + session.manifest.files[0].file_name = "recording-0.webm".to_owned(); + let (client, requests) = spawn_provider(|_| 0).await; + + let error = session.analyze(&client).await.unwrap_err(); + + assert!( + matches!(error, RecordingAnalysisError::Unsupported("webm")), + "{error:?}" + ); + assert!(!error.is_transient()); + assert!(requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn a_cancelled_analysis_stops() { + let session = Session::new(10); + let (client, requests) = spawn_provider(|_| 0).await; + let cancel = CancellationToken::new(); + cancel.cancel(); + + let error = client + .analyze_recording(&session.manifest, &session.manifest_path) + .cancellation(cancel) + .send() + .await + .unwrap_err(); + + assert!(matches!(error, RecordingAnalysisError::Cancelled), "{error:?}"); + assert!(requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn discard_removes_the_working_files_and_the_log() { + let session = Session::new(10); + let (client, _requests) = spawn_provider(|index| if index == 0 { 503 } else { 0 }).await; + session.analyze(&client).await.unwrap_err(); + let log = recording_dir(&session.manifest_path).join(LOG_FILE); + std::fs::write(&log, "left behind").unwrap(); + + discard(&session.manifest_path).await.unwrap(); + + assert!(!session.workspace().exists()); + assert!(!log.exists()); + assert!(recording_dir(&session.manifest_path).join("recording-0.cast").exists()); + discard(&session.manifest_path).await.unwrap(); +} + +#[test] +fn the_analysis_can_run_on_another_thread_with_a_progress_callback_that_is_not_sync() { + fn assert_send(_: impl Future + Send) {} + + let manifest = RecordingManifest { + start_time: 0, + duration: 0, + files: Vec::new(), + }; + let client = AiClient::builder() + .provider(Provider::OpenAi) + .model(MODEL) + .api_key("sk-test-secret") + .http_client(reqwest::Client::new()) + .build() + .unwrap(); + let progress = std::cell::Cell::new(0); + + assert_send( + client + .analyze_recording(&manifest, Utf8Path::new("recording.json")) + .on_progress(move |_| { + progress.set(progress.get() + 1); + async {} + }) + .send(), + ); +} + +#[test] +fn halves_are_cut_on_the_line_boundary_closest_to_the_middle() { + let line = format!("[1.0] {}\n", "a".repeat(94)); + let part = line.repeat(30); + + let (first, second) = split_in_half(&part).unwrap(); + + assert_eq!(first.len(), 15 * line.len()); + assert_eq!(second.len(), 15 * line.len()); + + let uneven = format!("{line}{}", "b".repeat(1900)); + let (first, second) = split_in_half(&uneven).unwrap(); + assert_eq!(first, line); + assert_eq!(second, "b".repeat(1900)); + + assert!(split_in_half(&line.repeat(3)).is_none(), "too short"); + assert!(split_in_half(&"c".repeat(MIN_SPLIT_LEN * 2)).is_none(), "one line"); +} + +#[test] +fn usage_total_is_unknown_once_a_count_is() { + let usage = |input_tokens, output_tokens| Usage { + input_tokens, + output_tokens, + }; + + assert_eq!(add_usage(Some(usage(1, 2)), Some(usage(10, 20))), Some(usage(11, 22))); + assert_eq!(add_usage(Some(usage(1, 2)), None), None); + assert_eq!(add_usage(None, Some(usage(10, 20))), None); +} diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs b/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs new file mode 100644 index 000000000..83fabea15 --- /dev/null +++ b/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs @@ -0,0 +1,720 @@ +//! Turns the terminal recordings of a session into transcript chunk files sent to the AI. +//! +//! Each transcript line reads `[] `. +//! Recordings are read as a stream, so memory use depends on the chunk size, not on the recording size. + +use std::fs::File; +use std::io::{self, BufRead, BufReader, BufWriter, Write as _}; + +use camino::{Utf8Path, Utf8PathBuf}; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use super::RecordingManifest; + +/// Longest text kept from one terminal line, in characters. +const MAX_LINE_CHARS: usize = 500; + +/// Longest asciicast event read; a longer one is skipped. +const MAX_CAST_EVENT_LEN: usize = 1024 * 1024; + +#[derive(Debug, thiserror::Error)] +pub(super) enum TranscriptError { + #[error("unsupported recording type: {0}")] + Unsupported(&'static str), + #[error("session has no terminal recording")] + NoTerminalRecording, + #[error("failed to read {file_name}: {reason}")] + Invalid { file_name: String, reason: String }, + #[error("failed to write the transcript: {0}")] + Write(io::Error), + #[error("transcript cancelled before its end")] + Cancelled, +} + +/// Kind of a recording, from its file extension. +enum RecordingKind { + Asciicast, + Trp, + Video, + /// Not a recording, such as a session log. + Other, +} + +impl RecordingKind { + fn of(file_name: &str) -> Self { + match Utf8Path::new(file_name).extension() { + Some("cast") => Self::Asciicast, + Some("trp") => Self::Trp, + Some("webm") => Self::Video, + _ => Self::Other, + } + } +} + +pub(super) fn chunk_path(workspace: &Utf8Path, index: usize) -> Utf8PathBuf { + workspace.join(format!("chunk-{index:04}.txt")) +} + +/// Writes the transcript of the terminal recordings listed in `manifest`, read from `dir`, into chunk files of at most +/// `max_len` bytes in `workspace`, cut on line boundaries, and returns the number of chunks. +/// +/// Returns [`TranscriptError::Cancelled`] soon after `cancel` is cancelled, leaving the chunk files incomplete. +pub(super) fn write_chunks( + manifest: &RecordingManifest, + dir: &Utf8Path, + workspace: &Utf8Path, + max_len: usize, + cancel: &CancellationToken, +) -> Result { + let mut transcript = Transcript::new(ChunkWriter::new(workspace, max_len), cancel); + let mut has_terminal_recording = false; + let mut unsupported = None; + + for file in &manifest.files { + let to_transcript_error = |error: CastError| match error { + CastError::Read(reason) => TranscriptError::Invalid { + file_name: file.file_name.clone(), + reason, + }, + CastError::Write(error) => TranscriptError::Write(error), + CastError::Cancelled => TranscriptError::Cancelled, + }; + + let offset = u32::try_from(file.start_time.saturating_sub(manifest.start_time).max(0)).unwrap_or(u32::MAX); + let offset = f64::from(offset); + + let open = || { + File::open(dir.join(&file.file_name)) + .map(BufReader::new) + .map_err(|error| to_transcript_error(CastError::Read(error.to_string()))) + }; + + match RecordingKind::of(&file.file_name) { + RecordingKind::Asciicast => transcript.add_cast(open()?, offset).map_err(to_transcript_error)?, + RecordingKind::Trp => transcript.add_trp(open()?, offset).map_err(to_transcript_error)?, + RecordingKind::Video => { + unsupported.get_or_insert("webm"); + continue; + } + RecordingKind::Other => continue, + } + + has_terminal_recording = true; + } + + if !has_terminal_recording { + return Err(unsupported.map_or(TranscriptError::NoTerminalRecording, TranscriptError::Unsupported)); + } + + transcript.chunks.finish().map_err(TranscriptError::Write) +} + +enum CastError { + Read(String), + Write(io::Error), + Cancelled, +} + +/// Writes lines into numbered chunk files, starting a new file when the next line would not fit. +struct ChunkWriter { + workspace: Utf8PathBuf, + max_len: usize, + count: usize, + current: Option>, + current_len: usize, +} + +impl ChunkWriter { + fn new(workspace: &Utf8Path, max_len: usize) -> Self { + Self { + workspace: workspace.to_owned(), + max_len, + count: 0, + current: None, + current_len: 0, + } + } + + fn write_line(&mut self, line: &str) -> io::Result<()> { + if self.current.is_some() && self.current_len + line.len() > self.max_len { + self.close_current()?; + } + + let current = match &mut self.current { + Some(current) => current, + None => { + let file = File::create(chunk_path(&self.workspace, self.count))?; + self.count += 1; + self.current_len = 0; + self.current.insert(BufWriter::new(file)) + } + }; + + current.write_all(line.as_bytes())?; + self.current_len += line.len(); + + Ok(()) + } + + fn close_current(&mut self) -> io::Result<()> { + if let Some(current) = self.current.take() { + current + .into_inner() + .map_err(io::IntoInnerError::into_error)? + .sync_all()?; + } + + Ok(()) + } + + fn finish(mut self) -> io::Result { + self.close_current()?; + Ok(self.count) + } +} + +struct Transcript<'a> { + chunks: ChunkWriter, + last_line: String, + cancel: &'a CancellationToken, +} + +impl<'a> Transcript<'a> { + fn new(chunks: ChunkWriter, cancel: &'a CancellationToken) -> Self { + Self { + chunks, + last_line: String::new(), + cancel, + } + } + + /// Adds the terminal output of an asciicast v2 or v3 recording that started `offset` seconds into the session. + fn add_cast(&mut self, mut cast: impl BufRead, offset: f64) -> Result<(), CastError> { + let mut line = Vec::new(); + + let events = core::iter::from_fn(|| match read_event(&mut cast, &mut line) { + Ok(true) => Some(Ok(core::mem::take(&mut line))), + Ok(false) => None, + Err(error) => Some(Err(CastError::Read(error.to_string()))), + }); + + self.add_cast_events(events, offset) + } + + /// Adds the terminal output of a TRP recording that started `offset` seconds into the session. + fn add_trp(&mut self, trp: impl io::Read, offset: f64) -> Result<(), CastError> { + let events = terminal_streamer::trp_decoder::AsciicastLines::new(trp).map(|line| { + line.map(String::into_bytes) + .map_err(|error| CastError::Read(format!("{error:#}"))) + }); + + self.add_cast_events(events, offset) + } + + /// Adds the terminal output of asciicast lines, the header first. + fn add_cast_events( + &mut self, + mut lines: impl Iterator, CastError>>, + offset: f64, + ) -> Result<(), CastError> { + let header = loop { + let line = lines + .next() + .ok_or_else(|| CastError::Read("empty asciicast".to_owned()))??; + + if !line.trim_ascii().is_empty() { + break serde_json::from_slice::(&line) + .map_err(|error| CastError::Read(format!("invalid asciicast header: {error}")))?; + } + }; + + let relative_times = header["version"].as_u64() == Some(3); + + let mut terminal = TerminalText::default(); + let mut time = 0.0; + + for line in lines { + if self.cancel.is_cancelled() { + return Err(CastError::Cancelled); + } + + // Only the output is used: typed passwords are usually not echoed, but printed secrets still reach the AI. + let Ok((event_time, code, data)) = serde_json::from_slice::<(f64, String, String)>(&line?) else { + continue; + }; + + time = if relative_times { time + event_time } else { event_time }; + + if code == "o" { + terminal.feed(offset + time, &data, self).map_err(CastError::Write)?; + } + } + + terminal.end_line(self).map_err(CastError::Write) + } + + fn push_line(&mut self, time: f64, text: &str) -> io::Result<()> { + let text = text.trim(); + + if text.is_empty() || text == self.last_line { + return Ok(()); + } + + let text = match text.char_indices().nth(MAX_LINE_CHARS) { + Some((cut, _)) => &text[..cut], + None => text, + }; + + self.chunks.write_line(&format!("[{time:.1}] {text}\n"))?; + text.clone_into(&mut self.last_line); + + Ok(()) + } +} + +/// Reads the next asciicast event into `line`, skipping events longer than [`MAX_CAST_EVENT_LEN`]. +fn read_event(reader: &mut impl BufRead, line: &mut Vec) -> io::Result { + let limit = u64::try_from(MAX_CAST_EVENT_LEN).unwrap_or(u64::MAX); + + loop { + line.clear(); + + if io::Read::take(&mut *reader, limit).read_until(b'\n', line)? == 0 { + return Ok(false); + } + + if line.len() < MAX_CAST_EVENT_LEN || line.ends_with(b"\n") { + return Ok(true); + } + + warn!(max_len = MAX_CAST_EVENT_LEN, "Skipped an oversized asciicast event"); + skip_line(reader)?; + } +} + +fn skip_line(reader: &mut impl BufRead) -> io::Result<()> { + loop { + let buffer = reader.fill_buf()?; + + if buffer.is_empty() { + return Ok(()); + } + + if let Some(end) = buffer.iter().position(|&byte| byte == b'\n') { + reader.consume(end + 1); + return Ok(()); + } + + let len = buffer.len(); + reader.consume(len); + } +} + +#[derive(Default, Clone, Copy)] +enum EscapeState { + #[default] + Text, + Escape, + EscapeArgument, + ControlSequence, + OperatingSystemCommand, + OperatingSystemCommandEscape, +} + +/// Rebuilds the text lines of a terminal output stream, without escape sequences. +#[derive(Default)] +struct TerminalText { + state: EscapeState, + line: String, + line_start: Option, + carriage_return: bool, +} + +impl TerminalText { + fn feed(&mut self, time: f64, data: &str, transcript: &mut Transcript<'_>) -> io::Result<()> { + for c in data.chars() { + self.state = match (self.state, c) { + (EscapeState::Text, '\x1b') => EscapeState::Escape, + (EscapeState::Text, '\n') => { + self.end_line(transcript)?; + EscapeState::Text + } + (EscapeState::Text, '\r') => { + self.carriage_return = true; + EscapeState::Text + } + (EscapeState::Text, '\x08') => { + self.line.pop(); + EscapeState::Text + } + (EscapeState::Text, '\t') => { + self.put(' ', time); + EscapeState::Text + } + (EscapeState::Text, c) => { + if !c.is_control() { + self.put(c, time); + } + EscapeState::Text + } + (EscapeState::Escape, '[') => EscapeState::ControlSequence, + (EscapeState::Escape, ']') => EscapeState::OperatingSystemCommand, + (EscapeState::Escape, '(' | ')' | '*' | '+' | '#' | '%') => EscapeState::EscapeArgument, + (EscapeState::Escape | EscapeState::EscapeArgument | EscapeState::OperatingSystemCommandEscape, _) => { + EscapeState::Text + } + (EscapeState::ControlSequence, '\x40'..='\x7e') => EscapeState::Text, + (EscapeState::ControlSequence, _) => EscapeState::ControlSequence, + (EscapeState::OperatingSystemCommand, '\x07') => EscapeState::Text, + (EscapeState::OperatingSystemCommand, '\x1b') => EscapeState::OperatingSystemCommandEscape, + (EscapeState::OperatingSystemCommand, _) => EscapeState::OperatingSystemCommand, + }; + } + + Ok(()) + } + + fn put(&mut self, c: char, time: f64) { + // A carriage return not followed by a line feed means the line is redrawn. + if self.carriage_return { + self.carriage_return = false; + self.line.clear(); + self.line_start = None; + } + + self.line_start.get_or_insert(time); + + // Text past the kept length is dropped anyway, so a line that never ends cannot grow without bound. + if self.line.len() < MAX_LINE_CHARS * 4 { + self.line.push(c); + } + } + + fn end_line(&mut self, transcript: &mut Transcript<'_>) -> io::Result<()> { + self.carriage_return = false; + + if let Some(start) = self.line_start.take() { + transcript.push_line(start, &self.line)?; + } + + self.line.clear(); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::recording_analysis::RecordingFile; + + fn read_chunks(dir: &Utf8Path, count: usize) -> Vec { + (0..count) + .map(|index| std::fs::read_to_string(chunk_path(dir, index)).expect("chunk file")) + .collect() + } + + fn transcript_of(cast: &str, offset: f64) -> String { + let dir = tempfile::tempdir().expect("temp dir"); + let dir = Utf8Path::from_path(dir.path()).expect("UTF-8"); + let cancel = CancellationToken::new(); + let mut transcript = Transcript::new(ChunkWriter::new(dir, usize::MAX), &cancel); + assert!(transcript.add_cast(cast.as_bytes(), offset).is_ok()); + let count = transcript.chunks.finish().expect("written"); + read_chunks(dir, count).concat() + } + + const CAST: &str = r#"{"version": 2, "width": 80, "height": 24} +[0.3,"o","\u001b]0;user@host: ~\u0007\u001b[01;32muser@host\u001b[00m:~$ "] +[1.0,"i","l"] +[1.1,"o","l"] +[1.2,"o","s\r\n"] +[1.5,"o","file-a file-b\r\n"] +[2.0,"o","user@host:~$ sudo passwd david\r\n[sudo] password for user: "] +[3.0,"i","hunter2\r"] +[3.5,"o","\r\n"] +[4.0,"o","progress 10%\rprogress 100%\r\n"] +[5.0,"o","same\r\nsame\r\n"] +[6.0,"o","typo\b\b\u001b[Kps\r\n"] +[7.0,"r","100x30"] +"#; + + #[test] + fn cast_output_becomes_timed_plain_lines() { + assert_eq!( + transcript_of(CAST, 10.0), + concat!( + "[10.3] user@host:~$ ls\n", + "[11.5] file-a file-b\n", + "[12.0] user@host:~$ sudo passwd david\n", + "[12.0] [sudo] password for user:\n", + "[14.0] progress 100%\n", + "[15.0] same\n", + "[16.0] typs\n", + ) + ); + } + + #[test] + fn asciicast_v3_times_are_intervals() { + let cast = "{\"version\": 3, \"term\": {\"cols\": 80, \"rows\": 24}}\n[1.0,\"o\",\"a\\r\\n\"]\n[0.5,\"o\",\"b\\r\\n\"]\n"; + + assert_eq!(transcript_of(cast, 0.0), "[1.0] a\n[1.5] b\n"); + } + + #[test] + fn long_lines_are_cut() { + let cast = format!( + "{{\"version\": 2}}\n[0,\"o\",\"{}\"]\n", + "é".repeat(MAX_LINE_CHARS + 10) + ); + + let transcript = transcript_of(&cast, 0.0); + + assert_eq!(transcript.trim_end().chars().count(), "[0.0] ".len() + MAX_LINE_CHARS); + } + + #[test] + fn oversized_cast_events_are_skipped() { + let cast = format!( + "{{\"version\": 2}}\n[0,\"o\",\"{}\"]\n[1,\"o\",\"kept\\r\\n\"]\n", + "x".repeat(MAX_CAST_EVENT_LEN) + ); + + assert_eq!(transcript_of(&cast, 0.0), "[1.0] kept\n"); + } + + #[test] + fn chunks_keep_whole_lines_within_the_budget() { + let dir = tempfile::tempdir().expect("temp dir"); + let dir = Utf8Path::from_path(dir.path()).expect("UTF-8"); + let mut chunks = ChunkWriter::new(dir, 22); + for line in ["[1.0] aaaa\n", "[2.0] bbbb\n", "[3.0] cccc\n"] { + chunks.write_line(line).expect("written"); + } + + let count = chunks.finish().expect("written"); + + assert_eq!(read_chunks(dir, count), ["[1.0] aaaa\n[2.0] bbbb\n", "[3.0] cccc\n"]); + assert_eq!(ChunkWriter::new(dir, 22).finish().expect("nothing written"), 0); + } + + /// Generates a large recording lazily and records how many events were read when the first chunk file was complete. + struct LazyRecording { + next_event: usize, + events: usize, + event: fn(usize) -> Vec, + pending: Vec, + second_chunk: Utf8PathBuf, + read_when_first_chunk_done: Arc, + } + + impl LazyRecording { + fn new(dir: &Utf8Path, events: usize, event: fn(usize) -> Vec) -> (Self, Arc) { + let read_when_first_chunk_done = Arc::new(AtomicUsize::new(0)); + let recording = Self { + next_event: 0, + events, + event, + pending: Vec::new(), + second_chunk: chunk_path(dir, 1), + read_when_first_chunk_done: Arc::clone(&read_when_first_chunk_done), + }; + (recording, read_when_first_chunk_done) + } + } + + impl io::Read for LazyRecording { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.pending.is_empty() { + if self.next_event == self.events { + return Ok(0); + } + + if self.second_chunk.exists() { + let _ = self.read_when_first_chunk_done.compare_exchange( + 0, + self.next_event, + Ordering::Relaxed, + Ordering::Relaxed, + ); + } + + self.pending = (self.event)(self.next_event); + self.next_event += 1; + } + + let n = buf.len().min(self.pending.len()); + buf[..n].copy_from_slice(&self.pending[..n]); + self.pending.drain(..n); + Ok(n) + } + } + + fn cast_event(event: usize) -> Vec { + if event == 0 { + b"{\"version\": 2}\n".to_vec() + } else { + format!("[{event}.0,\"o\",\"line {event} of a long session\\r\\n\"]\n").into_bytes() + } + } + + fn trp_packet(time_delta: u32, event_type: u16, payload: &[u8]) -> Vec { + let mut packet = Vec::new(); + packet.extend_from_slice(&time_delta.to_le_bytes()); + packet.extend_from_slice(&event_type.to_le_bytes()); + packet.extend_from_slice(&u16::try_from(payload.len()).expect("small").to_le_bytes()); + packet.extend_from_slice(payload); + packet + } + + fn trp_event(event: usize) -> Vec { + if event == 0 { + trp_packet(0, 4, b"") + } else { + trp_packet(1000, 0, format!("line {event} of a long session\r\n").as_bytes()) + } + } + + fn assert_streamed_into_bounded_chunk_files(event: fn(usize) -> Vec, trp: bool) { + let dir = tempfile::tempdir().expect("temp dir"); + let dir = Utf8Path::from_path(dir.path()).expect("UTF-8"); + let events = 20_000; + let (recording, read_when_first_chunk_done) = LazyRecording::new(dir, events, event); + + let cancel = CancellationToken::new(); + let mut transcript = Transcript::new(ChunkWriter::new(dir, 4096), &cancel); + let recording = BufReader::with_capacity(64, recording); + let added = if trp { + transcript.add_trp(recording, 0.0) + } else { + transcript.add_cast(recording, 0.0) + }; + assert!(added.is_ok()); + let count = transcript.chunks.finish().expect("written"); + + assert!(count > 100, "{count}"); + for chunk in read_chunks(dir, count) { + assert!(chunk.len() <= 4096); + assert!(chunk.ends_with('\n')); + } + + let read = read_when_first_chunk_done.load(Ordering::Relaxed); + assert!( + read > 0 && read < events / 50, + "first chunk done after {read} of {events} events" + ); + } + + #[test] + fn large_casts_are_streamed_into_bounded_chunk_files() { + assert_streamed_into_bounded_chunk_files(cast_event, false); + } + + #[test] + fn large_trp_recordings_are_streamed_into_bounded_chunk_files() { + assert_streamed_into_bounded_chunk_files(trp_event, true); + } + + #[test] + fn transcript_stops_once_cancelled() { + let dir = tempfile::tempdir().expect("temp dir"); + let dir = Utf8Path::from_path(dir.path()).expect("UTF-8"); + let cancel = CancellationToken::new(); + cancel.cancel(); + let (recording, _) = LazyRecording::new(dir, 1000, cast_event); + + let mut transcript = Transcript::new(ChunkWriter::new(dir, 4096), &cancel); + let result = transcript.add_cast(BufReader::new(recording), 0.0); + + assert!(matches!(result, Err(CastError::Cancelled))); + } + + fn session_dir(files: &[(&str, &[u8])]) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("temp dir"); + for (name, contents) in files { + std::fs::write(dir.path().join(name), contents).expect("write recording"); + } + dir + } + + fn manifest(start_time: i64, files: &[(&str, i64)]) -> RecordingManifest { + RecordingManifest { + start_time, + duration: 90, + files: files + .iter() + .map(|&(file_name, start_time)| RecordingFile { + file_name: file_name.to_owned(), + start_time, + }) + .collect(), + } + } + + fn read(manifest: &RecordingManifest, dir: &tempfile::TempDir) -> Result { + let path = Utf8Path::from_path(dir.path()).expect("utf8 path"); + let workspace = path.join("workspace"); + std::fs::create_dir(&workspace).expect("workspace"); + let count = write_chunks(manifest, path, &workspace, usize::MAX, &CancellationToken::new())?; + Ok(read_chunks(&workspace, count).concat()) + } + + #[test] + fn recordings_are_offset_by_their_start_time() { + let manifest = manifest( + 100, + &[ + ("recording-0.cast", 100), + ("recording-1.cast", 160), + ("ai-analysis-0.slog", 100), + ], + ); + let first: &[u8] = b"{\"version\": 2}\n[1.5,\"o\",\"whoami\\r\\n\"]\n"; + let second: &[u8] = b"{\"version\": 2}\n[2.0,\"o\",\"exit\\r\\n\"]\n"; + let dir = session_dir(&[("recording-0.cast", first), ("recording-1.cast", second)]); + + assert_eq!( + read(&manifest, &dir).expect("transcript"), + "[1.5] whoami\n[62.0] exit\n" + ); + } + + #[test] + fn video_only_sessions_are_unsupported() { + let dir = session_dir(&[]); + + assert_eq!( + read(&manifest(1, &[("recording-0.webm", 1)]), &dir) + .expect_err("webm only") + .to_string(), + "unsupported recording type: webm" + ); + } + + #[test] + fn trp_recordings_are_decoded() { + let mut trp = trp_packet(0, 4, b""); + trp.extend(trp_packet(2500, 0, b"uptime\r\n")); + let dir = session_dir(&[("recording-0.trp", &trp)]); + + assert_eq!( + read(&manifest(100, &[("recording-0.trp", 100)]), &dir).expect("transcript"), + "[2.5] uptime\n" + ); + } + + #[test] + fn sessions_without_recording_have_no_transcript() { + let dir = session_dir(&[]); + + assert_eq!( + read(&manifest(1, &[]), &dir).expect_err("no recording").to_string(), + "session has no terminal recording" + ); + } +} diff --git a/crates/devolutions-gateway-ai/tests/providers.rs b/crates/devolutions-gateway-ai/tests/providers.rs index e76eda94e..f3359bfbb 100644 --- a/crates/devolutions-gateway-ai/tests/providers.rs +++ b/crates/devolutions-gateway-ai/tests/providers.rs @@ -846,6 +846,19 @@ async fn error_answer_over_the_size_limit_keeps_the_status() { } } +/// A base URL whose host, port and path a transport error must not repeat. +fn private_url(addr: std::net::SocketAddr) -> Url { + Url::parse(&format!("http://{addr}/private-endpoint/v1/")).unwrap() +} + +// Errors end up in logs and in stored Task results, where the endpoint of the provider must not appear. +fn assert_url_hidden(error: &Error, addr: std::net::SocketAddr) { + for shown in [error.to_string(), format!("{error:?}")] { + assert!(!shown.contains("private-endpoint"), "{shown}"); + assert!(!shown.contains(&addr.to_string()), "{shown}"); + } +} + #[tokio::test] async fn answer_cut_by_a_closed_connection_is_transient() { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); @@ -860,7 +873,7 @@ async fn answer_cut_by_a_closed_connection_is_transient() { .unwrap(); }); - let error = client(Provider::OpenAi, Url::parse(&format!("http://{addr}/v1/")).unwrap()) + let error = client(Provider::OpenAi, private_url(addr)) .describe_session_actions("[0] whoami") .send() .await @@ -868,6 +881,7 @@ async fn answer_cut_by_a_closed_connection_is_transient() { assert!(matches!(error, Error::Transport { .. }), "{error:?}"); assert!(error.is_transient()); + assert_url_hidden(&error, addr); } #[tokio::test] @@ -876,7 +890,7 @@ async fn unreachable_provider_is_transient() { let addr = listener.local_addr().unwrap(); drop(listener); - let error = client(Provider::OpenAi, Url::parse(&format!("http://{addr}/v1/")).unwrap()) + let error = client(Provider::OpenAi, private_url(addr)) .describe_session_actions("[0] whoami") .send() .await @@ -884,6 +898,7 @@ async fn unreachable_provider_is_transient() { assert!(matches!(error, Error::Transport { .. }), "{error:?}"); assert!(error.is_transient()); + assert_url_hidden(&error, addr); } #[tokio::test] @@ -906,7 +921,7 @@ async fn silent_provider_times_out_as_transient() { .provider(Provider::OpenAiCompatible) .model(MODEL) .api_key(API_KEY) - .base_url(Url::parse(&format!("http://{addr}/v1/")).unwrap()) + .base_url(private_url(addr)) .http_client(reqwest::Client::builder().no_proxy().build().unwrap()) .request_timeout(Duration::from_millis(200)) .build() @@ -918,5 +933,6 @@ async fn silent_provider_times_out_as_transient() { assert!(matches!(error, Error::Transport { .. }), "{error:?}"); assert!(error.is_transient()); + assert_url_hidden(&error, addr); assert!(started.elapsed() < Duration::from_secs(5), "{:?}", started.elapsed()); } diff --git a/crates/terminal-streamer/src/trp_decoder.rs b/crates/terminal-streamer/src/trp_decoder.rs index 751ec3d8c..77c9d09ed 100644 --- a/crates/terminal-streamer/src/trp_decoder.rs +++ b/crates/terminal-streamer/src/trp_decoder.rs @@ -1,3 +1,5 @@ +use std::collections::VecDeque; +use std::io::Read; use std::pin::Pin; use std::task::{Context, Poll}; @@ -80,107 +82,226 @@ impl AsyncRead for AsyncReadChannel { } } -async fn parse_trp_stream( - mut input_stream: impl AsyncRead + Unpin + Send + 'static, - mut tx: tokio::sync::mpsc::Sender>, -) -> anyhow::Result<()> { - let mut time = 0.0; - let mut before_setup_cache = Some(Vec::new()); - let mut header = AsciinemaHeader::default(); +/// Decodes a complete TRP recording into asciicast v2 lines, without their line ending. +/// +/// The recording is read one packet at a time, so memory use does not grow with its size. +/// A truncated last packet, as left by an interrupted recording, is ignored. +/// The lines end after the first error. +pub struct AsciicastLines { + input: R, + decoder: Option, + pending: VecDeque, + payload: Vec, +} - loop { - let mut packet_head_buffer = [0u8; 8]; - if let Err(e) = input_stream.read_exact(&mut packet_head_buffer).await { - if e.kind() == std::io::ErrorKind::UnexpectedEof { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - continue; - } - anyhow::bail!(e); +impl AsciicastLines { + pub fn new(input: R) -> Self { + Self { + input, + decoder: Some(TrpDecoder::default()), + pending: VecDeque::new(), + payload: Vec::new(), } + } - let time_delta = u32::from_le_bytes(packet_head_buffer[0..4].try_into()?); - let event_type = u16::from_le_bytes(packet_head_buffer[4..6].try_into()?); - let size = u16::from_le_bytes(packet_head_buffer[6..8].try_into()?); + /// Decodes the next packet into `pending`; at the end of the input, flushes the decoder. + fn decode_next_packet(&mut self) -> anyhow::Result<()> { + let Some(decoder) = self.decoder.as_mut() else { + return Ok(()); + }; - time += f64::from(time_delta) / 1000.0; + let mut header = [0u8; PACKET_HEADER_SIZE]; + if read_complete(&mut self.input, &mut header)? { + let header = PacketHeader::parse(&header); + self.payload.resize(usize::from(header.size), 0); - let mut event_payload = vec![0u8; size as usize]; - if let Err(e) = input_stream.read_exact(&mut event_payload).await { - if e.kind() == std::io::ErrorKind::UnexpectedEof { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - continue; + if read_complete(&mut self.input, &mut self.payload)? { + self.pending.extend(decoder.push(&header, &self.payload)?); + return Ok(()); } - anyhow::bail!(e); } - match event_type { - 0 => { - // Terminal output - let event_payload = String::from_utf8_lossy(&event_payload).into_owned(); - let event = AsciinemaEvent::TerminalOutput { - payload: event_payload, - time, - }; - match before_setup_cache { - Some(ref mut cache) => { - cache.push(event); - } - None => { - send(&mut tx, event.to_json()).await?; - } - } + if let Some(decoder) = self.decoder.take() { + self.pending.extend(decoder.finish()); + } + + Ok(()) + } +} + +impl Iterator for AsciicastLines { + type Item = anyhow::Result; + + fn next(&mut self) -> Option { + loop { + if let Some(line) = self.pending.pop_front() { + return Some(Ok(line)); + } + + self.decoder.as_ref()?; + + if let Err(error) = self.decode_next_packet() { + self.decoder = None; + return Some(Err(error)); } - 1 => { - let event_payload = String::from_utf8_lossy(&event_payload).into_owned(); - let event = AsciinemaEvent::UserInput { - payload: event_payload, - time, + } + } +} + +/// Fills `buffer`, or returns `false` if the input ends first. +fn read_complete(input: &mut impl Read, buffer: &mut [u8]) -> std::io::Result { + match input.read_exact(buffer) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), + Err(error) => Err(error), + } +} + +const PACKET_HEADER_SIZE: usize = 8; + +struct PacketHeader { + time_delta: u32, + event_type: u16, + size: u16, +} + +impl PacketHeader { + fn parse(buffer: &[u8; PACKET_HEADER_SIZE]) -> Self { + Self { + time_delta: u32::from_le_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]), + event_type: u16::from_le_bytes([buffer[4], buffer[5]]), + size: u16::from_le_bytes([buffer[6], buffer[7]]), + } + } +} + +/// Most payload bytes held while waiting for the terminal setup; past it, the header is sent with the size known so far. +const MAX_BEFORE_SETUP_LEN: usize = 1024 * 1024; + +/// Turns TRP packets into asciicast lines, holding the events that come before the terminal setup. +struct TrpDecoder { + time: f64, + before_setup_cache: Option>, + before_setup_len: usize, + header: AsciinemaHeader, +} + +impl Default for TrpDecoder { + fn default() -> Self { + Self { + time: 0.0, + before_setup_cache: Some(Vec::new()), + before_setup_len: 0, + header: AsciinemaHeader::default(), + } + } +} + +impl TrpDecoder { + fn push(&mut self, packet: &PacketHeader, payload: &[u8]) -> anyhow::Result> { + self.time += f64::from(packet.time_delta) / 1000.0; + let time = self.time; + + let mut lines = Vec::new(); + + match packet.event_type { + 0 | 1 => { + let packet_len = payload.len(); + let payload = String::from_utf8_lossy(payload).into_owned(); + let event = if packet.event_type == 0 { + AsciinemaEvent::TerminalOutput { payload, time } + } else { + AsciinemaEvent::UserInput { payload, time } }; - match before_setup_cache { + match self.before_setup_cache { Some(ref mut cache) => { cache.push(event); + self.before_setup_len += packet_len; + + // A recording that never sends its terminal setup must not be held whole in memory. + if self.before_setup_len > MAX_BEFORE_SETUP_LEN { + lines.extend(self.flush_before_setup()); + } } - None => { - send(&mut tx, event.to_json()).await?; - } + None => lines.push(event.to_json()), } } 2 => { // Terminal size change. Payload is little-endian [columns, rows]. - if event_payload.len() < 4 { - anyhow::bail!( - "invalid terminal size change payload length (len={})", - event_payload.len() - ); + if payload.len() < 4 { + anyhow::bail!("invalid terminal size change payload length (len={})", payload.len()); } - header.col = u16::from_le_bytes(event_payload[0..2].try_into()?); - header.row = u16::from_le_bytes(event_payload[2..4].try_into()?); - if before_setup_cache.is_none() { + self.header.col = u16::from_le_bytes([payload[0], payload[1]]); + self.header.row = u16::from_le_bytes([payload[2], payload[3]]); + if self.before_setup_cache.is_none() { let event = AsciinemaEvent::Resize { - width: header.col, - height: header.row, + width: self.header.col, + height: self.header.row, time, }; - send(&mut tx, event.to_json()).await?; + lines.push(event.to_json()); } } 4 => { // Terminal setup - if before_setup_cache.is_some() { - let header_json = header.to_json(); - send(&mut tx, header_json).await?; - if let Some(ref mut cache) = before_setup_cache { - for event in cache.drain(..) { - send(&mut tx, event.to_json()).await?; - } - } - before_setup_cache = None; + if self.before_setup_cache.is_some() { + lines.extend(self.flush_before_setup()); } else { warn!("Received terminal setup event but cache is empty"); } } _ => {} } + + Ok(lines) + } + + /// Returns the header and the cached events, once; the events that follow are not cached anymore. + fn flush_before_setup(&mut self) -> Vec { + match self.before_setup_cache.take() { + Some(cache) => core::iter::once(self.header.to_json()) + .chain(cache.iter().map(AsciinemaEvent::to_json)) + .collect(), + None => Vec::new(), + } + } + + /// Flushes the cached events of a recording that never sent its terminal setup. + fn finish(mut self) -> Vec { + self.flush_before_setup() + } +} + +async fn parse_trp_stream( + mut input_stream: impl AsyncRead + Unpin + Send + 'static, + mut tx: tokio::sync::mpsc::Sender>, +) -> anyhow::Result<()> { + let mut decoder = TrpDecoder::default(); + + loop { + let mut packet_head_buffer = [0u8; PACKET_HEADER_SIZE]; + if let Err(e) = input_stream.read_exact(&mut packet_head_buffer).await { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + continue; + } + anyhow::bail!(e); + } + + let packet = PacketHeader::parse(&packet_head_buffer); + + let mut event_payload = vec![0u8; usize::from(packet.size)]; + if let Err(e) = input_stream.read_exact(&mut event_payload).await { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + continue; + } + anyhow::bail!(e); + } + + for line in decoder.push(&packet, &event_payload)? { + send(&mut tx, line).await?; + } } } @@ -189,3 +310,151 @@ async fn send(sender: &mut tokio::sync::mpsc::Sender>, mu sender.send(Ok(json)).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn packet(time_delta: u32, event_type: u16, payload: &[u8]) -> Vec { + let mut packet = Vec::new(); + packet.extend_from_slice(&time_delta.to_le_bytes()); + packet.extend_from_slice(&event_type.to_le_bytes()); + packet.extend_from_slice(&u16::try_from(payload.len()).expect("small payload").to_le_bytes()); + packet.extend_from_slice(payload); + packet + } + + fn decode(trp: &[u8]) -> anyhow::Result { + AsciicastLines::new(trp) + .map(|line| Ok(format!("{}\n", line?))) + .collect() + } + + #[test] + fn decodes_a_complete_recording_and_ignores_a_truncated_tail() { + let mut trp = Vec::new(); + trp.extend(packet(0, 2, &[100, 0, 30, 0])); + trp.extend(packet(500, 0, b"$ ")); + trp.extend(packet(0, 4, &[])); + trp.extend(packet(1000, 1, b"l")); + trp.extend(packet(250, 0, b"ls\r\n")); + trp.extend(&packet(10, 0, b"lost")[..9]); + + let cast = decode(&trp).expect("valid recording"); + + assert_eq!( + cast, + concat!( + "{\"version\": 2, \"width\": 100, \"height\": 30}\n", + "[0.5,\"o\",\"$ \"]\n", + "[1.5,\"i\",\"l\"]\n", + r#"[1.75,"o","ls\u000d\u000a"]"#, + "\n", + ) + ); + } + + #[tokio::test] + async fn complete_recording_matches_the_live_stream_decoder() { + use tokio::io::AsyncBufReadExt as _; + + let mut trp = Vec::new(); + trp.extend(packet(0, 2, &[120, 0, 40, 0])); + trp.extend(packet(100, 0, b"before setup")); + trp.extend(packet(0, 4, &[])); + trp.extend(packet(300, 1, b"x")); + trp.extend(packet(0, 2, &[80, 0, 24, 0])); + trp.extend(packet(42, 0, "é\u{1b}[0m".as_bytes())); + + let complete = decode(&trp).expect("valid recording"); + + let (task, live_reader) = decode_stream(std::io::Cursor::new(trp)).expect("start the live decoder"); + let mut live_lines = tokio::io::BufReader::new(live_reader).lines(); + let mut streamed = String::new(); + for _ in complete.lines() { + let line = live_lines + .next_line() + .await + .expect("read a live line") + .expect("a live line"); + streamed.push_str(&line); + streamed.push('\n'); + } + task.abort(); + + assert_eq!(complete, streamed); + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("disk failure")) + } + } + + #[test] + fn lines_come_out_before_the_rest_of_the_recording_is_read() { + let mut trp = Vec::new(); + trp.extend(packet(0, 4, &[])); + trp.extend(packet(1000, 0, b"first")); + + let mut lines = AsciicastLines::new(Read::chain(trp.as_slice(), FailingReader)); + + assert_eq!( + lines.next().expect("header").expect("valid header"), + "{\"version\": 2, \"width\": 80, \"height\": 24}" + ); + assert_eq!( + lines.next().expect("event").expect("valid event"), + "[1,\"o\",\"first\"]" + ); + let error = lines.next().expect("read error").expect_err("the rest fails"); + assert!(error.to_string().contains("disk failure"), "{error:#}"); + assert!(lines.next().is_none(), "nothing comes after an error"); + } + + #[test] + fn events_waiting_for_the_terminal_setup_are_bounded() { + let payload = vec![b'x'; 60_000]; + let packets = MAX_BEFORE_SETUP_LEN / payload.len() + 1; + let mut trp = Vec::new(); + for _ in 0..packets { + trp.extend(packet(0, 0, &payload)); + } + + // The setup never comes: the rest of the recording fails to read. + let lines = AsciicastLines::new(Read::chain(trp.as_slice(), FailingReader)).collect::>(); + + assert_eq!(lines.len(), 1 + packets + 1, "header, every event, then the read error"); + assert_eq!( + lines[0].as_ref().expect("valid header"), + "{\"version\": 2, \"width\": 80, \"height\": 24}" + ); + assert!(lines[1..=packets].iter().all(Result::is_ok)); + assert!(lines[packets + 1].is_err()); + } + + #[test] + fn malformed_input_fails_cleanly() { + let error = decode(&packet(0, 2, &[1, 2])).expect_err("short resize payload"); + assert!( + error + .to_string() + .contains("invalid terminal size change payload length") + ); + + let cast = decode(&[0xFF; 5]).expect("input shorter than a packet header"); + assert_eq!(cast, "{\"version\": 2, \"width\": 80, \"height\": 24}\n"); + } + + #[test] + fn events_of_a_recording_without_setup_are_kept() { + let cast = decode(&packet(2000, 0, b"x")).expect("valid recording"); + + assert_eq!( + cast, + "{\"version\": 2, \"width\": 80, \"height\": 24}\n[2,\"o\",\"x\"]\n" + ); + } +} diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 24acd386c..15503a644 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" @@ -37,6 +38,7 @@ sysevent.path = "../crates/sysevent" sysevent-codes.path = "../crates/sysevent-codes" agent-tunnel = { path = "../crates/agent-tunnel" } gateway-db.path = "../crates/gateway-db" +provisioner-task.path = "../crates/provisioner-task" ironrdp-pdu = { version = "0.9", features = ["std"] } ironrdp-core = { version = "0.2", features = ["std"] } ironrdp-rdcleanpath = "0.2" @@ -90,6 +92,7 @@ tracing = "0.1" # Async, futures… tokio = { version = "1.52", features = ["signal", "net", "io-util", "time", "rt", "rt-multi-thread", "sync", "macros", "parking_lot", "fs"] } +tokio-util = "0.7" tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "ring"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "stream", "http2", "socks"] } http-client-proxy = { path = "../crates/http-client-proxy" } diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 04e0934d0..57179c2ce 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -1426,6 +1426,135 @@ ifdef::internal-generation[] endif::internal-generation[] +[.startAiAnalysis] +==== startAiAnalysis + +`POST /jet/jrec/{session_id}/ai-analysis` + +Starts an AI analysis of a recorded session + +===== Description + +Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + + +// markup not found, no include::{specDir}jet/jrec/\{session_id\}/ai-analysis/POST/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 + +| session_id +| Recorded session ID +| X +| null +| + +|=== + +====== Body Parameter + +[cols="2,3,1,1,1"] +|=== +|Name| Description| Required| Default| Pattern + +| AiAnalysisRequest +| AI analysis settings <> +| X +| +| + +|=== + + + + + +===== Return Type + +<> + + +===== Content Type + +* application/json + +===== Responses + +.HTTP Response Codes +[cols="2,3,1"] +|=== +| Code | Message | Datatype + + +| 200 +| The same request came in before; nothing new starts +| <> + + +| 202 +| The AI analysis Task is created and runs in the background +| <> + + +| 400 +| Invalid request body or AI settings +| <<>> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 404 +| The session has no recording +| <<>> + + +| 409 +| The AI analysis cannot start now +| <> + + +| 500 +| Unexpected server error +| <<>> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/jrec/\{session_id\}/ai-analysis/POST/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + [.Jrl] === Jrl @@ -2517,6 +2646,116 @@ ifdef::internal-generation[] endif::internal-generation[] +[.Tasks] +=== Tasks + + +[.getTask] +==== getTask + +`GET /jet/tasks/{id}` + +Gets a Task, the record of one piece of work the provisioner asked Gateway to do + +===== Description + +Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + + +// 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 +| The Task +| <> + + +| 400 +| Bad request +| <<>> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 404 +| No Task has this ID +| <<>> + + +| 500 +| Unexpected server error +| <<>> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/tasks/\{id\}/GET/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + [.Traffic] === Traffic @@ -3371,6 +3610,8 @@ endif::internal-generation[] | gateway.net.monitor.drain | gateway.agent.delete | gateway.agent.read +| gateway.recording.analyze +| gateway.tasks.read |=== @@ -3544,6 +3785,278 @@ endif::internal-generation[] |=== +[#AiAnalysisConflict] +=== _AiAnalysisConflict_ + +Why an AI analysis cannot start now + + +[.fields-AiAnalysisConflict] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| code +| X +| +| <> +| +| task_id_conflict, recording_active, analysis_in_progress, + +| taskId +| +| X +| UUID +| The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. +| uuid + +|=== + + + +[#AiAnalysisConflictCode] +=== _AiAnalysisConflictCode_ + + + + + + +[.fields-AiAnalysisConflictCode] +[cols="1"] +|=== +| Enum Values + +| task_id_conflict +| recording_active +| analysis_in_progress + +|=== + + +[#AiAnalysisFailedPayload] +=== _AiAnalysisFailedPayload_ + +Payload of a `failed` AI analysis Task: why it failed + + +[.fields-AiAnalysisFailedPayload] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| attempts +| X +| +| Integer +| Number of attempts started. +| int32 + +| details +| X +| X +| oas_any_type_not_mapped +| What went wrong: a text, except for `timed out`, where it is `{ \"lastPayload\": }`. +| + +| reason +| X +| +| String +| Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. +| + +|=== + + + +[#AiAnalysisRequest] +=== _AiAnalysisRequest_ + +Settings of an AI analysis + + +[.fields-AiAnalysisRequest] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| apiKey +| X +| +| String +| API key of the provider, kept in memory only until the Task ends. +| password + +| baseUrl +| +| X +| String +| Endpoint of the provider API, kept in memory only; 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, gemini, openai-compatible, + +| taskId +| X +| +| UUID +| Task ID, chosen by the caller so it can send the same request again safely. +| uuid + +|=== + + + +[#AiAnalysisRunningPayload] +=== _AiAnalysisRunningPayload_ + +Payload of a `running` AI analysis Task: its progress + + +[.fields-AiAnalysisRunningPayload] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| done +| +| X +| Long +| Number of transcript chunks described so far; set while `describing`. +| int64 + +| step +| X +| +| String +| Current step, such as `preparing` or `describing`. +| + +| total +| +| X +| Long +| Number of transcript chunks to describe; set while `describing`. +| int64 + +|=== + + + +[#AiAnalysisSucceededPayload] +=== _AiAnalysisSucceededPayload_ + +Payload of a `succeeded` AI analysis Task: the log it added to the session + + +[.fields-AiAnalysisSucceededPayload] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| fileName +| X +| +| String +| Name of the new log in the session manifest, such as `ai-analysis-0.slog`. +| + +| model +| X +| +| String +| Model that wrote the log, as reported by the AI provider; the requested model when it reports none. +| + +| promptVersion +| X +| +| String +| Version of the instructions sent to the AI provider. +| + +| usage +| +| X +| <> +| +| + +|=== + + + +[#AiAnalysisUsage] +=== _AiAnalysisUsage_ + +Tokens counted by an AI provider + + +[.fields-AiAnalysisUsage] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| inputTokens +| X +| +| Long +| +| int64 + +| outputTokens +| X +| +| Long +| +| int64 + +|=== + + + +[#AiProvider] +=== _AiProvider_ + +AI provider to ask; `openai-compatible` needs a base URL + + + + +[.fields-AiProvider] +[cols="1"] +|=== +| Enum Values + +| openai +| anthropic +| mistral +| gemini +| openai-compatible + +|=== + + [#AppCredential] === _AppCredential_ @@ -5564,6 +6077,124 @@ Subscriber configuration +[#TaskInfo] +=== _TaskInfo_ + +A Task: the permanent record of one piece of work the provisioner asked Gateway to do + +The work runs in the background; `state` and `payload` tell how it goes. +The shape of `params` and `payload` belongs to the kind. +For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an +`AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`. + + +[.fields-TaskInfo] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| attempts +| X +| +| Integer +| Number of attempts started so far. +| int32 + +| createdAt +| X +| +| Date +| When the Task was created. +| date-time + +| deadlineAt +| X +| +| Date +| When an unfinished Task becomes failed, waiting and retries included. +| date-time + +| finishedAt +| +| X +| Date +| When it succeeded or failed. +| date-time + +| id +| X +| +| UUID +| Task ID, chosen by the provisioner. +| uuid + +| kind +| X +| +| String +| Kind of work, such as `recording.ai-analysis`. +| + +| params +| X +| +| Object +| What was asked, without secrets. +| + +| payload +| +| X +| Object +| Details of the current state; null while `queued`. +| + +| startedAt +| +| X +| Date +| When its first attempt started. +| date-time + +| state +| X +| +| <> +| +| queued, running, succeeded, failed, + +| target +| X +| +| String +| What the work is about, such as a session ID. +| + +|=== + + + +[#TaskState] +=== _TaskState_ + + + + + + +[.fields-TaskState] +[cols="1"] +|=== +| Enum Values + +| queued +| running +| succeeded +| 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..1685700fe 100644 --- a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES @@ -9,6 +9,14 @@ docs/AgentApi.md docs/AgentDomainAdvertisement.md docs/AgentInfo.md docs/AgentStatus.md +docs/AiAnalysisConflict.md +docs/AiAnalysisConflictCode.md +docs/AiAnalysisFailedPayload.md +docs/AiAnalysisRequest.md +docs/AiAnalysisRunningPayload.md +docs/AiAnalysisSucceededPayload.md +docs/AiAnalysisUsage.md +docs/AiProvider.md docs/AppCredential.md docs/AppCredentialKind.md docs/AppTokenContentType.md @@ -70,6 +78,9 @@ docs/SetUpdateScheduleRequest.md docs/SubProvisionerKey.md docs/Subscriber.md docs/TargetConnectionOptions.md +docs/TaskInfo.md +docs/TaskState.md +docs/TasksApi.md docs/TrafficApi.md docs/TrafficEventResponse.md docs/TransportProtocolResponse.md @@ -88,6 +99,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 +129,14 @@ 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/AiAnalysisConflict.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisConflictCode.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisRequest.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisRunningPayload.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisSucceededPayload.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisUsage.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 +188,8 @@ 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/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..f4c4ba470 100644 --- a/devolutions-gateway/openapi/dotnet-client/README.md +++ b/devolutions-gateway/openapi/dotnet-client/README.md @@ -156,6 +156,7 @@ Class | Method | HTTP request | Description *JrecApi* | [**ListRecordings**](docs/JrecApi.md#listrecordings) | **GET** /jet/jrec/list | Lists all recordings stored on this instance *JrecApi* | [**PullRecordingFile**](docs/JrecApi.md#pullrecordingfile) | **GET** /jet/jrec/pull/{id}/{filename} | Retrieves a recording file for a given session *JrecApi* | [**PullRecordingSession**](docs/JrecApi.md#pullrecordingsession) | **GET** /jet/jrec/pull/{id} | Downloads an entire recorded session as a ZIP archive +*JrecApi* | [**StartAiAnalysis**](docs/JrecApi.md#startaianalysis) | **POST** /jet/jrec/{session_id}/ai-analysis | Starts an AI analysis of a recorded session *JrlApi* | [**GetJrlInfo**](docs/JrlApi.md#getjrlinfo) | **GET** /jet/jrl/info | Retrieves current JRL (Json Revocation List) info *JrlApi* | [**UpdateJrl**](docs/JrlApi.md#updatejrl) | **POST** /jet/jrl | Updates JRL (Json Revocation List) using a JRL token *NetApi* | [**GetNetConfig**](docs/NetApi.md#getnetconfig) | **GET** /jet/net/config | Lists network interfaces @@ -166,6 +167,7 @@ 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 a Task, the record of one piece of work the provisioner asked Gateway to do *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,14 @@ Class | Method | HTTP request | Description - [Model.AgentDomainAdvertisement](docs/AgentDomainAdvertisement.md) - [Model.AgentInfo](docs/AgentInfo.md) - [Model.AgentStatus](docs/AgentStatus.md) + - [Model.AiAnalysisConflict](docs/AiAnalysisConflict.md) + - [Model.AiAnalysisConflictCode](docs/AiAnalysisConflictCode.md) + - [Model.AiAnalysisFailedPayload](docs/AiAnalysisFailedPayload.md) + - [Model.AiAnalysisRequest](docs/AiAnalysisRequest.md) + - [Model.AiAnalysisRunningPayload](docs/AiAnalysisRunningPayload.md) + - [Model.AiAnalysisSucceededPayload](docs/AiAnalysisSucceededPayload.md) + - [Model.AiAnalysisUsage](docs/AiAnalysisUsage.md) + - [Model.AiProvider](docs/AiProvider.md) - [Model.AppCredential](docs/AppCredential.md) - [Model.AppCredentialKind](docs/AppCredentialKind.md) - [Model.AppTokenContentType](docs/AppTokenContentType.md) @@ -237,6 +247,8 @@ Class | Method | HTTP request | Description - [Model.SubProvisionerKey](docs/SubProvisionerKey.md) - [Model.Subscriber](docs/Subscriber.md) - [Model.TargetConnectionOptions](docs/TargetConnectionOptions.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) diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflict.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflict.md new file mode 100644 index 000000000..af1ed1fa7 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflict.md @@ -0,0 +1,12 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisConflict +Why an AI analysis cannot start now + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Code** | **AiAnalysisConflictCode** | | +**TaskId** | **Guid?** | The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. | [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/AiAnalysisConflictCode.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflictCode.md new file mode 100644 index 000000000..f67809bcc --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflictCode.md @@ -0,0 +1,9 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisConflictCode + +## 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/AiAnalysisFailedPayload.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisFailedPayload.md new file mode 100644 index 000000000..ea5eec197 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisFailedPayload.md @@ -0,0 +1,13 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisFailedPayload +Payload of a `failed` AI analysis Task: why it failed + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Attempts** | **int** | Number of attempts started. | +**Details** | **Object** | What went wrong: a text, except for `timed out`, where it is `{ \"lastPayload\": <payload before the timeout> }`. | +**Reason** | **string** | Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. | + +[[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/AiAnalysisRequest.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRequest.md new file mode 100644 index 000000000..7fb6f7d0e --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRequest.md @@ -0,0 +1,16 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisRequest +Settings of an AI analysis + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**ApiKey** | **string** | API key of the provider, kept in memory only until the Task ends. | +**BaseUrl** | **string** | Endpoint of the provider API, kept in memory only; 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** | | +**TaskId** | **Guid** | Task ID, chosen by the caller so it can send the same request again safely. | + +[[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/AiAnalysisRunningPayload.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRunningPayload.md new file mode 100644 index 000000000..0252ecd4b --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRunningPayload.md @@ -0,0 +1,13 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisRunningPayload +Payload of a `running` AI analysis Task: its progress + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Done** | **long?** | Number of transcript chunks described so far; set while `describing`. | [optional] +**Step** | **string** | Current step, such as `preparing` or `describing`. | +**Total** | **long?** | Number of transcript chunks to describe; set while `describing`. | [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/AiAnalysisSucceededPayload.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisSucceededPayload.md new file mode 100644 index 000000000..6afc9bcea --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisSucceededPayload.md @@ -0,0 +1,14 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisSucceededPayload +Payload of a `succeeded` AI analysis Task: the log it added to the session + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**FileName** | **string** | Name of the new log in the session manifest, such as `ai-analysis-0.slog`. | +**Model** | **string** | Model that wrote the log, as reported by the AI provider; the requested model when it reports none. | +**PromptVersion** | **string** | Version of the instructions sent to the AI provider. | +**Usage** | [**AiAnalysisUsage**](AiAnalysisUsage.md) | | [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/AiAnalysisUsage.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisUsage.md new file mode 100644 index 000000000..28e94dc22 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisUsage.md @@ -0,0 +1,12 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisUsage +Tokens counted by an AI provider + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**InputTokens** | **long** | | +**OutputTokens** | **long** | | + +[[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..a553e6c22 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.AiProvider +AI provider to ask; `openai-compatible` needs a base URL + +## 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/JrecApi.md b/devolutions-gateway/openapi/dotnet-client/docs/JrecApi.md index 5777700ac..5f6c064a0 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/JrecApi.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/JrecApi.md @@ -9,6 +9,7 @@ All URIs are relative to *http://localhost* | [**ListRecordings**](JrecApi.md#listrecordings) | **GET** /jet/jrec/list | Lists all recordings stored on this instance | | [**PullRecordingFile**](JrecApi.md#pullrecordingfile) | **GET** /jet/jrec/pull/{id}/{filename} | Retrieves a recording file for a given session | | [**PullRecordingSession**](JrecApi.md#pullrecordingsession) | **GET** /jet/jrec/pull/{id} | Downloads an entire recorded session as a ZIP archive | +| [**StartAiAnalysis**](JrecApi.md#startaianalysis) | **POST** /jet/jrec/{session_id}/ai-analysis | Starts an AI analysis of a recorded session | # **DeleteManyRecordings** @@ -513,3 +514,110 @@ catch (ApiException e) [[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) + +# **StartAiAnalysis** +> TaskInfo StartAiAnalysis (Guid sessionId, AiAnalysisRequest aiAnalysisRequest) + +Starts an AI analysis of a recorded session + +Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + +### 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 StartAiAnalysisExample + { + 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 JrecApi(httpClient, config, httpClientHandler); + var sessionId = "sessionId_example"; // Guid | Recorded session ID + var aiAnalysisRequest = new AiAnalysisRequest(); // AiAnalysisRequest | AI analysis settings + + try + { + // Starts an AI analysis of a recorded session + TaskInfo result = apiInstance.StartAiAnalysis(sessionId, aiAnalysisRequest); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling JrecApi.StartAiAnalysis: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); + } + } + } +} +``` + +#### Using the StartAiAnalysisWithHttpInfo variant +This returns an ApiResponse object which contains the response data, status code and headers. + +```csharp +try +{ + // Starts an AI analysis of a recorded session + ApiResponse response = apiInstance.StartAiAnalysisWithHttpInfo(sessionId, aiAnalysisRequest); + 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 JrecApi.StartAiAnalysisWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **sessionId** | **Guid** | Recorded session ID | | +| **aiAnalysisRequest** | [**AiAnalysisRequest**](AiAnalysisRequest.md) | AI analysis settings | | + +### Return type + +[**TaskInfo**](TaskInfo.md) + +### Authorization + +[scope_token](../README.md#scope_token) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | The same request came in before; nothing new starts | - | +| **202** | The AI analysis Task is created and runs in the background | - | +| **400** | Invalid request body or AI settings | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **404** | The session has no recording | - | +| **409** | The AI analysis cannot start now | - | +| **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/docs/TaskInfo.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md new file mode 100644 index 000000000..6e0fc579d --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md @@ -0,0 +1,21 @@ +# Devolutions.Gateway.Client.Model.TaskInfo +A Task: the permanent record of one piece of work the provisioner asked Gateway to do The work runs in the background; `state` and `payload` tell how it goes. The shape of `params` and `payload` belongs to the kind. For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an `AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Attempts** | **int** | Number of attempts started so far. | +**CreatedAt** | **DateTime** | When the Task was created. | +**DeadlineAt** | **DateTime** | When an unfinished Task becomes failed, waiting and retries included. | +**FinishedAt** | **DateTime?** | When it succeeded or failed. | [optional] +**Id** | **Guid** | Task ID, chosen by the provisioner. | +**Kind** | **string** | Kind of work, such as `recording.ai-analysis`. | +**Params** | **Object** | What was asked, without secrets. | +**Payload** | **Object** | Details of the current state; null while `queued`. | [optional] +**StartedAt** | **DateTime?** | When its first attempt started. | [optional] +**State** | **TaskState** | | +**Target** | **string** | What the work is about, such as a session ID. | + +[[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..a799b3321 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md @@ -0,0 +1,111 @@ +# 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 a Task, the record of one piece of work the provisioner asked Gateway to do | + + +# **GetTask** +> TaskInfo GetTask (Guid id) + +Gets a Task, the record of one piece of work the provisioner asked Gateway to do + +Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + +### 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 a Task, the record of one piece of work the provisioner asked Gateway to do + 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 a Task, the record of one piece of work the provisioner asked Gateway to do + 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** | The Task | - | +| **400** | Bad request | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **404** | No Task has 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) + diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/JrecApi.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/JrecApi.cs index 17a031fcd..6af513365 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/JrecApi.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/JrecApi.cs @@ -126,6 +126,29 @@ public interface IJrecApiSync : IApiAccessor /// Recorded session ID /// ApiResponse of FileParameter ApiResponse PullRecordingSessionWithHttpInfo(Guid id); + /// + /// Starts an AI analysis of a recorded session + /// + /// + /// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// TaskInfo + TaskInfo StartAiAnalysis(Guid sessionId, AiAnalysisRequest aiAnalysisRequest); + + /// + /// Starts an AI analysis of a recorded session + /// + /// + /// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// ApiResponse of TaskInfo + ApiResponse StartAiAnalysisWithHttpInfo(Guid sessionId, AiAnalysisRequest aiAnalysisRequest); #endregion Synchronous Operations } @@ -252,6 +275,31 @@ public interface IJrecApiAsync : IApiAccessor /// Cancellation Token to cancel the request. /// Task of ApiResponse (FileParameter) System.Threading.Tasks.Task> PullRecordingSessionWithHttpInfoAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + /// + /// Starts an AI analysis of a recorded session + /// + /// + /// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + System.Threading.Tasks.Task StartAiAnalysisAsync(Guid sessionId, AiAnalysisRequest aiAnalysisRequest, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + + /// + /// Starts an AI analysis of a recorded session + /// + /// + /// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + System.Threading.Tasks.Task> StartAiAnalysisWithHttpInfoAsync(Guid sessionId, AiAnalysisRequest aiAnalysisRequest, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); #endregion Asynchronous Operations } @@ -1080,5 +1128,140 @@ public Devolutions.Gateway.Client.Client.ApiResponse PullRecordin return localVarResponse; } + /// + /// Starts an AI analysis of a recorded session Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// TaskInfo + public TaskInfo StartAiAnalysis(Guid sessionId, AiAnalysisRequest aiAnalysisRequest) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = StartAiAnalysisWithHttpInfo(sessionId, aiAnalysisRequest); + return localVarResponse.Data; + } + + /// + /// Starts an AI analysis of a recorded session Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// ApiResponse of TaskInfo + public Devolutions.Gateway.Client.Client.ApiResponse StartAiAnalysisWithHttpInfo(Guid sessionId, AiAnalysisRequest aiAnalysisRequest) + { + // verify the required parameter 'aiAnalysisRequest' is set + if (aiAnalysisRequest == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'aiAnalysisRequest' when calling JrecApi->StartAiAnalysis"); + + 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.PathParameters.Add("session_id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(sessionId)); // path parameter + localVarRequestOptions.Data = aiAnalysisRequest; + + // 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.Post("/jet/jrec/{session_id}/ai-analysis", localVarRequestOptions, this.Configuration); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("StartAiAnalysis", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Starts an AI analysis of a recorded session Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + public async System.Threading.Tasks.Task StartAiAnalysisAsync(Guid sessionId, AiAnalysisRequest aiAnalysisRequest, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await StartAiAnalysisWithHttpInfoAsync(sessionId, aiAnalysisRequest, cancellationToken).ConfigureAwait(false); + return localVarResponse.Data; + } + + /// + /// Starts an AI analysis of a recorded session Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + /// + /// Thrown when fails to make API call + /// Recorded session ID + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + public async System.Threading.Tasks.Task> StartAiAnalysisWithHttpInfoAsync(Guid sessionId, AiAnalysisRequest aiAnalysisRequest, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + // verify the required parameter 'aiAnalysisRequest' is set + if (aiAnalysisRequest == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'aiAnalysisRequest' when calling JrecApi->StartAiAnalysis"); + + + 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.PathParameters.Add("session_id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(sessionId)); // path parameter + localVarRequestOptions.Data = aiAnalysisRequest; + + // 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.PostAsync("/jet/jrec/{session_id}/ai-analysis", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("StartAiAnalysis", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + } } 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..53b8bbaa2 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs @@ -0,0 +1,417 @@ +/* + * 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 a Task, the record of one piece of work the provisioner asked Gateway to do + /// + /// + /// Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// Thrown when fails to make API call + /// Task ID + /// TaskInfo + TaskInfo GetTask(Guid id); + + /// + /// Gets a Task, the record of one piece of work the provisioner asked Gateway to do + /// + /// + /// Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// Thrown when fails to make API call + /// Task ID + /// ApiResponse of TaskInfo + ApiResponse GetTaskWithHttpInfo(Guid id); + #endregion Synchronous Operations + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApiAsync : IApiAccessor + { + #region Asynchronous Operations + /// + /// Gets a Task, the record of one piece of work the provisioner asked Gateway to do + /// + /// + /// Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// 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 a Task, the record of one piece of work the provisioner asked Gateway to do + /// + /// + /// Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// 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)); + #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 a Task, the record of one piece of work the provisioner asked Gateway to do Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// 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 a Task, the record of one piece of work the provisioner asked Gateway to do Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// 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 a Task, the record of one piece of work the provisioner asked Gateway to do Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// 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 a Task, the record of one piece of work the provisioner asked Gateway to do Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + /// + /// 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; + } + + } +} 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..f5ae05007 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,19 @@ public enum AccessScope /// Enum GatewayAgentRead for value: gateway.agent.read /// [EnumMember(Value = "gateway.agent.read")] - GatewayAgentRead = 19 + GatewayAgentRead = 19, + + /// + /// Enum GatewayRecordingAnalyze for value: gateway.recording.analyze + /// + [EnumMember(Value = "gateway.recording.analyze")] + GatewayRecordingAnalyze = 20, + + /// + /// Enum GatewayTasksRead for value: gateway.tasks.read + /// + [EnumMember(Value = "gateway.tasks.read")] + GatewayTasksRead = 21 } public static class AccessScopeExtensions @@ -195,6 +207,10 @@ public static string ToValue(this AccessScope variant) return "gateway.agent.delete"; case AccessScope.GatewayAgentRead: return "gateway.agent.read"; + case AccessScope.GatewayRecordingAnalyze: + return "gateway.recording.analyze"; + 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/AiAnalysisConflict.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisConflict.cs new file mode 100644 index 000000000..7fc19bc47 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisConflict.cs @@ -0,0 +1,99 @@ +/* + * 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 an AI analysis cannot start now + /// + [DataContract(Name = "AiAnalysisConflict")] + public partial class AiAnalysisConflict : IValidatableObject + { + + /// + /// Gets or Sets Code + /// + [DataMember(Name = "code", IsRequired = true, EmitDefaultValue = true)] + public AiAnalysisConflictCode Code { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisConflict() { } + /// + /// Initializes a new instance of the class. + /// + /// code (required). + /// The AI analysis of this session that is not finished yet; set for `analysis_in_progress`.. + public AiAnalysisConflict(AiAnalysisConflictCode code = default(AiAnalysisConflictCode), Guid? taskId = default(Guid?)) + { + this.Code = code; + this.TaskId = taskId; + } + + /// + /// The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. + /// + /// The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. + [DataMember(Name = "taskId", EmitDefaultValue = true)] + public Guid? TaskId { 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 AiAnalysisConflict {\n"); + sb.Append(" Code: ").Append(Code).Append("\n"); + sb.Append(" TaskId: ").Append(TaskId).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/AiAnalysisConflictCode.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisConflictCode.cs new file mode 100644 index 000000000..52284a5d8 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisConflictCode.cs @@ -0,0 +1,76 @@ +/* + * 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 AiAnalysisConflictCode + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum AiAnalysisConflictCode + { + /// + /// Enum TaskIdConflict for value: task_id_conflict + /// + [EnumMember(Value = "task_id_conflict")] + TaskIdConflict = 1, + + /// + /// Enum RecordingActive for value: recording_active + /// + [EnumMember(Value = "recording_active")] + RecordingActive = 2, + + /// + /// Enum AnalysisInProgress for value: analysis_in_progress + /// + [EnumMember(Value = "analysis_in_progress")] + AnalysisInProgress = 3 + } + + public static class AiAnalysisConflictCodeExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this AiAnalysisConflictCode variant) + { + switch (variant) + { + case AiAnalysisConflictCode.TaskIdConflict: + return "task_id_conflict"; + case AiAnalysisConflictCode.RecordingActive: + return "recording_active"; + case AiAnalysisConflictCode.AnalysisInProgress: + return "analysis_in_progress"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs new file mode 100644 index 000000000..b793d7bcc --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs @@ -0,0 +1,126 @@ +/* + * 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 +{ + /// + /// Payload of a `failed` AI analysis Task: why it failed + /// + [DataContract(Name = "AiAnalysisFailedPayload")] + public partial class AiAnalysisFailedPayload : IValidatableObject + { + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisFailedPayload() { } + /// + /// Initializes a new instance of the class. + /// + /// Number of attempts started. (required). + /// What went wrong: a text, except for `timed out`, where it is `{ \"lastPayload\": <payload before the timeout> }`. (required). + /// Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. (required). + public AiAnalysisFailedPayload(int attempts = default(int), Object details = default(Object), string reason = default(string)) + { + this.Attempts = attempts; + // to ensure "details" is required (not null) + if (details == null) + { + throw new ArgumentNullException("details is a required property for AiAnalysisFailedPayload and cannot be null"); + } + this.Details = details; + // to ensure "reason" is required (not null) + if (reason == null) + { + throw new ArgumentNullException("reason is a required property for AiAnalysisFailedPayload and cannot be null"); + } + this.Reason = reason; + } + + /// + /// Number of attempts started. + /// + /// Number of attempts started. + [DataMember(Name = "attempts", IsRequired = true, EmitDefaultValue = true)] + public int Attempts { get; set; } + + /// + /// What went wrong: a text, except for `timed out`, where it is `{ \"lastPayload\": <payload before the timeout> }`. + /// + /// What went wrong: a text, except for `timed out`, where it is `{ \"lastPayload\": <payload before the timeout> }`. + [DataMember(Name = "details", IsRequired = true, EmitDefaultValue = true)] + public Object Details { get; set; } + + /// + /// Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. + /// + /// Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. + [DataMember(Name = "reason", IsRequired = true, EmitDefaultValue = true)] + public string Reason { 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 AiAnalysisFailedPayload {\n"); + sb.Append(" Attempts: ").Append(Attempts).Append("\n"); + sb.Append(" Details: ").Append(Details).Append("\n"); + sb.Append(" Reason: ").Append(Reason).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) + { + // Attempts (int) minimum + if (this.Attempts < (int)0) + { + yield return new ValidationResult("Invalid value for Attempts, must be a value greater than or equal to 0.", new [] { "Attempts" }); + } + + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRequest.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRequest.cs new file mode 100644 index 000000000..e08f5e153 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRequest.cs @@ -0,0 +1,155 @@ +/* + * 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 +{ + /// + /// Settings of an AI analysis + /// + [DataContract(Name = "AiAnalysisRequest")] + public partial class AiAnalysisRequest : 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 AiAnalysisRequest() { } + /// + /// Initializes a new instance of the class. + /// + /// API key of the provider, kept in memory only until the Task ends. (required). + /// Endpoint of the provider API, kept in memory only; required for `openai-compatible`.. + /// Upper bound of tokens in each AI answer.. + /// Model identifier, passed to the provider as is. (required). + /// provider (required). + /// Task ID, chosen by the caller so it can send the same request again safely. (required). + public AiAnalysisRequest(string apiKey = default(string), string baseUrl = default(string), int? maxOutputTokens = default(int?), string model = default(string), AiProvider provider = default(AiProvider), Guid taskId = default(Guid)) + { + // to ensure "apiKey" is required (not null) + if (apiKey == null) + { + throw new ArgumentNullException("apiKey is a required property for AiAnalysisRequest and cannot be null"); + } + this.ApiKey = apiKey; + // to ensure "model" is required (not null) + if (model == null) + { + throw new ArgumentNullException("model is a required property for AiAnalysisRequest and cannot be null"); + } + this.Model = model; + this.Provider = provider; + this.TaskId = taskId; + this.BaseUrl = baseUrl; + this.MaxOutputTokens = maxOutputTokens; + } + + /// + /// API key of the provider, kept in memory only until the Task ends. + /// + /// API key of the provider, kept in memory only until the Task ends. + [DataMember(Name = "apiKey", IsRequired = true, EmitDefaultValue = true)] + public string ApiKey { get; set; } + + /// + /// Endpoint of the provider API, kept in memory only; required for `openai-compatible`. + /// + /// Endpoint of the provider API, kept in memory only; 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; } + + /// + /// Task ID, chosen by the caller so it can send the same request again safely. + /// + /// Task ID, chosen by the caller so it can send the same request again safely. + [DataMember(Name = "taskId", IsRequired = true, EmitDefaultValue = true)] + public Guid TaskId { 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 AiAnalysisRequest {\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(" TaskId: ").Append(TaskId).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/AiAnalysisRunningPayload.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRunningPayload.cs new file mode 100644 index 000000000..2d8471c8c --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRunningPayload.cs @@ -0,0 +1,127 @@ +/* + * 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 +{ + /// + /// Payload of a `running` AI analysis Task: its progress + /// + [DataContract(Name = "AiAnalysisRunningPayload")] + public partial class AiAnalysisRunningPayload : IValidatableObject + { + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisRunningPayload() { } + /// + /// Initializes a new instance of the class. + /// + /// Number of transcript chunks described so far; set while `describing`.. + /// Current step, such as `preparing` or `describing`. (required). + /// Number of transcript chunks to describe; set while `describing`.. + public AiAnalysisRunningPayload(long? done = default(long?), string step = default(string), long? total = default(long?)) + { + // to ensure "step" is required (not null) + if (step == null) + { + throw new ArgumentNullException("step is a required property for AiAnalysisRunningPayload and cannot be null"); + } + this.Step = step; + this.Done = done; + this.Total = total; + } + + /// + /// Number of transcript chunks described so far; set while `describing`. + /// + /// Number of transcript chunks described so far; set while `describing`. + [DataMember(Name = "done", EmitDefaultValue = true)] + public long? Done { get; set; } + + /// + /// Current step, such as `preparing` or `describing`. + /// + /// Current step, such as `preparing` or `describing`. + [DataMember(Name = "step", IsRequired = true, EmitDefaultValue = true)] + public string Step { get; set; } + + /// + /// Number of transcript chunks to describe; set while `describing`. + /// + /// Number of transcript chunks to describe; set while `describing`. + [DataMember(Name = "total", EmitDefaultValue = true)] + public long? Total { 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 AiAnalysisRunningPayload {\n"); + sb.Append(" Done: ").Append(Done).Append("\n"); + sb.Append(" Step: ").Append(Step).Append("\n"); + sb.Append(" Total: ").Append(Total).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) + { + // Done (long?) minimum + if (this.Done < (long?)0) + { + yield return new ValidationResult("Invalid value for Done, must be a value greater than or equal to 0.", new [] { "Done" }); + } + + // Total (long?) minimum + if (this.Total < (long?)0) + { + yield return new ValidationResult("Invalid value for Total, must be a value greater than or equal to 0.", new [] { "Total" }); + } + + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisSucceededPayload.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisSucceededPayload.cs new file mode 100644 index 000000000..ff1339300 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisSucceededPayload.cs @@ -0,0 +1,134 @@ +/* + * 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 +{ + /// + /// Payload of a `succeeded` AI analysis Task: the log it added to the session + /// + [DataContract(Name = "AiAnalysisSucceededPayload")] + public partial class AiAnalysisSucceededPayload : IValidatableObject + { + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisSucceededPayload() { } + /// + /// Initializes a new instance of the class. + /// + /// Name of the new log in the session manifest, such as `ai-analysis-0.slog`. (required). + /// Model that wrote the log, as reported by the AI provider; the requested model when it reports none. (required). + /// Version of the instructions sent to the AI provider. (required). + /// usage. + public AiAnalysisSucceededPayload(string fileName = default(string), string model = default(string), string promptVersion = default(string), AiAnalysisUsage usage = default(AiAnalysisUsage)) + { + // to ensure "fileName" is required (not null) + if (fileName == null) + { + throw new ArgumentNullException("fileName is a required property for AiAnalysisSucceededPayload and cannot be null"); + } + this.FileName = fileName; + // to ensure "model" is required (not null) + if (model == null) + { + throw new ArgumentNullException("model is a required property for AiAnalysisSucceededPayload and cannot be null"); + } + this.Model = model; + // to ensure "promptVersion" is required (not null) + if (promptVersion == null) + { + throw new ArgumentNullException("promptVersion is a required property for AiAnalysisSucceededPayload and cannot be null"); + } + this.PromptVersion = promptVersion; + this.Usage = usage; + } + + /// + /// Name of the new log in the session manifest, such as `ai-analysis-0.slog`. + /// + /// Name of the new log in the session manifest, such as `ai-analysis-0.slog`. + [DataMember(Name = "fileName", IsRequired = true, EmitDefaultValue = true)] + public string FileName { get; set; } + + /// + /// Model that wrote the log, as reported by the AI provider; the requested model when it reports none. + /// + /// Model that wrote the log, as reported by the AI provider; the requested model when it reports none. + [DataMember(Name = "model", IsRequired = true, EmitDefaultValue = true)] + public string Model { get; set; } + + /// + /// Version of the instructions sent to the AI provider. + /// + /// Version of the instructions sent to the AI provider. + [DataMember(Name = "promptVersion", IsRequired = true, EmitDefaultValue = true)] + public string PromptVersion { get; set; } + + /// + /// Gets or Sets Usage + /// + [DataMember(Name = "usage", EmitDefaultValue = true)] + public AiAnalysisUsage Usage { 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 AiAnalysisSucceededPayload {\n"); + sb.Append(" FileName: ").Append(FileName).Append("\n"); + sb.Append(" Model: ").Append(Model).Append("\n"); + sb.Append(" PromptVersion: ").Append(PromptVersion).Append("\n"); + sb.Append(" Usage: ").Append(Usage).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/AiAnalysisUsage.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisUsage.cs new file mode 100644 index 000000000..812d5255d --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisUsage.cs @@ -0,0 +1,110 @@ +/* + * 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 +{ + /// + /// Tokens counted by an AI provider + /// + [DataContract(Name = "AiAnalysisUsage")] + public partial class AiAnalysisUsage : IValidatableObject + { + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisUsage() { } + /// + /// Initializes a new instance of the class. + /// + /// inputTokens (required). + /// outputTokens (required). + public AiAnalysisUsage(long inputTokens = default(long), long outputTokens = default(long)) + { + this.InputTokens = inputTokens; + this.OutputTokens = outputTokens; + } + + /// + /// Gets or Sets InputTokens + /// + [DataMember(Name = "inputTokens", IsRequired = true, EmitDefaultValue = true)] + public long InputTokens { get; set; } + + /// + /// Gets or Sets OutputTokens + /// + [DataMember(Name = "outputTokens", IsRequired = true, EmitDefaultValue = true)] + public long OutputTokens { 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 AiAnalysisUsage {\n"); + sb.Append(" InputTokens: ").Append(InputTokens).Append("\n"); + sb.Append(" OutputTokens: ").Append(OutputTokens).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) + { + // InputTokens (long) minimum + if (this.InputTokens < (long)0) + { + yield return new ValidationResult("Invalid value for InputTokens, must be a value greater than or equal to 0.", new [] { "InputTokens" }); + } + + // OutputTokens (long) minimum + if (this.OutputTokens < (long)0) + { + yield return new ValidationResult("Invalid value for OutputTokens, must be a value greater than or equal to 0.", new [] { "OutputTokens" }); + } + + 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..da7b2dbe9 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs @@ -0,0 +1,93 @@ +/* + * 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 provider to ask; `openai-compatible` needs a base URL + /// + /// AI provider to ask; `openai-compatible` needs a base URL + [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 Gemini for value: gemini + /// + [EnumMember(Value = "gemini")] + Gemini = 4, + + /// + /// Enum OpenaiCompatible for value: openai-compatible + /// + [EnumMember(Value = "openai-compatible")] + OpenaiCompatible = 5 + } + + 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.Gemini: + return "gemini"; + 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/TaskInfo.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs new file mode 100644 index 000000000..5b5f40bda --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs @@ -0,0 +1,210 @@ +/* + * 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 Task: the permanent record of one piece of work the provisioner asked Gateway to do The work runs in the background; `state` and `payload` tell how it goes. The shape of `params` and `payload` belongs to the kind. For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an `AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`. + /// + [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. + /// + /// Number of attempts started so far. (required). + /// When the Task was created. (required). + /// When an unfinished Task becomes failed, waiting and retries included. (required). + /// When it succeeded or failed.. + /// Task ID, chosen by the provisioner. (required). + /// Kind of work, such as `recording.ai-analysis`. (required). + /// What was asked, without secrets. (required). + /// Details of the current state; null while `queued`.. + /// When its first attempt started.. + /// state (required). + /// What the work is about, such as a session ID. (required). + public TaskInfo(int attempts = default(int), DateTime createdAt = default(DateTime), DateTime deadlineAt = default(DateTime), DateTime? finishedAt = default(DateTime?), Guid id = default(Guid), string kind = default(string), Object varParams = default(Object), Object payload = default(Object), DateTime? startedAt = default(DateTime?), TaskState state = default(TaskState), string target = default(string)) + { + this.Attempts = attempts; + this.CreatedAt = createdAt; + this.DeadlineAt = deadlineAt; + 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; + // to ensure "varParams" is required (not null) + if (varParams == null) + { + throw new ArgumentNullException("varParams is a required property for TaskInfo and cannot be null"); + } + this.Params = varParams; + this.State = state; + // to ensure "target" is required (not null) + if (target == null) + { + throw new ArgumentNullException("target is a required property for TaskInfo and cannot be null"); + } + this.Target = target; + this.FinishedAt = finishedAt; + this.Payload = payload; + this.StartedAt = startedAt; + } + + /// + /// Number of attempts started so far. + /// + /// Number of attempts started so far. + [DataMember(Name = "attempts", IsRequired = true, EmitDefaultValue = true)] + public int Attempts { get; set; } + + /// + /// When the Task was created. + /// + /// When the Task was created. + [DataMember(Name = "createdAt", IsRequired = true, EmitDefaultValue = true)] + public DateTime CreatedAt { get; set; } + + /// + /// When an unfinished Task becomes failed, waiting and retries included. + /// + /// When an unfinished Task becomes failed, waiting and retries included. + [DataMember(Name = "deadlineAt", IsRequired = true, EmitDefaultValue = true)] + public DateTime DeadlineAt { get; set; } + + /// + /// When it succeeded or failed. + /// + /// When it succeeded or failed. + [DataMember(Name = "finishedAt", EmitDefaultValue = true)] + public DateTime? FinishedAt { get; set; } + + /// + /// Task ID, chosen by the provisioner. + /// + /// Task ID, chosen by the provisioner. + [DataMember(Name = "id", IsRequired = true, EmitDefaultValue = true)] + public Guid Id { get; set; } + + /// + /// Kind of work, such as `recording.ai-analysis`. + /// + /// Kind of work, such as `recording.ai-analysis`. + [DataMember(Name = "kind", IsRequired = true, EmitDefaultValue = true)] + public string Kind { get; set; } + + /// + /// What was asked, without secrets. + /// + /// What was asked, without secrets. + [DataMember(Name = "params", IsRequired = true, EmitDefaultValue = true)] + public Object Params { get; set; } + + /// + /// Details of the current state; null while `queued`. + /// + /// Details of the current state; null while `queued`. + [DataMember(Name = "payload", EmitDefaultValue = true)] + public Object Payload { get; set; } + + /// + /// When its first attempt started. + /// + /// When its first attempt started. + [DataMember(Name = "startedAt", EmitDefaultValue = true)] + public DateTime? StartedAt { get; set; } + + /// + /// What the work is about, such as a session ID. + /// + /// What the work is about, such as a session ID. + [DataMember(Name = "target", IsRequired = true, EmitDefaultValue = true)] + public string Target { 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(" Attempts: ").Append(Attempts).Append("\n"); + sb.Append(" CreatedAt: ").Append(CreatedAt).Append("\n"); + sb.Append(" DeadlineAt: ").Append(DeadlineAt).Append("\n"); + sb.Append(" FinishedAt: ").Append(FinishedAt).Append("\n"); + sb.Append(" Id: ").Append(Id).Append("\n"); + sb.Append(" Kind: ").Append(Kind).Append("\n"); + sb.Append(" Params: ").Append(Params).Append("\n"); + sb.Append(" Payload: ").Append(Payload).Append("\n"); + sb.Append(" StartedAt: ").Append(StartedAt).Append("\n"); + sb.Append(" State: ").Append(State).Append("\n"); + sb.Append(" Target: ").Append(Target).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) + { + // Attempts (int) minimum + if (this.Attempts < (int)0) + { + yield return new ValidationResult("Invalid value for Attempts, must be a value greater than or equal to 0.", new [] { "Attempts" }); + } + + 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..425797788 --- /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 Queued for value: queued + /// + [EnumMember(Value = "queued")] + Queued = 1, + + /// + /// Enum Running for value: running + /// + [EnumMember(Value = "running")] + Running = 2, + + /// + /// Enum Succeeded for value: succeeded + /// + [EnumMember(Value = "succeeded")] + Succeeded = 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.Queued: + return "queued"; + case TaskState.Running: + return "running"; + case TaskState.Succeeded: + return "succeeded"; + 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..b3929f643 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -346,6 +346,67 @@ paths: security: - jrec_token: - pull + /jet/jrec/{session_id}/ai-analysis: + post: + tags: + - Jrec + summary: Starts an AI analysis of a recorded session + description: |- + Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a + new log, listed under `artifacts.ai-analysis` in its manifest. + The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. + + Sending the same request again returns the same Task and starts nothing new. + The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished + Tasks. + operationId: StartAiAnalysis + parameters: + - name: session_id + in: path + description: Recorded session ID + required: true + schema: + type: string + format: uuid + requestBody: + description: AI analysis settings + content: + application/json: + schema: + $ref: '#/components/schemas/AiAnalysisRequest' + required: true + responses: + '200': + description: The same request came in before; nothing new starts + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInfo' + '202': + description: The AI analysis Task is created and runs in the background + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInfo' + '400': + description: Invalid request body or AI settings + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '404': + description: The session has no recording + '409': + description: The AI analysis cannot start now + content: + application/json: + schema: + $ref: '#/components/schemas/AiAnalysisConflict' + '500': + description: Unexpected server error + security: + - scope_token: + - gateway.recording.analyze /jet/jrl: post: tags: @@ -899,6 +960,43 @@ paths: security: - scope_token: - gateway.sessions.read + /jet/tasks/{id}: + get: + tags: + - Tasks + summary: Gets a Task, the record of one piece of work the provisioner asked Gateway to do + description: |- + Tasks are kept forever, across Gateway restarts. + An unfinished Task past its deadline is reported as failed. + operationId: GetTask + parameters: + - name: id + in: path + description: Task ID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: The Task + 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 has this ID + '500': + description: Unexpected server error + security: + - scope_token: + - gateway.tasks.read /jet/traffic/ack: post: tags: @@ -1326,6 +1424,8 @@ components: - gateway.net.monitor.drain - gateway.agent.delete - gateway.agent.read + - gateway.recording.analyze + - gateway.tasks.read AckRequest: type: object required: @@ -1402,6 +1502,142 @@ components: - offline - online - unresponsive + AiAnalysisConflict: + type: object + description: Why an AI analysis cannot start now + required: + - code + properties: + code: + $ref: '#/components/schemas/AiAnalysisConflictCode' + taskId: + type: string + format: uuid + description: The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. + nullable: true + AiAnalysisConflictCode: + type: string + enum: + - task_id_conflict + - recording_active + - analysis_in_progress + AiAnalysisFailedPayload: + type: object + description: 'Payload of a `failed` AI analysis Task: why it failed' + required: + - reason + - details + - attempts + properties: + attempts: + type: integer + format: int32 + description: Number of attempts started. + minimum: 0 + details: + description: 'What went wrong: a text, except for `timed out`, where it is `{ "lastPayload": }`.' + reason: + type: string + description: Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. + AiAnalysisRequest: + type: object + description: Settings of an AI analysis + required: + - taskId + - provider + - model + - apiKey + properties: + apiKey: + type: string + format: password + description: API key of the provider, kept in memory only until the Task ends. + baseUrl: + type: string + description: Endpoint of the provider API, kept in memory only; 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' + taskId: + type: string + format: uuid + description: Task ID, chosen by the caller so it can send the same request again safely. + additionalProperties: false + AiAnalysisRunningPayload: + type: object + description: 'Payload of a `running` AI analysis Task: its progress' + required: + - step + properties: + done: + type: integer + format: int64 + description: Number of transcript chunks described so far; set while `describing`. + nullable: true + minimum: 0 + step: + type: string + description: Current step, such as `preparing` or `describing`. + total: + type: integer + format: int64 + description: Number of transcript chunks to describe; set while `describing`. + nullable: true + minimum: 0 + AiAnalysisSucceededPayload: + type: object + description: 'Payload of a `succeeded` AI analysis Task: the log it added to the session' + required: + - fileName + - model + - promptVersion + properties: + fileName: + type: string + description: Name of the new log in the session manifest, such as `ai-analysis-0.slog`. + model: + type: string + description: Model that wrote the log, as reported by the AI provider; the requested model when it reports none. + promptVersion: + type: string + description: Version of the instructions sent to the AI provider. + usage: + allOf: + - $ref: '#/components/schemas/AiAnalysisUsage' + nullable: true + AiAnalysisUsage: + type: object + description: Tokens counted by an AI provider + required: + - inputTokens + - outputTokens + properties: + inputTokens: + type: integer + format: int64 + minimum: 0 + outputTokens: + type: integer + format: int64 + minimum: 0 + AiProvider: + type: string + description: AI provider to ask; `openai-compatible` needs a base URL + enum: + - openai + - anthropic + - mistral + - gemini + - openai-compatible AppCredential: type: object required: @@ -2392,6 +2628,74 @@ components: Format: `://:` (port is required). Supported schemes are `tcp` and `udp`. nullable: true + TaskInfo: + type: object + description: |- + A Task: the permanent record of one piece of work the provisioner asked Gateway to do + + The work runs in the background; `state` and `payload` tell how it goes. + The shape of `params` and `payload` belongs to the kind. + For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an + `AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`. + required: + - id + - kind + - target + - params + - state + - attempts + - createdAt + - deadlineAt + properties: + attempts: + type: integer + format: int32 + description: Number of attempts started so far. + minimum: 0 + createdAt: + type: string + format: date-time + description: When the Task was created. + deadlineAt: + type: string + format: date-time + description: When an unfinished Task becomes failed, waiting and retries included. + finishedAt: + type: string + format: date-time + description: When it succeeded or failed. + nullable: true + id: + type: string + format: uuid + description: Task ID, chosen by the provisioner. + kind: + type: string + description: Kind of work, such as `recording.ai-analysis`. + params: + type: object + description: What was asked, without secrets. + payload: + type: object + description: Details of the current state; null while `queued`. + nullable: true + startedAt: + type: string + format: date-time + description: When its first attempt started. + nullable: true + state: + $ref: '#/components/schemas/TaskState' + target: + type: string + description: What the work is about, such as a session ID. + TaskState: + type: string + enum: + - queued + - running + - succeeded + - failed TrafficEventResponse: type: object required: diff --git a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES index 84eba61ed..b60de50f0 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,14 @@ model/addressFamily.ts model/agentDomainAdvertisement.ts model/agentInfo.ts model/agentStatus.ts +model/aiAnalysisConflict.ts +model/aiAnalysisConflictCode.ts +model/aiAnalysisFailedPayload.ts +model/aiAnalysisRequest.ts +model/aiAnalysisRunningPayload.ts +model/aiAnalysisSucceededPayload.ts +model/aiAnalysisUsage.ts +model/aiProvider.ts model/appCredential.ts model/appCredentialKind.ts model/appTokenContentType.ts @@ -78,6 +87,8 @@ model/setUpdateScheduleRequest.ts model/subProvisionerKey.ts model/subscriber.ts model/targetConnectionOptions.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/jrec.service.ts b/devolutions-gateway/openapi/ts-angular-client/api/jrec.service.ts index b1e8e0a18..a4eef3b32 100644 --- a/devolutions-gateway/openapi/ts-angular-client/api/jrec.service.ts +++ b/devolutions-gateway/openapi/ts-angular-client/api/jrec.service.ts @@ -16,8 +16,14 @@ import { HttpClient, HttpHeaders, HttpParams, import { CustomHttpParameterCodec } from '../encoder'; import { Observable } from 'rxjs'; +// @ts-ignore +import { AiAnalysisConflict } from '../model/aiAnalysisConflict'; +// @ts-ignore +import { AiAnalysisRequest } from '../model/aiAnalysisRequest'; // @ts-ignore import { DeleteManyResult } from '../model/deleteManyResult'; +// @ts-ignore +import { TaskInfo } from '../model/taskInfo'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; @@ -445,4 +451,90 @@ export class JrecService { ); } + /** + * Starts an AI analysis of a recorded session + * Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a new log, listed under `artifacts.ai-analysis` in its manifest. The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. Sending the same request again returns the same Task and starts nothing new. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + * @param sessionId Recorded session ID + * @param aiAnalysisRequest AI analysis settings + * @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 startAiAnalysis(sessionId: string, aiAnalysisRequest: AiAnalysisRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public startAiAnalysis(sessionId: string, aiAnalysisRequest: AiAnalysisRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startAiAnalysis(sessionId: string, aiAnalysisRequest: AiAnalysisRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startAiAnalysis(sessionId: string, aiAnalysisRequest: AiAnalysisRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (sessionId === null || sessionId === undefined) { + throw new Error('Required parameter sessionId was null or undefined when calling startAiAnalysis.'); + } + if (aiAnalysisRequest === null || aiAnalysisRequest === undefined) { + throw new Error('Required parameter aiAnalysisRequest was null or undefined when calling startAiAnalysis.'); + } + + 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; + } + + + // 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/jrec/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}/ai-analysis`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: aiAnalysisRequest, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + } 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..c5eab5251 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts @@ -0,0 +1,165 @@ +/** + * 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 { 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 a Task, the record of one piece of work the provisioner asked Gateway to do + * Tasks are kept forever, across Gateway restarts. An unfinished Task past its deadline is reported as failed. + * @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 + } + ); + } + +} diff --git a/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts b/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts index 0827c35b7..97c1a229f 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.recording.analyze' | 'gateway.tasks.read'; export const AccessScope = { Star: '*' as AccessScope, @@ -30,6 +30,8 @@ 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, + GatewayRecordingAnalyze: 'gateway.recording.analyze' as AccessScope, + GatewayTasksRead: 'gateway.tasks.read' as AccessScope }; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflict.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflict.ts new file mode 100644 index 000000000..322bc7147 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflict.ts @@ -0,0 +1,26 @@ +/** + * 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 { AiAnalysisConflictCode } from './aiAnalysisConflictCode'; + + +/** + * Why an AI analysis cannot start now + */ +export interface AiAnalysisConflict { + code: AiAnalysisConflictCode; + /** + * The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. + */ + taskId?: string | null; +} +export namespace AiAnalysisConflict { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflictCode.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflictCode.ts new file mode 100644 index 000000000..ac7e69575 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflictCode.ts @@ -0,0 +1,19 @@ +/** + * 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 AiAnalysisConflictCode = 'task_id_conflict' | 'recording_active' | 'analysis_in_progress'; + +export const AiAnalysisConflictCode = { + TaskIdConflict: 'task_id_conflict' as AiAnalysisConflictCode, + RecordingActive: 'recording_active' as AiAnalysisConflictCode, + AnalysisInProgress: 'analysis_in_progress' as AiAnalysisConflictCode +}; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisFailedPayload.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisFailedPayload.ts new file mode 100644 index 000000000..83e88a4da --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisFailedPayload.ts @@ -0,0 +1,29 @@ +/** + * 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. + */ + + +/** + * Payload of a `failed` AI analysis Task: why it failed + */ +export interface AiAnalysisFailedPayload { + /** + * Number of attempts started. + */ + attempts: number; + /** + * What went wrong: a text, except for `timed out`, where it is `{ \"lastPayload\": }`. + */ + details: any | null; + /** + * Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. + */ + reason: string; +} + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRequest.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRequest.ts new file mode 100644 index 000000000..691788436 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRequest.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 { AiProvider } from './aiProvider'; + + +/** + * Settings of an AI analysis + */ +export interface AiAnalysisRequest { + /** + * API key of the provider, kept in memory only until the Task ends. + */ + apiKey: string; + /** + * Endpoint of the provider API, kept in memory only; 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; + /** + * Task ID, chosen by the caller so it can send the same request again safely. + */ + taskId: string; +} +export namespace AiAnalysisRequest { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRunningPayload.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRunningPayload.ts new file mode 100644 index 000000000..3b6ddd03e --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRunningPayload.ts @@ -0,0 +1,29 @@ +/** + * 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. + */ + + +/** + * Payload of a `running` AI analysis Task: its progress + */ +export interface AiAnalysisRunningPayload { + /** + * Number of transcript chunks described so far; set while `describing`. + */ + done?: number | null; + /** + * Current step, such as `preparing` or `describing`. + */ + step: string; + /** + * Number of transcript chunks to describe; set while `describing`. + */ + total?: number | null; +} + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisSucceededPayload.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisSucceededPayload.ts new file mode 100644 index 000000000..60cfbdf4e --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisSucceededPayload.ts @@ -0,0 +1,31 @@ +/** + * 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 { AiAnalysisUsage } from './aiAnalysisUsage'; + + +/** + * Payload of a `succeeded` AI analysis Task: the log it added to the session + */ +export interface AiAnalysisSucceededPayload { + /** + * Name of the new log in the session manifest, such as `ai-analysis-0.slog`. + */ + fileName: string; + /** + * Model that wrote the log, as reported by the AI provider; the requested model when it reports none. + */ + model: string; + /** + * Version of the instructions sent to the AI provider. + */ + promptVersion: string; + usage?: AiAnalysisUsage | null; +} + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisUsage.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisUsage.ts new file mode 100644 index 000000000..a7c7a16ba --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisUsage.ts @@ -0,0 +1,19 @@ +/** + * 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. + */ + + +/** + * Tokens counted by an AI provider + */ +export interface AiAnalysisUsage { + inputTokens: number; + outputTokens: number; +} + 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..01385edbb --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ + + +/** + * AI provider to ask; `openai-compatible` needs a base URL + */ +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 +}; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/models.ts b/devolutions-gateway/openapi/ts-angular-client/model/models.ts index 7d7eb4f0f..114f5b41d 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/models.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/models.ts @@ -5,6 +5,14 @@ export * from './addressFamily'; export * from './agentDomainAdvertisement'; export * from './agentInfo'; export * from './agentStatus'; +export * from './aiAnalysisConflict'; +export * from './aiAnalysisConflictCode'; +export * from './aiAnalysisFailedPayload'; +export * from './aiAnalysisRequest'; +export * from './aiAnalysisRunningPayload'; +export * from './aiAnalysisSucceededPayload'; +export * from './aiAnalysisUsage'; +export * from './aiProvider'; export * from './appCredential'; export * from './appCredentialKind'; export * from './appTokenContentType'; @@ -56,6 +64,8 @@ export * from './setUpdateScheduleRequest'; export * from './subProvisionerKey'; export * from './subscriber'; export * from './targetConnectionOptions'; +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/taskInfo.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts new file mode 100644 index 000000000..77cb67369 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts @@ -0,0 +1,62 @@ +/** + * 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 Task: the permanent record of one piece of work the provisioner asked Gateway to do The work runs in the background; `state` and `payload` tell how it goes. The shape of `params` and `payload` belongs to the kind. For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an `AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`. + */ +export interface TaskInfo { + /** + * Number of attempts started so far. + */ + attempts: number; + /** + * When the Task was created. + */ + createdAt: string; + /** + * When an unfinished Task becomes failed, waiting and retries included. + */ + deadlineAt: string; + /** + * When it succeeded or failed. + */ + finishedAt?: string | null; + /** + * Task ID, chosen by the provisioner. + */ + id: string; + /** + * Kind of work, such as `recording.ai-analysis`. + */ + kind: string; + /** + * What was asked, without secrets. + */ + params: object; + /** + * Details of the current state; null while `queued`. + */ + payload?: object | null; + /** + * When its first attempt started. + */ + startedAt?: string | null; + state: TaskState; + /** + * What the work is about, such as a session ID. + */ + target: string; +} +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..568b0e257 --- /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 = 'queued' | 'running' | 'succeeded' | 'failed'; + +export const TaskState = { + Queued: 'queued' as TaskState, + Running: 'running' as TaskState, + Succeeded: 'succeeded' as TaskState, + Failed: 'failed' as TaskState +}; + diff --git a/devolutions-gateway/src/api/jrec.rs b/devolutions-gateway/src/api/jrec.rs index 3690204cc..fee0d8a1f 100644 --- a/devolutions-gateway/src/api/jrec.rs +++ b/devolutions-gateway/src/api/jrec.rs @@ -11,8 +11,8 @@ use axum::body::Body; use axum::extract::ws::{CloseFrame, WebSocket}; use axum::extract::{self, ConnectInfo, Query, State, WebSocketUpgrade}; use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue}; -use axum::response::Response; -use axum::routing::{delete, get}; +use axum::response::{IntoResponse as _, Response}; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; use bytes::Bytes; use cadeau::xmf; @@ -20,17 +20,22 @@ use camino::{Utf8Path, Utf8PathBuf}; use devolutions_gateway_task::ShutdownSignal; use futures::stream; use hyper::StatusCode; +use secrecy::SecretString; use tokio::io::AsyncReadExt as _; use tracing::Instrument as _; +use url::Url; use uuid::Uuid; use zip::CompressionMethod; use zip::write::SimpleFileOptions; use crate::DgwState; use crate::api::heartbeat::recording_storage_health; +use crate::api::tasks::TaskInfo; use crate::artifacts::JrecArtifacts; -use crate::extract::{JrecToken, RecordingDeleteScope, RecordingsReadScope}; +use crate::extract::{JrecToken, RecordingAnalyzeScope, RecordingDeleteScope, RecordingsReadScope}; use crate::http::{HttpError, HttpErrorBuilder}; +use crate::provisioner_tasks::ai::{AiProvider, AiSettings}; +use crate::provisioner_tasks::recording_ai_analysis::{self, StartError, StartOutcome, StartRequest}; use crate::recording::{PushOutcome, RecordingMessageSender}; use crate::token::{JrecTokenClaims, RecordingFileType, RecordingOperation}; @@ -59,6 +64,7 @@ pub fn make_router(state: DgwState) -> Router { .route("/play", get(get_player)) .route("/play/{*path}", get(get_player)) .route("/shadow/{id}", get(shadow_recording)) + .route("/{session_id}/ai-analysis", post(start_ai_analysis)) .with_state(state) } @@ -426,6 +432,147 @@ async fn delete_recording(recording_path: &Utf8Path) -> anyhow::Result<()> { Ok(()) } +/// Settings of an AI analysis +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AiAnalysisRequest { + /// Task ID, chosen by the caller so it can send the same request again safely. + task_id: Uuid, + provider: AiProvider, + /// Model identifier, passed to the provider as is. + model: String, + /// Endpoint of the provider API, kept in memory only; required for `openai-compatible`. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde(default)] + base_url: Option, + /// Upper bound of tokens in each AI answer. + #[serde(default)] + max_output_tokens: Option, + /// API key of the provider, kept in memory only until the Task ends. + #[cfg_attr(feature = "openapi", schema(value_type = String, format = Password))] + api_key: SecretString, +} + +/// Why an AI analysis cannot start now +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisConflict { + code: AiAnalysisConflictCode, + /// The AI analysis of this session that is not finished yet; set for `analysis_in_progress`. + #[serde(skip_serializing_if = "Option::is_none")] + task_id: Option, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AiAnalysisConflictCode { + /// The Task ID is already used by a Task with other parameters. + TaskIdConflict, + /// The session is still recording. + RecordingActive, + /// Another AI analysis of this session is not finished yet. + AnalysisInProgress, +} + +fn ai_analysis_conflict(code: AiAnalysisConflictCode, task_id: Option) -> Response { + debug!(?code, task.id = ?task_id, "AI analysis refused"); + (StatusCode::CONFLICT, Json(AiAnalysisConflict { code, task_id })).into_response() +} + +/// Starts an AI analysis of a recorded session +/// +/// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a +/// new log, listed under `artifacts.ai-analysis` in its manifest. +/// The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`. +/// +/// Sending the same request again returns the same Task and starts nothing new. +/// The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished +/// Tasks. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "StartAiAnalysis", + tag = "Jrec", + path = "/jet/jrec/{session_id}/ai-analysis", + params( + ("session_id" = Uuid, Path, description = "Recorded session ID"), + ), + request_body(content = AiAnalysisRequest, description = "AI analysis settings", content_type = "application/json"), + responses( + (status = 200, description = "The same request came in before; nothing new starts", body = TaskInfo), + (status = 202, description = "The AI analysis Task is created and runs in the background", body = TaskInfo), + (status = 400, description = "Invalid request body or AI settings"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "The session has no recording"), + (status = 409, description = "The AI analysis cannot start now", body = AiAnalysisConflict), + (status = 500, description = "Unexpected server error"), + ), + security(("scope_token" = ["gateway.recording.analyze"])), +))] +pub(crate) async fn start_ai_analysis( + State(state): State, + _scope: RecordingAnalyzeScope, + extract::Path(session_id): extract::Path, + body: Bytes, +) -> Result { + let request: AiAnalysisRequest = serde_json::from_slice(&body).map_err(|error| { + // The serde message may quote a value of the body, such as a misplaced API key, so only its place is logged. + debug!( + category = ?error.classify(), + line = error.line(), + column = error.column(), + "Invalid AI analysis request body" + ); + HttpError::bad_request().msg("invalid AI analysis request body") + })?; + + let AiAnalysisRequest { + task_id, + provider, + model, + base_url, + max_output_tokens, + api_key, + } = request; + + let outcome = recording_ai_analysis::start( + &state, + StartRequest { + task_id, + session_id, + settings: AiSettings { + provider, + model, + base_url, + max_output_tokens, + }, + api_key, + }, + ) + .await; + + match outcome { + Ok(StartOutcome::Created(task)) => Ok((StatusCode::ACCEPTED, Json(TaskInfo::from(task))).into_response()), + Ok(StartOutcome::Existing(task)) => Ok((StatusCode::OK, Json(TaskInfo::from(task))).into_response()), + Ok(StartOutcome::IdConflict(_)) => Ok(ai_analysis_conflict(AiAnalysisConflictCode::TaskIdConflict, None)), + Ok(StartOutcome::TargetBusy { active_task_id }) => Ok(ai_analysis_conflict( + AiAnalysisConflictCode::AnalysisInProgress, + Some(active_task_id), + )), + Err(StartError::RecordingActive) => Ok(ai_analysis_conflict(AiAnalysisConflictCode::RecordingActive, None)), + Err(StartError::InvalidSettings(error)) => { + Err(HttpError::bad_request().with_msg("invalid AI settings").build(error)) + } + Err(StartError::RecordingNotFound) => Err(HttpError::not_found().msg("session has no recording")), + Err(StartError::Internal(error)) => Err(HttpError::internal() + .with_msg("failed to start the AI analysis") + .build(error)), + } +} + /// Lists all recordings stored on this instance #[cfg_attr(feature = "openapi", utoipa::path( get, @@ -1030,6 +1177,52 @@ mod tests { use super::*; + #[tokio::test] + async fn ai_analysis_of_a_session_still_recording_is_a_conflict() { + use axum::extract::connect_info::MockConnectInfo; + use base64::Engine as _; + use tower::ServiceExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let config = serde_json::json!({ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [{ "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" }], + "RecordingPath": dir.path().to_str().expect("UTF-8"), + "__debug__": { "disable_token_validation": true }, + }); + let (state, _handles) = DgwState::mock(&config.to_string()).await.expect("mock state"); + let session_id = Uuid::new_v4(); + state.recordings.active_recordings.insert(session_id); + let app = crate::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000)))); + + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let claims = serde_json::json!({ "jti": Uuid::new_v4(), "exp": i64::from(u32::MAX), "scope": "gateway.recording.analyze" }); + let token = format!( + "{}.{}.{}", + engine.encode(r#"{"alg":"RS256","cty":"SCOPE"}"#), + engine.encode(claims.to_string()), + engine.encode(b"signature"), + ); + let body = serde_json::json!({ "taskId": Uuid::new_v4(), "provider": "openai", "model": "gpt-test", "apiKey": "sk-test" }); + let request = axum::http::Request::builder() + .method("POST") + .uri(format!("/jet/jrec/{session_id}/ai-analysis")) + .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .expect("request"); + + let response = app.oneshot(request).await.expect("response"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = response.into_body().collect().await.expect("body").to_bytes(); + assert_eq!( + serde_json::from_slice::(&body).expect("JSON"), + serde_json::json!({ "code": "recording_active" }) + ); + } + #[test] fn rejects_unsafe_recording_file_names() { assert!(is_safe_recording_file_name("recording-0.webm")); diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index c7b222ebc..bdf54257d 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; @@ -28,6 +29,7 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { .nest("/jet/config", config::make_router(state.clone())) .nest("/jet/session", session::make_router(state.clone())) .nest("/jet/sessions", sessions::make_router(state.clone())) + .nest("/jet/tasks", tasks::make_router(state.clone())) .nest("/jet/diagnostics", diagnostics::make_router(state.clone())) .route("/jet/jmux", axum::routing::get(jmux::handler)) .route("/jet/rdp", axum::routing::get(rdp::handler)) diff --git a/devolutions-gateway/src/api/tasks.rs b/devolutions-gateway/src/api/tasks.rs new file mode 100644 index 000000000..8333bda5f --- /dev/null +++ b/devolutions-gateway/src/api/tasks.rs @@ -0,0 +1,130 @@ +use axum::extract::{self, State}; +use axum::routing::get; +use axum::{Json, Router}; +use provisioner_task::{ProvisionerTask, ProvisionerTaskState}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::DgwState; +use crate::extract::TasksReadScope; +use crate::http::HttpError; + +pub fn make_router(state: DgwState) -> Router { + Router::new().route("/{id}", get(get_task)).with_state(state) +} + +/// Gets a Task, the record of one piece of work the provisioner asked Gateway to do +/// +/// Tasks are kept forever, across Gateway restarts. +/// An unfinished Task past its deadline is reported as failed. +#[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 = "The Task", 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 has this ID"), + (status = 500, description = "Unexpected server error"), + ), + security(("scope_token" = ["gateway.tasks.read"])), +))] +pub(crate) async fn get_task( + State(DgwState { provisioner_tasks, .. }): State, + _scope: TasksReadScope, + extract::Path(id): extract::Path, +) -> Result, HttpError> { + let task = provisioner_tasks + .get(id, OffsetDateTime::now_utc()) + .await + .map_err(HttpError::internal().with_msg("failed to read the task").err())? + .ok_or_else(|| HttpError::not_found().msg("task not found"))?; + + Ok(Json(TaskInfo::from(task))) +} + +/// A Task: the permanent record of one piece of work the provisioner asked Gateway to do +/// +/// The work runs in the background; `state` and `payload` tell how it goes. +/// The shape of `params` and `payload` belongs to the kind. +/// For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an +/// `AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TaskInfo { + /// Task ID, chosen by the provisioner. + id: Uuid, + /// Kind of work, such as `recording.ai-analysis`. + kind: String, + /// What the work is about, such as a session ID. + target: String, + /// What was asked, without secrets. + #[cfg_attr(feature = "openapi", schema(value_type = Object))] + params: serde_json::Value, + state: TaskState, + /// Details of the current state; null while `queued`. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + payload: serde_json::Value, + /// Number of attempts started so far. + attempts: u32, + /// When the Task was created. + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + /// When its first attempt started. + #[serde(with = "time::serde::rfc3339::option")] + started_at: Option, + /// When it succeeded or failed. + #[serde(with = "time::serde::rfc3339::option")] + finished_at: Option, + /// When an unfinished Task becomes failed, waiting and retries included. + #[serde(with = "time::serde::rfc3339")] + deadline_at: OffsetDateTime, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum TaskState { + /// Waiting for its first attempt. + Queued, + /// An attempt runs, or the next one waits for a retry. + Running, + Succeeded, + Failed, +} + +impl From for TaskState { + fn from(state: ProvisionerTaskState) -> Self { + match state { + ProvisionerTaskState::Queued => Self::Queued, + ProvisionerTaskState::Running => Self::Running, + ProvisionerTaskState::Succeeded => Self::Succeeded, + ProvisionerTaskState::Failed => Self::Failed, + } + } +} + +impl From for TaskInfo { + fn from(task: ProvisionerTask) -> Self { + Self { + id: task.id, + kind: task.kind, + target: task.target, + params: task.params, + state: TaskState::from(task.state), + payload: task.payload, + attempts: task.attempts, + created_at: task.created_at, + started_at: task.started_at, + finished_at: task.finished_at, + deadline_at: task.deadline_at, + } + } +} diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index b22ee0461..f59b0ff40 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -321,6 +321,42 @@ where } } +#[derive(Clone, Copy)] +pub struct RecordingAnalyzeScope; + +impl FromRequestParts for RecordingAnalyzeScope +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::RecordingAnalyze => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + +#[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")), + } + } +} + #[derive(Clone, Copy)] pub struct UpdateScope; diff --git a/devolutions-gateway/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 8985f3184..7b9db2ec5 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; + +/// Runs of a job before the job queue gives up on it. +pub const 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(MAX_ATTEMPTS) .build(); let queue = Arc::new(queue); @@ -197,11 +204,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 +233,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,11 +288,14 @@ 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::provisioner_tasks::recording_ai_analysis::RecordingAiAnalysisJob; use crate::recording::RemuxJob; match name { @@ -296,6 +308,10 @@ impl JobReader for DgwJobReader { serde_json::from_str(json).context("failed to deserialize DeleteRecordingsJob")?; Ok(Box::new(job)) } + RecordingAiAnalysisJob::NAME => { + let job = RecordingAiAnalysisJob::read_json(json, self.state.clone())?; + Ok(Box::new(job)) + } _ => anyhow::bail!("unknown job name: {name}"), } } diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index cd53900b2..df438f3ec 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -30,6 +30,7 @@ pub mod log; pub mod middleware; pub mod ngrok; pub mod plugin_manager; +pub mod provisioner_tasks; pub mod provisioning; pub mod proxy; pub mod rd_clean_path; @@ -68,6 +69,8 @@ pub struct DgwState { pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, + pub provisioner_tasks: provisioner_task::DynProvisionerTaskStore, + pub ai_keys: provisioner_tasks::ai_key_store::AiKeyStore, } #[doc(hidden)] @@ -82,7 +85,7 @@ pub struct MockHandles { impl DgwState { #[doc(hidden)] - pub fn mock(json_config: &str) -> anyhow::Result<(Self, MockHandles)> { + pub async fn mock(json_config: &str) -> anyhow::Result<(Self, MockHandles)> { let conf_handle = config::ConfHandle::mock(json_config)?; let token_cache = Arc::new(token::new_token_cache()); let jrl = Arc::new(parking_lot::Mutex::new(token::JrlTokenClaims::default())); @@ -95,6 +98,10 @@ impl DgwState { let provisioning = provisioning::ProvisioningStore::new(); let synthetic_kdc_registry = credential_injection::SyntheticKdcRegistry::new(); let monitoring_state = Arc::new(network_monitor::State::new(Arc::new(MockMonitorsCache))?); + let provisioner_tasks = gateway_db::GatewayDb::open_path(":memory:").await?.connect().await?; + let provisioner_tasks = Arc::new(gateway_db::provisioner_task::LibSqlProvisionerTaskStore::new( + provisioner_tasks, + )); let state = Self { conf_handle, @@ -110,6 +117,8 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, + provisioner_tasks, + ai_keys: provisioner_tasks::ai_key_store::AiKeyStore::new(), }; let handles = MockHandles { diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 5052f6704..833d9450c 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -24,6 +24,8 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::jrec::list_recordings, crate::api::jrec::pull_recording_session, crate::api::jrec::pull_recording_file, + crate::api::jrec::start_ai_analysis, + crate::api::tasks::get_task, crate::api::webapp::sign_app_token, crate::api::webapp::sign_session_token, crate::api::update::get_update_products, @@ -58,6 +60,16 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; ConfigPatch, crate::api::jrl::JrlInfo, crate::api::jrec::DeleteManyResult, + crate::api::jrec::AiAnalysisRequest, + crate::api::jrec::AiAnalysisConflict, + crate::api::jrec::AiAnalysisConflictCode, + crate::provisioner_tasks::ai::AiProvider, + crate::api::tasks::TaskInfo, + crate::api::tasks::TaskState, + AiAnalysisRunningPayload, + AiAnalysisSucceededPayload, + AiAnalysisUsage, + AiAnalysisFailedPayload, crate::token::AccessScope, crate::api::webapp::AppTokenSignRequest, crate::api::webapp::AppTokenContentType, @@ -136,6 +148,56 @@ struct SessionInfo { destination_host: Option, } +/// Payload of a `running` AI analysis Task: its progress +#[allow(dead_code)] +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +struct AiAnalysisRunningPayload { + /// Current step, such as `preparing` or `describing`. + step: String, + /// Number of transcript chunks described so far; set while `describing`. + done: Option, + /// Number of transcript chunks to describe; set while `describing`. + total: Option, +} + +/// Payload of a `succeeded` AI analysis Task: the log it added to the session +#[allow(dead_code)] +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +struct AiAnalysisSucceededPayload { + /// Name of the new log in the session manifest, such as `ai-analysis-0.slog`. + file_name: String, + /// Model that wrote the log, as reported by the AI provider; the requested model when it reports none. + model: String, + /// Tokens counted by the AI provider; absent when it did not report them for every request. + usage: Option, + /// Version of the instructions sent to the AI provider. + prompt_version: String, +} + +/// Tokens counted by an AI provider +#[allow(dead_code)] +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +struct AiAnalysisUsage { + input_tokens: u64, + output_tokens: u64, +} + +/// Payload of a `failed` AI analysis Task: why it failed +#[allow(dead_code)] +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +struct AiAnalysisFailedPayload { + /// Kind of failure, such as `permanent error`, `attempts exhausted`, `key lost` or `timed out`. + reason: String, + /// What went wrong: a text, except for `timed out`, where it is `{ "lastPayload": }`. + details: serde_json::Value, + /// Number of attempts started. + attempts: u32, +} + #[allow(unused)] #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "kebab-case")] diff --git a/devolutions-gateway/src/provisioner_tasks/ai.rs b/devolutions-gateway/src/provisioner_tasks/ai.rs new file mode 100644 index 000000000..6a408e92c --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/ai.rs @@ -0,0 +1,215 @@ +//! AI settings shared by the Task kinds that call an AI provider. +//! +//! The API key and the base URL never reach `gateway.db`: they stay in memory, in the +//! [`AiKeyStore`](super::ai_key_store::AiKeyStore), until the Task ends. + +use core::fmt; + +use devolutions_gateway_ai::recording_analysis::RecordingAnalysisError; +use devolutions_gateway_ai::{AiClient, BuildError, Provider}; +use secrecy::SecretString; +use url::Url; + +use super::AttemptError; +use crate::config::Conf; + +/// AI provider to ask; `openai-compatible` needs a base URL +#[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 = "gemini")] + Gemini, + #[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::Gemini => Provider::Gemini, + AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, + } + } +} + +/// How to reach an AI provider, except the API key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AiSettings { + pub provider: AiProvider, + pub model: String, + /// Required for [`AiProvider::OpenAiCompatible`]; the others have a default. + pub base_url: Option, + /// Upper bound of tokens in each AI answer. + pub max_output_tokens: Option, +} + +/// Why AI settings were refused before a Task was recorded. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AiSettingsError { + #[error("AI model is missing")] + MissingModel, + #[error("API key is missing")] + MissingApiKey, + #[error("base URL is missing for this AI provider")] + MissingBaseUrl, + #[error("invalid AI settings: {0}")] + Invalid(String), +} + +impl From<&BuildError> for AiSettingsError { + fn from(error: &BuildError) -> Self { + match error { + BuildError::MissingModel => Self::MissingModel, + BuildError::MissingApiKey(_) => Self::MissingApiKey, + BuildError::MissingBaseUrl(_) => Self::MissingBaseUrl, + _ => Self::Invalid(error.to_string()), + } + } +} + +pub(crate) enum ClientError { + Build(BuildError), + HttpClient(reqwest::Error), +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClientError::Build(error) => fmt::Display::fmt(error, f), + ClientError::HttpClient(error) => write!(f, "failed to build the HTTP client: {error}"), + } + } +} + +impl AiSettings { + /// Builds a client going through the proxy configured for Gateway. + pub(crate) fn client(&self, conf: &Conf, api_key: &SecretString) -> 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 = 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| ClientError::HttpClient(error.without_url()))?; + + builder = builder.http_client(http_client); + } + + builder.build().map_err(ClientError::Build) + } +} + +/// Retries only the failures that may pass later, such as a rate limit or a network error. +impl From for AttemptError { + fn from(error: RecordingAnalysisError) -> Self { + match error { + RecordingAnalysisError::Cancelled => AttemptError::Cancelled, + error if error.is_transient() => AttemptError::Transient(error.to_string()), + error => AttemptError::Permanent(error.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use devolutions_gateway_ai::Error; + + use super::*; + + #[test] + fn build_errors_map_to_settings_errors() { + assert_eq!( + AiSettingsError::from(&BuildError::MissingModel), + AiSettingsError::MissingModel + ); + assert_eq!( + AiSettingsError::from(&BuildError::MissingApiKey(Provider::OpenAi)), + AiSettingsError::MissingApiKey + ); + assert_eq!( + AiSettingsError::from(&BuildError::MissingBaseUrl(Provider::OpenAiCompatible)), + AiSettingsError::MissingBaseUrl + ); + assert!(matches!( + AiSettingsError::from(&BuildError::InvalidApiKey), + AiSettingsError::Invalid(_) + )); + } + + #[test] + fn rate_limits_server_and_network_errors_are_transient() { + let attempt = |error: Error| AttemptError::from(RecordingAnalysisError::Ai(error)); + let status = |status| Error::Status { + status, + message: "failed".to_owned(), + code: None, + retry_after: None, + }; + + let network = Error::Transport { + message: "connection refused".to_owned(), + }; + assert!(matches!(attempt(network), AttemptError::Transient(_))); + + for code in [429, 500, 503] { + assert!(matches!(attempt(status(code)), AttemptError::Transient(_)), "{code}"); + } + + for code in [400, 401, 403, 404] { + assert!(matches!(attempt(status(code)), AttemptError::Permanent(_)), "{code}"); + } + + let invalid = Error::InvalidOutput { + reason: "no valid action line".to_owned(), + }; + assert!(matches!(attempt(invalid), AttemptError::Permanent(_))); + + assert!(matches!( + AttemptError::from(RecordingAnalysisError::Truncated), + AttemptError::Permanent(_) + )); + assert_eq!( + AttemptError::from(RecordingAnalysisError::Cancelled), + AttemptError::Cancelled + ); + } + + #[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 + ); + } + } +} diff --git a/devolutions-gateway/src/provisioner_tasks/ai_key_store.rs b/devolutions-gateway/src/provisioner_tasks/ai_key_store.rs new file mode 100644 index 000000000..20bb288af --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/ai_key_store.rs @@ -0,0 +1,193 @@ +//! API keys of the Tasks that call an AI provider, kept in memory only. +//! +//! Nobody wants an API key sitting on disk, so the key lives here from the moment its Task is created until the Task +//! ends, and never longer than the Task deadline. A restart forgets every key, so the Tasks still waiting fail instead +//! of running without one. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use devolutions_gateway_task::{ShutdownSignal, Task}; +use parking_lot::Mutex; +use secrecy::SecretString; +use time::OffsetDateTime; +use tokio::sync::Notify; +use url::Url; +use uuid::Uuid; + +/// What a Task needs to reach its AI provider and must never write to disk. +/// +/// The key is wiped from memory when the last copy is dropped. +#[derive(Debug, Clone)] +pub struct AiAccess { + pub api_key: SecretString, + pub base_url: Option, +} + +#[derive(Debug)] +struct Entry { + access: AiAccess, + expires_at: OffsetDateTime, +} + +/// Task ID → [`AiAccess`], each entry dropped when its Task ends or at its deadline. +#[derive(Debug, Clone, Default)] +pub struct AiKeyStore { + entries: Arc>>, + cleanup_notify: Arc, +} + +impl AiKeyStore { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, task_id: Uuid, access: AiAccess, expires_at: OffsetDateTime) { + self.entries.lock().insert(task_id, Entry { access, expires_at }); + self.cleanup_notify.notify_one(); + } + + /// Returns `None` once the entry is removed or past its expiry. + pub fn get(&self, task_id: Uuid, now: OffsetDateTime) -> Option { + let mut entries = self.entries.lock(); + let entry = entries.get(&task_id)?; + + if now >= entry.expires_at { + entries.remove(&task_id); + return None; + } + + Some(entry.access.clone()) + } + + pub fn remove(&self, task_id: Uuid) { + self.entries.lock().remove(&task_id); + } + + fn remove_expired(&self, now: OffsetDateTime) { + self.entries.lock().retain(|_, entry| now < entry.expires_at); + } + + fn next_expiry(&self) -> Option { + self.entries.lock().values().map(|entry| entry.expires_at).min() + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.lock().len() + } +} + +pub struct CleanupTask { + pub handle: AiKeyStore, +} + +#[async_trait] +impl Task for CleanupTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "AI key store cleanup"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { + cleanup_task(self.handle, shutdown_signal).await; + Ok(()) + } +} + +#[instrument(skip_all)] +async fn cleanup_task(handle: AiKeyStore, mut shutdown_signal: ShutdownSignal) { + debug!("Task started"); + + loop { + let now = OffsetDateTime::now_utc(); + handle.remove_expired(now); + + match handle.next_expiry() { + Some(deadline) => { + let delay = (deadline - now).try_into().unwrap_or_default(); + tokio::select! { + () = tokio::time::sleep(delay) => {} + () = handle.cleanup_notify.notified() => {} + () = shutdown_signal.wait() => break, + } + } + None => { + tokio::select! { + () = handle.cleanup_notify.notified() => {} + () = shutdown_signal.wait() => break, + } + } + } + } + + debug!("Task terminated"); +} + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret as _; + use time::Duration; + + use super::*; + + fn access(key: &str) -> AiAccess { + AiAccess { + api_key: SecretString::from(key), + base_url: None, + } + } + + fn now() -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("valid timestamp") + } + + #[test] + fn key_is_kept_until_removed() { + let store = AiKeyStore::new(); + let task_id = Uuid::new_v4(); + store.insert(task_id, access("sk-1"), now() + Duration::hours(2)); + + let found = store.get(task_id, now()).expect("key is there"); + assert_eq!(found.api_key.expose_secret(), "sk-1"); + + store.remove(task_id); + assert!(store.get(task_id, now()).is_none()); + } + + #[test] + fn key_expires_at_the_task_deadline() { + let store = AiKeyStore::new(); + let task_id = Uuid::new_v4(); + let deadline = now() + Duration::hours(2); + store.insert(task_id, access("sk-1"), deadline); + + assert!(store.get(task_id, deadline - Duration::seconds(1)).is_some()); + assert!(store.get(task_id, deadline).is_none()); + assert_eq!(store.len(), 0, "an expired key is dropped when read"); + } + + #[test] + fn cleanup_drops_expired_keys_only() { + let store = AiKeyStore::new(); + let expired = Uuid::new_v4(); + let live = Uuid::new_v4(); + store.insert(expired, access("sk-1"), now() - Duration::seconds(1)); + store.insert(live, access("sk-2"), now() + Duration::hours(1)); + + assert_eq!(store.next_expiry(), Some(now() - Duration::seconds(1))); + store.remove_expired(now()); + + assert_eq!(store.len(), 1); + assert!(store.get(live, now()).is_some()); + assert_eq!(store.next_expiry(), Some(now() + Duration::hours(1))); + } + + #[test] + fn debug_output_hides_the_key() { + let store = AiKeyStore::new(); + store.insert(Uuid::new_v4(), access("sk-secret-value"), now()); + + assert!(!format!("{store:?}").contains("sk-secret-value")); + } +} diff --git a/devolutions-gateway/src/provisioner_tasks/mod.rs b/devolutions-gateway/src/provisioner_tasks/mod.rs new file mode 100644 index 000000000..7511a024c --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/mod.rs @@ -0,0 +1,17 @@ +//! Work the provisioner asks Gateway to do: each request is a Task recorded in `gateway.db`, run as a job on the job +//! queue. + +pub mod ai; +pub mod ai_key_store; +pub mod recording_ai_analysis; + +/// Why one attempt of a Task failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AttemptError { + /// Worth another attempt later, such as a rate limit or a network error. + Transient(String), + /// Another attempt would fail the same way. + Permanent(String), + /// The attempt was cancelled before its end, such as when it ran out of time. + Cancelled, +} diff --git a/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/mod.rs b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/mod.rs new file mode 100644 index 000000000..97ec37611 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/mod.rs @@ -0,0 +1,607 @@ +//! AI analysis (`recording.ai-analysis`): describes what the user did in one recorded session, and adds the result to +//! the session as a new log. +//! +//! [`start`] records the Task and queues its job. The job runs the analysis of the AI crate, +//! [`AiClient::analyze_recording`](devolutions_gateway_ai::AiClient::analyze_recording), which keeps its working files +//! next to the session manifest so that a retry resumes where the last attempt stopped, then adds the log to the +//! session. The working files go away when the Task ends. + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Weak}; + +use anyhow::Context as _; +use async_trait::async_trait; +use camino::{Utf8Path, Utf8PathBuf}; +use devolutions_gateway_ai::Usage; +use devolutions_gateway_ai::recording_analysis::{self, Progress, RecordingFile, RecordingManifest}; +use devolutions_gateway_ai::session_actions::PROMPT_VERSION; +use provisioner_task::{CreateOutcome, NewProvisionerTask, ProvisionerTask}; +use secrecy::SecretString; +use serde_json::json; +use time::OffsetDateTime; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::AttemptError; +use super::ai::{AiProvider, AiSettings, AiSettingsError, ClientError}; +use super::ai_key_store::AiAccess; +use crate::DgwState; +use crate::artifacts::ArtifactKind; +use crate::recording::{FinishedRecordingError, JrecManifest, RecordingMessageSender}; + +pub const KIND: &str = "recording.ai-analysis"; + +/// Time an AI analysis gets from its creation to its end, waiting and retries included. +pub const TASK_DEADLINE: time::Duration = time::Duration::hours(2); + +/// Time one attempt of an AI analysis gets. +pub const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60); + +const KEY_LOST: &str = "the API key is no longer available, such as after a Gateway restart"; + +/// Tokens counted by the AI provider, as kept in Task results. +/// +/// It mirrors [`Usage`], so a change in the AI crate never changes what we store. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct TokenUsage { + input_tokens: u64, + output_tokens: u64, +} + +impl From for TokenUsage { + fn from(usage: Usage) -> Self { + Self { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + } + } +} + +/// What a caller asks for when it starts an AI analysis. +#[derive(Debug)] +pub struct StartRequest { + /// Chosen by the caller, so it can ask again safely. + pub task_id: Uuid, + pub session_id: Uuid, + pub settings: AiSettings, + pub api_key: SecretString, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum StartOutcome { + /// A new Task is recorded and its job is queued. + Created(ProvisionerTask), + /// The same request came in before; nothing new runs. + Existing(ProvisionerTask), + /// The Task ID is already used for something else. + IdConflict(ProvisionerTask), + /// Another AI analysis of this session is not finished yet. + TargetBusy { active_task_id: Uuid }, +} + +#[derive(Debug, thiserror::Error)] +pub enum StartError { + #[error(transparent)] + InvalidSettings(AiSettingsError), + /// The session is still recording, so there is nothing complete to analyse yet. + #[error("session is still recording")] + RecordingActive, + /// The session has no recording to analyse. + #[error("session has no recording")] + RecordingNotFound, + #[error(transparent)] + Internal(anyhow::Error), +} + +/// What an AI analysis Task records as its parameters: the AI settings without the API key and the base URL. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Params { + provider: AiProvider, + model: String, + max_output_tokens: Option, +} + +/// Result payload of a successful AI analysis. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Analysis { + /// Name of the new log in the session manifest, such as `ai-analysis-0.slog`. + file_name: String, + /// Model that wrote the log, as reported by the AI provider; the requested model when it reports none. + model: String, + /// Tokens the AI provider counted for the log, answers cut and asked again included. + /// + /// Absent when the provider did not report them for every request. + #[serde(skip_serializing_if = "Option::is_none")] + usage: Option, + prompt_version: &'static str, +} + +/// Records an AI analysis Task and queues the job that runs it. +/// +/// Only a new Task keeps the API key and queues a job; asking again for a known Task changes nothing, even while its +/// session is recording again. +pub async fn start(state: &DgwState, request: StartRequest) -> Result { + let StartRequest { + task_id, + session_id, + settings, + api_key, + } = request; + + let conf = state.conf_handle.get_conf(); + + match settings.client(&conf, &api_key) { + Ok(_) => {} + Err(ClientError::Build(error)) => { + debug!(%error, "Invalid AI settings"); + return Err(StartError::InvalidSettings(AiSettingsError::from(&error))); + } + Err(ClientError::HttpClient(error)) => { + return Err(StartError::Internal( + anyhow::Error::new(error).context("build the HTTP client for the AI provider"), + )); + } + } + + let params = Params { + provider: settings.provider, + model: settings.model, + max_output_tokens: settings.max_output_tokens, + }; + let params = serde_json::to_value(params) + .context("serialize the AI analysis parameters") + .map_err(StartError::Internal)?; + + let now = OffsetDateTime::now_utc(); + let target = session_id.to_string(); + + // A repeated request gets its Task back whatever its session does now, so the ID is looked up first. + let known = state + .provisioner_tasks + .get(task_id, now) + .await + .map_err(StartError::Internal)?; + + if let Some(task) = known { + let same_request = task.kind == KIND && task.target == target && task.params == params; + return Ok(if same_request { + StartOutcome::Existing(task) + } else { + StartOutcome::IdConflict(task) + }); + } + + if state.recordings.active_recordings.contains(session_id) { + return Err(StartError::RecordingActive); + } + + match state.recordings.get_finished(session_id).await { + Ok(_) => {} + Err(FinishedRecordingError::Recording) => return Err(StartError::RecordingActive), + Err(FinishedRecordingError::NotFound) => return Err(StartError::RecordingNotFound), + Err(FinishedRecordingError::Other(error)) => { + return Err(StartError::Internal(error.context("read the recording"))); + } + } + + let outcome = state + .provisioner_tasks + .create( + NewProvisionerTask { + id: task_id, + kind: KIND.to_owned(), + target, + params, + deadline_at: now + TASK_DEADLINE, + }, + now, + ) + .await + .map_err(StartError::Internal)?; + + let task = match outcome { + CreateOutcome::Created(task) => task, + CreateOutcome::Existing(task) => return Ok(StartOutcome::Existing(task)), + CreateOutcome::IdConflict(task) => return Ok(StartOutcome::IdConflict(task)), + CreateOutcome::TargetBusy(active) => { + return Ok(StartOutcome::TargetBusy { + active_task_id: active.id, + }); + } + }; + + // The job may run as soon as it is queued, so the key must be in place first. + state.ai_keys.insert( + task_id, + AiAccess { + api_key, + base_url: settings.base_url, + }, + task.deadline_at, + ); + + if let Err(error) = state + .job_queue_handle + .enqueue(RecordingAiAnalysisJob::new(task_id, state.clone())) + .await + { + error!(task.id = %task_id, error = format!("{error:#}"), "Failed to queue the AI analysis"); + fail( + state, + task_id, + Some(session_id), + failure("not queued", &format!("{error:#}"), 0), + ) + .await; + return Err(StartError::Internal(error)); + } + + info!(task.id = %task_id, session.id = %session_id, "AI analysis queued"); + + Ok(StartOutcome::Created(task)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct JobDef { + task_id: Uuid, +} + +/// Runs one attempt of an AI analysis Task; the job queue runs it again while it asks for a retry. +pub struct RecordingAiAnalysisJob { + task_id: Uuid, + state: DgwState, + attempt_timeout: std::time::Duration, +} + +impl RecordingAiAnalysisJob { + pub const NAME: &'static str = "recording-ai-analysis"; + + pub fn new(task_id: Uuid, state: DgwState) -> Self { + Self { + task_id, + state, + attempt_timeout: ATTEMPT_TIMEOUT, + } + } + + pub fn read_json(json: &str, state: DgwState) -> anyhow::Result { + let def: JobDef = serde_json::from_str(json).context("failed to deserialize RecordingAiAnalysisJob")?; + Ok(Self::new(def.task_id, state)) + } +} + +#[async_trait] +impl job_queue::Job for RecordingAiAnalysisJob { + fn name(&self) -> &str { + Self::NAME + } + + fn write_json(&self) -> anyhow::Result { + serde_json::to_string(&JobDef { task_id: self.task_id }).context("failed to serialize RecordingAiAnalysisJob") + } + + async fn run(&mut self) -> anyhow::Result<()> { + run_attempt(&self.state, self.task_id, self.attempt_timeout).await + } +} + +/// Returns an error only to ask the job queue for another attempt. +async fn run_attempt(state: &DgwState, task_id: Uuid, attempt_timeout: std::time::Duration) -> anyhow::Result<()> { + let tasks = &state.provisioner_tasks; + let now = OffsetDateTime::now_utc(); + + let started = tasks + .start_attempt(task_id, json!({ "step": "preparing" }), now) + .await?; + + // A finished Task is still read: its session tells which working files to remove. + let Some(task) = tasks.get(task_id, now).await? else { + debug!(task.id = %task_id, "AI analysis Task is unknown, so this attempt does not run"); + end(state, task_id, None).await; + return Ok(()); + }; + + let attempts = task.attempts; + + let (session_id, params) = match parse_task(&task) { + Ok(parsed) => parsed, + Err(error) => { + error!(task.id = %task_id, error = format!("{error:#}"), "Invalid AI analysis Task"); + fail( + state, + task_id, + None, + failure("invalid task", &format!("{error:#}"), attempts), + ) + .await; + return Ok(()); + } + }; + + if !started { + debug!(task.id = %task_id, "AI analysis is over, so this attempt does not run"); + end(state, task_id, Some(session_id)).await; + return Ok(()); + } + + let Some(access) = state.ai_keys.get(task_id, now) else { + warn!(task.id = %task_id, attempts, "AI analysis lost its API key"); + fail( + state, + task_id, + Some(session_id), + failure("key lost", KEY_LOST, attempts), + ) + .await; + return Ok(()); + }; + + let settings = AiSettings { + provider: params.provider, + model: params.model, + base_url: access.base_url, + max_output_tokens: params.max_output_tokens, + }; + + info!(task.id = %task_id, session.id = %session_id, attempts, "AI analysis attempt started"); + + let cancel = CancellationToken::new(); + + // The attempt has its own Tokio task, so a panic fails the Task instead of leaving it running. + let mut handle = tokio::spawn(analyze( + state.clone(), + task_id, + session_id, + settings, + access.api_key, + cancel.clone(), + )); + + let joined = match tokio::time::timeout(attempt_timeout, &mut handle).await { + Ok(joined) => joined, + Err(_) => { + // Aborting cannot stop its blocking steps, so the attempt is cancelled and awaited instead: the next + // attempt never shares the working files with it. + cancel.cancel(); + handle.await + } + }; + + let outcome = match joined { + Ok(Err(AttemptError::Cancelled)) => Err(AttemptError::Transient(format!( + "the attempt took longer than {attempt_timeout:?}" + ))), + Ok(outcome) => outcome, + Err(_) => Err(AttemptError::Permanent("the attempt panicked".to_owned())), + }; + + match outcome { + Ok(analysis) => { + // An error here must not ask for a retry: the log is already added to the session. + match tasks.succeed(task_id, json!(analysis), OffsetDateTime::now_utc()).await { + Ok(true) => info!( + task.id = %task_id, + session.id = %session_id, + attempts, + file = %analysis.file_name, + model = %analysis.model, + usage = ?analysis.usage, + "AI analysis succeeded" + ), + Ok(false) => warn!( + task.id = %task_id, + session.id = %session_id, + file = %analysis.file_name, + "AI analysis added its log after its Task had ended, such as past its deadline; the Task keeps its earlier end" + ), + Err(error) => error!( + task.id = %task_id, + file = %analysis.file_name, + error = format!("{error:#}"), + "Failed to record the AI analysis result" + ), + } + + end(state, task_id, Some(session_id)).await; + } + Err(AttemptError::Transient(details)) if attempts < crate::job_queue::MAX_ATTEMPTS => { + warn!(task.id = %task_id, attempts, %details, "AI analysis attempt failed; it will be retried"); + anyhow::bail!("AI analysis attempt failed: {details}"); + } + Err(AttemptError::Transient(details)) => { + warn!(task.id = %task_id, attempts, %details, "AI analysis failed after its last attempt"); + fail( + state, + task_id, + Some(session_id), + failure("attempts exhausted", &details, attempts), + ) + .await; + } + Err(AttemptError::Permanent(details)) => { + warn!(task.id = %task_id, attempts, %details, "AI analysis failed"); + fail( + state, + task_id, + Some(session_id), + failure("permanent error", &details, attempts), + ) + .await; + } + Err(AttemptError::Cancelled) => unreachable!("a cancelled attempt is reported as a timeout above"), + } + + Ok(()) +} + +fn parse_task(task: &ProvisionerTask) -> anyhow::Result<(Uuid, Params)> { + let session_id = Uuid::parse_str(&task.target).context("invalid session ID")?; + let params = serde_json::from_value(task.params.clone()).context("invalid parameters")?; + Ok((session_id, params)) +} + +fn failure(reason: &str, details: &str, attempts: u32) -> serde_json::Value { + json!({ "reason": reason, "details": details, "attempts": attempts }) +} + +async fn fail(state: &DgwState, task_id: Uuid, session_id: Option, payload: serde_json::Value) { + if let Err(error) = state + .provisioner_tasks + .fail(task_id, payload, OffsetDateTime::now_utc()) + .await + { + error!(task.id = %task_id, error = format!("{error:#}"), "Failed to record the AI analysis failure"); + } + + end(state, task_id, session_id).await; +} + +/// Drops what an AI analysis keeps only while it runs: its API key and the working files of its session. +async fn end(state: &DgwState, task_id: Uuid, session_id: Option) { + state.ai_keys.remove(task_id); + + let Some(session_id) = session_id else { + return; + }; + + let manifest_path = manifest_path(state, session_id); + let _session = lock_session(session_id).await; + + if let Err(error) = recording_analysis::discard(&manifest_path).await { + warn!(task.id = %task_id, session.id = %session_id, %error, "Failed to remove the AI analysis working files"); + } +} + +fn manifest_path(state: &DgwState, session_id: Uuid) -> Utf8PathBuf { + state + .conf_handle + .get_conf() + .recording_path + .join(session_id.to_string()) + .join("recording.json") +} + +/// Waits until no other AI analysis of the session runs. +/// +/// The working files belong to the session, and a Task that ended past its deadline may still run an attempt while +/// another Task of the session starts. +async fn lock_session(session_id: Uuid) -> tokio::sync::OwnedMutexGuard<()> { + static LOCKS: LazyLock>>>> = + LazyLock::new(Default::default); + + let lock = { + let mut locks = LOCKS.lock(); + locks.retain(|_, lock| lock.strong_count() > 0); + + match locks.get(&session_id).and_then(Weak::upgrade) { + Some(lock) => lock, + None => { + let lock = Arc::new(tokio::sync::Mutex::new(())); + locks.insert(session_id, Arc::downgrade(&lock)); + lock + } + } + }; + + lock.lock_owned().await +} + +async fn report_progress(state: &DgwState, task_id: Uuid, progress: Progress) { + let payload = json!({ + "step": "describing", + "done": progress.described_chunks, + "total": progress.total_chunks, + }); + + if let Err(error) = state.provisioner_tasks.update_running(task_id, payload).await { + warn!(task.id = %task_id, error = format!("{error:#}"), "Failed to record the AI analysis progress"); + } +} + +/// What the AI crate needs from the session manifest. +fn analysis_manifest(manifest: &JrecManifest) -> RecordingManifest { + RecordingManifest { + start_time: manifest.start_time(), + duration: manifest.duration(), + files: manifest + .files() + .iter() + .map(|file| RecordingFile { + file_name: file.file_name().to_owned(), + start_time: file.start_time(), + }) + .collect(), + } +} + +async fn analyze( + state: DgwState, + task_id: Uuid, + session_id: Uuid, + settings: AiSettings, + api_key: SecretString, + cancel: CancellationToken, +) -> Result { + let conf = state.conf_handle.get_conf(); + + let client = settings + .client(&conf, &api_key) + .map_err(|error| AttemptError::Permanent(error.to_string()))?; + + let _session = lock_session(session_id).await; + + let recording = state + .recordings + .get_finished(session_id) + .await + .map_err(|error| AttemptError::Permanent(format!("{error:#}")))?; + + let manifest = analysis_manifest(&recording.manifest); + let manifest_path = recording.manifest_path(); + + let mut request = client + .analyze_recording(&manifest, &manifest_path) + .cancellation(cancel) + .on_progress(|progress| report_progress(&state, task_id, progress)); + + if let Some(max_output_tokens) = settings.max_output_tokens { + request = request.max_output_tokens(max_output_tokens); + } + + let analysis = request.send().await?; + + let file_name = add_log(&state.recordings, session_id, &analysis.log_path) + .await + .map_err(|error| AttemptError::Permanent(format!("failed to add the log: {error:#}")))?; + + debug!(task.id = %task_id, file = %file_name, actions = analysis.actions, "Session log added"); + + Ok(Analysis { + file_name, + model: analysis.model, + usage: analysis.usage.map(TokenUsage::from), + prompt_version: PROMPT_VERSION, + }) +} + +/// Adds the complete log the analysis left next to the manifest to the session. +async fn add_log(recordings: &RecordingMessageSender, session_id: Uuid, log_path: &Utf8Path) -> anyhow::Result { + let artifact_path = recordings.add_artifact(session_id, ArtifactKind::AiAnalysis).await?; + + // The log is in the session folder, so a rename moves it in at once and the session never holds a partial one. + tokio::fs::rename(log_path, &artifact_path) + .await + .with_context(|| format!("move the log to {artifact_path}"))?; + + Ok(artifact_path + .file_name() + .context("artifact path has no file name")? + .to_owned()) +} diff --git a/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/tests.rs b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/tests.rs new file mode 100644 index 000000000..a53b4802c --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/tests.rs @@ -0,0 +1,796 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::http::StatusCode; +use camino::Utf8Path; +use devolutions_gateway_task::ShutdownHandle; +use job_queue::Job as _; +use parking_lot::Mutex; +use provisioner_task::ProvisionerTaskState; + +use super::*; +use crate::job_queue::{JobMessage, JobQueueReceiver, MAX_ATTEMPTS}; +use crate::recording::RecordingManagerTask; +use crate::{DgwState, MockHandles}; + +const API_KEY: &str = "sk-recording-ai-analysis-test-secret"; + +fn config(recording_path: &Utf8Path) -> 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" }, + "RecordingPath": recording_path, + }) + .to_string() +} + +fn openai(model: &str) -> AiSettings { + AiSettings { + provider: AiProvider::OpenAi, + model: model.to_owned(), + base_url: None, + max_output_tokens: None, + } +} + +struct Harness { + state: DgwState, + job_queue_rx: JobQueueReceiver, + recording_path: Utf8PathBuf, + _keep: Box, + _dir: tempfile::TempDir, +} + +impl Harness { + async fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let recording_path = Utf8PathBuf::from_path_buf(dir.path().join("recordings")).expect("UTF-8"); + + let (state, handles) = DgwState::mock(&config(&recording_path)).await.expect("mock state"); + let MockHandles { + session_manager_rx, + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + shutdown_handle, + } = handles; + + let recording_manager = RecordingManagerTask::new( + recording_manager_rx, + recording_path.clone(), + state.sessions.clone(), + state.job_queue_handle.clone(), + ); + let (recording_shutdown_handle, recording_shutdown_signal) = ShutdownHandle::new(); + tokio::spawn(devolutions_gateway_task::Task::run( + recording_manager, + recording_shutdown_signal, + )); + + Self { + state, + job_queue_rx, + recording_path, + _keep: Box::new(( + session_manager_rx, + subscriber_rx, + traffic_audit_rx, + shutdown_handle, + recording_shutdown_handle, + )), + _dir: dir, + } + } + + async fn start(&self, task_id: Uuid, session_id: Uuid, settings: AiSettings) -> Result { + start( + &self.state, + StartRequest { + task_id, + session_id, + settings, + api_key: SecretString::from(API_KEY), + }, + ) + .await + } + + /// Starts an AI analysis of `session_id` and returns its Task ID. + async fn start_created(&self, session_id: Uuid, settings: AiSettings) -> Uuid { + let task_id = Uuid::new_v4(); + let outcome = self.start(task_id, session_id, settings).await.expect("started"); + assert!(matches!(outcome, StartOutcome::Created(_)), "{outcome:?}"); + task_id + } + + async fn run(&self, task_id: Uuid) -> anyhow::Result<()> { + RecordingAiAnalysisJob::new(task_id, self.state.clone()).run().await + } + + async fn task(&self, task_id: Uuid) -> ProvisionerTask { + self.state + .provisioner_tasks + .get(task_id, OffsetDateTime::now_utc()) + .await + .expect("read task") + .expect("task exists") + } + + /// Working files of the AI analyses of session_id. + fn workspace(&self, session_id: Uuid) -> Utf8PathBuf { + self.recording_path.join(session_id.to_string()).join(".ai-analysis") + } + + fn has_key(&self, task_id: Uuid) -> bool { + self.state.ai_keys.get(task_id, OffsetDateTime::now_utc()).is_some() + } + + /// Writes a finished session with a short terminal recording. + fn write_session(&self) -> Uuid { + self.write_long_session(10) + } + + /// Writes a finished session whose terminal output is `lines` lines of about 100 bytes each. + fn write_long_session(&self, lines: usize) -> Uuid { + let session_id = Uuid::new_v4(); + let dir = self.recording_path.join(session_id.to_string()); + std::fs::create_dir_all(&dir).expect("session dir"); + + let manifest = json!({ + "sessionId": session_id, + "startTime": 1_787_255_035, + "duration": lines, + "files": [{ "fileName": "recording-0.cast", "startTime": 1_787_255_035, "duration": lines }], + }); + std::fs::write(dir.join("recording.json"), manifest.to_string()).expect("manifest"); + + let mut cast = String::from("{\"version\": 2}\n"); + for line in 0..lines { + cast.push_str(&format!("[{line}.0,\"o\",\"line {line} {}\\r\\n\"]\n", "x".repeat(80))); + } + std::fs::write(dir.join("recording-0.cast"), cast).expect("cast"); + + session_id + } +} + +/// A mock OpenAI-compatible provider: `status` picks the answer of the n-th request, 0 being a valid answer. +async fn spawn_ai_provider( + status: impl Fn(usize) -> u16 + Send + Sync + 'static, +) -> (AiSettings, Arc>>) { + spawn_ai_provider_after(Duration::ZERO, status).await +} + +/// Like [`spawn_ai_provider`], answering each request after `delay`. +async fn spawn_ai_provider_after( + delay: Duration, + status: impl Fn(usize) -> u16 + Send + Sync + 'static, +) -> (AiSettings, Arc>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let status = Arc::new(status); + + let app = axum::Router::new().fallback(axum::routing::post({ + let requests = Arc::clone(&requests); + move |body: String| { + let requests = Arc::clone(&requests); + let status = Arc::clone(&status); + async move { + tokio::time::sleep(delay).await; + + let index = { + let mut requests = requests.lock(); + requests.push(body); + requests.len() - 1 + }; + + let answer = json!({ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gpt-test-2026-09-30", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": format!("{{\"offsetSeconds\":{index},\"description\":\"Step {index}\"}}") }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } + }); + + match status(index) { + 0 => (StatusCode::OK, axum::Json(answer)), + code => ( + StatusCode::from_u16(code).expect("valid status"), + axum::Json(json!({ "error": { "message": "scripted failure" } })), + ), + } + } + } + })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("address"); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + + let settings = AiSettings { + provider: AiProvider::OpenAiCompatible, + model: "gpt-test".to_owned(), + base_url: Some(format!("http://{addr}/v1/").parse().expect("URL")), + max_output_tokens: None, + }; + + (settings, requests) +} + +fn first_line(path: &Utf8Path) -> String { + std::fs::read_to_string(path) + .expect("chunk") + .lines() + .next() + .expect("line") + .to_owned() +} + +#[tokio::test] +async fn start_records_the_task_keeps_the_key_and_queues_a_job() { + let mut harness = Harness::new().await; + let task_id = Uuid::new_v4(); + let session_id = harness.write_session(); + let mut settings = openai("gpt-test"); + settings.base_url = Some("https://ai.example/v1/".parse().expect("URL")); + + let StartOutcome::Created(task) = harness.start(task_id, session_id, settings).await.expect("started") else { + panic!("task must be created"); + }; + + assert_eq!(task.id, task_id); + assert_eq!(task.kind, KIND); + assert_eq!(task.target, session_id.to_string()); + assert_eq!(task.state, ProvisionerTaskState::Queued); + assert_eq!( + task.params, + json!({ "provider": "openai", "model": "gpt-test", "maxOutputTokens": null }), + "neither the key nor the base URL is recorded" + ); + assert_eq!(task.deadline_at - task.created_at, TASK_DEADLINE); + + let access = harness + .state + .ai_keys + .get(task_id, OffsetDateTime::now_utc()) + .expect("key is kept"); + assert_eq!(access.base_url.expect("base URL").as_str(), "https://ai.example/v1/"); + + let JobMessage { job, schedule_for } = harness.job_queue_rx.try_recv().expect("job queued"); + assert_eq!(job.name(), RecordingAiAnalysisJob::NAME); + assert_eq!( + job.write_json().expect("job JSON"), + format!(r#"{{"taskId":"{task_id}"}}"#) + ); + assert!(schedule_for.is_none()); +} + +#[tokio::test] +async fn asking_again_creates_nothing_new() { + let mut harness = Harness::new().await; + let task_id = Uuid::new_v4(); + let session_id = harness.write_session(); + harness + .start(task_id, session_id, openai("gpt-test")) + .await + .expect("started"); + harness.job_queue_rx.try_recv().expect("first job"); + + let same = harness + .start(task_id, session_id, openai("gpt-test")) + .await + .expect("same request"); + assert!( + matches!(same, StartOutcome::Existing(ref task) if task.id == task_id), + "{same:?}" + ); + + let other_params = harness + .start(task_id, session_id, openai("gpt-other")) + .await + .expect("other parameters"); + assert!(matches!(other_params, StartOutcome::IdConflict(_)), "{other_params:?}"); + + let busy = harness + .start(Uuid::new_v4(), session_id, openai("gpt-test")) + .await + .expect("other task"); + assert_eq!( + busy, + StartOutcome::TargetBusy { + active_task_id: task_id + } + ); + + assert!(harness.job_queue_rx.try_recv().is_err(), "no other job is queued"); + assert_eq!(harness.state.ai_keys.len(), 1); +} + +#[tokio::test] +async fn asking_again_returns_the_task_even_while_the_session_records_again() { + let mut harness = Harness::new().await; + let task_id = Uuid::new_v4(); + let session_id = harness.write_session(); + harness + .start(task_id, session_id, openai("gpt-test")) + .await + .expect("started"); + harness.job_queue_rx.try_recv().expect("first job"); + harness.state.recordings.active_recordings.insert(session_id); + + let same = harness + .start(task_id, session_id, openai("gpt-test")) + .await + .expect("same request"); + assert!(matches!(same, StartOutcome::Existing(_)), "{same:?}"); + + let other_params = harness + .start(task_id, session_id, openai("gpt-other")) + .await + .expect("other parameters"); + assert!(matches!(other_params, StartOutcome::IdConflict(_)), "{other_params:?}"); + + let new_task = harness.start(Uuid::new_v4(), session_id, openai("gpt-test")).await; + assert!(matches!(new_task, Err(StartError::RecordingActive)), "{new_task:?}"); +} + +#[tokio::test] +async fn refuses_a_session_without_recording() { + let harness = Harness::new().await; + let task_id = Uuid::new_v4(); + + let error = harness + .start(task_id, Uuid::new_v4(), openai("gpt-test")) + .await + .expect_err("no recording"); + + assert!(matches!(error, StartError::RecordingNotFound), "{error:?}"); + assert!( + harness + .state + .provisioner_tasks + .get(task_id, OffsetDateTime::now_utc()) + .await + .expect("read") + .is_none() + ); + assert_eq!(harness.state.ai_keys.len(), 0); +} + +#[tokio::test] +async fn refuses_a_session_that_is_still_recording() { + let harness = Harness::new().await; + let task_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + harness.state.recordings.active_recordings.insert(session_id); + + let error = harness + .start(task_id, session_id, openai("gpt-test")) + .await + .expect_err("still recording"); + + assert!(matches!(error, StartError::RecordingActive), "{error:?}"); + assert!( + harness + .state + .provisioner_tasks + .get(task_id, OffsetDateTime::now_utc()) + .await + .expect("read") + .is_none() + ); +} + +#[tokio::test] +async fn refuses_invalid_ai_settings() { + let harness = Harness::new().await; + let task_id = Uuid::new_v4(); + + let error = harness + .start(task_id, Uuid::new_v4(), openai(" ")) + .await + .expect_err("empty model"); + assert!( + matches!(error, StartError::InvalidSettings(AiSettingsError::MissingModel)), + "{error:?}" + ); + + let mut no_base_url = openai("gpt-test"); + no_base_url.provider = AiProvider::OpenAiCompatible; + let error = harness + .start(task_id, Uuid::new_v4(), no_base_url) + .await + .expect_err("no base URL"); + assert!( + matches!(error, StartError::InvalidSettings(AiSettingsError::MissingBaseUrl)), + "{error:?}" + ); + + assert_eq!(harness.state.ai_keys.len(), 0); +} + +#[tokio::test] +async fn retry_resumes_at_the_first_chunk_without_a_checkpoint() { + let harness = Harness::new().await; + // About 1 MB of transcript: three chunks. + let session_id = harness.write_long_session(10_000); + let (settings, requests) = spawn_ai_provider(|index| if index == 1 { 503 } else { 0 }).await; + let task_id = harness.start_created(session_id, settings).await; + let workspace = harness.workspace(session_id); + + assert!(harness.run(task_id).await.is_err(), "a provider outage is retried"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Running); + assert_eq!(task.payload, json!({ "step": "describing", "done": 1, "total": 3 })); + assert!(harness.has_key(task_id), "the key is kept for the retry"); + assert_eq!( + std::fs::read_to_string(workspace.join("chunks.done")).expect("chunks"), + "3" + ); + assert!(workspace.join("chunk-0000.json").exists()); + assert!(!workspace.join("chunk-0001.json").exists()); + let chunk_starts = (0..3) + .map(|index| first_line(&workspace.join(format!("chunk-{index:04}.txt")))) + .collect::>(); + assert_eq!(requests.lock().len(), 2); + + harness.run(task_id).await.expect("second attempt succeeds"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Succeeded, "{:?}", task.payload); + assert_eq!(task.attempts, 2); + assert_eq!( + task.payload, + json!({ + "fileName": "ai-analysis-0.slog", + "model": "gpt-test-2026-09-30", + "usage": { "inputTokens": 30, "outputTokens": 60 }, + "promptVersion": PROMPT_VERSION, + }), + "chunk 0 counts from its checkpoint, the failed request not at all" + ); + + let requests = requests.lock().clone(); + assert_eq!(requests.len(), 4, "only chunks 1 and 2 are sent again"); + for (request, chunk) in requests.iter().zip([0, 1, 1, 2]) { + assert!(request.contains(&chunk_starts[chunk]), "request for chunk {chunk}"); + } + assert!(requests.iter().all(|request| !request.contains(API_KEY))); + + let log = std::fs::read_to_string( + harness + .recording_path + .join(session_id.to_string()) + .join("ai-analysis-0.slog"), + ) + .expect("log"); + let descriptions = log + .lines() + .map(|line| serde_json::from_str::(line).expect("JSON")["description"].clone()) + .collect::>(); + assert_eq!( + descriptions, + ["Session started", "Step 0", "Step 2", "Step 3", "Session ended"].map(serde_json::Value::from) + ); + + assert!(!workspace.exists(), "the workspace is deleted on success"); + assert!(!harness.has_key(task_id), "the key is dropped on success"); +} + +#[tokio::test] +async fn permanent_error_fails_without_retry() { + let harness = Harness::new().await; + let session_id = harness.write_long_session(10); + let (settings, requests) = spawn_ai_provider(|_| 401).await; + let task_id = harness.start_created(session_id, settings).await; + + harness.run(task_id).await.expect("no retry"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["reason"], "permanent error"); + assert!( + task.payload["details"].as_str().expect("details").contains("401"), + "{}", + task.payload + ); + assert_eq!(task.payload["attempts"], 1); + assert_eq!(requests.lock().len(), 1); + assert!(!harness.workspace(session_id).exists()); + assert!(!harness.has_key(task_id)); +} + +#[tokio::test] +async fn recording_deleted_after_start_fails() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = harness.start_created(session_id, openai("gpt-test")).await; + std::fs::remove_dir_all(harness.recording_path.join(session_id.to_string())).expect("delete the recording"); + + harness.run(task_id).await.expect("no retry"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["details"], "session has no recording"); +} + +#[tokio::test] +async fn transient_errors_fail_the_task_once_attempts_are_exhausted() { + let harness = Harness::new().await; + let session_id = harness.write_long_session(10); + let (settings, requests) = spawn_ai_provider(|_| 503).await; + let task_id = harness.start_created(session_id, settings).await; + + for attempt in 1..MAX_ATTEMPTS { + assert!(harness.run(task_id).await.is_err(), "attempt {attempt} is retried"); + assert_eq!(harness.task(task_id).await.state, ProvisionerTaskState::Running); + } + + harness.run(task_id).await.expect("the last attempt is not retried"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.attempts, MAX_ATTEMPTS); + assert_eq!(task.payload["reason"], "attempts exhausted"); + assert!( + task.payload["details"].as_str().expect("details").contains("503"), + "{}", + task.payload + ); + assert_eq!(requests.lock().len(), 5); + assert!(!harness.workspace(session_id).exists()); + assert!(!harness.has_key(task_id)); +} + +#[tokio::test] +async fn slow_attempt_is_retried() { + let harness = Harness::new().await; + let session_id = harness.write_long_session(10); + + // A provider that never answers makes the attempt outlast its timeout. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("address"); + tokio::spawn(async move { + while let Ok((connection, _)) = listener.accept().await { + tokio::spawn(async move { + let _connection = connection; + std::future::pending::<()>().await; + }); + } + }); + let settings = AiSettings { + provider: AiProvider::OpenAiCompatible, + model: "gpt-test".to_owned(), + base_url: Some(format!("http://{addr}/v1/").parse().expect("URL")), + max_output_tokens: None, + }; + let task_id = harness.start_created(session_id, settings).await; + + let mut job = RecordingAiAnalysisJob::new(task_id, harness.state.clone()); + job.attempt_timeout = Duration::from_millis(500); + + let error = job.run().await.expect_err("timeout is retried"); + + assert!(error.to_string().contains("took longer than"), "{error:#}"); + assert_eq!(harness.task(task_id).await.state, ProvisionerTaskState::Running); + assert!(harness.has_key(task_id)); + assert!( + harness.workspace(session_id).join("chunks.done").exists(), + "the chunks are kept for the retry" + ); +} + +#[tokio::test] +async fn log_added_after_the_deadline_leaves_the_task_failed() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, _requests) = spawn_ai_provider_after(Duration::from_secs(4), |_| 0).await; + let task_id = Uuid::new_v4(); + let now = OffsetDateTime::now_utc(); + harness + .state + .provisioner_tasks + .create( + NewProvisionerTask { + id: task_id, + kind: KIND.to_owned(), + target: session_id.to_string(), + params: json!({ "provider": "openai-compatible", "model": "gpt-test", "maxOutputTokens": null }), + deadline_at: now + time::Duration::seconds(2), + }, + now, + ) + .await + .expect("create"); + harness.state.ai_keys.insert( + task_id, + AiAccess { + api_key: SecretString::from(API_KEY), + base_url: settings.base_url, + }, + now + time::Duration::hours(1), + ); + + let mut job = RecordingAiAnalysisJob::new(task_id, harness.state.clone()); + let job = tokio::spawn(async move { job.run().await }); + + // Reading the Task past its deadline, as a status request does, ends it while the AI is still answering. + tokio::time::sleep(Duration::from_secs(3)).await; + assert_eq!(harness.task(task_id).await.state, ProvisionerTaskState::Failed); + + job.await.expect("job task").expect("no retry"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["reason"], "timed out"); + assert!( + harness + .recording_path + .join(session_id.to_string()) + .join("ai-analysis-0.slog") + .exists(), + "the log stays in the session" + ); + assert!(!harness.workspace(session_id).exists()); + assert!(!harness.has_key(task_id)); +} + +#[tokio::test] +async fn lost_key_fails_the_task() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = harness.start_created(session_id, openai("gpt-test")).await; + std::fs::create_dir_all(harness.workspace(session_id)).expect("workspace"); + + // A restart keeps `gateway.db` and the job queue, but not the keys held in memory. + harness.state.ai_keys.remove(task_id); + + harness.run(task_id).await.expect("no retry"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!( + task.payload, + json!({ "reason": "key lost", "details": KEY_LOST, "attempts": 1 }) + ); + assert!(!harness.workspace(session_id).exists()); +} + +#[tokio::test] +async fn overdue_task_does_not_run() { + let harness = Harness::new().await; + let task_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let now = OffsetDateTime::now_utc(); + harness + .state + .provisioner_tasks + .create( + NewProvisionerTask { + id: task_id, + kind: KIND.to_owned(), + target: session_id.to_string(), + params: json!({ "provider": "openai", "model": "gpt-test", "maxOutputTokens": null }), + deadline_at: now - time::Duration::seconds(1), + }, + now - time::Duration::hours(2), + ) + .await + .expect("create"); + harness.state.ai_keys.insert( + task_id, + AiAccess { + api_key: SecretString::from(API_KEY), + base_url: None, + }, + now + time::Duration::hours(1), + ); + std::fs::create_dir_all(harness.workspace(session_id)).expect("workspace"); + + harness.run(task_id).await.expect("no retry"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["reason"], "timed out"); + assert_eq!(task.attempts, 0); + assert!(!harness.has_key(task_id)); + assert!(!harness.workspace(session_id).exists()); +} + +#[tokio::test] +async fn finished_task_does_not_run_again() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, _requests) = spawn_ai_provider(|_| 401).await; + let task_id = harness.start_created(session_id, settings).await; + + harness.run(task_id).await.expect("first run"); + std::fs::create_dir_all(harness.workspace(session_id)).expect("workspace left behind"); + harness.run(task_id).await.expect("second run"); + + assert_eq!(harness.task(task_id).await.attempts, 1); + assert!( + !harness.workspace(session_id).exists(), + "a job that finds its Task finished deletes the workspace" + ); +} + +#[tokio::test] +async fn job_is_read_back_from_its_json() { + let harness = Harness::new().await; + let task_id = Uuid::new_v4(); + let json = RecordingAiAnalysisJob::new(task_id, harness.state.clone()) + .write_json() + .expect("job JSON"); + + let job = RecordingAiAnalysisJob::read_json(&json, harness.state).expect("valid job"); + + assert_eq!(job.task_id, task_id); + assert_eq!(job.attempt_timeout, ATTEMPT_TIMEOUT); +} + +#[tokio::test] +async fn analyses_of_one_session_run_one_at_a_time() { + let session_id = Uuid::new_v4(); + let first = lock_session(session_id).await; + + let other_session = tokio::time::timeout(Duration::from_secs(1), lock_session(Uuid::new_v4())).await; + assert!(other_session.is_ok(), "another session is not blocked"); + + let second = tokio::spawn(lock_session(session_id)); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!second.is_finished(), "the second analysis waits"); + + drop(first); + tokio::time::timeout(Duration::from_secs(1), second) + .await + .expect("the second analysis goes on") + .expect("lock task"); +} + +#[tokio::test] +async fn working_files_left_by_an_earlier_task_go_with_the_task_that_ends() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, _requests) = spawn_ai_provider(|_| 401).await; + let task_id = harness.start_created(session_id, settings).await; + std::fs::create_dir_all(harness.workspace(session_id)).expect("working files of a crashed analysis"); + let log = harness + .recording_path + .join(session_id.to_string()) + .join(".ai-analysis.slog"); + std::fs::write(&log, "never moved").expect("log of a crashed analysis"); + + harness.run(task_id).await.expect("no retry"); + + assert_eq!(harness.task(task_id).await.state, ProvisionerTaskState::Failed); + assert!(!harness.workspace(session_id).exists()); + assert!(!log.exists()); +} +#[test] +fn unknown_usage_is_left_out_of_the_result() { + let analysis = Analysis { + file_name: "ai-analysis-0.slog".to_owned(), + model: "gpt-test-2026-09-30".to_owned(), + usage: None, + prompt_version: PROMPT_VERSION, + }; + + assert_eq!( + json!(analysis), + json!({ + "fileName": "ai-analysis-0.slog", + "model": "gpt-test-2026-09-30", + "promptVersion": PROMPT_VERSION, + }) + ); +} diff --git a/devolutions-gateway/src/recording.rs b/devolutions-gateway/src/recording.rs index 59905df5b..a31e014f2 100644 --- a/devolutions-gateway/src/recording.rs +++ b/devolutions-gateway/src/recording.rs @@ -30,15 +30,26 @@ const BUFFER_WRITER_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct JrecFile { +pub(crate) struct JrecFile { file_name: String, start_time: i64, duration: i64, } +impl JrecFile { + pub(crate) fn file_name(&self) -> &str { + &self.file_name + } + + /// Unix seconds. + pub(crate) fn start_time(&self) -> i64 { + self.start_time + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct JrecManifest { +pub(crate) struct JrecManifest { session_id: Uuid, start_time: i64, duration: i64, @@ -48,6 +59,20 @@ struct JrecManifest { } impl JrecManifest { + /// Unix seconds. + pub(crate) fn start_time(&self) -> i64 { + self.start_time + } + + /// Seconds. + pub(crate) fn duration(&self) -> i64 { + self.duration + } + + pub(crate) fn files(&self) -> &[JrecFile] { + &self.files + } + fn read_from_file(path: impl AsRef) -> anyhow::Result { let json = std::fs::read(path)?; let manifest = serde_json::from_slice(&json)?; @@ -220,7 +245,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() @@ -257,6 +282,10 @@ enum RecordingManagerMessage { kind: ArtifactKind, channel: oneshot::Sender>, }, + GetFinished { + id: Uuid, + channel: oneshot::Sender>, + }, Disconnect { id: Uuid, }, @@ -281,6 +310,29 @@ enum RecordingManagerMessage { }, } +/// A session that is not recording anymore and has at least one Recording. +#[derive(Debug)] +pub(crate) struct FinishedRecording { + pub(crate) dir: Utf8PathBuf, + pub(crate) manifest: JrecManifest, +} + +impl FinishedRecording { + pub(crate) fn manifest_path(&self) -> Utf8PathBuf { + self.dir.join("recording.json") + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum FinishedRecordingError { + #[error("session has no recording")] + NotFound, + #[error("session is still recording")] + Recording, + #[error(transparent)] + Other(anyhow::Error), +} + impl fmt::Debug for RecordingManagerMessage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -300,6 +352,9 @@ impl fmt::Debug for RecordingManagerMessage { .field("id", id) .field("kind", kind) .finish_non_exhaustive(), + RecordingManagerMessage::GetFinished { id, channel: _ } => { + f.debug_struct("GetFinished").field("id", id).finish_non_exhaustive() + } RecordingManagerMessage::Disconnect { id } => f.debug_struct("Disconnect").field("id", id).finish(), RecordingManagerMessage::GetState { id, channel: _ } => { f.debug_struct("GetState").field("id", id).finish_non_exhaustive() @@ -365,6 +420,36 @@ impl RecordingMessageSender { rx.await.context("couldn't receive AddArtifact result")? } + pub(crate) async fn get_finished(&self, id: Uuid) -> Result { + let (tx, rx) = oneshot::channel(); + self.channel + .send(RecordingManagerMessage::GetFinished { id, channel: tx }) + .await + .ok() + .context("couldn't send GetFinished message") + .map_err(FinishedRecordingError::Other)?; + let dir = rx + .await + .context("couldn't receive GetFinished result") + .map_err(FinishedRecordingError::Other)??; + + // The manifest is read here, so the recording manager task never waits on the disk for it. + let manifest = match fs::read(dir.join("recording.json")).await { + Ok(manifest) => manifest, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Err(FinishedRecordingError::NotFound), + Err(error) => { + return Err(FinishedRecordingError::Other( + anyhow::Error::new(error).context("read manifest from disk"), + )); + } + }; + let manifest = serde_json::from_slice(&manifest) + .context("parse manifest") + .map_err(FinishedRecordingError::Other)?; + + Ok(FinishedRecording { dir, manifest }) + } + async fn disconnect(&self, id: Uuid) -> anyhow::Result<()> { self.channel .send(RecordingManagerMessage::Disconnect { id }) @@ -727,6 +812,15 @@ impl RecordingManagerTask { Ok(artifact_path) } + fn handle_get_finished(&self, id: Uuid) -> Result { + // The transcript is built from the whole recording, so one still being pushed is not ready. + if self.ongoing_recordings.contains_key(&id) { + return Err(FinishedRecordingError::Recording); + } + + Ok(self.recordings_path.join(id.to_string())) + } + fn handle_remove(&mut self, id: Uuid) { if let Some(ongoing) = self.ongoing_recordings.get(&id) { let now = time::OffsetDateTime::now_utc().unix_timestamp(); @@ -857,6 +951,9 @@ async fn recording_manager_task( RecordingManagerMessage::AddArtifact { id, kind, channel } => { let _ = channel.send(manager.handle_add_artifact(id, kind).await); }, + RecordingManagerMessage::GetFinished { id, channel } => { + let _ = channel.send(manager.handle_get_finished(id)); + } RecordingManagerMessage::Disconnect { id } => { if let Err(e) = manager.handle_disconnect(id).await { error!(error = format!("{e:#}"), "handle_disconnect"); @@ -1253,6 +1350,25 @@ mod tests { assert_eq!(harness.sender.get_count().await.expect("count"), 1); } + #[tokio::test] + async fn finished_recordings_are_read_through_the_manager() { + let harness = Harness::start(); + let id = Uuid::new_v4(); + harness.write_manifest(id, MASTER_MANIFEST); + + let finished = harness.sender.get_finished(id).await.expect("finished"); + assert_eq!(finished.dir, harness.recordings_path.join(id.to_string())); + assert_eq!(finished.manifest.files()[0].file_name(), "recording-0.webm"); + + let ongoing = Uuid::new_v4(); + harness.connect(ongoing, WEBM).await; + let result = harness.sender.get_finished(ongoing).await; + assert!(matches!(result, Err(FinishedRecordingError::Recording)), "{result:?}"); + + let missing = harness.sender.get_finished(Uuid::new_v4()).await; + assert!(matches!(missing, Err(FinishedRecordingError::NotFound)), "{missing:?}"); + } + #[test] fn manifest_without_artifacts_round_trips_byte_for_byte() { let dir = tempfile::tempdir().expect("temp dir"); diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index b07b96c14..e08210984 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -337,6 +337,16 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { None }; + let provisioner_tasks_conn = gateway_db + .connect() + .await + .context("failed to connect the provisioner tasks to the gateway database")?; + let provisioner_tasks: provisioner_task::DynProvisionerTaskStore = Arc::new( + gateway_db::provisioner_task::LibSqlProvisionerTaskStore::new(provisioner_tasks_conn), + ); + + let ai_keys = devolutions_gateway::provisioner_tasks::ai_key_store::AiKeyStore::new(); + let state = DgwState { conf_handle: conf_handle.clone(), token_cache: Arc::clone(&token_cache), @@ -351,6 +361,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, + provisioner_tasks, + ai_keys: ai_keys.clone(), }; for listener in &conf.listeners { @@ -384,6 +396,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(devolutions_gateway::provisioning::CleanupTask { handle: provisioning }); + tasks.register(devolutions_gateway::provisioner_tasks::ai_key_store::CleanupTask { handle: ai_keys }); + tasks.register(devolutions_gateway::credential_injection::CleanupTask { handle: synthetic_kdc_registry, }); @@ -415,7 +429,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/token.rs b/devolutions-gateway/src/token.rs index 97d268e44..f97e84088 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -511,6 +511,10 @@ pub enum AccessScope { AgentDelete, #[serde(rename = "gateway.agent.read")] AgentRead, + #[serde(rename = "gateway.recording.analyze")] + RecordingAnalyze, + #[serde(rename = "gateway.tasks.read")] + TasksRead, } #[derive(Clone, Serialize, Deserialize)] diff --git a/devolutions-gateway/tests/ai_analysis.rs b/devolutions-gateway/tests/ai_analysis.rs new file mode 100644 index 000000000..66fc79018 --- /dev/null +++ b/devolutions-gateway/tests/ai_analysis.rs @@ -0,0 +1,483 @@ +#![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used)] + +use std::io::Write; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::body::Body; +use axum::extract::connect_info::MockConnectInfo; +use axum::http::{Request, StatusCode}; +use base64::Engine as _; +use camino::{Utf8Path, Utf8PathBuf}; +use devolutions_gateway::job_queue::{JobMessage, JobQueueReceiver}; +use devolutions_gateway::recording::RecordingManagerTask; +use devolutions_gateway::{DgwState, MockHandles}; +use devolutions_gateway_task::ShutdownHandle; +use http_body_util::BodyExt as _; +use serde_json::{Value, json}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use tower::ServiceExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; +use uuid::Uuid; + +const API_KEY: &str = "sk-ai-analysis-endpoint-test-secret"; + +fn config(recording_path: &Utf8Path) -> 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" }, + "RecordingPath": recording_path, + "__debug__": { "disable_token_validation": true }, + }) + .to_string() +} + +fn scope_token(scope: &str) -> String { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(r#"{"alg":"RS256","typ":"JWT","cty":"SCOPE"}"#); + let payload = engine.encode( + json!({ + "jti": Uuid::new_v4(), + "exp": OffsetDateTime::now_utc().unix_timestamp() + 3600, + "scope": scope, + }) + .to_string(), + ); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") +} + +/// Collects every log line, so a test can check that no secret reaches the logs. +#[derive(Clone, Default)] +struct LogBuffer(Arc>>); + +impl Write for LogBuffer { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl LogBuffer { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +struct Harness { + app: Router, + job_queue_rx: JobQueueReceiver, + recording_path: Utf8PathBuf, + db_path: Utf8PathBuf, + _keep: Box, + _dir: tempfile::TempDir, +} + +impl Harness { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let recording_path = root.join("recordings"); + let db_path = root.join("gateway.db"); + + let (mut state, handles) = DgwState::mock(&config(&recording_path)).await.unwrap(); + + // A file, unlike the in-memory default, can be searched for secrets afterwards. + let connection = gateway_db::GatewayDb::open_path(db_path.as_str()) + .await + .unwrap() + .connect() + .await + .unwrap(); + state.provisioner_tasks = Arc::new(gateway_db::provisioner_task::LibSqlProvisionerTaskStore::new( + connection, + )); + + let MockHandles { + session_manager_rx, + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + shutdown_handle, + } = handles; + + let recording_manager = RecordingManagerTask::new( + recording_manager_rx, + recording_path.clone(), + state.sessions.clone(), + state.job_queue_handle.clone(), + ); + let (recording_shutdown_handle, recording_shutdown_signal) = ShutdownHandle::new(); + tokio::spawn(devolutions_gateway_task::Task::run( + recording_manager, + recording_shutdown_signal, + )); + + let app = devolutions_gateway::make_http_service(state) + .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000)))); + + Self { + app, + job_queue_rx, + recording_path, + db_path, + _keep: Box::new(( + session_manager_rx, + subscriber_rx, + traffic_audit_rx, + shutdown_handle, + recording_shutdown_handle, + )), + _dir: dir, + } + } + + fn write_session(&self) -> Uuid { + let session_id = Uuid::new_v4(); + let dir = self.recording_path.join(session_id.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + + let manifest = json!({ + "sessionId": session_id, + "startTime": 1_787_255_035, + "duration": 3, + "files": [{ "fileName": "recording-0.cast", "startTime": 1_787_255_035, "duration": 3 }], + }); + std::fs::write(dir.join("recording.json"), manifest.to_string()).unwrap(); + std::fs::write( + dir.join("recording-0.cast"), + "{\"version\": 2}\n[0.5,\"o\",\"$ ls\\r\\n\"]\n[1.0,\"o\",\"file.txt\\r\\n\"]\n", + ) + .unwrap(); + + session_id + } + + async fn send(&self, request: Request) -> (StatusCode, Value) { + let response = self.app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + (status, body) + } + + async fn post_raw(&self, session_id: Uuid, scope: &str, body: String) -> (StatusCode, Value) { + let request = Request::builder() + .method("POST") + .uri(format!("/jet/jrec/{session_id}/ai-analysis")) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", scope_token(scope))) + .body(Body::from(body)) + .unwrap(); + self.send(request).await + } + + async fn post(&self, session_id: Uuid, body: &Value) -> (StatusCode, Value) { + self.post_raw(session_id, "gateway.recording.analyze", body.to_string()) + .await + } + + async fn get_task(&self, task_id: Uuid, scope: &str) -> (StatusCode, Value) { + let request = Request::builder() + .uri(format!("/jet/tasks/{task_id}")) + .header("authorization", format!("Bearer {}", scope_token(scope))) + .body(Body::empty()) + .unwrap(); + self.send(request).await + } + + async fn run_queued_job(&mut self) { + let JobMessage { mut job, .. } = self.job_queue_rx.try_recv().expect("a queued job"); + job.run().await.unwrap(); + } + + fn database_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + for suffix in ["", "-wal"] { + if let Ok(content) = std::fs::read(format!("{}{suffix}", self.db_path)) { + bytes.extend(content); + } + } + bytes + } +} + +fn request(task_id: Uuid, model: &str) -> Value { + json!({ "taskId": task_id, "provider": "openai", "model": model, "apiKey": API_KEY }) +} + +/// A mock OpenAI-compatible provider answering every request with one action, or with `status` when it is not 200. +async fn spawn_ai_provider(status: u16) -> String { + let app = Router::new().fallback(axum::routing::post(move || async move { + if status != 200 { + return ( + StatusCode::from_u16(status).unwrap(), + axum::Json(json!({ "error": { "message": "scripted failure" } })), + ); + } + + let answer = json!({ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gpt-test-2026-09-30", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "{\"offsetSeconds\":0,\"description\":\"Listed files\"}" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } + }); + (StatusCode::OK, axum::Json(answer)) + })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + format!("http://{addr}/v1/") +} + +fn assert_rfc3339(value: &Value) { + let text = value.as_str().unwrap_or_else(|| panic!("not a string: {value}")); + OffsetDateTime::parse(text, &Rfc3339).unwrap_or_else(|error| panic!("{text}: {error}")); +} + +#[tokio::test] +async fn start_returns_202_then_200_for_the_same_request() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = Uuid::new_v4(); + + let (status, created) = harness.post(session_id, &request(task_id, "gpt-test")).await; + assert_eq!(status, StatusCode::ACCEPTED, "{created}"); + assert_eq!(created["id"], task_id.to_string()); + assert_eq!(created["kind"], "recording.ai-analysis"); + assert_eq!(created["target"], session_id.to_string()); + assert_eq!( + created["params"], + json!({ "provider": "openai", "model": "gpt-test", "maxOutputTokens": null }) + ); + assert_eq!(created["state"], "queued"); + assert_eq!(created["payload"], Value::Null); + assert_eq!(created["attempts"], 0); + assert_rfc3339(&created["createdAt"]); + assert_rfc3339(&created["deadlineAt"]); + assert_eq!(created["startedAt"], Value::Null); + assert_eq!(created["finishedAt"], Value::Null); + + let (status, again) = harness.post(session_id, &request(task_id, "gpt-test")).await; + assert_eq!(status, StatusCode::OK, "{again}"); + assert_eq!(again, created); +} + +#[tokio::test] +async fn start_conflicts_return_409_with_a_code() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = Uuid::new_v4(); + harness.post(session_id, &request(task_id, "gpt-test")).await; + + let (status, body) = harness.post(session_id, &request(task_id, "gpt-other")).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body, json!({ "code": "task_id_conflict" })); + + let (status, body) = harness.post(session_id, &request(Uuid::new_v4(), "gpt-test")).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body, json!({ "code": "analysis_in_progress", "taskId": task_id })); +} + +#[tokio::test] +async fn start_without_recording_returns_404() { + let harness = Harness::new().await; + + let (status, _) = harness.post(Uuid::new_v4(), &request(Uuid::new_v4(), "gpt-test")).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn invalid_bodies_and_settings_return_400() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = Uuid::new_v4(); + + let mut unknown_field = request(task_id, "gpt-test"); + unknown_field["temperature"] = json!(0.5); + + let mut no_key = request(task_id, "gpt-test"); + no_key.as_object_mut().unwrap().remove("apiKey"); + + let mut empty_key = request(task_id, "gpt-test"); + empty_key["apiKey"] = json!(""); + + let mut no_base_url = request(task_id, "gpt-test"); + no_base_url["provider"] = json!("openai-compatible"); + + let mut bad_base_url = request(task_id, "gpt-test"); + bad_base_url["baseUrl"] = json!("ftp://ai.example/v1/"); + + let mut unknown_provider = request(task_id, "gpt-test"); + unknown_provider["provider"] = json!("skynet"); + + for (case, body) in [ + ("unknown field", unknown_field.to_string()), + ("missing API key", no_key.to_string()), + ("empty API key", empty_key.to_string()), + ("empty model", request(task_id, " ").to_string()), + ("missing base URL", no_base_url.to_string()), + ("unsupported base URL", bad_base_url.to_string()), + ("unknown provider", unknown_provider.to_string()), + ("not JSON", "{".to_owned()), + ] { + let (status, _) = harness.post_raw(session_id, "gateway.recording.analyze", body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{case}"); + } + + let (status, _) = harness.get_task(task_id, "gateway.tasks.read").await; + assert_eq!(status, StatusCode::NOT_FOUND, "no Task is recorded"); +} + +#[tokio::test] +async fn endpoints_require_their_scope() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let body = request(Uuid::new_v4(), "gpt-test").to_string(); + + for scope in [ + "gateway.tasks.read", + "gateway.recording.delete", + "gateway.recordings.read", + ] { + let (status, _) = harness.post_raw(session_id, scope, body.clone()).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{scope}"); + } + + for scope in ["gateway.recording.analyze", "gateway.sessions.read"] { + let (status, _) = harness.get_task(Uuid::new_v4(), scope).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{scope}"); + } + + let (status, _) = harness.post_raw(session_id, "*", body).await; + assert_eq!(status, StatusCode::ACCEPTED, "the wildcard scope is accepted"); + + let (status, _) = harness.get_task(Uuid::new_v4(), "*").await; + assert_eq!(status, StatusCode::NOT_FOUND, "the wildcard scope is accepted"); +} + +#[tokio::test] +async fn get_task_returns_404_for_an_unknown_id() { + let harness = Harness::new().await; + + let (status, _) = harness.get_task(Uuid::new_v4(), "gateway.tasks.read").await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn analysis_succeeds_without_leaking_the_key_or_the_base_url() { + let logs = LogBuffer::default(); + let _guard = tracing_subscriber::fmt() + .with_writer({ + let logs = logs.clone(); + move || logs.clone() + }) + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .set_default(); + + let mut harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_ai_provider(200).await; + + let body = json!({ + "taskId": task_id, + "provider": "openai-compatible", + "model": "gpt-test", + "baseUrl": base_url, + "maxOutputTokens": 1000, + "apiKey": API_KEY, + }); + + let (status, created) = harness.post(session_id, &body).await; + assert_eq!(status, StatusCode::ACCEPTED, "{created}"); + + harness.run_queued_job().await; + + let (status, task) = harness.get_task(task_id, "gateway.tasks.read").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(task["state"], "succeeded", "{task}"); + assert_eq!(task["attempts"], 1); + assert_eq!(task["payload"]["fileName"], "ai-analysis-0.slog"); + assert_eq!(task["payload"]["model"], "gpt-test-2026-09-30"); + assert_eq!( + task["payload"]["usage"], + json!({ "inputTokens": 10, "outputTokens": 20 }) + ); + assert_rfc3339(&task["startedAt"]); + assert_rfc3339(&task["finishedAt"]); + + let (status, again) = harness.post(session_id, &body).await; + assert_eq!(status, StatusCode::OK, "{again}"); + + let responses = [created, task, again].map(|body| body.to_string()).join("\n"); + let logs = logs.text(); + let database = String::from_utf8_lossy(&harness.database_bytes()).into_owned(); + assert!(!database.is_empty(), "gateway.db is read"); + assert!(logs.contains("AI analysis succeeded"), "logs are captured"); + + for secret in [API_KEY, base_url.as_str()] { + assert!(!responses.contains(secret), "{secret} in a response"); + assert!(!logs.contains(secret), "{secret} in the logs"); + assert!(!database.contains(secret), "{secret} in gateway.db"); + } +} + +#[tokio::test] +async fn provider_refusing_the_key_fails_the_task() { + let mut harness = Harness::new().await; + let session_id = harness.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_ai_provider(401).await; + + let body = json!({ + "taskId": task_id, + "provider": "openai-compatible", + "model": "gpt-test", + "baseUrl": base_url, + "apiKey": API_KEY, + }); + let (status, _) = harness.post(session_id, &body).await; + assert_eq!(status, StatusCode::ACCEPTED); + + harness.run_queued_job().await; + + let (status, task) = harness.get_task(task_id, "gateway.tasks.read").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(task["state"], "failed", "{task}"); + assert_eq!(task["payload"]["reason"], "permanent error"); + assert_eq!(task["payload"]["attempts"], 1); + + let (status, _) = harness.post(session_id, &request(Uuid::new_v4(), "gpt-test")).await; + assert_eq!( + status, + StatusCode::ACCEPTED, + "a failed analysis no longer blocks the session" + ); +} diff --git a/devolutions-gateway/tests/dvls_compatibility.rs b/devolutions-gateway/tests/dvls_compatibility.rs index e727c8e01..a530f9212 100644 --- a/devolutions-gateway/tests/dvls_compatibility.rs +++ b/devolutions-gateway/tests/dvls_compatibility.rs @@ -301,6 +301,10 @@ mod as_of_v2022_3_0_0 { JrlRead, #[serde(rename = "gateway.config.write")] ConfigWrite, + #[serde(rename = "gateway.recording.analyze")] + RecordingAnalyze, + #[serde(rename = "gateway.tasks.read")] + TasksRead, } fn dvls_access_scope() -> impl Strategy { @@ -312,6 +316,8 @@ mod as_of_v2022_3_0_0 { Just(DvlsAccessScope::JrlRead), Just(DvlsAccessScope::ConfigWrite), Just(DvlsAccessScope::ConfigWrite), + Just(DvlsAccessScope::RecordingAnalyze), + Just(DvlsAccessScope::TasksRead), ] } diff --git a/devolutions-gateway/tests/preflight.rs b/devolutions-gateway/tests/preflight.rs index 1cbcf8480..71a312c62 100644 --- a/devolutions-gateway/tests/preflight.rs +++ b/devolutions-gateway/tests/preflight.rs @@ -58,15 +58,15 @@ fn unsigned_jws(payload: serde_json::Value) -> anyhow::Result { Ok(format!("{header}.{payload}.{signature}")) } -fn make_router() -> anyhow::Result<(Router, DgwState, MockHandles)> { - let (state, handles) = DgwState::mock(CONFIG)?; +async fn make_router() -> anyhow::Result<(Router, DgwState, MockHandles)> { + let (state, handles) = DgwState::mock(CONFIG).await?; let app = devolutions_gateway::make_http_service(state.clone()) .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); Ok((app, state, handles)) } -fn make_router_with_config(config: &str) -> anyhow::Result<(Router, DgwState, MockHandles)> { - let (state, handles) = DgwState::mock(config)?; +async fn make_router_with_config(config: &str) -> anyhow::Result<(Router, DgwState, MockHandles)> { + let (state, handles) = DgwState::mock(config).await?; let app = devolutions_gateway::make_http_service(state.clone()) .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); Ok((app, state, handles)) @@ -83,7 +83,7 @@ fn init_logger() -> tracing::subscriber::DefaultGuard { async fn test_provision_credentials_success() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ @@ -124,7 +124,7 @@ async fn test_provision_credentials_success_when_unstable_disabled() -> anyhow:: // `provision-credentials` is protocol-neutral: NTLM credential injection relies on this // path even when the unstable feature flag is off. let config = CONFIG.replace("\"enable_unstable\": true", "\"enable_unstable\": false"); - let (app, _state, _handles) = make_router_with_config(&config)?; + let (app, _state, _handles) = make_router_with_config(&config).await?; let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ @@ -161,7 +161,7 @@ async fn test_provision_credentials_success_when_unstable_disabled() -> anyhow:: async fn test_provision_credentials_rejects_alternate_targets() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; let token = unsigned_jws(json!({ "jti": "5e3e833f-84c7-4541-b676-acc3299e39b8", @@ -205,7 +205,7 @@ async fn test_provision_credentials_rejects_alternate_targets() -> anyhow::Resul async fn test_provision_credentials_rejects_missing_target_hostname() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; let token = unsigned_jws(json!({ "jti": "5e3e833f-84c7-4541-b676-acc3299e39b8" @@ -247,7 +247,7 @@ async fn test_provision_credentials_rejects_missing_target_hostname() -> anyhow: async fn test_provision_token_overwrite_alert() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; // Same JTI twice: second provision-token replaces the stored token-only entry (master behavior). let token = unsigned_jws(json!({ @@ -288,7 +288,7 @@ async fn test_provision_token_overwrite_alert() -> anyhow::Result<()> { async fn test_provision_connection_options_success() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ @@ -319,7 +319,7 @@ async fn test_provision_connection_options_success() -> anyhow::Result<()> { async fn test_provision_credentials_and_connection_options_fold() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ @@ -361,7 +361,7 @@ async fn test_provision_credentials_and_connection_options_fold() -> anyhow::Res async fn test_provision_invalid_params() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, _state, _handles) = make_router()?; + let (app, _state, _handles) = make_router().await?; let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1ZTNlODMzZi04NGM3LTQ1NDEtYjY3Ni1hY2MzMjk5ZTM5YjgifQ.1qECGlrW7y9HWFArc6GPHLGTOY7PhAvzKJ5XMRBg4k4"; diff --git a/devolutions-gateway/tests/traffic.rs b/devolutions-gateway/tests/traffic.rs index 7175d3bb5..3c9e4a63c 100644 --- a/devolutions-gateway/tests/traffic.rs +++ b/devolutions-gateway/tests/traffic.rs @@ -71,7 +71,7 @@ impl Drop for HandlesGuard { /// Build a Router with a live TrafficAuditManagerTask and real handle, /// similar to tests/preflight.rs harness. async fn make_router() -> anyhow::Result<(Router, DgwState, HandlesGuard)> { - let (mut state, handles) = DgwState::mock(CONFIG)?; + let (mut state, handles) = DgwState::mock(CONFIG).await?; // Start the manager with an in-memory DB. let manager = TrafficAuditManagerTask::init(":memory:").await?; diff --git a/devolutions-gateway/tests/webapp.rs b/devolutions-gateway/tests/webapp.rs index 8e090e9d1..25755d7e0 100644 --- a/devolutions-gateway/tests/webapp.rs +++ b/devolutions-gateway/tests/webapp.rs @@ -60,7 +60,7 @@ fn initialize_conf() { async fn custom_authentication_flow() -> anyhow::Result<()> { let (cov, _guard) = init_cov_mark(); initialize_conf(); - let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG)?; + let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG).await?; let mut app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -191,7 +191,7 @@ async fn static_assets_are_served(#[case] subdir: &str, #[case] uri_prefix: &str config["WebApp"]["StaticRootPath"] = json!(static_root); let config = config.to_string(); - let (state, _handle) = devolutions_gateway::DgwState::mock(&config)?; + let (state, _handle) = devolutions_gateway::DgwState::mock(&config).await?; let mut app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -229,7 +229,7 @@ async fn static_assets_are_served(#[case] subdir: &str, #[case] uri_prefix: &str async fn sign_app_token_bad_password() -> anyhow::Result<()> { let (cov, _guard) = init_cov_mark(); initialize_conf(); - let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG)?; + let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG).await?; let app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -267,7 +267,7 @@ async fn sign_app_token_bad_password() -> anyhow::Result<()> { async fn sign_app_token_username_mismatch() -> anyhow::Result<()> { let (cov, _guard) = init_cov_mark(); initialize_conf(); - let (state, _handles) = devolutions_gateway::DgwState::mock(CONFIG)?; + let (state, _handles) = devolutions_gateway::DgwState::mock(CONFIG).await?; let app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); diff --git a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs index b43e29764..fcc5c60ad 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs @@ -208,6 +208,26 @@ public void ScopeClaimsAgentRead() Assert.Equal(EXPECTED, result); } + [Fact] + public void ScopeClaimsRecordingAnalyze() + { + const string EXPECTED = """{"scope":"gateway.recording.analyze","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = new ScopeClaims(gatewayId, AccessScope.GatewayRecordingAnalyze); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void ScopeClaimsTasksRead() + { + const string EXPECTED = """{"scope":"gateway.tasks.read","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = new ScopeClaims(gatewayId, AccessScope.GatewayTasksRead); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + } + [Fact] public void EnrollmentClaimsAllFields() { diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs index bc953a2b1..98d7766c7 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs @@ -30,6 +30,8 @@ internal AccessScope(string value) public static AccessScope GatewayNetMonitorDrain = new AccessScope("gateway.net.monitor.drain"); public static AccessScope GatewayAgentDelete = new AccessScope("gateway.agent.delete"); public static AccessScope GatewayAgentRead = new AccessScope("gateway.agent.read"); + public static AccessScope GatewayRecordingAnalyze = new AccessScope("gateway.recording.analyze"); + public static AccessScope GatewayTasksRead = new AccessScope("gateway.tasks.read"); public override string? ToString() {