feat(storage): serve the Storage API from Rust by eldadfux · Pull Request #14683 · appwrite/appwrite · GitHub
Skip to content

feat(storage): serve the Storage API from Rust - #14683

Open
eldadfux wants to merge 5 commits into
feat/rust-utopia-conversionfrom
feat/rust-storage
Open

eldadfux wants to merge 5 commits into
feat/rust-utopia-conversionfrom
feat/rust-storage

Conversation

@eldadfux

Copy link
Copy Markdown
Member

Summary

  • The Storage service now runs in appwrite-rust. Traefik sends /v1/storage there when the rust compose profile is on, the same way it already sends Users and Teams. The PHP module is still in the tree.
  • All 14 routes keep the PHP contract: buckets, files, preview, download, view, and push. The dev compose file runs the Rust container as root so it can write the shared upload volume. The image itself still defaults to the nonroot user.
  • The PHP Storage suite through Traefik (_APP_E2E_ENDPOINT=http://appwrite.test/v1) passed: 108 tests, 2883 assertions. CI runs that suite on the Rust E2E job. Antivirus, screenshots, abuse, queue retry, and usage outage stay excluded, matching the PHP job. /v1/s3 stays on PHP.

Benchmark

ALLOW_BUSY=1 tests/benchmarks/api/compare.sh storage, 60s per step, results in tests/benchmarks/results/storage.json. Other cargo jobs held the load between about 20 and 33 for the whole run, so these numbers are slower than a quiet machine. Both sides ran under that load.

At 50 req/s both sides held the rate with no errors.

req/s p50 p99 API CPU peak memory
PHP 50.5 15.9 ms 721 ms 36.5 ms/req 2644 MiB
Rust 50.5 9.3 ms 328 ms 12.6 ms/req 928 MiB

At 50 virtual users, Rust reached 487 req/s and PHP 209 req/s, with no errors. At 200 req/s Rust held the rate (p50 6.5 ms). PHP delivered 106 req/s and dropped 5746 iterations. At 500 req/s both sides crossed the 1% server-error budget.

Test plan

  • cargo fmt -p appwrite-storage and cargo clippy -p appwrite-storage --all-targets -- -D warnings
  • paratest --functional tests/e2e/Services/Storage through Traefik, excluding abuseEnabled, screenshots, antivirus, queueRetry, usageOutage
  • tests/benchmarks/api/compare.sh storage

Checklist

  • Have you read the Contributing Guidelines on issues?
  • If the PR includes a change to an API's metadata (desc, label, params, etc.), does it also include updated API specs and example docs? The routes keep the PHP metadata.

Made with Cursor

Traefik sends /v1/storage to the Rust API, alongside Users and Teams. The PHP module stays. The local-device suite passes through Traefik, and the benchmark compares both runtimes on the same stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
@tenki-reviewer

tenki-reviewer Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 5 medium

💬 Inline comments (4)

📍 Findings outside the diff (1) — 🟡 1 medium — defects on lines GitHub can't attach comments to

🟡 Medium — CI never verifies /v1/storage reaches Rust · ci.yml:1119–1122 · unchanged line

// .github/workflows/ci.yml
1119	      - name: Verify /v1/users is served by Rust
1120	        run: |
1121	          docker compose exec -T appwrite curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: appwrite.test' http://appwrite.test/v1/users
1122	          docker compose exec -T appwrite-rust /usr/local/bin/appwrite-rust health

The e2e_rust lane's routing verification checks only that /v1/users returns a status code and that the health endpoint passes (.github/workflows/ci.yml:1119-1122); nothing asserts /v1/storage is served by Rust. Because the PHP API serves /v1/storage on the same Traefik endpoint, a wrong or regressed routing rule makes the Storage e2e suite pass against PHP while the lane green-lights Rust coverage it does not have.

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Push route CSP leaves none unquoted (content.rs:141) — The push handler serves file bytes with the CSP script-src none; where the none keyword is not quoted (crates/appwrite/storage/src/http/content.rs:141), unlike the correct script-src 'none'; used by view (line 101).

The PR introduces a new appwrite-storage Rust crate porting the PHP Storage service end-to-end: bucket and file CRUD, chunked upload lifecycle with distributed locking, preview/autogravity, AES-GCM encryption, and ClamAV scanning. Supporting changes wire the new crate into the Rust server, add storage collection schemas and error types to appwrite/core, and route /v1/storage through Traefik with a storage-specific body limit in the shared HTTP server.

Files Change
crates/appwrite/storage/ New Storage crate: base/device/folder helpers, bucket/file/content/meta/scan HTTP handlers, models, and the 14-route table mirroring PHP scopes, events, and abuse labels.
crates/appwrite/core/, crates/appwrite/server/ Adds BUCKETS/FILES query schemas, storage error variants, Context.resource_token wiring, shutdown-hook event filtering, and 12 MB body-limit server config.
crates/utopia/http/src/server.rs Reworks body collection for the 413 path to drain over-limit bodies without Limited.
docker-compose.override.yml, Dockerfile.rust, Cargo.toml, .github/workflows/ci.yml Infra wiring: distroless Rust image, Traefik routing for /v1/storage, env passthrough, and CI lanes running the Storage e2e suite.
rfc/rust.md, tests/benchmarks/results/storage.json Documentation and benchmark result updates for the migration.

Reviewed commit: 25d0e0a

@hansi-codes

hansi-codes Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Tier B · Needs changes before merging

Four findings from the earlier review remain open, covering Storage device configuration compatibility, download buffering, request-body memory growth, and unbounded draining on overload.

This PR ports the 14 Storage API routes to the Rust service, routes /v1/storage through Traefik under the Rust profile, and keeps /v1/s3 and the PHP module in place. It adds Rust Storage support and integration coverage, configures the development container to share the upload volume, and documents the migration and benchmark results. The latest updates refresh the Teams benchmark data and present per-load endpoint latency comparisons as verdict badges.

Latest changes: The newest commits refresh the Teams benchmark run, move endpoint median comparisons into per-load verdict badges with updated chart layout and copy, and remove a machine-specific Vite worktree launch configuration.

Verdict New comments Fixed Still open
💬 Commented 0 0 4
Fix with agent prompt
### Issue 1
crates/appwrite/storage/src/device.rs:166-175
**Preserve the existing storage device configuration**

This factory only handles `_APP_STORAGE_DEVICE` values `s3` and `awss3`; otherwise it silently selects local storage. It ignores `_APP_CONNECTIONS_STORAGE` and the provider-specific device settings that `getDevice()` in `app/init/resources.php` supports, so installations using those configurations will no longer find existing files and may write new uploads to the wrong backend.

### Issue 2
crates/appwrite/storage/src/http/content.rs:284
**Avoid buffering the entire file for downloads**

`serve` reads the whole object into a `Vec` before handling ranges or returning the response, including plain files larger than the 20 MB transformation buffer and range requests that need only a small slice. PHP streams large files in bounded chunks or reads only the requested range, so concurrent downloads of valid large files can exhaust the Rust service's memory.

### Issue 3
crates/appwrite/server/src/main.rs:91
**Size concurrency against the request-body memory budget**

This admits 4,096 simultaneous requests while the server buffers each body up to the 12 MiB limit, so concurrent large uploads can require roughly 48 GiB just for request bodies and OOM the Rust process. Please size this cap against a bounded memory budget or add an aggregate body-memory limit.

### Issue 4
crates/utopia/http/src/server.rs:142
**Bound the time spent draining rejected request bodies**

This waits for EOF before returning 503, and `collect_body` keeps draining even after the size limit is exceeded; with no body-read timeout, clients can pause or trickle data indefinitely and keep arbitrarily many rejected connection tasks open beyond the concurrency cap. Please use a bounded drain/deadline so overload responses cannot be held open this way.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 13
File Change
.claude/launch.json Removes a machine-specific local Vite worktree launch target.
.github/workflows/ci.yml Adds the Storage E2E suite to the Rust CI job.
Cargo.toml, Cargo.lock, crates/appwrite/server/Cargo.toml, crates/appwrite/server/src/main.rs Adds the Storage crate to the workspace and server and configures Rust HTTP request handling.
Dockerfile.rust, Dockerfile.rust.dockerignore, docker-compose.override.yml Includes preview assets and configures the Rust container, shared upload volume, and Storage routing.
crates/appwrite/storage/ Implements Storage routes and support for buckets, files, content delivery, devices, uploads, previews, and scanning.
crates/appwrite/core/src/ Adds Storage schemas and errors plus platform context and lifecycle support for the Rust routes.
crates/utopia/http/src/server.rs, crates/utopia/http/tests/server.rs Adds overload and request-body handling with a saturation regression test.
crates/appwrite/hooks.json, rfc/rust.md Updates the cache-hook migration status and documents the Rust Storage migration.
apps/crates/content/changelog/storage.md, apps/crates/content/facts.json, apps/crates/content/tasks.json Adds Storage migration, benchmark, and project-status content.
apps/crates/src/components/BenchAppwrite.tsx, apps/crates/src/routes/migration/services/$service.tsx Displays endpoint latency verdicts at each load step and updates the benchmark explanation.
tests/e2e/results/storage.rust.json, tests/benchmarks/results/storage.json, tests/benchmarks/results/teams.json Records Rust Storage E2E and benchmark results and refreshes the Teams benchmark run.
crates/appwrite/hooks.json Updates the cache-hook migration status for Rust Storage previews.
rfc/rust.md Documents the Rust Storage migration.
⏳ Still open from earlier reviews · 4

Reviewed the commits since bb4e21c · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@github-actions

Copy link
Copy Markdown

Security rules

No new WARNING or ERROR findings from security rules.

19 existing findings tracked in .semgrep/baseline.json
  • php.appwrite.guest-write-without-abuse-limit (12)
  • php.appwrite.secret-compare-timing (5)
  • php.appwrite.weak-secret-env-default (2)

Posted by Checks / Rules. Re-runs update this comment in place. Rule details and baseline: .semgrep/README.md.

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR migrates the Storage service (buckets, files, previews, chunked uploads, antivirus scanning) to Rust, adding compose/Traefik routing for /v1/storage and CI coverage, with several medium-severity issues around encrypted-file compatibility, memory-buffered range serving, and infra verification.

Key findings

Comment on lines +284 to +295
let mut bytes = base::read_all(device, &path).await?;
if !file.string_or_empty("openSSLCipher").is_empty() {
bytes = decrypt(&ctx.state.config.openssl_key_v1, file, &bytes)?;
}
bytes = decompress(&file.string_or_empty("algorithm"), bytes)?;
let size = base::long(file, "sizeOriginal");
let ranged = range_of(ctx, size, open_end)?;
let (status, body) = match ranged {
Some((start, end)) => (StatusCode::PARTIAL_CONTENT, slice(&bytes, start, end)),
None => (StatusCode::OK, bytes),
};
let mut response = Response::with_body(status, content_type, body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 performance · medium

Range requests load and decrypt the whole file

serve reads the entire file via base::read_all and runs decrypt/decompress over the full buffer before range_of narrows the response to at most OUTPUT_CHUNK/READ_BUFFER bytes (crates/appwrite/storage/src/http/content.rs:284). For a multi-GB file, every ranged download/view request pays full I/O, decrypt, decompress and an extra Vec copy in slice just to emit ~10 MB. This regresses per-request memory and latency proportionally to file size on a hot path.

📋 Prompt for AI Agents

In crates/appwrite/storage/src/http/content.rs, restructure serve (around lines 270-310): call range_of first using the file's sizeOriginal, and when a range is present read only the requested span with device.read(&path, start, Some(end - start + 1)) instead of base::read_all, slicing after decrypt/decompress only when the file has a cipher/algorithm. Keep the full-file read only when no Range header is present.

Comment on lines +341 to +346
fn decrypt(key: &str, file: &Document, data: &[u8]) -> Result<Vec<u8>> {
let iv = hex::decode(file.string_or_empty("openSSLIV")).unwrap_or_default();
let tag = hex::decode(file.string_or_empty("openSSLTag")).unwrap_or_default();
appwrite_core::crypto::open(key, data, &iv, &tag)
.ok_or_else(|| Error::with_message(ErrorType::GeneralServerError, "Failed to decrypt file"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 bug · medium

PHP-written encrypted files fail Rust decryption

PHP encrypts stored files with OpenSSL::encrypt($data, CIPHER_AES_128_GCM, $key, 0, $iv, $tag) where options = 0 emits base64-encoded ciphertext, and decrypts the same way (src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php:476). Rust seal/open work on raw ciphertext and a raw 16-byte GCM tag (crates/appwrite/core/src/crypto.rs:70-90), so any file written by the PHP runtime with bucket encryption enabled fails tag verification in Rust's decrypt and download/view/preview return 500 'Failed to decrypt file'. The reverse also holds for Rust-written files read by PHP.

📋 Prompt for AI Agents

In crates/appwrite/storage/src/http/content.rs lines 341-346 (decrypt), detect the PHP encoding (options=0 base64 ciphertext per src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php:476) and base64-decode the data before calling appwrite_core::crypto::open, falling back to raw bytes when base64 decoding fails, so files encrypted by either runtime decrypt correctly. Verify the PHP serving decrypt options (Download/Get.php uses options=0) before choosing the default path.

Comment on lines +619 to +624
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
- _APP_STORAGE_S3_REGION
- _APP_STORAGE_S3_BUCKET
- _APP_STORAGE_S3_ENDPOINT

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 bug · medium

Rust storage silently ignores non-AWS S3 devices

The Traefik rule routes all /v1/storage traffic to the Rust container, but Rust's storage factory recognizes only _APP_STORAGE_DEVICE values s3/awss3 (plus the implicit local) and silently falls back to the local device otherwise (crates/appwrite/storage/src/device.rs:172-175). PHP supports do_spaces, backblaze, linode and wasabi devices, and their env vars are not passed to the Rust service either (docker-compose.override.yml:619-624). A deployment configured with one of those devices gets uploads written to the local volume by Rust while PHP uses the object store, producing divergent file placement depending on which runtime served the request.

📋 Prompt for AI Agents

In crates/appwrite/storage/src/device.rs for_root() (around lines 165-176), treat an unimplemented _APP_STORAGE_DEVICE value as a startup error (or map the additional S3-compatible provider names PHP supports onto the same S3 device with their env vars) instead of silently falling back to Stored::Local; also add the missing provider env vars to the appwrite-rust service environment in docker-compose.override.yml.

Comment on lines +161 to +178
async fn collect_body(mut body: Incoming, max: usize) -> Result<Bytes, ()> {
let mut collected = Vec::new();
let mut over = false;
loop {
match body.frame().await {
Some(Ok(frame)) => {
let Ok(data) = frame.into_data() else { continue };
if over || collected.len().saturating_add(data.len()) > max {
over = true;
continue;
}
collected.extend_from_slice(&data);
}
Some(Err(_)) => return Err(()),
None => break,
}
}
if over { Err(()) } else { Ok(Bytes::from(collected)) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 bug · medium

Unbounded over-limit drain holds concurrency slot

collect_body keeps reading frames after the limit is exceeded with no cap on drained bytes and no timeout (crates/utopia/http/src/server.rs:161-178), and the in-flight guard is taken before collection (crates/utopia/http/src/server.rs:135). header_read_timeout protects only header reads, so a client sending a huge Content-Length or endless chunked stream occupies its concurrency slot, the worker task and bandwidth for the entire upload; under concurrent abuse this is a cheap resource-exhaustion vector the previous Limited path did not have. The drain itself is deliberate (so Traefik forwards the 413), but it needs a bound.

📋 Prompt for AI Agents

In crates/utopia/http/src/server.rs, collect_body (lines 161-178): track drained bytes after over is set and stop reading once a bounded drain window (for example a few MB past max) or a short deadline is reached, returning Err(()) at that point so the connection closes instead of draining the client's stream to EOF.

@github-actions

Copy link
Copy Markdown

✨ Benchmark results

Comparing feat/rust-utopia-conversion (before) → feat/rust-storage (after).

Metric Before After Change
🚀 Requests/sec 175.33 176.08 ⚪ +0.4%
⏱️ Latency P50 98.51 ms 98.52 ms ⚪ 0%
⏱️ Latency P95 236.34 ms 229.05 ms ⚪ -3.1%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 98.52 229.05 11,400 176.08 -7.29
Account 192.7 331.15 600 10.04 -18.7
TablesDB 95.35 176.22 6,200 99.13 -13.68
Storage 92.06 217.57 3,000 49.44 +7.17
Functions 138.87 257.27 1,600 27.03 -26.7

Top API waits (after)

API request Max wait (ms)
account.name.update 493.76
storage.buckets.create 437.63
account.prefs.update 386.68
functions.variables.create 377.14
tablesdb.rows.create 375.91

The service page, facts, tasks and changelog now show Storage served by Rust, with the recorded suite and the benchmark.

Co-authored-by: Cursor <cursoragent@cursor.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Tier B · 2 blocking findings to address. Summary

Comment on lines +166 to +175
let configured = utopia_system::env("_APP_STORAGE_DEVICE").unwrap_or_else(|| "local".to_owned());
let key = utopia_system::env("_APP_STORAGE_S3_ACCESS_KEY").unwrap_or_default();
let secret = utopia_system::env("_APP_STORAGE_S3_SECRET").unwrap_or_default();
let region = utopia_system::env("_APP_STORAGE_S3_REGION").unwrap_or_default();
let bucket = utopia_system::env("_APP_STORAGE_S3_BUCKET").unwrap_or_default();
let endpoint = utopia_system::env("_APP_STORAGE_S3_ENDPOINT").unwrap_or_default();
let s3 =
matches!(configured.as_str(), "s3" | "awss3") && !key.is_empty() && !secret.is_empty() && !bucket.is_empty();
if !s3 {
return Ok(Stored::Local(Local::new(root)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Preserve the existing storage device configuration

This factory only handles _APP_STORAGE_DEVICE values s3 and awss3; otherwise it silently selects local storage. It ignores _APP_CONNECTIONS_STORAGE and the provider-specific device settings that getDevice() in app/init/resources.php supports, so installations using those configurations will no longer find existing files and may write new uploads to the wrong backend.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/appwrite/storage/src/device.rs
Line: 166-175

Comment:
**Preserve the existing storage device configuration**

This factory only handles `_APP_STORAGE_DEVICE` values `s3` and `awss3`; otherwise it silently selects local storage. It ignores `_APP_CONNECTIONS_STORAGE` and the provider-specific device settings that `getDevice()` in `app/init/resources.php` supports, so installations using those configurations will no longer find existing files and may write new uploads to the wrong backend.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · bug · Reply if this doesn't apply.

if !device.exists(&path).await? {
return Err(Error::new(ErrorType::StorageFileNotFound));
}
let mut bytes = base::read_all(device, &path).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Avoid buffering the entire file for downloads

serve reads the whole object into a Vec before handling ranges or returning the response, including plain files larger than the 20 MB transformation buffer and range requests that need only a small slice. PHP streams large files in bounded chunks or reads only the requested range, so concurrent downloads of valid large files can exhaust the Rust service's memory.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/appwrite/storage/src/http/content.rs
Line: 284

Comment:
**Avoid buffering the entire file for downloads**

`serve` reads the whole object into a `Vec` before handling ranges or returning the response, including plain files larger than the 20 MB transformation buffer and range requests that need only a small slice. PHP streams large files in bounded chunks or reads only the requested range, so concurrent downloads of valid large files can exhaust the Rust service's memory.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · performance · Reply if this doesn't apply.

A minute at 500 req/s held more than 1,000 requests. The server answered 503 before reading the body, so uploads failed as broken pipes. The Rust API now allows 4,096 in flight, reads the body before a 503, and reads upload bytes off the runtime threads.

Co-authored-by: Cursor <cursoragent@cursor.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Tier B · 2 blocking findings to address. Summary

// (a busy machine, preview renders, chunked uploads) holds that many
// requests. The benchmark client opens up to 1,040 of them at 500
// req/s, and each rejection was a 503. 4,096 still bounds a flood.
options.max_concurrency = appwrite_core::config::env_int("_APP_RUST_MAX_CONCURRENCY", 4_096).max(1) as usize;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Size concurrency against the request-body memory budget

This admits 4,096 simultaneous requests while the server buffers each body up to the 12 MiB limit, so concurrent large uploads can require roughly 48 GiB just for request bodies and OOM the Rust process. Please size this cap against a bounded memory budget or add an aggregate body-memory limit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/appwrite/server/src/main.rs
Line: 91

Comment:
**Size concurrency against the request-body memory budget**

This admits 4,096 simultaneous requests while the server buffers each body up to the 12 MiB limit, so concurrent large uploads can require roughly 48 GiB just for request bodies and OOM the Rust process. Please size this cap against a bounded memory budget or add an aggregate body-memory limit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · performance · Reply if this doesn't apply.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bound the time spent draining rejected request bodies

This waits for EOF before returning 503, and collect_body keeps draining even after the size limit is exceeded; with no body-read timeout, clients can pause or trickle data indefinitely and keep arbitrarily many rejected connection tasks open beyond the concurrency cap. Please use a bounded drain/deadline so overload responses cannot be held open this way.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/utopia/http/src/server.rs
Line: 142

Comment:
**Bound the time spent draining rejected request bodies**

This waits for EOF before returning 503, and `collect_body` keeps draining even after the size limit is exceeded; with no body-read timeout, clients can pause or trickle data indefinitely and keep arbitrarily many rejected connection tasks open beyond the concurrency cap. Please use a bounded drain/deadline so overload responses cannot be held open this way.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · security · Reply if this doesn't apply.

Rust now holds 500 requests per second with no server errors. The service page, facts and changelog use that run.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the speedup labels under each latency curve with the same verdict badges used elsewhere, and publish the latest Teams run.

Co-authored-by: Cursor <cursoragent@cursor.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant