Skip to content

Commit f398d0c

Browse files
feat(dgw): run AI analysis as a provisioner task
Gateway can now describe what the user did in a recorded session with an AI provider, and add the result to the session as a new log. The analysis itself is the AI crate's `analyze_recording`; Gateway gives it the session manifest once the recording is finished, then adds the log it returns to the session through the recording manager. Provisioner tasks run through a ProvisionerTaskRunner, the one place that keeps a task's record in gateway.db and the job running it on the job queue in step. Each kind of work is a ProvisionerTask: what one attempt does, and what to drop once its record ends. The runner records the Task and queues its job, starts each attempt, cancels and awaits an attempt past its timeout, retries transient failures until the job queue's attempts are used up, and records how the Task ends. The Task is the source of truth and its job only wakes it up: each Task names the one job allowed to run it, so a stale or duplicate job stops without running anything. At startup, before the job runner claims anything, every unfinished Task is handed to a new job, which also covers a Task recorded just before a crash, before its job was queued. AI analysis (`recording-ai-analysis`) is the first kind. A repeated request with the same Task ID gets its Task back, even while the session records again. A new request is refused while the session is recording, or when it has no recording. The API key and the base URL stay in memory only, encrypted, as the Task's secret in the provisioning store, until the Task ends or reaches its deadline. After a restart the attempt finds no key and fails its Task. Neither the key nor the URL appears in errors, logs or stored results: the AI client (#2043) leaves the URL out of its transport errors. Analyses of one session run one at a time, so two attempts never share the working files the AI crate keeps next to the manifest. Those files are removed when the Task ends. Starting an AI analysis is an internal function for now; the HTTP endpoint comes next. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent d0bab56 commit f398d0c

12 files changed

Lines changed: 2581 additions & 15 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎devolutions-gateway/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ transport.path = "../crates/transport"
2424
jmux-proxy.path = "../crates/jmux-proxy"
2525
devolutions-agent-shared.path = "../crates/devolutions-agent-shared"
2626
devolutions-gateway-task.path = "../crates/devolutions-gateway-task"
27+
devolutions-gateway-ai.path = "../crates/devolutions-gateway-ai"
2728
devolutions-log.path = "../crates/devolutions-log"
2829
job-queue.path = "../crates/job-queue"
2930
job-queue-libsql.path = "../crates/job-queue-libsql"
@@ -93,6 +94,7 @@ tracing = "0.1"
9394

9495
# Async, futures…
9596
tokio = { version = "1.52", features = ["signal", "net", "io-util", "time", "rt", "rt-multi-thread", "sync", "macros", "parking_lot", "fs"] }
97+
tokio-util = "0.7"
9698
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "ring"] }
9799
socket2 = "0.5"
98100
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "stream", "http2", "socks"] }

‎devolutions-gateway/src/job_queue.rs‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ use std::time::Duration;
66
use anyhow::Context as _;
77
use async_trait::async_trait;
88
use devolutions_gateway_task::{ChildTask, ShutdownSignal, Task};
9-
use job_queue::{DynJobQueue, Job, JobCtx, JobQueue, JobReader, JobRunner, RunnerWaker};
9+
use job_queue::{DynJob, DynJobQueue, Job, JobCtx, JobQueue, JobReader, JobRunner, RunnerWaker};
1010
use job_queue_libsql::libsql;
1111
use time::OffsetDateTime;
1212
use tokio::sync::{Notify, mpsc};
1313

14+
use crate::provisioner_tasks::runner::{ProvisionerTaskJob, ProvisionerTaskRunner};
15+
1416
/// Runs of a job before the job queue gives up on it.
1517
pub const MAX_ATTEMPTS: u32 = 5;
1618

@@ -41,6 +43,7 @@ pub struct JobRunnerTask {
4143
notify_runner: Arc<Notify>,
4244
runner_waker: RunnerWaker,
4345
queue: DynJobQueue,
46+
tasks: ProvisionerTaskRunner,
4447
}
4548

4649
impl JobQueueCtx {
@@ -86,6 +89,14 @@ impl JobQueueCtx {
8689
job_queue_handle: handle,
8790
})
8891
}
92+
93+
/// Writes a job to the queue right away, without going through [`JobQueueHandle`].
94+
///
95+
/// For startup, before the job runner claims anything.
96+
pub async fn push<T: Job + 'static>(&self, job: T) -> anyhow::Result<()> {
97+
let job: DynJob = Box::new(job);
98+
self.queue.push_job(&job, None).await
99+
}
89100
}
90101

91102
impl JobQueueHandle {
@@ -201,11 +212,12 @@ async fn job_queue_task(ctx: JobQueueTask, mut shutdown_signal: ShutdownSignal)
201212
}
202213

203214
impl JobRunnerTask {
204-
pub fn new(ctx: &JobQueueCtx) -> Self {
215+
pub fn new(ctx: &JobQueueCtx, tasks: ProvisionerTaskRunner) -> Self {
205216
Self {
206217
notify_runner: Arc::clone(&ctx.notify_runner),
207218
runner_waker: RunnerWaker::clone(&ctx.runner_waker),
208219
queue: Arc::clone(&ctx.queue),
220+
tasks,
209221
}
210222
}
211223
}
@@ -229,9 +241,10 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal
229241
notify_runner,
230242
runner_waker,
231243
queue,
244+
tasks,
232245
} = ctx;
233246

234-
let reader = DgwJobReader;
247+
let reader = DgwJobReader { tasks };
235248

236249
let spawn = |mut ctx: JobCtx, callback: job_queue::SpawnCallback| {
237250
tokio::spawn(async move {
@@ -283,10 +296,12 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal
283296
Ok(())
284297
}
285298

286-
struct DgwJobReader;
299+
struct DgwJobReader {
300+
tasks: ProvisionerTaskRunner,
301+
}
287302

288303
impl JobReader for DgwJobReader {
289-
fn read_json(&self, name: &str, json: &str) -> anyhow::Result<job_queue::DynJob> {
304+
fn read_json(&self, name: &str, json: &str) -> anyhow::Result<DynJob> {
290305
use crate::api::jrec::DeleteRecordingsJob;
291306
use crate::recording::RemuxJob;
292307

@@ -300,6 +315,10 @@ impl JobReader for DgwJobReader {
300315
serde_json::from_str(json).context("failed to deserialize DeleteRecordingsJob")?;
301316
Ok(Box::new(job))
302317
}
318+
ProvisionerTaskJob::NAME => {
319+
let job = ProvisionerTaskJob::read_json(json, self.tasks.clone())?;
320+
Ok(Box::new(job))
321+
}
303322
_ => anyhow::bail!("unknown job name: {name}"),
304323
}
305324
}

‎devolutions-gateway/src/lib.rs‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub mod log;
3030
pub mod middleware;
3131
pub mod ngrok;
3232
pub mod plugin_manager;
33+
pub mod provisioner_tasks;
3334
pub mod provisioning;
3435
pub mod proxy;
3536
pub mod rd_clean_path;
@@ -69,7 +70,7 @@ pub struct DgwState {
6970
pub monitoring_state: Arc<network_monitor::State>,
7071
pub traffic_audit_handle: traffic_audit::TrafficAuditHandle,
7172
pub agent_tunnel_handle: Option<Arc<agent_tunnel::AgentTunnelHandle>>,
72-
pub provisioner_tasks: provisioner_task::DynProvisionerTaskStore,
73+
pub provisioner_tasks: provisioner_tasks::runner::ProvisionerTaskRunner,
7374
}
7475

7576
#[doc(hidden)]
@@ -97,9 +98,15 @@ impl DgwState {
9798
let provisioning = provisioning::ProvisioningStore::new();
9899
let synthetic_kdc_registry = credential_injection::SyntheticKdcRegistry::new();
99100
let monitoring_state = Arc::new(network_monitor::State::new(Arc::new(MockMonitorsCache))?);
100-
let provisioner_tasks = gateway_db::GatewayDb::open_path(":memory:").await?.connect().await?;
101-
let provisioner_tasks =
102-
Arc::new(provisioner_task_libsql::LibSqlProvisionerTaskStore::open(provisioner_tasks).await?);
101+
let task_store = gateway_db::GatewayDb::open_path(":memory:").await?.connect().await?;
102+
let task_store = Arc::new(provisioner_task_libsql::LibSqlProvisionerTaskStore::open(task_store).await?);
103+
let provisioner_tasks = provisioner_tasks::task_runner(
104+
task_store,
105+
job_queue_handle.clone(),
106+
conf_handle.clone(),
107+
recording_manager_handle.clone(),
108+
provisioning.clone(),
109+
);
103110

104111
let state = Self {
105112
conf_handle,

0 commit comments

Comments
 (0)