Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -10370,6 +10370,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"404": {
"description": "Not found",
},
"416": {
"description": "Requested byte range is not satisfiable",
},
},
"summary": "Download session recording for a specific attempt",
"tags": [
Expand Down Expand Up @@ -10456,6 +10459,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"404": {
"description": "Not found",
},
"416": {
"description": "Requested byte range is not satisfiable",
},
},
"summary": "Download session recording",
"tags": [
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/routes/requests/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,50 @@ describe("Video endpoints", () => {
expect(mockDownload).toHaveBeenCalledWith(0, 500);
});

it.each([
["bytes=10-999", 10, 16],
["bytes=10-", 10, 16],
["bytes=-5", 12, 16],
["bytes=-100", 0, 16],
])("serves the available bytes for %s", async (range, start, end) => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video.webm")] });
const content = "mock-blob-content".slice(start, end + 1);
mockDownload.mockResolvedValue({ readableStreamBody: readableFrom(content) });

const res = await supertest(testServer()).get(vidUrl("req-1"))
.set("Range", range).buffer(true).parse(rawParser);

expect(res.status).toBe(206);
expect(res.headers["content-range"]).toBe(`bytes ${start}-${end}/17`);
expect(res.headers["content-length"]).toBe(String(content.length));
expect(res.body.toString()).toBe(content);
expect(mockDownload).toHaveBeenCalledWith(start, content.length);
});

it.each(["bytes=17-", "bytes=30-40", "bytes=10-5", "bytes=-0"])(
"rejects unsatisfiable %s before downloading", async (range) => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video.webm")] });

const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", range);

expect(res.status).toBe(416);
expect(res.headers["content-range"]).toBe("bytes */17");
expect(mockDownload).not.toHaveBeenCalled();
},
);

it.each(["items=0-1", "bytes=0-1,4-5", "invalid"])(
"ignores unsupported or malformed range %s", async (range) => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video.webm")] });

const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", range);

expect(res.status).toBe(200);
expect(res.headers["content-range"]).toBeUndefined();
expect(mockDownload).toHaveBeenCalledWith();
},
);

it("returns 400 for invalid iteration number", async () => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done" });
const res = await supertest(testServer()).get(vidUrl("req-1", "?iteration=abc"));
Expand Down
53 changes: 31 additions & 22 deletions apps/api/src/routes/requests/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,29 +77,32 @@ async function handleVideo(
const properties = await blobClient.getProperties();
const totalSize = properties.contentLength ?? 0;

// Support HTTP Range requests for video seeking
const rangeHeader = req.headers.range;
if (rangeHeader && totalSize > 0) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
const chunkSize = end - start + 1;

const downloadResponse = await blobClient.download(start, chunkSize);
if (!downloadResponse.readableStreamBody) {
res.status(500).json({ error: "Failed to download video file" });
return;
}
// Support one complete byte-range specification. Ignore other units,
// malformed headers and multipart ranges, which this endpoint cannot serve.
const range = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
if (range && (range[1] || range[2])) {
const start = range[1] ? Number(range[1]) : Math.max(0, totalSize - Number(range[2]));
const end = range[1] && range[2] ? Math.min(Number(range[2]), totalSize - 1) : totalSize - 1;
if (start > end || start >= totalSize) {
res.setHeader("Content-Range", `bytes */${totalSize}`);
res.status(416).end();
return;
}
const chunkSize = end - start + 1;

res.status(206);
res.setHeader("Content-Type", "video/webm");
res.setHeader("Content-Range", `bytes ${start}-${end}/${totalSize}`);
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", chunkSize);
downloadResponse.readableStreamBody.pipe(res);
const downloadResponse = await blobClient.download(start, chunkSize);
if (!downloadResponse.readableStreamBody) {
res.status(500).json({ error: "Failed to download video file" });
return;
}

res.status(206);
res.setHeader("Content-Type", "video/webm");
res.setHeader("Content-Range", `bytes ${start}-${end}/${totalSize}`);
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", chunkSize);
downloadResponse.readableStreamBody.pipe(res);
return;
}

const downloadResponse = await blobClient.download();
Expand Down Expand Up @@ -130,7 +133,10 @@ apiRoute(ctx.app, ctx.registry, {
response: z.any(),
rawResponse: true,
responseDescription: "WebM video recording (supports Range requests)",
errorResponses: { 404: { description: "Not found" } },
errorResponses: {
404: { description: "Not found" },
416: { description: "Requested byte range is not satisfiable" },
},
handler: async (req, res) => {
try {
const { id } = req.params;
Expand Down Expand Up @@ -162,7 +168,10 @@ apiRoute(ctx.app, ctx.registry, {
response: z.any(),
rawResponse: true,
responseDescription: "WebM video recording (supports Range requests)",
errorResponses: { 404: { description: "Not found" } },
errorResponses: {
404: { description: "Not found" },
416: { description: "Requested byte range is not satisfiable" },
},
handler: async (req, res) => {
try {
const { id, runId } = req.params;
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/app-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,14 @@ agent-version records may omit `queueName`; registry readers treat missing,
blank, and whitespace-only values as unavailable rather than throwing. New
registrations continue to require a non-empty queue.

### Recording downloads

The request-level and attempt-level `/video` routes support a single HTTP byte
range, including open-ended and suffix ranges. Range ends are clamped to the
recording size. Unsatisfiable ranges return **416** with
`Content-Range: bytes */<size>` before downloading from storage. Unsupported
range units and multipart ranges are ignored and receive the full **200** response.

### Run submission flow

1. User submits via Portal or CLI with: **task**, **criteria** (required), **worker**, **model** (required), and optionally **agentVersion**, a **codebase** selection, and/or a per-gate **`gates`** configuration (see [Gates](#gates--multi-phase-evaluation-pipeline))
Expand Down
2 changes: 1 addition & 1 deletion website/src/openapi/scope-openapi.json

Large diffs are not rendered by default.