From 1842411ca2583b2cd63f0aed39aaa9125b36beb4 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 23 Oct 2026 17:32:47 -0400 Subject: [PATCH 1/4] docs(dgw): state that search reads slog recordings Active Directory Console pushes its log as a recording, so it sits under `files` in `recording.json`, where search does not look. The search intent now covers the `.slog` files listed under `files` as well as under `artifacts`, `files` first, and defers choosing other file types. Co-Authored-By: Claude Opus 5.5 --- devolutions-gateway/src/api/jrec/search.intent.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/devolutions-gateway/src/api/jrec/search.intent.md b/devolutions-gateway/src/api/jrec/search.intent.md index 3bca91179..11e688053 100644 --- a/devolutions-gateway/src/api/jrec/search.intent.md +++ b/devolutions-gateway/src/api/jrec/search.intent.md @@ -1,14 +1,15 @@ ## What is searched -Search reads only the files listed under `artifacts` in `recording.json` whose type is `slog`. -Recording files listed under `files` are not searched, a `.slog` recording included. +Search covers the `.slog` files listed in `recording.json`, under `files` as well as under `artifacts`. +`files` is searched so that Active Directory Console logs, which are pushed as recordings, are searchable, the ones already stored included, with no change to how they are pushed. +Within a recording, the `.slog` files listed under `files` are searched first, then those listed under `artifacts`, by convention, in the order `recording.json` lists them and a recording pull zips them. Each log is searched once, even when the request repeats its recording ID or the manifest lists it twice. Search finds a recording and its logs where a recording pull does: in the folder named by the hyphenated recording ID, and only under a file name the pull accepts, so a log listed as `../outside.slog` is not searched. A log that is missing or cannot be read is skipped and the search goes on, as `rg` and `grep` go on past a file they cannot open. The query is matched literally, as `rg -F` and `grep -F` do, so its spaces are part of it and an empty query matches every line. A line holding a JSON object is matched case-insensitively against the object's string values, at any depth and once unescaped; field names, numbers, booleans and null never match. -Any other line is matched case-insensitively against its raw text, so that any kind of artifact, present or future, can be searched. +Any other line is matched case-insensitively against its raw text, so that any kind of log, present or future, can be searched. A line cut at 256 KiB follows the same rule: what was read is matched by its string values when it parses as a JSON object, and as raw text otherwise. Values are matched rather than raw text because users search the text they see: raw matching would make a field name such as `description` match every line, and would miss text that JSON escapes, such as `DOMAIN\user` stored as `DOMAIN\\user`. @@ -57,4 +58,4 @@ A log replaced while paging, as an AI analysis rerun does, is read from the curs ## Later Regular expressions or wildcards, a total hit count, and combining words are wanted later, and the request shape must not preclude them. -Choosing which artifact kinds to search, or searching other sources such as the `.slog` recordings under `files`, is wanted later; the request shape must not preclude it either. +Choosing which file types to search, such as `.cast` besides `.slog`, is wanted later; the request shape must not preclude it either. From f7614709ad75758bf4d2ffd5eaea5dffebc3e878 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 23 Oct 2026 17:32:48 -0400 Subject: [PATCH 2/4] feat(dgw): search Active Directory Console logs Search read only the `.slog` files listed under `artifacts` in `recording.json`. Active Directory Console pushes its log as a recording, listed under `files`, so none of its logs, stored ones included, could be found. Search now reads the `.slog` files under `files` too, before those under `artifacts`, in the order the manifest and the pull ZIP use. A name listed in both sections is searched once, so a cursor still resumes at a single place. Other file types under `files`, such as `.webm`, and unsafe names are still skipped. Co-Authored-By: Claude Opus 5.5 --- devolutions-gateway/src/api/jrec/search.rs | 40 +++++++++++++-------- testsuite/tests/cli/dgw/recording_search.rs | 19 +++++++--- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/devolutions-gateway/src/api/jrec/search.rs b/devolutions-gateway/src/api/jrec/search.rs index afb7c7dca..cc7a05ba5 100644 --- a/devolutions-gateway/src/api/jrec/search.rs +++ b/devolutions-gateway/src/api/jrec/search.rs @@ -146,9 +146,9 @@ pub(crate) struct RecordingSearchResponse { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RecordingSearchHit { - /// The recording whose artifact holds the line. + /// The recording whose log holds the line. recording_id: Uuid, - /// Name of the `.slog` artifact holding the line, as listed in `recording.json`. + /// Name of the `.slog` file holding the line, as listed in `recording.json`. file_name: String, /// Number of the line in the file, counting from 1. #[cfg_attr(feature = "openapi", schema(minimum = 1))] @@ -161,7 +161,7 @@ pub(crate) struct RecordingSearchHit { truncated: bool, } -/// Searches the `.slog` artifacts, such as AI analysis logs, of recordings stored on this instance +/// Searches the `.slog` files of recordings stored on this instance, such as Active Directory Console logs /// /// The query is a case-insensitive substring, and an empty query matches every line. /// In a line holding a JSON object, it is matched against the string values of the object at any depth, as they read @@ -169,7 +169,7 @@ pub(crate) struct RecordingSearchHit { /// Any other line, such as one still being written, is matched as raw text. /// A line longer than 256 KiB is matched on its first 256 KiB only, by the same rule, so a cut JSON object is usually /// matched as raw text. -/// Only the files listed under `artifacts` in `recording.json` with the `slog` type are searched. +/// Only the files listed in `recording.json` with the `slog` type are searched, under `files` and under `artifacts`. /// Recordings without a readable `recording.json` and IDs with no recording on this instance give no hits. /// /// Results come in pages. @@ -773,7 +773,7 @@ impl Search { } } -/// The `.slog` artifacts of one recording. +/// The `.slog` files of one recording. struct RecordingLogs { recording_id: Uuid, dir: Utf8PathBuf, @@ -781,16 +781,24 @@ struct RecordingLogs { file_names: Vec, } -/// Lists the `.slog` artifacts of a recording, such as its AI analysis, from `recording.json`. +/// Lists the `.slog` files of a recording from `recording.json`, those under `files` first, then the artifacts. async fn read_recording_logs(dir: Utf8PathBuf, recording_id: Uuid) -> Option { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Manifest { start_time: i64, #[serde(default)] + files: Vec, + #[serde(default)] artifacts: JrecArtifacts, } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct ManifestFile { + file_name: String, + } + let path = dir.join("recording.json"); // Every search reads every manifest again, so a failure here repeats on each search. @@ -806,9 +814,10 @@ async fn read_recording_logs(dir: Utf8PathBuf, recording_id: Uuid) -> Option anyhow::Result<()> { @@ -81,6 +82,7 @@ fn write_recordings(root: &Path) -> anyhow::Result<()> { "files": [ { "fileName": "recording-0.slog", "startTime": 100, "duration": 10 }, { "fileName": "recording-1.webm", "startTime": 100, "duration": 10 }, + { "fileName": "../outside.slog", "startTime": 100, "duration": 10 }, ], "artifacts": { "ai-analysis": [ @@ -200,7 +202,7 @@ fn hit_summaries(body: &serde_json::Value) -> anyhow::Result anyhow::Result<()> { +async fn searches_the_slog_files_of_every_recording_newest_first() -> anyhow::Result<()> { let (_recordings, config_handle, _process) = start_with_recordings().await?; let response = search( @@ -216,6 +218,7 @@ async fn searches_the_slog_artifacts_of_every_recording_newest_first() -> anyhow hit_summaries(&body)?, [ (NEWER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 1), + (OLDER_RECORDING.to_string(), "recording-0.slog".to_owned(), 1), (OLDER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 2), ] ); @@ -224,6 +227,7 @@ async fn searches_the_slog_artifacts_of_every_recording_newest_first() -> anyhow Ok(()) } +// oracle: intent devolutions-gateway/src/api/jrec/search.intent.md §What is searched // oracle: intent devolutions-gateway/src/api/jrec/search.intent.md §Request #[tokio::test] async fn searches_each_listed_recording_once() -> anyhow::Result<()> { @@ -243,7 +247,10 @@ async fn searches_each_listed_recording_once() -> anyhow::Result<()> { let body: serde_json::Value = response.json().await?; assert_eq!( hit_summaries(&body)?, - [(OLDER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 2)] + [ + (OLDER_RECORDING.to_string(), "recording-0.slog".to_owned(), 1), + (OLDER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 2), + ] ); Ok(()) @@ -336,6 +343,7 @@ async fn pages_through_recordings_that_start_together_highest_id_first() -> anyh Ok(()) } +// oracle: intent devolutions-gateway/src/api/jrec/search.intent.md §What is searched // oracle: intent devolutions-gateway/src/api/jrec/search.intent.md §Request // oracle: intent devolutions-gateway/src/api/jrec/search.intent.md §Pages #[tokio::test] @@ -352,10 +360,11 @@ async fn pages_through_listed_recordings_in_their_order() -> anyhow::Result<()> ) .await?; - assert_eq!(pages.len(), 2); + assert_eq!(pages.len(), 3); assert_eq!( hit_summaries_of(&pages)?, [ + (OLDER_RECORDING.to_string(), "recording-0.slog".to_owned(), 1), (OLDER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 2), (NEWER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 1), ] From c7edac90875b0e1f1036f485e22bf736557d3387 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 27 Oct 2026 12:01:32 -0400 Subject: [PATCH 3/4] test(dgw): check search keeps manifest log order Within a recording, search reads the `.slog` files under `files` first, then those under `artifacts`, each in the order `recording.json` lists them. No test listed two logs in one section, so reversing or sorting either section went unnoticed. Issue: DGW-463 Co-Authored-By: Claude Opus 5.5 --- devolutions-gateway/src/api/jrec/search.rs | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/devolutions-gateway/src/api/jrec/search.rs b/devolutions-gateway/src/api/jrec/search.rs index cc7a05ba5..4d4a0cf2e 100644 --- a/devolutions-gateway/src/api/jrec/search.rs +++ b/devolutions-gateway/src/api/jrec/search.rs @@ -1388,6 +1388,30 @@ mod tests { assert_eq!(recording.file_names, ["ai-analysis-0.slog"]); } + // oracle: intent devolutions-gateway/src/api/jrec/search.intent.md §What is searched + #[tokio::test] + async fn lists_logs_in_manifest_order_files_first() { + let root = tempfile::tempdir().expect("temp dir"); + std::fs::write( + root.path().join("recording.json"), + r#"{"startTime":100,"files":[{"fileName":"recording-1.slog"},{"fileName":"recording-0.slog"}],"artifacts":{"ai-analysis":[{"fileName":"ai-analysis-1.slog"},{"fileName":"ai-analysis-0.slog"}]}}"#, + ) + .expect("write manifest"); + let dir = Utf8PathBuf::from_path_buf(root.path().to_owned()).expect("UTF-8 path"); + + let recording = read_recording_logs(dir, Uuid::nil()).await.expect("readable manifest"); + + assert_eq!( + recording.file_names, + [ + "recording-1.slog", + "recording-0.slog", + "ai-analysis-1.slog", + "ai-analysis-0.slog" + ] + ); + } + /// Lists the recordings of a root holding a folder per name, each with a manifest that starts at the same time. async fn list_recording_folders(names: impl IntoIterator) -> Vec { let root = tempfile::tempdir().expect("temp dir"); From 86cde1876174f46192e6b341eb39dab46aa2a6a7 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 28 Oct 2026 10:28:45 -0400 Subject: [PATCH 4/4] test(dgw): cite the page rule the newest-first search test checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test asserts that a fully read search returns no `nextCursor`, which is a rule of search.intent.md §Pages. Co-Authored-By: Claude Opus 5.5 --- testsuite/tests/cli/dgw/recording_search.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/testsuite/tests/cli/dgw/recording_search.rs b/testsuite/tests/cli/dgw/recording_search.rs index 94e4ee3b7..0fca83ece 100644 --- a/testsuite/tests/cli/dgw/recording_search.rs +++ b/testsuite/tests/cli/dgw/recording_search.rs @@ -201,6 +201,7 @@ fn hit_summaries(body: &serde_json::Value) -> anyhow::Result anyhow::Result<()> { let (_recordings, config_handle, _process) = start_with_recordings().await?;