Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,24 @@ and drop the `mise exec --` / shell-builtin workarounds.
Single Cargo workspace (`Cargo.toml`). Shared dependency versions are declared in `[workspace.dependencies]`. Add
new deps there, not in individual crate `[dependencies]`.

## Avoid GPL and LGPL dependencies

Every license `config/deny.toml`'s `[licenses] allow` list currently accepts is permissive (Apache-2.0, MIT,
BSD, Zlib, ISC, MPL-2.0, etc.) -- none copyleft. Keep it that way: do not add a new direct or transitive
dependency whose license is GPL (any version) or LGPL (any version), and do not add GPL/LGPL to the
`deny.toml` allow list, even when the license is individually OSI-approved (LGPL is). Being OSI-approved is
not sufficient on its own here -- copyleft obligations are a different category of concern from a permissive
license, and accepting one is a project-policy decision, not a mechanical license check.

Concrete precedent: `viuer` (added for the storage service's tty image-preview feature) pulled in
`ansi_colours` under LGPL-3.0-or-later as an unconditional dependency for its RGB-to-256-color quantization
path. Rather than allow-listing LGPL, the feature was reimplemented as a small hand-rolled ANSI truecolor
renderer in `services/storage/src/tty_image.rs`, built on the `image` crate the workspace already depended on
via `deno_image`/`ws-web-runner` -- truecolor output needs no palette quantization at all, so there was
nothing left for the LGPL dependency to do. If a future dependency's only viable path involves a GPL/LGPL
component, treat that the same way: look for a permissively-licensed alternative or a narrower reimplementation
first, and only bring the question to the user if neither is workable -- don't add the exception yourself.
Comment on lines +1078 to +1085

Copy link
Copy Markdown

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_colours precedent is duplicated between repository policy and module documentation; retain the policy rationale in CLAUDE.md and 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 1078 - 1085, The licensing rationale is duplicated
across the repository policy and module documentation. Keep the detailed
viuer/ansi_colours precedent in CLAUDE.md lines 1078-1085 unchanged, and remove
the duplicated dependency-license explanation from
services/storage/src/tty_image.rs lines 3-8 while retaining only documentation
about the renderer’s behavior.

Source: Coding guidelines


## Clippy lints

**Never weaken or disable a lint to make code pass -- not the workspace lint config (`[workspace.lints.*]`,
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ futures-util = "0.3"
heck = "0.5"
hostname = "0.4"
humantime-serde = "1"
# Already a workspace dependency via deno_image/ws-web-runner, so this adds no new crate or license to review.
# Explicit format features only (no avif/dds/exr/hdr/ico/pnm/qoi/tga/tiff): et-storage-service only decodes
# the extensions is_image_filename() recognises.
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp"] }
int-otlp-mock = { path = "libs/otlp-mock", version = "0.1.0" }
js-sys = "0.3"
kdl = { version = "6", features = ["v1"] }
Expand Down
1 change: 1 addition & 0 deletions services/storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ actix-web-thiserror.workspace = true
edge-toolkit.workspace = true
fs-err.workspace = true
futures-util.workspace = true
image.workspace = true
log.workspace = true
serde.workspace = true
serde-inline-default.workspace = true
Expand Down
1 change: 1 addition & 0 deletions services/storage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use serde_default::DefaultFromSerde;
use thiserror::Error;

pub mod routes;
mod tty_image;

pub use self::routes::put_file;

Expand Down
47 changes: 44 additions & 3 deletions services/storage/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -S

Repository: edge-toolkit/core

Length of output: 847


🏁 Script executed:

set -e
sed -n '1,220p' services/storage/src/routes.rs

Repository: 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}")
PY

Repository: 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/src

Repository: 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}")
PY

Repository: 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.rs

Repository: edge-toolkit/core

Length of output: 4483


Offload terminal rendering from the request worker. show_image_on_tty is synchronous here and does image decode, resize, string generation, and stdout I/O on the Actix worker. Move it to a bounded blocking/background task after persistence, while keeping the warn! on render failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/storage/src/routes.rs` around lines 111 - 113, Update the
image-handling branch around show_image_on_tty to run terminal rendering in a
bounded blocking/background task after persistence, rather than synchronously on
the Actix request worker. Preserve the existing image detection and info!
logging, and propagate render failures to warn! from the spawned task.

}

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

Copy link
Copy Markdown

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

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.

  • services/storage/src/routes.rs#L119-L126: make Line 119 a complete period-terminated summary and remove the tty_image cross-reference.
  • services/storage/tests/image_logging.rs#L1-L2: make Line 1 a complete period-terminated summary and remove the put_file cross-reference.

As per coding guidelines, "fix first-line doc-summary punctuation" and "do not duplicate explanations or add documentation cross-references."

📍 Affects 2 files
  • services/storage/src/routes.rs#L119-L126 (this comment)
  • services/storage/tests/image_logging.rs#L1-L2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/storage/src/routes.rs` around lines 119 - 126, Update the first-line
Rust doc summaries at services/storage/src/routes.rs:119-126 and
services/storage/tests/image_logging.rs:1-2 so each is a self-contained,
complete sentence ending with a period; remove the mid-sentence wording and
cross-references to tty_image and put_file, while leaving the remaining
documentation unchanged.

Source: 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(
Expand Down
83 changes: 83 additions & 0 deletions services/storage/src/tty_image.rs
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

Copy link
Copy Markdown

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

Register every modified Rust file in the doc-summary rule. config/ast-grep/rules/doc-summary-ends-with-period.yaml is not updated in this change set.

  • services/storage/src/tty_image.rs#L1-L13: add this new Rust file to the sorted files: list.
  • services/storage/src/lib.rs#L12-L12: retain or add this modified Rust file in the sorted files: list.
  • services/storage/src/routes.rs#L100-L149: retain or add this modified Rust file in the sorted files: list.
  • services/storage/tests/image_logging.rs#L1-L29: add this modified Rust test file to the sorted files: list.
  • services/storage/tests/put.rs#L110-L136: retain or add this modified Rust test file in the sorted files: list.

As per coding guidelines, "Add every modified Rust file to config/ast-grep/rules/doc-summary-ends-with-period.yaml's sorted files: list."

📍 Affects 5 files
  • services/storage/src/tty_image.rs#L1-L13 (this comment)
  • services/storage/src/lib.rs#L12-L12
  • services/storage/src/routes.rs#L100-L149
  • services/storage/tests/image_logging.rs#L1-L29
  • services/storage/tests/put.rs#L110-L136
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/storage/src/tty_image.rs` around lines 1 - 13, Update
config/ast-grep/rules/doc-summary-ends-with-period.yaml by adding every modified
Rust file to its sorted files list: services/storage/src/tty_image.rs (anchor),
services/storage/src/lib.rs, services/storage/src/routes.rs,
services/storage/tests/image_logging.rs, and services/storage/tests/put.rs.
Retain any entries already present and do not modify the Rust implementation
files directly.

Source: 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.rs

Repository: edge-toolkit/core

Length of output: 5527


Gate tty rendering on stdout
render() still runs when stdout is redirected, so valid image uploads can emit ANSI escapes into logs and pay the decode/resize cost unnecessarily. Return early when std::io::stdout().is_terminal() is false.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/storage/src/tty_image.rs` around lines 30 - 31, Update
tty_image::render to check whether stdout is a terminal before calling
image::open; return successfully immediately when
std::io::stdout().is_terminal() is false, preserving the existing image
rendering path for terminal output.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -S

Repository: 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" . -S

Repository: edge-toolkit/core

Length of output: 448


Bound the rendered thumbnail size. rows scales directly with the decoded aspect ratio, so a very tall, narrow image can make resize_exact allocate a huge RGBA buffer and then build an equally large ANSI string. Clamp the rendered rows and add decoder limits before image::open so a single upload cannot exhaust memory or CPU.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/storage/src/tty_image.rs` around lines 31 - 57, Bound thumbnail
resource usage in the image-rendering function: configure decoder limits before
image::open to reject excessively large dimensions or decoded pixels, and clamp
the aspect-derived rows value before sample_height and resize_exact are used.
Preserve the existing minimum one-row behavior while enforcing a finite maximum
that also limits the generated ANSI output.


let mut out = String::new();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty call to `new()`


The new() function is used to initialise an object with specific data.
If no arguments are passed, the behaviour is identical to default().

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(())
}
29 changes: 29 additions & 0 deletions services/storage/tests/image_logging.rs
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"
);
}
}
28 changes: 28 additions & 0 deletions services/storage/tests/put.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

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

Correct the test's failure explanation.

This test fails rendering because Line 126 supplies malformed PNG bytes, not because cargo test lacks a terminal; viuer is no longer used, and stdout writes are intentionally ignored. Rewrite the first doc-summary line as a complete sentence ending in a period. As per coding guidelines, "fix first-line doc-summary punctuation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/storage/tests/put.rs` around lines 110 - 112, Update the first
doc-summary line for the image PUT test to explain that rendering fails because
line 126 provides malformed PNG bytes, not because of terminal availability or
viuer behavior. Make the summary a complete sentence ending with a period, while
preserving the remaining documentation.

Source: 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
Expand Down
7 changes: 7 additions & 0 deletions services/ws-modules/pydata1/pkg/et_ws_pydata1.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const PYODIDE_BASE_PATH = "/modules/pyodide/";

let pyodide = null;
let pyMod = null;
let moduleVersion = null;

function loadPyodideScript() {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -67,6 +68,7 @@ export default async function init() {
pyodide.runPython(`import sys\nsys.path.insert(0, "/tmp/${wheelName}")`);
};
const pkg = await fetch(new URL("package.json", import.meta.url)).then((r) => r.json());
moduleVersion = pkg.version;
const ownWheel = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`;
await injectWheel(ownWheel);

Expand Down Expand Up @@ -115,6 +117,10 @@ export async function run() {
const el = document.getElementById("module-output");
if (el) el.value = (el.value ? el.value + "\n" : "") + msg;
};
// Diagnostic marker: this is the version fetched from package.json + injected as the wheel filename, so
// seeing the right number here after a version bump proves the module reloaded fresh (no stale-cache reuse
// of the package.json/wheel fetches below, neither of which is cache-busted).
log(`pydata1 version: ${moduleVersion}`);

// The Python side runs `Client(base_url=...)` against this origin and
// does PUT/GET itself via the generated client + pyodide-http patch.
Expand All @@ -125,6 +131,7 @@ export async function run() {
pyodide.toPy(sleep),
pyodide.toPy(log),
pyodide.toPy(() => {}),
pyodide.toPy(() => document.getElementById("upload-consent")?.checked ?? false),
);
} catch (err) {
log(`pydata1 run failed: ${String(err)}`);
Expand Down
2 changes: 1 addition & 1 deletion services/ws-modules/pydata1/pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"main": "et_ws_pydata1.js",
"name": "et-ws-pydata1",
"type": "module",
"version": "0.1.0"
"version": "0.1.2"
}
25 changes: 24 additions & 1 deletion services/ws-modules/pydata1/pydata1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from et_rest_client.types import File


async def run(agent_id, base_url, sleep_ms, log, set_status) -> None:
async def run(agent_id, base_url, sleep_ms, log, set_status, upload_consent) -> None:
"""Execute the data1 workflow: store, fetch, verify."""
# httpx ships its own transport stack; in Pyodide that stack has no
# network access. `pyodide_http.patch_all()` swaps httpx's transports
Expand Down Expand Up @@ -48,6 +48,29 @@
set_status(msg)
raise RuntimeError("Data mismatch")

# Diagnostic: exercises the exact same "read the page's upload-consent checkbox, then gate a storage
# PUT on it" mechanism pyeye1 uses, but fully within pydata1's headless, camera-free, fully-observable
# workflow -- so the checkbox/upload plumbing itself can be verified without needing a real camera or a
# remote device to drive it.
consent = upload_consent()
log(f"pydata1: upload_consent() returned {consent}")
if consent:
consent_filename = "consent_test.txt"
consent_content = f"consent test at {datetime.now(timezone.utc).isoformat()}"
try:
async with Client(base_url=base_url) as c:
await put_file.asyncio_detailed(
agent_id,
consent_filename,
client=c,
body=File(payload=consent_content.encode("utf-8")),
)
log(f"pydata1: consent-gated upload succeeded: {consent_filename}")
except Exception as exc:

Check warning on line 69 in services/ws-modules/pydata1/pydata1/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-modules/pydata1/pydata1/__init__.py#L69

Catching too general exception Exception
log(f"pydata1: consent-gated upload failed: {exc}")
else:
log("pydata1: upload consent not granted, skipping consent-gated upload")

await sleep_ms(2000)
log("pydata1: workflow complete")
set_status("pydata1: workflow complete")
2 changes: 1 addition & 1 deletion services/ws-modules/pydata1/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description = "Python data 1"
license = "Apache-2.0 OR MIT"
name = "et-ws-pydata1"
requires-python = ">=3.10"
version = "0.1.0"
version = "0.1.2"

[build-system]
build-backend = "uv_build"
Expand Down
Loading
Loading