Download latest artifacts as a ZIP - #705
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an end-to-end “Download all” flow for run artifacts: a new server endpoint streams a ZIP containing the latest version of each artifact path (matching the artifacts page’s “latest” semantics), updates the web UI to expose the action, and documents/regenerates the OpenAPI + TypeScript client surface.
Changes:
- Add
GET /api/v1/runs/{id}/artifacts/downloadto stream a ZIP of latest artifacts without buffering full contents in memory. - Extend
fabro-storewith a streaming artifact read API (get_stream) and add server-side ZIP path validation. - Add a “Download all” action in the run artifacts page, and update OpenAPI + generated TS client.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| lib/apps/fabro-server/src/server/handler/artifacts.rs | Implements ZIP streaming endpoint, latest-artifact selection, and ZIP path validation. |
| lib/components/fabro-store/src/artifact_store.rs | Adds streaming read support (get_stream) and a shared relative-path validator helper. |
| lib/apps/fabro-server/src/server.rs | Excludes application/zip responses from response compression. |
| lib/apps/fabro-server/src/server/handler/mod.rs | Wires the new route in demo mode as not_implemented. |
| lib/apps/fabro-server/src/server/tests.rs | Adds integration test asserting ZIP contents/headers and “latest” selection behavior. |
| docs/public/api-reference/fabro-api.yaml | Documents the new download endpoint and response shape. |
| lib/packages/fabro-api-client/src/api/run-internals-api.ts | Regenerates API client bindings for downloadRunArtifacts. |
| apps/fabro-web/app/routes/run-artifacts.tsx | Adds “Download all” link in the artifacts UI header. |
| apps/fabro-web/app/lib/api-client.ts | Adds runArtifactsDownloadUrl() helper for the download href. |
| apps/fabro-web/app/lib/api-client.test.ts | Adds unit test for runArtifactsDownloadUrl() escaping. |
| lib/apps/fabro-server/Cargo.toml | Adds async_zip dependency for ZIP generation. |
| Cargo.toml | Adds workspace dependency entry for async_zip. |
| Cargo.lock | Locks the new async_zip dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Path safety now lives in one place. The NUL-byte and drive-letter rules move from a server-only helper into the store's own filename validation, so uploads reject those paths at write time instead of only the ZIP read path catching them. The download still re-checks, because artifacts stored before the rule existed can still carry an unsafe path, but it now skips a bad path rather than failing the whole archive. Promote is_boundary_stage to RunProjection and drop the three identical private copies. The ZIP download used a node-name match instead, which would have dropped artifacts from a working node that happened to be named "start". Compress the archive. Entries were Stored while the response was also excluded from transfer compression, so text artifacts moved at full size. async_zip gains the deflate feature; async-compression and flate2 were already in the lock file. Log archive failures unconditionally. The send-succeeded guard meant a client that had already disconnected left no record at all, which is the case where the log is the only evidence. Also: collapse the duplicate 500 arms, drop the dead stage-ID tiebreaker and the cached order in the selection map, name the accessible label after the visible one, share the run URL prefix between the two download href builders, and document the mid-stream truncation behavior in the OpenAPI description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran a simplification pass over this branch (three parallel reviews: reuse, quality, efficiency). Path safety moved to one place. The NUL-byte and drive-letter checks were server-only, so uploads could still store
The archive is now compressed. Entries were Archive failures always log. Smaller: collapsed the two duplicate 500 arms; dropped the dead stage-ID tiebreaker and the cached order from the selection map; Considered and left alone: the Not done, worth a follow-up: the "latest version wins" rule is implemented twice, here and in Verified: |
Deflate flushes its output in 8 KiB blocks, and each write became its own allocation, channel send, and HTTP body frame. A 64 KiB BufWriter in front of the sink cuts all three by eight. An artifact deleted between the listing and its read no longer aborts the whole archive. That race is a run being pruned mid-download; leaving the file out beats handing back a truncated ZIP missing everything after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up from the efficiency review, pushed as Batched the archive writes. Deflate flushes output in 8 KiB blocks, and A vanished artifact no longer aborts the archive. If an object disappears between the LIST and its GET — realistically One thing I did not do, and I think it needs a decision. There is no cap on archive size, artifact count, or time. Per-artifact uploads are capped at 10 MB and multipart requests at 50 MB, but a run accumulates artifacts across every stage and retry, so the total is unbounded — one authenticated GET turns into unbounded object-store reads plus egress, and the router has no timeout or concurrency limit. Deflate adds CPU per in-flight download on top. The fix is cheap because Also confirmed as non-issues, so nobody re-investigates: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
docs/public/api-reference/fabro-api.yaml:3293
- The spec text says “
startandexitcontrol nodes”, but the implementation excludes boundary stages by handler type (not node ID). This description is ambiguous for workflows where the control node IDs aren’t literallystart/exit(or where a real stage happens to be namedstart).
Streams a ZIP archive with the latest captured version of each artifact path.
Stage order and retry number determine the latest version, matching the artifacts page.
Captures from the `start` and `exit` control nodes are excluded.
lib/apps/fabro-server/src/server/handler/artifacts.rs:224
latest_run_artifactsbreaks ties only on(stage_order, retry). When those are equal (e.g. unknown stages wherestage_orderisNone), the chosen “latest” depends on iteration order rather than matching the artifacts UI’s additional stage-id tie-breaker, which can make ZIP contents surprising/non-deterministic.
.iter_stages()
.enumerate()
.map(|(order, (stage_id, _))| (stage_id.clone(), order))
.collect::<HashMap<_, _>>();
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
lib/components/fabro-store/src/artifact_store.rs:538
- If ':' is rejected in artifact paths for Windows safety, it should be covered by this validation test set (e.g.
logs:output.txtorfile.txt:ads) to prevent regressions.
for filename in [
"",
"/escape.txt",
"../escape.txt",
"logs//output.txt",
"logs/./output.txt",
r"logs\output.txt",
"C:/escape.txt",
"c:escape.txt",
"bad\0name.txt",
] {
lib/apps/fabro-server/src/server/handler/artifacts.rs:311
- This logs a warning and tries to poison the body even when the failure is just the client disconnecting mid-download (the sink maps receiver drop to
BrokenPipe). That can create noisy/misleading warnings in normal usage (canceled downloads). Consider treatingBrokenPipeas a normal termination and skipping the warn/error signalling in that case (similar to how sandbox stdin handling treats BrokenPipe as non-error).
tokio::spawn(async move {
if let Err(error) = write_artifact_archive(writer, artifact_store, run_id, artifacts).await
{
// Log before signalling: the send fails when the caller has already
// gone away, and that is exactly when this log is the only record
lib/components/fabro-store/src/artifact_store.rs:272
validate_filename_segmentsstill permits ':' in non-drive-letter positions (e.g.logs:output.txtorfile.txt:ads). On Windows/NTFS this can be an invalid filename and can also be interpreted as an Alternate Data Stream, so allowing it undermines the stated “portable/safe on Windows” guarantee and could create surprising extraction behavior.
This issue also appears on line 528 of the same file.
let bytes = filename.as_bytes();
if bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':') {
return Err(Error::Other(
"artifact filename must not start with a drive letter".to_string(),
));
}
let segments = filename.split('/').collect::<Vec<_>>();
Two artifacts can share a filename, a retry, and an absent stage, in which case the winner was whichever the object store listed first. Break the tie on the serialized stage ID, which is the third key the artifacts page sorts on. Compare the `node@visit` string rather than StageId's own ordering: the page compares the string, so "unknown@2" beats "unknown@10" there and now here too. The spec said captures from the `start` and `exit` nodes are excluded, but the exclusion is by handler type, so a node named `start` that does real work keeps its artifacts. Say that instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Worked through the Copilot review. All three findings were valid — two of them against changes I made in the simplify pass, not the original PR. Fixed in Tie-break ordering (inline thread, now resolved). The original Spec wording (suppressed comment on Non-deterministic ties (suppressed comment on Copilot cannot resolve its own suppressed comments, so I'm answering them here rather than leaving them looking unaddressed. Two things Copilot did not raise that are still open, both from the earlier review pass and unchanged:
Verified after the change: 2767 tests pass across the four touched crates, nightly clippy and fmt clean, both generated clients regenerated and typechecking. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lib/packages/fabro-api-client/src/api/run-internals-api.ts:170
downloadRunArtifactsreturns anAxiosPromise<File>forapplication/zip, but the generated request options don't set an AxiosresponseType. With Axios defaults, this will be treated as text/JSON and can corrupt the ZIP payload unless every caller remembers to overrideoptions.responseTypemanually.
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
lib/apps/fabro-server/src/server/tests.rs:10607
- This enum literal has extra spacing (
actor: None) that will causecargo fmt --checkto fail (and diverges from rustfmt output elsewhere).
workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
},
Summary
Test plan
cargo nextest run -p fabro-servercargo nextest run -p fabro-storecargo nextest run -p fabro-server --features test-support all_spec_routes_are_routablecargo +nightly-2026-04-14 clippy -p fabro-store -p fabro-server --all-targets -- -D warningscargo +nightly-2026-04-14 fmt --check --allcd apps/fabro-web && bun run testcd apps/fabro-web && bun run typecheckcd apps/fabro-web && bun run buildcd lib/packages/fabro-api-client && bun run typecheck