Skip to content

chore(openapi): add AI analysis endpoints and scopes - #2054

Merged
irvingouj@Devolutions (irvingoujAtDevolution) merged 172 commits into
masterfrom
feat/ai-analysis-openapi
Oct 30, 2026
Merged

irvingouj@Devolutions (irvingoujAtDevolution) merged 172 commits into
masterfrom
feat/ai-analysis-openapi

Conversation

@irvingoujAtDevolution

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Adds the AI analysis endpoints of #2044 to the OpenAPI document and the clients, so a provisioner can start and read an analysis Task with generated code.

  • AccessScope in Devolutions.Gateway.Utils and in both clients gains gateway.tasks.recording-ai-analysis.start and gateway.tasks.recording-ai-analysis.read.
  • gateway-api.yaml, doc/index.adoc, the .NET client and the TS Angular client gain PUT and GET /jet/tasks/recording-ai-analysis/{task_id} (in TasksApi / TasksService) and their models: the request (AiAnalysisRequest, AiProvider), the Task (AiAnalysisTask, TaskState, AiAnalysisParams), its running, succeeded and failed payloads (AiAnalysisPayload), why a Task failed (AiAnalysisError, AiAnalysisErrorKind, AiAnalysisBadAnswer, AiAnalysisRecoverableError, AiAnalysisRecoverableErrorKind), the tokens it used (AiAnalysisUsage) and the 409 body (AiAnalysisConflict, AiAnalysisConflictCode).
  • The .NET models print [redacted] for a password field in ToString(), so logging an AiAnalysisRequest doesn't show the API key. The template is a copy of the openapi-generator 7.9.0 modelGeneric.mustache with that one line changed.

The generated files come from tools/generate-openapi and openapi-generator 7.9.0, trimmed to the AI analysis parts: the generator also bumps the spec version and lists more pullRecordingFile content types in the clients and docs, which master never regenerated.

Issue: DGW-462

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Let maintainers know that an action is required on their side

  • Add the label release-required Please cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module) when you request a maintainer to cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module)

  • Add the label release-blocker Follow-up is required before cutting a new release if a follow-up is required before cutting a new release

  • Add the label publish-required Please publish libraries (`Devolutions.Gateway.Utils`, OpenAPI clients, etc) when you request a maintainer to publish libraries (Devolutions.Gateway.Utils, OpenAPI clients, etc.)

  • Add the label publish-blocker Follow-up is required before publishing libraries if a follow-up is required before publishing libraries

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) changed the title feat/ai analysis openapi chore(openapi): add the AI analysis and task endpoints to the clients Oct 8, 2026
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) force-pushed the feat/ai-analysis-openapi branch 2 times, most recently from d01b043 to 581df74 Compare October 8, 2026 19:15
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) force-pushed the feat/ai-analysis-openapi branch 2 times, most recently from 12613a4 to 6f3941d Compare October 8, 2026 20:16
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) force-pushed the feat/ai-analysis-openapi branch 6 times, most recently from 8a01978 to 038abb6 Compare October 8, 2026 22:57
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) marked this pull request as ready for review October 8, 2026 23:35
Copilot AI balanced review requested due to automatic review settings October 8, 2026 23:35
No intent sentence decides what these seven tests expect, but each one alone catches a fault, so their markers now name that fault instead of an intent section that does not decide the value.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The work of the progress test took 2.5 s against a first deadline 2 to 3 s away, and it ignored the cancellation, so the test passed even when progress never moved the deadline.
It now works for 4 s and stops once cancelled, so only a moved deadline lets it succeed.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tokens of a failed Task are decided by the jrec intent, and the deadline an attempt start sets by the 30 minutes without progress of the provisioner tasks intent, not by the sections these tests cited.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The metadata of a Task belongs to the task reading scope, so reading it with only `gateway.recordings.read` must be refused; no test sent that scope.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alysis

Each kind of task gets its own typed route under `/jet/tasks/<kind>/{id}`, a public convention the next kinds can follow.
`PUT /jet/tasks/recording-ai-analysis/{taskId}` starts an analysis, with the session ID in the body, and a GET on the same path reads it.
The answer carries `sessionId` and leaves out `kind`, `target` and `attempts`, which only matter inside Gateway.
Its params and payload are documented as the AI analysis schemas.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An AI analysis must not read a video that its remux is about to replace, and only the recording module should know how a remux is tracked.
The recording manager writes `<file>.remuxing` before it schedules the remux, the remux job removes it, and the AI analysis asks the recording module whether a session has such a mark.
A mark older than an hour no longer counts, so a crash between the mark and its job cannot block the analyses of the session for good.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the routes


The record's `attempts` is the one count of attempts, so the job queue and the runner never disagree on when a Task gives up.
A recoverable failure queues the next attempt with the same token, 30 seconds later and doubling, and the job returns Ok.
The job queue then retries a job only when what it had to record or queue could not be stored.
The extractors of the AI analysis routes check the start and read scopes, since authorization belongs to the API layer.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every unfinished Task gets a new job token and a new job at startup, so a job left from before stops as superseded and the runner never needs to know which jobs are stored.
A kind that cannot outlive a restart says so with `after_restart`: an AI analysis, whose key lived in memory only, fails with `keyLost` at once, and its new job only ends it.
`JobQueue::job_definitions` is removed, since nothing uses it.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
utoipa turns a `value_type` path into the schema name as written, so `crate::openapi::AiAnalysisParams` became the reference `crate.openapi.AiAnalysisParams`, which names no schema.
A bare name refers to the registered schema, and since utoipa never uses the Rust type behind it, the schema types stay private to `openapi`.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es out


Waiting for a remux is not progress, so an AI analysis waiting on a mark that no remux will remove fails once 30 minutes pass without progress.
A mark that stops counting after 10 minutes lets the analysis go on in time, and no remux takes that long.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s it


A record that cannot be failed at startup still gets a new job, so a task that cannot outlive a restart must fail the same way when that job runs; a required `restart_failure` makes each kind decide it.
A success that cannot be recorded fails the Task instead of being retried, since a retry would do the paid work again; a store that fails to record a success now checks it.
The retry delay doubles with `saturating_pow`, so no attempt count can overflow the shift.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restart after the log was written but before the success was recorded fails the Task with `keyLost` and removes the unlisted log.
A restart after the success was recorded but before the manifest listed the log keeps the job of the Task, which lists the log.
The remux tests name the mark through `remux_marker`, so they follow its file name.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mark written when the remux is scheduled also ages while the job waits to run, so a slow queue could make it stale before the remux replaced the file.
The remux job marks the file again as it starts, and a mark stops counting after 20 minutes, which a compile-time check keeps under the 30 minutes an AI analysis may go without progress.
A mark that disappears while the session folder is read means its remux ended, not that the storage failed.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Task whose success and failure both fail to be recorded is left to a job that runs past its deadline, when the store fails it, so the paid work is never redone.
A Task whose failure at startup cannot be recorded still gets a new job, and a Task keeps the attempts it made across a restart.
A Task that was waiting to retry runs at once after a restart, since retry timing is no promise to the caller.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The answer's `sessionId`, the fields it leaves out, retries and the failure at startup are not decided by an intent file, so those assertions carry their own markers.
The location test follows the `Location` header instead of comparing it with a path.
A restart after an attempt that spent tokens keeps them in the failed Task.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remux job marks the file again as it starts, so the 20 minutes cover the remux itself, but until then the mark still ages from the scheduling; the doc now says so instead of promising more.
Both places that write the mark share one helper, as removing it already does.
A Task whose end can be neither recorded nor queued must not make the job fail, or the job queue would redo the paid work; a test now checks it.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpt in three steps


`Permanent` keeps only the failures any Task may have, such as a passed deadline, and holds those of the Task's own kind in an untagged variant, so a new kind of Task brings its failures without adding to the shared ones.
The AI analysis defines its own failures (`aiRejected`, `keyLost`, `noRecording` and the others) next to its work, and the JSON of a failed Task stays the same.
An attempt now runs as a prelude that starts it on the record, a body that runs the work, and a completion that records how it ended, so each step can be read on its own.
`AiAnalysisPayload` only describes the payload in the OpenAPI document, so its never-built variants are expected.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tasks route module has a child module, so it lives in `api/tasks/mod.rs`, as the lint on module files requires.
An attempt that fails for now queues the next one itself and returns Ok, so the test of a new Task after an outage expects Ok, as the other retry tests do.
The jrec routes do not use `IntoResponse`.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thout unwrapping


`Permanent::Kind` and `AttemptError::of_kind` say that the failure belongs to the Task's kind of work; `Task` read as if it built a Task.
`Permanent::try_map` keeps the list of shared failures next to the enum, and a failure that cannot be written as JSON becomes `internal` instead of a panic at startup or in an attempt.
A test keeps the AI analysis failure names apart from the shared ones, since the caller tells failures apart by name only.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or its remux

The analysis needs nothing a remux adds: it reads the tracks, the cluster timestamps and the blocks of a WebM in order, and stops at a cut-short end.
A remux replaces only the video file, by renaming its output over it, and an analysis that has the file open keeps reading the bytes it opened.
So the remux mark, its 20-minute cap and the wait go, and the remux job is back to what master has.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the wait for the remux produced it, so no attempt can fail with it now, and a caller would handle a kind that never comes.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arted attempt

A review against Benoit's Evaluation found two ways recovery got worse.
A crash inside `resume`, between failing a Task with `keyLost` and queuing
its new job, left the Task's working files behind for good. And when a
terminal write failed, the job queue ran the job again, which ran the paid
AI attempt again, even past the 5-attempt limit.

The record now keeps whether its end is done (`ended_at`, migration 2), and
`resume` queues the end of every finished Task that has none, so an end no
longer depends on a job surviving. Each job names the attempt it starts, and
`start_attempt` starts it only when it is the next one, so a rerun or
duplicate job never runs the attempt twice; the Task fails at its deadline
and is ended then.

A success that can't be recorded now ends that way too: `deadlinePassed` at
the deadline, instead of trying to record `internal` right away.

Issue: DGW-462
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ependencies

`TaskJob<T>` now runs one attempt in three steps: the prelude records its
start, the body runs the task, and the completion records how it ended. A
recoverable failure returns an error, so the job queue runs the same job
again. Migration 2 is gone: `resume` gives each unfinished Task a new job
token, stores its new job, then fails a Task that can't outlive a restart.

`ProvisionerTaskRunner` becomes `ProvisionerTaskManager`, since it doesn't
run anything: it creates, reads and resumes Tasks, and holds a push-only
`DurableJobQueue`.

The job reader names each Task job explicitly
(`TaskJob::<RecordingAiAnalysis>::NAME`), parses it into a
`TaskJobUnhydrated`, and hydrates it with the `InMemoryJobDependencies` set
once on the job queue handle. `DgwState` no longer carries
`provisioner_tasks` or `secrets`.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`create` only writes the two records: the Task in gateway.db and its job
in job_queue.db, with the API key kept in the secret store in between.
The job queue delivers each job at least once; the record keeps where the
Task left off, so after a restart the job picks it up from there, and the
prelude fails a Task whose attempts are spent.

`TaskJob::run` waits `retry_delay` and runs the next attempt itself after
a recoverable failure, instead of returning an error for the job queue to
retry. An error from `run` now only means a write or an end failed.

There is no startup resume anymore. `requeue_lost_jobs` stores the job of
a queued Task that has none, before the job runner starts. With a single
job per Task, the job token has nothing left to guard, so it is removed,
and migration 2 drops its column.

`DgwState` holds the task store, the secret store and a `DurableJobQueue`,
shared with the job dependencies, which only the job runner gets.
A job is pushed in its stored form (`StorableJob`) and hydrated from it
(`UnhydratedJob`), so a runnable `TaskJob` exists only in the runner.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…modules

The provisioner task service, the one task holding the task store and
the job queue, stores the job inside the transaction that records the
Task, so a Task is never recorded without its job; the handlers only ask
it, as they ask the recording manager. Nothing is left to find at
startup, so `requeue_lost_jobs` and the job queue's `job_defs` go.

A job stored for a Task whose transaction rolled back stays in the job
queue, so the job token comes back: a job runs only the Task recorded
with its token. Migration 2 is gone, and the table keeps the schema of
#2041.

A success is published in the write that records it (`publish`), so a
succeeded Task always has its output listed; the unlisted log a Task
kept until its end, and the end that listed it, are gone with it.

`provisioner_tasks::runner` is split into `attempt`, `job` and `service`,
with the `ProvisionerTask` trait in `provisioner_tasks` itself. The
service pushes a `TaskJob` like any other job, so the job queue crates are
as on master. A `TaskJob` owns its task and runs the attempt inline: a
panic ends the job, and the record fails at its deadline.

The API answers with typed DTOs: the handler reads `params` and the
payload of a Task into the shapes the OpenAPI document declares, so a
record of another shape is an error on Gateway's side and never reaches
the caller.

The intent states the guarantee a Task gives, how recovery is split
between the job queue and the job, that only a success has a guarantee
on its result, and that the secret store is shared. The AI analysis API
intent moves next to `api/tasks/`, with its routes.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The job of a new Task was scheduled 2 s ahead so the job queue would claim
it after the record is committed. That was never needed: the job's first
read of its record waits on the same store lock `create` holds until the
commit, so an early claim reads the committed record anyway.

Issue: DGW-462

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… feat/ai-analysis-openapi

# Conflicts:
#	crates/devolutions-gateway-ai/src/recording_analysis/tests.rs
#	crates/provisioner-task-libsql/src/lib.rs
#	crates/provisioner-task/src/lib.rs
#	devolutions-gateway/src/api/jrec.intent.md
#	devolutions-gateway/src/api/tasks/recording_ai_analysis.rs
#	devolutions-gateway/src/job_queue.rs
#	devolutions-gateway/src/lib.rs
#	devolutions-gateway/src/openapi.rs
#	devolutions-gateway/src/provisioner_tasks/INTENT.md
#	devolutions-gateway/src/provisioner_tasks/ai.rs
#	devolutions-gateway/src/provisioner_tasks/mod.rs
#	devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/failure.rs
#	devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/mod.rs
#	devolutions-gateway/src/provisioner_tasks/recording_ai_analysis/tests.rs
#	devolutions-gateway/src/recording.intent.md
#	devolutions-gateway/src/recording.rs
#	devolutions-gateway/src/service.rs
#	testsuite/tests/cli/dgw/ai_analysis.rs
A success that cannot be recorded leaves the Task running for its next
attempt instead of failing it, so the `notQueued` kind is gone from the
API; the spec, both clients and the document follow.

Issue: DGW-462
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants