Skip to content

Commit 1f063bb

Browse files
feat(dgw): add endpoints to start an AI analysis and read a task
The provisioner can now start an AI analysis of a recorded session with `POST /jet/jrec/{session_id}/ai-analysis` (scope `gateway.recording.analyze`), and follow any Task with `GET /jet/tasks/{id}` (scope `gateway.tasks.read`). The request body holds the Task ID chosen by the caller, the AI provider and model, an optional base URL and output token limit, and the API key. A new Task answers 202, the same request again answers 200 with the same Task. Gateway answers 409 with a code when the Task ID is used for other parameters, when the session still records, or when another AI analysis of the session is not finished; the last one names the Task ID. A session without recording answers 404, invalid settings or an unknown field answer 400. The API key and the base URL stay in memory only: they are never logged, returned or written to gateway.db. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 24f09d7 commit 1f063bb

9 files changed

Lines changed: 934 additions & 3 deletions

File tree

‎devolutions-gateway/src/api/jrec.rs‎

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,31 @@ use axum::body::Body;
1111
use axum::extract::ws::{CloseFrame, WebSocket};
1212
use axum::extract::{self, ConnectInfo, Query, State, WebSocketUpgrade};
1313
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue};
14-
use axum::response::Response;
15-
use axum::routing::{delete, get};
14+
use axum::response::{IntoResponse as _, Response};
15+
use axum::routing::{delete, get, post};
1616
use axum::{Json, Router};
1717
use bytes::Bytes;
1818
use cadeau::xmf;
1919
use camino::{Utf8Path, Utf8PathBuf};
2020
use devolutions_gateway_task::ShutdownSignal;
2121
use futures::stream;
2222
use hyper::StatusCode;
23+
use secrecy::SecretString;
2324
use tokio::io::AsyncReadExt as _;
2425
use tracing::Instrument as _;
26+
use url::Url;
2527
use uuid::Uuid;
2628
use zip::CompressionMethod;
2729
use zip::write::SimpleFileOptions;
2830

2931
use crate::DgwState;
3032
use crate::api::heartbeat::recording_storage_health;
33+
use crate::api::tasks::TaskInfo;
3134
use crate::artifacts::JrecArtifacts;
32-
use crate::extract::{JrecToken, RecordingDeleteScope, RecordingsReadScope};
35+
use crate::extract::{JrecToken, RecordingAnalyzeScope, RecordingDeleteScope, RecordingsReadScope};
3336
use crate::http::{HttpError, HttpErrorBuilder};
37+
use crate::provisioner_tasks::ai::{AiProvider, AiSettings};
38+
use crate::provisioner_tasks::recording_ai_analysis::{self, StartError, StartOutcome, StartRequest};
3439
use crate::recording::{PushOutcome, RecordingMessageSender};
3540
use crate::token::{JrecTokenClaims, RecordingFileType, RecordingOperation};
3641

@@ -59,6 +64,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
5964
.route("/play", get(get_player))
6065
.route("/play/{*path}", get(get_player))
6166
.route("/shadow/{id}", get(shadow_recording))
67+
.route("/{session_id}/ai-analysis", post(start_ai_analysis))
6268
.with_state(state)
6369
}
6470

@@ -426,6 +432,147 @@ async fn delete_recording(recording_path: &Utf8Path) -> anyhow::Result<()> {
426432
Ok(())
427433
}
428434

435+
/// Settings of an AI analysis
436+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
437+
#[derive(Deserialize)]
438+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
439+
pub(crate) struct AiAnalysisRequest {
440+
/// Task ID, chosen by the caller so it can send the same request again safely.
441+
task_id: Uuid,
442+
provider: AiProvider,
443+
/// Model identifier, passed to the provider as is.
444+
model: String,
445+
/// Endpoint of the provider API, kept in memory only; required for `openai-compatible`.
446+
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
447+
#[serde(default)]
448+
base_url: Option<Url>,
449+
/// Upper bound of tokens in each AI answer.
450+
#[serde(default)]
451+
max_output_tokens: Option<u32>,
452+
/// API key of the provider, kept in memory only until the Task ends.
453+
#[cfg_attr(feature = "openapi", schema(value_type = String, format = Password))]
454+
api_key: SecretString,
455+
}
456+
457+
/// Why an AI analysis cannot start now
458+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
459+
#[derive(Debug, Serialize)]
460+
#[serde(rename_all = "camelCase")]
461+
pub(crate) struct AiAnalysisConflict {
462+
code: AiAnalysisConflictCode,
463+
/// The AI analysis of this session that is not finished yet; set for `analysis_in_progress`.
464+
#[serde(skip_serializing_if = "Option::is_none")]
465+
task_id: Option<Uuid>,
466+
}
467+
468+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
469+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
470+
#[serde(rename_all = "snake_case")]
471+
pub(crate) enum AiAnalysisConflictCode {
472+
/// The Task ID is already used by a Task with other parameters.
473+
TaskIdConflict,
474+
/// The session is still recording.
475+
RecordingActive,
476+
/// Another AI analysis of this session is not finished yet.
477+
AnalysisInProgress,
478+
}
479+
480+
fn ai_analysis_conflict(code: AiAnalysisConflictCode, task_id: Option<Uuid>) -> Response {
481+
debug!(?code, task.id = ?task_id, "AI analysis refused");
482+
(StatusCode::CONFLICT, Json(AiAnalysisConflict { code, task_id })).into_response()
483+
}
484+
485+
/// Starts an AI analysis of a recorded session
486+
///
487+
/// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a
488+
/// new log, listed under `artifacts.ai-analysis` in its manifest.
489+
/// The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`.
490+
///
491+
/// Sending the same request again returns the same Task and starts nothing new.
492+
/// The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished
493+
/// Tasks.
494+
#[cfg_attr(feature = "openapi", utoipa::path(
495+
post,
496+
operation_id = "StartAiAnalysis",
497+
tag = "Jrec",
498+
path = "/jet/jrec/{session_id}/ai-analysis",
499+
params(
500+
("session_id" = Uuid, Path, description = "Recorded session ID"),
501+
),
502+
request_body(content = AiAnalysisRequest, description = "AI analysis settings", content_type = "application/json"),
503+
responses(
504+
(status = 200, description = "The same request came in before; nothing new starts", body = TaskInfo),
505+
(status = 202, description = "The AI analysis Task is created and runs in the background", body = TaskInfo),
506+
(status = 400, description = "Invalid request body or AI settings"),
507+
(status = 401, description = "Invalid or missing authorization token"),
508+
(status = 403, description = "Insufficient permissions"),
509+
(status = 404, description = "The session has no recording"),
510+
(status = 409, description = "The AI analysis cannot start now", body = AiAnalysisConflict),
511+
(status = 500, description = "Unexpected server error"),
512+
),
513+
security(("scope_token" = ["gateway.recording.analyze"])),
514+
))]
515+
pub(crate) async fn start_ai_analysis(
516+
State(state): State<DgwState>,
517+
_scope: RecordingAnalyzeScope,
518+
extract::Path(session_id): extract::Path<Uuid>,
519+
body: Bytes,
520+
) -> Result<Response, HttpError> {
521+
let request: AiAnalysisRequest = serde_json::from_slice(&body).map_err(|error| {
522+
// The serde message may quote a value of the body, such as a misplaced API key, so only its place is logged.
523+
debug!(
524+
category = ?error.classify(),
525+
line = error.line(),
526+
column = error.column(),
527+
"Invalid AI analysis request body"
528+
);
529+
HttpError::bad_request().msg("invalid AI analysis request body")
530+
})?;
531+
532+
let AiAnalysisRequest {
533+
task_id,
534+
provider,
535+
model,
536+
base_url,
537+
max_output_tokens,
538+
api_key,
539+
} = request;
540+
541+
let outcome = recording_ai_analysis::start(
542+
&state,
543+
StartRequest {
544+
task_id,
545+
session_id,
546+
settings: AiSettings {
547+
provider,
548+
model,
549+
base_url,
550+
max_output_tokens,
551+
},
552+
api_key,
553+
},
554+
)
555+
.await;
556+
557+
match outcome {
558+
Ok(StartOutcome::Created(task)) => Ok((StatusCode::ACCEPTED, Json(TaskInfo::from(task))).into_response()),
559+
Ok(StartOutcome::Existing(task)) => Ok((StatusCode::OK, Json(TaskInfo::from(task))).into_response()),
560+
Ok(StartOutcome::IdConflict(_)) => Ok(ai_analysis_conflict(AiAnalysisConflictCode::TaskIdConflict, None)),
561+
Ok(StartOutcome::TargetBusy { active_task_id }) => Ok(ai_analysis_conflict(
562+
AiAnalysisConflictCode::AnalysisInProgress,
563+
Some(active_task_id),
564+
)),
565+
Err(StartError::RecordingActive) => Ok(ai_analysis_conflict(AiAnalysisConflictCode::RecordingActive, None)),
566+
Err(StartError::InvalidSettings(error)) => {
567+
Err(HttpError::bad_request().with_msg("invalid AI settings").build(error))
568+
}
569+
Err(StartError::RecordingNotFound) => Err(HttpError::not_found().msg("session has no recording")),
570+
Err(StartError::Internal(error)) => Err(HttpError::internal()
571+
.with_msg("failed to start the AI analysis")
572+
.build(error)),
573+
}
574+
}
575+
429576
/// Lists all recordings stored on this instance
430577
#[cfg_attr(feature = "openapi", utoipa::path(
431578
get,
@@ -1030,6 +1177,52 @@ mod tests {
10301177

10311178
use super::*;
10321179

1180+
#[tokio::test]
1181+
async fn ai_analysis_of_a_session_still_recording_is_a_conflict() {
1182+
use axum::extract::connect_info::MockConnectInfo;
1183+
use base64::Engine as _;
1184+
use tower::ServiceExt as _;
1185+
1186+
let dir = tempfile::tempdir().expect("temp dir");
1187+
let config = serde_json::json!({
1188+
"ProvisionerPublicKeyData": {
1189+
"Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB"
1190+
},
1191+
"Listeners": [{ "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" }],
1192+
"RecordingPath": dir.path().to_str().expect("UTF-8"),
1193+
"__debug__": { "disable_token_validation": true },
1194+
});
1195+
let (state, _handles) = DgwState::mock(&config.to_string()).await.expect("mock state");
1196+
let session_id = Uuid::new_v4();
1197+
state.recordings.active_recordings.insert(session_id);
1198+
let app = crate::make_http_service(state).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000))));
1199+
1200+
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1201+
let claims = serde_json::json!({ "jti": Uuid::new_v4(), "exp": i64::from(u32::MAX), "scope": "gateway.recording.analyze" });
1202+
let token = format!(
1203+
"{}.{}.{}",
1204+
engine.encode(r#"{"alg":"RS256","cty":"SCOPE"}"#),
1205+
engine.encode(claims.to_string()),
1206+
engine.encode(b"signature"),
1207+
);
1208+
let body = serde_json::json!({ "taskId": Uuid::new_v4(), "provider": "openai", "model": "gpt-test", "apiKey": "sk-test" });
1209+
let request = axum::http::Request::builder()
1210+
.method("POST")
1211+
.uri(format!("/jet/jrec/{session_id}/ai-analysis"))
1212+
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
1213+
.body(Body::from(body.to_string()))
1214+
.expect("request");
1215+
1216+
let response = app.oneshot(request).await.expect("response");
1217+
1218+
assert_eq!(response.status(), StatusCode::CONFLICT);
1219+
let body = response.into_body().collect().await.expect("body").to_bytes();
1220+
assert_eq!(
1221+
serde_json::from_slice::<serde_json::Value>(&body).expect("JSON"),
1222+
serde_json::json!({ "code": "recording_active" })
1223+
);
1224+
}
1225+
10331226
#[test]
10341227
fn rejects_unsafe_recording_file_names() {
10351228
assert!(is_safe_recording_file_name("recording-0.webm"));

‎devolutions-gateway/src/api/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod preflight;
1313
pub mod rdp;
1414
pub mod session;
1515
pub mod sessions;
16+
pub mod tasks;
1617
pub mod traffic;
1718
pub mod tunnel;
1819
pub mod update;
@@ -28,6 +29,7 @@ pub fn make_router<S>(state: crate::DgwState) -> axum::Router<S> {
2829
.nest("/jet/config", config::make_router(state.clone()))
2930
.nest("/jet/session", session::make_router(state.clone()))
3031
.nest("/jet/sessions", sessions::make_router(state.clone()))
32+
.nest("/jet/tasks", tasks::make_router(state.clone()))
3133
.nest("/jet/diagnostics", diagnostics::make_router(state.clone()))
3234
.route("/jet/jmux", axum::routing::get(jmux::handler))
3335
.route("/jet/rdp", axum::routing::get(rdp::handler))
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use axum::extract::{self, State};
2+
use axum::routing::get;
3+
use axum::{Json, Router};
4+
use provisioner_task::{ProvisionerTask, ProvisionerTaskState};
5+
use time::OffsetDateTime;
6+
use uuid::Uuid;
7+
8+
use crate::DgwState;
9+
use crate::extract::TasksReadScope;
10+
use crate::http::HttpError;
11+
12+
pub fn make_router<S>(state: DgwState) -> Router<S> {
13+
Router::new().route("/{id}", get(get_task)).with_state(state)
14+
}
15+
16+
/// Gets a Task, the record of one piece of work the provisioner asked Gateway to do
17+
///
18+
/// Tasks are kept forever, across Gateway restarts.
19+
/// An unfinished Task past its deadline is reported as failed.
20+
#[cfg_attr(feature = "openapi", utoipa::path(
21+
get,
22+
operation_id = "GetTask",
23+
tag = "Tasks",
24+
path = "/jet/tasks/{id}",
25+
params(
26+
("id" = Uuid, Path, description = "Task ID"),
27+
),
28+
responses(
29+
(status = 200, description = "The Task", body = TaskInfo),
30+
(status = 400, description = "Bad request"),
31+
(status = 401, description = "Invalid or missing authorization token"),
32+
(status = 403, description = "Insufficient permissions"),
33+
(status = 404, description = "No Task has this ID"),
34+
(status = 500, description = "Unexpected server error"),
35+
),
36+
security(("scope_token" = ["gateway.tasks.read"])),
37+
))]
38+
pub(crate) async fn get_task(
39+
State(DgwState { provisioner_tasks, .. }): State<DgwState>,
40+
_scope: TasksReadScope,
41+
extract::Path(id): extract::Path<Uuid>,
42+
) -> Result<Json<TaskInfo>, HttpError> {
43+
let task = provisioner_tasks
44+
.get(id, OffsetDateTime::now_utc())
45+
.await
46+
.map_err(HttpError::internal().with_msg("failed to read the task").err())?
47+
.ok_or_else(|| HttpError::not_found().msg("task not found"))?;
48+
49+
Ok(Json(TaskInfo::from(task)))
50+
}
51+
52+
/// A Task: the permanent record of one piece of work the provisioner asked Gateway to do
53+
///
54+
/// The work runs in the background; `state` and `payload` tell how it goes.
55+
/// The shape of `params` and `payload` belongs to the kind.
56+
/// For `recording.ai-analysis`, `payload` is an `AiAnalysisRunningPayload` while `running`, an
57+
/// `AiAnalysisSucceededPayload` once `succeeded`, and an `AiAnalysisFailedPayload` once `failed`.
58+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
59+
#[derive(Debug, Serialize)]
60+
#[serde(rename_all = "camelCase")]
61+
pub(crate) struct TaskInfo {
62+
/// Task ID, chosen by the provisioner.
63+
id: Uuid,
64+
/// Kind of work, such as `recording.ai-analysis`.
65+
kind: String,
66+
/// What the work is about, such as a session ID.
67+
target: String,
68+
/// What was asked, without secrets.
69+
#[cfg_attr(feature = "openapi", schema(value_type = Object))]
70+
params: serde_json::Value,
71+
state: TaskState,
72+
/// Details of the current state; null while `queued`.
73+
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
74+
payload: serde_json::Value,
75+
/// Number of attempts started so far.
76+
attempts: u32,
77+
/// When the Task was created.
78+
#[serde(with = "time::serde::rfc3339")]
79+
created_at: OffsetDateTime,
80+
/// When its first attempt started.
81+
#[serde(with = "time::serde::rfc3339::option")]
82+
started_at: Option<OffsetDateTime>,
83+
/// When it succeeded or failed.
84+
#[serde(with = "time::serde::rfc3339::option")]
85+
finished_at: Option<OffsetDateTime>,
86+
/// When an unfinished Task becomes failed, waiting and retries included.
87+
#[serde(with = "time::serde::rfc3339")]
88+
deadline_at: OffsetDateTime,
89+
}
90+
91+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
93+
#[serde(rename_all = "lowercase")]
94+
pub(crate) enum TaskState {
95+
/// Waiting for its first attempt.
96+
Queued,
97+
/// An attempt runs, or the next one waits for a retry.
98+
Running,
99+
Succeeded,
100+
Failed,
101+
}
102+
103+
impl From<ProvisionerTaskState> for TaskState {
104+
fn from(state: ProvisionerTaskState) -> Self {
105+
match state {
106+
ProvisionerTaskState::Queued => Self::Queued,
107+
ProvisionerTaskState::Running => Self::Running,
108+
ProvisionerTaskState::Succeeded => Self::Succeeded,
109+
ProvisionerTaskState::Failed => Self::Failed,
110+
}
111+
}
112+
}
113+
114+
impl From<ProvisionerTask> for TaskInfo {
115+
fn from(task: ProvisionerTask) -> Self {
116+
Self {
117+
id: task.id,
118+
kind: task.kind,
119+
target: task.target,
120+
params: task.params,
121+
state: TaskState::from(task.state),
122+
payload: task.payload,
123+
attempts: task.attempts,
124+
created_at: task.created_at,
125+
started_at: task.started_at,
126+
finished_at: task.finished_at,
127+
deadline_at: task.deadline_at,
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)