-
Notifications
You must be signed in to change notification settings - Fork 0
Log images to server tty #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,7 +24,7 @@ use std::path::PathBuf; | |
| use actix_web::{HttpRequest, HttpResponse, web}; | ||
| use edge_toolkit::ws_server::AgentRegistry; | ||
| use futures_util::StreamExt as _; | ||
| use tracing::info; | ||
| use tracing::{info, warn}; | ||
|
|
||
| use crate::{StorageConfig, StorageError}; | ||
|
|
||
|
|
@@ -97,15 +97,56 @@ where | |
| let path = agent_dir.join(&filename); | ||
| info!("Agent {} storing file: {:?}", agent_id, path); | ||
|
|
||
| let mut file = tokio::fs::File::create(path).await?; | ||
| let mut file = tokio::fs::File::create(&path).await?; | ||
| let mut bytes_written: u64 = 0; | ||
| while let Some(chunk) = payload.next().await { | ||
| let chunk = chunk?; | ||
| let _copied: u64 = tokio::io::copy(&mut chunk.as_ref(), &mut file).await?; | ||
| let copied = tokio::io::copy(&mut chunk.as_ref(), &mut file).await?; | ||
| bytes_written = bytes_written.saturating_add(copied); | ||
| } | ||
|
|
||
| // This handler is the only path a file reaches storage through, so it is where every stored file can be | ||
| // watched exactly once with an accurate byte count and zero extra I/O -- a separate filesystem watcher | ||
| // would duplicate that work and risk observing a write mid-flight. | ||
| if is_image_filename(&filename) { | ||
| info!("Agent {} stored image {:?} ({} bytes)", agent_id, path, bytes_written); | ||
| show_image_on_tty(&path); | ||
|
Comment on lines
+111
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: set -e
rg -n "show_image_on_tty|is_image_filename|stored image" services/storage/src -SRepository: edge-toolkit/core Length of output: 847 🏁 Script executed: set -e
sed -n '1,220p' services/storage/src/routes.rsRepository: edge-toolkit/core Length of output: 6988 🏁 Script executed: set -e
python3 - <<'PY'
from pathlib import Path
p = Path('services/storage/src/routes.rs')
for i, line in enumerate(p.read_text().splitlines(), 1):
if 90 <= i <= 150:
print(f"{i:4}: {line}")
PYRepository: edge-toolkit/core Length of output: 3296 🏁 Script executed: set -e
printf '%s\n' '--- routes.rs ---'
python3 - <<'PY'
from pathlib import Path
p = Path('services/storage/src/routes.rs')
text = p.read_text().splitlines()
for i, line in enumerate(text, 1):
if 1 <= i <= 220:
print(f"{i:4}: {line}")
PY
printf '\n%s\n' '--- show_image_on_tty references ---'
rg -n "fn show_image_on_tty|show_image_on_tty\(" -S services/storage/srcRepository: edge-toolkit/core Length of output: 8227 🏁 Script executed: set -e
python3 - <<'PY'
from pathlib import Path
p = Path('services/storage/src/routes.rs')
for i, line in enumerate(p.read_text().splitlines(), 1):
if 103 <= i <= 118:
print(f"{i:4}: {line}")
PYRepository: edge-toolkit/core Length of output: 919 🏁 Script executed: set -e
wc -l services/storage/src/tty_image.rs
sed -n '1,220p' services/storage/src/tty_image.rsRepository: edge-toolkit/core Length of output: 4483 Offload terminal rendering from the request worker. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| Ok(HttpResponse::Ok().finish()) | ||
| } | ||
|
|
||
| /// Render a thumbnail of the image at `path` directly to stdout, so the operator actually *sees* what was | ||
| /// stored rather than just its filename and byte count. | ||
| /// | ||
| /// This bypasses `tracing` entirely and writes straight to the terminal: the escape sequences (ANSI | ||
| /// truecolor half-block art -- see the `tty_image` module) are tty-only presentation, not structured log | ||
| /// data, and would otherwise get shipped to the OTLP log exporter as log-record noise. Decode/render | ||
| /// failures (a corrupt upload, a non-terminal stdout) only cost tty visibility, not the request, so they're | ||
| /// reported via `warn!` rather than via `?`. | ||
|
Comment on lines
+119
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Use self-contained first-line Rust doc summaries. Both new doc blocks begin mid-sentence and point readers at another source file instead of stating a complete local summary.
As per coding guidelines, "fix first-line doc-summary punctuation" and "do not duplicate explanations or add documentation cross-references." 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| #[expect( | ||
| clippy::single_call_fn, | ||
| reason = "distinct step of put_file; kept separate for readability and testing" | ||
| )] | ||
| fn show_image_on_tty(path: &std::path::Path) { | ||
| if let Err(error) = crate::tty_image::render(path) { | ||
| warn!("failed to render stored image {} to tty: {error}", path.display()); | ||
| } | ||
| } | ||
|
|
||
| /// Return whether `filename`'s extension marks it as an image, for the tty log line in [`put_file`]. | ||
| /// | ||
| /// Extension-only: the handler streams bytes straight to disk without buffering, so sniffing magic bytes | ||
| /// would need a peek-buffer or a post-write read-back, while the filename is already on hand for free. | ||
| #[must_use] | ||
| pub fn is_image_filename(filename: &std::path::Path) -> bool { | ||
| const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "bmp"]; | ||
| filename | ||
| .extension() | ||
| .and_then(|ext| ext.to_str()) | ||
| .is_some_and(|ext| IMAGE_EXTENSIONS.iter().any(|known| known.eq_ignore_ascii_case(ext))) | ||
| } | ||
|
|
||
| /// Download a file previously written to the named agent's storage bucket. | ||
| #[cfg(feature = "openapi-spec")] | ||
| #[utoipa::path( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| //! Hand-rolled ANSI truecolor half-block renderer for images on a real terminal. | ||
| //! | ||
| //! Kept dependency-free beyond the `image` crate (already resolved in the workspace via | ||
| //! deno_image/ws-web-runner, so this adds no new crate to review) rather than pulling in a terminal-image | ||
| //! crate: `viuer`, the obvious off-the-shelf choice, drags in `ansi_colours` (LGPL-3.0-or-later) as an | ||
| //! unconditional dependency for its RGB-to-256-color quantization path -- copyleft, and the first such | ||
| //! license this workspace would have had to accept. Emitting 24-bit truecolor SGR codes directly needs no | ||
| //! palette quantization at all, so there is nothing for that dependency to do here. | ||
| //! | ||
| //! Uses the "half-block" technique standard terminal-image tools (viu, chafa, catimg, viuer's own fallback) | ||
| //! use: each terminal row encodes two source-pixel rows via the upper-half-block character `\u{2580}`, whose | ||
| //! foreground and background colors are set independently, doubling the effective vertical resolution for | ||
| //! the same character-cell budget. | ||
|
Comment on lines
+1
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Register every modified Rust file in the doc-summary rule.
As per coding guidelines, "Add every modified Rust file to 📍 Affects 5 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| use std::fmt::Write as _; | ||
| use std::io::Write as _; | ||
| use std::path::Path; | ||
|
|
||
| /// Terminal columns the rendered thumbnail is resized to fit within. | ||
| const TARGET_COLUMNS: u32 = 48; | ||
|
|
||
| /// Render a thumbnail of the image at `path` directly to stdout using ANSI truecolor half-block art. | ||
| /// | ||
| /// Returns the underlying `image` decode error on failure; the caller decides how to report it (this module | ||
| /// stays IO-boundary-agnostic rather than picking a logging mechanism itself). | ||
| #[expect( | ||
| clippy::single_call_fn, | ||
| reason = "distinct step of show_image_on_tty; kept separate for readability and testing" | ||
| )] | ||
| pub fn render(path: &Path) -> image::ImageResult<()> { | ||
| let source = image::open(path)?; | ||
|
Comment on lines
+30
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: set -euo pipefail
printf '## tty_image.rs\n'
sed -n '1,220p' services/storage/src/tty_image.rs
printf '\n## render usages\n'
rg -n "tty_image::render|render\(.*tty_image|render\(" services/storage -g'*.rs'
printf '\n## terminal/stdout checks in storage service\n'
rg -n "is_terminal|IsTerminal|stdout\(\)\.is_terminal|stderr\(\)\.is_terminal|tty" services/storage -g'*.rs'Repository: edge-toolkit/core Length of output: 6223 🏁 Script executed: sed -n '90,150p' services/storage/src/routes.rs
printf '\n## tests mentioning tty render\n'
sed -n '90,150p' services/storage/tests/put.rsRepository: edge-toolkit/core Length of output: 5527 Gate tty rendering on stdout 🤖 Prompt for AI Agents |
||
| if source.width() == 0 || source.height() == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Each output row consumes two source-pixel rows (the half-block trick above), so the resize target's | ||
| // height is twice the column-derived row count; `max(2)` keeps a hairline-thin source image visible as | ||
| // at least one row instead of rounding away to nothing. The float math and truncating cast are both | ||
| // genuinely needed to turn a source aspect ratio into a terminal row count; TARGET_COLUMNS being a small | ||
| // constant keeps the result comfortably within u32 for any real image. | ||
| #[expect( | ||
| clippy::float_arithmetic, | ||
| reason = "aspect-ratio scaling to a row count needs float math" | ||
| )] | ||
| let aspect = f64::from(source.height()) / f64::from(source.width()); | ||
| #[expect( | ||
| clippy::as_conversions, | ||
| clippy::cast_possible_truncation, | ||
| clippy::cast_sign_loss, | ||
| clippy::float_arithmetic, | ||
| reason = "converting the aspect-scaled row count (always small and non-negative) back to u32" | ||
| )] | ||
| let rows = ((aspect * f64::from(TARGET_COLUMNS) / 2.0).round().max(1.0)) as u32; | ||
| let sample_height = rows.saturating_mul(2).max(2); | ||
| let resized = source | ||
| .resize_exact(TARGET_COLUMNS, sample_height, image::imageops::FilterType::Triangle) | ||
| .to_rgba8(); | ||
|
Comment on lines
+31
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the target file and any related constants/usages.
git ls-files services/storage/src/tty_image.rs
wc -l services/storage/src/tty_image.rs
cat -n services/storage/src/tty_image.rs | sed -n '1,220p'
printf '\n-- nearby references --\n'
rg -n "TARGET_COLUMNS|rows|min\(MAX_ROWS\)|resize_exact|image::open|decode" services/storage/src -SRepository: edge-toolkit/core Length of output: 6331 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Probe the image crate API usage in the repo to see whether decode limits are already applied elsewhere.
rg -n "limits\(|with_guessed_format|ImageReader|max_dimensions|max_alloc" . -SRepository: edge-toolkit/core Length of output: 448 Bound the rendered thumbnail size. 🤖 Prompt for AI Agents |
||
|
|
||
| let mut out = String::new(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| for row in 0..rows { | ||
| let top_y = row.saturating_mul(2); | ||
| let bottom_y = top_y.saturating_add(1); | ||
| for col in 0..TARGET_COLUMNS { | ||
| let top = resized.get_pixel(col, top_y); | ||
| let bottom = resized.get_pixel(col, bottom_y); | ||
| // Writing to a String cannot fail. A named (not bare `_`) binding discards the Result without | ||
| // unwrap/expect or a bare `_`/`.ok()` (all denied by this workspace's lint set in different ways). | ||
| let _write_result = write!( | ||
| out, | ||
| "\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m\u{2580}", | ||
| top[0], top[1], top[2], bottom[0], bottom[1], bottom[2] | ||
| ); | ||
| } | ||
| out.push_str("\x1b[0m\n"); | ||
| } | ||
|
|
||
| // One write for the whole thumbnail (not one per escape code) keeps the render from interleaving with | ||
| // other stdout lines on a shared terminal. A failed stdout write only costs tty visibility, not | ||
| // correctness, so it's discarded here (named binding, not bare `_`/`.ok()`) rather than surfaced as | ||
| // this function's own error. | ||
| let _write_result = std::io::stdout().write_all(out.as_bytes()); | ||
| Ok(()) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| //! Tests for `is_image_filename`, the extension check behind the storage service's tty log line for | ||
| //! stored images (see `put_file` in `src/routes.rs`). | ||
|
|
||
| #![cfg(test)] | ||
|
|
||
| use std::path::Path; | ||
|
|
||
| use et_storage_service::routes::is_image_filename; | ||
|
|
||
| #[test] | ||
| fn recognizes_common_image_extensions_case_insensitively() { | ||
| for ext in ["png", "jpg", "jpeg", "gif", "webp", "bmp", "PNG", "Jpg"] { | ||
| let filename = format!("capture.{ext}"); | ||
| assert!( | ||
| is_image_filename(Path::new(&filename)), | ||
| "{filename} should be recognized as an image" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_non_image_extensions_and_extensionless_names() { | ||
| for name in ["notes.txt", "data.json", "archive.tar.gz", "no-extension", ".hidden"] { | ||
| assert!( | ||
| !is_image_filename(Path::new(name)), | ||
| "{name} should not be recognized as an image" | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,6 +107,34 @@ async fn writes_file_for_registered_agent() { | |
| assert_eq!(written, body); | ||
| } | ||
|
|
||
| /// An image PUT under `cargo test` has no real terminal on stdout, so `viuer`'s decode-and-render step | ||
| /// (triggered by the `.png` extension) is expected to fail internally. The route must still store the file | ||
| /// and return 200 regardless -- tty display is a best-effort side effect, never a reason to fail the upload. | ||
|
Comment on lines
+110
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Correct the test's failure explanation. This test fails rendering because Line 126 supplies malformed PNG bytes, not because 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| #[actix_rt::test] | ||
| async fn stores_an_image_and_returns_200_even_though_tty_rendering_cannot_succeed_in_tests() { | ||
| let tmp = tempfile::tempdir().unwrap(); | ||
| let config = storage_config(&tmp); | ||
| let registry = registry_with_agent("agent-1"); | ||
| let app = test::init_service( | ||
| App::new() | ||
| .app_data(web::Data::new(registry)) | ||
| .app_data(web::Data::new(config.clone())) | ||
| .configure(|cfg| configure::<()>(cfg, &config)), | ||
| ) | ||
| .await; | ||
|
|
||
| let body = b"\x89PNG\r\n\x1a\nnot a real png, just image-extension-shaped bytes".as_ref(); | ||
| let req = test::TestRequest::put() | ||
| .uri("/storage/agent-1/capture.png") | ||
| .set_payload(body) | ||
| .to_request(); | ||
| let resp = test::call_service(&app, req).await; | ||
|
|
||
| assert_eq!(resp.status(), StatusCode::OK); | ||
| let written = fs_err::read(tmp.path().join("agent-1").join("capture.png")).unwrap(); | ||
| assert_eq!(written, body); | ||
| } | ||
|
|
||
| #[actix_rt::test] | ||
| async fn surfaces_io_failure_as_500() { | ||
| // Point the storage root at a *file* (not a directory). The handler's | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,5 +8,5 @@ | |
| "main": "et_ws_pydata1.js", | ||
| "name": "et-ws-pydata1", | ||
| "type": "module", | ||
| "version": "0.1.0" | ||
| "version": "0.1.2" | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Keep the licensing rationale in one location. The detailed
viuer/ansi_coloursprecedent is duplicated between repository policy and module documentation; retain the policy rationale inCLAUDE.mdand keep the module docs focused on renderer behavior.CLAUDE.md#L1078-L1085: retain the repository policy and precedent here.services/storage/src/tty_image.rs#L3-L8: remove the duplicated dependency-license explanation.As per coding guidelines, "Document each fact exactly once in its most relevant location; do not duplicate explanations or add documentation cross-references."
📍 Affects 2 files
CLAUDE.md#L1078-L1085(this comment)services/storage/src/tty_image.rs#L3-L8🤖 Prompt for AI Agents
Source: Coding guidelines