From c2dc1c785606e15fd96a83253bc37f2e6a9c3753 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 7 Oct 2026 16:52:57 -0400 Subject: [PATCH 1/8] feat(dgw): analyze a recording in the AI crate AiClient::analyze_recording takes the parsed session manifest and its path and does the rest: it reads the terminal recordings as a stream into transcript chunks, asks the AI about each chunk (a truncated answer is asked again in two halves), saves each answer as a checkpoint, and merges them into a Session Recording Log next to the manifest. Its working files live in .ai-analysis/ next to the manifest, so analysing again after a failure only asks about the chunks left; files made with another model or prompt are not reused. Only asciicast recordings are read for now. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 5 + crates/devolutions-gateway-ai/Cargo.toml | 6 + crates/devolutions-gateway-ai/src/client.rs | 5 + crates/devolutions-gateway-ai/src/lib.rs | 5 + .../src/recording_analysis/checkpoint.rs | 180 +++++ .../src/recording_analysis/mod.rs | 547 ++++++++++++++ .../src/recording_analysis/slog.rs | 177 +++++ .../src/recording_analysis/tests.rs | 370 ++++++++++ .../src/recording_analysis/transcript.rs | 686 ++++++++++++++++++ 9 files changed, 1981 insertions(+) create mode 100644 crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs create mode 100644 crates/devolutions-gateway-ai/src/recording_analysis/mod.rs create mode 100644 crates/devolutions-gateway-ai/src/recording_analysis/slog.rs create mode 100644 crates/devolutions-gateway-ai/src/recording_analysis/tests.rs create mode 100644 crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs diff --git a/Cargo.lock b/Cargo.lock index 725169058..da3a769a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,12 +1920,17 @@ dependencies = [ name = "devolutions-gateway-ai" version = "0.0.0" dependencies = [ + "anyhow", + "camino", "reqwest", "secrecy", "serde", "serde_json", + "tempfile", "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..0ccaa2849 100644 --- a/crates/devolutions-gateway-ai/Cargo.toml +++ b/crates/devolutions-gateway-ai/Cargo.toml @@ -10,13 +10,19 @@ 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" 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..d00f2749b 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 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..1b4a1ff22 --- /dev/null +++ b/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs @@ -0,0 +1,686 @@ +//! 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 => { + unsupported.get_or_insert("trp"); + continue; + } + 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 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 assert_streamed_into_bounded_chunk_files(event: fn(usize) -> Vec) { + 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); + assert!(transcript.add_cast(recording, 0.0).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); + } + + #[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_only_sessions_are_unsupported() { + let dir = session_dir(&[]); + + assert_eq!( + read(&manifest(1, &[("recording-0.trp", 1)]), &dir) + .expect_err("trp only") + .to_string(), + "unsupported recording type: trp" + ); + } + + #[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" + ); + } +} From ed7ee483c94c3ecd0e6b3353e1d92463d84c6b58 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 6 Oct 2026 15:10:51 -0400 Subject: [PATCH 2/8] refactor(dgw): give the gateway state the provisioner task store The Gateway state now holds the provisioner task store, on its own gateway.db connection, so the work it records can reach it. Building the mock state opens an in-memory database, so `DgwState::mock` is now async. The job queue attempt limit is now the named constant `MAX_ATTEMPTS`, still 5, so a job can refer to it. No behavior change. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + devolutions-gateway/Cargo.toml | 1 + devolutions-gateway/src/job_queue.rs | 4 ++++ devolutions-gateway/src/lib.rs | 8 +++++++- devolutions-gateway/src/service.rs | 9 +++++++++ devolutions-gateway/tests/preflight.rs | 24 ++++++++++++------------ devolutions-gateway/tests/traffic.rs | 2 +- devolutions-gateway/tests/webapp.rs | 8 ++++---- 8 files changed, 39 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da3a769a0..ab06019e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1868,6 +1868,7 @@ dependencies = [ "picky-krb", "pin-project-lite 0.2.17", "proptest", + "provisioner-task", "rand 0.10.2", "reqwest", "rstest", diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 24acd386c..c426f8b62 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -37,6 +37,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" diff --git a/devolutions-gateway/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 8985f3184..615bbfb63 100644 --- a/devolutions-gateway/src/job_queue.rs +++ b/devolutions-gateway/src/job_queue.rs @@ -11,6 +11,9 @@ use job_queue_libsql::libsql; use time::OffsetDateTime; use tokio::sync::{Notify, mpsc}; +/// 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, @@ -59,6 +62,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); diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index cd53900b2..ddfd53969 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -68,6 +68,7 @@ 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, } #[doc(hidden)] @@ -82,7 +83,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 +96,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 +115,7 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, + provisioner_tasks, }; let handles = MockHandles { diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index b07b96c14..c986e478b 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -337,6 +337,14 @@ 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 = Arc::new(gateway_db::provisioner_task::LibSqlProvisionerTaskStore::new( + provisioner_tasks_conn, + )); + let state = DgwState { conf_handle: conf_handle.clone(), token_cache: Arc::clone(&token_cache), @@ -351,6 +359,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, + provisioner_tasks, }; for listener in &conf.listeners { 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)))); From 7d7a9bdfb874cee2563fad25153c38df63911e37 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 7 Oct 2026 17:03:00 -0400 Subject: [PATCH 3/8] feat(dgw): run AI analysis as a provisioner task Gateway can now describe what the user did in a recorded terminal session with an AI provider, and add the result to the session as a new log. Each request is a Task recorded in gateway.db (kind `recording.ai-analysis`), run as a job on the existing job queue. The analysis itself is the AI crate's `analyze_recording`; Gateway gives it the session manifest once the recording is finished, then adds the log it returns to the session through the recording manager. A repeated request with the same Task ID gets its Task back, even while the session records again. A new request is refused while the session is recording, or when it has no recording. The API key and the base URL stay in memory only, until the Task ends or reaches its deadline. After a restart the job finds no key and fails its Task. Neither the key nor the URL appears in errors, logs or stored results: the AI client now drops the URL from its transport errors. Rate limits, server and network errors, and attempts running longer than 30 minutes, are retried by the job queue; the Task fails once its attempts are used up, or on any other error. A timed-out attempt is cancelled and awaited, and analyses of one session run one at a time, so two attempts never share the working files the AI crate keeps next to the manifest. Those files are removed when the Task ends, or when a job finds its Task ended. Starting an AI analysis is an internal function for now; the HTTP endpoint comes next. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 + crates/devolutions-gateway-ai/src/client.rs | 5 +- crates/devolutions-gateway-ai/src/error.rs | 10 +- .../devolutions-gateway-ai/tests/providers.rs | 22 +- devolutions-gateway/Cargo.toml | 2 + devolutions-gateway/src/job_queue.rs | 18 +- devolutions-gateway/src/lib.rs | 3 + .../src/provisioner_tasks/ai.rs | 213 +++++ .../src/provisioner_tasks/ai_key_store.rs | 193 +++++ .../src/provisioner_tasks/mod.rs | 17 + .../recording_ai_analysis/mod.rs | 607 +++++++++++++ .../recording_ai_analysis/tests.rs | 796 ++++++++++++++++++ devolutions-gateway/src/recording.rs | 122 ++- devolutions-gateway/src/service.rs | 16 +- 14 files changed, 2008 insertions(+), 18 deletions(-) create mode 100644 devolutions-gateway/src/provisioner_tasks/ai.rs create mode 100644 devolutions-gateway/src/provisioner_tasks/ai_key_store.rs create mode 100644 devolutions-gateway/src/provisioner_tasks/mod.rs create mode 100644 devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/mod.rs create mode 100644 devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/tests.rs diff --git a/Cargo.lock b/Cargo.lock index ab06019e2..2c0f0e46c 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", @@ -1896,6 +1897,7 @@ dependencies = [ "tokio-rustls", "tokio-test", "tokio-tungstenite", + "tokio-util", "tower 0.5.3", "tower-http 0.5.2", "tracing", diff --git a/crates/devolutions-gateway-ai/src/client.rs b/crates/devolutions-gateway-ai/src/client.rs index d00f2749b..e5898fcc6 100644 --- a/crates/devolutions-gateway-ai/src/client.rs +++ b/crates/devolutions-gateway-ai/src/client.rs @@ -235,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" @@ -257,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/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/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index c426f8b62..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" @@ -91,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/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 615bbfb63..7b9db2ec5 100644 --- a/devolutions-gateway/src/job_queue.rs +++ b/devolutions-gateway/src/job_queue.rs @@ -11,6 +11,8 @@ 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; @@ -41,6 +43,7 @@ pub struct JobRunnerTask { notify_runner: Arc, runner_waker: RunnerWaker, queue: DynJobQueue, + state: DgwState, } impl JobQueueCtx { @@ -201,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, } } } @@ -229,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 { @@ -283,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 { @@ -300,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 ddfd53969..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; @@ -69,6 +70,7 @@ pub struct DgwState { 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)] @@ -116,6 +118,7 @@ impl DgwState { 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/provisioner_tasks/ai.rs b/devolutions-gateway/src/provisioner_tasks/ai.rs new file mode 100644 index 000000000..8d066c6e5 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/ai.rs @@ -0,0 +1,213 @@ +//! 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; + +#[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 c986e478b..e08210984 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -341,9 +341,11 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .connect() .await .context("failed to connect the provisioner tasks to the gateway database")?; - let provisioner_tasks = Arc::new(gateway_db::provisioner_task::LibSqlProvisionerTaskStore::new( - provisioner_tasks_conn, - )); + 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(), @@ -360,6 +362,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, provisioner_tasks, + ai_keys: ai_keys.clone(), }; for listener in &conf.listeners { @@ -393,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, }); @@ -424,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); From e4a5e19b72ce19069bcbfa800d19324ec8ff5927 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 6 Oct 2026 16:21:34 -0400 Subject: [PATCH 4/8] feat(dgw): add endpoints to start an AI analysis and read a task The provisioner can now start an AI analysis of a recorded session with `POST /jet/jrec/{session_id}/ai-analysis` (scope `gateway.recording.analyze`), and follow any Task with `GET /jet/tasks/{id}` (scope `gateway.tasks.read`). The request body holds the Task ID chosen by the caller, the AI provider and model, an optional base URL and output token limit, and the API key. A new Task answers 202, the same request again answers 200 with the same Task. Gateway answers 409 with a code when the Task ID is used for other parameters, when the session still records, or when another AI analysis of the session is not finished; the last one names the Task ID. A session without recording answers 404, invalid settings or an unknown field answer 400. The API key and the base URL stay in memory only: they are never logged, returned or written to gateway.db. Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/src/api/jrec.rs | 199 +++++++- devolutions-gateway/src/api/mod.rs | 2 + devolutions-gateway/src/api/tasks.rs | 130 +++++ devolutions-gateway/src/extract.rs | 36 ++ devolutions-gateway/src/openapi.rs | 62 +++ .../src/provisioner_tasks/ai.rs | 2 + devolutions-gateway/src/token.rs | 4 + devolutions-gateway/tests/ai_analysis.rs | 483 ++++++++++++++++++ .../tests/dvls_compatibility.rs | 6 + 9 files changed, 921 insertions(+), 3 deletions(-) create mode 100644 devolutions-gateway/src/api/tasks.rs create mode 100644 devolutions-gateway/tests/ai_analysis.rs 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/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 index 8d066c6e5..6a408e92c 100644 --- a/devolutions-gateway/src/provisioner_tasks/ai.rs +++ b/devolutions-gateway/src/provisioner_tasks/ai.rs @@ -13,6 +13,8 @@ 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")] 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), ] } From d4009753e2628058c0b86d4de616698f8c1f27bc Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 6 Oct 2026 16:47:48 -0400 Subject: [PATCH 5/8] feat(dotnet-utils): add the AI analysis and task read scopes DVLS can now sign scope tokens for `gateway.recording.analyze`, to start an AI analysis of a recorded session, and `gateway.tasks.read`, to read a Task. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../JsonSerializationTests.cs | 20 +++++++++++++++++++ .../src/AccessScope.cs | 2 ++ 2 files changed, 22 insertions(+) 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() { From 608326235ec9ddb64d515140632365581223b689 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 6 Oct 2026 16:27:26 -0400 Subject: [PATCH 6/8] chore(openapi): regenerate clients for AI analysis Adds `POST /jet/jrec/{session_id}/ai-analysis`, `GET /jet/tasks/{id}`, their schemas and the `gateway.recording.analyze` and `gateway.tasks.read` scopes to gateway-api.yaml, and regenerates the dotnet and TypeScript clients and the docs. Only the AI analysis output is kept; the existing version and content-type drift of the committed clients is left as is. Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/openapi/doc/index.adoc | 631 ++++++++++++++++++ .../dotnet-client/.openapi-generator/FILES | 22 + .../openapi/dotnet-client/README.md | 12 + .../dotnet-client/docs/AiAnalysisConflict.md | 12 + .../docs/AiAnalysisConflictCode.md | 9 + .../docs/AiAnalysisFailedPayload.md | 13 + .../dotnet-client/docs/AiAnalysisRequest.md | 16 + .../docs/AiAnalysisRunningPayload.md | 13 + .../docs/AiAnalysisSucceededPayload.md | 14 + .../dotnet-client/docs/AiAnalysisUsage.md | 12 + .../openapi/dotnet-client/docs/AiProvider.md | 10 + .../openapi/dotnet-client/docs/JrecApi.md | 108 +++ .../openapi/dotnet-client/docs/TaskInfo.md | 21 + .../openapi/dotnet-client/docs/TaskState.md | 9 + .../openapi/dotnet-client/docs/TasksApi.md | 111 +++ .../Devolutions.Gateway.Client/Api/JrecApi.cs | 183 +++++ .../Api/TasksApi.cs | 417 ++++++++++++ .../Model/AccessScope.cs | 18 +- .../Model/AiAnalysisConflict.cs | 99 +++ .../Model/AiAnalysisConflictCode.cs | 76 +++ .../Model/AiAnalysisFailedPayload.cs | 126 ++++ .../Model/AiAnalysisRequest.cs | 155 +++++ .../Model/AiAnalysisRunningPayload.cs | 127 ++++ .../Model/AiAnalysisSucceededPayload.cs | 134 ++++ .../Model/AiAnalysisUsage.cs | 110 +++ .../Model/AiProvider.cs | 93 +++ .../Model/TaskInfo.cs | 210 ++++++ .../Model/TaskState.cs | 84 +++ devolutions-gateway/openapi/gateway-api.yaml | 304 +++++++++ .../.openapi-generator/FILES | 11 + .../openapi/ts-angular-client/api/api.ts | 4 +- .../ts-angular-client/api/jrec.service.ts | 92 +++ .../ts-angular-client/api/tasks.service.ts | 165 +++++ .../ts-angular-client/model/accessScope.ts | 6 +- .../model/aiAnalysisConflict.ts | 26 + .../model/aiAnalysisConflictCode.ts | 19 + .../model/aiAnalysisFailedPayload.ts | 29 + .../model/aiAnalysisRequest.ts | 42 ++ .../model/aiAnalysisRunningPayload.ts | 29 + .../model/aiAnalysisSucceededPayload.ts | 31 + .../model/aiAnalysisUsage.ts | 19 + .../ts-angular-client/model/aiProvider.ts | 24 + .../openapi/ts-angular-client/model/models.ts | 10 + .../ts-angular-client/model/taskInfo.ts | 62 ++ .../ts-angular-client/model/taskState.ts | 20 + 45 files changed, 3734 insertions(+), 4 deletions(-) create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflict.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflictCode.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisFailedPayload.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRequest.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRunningPayload.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisSucceededPayload.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisUsage.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TaskState.md create mode 100644 devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisConflict.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisConflictCode.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRequest.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRunningPayload.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisSucceededPayload.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisUsage.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs create mode 100644 devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs create mode 100644 devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflict.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisConflictCode.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisFailedPayload.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRequest.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRunningPayload.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisSucceededPayload.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisUsage.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts create mode 100644 devolutions-gateway/openapi/ts-angular-client/model/taskState.ts diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 04e0934d0..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 +}; + From 8373a0b276ab292f15c5040bc9a5765d9c875f1a Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sat, 26 Sep 2026 18:48:07 -0400 Subject: [PATCH 7/8] feat(terminal-streamer): decode a complete TRP recording A finished recording can now be read as asciicast lines without tailing it. The lines come out one packet at a time, so memory use does not grow with the recording. The packet decoding is shared with the live stream decoder. Events written before the terminal setup are still held until the setup arrives, but no more than 1 MiB of them: past that, the header goes out with the terminal size known so far, so a recording that never sends its setup is not held whole in memory. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/terminal-streamer/src/trp_decoder.rs | 409 ++++++++++++++++---- 1 file changed, 339 insertions(+), 70 deletions(-) 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" + ); + } +} From 09841cf2da668118267ba96749d4061c4271901d Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 7 Oct 2026 17:08:20 -0400 Subject: [PATCH 8/8] feat(dgw): analyze TRP recordings with AI AI analysis only read asciicast recordings so far and refused TRP ones as unsupported. The AI crate now reads TRP sessions through the streaming TRP decoder, so their memory use stays bounded by the chunk size like asciicast. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/devolutions-gateway-ai/Cargo.toml | 1 + .../src/recording_analysis/transcript.rs | 60 +++++++++++++++---- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c0f0e46c..e14749943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1930,6 +1930,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "terminal-streamer", "thiserror 2.0.20", "time", "tokio 1.52.3", diff --git a/crates/devolutions-gateway-ai/Cargo.toml b/crates/devolutions-gateway-ai/Cargo.toml index 0ccaa2849..d46bb206e 100644 --- a/crates/devolutions-gateway-ai/Cargo.toml +++ b/crates/devolutions-gateway-ai/Cargo.toml @@ -16,6 +16,7 @@ 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"] } diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs b/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs index 1b4a1ff22..83fabea15 100644 --- a/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs +++ b/crates/devolutions-gateway-ai/src/recording_analysis/transcript.rs @@ -92,10 +92,7 @@ pub(super) fn write_chunks( match RecordingKind::of(&file.file_name) { RecordingKind::Asciicast => transcript.add_cast(open()?, offset).map_err(to_transcript_error)?, - RecordingKind::Trp => { - unsupported.get_or_insert("trp"); - continue; - } + RecordingKind::Trp => transcript.add_trp(open()?, offset).map_err(to_transcript_error)?, RecordingKind::Video => { unsupported.get_or_insert("webm"); continue; @@ -205,6 +202,16 @@ impl<'a> Transcript<'a> { 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, @@ -556,7 +563,24 @@ mod tests { } } - fn assert_streamed_into_bounded_chunk_files(event: fn(usize) -> Vec) { + 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; @@ -565,7 +589,12 @@ mod tests { let cancel = CancellationToken::new(); let mut transcript = Transcript::new(ChunkWriter::new(dir, 4096), &cancel); let recording = BufReader::with_capacity(64, recording); - assert!(transcript.add_cast(recording, 0.0).is_ok()); + 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}"); @@ -583,7 +612,12 @@ mod tests { #[test] fn large_casts_are_streamed_into_bounded_chunk_files() { - assert_streamed_into_bounded_chunk_files(cast_event); + 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] @@ -663,14 +697,14 @@ mod tests { } #[test] - fn trp_only_sessions_are_unsupported() { - let dir = session_dir(&[]); + 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(1, &[("recording-0.trp", 1)]), &dir) - .expect_err("trp only") - .to_string(), - "unsupported recording type: trp" + read(&manifest(100, &[("recording-0.trp", 100)]), &dir).expect("transcript"), + "[2.5] uptime\n" ); }