diff --git a/Cargo.lock b/Cargo.lock index 688d542242..ec3bdc383b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2045,6 +2045,7 @@ dependencies = [ "cfg-if", "chacha20poly1305", "devolutions-agent-shared", + "devolutions-gateway-ai", "devolutions-gateway-generators", "devolutions-gateway-task", "devolutions-log", @@ -2083,6 +2084,8 @@ dependencies = [ "picky-krb", "pin-project-lite 0.2.17", "proptest", + "provisioner-task", + "provisioner-task-libsql", "rand 0.10.2", "reqwest", "rstest", @@ -2111,6 +2114,7 @@ dependencies = [ "tokio-rustls", "tokio-test", "tokio-tungstenite", + "tokio-util", "tower 0.5.3", "tower-http 0.5.2", "tracing", @@ -8196,6 +8200,7 @@ dependencies = [ "sysevent-winevent", "tempfile", "test-utils", + "time", "tokio 1.52.3", "tokio-rustls", "tokio-tungstenite", diff --git a/crates/devolutions-gateway-ai/examples/analyze_recording.rs b/crates/devolutions-gateway-ai/examples/analyze_recording.rs index 94bf8b1d13..d390b7c5d4 100644 --- a/crates/devolutions-gateway-ai/examples/analyze_recording.rs +++ b/crates/devolutions-gateway-ai/examples/analyze_recording.rs @@ -246,10 +246,16 @@ async fn main() -> anyhow::Result<()> { print_actions(&std::fs::read_to_string(&analysis.log_path)?)?; println!(); - let usage = analysis.usage.map_or_else( - || "not reported".to_owned(), - |usage| format!("{} in, {} out", usage.input_tokens, usage.output_tokens), - ); + let usage = match analysis.usage.unreported_requests { + 0 => format!( + "{} in, {} out", + analysis.usage.input_tokens, analysis.usage.output_tokens + ), + unreported => format!( + "{} in, {} out, and {unreported} requests not reported", + analysis.usage.input_tokens, analysis.usage.output_tokens + ), + }; println!(" {} {}", style(DIM, "model "), analysis.model); println!(" {} {}", style(DIM, "prompt "), analysis.prompt_version); println!(" {} {usage}", style(DIM, "tokens ")); diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs b/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs index e5a3a4c4e5..a0b81ece2d 100644 --- a/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs +++ b/crates/devolutions-gateway-ai/src/recording_analysis/checkpoint.rs @@ -8,60 +8,19 @@ use anyhow::Context as _; use camino::{Utf8Path, Utf8PathBuf}; use serde::{Deserialize, Serialize}; +use crate::Response; use crate::session_actions::Action; -use crate::{Response, Usage}; - -/// 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)] +/// Actions found in one chunk, with the model that described them. +#[derive(Debug, Clone, Default, 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, -} - -impl Default for DescribedChunk { - /// No action yet, and no token counted yet. - fn default() -> Self { - Self { - actions: Vec::new(), - model: None, - usage: Some(Usage::default()), - } - } } impl DescribedChunk { @@ -69,7 +28,6 @@ impl DescribedChunk { pub(super) fn add(&mut self, response: Response>) { self.actions.extend(response.output); self.model = self.model.take().or(response.model); - self.usage = super::add_usage(self.usage, response.usage); } } @@ -78,8 +36,6 @@ impl DescribedChunk { struct SavedChunk { #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - usage: Option, actions: Vec, } @@ -100,7 +56,6 @@ pub(super) fn write(path: &Utf8Path, chunk: &DescribedChunk) -> anyhow::Result<( let saved = SavedChunk { model: chunk.model.clone(), - usage: chunk.usage.map(TokenUsage::from), actions: chunk .actions .iter() @@ -141,7 +96,6 @@ pub(super) fn read(path: &Utf8Path) -> anyhow::Result { Ok(DescribedChunk { actions, model: saved.model, - usage: saved.usage.map(Usage::from), }) } @@ -169,10 +123,6 @@ mod tests { }, ], model: Some("gpt-test-2026-09-30".to_owned()), - usage: Some(Usage { - input_tokens: 10, - output_tokens: 20, - }), }; write(&path, &chunk).expect("written"); @@ -183,18 +133,17 @@ mod tests { } #[test] - fn unreported_model_and_usage_stay_unknown() { + fn an_unreported_model_stays_unknown() { + // oracle: property round-trip 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 index e598d98339..1a5c3b6cb4 100644 --- a/crates/devolutions-gateway-ai/src/recording_analysis/mod.rs +++ b/crates/devolutions-gateway-ai/src/recording_analysis/mod.rs @@ -33,8 +33,8 @@ use std::fmt; use std::fs::File; use std::io::{self, BufWriter}; use std::ops::Range; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use camino::{Utf8Path, Utf8PathBuf}; @@ -51,7 +51,7 @@ use self::transcript::TranscriptError; use self::video::VideoError; use crate::screen_actions::Screenshot; use crate::session_actions::Action; -use crate::{AiClient, Error, Language, Usage}; +use crate::{AiClient, Error, Language, Response, Usage}; /// Input tokens sent in one AI request, estimated at 4 characters per token. const MAX_INPUT_TOKENS_PER_REQUEST: usize = 100_000; @@ -105,13 +105,14 @@ pub enum Progress { /// /// Sent every half second while it lasts. Reading { read_bytes: u64, total_bytes: u64 }, - /// Asking the AI: chunks already described, out of all chunks. + /// Asking the AI: chunks already described, out of all chunks, and the tokens of the requests sent so far. /// - /// Sent before each chunk, then once all are described. That last event also covers the summary request, which - /// follows when the chunks have actions. + /// Sent before and after each request to the AI, and once all chunks are described. + /// A request counts as unreported until its answer comes, so usage cut off with a request is never shown as complete. Describing { described_chunks: usize, total_chunks: usize, + usage: UsageSoFar, }, /// Writing the log from the answers. Writing, @@ -138,6 +139,7 @@ impl Progress { Self::Describing { described_chunks, total_chunks, + .. } => Some(( u64::try_from(described_chunks).unwrap_or(u64::MAX), u64::try_from(total_chunks).unwrap_or(u64::MAX), @@ -175,6 +177,7 @@ impl fmt::Display for Progress { Self::Describing { described_chunks, total_chunks, + .. } => write!( f, "asking the AI: {described_chunks} of {total_chunks} chunks described" @@ -184,6 +187,32 @@ impl fmt::Display for Progress { } } +/// Tokens the provider counted for the requests of an analysis, and the requests it did not count. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct UsageSoFar { + /// Tokens of the prompts and the inputs. + pub input_tokens: u64, + /// Tokens of the answers. + pub output_tokens: u64, + /// Requests that may have cost tokens the provider did not report, such as an answer without a token count, a request + /// that got no answer, or one still waiting for it; while it is not zero, the two counts are short of what the + /// provider billed. + pub unreported_requests: u64, +} + +impl UsageSoFar { + /// Counts the tokens of one more request, `None` when the provider did not report them. + fn add(&mut self, usage: Option) { + match usage { + Some(usage) => { + self.input_tokens = self.input_tokens.saturating_add(usage.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(usage.output_tokens); + } + None => self.unreported_requests = self.unreported_requests.saturating_add(1), + } + } +} + #[expect(clippy::cast_precision_loss, reason = "shown with one decimal")] fn megabytes(bytes: u64) -> f64 { bytes as f64 / 1_048_576.0 @@ -203,10 +232,10 @@ pub struct RecordingAnalysis { /// Versions of the prompts that wrote the log, such as /// `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. pub prompt_version: String, - /// Tokens the provider counted for the whole analysis, answers cut and asked again included. + /// Tokens of the requests this analysis sent, answers cut and asked again included. /// - /// `None` when the provider did not report them for every request. - pub usage: Option, + /// An analysis that resumed from the working files of an earlier one does not count the requests of that one. + pub usage: UsageSoFar, } /// Error returned when an analysis fails. @@ -354,8 +383,9 @@ impl AnalyzeRecording<'_> { /// Starts the analysis on the Tokio runtime and returns its events. /// /// The events are [`RecordingAnalysisEvent::Progress`] while it runs, then [`RecordingAnalysisEvent::Complete`] - /// or [`RecordingAnalysisEvent::Failure`], which is always the last one. Progress events are dropped while the - /// channel is full, so a slow reader only misses some of them; the last event is never dropped. + /// or [`RecordingAnalysisEvent::Failure`], which is always the last one. + /// [`Progress::Reading`] events are dropped while the channel is full, so a slow reader only misses some of them; + /// the other events never are, so the last [`Progress::Describing`] counts every request sent before a failure. /// The channel ending without a last event means the analysis panicked. /// /// Dropping the receiver cancels the analysis. @@ -387,6 +417,7 @@ impl AnalyzeRecording<'_> { max_output_tokens: self.max_output_tokens, cancel, events, + usage: Mutex::new(UsageSoFar::default()), }; tokio::spawn(async move { @@ -428,6 +459,7 @@ struct Run { max_output_tokens: Option, cancel: CancellationToken, events: mpsc::Sender, + usage: Mutex, } impl Run { @@ -461,7 +493,7 @@ impl Run { move || prepare(&manifest, &dir, &workspace, &settings, &read_bytes, &cancel) }); // The first event of an analysis is always the start of the reading, however quickly the files are read. - self.report(Progress::Reading { + self.report_reading(Progress::Reading { read_bytes: 0, total_bytes, }); @@ -478,7 +510,7 @@ impl Run { .map_err(|_| RecordingAnalysisError::Files("an AI analysis step panicked".to_owned()))??; } _ = ticks.tick() => { - self.report(Progress::Reading { + self.report_reading(Progress::Reading { read_bytes: read_bytes.load(Ordering::Relaxed), total_bytes, }); @@ -500,19 +532,13 @@ impl Run { continue; } - self.report(Progress::Describing { - described_chunks: index, - total_chunks: total, - }); - let described = match chunk { Chunk::Transcript { file } => { let transcript = tokio::fs::read_to_string(workspace.join(file)) .await .map_err(|error| files_error(&error))?; - let describe = - describe_transcript(&self.client, self.max_output_tokens, &self.language, &transcript); + let describe = self.describe_transcript(index, total, &transcript); tokio::select! { () = cancel.cancelled() => return Err(RecordingAnalysisError::Cancelled), @@ -526,13 +552,7 @@ impl Run { None => None, }; - let describe = describe_screenshots( - &self.client, - self.max_output_tokens, - &self.language, - &frames, - context.as_ref(), - ); + let describe = self.describe_screenshots(index, total, &frames, context.as_ref()); tokio::select! { () = cancel.cancelled() => return Err(RecordingAnalysisError::Cancelled), @@ -545,19 +565,15 @@ impl Run { 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(Progress::Describing { - described_chunks: total, - total_chunks: total, - }); + self.report_describing(total, total).await; - let DescribedChunk { actions, model, usage } = blocking({ + let DescribedChunk { actions, model } = blocking({ let workspace = workspace.clone(); let cancel = cancel.clone(); move || read_checkpoints(&workspace, total, &cancel) @@ -575,17 +591,15 @@ impl Run { let summary = tokio::select! { () = cancel.cancelled() => return Err(RecordingAnalysisError::Cancelled), - summary = request.send() => summary, + summary = self.ask(request.send(), total, total) => summary, }; Some(summary?) }; - let (model, usage) = match &summary { - Some(summary) => (model.or_else(|| summary.model.clone()), add_usage(usage, summary.usage)), - None => (model, usage), - }; - let model = model.unwrap_or_else(|| self.client.model().to_owned()); + let model = model + .or_else(|| summary.as_ref().and_then(|summary| summary.model.clone())) + .unwrap_or_else(|| self.client.model().to_owned()); let prompt_version = match &summary { Some(_) => format!( "{}+{}", @@ -596,7 +610,7 @@ impl Run { }; let action_count = actions.len(); - self.report(Progress::Writing); + self.report(Progress::Writing).await; blocking({ let workspace = workspace.clone(); @@ -642,12 +656,170 @@ impl Run { actions: action_count, model, prompt_version, - usage, + usage: self.usage(), + }) + } + + /// Asks the AI about one transcript chunk, the chunk `index` of `total`; a truncated answer is asked again as two + /// halves. + async fn describe_transcript( + &self, + index: usize, + total: usize, + chunk: &str, + ) -> Result { + let mut parts = VecDeque::from([chunk]); + let mut described = DescribedChunk::default(); + + while let Some(part) = parts.pop_front() { + let mut request = self + .client + .describe_session_actions(part) + .language(self.language.clone()); + + if let Some(max_output_tokens) = self.max_output_tokens { + request = request.max_output_tokens(max_output_tokens); + } + + let answer = self.ask(request.send(), index, total).await; + + match answer { + Ok(response) => described.add(response), + Err(Error::Truncated { .. }) => { + 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) + } + + /// Asks the AI about one chunk of screenshots, the chunk `index` of `total`, in parts that fit in a request; a + /// truncated answer is asked again as two halves. + /// + /// Each part starts with the screenshot before it as context: `context` for the first part. + async fn describe_screenshots( + &self, + index: usize, + total: usize, + frames: &[LoadedScreenshot], + context: Option<&LoadedScreenshot>, + ) -> Result { + let mut parts = VecDeque::from(split_by_size(frames)); + let mut described = DescribedChunk::default(); + + while let Some(part) = parts.pop_front() { + let before = match part.start { + 0 => context, + start => frames.get(start - 1), + }; + + let screenshots = before + .map(|before| before.as_screenshot(true)) + .into_iter() + .chain(frames[part.clone()].iter().map(|frame| frame.as_screenshot(false))) + .collect::>(); + + let mut request = self + .client + .describe_screen_actions(&screenshots) + .language(self.language.clone()); + + if let Some(max_output_tokens) = self.max_output_tokens { + request = request.max_output_tokens(max_output_tokens); + } + + let answer = self.ask(request.send(), index, total).await; + + match answer { + Ok(response) => described.add(response), + Err(Error::Truncated { .. }) => { + if part.len() < 2 { + return Err(RecordingAnalysisError::Truncated); + } + + let middle = part.start + part.len() / 2; + debug!( + screenshots = part.len(), + "AI answer truncated; asking again in two halves" + ); + parts.push_front(middle..part.end); + parts.push_front(part.start..middle); + } + Err(error) => return Err(error.into()), + } + } + + Ok(described) + } + + /// Sends one request to the AI and counts its tokens, reporting them with the chunks described so far before the + /// request, as one unreported request, and again once it is answered. + async fn ask( + &self, + request: impl Future, Error>>, + described_chunks: usize, + total_chunks: usize, + ) -> Result, Error> { + self.usage.lock().unwrap_or_else(PoisonError::into_inner).add(None); + self.report_describing(described_chunks, total_chunks).await; + + let answer = request.await; + + { + let mut usage = self.usage.lock().unwrap_or_else(PoisonError::into_inner); + usage.unreported_requests -= 1; + + match &answer { + Ok(response) => usage.add(response.usage), + Err(Error::Truncated { usage: reported }) => usage.add(*reported), + // An error status means the provider turned the request down instead of answering it. + Err(Error::Status { .. }) => {} + // The answer, if any, was lost or unreadable, so the provider may have counted tokens we never saw. + Err(_) => usage.add(None), + } + } + + self.report_describing(described_chunks, total_chunks).await; + + answer + } + + fn usage(&self) -> UsageSoFar { + *self.usage.lock().unwrap_or_else(PoisonError::into_inner) + } + + async fn report_describing(&self, described_chunks: usize, total_chunks: usize) { + self.report(Progress::Describing { + described_chunks, + total_chunks, + usage: self.usage(), }) + .await; + } + + /// Sends a progress event, waiting while the reader is behind; a reader that went away cancels the analysis. + async fn report(&self, progress: Progress) { + if self + .events + .send(RecordingAnalysisEvent::Progress(progress)) + .await + .is_err() + { + self.cancel.cancel(); + } } - /// Sends a progress event, unless the reader is behind; a reader that went away cancels the analysis. - fn report(&self, progress: Progress) { + /// Sends a reading event, unless the reader is behind, since another one follows soon; a reader that went away + /// cancels the analysis. + fn report_reading(&self, progress: Progress) { if let Err(mpsc::error::TrySendError::Closed(_)) = self.events.try_send(RecordingAnalysisEvent::Progress(progress)) { @@ -677,43 +849,6 @@ fn open_counted(path: &Utf8Path, read_bytes: &Arc) -> io::Result, - language: &Language, - chunk: &str, -) -> Result { - let mut parts = VecDeque::from([chunk]); - let mut described = DescribedChunk::default(); - - while let Some(part) = parts.pop_front() { - let mut request = client.describe_session_actions(part).language(language.clone()); - - if let Some(max_output_tokens) = max_output_tokens { - request = request.max_output_tokens(max_output_tokens); - } - - match request.send().await { - Ok(response) => described.add(response), - 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) -} - /// A screenshot read back from the working files. struct LoadedScreenshot { frame: PlannedFrame, @@ -750,62 +885,6 @@ async fn read_screenshots( Ok(loaded) } -/// Asks the AI about one chunk of screenshots, in parts that fit in a request; a truncated answer is asked again as -/// two halves. -/// -/// Each part starts with the screenshot before it as context: `context` for the first part. -async fn describe_screenshots( - client: &AiClient, - max_output_tokens: Option, - language: &Language, - frames: &[LoadedScreenshot], - context: Option<&LoadedScreenshot>, -) -> Result { - let mut parts = VecDeque::from(split_by_size(frames)); - let mut described = DescribedChunk::default(); - - while let Some(part) = parts.pop_front() { - let before = match part.start { - 0 => context, - start => frames.get(start - 1), - }; - - let screenshots = before - .map(|before| before.as_screenshot(true)) - .into_iter() - .chain(frames[part.clone()].iter().map(|frame| frame.as_screenshot(false))) - .collect::>(); - - let mut request = client.describe_screen_actions(&screenshots).language(language.clone()); - - if let Some(max_output_tokens) = max_output_tokens { - request = request.max_output_tokens(max_output_tokens); - } - - match request.send().await { - Ok(response) => described.add(response), - Err(Error::Truncated { usage }) => { - described.usage = add_usage(described.usage, usage); - - if part.len() < 2 { - return Err(RecordingAnalysisError::Truncated); - } - - let middle = part.start + part.len() / 2; - debug!( - screenshots = part.len(), - "AI answer truncated; asking again in two halves" - ); - parts.push_front(middle..part.end); - parts.push_front(part.start..middle); - } - Err(error) => return Err(error.into()), - } - } - - Ok(described) -} - /// Cuts `frames` into consecutive parts of at most [`MAX_SCREENSHOT_BYTES_PER_REQUEST`] bytes, each with one /// screenshot at least. fn split_by_size(frames: &[LoadedScreenshot]) -> Vec> { @@ -1035,11 +1114,6 @@ fn in_time_order(recordings: &[R], start_time: impl Fn(&R) -> i64) -> sorted } -/// 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 { @@ -1068,8 +1142,7 @@ fn split_in_half(part: &str) -> Option<(&str, &str)> { Some(part.split_at(cut)) } -/// Reads the actions of every checkpoint, in time order, with the first model reported for a chunk and the tokens of -/// all of them. +/// Reads the actions of every checkpoint, in time order, with the first model reported for a chunk. fn read_checkpoints( workspace: &Utf8Path, total: usize, @@ -1084,7 +1157,6 @@ fn read_checkpoints( let described = checkpoint::read(&checkpoint::path(workspace, index)).map_err(|error| files_error(&error))?; all.model = all.model.or(described.model); - all.usage = add_usage(all.usage, described.usage); all.actions.extend(described.actions); } diff --git a/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs b/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs index 82b18b7712..702381e8fe 100644 --- a/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs +++ b/crates/devolutions-gateway-ai/src/recording_analysis/tests.rs @@ -15,7 +15,8 @@ const REPORTED_MODEL: &str = "gpt-test-2026-09-30"; const SUMMARY_PROMPT_START: &str = "You summarize"; /// A mock OpenAI-compatible provider: `status` picks the answer of the n-th request, 0 being a valid answer, which is -/// the action `Step n`, or `Summary n` for a summary request. +/// the action `Step n`, or `Summary n` for a summary request, counted as 10 input and 20 output tokens; 1 is the same +/// answer without a token count. /// /// Returns a client for it and the bodies of the requests it got. async fn spawn_provider(status: fn(usize) -> u16) -> (AiClient, Arc>>) { @@ -73,9 +74,8 @@ async fn spawn_provider_answering( }; let (status, answer) = match status(index) { - 0 => ( - 200, - serde_json::json!({ + scripted @ (0 | 1) => { + let mut answer = serde_json::json!({ "model": REPORTED_MODEL, "choices": [{ "index": 0, @@ -85,9 +85,12 @@ async fn spawn_provider_answering( }, "finish_reason": "stop", }], - "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 }, - }), - ), + }); + if scripted == 0 { + answer["usage"] = serde_json::json!({ "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 }); + } + (200, answer) + } status => ( status, serde_json::json!({ "error": { "message": "scripted failure" } }), @@ -182,6 +185,14 @@ async fn finish( panic!("the events ended without a last event"); } +fn tokens(input_tokens: u64, output_tokens: u64) -> UsageSoFar { + UsageSoFar { + input_tokens, + output_tokens, + unreported_requests: 0, + } +} + fn entries(log: &Utf8Path) -> Vec { std::fs::read_to_string(log) .unwrap() @@ -222,10 +233,11 @@ async fn the_log_is_left_next_to_the_manifest_and_the_working_files_removed() { ); assert_eq!( analysis.usage, - Some(Usage { + UsageSoFar { input_tokens: 20, - output_tokens: 40 - }), + output_tokens: 40, + unreported_requests: 0, + }, "the summary request counts too" ); assert_eq!( @@ -254,6 +266,7 @@ async fn the_log_is_left_next_to_the_manifest_and_the_working_files_removed() { }; assert_eq!(total_bytes, recording_size); assert!(read_bytes <= total_bytes); + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis assert_eq!( progress .iter() @@ -263,13 +276,37 @@ async fn the_log_is_left_next_to_the_manifest_and_the_working_files_removed() { [ Progress::Describing { described_chunks: 0, - total_chunks: 1 + total_chunks: 1, + usage: UsageSoFar { + unreported_requests: 1, + ..tokens(0, 0) + }, + }, + Progress::Describing { + described_chunks: 0, + total_chunks: 1, + usage: tokens(10, 20), }, Progress::Describing { described_chunks: 1, - total_chunks: 1 + total_chunks: 1, + usage: tokens(10, 20), }, - ] + Progress::Describing { + described_chunks: 1, + total_chunks: 1, + usage: UsageSoFar { + unreported_requests: 1, + ..tokens(10, 20) + }, + }, + Progress::Describing { + described_chunks: 1, + total_chunks: 1, + usage: tokens(20, 40), + }, + ], + "a request counts as unreported until its answer comes" ); assert_eq!(progress.last(), Some(&Progress::Writing)); } @@ -298,14 +335,52 @@ async fn analysing_again_only_asks_about_the_chunks_left() { ); assert_eq!( analysis.usage, - Some(Usage { - input_tokens: 40, - output_tokens: 80 - }), - "chunk 0 counts from its checkpoint, the failed request not at all" + tokens(30, 60), + "only the requests of this analysis count, so chunk 0 belongs to the first one" + ); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn requests_without_a_token_count_are_counted_apart() { + let session = Session::new(10); + // The chunk is request 0, answered without a token count, and the summary request 1. + let (client, _requests) = spawn_provider(|index| if index == 0 { 1 } else { 0 }).await; + + let analysis = session.analyze(&client).await.unwrap(); + + assert_eq!( + analysis.usage, + UsageSoFar { + input_tokens: 10, + output_tokens: 20, + unreported_requests: 1, + } ); } +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn a_failed_analysis_reports_the_tokens_it_spent() { + // About 1 MB of transcript: three chunks. + let session = Session::new(10_000); + // Chunk 0 is answered, then the provider fails chunk 1 with a server error, which costs no token. + let (client, _requests) = spawn_provider(|index| if index == 1 { 503 } else { 0 }).await; + + let (progress, result) = finish( + client + .analyze_recording(&session.manifest, &session.manifest_path) + .start(), + ) + .await; + + result.unwrap_err(); + let Some(Progress::Describing { usage, .. }) = progress.last() else { + panic!("the tokens are reported before the failure: {progress:?}"); + }; + assert_eq!(*usage, tokens(10, 20)); +} + #[tokio::test] async fn working_files_of_another_analysis_are_not_reused() { let session = Session::new(10); @@ -320,7 +395,6 @@ async fn working_files_of_another_analysis_are_not_reused() { &DescribedChunk { actions: Vec::new(), model: None, - usage: None, }, ) .unwrap(); @@ -541,6 +615,7 @@ fn progress_tells_its_stage_and_how_far_it_is() { let describing = Progress::Describing { described_chunks: 2, total_chunks: 5, + usage: UsageSoFar::default(), }; assert_eq!(describing.stage(), "describing"); assert_eq!(describing.percent(), 40); @@ -651,15 +726,3 @@ fn halves_are_cut_on_the_line_boundary_closest_to_the_middle() { 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/provisioner-task-libsql/src/lib.rs b/crates/provisioner-task-libsql/src/lib.rs index 3a0f03972d..2a66cae6de 100644 --- a/crates/provisioner-task-libsql/src/lib.rs +++ b/crates/provisioner-task-libsql/src/lib.rs @@ -11,7 +11,7 @@ use anyhow::{Context as _, bail}; use async_trait::async_trait; use libsql::{Connection, Row, TransactionBehavior, params}; use provisioner_task::{ - AttemptStart, CreateOutcome, NewProvisionerTaskRecord, ProvisionerTaskRecord, ProvisionerTaskState, + AttemptStart, BeforeCommit, CreateOutcome, NewProvisionerTaskRecord, ProvisionerTaskRecord, ProvisionerTaskState, ProvisionerTaskStore, }; use time::OffsetDateTime; @@ -43,9 +43,9 @@ impl LibSqlProvisionerTaskStore { Ok(Self { conn: Mutex::new(conn) }) } - /// Ends the Task as `state` if it is currently in one of `from`. + /// Ends the Task as `state` if it is currently in one of `from`, once `before_commit` has run. /// - /// A Task past its deadline is failed as timed out first, and then not ended again. + /// A Task past its deadline is failed for it first, and then not ended again. async fn finish( &self, id: Uuid, @@ -53,6 +53,7 @@ impl LibSqlProvisionerTaskStore { state: ProvisionerTaskState, payload: serde_json::Value, now: OffsetDateTime, + before_commit: Option>, ) -> anyhow::Result { let conn = self.conn.lock().await; let tx = conn @@ -71,6 +72,13 @@ impl LibSqlProvisionerTaskStore { return Ok(false); } + if let Some(before_commit) = before_commit + && let Err(error) = before_commit.await + { + tx.rollback().await.context("end task finish")?; + return Err(error); + } + tx.execute( "UPDATE provisioner_task_records SET state = ?2, payload = ?3, finished_at = ?4 WHERE id = ?1", params![ @@ -131,11 +139,10 @@ async fn fail_if_overdue( return Ok(task); } - let payload = serde_json::json!({ - "reason": "timed out", - "details": { "lastPayload": task.payload }, - "attempts": task.attempts, - }); + let mut payload = serde_json::json!({ "error": { "kind": "deadlinePassed" } }); + if let Some(usage) = task.payload.get("usage") { + payload["usage"] = usage.clone(); + } conn.execute( "UPDATE provisioner_task_records SET state = 'failed', payload = ?2, finished_at = ?3 @@ -145,14 +152,19 @@ async fn fail_if_overdue( .await .context("fail overdue task")?; - warn!(task.id = %task.id, task.kind = %task.kind, attempts = task.attempts, "Task timed out"); + warn!(task.id = %task.id, task.kind = %task.kind, attempts = task.attempts, "Task failed past its deadline"); find(conn, task.id).await?.context("overdue task is missing") } #[async_trait] impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { - async fn create(&self, task: NewProvisionerTaskRecord, now: OffsetDateTime) -> anyhow::Result { + async fn create( + &self, + task: NewProvisionerTaskRecord, + now: OffsetDateTime, + store_job: BeforeCommit<'_>, + ) -> anyhow::Result { let conn = self.conn.lock().await; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) @@ -198,6 +210,11 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { .await .context("insert task")?; + if let Err(error) = store_job.await { + tx.rollback().await.context("end task creation")?; + return Err(error.context("store the task job")); + } + let created = find(&tx, task.id).await?.context("created task is missing")?; tx.commit().await.context("commit task creation")?; @@ -224,8 +241,8 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { async fn start_attempt( &self, id: Uuid, - job_token: Uuid, payload: serde_json::Value, + deadline_at: OffsetDateTime, now: OffsetDateTime, ) -> anyhow::Result { let conn = self.conn.lock().await; @@ -239,11 +256,6 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { return Ok(AttemptStart::Unknown); }; - if task.job_token != job_token { - tx.rollback().await.context("end task attempt")?; - return Ok(AttemptStart::Superseded); - } - let task = fail_if_overdue(&tx, task, now).await?; if task.state.is_finished() { @@ -253,9 +265,15 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { tx.execute( "UPDATE provisioner_task_records - SET state = 'running', payload = ?2, attempts = attempts + 1, started_at = COALESCE(started_at, ?3) + SET state = 'running', payload = ?2, attempts = attempts + 1, started_at = COALESCE(started_at, ?3), + deadline_at = ?4 WHERE id = ?1", - params![id.to_string(), payload.to_string(), now.unix_timestamp()], + params![ + id.to_string(), + payload.to_string(), + now.unix_timestamp(), + deadline_at.unix_timestamp() + ], ) .await .context("start task attempt")?; @@ -266,35 +284,6 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { Ok(AttemptStart::Started(started)) } - async fn replace_job_token(&self, id: Uuid, job_token: Uuid, now: OffsetDateTime) -> anyhow::Result { - let conn = self.conn.lock().await; - let tx = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .context("begin job token replacement")?; - - let Some(task) = find(&tx, id).await? else { - tx.rollback().await.context("end job token replacement")?; - return Ok(false); - }; - let task = fail_if_overdue(&tx, task, now).await?; - - let replaced = !task.state.is_finished(); - - if replaced { - tx.execute( - "UPDATE provisioner_task_records SET job_token = ?2 WHERE id = ?1", - params![id.to_string(), job_token.to_string()], - ) - .await - .context("replace task job token")?; - } - - tx.commit().await.context("commit job token replacement")?; - - Ok(replaced) - } - async fn list_unfinished(&self, now: OffsetDateTime) -> anyhow::Result> { let conn = self.conn.lock().await; let tx = conn @@ -331,7 +320,13 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { Ok(unfinished) } - async fn update_running(&self, id: Uuid, payload: serde_json::Value, now: OffsetDateTime) -> anyhow::Result { + async fn update_running( + &self, + id: Uuid, + payload: serde_json::Value, + deadline_at: OffsetDateTime, + now: OffsetDateTime, + ) -> anyhow::Result { let conn = self.conn.lock().await; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) @@ -348,8 +343,8 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { if running { tx.execute( - "UPDATE provisioner_task_records SET payload = ?2 WHERE id = ?1", - params![id.to_string(), payload.to_string()], + "UPDATE provisioner_task_records SET payload = ?2, deadline_at = ?3 WHERE id = ?1", + params![id.to_string(), payload.to_string(), deadline_at.unix_timestamp()], ) .await .context("update running task")?; @@ -360,13 +355,20 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { Ok(running) } - async fn succeed(&self, id: Uuid, payload: serde_json::Value, now: OffsetDateTime) -> anyhow::Result { + async fn succeed( + &self, + id: Uuid, + payload: serde_json::Value, + now: OffsetDateTime, + publish: BeforeCommit<'_>, + ) -> anyhow::Result { self.finish( id, &[ProvisionerTaskState::Running], ProvisionerTaskState::Succeeded, payload, now, + Some(publish), ) .await } @@ -378,6 +380,7 @@ impl ProvisionerTaskStore for LibSqlProvisionerTaskStore { ProvisionerTaskState::Failed, payload, now, + None, ) .await } @@ -539,6 +542,11 @@ mod tests { OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("valid timestamp") } + /// Nothing to do before the commit. + fn nothing() -> BeforeCommit<'static> { + Box::pin(async { Ok(()) }) + } + fn new_task(target: &str) -> NewProvisionerTaskRecord { NewProvisionerTaskRecord { id: Uuid::new_v4(), @@ -551,7 +559,7 @@ mod tests { } async fn create(store: &LibSqlProvisionerTaskStore, task: NewProvisionerTaskRecord) -> CreateOutcome { - store.create(task, now()).await.expect("create task") + store.create(task, now(), nothing()).await.expect("create task") } #[tokio::test] @@ -566,9 +574,30 @@ mod tests { assert_eq!(created.state, ProvisionerTaskState::Queued); assert_eq!(created.attempts, 0); assert_eq!(created.params, task.params); + assert_eq!(created.job_token, task.job_token); assert_eq!(store.get(task.id, now()).await.expect("get task"), Some(created)); } + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Management + #[tokio::test] + async fn a_task_whose_job_is_not_stored_is_not_recorded() { + let store = store().await; + let task = new_task("session-1"); + + let error = store + .create( + task.clone(), + now(), + Box::pin(async { anyhow::bail!("the job queue is gone") }), + ) + .await + .expect_err("the job is not stored"); + + assert!(error.to_string().contains("store the task job"), "{error:#}"); + assert_eq!(store.get(task.id, now()).await.expect("get task"), None); + assert!(matches!(create(&store, task).await, CreateOutcome::Created(_))); + } + #[tokio::test] async fn same_request_again_returns_the_existing_task() { let store = store().await; @@ -640,7 +669,7 @@ mod tests { assert!( !store - .succeed(task.id, serde_json::json!({}), now()) + .succeed(task.id, serde_json::json!({}), now(), nothing()) .await .expect("succeed queued task") ); @@ -661,6 +690,74 @@ mod tests { ); } + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis + #[tokio::test] + async fn a_task_whose_result_is_not_published_stays_running() { + let store = store().await; + let task = new_task("session-1"); + create(&store, task.clone()).await; + store + .start_attempt(task.id, serde_json::json!({}), now() + Duration::hours(2), now()) + .await + .expect("start"); + let published = std::sync::atomic::AtomicBool::new(false); + + let error = store + .succeed( + task.id, + serde_json::json!({ "log": "ai-analysis-0.slog" }), + now(), + Box::pin(async { anyhow::bail!("the manifest cannot be written") }), + ) + .await + .expect_err("the success is not recorded"); + assert!(error.to_string().contains("manifest"), "{error:#}"); + assert_eq!( + store.get(task.id, now()).await.expect("get task").expect("task").state, + ProvisionerTaskState::Running + ); + + assert!( + store + .succeed( + task.id, + serde_json::json!({ "log": "ai-analysis-0.slog" }), + now(), + Box::pin(async { + published.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }), + ) + .await + .expect("succeed") + ); + assert!(published.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!( + store.get(task.id, now()).await.expect("get task").expect("task").state, + ProvisionerTaskState::Succeeded + ); + } + + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis + #[tokio::test] + async fn a_task_that_is_not_running_does_not_publish() { + let store = store().await; + let task = new_task("session-1"); + create(&store, task.clone()).await; + + assert!( + !store + .succeed( + task.id, + serde_json::json!({}), + now(), + Box::pin(async { panic!("a queued Task publishes nothing") }), + ) + .await + .expect("succeed queued task") + ); + } + #[tokio::test] async fn overdue_task_is_failed_and_frees_its_target() { let store = store().await; @@ -670,8 +767,8 @@ mod tests { store .start_attempt( overdue.id, - overdue.job_token, - serde_json::json!({ "done": 3, "total": 10 }), + serde_json::json!({ "done": 3, "total": 10, "usage": { "inputTokens": 1200 } }), + now() - Duration::minutes(1), now() - Duration::minutes(2), ) .await @@ -688,9 +785,10 @@ mod tests { .expect("get task") .expect("task exists"); assert_eq!(failed.state, ProvisionerTaskState::Failed); - assert_eq!(failed.payload["reason"], "timed out"); - assert_eq!(failed.payload["attempts"], 1); - assert_eq!(failed.payload["details"]["lastPayload"]["done"], 3); + assert_eq!( + failed.payload, + serde_json::json!({ "error": { "kind": "deadlinePassed" }, "usage": { "inputTokens": 1200 } }) + ); } #[tokio::test] @@ -701,20 +799,35 @@ mod tests { assert!(matches!( store - .start_attempt(task.id, task.job_token, serde_json::json!({ "done": 0 }), now()) + .start_attempt( + task.id, + serde_json::json!({ "done": 0 }), + now() + Duration::hours(2), + now() + ) .await .expect("start"), AttemptStart::Started(_) )); assert!( store - .update_running(task.id, serde_json::json!({ "done": 1 }), now()) + .update_running( + task.id, + serde_json::json!({ "done": 1 }), + now() + Duration::hours(2), + now() + ) .await .expect("update") ); assert!(matches!( store - .start_attempt(task.id, task.job_token, serde_json::json!({ "done": 1 }), now()) + .start_attempt( + task.id, + serde_json::json!({ "done": 1 }), + now() + Duration::hours(2), + now() + ) .await .expect("retry"), AttemptStart::Started(_) @@ -733,20 +846,20 @@ mod tests { ); assert!( !store - .succeed(task.id, serde_json::json!({}), now()) + .succeed(task.id, serde_json::json!({}), now(), nothing()) .await .expect("finish again") ); assert!(matches!( store - .start_attempt(task.id, task.job_token, serde_json::json!({}), now()) + .start_attempt(task.id, serde_json::json!({}), now() + Duration::hours(2), now()) .await .expect("start after finish"), AttemptStart::Finished(_) )); assert!( !store - .update_running(task.id, serde_json::json!({}), now()) + .update_running(task.id, serde_json::json!({}), now() + Duration::hours(2), now()) .await .expect("update after finish") ); @@ -763,13 +876,23 @@ mod tests { let task = new_task("session-1"); create(&store, task.clone()).await; store - .start_attempt(task.id, task.job_token, serde_json::json!({ "done": 0 }), now()) + .start_attempt( + task.id, + serde_json::json!({ "done": 0 }), + now() + Duration::hours(2), + now(), + ) .await .expect("start"); assert!( store - .succeed(task.id, serde_json::json!({ "log": "ai-analysis-0.slog" }), now()) + .succeed( + task.id, + serde_json::json!({ "log": "ai-analysis-0.slog" }), + now(), + nothing() + ) .await .expect("succeed") ); @@ -787,20 +910,30 @@ mod tests { overdue.deadline_at = now() + Duration::minutes(1); create(&store, overdue.clone()).await; store - .start_attempt(overdue.id, overdue.job_token, serde_json::json!({ "done": 3 }), now()) + .start_attempt( + overdue.id, + serde_json::json!({ "done": 3 }), + now() + Duration::minutes(1), + now(), + ) .await .expect("start"); let late = now() + Duration::minutes(2); assert!( !store - .update_running(overdue.id, serde_json::json!({ "done": 4 }), late) + .update_running( + overdue.id, + serde_json::json!({ "done": 4 }), + late + Duration::minutes(30), + late, + ) .await .expect("update late") ); assert!( !store - .succeed(overdue.id, serde_json::json!({ "log": "late" }), late) + .succeed(overdue.id, serde_json::json!({ "log": "late" }), late, nothing()) .await .expect("succeed late") ); @@ -817,8 +950,65 @@ mod tests { .expect("get task") .expect("task exists"); assert_eq!(failed.state, ProvisionerTaskState::Failed); - assert_eq!(failed.payload["reason"], "timed out"); - assert_eq!(failed.payload["details"]["lastPayload"]["done"], 3); + assert_eq!( + failed.payload, + serde_json::json!({ "error": { "kind": "deadlinePassed" } }) + ); + } + + #[tokio::test] + async fn an_attempt_start_sets_the_deadline() { + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis + let store = store().await; + let task = new_task("session-1"); + create(&store, task.clone()).await; + store + .start_attempt(task.id, serde_json::json!({}), now() + Duration::minutes(30), now()) + .await + .expect("start"); + + // Still within the deadline of the queued Task, but past the one its attempt set. + let task = store + .get(task.id, now() + Duration::minutes(40)) + .await + .expect("get task") + .expect("task exists"); + assert_eq!(task.state, ProvisionerTaskState::Failed); + } + + #[tokio::test] + async fn progress_moves_the_deadline_forward() { + // oracle: intent crates/provisioner-task/INTENT.md §invariants + let store = store().await; + let task = new_task("session-1"); + create(&store, task.clone()).await; + store + .start_attempt(task.id, serde_json::json!({}), now() + Duration::minutes(30), now()) + .await + .expect("start"); + let moved = now() + Duration::minutes(50); + assert!( + store + .update_running( + task.id, + serde_json::json!({ "done": 1 }), + moved, + now() + Duration::minutes(20) + ) + .await + .expect("report progress") + ); + + let running = store + .get(task.id, now() + Duration::minutes(40)) + .await + .expect("get task") + .expect("task exists"); + assert_eq!(running.state, ProvisionerTaskState::Running); + assert_eq!(running.deadline_at, moved); + + let failed = store.get(task.id, moved).await.expect("get task").expect("task exists"); + assert_eq!(failed.state, ProvisionerTaskState::Failed); } #[tokio::test] @@ -830,7 +1020,7 @@ mod tests { assert!(matches!( store - .start_attempt(overdue.id, overdue.job_token, serde_json::json!({}), now()) + .start_attempt(overdue.id, serde_json::json!({}), now() + Duration::hours(2), now()) .await .expect("start attempt"), AttemptStart::Finished(_) @@ -841,7 +1031,10 @@ mod tests { .expect("get task") .expect("task exists"); assert_eq!(read.state, ProvisionerTaskState::Failed); - assert_eq!(read.payload["reason"], "timed out"); + assert_eq!( + read.payload, + serde_json::json!({ "error": { "kind": "deadlinePassed" } }) + ); let CreateOutcome::Existing(again) = create(&store, overdue).await else { panic!("same request must return the existing task"); @@ -849,65 +1042,6 @@ mod tests { assert_eq!(again.state, ProvisionerTaskState::Failed); } - #[tokio::test] - async fn only_the_job_holding_the_token_runs_the_task() { - let store = store().await; - let task = new_task("session-1"); - create(&store, task.clone()).await; - - assert_eq!( - store - .start_attempt(task.id, Uuid::new_v4(), serde_json::json!({}), now()) - .await - .expect("start with another token"), - AttemptStart::Superseded - ); - assert_eq!( - store - .get(task.id, now()) - .await - .expect("get task") - .expect("task") - .attempts, - 0, - "a superseded job counts no attempt" - ); - - let new_token = Uuid::new_v4(); - assert!( - store - .replace_job_token(task.id, new_token, now()) - .await - .expect("replace token") - ); - assert_eq!( - store - .start_attempt(task.id, task.job_token, serde_json::json!({}), now()) - .await - .expect("start with the old token"), - AttemptStart::Superseded - ); - assert!(matches!( - store - .start_attempt(task.id, new_token, serde_json::json!({}), now()) - .await - .expect("start with the new token"), - AttemptStart::Started(started) if started.job_token == new_token && started.attempts == 1 - )); - - store - .fail(task.id, serde_json::json!({ "reason": "boom" }), now()) - .await - .expect("fail"); - assert!( - !store - .replace_job_token(task.id, Uuid::new_v4(), now()) - .await - .expect("replace token of a finished task"), - "a finished Task gets no new job" - ); - } - #[tokio::test] async fn unfinished_tasks_are_listed_and_overdue_ones_failed() { let store = store().await; @@ -920,7 +1054,7 @@ mod tests { create(&store, task.clone()).await; } store - .start_attempt(running.id, running.job_token, serde_json::json!({}), now()) + .start_attempt(running.id, serde_json::json!({}), now() + Duration::hours(2), now()) .await .expect("start"); store @@ -942,8 +1076,8 @@ mod tests { .await .expect("get task") .expect("task") - .payload["reason"], - "timed out" + .payload["error"]["kind"], + "deadlinePassed" ); } @@ -954,20 +1088,18 @@ mod tests { assert_eq!(store.get(Uuid::new_v4(), now()).await.expect("get task"), None); assert_eq!( store - .start_attempt(Uuid::new_v4(), Uuid::new_v4(), serde_json::json!({}), now()) + .start_attempt(Uuid::new_v4(), serde_json::json!({}), now() + Duration::hours(2), now()) .await .expect("start attempt"), AttemptStart::Unknown ); - assert!( - !store - .replace_job_token(Uuid::new_v4(), Uuid::new_v4(), now()) - .await - .expect("replace job token") - ); for finished in [ - store.update_running(Uuid::new_v4(), serde_json::json!({}), now()).await, - store.succeed(Uuid::new_v4(), serde_json::json!({}), now()).await, + store + .update_running(Uuid::new_v4(), serde_json::json!({}), now() + Duration::hours(2), now()) + .await, + store + .succeed(Uuid::new_v4(), serde_json::json!({}), now(), nothing()) + .await, store.fail(Uuid::new_v4(), serde_json::json!({}), now()).await, ] { assert!(!finished.expect("update unknown task")); diff --git a/crates/provisioner-task/INTENT.md b/crates/provisioner-task/INTENT.md index 40d2a64d25..f8dcc766a4 100644 --- a/crates/provisioner-task/INTENT.md +++ b/crates/provisioner-task/INTENT.md @@ -1,5 +1,5 @@ # Background -Provisioner Tasks are work that a provisioner (DVLS, Hub) assigns to Gateway. +Provisioner Tasks (hereafter "Tasks") are work that a provisioner (DVLS, Hub) assigns to Gateway. # Invariants The provisioner chooses each Task's ID, so it can ask again without knowing whether its first request went through. @@ -13,9 +13,12 @@ Only these transitions exist: - `Running` -> `Succeeded` - `Running` -> `Failed` -Each Task has a deadline counted from when Gateway receives it. +Each Task has a deadline. +While the Task runs, its deadline may move forward each time it makes progress. +Some work has no known length: a recording can be arbitrarily long, so any fixed deadline would leave some recordings impossible to analyze. +With a moving deadline, a Task fails only once it stops making progress, however long it runs. An unfinished Task past its deadline is failed, so a lost job can't block its target forever. # Tasks and jobs A Task is not a job. -A Task keeps what was asked, how it went and how it ended, and stays for auditing after its job is gone; the job queue only schedules and retries the work. +A Task keeps what was asked, how it went and how it ended, and stays for auditing after its job is gone; the job queue only delivers the job, and the job runs and retries the attempts itself. diff --git a/crates/provisioner-task/src/lib.rs b/crates/provisioner-task/src/lib.rs index be3d8431dc..33c002c0ed 100644 --- a/crates/provisioner-task/src/lib.rs +++ b/crates/provisioner-task/src/lib.rs @@ -3,6 +3,8 @@ //! A Task remembers what was asked, how it is going and how it ended, and it stays around for auditing. The work //! itself runs as a job on the job queue; that job is gone once it finishes, but its Task is not. +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; @@ -46,8 +48,11 @@ pub struct ProvisionerTaskRecord { pub started_at: Option, pub finished_at: Option, /// After this, an unfinished Task is failed, so a lost job can't block its target forever. + /// + /// It moves forward while the Task runs, each time an attempt starts or reports progress. pub deadline_at: OffsetDateTime, - /// The job that may run this Task: a job with another token is stale or a duplicate, and must not run it. + /// The job that runs this Task: a job with another token was stored for a Task that was never recorded, and must + /// not run this one. pub job_token: Uuid, } @@ -63,6 +68,10 @@ pub struct NewProvisionerTaskRecord { pub job_token: Uuid, } +/// Work the store does inside the transaction of a write, before it commits; an error rolls the write back and is +/// returned. See [`ProvisionerTaskStore::create`] and [`ProvisionerTaskStore::succeed`]. +pub type BeforeCommit<'a> = Pin> + Send + 'a>>; + #[derive(Debug, Clone, PartialEq)] pub enum CreateOutcome { Created(ProvisionerTaskRecord), @@ -81,50 +90,69 @@ pub enum AttemptStart { Started(ProvisionerTaskRecord), /// The Task is finished, possibly just now because it is past its deadline. Finished(ProvisionerTaskRecord), - /// Another job now runs the Task, so this one is stale or a duplicate. - Superseded, /// No Task has this ID. Unknown, } +/// Where Tasks are kept. +/// +/// A Task past its deadline fails with the payload `{ "error": { "kind": "deadlinePassed" } }`, plus the `usage` of +/// its last payload when it has one, so what it spent stays known. #[async_trait] pub trait ProvisionerTaskStore: Send + Sync { /// Records a new `queued` Task, unless the ID is known or its target is busy. /// + /// `store_job` runs once the record is written and before it is committed, so a Task is recorded only with its + /// job: if it fails, nothing is recorded and its error is returned. + /// /// Like every method that takes `now`, it first fails the Tasks it looks at that are past their deadline. - async fn create(&self, task: NewProvisionerTaskRecord, now: OffsetDateTime) -> anyhow::Result; + async fn create( + &self, + task: NewProvisionerTaskRecord, + now: OffsetDateTime, + store_job: BeforeCommit<'_>, + ) -> anyhow::Result; /// Reads a Task, after failing it if it is past its deadline. async fn get(&self, id: Uuid, now: OffsetDateTime) -> anyhow::Result>; - /// Marks the start of one more attempt by the job holding `job_token`: `running`, with one more attempt counted. + /// Marks the start of one more attempt: `running`, with one more attempt counted and its deadline moved to + /// `deadline_at`. /// - /// Only [`AttemptStart::Started`] lets the attempt run. A job that no longer holds the token gets - /// [`AttemptStart::Superseded`], and nothing changes. + /// Only [`AttemptStart::Started`] lets the attempt run. async fn start_attempt( &self, id: Uuid, - job_token: Uuid, payload: serde_json::Value, + deadline_at: OffsetDateTime, now: OffsetDateTime, ) -> anyhow::Result; - /// Hands an unfinished Task to a new job, so the job that had it stops at its next attempt. - /// - /// Only the start of an attempt checks the token, so call it only while the old job runs no attempt, such as at - /// startup before any job runs. Returns `false` if the Task is unknown or finished. - async fn replace_job_token(&self, id: Uuid, job_token: Uuid, now: OffsetDateTime) -> anyhow::Result; - /// Every unfinished Task, after failing those past their deadline. async fn list_unfinished(&self, now: OffsetDateTime) -> anyhow::Result>; - /// Replaces the payload of a `running` Task, e.g. to report progress. Returns `false` if it is not running, such - /// as when it was just failed for being past its deadline. - async fn update_running(&self, id: Uuid, payload: serde_json::Value, now: OffsetDateTime) -> anyhow::Result; + /// Replaces the payload of a `running` Task and moves its deadline to `deadline_at`, e.g. to report progress. + /// Returns `false` if it is not running, such as when it was just failed for being past its deadline. + async fn update_running( + &self, + id: Uuid, + payload: serde_json::Value, + deadline_at: OffsetDateTime, + now: OffsetDateTime, + ) -> anyhow::Result; /// Ends a `running` Task as `succeeded`. Returns `false` if it is not running, such as when it was just failed for /// being past its deadline. - async fn succeed(&self, id: Uuid, payload: serde_json::Value, now: OffsetDateTime) -> anyhow::Result; + /// + /// `publish` runs once the Task is found running and before its success is written, so a succeeded Task always + /// has what `publish` does: if it fails, the Task stays running and its error is returned. + async fn succeed( + &self, + id: Uuid, + payload: serde_json::Value, + now: OffsetDateTime, + publish: BeforeCommit<'_>, + ) -> anyhow::Result; /// Ends a `queued` or `running` Task as `failed`. Returns `false` if it was already finished, such as when it was /// just failed for being past its deadline. diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index d42ea03f45..a2c18215b2 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" @@ -38,6 +39,8 @@ sysevent-codes.path = "../crates/sysevent-codes" agent-tunnel = { path = "../crates/agent-tunnel" } agent-tunnel-libsql.path = "../crates/agent-tunnel-libsql" gateway-db.path = "../crates/gateway-db" +provisioner-task.path = "../crates/provisioner-task" +provisioner-task-libsql.path = "../crates/provisioner-task-libsql" ironrdp-pdu = { version = "0.9", features = ["std"] } ironrdp-core = { version = "0.2", features = ["std"] } ironrdp-rdcleanpath = "0.2" @@ -91,6 +94,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"] } socket2 = "0.5" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "stream", "http2", "socks"] } diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index d63c02b8e2..018a77c4a2 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -2613,6 +2613,245 @@ ifdef::internal-generation[] endif::internal-generation[] +[.Tasks] +=== Tasks + + +[.getAiAnalysis] +==== getAiAnalysis + +`GET /jet/tasks/recording-ai-analysis/{task_id}` + +Gets an AI analysis of a recorded session + +===== Description + +Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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/recording-ai-analysis/\{task_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 + +| task_id +| Task ID of the AI analysis +| X +| null +| + +|=== + + + + + + +===== Return Type + +<> + + +===== Content Type + +* application/json + +===== Responses + +.HTTP Response Codes +[cols="2,3,1"] +|=== +| Code | Message | Datatype + + +| 200 +| The AI analysis Task +| <> + + +| 400 +| Bad request +| <<>> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 404 +| No AI analysis has this Task ID +| <<>> + + +| 500 +| Unexpected server error +| <<>> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/tasks/recording-ai-analysis/\{task_id\}/GET/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + +[.startAiAnalysis] +==== startAiAnalysis + +`PUT /jet/tasks/recording-ai-analysis/{task_id}` + +Starts an AI analysis of a recorded session + +===== Description + +Gateway asks the AI provider what the user did in the session recording, and puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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/tasks/recording-ai-analysis/\{task_id\}/PUT/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 + +| task_id +| Task ID, chosen by the caller so it can send the same request again safely +| 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, AI settings or language +| <<>> + + +| 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/tasks/recording-ai-analysis/\{task_id\}/PUT/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + [.Traffic] === Traffic @@ -3405,238 +3644,868 @@ Requests a session token using a web application token | <<>> -| 401 -| Invalid or missing authorization header -| <<>> +| 401 +| Invalid or missing authorization header +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 415 +| Unsupported content type in request body +| <<>> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/webapp/session-token/POST/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + +[#models] +== Models + + +[#AccessScope] +=== _AccessScope_ + + + + + + +[.fields-AccessScope] +[cols="1"] +|=== +| Enum Values + +| * +| 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.recordings.search +| gateway.tasks.recording-ai-analysis.start +| gateway.tasks.recording-ai-analysis.read + +|=== + + +[#AckRequest] +=== _AckRequest_ + + + + +[.fields-AckRequest] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| ids +| X +| +| List of <> +| Array of event IDs to acknowledge (1-10000 items, ULID format) +| + +|=== + + + +[#AckResponse] +=== _AckResponse_ + + + + +[.fields-AckResponse] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| deleted_count +| X +| +| Long +| Number of events that were acknowledged and deleted +| int64 + +|=== + + + +[#AddressFamily] +=== _AddressFamily_ + + + + + + +[.fields-AddressFamily] +[cols="1"] +|=== +| Enum Values + +| IPv4 +| IPv6 + +|=== + + +[#AgentDomainAdvertisement] +=== _AgentDomainAdvertisement_ + + + + +[.fields-AgentDomainAdvertisement] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| auto_detected +| X +| +| Boolean +| Whether the Agent discovered the domain automatically. +| + +| domain +| X +| +| String +| Domain route advertised by the Agent. +| + +|=== + + + +[#AgentInfo] +=== _AgentInfo_ + + + + +[.fields-AgentInfo] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| agent_id +| X +| +| UUID +| Stable Agent identity. +| uuid + +| domains +| +| X +| List of <> +| Domain routes currently advertised by the Agent. +| + +| last_seen +| +| X +| Date +| Time of the Agent's last heartbeat. +| date-time + +| name +| X +| +| String +| Unique management name assigned during enrollment. +| + +| status +| X +| +| <> +| +| offline, online, unresponsive, + +| subnets +| +| X +| List of <> +| Subnet routes currently advertised by the Agent. +| + +|=== + + + +[#AgentStatus] +=== _AgentStatus_ + + + + + + +[.fields-AgentStatus] +[cols="1"] +|=== +| Enum Values + +| offline +| online +| unresponsive + +|=== + + +[#AiAnalysisBadAnswer] +=== _AiAnalysisBadAnswer_ + +What was wrong with an answer of the AI provider + + + + +[.fields-AiAnalysisBadAnswer] +[cols="1"] +|=== +| Enum Values + +| invalid +| truncated +| refused +| notExpectedJson + +|=== + + +[#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 + +|=== + + +[#AiAnalysisError] +=== _AiAnalysisError_ + +Why an AI analysis Task failed: a kind, with the fields of that kind; there is no text for people, so the caller +words it + + +[.fields-AiAnalysisError] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| file +| +| X +| String +| Recording file that cannot be read, for `unreadableRecording`. +| + +| kind +| X +| +| <> +| +| aiRejected, aiBadAnswer, keyLost, noRecording, mixedRecording, unreadableRecording, invalidTask, panicked, internal, attemptsExhausted, deadlinePassed, + +| last +| +| X +| <> +| +| + +| status +| +| X +| Integer +| HTTP status the AI provider answered, for `aiRejected`. +| int32 + +| why +| +| X +| <> +| +| invalid, truncated, refused, notExpectedJson, + +|=== + + + +[#AiAnalysisErrorKind] +=== _AiAnalysisErrorKind_ + +Kind of failure of an AI analysis Task + + + + +[.fields-AiAnalysisErrorKind] +[cols="1"] +|=== +| Enum Values + +| aiRejected +| aiBadAnswer +| keyLost +| noRecording +| mixedRecording +| unreadableRecording +| invalidTask +| panicked +| internal +| attemptsExhausted +| deadlinePassed + +|=== + + +[#AiAnalysisFailedPayload] +=== _AiAnalysisFailedPayload_ + +Payload of a `failed` AI analysis Task: why it failed, and the tokens it used + + +[.fields-AiAnalysisFailedPayload] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| error +| X +| +| <> +| +| + +| usage +| +| X +| <> +| +| + +|=== + + + +[#AiAnalysisParams] +=== _AiAnalysisParams_ + +Params of an AI analysis Task: what was asked, without the API key and the base URL + + +[.fields-AiAnalysisParams] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| language +| +| X +| String +| Language the AI writes in; absent when not asked, so the AI writes in English. +| + +| maxOutputTokens +| +| X +| Long +| Upper bound of tokens in each AI answer; null when not asked. +| int64 + +| model +| X +| +| String +| Model identifier, passed to the provider as is. +| + +| provider +| X +| +| <> +| +| openai, anthropic, mistral, gemini, openai-compatible, + +|=== + + + +[#AiAnalysisPayload] +=== _AiAnalysisPayload_ + +Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own + + +[.fields-AiAnalysisPayload] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| done +| +| X +| Long +| Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. +| int64 + +| step +| X +| +| String +| Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). +| + +| total +| +| X +| Long +| Work the step has in all, in the same unit as `done`; absent in the other steps. +| int64 + +| usage +| X +| X +| <> +| +| + +| fileName +| X +| +| String +| Name of the 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 +| Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. +| -| 403 -| Insufficient permissions -| <<>> +| error +| X +| +| <> +| +| +|=== -| 415 -| Unsupported content type in request body -| <<>> -|=== +[#AiAnalysisRecoverableError] +=== _AiAnalysisRecoverableError_ -ifdef::internal-generation[] -===== Implementation +A failure of an attempt that was worth another attempt -// markup not found, no include::{specDir}jet/webapp/session-token/POST/implementation.adoc[opts=optional] +[.fields-AiAnalysisRecoverableError] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format -endif::internal-generation[] +| kind +| X +| +| <> +| +| aiUnreachable, aiBusy, +| status +| +| X +| Integer +| HTTP status the AI provider answered, for `aiBusy`. +| int32 -[#models] -== Models +|=== -[#AccessScope] -=== _AccessScope_ +[#AiAnalysisRecoverableErrorKind] +=== _AiAnalysisRecoverableErrorKind_ +Kind of a failure worth another attempt -[.fields-AccessScope] +[.fields-AiAnalysisRecoverableErrorKind] [cols="1"] |=== | Enum Values -| * -| 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.recordings.search +| aiUnreachable +| aiBusy |=== -[#AckRequest] -=== _AckRequest_ - +[#AiAnalysisRequest] +=== _AiAnalysisRequest_ +Settings of an AI analysis -[.fields-AckRequest] +[.fields-AiAnalysisRequest] [cols="2,1,1,2,4,1"] |=== | Field Name| Required| Nullable | Type| Description | Format -| ids +| apiKey | X | -| List of <> -| Array of event IDs to acknowledge (1-10000 items, ULID format) -| - -|=== - - +| String +| API key of the provider, kept in memory only until the Task ends. +| password -[#AckResponse] -=== _AckResponse_ +| baseUrl +| +| X +| String +| Endpoint of the provider API, kept in memory only; required for `openai-compatible`. +| +| language +| +| X +| String +| Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. +| +| maxOutputTokens +| +| X +| Long +| Upper bound of tokens in each AI answer. +| int64 +| model +| X +| +| String +| Model identifier, passed to the provider as is. +| -[.fields-AckResponse] -[cols="2,1,1,2,4,1"] -|=== -| Field Name| Required| Nullable | Type| Description | Format +| provider +| X +| +| <> +| +| openai, anthropic, mistral, gemini, openai-compatible, -| deleted_count +| sessionId | X | -| Long -| Number of events that were acknowledged and deleted -| int64 +| UUID +| Recorded session to analyze. +| uuid |=== -[#AddressFamily] -=== _AddressFamily_ +[#AiAnalysisRunningPayload] +=== _AiAnalysisRunningPayload_ +Payload of a `running` AI analysis Task: its progress, and the tokens it used so far +[.fields-AiAnalysisRunningPayload] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format +| done +| +| X +| Long +| Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. +| int64 +| step +| X +| +| String +| Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). +| -[.fields-AddressFamily] -[cols="1"] -|=== -| Enum Values +| total +| +| X +| Long +| Work the step has in all, in the same unit as `done`; absent in the other steps. +| int64 -| IPv4 -| IPv6 +| usage +| X +| +| <> +| +| |=== -[#AgentDomainAdvertisement] -=== _AgentDomainAdvertisement_ +[#AiAnalysisSucceededPayload] +=== _AiAnalysisSucceededPayload_ +Payload of a `succeeded` AI analysis Task: the log it put in the session -[.fields-AgentDomainAdvertisement] +[.fields-AiAnalysisSucceededPayload] [cols="2,1,1,2,4,1"] |=== | Field Name| Required| Nullable | Type| Description | Format -| auto_detected +| fileName | X | -| Boolean -| Whether the Agent discovered the domain automatically. +| String +| Name of the log in the session manifest, such as `ai-analysis-0.slog`. | -| domain +| model | X | | String -| Domain route advertised by the Agent. +| Model that wrote the log, as reported by the AI provider; the requested model when it reports none. +| + +| promptVersion +| X +| +| String +| Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. +| + +| usage +| X +| +| <> +| | |=== -[#AgentInfo] -=== _AgentInfo_ +[#AiAnalysisTask] +=== _AiAnalysisTask_ +An AI analysis Task: the permanent record of one analysis the provisioner asked for +The work runs in the background; `state` and `payload` tell how it goes. -[.fields-AgentInfo] +[.fields-AiAnalysisTask] [cols="2,1,1,2,4,1"] |=== | Field Name| Required| Nullable | Type| Description | Format -| agent_id +| createdAt +| X +| +| Date +| When the Task was created. +| date-time + +| deadlineAt +| X +| +| Date +| Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or the Task makes progress. +| date-time + +| finishedAt +| +| X +| Date +| When it succeeded or failed. +| date-time + +| id | X | | UUID -| Stable Agent identity. +| Task ID, chosen by the provisioner. | uuid -| domains +| params +| X +| +| <> +| +| + +| payload | | X -| List of <> -| Domain routes currently advertised by the Agent. +| <> +| | -| last_seen +| sessionId +| X +| +| UUID +| Recorded session the analysis is about. +| uuid + +| startedAt | | X | Date -| Time of the Agent's last heartbeat. +| When its first attempt started. | date-time -| name +| state | X | -| String -| Unique management name assigned during enrollment. -| +| <> +| +| queued, running, succeeded, failed, -| status +|=== + + + +[#AiAnalysisUsage] +=== _AiAnalysisUsage_ + +Tokens the AI provider counted for an AI analysis Task, over all its attempts + + +[.fields-AiAnalysisUsage] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| inputTokens | X | -| <> +| Long | -| offline, online, unresponsive, +| int64 -| subnets +| outputTokens +| X | +| Long +| +| int64 + +| unreportedRequests | X -| List of <> -| Subnet routes currently advertised by the Agent. -| +| +| Long +| Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. +| int64 |=== -[#AgentStatus] -=== _AgentStatus_ - +[#AiProvider] +=== _AiProvider_ +AI provider to ask; `openai-compatible` needs a base URL -[.fields-AgentStatus] +[.fields-AiProvider] [cols="1"] |=== | Enum Values -| offline -| online -| unresponsive +| openai +| anthropic +| mistral +| gemini +| openai-compatible |=== @@ -5847,6 +6716,27 @@ Subscriber configuration +[#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 b2982cd9e2..33424ccca0 100644 --- a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES @@ -9,6 +9,22 @@ docs/AgentApi.md docs/AgentDomainAdvertisement.md docs/AgentInfo.md docs/AgentStatus.md +docs/AiAnalysisBadAnswer.md +docs/AiAnalysisConflict.md +docs/AiAnalysisConflictCode.md +docs/AiAnalysisError.md +docs/AiAnalysisErrorKind.md +docs/AiAnalysisFailedPayload.md +docs/AiAnalysisParams.md +docs/AiAnalysisPayload.md +docs/AiAnalysisRecoverableError.md +docs/AiAnalysisRecoverableErrorKind.md +docs/AiAnalysisRequest.md +docs/AiAnalysisRunningPayload.md +docs/AiAnalysisSucceededPayload.md +docs/AiAnalysisTask.md +docs/AiAnalysisUsage.md +docs/AiProvider.md docs/AppCredential.md docs/AppCredentialKind.md docs/AppTokenContentType.md @@ -75,6 +91,8 @@ docs/StartTimeRange.md docs/SubProvisionerKey.md docs/Subscriber.md docs/TargetConnectionOptions.md +docs/TaskState.md +docs/TasksApi.md docs/TrafficApi.md docs/TrafficEventResponse.md docs/TransportProtocolResponse.md @@ -93,6 +111,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 @@ -122,6 +141,22 @@ 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/AiAnalysisBadAnswer.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisConflict.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisConflictCode.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisError.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisErrorKind.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisParams.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisPayload.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableError.cs +src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableErrorKind.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/AiAnalysisTask.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 @@ -178,6 +213,7 @@ src/Devolutions.Gateway.Client/Model/StartTimeRange.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/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 f08c67eb32..27f83eb54a 100644 --- a/devolutions-gateway/openapi/dotnet-client/README.md +++ b/devolutions-gateway/openapi/dotnet-client/README.md @@ -167,6 +167,8 @@ Class | Method | HTTP request | Description *PreflightApi* | [**PostPreflight**](docs/PreflightApi.md#postpreflight) | **POST** /jet/preflight | Performs a batch of preflight operations *SessionsApi* | [**GetSessions**](docs/SessionsApi.md#getsessions) | **GET** /jet/sessions | Lists running sessions *SessionsApi* | [**TerminateSession**](docs/SessionsApi.md#terminatesession) | **POST** /jet/session/{id}/terminate | Terminate forcefully a running session +*TasksApi* | [**GetAiAnalysis**](docs/TasksApi.md#getaianalysis) | **GET** /jet/tasks/recording-ai-analysis/{task_id} | Gets an AI analysis of a recorded session +*TasksApi* | [**StartAiAnalysis**](docs/TasksApi.md#startaianalysis) | **PUT** /jet/tasks/recording-ai-analysis/{task_id} | Starts an AI analysis of a recorded session *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. @@ -187,6 +189,22 @@ Class | Method | HTTP request | Description - [Model.AgentDomainAdvertisement](docs/AgentDomainAdvertisement.md) - [Model.AgentInfo](docs/AgentInfo.md) - [Model.AgentStatus](docs/AgentStatus.md) + - [Model.AiAnalysisBadAnswer](docs/AiAnalysisBadAnswer.md) + - [Model.AiAnalysisConflict](docs/AiAnalysisConflict.md) + - [Model.AiAnalysisConflictCode](docs/AiAnalysisConflictCode.md) + - [Model.AiAnalysisError](docs/AiAnalysisError.md) + - [Model.AiAnalysisErrorKind](docs/AiAnalysisErrorKind.md) + - [Model.AiAnalysisFailedPayload](docs/AiAnalysisFailedPayload.md) + - [Model.AiAnalysisParams](docs/AiAnalysisParams.md) + - [Model.AiAnalysisPayload](docs/AiAnalysisPayload.md) + - [Model.AiAnalysisRecoverableError](docs/AiAnalysisRecoverableError.md) + - [Model.AiAnalysisRecoverableErrorKind](docs/AiAnalysisRecoverableErrorKind.md) + - [Model.AiAnalysisRequest](docs/AiAnalysisRequest.md) + - [Model.AiAnalysisRunningPayload](docs/AiAnalysisRunningPayload.md) + - [Model.AiAnalysisSucceededPayload](docs/AiAnalysisSucceededPayload.md) + - [Model.AiAnalysisTask](docs/AiAnalysisTask.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) @@ -243,6 +261,7 @@ Class | Method | HTTP request | Description - [Model.SubProvisionerKey](docs/SubProvisionerKey.md) - [Model.Subscriber](docs/Subscriber.md) - [Model.TargetConnectionOptions](docs/TargetConnectionOptions.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/AiAnalysisBadAnswer.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisBadAnswer.md new file mode 100644 index 0000000000..5457441c5c --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisBadAnswer.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisBadAnswer +What was wrong with an answer of the AI provider + +## 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/AiAnalysisConflict.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisConflict.md new file mode 100644 index 0000000000..af1ed1fa79 --- /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 0000000000..f67809bcc5 --- /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/AiAnalysisError.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisError.md new file mode 100644 index 0000000000..e612d0c2a2 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisError.md @@ -0,0 +1,15 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisError +Why an AI analysis Task failed: a kind, with the fields of that kind; there is no text for people, so the caller words it + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**File** | **string** | Recording file that cannot be read, for `unreadableRecording`. | [optional] +**Kind** | **AiAnalysisErrorKind** | | +**Last** | [**AiAnalysisRecoverableError**](AiAnalysisRecoverableError.md) | | [optional] +**Status** | **int?** | HTTP status the AI provider answered, for `aiRejected`. | [optional] +**Why** | **AiAnalysisBadAnswer** | | [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/AiAnalysisErrorKind.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisErrorKind.md new file mode 100644 index 0000000000..f5b1ceffa7 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisErrorKind.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisErrorKind +Kind of failure of an AI analysis Task + +## 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 0000000000..b4c4dacabd --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisFailedPayload.md @@ -0,0 +1,12 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisFailedPayload +Payload of a `failed` AI analysis Task: why it failed, and the tokens it used + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Error** | [**AiAnalysisError**](AiAnalysisError.md) | | +**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/AiAnalysisParams.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisParams.md new file mode 100644 index 0000000000..b8ce560414 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisParams.md @@ -0,0 +1,14 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisParams +Params of an AI analysis Task: what was asked, without the API key and the base URL + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Language** | **string** | Language the AI writes in; absent when not asked, so the AI writes in English. | [optional] +**MaxOutputTokens** | **long?** | Upper bound of tokens in each AI answer; null when not asked. | [optional] +**Model** | **string** | Model identifier, passed to the provider as is. | +**Provider** | **AiProvider** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisPayload.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisPayload.md new file mode 100644 index 0000000000..9c3f6012fe --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisPayload.md @@ -0,0 +1,18 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisPayload +Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Done** | **long?** | Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. | [optional] +**Step** | **string** | Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). | +**Total** | **long?** | Work the step has in all, in the same unit as `done`; absent in the other steps. | [optional] +**Usage** | [**AiAnalysisUsage**](AiAnalysisUsage.md) | | +**FileName** | **string** | Name of the 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** | Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. | +**Error** | [**AiAnalysisError**](AiAnalysisError.md) | | + +[[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/AiAnalysisRecoverableError.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRecoverableError.md new file mode 100644 index 0000000000..3f1ea2cc1e --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRecoverableError.md @@ -0,0 +1,12 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisRecoverableError +A failure of an attempt that was worth another attempt + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Kind** | **AiAnalysisRecoverableErrorKind** | | +**Status** | **int?** | HTTP status the AI provider answered, for `aiBusy`. | [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/AiAnalysisRecoverableErrorKind.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRecoverableErrorKind.md new file mode 100644 index 0000000000..dcd264e9b1 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRecoverableErrorKind.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisRecoverableErrorKind +Kind of a failure worth another attempt + +## 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/AiAnalysisRequest.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRequest.md new file mode 100644 index 0000000000..eae9bf80d6 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRequest.md @@ -0,0 +1,17 @@ +# 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] +**Language** | **string** | Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. | [optional] +**MaxOutputTokens** | **long?** | Upper bound of tokens in each AI answer. | [optional] +**Model** | **string** | Model identifier, passed to the provider as is. | +**Provider** | **AiProvider** | | +**SessionId** | **Guid** | Recorded session to analyze. | + +[[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 0000000000..41fe22365d --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisRunningPayload.md @@ -0,0 +1,14 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisRunningPayload +Payload of a `running` AI analysis Task: its progress, and the tokens it used so far + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Done** | **long?** | Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. | [optional] +**Step** | **string** | Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). | +**Total** | **long?** | Work the step has in all, in the same unit as `done`; absent in the other steps. | [optional] +**Usage** | [**AiAnalysisUsage**](AiAnalysisUsage.md) | | + +[[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 0000000000..0ead901c69 --- /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 put in the session + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**FileName** | **string** | Name of the 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** | Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. | +**Usage** | [**AiAnalysisUsage**](AiAnalysisUsage.md) | | + +[[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/AiAnalysisTask.md b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisTask.md new file mode 100644 index 0000000000..299eb2c939 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisTask.md @@ -0,0 +1,19 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisTask +An AI analysis Task: the permanent record of one analysis the provisioner asked for The work runs in the background; `state` and `payload` tell how it goes. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**CreatedAt** | **DateTime** | When the Task was created. | +**DeadlineAt** | **DateTime** | Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or the Task makes progress. | +**FinishedAt** | **DateTime?** | When it succeeded or failed. | [optional] +**Id** | **Guid** | Task ID, chosen by the provisioner. | +**Params** | [**AiAnalysisParams**](AiAnalysisParams.md) | | +**Payload** | [**AiAnalysisPayload**](AiAnalysisPayload.md) | | [optional] +**SessionId** | **Guid** | Recorded session the analysis is about. | +**StartedAt** | **DateTime?** | When its first attempt started. | [optional] +**State** | **TaskState** | | + +[[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 0000000000..353e3975c3 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiAnalysisUsage.md @@ -0,0 +1,13 @@ +# Devolutions.Gateway.Client.Model.AiAnalysisUsage +Tokens the AI provider counted for an AI analysis Task, over all its attempts + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**InputTokens** | **long** | | +**OutputTokens** | **long** | | +**UnreportedRequests** | **long** | Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. | + +[[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 0000000000..a553e6c223 --- /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/TaskState.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md new file mode 100644 index 0000000000..40328b8ece --- /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 0000000000..bedda0b0a9 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md @@ -0,0 +1,219 @@ +# Devolutions.Gateway.Client.Api.TasksApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|--------|--------------|-------------| +| [**GetAiAnalysis**](TasksApi.md#getaianalysis) | **GET** /jet/tasks/recording-ai-analysis/{task_id} | Gets an AI analysis of a recorded session | +| [**StartAiAnalysis**](TasksApi.md#startaianalysis) | **PUT** /jet/tasks/recording-ai-analysis/{task_id} | Starts an AI analysis of a recorded session | + + +# **GetAiAnalysis** +> AiAnalysisTask GetAiAnalysis (Guid taskId) + +Gets an AI analysis of a recorded session + +Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 GetAiAnalysisExample + { + 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 taskId = "taskId_example"; // Guid | Task ID of the AI analysis + + try + { + // Gets an AI analysis of a recorded session + AiAnalysisTask result = apiInstance.GetAiAnalysis(taskId); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling TasksApi.GetAiAnalysis: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); + } + } + } +} +``` + +#### Using the GetAiAnalysisWithHttpInfo variant +This returns an ApiResponse object which contains the response data, status code and headers. + +```csharp +try +{ + // Gets an AI analysis of a recorded session + ApiResponse response = apiInstance.GetAiAnalysisWithHttpInfo(taskId); + 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.GetAiAnalysisWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **taskId** | **Guid** | Task ID of the AI analysis | | + +### Return type + +[**AiAnalysisTask**](AiAnalysisTask.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 AI analysis Task | - | +| **400** | Bad request | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **404** | No AI analysis has this Task 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) + + +# **StartAiAnalysis** +> AiAnalysisTask StartAiAnalysis (Guid taskId, AiAnalysisRequest aiAnalysisRequest) + +Starts an AI analysis of a recorded session + +Gateway asks the AI provider what the user did in the session recording, and puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 TasksApi(httpClient, config, httpClientHandler); + var taskId = "taskId_example"; // Guid | Task ID, chosen by the caller so it can send the same request again safely + var aiAnalysisRequest = new AiAnalysisRequest(); // AiAnalysisRequest | AI analysis settings + + try + { + // Starts an AI analysis of a recorded session + AiAnalysisTask result = apiInstance.StartAiAnalysis(taskId, aiAnalysisRequest); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling TasksApi.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(taskId, 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 TasksApi.StartAiAnalysisWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **taskId** | **Guid** | Task ID, chosen by the caller so it can send the same request again safely | | +| **aiAnalysisRequest** | [**AiAnalysisRequest**](AiAnalysisRequest.md) | AI analysis settings | | + +### Return type + +[**AiAnalysisTask**](AiAnalysisTask.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 | * Location - Path of the new Task, `/jet/tasks/recording-ai-analysis/{task_id}`
| +| **400** | Invalid request body, AI settings or language | - | +| **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/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 0000000000..af46850ae2 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs @@ -0,0 +1,600 @@ +/* + * 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 an AI analysis of a recorded session + /// + /// + /// Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// AiAnalysisTask + AiAnalysisTask GetAiAnalysis(Guid taskId); + + /// + /// Gets an AI analysis of a recorded session + /// + /// + /// Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// ApiResponse of AiAnalysisTask + ApiResponse GetAiAnalysisWithHttpInfo(Guid taskId); + /// + /// Starts an AI analysis of a recorded session + /// + /// + /// Gateway asks the AI provider what the user did in the session recording, and puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// AiAnalysisTask + AiAnalysisTask StartAiAnalysis(Guid taskId, AiAnalysisRequest aiAnalysisRequest); + + /// + /// Starts an AI analysis of a recorded session + /// + /// + /// Gateway asks the AI provider what the user did in the session recording, and puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// ApiResponse of AiAnalysisTask + ApiResponse StartAiAnalysisWithHttpInfo(Guid taskId, AiAnalysisRequest aiAnalysisRequest); + #endregion Synchronous Operations + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApiAsync : IApiAccessor + { + #region Asynchronous Operations + /// + /// Gets an AI analysis of a recorded session + /// + /// + /// Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// Cancellation Token to cancel the request. + /// Task of AiAnalysisTask + System.Threading.Tasks.Task GetAiAnalysisAsync(Guid taskId, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + + /// + /// Gets an AI analysis of a recorded session + /// + /// + /// Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (AiAnalysisTask) + System.Threading.Tasks.Task> GetAiAnalysisWithHttpInfoAsync(Guid taskId, 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 puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of AiAnalysisTask + System.Threading.Tasks.Task StartAiAnalysisAsync(Guid taskId, 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 puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (AiAnalysisTask) + System.Threading.Tasks.Task> StartAiAnalysisWithHttpInfoAsync(Guid taskId, AiAnalysisRequest aiAnalysisRequest, 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 an AI analysis of a recorded session Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// AiAnalysisTask + public AiAnalysisTask GetAiAnalysis(Guid taskId) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = GetAiAnalysisWithHttpInfo(taskId); + return localVarResponse.Data; + } + + /// + /// Gets an AI analysis of a recorded session Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// ApiResponse of AiAnalysisTask + public Devolutions.Gateway.Client.Client.ApiResponse GetAiAnalysisWithHttpInfo(Guid taskId) + { + 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("task_id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(taskId)); // 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/recording-ai-analysis/{task_id}", localVarRequestOptions, this.Configuration); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("GetAiAnalysis", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Gets an AI analysis of a recorded session Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// Cancellation Token to cancel the request. + /// Task of AiAnalysisTask + public async System.Threading.Tasks.Task GetAiAnalysisAsync(Guid taskId, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await GetAiAnalysisWithHttpInfoAsync(taskId, cancellationToken).ConfigureAwait(false); + return localVarResponse.Data; + } + + /// + /// Gets an AI analysis of a recorded session Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. 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 of the AI analysis + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (AiAnalysisTask) + public async System.Threading.Tasks.Task> GetAiAnalysisWithHttpInfoAsync(Guid taskId, 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("task_id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(taskId)); // 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/recording-ai-analysis/{task_id}", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("GetAiAnalysis", 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 puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// AiAnalysisTask + public AiAnalysisTask StartAiAnalysis(Guid taskId, AiAnalysisRequest aiAnalysisRequest) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = StartAiAnalysisWithHttpInfo(taskId, 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 puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// ApiResponse of AiAnalysisTask + public Devolutions.Gateway.Client.Client.ApiResponse StartAiAnalysisWithHttpInfo(Guid taskId, 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 TasksApi->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("task_id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(taskId)); // 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.Put("/jet/tasks/recording-ai-analysis/{task_id}", 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 puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of AiAnalysisTask + public async System.Threading.Tasks.Task StartAiAnalysisAsync(Guid taskId, AiAnalysisRequest aiAnalysisRequest, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await StartAiAnalysisWithHttpInfoAsync(taskId, 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 puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. 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 + /// Task ID, chosen by the caller so it can send the same request again safely + /// AI analysis settings + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (AiAnalysisTask) + public async System.Threading.Tasks.Task> StartAiAnalysisWithHttpInfoAsync(Guid taskId, 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 TasksApi->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("task_id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(taskId)); // 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.PutAsync("/jet/tasks/recording-ai-analysis/{task_id}", 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/Model/AccessScope.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs index 03cf404273..1f751b189c 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 @@ -151,7 +151,19 @@ public enum AccessScope /// Enum GatewayRecordingsSearch for value: gateway.recordings.search /// [EnumMember(Value = "gateway.recordings.search")] - GatewayRecordingsSearch = 20 + GatewayRecordingsSearch = 20, + + /// + /// Enum GatewayTasksRecordingAiAnalysisStart for value: gateway.tasks.recording-ai-analysis.start + /// + [EnumMember(Value = "gateway.tasks.recording-ai-analysis.start")] + GatewayTasksRecordingAiAnalysisStart = 21, + + /// + /// Enum GatewayTasksRecordingAiAnalysisRead for value: gateway.tasks.recording-ai-analysis.read + /// + [EnumMember(Value = "gateway.tasks.recording-ai-analysis.read")] + GatewayTasksRecordingAiAnalysisRead = 22 } public static class AccessScopeExtensions @@ -203,6 +215,10 @@ public static string ToValue(this AccessScope variant) return "gateway.agent.read"; case AccessScope.GatewayRecordingsSearch: return "gateway.recordings.search"; + case AccessScope.GatewayTasksRecordingAiAnalysisStart: + return "gateway.tasks.recording-ai-analysis.start"; + case AccessScope.GatewayTasksRecordingAiAnalysisRead: + return "gateway.tasks.recording-ai-analysis.read"; default: throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); } diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisBadAnswer.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisBadAnswer.cs new file mode 100644 index 0000000000..3eb5329b7e --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisBadAnswer.cs @@ -0,0 +1,85 @@ +/* + * 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 +{ + /// + /// What was wrong with an answer of the AI provider + /// + /// What was wrong with an answer of the AI provider + [JsonConverter(typeof(StringEnumConverter))] + public enum AiAnalysisBadAnswer + { + /// + /// Enum Invalid for value: invalid + /// + [EnumMember(Value = "invalid")] + Invalid = 1, + + /// + /// Enum Truncated for value: truncated + /// + [EnumMember(Value = "truncated")] + Truncated = 2, + + /// + /// Enum Refused for value: refused + /// + [EnumMember(Value = "refused")] + Refused = 3, + + /// + /// Enum NotExpectedJson for value: notExpectedJson + /// + [EnumMember(Value = "notExpectedJson")] + NotExpectedJson = 4 + } + + public static class AiAnalysisBadAnswerExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this AiAnalysisBadAnswer variant) + { + switch (variant) + { + case AiAnalysisBadAnswer.Invalid: + return "invalid"; + case AiAnalysisBadAnswer.Truncated: + return "truncated"; + case AiAnalysisBadAnswer.Refused: + return "refused"; + case AiAnalysisBadAnswer.NotExpectedJson: + return "notExpectedJson"; + 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 0000000000..7fc19bc470 --- /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 0000000000..52284a5d8b --- /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/AiAnalysisError.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisError.cs new file mode 100644 index 0000000000..14f4a633b5 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisError.cs @@ -0,0 +1,133 @@ +/* + * 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 Task failed: a kind, with the fields of that kind; there is no text for people, so the caller words it + /// + [DataContract(Name = "AiAnalysisError")] + public partial class AiAnalysisError : IValidatableObject + { + + /// + /// Gets or Sets Kind + /// + [DataMember(Name = "kind", IsRequired = true, EmitDefaultValue = true)] + public AiAnalysisErrorKind Kind { get; set; } + + /// + /// Gets or Sets Why + /// + [DataMember(Name = "why", EmitDefaultValue = true)] + public AiAnalysisBadAnswer? Why { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisError() { } + /// + /// Initializes a new instance of the class. + /// + /// Recording file that cannot be read, for `unreadableRecording`.. + /// kind (required). + /// last. + /// HTTP status the AI provider answered, for `aiRejected`.. + /// why. + public AiAnalysisError(string file = default(string), AiAnalysisErrorKind kind = default(AiAnalysisErrorKind), AiAnalysisRecoverableError last = default(AiAnalysisRecoverableError), int? status = default(int?), AiAnalysisBadAnswer? why = default(AiAnalysisBadAnswer?)) + { + this.Kind = kind; + this.File = file; + this.Last = last; + this.Status = status; + this.Why = why; + } + + /// + /// Recording file that cannot be read, for `unreadableRecording`. + /// + /// Recording file that cannot be read, for `unreadableRecording`. + [DataMember(Name = "file", EmitDefaultValue = true)] + public string File { get; set; } + + /// + /// Gets or Sets Last + /// + [DataMember(Name = "last", EmitDefaultValue = true)] + public AiAnalysisRecoverableError Last { get; set; } + + /// + /// HTTP status the AI provider answered, for `aiRejected`. + /// + /// HTTP status the AI provider answered, for `aiRejected`. + [DataMember(Name = "status", EmitDefaultValue = true)] + public int? Status { 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 AiAnalysisError {\n"); + sb.Append(" File: ").Append(File).Append("\n"); + sb.Append(" Kind: ").Append(Kind).Append("\n"); + sb.Append(" Last: ").Append(Last).Append("\n"); + sb.Append(" Status: ").Append(Status).Append("\n"); + sb.Append(" Why: ").Append(Why).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) + { + // Status (int?) minimum + if (this.Status < (int?)0) + { + yield return new ValidationResult("Invalid value for Status, must be a value greater than or equal to 0.", new [] { "Status" }); + } + + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisErrorKind.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisErrorKind.cs new file mode 100644 index 0000000000..c1c9c144af --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisErrorKind.cs @@ -0,0 +1,141 @@ +/* + * 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 +{ + /// + /// Kind of failure of an AI analysis Task + /// + /// Kind of failure of an AI analysis Task + [JsonConverter(typeof(StringEnumConverter))] + public enum AiAnalysisErrorKind + { + /// + /// Enum AiRejected for value: aiRejected + /// + [EnumMember(Value = "aiRejected")] + AiRejected = 1, + + /// + /// Enum AiBadAnswer for value: aiBadAnswer + /// + [EnumMember(Value = "aiBadAnswer")] + AiBadAnswer = 2, + + /// + /// Enum KeyLost for value: keyLost + /// + [EnumMember(Value = "keyLost")] + KeyLost = 3, + + /// + /// Enum NoRecording for value: noRecording + /// + [EnumMember(Value = "noRecording")] + NoRecording = 4, + + /// + /// Enum MixedRecording for value: mixedRecording + /// + [EnumMember(Value = "mixedRecording")] + MixedRecording = 5, + + /// + /// Enum UnreadableRecording for value: unreadableRecording + /// + [EnumMember(Value = "unreadableRecording")] + UnreadableRecording = 6, + + /// + /// Enum InvalidTask for value: invalidTask + /// + [EnumMember(Value = "invalidTask")] + InvalidTask = 7, + + /// + /// Enum Panicked for value: panicked + /// + [EnumMember(Value = "panicked")] + Panicked = 8, + + /// + /// Enum Internal for value: internal + /// + [EnumMember(Value = "internal")] + Internal = 9, + + /// + /// Enum AttemptsExhausted for value: attemptsExhausted + /// + [EnumMember(Value = "attemptsExhausted")] + AttemptsExhausted = 10, + + /// + /// Enum DeadlinePassed for value: deadlinePassed + /// + [EnumMember(Value = "deadlinePassed")] + DeadlinePassed = 11 + } + + public static class AiAnalysisErrorKindExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this AiAnalysisErrorKind variant) + { + switch (variant) + { + case AiAnalysisErrorKind.AiRejected: + return "aiRejected"; + case AiAnalysisErrorKind.AiBadAnswer: + return "aiBadAnswer"; + case AiAnalysisErrorKind.KeyLost: + return "keyLost"; + case AiAnalysisErrorKind.NoRecording: + return "noRecording"; + case AiAnalysisErrorKind.MixedRecording: + return "mixedRecording"; + case AiAnalysisErrorKind.UnreadableRecording: + return "unreadableRecording"; + case AiAnalysisErrorKind.InvalidTask: + return "invalidTask"; + case AiAnalysisErrorKind.Panicked: + return "panicked"; + case AiAnalysisErrorKind.Internal: + return "internal"; + case AiAnalysisErrorKind.AttemptsExhausted: + return "attemptsExhausted"; + case AiAnalysisErrorKind.DeadlinePassed: + return "deadlinePassed"; + 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 0000000000..2ff1c9c54a --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisFailedPayload.cs @@ -0,0 +1,103 @@ +/* + * 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, and the tokens it used + /// + [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. + /// + /// error (required). + /// usage. + public AiAnalysisFailedPayload(AiAnalysisError error = default(AiAnalysisError), AiAnalysisUsage usage = default(AiAnalysisUsage)) + { + // to ensure "error" is required (not null) + if (error == null) + { + throw new ArgumentNullException("error is a required property for AiAnalysisFailedPayload and cannot be null"); + } + this.Error = error; + this.Usage = usage; + } + + /// + /// Gets or Sets Error + /// + [DataMember(Name = "error", IsRequired = true, EmitDefaultValue = true)] + public AiAnalysisError Error { 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 AiAnalysisFailedPayload {\n"); + sb.Append(" Error: ").Append(Error).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/AiAnalysisParams.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisParams.cs new file mode 100644 index 0000000000..98c231bf57 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisParams.cs @@ -0,0 +1,136 @@ +/* + * 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 +{ + /// + /// Params of an AI analysis Task: what was asked, without the API key and the base URL + /// + [DataContract(Name = "AiAnalysisParams")] + public partial class AiAnalysisParams : 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 AiAnalysisParams() { } + /// + /// Initializes a new instance of the class. + /// + /// Language the AI writes in; absent when not asked, so the AI writes in English.. + /// Upper bound of tokens in each AI answer; null when not asked.. + /// Model identifier, passed to the provider as is. (required). + /// provider (required). + public AiAnalysisParams(string language = default(string), long? maxOutputTokens = default(long?), string model = default(string), AiProvider provider = default(AiProvider)) + { + // to ensure "model" is required (not null) + if (model == null) + { + throw new ArgumentNullException("model is a required property for AiAnalysisParams and cannot be null"); + } + this.Model = model; + this.Provider = provider; + this.Language = language; + this.MaxOutputTokens = maxOutputTokens; + } + + /// + /// Language the AI writes in; absent when not asked, so the AI writes in English. + /// + /// Language the AI writes in; absent when not asked, so the AI writes in English. + [DataMember(Name = "language", EmitDefaultValue = true)] + public string Language { get; set; } + + /// + /// Upper bound of tokens in each AI answer; null when not asked. + /// + /// Upper bound of tokens in each AI answer; null when not asked. + [DataMember(Name = "maxOutputTokens", EmitDefaultValue = true)] + public long? MaxOutputTokens { get; set; } + + /// + /// Model identifier, passed to the provider as is. + /// + /// Model identifier, passed to the provider as is. + [DataMember(Name = "model", IsRequired = true, EmitDefaultValue = true)] + public string Model { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class AiAnalysisParams {\n"); + sb.Append(" Language: ").Append(Language).Append("\n"); + sb.Append(" MaxOutputTokens: ").Append(MaxOutputTokens).Append("\n"); + sb.Append(" Model: ").Append(Model).Append("\n"); + sb.Append(" Provider: ").Append(Provider).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + // MaxOutputTokens (long?) maximum + if (this.MaxOutputTokens > (long?)4294967295) + { + yield return new ValidationResult("Invalid value for MaxOutputTokens, must be a value less than or equal to 4294967295.", new [] { "MaxOutputTokens" }); + } + + // MaxOutputTokens (long?) minimum + if (this.MaxOutputTokens < (long?)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/AiAnalysisPayload.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisPayload.cs new file mode 100644 index 0000000000..757200da16 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisPayload.cs @@ -0,0 +1,308 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; +using System.Reflection; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own + /// + [JsonConverter(typeof(AiAnalysisPayloadJsonConverter))] + [DataContract(Name = "AiAnalysisPayload")] + public partial class AiAnalysisPayload : AbstractOpenAPISchema, IValidatableObject + { + /// + /// Initializes a new instance of the class + /// with the class + /// + /// An instance of AiAnalysisRunningPayload. + public AiAnalysisPayload(AiAnalysisRunningPayload actualInstance) + { + this.IsNullable = false; + this.SchemaType= "oneOf"; + this.ActualInstance = actualInstance ?? throw new ArgumentException("Invalid instance found. Must not be null."); + } + + /// + /// Initializes a new instance of the class + /// with the class + /// + /// An instance of AiAnalysisSucceededPayload. + public AiAnalysisPayload(AiAnalysisSucceededPayload actualInstance) + { + this.IsNullable = false; + this.SchemaType= "oneOf"; + this.ActualInstance = actualInstance ?? throw new ArgumentException("Invalid instance found. Must not be null."); + } + + /// + /// Initializes a new instance of the class + /// with the class + /// + /// An instance of AiAnalysisFailedPayload. + public AiAnalysisPayload(AiAnalysisFailedPayload actualInstance) + { + this.IsNullable = false; + this.SchemaType= "oneOf"; + this.ActualInstance = actualInstance ?? throw new ArgumentException("Invalid instance found. Must not be null."); + } + + + private Object _actualInstance; + + /// + /// Gets or Sets ActualInstance + /// + public override Object ActualInstance + { + get + { + return _actualInstance; + } + set + { + if (value.GetType() == typeof(AiAnalysisFailedPayload) || value is AiAnalysisFailedPayload) + { + this._actualInstance = value; + } + else if (value.GetType() == typeof(AiAnalysisRunningPayload) || value is AiAnalysisRunningPayload) + { + this._actualInstance = value; + } + else if (value.GetType() == typeof(AiAnalysisSucceededPayload) || value is AiAnalysisSucceededPayload) + { + this._actualInstance = value; + } + else + { + throw new ArgumentException("Invalid instance found. Must be the following types: AiAnalysisFailedPayload, AiAnalysisRunningPayload, AiAnalysisSucceededPayload"); + } + } + } + + /// + /// Get the actual instance of `AiAnalysisRunningPayload`. If the actual instance is not `AiAnalysisRunningPayload`, + /// the InvalidClassException will be thrown + /// + /// An instance of AiAnalysisRunningPayload + public AiAnalysisRunningPayload GetAiAnalysisRunningPayload() + { + return (AiAnalysisRunningPayload)this.ActualInstance; + } + + /// + /// Get the actual instance of `AiAnalysisSucceededPayload`. If the actual instance is not `AiAnalysisSucceededPayload`, + /// the InvalidClassException will be thrown + /// + /// An instance of AiAnalysisSucceededPayload + public AiAnalysisSucceededPayload GetAiAnalysisSucceededPayload() + { + return (AiAnalysisSucceededPayload)this.ActualInstance; + } + + /// + /// Get the actual instance of `AiAnalysisFailedPayload`. If the actual instance is not `AiAnalysisFailedPayload`, + /// the InvalidClassException will be thrown + /// + /// An instance of AiAnalysisFailedPayload + public AiAnalysisFailedPayload GetAiAnalysisFailedPayload() + { + return (AiAnalysisFailedPayload)this.ActualInstance; + } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class AiAnalysisPayload {\n"); + sb.Append(" ActualInstance: ").Append(this.ActualInstance).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public override string ToJson() + { + return JsonConvert.SerializeObject(this.ActualInstance, AiAnalysisPayload.SerializerSettings); + } + + /// + /// Converts the JSON string into an instance of AiAnalysisPayload + /// + /// JSON string + /// An instance of AiAnalysisPayload + public static AiAnalysisPayload FromJson(string jsonString) + { + AiAnalysisPayload newAiAnalysisPayload = null; + + if (string.IsNullOrEmpty(jsonString)) + { + return newAiAnalysisPayload; + } + int match = 0; + List matchedTypes = new List(); + + try + { + // if it does not contains "AdditionalProperties", use SerializerSettings to deserialize + if (typeof(AiAnalysisFailedPayload).GetProperty("AdditionalProperties") == null) + { + newAiAnalysisPayload = new AiAnalysisPayload(JsonConvert.DeserializeObject(jsonString, AiAnalysisPayload.SerializerSettings)); + } + else + { + newAiAnalysisPayload = new AiAnalysisPayload(JsonConvert.DeserializeObject(jsonString, AiAnalysisPayload.AdditionalPropertiesSerializerSettings)); + } + matchedTypes.Add("AiAnalysisFailedPayload"); + match++; + } + catch (Exception exception) + { + // deserialization failed, try the next one + System.Diagnostics.Debug.WriteLine(string.Format("Failed to deserialize `{0}` into AiAnalysisFailedPayload: {1}", jsonString, exception.ToString())); + } + + try + { + // if it does not contains "AdditionalProperties", use SerializerSettings to deserialize + if (typeof(AiAnalysisRunningPayload).GetProperty("AdditionalProperties") == null) + { + newAiAnalysisPayload = new AiAnalysisPayload(JsonConvert.DeserializeObject(jsonString, AiAnalysisPayload.SerializerSettings)); + } + else + { + newAiAnalysisPayload = new AiAnalysisPayload(JsonConvert.DeserializeObject(jsonString, AiAnalysisPayload.AdditionalPropertiesSerializerSettings)); + } + matchedTypes.Add("AiAnalysisRunningPayload"); + match++; + } + catch (Exception exception) + { + // deserialization failed, try the next one + System.Diagnostics.Debug.WriteLine(string.Format("Failed to deserialize `{0}` into AiAnalysisRunningPayload: {1}", jsonString, exception.ToString())); + } + + try + { + // if it does not contains "AdditionalProperties", use SerializerSettings to deserialize + if (typeof(AiAnalysisSucceededPayload).GetProperty("AdditionalProperties") == null) + { + newAiAnalysisPayload = new AiAnalysisPayload(JsonConvert.DeserializeObject(jsonString, AiAnalysisPayload.SerializerSettings)); + } + else + { + newAiAnalysisPayload = new AiAnalysisPayload(JsonConvert.DeserializeObject(jsonString, AiAnalysisPayload.AdditionalPropertiesSerializerSettings)); + } + matchedTypes.Add("AiAnalysisSucceededPayload"); + match++; + } + catch (Exception exception) + { + // deserialization failed, try the next one + System.Diagnostics.Debug.WriteLine(string.Format("Failed to deserialize `{0}` into AiAnalysisSucceededPayload: {1}", jsonString, exception.ToString())); + } + + if (match == 0) + { + throw new InvalidDataException("The JSON string `" + jsonString + "` cannot be deserialized into any schema defined."); + } + else if (match > 1) + { + throw new InvalidDataException("The JSON string `" + jsonString + "` incorrectly matches more than one schema (should be exactly one match): " + String.Join(",", matchedTypes)); + } + + // deserialization is considered successful at this point if no exception has been thrown. + return newAiAnalysisPayload; + } + + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + + /// + /// Custom JSON converter for AiAnalysisPayload + /// + public class AiAnalysisPayloadJsonConverter : JsonConverter + { + /// + /// To write the JSON string + /// + /// JSON writer + /// Object to be converted into a JSON string + /// JSON Serializer + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteRawValue((string)(typeof(AiAnalysisPayload).GetMethod("ToJson").Invoke(value, null))); + } + + /// + /// To convert a JSON string into an object + /// + /// JSON reader + /// Object type + /// Existing value + /// JSON Serializer + /// The object converted from the JSON string + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + switch(reader.TokenType) + { + case JsonToken.StartObject: + return AiAnalysisPayload.FromJson(JObject.Load(reader).ToString(Formatting.None)); + case JsonToken.StartArray: + return AiAnalysisPayload.FromJson(JArray.Load(reader).ToString(Formatting.None)); + default: + return null; + } + } + + /// + /// Check if the object can be converted + /// + /// Object type + /// True if the object can be converted + public override bool CanConvert(Type objectType) + { + return false; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableError.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableError.cs new file mode 100644 index 0000000000..329a2557f3 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableError.cs @@ -0,0 +1,105 @@ +/* + * 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 failure of an attempt that was worth another attempt + /// + [DataContract(Name = "AiAnalysisRecoverableError")] + public partial class AiAnalysisRecoverableError : IValidatableObject + { + + /// + /// Gets or Sets Kind + /// + [DataMember(Name = "kind", IsRequired = true, EmitDefaultValue = true)] + public AiAnalysisRecoverableErrorKind Kind { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiAnalysisRecoverableError() { } + /// + /// Initializes a new instance of the class. + /// + /// kind (required). + /// HTTP status the AI provider answered, for `aiBusy`.. + public AiAnalysisRecoverableError(AiAnalysisRecoverableErrorKind kind = default(AiAnalysisRecoverableErrorKind), int? status = default(int?)) + { + this.Kind = kind; + this.Status = status; + } + + /// + /// HTTP status the AI provider answered, for `aiBusy`. + /// + /// HTTP status the AI provider answered, for `aiBusy`. + [DataMember(Name = "status", EmitDefaultValue = true)] + public int? Status { 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 AiAnalysisRecoverableError {\n"); + sb.Append(" Kind: ").Append(Kind).Append("\n"); + sb.Append(" Status: ").Append(Status).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) + { + // Status (int?) minimum + if (this.Status < (int?)0) + { + yield return new ValidationResult("Invalid value for Status, must be a value greater than or equal to 0.", new [] { "Status" }); + } + + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableErrorKind.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableErrorKind.cs new file mode 100644 index 0000000000..0189ed2a79 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRecoverableErrorKind.cs @@ -0,0 +1,69 @@ +/* + * 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 +{ + /// + /// Kind of a failure worth another attempt + /// + /// Kind of a failure worth another attempt + [JsonConverter(typeof(StringEnumConverter))] + public enum AiAnalysisRecoverableErrorKind + { + /// + /// Enum AiUnreachable for value: aiUnreachable + /// + [EnumMember(Value = "aiUnreachable")] + AiUnreachable = 1, + + /// + /// Enum AiBusy for value: aiBusy + /// + [EnumMember(Value = "aiBusy")] + AiBusy = 2 + } + + public static class AiAnalysisRecoverableErrorKindExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this AiAnalysisRecoverableErrorKind variant) + { + switch (variant) + { + case AiAnalysisRecoverableErrorKind.AiUnreachable: + return "aiUnreachable"; + case AiAnalysisRecoverableErrorKind.AiBusy: + return "aiBusy"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} 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 0000000000..50d5acd6c9 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRequest.cs @@ -0,0 +1,183 @@ +/* + * 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`.. + /// Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent.. + /// Upper bound of tokens in each AI answer.. + /// Model identifier, passed to the provider as is. (required). + /// provider (required). + /// Recorded session to analyze. (required). + public AiAnalysisRequest(string apiKey = default(string), string baseUrl = default(string), string language = default(string), long? maxOutputTokens = default(long?), string model = default(string), AiProvider provider = default(AiProvider), Guid sessionId = 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.SessionId = sessionId; + this.BaseUrl = baseUrl; + this.Language = language; + 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; } + + /// + /// Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. + /// + /// Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. + [DataMember(Name = "language", EmitDefaultValue = true)] + public string Language { get; set; } + + /// + /// Upper bound of tokens in each AI answer. + /// + /// Upper bound of tokens in each AI answer. + [DataMember(Name = "maxOutputTokens", EmitDefaultValue = true)] + public long? 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; } + + /// + /// Recorded session to analyze. + /// + /// Recorded session to analyze. + [DataMember(Name = "sessionId", IsRequired = true, EmitDefaultValue = true)] + public Guid SessionId { 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("[redacted]").Append("\n"); + sb.Append(" BaseUrl: ").Append(BaseUrl).Append("\n"); + sb.Append(" Language: ").Append(Language).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(" SessionId: ").Append(SessionId).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) + { + // Language (string) maxLength + if (this.Language != null && this.Language.Length > 10) + { + yield return new ValidationResult("Invalid value for Language, length must be less than 10.", new [] { "Language" }); + } + + // Language (string) minLength + if (this.Language != null && this.Language.Length < 1) + { + yield return new ValidationResult("Invalid value for Language, length must be greater than 1.", new [] { "Language" }); + } + + // MaxOutputTokens (long?) maximum + if (this.MaxOutputTokens > (long?)4294967295) + { + yield return new ValidationResult("Invalid value for MaxOutputTokens, must be a value less than or equal to 4294967295.", new [] { "MaxOutputTokens" }); + } + + // MaxOutputTokens (long?) minimum + if (this.MaxOutputTokens < (long?)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 0000000000..ac46ed3027 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisRunningPayload.cs @@ -0,0 +1,141 @@ +/* + * 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, and the tokens it used so far + /// + [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. + /// + /// Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps.. + /// Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). (required). + /// Work the step has in all, in the same unit as `done`; absent in the other steps.. + /// usage (required). + public AiAnalysisRunningPayload(long? done = default(long?), string step = default(string), long? total = default(long?), AiAnalysisUsage usage = default(AiAnalysisUsage)) + { + // 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; + // to ensure "usage" is required (not null) + if (usage == null) + { + throw new ArgumentNullException("usage is a required property for AiAnalysisRunningPayload and cannot be null"); + } + this.Usage = usage; + this.Done = done; + this.Total = total; + } + + /// + /// Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. + /// + /// Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. + [DataMember(Name = "done", EmitDefaultValue = true)] + public long? Done { get; set; } + + /// + /// Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). + /// + /// Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). + [DataMember(Name = "step", IsRequired = true, EmitDefaultValue = true)] + public string Step { get; set; } + + /// + /// Work the step has in all, in the same unit as `done`; absent in the other steps. + /// + /// Work the step has in all, in the same unit as `done`; absent in the other steps. + [DataMember(Name = "total", EmitDefaultValue = true)] + public long? Total { get; set; } + + /// + /// Gets or Sets Usage + /// + [DataMember(Name = "usage", IsRequired = true, 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 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(" 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) + { + // 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 0000000000..b9a6a9055f --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisSucceededPayload.cs @@ -0,0 +1,139 @@ +/* + * 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 put in 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 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). + /// Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. (required). + /// usage (required). + 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; + // to ensure "usage" is required (not null) + if (usage == null) + { + throw new ArgumentNullException("usage is a required property for AiAnalysisSucceededPayload and cannot be null"); + } + this.Usage = usage; + } + + /// + /// Name of the log in the session manifest, such as `ai-analysis-0.slog`. + /// + /// Name of the 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; } + + /// + /// Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. + /// + /// Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. + [DataMember(Name = "promptVersion", IsRequired = true, EmitDefaultValue = true)] + public string PromptVersion { get; set; } + + /// + /// Gets or Sets Usage + /// + [DataMember(Name = "usage", IsRequired = true, 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/AiAnalysisTask.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisTask.cs new file mode 100644 index 0000000000..236a1d1b8d --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisTask.cs @@ -0,0 +1,172 @@ +/* + * 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 +{ + /// + /// An AI analysis Task: the permanent record of one analysis the provisioner asked for The work runs in the background; `state` and `payload` tell how it goes. + /// + [DataContract(Name = "AiAnalysisTask")] + public partial class AiAnalysisTask : 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 AiAnalysisTask() { } + /// + /// Initializes a new instance of the class. + /// + /// When the Task was created. (required). + /// Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or the Task makes progress. (required). + /// When it succeeded or failed.. + /// Task ID, chosen by the provisioner. (required). + /// varParams (required). + /// payload. + /// Recorded session the analysis is about. (required). + /// When its first attempt started.. + /// state (required). + public AiAnalysisTask(DateTime createdAt = default(DateTime), DateTime deadlineAt = default(DateTime), DateTime? finishedAt = default(DateTime?), Guid id = default(Guid), AiAnalysisParams varParams = default(AiAnalysisParams), AiAnalysisPayload payload = default(AiAnalysisPayload), Guid sessionId = default(Guid), DateTime? startedAt = default(DateTime?), TaskState state = default(TaskState)) + { + this.CreatedAt = createdAt; + this.DeadlineAt = deadlineAt; + this.Id = id; + // to ensure "varParams" is required (not null) + if (varParams == null) + { + throw new ArgumentNullException("varParams is a required property for AiAnalysisTask and cannot be null"); + } + this.Params = varParams; + this.SessionId = sessionId; + this.State = state; + this.FinishedAt = finishedAt; + this.Payload = payload; + this.StartedAt = startedAt; + } + + /// + /// When the Task was created. + /// + /// When the Task was created. + [DataMember(Name = "createdAt", IsRequired = true, EmitDefaultValue = true)] + public DateTime CreatedAt { get; set; } + + /// + /// Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or the Task makes progress. + /// + /// Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or the Task makes progress. + [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; } + + /// + /// Gets or Sets Params + /// + [DataMember(Name = "params", IsRequired = true, EmitDefaultValue = true)] + public AiAnalysisParams Params { get; set; } + + /// + /// Gets or Sets Payload + /// + [DataMember(Name = "payload", EmitDefaultValue = true)] + public AiAnalysisPayload Payload { get; set; } + + /// + /// Recorded session the analysis is about. + /// + /// Recorded session the analysis is about. + [DataMember(Name = "sessionId", IsRequired = true, EmitDefaultValue = true)] + public Guid SessionId { get; set; } + + /// + /// When its first attempt started. + /// + /// When its first attempt started. + [DataMember(Name = "startedAt", EmitDefaultValue = true)] + public DateTime? StartedAt { 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 AiAnalysisTask {\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(" Params: ").Append(Params).Append("\n"); + sb.Append(" Payload: ").Append(Payload).Append("\n"); + sb.Append(" SessionId: ").Append(SessionId).Append("\n"); + sb.Append(" StartedAt: ").Append(StartedAt).Append("\n"); + sb.Append(" State: ").Append(State).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 0000000000..59c7be453c --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiAnalysisUsage.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 +{ + /// + /// Tokens the AI provider counted for an AI analysis Task, over all its attempts + /// + [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). + /// Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. (required). + public AiAnalysisUsage(long inputTokens = default(long), long outputTokens = default(long), long unreportedRequests = default(long)) + { + this.InputTokens = inputTokens; + this.OutputTokens = outputTokens; + this.UnreportedRequests = unreportedRequests; + } + + /// + /// 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; } + + /// + /// Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. + /// + /// Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. + [DataMember(Name = "unreportedRequests", IsRequired = true, EmitDefaultValue = true)] + public long UnreportedRequests { 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(" UnreportedRequests: ").Append(UnreportedRequests).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" }); + } + + // UnreportedRequests (long) minimum + if (this.UnreportedRequests < (long)0) + { + yield return new ValidationResult("Invalid value for UnreportedRequests, must be a value greater than or equal to 0.", new [] { "UnreportedRequests" }); + } + + 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 0000000000..da7b2dbe9a --- /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/TaskState.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs new file mode 100644 index 0000000000..4257977889 --- /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/dotnet-client/templates/modelGeneric.mustache b/devolutions-gateway/openapi/dotnet-client/templates/modelGeneric.mustache new file mode 100644 index 0000000000..4c9857b606 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/templates/modelGeneric.mustache @@ -0,0 +1,430 @@ + /// + /// {{description}}{{^description}}{{classname}}{{/description}} + /// + {{#vendorExtensions.x-cls-compliant}} + [CLSCompliant({{{vendorExtensions.x-cls-compliant}}})] + {{/vendorExtensions.x-cls-compliant}} + {{#vendorExtensions.x-com-visible}} + [ComVisible({{{vendorExtensions.x-com-visible}}})] + {{/vendorExtensions.x-com-visible}} + [DataContract(Name = "{{{name}}}")] + {{^useUnityWebRequest}} + {{#discriminator}} + [JsonConverter(typeof(JsonSubtypes), "{{{discriminatorName}}}")] + {{#mappedModels}} + [JsonSubtypes.KnownSubType(typeof({{{modelName}}}), "{{^vendorExtensions.x-discriminator-value}}{{{mappingName}}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{.}}}{{/vendorExtensions.x-discriminator-value}}")] + {{/mappedModels}} + {{/discriminator}} + {{/useUnityWebRequest}} + {{>visibility}} partial class {{classname}}{{#lambda.firstDot}}{{#parent}} : .{{/parent}}{{#validatable}} : .{{/validatable}}{{#equatable}} : .{{/equatable}}{{/lambda.firstDot}}{{#lambda.joinWithComma}}{{#parent}}{{{.}}} {{/parent}}{{#equatable}}IEquatable<{{classname}}> {{/equatable}}{{#validatable}}IValidatableObject {{/validatable}}{{/lambda.joinWithComma}} + { + {{#vars}} + {{#items.isEnum}} + {{#items}} + {{^complexType}} +{{>modelInnerEnum}} + {{/complexType}} + {{/items}} + {{/items.isEnum}} + {{#isEnum}} + {{^complexType}} +{{>modelInnerEnum}} + {{/complexType}} + {{/isEnum}} + {{#isEnum}} + + /// + /// {{description}}{{^description}}Gets or Sets {{{name}}}{{/description}} + /// + {{#description}} + /// {{.}} + {{/description}} + {{#example}} + /* + {{.}} + */ + {{/example}} + {{^conditionalSerialization}} + [DataMember(Name = "{{baseName}}"{{#required}}, IsRequired = true{{/required}}, EmitDefaultValue = {{#vendorExtensions.x-emit-default-value}}true{{/vendorExtensions.x-emit-default-value}}{{^vendorExtensions.x-emit-default-value}}{{#required}}true{{/required}}{{^required}}{{#isBoolean}}true{{/isBoolean}}{{^isBoolean}}{{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}}{{/isBoolean}}{{/required}}{{/vendorExtensions.x-emit-default-value}})] + {{#deprecated}} + [Obsolete] + {{/deprecated}} + public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} {{name}} { get; set; } + {{#isReadOnly}} + + /// + /// Returns false as {{name}} should not be serialized given that it's read-only. + /// + /// false (boolean) + public bool ShouldSerialize{{name}}() + { + return false; + } + {{/isReadOnly}} + {{/conditionalSerialization}} + {{#conditionalSerialization}} + {{#isReadOnly}} + [DataMember(Name = "{{baseName}}"{{#required}}, IsRequired = true{{/required}}, EmitDefaultValue = {{#vendorExtensions.x-emit-default-value}}true{{/vendorExtensions.x-emit-default-value}}{{^vendorExtensions.x-emit-default-value}}{{#required}}true{{/required}}{{^required}}{{#isBoolean}}true{{/isBoolean}}{{^isBoolean}}{{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}}{{/isBoolean}}{{/required}}{{/vendorExtensions.x-emit-default-value}})] + {{#deprecated}} + [Obsolete] + {{/deprecated}} + public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} {{name}} { get; set; } + + + /// + /// Returns false as {{name}} should not be serialized given that it's read-only. + /// + /// false (boolean) + public bool ShouldSerialize{{name}}() + { + return false; + } + {{/isReadOnly}} + + {{^isReadOnly}} + [DataMember(Name = "{{baseName}}"{{#required}}, IsRequired = true{{/required}}, EmitDefaultValue = {{#vendorExtensions.x-emit-default-value}}true{{/vendorExtensions.x-emit-default-value}}{{^vendorExtensions.x-emit-default-value}}{{#required}}true{{/required}}{{^required}}{{#isBoolean}}true{{/isBoolean}}{{^isBoolean}}{{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}}{{/isBoolean}}{{/required}}{{/vendorExtensions.x-emit-default-value}})] + {{#deprecated}} + [Obsolete] + {{/deprecated}} + public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} {{name}} + { + get{ return _{{name}};} + set + { + _{{name}} = value; + _flag{{name}} = true; + } + } + private {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} _{{name}}; + private bool _flag{{name}}; + + /// + /// Returns false as {{name}} should not be serialized given that it's read-only. + /// + /// false (boolean) + public bool ShouldSerialize{{name}}() + { + return _flag{{name}}; + } + {{/isReadOnly}} + {{/conditionalSerialization}} + {{/isEnum}} + {{/vars}} + {{#hasRequired}} + {{^hasOnlyReadOnly}} + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + {{^isAdditionalPropertiesTrue}} + protected {{classname}}() { } + {{/isAdditionalPropertiesTrue}} + {{#isAdditionalPropertiesTrue}} + protected {{classname}}() + { + this.AdditionalProperties = new Dictionary(); + } + {{/isAdditionalPropertiesTrue}} + {{/hasOnlyReadOnly}} + {{/hasRequired}} + /// + /// Initializes a new instance of the class. + /// + {{#readWriteVars}} + /// {{description}}{{^description}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}{{/description}}{{#required}} (required){{/required}}{{#defaultValue}} (default to {{.}}){{/defaultValue}}. + {{/readWriteVars}} + {{#hasOnlyReadOnly}} + [JsonConstructorAttribute] + {{/hasOnlyReadOnly}} + public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}} {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{#defaultValue}}{{^isDateTime}}{{#isString}}{{^isEnum}}@{{/isEnum}}{{/isString}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default({{{datatypeWithEnum}}}){{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default({{{datatypeWithEnum}}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}}){{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}} + { + {{#vars}} + {{^isInherited}} + {{^isReadOnly}} + {{#required}} + {{^conditionalSerialization}} + {{^vendorExtensions.x-csharp-value-type}} + // to ensure "{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}" is required (not null) + if ({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} == null) + { + throw new ArgumentNullException("{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} is a required property for {{classname}} and cannot be null"); + } + this.{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + {{/vendorExtensions.x-csharp-value-type}} + {{#vendorExtensions.x-csharp-value-type}} + this.{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + {{/vendorExtensions.x-csharp-value-type}} + {{/conditionalSerialization}} + {{#conditionalSerialization}} + {{^vendorExtensions.x-csharp-value-type}} + // to ensure "{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}" is required (not null) + if ({{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} == null) + { + throw new ArgumentNullException("{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} is a required property for {{classname}} and cannot be null"); + } + this._{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + {{/vendorExtensions.x-csharp-value-type}} + {{#vendorExtensions.x-csharp-value-type}} + this._{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + {{/vendorExtensions.x-csharp-value-type}} + {{/conditionalSerialization}} + {{/required}} + {{/isReadOnly}} + {{/isInherited}} + {{/vars}} + {{#vars}} + {{^isInherited}} + {{^isReadOnly}} + {{^required}} + {{#defaultValue}} + {{^conditionalSerialization}} + {{^vendorExtensions.x-csharp-value-type}} + // use default value if no "{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}" provided + this.{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} ?? {{#isString}}@{{/isString}}{{{defaultValue}}}; + {{/vendorExtensions.x-csharp-value-type}} + {{#vendorExtensions.x-csharp-value-type}} + this.{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + {{/vendorExtensions.x-csharp-value-type}} + {{/conditionalSerialization}} + {{/defaultValue}} + {{^defaultValue}} + {{^conditionalSerialization}} + this.{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + {{/conditionalSerialization}} + {{#conditionalSerialization}} + this._{{name}} = {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}; + if (this.{{name}} != null) + { + this._flag{{name}} = true; + } + {{/conditionalSerialization}} + {{/defaultValue}} + {{/required}} + {{/isReadOnly}} + {{/isInherited}} + {{/vars}} + {{#isAdditionalPropertiesTrue}} + this.AdditionalProperties = new Dictionary(); + {{/isAdditionalPropertiesTrue}} + } + + {{#vars}} + {{^isInherited}} + {{^isEnum}} + /// + /// {{description}}{{^description}}Gets or Sets {{{name}}}{{/description}} + /// {{#description}} + /// {{.}}{{/description}} + {{#example}} + /* + {{.}} + */ + {{/example}} + {{^conditionalSerialization}} + [DataMember(Name = "{{baseName}}"{{#required}}, IsRequired = true{{/required}}, EmitDefaultValue = {{#vendorExtensions.x-emit-default-value}}true{{/vendorExtensions.x-emit-default-value}}{{^vendorExtensions.x-emit-default-value}}{{#required}}true{{/required}}{{^required}}{{#isBoolean}}true{{/isBoolean}}{{^isBoolean}}{{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}}{{/isBoolean}}{{/required}}{{/vendorExtensions.x-emit-default-value}})] + {{#isDate}} + {{^supportsDateOnly}} + [JsonConverter(typeof(OpenAPIDateConverter))] + {{/supportsDateOnly}} + {{/isDate}} + {{#deprecated}} + [Obsolete] + {{/deprecated}} + public {{{dataType}}} {{name}} { get; {{#isReadOnly}}private {{/isReadOnly}}set; } + + {{#isReadOnly}} + /// + /// Returns false as {{name}} should not be serialized given that it's read-only. + /// + /// false (boolean) + public bool ShouldSerialize{{name}}() + { + return false; + } + {{/isReadOnly}} + {{/conditionalSerialization}} + {{#conditionalSerialization}} + {{#isReadOnly}} + [DataMember(Name = "{{baseName}}"{{#required}}, IsRequired = true{{/required}}, EmitDefaultValue = {{#vendorExtensions.x-emit-default-value}}true{{/vendorExtensions.x-emit-default-value}}{{^vendorExtensions.x-emit-default-value}}{{#required}}true{{/required}}{{^required}}{{#isBoolean}}true{{/isBoolean}}{{^isBoolean}}{{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}}{{/isBoolean}}{{/required}}{{/vendorExtensions.x-emit-default-value}})] + {{#isDate}} + [JsonConverter(typeof(OpenAPIDateConverter))] + {{/isDate}} + {{#deprecated}} + [Obsolete] + {{/deprecated}} + public {{{dataType}}} {{name}} { get; private set; } + + /// + /// Returns false as {{name}} should not be serialized given that it's read-only. + /// + /// false (boolean) + public bool ShouldSerialize{{name}}() + { + return false; + } + {{/isReadOnly}} + {{^isReadOnly}} + {{#isDate}} + [JsonConverter(typeof(OpenAPIDateConverter))] + {{/isDate}} + [DataMember(Name = "{{baseName}}"{{#required}}, IsRequired = true{{/required}}, EmitDefaultValue = {{#vendorExtensions.x-emit-default-value}}true{{/vendorExtensions.x-emit-default-value}}{{^vendorExtensions.x-emit-default-value}}{{#required}}true{{/required}}{{^required}}{{#isBoolean}}true{{/isBoolean}}{{^isBoolean}}{{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}}{{/isBoolean}}{{/required}}{{/vendorExtensions.x-emit-default-value}})] + {{#deprecated}} + [Obsolete] + {{/deprecated}} + public {{{dataType}}} {{name}} + { + get{ return _{{name}};} + set + { + _{{name}} = value; + _flag{{name}} = true; + } + } + private {{{dataType}}} _{{name}}; + private bool _flag{{name}}; + + /// + /// Returns false as {{name}} should not be serialized given that it's read-only. + /// + /// false (boolean) + public bool ShouldSerialize{{name}}() + { + return _flag{{name}}; + } + {{/isReadOnly}} + {{/conditionalSerialization}} + {{/isEnum}} + {{/isInherited}} + {{/vars}} + {{#isAdditionalPropertiesTrue}} + /// + /// Gets or Sets additional properties + /// + [JsonExtensionData] + public IDictionary AdditionalProperties { get; set; } + + {{/isAdditionalPropertiesTrue}} + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class {{classname}} {\n"); + {{#parent}} + sb.Append(" ").Append(base.ToString().Replace("\n", "\n ")).Append("\n"); + {{/parent}} + {{#vars}} + sb.Append(" {{name}}: ").Append({{#isPassword}}"[redacted]"{{/isPassword}}{{^isPassword}}{{name}}{{/isPassword}}).Append("\n"); + {{/vars}} + {{#isAdditionalPropertiesTrue}} + sb.Append(" AdditionalProperties: ").Append(AdditionalProperties).Append("\n"); + {{/isAdditionalPropertiesTrue}} + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public {{#parent}}{{^isArray}}{{^isMap}}override {{/isMap}}{{/isArray}}{{/parent}}{{^parent}}virtual {{/parent}}string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + {{#equatable}} + + /// + /// Returns true if objects are equal + /// + /// Object to be compared + /// Boolean + public override bool Equals(object input) + { + {{#useCompareNetObjects}} + return OpenAPIClientUtils.compareLogic.Compare(this, input as {{classname}}).AreEqual; + {{/useCompareNetObjects}} + {{^useCompareNetObjects}} + return this.Equals(input as {{classname}}); + {{/useCompareNetObjects}} + } + + /// + /// Returns true if {{classname}} instances are equal + /// + /// Instance of {{classname}} to be compared + /// Boolean + public bool Equals({{classname}} input) + { + {{#useCompareNetObjects}} + return OpenAPIClientUtils.compareLogic.Compare(this, input).AreEqual; + {{/useCompareNetObjects}} + {{^useCompareNetObjects}} + if (input == null) + { + return false; + } + return {{#vars}}{{#parent}}base.Equals(input) && {{/parent}}{{^isContainer}} + ( + this.{{name}} == input.{{name}} || + {{^vendorExtensions.x-is-value-type}} + (this.{{name}} != null && + this.{{name}}.Equals(input.{{name}})) + {{/vendorExtensions.x-is-value-type}} + {{#vendorExtensions.x-is-value-type}} + this.{{name}}.Equals(input.{{name}}) + {{/vendorExtensions.x-is-value-type}} + ){{^-last}} && {{/-last}}{{/isContainer}}{{#isContainer}} + ( + this.{{name}} == input.{{name}} || + {{^vendorExtensions.x-is-value-type}}this.{{name}} != null && + input.{{name}} != null && + {{/vendorExtensions.x-is-value-type}}this.{{name}}.SequenceEqual(input.{{name}}) + ){{^-last}} && {{/-last}}{{/isContainer}}{{/vars}}{{^vars}}{{#parent}}base.Equals(input){{/parent}}{{^parent}}false{{/parent}}{{/vars}}{{^isAdditionalPropertiesTrue}};{{/isAdditionalPropertiesTrue}} + {{#isAdditionalPropertiesTrue}} + && (this.AdditionalProperties.Count == input.AdditionalProperties.Count && !this.AdditionalProperties.Except(input.AdditionalProperties).Any()); + {{/isAdditionalPropertiesTrue}} + {{/useCompareNetObjects}} + } + + /// + /// Gets the hash code + /// + /// Hash code + public override int GetHashCode() + { + unchecked // Overflow is fine, just wrap + { + {{#parent}} + int hashCode = base.GetHashCode(); + {{/parent}} + {{^parent}} + int hashCode = 41; + {{/parent}} + {{#vars}} + {{^vendorExtensions.x-is-value-type}} + if (this.{{name}} != null) + { + hashCode = (hashCode * 59) + this.{{name}}.GetHashCode(); + } + {{/vendorExtensions.x-is-value-type}} + {{#vendorExtensions.x-is-value-type}} + hashCode = (hashCode * 59) + this.{{name}}.GetHashCode(); + {{/vendorExtensions.x-is-value-type}} + {{/vars}} + {{#isAdditionalPropertiesTrue}} + if (this.AdditionalProperties != null) + { + hashCode = (hashCode * 59) + this.AdditionalProperties.GetHashCode(); + } + {{/isAdditionalPropertiesTrue}} + return hashCode; + } + } + {{/equatable}} + +{{#validatable}} +{{>validatable}} +{{/validatable}} + } diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 9ec75de7f3..f3ed77ab0e 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -943,6 +943,114 @@ paths: security: - scope_token: - gateway.sessions.read + /jet/tasks/recording-ai-analysis/{task_id}: + get: + tags: + - Tasks + summary: Gets an AI analysis of a recorded session + description: |- + Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens + it used in every state but `queued`. + Tasks are kept forever, across Gateway restarts; an unfinished Task past its deadline is reported as failed. + operationId: GetAiAnalysis + parameters: + - name: task_id + in: path + description: Task ID of the AI analysis + required: true + schema: + type: string + format: uuid + responses: + '200': + description: The AI analysis Task + content: + application/json: + schema: + $ref: '#/components/schemas/AiAnalysisTask' + '400': + description: Bad request + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '404': + description: No AI analysis has this Task ID + '500': + description: Unexpected server error + security: + - scope_token: + - gateway.tasks.recording-ai-analysis.read + put: + tags: + - Tasks + summary: Starts an AI analysis of a recorded session + description: |- + Gateway asks the AI provider what the user did in the session recording, and puts the answer in the session as its + log, listed under `artifacts.ai-analysis` in its manifest. + The answer is in the `language` of the request, English without one. + The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. + + Sending the same request again returns the same Task and starts nothing new. + A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success + is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes + everything it made. + A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. + 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: task_id + in: path + description: Task ID, chosen by the caller so it can send the same request again safely + 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/AiAnalysisTask' + '202': + description: The AI analysis Task is created and runs in the background + headers: + Location: + schema: + type: string + description: Path of the new Task, `/jet/tasks/recording-ai-analysis/{task_id}` + content: + application/json: + schema: + $ref: '#/components/schemas/AiAnalysisTask' + '400': + description: Invalid request body, AI settings or language + '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.tasks.recording-ai-analysis.start /jet/traffic/ack: post: tags: @@ -1371,6 +1479,8 @@ components: - gateway.agent.delete - gateway.agent.read - gateway.recordings.search + - gateway.tasks.recording-ai-analysis.start + - gateway.tasks.recording-ai-analysis.read AckRequest: type: object required: @@ -1447,6 +1557,306 @@ components: - offline - online - unresponsive + AiAnalysisBadAnswer: + type: string + description: What was wrong with an answer of the AI provider + enum: + - invalid + - truncated + - refused + - notExpectedJson + 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 + AiAnalysisError: + type: object + description: |- + Why an AI analysis Task failed: a kind, with the fields of that kind; there is no text for people, so the caller + words it + required: + - kind + properties: + file: + type: string + description: Recording file that cannot be read, for `unreadableRecording`. + nullable: true + kind: + $ref: '#/components/schemas/AiAnalysisErrorKind' + last: + allOf: + - $ref: '#/components/schemas/AiAnalysisRecoverableError' + nullable: true + status: + type: integer + format: int32 + description: HTTP status the AI provider answered, for `aiRejected`. + nullable: true + minimum: 0 + why: + allOf: + - $ref: '#/components/schemas/AiAnalysisBadAnswer' + nullable: true + AiAnalysisErrorKind: + type: string + description: Kind of failure of an AI analysis Task + enum: + - aiRejected + - aiBadAnswer + - keyLost + - noRecording + - mixedRecording + - unreadableRecording + - invalidTask + - panicked + - internal + - attemptsExhausted + - deadlinePassed + AiAnalysisFailedPayload: + type: object + description: 'Payload of a `failed` AI analysis Task: why it failed, and the tokens it used' + required: + - error + properties: + error: + $ref: '#/components/schemas/AiAnalysisError' + usage: + allOf: + - $ref: '#/components/schemas/AiAnalysisUsage' + nullable: true + AiAnalysisParams: + type: object + description: 'Params of an AI analysis Task: what was asked, without the API key and the base URL' + required: + - provider + - model + properties: + language: + type: string + description: Language the AI writes in; absent when not asked, so the AI writes in English. + nullable: true + maxOutputTokens: + type: integer + format: int64 + description: Upper bound of tokens in each AI answer; null when not asked. + nullable: true + maximum: 4294967295 + minimum: 0 + model: + type: string + description: Model identifier, passed to the provider as is. + provider: + $ref: '#/components/schemas/AiProvider' + AiAnalysisPayload: + oneOf: + - $ref: '#/components/schemas/AiAnalysisRunningPayload' + - $ref: '#/components/schemas/AiAnalysisSucceededPayload' + - $ref: '#/components/schemas/AiAnalysisFailedPayload' + description: 'Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own' + AiAnalysisRecoverableError: + type: object + description: A failure of an attempt that was worth another attempt + required: + - kind + properties: + kind: + $ref: '#/components/schemas/AiAnalysisRecoverableErrorKind' + status: + type: integer + format: int32 + description: HTTP status the AI provider answered, for `aiBusy`. + nullable: true + minimum: 0 + AiAnalysisRecoverableErrorKind: + type: string + description: Kind of a failure worth another attempt + enum: + - aiUnreachable + - aiBusy + AiAnalysisRequest: + type: object + description: Settings of an AI analysis + required: + - sessionId + - 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 + language: + type: string + description: Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. + nullable: true + maxLength: 10 + minLength: 1 + maxOutputTokens: + type: integer + format: int64 + description: Upper bound of tokens in each AI answer. + nullable: true + maximum: 4294967295 + minimum: 0 + model: + type: string + description: Model identifier, passed to the provider as is. + provider: + $ref: '#/components/schemas/AiProvider' + sessionId: + type: string + format: uuid + description: Recorded session to analyze. + AiAnalysisRunningPayload: + type: object + description: 'Payload of a `running` AI analysis Task: its progress, and the tokens it used so far' + required: + - step + - usage + properties: + done: + type: integer + format: int64 + description: 'Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps.' + nullable: true + minimum: 0 + step: + type: string + description: |- + Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is + asked about the recording, part by part), then `writing` (the log is written). + total: + type: integer + format: int64 + description: Work the step has in all, in the same unit as `done`; absent in the other steps. + nullable: true + minimum: 0 + usage: + $ref: '#/components/schemas/AiAnalysisUsage' + AiAnalysisSucceededPayload: + type: object + description: 'Payload of a `succeeded` AI analysis Task: the log it put in the session' + required: + - fileName + - model + - usage + - promptVersion + properties: + fileName: + type: string + description: Name of the 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: |- + Versions of the prompts that wrote the log, such as + `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. + usage: + $ref: '#/components/schemas/AiAnalysisUsage' + AiAnalysisTask: + type: object + description: |- + An AI analysis Task: the permanent record of one analysis the provisioner asked for + + The work runs in the background; `state` and `payload` tell how it goes. + required: + - id + - sessionId + - params + - state + - createdAt + - deadlineAt + properties: + createdAt: + type: string + format: date-time + description: When the Task was created. + deadlineAt: + type: string + format: date-time + description: |- + Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or + the Task makes progress. + 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. + params: + $ref: '#/components/schemas/AiAnalysisParams' + payload: + allOf: + - $ref: '#/components/schemas/AiAnalysisPayload' + nullable: true + sessionId: + type: string + format: uuid + description: Recorded session the analysis is about. + startedAt: + type: string + format: date-time + description: When its first attempt started. + nullable: true + state: + $ref: '#/components/schemas/TaskState' + AiAnalysisUsage: + type: object + description: Tokens the AI provider counted for an AI analysis Task, over all its attempts + required: + - inputTokens + - outputTokens + - unreportedRequests + properties: + inputTokens: + type: integer + format: int64 + minimum: 0 + outputTokens: + type: integer + format: int64 + minimum: 0 + unreportedRequests: + type: integer + format: int64 + description: |- + Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one + still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. + 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: @@ -2556,6 +2966,13 @@ components: Format: `://:` (port is required). Supported schemes are `tcp` and `udp`. nullable: true + 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 a7c07e7165..198e41778b 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,22 @@ model/addressFamily.ts model/agentDomainAdvertisement.ts model/agentInfo.ts model/agentStatus.ts +model/aiAnalysisBadAnswer.ts +model/aiAnalysisConflict.ts +model/aiAnalysisConflictCode.ts +model/aiAnalysisError.ts +model/aiAnalysisErrorKind.ts +model/aiAnalysisFailedPayload.ts +model/aiAnalysisParams.ts +model/aiAnalysisPayload.ts +model/aiAnalysisRecoverableError.ts +model/aiAnalysisRecoverableErrorKind.ts +model/aiAnalysisRequest.ts +model/aiAnalysisRunningPayload.ts +model/aiAnalysisSucceededPayload.ts +model/aiAnalysisTask.ts +model/aiAnalysisUsage.ts +model/aiProvider.ts model/appCredential.ts model/appCredentialKind.ts model/appTokenContentType.ts @@ -83,6 +100,7 @@ model/startTimeRange.ts model/subProvisionerKey.ts model/subscriber.ts model/targetConnectionOptions.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 a7eb029c9a..97736f4987 100644 --- a/devolutions-gateway/openapi/ts-angular-client/api/api.ts +++ b/devolutions-gateway/openapi/ts-angular-client/api/api.ts @@ -20,10 +20,12 @@ export * from './preflight.service'; import { PreflightService } from './preflight.service'; export * from './sessions.service'; import { SessionsService } from './sessions.service'; +export * from './tasks.service'; +import { TasksService } from './tasks.service'; export * from './traffic.service'; import { TrafficService } from './traffic.service'; export * from './update.service'; import { UpdateService } from './update.service'; export * from './webApp.service'; import { WebAppService } from './webApp.service'; -export const APIS = [AgentService, ConfigService, DiagnosticsService, HealthService, HeartbeatService, JrecService, JrlService, NetService, NetworkMonitoringService, PreflightService, SessionsService, TrafficService, UpdateService, WebAppService]; +export const APIS = [AgentService, ConfigService, DiagnosticsService, HealthService, HeartbeatService, JrecService, JrlService, NetService, NetworkMonitoringService, PreflightService, SessionsService, TasksService, TrafficService, UpdateService, WebAppService]; diff --git a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts new file mode 100644 index 0000000000..fd8c7cdd98 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts @@ -0,0 +1,255 @@ +/** + * 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 { AiAnalysisConflict } from '../model/aiAnalysisConflict'; +// @ts-ignore +import { AiAnalysisRequest } from '../model/aiAnalysisRequest'; +// @ts-ignore +import { AiAnalysisTask } from '../model/aiAnalysisTask'; + +// @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 an AI analysis of a recorded session + * Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens it used in every state but `queued`. Tasks are kept forever, across Gateway restarts; an unfinished Task past its deadline is reported as failed. + * @param taskId Task ID of the AI analysis + * @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 getAiAnalysis(taskId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getAiAnalysis(taskId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAiAnalysis(taskId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAiAnalysis(taskId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (taskId === null || taskId === undefined) { + throw new Error('Required parameter taskId was null or undefined when calling getAiAnalysis.'); + } + + 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/recording-ai-analysis/${this.configuration.encodeParam({name: "taskId", value: taskId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * Starts an AI analysis of a recorded session + * Gateway asks the AI provider what the user did in the session recording, and puts the answer in the session as its log, listed under `artifacts.ai-analysis` in its manifest. The answer is in the `language` of the request, English without one. The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. Sending the same request again returns the same Task and starts nothing new. A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes everything it made. A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished Tasks. + * @param taskId Task ID, chosen by the caller so it can send the same request again safely + * @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(taskId: string, aiAnalysisRequest: AiAnalysisRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public startAiAnalysis(taskId: string, aiAnalysisRequest: AiAnalysisRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startAiAnalysis(taskId: string, aiAnalysisRequest: AiAnalysisRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startAiAnalysis(taskId: string, aiAnalysisRequest: AiAnalysisRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (taskId === null || taskId === undefined) { + throw new Error('Required parameter taskId 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/tasks/recording-ai-analysis/${this.configuration.encodeParam({name: "taskId", value: taskId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}`; + return this.httpClient.request('put', `${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/model/accessScope.ts b/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts index 07dc408fcb..5729dd3dec 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' | 'gateway.recordings.search'; +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.recordings.search' | 'gateway.tasks.recording-ai-analysis.start' | 'gateway.tasks.recording-ai-analysis.read'; export const AccessScope = { Star: '*' as AccessScope, @@ -31,6 +31,8 @@ export const AccessScope = { GatewayNetMonitorDrain: 'gateway.net.monitor.drain' as AccessScope, GatewayAgentDelete: 'gateway.agent.delete' as AccessScope, GatewayAgentRead: 'gateway.agent.read' as AccessScope, - GatewayRecordingsSearch: 'gateway.recordings.search' as AccessScope + GatewayRecordingsSearch: 'gateway.recordings.search' as AccessScope, + GatewayTasksRecordingAiAnalysisStart: 'gateway.tasks.recording-ai-analysis.start' as AccessScope, + GatewayTasksRecordingAiAnalysisRead: 'gateway.tasks.recording-ai-analysis.read' as AccessScope }; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisBadAnswer.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisBadAnswer.ts new file mode 100644 index 0000000000..2179a57e56 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisBadAnswer.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ + + +/** + * What was wrong with an answer of the AI provider + */ +export type AiAnalysisBadAnswer = 'invalid' | 'truncated' | 'refused' | 'notExpectedJson'; + +export const AiAnalysisBadAnswer = { + Invalid: 'invalid' as AiAnalysisBadAnswer, + Truncated: 'truncated' as AiAnalysisBadAnswer, + Refused: 'refused' as AiAnalysisBadAnswer, + NotExpectedJson: 'notExpectedJson' as AiAnalysisBadAnswer +}; + 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 0000000000..322bc7147f --- /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 0000000000..ac7e695752 --- /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/aiAnalysisError.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisError.ts new file mode 100644 index 0000000000..c127a45491 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisError.ts @@ -0,0 +1,34 @@ +/** + * 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 { AiAnalysisBadAnswer } from './aiAnalysisBadAnswer'; +import { AiAnalysisErrorKind } from './aiAnalysisErrorKind'; +import { AiAnalysisRecoverableError } from './aiAnalysisRecoverableError'; + + +/** + * Why an AI analysis Task failed: a kind, with the fields of that kind; there is no text for people, so the caller words it + */ +export interface AiAnalysisError { + /** + * Recording file that cannot be read, for `unreadableRecording`. + */ + file?: string | null; + kind: AiAnalysisErrorKind; + last?: AiAnalysisRecoverableError | null; + /** + * HTTP status the AI provider answered, for `aiRejected`. + */ + status?: number | null; + why?: AiAnalysisBadAnswer | null; +} +export namespace AiAnalysisError { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisErrorKind.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisErrorKind.ts new file mode 100644 index 0000000000..7a54a7e37b --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisErrorKind.ts @@ -0,0 +1,30 @@ +/** + * 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. + */ + + +/** + * Kind of failure of an AI analysis Task + */ +export type AiAnalysisErrorKind = 'aiRejected' | 'aiBadAnswer' | 'keyLost' | 'noRecording' | 'mixedRecording' | 'unreadableRecording' | 'invalidTask' | 'panicked' | 'internal' | 'attemptsExhausted' | 'deadlinePassed'; + +export const AiAnalysisErrorKind = { + AiRejected: 'aiRejected' as AiAnalysisErrorKind, + AiBadAnswer: 'aiBadAnswer' as AiAnalysisErrorKind, + KeyLost: 'keyLost' as AiAnalysisErrorKind, + NoRecording: 'noRecording' as AiAnalysisErrorKind, + MixedRecording: 'mixedRecording' as AiAnalysisErrorKind, + UnreadableRecording: 'unreadableRecording' as AiAnalysisErrorKind, + InvalidTask: 'invalidTask' as AiAnalysisErrorKind, + Panicked: 'panicked' as AiAnalysisErrorKind, + Internal: 'internal' as AiAnalysisErrorKind, + AttemptsExhausted: 'attemptsExhausted' as AiAnalysisErrorKind, + DeadlinePassed: 'deadlinePassed' as AiAnalysisErrorKind +}; + 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 0000000000..27217f72ca --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisFailedPayload.ts @@ -0,0 +1,21 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { AiAnalysisError } from './aiAnalysisError'; +import { AiAnalysisUsage } from './aiAnalysisUsage'; + + +/** + * Payload of a `failed` AI analysis Task: why it failed, and the tokens it used + */ +export interface AiAnalysisFailedPayload { + error: AiAnalysisError; + usage?: AiAnalysisUsage | null; +} + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisParams.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisParams.ts new file mode 100644 index 0000000000..8822f359ca --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisParams.ts @@ -0,0 +1,34 @@ +/** + * 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'; + + +/** + * Params of an AI analysis Task: what was asked, without the API key and the base URL + */ +export interface AiAnalysisParams { + /** + * Language the AI writes in; absent when not asked, so the AI writes in English. + */ + language?: string | null; + /** + * Upper bound of tokens in each AI answer; null when not asked. + */ + maxOutputTokens?: number | null; + /** + * Model identifier, passed to the provider as is. + */ + model: string; + provider: AiProvider; +} +export namespace AiAnalysisParams { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisPayload.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisPayload.ts new file mode 100644 index 0000000000..40b9e9eef7 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisPayload.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 { AiAnalysisSucceededPayload } from './aiAnalysisSucceededPayload'; +import { AiAnalysisError } from './aiAnalysisError'; +import { AiAnalysisRunningPayload } from './aiAnalysisRunningPayload'; +import { AiAnalysisUsage } from './aiAnalysisUsage'; +import { AiAnalysisFailedPayload } from './aiAnalysisFailedPayload'; + + +/** + * Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own + */ +/** + * @type AiAnalysisPayload + * Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own + * @export + */ +export type AiAnalysisPayload = AiAnalysisFailedPayload | AiAnalysisRunningPayload | AiAnalysisSucceededPayload; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRecoverableError.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRecoverableError.ts new file mode 100644 index 0000000000..cae6f29030 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRecoverableError.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 { AiAnalysisRecoverableErrorKind } from './aiAnalysisRecoverableErrorKind'; + + +/** + * A failure of an attempt that was worth another attempt + */ +export interface AiAnalysisRecoverableError { + kind: AiAnalysisRecoverableErrorKind; + /** + * HTTP status the AI provider answered, for `aiBusy`. + */ + status?: number | null; +} +export namespace AiAnalysisRecoverableError { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRecoverableErrorKind.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRecoverableErrorKind.ts new file mode 100644 index 0000000000..585cf4dace --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRecoverableErrorKind.ts @@ -0,0 +1,21 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * Kind of a failure worth another attempt + */ +export type AiAnalysisRecoverableErrorKind = 'aiUnreachable' | 'aiBusy'; + +export const AiAnalysisRecoverableErrorKind = { + AiUnreachable: 'aiUnreachable' as AiAnalysisRecoverableErrorKind, + AiBusy: 'aiBusy' as AiAnalysisRecoverableErrorKind +}; + 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 0000000000..4615d0d407 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRequest.ts @@ -0,0 +1,46 @@ +/** + * 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; + /** + * Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. + */ + language?: 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; + /** + * Recorded session to analyze. + */ + sessionId: 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 0000000000..cfea248dc2 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisRunningPayload.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 `running` AI analysis Task: its progress, and the tokens it used so far + */ +export interface AiAnalysisRunningPayload { + /** + * Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. + */ + done?: number | null; + /** + * Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is asked about the recording, part by part), then `writing` (the log is written). + */ + step: string; + /** + * Work the step has in all, in the same unit as `done`; absent in the other steps. + */ + total?: number | null; + usage: AiAnalysisUsage; +} + 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 0000000000..258fde94b2 --- /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 put in the session + */ +export interface AiAnalysisSucceededPayload { + /** + * Name of the 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; + /** + * Versions of the prompts that wrote the log, such as `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. + */ + promptVersion: string; + usage: AiAnalysisUsage; +} + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisTask.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisTask.ts new file mode 100644 index 0000000000..a85b03810d --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisTask.ts @@ -0,0 +1,50 @@ +/** + * 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 { AiAnalysisParams } from './aiAnalysisParams'; +import { AiAnalysisPayload } from './aiAnalysisPayload'; +import { TaskState } from './taskState'; + + +/** + * An AI analysis Task: the permanent record of one analysis the provisioner asked for The work runs in the background; `state` and `payload` tell how it goes. + */ +export interface AiAnalysisTask { + /** + * When the Task was created. + */ + createdAt: string; + /** + * Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or the Task makes progress. + */ + deadlineAt: string; + /** + * When it succeeded or failed. + */ + finishedAt?: string | null; + /** + * Task ID, chosen by the provisioner. + */ + id: string; + params: AiAnalysisParams; + payload?: AiAnalysisPayload | null; + /** + * Recorded session the analysis is about. + */ + sessionId: string; + /** + * When its first attempt started. + */ + startedAt?: string | null; + state: TaskState; +} +export namespace AiAnalysisTask { +} + + 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 0000000000..96bba67aa8 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiAnalysisUsage.ts @@ -0,0 +1,23 @@ +/** + * 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 the AI provider counted for an AI analysis Task, over all its attempts + */ +export interface AiAnalysisUsage { + inputTokens: number; + outputTokens: number; + /** + * Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. + */ + unreportedRequests: 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 0000000000..01385edbba --- /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 0c26c4654b..f3e43a0a9b 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/models.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/models.ts @@ -5,6 +5,22 @@ export * from './addressFamily'; export * from './agentDomainAdvertisement'; export * from './agentInfo'; export * from './agentStatus'; +export * from './aiAnalysisBadAnswer'; +export * from './aiAnalysisConflict'; +export * from './aiAnalysisConflictCode'; +export * from './aiAnalysisError'; +export * from './aiAnalysisErrorKind'; +export * from './aiAnalysisFailedPayload'; +export * from './aiAnalysisParams'; +export * from './aiAnalysisPayload'; +export * from './aiAnalysisRecoverableError'; +export * from './aiAnalysisRecoverableErrorKind'; +export * from './aiAnalysisRequest'; +export * from './aiAnalysisRunningPayload'; +export * from './aiAnalysisSucceededPayload'; +export * from './aiAnalysisTask'; +export * from './aiAnalysisUsage'; +export * from './aiProvider'; export * from './appCredential'; export * from './appCredentialKind'; export * from './appTokenContentType'; @@ -61,6 +77,7 @@ export * from './startTimeRange'; export * from './subProvisionerKey'; export * from './subscriber'; export * from './targetConnectionOptions'; +export * from './taskState'; export * from './trafficEventResponse'; export * from './transportProtocolResponse'; export * from './updateProductInfo'; 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 0000000000..568b0e2572 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts @@ -0,0 +1,20 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export type TaskState = 'queued' | 'running' | 'succeeded' | 'failed'; + +export const TaskState = { + Queued: 'queued' as TaskState, + Running: 'running' as TaskState, + Succeeded: 'succeeded' as TaskState, + Failed: 'failed' as TaskState +}; + diff --git a/devolutions-gateway/src/api/jrec.intent.md b/devolutions-gateway/src/api/jrec.intent.md new file mode 100644 index 0000000000..b181fc5c15 --- /dev/null +++ b/devolutions-gateway/src/api/jrec.intent.md @@ -0,0 +1,3 @@ +# Background + +Jrec is the endpoint to push, streaming, read recordings and its associated files. diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index c7b222ebc1..241b446016 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; @@ -25,6 +26,7 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { .route("/jet/preflight", axum::routing::post(preflight::post_preflight)) .nest("/jet/jrl", jrl::make_router(state.clone())) .nest("/jet/jrec", jrec::make_router(state.clone())) + .nest("/jet/tasks", tasks::make_router(state.clone())) .nest("/jet/config", config::make_router(state.clone())) .nest("/jet/session", session::make_router(state.clone())) .nest("/jet/sessions", sessions::make_router(state.clone())) diff --git a/devolutions-gateway/src/api/tasks/mod.rs b/devolutions-gateway/src/api/tasks/mod.rs new file mode 100644 index 0000000000..fe05b0580b --- /dev/null +++ b/devolutions-gateway/src/api/tasks/mod.rs @@ -0,0 +1,41 @@ +//! Endpoints of provisioner tasks: each kind of Task is started and read under `/jet/tasks/{kind}/{task_id}`. + +pub(crate) mod recording_ai_analysis; + +use axum::Router; +use axum::routing::put; +use provisioner_task::ProvisionerTaskState; + +use crate::DgwState; + +pub fn make_router(state: DgwState) -> Router { + Router::new() + .route( + "/recording-ai-analysis/{task_id}", + put(recording_ai_analysis::start_ai_analysis).get(recording_ai_analysis::get_ai_analysis), + ) + .with_state(state) +} + +#[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, + } + } +} diff --git a/devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md b/devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md new file mode 100644 index 0000000000..38d59475d1 --- /dev/null +++ b/devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md @@ -0,0 +1,9 @@ +# AI analysis + +An AI analysis is a task initiated by the provisioner. +It requires the scope token with access `gateway.tasks.recording-ai-analysis.start` to start one with `PUT /jet/tasks/recording-ai-analysis/{task_id}`, and the scope token with access `gateway.tasks.recording-ai-analysis.read` to read its state with `GET /jet/tasks/recording-ai-analysis/{task_id}`. +The analyzed output belongs to the recording reading scope, however, the associated metadata, including but not limited to error messages, token usages etc. belong to the task reading scope. +The caller chooses the language of the analysis in the request, under the rules of `crates/devolutions-gateway-ai/INTENT.md`. The language is part of the Task like every other parameter, so the same Task ID sent again with another language, model or provider is a conflict. +A request sent again is compared on its parameters only: the API key and the base URL are never stored, so another key or base URL gets the same Task, which keeps the first ones. +A new Task answers 202 with its path in `Location`, and the same request again answers 200 with the same Task. A conflict answers 409 with a `code`: `task_id_conflict`, `recording_active`, or `analysis_in_progress` with the running `taskId`. A session without recording, or an unknown Task, answers 404. An invalid request answers 400 and records no Task. Unknown fields are ignored, so a newer provisioner works with an older Gateway. The provider is one of `openai`, `anthropic`, `mistral`, `gemini` or `openai-compatible`. +Every Task reports the tokens it used so far, whether it is running, succeeded or failed, so the provisioner can add up what all analyses cost, failed ones included. Requests whose usage the provider didn't report are counted apart, so a total is never shown as complete when it isn't. A succeeded Task also shows the log's file name, the model the provider reported, and the prompt versions. diff --git a/devolutions-gateway/src/api/tasks/recording_ai_analysis.rs b/devolutions-gateway/src/api/tasks/recording_ai_analysis.rs new file mode 100644 index 0000000000..00a9fcd963 --- /dev/null +++ b/devolutions-gateway/src/api/tasks/recording_ai_analysis.rs @@ -0,0 +1,586 @@ +//! `/jet/tasks/recording-ai-analysis/{task_id}`: start and read the AI analysis of a recorded session. + +use anyhow::Context as _; +use axum::Json; +use axum::body::Bytes; +use axum::extract::{self, State}; +use axum::http::header::{HeaderValue, LOCATION}; +use axum::response::{IntoResponse as _, Response}; +use devolutions_gateway_ai::Language; +use hyper::StatusCode; +use provisioner_task::ProvisionerTaskRecord; +use secrecy::SecretString; +use time::OffsetDateTime; +use url::Url; +use uuid::Uuid; + +use super::TaskState; +use crate::DgwState; +use crate::extract::{RecordingAiAnalysisReadScope, RecordingAiAnalysisStartScope}; +use crate::http::HttpError; +use crate::provisioner_tasks::ai::{AiProvider, AiSettings}; +use crate::provisioner_tasks::recording_ai_analysis::{self as analysis, StartError, StartOutcome, StartRequest}; + +/// Settings of an AI analysis +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisRequest { + /// Recorded session to analyze. + session_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. + #[cfg_attr(feature = "openapi", schema(value_type = Option, minimum = 0, maximum = 4_294_967_295_u64))] + #[serde(default)] + max_output_tokens: Option, + /// Language the AI writes in, any text of 1 to 10 characters such as `fr-CA` or `français`; English when absent. + #[cfg_attr(feature = "openapi", schema(min_length = 1, max_length = 10))] + #[serde(default)] + language: 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, +} + +/// An AI analysis Task: the permanent record of one analysis the provisioner asked for +/// +/// The work runs in the background; `state` and `payload` tell how it goes. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisTask { + /// Task ID, chosen by the provisioner. + id: Uuid, + /// Recorded session the analysis is about. + session_id: Uuid, + /// What was asked, without the API key and the base URL. + params: AiAnalysisParams, + state: TaskState, + /// Details of the state: progress while `running`, the log once `succeeded`, the reason once `failed`; null while + /// `queued`. + payload: Option, + /// 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, + /// Time at which a Task still unfinished fails; while the Task runs, it moves ahead each time an attempt starts or + /// the Task makes progress. + #[serde(with = "time::serde::rfc3339")] + deadline_at: OffsetDateTime, +} + +/// Params of an AI analysis Task: what was asked, without the API key and the base URL +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisParams { + provider: AiProvider, + /// Model identifier, passed to the provider as is. + model: String, + /// Upper bound of tokens in each AI answer; null when not asked. + #[cfg_attr(feature = "openapi", schema(value_type = Option, minimum = 0, maximum = 4_294_967_295_u64))] + max_output_tokens: Option, + /// Language the AI writes in; absent when not asked, so the AI writes in English. + #[serde(default, skip_serializing_if = "Option::is_none")] + language: Option, +} + +/// Details of the state of an AI analysis Task: `running`, `succeeded` and `failed` each have their own +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub(crate) enum AiAnalysisPayload { + Running(AiAnalysisRunningPayload), + Succeeded(AiAnalysisSucceededPayload), + Failed(AiAnalysisFailedPayload), +} + +/// Payload of a `running` AI analysis Task: its progress, and the tokens it used so far +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisRunningPayload { + /// Current step: `preparing` (the attempt starts), `reading` (the recordings are read), `describing` (the AI is + /// asked about the recording, part by part), then `writing` (the log is written). + step: String, + /// Work done in the step: bytes read while `reading`, parts described while `describing`; absent in the other steps. + #[serde(default, skip_serializing_if = "Option::is_none")] + done: Option, + /// Work the step has in all, in the same unit as `done`; absent in the other steps. + #[serde(default, skip_serializing_if = "Option::is_none")] + total: Option, + usage: AiAnalysisUsage, +} + +/// Payload of a `succeeded` AI analysis Task: the log it put in the session +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisSucceededPayload { + /// Name of the 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, + usage: AiAnalysisUsage, + /// Versions of the prompts that wrote the log, such as + /// `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. + prompt_version: String, +} + +/// Tokens the AI provider counted for an AI analysis Task, over all its attempts +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisUsage { + input_tokens: u64, + output_tokens: u64, + /// Requests that may have cost tokens the provider did not report, such as a request that got no answer, or one + /// still waiting for it; while it is not 0, `inputTokens` and `outputTokens` are short of what the provider billed. + unreported_requests: u64, +} + +/// Payload of a `failed` AI analysis Task: why it failed, and the tokens it used +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisFailedPayload { + error: AiAnalysisError, + /// Absent only for a Task that never ran before its deadline passed, since it used no token. + #[serde(default, skip_serializing_if = "Option::is_none")] + usage: Option, +} + +/// Why an AI analysis Task failed: a kind, with the fields of that kind; there is no text for people, so the caller +/// words it +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisError { + kind: AiAnalysisErrorKind, + /// HTTP status the AI provider answered, for `aiRejected`. + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, + /// What was wrong with the answer, for `aiBadAnswer`. + #[serde(default, skip_serializing_if = "Option::is_none")] + why: Option, + /// Recording file that cannot be read, for `unreadableRecording`. + #[serde(default, skip_serializing_if = "Option::is_none")] + file: Option, + /// Failure of the last attempt, for `attemptsExhausted`. + #[serde(default, skip_serializing_if = "Option::is_none")] + last: Option, +} + +/// Kind of failure of an AI analysis Task +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum AiAnalysisErrorKind { + /// The AI provider refused the request for good, such as for a wrong API key (401) or a spent quota. + AiRejected, + /// The AI provider answered, but not with something usable. + AiBadAnswer, + /// The API key is gone, such as after a Gateway restart. + KeyLost, + /// The session has no recording, or it was deleted. + NoRecording, + /// The session mixes terminal and video recordings. + MixedRecording, + /// A recording file of the session cannot be read. + UnreadableRecording, + /// The Task itself is not valid, such as with parameters this Gateway cannot read. + InvalidTask, + /// The attempt panicked. + Panicked, + /// Something went wrong inside Gateway, such as a file it could not write, or a restart that cut the last attempt + /// short. + Internal, + /// Every attempt failed with a failure worth another attempt. + AttemptsExhausted, + /// The Task did not start in time, or went too long without progress. + DeadlinePassed, +} + +/// What was wrong with an answer of the AI provider +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum AiAnalysisBadAnswer { + /// The answer is not in the format of the provider API. + Invalid, + /// The answer was cut short by the output token limit or the context window. + Truncated, + /// The provider refused to answer, such as when its content filter blocked the request. + Refused, + /// The model did not answer in the format it was asked for. + NotExpectedJson, +} + +/// A failure of an attempt that was worth another attempt +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiAnalysisRecoverableError { + kind: AiAnalysisRecoverableErrorKind, + /// HTTP status the AI provider answered, for `aiBusy`. + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, +} + +/// Kind of a failure worth another attempt +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum AiAnalysisRecoverableErrorKind { + /// The AI provider could not be reached, or did not answer in time. + AiUnreachable, + /// The AI provider is busy or failing for now, such as with a rate limit (429) or a server error (5xx). + AiBusy, +} + +impl TryFrom for AiAnalysisTask { + type Error = anyhow::Error; + + /// The record as the API shows it; a record whose params or payload are not of this shape is an error, so a + /// Gateway that stored something else never passes it on. + fn try_from(task: ProvisionerTaskRecord) -> Result { + let state = TaskState::from(task.state); + let payload = match state { + TaskState::Queued => None, + TaskState::Running => Some(AiAnalysisPayload::Running( + serde_json::from_value(task.payload).context("running payload")?, + )), + TaskState::Succeeded => Some(AiAnalysisPayload::Succeeded( + serde_json::from_value(task.payload).context("succeeded payload")?, + )), + TaskState::Failed => Some(AiAnalysisPayload::Failed( + serde_json::from_value(task.payload).context("failed payload")?, + )), + }; + + Ok(Self { + id: task.id, + session_id: Uuid::parse_str(&task.target).context("session ID")?, + params: serde_json::from_value(task.params).context("params")?, + state, + payload, + created_at: task.created_at, + started_at: task.started_at, + finished_at: task.finished_at, + deadline_at: task.deadline_at, + }) + } +} + +fn task_json(task: ProvisionerTaskRecord) -> Result, HttpError> { + AiAnalysisTask::try_from(task).map(Json).map_err( + HttpError::internal() + .with_msg("the AI analysis Task is not of the expected shape") + .err(), + ) +} + +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 puts the answer in the session as its +/// log, listed under `artifacts.ai-analysis` in its manifest. +/// The answer is in the `language` of the request, English without one. +/// The work runs in the background as a Task; follow it with `GET /jet/tasks/recording-ai-analysis/{task_id}`. +/// +/// Sending the same request again returns the same Task and starts nothing new. +/// A new Task for the session starts from scratch, and replaces the earlier AI analysis of the session once its success +/// is recorded, so a session holds at most one; a Task that fails leaves the earlier analysis in place and removes +/// everything it made. +/// A Task fails if it does not start within 2 hours, or, once it runs, after 30 minutes without progress. +/// 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( + put, + operation_id = "StartAiAnalysis", + tag = "Tasks", + path = "/jet/tasks/recording-ai-analysis/{task_id}", + params( + ("task_id" = Uuid, Path, description = "Task ID, chosen by the caller so it can send the same request again safely"), + ), + 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 = AiAnalysisTask), + (status = 202, description = "The AI analysis Task is created and runs in the background", body = AiAnalysisTask, headers( + ("Location" = String, description = "Path of the new Task, `/jet/tasks/recording-ai-analysis/{task_id}`"), + )), + (status = 400, description = "Invalid request body, AI settings or language"), + (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.tasks.recording-ai-analysis.start"])), +))] +pub(crate) async fn start_ai_analysis( + State(state): State, + _scope: RecordingAiAnalysisStartScope, + extract::Path(task_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 { + session_id, + provider, + model, + base_url, + max_output_tokens, + language, + api_key, + } = request; + + let language = language + .map(Language::new) + .transpose() + .map_err(HttpError::bad_request().with_msg("invalid language").err())?; + + let outcome = analysis::start( + &state, + StartRequest { + task_id, + session_id, + settings: AiSettings { + provider, + model, + base_url, + max_output_tokens, + }, + language, + api_key, + }, + ) + .await; + + match outcome { + Ok(StartOutcome::Created(task)) => { + let location = HeaderValue::try_from(format!("/jet/tasks/recording-ai-analysis/{}", task.id)) + .map_err(HttpError::internal().with_msg("invalid Location header").err())?; + Ok((StatusCode::ACCEPTED, [(LOCATION, location)], task_json(task)?).into_response()) + } + Ok(StartOutcome::Existing(task)) => Ok((StatusCode::OK, task_json(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)), + } +} + +/// Gets an AI analysis of a recorded session +/// +/// Returns the Task of the analysis: its state, its progress while it runs, its result once it is over, and the tokens +/// it used in every state but `queued`. +/// 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 = "GetAiAnalysis", + tag = "Tasks", + path = "/jet/tasks/recording-ai-analysis/{task_id}", + params( + ("task_id" = Uuid, Path, description = "Task ID of the AI analysis"), + ), + responses( + (status = 200, description = "The AI analysis Task", body = AiAnalysisTask), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "No AI analysis has this Task ID"), + (status = 500, description = "Unexpected server error"), + ), + security(("scope_token" = ["gateway.tasks.recording-ai-analysis.read"])), +))] +pub(crate) async fn get_ai_analysis( + State(state): State, + _scope: RecordingAiAnalysisReadScope, + extract::Path(task_id): extract::Path, +) -> Result, HttpError> { + let task = analysis::get(&state, task_id) + .await + .map_err(HttpError::internal().with_msg("failed to read the AI analysis").err())? + // A Task of another kind is not found under this path. + .filter(|task| task.kind == analysis::KIND) + .ok_or_else(|| HttpError::not_found().msg("AI analysis not found"))?; + + task_json(task) +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use axum::body::Body; + use http_body_util::BodyExt as _; + + use super::*; + + fn record(state: provisioner_task::ProvisionerTaskState, payload: serde_json::Value) -> ProvisionerTaskRecord { + let now = OffsetDateTime::now_utc(); + ProvisionerTaskRecord { + id: Uuid::new_v4(), + kind: analysis::KIND.to_owned(), + target: Uuid::new_v4().to_string(), + params: serde_json::json!({ "provider": "openai", "model": "gpt-test", "maxOutputTokens": null }), + state, + payload, + attempts: 0, + created_at: now, + started_at: None, + finished_at: None, + deadline_at: now, + job_token: Uuid::new_v4(), + } + } + + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + #[test] + fn a_task_failed_before_it_ran_is_shown_without_usage() { + let record = record( + provisioner_task::ProvisionerTaskState::Failed, + serde_json::json!({ "error": { "kind": "deadlinePassed" } }), + ); + + let task = AiAnalysisTask::try_from(record).expect("a failed Task without usage"); + + let payload = serde_json::to_value(task.payload).expect("JSON"); + assert_eq!(payload, serde_json::json!({ "error": { "kind": "deadlinePassed" } })); + } + + // oracle: mutant a payload of another shape is passed on to the caller as it is + #[test] + fn a_task_of_another_shape_is_refused() { + let record = record( + provisioner_task::ProvisionerTaskState::Succeeded, + serde_json::json!({ "error": { "kind": "internal" } }), + ); + + let error = AiAnalysisTask::try_from(record).expect_err("a succeeded Task with a failed payload"); + + assert!(error.to_string().contains("succeeded payload"), "{error:#}"); + } + + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + #[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"); + // `start` asks the provisioner task service whether it knows the Task before it looks at the session. + let conn = gateway_db::GatewayDb::open_path(":memory:") + .await + .expect("open gateway database") + .connect() + .await + .expect("connect"); + let store = provisioner_task_libsql::LibSqlProvisionerTaskStore::open(conn) + .await + .expect("open task store"); + let service = crate::provisioner_tasks::ProvisionerTaskService::new( + std::sync::Arc::new(store), + crate::job_queue::TestJobQueue::new().await, + handles.provisioner_task_rx, + ); + let (_service_shutdown, service_shutdown_signal) = devolutions_gateway_task::ShutdownHandle::new(); + tokio::spawn(devolutions_gateway_task::Task::run(service, service_shutdown_signal)); + 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.tasks.recording-ai-analysis.start" }); + let token = format!( + "{}.{}.{}", + engine.encode(r#"{"alg":"RS256","cty":"SCOPE"}"#), + engine.encode(claims.to_string()), + engine.encode(b"signature"), + ); + let body = serde_json::json!({ "sessionId": session_id, "provider": "openai", "model": "gpt-test", "apiKey": "sk-test" }); + let request = axum::http::Request::builder() + .method("PUT") + .uri(format!("/jet/tasks/recording-ai-analysis/{}", Uuid::new_v4())) + .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" }) + ); + } +} diff --git a/devolutions-gateway/src/artifacts.rs b/devolutions-gateway/src/artifacts.rs index a2ecd5fa89..d62f5d8037 100644 --- a/devolutions-gateway/src/artifacts.rs +++ b/devolutions-gateway/src/artifacts.rs @@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize}; use crate::token::RecordingFileType; -/// Non-recording artifacts, one list per [`ArtifactKind`]. Each list is append-only, like `files`: names -/// are derived from positions. +/// Non-recording artifacts, one list per [`ArtifactKind`]. Each list only grows, like `files`, until it is cleared +/// as a whole: names are derived from positions. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct JrecArtifacts { diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index a000e46cf1..4f5f1549ae 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -321,6 +321,44 @@ where } } +/// A scope token allowed to start an AI analysis: `*`, or `gateway.tasks.recording-ai-analysis.start`. +#[derive(Clone, Copy)] +pub struct RecordingAiAnalysisStartScope; + +impl FromRequestParts for RecordingAiAnalysisStartScope +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::RecordingAiAnalysisStart => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + +/// A scope token allowed to read AI analyses: `*`, or `gateway.tasks.recording-ai-analysis.read`. +#[derive(Clone, Copy)] +pub struct RecordingAiAnalysisReadScope; + +impl FromRequestParts for RecordingAiAnalysisReadScope +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::RecordingAiAnalysisRead => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + #[derive(Clone, Copy)] pub struct RecordingsSearchScope; diff --git a/devolutions-gateway/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 8985f3184f..832d68ff67 100644 --- a/devolutions-gateway/src/job_queue.rs +++ b/devolutions-gateway/src/job_queue.rs @@ -6,11 +6,18 @@ use std::time::Duration; use anyhow::Context as _; use async_trait::async_trait; use devolutions_gateway_task::{ChildTask, ShutdownSignal, Task}; -use job_queue::{DynJobQueue, Job, JobCtx, JobQueue, JobReader, JobRunner, RunnerWaker}; +use job_queue::{DynJob, DynJobQueue, Job, JobCtx, JobQueue, JobReader, JobRunner, RunnerWaker}; use job_queue_libsql::libsql; +use provisioner_task::DynProvisionerTaskStore; use time::OffsetDateTime; use tokio::sync::{Notify, mpsc}; +use crate::config::ConfHandle; +use crate::provisioner_tasks::TaskJob; +use crate::provisioner_tasks::recording_ai_analysis::RecordingAiAnalysis; +use crate::recording::RecordingMessageSender; +use crate::secret_store::SecretStore; + pub struct JobQueueCtx { notify_runner: Arc, runner_waker: RunnerWaker, @@ -29,6 +36,16 @@ pub struct JobQueueHandle(mpsc::Sender); pub type JobQueueReceiver = mpsc::Receiver; +/// What jobs need to run beyond their stored JSON, kept in memory only, such as the secrets of a Task. +/// +/// Only the job runner holds it, to hydrate the jobs it reads back. +pub struct InMemoryJobDependencies { + pub conf_handle: ConfHandle, + pub recordings: RecordingMessageSender, + pub secrets: SecretStore, + pub task_store: DynProvisionerTaskStore, +} + pub struct JobQueueTask { queue: DynJobQueue, job_queue_rx: JobQueueReceiver, @@ -38,6 +55,7 @@ pub struct JobRunnerTask { notify_runner: Arc, runner_waker: RunnerWaker, queue: DynJobQueue, + in_memory_job_dependencies: InMemoryJobDependencies, } impl JobQueueCtx { @@ -82,6 +100,11 @@ impl JobQueueCtx { job_queue_handle: handle, }) } + + /// The queue itself, for the one service whose callers must know their job is stored. + pub fn queue(&self) -> DynJobQueue { + Arc::clone(&self.queue) + } } impl JobQueueHandle { @@ -197,11 +220,13 @@ async fn job_queue_task(ctx: JobQueueTask, mut shutdown_signal: ShutdownSignal) } impl JobRunnerTask { - pub fn new(ctx: &JobQueueCtx) -> Self { + /// The job runner, which hydrates each job it reads back with `in_memory_job_dependencies`. + pub fn new(ctx: &JobQueueCtx, in_memory_job_dependencies: InMemoryJobDependencies) -> Self { Self { notify_runner: Arc::clone(&ctx.notify_runner), runner_waker: RunnerWaker::clone(&ctx.runner_waker), queue: Arc::clone(&ctx.queue), + in_memory_job_dependencies, } } } @@ -225,9 +250,12 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal notify_runner, runner_waker, queue, + in_memory_job_dependencies, } = ctx; - let reader = DgwJobReader; + let reader = DgwJobReader { + in_memory_job_dependencies, + }; let spawn = |mut ctx: JobCtx, callback: job_queue::SpawnCallback| { tokio::spawn(async move { @@ -279,10 +307,12 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal Ok(()) } -struct DgwJobReader; +struct DgwJobReader { + in_memory_job_dependencies: InMemoryJobDependencies, +} impl JobReader for DgwJobReader { - fn read_json(&self, name: &str, json: &str) -> anyhow::Result { + fn read_json(&self, name: &str, json: &str) -> anyhow::Result { use crate::api::jrec::DeleteRecordingsJob; use crate::recording::RemuxJob; @@ -296,7 +326,98 @@ impl JobReader for DgwJobReader { serde_json::from_str(json).context("failed to deserialize DeleteRecordingsJob")?; Ok(Box::new(job)) } + TaskJob::::NAME => { + let dependencies = &self.in_memory_job_dependencies; + let job = TaskJob::read_json( + json, + RecordingAiAnalysis::from_dependencies(dependencies), + Arc::clone(&dependencies.task_store), + )?; + Ok(Box::new(job)) + } _ => anyhow::bail!("unknown job name: {name}"), } } } + +/// A job the job queue of tests stored, as the job runner reads it back. +#[cfg(test)] +pub(crate) struct StoredJob { + pub(crate) name: String, + pub(crate) json: String, +} + +/// The job queue of tests: the queue Gateway runs, in memory, which also hands each job it stores to the test. +#[cfg(test)] +pub(crate) struct TestJobQueue { + inner: job_queue_libsql::LibSqlJobQueue, + stored: parking_lot::Mutex>, +} + +#[cfg(test)] +impl TestJobQueue { + pub(crate) async fn new() -> Arc { + let conn = libsql::Builder::new_local(":memory:") + .build() + .await + .expect("build database") + .connect() + .expect("open database connection"); + let inner = job_queue_libsql::LibSqlJobQueue::builder() + .runner_waker(RunnerWaker::new(|| {})) + .conn(conn) + .build(); + inner.setup().await.expect("queue setup"); + + Arc::new(Self { + inner, + stored: parking_lot::Mutex::default(), + }) + } + + /// Takes the oldest job stored and not taken yet. + pub(crate) fn take(&self) -> Option { + self.stored.lock().pop_front() + } +} + +#[cfg(test)] +#[async_trait] +impl JobQueue for TestJobQueue { + async fn setup(&self) -> anyhow::Result<()> { + self.inner.setup().await + } + + async fn reset_claimed_jobs(&self) -> anyhow::Result<()> { + self.inner.reset_claimed_jobs().await + } + + async fn push_job(&self, job: &DynJob, schedule_for: Option) -> anyhow::Result<()> { + self.inner.push_job(job, schedule_for).await?; + self.stored.lock().push_back(StoredJob { + name: job.name().to_owned(), + json: job.write_json()?, + }); + Ok(()) + } + + async fn claim_jobs(&self, reader: &dyn JobReader, number_of_jobs: usize) -> anyhow::Result> { + self.inner.claim_jobs(reader, number_of_jobs).await + } + + async fn delete_job(&self, job_id: uuid::Uuid) -> anyhow::Result<()> { + self.inner.delete_job(job_id).await + } + + async fn fail_job(&self, job_id: uuid::Uuid, schedule_for: OffsetDateTime) -> anyhow::Result<()> { + self.inner.fail_job(job_id, schedule_for).await + } + + async fn clear_failed(&self) -> anyhow::Result<()> { + self.inner.clear_failed().await + } + + async fn next_scheduled_date(&self) -> anyhow::Result> { + self.inner.next_scheduled_date().await + } +} diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index a163bfa403..56dca423a6 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,8 @@ pub struct DgwState { pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, + pub provisioner_tasks: provisioner_tasks::ProvisionerTaskHandle, + pub secrets: secret_store::SecretStore, } #[doc(hidden)] @@ -78,12 +81,13 @@ pub struct MockHandles { pub subscriber_rx: subscriber::SubscriberReceiver, pub job_queue_rx: job_queue::JobQueueReceiver, pub traffic_audit_rx: traffic_audit::TrafficAuditReceiver, + pub provisioner_task_rx: provisioner_tasks::ProvisionerTaskReceiver, pub shutdown_handle: devolutions_gateway_task::ShutdownHandle, } 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())); @@ -96,6 +100,7 @@ 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, provisioner_task_rx) = provisioner_tasks::provisioner_task_channel(); let state = Self { conf_handle, @@ -111,6 +116,8 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, + provisioner_tasks, + secrets: secret_store::SecretStore::default(), }; let handles = MockHandles { @@ -119,6 +126,7 @@ impl DgwState { subscriber_rx, job_queue_rx, traffic_audit_rx, + provisioner_task_rx, shutdown_handle, }; diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index e023be1436..e1166a9396 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -4,7 +4,13 @@ use utoipa::{Modify, OpenApi}; use uuid::Uuid; use crate::api::preflight::PreflightAlertStatus; +use crate::api::tasks::recording_ai_analysis::{ + AiAnalysisBadAnswer, AiAnalysisErrorKind, AiAnalysisRecoverableErrorKind, +}; use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; +use crate::provisioner_tasks::Permanent; +use crate::provisioner_tasks::ai::AiProvider; +use crate::provisioner_tasks::recording_ai_analysis::{BadAnswer, Failure, Recoverable}; #[derive(OpenApi)] #[openapi( @@ -25,6 +31,8 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::jrec::pull_recording_session, crate::api::jrec::pull_recording_file, crate::api::jrec::search::search_recordings, + crate::api::tasks::recording_ai_analysis::start_ai_analysis, + crate::api::tasks::recording_ai_analysis::get_ai_analysis, crate::api::webapp::sign_app_token, crate::api::webapp::sign_session_token, crate::api::update::get_update_products, @@ -64,6 +72,23 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::jrec::search::StartTimeRange, crate::api::jrec::search::RecordingSearchResponse, crate::api::jrec::search::RecordingSearchHit, + crate::api::tasks::recording_ai_analysis::AiAnalysisRequest, + crate::api::tasks::recording_ai_analysis::AiAnalysisConflict, + crate::api::tasks::recording_ai_analysis::AiAnalysisConflictCode, + AiProvider, + crate::api::tasks::recording_ai_analysis::AiAnalysisTask, + crate::api::tasks::TaskState, + crate::api::tasks::recording_ai_analysis::AiAnalysisParams, + crate::api::tasks::recording_ai_analysis::AiAnalysisPayload, + crate::api::tasks::recording_ai_analysis::AiAnalysisRunningPayload, + crate::api::tasks::recording_ai_analysis::AiAnalysisSucceededPayload, + crate::api::tasks::recording_ai_analysis::AiAnalysisUsage, + crate::api::tasks::recording_ai_analysis::AiAnalysisFailedPayload, + crate::api::tasks::recording_ai_analysis::AiAnalysisError, + AiAnalysisErrorKind, + AiAnalysisBadAnswer, + crate::api::tasks::recording_ai_analysis::AiAnalysisRecoverableError, + AiAnalysisRecoverableErrorKind, crate::token::AccessScope, crate::api::webapp::AppTokenSignRequest, crate::api::webapp::AppTokenContentType, @@ -142,6 +167,34 @@ struct SessionInfo { destination_host: Option, } +// The API writes the error kinds out as its own enums, so these matches stop the build until a new kind or field of +// the Task is written out there too. +const _: fn(&Permanent) -> AiAnalysisErrorKind = |error| match error { + Permanent::Kind(Failure::AiRejected { status: _ }) => AiAnalysisErrorKind::AiRejected, + Permanent::Kind(Failure::AiBadAnswer { why: _ }) => AiAnalysisErrorKind::AiBadAnswer, + Permanent::Kind(Failure::KeyLost) => AiAnalysisErrorKind::KeyLost, + Permanent::Kind(Failure::NoRecording) => AiAnalysisErrorKind::NoRecording, + Permanent::Kind(Failure::MixedRecording) => AiAnalysisErrorKind::MixedRecording, + Permanent::Kind(Failure::UnreadableRecording { file: _ }) => AiAnalysisErrorKind::UnreadableRecording, + Permanent::InvalidTask => AiAnalysisErrorKind::InvalidTask, + Permanent::Panicked => AiAnalysisErrorKind::Panicked, + Permanent::Internal => AiAnalysisErrorKind::Internal, + Permanent::AttemptsExhausted { last: _ } => AiAnalysisErrorKind::AttemptsExhausted, + Permanent::DeadlinePassed => AiAnalysisErrorKind::DeadlinePassed, +}; + +const _: fn(&Recoverable) -> AiAnalysisRecoverableErrorKind = |error| match error { + Recoverable::AiUnreachable => AiAnalysisRecoverableErrorKind::AiUnreachable, + Recoverable::AiBusy { status: _ } => AiAnalysisRecoverableErrorKind::AiBusy, +}; + +const _: fn(&BadAnswer) -> AiAnalysisBadAnswer = |why| match why { + BadAnswer::Invalid => AiAnalysisBadAnswer::Invalid, + BadAnswer::Truncated => AiAnalysisBadAnswer::Truncated, + BadAnswer::Refused => AiAnalysisBadAnswer::Refused, + BadAnswer::NotExpectedJson => AiAnalysisBadAnswer::NotExpectedJson, +}; + #[allow(unused)] #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "kebab-case")] diff --git a/devolutions-gateway/src/provisioner_tasks/INTENT.md b/devolutions-gateway/src/provisioner_tasks/INTENT.md new file mode 100644 index 0000000000..28bbed3d4f --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/INTENT.md @@ -0,0 +1,44 @@ +# Background +Provisioner Tasks (hereafter "Tasks") are tasks issued for Gateway to execute. Tasks's state are pesisted in `gateway.db`, see more at `../../../crates/provisioner-task-libsql/`. + + +# Invariants + +## Management +Once a provisioner gets a successful answer to its start request, the Task is never lost and never stuck, even if Gateway restarts: it ends in success or failure, within its limit of attempts and its deadline. +It may end in failure, such as `keyLost`. +Gateway does not handle a database failure, such as a failed write to `gateway.db` or to the job queue, since it cannot be handled reasonably. +Provisioner tasks run inside a job. +Recovery from a crashed Gateway is managed by the job queue: when Gateway starts, it delivers again every job it had not finished. +The job keeps only the ID of its Provisioner Task and the job token recorded with it, and runs the Provisioner Task only if the two tokens match. +The record keeps the attempts, the deadline, the payload and the end of a Provisioner Task, and each kind keeps its own progress, such as the working files of an AI analysis, so a job delivered again picks up where the last one left off. +A job delivered again in the middle of an attempt starts a new one, which counts toward the limit of attempts; with none left, the Provisioner Task fails as `internal`. +Recoverable errors are recovered within the task job itself, which waits and runs the next attempt up to the limit, and are never handed to the job queue. +The job returns an error to the job queue only when it cannot read or write the record, cannot publish the result of a success, or cannot finish what a Provisioner Task leaves to do once it ends; the job queue then runs the job again. +The handlers only record the Provisioner Task with its job and keep the secrets it needs, and never take part in its execution. +Reading a Provisioner Task past its deadline fails it. + +## Failures +The error in the payload of a failed Provisioner Task is its permanent error, giving up included, as a closed set of kinds without text for people, because Gateway does not translate: the caller words it. + + +## Naming +A Provisioner Task is a long-running task a provisioner asks Gateway to do. +Its record, in `gateway.db`, and its job, on the job queue, are only the means to carry it out. + +## Secrets +Secrets a Task needs, such as an API key or a base URL that may carry credentials, never reach the disk: not `gateway.db`, not the job, not the logs. +They are kept encrypted in memory, in the `SecretStore`, until the Task ends or reaches its deadline. +After a Gateway restart they are gone, and the Tasks that needed them fail; this is accepted. + +## AI analysis +Only a finished recording is analyzed. +A session that mixes terminal and video recordings cannot be analyzed. +Two analyses of the same session never run at the same time. +An analysis keeps working files next to the recording, so a retry can pick up where it stopped. When a Task ends, it deletes its own working files, whether it succeeded or failed. A new Task first deletes any working files an earlier analysis left behind. A Task never deletes another Task's working files. +A session keeps one AI analysis: a new Task replaces the earlier one atomically once it succeeds. Keeping one analysis per language is a future extension that must stay possible. +The only guarantee on results is that a succeeded Task always has its result persisted: its log is listed in the manifest before `succeeded` is written. Any other outcome may leave anything behind, and the provisioner runs the analysis again. +A recoverable failure, such as a rate limit (429), is retried within the job, and each retry continues from the progress saved so far; only a new Task starts over. A Gateway restart loses the API key, so an unfinished Task fails with `keyLost` at its next attempt, without a retry; this is enforced for security, since the key never reaches the disk. +A rate limit, a server error, a lost connection or a request timeout is recoverable; a refused API key (401), a spent quota, or an answer the model cut short or refused is permanent. +An AI analysis fails if it hasn't started within 2 hours. Once it runs, it fails only after 30 minutes without progress, so a long recording that keeps making progress is never cut off. Each request to the AI times out after 10 minutes and is retried like any other recoverable failure. +An analysis does not wait for Gateway to remux the session's videos: it reads the tracks, the cluster timestamps and the blocks of a WebM in order, which a remux does not change, and a remux replaces the file by renaming its output over it, so an analysis that has the file open keeps reading what it opened. diff --git a/devolutions-gateway/src/provisioner_tasks/ai.rs b/devolutions-gateway/src/provisioner_tasks/ai.rs new file mode 100644 index 0000000000..4f103c475b --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/ai.rs @@ -0,0 +1,212 @@ +//! AI settings shared by the provisioner tasks that call an AI provider. +//! +//! The API key and the base URL never reach `gateway.db`: they stay in memory, encrypted, as the secret of their Task +//! in the [`SecretStore`](crate::secret_store::SecretStore), until the Task ends. + +use core::fmt; + +use anyhow::Context as _; +use devolutions_gateway_ai::{AiClient, BuildError, Provider}; +use secrecy::{ExposeSecret as _, SecretString}; +use url::Url; + +use crate::config::Conf; + +/// AI provider to ask; `openai-compatible` needs a base URL +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiProvider { + #[serde(rename = "openai")] + OpenAi, + #[serde(rename = "anthropic")] + Anthropic, + #[serde(rename = "mistral")] + Mistral, + #[serde(rename = "gemini")] + Gemini, + #[serde(rename = "openai-compatible")] + OpenAiCompatible, +} + +impl From for Provider { + fn from(provider: AiProvider) -> Self { + match provider { + AiProvider::OpenAi => Provider::OpenAi, + AiProvider::Anthropic => Provider::Anthropic, + AiProvider::Mistral => Provider::Mistral, + AiProvider::Gemini => Provider::Gemini, + AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, + } + } +} + +/// How to reach an AI provider, except the API key. +#[derive(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, +} + +impl fmt::Debug for AiSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AiSettings") + .field("provider", &self.provider) + .field("model", &self.model) + .field("base_url", &self.base_url.as_ref().map(|_| "")) + .field("max_output_tokens", &self.max_output_tokens) + .finish() + } +} + +/// 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}"), + } + } +} + +/// What a Task needs to reach its AI provider and must never write to disk. +#[derive(Clone)] +pub(crate) struct AiAccess { + pub(crate) api_key: SecretString, + pub(crate) base_url: Option, +} + +impl fmt::Debug for AiAccess { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AiAccess") + .field("api_key", &self.api_key) + .field("base_url", &self.base_url.as_ref().map(|_| "")) + .finish() + } +} + +/// [`AiAccess`] as written into its task secret. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AiAccessSecretRef<'a> { + api_key: &'a str, + base_url: Option<&'a Url>, +} + +/// [`AiAccess`] as read back from its task secret. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AiAccessSecret { + api_key: String, + base_url: Option, +} + +impl AiAccess { + /// The task secret holding this access. + pub(crate) fn to_secret(&self) -> anyhow::Result { + let json = serde_json::to_string(&AiAccessSecretRef { + api_key: self.api_key.expose_secret(), + base_url: self.base_url.as_ref(), + }) + .context("serialize the AI access")?; + + Ok(SecretString::from(json)) + } + + /// Reads back an access from its task secret. + pub(crate) fn from_secret(secret: &SecretString) -> anyhow::Result { + // The parse error is dropped: it may quote the API key. + let access: AiAccessSecret = + serde_json::from_str(secret.expose_secret()).map_err(|_| anyhow::anyhow!("invalid AI access secret"))?; + + Ok(Self { + api_key: SecretString::from(access.api_key), + base_url: access.base_url, + }) + } +} + +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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + #[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/attempt.rs b/devolutions-gateway/src/provisioner_tasks/attempt.rs new file mode 100644 index 0000000000..b89072e495 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/attempt.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use parking_lot::Mutex; +use provisioner_task::DynProvisionerTaskStore; +use time::OffsetDateTime; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::deadline; + +/// One attempt of a record, as its task sees it. +pub struct Attempt { + pub task_id: Uuid, + pub target: String, + /// Attempts started so far, this one included. + pub attempts: u32, + /// Payload of the record as this attempt started, from [`ProvisionerTask::starting_payload`]. + pub payload: serde_json::Value, + /// Cancelled once the record is past its deadline or no longer running. + pub cancel: CancellationToken, + pub(super) progress_timeout: time::Duration, + pub(super) store: DynProvisionerTaskStore, + /// The deadline the record has, shared with the job so it cancels the attempt once the deadline passes. + pub(super) deadline_at: Arc>, +} + +impl Attempt { + /// Deadline of the record, after which the attempt is cancelled. + pub fn deadline(&self) -> OffsetDateTime { + *self.deadline_at.lock() + } + + /// Replaces the payload of the running record and moves its deadline ahead, so whoever asks about it sees how the + /// attempt goes. + /// + /// A record that is no longer running, such as one past its deadline, cancels the attempt. + pub async fn report_progress(&self, payload: serde_json::Value) { + let now = OffsetDateTime::now_utc(); + let deadline_at = deadline(now, self.progress_timeout); + + match self.store.update_running(self.task_id, payload, deadline_at, now).await { + Ok(true) => *self.deadline_at.lock() = deadline_at, + Ok(false) => { + debug!(task.id = %self.task_id, "Task is no longer running, so its attempt stops"); + self.cancel.cancel(); + } + Err(error) => { + warn!(task.id = %self.task_id, error = format!("{error:#}"), "Failed to record the Task progress"); + } + } + } +} diff --git a/devolutions-gateway/src/provisioner_tasks/job.rs b/devolutions-gateway/src/provisioner_tasks/job.rs new file mode 100644 index 0000000000..3071810af5 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/job.rs @@ -0,0 +1,323 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use anyhow::Context as _; +use async_trait::async_trait; +use parking_lot::Mutex; +use provisioner_task::{AttemptStart, DynProvisionerTaskStore, ProvisionerTaskRecord}; +use serde::Serialize; +use time::OffsetDateTime; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{Attempt, AttemptError, MAX_ATTEMPTS, Permanent, ProvisionerTask, deadline}; + +/// `value` as JSON for the logs, empty if it cannot be written. +fn log_json(value: &impl Serialize) -> serde_json::Value { + serde_json::to_value(value).unwrap_or_default() +} + +/// The job of a record of the task `T`: it wakes the record up, and the record decides what runs. +/// +/// It runs the attempts of `T` until the record ends, each in three steps: the prelude records its start, the body runs +/// [`ProvisionerTask::run`], and the completion records how the last one ended. A record that has ended is ended +/// instead. +pub struct TaskJob { + pub(super) task_id: Uuid, + job_token: Uuid, + task: T, + store: DynProvisionerTaskStore, +} + +/// What the job queue stores of a [`TaskJob`]: the ID of its record and the token recorded with it. +#[derive(Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", bound = "")] +struct TaskJobUnhydrated { + task_id: Uuid, + /// Matches [`ProvisionerTaskRecord::job_token`] of the record this job was stored with. + job_token: Uuid, + #[serde(skip)] + task: PhantomData, +} + +impl TaskJob { + /// The name its jobs are stored under. + pub const NAME: &'static str = T::KIND; + + /// The job of the record `task_id`, recorded with `job_token`. + pub(super) fn new(task_id: Uuid, job_token: Uuid, task: T, store: DynProvisionerTaskStore) -> Self { + Self { + task_id, + job_token, + task, + store, + } + } + + /// The job read back from what the job queue stored of it, with its task and the task store. + pub fn read_json(json: &str, task: T, store: DynProvisionerTaskStore) -> anyhow::Result { + let TaskJobUnhydrated:: { task_id, job_token, .. } = + serde_json::from_str(json).context("failed to deserialize TaskJob")?; + + Ok(Self::new(task_id, job_token, task, store)) + } + + /// Records the failure, from the payload the record has, then ends the record. + async fn fail(&self, error: &Permanent) -> anyhow::Result<()> { + let now = OffsetDateTime::now_utc(); + let last = self + .store + .get(self.task_id, now) + .await + .context("read the Task before its failure")? + .map_or(serde_json::Value::Null, |record| record.payload); + + self.store + .fail(self.task_id, self.task.failed_payload(error, &last), now) + .await + .context("record the Task failure")?; + + self.end().await + } + + /// Ends the record, read again so its task sees how it ended. + async fn end(&self) -> anyhow::Result<()> { + match self.store.get(self.task_id, OffsetDateTime::now_utc()).await? { + Some(record) => self.end_record(&record).await, + None => Ok(()), + } + } + + /// Does what the finished record leaves to do; a record still unfinished is left to its attempt. + async fn end_record(&self, record: &ProvisionerTaskRecord) -> anyhow::Result<()> { + if !record.state.is_finished() { + return Ok(()); + } + + self.task.on_end(record).await.context("end the Task") + } + + /// The prelude: marks the start of one more attempt on the record, unless it has ended. + /// + /// A record that has ended is ended instead. A record not there yet is an error, so the job queue runs the job + /// again once it is; a record with another job token belongs to another job, so this one does nothing. + async fn prelude(&self) -> anyhow::Result> { + let Self { task, store, .. } = self; + let now = OffsetDateTime::now_utc(); + + // The starting payload carries over what earlier attempts reported, so the record is read first. + let Some(record) = store.get(self.task_id, now).await? else { + anyhow::bail!("Task {} is not recorded yet", self.task_id); + }; + + if record.job_token != self.job_token { + debug!(task.id = %self.task_id, "Task has another job, so this attempt does not run"); + return Ok(None); + } + + if record.state.is_finished() { + debug!(task.id = %self.task_id, "Task is over, so this attempt does not run"); + self.end_record(&record).await?; + return Ok(None); + } + + // A restart in the middle of the last attempt delivers the job again. + if MAX_ATTEMPTS <= record.attempts { + warn!( + task.id = %self.task_id, + task.kind = %record.kind, + attempts = record.attempts, + "Task has no attempt left, so it fails without running another" + ); + self.fail(&Permanent::Internal).await?; + return Ok(None); + } + + let starting_payload = task.starting_payload(&record.payload); + let deadline_at = deadline(now, task.progress_timeout()); + + match store + .start_attempt(self.task_id, starting_payload, deadline_at, now) + .await? + { + AttemptStart::Started(record) => Ok(Some(record)), + AttemptStart::Finished(record) => { + debug!(task.id = %self.task_id, "Task is over, so this attempt does not run"); + self.end_record(&record).await?; + Ok(None) + } + AttemptStart::Unknown => anyhow::bail!("Task {} is not recorded anymore", self.task_id), + } + } + + /// The body: runs the attempt of `T`, and cancels it once the record is past its deadline. + async fn body( + &self, + record: &ProvisionerTaskRecord, + ) -> Result> { + let params = match serde_json::from_value::(record.params.clone()) { + Ok(params) => params, + Err(error) => { + warn!(task.id = %record.id, %error, "Invalid Task parameters"); + return Err(AttemptError::Permanent(Permanent::InvalidTask)); + } + }; + + // Progress moves the deadline, so the attempt shares it with the loop below that enforces it. + let deadline_at = Arc::new(Mutex::new(record.deadline_at)); + let cancel = CancellationToken::new(); + let attempt = Attempt { + task_id: record.id, + target: record.target.clone(), + attempts: record.attempts, + payload: record.payload.clone(), + cancel: cancel.clone(), + progress_timeout: self.task.progress_timeout(), + store: Arc::clone(&self.store), + deadline_at: Arc::clone(&deadline_at), + }; + + info!(task.id = %record.id, task.kind = %record.kind, attempts = record.attempts, "Task attempt started"); + + let run = self.task.run(&attempt, params); + tokio::pin!(run); + + let mut past_deadline = false; + let outcome = loop { + let wait = + std::time::Duration::try_from(*deadline_at.lock() - OffsetDateTime::now_utc()).unwrap_or_default(); + + tokio::select! { + outcome = &mut run => break outcome, + () = tokio::time::sleep(wait) => { + // Progress may have moved the deadline while the timer ran. + if OffsetDateTime::now_utc() < *deadline_at.lock() { + continue; + } + + warn!( + task.id = %record.id, + task.kind = %record.kind, + attempts = record.attempts, + "Task went past its deadline; its attempt is cancelled" + ); + past_deadline = true; + // Dropping cannot stop blocking steps, so the attempt is cancelled and awaited instead: what runs + // once the Task ends never runs alongside it. + cancel.cancel(); + break run.await; + } + } + }; + + match outcome { + // The attempt stopped because it was cancelled, whatever error it reports for it. + Err(_) if past_deadline => Err(AttemptError::Permanent(Permanent::DeadlinePassed)), + outcome => outcome, + } + } + + /// The completion: publishes and records how the last attempt ended, then ends the record. + /// + /// A success is published in the step that records it, so a `succeeded` record always has its output published. + /// Recording the end and dropping what the record kept are separate steps, so a failed drop never undoes the end. + async fn completion( + &self, + record: &ProvisionerTaskRecord, + outcome: Result>, + ) -> anyhow::Result<()> { + let store = &self.store; + let (task_id, attempts) = (record.id, record.attempts); + + match outcome { + Ok(output) => { + let payload = match serde_json::to_value(&output) { + Ok(payload) => payload, + Err(error) => { + error!(task.id = %task_id, %error, "Failed to serialize the Task result"); + return self.fail(&Permanent::Internal).await; + } + }; + + let publish = Box::pin(async { + self.task + .publish(record, &output) + .await + .context("publish the Task result") + }); + let succeeded = store + .succeed(task_id, payload, OffsetDateTime::now_utc(), publish) + .await + .context("record the Task success")?; + + if succeeded { + info!(task.id = %task_id, task.kind = %record.kind, attempts, "Task succeeded"); + } else { + warn!( + task.id = %task_id, + task.kind = %record.kind, + "Task attempt succeeded after the Task had ended, such as past its deadline; the Task keeps its earlier end" + ); + } + + self.end().await + } + Err(error) => { + warn!( + task.id = %task_id, + task.kind = %record.kind, + attempts, + error = %log_json(&error), + "Task failed" + ); + self.fail(&error).await + } + } + } +} + +#[async_trait] +impl job_queue::Job for TaskJob { + fn name(&self) -> &str { + Self::NAME + } + + fn write_json(&self) -> anyhow::Result { + serde_json::to_string(&TaskJobUnhydrated:: { + task_id: self.task_id, + job_token: self.job_token, + task: PhantomData, + }) + .context("failed to serialize TaskJob") + } + + async fn run(&mut self) -> anyhow::Result<()> { + loop { + let Some(record) = self.prelude().await? else { + return Ok(()); + }; + + // The body ends the attempt; the completion ends the record, so only what ends it reaches the completion. + let outcome = match self.body(&record).await { + Err(AttemptError::Recoverable(error)) if record.attempts < MAX_ATTEMPTS => { + let delay = self.task.retry_delay(record.attempts); + warn!( + task.id = %record.id, + task.kind = %record.kind, + attempts = record.attempts, + error = %log_json(&error), + %delay, + "Task attempt failed; the next one runs after a delay" + ); + tokio::time::sleep(std::time::Duration::try_from(delay).unwrap_or_default()).await; + continue; + } + Err(AttemptError::Recoverable(last)) => Err(Permanent::AttemptsExhausted { last }), + Err(AttemptError::Permanent(error)) => Err(error), + Ok(output) => Ok(output), + }; + + return self.completion(&record, outcome).await; + } + } +} diff --git a/devolutions-gateway/src/provisioner_tasks/mod.rs b/devolutions-gateway/src/provisioner_tasks/mod.rs new file mode 100644 index 0000000000..22152dadca --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/mod.rs @@ -0,0 +1,152 @@ +//! Work the provisioner asks Gateway to do: each request is a Task recorded in `gateway.db`, run on the job queue as a +//! [`TaskJob`]. This module is the one place that keeps a task's record and the job running it in step. +//! +//! Each kind of work is a [`ProvisionerTask`]: what one attempt does, what its payloads hold, and what to do once it has +//! ended. +//! Its job is a [`TaskJob`], a job like any other on the job queue, which wraps each attempt in a prelude and a +//! completion: the prelude records the start of the attempt, and the completion publishes and records how it ended, +//! then ends the record. +//! After a recoverable failure, the job waits [`ProvisionerTask::retry_delay`] and runs the next attempt itself, up to +//! [`MAX_ATTEMPTS`]. +//! [`ProvisionerTaskService`] stores the job while it records the Task, so a record never exists without its job. +//! +//! The record is the source of truth and the job only wakes it up. +//! The job queue delivers the job at least once, so after a restart the job picks the record up where it left off. +//! A job whose record is not there yet returns an error, so the job queue delivers it again later; the record of a job +//! whose creation was cut short never comes, and the job queue drops the job once its retries are spent. + +pub mod ai; +pub mod recording_ai_analysis; + +mod attempt; +mod job; +mod service; +#[cfg(test)] +mod tests; + +use async_trait::async_trait; +use provisioner_task::ProvisionerTaskRecord; +use serde::Serialize; +use serde::de::DeserializeOwned; +use time::OffsetDateTime; + +pub use self::attempt::Attempt; +pub use self::job::TaskJob; +pub use self::service::{ + ProvisionerTaskHandle, ProvisionerTaskReceiver, ProvisionerTaskService, provisioner_task_channel, +}; + +/// Attempts a record gets: a recoverable failure of the last one fails the record. +pub const MAX_ATTEMPTS: u32 = 5; + +/// A provisioner task: the work Gateway does for one kind of request, such as `recording-ai-analysis`. +/// +/// One value serves every record of its kind, so it keeps only what they share; what belongs to one record comes in +/// through [`Attempt`] and the parameters. +#[async_trait] +pub trait ProvisionerTask: Send + Sync + 'static { + /// The kind written in the records of this task, and the name of its jobs. + const KIND: &'static str; + + /// Time from the creation of a record until its first attempt must start. + const START_WINDOW: time::Duration; + + /// What the record keeps as asked; never secrets, since records are kept and returned to callers. + type Params: Serialize + DeserializeOwned + Send + 'static; + + /// The payload of a `succeeded` record. + type Output: Serialize + Send + Sync + 'static; + + /// Why an attempt of this task fails for good, next to the failures any task may have. + type Failure: Serialize + Send + Sync + 'static; + + /// Why an attempt of this task fails for now, so a later attempt may succeed. + type Recoverable: Serialize + Send + Sync + 'static; + + /// Time a running record may go without progress: each attempt start and each progress report moves its deadline + /// this far ahead. + fn progress_timeout(&self) -> time::Duration; + + /// Time to wait after attempt `attempts` failed with a recoverable failure, before the next attempt starts. + fn retry_delay(&self, attempts: u32) -> time::Duration; + + /// The payload of the running record as an attempt starts, from `last`, its payload until then: `null` before its + /// first attempt. + fn starting_payload(&self, last: &serde_json::Value) -> serde_json::Value; + + /// The payload of a record that failed with `error`, from `last`, its payload until then: `null` if it never ran. + fn failed_payload( + &self, + error: &Permanent, + last: &serde_json::Value, + ) -> serde_json::Value; + + /// Runs one attempt. + /// + /// The attempt should stop soon after [`Attempt::cancel`] is cancelled, which happens once the record is past its + /// deadline. + async fn run( + &self, + attempt: &Attempt, + params: Self::Params, + ) -> Result>; + + /// Publishes what a successful attempt produced, such as listing its file where callers find it. + /// + /// It runs in the step that records the success, before the success is written, so a `succeeded` record always + /// has its output published; an error leaves the record running, and the job queue runs the job again. + async fn publish(&self, record: &ProvisionerTaskRecord, output: &Self::Output) -> anyhow::Result<()>; + + /// Finishes what the record leaves to do once it has ended, however it ended, such as dropping what was kept only + /// while it ran. + /// + /// It may be called again for a record that already ended, so it must be idempotent; an error returns from the job, + /// and the job queue runs the job again, which calls it again. + async fn on_end(&self, record: &ProvisionerTaskRecord) -> anyhow::Result<()>; +} + +/// The deadline `timeout` after `now`, in whole seconds as the store keeps it, so Gateway and the store agree on when +/// it passes. +fn deadline(now: OffsetDateTime, timeout: time::Duration) -> OffsetDateTime { + (now + timeout).truncate_to_second() +} + +/// Why one attempt of a Task failed, with `F` and `R` the failures of its own kind of work. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttemptError { + /// Worth another attempt later, such as a rate limit or a network error. + Recoverable(R), + /// Another attempt would fail the same way, so the Task fails at once. + Permanent(Permanent), +} + +impl AttemptError { + /// A permanent failure of the Task's own kind of work. + pub fn of_kind(failure: F) -> Self { + Self::Permanent(Permanent::Kind(failure)) + } +} + +/// Why a Task failed, as the `error` of its payload: a closed set of kinds without text for people, since the caller +/// words them. +/// +/// The kinds below can happen to any Task, while [`Permanent::Kind`] holds those of its own kind of work, so a new kind +/// of Task brings its failures without adding to these. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Permanent { + /// The Task itself is not valid, such as with parameters this Gateway cannot read. + InvalidTask, + /// The attempt panicked. + Panicked, + /// Something went wrong inside Gateway, such as a file it could not write, or a restart that cut the last attempt + /// short. + Internal, + /// Every attempt failed with a recoverable failure, the last one being `last`. + AttemptsExhausted { last: R }, + /// The Task did not start in time, or went too long without progress. + DeadlinePassed, + /// A failure of the Task's own kind of work, which names its own `kind`. + #[serde(untagged)] + Kind(F), +} diff --git a/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/failure.rs b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/failure.rs new file mode 100644 index 0000000000..582a4c246b --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/failure.rs @@ -0,0 +1,180 @@ +//! Why an AI analysis fails, next to the failures any Task may have. + +use devolutions_gateway_ai::Error; +use devolutions_gateway_ai::recording_analysis::RecordingAnalysisError; + +use crate::provisioner_tasks::{AttemptError, Permanent}; + +/// Why one attempt of an AI analysis failed. +pub type AnalysisError = AttemptError; + +/// Why an AI analysis failed for good, as the `error` of its payload, next to the failures any Task may have. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Failure { + /// The AI provider refused the request for good, such as for a wrong API key (401) or a spent quota. + AiRejected { status: u16 }, + /// The AI provider answered, but not with something usable. + AiBadAnswer { why: BadAnswer }, + /// The API key is gone, such as after a Gateway restart. + KeyLost, + /// The session has no recording, or it was deleted. + NoRecording, + /// The session mixes terminal and video recordings. + MixedRecording, + /// A recording file of the session cannot be read. + UnreadableRecording { file: String }, +} + +/// A failure of an AI analysis attempt worth another attempt. +/// +/// It reaches the caller only as the `last` failure of [`Permanent::AttemptsExhausted`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Recoverable { + /// The AI provider could not be reached, or did not answer in time. + AiUnreachable, + /// The AI provider is busy or failing for now, such as with a rate limit (429) or a server error (5xx). + AiBusy { status: u16 }, +} + +/// What was wrong with an answer of the AI provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BadAnswer { + /// The answer is not in the format of the provider API. + Invalid, + /// The answer was cut short by the output token limit or the context window. + Truncated, + /// The provider refused to answer, such as when its content filter blocked the request. + Refused, + /// The model did not answer in the format it was asked for. + NotExpectedJson, +} + +/// Retries only the failures that may pass later, such as a rate limit or a network error. +impl From for AnalysisError { + fn from(error: RecordingAnalysisError) -> Self { + match error { + RecordingAnalysisError::Ai(error) => Self::from(error), + RecordingAnalysisError::NoRecording => Self::of_kind(Failure::NoRecording), + RecordingAnalysisError::InvalidRecording { file_name, .. } => { + Self::of_kind(Failure::UnreadableRecording { file: file_name }) + } + RecordingAnalysisError::Truncated => Self::of_kind(Failure::AiBadAnswer { + why: BadAnswer::Truncated, + }), + // The job cancels an attempt only once its Task is past its deadline. + RecordingAnalysisError::Cancelled => Self::Permanent(Permanent::DeadlinePassed), + _ => Self::Permanent(Permanent::Internal), + } + } +} + +impl From for AnalysisError { + fn from(error: Error) -> Self { + let bad_answer = |why| Self::of_kind(Failure::AiBadAnswer { why }); + + match &error { + Error::Transport { .. } => Self::Recoverable(Recoverable::AiUnreachable), + Error::Status { status, .. } if error.is_transient() => { + Self::Recoverable(Recoverable::AiBusy { status: *status }) + } + Error::Status { status, .. } => Self::of_kind(Failure::AiRejected { status: *status }), + Error::InvalidResponse { .. } => bad_answer(BadAnswer::Invalid), + Error::Truncated { .. } => bad_answer(BadAnswer::Truncated), + Error::Refused { .. } => bad_answer(BadAnswer::Refused), + Error::InvalidOutput { .. } => bad_answer(BadAnswer::NotExpectedJson), + _ => Self::Permanent(Permanent::Internal), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // oracle: mutant a failure of the AI analysis takes the name of one any Task may have, so callers mix them up + #[test] + fn ai_analysis_failures_have_names_of_their_own() { + fn kind(failure: impl serde::Serialize) -> serde_json::Value { + serde_json::to_value(failure).expect("JSON")["kind"].clone() + } + + let shared = [ + Permanent::<(), ()>::InvalidTask, + Permanent::Panicked, + Permanent::Internal, + Permanent::AttemptsExhausted { last: () }, + Permanent::DeadlinePassed, + ] + .map(kind); + + for failure in [ + Failure::AiRejected { status: 401 }, + Failure::AiBadAnswer { + why: BadAnswer::Invalid, + }, + Failure::KeyLost, + Failure::NoRecording, + Failure::MixedRecording, + Failure::UnreadableRecording { + file: "recording-0.webm".to_owned(), + }, + ] { + let name = kind(&failure); + assert!(!shared.contains(&name), "{name} is also a failure any Task may have"); + } + } + + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis + #[test] + fn transient_ai_errors_are_recoverable_and_the_others_permanent() { + let attempt = |error: Error| AnalysisError::from(RecordingAnalysisError::Ai(error)); + let status = |status, code: Option<&str>| Error::Status { + status, + message: "failed".to_owned(), + code: code.map(str::to_owned), + retry_after: None, + }; + + let network = Error::Transport { + message: "connection refused".to_owned(), + }; + assert_eq!(attempt(network), AnalysisError::Recoverable(Recoverable::AiUnreachable)); + + for code in [429, 500, 503] { + assert_eq!( + attempt(status(code, None)), + AnalysisError::Recoverable(Recoverable::AiBusy { status: code }) + ); + } + + for code in [400, 401, 403, 404] { + assert_eq!( + attempt(status(code, None)), + AnalysisError::of_kind(Failure::AiRejected { status: code }) + ); + } + + assert_eq!( + attempt(status(429, Some("insufficient_quota"))), + AnalysisError::of_kind(Failure::AiRejected { status: 429 }), + "a spent quota" + ); + assert_eq!( + AnalysisError::from(RecordingAnalysisError::Truncated), + AnalysisError::of_kind(Failure::AiBadAnswer { + why: BadAnswer::Truncated + }) + ); + assert_eq!( + attempt(Error::Refused { + reason: "content filter".to_owned() + }), + AnalysisError::of_kind(Failure::AiBadAnswer { + why: BadAnswer::Refused + }) + ); + } +} 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 0000000000..99afc6b334 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/mod.rs @@ -0,0 +1,579 @@ +//! AI analysis (`recording-ai-analysis`): describes what the user did in one recorded session, and puts the result in +//! the session as its log, in place of the earlier one. +//! +//! [`start`] checks the request and records the Task with [`create`](super::create). +//! Each attempt 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 leaves the log in the +//! session folder. +//! The step that records the success lists that log in the manifest in place of the earlier one, so a succeeded Task +//! always has its log listed; a Task that fails removes it instead. +//! The API key and the working files go away when the Task ends. + +mod failure; +#[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::Utf8PathBuf; +use devolutions_gateway_ai::Language; +use devolutions_gateway_ai::recording_analysis::{ + self, Progress, RecordingAnalysisEvent, RecordingFile, RecordingManifest, Recordings, RecordingsError, UsageSoFar, +}; +use provisioner_task::{CreateOutcome, ProvisionerTaskRecord}; +use secrecy::SecretString; +use serde_json::json; +use uuid::Uuid; + +pub use self::failure::{AnalysisError, BadAnswer, Failure, Recoverable}; +use super::ai::{AiAccess, AiProvider, AiSettings, AiSettingsError, ClientError}; +use super::{Attempt, Permanent, ProvisionerTask}; +use crate::DgwState; +use crate::artifacts::ArtifactKind; +use crate::config::ConfHandle; +use crate::job_queue::InMemoryJobDependencies; +use crate::recording::{FinishedRecordingError, JrecManifest, RecordingMessageSender}; +use crate::secret_store::{SecretId, SecretStore}; + +pub const KIND: &str = "recording-ai-analysis"; + +/// Time from the creation of an AI analysis until it must start. +pub const START_WINDOW: time::Duration = time::Duration::hours(2); + +/// Time a running AI analysis may go without progress before it fails. +pub const PROGRESS_TIMEOUT: time::Duration = time::Duration::minutes(30); + +/// Time to wait after the first recoverable failure, doubled after each next one. +pub const FIRST_RETRY_DELAY: time::Duration = time::Duration::seconds(30); + +/// Tokens the AI provider counted for a Task, over all its attempts, as kept in its payloads. +/// +/// It mirrors [`UsageSoFar`], so a change in the AI crate never changes what we store. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenUsage { + input_tokens: u64, + output_tokens: u64, + /// Requests whose tokens the provider did not report, so the two counts are short of what it billed. + unreported_requests: u64, +} + +impl TokenUsage { + /// The usage in a payload of the Task; none before its first attempt. + fn of(payload: &serde_json::Value) -> Self { + payload + .get("usage") + .and_then(|usage| serde_json::from_value(usage.clone()).ok()) + .unwrap_or_default() + } + + /// This usage, with that of the attempt running now. + fn with(self, attempt: UsageSoFar) -> Self { + Self { + input_tokens: self.input_tokens.saturating_add(attempt.input_tokens), + output_tokens: self.output_tokens.saturating_add(attempt.output_tokens), + unreported_requests: self.unreported_requests.saturating_add(attempt.unreported_requests), + } + } +} + +/// Payload of a running AI analysis Task: how far it is, and the tokens it used so far. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Running { + /// `preparing` until the analysis reports its stage: `reading`, `describing` or `writing`. + step: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + done: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total: Option, + usage: TokenUsage, +} + +/// 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, + /// Language of the analysis; English when absent. + pub language: Option, + pub api_key: SecretString, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum StartOutcome { + /// A new Task is recorded and its job is queued. + Created(ProvisionerTaskRecord), + /// The same request came in before; nothing new runs. + Existing(ProvisionerTaskRecord), + /// The Task ID is already used for something else. + IdConflict(ProvisionerTaskRecord), + /// 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, and the +/// language. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Params { + provider: AiProvider, + model: String, + max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + language: Option, +} + +/// Result payload of a successful AI analysis. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub 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 Task, over all its attempts. + usage: TokenUsage, + /// Versions of the prompts that wrote the log, such as + /// `session-actions-3f9a1c2b7e4d0a51+session-summary-8b20e9a4c1f70d36`. + prompt_version: String, + /// Where the analysis left the log, until the success lists it under `file_name`. + #[serde(skip)] + log_path: Utf8PathBuf, +} + +/// Records an AI analysis Task and stores its job. +/// +/// Only a new Task keeps the API key; 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, + language, + 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, + language, + }; + 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) + .await + .map_err(StartError::Internal)?; + + if let Some(record) = known { + let params = serde_json::to_value(¶ms) + .context("serialize the AI analysis parameters") + .map_err(StartError::Internal)?; + let same_request = record.kind == KIND && record.target == target && record.params == params; + return Ok(if same_request { + StartOutcome::Existing(record) + } else { + StartOutcome::IdConflict(record) + }); + } + + 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 access = AiAccess { + api_key, + base_url: settings.base_url, + }; + + let secrets = state.secrets.clone(); + let outcome = state + .provisioner_tasks + .create( + RecordingAiAnalysis::new( + state.conf_handle.clone(), + state.recordings.clone(), + state.secrets.clone(), + ), + task_id, + target, + ¶ms, + move |record| { + // The job may run as soon as the Task is recorded, so the key must be in place first. + secrets.insert(SecretId(record.id), &access.to_secret()?, record.deadline_at) + }, + ) + .await + .map_err(StartError::Internal)?; + + Ok(match outcome { + CreateOutcome::Created(task) => { + debug!(task.id = %task_id, session.id = %session_id, "AI analysis queued"); + StartOutcome::Created(task) + } + CreateOutcome::Existing(task) => StartOutcome::Existing(task), + CreateOutcome::IdConflict(task) => StartOutcome::IdConflict(task), + CreateOutcome::TargetBusy(active) => StartOutcome::TargetBusy { + active_task_id: active.id, + }, + }) +} + +/// Returns the Task with this ID, of any kind; one past its deadline is failed first. +pub async fn get(state: &DgwState, task_id: Uuid) -> anyhow::Result> { + state.provisioner_tasks.get(task_id).await +} + +/// The `recording-ai-analysis` provisioner task. +pub struct RecordingAiAnalysis { + conf_handle: ConfHandle, + recordings: RecordingMessageSender, + secrets: SecretStore, + progress_timeout: time::Duration, + first_retry_delay: time::Duration, +} + +impl RecordingAiAnalysis { + fn new(conf_handle: ConfHandle, recordings: RecordingMessageSender, secrets: SecretStore) -> Self { + Self { + conf_handle, + recordings, + secrets, + progress_timeout: PROGRESS_TIMEOUT, + first_retry_delay: FIRST_RETRY_DELAY, + } + } + + pub(crate) fn from_dependencies(dependencies: &InMemoryJobDependencies) -> Self { + Self::new( + dependencies.conf_handle.clone(), + dependencies.recordings.clone(), + dependencies.secrets.clone(), + ) + } + + fn manifest_path(&self, session_id: Uuid) -> Utf8PathBuf { + self.conf_handle + .get_conf() + .recording_path + .join(session_id.to_string()) + .join("recording.json") + } +} + +#[async_trait] +impl ProvisionerTask for RecordingAiAnalysis { + const KIND: &'static str = KIND; + + const START_WINDOW: time::Duration = START_WINDOW; + + type Params = Params; + + type Output = Analysis; + + type Failure = Failure; + + type Recoverable = Recoverable; + + fn progress_timeout(&self) -> time::Duration { + self.progress_timeout + } + + fn retry_delay(&self, attempts: u32) -> time::Duration { + self.first_retry_delay * 2u32.pow(attempts.saturating_sub(1)) + } + + fn starting_payload(&self, last: &serde_json::Value) -> serde_json::Value { + json!(Running { + step: "preparing", + done: None, + total: None, + usage: TokenUsage::of(last), + }) + } + + fn failed_payload(&self, error: &Permanent, last: &serde_json::Value) -> serde_json::Value { + json!({ "error": error, "usage": TokenUsage::of(last) }) + } + + async fn run(&self, attempt: &Attempt, params: Params) -> Result { + let session_id = Uuid::parse_str(&attempt.target).map_err(|error| { + warn!(task.id = %attempt.task_id, %error, "Invalid session ID"); + AnalysisError::Permanent(Permanent::InvalidTask) + })?; + + // UNTESTED: the key lives until the deadline, which this attempt start moved, so a retry hours after the Task was + // created still finds it; every move of the deadline must move the key's expiry too. + self.secrets.set_expiry(SecretId(attempt.task_id), attempt.deadline()); + + // Waiting for an earlier analysis of the session is no progress, so the deadline cancels it too. + let _session = tokio::select! { + session = lock_session(session_id) => session, + () = attempt.cancel.cancelled() => return Err(AnalysisError::Permanent(Permanent::DeadlinePassed)), + }; + + // A new Task never resumes from what an earlier analysis left, while its retries resume from their own files. + if attempt.attempts == 1 { + recording_analysis::discard(&self.manifest_path(session_id), None) + .await + .map_err(|error| { + error!(task.id = %attempt.task_id, %error, "Failed to remove the earlier AI analysis working files"); + AnalysisError::Permanent(Permanent::Internal) + })?; + } + + let secret = self.secrets.get(SecretId(attempt.task_id)).map_err(|error| { + error!(task.id = %attempt.task_id, error = format!("{error:#}"), "Failed to read the API key"); + AnalysisError::Permanent(Permanent::Internal) + })?; + + let Some(secret) = secret else { + warn!(task.id = %attempt.task_id, attempts = attempt.attempts, "AI analysis lost its API key"); + return Err(AnalysisError::of_kind(Failure::KeyLost)); + }; + + let access = AiAccess::from_secret(&secret).map_err(|error| { + error!(task.id = %attempt.task_id, error = format!("{error:#}"), "Failed to read the API key"); + AnalysisError::Permanent(Permanent::Internal) + })?; + + let settings = AiSettings { + provider: params.provider, + model: params.model, + base_url: access.base_url, + max_output_tokens: params.max_output_tokens, + }; + + self.analyze(attempt, session_id, settings, params.language, access.api_key) + .await + } + + async fn publish(&self, record: &ProvisionerTaskRecord, output: &Analysis) -> anyhow::Result<()> { + let session_id = Uuid::parse_str(&record.target).context("parse the session ID")?; + + // Nothing else runs on the session meanwhile: a newer Task of the session cannot start before this one ends. + self.recordings + .replace_artifacts(session_id, ArtifactKind::AiAnalysis, &output.log_path) + .await + .with_context(|| format!("list {}", output.log_path)) + } + + async fn on_end(&self, record: &ProvisionerTaskRecord) -> anyhow::Result<()> { + self.secrets.remove(SecretId(record.id)); + + let Ok(session_id) = Uuid::parse_str(&record.target) else { + return Ok(()); + }; + + let _session = lock_session(session_id).await; + + recording_analysis::discard(&self.manifest_path(session_id), Some(&record.id.to_string())) + .await + .context("remove the AI analysis working files") + } +} + +impl RecordingAiAnalysis { + /// Runs the analysis while the caller holds the lock of the session. + async fn analyze( + &self, + attempt: &Attempt, + session_id: Uuid, + settings: AiSettings, + language: Option, + api_key: SecretString, + ) -> Result { + let conf = self.conf_handle.get_conf(); + + let client = settings.client(&conf, &api_key).map_err(|error| { + error!(task.id = %attempt.task_id, %error, "Failed to build the AI client"); + AnalysisError::Permanent(Permanent::Internal) + })?; + + let recording = self + .recordings + .get_finished(session_id) + .await + .map_err(|error| match error { + FinishedRecordingError::NotFound => AnalysisError::of_kind(Failure::NoRecording), + error => { + error!(task.id = %attempt.task_id, error = format!("{error:#}"), "Failed to read the recording"); + AnalysisError::Permanent(Permanent::Internal) + } + })?; + + let manifest = analysis_manifest(&recording.manifest).map_err(|error| match error { + RecordingsError::Mixed => AnalysisError::of_kind(Failure::MixedRecording), + _ => AnalysisError::of_kind(Failure::NoRecording), + })?; + let manifest_path = recording.manifest_path(); + + let mut request = client + .analyze_recording(&manifest, &manifest_path) + .owner(attempt.task_id.to_string()) + .cancellation(attempt.cancel.clone()); + + if let Some(max_output_tokens) = settings.max_output_tokens { + request = request.max_output_tokens(max_output_tokens); + } + + if let Some(language) = language { + request = request.language(language); + } + + let earlier = TokenUsage::of(&attempt.payload); + let mut usage = earlier; + let mut events = request.start(); + let mut recorded = None; + + let analysis = loop { + match events.recv().await { + Some(RecordingAnalysisEvent::Progress(progress)) => { + // Reading sends an event every half second, so while reading the Task is updated only when the percent + // changes. + let done = match progress { + Progress::Reading { .. } => u64::from(progress.percent()), + _ => progress.done_and_total().map_or(0, |(done, _)| done), + }; + let attempt_usage = match progress { + Progress::Describing { usage, .. } => Some(usage), + _ => None, + }; + let shown = (progress.stage(), done, attempt_usage); + + if recorded != Some(shown) { + recorded = Some(shown); + + if let Some(attempt_usage) = attempt_usage { + usage = earlier.with(attempt_usage); + } + + attempt.report_progress(progress_payload(&progress, usage)).await; + self.secrets.set_expiry(SecretId(attempt.task_id), attempt.deadline()); + } + } + Some(RecordingAnalysisEvent::Complete(analysis)) => break analysis, + Some(RecordingAnalysisEvent::Failure(error)) => { + warn!(task.id = %attempt.task_id, %error, "AI analysis attempt failed"); + return Err(error.into()); + } + Some(_) => {} + None => { + error!(task.id = %attempt.task_id, "The AI analysis stopped unexpectedly"); + return Err(AnalysisError::Permanent(Permanent::Panicked)); + } + } + }; + + debug!(task.id = %attempt.task_id, actions = analysis.actions, "AI analysis log written"); + + Ok(Analysis { + file_name: ArtifactKind::AiAnalysis.file_name(0), + model: analysis.model, + usage: earlier.with(analysis.usage), + prompt_version: analysis.prompt_version, + log_path: analysis.log_path, + }) + } +} + +/// The payload of a running AI analysis Task: its progress, for whoever asks about it, and the tokens it used so far. +fn progress_payload(progress: &Progress, usage: TokenUsage) -> serde_json::Value { + let (done, total) = progress.done_and_total().unzip(); + + json!(Running { + step: progress.stage(), + done, + total, + usage, + }) +} + +/// 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 +} + +/// What the AI crate needs from the session manifest; a session without recordings, or with both terminal and video ones, +/// cannot be analyzed. +fn analysis_manifest(manifest: &JrecManifest) -> Result { + let recordings = Recordings::from_files(manifest.files().iter().map(|file| RecordingFile { + file_name: file.file_name().to_owned(), + start_time: file.start_time(), + }))?; + + Ok(RecordingManifest { + start_time: manifest.start_time(), + duration: manifest.duration(), + recordings, + }) +} 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 0000000000..7a5182b731 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/tests.rs @@ -0,0 +1,1031 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::http::StatusCode; +use camino::Utf8Path; +use devolutions_gateway_ai::session_actions::PROMPT_VERSION; +use devolutions_gateway_ai::{Language, session_summary}; +use devolutions_gateway_task::ShutdownHandle; +use job_queue::Job as _; +use parking_lot::Mutex; +use provisioner_task::{DynProvisionerTaskStore, NewProvisionerTaskRecord, ProvisionerTaskState}; +use time::OffsetDateTime; + +use super::*; +use crate::job_queue::{StoredJob, TestJobQueue}; +use crate::provisioner_tasks::{MAX_ATTEMPTS, ProvisionerTaskService, TaskJob}; +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, + progress_timeout: time::Duration, + first_retry_delay: time::Duration, + store: DynProvisionerTaskStore, + secrets: SecretStore, + job_queue: Arc, + 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, + provisioner_task_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, + )); + + // `start` stores its jobs where the test can take them. + let job_queue = TestJobQueue::new().await; + let conn = gateway_db::GatewayDb::open_path(":memory:") + .await + .expect("open gateway database") + .connect() + .await + .expect("connect"); + let store: DynProvisionerTaskStore = Arc::new( + provisioner_task_libsql::LibSqlProvisionerTaskStore::open(conn) + .await + .expect("open task store"), + ); + let service = ProvisionerTaskService::new( + Arc::clone(&store), + Arc::::clone(&job_queue), + provisioner_task_rx, + ); + let (service_shutdown_handle, service_shutdown_signal) = ShutdownHandle::new(); + tokio::spawn(devolutions_gateway_task::Task::run(service, service_shutdown_signal)); + let secrets = state.secrets.clone(); + + Self { + state, + progress_timeout: PROGRESS_TIMEOUT, + first_retry_delay: time::Duration::ZERO, + store, + secrets, + job_queue, + recording_path, + _keep: Box::new(( + session_manager_rx, + job_queue_rx, + subscriber_rx, + traffic_audit_rx, + shutdown_handle, + recording_shutdown_handle, + service_shutdown_handle, + )), + _dir: dir, + } + } + + async fn start(&self, task_id: Uuid, session_id: Uuid, settings: AiSettings) -> Result { + self.start_in(task_id, session_id, settings, None).await + } + + /// Same as [`Harness::start`], asking for the analysis in `language`. + async fn start_in( + &self, + task_id: Uuid, + session_id: Uuid, + settings: AiSettings, + language: Option, + ) -> Result { + start( + &self.state, + StartRequest { + task_id, + session_id, + settings, + language, + 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 + } + + /// The task every job of this harness runs, with its timings. + fn analysis(&self) -> RecordingAiAnalysis { + RecordingAiAnalysis { + progress_timeout: self.progress_timeout, + first_retry_delay: self.first_retry_delay, + ..RecordingAiAnalysis::new( + self.state.conf_handle.clone(), + self.state.recordings.clone(), + self.secrets.clone(), + ) + } + } + + /// The job of the Task, as the job queue would run it. + async fn job(&self, task_id: Uuid) -> TaskJob { + let json = json!({ "taskId": task_id, "jobToken": self.task(task_id).await.job_token }).to_string(); + + TaskJob::read_json(&json, self.analysis(), Arc::clone(&self.store)).expect("valid job") + } + + async fn run(&self, task_id: Uuid) -> anyhow::Result<()> { + self.job(task_id).await.run().await + } + + /// Takes every queued job. + fn queued_jobs(&mut self) -> Vec { + std::iter::from_fn(|| self.job_queue.take()).collect() + } + + /// Records a Task directly, as if it was created at `created_at`. + async fn create_task( + &self, + task_id: Uuid, + session_id: Uuid, + provider: &str, + created_at: OffsetDateTime, + deadline_at: OffsetDateTime, + ) { + self.store + .create( + NewProvisionerTaskRecord { + id: task_id, + kind: KIND.to_owned(), + target: session_id.to_string(), + params: json!({ "provider": provider, "model": "gpt-test", "maxOutputTokens": null }), + deadline_at, + job_token: Uuid::new_v4(), + }, + created_at, + Box::pin(async { Ok(()) }), + ) + .await + .expect("create"); + } + + async fn task(&self, task_id: Uuid) -> ProvisionerTaskRecord { + self.store + .get(task_id, OffsetDateTime::now_utc()) + .await + .expect("read task") + .expect("task exists") + } + + /// Runs the Tasks with an AI analysis that fails after `progress_timeout` without progress. + fn with_progress_timeout(&mut self, progress_timeout: time::Duration) { + self.progress_timeout = progress_timeout; + } + + /// 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 access(&self, task_id: Uuid) -> Option { + self.secrets + .get(SecretId(task_id)) + .expect("read the secret") + .map(|secret| AiAccess::from_secret(&secret).expect("AI access")) + } + + fn has_key(&self, task_id: Uuid) -> bool { + self.access(task_id).is_some() + } + + /// Hands `task_id` an API key directly, as `start` does. + fn insert_key(&self, task_id: Uuid, base_url: Option, expires_at: OffsetDateTime) { + let access = AiAccess { + api_key: SecretString::from(API_KEY), + base_url, + }; + self.secrets + .insert(SecretId(task_id), &access.to_secret().expect("secret"), expires_at) + .expect("insert the secret"); + } + + /// 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!("{{\"line\":\"L1\",\"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) +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Secrets +#[tokio::test] +async fn start_records_the_task_keeps_the_key_and_queues_a_job() { + let 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, START_WINDOW); + + let access = harness.access(task_id).expect("key is kept"); + assert_eq!(access.base_url.expect("base URL").as_str(), "https://ai.example/v1/"); + + let StoredJob { name, json } = harness.job_queue.take().expect("job queued"); + assert_eq!(name, KIND); + assert_eq!( + json, + format!(r#"{{"taskId":"{task_id}","jobToken":"{}"}}"#, task.job_token), + "the job is stored with the token of the record" + ); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn asking_again_creates_nothing_new() { + let 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.take().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.take().is_none(), "no other job is queued"); + assert!(harness.has_key(task_id), "the first key stays"); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn asking_again_returns_the_task_even_while_the_session_records_again() { + let 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.take().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:?}"); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[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 + .store + .get(task_id, OffsetDateTime::now_utc()) + .await + .expect("read") + .is_none() + ); + assert!(!harness.has_key(task_id)); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[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 + .store + .get(task_id, OffsetDateTime::now_utc()) + .await + .expect("read") + .is_none() + ); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[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!(!harness.has_key(task_id)); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn retry_resumes_at_the_first_chunk_without_a_checkpoint() { + let mut 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; + // The job of the creation, so a job queued by a retry would be left to find. + harness.queued_jobs(); + let workspace = harness.workspace(session_id); + + harness.run(task_id).await.expect("the second attempt succeeds"); + assert!(harness.queued_jobs().is_empty(), "the job runs the next attempt itself"); + + 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": 40, "outputTokens": 80, "unreportedRequests": 0 }, + "promptVersion": format!("{PROMPT_VERSION}+{}", session_summary::PROMPT_VERSION), + }), + "every attempt counts, and the refused request costs nothing" + ); + + let requests = requests.lock().clone(); + assert_eq!( + requests.len(), + 5, + "only chunks 1 and 2 are sent again, then the summary" + ); + assert_eq!(requests[2], requests[1], "the refused chunk is sent again"); + assert_ne!(requests[1], requests[0]); + assert_ne!(requests[3], requests[1]); + 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"); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[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, + json!({ + "error": { "kind": "aiRejected", "status": 401 }, + "usage": { "inputTokens": 0, "outputTokens": 0, "unreportedRequests": 0 }, + }) + ); + assert_eq!(task.attempts, 1); + assert_eq!(requests.lock().len(), 1); + assert!(!harness.workspace(session_id).exists()); + assert!(!harness.has_key(task_id)); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn a_session_mixing_terminal_and_video_fails() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let manifest_path = harness + .recording_path + .join(session_id.to_string()) + .join("recording.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).expect("manifest")).expect("manifest JSON"); + manifest["files"] + .as_array_mut() + .expect("files") + .push(json!({ "fileName": "recording-1.webm", "startTime": 1_787_255_035, "duration": 1 })); + std::fs::write(&manifest_path, manifest.to_string()).expect("manifest"); + let task_id = harness.start_created(session_id, openai("gpt-test")).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["error"], json!({ "kind": "mixedRecording" })); +} + +// oracle: mutant a recording deleted after the Task starts fails it as internal instead of noRecording +#[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["error"], json!({ "kind": "noRecording" })); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Management +#[tokio::test] +async fn transient_errors_fail_the_task_once_attempts_are_exhausted() { + let mut 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; + // The job of the creation, so only a job queued by a retry is left to find. + harness.queued_jobs(); + + harness.run(task_id).await.expect("the job runs every attempt itself"); + assert!(harness.queued_jobs().is_empty(), "no other job is queued"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.attempts, MAX_ATTEMPTS); + assert_eq!( + task.payload["error"], + json!({ "kind": "attemptsExhausted", "last": { "kind": "aiBusy", "status": 503 } }) + ); + assert_eq!(requests.lock().len(), 5); + assert!(!harness.workspace(session_id).exists()); + assert!(!harness.has_key(task_id)); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn an_analysis_without_progress_fails_at_its_deadline() { + let mut harness = Harness::new().await; + let session_id = harness.write_long_session(10); + + // A provider that never answers stops the progress of the analysis. + 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; + harness.with_progress_timeout(time::Duration::seconds(2)); + + harness.run(task_id).await.expect("no retry"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + assert_eq!( + task.payload, + json!({ + "error": { "kind": "deadlinePassed" }, + "usage": { "inputTokens": 0, "outputTokens": 0, "unreportedRequests": 1 }, + }), + "the request cut off at the deadline may have cost tokens" + ); + assert!(!harness.workspace(session_id).exists()); + assert!(!harness.has_key(task_id)); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[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.secrets.remove(SecretId(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!({ + "error": { "kind": "keyLost" }, + "usage": { "inputTokens": 0, "outputTokens": 0, "unreportedRequests": 0 }, + }) + ); + assert!(!harness.workspace(session_id).exists()); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[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 + .create_task( + task_id, + session_id, + "openai", + now - time::Duration::hours(2), + now - time::Duration::seconds(1), + ) + .await; + harness.insert_key(task_id, 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, + json!({ "error": { "kind": "deadlinePassed" } }), + "a Task that never ran spent nothing" + ); + assert_eq!(task.attempts, 0); + assert!(!harness.has_key(task_id)); + assert!(!harness.workspace(session_id).exists()); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Management +#[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"); + let asked = requests.lock().len(); + harness.run(task_id).await.expect("second run"); + + assert_eq!(harness.task(task_id).await.attempts, 1); + assert_eq!(requests.lock().len(), asked, "the AI is not asked again"); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[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"); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[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()); +} +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn a_new_task_replaces_the_result_of_the_earlier_one() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, _requests) = spawn_ai_provider(|_| 0).await; + let first = harness.start_created(session_id, settings.clone()).await; + harness.run(first).await.expect("first analysis"); + + let second = harness.start_created(session_id, settings).await; + harness.run(second).await.expect("second analysis"); + + let task = harness.task(second).await; + assert_eq!(task.state, ProvisionerTaskState::Succeeded, "{:?}", task.payload); + assert_eq!(task.payload["fileName"], "ai-analysis-0.slog"); + let dir = harness.recording_path.join(session_id.to_string()); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.join("recording.json")).expect("manifest")).expect("manifest JSON"); + assert_eq!( + manifest["artifacts"]["ai-analysis"], + json!([{ "fileName": "ai-analysis-0.slog" }]), + "one analysis per recording" + ); + assert!(!dir.join("ai-analysis-1.slog").exists()); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn a_new_task_that_fails_keeps_the_earlier_analysis() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, _requests) = spawn_ai_provider(|_| 0).await; + let first = harness.start_created(session_id, settings.clone()).await; + harness.run(first).await.expect("first analysis"); + + let second = harness.start_created(session_id, settings).await; + harness.secrets.remove(SecretId(second)); + harness.run(second).await.expect("no retry"); + + let task = harness.task(second).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["error"], json!({ "kind": "keyLost" })); + let dir = harness.recording_path.join(session_id.to_string()); + assert_eq!(ai_analyses(&dir), json!([{ "fileName": "ai-analysis-0.slog" }])); + assert!(dir.join("ai-analysis-0.slog").exists()); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn a_failed_task_reports_the_tokens_it_spent() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + // The chunk is answered, then the provider refuses the summary request for good. + let (settings, _requests) = spawn_ai_provider(|index| if index == 1 { 401 } else { 0 }).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["usage"], + json!({ "inputTokens": 10, "outputTokens": 20, "unreportedRequests": 0 }) + ); +} + +/// The AI analyses the manifest in `dir` lists. +fn ai_analyses(dir: &Utf8Path) -> serde_json::Value { + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.join("recording.json")).expect("manifest")).expect("manifest JSON"); + manifest["artifacts"] + .get("ai-analysis") + .cloned() + .unwrap_or_else(|| json!([])) +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn the_analysis_is_in_the_language_of_the_request() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, requests) = spawn_ai_provider(|_| 0).await; + let task_id = Uuid::new_v4(); + let language = Language::new("fr-CA").expect("language"); + + let outcome = harness + .start_in(task_id, session_id, settings.clone(), Some(language.clone())) + .await + .expect("started"); + assert!(matches!(outcome, StartOutcome::Created(_)), "{outcome:?}"); + harness.run(task_id).await.expect("analysis"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Succeeded, "{:?}", task.payload); + assert_eq!(task.params["language"], "fr-CA"); + assert!(requests.lock().iter().all(|request| request.contains("`fr-CA`"))); + + let other = harness + .start_in( + task_id, + session_id, + settings, + Some(Language::new("de-DE").expect("language")), + ) + .await + .expect("started"); + assert!( + matches!(other, StartOutcome::IdConflict(_)), + "the same Task ID with another language is another request: {other:?}" + ); +} + +// oracle: intent crates/devolutions-gateway-ai/INTENT.md §Analyze Recording +#[tokio::test] +async fn the_analysis_is_in_english_without_a_language() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, requests) = spawn_ai_provider(|_| 0).await; + let task_id = harness.start_created(session_id, settings).await; + + harness.run(task_id).await.expect("analysis"); + + let task = harness.task(task_id).await; + assert_eq!(task.state, ProvisionerTaskState::Succeeded, "{:?}", task.payload); + assert!(task.params.get("language").is_none(), "{}", task.params); + assert!(requests.lock().iter().all(|request| request.contains("`English`"))); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn a_new_task_never_resumes_from_an_earlier_task() { + let mut harness = Harness::new().await; + harness.first_retry_delay = time::Duration::hours(1); + // 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 first = harness.start_created(session_id, settings.clone()).await; + // As if Gateway stopped while the first Task waited for its next attempt. + tokio::time::timeout(Duration::from_secs(3), harness.run(first)) + .await + .expect_err("the first Task waits for its next attempt"); + assert!(harness.workspace(session_id).join("chunk-0000.json").exists()); + harness + .store + .fail(first, json!({}), OffsetDateTime::now_utc()) + .await + .expect("end the first Task"); + + let second = harness.start_created(session_id, settings).await; + harness.run(second).await.expect("second analysis"); + + assert_eq!(harness.task(second).await.state, ProvisionerTaskState::Succeeded); + assert_eq!( + requests.lock().len(), + 2 + 3 + 1, + "the new Task asks about every chunk again, then for the summary" + ); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn an_ended_task_leaves_the_working_files_of_another_task() { + let harness = Harness::new().await; + let session_id = harness.write_session(); + let (settings, _requests) = spawn_ai_provider(|_| 0).await; + let task_id = harness.start_created(session_id, settings).await; + let workspace = harness.workspace(session_id); + std::fs::create_dir_all(&workspace).expect("working files of a newer Task"); + std::fs::write( + workspace.join("analysis.json"), + json!({ + "owner": Uuid::new_v4().to_string(), + "promptVersion": "p", + "model": "m", + "language": "English", + "chunkLen": 1, + }) + .to_string(), + ) + .expect("settings of a newer Task"); + let record = harness.task(task_id).await; + let task = RecordingAiAnalysis::new( + harness.state.conf_handle.clone(), + harness.state.recordings.clone(), + harness.secrets.clone(), + ); + + task.on_end(&record).await.expect("end"); + + assert!(workspace.exists(), "the newer Task still resumes from them"); + assert!(!harness.has_key(task_id)); +} diff --git a/devolutions-gateway/src/provisioner_tasks/service.rs b/devolutions-gateway/src/provisioner_tasks/service.rs new file mode 100644 index 0000000000..7577ade0a2 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/service.rs @@ -0,0 +1,177 @@ +//! The one task that records Tasks: it holds the task store and the job queue, so a Task and its job are written +//! together or not at all, and the handlers only ask it. + +use std::sync::Arc; + +use anyhow::Context as _; +use async_trait::async_trait; +use devolutions_gateway_task::{ShutdownSignal, Task}; +use job_queue::{DynJob, DynJobQueue}; +use provisioner_task::{CreateOutcome, DynProvisionerTaskStore, NewProvisionerTaskRecord, ProvisionerTaskRecord}; +use time::OffsetDateTime; +use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; + +use super::{ProvisionerTask, TaskJob, deadline}; + +/// What is done for a new Task once its record is written and before it is committed, such as keeping its secrets. +type OnCreated = Box anyhow::Result<()> + Send>; + +/// Builds the job of a new Task from the task store, which only the service holds. +type JobOf = Box DynJob + Send>; + +enum Message { + Create { + record: NewProvisionerTaskRecord, + job_of: JobOf, + on_created: OnCreated, + reply: oneshot::Sender>, + }, + Get { + id: Uuid, + reply: oneshot::Sender>>, + }, +} + +/// Asks the provisioner task service to record or read Tasks. +#[derive(Clone)] +pub struct ProvisionerTaskHandle(mpsc::Sender); + +pub struct ProvisionerTaskReceiver(mpsc::Receiver); + +pub fn provisioner_task_channel() -> (ProvisionerTaskHandle, ProvisionerTaskReceiver) { + let (tx, rx) = mpsc::channel(64); + (ProvisionerTaskHandle(tx), ProvisionerTaskReceiver(rx)) +} + +impl ProvisionerTaskHandle { + /// Records a Task of `task` and stores the job that runs it, both or neither. + /// + /// `on_created` runs only for a new record, before its job is stored, so what the job needs is in place before it + /// can run; if it or storing the job fails, nothing is recorded. A known ID or a busy target creates nothing and + /// stores nothing. + pub async fn create( + &self, + task: T, + id: Uuid, + target: String, + params: &T::Params, + on_created: impl FnOnce(&NewProvisionerTaskRecord) -> anyhow::Result<()> + Send + 'static, + ) -> anyhow::Result { + let params = serde_json::to_value(params).context("serialize the task parameters")?; + let now = OffsetDateTime::now_utc(); + let job_token = Uuid::new_v4(); + let record = NewProvisionerTaskRecord { + id, + kind: T::KIND.to_owned(), + target, + params, + deadline_at: deadline(now, T::START_WINDOW), + job_token, + }; + let job_of: JobOf = Box::new(move |store| Box::new(TaskJob::new(id, job_token, task, store))); + + let (reply, outcome) = oneshot::channel(); + self.0 + .send(Message::Create { + record, + job_of, + on_created: Box::new(on_created), + reply, + }) + .await + .ok() + .context("couldn't send Create message")?; + outcome.await.context("couldn't receive Create result")? + } + + /// Reads a Task, after failing it if it is past its deadline. + pub async fn get(&self, id: Uuid) -> anyhow::Result> { + let (reply, record) = oneshot::channel(); + self.0 + .send(Message::Get { id, reply }) + .await + .ok() + .context("couldn't send Get message")?; + record.await.context("couldn't receive Get result")? + } +} + +/// The provisioner task service: the only one to write Tasks and their jobs from a request. +pub struct ProvisionerTaskService { + store: DynProvisionerTaskStore, + job_queue: DynJobQueue, + rx: ProvisionerTaskReceiver, +} + +impl ProvisionerTaskService { + pub fn new(store: DynProvisionerTaskStore, job_queue: DynJobQueue, rx: ProvisionerTaskReceiver) -> Self { + Self { store, job_queue, rx } + } + + async fn create( + &self, + record: NewProvisionerTaskRecord, + job_of: JobOf, + on_created: OnCreated, + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let job = job_of(Arc::clone(&self.store)); + + let store_job = async { + on_created(&record).context("prepare the Task")?; + self.job_queue.push_job(&job, None).await + }; + let outcome = self.store.create(record.clone(), now, Box::pin(store_job)).await?; + + if matches!(outcome, CreateOutcome::Created(_)) { + info!(task.id = %record.id, task.kind = %record.kind, "Task queued"); + } + + Ok(outcome) + } +} + +#[async_trait] +impl Task for ProvisionerTaskService { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "provisioner task service"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { + provisioner_task_service(self, shutdown_signal).await + } +} + +#[instrument(skip_all)] +async fn provisioner_task_service( + mut service: ProvisionerTaskService, + mut shutdown_signal: ShutdownSignal, +) -> anyhow::Result<()> { + debug!("Task started"); + + loop { + tokio::select! { + message = service.rx.0.recv() => { + let Some(message) = message else { + debug!("All senders are dead"); + break; + }; + + match message { + Message::Create { record, job_of, on_created, reply } => { + let _ = reply.send(service.create(record, job_of, on_created).await); + } + Message::Get { id, reply } => { + let _ = reply.send(service.store.get(id, OffsetDateTime::now_utc()).await); + } + } + } + () = shutdown_signal.wait() => break, + } + } + + debug!("Task terminated"); + + Ok(()) +} diff --git a/devolutions-gateway/src/provisioner_tasks/tests.rs b/devolutions-gateway/src/provisioner_tasks/tests.rs new file mode 100644 index 0000000000..64501ce258 --- /dev/null +++ b/devolutions-gateway/src/provisioner_tasks/tests.rs @@ -0,0 +1,524 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use devolutions_gateway_task::ShutdownHandle; +use job_queue::Job as _; +use parking_lot::Mutex; +use provisioner_task::{CreateOutcome, DynProvisionerTaskStore, NewProvisionerTaskRecord, ProvisionerTaskState}; +use serde_json::json; +use uuid::Uuid; + +use super::*; +use crate::job_queue::{StoredJob, TestJobQueue}; + +const KIND: &str = "test.echo"; + +/// A value whose attempt waits until it is cancelled. +const HANG: u32 = u32::MAX; + +/// A value whose attempt reports progress five times, half a second apart, then succeeds. +const PROGRESS: u32 = u32::MAX - 1; + +/// Why an Echo attempt fails for good. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum EchoFailure { + /// What the attempt needed is gone. + Gone, +} + +/// Why an Echo attempt fails for now. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum EchoRecoverable { + Busy, +} + +type EchoError = AttemptError; + +#[derive(Debug, Serialize, serde::Deserialize)] +struct EchoParams { + value: u32, +} + +/// A task whose attempts return `outcome`, counting its runs and the records it ended. +struct Echo { + outcome: fn(u32) -> Result, + progress_timeout: time::Duration, + runs: Arc, + ended: Arc>>, + /// Ends that fail before one succeeds. + end_failures: Arc, + /// Publications that fail before one succeeds. + publish_failures: Arc, + /// The records whose output was published. + published: Arc>>, +} + +#[async_trait] +impl ProvisionerTask for Echo { + const KIND: &'static str = KIND; + + const START_WINDOW: time::Duration = time::Duration::hours(1); + + type Params = EchoParams; + + type Output = serde_json::Value; + + type Failure = EchoFailure; + + type Recoverable = EchoRecoverable; + + fn progress_timeout(&self) -> time::Duration { + self.progress_timeout + } + + fn retry_delay(&self, _: u32) -> time::Duration { + time::Duration::ZERO + } + + fn starting_payload(&self, last: &serde_json::Value) -> serde_json::Value { + json!({ "after": last }) + } + + fn failed_payload( + &self, + error: &Permanent, + last: &serde_json::Value, + ) -> serde_json::Value { + json!({ "error": error, "last": last }) + } + + async fn run(&self, attempt: &Attempt, params: EchoParams) -> Result { + self.runs.fetch_add(1, Ordering::SeqCst); + + match params.value { + HANG => { + attempt.cancel.cancelled().await; + Err(AttemptError::Recoverable(EchoRecoverable::Busy)) + } + PROGRESS => { + // 4 s of work outlasts the first deadline, 2 to 3 s away, so only the progress keeps the attempt running. + for step in 0..8 { + tokio::select! { + () = tokio::time::sleep(std::time::Duration::from_millis(500)) => {} + () = attempt.cancel.cancelled() => return Err(AttemptError::Recoverable(EchoRecoverable::Busy)), + } + attempt.report_progress(json!({ "step": step })).await; + } + Ok(json!("done")) + } + value => (self.outcome)(value), + } + } + + async fn publish(&self, record: &ProvisionerTaskRecord, _: &serde_json::Value) -> anyhow::Result<()> { + if self + .publish_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| left.checked_sub(1)) + .is_ok() + { + anyhow::bail!("the publication failed"); + } + + self.published.lock().push(record.id); + Ok(()) + } + + async fn on_end(&self, record: &ProvisionerTaskRecord) -> anyhow::Result<()> { + if self + .end_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| left.checked_sub(1)) + .is_ok() + { + anyhow::bail!("the end failed"); + } + + self.ended.lock().push(record.id); + Ok(()) + } +} + +struct Harness { + outcome: fn(u32) -> Result, + progress_timeout: time::Duration, + store: DynProvisionerTaskStore, + job_queue: Arc, + tasks: ProvisionerTaskHandle, + /// Keeps the provisioner task service running. + _service_shutdown: ShutdownHandle, + runs: Arc, + ended: Arc>>, + end_failures: Arc, + publish_failures: Arc, + published: Arc>>, +} + +impl Harness { + async fn new(outcome: fn(u32) -> Result) -> Self { + Self::with_progress_timeout(outcome, time::Duration::hours(1)).await + } + + async fn with_progress_timeout( + outcome: fn(u32) -> Result, + progress_timeout: time::Duration, + ) -> Self { + let conn = gateway_db::GatewayDb::open_path(":memory:") + .await + .expect("open gateway database") + .connect() + .await + .expect("connect"); + let store: DynProvisionerTaskStore = Arc::new( + provisioner_task_libsql::LibSqlProvisionerTaskStore::open(conn) + .await + .expect("open task store"), + ); + let job_queue = TestJobQueue::new().await; + let (tasks, rx) = provisioner_task_channel(); + let (service_shutdown, shutdown_signal) = ShutdownHandle::new(); + let service = ProvisionerTaskService::new(Arc::clone(&store), Arc::::clone(&job_queue), rx); + tokio::spawn(devolutions_gateway_task::Task::run(service, shutdown_signal)); + let runs = Arc::new(AtomicUsize::new(0)); + let ended = Arc::new(Mutex::new(Vec::new())); + let end_failures = Arc::new(AtomicUsize::new(0)); + let publish_failures = Arc::new(AtomicUsize::new(0)); + let published = Arc::new(Mutex::new(Vec::new())); + + Self { + outcome, + progress_timeout, + store, + job_queue, + tasks, + _service_shutdown: service_shutdown, + runs, + ended, + end_failures, + publish_failures, + published, + } + } + + /// The task of this harness: every one shares its counters. + fn echo(&self) -> Echo { + Echo { + outcome: self.outcome, + progress_timeout: self.progress_timeout, + runs: Arc::clone(&self.runs), + ended: Arc::clone(&self.ended), + end_failures: Arc::clone(&self.end_failures), + publish_failures: Arc::clone(&self.publish_failures), + published: Arc::clone(&self.published), + } + } + + async fn create(&self, value: u32) -> Uuid { + let id = Uuid::new_v4(); + let outcome = self + .create_task(id, format!("target-{id}"), &EchoParams { value }, |_| Ok(())) + .await + .expect("create"); + assert!(matches!(outcome, CreateOutcome::Created(_)), "{outcome:?}"); + id + } + + async fn create_task( + &self, + id: Uuid, + target: String, + params: &EchoParams, + on_created: impl FnOnce(&NewProvisionerTaskRecord) -> anyhow::Result<()> + Send + 'static, + ) -> anyhow::Result { + self.tasks.create(self.echo(), id, target, params, on_created).await + } + + /// Records a Task directly, with the job token `job_token` and no job. + async fn record_task(&self, id: Uuid, params: serde_json::Value, job_token: Uuid) { + let now = OffsetDateTime::now_utc(); + self.store + .create( + NewProvisionerTaskRecord { + id, + kind: KIND.to_owned(), + target: id.to_string(), + params, + deadline_at: now + time::Duration::hours(1), + job_token, + }, + now, + Box::pin(async { Ok(()) }), + ) + .await + .expect("create"); + } + + /// The job of Task `id` with the job token `job_token`, as the job runner reads it. + fn job(&self, id: Uuid, job_token: Uuid) -> TaskJob { + let json = json!({ "taskId": id, "jobToken": job_token }).to_string(); + TaskJob::read_json(&json, self.echo(), Arc::clone(&self.store)).expect("valid job") + } + + /// Takes the oldest queued job, read back as the job runner reads it. + fn queued_job(&mut self) -> Box { + let StoredJob { name, json, .. } = self.job_queue.take().expect("a queued job"); + assert_eq!(name, KIND); + Box::new(TaskJob::read_json(&json, self.echo(), Arc::clone(&self.store)).expect("valid job")) + } + + async fn task(&self, id: Uuid) -> ProvisionerTaskRecord { + self.store + .get(id, OffsetDateTime::now_utc()) + .await + .expect("read task") + .expect("task exists") + } +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Management +#[tokio::test] +async fn only_a_new_task_runs_on_created_and_gets_a_job() { + let mut harness = Harness::new(|value| Ok(json!(value))).await; + let id = Uuid::new_v4(); + let created = Arc::new(AtomicUsize::new(0)); + let create = || { + let created = Arc::clone(&created); + harness.create_task(id, "target".to_owned(), &EchoParams { value: 1 }, move |_| { + created.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }; + + assert!(matches!(create().await.expect("create"), CreateOutcome::Created(_))); + assert!(matches!(create().await.expect("again"), CreateOutcome::Existing(_))); + + assert_eq!(created.load(Ordering::SeqCst), 1); + harness.queued_job(); + assert!(harness.job_queue.take().is_none(), "one job only"); +} + +// oracle: mutant the job is queued before the Task is prepared, so a Task that cannot be prepared still gets a job +#[tokio::test] +async fn a_task_that_cannot_be_prepared_gets_no_job() { + let harness = Harness::new(|value| Ok(json!(value))).await; + let id = Uuid::new_v4(); + + harness + .create_task(id, "target".to_owned(), &EchoParams { value: 1 }, |_| { + anyhow::bail!("no room for the secret") + }) + .await + .expect_err("preparing fails"); + + assert!(harness.job_queue.take().is_none(), "no job is queued"); + assert!( + harness + .store + .get(id, OffsetDateTime::now_utc()) + .await + .expect("read task") + .is_none(), + "no Task is recorded" + ); +} + +// oracle: mutant a job claimed before its Task is committed is dropped as done, and the Task never runs +#[tokio::test] +async fn a_job_whose_task_is_not_recorded_yet_is_run_again_later() { + let harness = Harness::new(|value| Ok(json!(value))).await; + let id = Uuid::new_v4(); + let job_token = Uuid::new_v4(); + let mut job = harness.job(id, job_token); + + job.run().await.expect_err("the job queue runs the job again"); + assert_eq!(harness.runs.load(Ordering::SeqCst), 0); + + harness.record_task(id, json!({ "value": 1 }), job_token).await; + job.run().await.expect("the Task is recorded now"); + + assert_eq!(harness.task(id).await.state, ProvisionerTaskState::Succeeded); + assert_eq!(harness.runs.load(Ordering::SeqCst), 1); +} + +// oracle: mutant the job stored for a Task that was never committed runs the Task recorded since under the same ID +#[tokio::test] +async fn only_the_job_recorded_with_the_task_runs_it() { + let mut harness = Harness::new(|value| Ok(json!(value))).await; + let id = harness.create(1).await; + let mut other_job = harness.job(id, Uuid::new_v4()); + + other_job.run().await.expect("the other job is done for good"); + assert_eq!(harness.runs.load(Ordering::SeqCst), 0); + assert_eq!(harness.task(id).await.state, ProvisionerTaskState::Queued); + + harness.queued_job().run().await.expect("the job of the Task runs it"); + assert_eq!(harness.task(id).await.state, ProvisionerTaskState::Succeeded); + assert_eq!(harness.runs.load(Ordering::SeqCst), 1); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Management +#[tokio::test] +async fn a_permanent_failure_fails_the_task_at_once() { + let mut harness = Harness::new(|_| Err(AttemptError::of_kind(EchoFailure::Gone))).await; + let id = harness.create(1).await; + + harness.queued_job().run().await.expect("no retry"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.attempts, 1); + assert_eq!(task.payload["error"], json!({ "kind": "gone" })); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Management +#[tokio::test] +async fn a_recoverable_failure_is_retried_up_to_the_last_attempt() { + let mut harness = Harness::new(|_| Err(AttemptError::Recoverable(EchoRecoverable::Busy))).await; + let id = harness.create(1).await; + harness + .queued_job() + .run() + .await + .expect("the job runs every attempt itself"); + assert!(harness.job_queue.take().is_none(), "no other job is queued"); + assert_eq!( + harness.runs.load(Ordering::SeqCst), + usize::try_from(MAX_ATTEMPTS).expect("a few attempts") + ); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.attempts, MAX_ATTEMPTS); + assert_eq!( + task.payload["error"], + json!({ "kind": "attemptsExhausted", "last": { "kind": "busy" } }) + ); +} + +// oracle: mutant the prelude starts an attempt whatever the attempts made, so a restart during the last attempt runs one past it +#[tokio::test] +async fn a_job_delivered_again_after_the_last_attempt_fails_the_task() { + let mut harness = Harness::new(|value| Ok(json!(value))).await; + let id = harness.create(1).await; + let now = OffsetDateTime::now_utc(); + for _ in 0..MAX_ATTEMPTS { + harness + .store + .start_attempt(id, json!(null), now + time::Duration::hours(1), now) + .await + .expect("start an attempt"); + } + + harness.queued_job().run().await.expect("no retry"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["error"], json!({ "kind": "internal" })); + assert_eq!(harness.runs.load(Ordering::SeqCst), 0, "no attempt past the last"); + assert_eq!(*harness.ended.lock(), [id]); +} + +// oracle: mutant a Task whose parameters cannot be read runs its attempt, or stays unfinished +#[tokio::test] +async fn invalid_parameters_fail_the_task() { + let harness = Harness::new(|value| Ok(json!(value))).await; + let id = Uuid::new_v4(); + let job_token = Uuid::new_v4(); + harness + .record_task(id, json!({ "value": "not a number" }), job_token) + .await; + let mut job = harness.job(id, job_token); + + job.run().await.expect("no retry"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["error"], json!({ "kind": "invalidTask" })); + assert_eq!(harness.runs.load(Ordering::SeqCst), 0); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn a_success_is_recorded_only_once_its_output_is_published() { + let mut harness = Harness::new(|value| Ok(json!(value))).await; + let id = harness.create(1).await; + let mut job = harness.queued_job(); + harness.publish_failures.store(1, Ordering::SeqCst); + + job.run().await.expect_err("the job queue runs the job again"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Running, "{:?}", task.payload); + assert!(harness.published.lock().is_empty()); + assert!(harness.ended.lock().is_empty()); + + job.run().await.expect("the publication succeeds"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Succeeded, "{:?}", task.payload); + assert_eq!(task.attempts, 2, "the output comes from a new attempt"); + assert_eq!(*harness.published.lock(), [id]); + assert_eq!(*harness.ended.lock(), [id]); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn an_end_that_fails_is_done_again_when_the_job_queue_runs_the_job_again() { + let mut harness = Harness::new(|value| Ok(json!(value))).await; + let id = harness.create(1).await; + let mut job = harness.queued_job(); + harness.end_failures.store(2, Ordering::SeqCst); + + for _ in 0..2 { + job.run().await.expect_err("the job queue runs the job again"); + assert!(harness.ended.lock().is_empty()); + } + + job.run().await.expect("the end succeeds"); + + assert_eq!(harness.task(id).await.state, ProvisionerTaskState::Succeeded); + assert_eq!(harness.runs.load(Ordering::SeqCst), 1, "the work is not done again"); + assert_eq!(*harness.ended.lock(), [id]); + assert!(harness.job_queue.take().is_none(), "no job is left"); +} + +// oracle: intent crates/provisioner-task/INTENT.md §Invariants +#[tokio::test] +async fn an_attempt_without_progress_is_cancelled_at_the_deadline() { + let mut harness = Harness::with_progress_timeout(|value| Ok(json!(value)), time::Duration::seconds(2)).await; + let id = harness.create(HANG).await; + + harness.queued_job().run().await.expect("no retry"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Failed); + assert_eq!(task.payload["error"], json!({ "kind": "deadlinePassed" })); + assert_eq!(*harness.ended.lock(), [id]); +} + +// oracle: intent crates/provisioner-task/INTENT.md §Invariants +#[tokio::test] +async fn progress_keeps_an_attempt_running_past_its_first_deadline() { + let mut harness = Harness::with_progress_timeout(|value| Ok(json!(value)), time::Duration::seconds(3)).await; + let id = harness.create(PROGRESS).await; + + harness.queued_job().run().await.expect("no retry"); + + let task = harness.task(id).await; + assert_eq!(task.state, ProvisionerTaskState::Succeeded, "{:?}", task.payload); + assert_eq!(task.payload, json!("done")); +} + +// oracle: property round-trip +#[tokio::test] +async fn the_job_is_read_back_from_its_json() { + let mut harness = Harness::new(|value| Ok(json!(value))).await; + let id = harness.create(1).await; + let job = harness.queued_job(); + + let json = job.write_json().expect("job JSON"); + let read = TaskJob::read_json(&json, harness.echo(), Arc::clone(&harness.store)).expect("valid job"); + + assert_eq!(read.task_id, id); + assert_eq!(job.name(), KIND); +} diff --git a/devolutions-gateway/src/recording.intent.md b/devolutions-gateway/src/recording.intent.md index 0a6b6ca5e5..6c56dd1b2f 100644 --- a/devolutions-gateway/src/recording.intent.md +++ b/devolutions-gateway/src/recording.intent.md @@ -9,6 +9,7 @@ When a session whose token requires recording starts and no recording starts for # Artifacts Artifacts are files that are not recordings; the only kind today is `ai-analysis`, stored with the `slog` file type. An endpoint to push artifacts is wanted later. +Gateway replaces the manifest in one step and never rewrites it in place, so neither a reader nor a crash ever leaves half of it. # Ending a recording diff --git a/devolutions-gateway/src/recording.rs b/devolutions-gateway/src/recording.rs index 0ebc4c730d..f362a69b60 100644 --- a/devolutions-gateway/src/recording.rs +++ b/devolutions-gateway/src/recording.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::Context as _; use async_trait::async_trait; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use devolutions_gateway_task::{ShutdownSignal, Task}; use futures::future::Either; use parking_lot::Mutex; @@ -31,15 +31,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, @@ -49,15 +60,48 @@ 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)?; Ok(manifest) } + /// Writes the manifest to a file of its own first, then renames it over `path`, so that neither a reader nor a crash + /// ever finds half of it. fn save_to_file(&self, path: impl AsRef) -> anyhow::Result<()> { + use std::io::Write as _; + + let path = path.as_ref(); + // jrec never serves a name that starts with a dot, so a file a crash left behind is never downloaded. + let mut partial_name = std::ffi::OsString::from("."); + partial_name.push(path.file_name().context("manifest path without a file name")?); + partial_name.push(".partial"); + let partial = path.with_file_name(partial_name); let json = serde_json::to_string_pretty(&self)?; - std::fs::write(path, json)?; + + let mut file = std::fs::File::create(&partial)?; + file.write_all(json.as_bytes())?; + // UNTESTED: a crash leaves the old manifest or the new one, never half of it, only while the file is synced + // before the rename. + file.sync_all()?; + drop(file); + + std::fs::rename(&partial, path)?; + Ok(()) } } @@ -335,7 +379,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() @@ -376,6 +420,16 @@ enum RecordingManagerMessage { kind: ArtifactKind, channel: oneshot::Sender>, }, + ReplaceArtifacts { + id: Uuid, + kind: ArtifactKind, + file: Utf8PathBuf, + channel: oneshot::Sender>, + }, + GetFinished { + id: Uuid, + channel: oneshot::Sender>, + }, Disconnect { id: Uuid, end: PushEnd, @@ -397,6 +451,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 { @@ -416,6 +493,20 @@ impl fmt::Debug for RecordingManagerMessage { .field("id", id) .field("kind", kind) .finish_non_exhaustive(), + RecordingManagerMessage::ReplaceArtifacts { + id, + kind, + file, + channel: _, + } => f + .debug_struct("ReplaceArtifacts") + .field("id", id) + .field("kind", kind) + .field("file", file) + .finish_non_exhaustive(), + RecordingManagerMessage::GetFinished { id, channel: _ } => { + f.debug_struct("GetFinished").field("id", id).finish_non_exhaustive() + } RecordingManagerMessage::Disconnect { id, end } => { f.debug_struct("Disconnect").field("id", id).field("end", end).finish() } @@ -481,6 +572,58 @@ impl RecordingMessageSender { rx.await.context("couldn't receive AddArtifact result")? } + /// Moves `file` into the session as its only artifact of `kind`, named `kind.file_name(0)`, in place of the earlier + /// ones. + /// + /// `file` must be in the session folder, so that a reader finds the earlier artifact or the new one, never none or a + /// partial one. + /// A `file` that is gone is an error, since only its maker moves it, and so is a session whose recording was deleted + /// meanwhile, which only loses `file`. + pub(crate) async fn replace_artifacts(&self, id: Uuid, kind: ArtifactKind, file: &Utf8Path) -> anyhow::Result<()> { + let (tx, rx) = oneshot::channel(); + self.channel + .send(RecordingManagerMessage::ReplaceArtifacts { + id, + kind, + file: file.to_owned(), + channel: tx, + }) + .await + .ok() + .context("couldn't send ReplaceArtifacts message")?; + rx.await.context("couldn't receive ReplaceArtifacts 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, end: PushEnd) -> anyhow::Result<()> { self.channel .send(RecordingManagerMessage::Disconnect { id, end }) @@ -846,6 +989,63 @@ impl RecordingManagerTask { Ok(artifact_path) } + async fn handle_replace_artifacts(&self, id: Uuid, kind: ArtifactKind, file: &Utf8Path) -> anyhow::Result<()> { + let recording_path = self.recordings_path.join(id.to_string()); + let manifest_path = recording_path.join("recording.json"); + + // A recording deleted while its artifact was being generated must stay deleted. + if !manifest_path.exists() { + match fs::remove_file(file).await { + Err(error) if error.kind() != io::ErrorKind::NotFound => { + return Err(anyhow::Error::new(error).context(format!("remove {file}"))); + } + _ => anyhow::bail!("recording {id} was deleted"), + } + } + + let mut manifest = JrecManifest::read_from_file(&manifest_path).context("read manifest from disk")?; + + let file_name = kind.file_name(0); + let artifact_path = recording_path.join(&file_name); + + // The rename swaps the file in at once and the manifest follows, so it never lists a missing or partial file. + fs::rename(file, &artifact_path) + .await + .with_context(|| format!("move {file} to {artifact_path}"))?; + + let earlier = core::mem::replace( + manifest.artifacts.of_kind_mut(kind), + vec![JrecArtifact { + file_name: file_name.clone(), + }], + ); + + manifest + .save_to_file(&manifest_path) + .context("write manifest to disk")?; + + for artifact in earlier.into_iter().filter(|artifact| artifact.file_name != file_name) { + let artifact_path = recording_path.join(&artifact.file_name); + match fs::remove_file(&artifact_path).await { + Err(error) if error.kind() != io::ErrorKind::NotFound => { + warn!(%error, %artifact_path, "Failed to remove an earlier artifact"); + } + _ => {} + } + } + + Ok(()) + } + + 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) { match ongoing.state { @@ -964,6 +1164,12 @@ async fn recording_manager_task( RecordingManagerMessage::AddArtifact { id, kind, channel } => { let _ = channel.send(manager.handle_add_artifact(id, kind).await); }, + RecordingManagerMessage::ReplaceArtifacts { id, kind, file, channel } => { + let _ = channel.send(manager.handle_replace_artifacts(id, kind, &file).await); + }, + RecordingManagerMessage::GetFinished { id, channel } => { + let _ = channel.send(manager.handle_get_finished(id)); + } RecordingManagerMessage::Disconnect { id, end } => { if let Err(e) = manager.handle_disconnect(id, end).await { error!(error = format!("{e:#}"), "handle_disconnect"); @@ -1487,6 +1693,26 @@ mod tests { assert_eq!(harness.sender.get_count().await.expect("count"), 1); } + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis + #[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/secret_store.intent.md b/devolutions-gateway/src/secret_store.intent.md index d24e08089d..9e65642ca9 100644 --- a/devolutions-gateway/src/secret_store.intent.md +++ b/devolutions-gateway/src/secret_store.intent.md @@ -13,3 +13,8 @@ All of this is best effort, as if someone has root access and we still try our b ## Replacement Storing a secret under an ID that already has one replaces the old one. + + +## Sharing + +Gateway has one secret store, shared by the handlers and the background services, so a secret a handler stores is found by the background service that uses it, such as the API key of an AI analysis. diff --git a/devolutions-gateway/src/secret_store.rs b/devolutions-gateway/src/secret_store.rs index 6503160464..ea8d9179f7 100644 --- a/devolutions-gateway/src/secret_store.rs +++ b/devolutions-gateway/src/secret_store.rs @@ -72,6 +72,22 @@ impl SecretStore { .map(Some) } + /// Moves the expiry of the secret of `id` to `expires_at`, unless it is already removed or expired. + pub fn set_expiry(&self, id: SecretId, expires_at: time::OffsetDateTime) { + self.set_expiry_at(id, expires_at, time::OffsetDateTime::now_utc()); + } + + fn set_expiry_at(&self, id: SecretId, expires_at: time::OffsetDateTime, now: time::OffsetDateTime) { + if let Some(entry) = self.entries.lock().get_mut(&id) + && now < entry.expires_at + { + entry.expires_at = expires_at; + // UNTESTED: the cleanup task wakes up for the new expiry, so a secret moved to an earlier expiry is still + // removed on time; any change of an expiry must notify it. + self.cleanup_notify.notify_one(); + } + } + /// Removes the secret of `id`. pub fn remove(&self, id: SecretId) { self.entries.lock().remove(&id); @@ -195,4 +211,32 @@ mod tests { store.remove_expired(now + time::Duration::minutes(1)); assert!(store.entries.lock().is_empty(), "the cleanup pass removes it"); } + + // oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Secrets + #[test] + fn a_moved_expiry_keeps_the_secret_until_then() { + let store = SecretStore::default(); + let now = time::OffsetDateTime::now_utc(); + let moved = SecretId(Uuid::new_v4()); + let expired = SecretId(Uuid::new_v4()); + + store + .insert(moved, &SecretString::from("a"), now + time::Duration::minutes(1)) + .expect("insert"); + store.insert(expired, &SecretString::from("b"), now).expect("insert"); + + store.set_expiry_at(moved, now + time::Duration::hours(1), now); + store.set_expiry_at(expired, now + time::Duration::hours(1), now); + + assert!( + store + .get_at(moved, now + time::Duration::minutes(30)) + .expect("read") + .is_some() + ); + assert!( + store.get_at(expired, now).expect("read").is_none(), + "an expired secret stays gone" + ); + } } diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 258e15d230..828e1b3743 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -3,8 +3,10 @@ use std::time::Duration; use anyhow::Context as _; use devolutions_gateway::config::{Conf, ConfHandle}; +use devolutions_gateway::job_queue::{InMemoryJobDependencies, JobQueueTask, JobRunnerTask}; use devolutions_gateway::listener::{GatewayListener, ListenerUrls}; use devolutions_gateway::log::GatewayLog; +use devolutions_gateway::provisioner_tasks::{ProvisionerTaskService, provisioner_task_channel}; use devolutions_gateway::recording::recording_message_channel; use devolutions_gateway::session::session_manager_channel; use devolutions_gateway::subscriber::subscriber_channel; @@ -340,6 +342,28 @@ 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 task_store: provisioner_task::DynProvisionerTaskStore = Arc::new( + provisioner_task_libsql::LibSqlProvisionerTaskStore::open(provisioner_tasks_conn) + .await + .context("failed to initialize the provisioner task store")?, + ); + + let secrets = devolutions_gateway::secret_store::SecretStore::default(); + let (provisioner_tasks, provisioner_task_rx) = provisioner_task_channel(); + let provisioner_task_service = + ProvisionerTaskService::new(Arc::clone(&task_store), job_queue_ctx.queue(), provisioner_task_rx); + + let in_memory_job_dependencies = InMemoryJobDependencies { + conf_handle: conf_handle.clone(), + recordings: recording_manager_handle.clone(), + secrets: secrets.clone(), + task_store, + }; + let state = DgwState { conf_handle: conf_handle.clone(), token_cache: Arc::clone(&token_cache), @@ -354,6 +378,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, + provisioner_tasks, + secrets: secrets.clone(), }; for listener in &conf.listeners { @@ -387,6 +413,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(devolutions_gateway::provisioning::CleanupTask { handle: provisioning }); + tasks.register(devolutions_gateway::secret_store::CleanupTask { handle: secrets }); + + tasks.register(provisioner_task_service); + tasks.register(devolutions_gateway::credential_injection::CleanupTask { handle: synthetic_kdc_registry, }); @@ -418,8 +448,8 @@ 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::JobQueueTask::new(job_queue_ctx)); + tasks.register(JobRunnerTask::new(&job_queue_ctx, in_memory_job_dependencies)); + tasks.register(JobQueueTask::new(job_queue_ctx)); tasks.register(traffic_audit_task); diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index c922869a58..d645c2e095 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -513,6 +513,10 @@ pub enum AccessScope { AgentRead, #[serde(rename = "gateway.recordings.search")] RecordingsSearch, + #[serde(rename = "gateway.tasks.recording-ai-analysis.start")] + RecordingAiAnalysisStart, + #[serde(rename = "gateway.tasks.recording-ai-analysis.read")] + RecordingAiAnalysisRead, } #[derive(Clone, Serialize, Deserialize)] diff --git a/devolutions-gateway/tests/preflight.rs b/devolutions-gateway/tests/preflight.rs index 1cbcf84808..71a312c624 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 7175d3bb59..3c9e4a63cb 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 8e090e9d10..25755d7e0f 100644 --- a/devolutions-gateway/tests/webapp.rs +++ b/devolutions-gateway/tests/webapp.rs @@ -60,7 +60,7 @@ fn initialize_conf() { async fn custom_authentication_flow() -> anyhow::Result<()> { let (cov, _guard) = init_cov_mark(); initialize_conf(); - let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG)?; + let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG).await?; let mut app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -191,7 +191,7 @@ async fn static_assets_are_served(#[case] subdir: &str, #[case] uri_prefix: &str config["WebApp"]["StaticRootPath"] = json!(static_root); let config = config.to_string(); - let (state, _handle) = devolutions_gateway::DgwState::mock(&config)?; + let (state, _handle) = devolutions_gateway::DgwState::mock(&config).await?; let mut app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -229,7 +229,7 @@ async fn static_assets_are_served(#[case] subdir: &str, #[case] uri_prefix: &str async fn sign_app_token_bad_password() -> anyhow::Result<()> { let (cov, _guard) = init_cov_mark(); initialize_conf(); - let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG)?; + let (state, _handle) = devolutions_gateway::DgwState::mock(CONFIG).await?; let app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); @@ -267,7 +267,7 @@ async fn sign_app_token_bad_password() -> anyhow::Result<()> { async fn sign_app_token_username_mismatch() -> anyhow::Result<()> { let (cov, _guard) = init_cov_mark(); initialize_conf(); - let (state, _handles) = devolutions_gateway::DgwState::mock(CONFIG)?; + let (state, _handles) = devolutions_gateway::DgwState::mock(CONFIG).await?; let app = devolutions_gateway::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index cbd210e7be..1f47e506db 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -64,6 +64,7 @@ sysevent.path = "../crates/sysevent" sysevent-codes.path = "../crates/sysevent-codes" tempfile = "3" test-utils.path = "../crates/test-utils" +time = { version = "0.3", default-features = false, features = ["std", "parsing"] } tokio-rustls = { version = "0.26", features = ["ring"] } uuid = { version = "1", features = ["v4"] } diff --git a/testsuite/tests/cli/dgw/ai_analysis.rs b/testsuite/tests/cli/dgw/ai_analysis.rs new file mode 100644 index 0000000000..ad5b676898 --- /dev/null +++ b/testsuite/tests/cli/dgw/ai_analysis.rs @@ -0,0 +1,561 @@ +//! `/jet/tasks/recording-ai-analysis/{taskId}` tests, against a running Gateway and a mock AI provider. + +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use testsuite::cli::start_dgw; +use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, ProxyMode, VerbosityProfile}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::TcpListener; +use uuid::Uuid; + +const API_KEY: &str = "sk-ai-analysis-endpoint-test-secret"; +const START_SCOPE: &str = "gateway.tasks.recording-ai-analysis.start"; +const READ_SCOPE: &str = "gateway.tasks.recording-ai-analysis.read"; + +/// An unsigned scope token; the Gateway under test runs with token validation disabled. +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}") +} + +struct Gateway { + config: DgwConfigHandle, + process: tokio::process::Child, + client: reqwest::Client, +} + +impl Drop for Gateway { + // The config folder is deleted right after, and Windows can't delete the files Gateway still holds open. + fn drop(&mut self) { + let _ = self.process.start_kill(); + let deadline = Instant::now() + Duration::from_secs(10); + while matches!(self.process.try_wait(), Ok(None)) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +impl Gateway { + async fn start() -> Self { + let config = DgwConfig::builder() + .disable_token_validation(true) + .verbosity_profile(VerbosityProfile::ALL) + .proxy_mode(ProxyMode::Off) + .build() + .init() + .unwrap(); + let process = start_dgw(&config).await.unwrap(); + + Self { + config, + process, + client: reqwest::Client::new(), + } + } + + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.config.http_port()) + } + + /// Writes a finished session with one terminal recording in the default recording folder. + fn write_session(&self) -> Uuid { + let session_id = Uuid::new_v4(); + let dir = self.config.config_dir().join("recordings").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 put_raw(&self, task_id: Uuid, scope: &str, body: String) -> reqwest::Response { + self.client + .put(self.url(&format!("/jet/tasks/recording-ai-analysis/{task_id}"))) + .header("content-type", "application/json") + .bearer_auth(scope_token(scope)) + .body(body) + .send() + .await + .unwrap() + } + + async fn put(&self, task_id: Uuid, body: &Value) -> (StatusCode, Value) { + read(self.put_raw(task_id, START_SCOPE, body.to_string()).await).await + } + + async fn get_analysis(&self, task_id: Uuid, scope: &str) -> (StatusCode, Value) { + self.get(&format!("/jet/tasks/recording-ai-analysis/{task_id}"), scope) + .await + } + + async fn get(&self, path: &str, scope: &str) -> (StatusCode, Value) { + let response = self + .client + .get(self.url(path)) + .bearer_auth(scope_token(scope)) + .send() + .await + .unwrap(); + read(response).await + } + + /// Asks for the analysis until it has succeeded or failed. + async fn finished_analysis(&self, task_id: Uuid) -> Value { + let deadline = Instant::now() + Duration::from_secs(60); + + loop { + let (status, task) = self.get_analysis(task_id, READ_SCOPE).await; + assert_eq!(status, StatusCode::OK, "{task}"); + + if task["state"] == "succeeded" || task["state"] == "failed" { + return task; + } + + assert!(Instant::now() < deadline, "the analysis did not finish: {task}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + /// Reads the log files until they contain `text`, as the Gateway writes them in the background. + async fn logs_containing(&self, text: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + + loop { + let logs = self + .files(|name| name.starts_with("gateway") && name.ends_with(".log")) + .map(|path| std::fs::read_to_string(path).unwrap()) + .collect::(); + + if logs.contains(text) { + return logs; + } + + assert!(Instant::now() < deadline, "no {text:?} in the logs"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + /// `gateway.db` and its write-ahead log, where the Tasks are recorded. + fn database_bytes(&self) -> Vec { + self.files(|name| name == "gateway.db" || name == "gateway.db-wal") + .flat_map(|path| std::fs::read(path).unwrap()) + .collect() + } + + fn files(&self, keep: impl Fn(&str) -> bool) -> impl Iterator { + std::fs::read_dir(self.config.config_dir()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(move |path| path.file_name().and_then(|name| name.to_str()).is_some_and(&keep)) + } +} + +async fn read(response: reqwest::Response) -> (StatusCode, Value) { + let status = response.status(); + let body = response.bytes().await.unwrap(); + let body = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + (status, body) +} + +fn request(session_id: Uuid, model: &str, base_url: &str) -> Value { + json!({ + "sessionId": session_id, + "provider": "openai-compatible", + "model": model, + "baseUrl": base_url, + "apiKey": API_KEY, + }) +} + +/// A mock AI provider that never answers, so an analysis started against it stays in progress. +async fn spawn_silent_ai_provider() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let _stream = stream; + std::future::pending::<()>().await; + }); + } + }); + + format!("http://{addr}/v1/") +} + +/// 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 listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + + 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 answer = if status == 200 { + json!({ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gpt-test-2026-09-30", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "{\"line\":\"L1\",\"description\":\"Listed files\"}" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } + }) + } else { + json!({ "error": { "message": "scripted failure" } }) + } + .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(); + }); + } + }); + + 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}")); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn start_returns_202_then_200_for_the_same_request() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_silent_ai_provider().await; + + let (status, created) = gateway.put(task_id, &request(session_id, "gpt-test", &base_url)).await; + assert_eq!(status, StatusCode::ACCEPTED, "{created}"); + assert_eq!(created["id"], task_id.to_string()); + // oracle: mutant the answer leaves out the session the analysis is about + assert_eq!(created["sessionId"], session_id.to_string()); + assert_eq!( + created["params"], + json!({ "provider": "openai-compatible", "model": "gpt-test", "maxOutputTokens": null }) + ); + assert_eq!(created["state"], "queued"); + assert_eq!(created["payload"], Value::Null); + assert_rfc3339(&created["createdAt"]); + assert_rfc3339(&created["deadlineAt"]); + assert_eq!(created["startedAt"], Value::Null); + assert_eq!(created["finishedAt"], Value::Null); + for internal in ["kind", "target", "attempts"] { + // oracle: mutant the answer shows `kind`, `target` and `attempts`, which only matter inside Gateway + assert!(created.get(internal).is_none(), "{internal} is not returned"); + } + + let (status, again) = gateway.put(task_id, &request(session_id, "gpt-test", &base_url)).await; + assert_eq!(status, StatusCode::OK, "{again}"); + // The job may have started meanwhile, so only the fields a run leaves alone are compared. + for field in ["id", "sessionId", "params", "createdAt"] { + assert_eq!(again[field], created[field], "{field}"); + } +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn start_conflicts_return_409_with_a_code() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_silent_ai_provider().await; + gateway.put(task_id, &request(session_id, "gpt-test", &base_url)).await; + + let (status, body) = gateway.put(task_id, &request(session_id, "gpt-other", &base_url)).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body, json!({ "code": "task_id_conflict" })); + + let (status, body) = gateway + .put(task_id, &request(gateway.write_session(), "gpt-test", &base_url)) + .await; + assert_eq!(status, StatusCode::CONFLICT, "the same Task ID for another session"); + assert_eq!(body, json!({ "code": "task_id_conflict" })); + + let (status, body) = gateway + .put(Uuid::new_v4(), &request(session_id, "gpt-test", &base_url)) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body, json!({ "code": "analysis_in_progress", "taskId": task_id })); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn start_without_recording_returns_404() { + let gateway = Gateway::start().await; + let base_url = spawn_silent_ai_provider().await; + + let (status, _) = gateway + .put(Uuid::new_v4(), &request(Uuid::new_v4(), "gpt-test", &base_url)) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn start_ignores_unknown_fields() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let base_url = spawn_silent_ai_provider().await; + let mut body = request(session_id, "gpt-test", &base_url); + body["temperature"] = json!(0.5); + + let (status, created) = gateway.put(Uuid::new_v4(), &body).await; + + assert_eq!(status, StatusCode::ACCEPTED, "{created}"); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn invalid_bodies_and_settings_return_400() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_silent_ai_provider().await; + let valid = request(session_id, "gpt-test", &base_url); + + let mut no_session = valid.clone(); + no_session.as_object_mut().unwrap().remove("sessionId"); + + let mut no_key = valid.clone(); + no_key.as_object_mut().unwrap().remove("apiKey"); + + let mut empty_key = valid.clone(); + empty_key["apiKey"] = json!(""); + + let mut no_base_url = valid.clone(); + no_base_url.as_object_mut().unwrap().remove("baseUrl"); + + let mut bad_base_url = valid.clone(); + bad_base_url["baseUrl"] = json!("ftp://ai.example/v1/"); + + let mut unknown_provider = valid.clone(); + unknown_provider["provider"] = json!("skynet"); + + let mut long_language = valid.clone(); + long_language["language"] = json!("zh-Hant-TW-x"); + + let mut empty_language = valid.clone(); + empty_language["language"] = json!(""); + + for (case, body) in [ + ("missing session ID", no_session.to_string()), + ("missing API key", no_key.to_string()), + ("empty API key", empty_key.to_string()), + ("empty model", request(session_id, " ", &base_url).to_string()), + ("missing base URL", no_base_url.to_string()), + ("unsupported base URL", bad_base_url.to_string()), + ("unknown provider", unknown_provider.to_string()), + ("language longer than 10 characters", long_language.to_string()), + ("empty language", empty_language.to_string()), + ("not JSON", "{".to_owned()), + ] { + let response = gateway.put_raw(task_id, START_SCOPE, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case}"); + } + + let (status, _) = gateway.get_analysis(task_id, READ_SCOPE).await; + assert_eq!(status, StatusCode::NOT_FOUND, "no Task is recorded"); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn endpoints_require_their_scope() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let base_url = spawn_silent_ai_provider().await; + let body = request(session_id, "gpt-test", &base_url).to_string(); + + for scope in [READ_SCOPE, "gateway.recording.delete", "gateway.recordings.read"] { + let response = gateway.put_raw(Uuid::new_v4(), scope, body.clone()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{scope}"); + } + + for scope in [START_SCOPE, "gateway.sessions.read", "gateway.recordings.read"] { + let (status, _) = gateway.get_analysis(Uuid::new_v4(), scope).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{scope}"); + } + + let response = gateway.put_raw(Uuid::new_v4(), "*", body).await; + assert_eq!( + response.status(), + StatusCode::ACCEPTED, + "the wildcard scope is accepted" + ); + + let (status, _) = gateway.get_analysis(Uuid::new_v4(), "*").await; + assert_eq!(status, StatusCode::NOT_FOUND, "the wildcard scope is accepted"); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn an_analysis_is_found_at_its_location() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_silent_ai_provider().await; + + let response = gateway + .put_raw( + task_id, + START_SCOPE, + request(session_id, "gpt-test", &base_url).to_string(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let location = response.headers()["location"].to_str().unwrap().to_owned(); + + let (status, task) = gateway.get(&location, READ_SCOPE).await; + assert_eq!(status, StatusCode::OK, "{task}"); + assert_eq!(task["id"], task_id.to_string()); +} + +// oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis +#[tokio::test] +async fn get_analysis_returns_404_for_an_unknown_id() { + let gateway = Gateway::start().await; + + let (status, _) = gateway.get_analysis(Uuid::new_v4(), READ_SCOPE).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §Secrets +#[tokio::test] +async fn analysis_succeeds_without_leaking_the_key_or_the_base_url() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_ai_provider(200).await; + let mut body = request(session_id, "gpt-test", &base_url); + body["maxOutputTokens"] = json!(1000); + + let (status, created) = gateway.put(task_id, &body).await; + assert_eq!(status, StatusCode::ACCEPTED, "{created}"); + + let task = gateway.finished_analysis(task_id).await; + assert_eq!(task["state"], "succeeded", "{task}"); + assert_eq!(task["payload"]["fileName"], "ai-analysis-0.slog"); + assert_eq!(task["payload"]["model"], "gpt-test-2026-09-30"); + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + assert_eq!( + task["payload"]["usage"], + json!({ "inputTokens": 20, "outputTokens": 40, "unreportedRequests": 0 }), + "one request for the actions, one for the summary" + ); + assert_rfc3339(&task["startedAt"]); + assert_rfc3339(&task["finishedAt"]); + + let (status, again) = gateway.put(task_id, &body).await; + assert_eq!(status, StatusCode::OK, "{again}"); + + let responses = [created, task, again].map(|body| body.to_string()).join("\n"); + let logs = gateway.logs_containing("Task succeeded").await; + let database = String::from_utf8_lossy(&gateway.database_bytes()).into_owned(); + assert!(!database.is_empty(), "gateway.db is read"); + + 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"); + } +} + +// oracle: intent devolutions-gateway/src/provisioner_tasks/INTENT.md §AI analysis +#[tokio::test] +async fn provider_refusing_the_key_fails_the_task() { + let gateway = Gateway::start().await; + let session_id = gateway.write_session(); + let task_id = Uuid::new_v4(); + let base_url = spawn_ai_provider(401).await; + + let (status, _) = gateway.put(task_id, &request(session_id, "gpt-test", &base_url)).await; + assert_eq!(status, StatusCode::ACCEPTED); + + let task = gateway.finished_analysis(task_id).await; + assert_eq!(task["state"], "failed", "{task}"); + assert_eq!( + task["payload"], + json!({ + "error": { "kind": "aiRejected", "status": 401 }, + "usage": { "inputTokens": 0, "outputTokens": 0, "unreportedRequests": 0 }, + }) + ); + + let (status, _) = gateway + .put(Uuid::new_v4(), &request(session_id, "gpt-test", &base_url)) + .await; + assert_eq!( + status, + StatusCode::ACCEPTED, + "a failed analysis no longer blocks the session" + ); +} diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index 23da8ba6c8..85e2b02bcd 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -1,3 +1,4 @@ +mod ai_analysis; mod benign_disconnect; mod cli_args; mod heartbeat; diff --git a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs index da3df06c57..fdc487f77e 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs @@ -230,6 +230,28 @@ public void ScopeClaimsRecordingsSearch() Assert.Equal(EXPECTED, result); } + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + [Fact] + public void ScopeClaimsRecordingAiAnalysisStart() + { + const string EXPECTED = """{"scope":"gateway.tasks.recording-ai-analysis.start","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = new ScopeClaims(gatewayId, AccessScope.GatewayTasksRecordingAiAnalysisStart); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + } + + // oracle: intent devolutions-gateway/src/api/tasks/recording_ai_analysis.intent.md §AI analysis + [Fact] + public void ScopeClaimsRecordingAiAnalysisRead() + { + const string EXPECTED = """{"scope":"gateway.tasks.recording-ai-analysis.read","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = new ScopeClaims(gatewayId, AccessScope.GatewayTasksRecordingAiAnalysisRead); + 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 880b866253..dc1cb551ed 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs @@ -31,6 +31,8 @@ internal AccessScope(string value) public static AccessScope GatewayAgentDelete = new AccessScope("gateway.agent.delete"); public static AccessScope GatewayAgentRead = new AccessScope("gateway.agent.read"); public static AccessScope GatewayRecordingsSearch = new AccessScope("gateway.recordings.search"); + public static AccessScope GatewayTasksRecordingAiAnalysisStart = new AccessScope("gateway.tasks.recording-ai-analysis.start"); + public static AccessScope GatewayTasksRecordingAiAnalysisRead = new AccessScope("gateway.tasks.recording-ai-analysis.read"); public override string? ToString() {