Repository navigation
chore(openapi): add AI analysis endpoints and scopes - #2054
irvingouj@Devolutions (irvingoujAtDevolution) merged 172 commits into
Conversation
Let maintainers know that an action is required on their side
|
c527662 to
b1bce5c
Compare
b1bce5c to
737bf61
Compare
d01b043 to
581df74
Compare
581df74 to
76d5c6b
Compare
12613a4 to
6f3941d
Compare
8a01978 to
038abb6
Compare
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>
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.
AccessScopeinDevolutions.Gateway.Utilsand in both clients gainsgateway.tasks.recording-ai-analysis.startandgateway.tasks.recording-ai-analysis.read.gateway-api.yaml,doc/index.adoc, the .NET client and the TS Angular client gainPUTandGET /jet/tasks/recording-ai-analysis/{task_id}(inTasksApi/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).[redacted]for apasswordfield inToString(), so logging anAiAnalysisRequestdoesn't show the API key. The template is a copy of the openapi-generator 7.9.0modelGeneric.mustachewith that one line changed.The generated files come from
tools/generate-openapiand openapi-generator 7.9.0, trimmed to the AI analysis parts: the generator also bumps the spec version and lists morepullRecordingFilecontent types in the clients and docs, which master never regenerated.Issue: DGW-462