Skip to content

Commit 482a528

Browse files
pfbyjyclaude
andauthored
Load full task file tree in one request, inline small contents (#998)
* perf: load task file tree and contents in one round trip The task-definition file pane was slow because every interaction paid a full browser -> Next.js -> backend -> S3 round trip: one request for the root listing, one more per folder expand, and one per file click (archive- backed tasks get no per-file presigned URLs, so content always took the proxy path too). - Frontend: fetch the listing recursively once on open and build the full nested tree client-side. Folder expands are now instant; the lazy per-prefix loader is kept only as a fallback for non-recursive responses. The public-share auto-select now prefers instruction.md so the nested tree doesn't default to a file inside environment/. - Backend: archive-backed recursive listings inline small UTF-8 file contents (100KB/file, 4MB total caps) straight from the already-loaded tarball, so the tree and most file bodies arrive in a single response and file clicks render with no further requests. Binary and oversize members still go through the per-file endpoint. Inlining copies the member dicts so the per-process archive cache stays pristine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJwwqogxTKMCswnP8f4H7N * perf: drop lazy directory fallback, inline all layouts, add skeleton Follow-up to the one-round-trip listing change: - Remove the per-directory lazy-load fallback entirely (loadDirectory, loadingDirs, isLoaded, updateTree, buildNodesFromListing). Task trees are shallow; the single recursive request is the only load path now, and folder expands are pure UI state. - Inline small file contents server-side for the expanded per-file and plain S3 layouts too (concurrent bounded GETs next to S3), matching the archive path, so every task listing returns the tree and the file bodies in one response. Best-effort: binary/oversize/failed fetches fall back to the per-file URL. - Replace the loading spinners with a whole-pane skeleton that mirrors the sidebar + content layout while the listing request is in flight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJwwqogxTKMCswnP8f4H7N * fix: clear stale loading flag when selecting a file with cached content Switching from a file with an in-flight content fetch to one with inlined/cached content left fileContentLoading stuck true: the cancelled fetch skips its own reset and the cached-content early return never cleared it, so the content pane showed the skeleton indefinitely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJwwqogxTKMCswnP8f4H7N * perf: stream task file listings — tree first, contents as they load The one-shot listing blocked on the whole content fan-out before the client saw anything. Listings can now stream NDJSON (stream=1): a listing chunk goes out as soon as the tree is known, then per-file content chunks follow as bodies load, shallowest files first. The panel paints the tree off the first chunk and patches contents in behind it; a chunk for the file currently on screen renders immediately. - storage: parse task tarballs once (single gzip decompress) extracting member metadata and inline-eligible text bodies in the same pass, and cache (bytes, members, texts) per archive — warm listings and file reads do zero decompression. This also fixes the double decompress per listing flagged by review. stream_task_files() yields the bare tree then contents from the cached texts (archive) or an as-completed concurrent S3 fan-out (expanded/plain layouts). - API: stream=1 on the authed, public-share, and self-hosted task files endpoints returns NDJSON via a shared response helper; non-stream responses are unchanged. - Next.js proxies pass NDJSON bodies through instead of buffering. - Frontend requests stream=1 and consumes the stream progressively; endpoints that don't stream (trial files) ignore the param and the plain-JSON path still applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJwwqogxTKMCswnP8f4H7N * fix: address stream review findings - task_expand_handler unpacked _load_task_archive's old 2-tuple; every TASK_EXPAND job hitting an archive raised ValueError. Unpack the new (bytes, members, texts) shape (and align the test fake, which is why the suite missed it). - make_task_files_ndjson_response now awaits the first chunk before constructing the StreamingResponse, so listing-time failures (task not found, storage errors) return real HTTP error statuses instead of silently truncating an already-started 200 stream. - Frontend: a stream failure after the tree has painted no longer replaces the tree with an error state, and a failed dedicated content fetch no longer overwrites a body the stream already delivered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJwwqogxTKMCswnP8f4H7N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 232199f commit 482a528

11 files changed

Lines changed: 788 additions & 203 deletions

File tree

backend/api/routers/tasks.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
ensure_experiment_public,
6868
get_task_file_content_s3,
6969
list_task_files_s3,
70+
make_task_files_ndjson_response,
71+
stream_task_files_s3,
7072
)
7173
from oddish.core.idempotency import (
7274
IdempotencyReplay,
@@ -1636,11 +1638,17 @@ async def list_task_files(
16361638
True, description="Include presigned URLs for direct S3 access"
16371639
),
16381640
version: int | None = Query(None, description="Task version number"),
1639-
) -> dict:
1641+
stream: bool = Query(
1642+
False,
1643+
description="Stream NDJSON: the file tree first, then file contents",
1644+
),
1645+
):
16401646
"""List all files in a task's S3 directory.
16411647
16421648
When presign=True (default), includes presigned URLs for each file,
16431649
allowing clients to fetch content directly from S3 without additional API calls.
1650+
With stream=True the response is NDJSON: a listing chunk as soon as the
1651+
tree is known, then per-file content chunks as they load.
16441652
"""
16451653
auth.require_scope(APIKeyScope.READ)
16461654

@@ -1654,6 +1662,19 @@ async def list_task_files(
16541662
if version is None and task.current_version:
16551663
version = task.current_version.version
16561664

1665+
if stream:
1666+
return await make_task_files_ndjson_response(
1667+
stream_task_files_s3(
1668+
task_id=task_id,
1669+
prefix=prefix,
1670+
recursive=recursive,
1671+
limit=limit,
1672+
cursor=cursor,
1673+
presign=presign,
1674+
version=version,
1675+
)
1676+
)
1677+
16571678
return await list_task_files_s3(
16581679
task_id=task_id,
16591680
prefix=prefix,

frontend/src/app/api/public/experiments/[token]/tasks/[task_id]/files/route.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,24 @@ export async function GET(
2020
return NextResponse.json(error, { status: res.status });
2121
}
2222

23+
const cacheControl = "public, max-age=600, stale-while-revalidate=60";
24+
25+
// Streamed listings (stream=1) are NDJSON — pass the body through so the
26+
// client can paint the tree before the file contents finish loading.
27+
const contentType = res.headers.get("content-type") ?? "";
28+
if (contentType.includes("application/x-ndjson")) {
29+
return new NextResponse(res.body, {
30+
headers: {
31+
"Content-Type": "application/x-ndjson",
32+
"Cache-Control": cacheControl,
33+
},
34+
});
35+
}
36+
2337
const data = await res.json();
2438
return NextResponse.json(data, {
2539
headers: {
26-
"Cache-Control": "public, max-age=600, stale-while-revalidate=60",
40+
"Cache-Control": cacheControl,
2741
},
2842
});
2943
} catch (error) {

frontend/src/app/api/tasks/[task_id]/files/route.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,26 @@ export async function GET(
2727
return NextResponse.json(error, { status: res.status });
2828
}
2929

30-
const data = await res.json();
30+
// Presigned URLs expire in 15 min, so cache for 10 min to be safe.
31+
// This allows the browser to reuse the listing without hitting the backend.
32+
const cacheControl = "private, max-age=600, stale-while-revalidate=60";
33+
34+
// Streamed listings (stream=1) are NDJSON — pass the body through so the
35+
// client can paint the tree before the file contents finish loading.
36+
const contentType = res.headers.get("content-type") ?? "";
37+
if (contentType.includes("application/x-ndjson")) {
38+
return new NextResponse(res.body, {
39+
headers: {
40+
"Content-Type": "application/x-ndjson",
41+
"Cache-Control": cacheControl,
42+
},
43+
});
44+
}
3145

32-
// Presigned URLs expire in 15 min, so cache for 10 min to be safe
33-
// This allows the browser to reuse the listing without hitting the backend
46+
const data = await res.json();
3447
return NextResponse.json(data, {
3548
headers: {
36-
"Cache-Control": "private, max-age=600, stale-while-revalidate=60",
49+
"Cache-Control": cacheControl,
3750
},
3851
});
3952
} catch (error) {

0 commit comments

Comments
 (0)