Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1a7095a
feat(dgw): list session recording logs separately in the manifest
irvingoujAtDevolution Sep 25, 2026
f51e5c9
fix(dgw): refuse /shadow while only a session log is pushed
irvingoujAtDevolution Sep 27, 2026
796f4ff
refactor(dgw): use artifact naming for the session ZIP plan
irvingoujAtDevolution Sep 27, 2026
9f48746
feat(dgw): push session logs via materialType=log
irvingoujAtDevolution Sep 28, 2026
aea2d4e
refactor(dgw): push session logs on their own route
irvingoujAtDevolution Sep 28, 2026
0b61db5
refactor(dgw): push session logs with category=log
irvingoujAtDevolution Sep 29, 2026
c72b6f7
refactor(dgw): call the pushed file kind a category and keep the log …
irvingoujAtDevolution Sep 29, 2026
e9e9a40
feat(dgw): list non-recording artifacts by role in the manifest
irvingoujAtDevolution Sep 29, 2026
577a807
refactor(dgw): name the push destination PushTarget and pin role names
irvingoujAtDevolution Sep 29, 2026
296aeb8
fix(dgw): name the artifact query param kind and keep artifacts out o…
irvingoujAtDevolution Sep 29, 2026
f1c439f
refactor(dgw): track pushes per kind so artifacts never touch the rec…
irvingoujAtDevolution Sep 29, 2026
ca0a6b1
refactor(dgw): hard-type the manifest artifacts per kind
irvingoujAtDevolution Sep 29, 2026
2dc82c9
refactor(dgw): move the artifact types into artifacts.rs
irvingoujAtDevolution Sep 29, 2026
b62c8d9
refactor(dgw): keep artifact pushes out of the recording lifecycle
irvingoujAtDevolution Sep 29, 2026
0ca58cd
docs(dgw): rewrite the recording intent for artifacts
irvingoujAtDevolution Sep 29, 2026
808f4b4
refactor(dgw): update the last recording entry like master did
irvingoujAtDevolution Sep 29, 2026
ccd9116
refactor(dgw): trim the JREC push and ZIP changes back toward master
irvingoujAtDevolution Sep 29, 2026
9d3a40a
refactor(dgw): drop artifact pushes; add artifacts from Gateway only
irvingoujAtDevolution Sep 29, 2026
da4f172
docs(dgw): artifacts have no push endpoint yet
irvingoujAtDevolution Sep 29, 2026
26126aa
refactor(dgw): drop unused ArtifactKind derives
irvingoujAtDevolution Sep 29, 2026
5b0b8ab
refactor(dgw): stream artifacts through a writer instead of moving a …
irvingoujAtDevolution Sep 30, 2026
67b93c7
refactor(dgw): move the artifact writer into artifacts.rs
irvingoujAtDevolution Sep 30, 2026
b560157
refactor(dgw): add artifacts only to recorded sessions
irvingoujAtDevolution Sep 30, 2026
e86ed29
refactor(dgw): hand out a listed artifact file instead of a streaming…
irvingoujAtDevolution Sep 30, 2026
172c8af
Merge branch 'master' into feat/jrec-manifest-logs
irvingoujAtDevolution Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions devolutions-gateway/src/api/jrec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use zip::write::SimpleFileOptions;

use crate::DgwState;
use crate::api::heartbeat::recording_storage_health;
use crate::artifacts::JrecArtifacts;
use crate::extract::{JrecToken, RecordingDeleteScope, RecordingsReadScope};
use crate::http::{HttpError, HttpErrorBuilder};
use crate::recording::{PushOutcome, RecordingMessageSender};
Expand All @@ -36,7 +37,7 @@ use crate::token::{JrecTokenClaims, RecordingFileType, RecordingOperation};
/// Read chunk size when streaming a finished session ZIP from the temp file.
const ZIP_CHUNK_SIZE: usize = 64 * 1024;

/// Maximum files in a session ZIP (`recording.json` + clips).
/// Maximum files in a session ZIP (`recording.json` + clips and artifacts).
///
/// Reconnect windows only mint a small number of clips per session in practice;
/// this bound blocks pathological manifests without rejecting normal multi-clip packages.
Expand Down Expand Up @@ -633,6 +634,8 @@ where
#[serde(rename_all = "camelCase")]
struct RecordingZipManifest {
files: Vec<RecordingZipManifestFile>,
#[serde(default)]
artifacts: JrecArtifacts,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -672,7 +675,7 @@ impl RecordingZipPlan {
}
}

/// Snapshots `recording.json` and the clip files it references at call time.
/// Snapshots `recording.json` and the clip and artifact files it references at call time.
async fn snapshot_recording_zip_plan(recording_dir: &Utf8Path) -> Result<RecordingZipPlan, HttpError> {
let manifest_path = recording_dir.join("recording.json");
let manifest_bytes = tokio::fs::read(&manifest_path).await.map_err(|error| {
Expand All @@ -695,22 +698,27 @@ async fn snapshot_recording_zip_plan(recording_dir: &Utf8Path) -> Result<Recordi
HttpError::not_found().msg("requested recording does not exist")
})?;

let mut clip_names = Vec::with_capacity(manifest.files.len());
for file in manifest.files {
if !is_safe_recording_file_name(&file.file_name) {
let file_names = manifest
.files
.into_iter()
.map(|file| file.file_name)
.chain(manifest.artifacts.into_file_names());
let mut clip_names = Vec::with_capacity(file_names.size_hint().0);
for file_name in file_names {
if !is_safe_recording_file_name(&file_name) {
warn!(
file_name = %file.file_name,
%file_name,
"Skipping unsafe recording file name from manifest"
);
continue;
}

let path = recording_dir.join(&file.file_name);
let path = recording_dir.join(&file_name);
if path.is_file() {
clip_names.push(file.file_name);
clip_names.push(file_name);
} else {
warn!(
file_name = %file.file_name,
%file_name,
path = %path,
"Skipping missing recording file listed in manifest"
);
Expand Down Expand Up @@ -1091,6 +1099,44 @@ mod tests {
);
}

#[tokio::test]
async fn snapshots_manifest_artifacts_for_zip() {
let dir = tempfile::tempdir().expect("temp dir");
let dir_path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf8 path");

let manifest = serde_json::json!({
"sessionId": "33333333-3333-3333-3333-333333333333",
"startTime": 1,
"duration": 5,
"files": [
{ "fileName": "recording-0.webm", "startTime": 1, "duration": 5 }
],
"artifacts": {
"ai-analysis": [
{ "fileName": "ai-analysis-0.slog" },
{ "fileName": "ai-analysis-1.slog" }
]
}
});

tokio::fs::write(dir_path.join("recording.json"), manifest.to_string())
.await
.expect("write manifest");
for file_name in ["recording-0.webm", "ai-analysis-0.slog", "ai-analysis-1.slog"] {
tokio::fs::write(dir_path.join(file_name), b"content")
.await
.expect("write artifact");
}

let plan = snapshot_recording_zip_plan(&dir_path)
.await
.unwrap_or_else(|error| panic!("snapshot plan: {error}"));
assert_eq!(
plan.clip_names,
["recording-0.webm", "ai-analysis-0.slog", "ai-analysis-1.slog"]
);
}

#[tokio::test]
async fn zip_keeps_snapshotted_manifest_when_disk_manifest_changes() {
let dir = tempfile::tempdir().expect("temp dir");
Expand Down
79 changes: 79 additions & 0 deletions devolutions-gateway/src/artifacts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct JrecArtifacts {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ai_analysis: Vec<JrecArtifact>,
}

impl JrecArtifacts {
pub(crate) fn is_empty(&self) -> bool {
let Self { ai_analysis } = self;
ai_analysis.is_empty()
}

pub(crate) fn into_file_names(self) -> impl IntoIterator<Item = String> {
let Self { ai_analysis } = self;
ai_analysis.into_iter().map(|artifact| artifact.file_name)
}

pub(crate) fn of_kind_mut(&mut self, kind: ArtifactKind) -> &mut Vec<JrecArtifact> {
match kind {
ArtifactKind::AiAnalysis => &mut self.ai_analysis,
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JrecArtifact {
pub(crate) file_name: String,
}

/// Kind of a non-recording artifact, used as its key in the manifest `artifacts` object.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactKind {
AiAnalysis,
}

impl ArtifactKind {
pub const fn as_str(self) -> &'static str {
match self {
ArtifactKind::AiAnalysis => "ai-analysis",
}
}

pub(crate) const fn file_type(self) -> RecordingFileType {
match self {
ArtifactKind::AiAnalysis => RecordingFileType::SessionRecordingLog,
}
}

/// Name of the artifact at `index` in its kind's list, such as `ai-analysis-0.slog`.
pub(crate) fn file_name(self, index: usize) -> String {
format!("{}-{index}.{}", self.as_str(), self.file_type().extension())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn manifest_key_is_the_kind_name() {
let kind = ArtifactKind::AiAnalysis;
let mut artifacts = JrecArtifacts::default();
artifacts.of_kind_mut(kind).push(JrecArtifact {
file_name: "file".to_owned(),
});

let json = serde_json::to_value(&artifacts).expect("serialize artifacts");
let keys: Vec<_> = json.as_object().expect("object").keys().collect();
assert_eq!(keys, [kind.as_str()]);
}
}
1 change: 1 addition & 0 deletions devolutions-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ extern crate tracing;
pub mod openapi;

pub mod api;
pub mod artifacts;
pub mod cli;
pub mod config;
pub mod credential;
Expand Down
10 changes: 10 additions & 0 deletions devolutions-gateway/src/recording.intent.md
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Recording
Gateway recording is a continuous stream of bytes pushed from the client who pocesses the valid recording token.
The url is `/jet/jrec/push/{sessionId}?fileType={fileType}`.
We expect the `fileType` to be one of the following file types: `webm`, `cast`, `trp` and `slog`, which must be specified.
When connection is established with request of recordings for the session, if recording is not enabled within a short period of time, the connection will be closed with indication of violation of the recording policy.


# Artifacts
Artifacts are files that are not recordings, currently only have `ai-analysis` with combination to `slog` file type.
Currently, we do not have an api endpoint to push artifacts, but we will have one in the future.
Loading
Loading