diff --git a/atlas/domains.atlas b/atlas/domains.atlas
index 8e3a04bd..530037f1 100644
--- a/atlas/domains.atlas
+++ b/atlas/domains.atlas
@@ -78,6 +78,9 @@ domain media "Images & assets" {
contains mod:rosetta_store::assets::{renditions, image_refs}, mod:rosettad::image_renditions
touches mod:rosettad::maintenance_pace
contains path:crates/adapters/*/src/images.rs, file:crates/adapters/claude-code/src/media.rs
+ contains mod:rosetta_core::presented_media, mod:rosetta_adapter_support::presented_media, mod:rosetta_pipeline::presented_media
+ contains path:crates/adapters/*/src/presented_media.rs
+ touches mod:rosetta_capture::ledger::document
contains path:clients/room/src/components/file-viewer/, file:clients/room/src/lib/image-media.ts
see "docs/ARCHITECTURE.md#412-authored-images-2026-09-09"
}
diff --git a/clients/app/design/transcript-record.html b/clients/app/design/transcript-record.html
index 2b49e94e..75bd3c45 100644
--- a/clients/app/design/transcript-record.html
+++ b/clients/app/design/transcript-record.html
@@ -1896,6 +1896,7 @@
Images stay in view
Layout. The prompt keeps its filled text bubble, with images in a separate scrolling row beneath it. Replies use the same row beneath their prose. Every activity group collects its tool, team, peer, report and assignment images in one row below the group summary, outside both fold levels. Opening either fold keeps that row in place without duplicating thumbnails. Rounded square thumbnails are 144px wide (112px in a narrow column), and keep the row compact even for long screenshots. A press opens the whole image in the existing viewer; Escape returns to the same place.
Browse the row · 2026-10-01. A multi-image row opens one gallery in the existing viewer. A bottom strip shows every slot in order, with the current thumbnail outlined and its position shown. Previous/next buttons and Left/Right keys move between available images; Home/End reach the row’s ends. Navigation stops at the ends, keeps Fit/1:1 and fullscreen, and never crosses into another row. Missing images keep a disabled placeholder. Loading or retry leaves the strip available. Closing restores the original thumbnail or text link, including inside a full-message popup.
Image loading · 2026-10-01. The row reserves its tile geometry while small WebP thumbnails load. The bottom strip uses smaller thumbnails, Fit uses a display preview, and 1:1 loads the full image. Moving through the conversation releases offscreen image elements without changing the row’s place. Loading and retry keep gallery navigation available. Every generated size comes from the recorded source; the ledger preserves the original, while the image cache holds compressed WebP. SVG/AVIF tool-file previews retain browser rendering without adding original cache copies.
+ Sent videos · 2026-11-12. A video an agent sent the reader sits in the same row as the run's images, in transcript order. It differs from two laws below, on purpose. I-2: its tile is not square. It keeps the row's height (144px, 112px or 96px) and takes the video's own shape, 16:9 when the dimensions were not recorded, never narrower than a square nor wider than 21:9. I-2 and I-6: its name and size are printed on the tile instead of living in a tooltip, because there is no poster to recognise it by: nothing is requested for a video until the reader presses play, not a thumbnail and not the file. A press plays it in place with the browser's own controls; it does not open the room's viewer, and the gallery stays images only. While the video has focus, the arrow, Home and End keys are the video's and do not walk the row. The file is read through the session and played from an object URL the room revokes, so it can be scrubbed; a host whose address honours byte ranges may offer that address instead, and this app offers none because the daemon serves a file whole. Could not play video keeps the slot and offers Retry. A video that was sent but not captured keeps the same tile shape, dashed, with Video not captured , its name and its size; it is not a button and has no Retry. An image sent the same way says Image not captured , which narrows I-4: Image data was not recorded now covers missing bytes, and a recorded reference says it was not captured.
diff --git a/clients/app/package.json b/clients/app/package.json
index ed81e969..d5ea5b91 100644
--- a/clients/app/package.json
+++ b/clients/app/package.json
@@ -31,6 +31,7 @@
"test:activity-chart": "electron test/activity-chart.electron.mjs",
"test:models-chart": "electron test/models-chart.electron.mjs",
"test:room-host": "electron test/room-host.electron.mjs",
+ "test:video-tile": "electron test/video-tile.electron.mjs",
"test:cold-import": "electron test/cold-import.electron.mjs",
"typecheck": "tsc --noEmit"
},
diff --git a/clients/app/renderer/src/host/rosetta-ports.ts b/clients/app/renderer/src/host/rosetta-ports.ts
index d7b35fc9..5f174dcf 100644
--- a/clients/app/renderer/src/host/rosetta-ports.ts
+++ b/clients/app/renderer/src/host/rosetta-ports.ts
@@ -83,8 +83,13 @@ export function sameOriginAssetUrl(client: Pick
, asset
}
}
-/** Every image and attachment by the daemon's own URL: this daemon serves
- * an ` ` without a credential. */
+/** Every image and attachment by the daemon's own URL: this daemon serves an
+ * ` ` without a credential. A video has no address here
+ * (`mediaAssetUrl` is left out): the daemon answers a member whole, without
+ * byte ranges, and a `` loaded from such an address plays but cannot
+ * seek (every seek lands at 0, measured in this app's Electron, 2026-11-12).
+ * The room therefore reads the member's bytes and plays an object URL, which
+ * seeks. */
export function assetsOver(client: RosettaClient): AssetSource {
return {
imageAssetUrl: (asset, variant) => client.imageAssetUrl(asset, variant),
diff --git a/clients/app/test/video-tile.electron.mjs b/clients/app/test/video-tile.electron.mjs
new file mode 100644
index 00000000..9032a232
--- /dev/null
+++ b/clients/app/test/video-tile.electron.mjs
@@ -0,0 +1,509 @@
+// A sent video in the production rail, in a hidden window: HistoryRoom over
+// the room's demo ports, whose second transcript holds one captured and one
+// uncaptured video. Three hosts, one page load each (`?host=`):
+//
+// 1. The failure ladder. The host answers a video by an address on this
+// fixture's own server, which holds its response until the test releases
+// it and then sends a hand-assembled, header-only MP4 (an `ftyp` box and
+// padding). No browser can decode that, so the run walks the whole
+// ladder: nothing asked before the press, the host's address in a
+// ``, the bytes read once that address fails, and the "could not
+// play" face when the bytes cannot play either.
+// 2. The bytes path, which is what rosetta's own app takes (its host offers
+// no `mediaAssetUrl`). The session's `readAsset` answers a real clip:
+// leaving while that read is pending aborts it and makes no URL; played,
+// the object URL decodes, seeks, and is revoked on leaving.
+// 3. A host address that honours byte ranges (206): it plays and seeks
+// with no byte read through the session.
+//
+// No encoded video is committed. The real clip is recorded in the test window
+// itself, from a painted canvas, as H.264 in MP4; a build that cannot record
+// that fails the run by name.
+//
+// The page and its module are served by the plugin below rather than from
+// files: the room's sources are imported as they are, through the app's own
+// Vite config.
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { app, BrowserWindow } from 'electron';
+
+const root = fileURLToPath(new URL('..', import.meta.url));
+const temporary = mkdtempSync(path.join(os.tmpdir(), 'rosetta-video-tile-'));
+const headlessCi = process.platform === 'linux' && Boolean(process.env.CI);
+if (headlessCi) app.disableHardwareAcceleration();
+app.setPath('userData', path.join(temporary, 'profile'));
+app.on('window-all-closed', () => {});
+const pulse = setInterval(() => {}, 100);
+const deadline = setTimeout(() => { console.error('Video tile check timed out'); app.exit(1); }, 90000);
+const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
+let win, server;
+const check = (fn, ...args) => win.webContents.executeJavaScript(`(${fn.toString()})(...${JSON.stringify(args)})`);
+async function until(fn, message, ...args) {
+ const truthy = `(...a) => Boolean((${fn.toString()})(...a))`;
+ for (let i = 0; i < 240; i++) {
+ if (await win.webContents.executeJavaScript(`(${truthy})(...${JSON.stringify(args)})`)) return;
+ await pause(25);
+ }
+ throw Error(message);
+}
+const click = selector => check(s => { const el = document.querySelector(s); if (!el) throw Error(`no ${s}`); el.click(); return true; }, selector);
+
+// An `ftyp` box (24 bytes: isom, minor 512, compatible isom mp41) and a
+// `free` box of padding. A header with no `moov` and no media.
+const HEADER_ONLY_MP4 = Buffer.alloc(64);
+HEADER_ONLY_MP4.writeUInt32BE(24, 0); HEADER_ONLY_MP4.write('ftypisom', 4, 'latin1'); HEADER_ONLY_MP4.writeUInt32BE(512, 12);
+HEADER_ONLY_MP4.write('isommp41', 16, 'latin1'); HEADER_ONLY_MP4.writeUInt32BE(40, 24); HEADER_ONLY_MP4.write('free', 28, 'latin1');
+
+// A short real clip, recorded in the page: about a second and a half of a
+// painted 320×180 canvas. Returned as base64 for the fixture server to hold.
+const CLIP_TYPE = 'video/mp4;codecs=avc1.42E01E';
+const recordClip = async (type, ms) => {
+ if (typeof MediaRecorder !== 'function' || !MediaRecorder.isTypeSupported(type)) {
+ throw Error(`this build cannot record ${type}: no decodable clip to play, so nothing about playback was checked`);
+ }
+ const canvas = document.createElement('canvas');
+ canvas.width = 320; canvas.height = 180;
+ const context = canvas.getContext('2d');
+ let frame = 0;
+ const paint = () => {
+ context.fillStyle = `hsl(${(frame += 7) % 360} 60% 50%)`; context.fillRect(0, 0, 320, 180);
+ context.fillStyle = '#fff'; context.fillRect(frame % 300, 80, 20, 20);
+ };
+ paint();
+ const painting = setInterval(paint, 33);
+ const recorder = new MediaRecorder(canvas.captureStream(30), { mimeType: type, videoBitsPerSecond: 200000 });
+ const chunks = [];
+ recorder.ondataavailable = event => chunks.push(event.data);
+ const stopped = new Promise((resolve, reject) => { recorder.onstop = resolve; recorder.onerror = event => reject(event.error ?? Error('the recorder failed')); });
+ recorder.start();
+ await new Promise(resolve => setTimeout(resolve, ms));
+ recorder.stop();
+ await stopped;
+ clearInterval(painting);
+ const bytes = new Uint8Array(await new Blob(chunks).arrayBuffer());
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+};
+
+const PAGE = '/test/video-tile.html';
+// The recorded clip as a session's client reads it: whole, off the asset route.
+const CLIP = '/test/video-tile.clip.mp4';
+const MODULE = '/test/video-tile.fixture.js';
+const moduleId = path.join(root, 'test/video-tile.fixture.js');
+const HTML = `
+
+ Rosetta · A sent video
+
+
+`;
+// The room over its demo ports. The host's doors are wrapped to record what
+// the room asks of them. `?host=address`: `mediaAssetUrl` answers this
+// server's own address, and a read answers the demo's header-only member.
+// `?host=none`: the door is absent, as in rosetta's app, and a read answers
+// the recorded clip, at once or when the test delivers it.
+const FIXTURE = `
+import { createElement } from "react";
+import { createRoot } from "react-dom/client";
+import { HistoryRoom } from "../../room/src/HistoryRoom";
+import { createDemoPorts, createRoomDemo } from "../../room/src/fixtures";
+import "../renderer/src/styles/tokens.css";
+import "../../room/src/styles/room.css";
+import "../../room/src/styles/ingest.css";
+import "../../room/src/styles/transcript.css";
+import "../../room/src/styles/search.css";
+
+const demo = createRoomDemo();
+const base = createDemoPorts(demo);
+const sent = demo.timelines.get("s8").find((row) => row.body.type === "tool_use").body.tool.attachments;
+const host = new URLSearchParams(location.search).get("host");
+const log = { images: [], media: [], reads: [], made: [], revoked: [], events: [] };
+const signals = [];
+const pending = [];
+const clip = () => fetch(${JSON.stringify(CLIP)}).then((response) => response.arrayBuffer()).then((buffer) => new Uint8Array(buffer));
+// Media events do not bubble; they are heard on their way down.
+for (const name of ["playing", "seeked", "ended", "error"]) {
+ document.addEventListener(name, (event) => { if (event.target instanceof HTMLVideoElement) log.events.push(name); }, true);
+}
+const make = URL.createObjectURL.bind(URL);
+const revoke = URL.revokeObjectURL.bind(URL);
+URL.createObjectURL = (blob) => { const url = make(blob); log.made.push({ url, type: blob.type, size: blob.size }); return url; };
+URL.revokeObjectURL = (url) => { log.revoked.push(url); revoke(url); };
+const ports = {
+ catalog: base.catalog,
+ sessions: {
+ open(id) {
+ const client = base.sessions.open(id);
+ return { ...client, readAsset(asset, signal) {
+ log.reads.push(asset.url);
+ signals.push(signal);
+ if (host !== "none") return client.readAsset(asset, signal);
+ // Deliberately deaf to the signal: a held read's bytes arrive even
+ // after the tile left, so the room itself must drop them.
+ return fixture.holdReads ? new Promise((resolve) => { pending.push(() => resolve(clip())); }) : clip();
+ } };
+ },
+ close(id) { base.sessions.close(id); },
+ },
+ assets: {
+ imageAssetUrl(asset, variant) { log.images.push(asset.url); return base.assets.imageAssetUrl(asset, variant); },
+ attachmentAssetUrl: (asset) => base.assets.attachmentAssetUrl(asset),
+ ...(host === "none" ? {} : {
+ mediaAssetUrl(asset) { log.media.push(asset.url); return new URL(asset.url, location.origin).toString(); },
+ }),
+ },
+};
+const fixture = {
+ log, captured: sent[0].source.asset, uncaptured: sent[1], host, holdReads: false,
+ hasMediaDoor: "mediaAssetUrl" in ports.assets,
+ aborted: () => signals.map((signal) => signal.aborted),
+ // Hand every held read its bytes, and wait for the room to have seen them.
+ deliver: async () => {
+ const count = pending.length;
+ for (const go of pending.splice(0)) go();
+ await clip();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ return count;
+ },
+};
+window.videoFixture = fixture;
+createRoot(document.getElementById("root")).render(
+ createElement("div", { style: { height: "100%" } }, createElement(HistoryRoom, ports)));
+`;
+
+// What reached the asset route, as the server saw it, how it answered, and
+// the responses it holds. `serving` is the route's behaviour for one case.
+const requests = [];
+const answers = [];
+const held = [];
+const release = () => held.splice(0).forEach(end => end());
+const serving = { body: HEADER_ONLY_MP4, ranges: false, hold: true };
+let clip = null;
+const fixturePlugin = {
+ name: 'video-tile-fixture',
+ enforce: 'pre',
+ resolveId(id) { return id === MODULE || id === moduleId ? moduleId : null; },
+ load(id) { return id === moduleId ? FIXTURE : null; },
+ configureServer(vite) {
+ vite.middlewares.use((request, response, next) => {
+ const url = new URL(request.url ?? '/', 'http://fixture');
+ if (url.pathname === PAGE) {
+ vite.transformIndexHtml(PAGE, HTML).then(html => { response.setHeader('content-type', 'text/html'); response.end(html); }, next);
+ return;
+ }
+ if (url.pathname === CLIP) {
+ response.setHeader('content-type', 'video/mp4');
+ response.setHeader('cache-control', 'no-store');
+ response.setHeader('content-length', String(clip.byteLength));
+ response.end(clip);
+ return;
+ }
+ if (!/^\/api\/assets\/[a-f0-9]{64}\.(mp4|mov)$/.test(url.pathname)) { next(); return; }
+ requests.push(`${url.pathname}${url.search}`);
+ // A host's own address for the member, in two shapes. The ladder's
+ // answers whole (200, no ranges) because nothing ever plays from it:
+ // its body is a header no browser can decode. That is NOT what a host
+ // address should look like: Chromium plays a whole answer but cannot
+ // seek it, and raises no error the room could fall back on. An address
+ // a video plays from honours byte ranges (the 206 arm below). The
+ // daemon answers a member whole, so rosetta's app offers no host
+ // address at all and its videos take the bytes path.
+ const { body, ranges } = serving;
+ const answer = () => {
+ response.setHeader('content-type', 'video/mp4');
+ response.setHeader('cache-control', 'no-store');
+ const asked = ranges ? /^bytes=(\d+)-(\d*)$/.exec(request.headers.range ?? '') : null;
+ if (ranges) response.setHeader('accept-ranges', 'bytes');
+ if (asked === null) {
+ answers.push(200);
+ response.statusCode = 200;
+ response.setHeader('content-length', String(body.byteLength));
+ response.end(body);
+ return;
+ }
+ const start = Number(asked[1]);
+ const end = Math.min(asked[2] === '' ? body.byteLength - 1 : Number(asked[2]), body.byteLength - 1);
+ if (start > end) {
+ answers.push(416);
+ response.statusCode = 416;
+ response.setHeader('content-range', `bytes */${body.byteLength}`);
+ response.end();
+ return;
+ }
+ answers.push(206);
+ response.statusCode = 206;
+ response.setHeader('content-range', `bytes ${start}-${end}/${body.byteLength}`);
+ response.setHeader('content-length', String(end - start + 1));
+ response.end(body.subarray(start, end + 1));
+ };
+ if (serving.hold) held.push(answer); else answer();
+ });
+ },
+};
+
+async function run() {
+ try {
+ await app.whenReady(); clearInterval(pulse);
+ const { createServer } = await import('vite');
+ server = await createServer({ configFile: path.join(root, 'vite.config.ts'), root, logLevel: 'error', plugins: [fixturePlugin],
+ server: { host: '127.0.0.1', port: 0, strictPort: false, open: false, hmr: false, watch: null } });
+ await server.listen();
+ const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
+ win = new BrowserWindow({ width: 1440, height: 900, show: headlessCi,
+ webPreferences: { backgroundThrottling: false, contextIsolation: true, nodeIntegration: false } });
+ const errors = [];
+ win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message); });
+ const load = async host => {
+ await win.loadURL(`${origin}${PAGE}?host=${host}`);
+ await until(() => window.videoFixture, 'fixture ready');
+ await until(() => document.querySelectorAll('.ah-project-list-item').length === 6, 'projects through the catalog port');
+ };
+ const openSent = async () => {
+ await click('.ah-project-list-item[data-motion-key="p1"]');
+ await until(() => document.querySelector('.ah-session-list-item[data-motion-key="s8"]'), 'the project\'s sessions');
+ await click('.ah-session-list-item[data-motion-key="s8"]');
+ await until(() => document.querySelectorAll('.tr-attachments figure.tr-att__video').length === 2, 'the run\'s two video tiles');
+ // Let the transcript finish opening: a press in its first moments can
+ // lose the focus the tile takes (the room is still settling its own).
+ await pause(300);
+ };
+ const leave = async () => {
+ await click('.ah-project-list-item[data-motion-key="p0"]');
+ await until(() => !document.querySelector('.tr-attachments figure.tr-att__video'), 'the tiles leave with their transcript');
+ };
+ // A playing video, as the element itself reports it: paused, then sent
+ // to a point past the middle of what it says it can seek.
+ const played = async () => {
+ await until(() => document.querySelector('.tr-attachments video'), 'the video element');
+ await until(() => window.videoFixture.log.events.includes('playing'), 'the video plays');
+ return check(async () => {
+ const video = document.querySelector('.tr-attachments video');
+ const ranges = Array.from({ length: video.seekable.length }, (_, i) => [video.seekable.start(i), video.seekable.end(i)]);
+ const end = ranges.length === 0 ? 0 : ranges[ranges.length - 1][1];
+ const target = Math.round(end * 60) / 100;
+ video.pause();
+ const seeked = new Promise(resolve => video.addEventListener('seeked', () => resolve(true), { once: true }));
+ video.currentTime = target;
+ const fired = await Promise.race([seeked, new Promise(resolve => setTimeout(() => resolve(false), 3000))]);
+ return { src: video.getAttribute('src'), state: video.closest('figure').dataset.video, controls: video.controls,
+ width: video.videoWidth, height: video.videoHeight, ranges, end, target, fired, landed: video.currentTime,
+ error: video.error?.message ?? null, focused: document.activeElement === video };
+ });
+ };
+ const assertPlayed = (result, where) => {
+ assert.equal(result.error, null, `${where}: no element error`);
+ assert.deepEqual([result.state, result.controls, result.focused], ['ready', true, true], `${where}: the ready face, focused`);
+ assert.deepEqual([result.width, result.height], [320, 180], `${where}: a frame was decoded at the clip's own size`);
+ assert.equal(result.ranges.length, 1, `${where}: one seekable range`);
+ assert.ok(result.end > 0.5, `${where}: the seekable range ends above 0 (${result.end})`);
+ assert.ok(result.target > 0.3, `${where}: the seek target is inside the clip (${result.target})`);
+ assert.equal(result.fired, true, `${where}: the seek completes`);
+ assert.ok(result.landed >= result.target && result.landed <= result.end, `${where}: a seek to ${result.target} lands at or past it (${result.landed})`);
+ };
+ const log = () => check(() => window.videoFixture.log);
+
+ await load('address');
+ // The real clip, recorded here before anything is drawn from it.
+ clip = Buffer.from(await check(recordClip, CLIP_TYPE, 1500), 'base64');
+ assert.ok(clip.byteLength > 2000, `a recorded clip, not a header (${clip.byteLength} bytes)`);
+ assert.equal(clip.toString('latin1', 4, 8), 'ftyp', 'an MP4');
+ await openSent();
+ const { captured, uncaptured } = await check(() => ({ captured: window.videoFixture.captured, uncaptured: window.videoFixture.uncaptured }));
+ const address = `${origin}${captured.url}`;
+
+ // Before the press: a tile with a glyph, the name and the size. No
+ // poster, no thumbnail, no address asked, no byte read, no request.
+ const before = await check(() => {
+ const [play, missing] = document.querySelectorAll('.tr-attachments figure.tr-att__video');
+ const tile = play.querySelector('.tr-att__tile');
+ const dashed = missing.querySelector('.tr-att__tile');
+ return {
+ rail: document.querySelectorAll('.tr-attachments').length,
+ states: [play.dataset.video, missing.dataset.video],
+ play: { tag: tile.tagName, label: tile.getAttribute('aria-label'), name: tile.querySelector('.tr-att__name').textContent,
+ glyph: tile.querySelectorAll('svg').length, height: Math.round(tile.getBoundingClientRect().height),
+ width: Math.round(tile.getBoundingClientRect().width) },
+ missing: { tag: dashed.tagName, text: dashed.querySelector('span').textContent, facts: dashed.querySelector('.tr-att__facts').textContent,
+ buttons: missing.querySelectorAll('button').length, height: Math.round(dashed.getBoundingClientRect().height),
+ width: Math.round(dashed.getBoundingClientRect().width) },
+ media: document.querySelectorAll('.tr-attachments video, .tr-attachments img').length,
+ poster: document.querySelectorAll('[poster]').length,
+ };
+ });
+ writeFileSync(path.join(temporary, 'before-play.png'), (await win.webContents.capturePage()).toPNG());
+ assert.equal(before.rail, 1, 'one rail for the run');
+ assert.deepEqual(before.states, ['idle', 'uncaptured']);
+ assert.equal(before.play.tag, 'BUTTON');
+ assert.match(before.play.label, /^Play video — room-walkthrough\.mp4 · 3\.40 MB$/);
+ assert.deepEqual([before.play.name, before.play.glyph], ['room-walkthrough.mp4', 1]);
+ assert.deepEqual([before.play.height, before.play.width], [144, 256], 'the rail\'s height, the video\'s 16:9');
+ assert.deepEqual(before.missing, { tag: 'DIV', text: 'Video not captured', facts: `${uncaptured.name} · 11.0 MB`, buttons: 0, height: 144, width: 256 },
+ 'sent, not captured: the same shape, said in words, not a button, no Retry');
+ assert.deepEqual([before.media, before.poster], [0, 0], 'no poster and no thumbnail');
+ assert.deepEqual(requests, [], 'nothing asked of the asset route before the press');
+ const idle = await log();
+ assert.deepEqual([idle.media, idle.reads, idle.made, idle.images], [[], [], [], []], 'no address, no bytes, no image rendition for a video');
+
+ // The press: an inline at the host's address.
+ await click('.tr-attachments figure.tr-att__video button');
+ await until(() => document.querySelector('.tr-attachments video'), 'the video element');
+ const playing = await check(() => {
+ const video = document.querySelector('.tr-attachments video');
+ return { src: video.getAttribute('src'), controls: video.controls, poster: video.getAttribute('poster'),
+ state: video.closest('figure').dataset.video, focused: document.activeElement === video,
+ buttons: video.closest('figure').querySelectorAll('button').length };
+ });
+ assert.deepEqual(playing, { src: address, controls: true, poster: null, state: 'ready', focused: true, buttons: 0 });
+ for (let i = 0; i < 80 && requests.length === 0; i++) await pause(25);
+ assert.deepEqual(requests, [captured.url], 'the element asks the member\'s plain address, once');
+ const asked = await log();
+ assert.deepEqual([asked.media, asked.reads, asked.made], [[captured.url], [], []], 'one address asked, no bytes read yet');
+
+ // The rail's keys are the video's while it has focus: nothing is
+ // prevented, focus stays, the rail does not move. On the tile around the
+ // video the same key still walks the rail.
+ const keys = await check(() => {
+ const rail = document.querySelector('.tr-attachments .tr-att');
+ const video = rail.querySelector('video');
+ const left = rail.scrollLeft;
+ const press = (target, key) => {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ target.dispatchEvent(event);
+ return event.defaultPrevented;
+ };
+ video.focus({ preventScroll: true });
+ const onVideo = ['ArrowRight', 'ArrowLeft', 'Home', 'End'].map(key => press(video, key));
+ const stayed = document.activeElement === video && rail.scrollLeft === left;
+ const tile = video.closest('.tr-att__tile');
+ tile.focus({ preventScroll: true });
+ const onTile = press(tile, 'ArrowRight');
+ const moved = document.activeElement?.closest('figure')?.dataset.video;
+ video.focus({ preventScroll: true });
+ return { onVideo, stayed, onTile, moved };
+ });
+ assert.deepEqual(keys, { onVideo: [false, false, false, false], stayed: true, onTile: true, moved: 'uncaptured' });
+
+ // The address answers bytes the browser cannot decode: the room reads the
+ // member through the session and plays an object URL; that cannot play
+ // either, so the tile says so, offers Retry, and the URL is revoked.
+ release();
+ await until(() => window.videoFixture.log.reads.length === 1, 'the bytes are read once the address fails');
+ await until(() => document.querySelector('.tr-attachments figure[data-video="failed"]'), 'the could-not-play face');
+ const failed = await check(() => {
+ const figure = document.querySelector('.tr-attachments figure[data-video="failed"]');
+ return { text: figure.querySelector('.tr-att__tile span').textContent, retry: figure.querySelectorAll('.tr-att__retry').length,
+ named: figure.querySelector('.tr-att__retry').getAttribute('aria-label'),
+ videos: document.querySelectorAll('.tr-attachments video').length,
+ focused: document.activeElement === figure.querySelector('.tr-att__retry') };
+ });
+ assert.deepEqual(failed, { text: 'Could not play video', retry: 1, named: 'Retry video — room-walkthrough.mp4', videos: 0, focused: true },
+ 'focus follows the tile to Retry, which says what it retries');
+ writeFileSync(path.join(temporary, 'could-not-play.png'), (await win.webContents.capturePage()).toPNG());
+ const after = await log();
+ assert.deepEqual(after.reads, [captured.url], 'read once, through the session');
+ assert.equal(after.made.length, 1);
+ assert.deepEqual([after.made[0].type, after.made[0].size], ['video/mp4', 64], 'an object URL over the member\'s bytes');
+ assert.match(after.made[0].url, /^blob:/);
+ assert.deepEqual(after.revoked, [after.made[0].url], 'and revoked when it could not play');
+ assert.ok(requests.every(request => request === captured.url), 'only the member\'s plain address was ever requested');
+ assert.deepEqual(after.images, [], 'a video is never handed to imageAssetUrl');
+
+ // Retry asks the host again.
+ const seen = requests.length;
+ await click('.tr-attachments .tr-att__retry');
+ await until(() => document.querySelector('.tr-attachments video'), 'the video again on Retry');
+ assert.equal(await check(() => document.querySelector('.tr-attachments video').getAttribute('src')), address);
+ assert.deepEqual((await log()).media, [captured.url, captured.url]);
+ for (let i = 0; i < 80 && requests.length === seen; i++) await pause(25);
+ assert.equal(requests.length, seen + 1, 'one more request for the retry');
+
+ // Leaving the transcript takes the element with it.
+ await leave();
+ assert.equal(await check(() => document.querySelectorAll('.tr-attachments video').length), 0, 'the video leaves with its transcript');
+ release();
+
+ // ── A host with no video address, as rosetta's app is: the bytes path ──
+ Object.assign(serving, { body: clip, ranges: false, hold: false });
+ requests.length = 0; answers.length = 0;
+ await load('none');
+ assert.deepEqual(await check(() => [window.videoFixture.host, window.videoFixture.hasMediaDoor]), ['none', false], 'this host offers no mediaAssetUrl');
+
+ // Leaving while the read is still pending: the read is aborted, and the
+ // bytes that arrive afterwards make no URL.
+ await check(() => { window.videoFixture.holdReads = true; });
+ await openSent();
+ await click('.tr-attachments figure.tr-att__video button');
+ await until(() => document.querySelector('.tr-attachments figure[data-video="loading"]'), 'the loading face while the read is pending');
+ const waiting = await check(() => {
+ const button = document.querySelector('.tr-attachments figure[data-video="loading"] button');
+ return { label: button.getAttribute('aria-label'), busy: button.getAttribute('aria-busy'), text: button.querySelector('.tr-att__facts').textContent,
+ focused: document.activeElement === button, aborted: window.videoFixture.aborted(), videos: document.querySelectorAll('.tr-attachments video').length };
+ });
+ assert.deepEqual(waiting, { label: 'Loading video — room-walkthrough.mp4', busy: 'true', text: 'Loading video…', focused: true, aborted: [false], videos: 0 },
+ 'one read in flight, said aloud as loading');
+ await leave();
+ assert.deepEqual(await check(() => window.videoFixture.aborted()), [true], 'leaving aborts the read in flight');
+ assert.equal(await check(() => window.videoFixture.deliver()), 1, 'the held read is handed its bytes after the tile left');
+ const left = await log();
+ assert.deepEqual([left.reads, left.made, left.revoked], [[captured.url], [], []], 'late bytes for a tile that left make no URL');
+ assert.equal(await check(() => document.querySelectorAll('video').length), 0);
+
+ // Played from the bytes: a real frame, a seekable range, a seek that lands.
+ await check(() => { window.videoFixture.holdReads = false; });
+ await openSent();
+ await click('.tr-attachments figure.tr-att__video button');
+ const fromBytes = await played();
+ writeFileSync(path.join(temporary, 'playing-from-bytes.png'), (await win.webContents.capturePage()).toPNG());
+ assertPlayed(fromBytes, 'bytes');
+ const read = await log();
+ assert.deepEqual(read.reads, [captured.url, captured.url], 'one read through the session for this press');
+ assert.equal(read.made.length, 1, 'one object URL');
+ assert.deepEqual([read.made[0].type, read.made[0].size], ['video/mp4', clip.byteLength], 'over the member\'s recorded bytes');
+ assert.match(read.made[0].url, /^blob:/);
+ assert.equal(fromBytes.src, read.made[0].url, 'the element plays the object URL');
+ assert.deepEqual([read.media, read.revoked, read.images], [[], [], []], 'no host address asked, nothing revoked while it plays');
+ assert.deepEqual(requests, [], 'the member\'s address is never loaded by an element on this host');
+ const reachable = url => fetch(url).then(() => 'live', () => 'revoked');
+ assert.equal(await check(reachable, read.made[0].url), 'live');
+
+ // Leaving while that object URL is the element's src revokes it.
+ await leave();
+ const gone = await log();
+ assert.deepEqual(gone.revoked, [read.made[0].url], 'the object URL is revoked when its tile leaves');
+ assert.equal(gone.made.length, 1);
+ assert.equal(await check(reachable, read.made[0].url), 'revoked', 'and the browser no longer resolves it');
+
+ // ── A host address that honours byte ranges ──
+ Object.assign(serving, { body: clip, ranges: true, hold: false });
+ requests.length = 0; answers.length = 0;
+ await load('address');
+ await openSent();
+ await click('.tr-attachments figure.tr-att__video button');
+ const fromHost = await played();
+ assertPlayed(fromHost, 'host address');
+ assert.equal(fromHost.src, address, 'the element plays the host\'s address');
+ assert.ok(requests.length > 0 && requests.every(request => request === captured.url), 'only the member\'s plain address was requested');
+ assert.ok(answers.length > 0 && answers.every(status => status === 206), `every answer was a byte range (${answers.join(', ')})`);
+ const ranged = await log();
+ assert.deepEqual([ranged.media, ranged.reads, ranged.made, ranged.revoked, ranged.images], [[captured.url], [], [], [], []],
+ 'one address asked; no byte read through the session, no object URL');
+ assert.ok(!ranged.events.includes('error'), 'the element raised no error');
+ await leave();
+ assert.deepEqual((await log()).revoked, [], 'the host\'s address is the host\'s to revoke');
+
+ assert.deepEqual(errors.filter(message => !/Failed to load resource/.test(message)), []);
+ console.log(`PASS: a sent video in the rail on the demo set — nothing asked before the press (${idle.media.length} addresses, ${idle.reads.length} reads, 0 requests), a at the host's address, the rail's keys left to the video, the bytes read once the address failed, the could-not-play face with Retry and the object URL revoked, Retry asking the host again, an uncaptured video said in words; with no host address, a pending read aborted on leaving and its late bytes making no URL, a recorded ${clip.byteLength}-byte H.264 clip played from an object URL (${fromBytes.width}×${fromBytes.height}, seekable to ${fromBytes.end.toFixed(2)} s, a seek to ${fromBytes.target} landing at ${fromBytes.landed.toFixed(2)}) and that URL revoked on leaving; at a host address answering 206 the same clip played and seeked (seekable to ${fromHost.end.toFixed(2)} s, a seek to ${fromHost.target} landing at ${fromHost.landed.toFixed(2)}, ${answers.length} range answers, no bytes read). Screenshots: ${temporary}/before-play.png, ${temporary}/could-not-play.png, ${temporary}/playing-from-bytes.png`);
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ } finally {
+ clearTimeout(deadline);
+ release();
+ await server?.close();
+ app.exit(process.exitCode ?? 0);
+ }
+}
+run();
diff --git a/clients/app/test/video-tile.test.mjs b/clients/app/test/video-tile.test.mjs
new file mode 100644
index 00000000..ea951818
--- /dev/null
+++ b/clients/app/test/video-tile.test.mjs
@@ -0,0 +1,546 @@
+// Sent videos in the attachment rail (2026-11-12). A tool row's attachment of
+// kind `video` is a recorded member (`/api/assets/.mp4` or `.mov`) or
+// a reference to a file that was sent but not captured. The rail gathers it
+// beside the run's images, in order, and plays it in place: nothing is asked
+// before the reader presses play, and a video never enters an image path.
+// Everything here is synthesized; no daemon, archive or home directory.
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+import { RosettaClient } from "../../ts/client.ts";
+import { assetsOver } from "../renderer/src/host/rosetta-ports.ts";
+import { Attachments, attachmentStatus } from "../../room/src/components/transcript/Attachments.tsx";
+import { VideoFace, videoAspect, videoStatus } from "../../room/src/components/transcript/VideoTile.tsx";
+import { createAttachmentReader } from "../../room/src/components/transcript/attachment-file-source.ts";
+import { attachmentGallery, createAttachmentGallerySource } from "../../room/src/components/transcript/attachment-gallery.ts";
+import { IDLE_VIDEO, createVideoSource } from "../../room/src/components/transcript/video-source.ts";
+import { createDemoPorts, createRoomDemo } from "../../room/src/fixtures/index.ts";
+import { fmtBytes } from "../../room/src/lib/format.ts";
+import { isRecordedImageAsset, isRecordedVideoAsset } from "../../room/src/lib/image-media.ts";
+import { activityImages, isRailMedia, messageAttachments, timelineImages } from "../../room/src/lib/transcript/images.ts";
+import { ledgerLine } from "../../room/src/lib/transcript/index.ts";
+import { RoomProvider } from "../../room/src/room-context.tsx";
+import { entry, file, inline, tool } from "./support/ledger-fixtures.mjs";
+
+const ROOM = fileURLToPath(new URL("../../room/", import.meta.url));
+const fromRoom = createRequire(path.join(ROOM, "package.json"));
+const { createElement } = fromRoom("react");
+const { renderToStaticMarkup } = fromRoom("react-dom/server");
+
+const BASE = "http://127.0.0.1:4640";
+const hex = (digit) => digit.repeat(64);
+const png = (digit = "1") => ({ url: `/api/assets/${hex(digit)}.png`, digest: hex(digit), media_type: "image/png", byte_len: 4096 });
+const mp4 = (digit = "a") => ({ url: `/api/assets/${hex(digit)}.mp4`, digest: hex(digit), media_type: "video/mp4", byte_len: 3_562_144 });
+const mov = (digit = "b") => ({ url: `/api/assets/${hex(digit)}.mov`, digest: hex(digit), media_type: "video/quicktime", byte_len: 11_482_112 });
+const leaf = (digit) => ({ url: `/api/assets/${hex(digit)}.webp`, digest: hex(digit), media_type: "image/webp", byte_len: 42 });
+const bundle = () => ({ ...leaf("c"), image_variants: { rail: leaf("d"), tile: leaf("e"), tile2x: leaf("f"), preview: leaf("0") } });
+const reference = (pathname) => ({ type: "reference", reference: { type: "file", path: pathname, resolved: true } });
+
+const image = (id, asset = png(), over = {}) => ({ id, kind: "image", label: `[Image #${id}]`, name: `shot-${id}.png`,
+ media_type: asset.media_type, byte_len: asset.byte_len, dimensions: { width: 1440, height: 900 }, message_ranges: [],
+ source: { type: "asset", asset }, ...over });
+const video = (id, asset = mp4(), over = {}) => ({ id, kind: "video", label: null, name: `clip-${id}.mp4`,
+ media_type: asset.media_type, byte_len: asset.byte_len, dimensions: { width: 1920, height: 1080 }, message_ranges: [],
+ source: { type: "asset", asset }, ...over });
+const uncaptured = (id, kind = "video") => ({ id, kind, label: null, name: kind === "video" ? "before.mov" : "before.png",
+ media_type: kind === "video" ? "video/quicktime" : "image/png", byte_len: 11_482_112, dimensions: null, message_ranges: [],
+ source: reference(kind === "video" ? "/w/before.mov" : "/w/before.png") });
+
+const send = (attachments, over = {}) => tool({ type: "artifact", operation: "send", artifact_kind: "file", name: null,
+ description: inline("What changed"), resources: [file("/w/clip.mp4"), file("/w/before.mov")], media: null }, { attachments, ...over });
+
+/** A prompt with an image, then a send of a video, an image, a video that was
+ * not captured and a document, then a reply's image. */
+function mixedRun() {
+ const prompt = entry({ type: "user", text: inline("Look at [Image #1]."), attachments: [image("1")] }, { timeline_id: "prompt" });
+ const sent = send([video("1"), image("2", png("2")), uncaptured("3"),
+ { ...video("4"), kind: "document", name: "notes.pdf", media_type: "application/pdf" }], { timeline_id: "sent" });
+ const reply = entry({ type: "agent", text: inline("Done."), attachments: [video("1", mov()), image("2", png("3"))], citations: [] },
+ { timeline_id: "reply" });
+ return { prompt, sent, reply };
+}
+
+const recordingAssets = (over = {}) => {
+ const asked = { image: [], attachment: [], media: [] };
+ return { asked, assets: {
+ imageAssetUrl: (asset, variant) => { asked.image.push([asset.media_type, variant]); return `${BASE}${asset.url}?image=${variant}&v=1`; },
+ attachmentAssetUrl: (asset) => { asked.attachment.push(asset.url); return `${BASE}${asset.url}`; },
+ mediaAssetUrl: (asset) => { asked.media.push(asset.url); return `${BASE}${asset.url}`; },
+ ...over,
+ } };
+};
+const refusedRead = async () => { throw new Error("nothing is read before the reader asks"); };
+
+/* ── the gather ─────────────────────────────────────────────────────────── */
+
+test("the rail gathers videos beside images, in transcript order, and nothing else", () => {
+ const { prompt, sent, reply } = mixedRun();
+ const facts = timelineImages([prompt, sent, reply, sent]);
+ assert.deepEqual(facts.map((fact) => [fact.kind, fact.key]), [
+ ["image", "s1:prompt:attachment:1"],
+ ["video", "s1:sent:attachment:1"], ["image", "s1:sent:attachment:2"], ["video", "s1:sent:attachment:3"],
+ ["video", "s1:reply:attachment:1"], ["image", "s1:reply:attachment:2"],
+ ], "order is the transcript's, a repeated row is one occurrence, a document is not gathered");
+ assert.equal(facts[3].missing, true, "a video that was not captured keeps its slot");
+ assert.deepEqual([facts[3].name, facts[3].mediaType, facts[3].byteLen], ["before.mov", "video/quicktime", 11_482_112]);
+ assert.deepEqual(activityImages([{ kind: "line", line: ledgerLine(sent) }]).map((fact) => fact.kind), ["video", "image", "video"]);
+ assert.deepEqual(messageAttachments(sent).map(isRailMedia), [true, true, true, false]);
+});
+
+test("a gathered video reaches no image path: no rendition, no viewer handle, no gallery slot", async () => {
+ const { prompt, sent, reply } = mixedRun();
+ const facts = timelineImages([prompt, sent, reply]);
+ const { asked, assets } = recordingAssets();
+ const reader = createAttachmentReader(assets, refusedRead);
+ const videos = facts.filter((fact) => fact.kind === "video");
+ const images = facts.filter((fact) => fact.kind === "image");
+ assert.equal(videos.length, 3);
+ for (const fact of videos) {
+ assert.equal(reader.imageResource(fact), null, fact.key);
+ assert.equal(reader.canOpen(fact), false, fact.key);
+ }
+ for (const fact of images) {
+ assert.equal(reader.canOpen(fact), true, fact.key);
+ assert.equal(reader.videoAsset(fact), null, "an image is never a video");
+ reader.imageResource(fact).url("tile");
+ }
+ // The gallery of the whole row holds the images only, each still openable.
+ const { images: slots, initialKey } = attachmentGallery(reader, images[1], facts);
+ assert.equal(initialKey, images[1].key);
+ assert.deepEqual(slots.map((slot) => slot.key), images.map((fact) => fact.key));
+ assert.ok(slots.every((slot) => slot.handle !== null));
+ const source = createAttachmentGallerySource(reader);
+ for (const slot of slots) source.image(slot.handle).url("full");
+ // A video handed to the gallery's reader anyway is refused, not read.
+ await assert.rejects(source.read({ key: "v", name: "clip.mp4", path: null, size: 1, ref: videos[0] }, new AbortController().signal),
+ /Image data was not recorded/);
+ assert.equal(source.image({ key: "v", name: "clip.mp4", path: null, size: 1, ref: videos[0] }), null);
+ assert.ok(asked.image.length > 0);
+ assert.ok(asked.image.every(([mediaType]) => mediaType.startsWith("image/")), "imageAssetUrl is asked for images only");
+ assert.deepEqual(asked.media, [], "and no video address is asked by gathering or opening");
+});
+
+/* ── the member ─────────────────────────────────────────────────────────── */
+
+test("a recorded video is an mp4 or mov member with no rendition map; nothing else is", () => {
+ assert.equal(isRecordedVideoAsset(mp4()), true);
+ assert.equal(isRecordedVideoAsset(mov()), true);
+ for (const [why, asset] of [
+ ["a recorded image", png()],
+ ["a legacy bundle", bundle()],
+ ["a video with a rendition map", { ...mp4(), image_variants: {} }],
+ ["a bundle wearing a video address", { ...bundle(), url: mp4().url, digest: mp4().digest, media_type: "video/mp4" }],
+ ["another route", { ...mp4(), url: `/api/files/${hex("a")}.mp4` }],
+ ["another origin", { ...mp4(), url: `https://example.test/api/assets/${hex("a")}.mp4` }],
+ ["a query", { ...mp4(), url: `${mp4().url}?image=tile&v=1` }],
+ ["a short digest", { ...mp4(), url: "/api/assets/abc.mp4", digest: "abc" }],
+ ["an upper-case digest", { ...mp4(), url: `/api/assets/${hex("A")}.mp4`, digest: hex("A") }],
+ ["a name that is not its digest", { ...mp4(), url: `/api/assets/${hex("9")}.mp4` }],
+ ["another container", { ...mp4(), url: `/api/assets/${hex("a")}.webm`, media_type: "video/webm" }],
+ ["an extension that is not its type's", { ...mp4(), url: `/api/assets/${hex("a")}.mov` }],
+ ["a type that is not its extension's", { ...mov(), media_type: "video/mp4" }],
+ ["no bytes", { ...mp4(), byte_len: 0 }],
+ ["a missing asset", undefined],
+ ["a null asset", null],
+ ]) assert.equal(isRecordedVideoAsset(asset), false, why);
+ assert.equal(isRecordedImageAsset(mp4()), false, "and a video member is never a recorded image");
+ assert.equal(isRecordedImageAsset(mov()), false);
+});
+
+test("the reader offers a captured video's member and nothing for the rest", () => {
+ const { assets } = recordingAssets();
+ const reader = createAttachmentReader(assets, refusedRead);
+ const [captured, quick, sent, raw, wrongKind] = messageAttachments(send([
+ video("1"), video("2", mov()), uncaptured("3"),
+ video("4", { ...mp4("9"), url: "/test/fixtures/clip.mp4" }), image("5", mp4("8")),
+ ]));
+ assert.equal(reader.videoAsset(captured), captured.ref.source.asset);
+ assert.equal(reader.videoAsset(quick), quick.ref.source.asset);
+ assert.equal(reader.videoAsset(sent), null, "sent, not captured");
+ assert.equal(reader.videoAsset(raw), null, "a member no ingestion wrote");
+ assert.equal(reader.videoAsset(wrongKind), null, "an image slot holding a video member is neither");
+ assert.equal(reader.imageResource(wrongKind), null);
+ assert.equal(reader.assetUrl(captured), `${BASE}${mp4().url}`, "the plain address stays the identity");
+ assert.equal(reader.assetUrl(raw), null);
+ assert.equal(typeof reader.mediaAssetUrl, "function");
+ assert.equal(reader.mediaAssetUrl(mp4()), `${BASE}${mp4().url}`);
+ // A host that does not offer the member offers no video either.
+ const closed = createAttachmentReader({ imageAssetUrl: () => null, attachmentAssetUrl: () => null }, refusedRead);
+ assert.equal(closed.videoAsset(captured), null);
+ assert.equal(closed.mediaAssetUrl, undefined, "a host without the door has none");
+});
+
+test("rosetta's host gives a video no address: the daemon serves it whole, which cannot seek", () => {
+ const assets = assetsOver(new RosettaClient({ baseUrl: BASE }));
+ assert.equal(assets.mediaAssetUrl, undefined, "the room reads the bytes and plays an object URL instead");
+ assert.equal(assets.attachmentAssetUrl(mp4()), `${BASE}/api/assets/${hex("a")}.mp4`, "the member's identity stays");
+ assert.equal(assets.attachmentAssetUrl(mov()), `${BASE}/api/assets/${hex("b")}.mov`);
+ assert.equal(assets.attachmentAssetUrl({ ...mp4(), url: "https://example.test/clip.mp4" }), null, "only that daemon");
+});
+
+/* ── the load ───────────────────────────────────────────────────────────── */
+
+/** Fake doors, recording every ask. `read` settles when the test says. */
+function doors(over = {}) {
+ const log = { media: [], reads: [], made: [], revoked: [], states: [] };
+ let settle = null;
+ const fake = {
+ log,
+ resolveRead: (bytes) => settle.resolve(bytes),
+ rejectRead: (error) => settle.reject(error),
+ doors: {
+ readAsset: (asset, signal) => {
+ log.reads.push({ url: asset.url, signal });
+ return new Promise((resolve, reject) => { settle = { resolve, reject }; });
+ },
+ createObjectURL: (bytes, mediaType) => {
+ const url = `blob:fake/${log.made.length + 1}`;
+ log.made.push({ url, bytes, mediaType });
+ return url;
+ },
+ revokeObjectURL: (url) => { log.revoked.push(url); },
+ onChange: (state) => { log.states.push(state.phase === "ready" ? `ready:${state.from}` : state.phase); },
+ ...over,
+ },
+ };
+ return fake;
+}
+const hostUrl = (log) => (asset) => { log.media.push(asset.url); return `${BASE}${asset.url}`; };
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+const BYTES = new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112]);
+
+test("a video source asks nothing until the reader asks to play", async () => {
+ const fake = doors();
+ fake.doors.mediaAssetUrl = hostUrl(fake.log);
+ const source = createVideoSource(mp4(), fake.doors);
+ assert.equal(source.state(), IDLE_VIDEO);
+ source.elementFailed(`${BASE}${mp4().url}`);
+ await tick();
+ assert.deepEqual(fake.log, { media: [], reads: [], made: [], revoked: [], states: [] });
+ assert.deepEqual(source.state(), { phase: "idle" });
+});
+
+test("with a host address, play is that address and no byte is read", async () => {
+ const fake = doors();
+ fake.doors.mediaAssetUrl = hostUrl(fake.log);
+ const source = createVideoSource(mp4(), fake.doors);
+ source.play();
+ assert.deepEqual(source.state(), { phase: "ready", url: `${BASE}${mp4().url}`, from: "host" });
+ source.play();
+ await tick();
+ assert.deepEqual(fake.log.media, [mp4().url], "a second press while ready asks nothing");
+ assert.deepEqual(fake.log.states, ["ready:host"]);
+ assert.deepEqual([fake.log.reads, fake.log.made, fake.log.revoked], [[], [], []]);
+ source.dispose();
+ assert.deepEqual(fake.log.revoked, [], "the host's address is the host's to revoke");
+});
+
+test("a host address that arrives later is awaited as loading", async () => {
+ const fake = doors();
+ let answer;
+ fake.doors.mediaAssetUrl = () => new Promise((resolve) => { answer = resolve; });
+ const source = createVideoSource(mov(), fake.doors);
+ source.play();
+ assert.deepEqual(source.state(), { phase: "loading" });
+ answer(`blob:host/1`);
+ await tick();
+ assert.deepEqual(source.state(), { phase: "ready", url: "blob:host/1", from: "host" });
+ assert.deepEqual(fake.log.states, ["loading", "ready:host"]);
+ assert.deepEqual(fake.log.reads, []);
+});
+
+test("without a host address, play reads the bytes and plays an object URL", async () => {
+ const fake = doors();
+ const source = createVideoSource(mov(), fake.doors);
+ source.play();
+ assert.deepEqual(source.state(), { phase: "loading" });
+ assert.equal(fake.log.reads.length, 1);
+ assert.equal(fake.log.reads[0].url, mov().url);
+ assert.equal(fake.log.reads[0].signal.aborted, false);
+ source.play();
+ assert.equal(fake.log.reads.length, 1, "a press while loading is nothing");
+ fake.resolveRead(BYTES);
+ await tick();
+ assert.deepEqual(source.state(), { phase: "ready", url: "blob:fake/1", from: "bytes" });
+ assert.deepEqual(fake.log.made, [{ url: "blob:fake/1", bytes: BYTES, mediaType: "video/quicktime" }]);
+ assert.deepEqual(fake.log.states, ["loading", "ready:bytes"]);
+ assert.deepEqual(fake.log.revoked, []);
+});
+
+test("a host that answers null, nothing, a refusal or a throw falls to the bytes", async () => {
+ for (const [why, answer] of [
+ ["null", () => null], ["an empty address", () => ""], ["undefined", () => undefined],
+ ["a promise of null", () => Promise.resolve(null)], ["a rejection", () => Promise.reject(new Error("no"))],
+ ["a throw", () => { throw new Error("no"); }],
+ ]) {
+ const fake = doors({ mediaAssetUrl: answer });
+ const source = createVideoSource(mp4(), fake.doors);
+ source.play();
+ await tick();
+ assert.equal(fake.log.reads.length, 1, why);
+ fake.resolveRead(BYTES);
+ await tick();
+ assert.deepEqual(source.state(), { phase: "ready", url: "blob:fake/1", from: "bytes" }, why);
+ assert.deepEqual(fake.log.states, ["loading", "ready:bytes"], why);
+ }
+});
+
+test("when the element cannot load the host's address, the bytes are read; when it cannot play those, it failed", async () => {
+ const fake = doors();
+ fake.doors.mediaAssetUrl = hostUrl(fake.log);
+ const source = createVideoSource(mp4(), fake.doors);
+ source.play();
+ const address = source.state().url;
+ source.elementFailed("blob:some/other");
+ assert.equal(source.state().phase, "ready", "an error about another address is nothing");
+ source.elementFailed(address);
+ assert.deepEqual(source.state(), { phase: "loading" });
+ assert.equal(fake.log.reads.length, 1);
+ source.elementFailed(address);
+ assert.equal(fake.log.reads.length, 1, "a late repeat of the same error reads nothing more");
+ fake.resolveRead(BYTES);
+ await tick();
+ assert.deepEqual(source.state(), { phase: "ready", url: "blob:fake/1", from: "bytes" });
+ assert.deepEqual(fake.log.made.map((made) => made.mediaType), ["video/mp4"]);
+ source.elementFailed("blob:fake/1");
+ assert.deepEqual(source.state(), { phase: "failed" });
+ assert.deepEqual(fake.log.revoked, ["blob:fake/1"], "the object URL that could not play is revoked");
+ assert.deepEqual(fake.log.states, ["ready:host", "loading", "ready:bytes", "failed"]);
+ // Retry asks the host again: a host must not keep a failure, nor does the room.
+ source.play();
+ assert.deepEqual(fake.log.media, [mp4().url, mp4().url]);
+ assert.equal(source.state().phase, "ready");
+ assert.equal(source.state().from, "host");
+});
+
+test("a read that rejects is the failed state, and Retry reads again", async () => {
+ const fake = doors();
+ const source = createVideoSource(mp4(), fake.doors);
+ source.play();
+ fake.rejectRead(new Error("the daemon is away"));
+ await tick();
+ assert.deepEqual(source.state(), { phase: "failed" });
+ assert.deepEqual(fake.log.states, ["loading", "failed"]);
+ assert.deepEqual([fake.log.made, fake.log.revoked], [[], []]);
+ source.play();
+ assert.equal(fake.log.reads.length, 2);
+ fake.resolveRead(BYTES);
+ await tick();
+ assert.equal(source.state().phase, "ready");
+ // A read door that throws instead of rejecting, and a URL that cannot be made, fail the same way.
+ const thrown = doors({ readAsset: () => { throw new Error("no session"); } });
+ const second = createVideoSource(mp4(), thrown.doors);
+ second.play();
+ await tick();
+ assert.deepEqual(second.state(), { phase: "failed" });
+ const unmade = doors({ createObjectURL: () => { throw new Error("no URL"); } });
+ const third = createVideoSource(mp4(), unmade.doors);
+ third.play();
+ unmade.resolveRead(BYTES);
+ await tick();
+ assert.deepEqual(third.state(), { phase: "failed" });
+});
+
+test("dispose aborts a read in flight, and its late bytes make no URL", async () => {
+ const fake = doors();
+ const source = createVideoSource(mp4(), fake.doors);
+ source.play();
+ const { signal } = fake.log.reads[0];
+ source.dispose();
+ assert.equal(signal.aborted, true);
+ fake.resolveRead(BYTES);
+ await tick();
+ assert.deepEqual(fake.log.made, [], "nothing is made for a tile that left");
+ assert.deepEqual(fake.log.states, ["loading"], "and nothing is announced after disposal");
+ source.play();
+ source.elementFailed("blob:fake/1");
+ assert.equal(fake.log.reads.length, 1, "a disposed source asks nothing more");
+});
+
+test("dispose revokes the object URL it made, once", async () => {
+ const fake = doors();
+ const source = createVideoSource(mp4(), fake.doors);
+ source.play();
+ fake.resolveRead(BYTES);
+ await tick();
+ assert.equal(source.state().url, "blob:fake/1");
+ source.dispose();
+ source.dispose();
+ assert.deepEqual(fake.log.revoked, ["blob:fake/1"]);
+});
+
+/* ── the tile ───────────────────────────────────────────────────────────── */
+
+function renderRail(facts, assets = recordingAssets().assets) {
+ const reads = [];
+ const demo = createDemoPorts();
+ const client = demo.sessions.open("s0");
+ const ports = { ...demo, assets,
+ sessions: { open: () => ({ ...client, readAsset: async (asset) => { reads.push(asset.url); return BYTES; } }), close() {} } };
+ // An image tile picks its density from the window; this process has none.
+ globalThis.window ??= { devicePixelRatio: 1 };
+ const markup = renderToStaticMarkup(createElement(RoomProvider, { ports },
+ createElement(Attachments, { facts, onOpenAttachment: () => { throw new Error("a video opens nothing"); } })));
+ return { markup, reads };
+}
+const figures = (markup) => [...markup.matchAll(//gs)].map((match) => match[0]);
+const face = (props) => renderToStaticMarkup(createElement(VideoFace, { index: 0, playable: true, state: IDLE_VIDEO, ...props }));
+
+test("before play, a captured video is a button with a glyph, its name and its size, and asks nothing", () => {
+ const { asked, assets } = recordingAssets();
+ const [fact] = messageAttachments(send([video("1")]));
+ const { markup, reads } = renderRail([fact], assets);
+ const [figure] = figures(markup);
+ assert.match(figure, /^`), figure);
+ assert.match(figure, /]*aria-hidden="true">/);
+ assert.match(figure, /clip-1\.mp4<\/span>/);
+ assert.match(figure, new RegExp(`${fmtBytes(3_562_144)} `));
+ assert.doesNotMatch(markup, / 0 && asked.attachment.every((url) => url === mp4().url), true, "only its identity");
+});
+
+test("a video that was not captured keeps the tile's shape and says so; it is not a button and has no Retry", () => {
+ const [fact] = messageAttachments(send([uncaptured("1")]));
+ const { markup } = renderRail([fact]);
+ const [figure] = figures(markup);
+ assert.match(figure, /^Video not captured<\/span>/);
+ assert.match(figure, new RegExp(`before\\.mov · ${fmtBytes(11_482_112)} `));
+ assert.doesNotMatch(figure, / {
+ const [fact] = messageAttachments(send([video("1")]));
+ const failed = face({ fact, state: { phase: "failed" } });
+ assert.match(failed, /data-video="failed"/);
+ assert.match(failed, /Retry<\/button>/,
+ "Retry names what it retries: focus lands on it when a load fails");
+ assert.doesNotMatch(failed, //,
+ "the name a screen reader hears says it is loading, as the visible words do");
+ assert.match(loading, /Loading video…<\/span>/);
+ assert.doesNotMatch(loading, /
`));
+ assert.doesNotMatch(ready, /poster|Video is not available here<\/span>/);
+ assert.doesNotMatch(closed, / {
+ const [captured, foreign, sent, omitted, unrecorded, bare] = messageAttachments(send([
+ video("1"), video("2", { ...mp4("9"), url: "/test/fixtures/clip.mp4" }), uncaptured("3"),
+ video("4", mp4(), { source: { type: "unavailable", reason: "omitted" } }),
+ video("5", mp4(), { source: { type: "unavailable", reason: "not_recorded" } }),
+ ])).concat([{ ...messageAttachments(send([video("6")]))[0], ref: null }]);
+ assert.deepEqual([captured, foreign, sent, omitted, unrecorded, bare].map(videoStatus), [
+ "Video is not available here", "Video is not available here", "Video not captured",
+ "Video omitted from the recording", "Video data was not recorded", "Video data was not recorded",
+ ]);
+ // The host offers no address for the member (the app answers null for
+ // another origin's): the bytes were recorded, and the tile does not say otherwise.
+ const { markup } = renderRail([captured, foreign], recordingAssets({ attachmentAssetUrl: () => null }).assets);
+ const drawn = figures(markup);
+ assert.equal(drawn.length, 2);
+ for (const figure of drawn) {
+ assert.match(figure, /data-video="uncaptured"/);
+ assert.match(figure, / {
+ assert.equal(videoAspect(1920, 1080), 16 / 9);
+ assert.equal(videoAspect(null, null), 16 / 9);
+ assert.equal(videoAspect(0, 1080), 16 / 9);
+ assert.equal(videoAspect(1440, 1080), 4 / 3);
+ assert.equal(videoAspect(1080, 1920), 1, "a portrait video letterboxes in a square tile");
+ assert.equal(videoAspect(4000, 100), 21 / 9);
+ const [portrait] = messageAttachments(send([video("1", mp4(), { dimensions: { width: 1080, height: 1920 } })]));
+ assert.match(face({ fact: portrait }), /style="--tr-video-aspect:1\.0000"/);
+});
+
+test("one rail draws images and videos in order, and every kind and state says something", () => {
+ const { asked, assets } = recordingAssets();
+ const facts = messageAttachments(send([
+ image("1"), video("2"), uncaptured("3"), uncaptured("4", "image"),
+ { ...video("5"), kind: "audio", name: "voice.m4a", source: reference("/w/voice.m4a") },
+ video("6", mp4(), { source: { type: "unavailable", reason: "omitted" } }),
+ ]));
+ const { markup } = renderRail(facts, assets);
+ const drawn = figures(markup);
+ assert.equal(drawn.length, 6);
+ assert.deepEqual(drawn.map((figure) => /data-video="([^"]+)"/.exec(figure)?.[1] ?? null), [null, "idle", "uncaptured", null, null, "uncaptured"]);
+ assert.match(drawn[0], /
/);
+ assert.match(drawn[3], /Image not captured<\/span>/);
+ assert.doesNotMatch(drawn[3], /Attachment preview unavailable<\/span>/);
+ assert.match(drawn[5], /Video omitted from the recording<\/span>/);
+ assert.ok(asked.image.every(([mediaType]) => mediaType === "image/png"), "no video was handed to imageAssetUrl");
+ assert.deepEqual(asked.media, []);
+ assert.deepEqual(facts.map(attachmentStatus), [
+ "Image data was not recorded", "Video is not available here", "Video not captured", "Image not captured",
+ "Attachment preview unavailable", "Video omitted from the recording",
+ ]);
+ for (const kind of ["document", "audio", "file", "something-new"]) {
+ assert.equal(attachmentStatus({ ...facts[0], kind }), "Attachment preview unavailable", kind);
+ }
+});
+
+test("an unlabelled tile is numbered among its own kind, so the rail and the gallery call a picture the same", () => {
+ const { assets } = recordingAssets();
+ const bare = { label: null };
+ const facts = messageAttachments(send([
+ video("1", mp4(), { name: null }), image("2", png("2"), bare), uncaptured("3"), image("4", png("4"), bare),
+ { ...video("5"), kind: "document", name: "notes.pdf", media_type: "application/pdf" }, uncaptured("6", "image"),
+ video("7", mov(), { name: null }),
+ ]));
+ const drawn = figures(renderRail(facts, assets).markup);
+ const named = (figure) => /aria-label="Open (Image \d+) — /.exec(figure)?.[1] ?? /title="((?:Image|Video|Attachment) \d+)\b/.exec(figure)?.[1];
+ assert.deepEqual(drawn.map(named), ["Video 1", "Image 1", "Video 2", "Image 2", "Attachment 5", "Image 3", "Video 3"]);
+ assert.match(drawn[1], /aria-label="Open Image 1 — shot-2\.png"/, "the first picture, though a video stands before it");
+ assert.match(drawn[0], /aria-label="Play video — Video 1 · /, "a nameless video goes by its number among the videos");
+ // The gallery the first picture opens numbers the same row's images the same way.
+ const reader = createAttachmentReader(assets, refusedRead);
+ const images = facts.filter((fact) => fact.kind === "image");
+ const { images: slots } = attachmentGallery(reader, images[0], facts);
+ assert.deepEqual(slots.map((slot) => slot.label), ["Image 1", "Image 2", "Image 3"]);
+ assert.deepEqual(slots.map((slot) => slot.key), images.map((fact) => fact.key));
+ assert.deepEqual(images.map((fact) => named(drawn[facts.indexOf(fact)])), slots.map((slot) => slot.label));
+});
+
+/* ── the demo ───────────────────────────────────────────────────────────── */
+
+test("the demo holds one captured and one uncaptured video, and reads no encoded video", async () => {
+ const demo = createRoomDemo(Date.UTC(2026, 10, 12, 12));
+ const row = demo.timelines.get("s8").find((entry) => entry.body.type === "tool_use");
+ const [captured, sent] = row.body.tool.attachments;
+ assert.deepEqual([captured.kind, sent.kind], ["video", "video"]);
+ assert.equal(isRecordedVideoAsset(captured.source.asset), true);
+ assert.deepEqual(sent.source, { type: "reference", reference: { type: "file", path: "/Users/demo/Projects/rosetta/work/before-the-split.mov", resolved: true } });
+ assert.deepEqual([sent.name, sent.media_type, sent.dimensions], ["before-the-split.mov", "video/quicktime", null]);
+ assert.deepEqual(timelineImages(demo.timelines.get("s8")).map((fact) => [fact.kind, fact.missing]), [["video", false], ["video", true]]);
+ const ports = createDemoPorts(demo);
+ assert.equal(ports.assets.mediaAssetUrl, undefined, "the demo offers no video address: its bytes are read");
+ const bytes = await ports.sessions.open("s8").readAsset(captured.source.asset);
+ const text = (start, end) => String.fromCharCode(...bytes.subarray(start, end));
+ assert.equal(bytes.byteLength, 64);
+ assert.deepEqual([bytes[3], text(4, 8), text(8, 12), bytes[27], text(28, 32)], [24, "ftyp", "isom", 40, "free"], "a header and padding, no media");
+ assert.ok(bytes.subarray(32).every((byte) => byte === 0));
+});
diff --git a/clients/room/README.md b/clients/room/README.md
index 749950eb..13afb9e7 100644
--- a/clients/room/README.md
+++ b/clients/room/README.md
@@ -20,7 +20,10 @@ import "@vibefield/rosetta-room/styles/room.css"; // after the host's --vf-* tok
`@vibefield/rosetta-room/fixtures` renders the room with no daemon at all:
` ` (six projects, eleven sessions, a year
-of figures, two transcripts with images and web lines, all relative to now).
+of figures, two transcripts with images, web lines and two sent videos (one
+captured, one not), all relative to now). The demo commits no encoded video: a
+video member reads as a header-only MP4, so pressing play ends at "Could not
+play video".
`createColdImport({ now })` returns `{ ports, consent, daemon, controller }`
for a first import instead, over a seeded synthetic archive (by default the
MacBook trace's shape: 8,612 sessions in 1,139 trees over 119 projects;
@@ -48,7 +51,7 @@ sockets stay the simulator's.
|---|---|---|
| `CatalogPort` | `subscribeMeta` (runtimes, projects, every session row, live: one snapshot then mutations) · `status` → `CatalogStatus` · `projectStats(projectId, window)` → `ProjectStats` (per-day heat and per-session facts) · `usage` · `dayUsage` · `search(CatalogSearch)` · optional `onConnectionChange`, `unavailable` (the host's own words for why the index cannot answer), `boost` and `projectStatsBatch` (every listed project's figures in one ask; present, the room asks it once per refresh instead of `projectStats` per project) | its client's REST and `/ws/meta` (`catalogOver`) |
| `SessionPort` | `open(sessionId)` → a `SessionClient` bound to that session (its live subscription, child threads and team members each open their own; entries, report work, commands, plans, file targets, stored members), or a promise of one; `close(sessionId)` | the one client for every session; close is nothing |
-| `AssetSource` | `imageAssetUrl(asset, variant)` → a URL, null, or a promise of either · `attachmentAssetUrl(asset)` → the attachment's address, for identity and can-open only | the daemon's own URLs |
+| `AssetSource` | `imageAssetUrl(asset, variant)` → a URL, null, or a promise of either · `attachmentAssetUrl(asset)` → the attachment's address, for identity and can-open only · optional `mediaAssetUrl(asset)` → an address a `` can load, null, or a promise of either | the daemon's own URLs |
| `HostPort` (optional) | `homeDir` · `platform` · `openInBrowser(name, bytes, browserId?)` · optional `browsers()` · `favicon(site)` · optional `worktree: { open, gitFacts, fileAction, ports }` | the Electron bridge (`hostPortOver`) |
- **The room opens a session at most once at a time and follows every `open`
@@ -78,6 +81,30 @@ sockets stay the simulator's.
originals as placeholders. The SDK published as 0.1.2 answers an
original's `full` with its plain URL, so a host pairs this room with the
SDK released beside it.
+- **A sent video plays in place, and only when asked.** A tool row's
+ attachment of kind `video` is a recorded member (`/api/assets/.mp4`
+ or `.mov`, `video/mp4` or `video/quicktime`, no `image_variants`) or a
+ reference to a file that was sent but not captured. The rail draws a
+ captured one as a tile with a play glyph, the file's name and its size, at
+ the rail's height and the video's own shape (16:9 when its dimensions are
+ unknown, never narrower than a square nor wider than 21:9). There is no
+ poster and no thumbnail: the room asks nothing for a video until the reader
+ presses play. Then it asks the optional **`mediaAssetUrl(asset)`** for an
+ address a `` can load. A host answers one only for an address
+ that honours byte ranges: served whole, without ranges, a video plays but
+ cannot seek, and no error tells the room to fall back. The daemon serves a
+ member whole, so rosetta's app leaves `mediaAssetUrl` out. A host without
+ it, one that answers null, or one whose address the element cannot load
+ needs nothing more: the room reads the member's bytes through the reading
+ session's `readAsset` and plays an object URL it makes and revokes itself,
+ which seeks. If the bytes cannot be read or played either,
+ the tile says "Could not play video" and offers Retry, which asks the host
+ again. A video that was not captured keeps the tile's shape, says "Video not
+ captured" with its name and size, and is not a button; an image sent the
+ same way says "Image not captured". A video is never passed to
+ `imageAssetUrl`, never opens the image viewer and is not in its gallery.
+ While a video has focus the rail's arrow, Home and End keys are the
+ video's. `attachmentAssetUrl` is still asked for a video, as its identity.
- **`boost` is optional** because a grant never opens a write: only a host
holding the daemon's bearer offers it. Without it the index keeps its order.
- **`browsers()`** returns installed browser ids, names and icon URLs. HTML
diff --git a/clients/room/src/components/transcript/Attachments.tsx b/clients/room/src/components/transcript/Attachments.tsx
index 425bebfe..e39cb679 100644
--- a/clients/room/src/components/transcript/Attachments.tsx
+++ b/clients/room/src/components/transcript/Attachments.tsx
@@ -4,6 +4,7 @@ import { fmtBytes } from "../../lib/format";
import { useAttachmentReader } from "./attachment-file-source";
import { useImageUrl, useImageVisibility } from "../file-viewer/image-resource";
import type { AttachmentOpener } from "./attachment-gallery";
+import { VideoTile, videoStatus } from "./VideoTile";
import "../../styles/attachments.css";
/** Explicitly shared only by messages whose images are gathered in this run. */
@@ -23,17 +24,28 @@ export function useAttachmentActions(onOpenAttachment: AttachmentOpener, onRecei
return { open, reveal };
}
+/** Why an attachment has no picture here, in the words of its kind. Every
+ * kind answers something: a kind this rail does not draw is never blank. */
export function attachmentStatus(fact: AttachmentFact): string {
const source = fact.ref?.source;
- if (source?.type === "unavailable") {
- switch (source.reason) {
- case "omitted": return "Image omitted from the recording";
- case "invalid_data": return "Recorded image data is invalid";
- case "unsupported_media": return "Image format is not supported";
- case "not_recorded": return "Image data was not recorded";
- }
+ switch (fact.kind) {
+ case "image":
+ // Sent by path and not captured: the record names a file, not bytes.
+ if (source?.type === "reference") return "Image not captured";
+ if (source?.type === "unavailable") {
+ switch (source.reason) {
+ case "omitted": return "Image omitted from the recording";
+ case "invalid_data": return "Recorded image data is invalid";
+ case "unsupported_media": return "Image format is not supported";
+ case "not_recorded": return "Image data was not recorded";
+ }
+ }
+ return "Image data was not recorded";
+ case "video":
+ return videoStatus(fact);
+ default:
+ return "Attachment preview unavailable";
}
- return "Image data was not recorded";
}
function Tile({ fact, index, onOpen }: {
@@ -73,7 +85,7 @@ function Tile({ fact, index, onOpen }: {
- {failed ? "Could not load image" : fact.kind === "image" ? attachmentStatus(fact) : "Attachment preview unavailable"}
+ {failed ? "Could not load image" : attachmentStatus(fact)}
{failed && {
restoreFocus.current = true; setAttempt(value => value + 1); setFailedUrl(null);
}}>Retry }
@@ -144,11 +156,14 @@ export function Attachments({ facts, onOpenAttachment, reveal }: {
if (facts.length === 0) return null;
const page = (direction: number) => gallery.current?.scrollBy({ left: direction * gallery.current.clientWidth * 0.8,
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" });
+ const seen = { image: 0, video: 0 };
return
{
const tile = event.target.closest(".tr-att__tile");
if (tile && gallery.current) revealTile(gallery.current, tile);
}} onKeyDown={event => {
+ // A playing video keeps its own keys: arrows seek, Home and End jump.
+ if (event.target instanceof Element && event.target.closest("video") !== null) return;
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) || !gallery.current) return;
const tiles = [...gallery.current.querySelectorAll(".tr-att__tile")];
const index = tiles.findIndex(tile => tile.contains(event.target as Node));
@@ -157,8 +172,15 @@ export function Attachments({ facts, onOpenAttachment, reveal }: {
const next = event.key === "Home" ? 0 : event.key === "End" ? tiles.length - 1 : Math.max(0, Math.min(tiles.length - 1, index + (event.key === "ArrowRight" ? 1 : -1)));
tiles[next]?.focus({ preventScroll: true });
}}>
- {facts.map((fact, index) => onOpenAttachment(selected, facts)} />)}
+ {facts.map((fact, index) => {
+ const key = `${fact.key}:${attachments.assetUrl(fact) ?? "reference"}`;
+ // An unlabelled tile is numbered among its own kind, as the gallery
+ // numbers the row's images: "Image 1" names one picture in both.
+ const ordinal = fact.kind === "image" ? seen.image++ : fact.kind === "video" ? seen.video++ : index;
+ // A video plays in place: it has no open action and no image path.
+ return fact.kind === "video" ?
+ : onOpenAttachment(selected, facts)} />;
+ })}
{(edges.before || edges.after) &&
page(-1)}>‹ }
{(edges.before || edges.after) &&
page(1)}>› }
diff --git a/clients/room/src/components/transcript/AuthoredMessage.tsx b/clients/room/src/components/transcript/AuthoredMessage.tsx
index 7d371ddb..7ad67381 100644
--- a/clients/room/src/components/transcript/AuthoredMessage.tsx
+++ b/clients/room/src/components/transcript/AuthoredMessage.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useContext } from "react";
import type { CanonicalText } from "@vibefield/rosetta-protocol";
import type { AttachmentFact } from "../../lib/transcript";
+import { isRailMedia } from "../../lib/transcript/images";
import { ActivityImagesContext, Attachments, useAttachmentActions } from "./Attachments";
import { MessagePreview } from "./MessagePreview";
import { Prose } from "./Prose";
@@ -32,7 +33,8 @@ export function AuthoredMessage({ text, recordedText, attachments, messageKey, r
:
) : null;
return <>
{renderText ? renderText(content) : content}
-
fact.kind !== "image") : attachments}
+ {/* A run's own rail already holds this message's images and videos. */}
+ !isRailMedia(fact)) : attachments}
onOpenAttachment={open} reveal={actions.reveal} />
>;
}
diff --git a/clients/room/src/components/transcript/VideoTile.tsx b/clients/room/src/components/transcript/VideoTile.tsx
new file mode 100644
index 00000000..d9634c96
--- /dev/null
+++ b/clients/room/src/components/transcript/VideoTile.tsx
@@ -0,0 +1,152 @@
+// A video an agent sent, in the attachment rail. Before the reader asks, it is
+// a tile with a play glyph, the file's name and its size: no poster, no
+// thumbnail, no request. A press plays it in place. A video that was sent but
+// not captured keeps the same tile shape and says so; it is not a button.
+// A video never opens the image viewer and never asks an image rendition.
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import type { CSSProperties } from "react";
+import type { AttachmentFact } from "../../lib/transcript";
+import { fmtBytes } from "../../lib/format";
+import { useAttachmentReader } from "./attachment-file-source";
+import { IDLE_VIDEO, createVideoSource } from "./video-source";
+import type { VideoSource, VideoState } from "./video-source";
+
+const WIDEST = 21 / 9;
+
+/** Width over height for the tile at the rail's height: the video's own
+ * shape, 16:9 when it was not measured. Never narrower than the square image
+ * tile beside it, nor wider than 21:9; the video letterboxes inside. */
+export function videoAspect(width: number | null, height: number | null): number {
+ if (width === null || height === null || !(width > 0) || !(height > 0)) return 16 / 9;
+ return Math.min(WIDEST, Math.max(1, width / height));
+}
+
+/** Why a video cannot be played here. */
+export function videoStatus(fact: AttachmentFact): string {
+ const source = fact.ref?.source;
+ if (source?.type === "reference") return "Video not captured";
+ // Recorded, but not a member this room can address: the host offers no
+ // address for it, or it is not an MP4 or QuickTime member under its digest.
+ if (source?.type === "asset") return "Video is not available here";
+ if (source?.type === "unavailable") {
+ switch (source.reason) {
+ case "omitted": return "Video omitted from the recording";
+ case "invalid_data": return "Recorded video data is invalid";
+ case "unsupported_media": return "Video format is not supported";
+ case "not_recorded": return "Video data was not recorded";
+ }
+ }
+ return "Video data was not recorded";
+}
+
+function PlayGlyph() {
+ return
+
+
+ ;
+}
+
+function FilmGlyph() {
+ return
+
+ ;
+}
+
+export interface VideoFaceProps {
+ readonly fact: AttachmentFact;
+ /** The video's place among the rail's videos, for an unlabelled one's name. */
+ readonly index: number;
+ /** False: sent but not captured, or a member this host does not offer. */
+ readonly playable: boolean;
+ readonly state: VideoState;
+ readonly onPlay?: (() => void) | undefined;
+ readonly onElementError?: ((url: string) => void) | undefined;
+ /** The face's one focus target: the play button, the video, or Retry. */
+ readonly focusRef?: ((element: HTMLElement | null) => void) | undefined;
+ readonly figureRef?: ((element: HTMLElement | null) => void) | undefined;
+}
+
+/** One state of the tile, drawn from its props alone. */
+export function VideoFace({ fact, index, playable, state, onPlay, onElementError, focusRef, figureRef }: VideoFaceProps) {
+ const label = fact.ref?.label ?? `Video ${index + 1}`;
+ const name = fact.name?.trim() || label;
+ const size = fact.byteLen === null ? null : fmtBytes(fact.byteLen);
+ const facts = [name, size].filter(Boolean).join(" · ");
+ const details = [label, fact.name, fact.caption, size].filter(Boolean).join(" · ");
+ const style = { "--tr-video-aspect": videoAspect(fact.width, fact.height).toFixed(4) } as CSSProperties;
+ const playing = playable && state.phase === "ready";
+ return
+ {!playable ? (
+
+
+ {videoStatus(fact)}
+ {facts}
+
+ ) : state.phase === "failed" ? (
+
+
+ Could not play video
+ Retry
+
+ ) : state.phase === "ready" ? (
+
+ onElementError?.(state.url)} />
+
+ ) : (
+
+
+ {name}
+ {state.phase === "loading" ? "Loading video…" : size ?? "Video"}
+
+ )}
+ ;
+}
+
+/** The tile over its load: nothing is asked until the reader presses play. */
+export function VideoTile({ fact, index }: { readonly fact: AttachmentFact; readonly index: number }) {
+ const attachments = useAttachmentReader();
+ const asset = attachments.videoAsset(fact);
+ const digest = asset?.digest ?? null;
+ const [state, setState] = useState(IDLE_VIDEO);
+ const source = useRef(null);
+ const figure = useRef(null);
+ const target = useRef(null);
+ const restoreFocus = useRef(false);
+ // Leaving the rail, or the member or its reader changing, ends the load:
+ // the read is aborted and an object URL made for it is revoked.
+ useEffect(() => () => {
+ source.current?.dispose();
+ source.current = null;
+ setState(IDLE_VIDEO);
+ }, [digest, attachments]);
+ // A press swaps the button for the video (or for Retry). Keep the reader's
+ // focus on the tile through each swap, unless they have moved elsewhere.
+ useLayoutEffect(() => {
+ if (!restoreFocus.current) return;
+ const active = document.activeElement;
+ if (active !== null && active !== document.body && !figure.current?.contains(active)) {
+ restoreFocus.current = false;
+ return;
+ }
+ target.current?.focus({ preventScroll: true });
+ }, [state]);
+ const play = () => {
+ if (asset === null) return;
+ restoreFocus.current = true;
+ source.current ??= createVideoSource(asset, {
+ mediaAssetUrl: attachments.mediaAssetUrl,
+ readAsset: attachments.readAsset,
+ createObjectURL: (bytes, mediaType) => URL.createObjectURL(new Blob([bytes as BlobPart], { type: mediaType })),
+ revokeObjectURL: (url) => URL.revokeObjectURL(url),
+ onChange: setState,
+ });
+ source.current.play();
+ };
+ return source.current?.elementFailed(url)}
+ focusRef={(element) => { target.current = element; }} figureRef={(element) => { figure.current = element; }} />;
+}
diff --git a/clients/room/src/components/transcript/attachment-file-source.ts b/clients/room/src/components/transcript/attachment-file-source.ts
index 96d6829c..4bd6178a 100644
--- a/clients/room/src/components/transcript/attachment-file-source.ts
+++ b/clients/room/src/components/transcript/attachment-file-source.ts
@@ -10,12 +10,12 @@
import { useMemo } from "react";
import type { AssetRef } from "@vibefield/rosetta-protocol";
import type { AttachmentFact } from "../../lib/transcript";
-import type { AssetSource } from "../../ports";
+import type { AssetSource, AssetUrl } from "../../ports";
import { useReadingSession, useRoom } from "../../room-context";
import { resolveKind } from "../file-viewer";
import type { FileHandle, FileSource } from "../file-viewer";
import type { ImageResource } from "../file-viewer/types";
-import { isRecordedImageAsset } from "../../lib/image-media";
+import { isRecordedImageAsset, isRecordedVideoAsset } from "../../lib/image-media";
const EXTENSION_BY_MEDIA_TYPE: ReadonlyMap = new Map([
["image/png", "png"],
@@ -37,8 +37,16 @@ export interface AttachmentReader {
/** The address of bytes the daemon kept, never a provider-authored path. */
assetUrl(fact: AttachmentFact): string | null;
imageResource(fact: AttachmentFact): ImageResource | null;
+ /** The recorded member of a sent video the room may play, or null: not a
+ * video, not captured, or a member the host does not offer. Never an image
+ * resource: a video asks no rendition. */
+ videoAsset(fact: AttachmentFact): AssetRef | null;
+ /** The host's address for a ``, asked only on play; undefined on
+ * a host that offers none (the bytes are read instead). */
+ readonly mediaAssetUrl: ((asset: AssetRef) => AssetUrl) | undefined;
/** A stored transcript asset as the generic file viewer understands it. */
handleFor(fact: AttachmentFact): FileHandle | null;
+ /** Images only: the viewer and its gallery never take a video. */
canOpen(fact: AttachmentFact): boolean;
/** Stable by construction: `useFileContent` treats source identity as a read. */
readonly fileSource: FileSource;
@@ -68,6 +76,7 @@ export function createAttachmentReader(assets: AssetSource, readAsset: AssetRead
const source = fact.ref?.source;
if (source === undefined || source === null || source.type !== "asset") return null;
if (fact.kind === "image" && !isRecordedImageAsset(source.asset)) return null;
+ if (fact.kind === "video" && !isRecordedVideoAsset(source.asset)) return null;
return assets.attachmentAssetUrl(source.asset);
}
@@ -78,6 +87,11 @@ export function createAttachmentReader(assets: AssetSource, readAsset: AssetRead
return imageOf(assets, asset, fact.ref?.dimensions?.width, fact.ref?.dimensions?.height);
}
+ function videoAsset(fact: AttachmentFact): AssetRef | null {
+ const source = fact.ref?.source;
+ return fact.kind === "video" && source?.type === "asset" && assetUrl(fact) !== null ? source.asset : null;
+ }
+
function handleFor(fact: AttachmentFact): FileHandle | null {
const source = fact.ref?.source;
const url = assetUrl(fact);
@@ -107,6 +121,8 @@ export function createAttachmentReader(assets: AssetSource, readAsset: AssetRead
return {
assetUrl,
imageResource,
+ videoAsset,
+ mediaAssetUrl: assets.mediaAssetUrl === undefined ? undefined : (asset) => assets.mediaAssetUrl?.(asset) ?? null,
handleFor,
canOpen: (fact) => fact.kind === "image" && handleFor(fact) !== null,
fileSource,
diff --git a/clients/room/src/components/transcript/video-source.ts b/clients/room/src/components/transcript/video-source.ts
new file mode 100644
index 00000000..d4344436
--- /dev/null
+++ b/clients/room/src/components/transcript/video-source.ts
@@ -0,0 +1,153 @@
+// One sent video's load, as a state machine with no React and no DOM: every
+// door it uses is handed in, so a test drives it with fakes.
+//
+// idle ── play ──▶ loading ──▶ ready(url) ──▶ (the element fails) ──▶ failed
+//
+// Nothing is asked before `play`. The host's own address comes first
+// (`AssetSource.mediaAssetUrl`); when the host has none, answers none, or the
+// element then cannot load it, the member's bytes are read through the
+// reading session's client and played from an object URL made here. Only an
+// object URL made here is revoked here: a host's address is the host's.
+//
+// A host's address is trusted to seek. One served whole, without byte ranges,
+// plays in Chromium but cannot seek and raises no error to fall back on, so a
+// host answers one only where ranges are honoured. rosetta's own app answers
+// none: its videos take the bytes path, which seeks.
+import type { AssetRef } from "@vibefield/rosetta-protocol";
+import type { AssetUrl } from "../../ports";
+
+export type VideoState =
+ | { readonly phase: "idle" }
+ | { readonly phase: "loading" }
+ /** `from` says whose address plays: the host's, or the bytes read here. */
+ | { readonly phase: "ready"; readonly url: string; readonly from: "host" | "bytes" }
+ | { readonly phase: "failed" };
+
+export const IDLE_VIDEO: VideoState = { phase: "idle" };
+
+export interface VideoSourceDoors {
+ /** The host's address for a ``; absent on a host that offers none. */
+ readonly mediaAssetUrl?: ((asset: AssetRef) => AssetUrl) | undefined;
+ /** The member's bytes, through the reading session's client. */
+ readonly readAsset: (asset: AssetRef, signal: AbortSignal) => Promise;
+ readonly createObjectURL: (bytes: Uint8Array, mediaType: string) => string;
+ readonly revokeObjectURL: (url: string) => void;
+ /** Every state after the first, in order. Never called after `dispose`. */
+ readonly onChange?: ((state: VideoState) => void) | undefined;
+}
+
+export interface VideoSource {
+ state(): VideoState;
+ /** The reader asked to play, or to try again after a failure. A press while
+ * loading or ready is nothing. */
+ play(): void;
+ /** The element could not load or decode `url`. The host's address falls
+ * back to the bytes; the bytes' own failure is the failed face. A report
+ * about an address that is no longer the current one is nothing. */
+ elementFailed(url: string): void;
+ /** Abort a read in flight and revoke any object URL made here. */
+ dispose(): void;
+}
+
+export function createVideoSource(asset: AssetRef, doors: VideoSourceDoors): VideoSource {
+ let state: VideoState = IDLE_VIDEO;
+ let disposed = false;
+ // Each press is its own run: an answer that arrives for an older run, or
+ // after disposal, is dropped.
+ let run = 0;
+ let reading: AbortController | null = null;
+ let objectUrl: string | null = null;
+
+ const set = (next: VideoState) => {
+ state = next;
+ doors.onChange?.(next);
+ };
+ const current = (id: number) => !disposed && id === run;
+ const release = () => {
+ if (objectUrl === null) return;
+ const url = objectUrl;
+ objectUrl = null;
+ doors.revokeObjectURL(url);
+ };
+
+ function readBytes(id: number): void {
+ const controller = new AbortController();
+ reading = controller;
+ if (state.phase !== "loading") set({ phase: "loading" });
+ const settle = () => {
+ if (reading === controller) reading = null;
+ return current(id);
+ };
+ let read: Promise;
+ try {
+ read = Promise.resolve(doors.readAsset(asset, controller.signal));
+ } catch (error) {
+ read = Promise.reject(error);
+ }
+ read.then(
+ (bytes) => {
+ if (!settle()) return;
+ try {
+ objectUrl = doors.createObjectURL(bytes, asset.media_type);
+ } catch {
+ set({ phase: "failed" });
+ return;
+ }
+ set({ phase: "ready", url: objectUrl, from: "bytes" });
+ },
+ () => {
+ if (settle()) set({ phase: "failed" });
+ },
+ );
+ }
+
+ return {
+ state: () => state,
+ play() {
+ if (disposed || state.phase === "loading" || state.phase === "ready") return;
+ const id = (run += 1);
+ release();
+ let answer: AssetUrl = null;
+ try {
+ if (doors.mediaAssetUrl !== undefined) answer = doors.mediaAssetUrl(asset);
+ } catch {
+ answer = null;
+ }
+ if (typeof answer === "string" && answer !== "") {
+ set({ phase: "ready", url: answer, from: "host" });
+ return;
+ }
+ if (answer === null || typeof answer === "string") {
+ readBytes(id);
+ return;
+ }
+ set({ phase: "loading" });
+ Promise.resolve(answer).then(
+ (url) => {
+ if (!current(id)) return;
+ if (typeof url === "string" && url !== "") set({ phase: "ready", url, from: "host" });
+ else readBytes(id);
+ },
+ () => {
+ if (current(id)) readBytes(id);
+ },
+ );
+ },
+ elementFailed(url) {
+ if (disposed || state.phase !== "ready" || state.url !== url) return;
+ if (state.from === "host") {
+ readBytes(run);
+ return;
+ }
+ release();
+ set({ phase: "failed" });
+ },
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ reading?.abort();
+ reading = null;
+ release();
+ },
+ };
+}
diff --git a/clients/room/src/fixtures/index.ts b/clients/room/src/fixtures/index.ts
index a3cc7e51..41d57899 100644
--- a/clients/room/src/fixtures/index.ts
+++ b/clients/room/src/fixtures/index.ts
@@ -127,6 +127,26 @@ function original(id: string, seed: string, name: string, width: number, height:
};
}
+/** A video an agent sent and ingestion captured: one recorded member, played
+ * from its own bytes once the reader asks. No poster, no rendition map. */
+function video(id: string, seed: string, name: string, width: number, height: number, byteLen: number): AttachmentRef {
+ const digest = digestOf(seed);
+ const asset: AssetRef = { url: `/api/assets/${digest}.mp4`, digest, media_type: "video/mp4", byte_len: byteLen };
+ return {
+ id, kind: "video", label: null, name, media_type: asset.media_type, byte_len: asset.byte_len,
+ source: { type: "asset", asset }, dimensions: { width, height }, message_ranges: [],
+ };
+}
+
+/** A video that was sent but not captured: the record keeps its path, its
+ * declared type and its size, and no bytes. */
+function uncapturedVideo(id: string, path: string, mediaType: string, byteLen: number): AttachmentRef {
+ return {
+ id, kind: "video", label: null, name: path.slice(path.lastIndexOf("/") + 1), media_type: mediaType, byte_len: byteLen,
+ source: { type: "reference", reference: { type: "file", path, resolved: true } }, dimensions: null, message_ranges: [],
+ };
+}
+
function transcript(sessionId: string, startedAt: number) {
const rows: TimelineEntry[] = [];
const add = (body: TimelineBody, kind: GroupKind, model: string | null = null): void => {
@@ -145,12 +165,12 @@ function transcript(sessionId: string, startedAt: number) {
add({ type: "user", text: { type: "inline", text }, attachments }, "human_turn"),
agent: (text: string) =>
add({ type: "agent", text: { type: "inline", text }, attachments: [], citations: [] }, "agent_cycle", "claude-opus-5-5"),
- tool: (name: string, action: ToolAction, status: ToolStatus = "succeeded") =>
+ tool: (name: string, action: ToolAction, status: ToolStatus = "succeeded", attachments: AttachmentRef[] = []) =>
add({ type: "tool_use", tool: {
correlation: { key: `${sessionId}-k${rows.length + 1}`, derivation: "native_id" },
native: { id: null, namespace: null, name, provider: null },
status: { value: status, basis: "native_state" },
- action, attachments: [], failure: null, timing: null,
+ action, attachments, failure: null, timing: null,
} }, "agent_cycle"),
};
}
@@ -164,6 +184,11 @@ const shell = (command: string, finished = true): ToolAction => ({
background: false, outcome: finished ? { exit_code: 0, signal: null, process_id: null } : null,
});
+const send = (paths: readonly string[], caption: string): ToolAction => ({
+ type: "artifact", operation: "send", artifact_kind: "file", name: null, description: { type: "inline", text: caption },
+ resources: paths.map((path) => ({ type: "file", path, resolved: true })), media: null,
+});
+
/* ── the demo ───────────────────────────────────────────────────────────── */
export function createRoomDemo(now: number = Date.now()): RoomDemo {
@@ -241,6 +266,14 @@ export function createRoomDemo(now: number = Date.now()): RoomDemo {
two.user("Extract the room into a package that dials nothing.");
two.agent("The room reads four ports now: the catalog, one session's body, the desktop and the asset source.");
two.agent("rosetta's own app is the first host, over its one client.");
+ // Two recordings the agent sent: one captured, played in place on a press;
+ // one whose file was gone before it could be captured.
+ const recordings = ["/Users/demo/Projects/rosetta/work/room-walkthrough.mp4", "/Users/demo/Projects/rosetta/work/before-the-split.mov"] as const;
+ two.tool("SendUserFile", send(recordings, "The room over the demo ports, and the app before the split"), "succeeded", [
+ video("1", "room-walkthrough", "room-walkthrough.mp4", 1920, 1080, 3_562_144),
+ uncapturedVideo("2", recordings[1], "video/quicktime", 11_482_112),
+ ]);
+ two.agent("Two recordings are attached: the room over the demo ports, and the app before the split.");
timelines.set("s8", two.rows);
const status: CatalogStatus = {
@@ -264,7 +297,19 @@ function placeholderSvg(digest: string): string {
return ` `;
}
-/** The demo's addresses: every stored image is a drawn tile, inline. */
+/** What a video member reads as: the demo commits no encoded video, so this
+ * is a header only (an `ftyp` box, then a `free` box of padding). No browser
+ * can play it, and a harness that presses play sees the "could not play" face. */
+function headerOnlyMp4(): Uint8Array {
+ const bytes = new Uint8Array(64);
+ const text = (at: number, value: string) => { for (let i = 0; i < value.length; i += 1) bytes[at + i] = value.charCodeAt(i); };
+ bytes[3] = 24; text(4, "ftyp"); text(8, "isom"); bytes[14] = 2; text(16, "isom"); text(20, "mp41");
+ bytes[27] = 40; text(28, "free");
+ return bytes;
+}
+
+/** The demo's addresses: every stored image is a drawn tile, inline. It
+ * offers no `mediaAssetUrl`: a video's bytes are read through the session. */
export function demoAssets(): AssetSource {
return {
imageAssetUrl: (asset) => `data:image/svg+xml,${encodeURIComponent(placeholderSvg(asset.digest))}`,
@@ -307,7 +352,8 @@ function demoSessionClient(demo: RoomDemo): SessionClient {
readFileTarget: never,
readPlan: never,
readTextAsset: never,
- readAsset: async (asset) => new TextEncoder().encode(placeholderSvg(asset.digest)),
+ readAsset: async (asset) => asset.media_type.startsWith("video/") ? headerOnlyMp4()
+ : new TextEncoder().encode(placeholderSvg(asset.digest)),
};
}
diff --git a/clients/room/src/lib/image-media.ts b/clients/room/src/lib/image-media.ts
index 05b6a836..fd32c00d 100644
--- a/clients/room/src/lib/image-media.ts
+++ b/clients/room/src/lib/image-media.ts
@@ -60,3 +60,21 @@ export function isRecordedImageAsset(asset: AssetRef): boolean {
const extension = recordedImageExtension(asset.media_type);
return extension !== undefined && isMember(asset, asset.media_type, extension);
}
+
+/** The two video containers ingestion records, each with the one extension
+ * its content-addressed member name carries. */
+const RECORDED_VIDEO_EXTENSION: ReadonlyMap = new Map([
+ ["video/mp4", "mp4"],
+ ["video/quicktime", "mov"],
+]);
+
+/** A sent video the room may play: one recorded member,
+ * `/api/assets/.mp4` or `.mov`, with no rendition map. The room never
+ * draws it as an image and asks no rendition of it; `VideoTile` plays the
+ * member's own bytes once the reader asks. */
+export function isRecordedVideoAsset(asset: AssetRef): boolean {
+ // A malformed row's missing asset is refused, never a render-time throw.
+ if (asset == null || asset.image_variants != null) return false;
+ const extension = RECORDED_VIDEO_EXTENSION.get(asset.media_type);
+ return extension !== undefined && isMember(asset, asset.media_type, extension);
+}
diff --git a/clients/room/src/lib/transcript/images.ts b/clients/room/src/lib/transcript/images.ts
index f57d06ac..ebc8762d 100644
--- a/clients/room/src/lib/transcript/images.ts
+++ b/clients/room/src/lib/transcript/images.ts
@@ -1,6 +1,8 @@
-// Image occurrences from canonical activity, independent of either fold level.
-// Every displayed image is a recorded canonical member: an original or a
-// complete legacy WebP bundle, drawn only through the daemon's renditions.
+// Image and video occurrences from canonical activity, independent of either
+// fold level. Every displayed image is a recorded canonical member: an original
+// or a complete legacy WebP bundle, drawn only through the daemon's renditions.
+// A video is gathered beside the images, in the same order, and never enters
+// an image path: no rendition, no gallery, no viewer (`VideoTile` plays it).
import type { ResourceRef, TimelineEntry } from "@vibefield/rosetta-protocol";
import { projectAction } from "./families";
import type { PathFact } from "./families";
@@ -9,6 +11,11 @@ import { readAttachments } from "./turns";
import { resourceLabel, splitPath } from "./vocabulary";
import { isRecordedImageAsset } from "../image-media";
+/** What a run's rail gathers: recorded images, and the videos sent beside them. */
+export function isRailMedia(fact: Pick): boolean {
+ return fact.kind === "image" || fact.kind === "video";
+}
+
function resourcePath(resource: ResourceRef): PathFact {
const label = resourceLabel(resource);
return { label, ...splitPath(label), resource };
@@ -17,7 +24,7 @@ function resourcePath(resource: ResourceRef): PathFact {
function rowImages(row: TimelineEntry): AttachmentFact[] {
if (row.body.type !== "tool_use") return [];
const { action, native } = row.body.tool;
- const recorded = messageAttachments(row).filter(fact => fact.kind === "image");
+ const recorded = messageAttachments(row).filter(isRailMedia);
if (!["artifact", "workflow", "structured_output", "external"].includes(action.type)) return recorded;
const detail = projectAction(action, native, {}, { sessionId: row.session_id, timelineId: row.timeline_id }).detail;
const paths = detail.kind === "paths" ? detail.paths
@@ -39,7 +46,8 @@ function rowImages(row: TimelineEntry): AttachmentFact[] {
})];
}
-/** Keep occurrences in transcript order, including repeated reads of one image. */
+/** Keep occurrences in transcript order, including repeated reads of one image
+ * and the videos among them. */
export function activityImages(members: readonly LedgerMember[]): AttachmentFact[] {
return members.flatMap(member => member.kind === "line" ? member.line.rows.flatMap(rowImages) : []);
}
@@ -54,7 +62,8 @@ export function messageAttachments(row: TimelineEntry): AttachmentFact[] {
}));
}
-/** One rail for every image occurrence in the run, regardless of row type. */
+/** One rail for every image and video occurrence in the run, regardless of
+ * row type. */
export function timelineImages(rows: readonly TimelineEntry[]): AttachmentFact[] {
const seen = new Set();
return rows.flatMap(row => {
@@ -62,6 +71,6 @@ export function timelineImages(rows: readonly TimelineEntry[]): AttachmentFact[]
if (seen.has(identity)) return [];
seen.add(identity);
return row.body.type === "tool_use" ? rowImages(row)
- : messageAttachments(row).filter(fact => fact.kind === "image");
+ : messageAttachments(row).filter(isRailMedia);
});
}
diff --git a/clients/room/src/ports.ts b/clients/room/src/ports.ts
index 5bd3a8de..69843ee6 100644
--- a/clients/room/src/ports.ts
+++ b/clients/room/src/ports.ts
@@ -253,7 +253,7 @@ export interface HostPort {
readonly worktree?: WorktreeHostPort | undefined;
}
-/** An address an ` ` can load: now, later, or not at all. */
+/** An address an ` ` or a `` can load: now, later, or not at all. */
export type AssetUrl = string | null | PromiseLike;
/** Where every stored image and attachment is addressed. */
@@ -276,6 +276,20 @@ export interface AssetSource {
* room should not offer it. The room uses it as the attachment's identity
* and to decide it can be opened; it never loads it. */
attachmentAssetUrl(asset: AssetRef): string | null;
+ /**
+ * An address a `` can load for one recorded video member (MP4
+ * or QuickTime), or null. Optional. The room asks only after the reader
+ * presses play, never for a poster or a thumbnail. Answer one only for an
+ * address that honours byte ranges: an address served whole, without
+ * ranges, plays but cannot seek, and raises no error for the room to fall
+ * back on. The daemon serves a member whole, so rosetta's app leaves this
+ * door out. A host without this door, one that answers null, or one whose
+ * address the element then fails to load is not an error: the room reads
+ * the member's bytes through the reading session's client (`readAsset`)
+ * and plays an object URL it makes and revokes itself, which seeks.
+ * `attachmentAssetUrl` stays the member's identity and is never loaded.
+ */
+ mediaAssetUrl?(asset: AssetRef): AssetUrl;
}
/** Everything a host hands the room. */
diff --git a/clients/room/src/styles/attachments.css b/clients/room/src/styles/attachments.css
index b1b4f174..09837d40 100644
--- a/clients/room/src/styles/attachments.css
+++ b/clients/room/src/styles/attachments.css
@@ -27,3 +27,16 @@ button.tr-att__tile { padding: 0; border: 0; color: inherit; cursor: pointer; ap
@container (max-width: 300px) { .tr-att__tile--missing svg, .tr-att__tile--loading svg { display: none; } .tr-att__tile--missing, .tr-att__tile--loading { padding: 10px; } .tr-att figure { flex-basis: 96px; width: 96px; } .tr-att__tile { border-radius: 13px; } }
.tr-ledger > .tr-attachments { margin-top: 8px; margin-bottom: 12px; }
.tr-prompt-message + .tr-prompt-message:has(.tr-attachments), .tr-prompt-message:has(.tr-attachments) + .tr-prompt-message { margin-top: 8px; }
+/* §06A · sent videos, 2026-11-12: the rail's height and the video's own shape (16:9 when unmeasured); name and size stand in for a poster. */
+.tr-att { --tr-att-size: 144px; }
+.tr-att figure.tr-att__video { flex: 0 0 auto; width: calc(var(--tr-att-size) * var(--tr-video-aspect, 1.7778)); }
+.tr-att__video .tr-att__tile { aspect-ratio: auto; height: var(--tr-att-size); align-content: center; gap: 4px; padding: 12px; color: var(--vf-text-secondary); font-size: 11px; line-height: 1.4; text-align: center; }
+.tr-att__video .tr-att__tile--missing, .tr-att__video .tr-att__tile--loading { color: var(--vf-text-tertiary); }
+.tr-att__name, .tr-att__facts { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.tr-att__name { color: var(--vf-text-primary); font-weight: 500; }
+.tr-att__facts { color: var(--vf-text-tertiary); font-variant-numeric: tabular-nums; }
+.tr-att__video .tr-att__tile--playing { padding: 0; background: #000; }
+.tr-att__tile video { display: block; position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
+.tr-att__tile video:focus-visible { outline: 2px solid var(--vf-select); outline-offset: -2px; }
+@container (max-width: 440px) { .tr-att { --tr-att-size: 112px; } }
+@container (max-width: 300px) { .tr-att { --tr-att-size: 96px; } .tr-att__video .tr-att__tile { padding: 8px; gap: 2px; } }
diff --git a/crates/adapter-support/src/lib.rs b/crates/adapter-support/src/lib.rs
index 2d7c1559..3e2e27d1 100644
--- a/crates/adapter-support/src/lib.rs
+++ b/crates/adapter-support/src/lib.rs
@@ -10,6 +10,7 @@ pub mod adapter;
mod glob;
pub mod html_artifacts;
pub mod media;
+pub mod presented_media;
pub mod time;
pub mod tool;
diff --git a/crates/adapter-support/src/media.rs b/crates/adapter-support/src/media.rs
index 7f9e1f2a..de42be9d 100644
--- a/crates/adapter-support/src/media.rs
+++ b/crates/adapter-support/src/media.rs
@@ -3,9 +3,10 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use rosetta_core::ProjectionAsset;
+use rosetta_core::presented_media::VIDEO_MAX_BYTES;
use rosetta_core::timeline::{
AttachmentKind, AttachmentRef, AttachmentSource, AttachmentUnavailableReason as Missing,
- CORE_SCALAR_MAX_BYTES, ID_MAX_BYTES, ResourceRef,
+ CORE_SCALAR_MAX_BYTES, Dimensions, ID_MAX_BYTES, ResourceRef,
};
pub type MaterializedAttachment = (AttachmentRef, Option);
@@ -209,6 +210,72 @@ pub fn canonical_media_type(mime: &str) -> Option<&'static str> {
}
}
+/// The canonical type of a file an agent sent by path, or `None` when that
+/// type is not presented. Its own entry point: the inline-media table above
+/// stays as it is, so a recorded `video_url` data URL is still not captured.
+pub fn presented_media_type(declared: &str) -> Option<&'static str> {
+ match declared.trim().to_ascii_lowercase().as_str() {
+ "video/mp4" => Some("video/mp4"),
+ "video/quicktime" => Some("video/quicktime"),
+ _ => canonical_media_type(declared).filter(|mime| mime.starts_with("image/")),
+ }
+}
+
+/// The attachment kind of a canonical presented type.
+pub fn presented_kind(canonical: &str) -> Option {
+ match presented_media_type(canonical)? {
+ mime if mime != canonical => None,
+ mime if mime.starts_with("image/") => Some(AttachmentKind::Image),
+ _ => Some(AttachmentKind::Video),
+ }
+}
+
+/// The most bytes a presented file of a canonical type may have.
+pub fn presented_limit(canonical: &str) -> Option {
+ match presented_kind(canonical)? {
+ AttachmentKind::Image => Some(rosetta_media::MAX_INPUT_BYTES as u64),
+ _ => Some(VIDEO_MAX_BYTES),
+ }
+}
+
+/// Verify the captured bytes of a presented file against the type it was
+/// declared with and keep them as the member. An image passes the same
+/// header probe as inline media; a video passes when its container header
+/// names the declared type. `None` is every refusal: a type that is not
+/// presented, bytes over the limit, a wrong signature, a failed probe.
+pub fn from_presented_bytes(
+ media_type: &str,
+ bytes: Vec,
+) -> Option<(ProjectionAsset, Option)> {
+ let mime = presented_media_type(media_type)?;
+ if bytes.len() as u64 > presented_limit(mime)? {
+ return None;
+ }
+ match presented_kind(mime)? {
+ AttachmentKind::Image => match from_bytes("x", AttachmentKind::Image, mime, bytes) {
+ (
+ AttachmentRef {
+ source: AttachmentSource::Asset { .. },
+ dimensions,
+ ..
+ },
+ Some(asset),
+ ) => Some((asset, dimensions)),
+ _ => None,
+ },
+ _ => {
+ let probe = rosetta_media::probe_video(&bytes).ok()?;
+ if probe.media_type != mime {
+ return None;
+ }
+ let dimensions = probe
+ .dimensions
+ .filter(|size| size.width > 0 && size.height > 0);
+ Some((ProjectionAsset::new(mime, bytes)?, dimensions))
+ }
+ }
+}
+
fn magic_matches(mime: &str, bytes: &[u8]) -> bool {
match mime {
"image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
@@ -226,7 +293,6 @@ fn magic_matches(mime: &str, bytes: &[u8]) -> bool {
#[cfg(test)]
mod tests {
use super::*;
- use rosetta_core::timeline::Dimensions;
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
#[test]
@@ -294,6 +360,195 @@ mod tests {
}
));
}
+
+ /// A leading `ftyp` box with one compatible brand, then a `moov` header.
+ fn video(major: &[u8; 4], compatible: &[u8; 4]) -> Vec {
+ let mut bytes = 20_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(b"ftyp");
+ bytes.extend_from_slice(major);
+ bytes.extend_from_slice(&[0, 0, 2, 0]);
+ bytes.extend_from_slice(compatible);
+ bytes.extend_from_slice(b"\0\0\0\x08moov");
+ bytes
+ }
+
+ #[test]
+ fn presented_types_are_images_and_two_video_containers() {
+ for (declared, canonical, kind, limit) in [
+ ("image/png", "image/png", AttachmentKind::Image, 32),
+ ("image/apng", "image/png", AttachmentKind::Image, 32),
+ (" IMAGE/JPG ", "image/jpeg", AttachmentKind::Image, 32),
+ ("image/gif", "image/gif", AttachmentKind::Image, 32),
+ ("image/webp", "image/webp", AttachmentKind::Image, 32),
+ ("image/bmp", "image/bmp", AttachmentKind::Image, 32),
+ (
+ "image/vnd.microsoft.icon",
+ "image/x-icon",
+ AttachmentKind::Image,
+ 32,
+ ),
+ ("video/mp4", "video/mp4", AttachmentKind::Video, 64),
+ (" Video/MP4", "video/mp4", AttachmentKind::Video, 64),
+ (
+ "video/quicktime",
+ "video/quicktime",
+ AttachmentKind::Video,
+ 64,
+ ),
+ ] {
+ assert_eq!(
+ presented_media_type(declared),
+ Some(canonical),
+ "{declared}"
+ );
+ assert_eq!(presented_kind(canonical), Some(kind), "{canonical}");
+ assert_eq!(
+ presented_limit(canonical),
+ Some(limit * 1024 * 1024),
+ "{canonical}"
+ );
+ // Every presented type is one core accepts as a fact and a member.
+ assert!(rosetta_core::presented_media::MEDIA_TYPES.contains(&canonical));
+ assert!(ProjectionAsset::supported_extension(canonical).is_some());
+ }
+ for media_type in rosetta_core::presented_media::MEDIA_TYPES {
+ assert_eq!(presented_media_type(media_type), Some(media_type));
+ }
+ for declared in [
+ "",
+ "text/html",
+ "text/markdown",
+ "text/plain",
+ "application/pdf",
+ "image/svg+xml",
+ "image/heic",
+ "image/avif",
+ "video/webm",
+ "video/x-m4v",
+ "audio/mp4",
+ "video/mp4; codecs=avc1",
+ ] {
+ assert_eq!(presented_media_type(declared), None, "{declared}");
+ assert_eq!(presented_kind(declared), None, "{declared}");
+ assert_eq!(presented_limit(declared), None, "{declared}");
+ }
+ }
+
+ #[test]
+ fn presented_bytes_are_verified_against_their_declared_type() {
+ let png = STANDARD.decode(PNG).unwrap();
+ let (asset, dimensions) = from_presented_bytes("image/png", png.clone()).unwrap();
+ assert_eq!(asset.media_type, "image/png");
+ assert_eq!(asset.bytes, png);
+ assert_eq!(
+ asset.digest,
+ blake3::hash(&png).to_hex().to_string(),
+ "the member is the recorded bytes"
+ );
+ assert_eq!(
+ dimensions,
+ Some(Dimensions {
+ width: 1,
+ height: 1
+ })
+ );
+ asset.reference().validate().unwrap();
+
+ let mp4 = video(b"isom", b"mp41");
+ let (asset, dimensions) = from_presented_bytes("video/mp4", mp4.clone()).unwrap();
+ assert_eq!(
+ (asset.media_type.as_str(), &asset.bytes, dimensions),
+ ("video/mp4", &mp4, None)
+ );
+ assert!(asset.url().unwrap().ends_with(".mp4"));
+ asset.reference().validate().unwrap();
+
+ let mov = video(b"qt ", b"qt ");
+ let (asset, dimensions) = from_presented_bytes("video/quicktime", mov.clone()).unwrap();
+ assert_eq!(
+ (asset.media_type.as_str(), &asset.bytes, dimensions),
+ ("video/quicktime", &mov, None)
+ );
+ assert!(asset.url().unwrap().ends_with(".mov"));
+
+ // Wrong magic, in each direction.
+ assert_eq!(from_presented_bytes("image/png", mp4.clone()), None);
+ assert_eq!(from_presented_bytes("image/jpeg", png.clone()), None);
+ assert_eq!(from_presented_bytes("video/mp4", png.clone()), None);
+ assert_eq!(
+ from_presented_bytes("video/mp4", b"not a video".to_vec()),
+ None
+ );
+ assert_eq!(from_presented_bytes("video/mp4", Vec::new()), None);
+ assert_eq!(from_presented_bytes("image/png", Vec::new()), None);
+ // The detected container must be the declared one.
+ assert_eq!(from_presented_bytes("video/mp4", mov.clone()), None);
+ assert_eq!(from_presented_bytes("video/quicktime", mp4.clone()), None);
+ // A type that is not presented is refused whatever the bytes are.
+ assert_eq!(from_presented_bytes("video/webm", mp4.clone()), None);
+ assert_eq!(
+ from_presented_bytes("application/pdf", b"%PDF-1.7".to_vec()),
+ None
+ );
+ assert_eq!(from_presented_bytes("text/plain", b"hello".to_vec()), None);
+ // A truncated image fails the header probe, not just the signature.
+ assert_eq!(from_presented_bytes("image/png", png[..20].to_vec()), None);
+ }
+
+ #[test]
+ fn presented_bytes_over_the_limit_are_refused() {
+ let limit = VIDEO_MAX_BYTES as usize;
+ let mut video = video(b"isom", b"mp41");
+ video.resize(limit, 0);
+ let (asset, _) = from_presented_bytes("video/mp4", video.clone()).unwrap();
+ assert_eq!(asset.bytes.len(), limit);
+ video.push(0);
+ assert_eq!(from_presented_bytes("video/mp4", video), None);
+
+ let mut image = STANDARD.decode(PNG).unwrap();
+ image.resize(rosetta_media::MAX_INPUT_BYTES + 1, 0);
+ assert_eq!(from_presented_bytes("image/png", image), None);
+ }
+
+ /// The presented entry points are additions: inline media is classified
+ /// exactly as before, so a recorded video data URL is still not captured.
+ #[test]
+ fn inline_video_is_still_not_materialized() {
+ assert_eq!(canonical_media_type("video/mp4"), None);
+ assert_eq!(canonical_media_type("video/quicktime"), None);
+ let mp4 = video(b"isom", b"mp41");
+ let url = format!("data:video/mp4;base64,{}", STANDARD.encode(&mp4));
+ let (attachment, asset) = from_url("clip", AttachmentKind::Video, &url, None);
+ assert_eq!(asset, None);
+ assert_eq!(
+ attachment.source,
+ AttachmentSource::Unavailable {
+ reason: Missing::UnsupportedMedia
+ }
+ );
+ assert_eq!(attachment.media_type.as_deref(), Some("video/mp4"));
+ let (attachment, asset) = from_base64(
+ "clip",
+ AttachmentKind::Video,
+ "video/mp4",
+ &STANDARD.encode(&mp4),
+ );
+ assert_eq!(asset, None);
+ assert!(matches!(
+ attachment.source,
+ AttachmentSource::Unavailable {
+ reason: Missing::UnsupportedMedia
+ }
+ ));
+ let (attachment, asset) = from_bytes("clip", AttachmentKind::Video, "video/mp4", mp4);
+ assert_eq!(asset, None);
+ assert_eq!(
+ attachment.source,
+ AttachmentSource::Unavailable {
+ reason: Missing::UnsupportedMedia
+ }
+ );
+ }
}
#[cfg(test)]
diff --git a/crates/adapter-support/src/presented_media.rs b/crates/adapter-support/src/presented_media.rs
new file mode 100644
index 00000000..5bf929ce
--- /dev/null
+++ b/crates/adapter-support/src/presented_media.rs
@@ -0,0 +1,570 @@
+//! Files an agent sent the user by path. The one producer of the
+//! [`PresentedMedia`] fact and of the path-reference attachment it upgrades.
+//!
+//! Pure: the transcript names a path, a size and a type, and nothing here
+//! opens the file. The owning adapter proves the tool result and reads its
+//! own native field names; this module only decides which of those files can
+//! be presented and gives each one a stable slot. Capture reads the bytes
+//! later, in the pipeline.
+
+use std::collections::BTreeSet;
+
+use rosetta_core::presented_media::{
+ FILES_PER_RECORD, PresentedMedia, REFERENCE_MUTATION, upgrade_mutation,
+};
+use rosetta_core::rows::Ms;
+use rosetta_core::timeline::{AttachmentRef, AttachmentSource, ResourceRef};
+use rosetta_core::{IngestOp, ToolUpdatePatch};
+
+use crate::adapter::NormalizeCtx;
+use crate::{media, tool};
+
+/// One file a tool result lists as sent, in the adapter's native order.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PresentedFile<'a> {
+ /// The path as recorded.
+ pub path: &'a str,
+ /// The working directory a relative path resolves against, if known.
+ pub cwd: Option<&'a str>,
+ /// The size the transcript recorded for the file.
+ pub len: u64,
+ /// The type the transcript recorded for the file.
+ pub media_type: &'a str,
+}
+
+/// Attach the presentable files of one tool result to their tool row as path
+/// references, in one tool update, and return one fact per attached file.
+///
+/// Only the first [`FILES_PER_RECORD`] files are examined; a file's `ordinal`
+/// is its native index, so a skipped file never shifts a later file's slot.
+/// A file is skipped when its type is not a presented type, its size is
+/// zero, its path is HTML (the publication seam owns those), is a network
+/// path, holds a backslash, does not resolve lexically to an absolute path,
+/// or repeats; and when the finished fact does not validate (a device or
+/// process path, an over-long one), so nothing emitted here can fail a store
+/// batch.
+///
+/// A recorded path is presented as written or not at all. Capture opens the
+/// fact's path, so a path this producer rewrote would name a file the
+/// transcript did not: a backslash is a legal POSIX file-name byte, not a
+/// separator, and `..` after a symlinked directory is not its lexical
+/// parent. An absolute path is therefore kept only when it is already in
+/// normal form (no `.`, `..`, empty or trailing component), and a relative
+/// path only when it and its `cwd` are, so that the fact's path is the two
+/// joined by one `/` and nothing else.
+///
+/// The caller wraps each returned fact as [`IngestOp::PresentedMedia`].
+pub fn push(
+ ctx: &NormalizeCtx,
+ correlation_key: &str,
+ occurrence: &str,
+ files: &[PresentedFile<'_>],
+ at: Option,
+ ops: &mut Vec,
+) -> Vec {
+ let scope = blake3::hash(occurrence.as_bytes()).to_hex().to_string();
+ let mut seen = BTreeSet::new();
+ let mut attachments = Vec::new();
+ let mut facts = Vec::new();
+ for (ordinal, file) in files.iter().take(FILES_PER_RECORD).enumerate() {
+ let Some(fact) = fact(ctx, correlation_key, &scope, ordinal, file, at) else {
+ continue;
+ };
+ if !seen.insert(fact.path.clone()) {
+ continue;
+ }
+ attachments.push(fact.attachment.clone());
+ facts.push(fact);
+ }
+ if !attachments.is_empty() {
+ IngestOp::push_tool_update(
+ ops,
+ ctx.session_id.clone(),
+ correlation_key.to_owned(),
+ ctx.evidence(REFERENCE_MUTATION),
+ ToolUpdatePatch {
+ attachments,
+ ..Default::default()
+ },
+ at,
+ );
+ }
+ facts
+}
+
+fn fact(
+ ctx: &NormalizeCtx,
+ correlation_key: &str,
+ scope: &str,
+ ordinal: usize,
+ file: &PresentedFile<'_>,
+ at: Option,
+) -> Option {
+ let media_type = media::presented_media_type(file.media_type)?;
+ let kind = media::presented_kind(media_type)?;
+ if file.len == 0
+ || file.path.contains(['\0', '\\'])
+ || file.path.starts_with("//")
+ || rosetta_core::html_artifacts::is_html_path(file.path)
+ {
+ return None;
+ }
+ let Ok(ResourceRef::File {
+ path,
+ resolved: true,
+ }) = tool::lexical_resource(file.path, file.cwd, false)
+ else {
+ return None;
+ };
+ if !path.starts_with('/') || rosetta_core::html_artifacts::is_html_path(&path) {
+ return None;
+ }
+ // Resolution may only join a relative path to its `cwd`. It never
+ // collapses a component of either: see `push`.
+ let as_written = if file.path.starts_with('/') {
+ path == file.path
+ } else {
+ file.cwd.is_some_and(|cwd| {
+ path.strip_prefix(cwd)
+ .and_then(|rest| rest.strip_prefix('/'))
+ == Some(file.path)
+ })
+ };
+ if !as_written {
+ return None;
+ }
+ let mut attachment = AttachmentRef::new(
+ format!("{scope}:sent-{ordinal}"),
+ kind,
+ AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: path.clone(),
+ resolved: true,
+ },
+ },
+ );
+ attachment.name = path.rsplit('/').next().and_then(media::bounded_name);
+ attachment.media_type = Some(media_type.to_owned());
+ attachment.byte_len = Some(file.len);
+ let evidence = ctx.evidence(upgrade_mutation(u32::try_from(ordinal).ok()?));
+ let fact = PresentedMedia {
+ session_id: ctx.session_id.clone(),
+ source_offset: evidence.record_offset,
+ path,
+ expected_len: file.len,
+ media_type: media_type.to_owned(),
+ correlation_key: correlation_key.to_owned(),
+ attachment,
+ evidence,
+ at,
+ };
+ fact.validate().ok()?;
+ Some(fact)
+}
+
+#[cfg(test)]
+mod tests {
+ use rosetta_core::timeline::{AttachmentKind, CORE_SCALAR_MAX_BYTES};
+
+ use super::*;
+
+ fn ctx() -> NormalizeCtx {
+ let mut ctx = NormalizeCtx::new("runtime", "session");
+ ctx.src_offset = Some(4096);
+ ctx.source_identity = Some("source".into());
+ ctx.source_generation = 3;
+ ctx
+ }
+
+ fn file<'a>(path: &'a str, media_type: &'a str) -> PresentedFile<'a> {
+ PresentedFile {
+ path,
+ cwd: None,
+ len: 10,
+ media_type,
+ }
+ }
+
+ /// The one tool update a call pushed, as `(evidence index, attachments)`.
+ fn update(ops: &[IngestOp]) -> (u32, Vec) {
+ let [
+ IngestOp::ToolUpdate {
+ session_id,
+ correlation_key,
+ evidence,
+ patch,
+ at,
+ },
+ ] = ops
+ else {
+ panic!("expected exactly one tool update, got {ops:?}");
+ };
+ assert_eq!(session_id, "session");
+ assert_eq!(correlation_key, "tool-1");
+ assert_eq!(*at, Some(77));
+ assert_eq!(evidence.record_offset, 4096);
+ assert_eq!(evidence.generation, 3);
+ assert!(patch.request.is_none() && patch.outcome.is_none());
+ (evidence.mutation_index, patch.attachments.clone())
+ }
+
+ #[test]
+ fn sent_files_become_reference_slots_and_one_fact_each() {
+ let files = [
+ file("/work/out/shot.png", "image/png"),
+ file("/work/out/clip.mp4", "video/mp4"),
+ PresentedFile {
+ len: 99,
+ ..file("/work/out/screen recording.MOV", " Video/QuickTime ")
+ },
+ file("/work/out/photo.jpeg", "image/jpg"),
+ ];
+ let mut ops = Vec::new();
+ let facts = push(&ctx(), "tool-1", "result-uuid", &files, Some(77), &mut ops);
+
+ let scope = blake3::hash(b"result-uuid").to_hex().to_string();
+ let (index, attachments) = update(&ops);
+ assert_eq!(index, REFERENCE_MUTATION);
+ assert_eq!(
+ attachments.iter().map(|a| a.id.clone()).collect::>(),
+ (0..4)
+ .map(|ordinal| format!("{scope}:sent-{ordinal}"))
+ .collect::>()
+ );
+ assert_eq!(
+ attachments.iter().map(|a| a.kind).collect::>(),
+ [
+ AttachmentKind::Image,
+ AttachmentKind::Video,
+ AttachmentKind::Video,
+ AttachmentKind::Image
+ ]
+ );
+ assert_eq!(
+ attachments
+ .iter()
+ .map(|a| (a.name.as_deref(), a.media_type.as_deref(), a.byte_len))
+ .collect::>(),
+ [
+ (Some("shot.png"), Some("image/png"), Some(10)),
+ (Some("clip.mp4"), Some("video/mp4"), Some(10)),
+ (
+ Some("screen recording.MOV"),
+ Some("video/quicktime"),
+ Some(99)
+ ),
+ (Some("photo.jpeg"), Some("image/jpeg"), Some(10)),
+ ]
+ );
+ for attachment in &attachments {
+ attachment.validate().unwrap();
+ assert!(attachment.dimensions.is_none() && attachment.label.is_none());
+ assert!(attachment.message_ranges.is_empty());
+ }
+
+ // One fact per slot, in native order, each carrying its slot as
+ // attached and an upgrade stamp above the reference.
+ assert_eq!(facts.len(), 4);
+ for (ordinal, (fact, attachment)) in facts.iter().zip(&attachments).enumerate() {
+ fact.validate().unwrap();
+ assert_eq!(&fact.attachment, attachment);
+ assert_eq!(fact.session_id, "session");
+ assert_eq!(fact.correlation_key, "tool-1");
+ assert_eq!(fact.source_offset, 4096);
+ assert_eq!(fact.at, Some(77));
+ assert_eq!(
+ fact.evidence,
+ ctx().evidence(upgrade_mutation(ordinal as u32))
+ );
+ assert!(fact.evidence > ctx().evidence(REFERENCE_MUTATION));
+ assert!(fact.evidence < ctx().evidence(u32::MAX - 1));
+ assert_eq!(
+ attachment.source,
+ AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: fact.path.clone(),
+ resolved: true
+ }
+ }
+ );
+ }
+ assert_eq!(
+ facts
+ .iter()
+ .map(|f| (f.path.as_str(), f.media_type.as_str(), f.expected_len))
+ .collect::>(),
+ [
+ ("/work/out/shot.png", "image/png", 10),
+ ("/work/out/clip.mp4", "video/mp4", 10),
+ ("/work/out/screen recording.MOV", "video/quicktime", 99),
+ ("/work/out/photo.jpeg", "image/jpeg", 10),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_skipped_file_keeps_its_ordinal_and_emits_nothing() {
+ let long_name = format!("/{}.png", "n".repeat(CORE_SCALAR_MAX_BYTES));
+ let long_path = format!("/{}/a.png", "d/".repeat(CORE_SCALAR_MAX_BYTES / 2));
+ let files = [
+ file("/work/a.png", "image/png"), // 0 kept
+ file("/work/report.html", "text/html"), // 1 unknown type
+ file("/work/page.HTML", "image/png"), // 2 html path
+ file("/work/notes.md", "text/markdown"), // 3 unknown type
+ file("/work/doc.pdf", "application/pdf"), // 4 not presented
+ file("/work/clip.webm", "video/webm"), // 5 unknown type
+ file("/work/untyped.png", ""), // 6 no type
+ PresentedFile {
+ len: 0,
+ ..file("/work/empty.png", "image/png")
+ }, // 7 zero size
+ file("relative/b.png", "image/png"), // 8 relative, no cwd
+ file("//server/share/c.png", "image/png"), // 9 network path
+ file("\\\\server\\share\\c.png", "image/png"), // 10 network path
+ file("C:/Users/me/d.png", "image/png"), // 11 not rooted at /
+ file("/work/nul\0.png", "image/png"), // 12 NUL
+ file(&long_name, "image/png"), // 13 over-long name
+ file(&long_path, "image/png"), // 14 over-long path
+ file("/work/a.png", "image/png"), // 15 repeats 0
+ file("", "image/png"), // 16 empty path
+ PresentedFile {
+ cwd: Some("relative/cwd"),
+ ..file("e.png", "image/png")
+ }, // 17 cwd is not absolute
+ PresentedFile {
+ cwd: Some(""),
+ ..file("f.png", "image/png")
+ }, // 18 empty cwd
+ PresentedFile {
+ cwd: Some("/work"),
+ ..file("page.htm/.", "image/png")
+ }, // 19 html once resolved
+ PresentedFile {
+ cwd: Some("/work/sub"),
+ ..file("g.mov", "video/quicktime")
+ }, // 20 kept
+ PresentedFile {
+ cwd: Some("/work"),
+ ..file("a.png", "image/png")
+ }, // 21 repeats 0 once resolved
+ ];
+ let mut ops = Vec::new();
+ let facts = push(&ctx(), "tool-1", "occurrence", &files, Some(77), &mut ops);
+ let scope = blake3::hash(b"occurrence").to_hex().to_string();
+ let (_, attachments) = update(&ops);
+ assert_eq!(
+ attachments.iter().map(|a| a.id.clone()).collect::>(),
+ [format!("{scope}:sent-0"), format!("{scope}:sent-20")]
+ );
+ assert_eq!(
+ facts
+ .iter()
+ .map(|f| (f.path.as_str(), f.evidence.mutation_index))
+ .collect::>(),
+ [
+ ("/work/a.png", upgrade_mutation(0)),
+ ("/work/sub/g.mov", upgrade_mutation(20)),
+ ]
+ );
+ for fact in &facts {
+ fact.validate().unwrap();
+ }
+ }
+
+ /// Capture opens the fact's path, so the producer never presents a path
+ /// it would have to rewrite: the file opened would not be the file sent.
+ #[test]
+ fn a_recorded_path_is_presented_as_written_or_not_at_all() {
+ let in_work = |path| PresentedFile {
+ cwd: Some("/work"),
+ ..file(path, "image/png")
+ };
+ let files = [
+ file("/work/a.png", "image/png"), // 0 kept
+ file("/work/out/shot\\1.png", "image/png"), // 1 a backslash in a name
+ file("/work/link/../b.png", "image/png"), // 2 `..` after a directory
+ file("/\\server/share/x.png", "image/png"), // 3 `/\server`
+ file("\\work\\c.png", "image/png"), // 4 backslash-rooted
+ file("C:\\Users\\me\\d.png", "image/png"), // 5 a drive path
+ file("/work/./e.png", "image/png"), // 6 a `.` component
+ file("/work//f.png", "image/png"), // 7 an empty component
+ file("/work/g.png/", "image/png"), // 8 a trailing slash
+ file("/../h.png", "image/png"), // 9 `..` at the root
+ file("/work/./x/../a.png", "image/png"), // 10 would resolve to 0
+ in_work("/work/sub/../i.png"), // 11 absolute, beside a cwd
+ in_work("sub\\j.png"), // 12 a backslash, relative
+ PresentedFile {
+ cwd: Some("/work\\sub"),
+ ..file("k.png", "image/png")
+ }, // 13 a backslash in the cwd
+ file("/work/..hidden/l.png", "image/png"), // 14 kept: `..` inside a name
+ file("/work/.cache/m.png", "image/png"), // 15 kept: a dot name
+ in_work("/work/n.png"), // 16 kept: absolute as written
+ in_work("./sub/../o.png"), // 17 relative, not as written
+ in_work("../p.png"), // 18 leaves its cwd
+ PresentedFile {
+ cwd: Some("/work/x/.."),
+ ..file("q.png", "image/png")
+ }, // 19 a cwd not as written
+ PresentedFile {
+ cwd: Some("/work/"),
+ ..file("r.png", "image/png")
+ }, // 20 a cwd with a trailing slash
+ in_work("sub/s.png"), // 21 kept: joined to its cwd
+ ];
+ let mut ops = Vec::new();
+ let facts = push(&ctx(), "tool-1", "occurrence", &files, Some(77), &mut ops);
+ let scope = blake3::hash(b"occurrence").to_hex().to_string();
+ let (_, attachments) = update(&ops);
+ assert_eq!(
+ attachments.iter().map(|a| a.id.clone()).collect::>(),
+ [0, 14, 15, 16, 21].map(|ordinal| format!("{scope}:sent-{ordinal}"))
+ );
+ assert_eq!(
+ facts
+ .iter()
+ .map(|f| (f.path.as_str(), f.evidence.mutation_index))
+ .collect::>(),
+ [
+ ("/work/a.png", upgrade_mutation(0)),
+ ("/work/..hidden/l.png", upgrade_mutation(14)),
+ ("/work/.cache/m.png", upgrade_mutation(15)),
+ ("/work/n.png", upgrade_mutation(16)),
+ ("/work/sub/s.png", upgrade_mutation(21)),
+ ]
+ );
+ // A kept absolute path is the recorded string, byte for byte, in the
+ // fact and in the slot a reader sees.
+ for (fact, ordinal) in facts.iter().zip([0, 14, 15, 16]) {
+ fact.validate().unwrap();
+ assert_eq!(fact.path, files[ordinal].path);
+ assert_eq!(
+ fact.attachment.source,
+ AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: files[ordinal].path.to_owned(),
+ resolved: true
+ }
+ }
+ );
+ }
+ assert_eq!(attachments[1].name.as_deref(), Some("l.png"));
+ }
+
+ /// Opening `/dev/fd/N` duplicates the reader's own descriptor; a device
+ /// or process path is never presented, however it is spelled.
+ #[test]
+ fn a_device_or_process_path_is_skipped() {
+ let under = |cwd, path| PresentedFile {
+ cwd: Some(cwd),
+ ..file(path, "image/png")
+ };
+ let files = [
+ file("/dev/fd/3", "image/png"), // 0
+ file("/dev/null", "video/mp4"), // 1
+ file("/dev", "image/png"), // 2
+ file("/proc/self/fd/3", "image/png"), // 3
+ file("/proc", "video/quicktime"), // 4
+ file("/DEV/fd/3", "image/png"), // 5 a case-folding volume
+ file("/dev/../dev/fd/3", "image/png"), // 6 not as written, either
+ under("/dev", "fd/3"), // 7 joined to a device cwd
+ under("/proc", "self/fd/3"), // 8 joined to a process cwd
+ under("/work", "../dev/fd/3"), // 9 not as written, either
+ file("/devices/a.png", "image/png"), // 10 kept
+ file("/work/dev/b.png", "image/png"), // 11 kept
+ file("/work/proc/c.mp4", "video/mp4"), // 12 kept
+ ];
+ let mut ops = Vec::new();
+ let facts = push(&ctx(), "tool-1", "occurrence", &files, Some(77), &mut ops);
+ assert_eq!(
+ facts
+ .iter()
+ .map(|f| (f.path.as_str(), f.evidence.mutation_index))
+ .collect::>(),
+ [
+ ("/devices/a.png", upgrade_mutation(10)),
+ ("/work/dev/b.png", upgrade_mutation(11)),
+ ("/work/proc/c.mp4", upgrade_mutation(12)),
+ ]
+ );
+ assert_eq!(update(&ops).1.len(), 3);
+ // Alone, such a file pushes no update at all.
+ let mut ops = Vec::new();
+ assert!(
+ push(
+ &ctx(),
+ "tool-1",
+ "occurrence",
+ &files[..10],
+ Some(77),
+ &mut ops
+ )
+ .is_empty()
+ );
+ assert!(ops.is_empty());
+ }
+
+ #[test]
+ fn nothing_presentable_pushes_nothing() {
+ let mut ops = Vec::new();
+ assert!(push(&ctx(), "tool-1", "occurrence", &[], Some(77), &mut ops).is_empty());
+ let files = [
+ file("/work/report.html", "text/html"),
+ file("relative.png", "image/png"),
+ ];
+ assert!(push(&ctx(), "tool-1", "occurrence", &files, Some(77), &mut ops).is_empty());
+ assert!(ops.is_empty());
+ // A fact with no session or no correlation key cannot be applied.
+ let files = [file("/work/a.png", "image/png")];
+ assert!(push(&ctx(), "", "occurrence", &files, None, &mut ops).is_empty());
+ let orphan = NormalizeCtx::new("runtime", "");
+ assert!(push(&orphan, "tool-1", "occurrence", &files, None, &mut ops).is_empty());
+ assert!(ops.is_empty());
+ }
+
+ #[test]
+ fn only_the_first_thirty_two_files_are_examined() {
+ let paths: Vec = (0..40).map(|index| format!("/work/{index}.png")).collect();
+ let files: Vec<_> = paths.iter().map(|path| file(path, "image/png")).collect();
+ let mut ops = Vec::new();
+ let facts = push(&ctx(), "tool-1", "occurrence", &files, Some(77), &mut ops);
+ let (_, attachments) = update(&ops);
+ assert_eq!(attachments.len(), FILES_PER_RECORD);
+ assert_eq!(facts.len(), FILES_PER_RECORD);
+ assert_eq!(facts[31].path, "/work/31.png");
+ assert_eq!(facts[31].evidence.mutation_index, u32::MAX - 2);
+ // A skipped file inside the window is not replaced by one past it.
+ let mut files = files;
+ files[0].len = 0;
+ let mut ops = Vec::new();
+ let facts = push(&ctx(), "tool-1", "occurrence", &files, Some(77), &mut ops);
+ assert_eq!(facts.len(), FILES_PER_RECORD - 1);
+ assert_eq!(facts[0].path, "/work/1.png");
+ assert_eq!(facts.last().unwrap().path, "/work/31.png");
+ }
+
+ #[test]
+ fn slot_ids_are_scoped_by_occurrence_and_a_missing_offset_reads_as_zero() {
+ let files = [file("/work/a.png", "image/png")];
+ let mut first = Vec::new();
+ let mut second = Vec::new();
+ let a = push(&ctx(), "tool-1", "result-a", &files, Some(77), &mut first);
+ let b = push(&ctx(), "tool-1", "result-b", &files, Some(77), &mut second);
+ assert_ne!(a[0].attachment.id, b[0].attachment.id);
+ // Deterministic: the same inputs give the same slot.
+ let mut again = Vec::new();
+ assert_eq!(
+ push(&ctx(), "tool-1", "result-a", &files, Some(77), &mut again),
+ a
+ );
+ assert_eq!(again, first);
+
+ let mut direct = NormalizeCtx::new("runtime", "session");
+ direct.src_offset = Some(-1);
+ let mut ops = Vec::new();
+ let facts = push(&direct, "tool-1", "result-a", &files, None, &mut ops);
+ assert_eq!(facts[0].source_offset, 0);
+ assert_eq!(facts[0].evidence.record_offset, 0);
+ facts[0].validate().unwrap();
+ }
+}
diff --git a/crates/adapters/claude-code/src/adapter.rs b/crates/adapters/claude-code/src/adapter.rs
index da166d85..3dd85e9a 100644
--- a/crates/adapters/claude-code/src/adapter.rs
+++ b/crates/adapters/claude-code/src/adapter.rs
@@ -74,6 +74,11 @@ impl AgentAdapter for ClaudeCodeAdapter {
// 39 (2026-11-05, schema 30): a native publish keeps its description
// and counts the files-map members it did not keep; a files-map string
// is a source path resolved against `root`, never inline HTML.
+ // Still 39 after `SendUserFile` sent files became path-reference
+ // attachments and capture facts: a bump re-streams every source, and
+ // the tool row is otherwise unchanged. A row normalized earlier keeps
+ // no attachment until its source is next normalized or the projection
+ // is rebuilt (docs/ARCHITECTURE.md, "Mixed representations").
39
}
diff --git a/crates/adapters/claude-code/src/lib.rs b/crates/adapters/claude-code/src/lib.rs
index 5593210c..54b174b4 100644
--- a/crates/adapters/claude-code/src/lib.rs
+++ b/crates/adapters/claude-code/src/lib.rs
@@ -21,6 +21,7 @@ mod envelope;
mod html_artifacts;
mod html_recovery;
pub mod normalize;
+mod presented_media;
pub mod raw;
pub mod spec;
pub mod time;
diff --git a/crates/adapters/claude-code/src/normalize.rs b/crates/adapters/claude-code/src/normalize.rs
index e37a48c6..03d7d245 100644
--- a/crates/adapters/claude-code/src/normalize.rs
+++ b/crates/adapters/claude-code/src/normalize.rs
@@ -117,6 +117,14 @@ pub struct ClaudeCodeState {
/// callback update the exact act after an `async_launched` receipt.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
notification_calls: BTreeMap,
+ /// Result records carry no tool name. Remember `SendUserFile` opener ids
+ /// so a look-alike `attachments[]` payload from another tool can never
+ /// name a file to present. Oldest opener first, each id once, so the
+ /// bound drops by age. On the wire it is the array of ids it has been
+ /// since it was a set, so a checkpoint written then restores, with its
+ /// sorted order standing in for the ages it never recorded.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ send_file_calls: Vec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -197,6 +205,22 @@ impl ClaudeCodeState {
self.delegate_calls.remove(tool_use_id)
}
+ /// Remember an opener as the newest. Past 256 the oldest go: an opener
+ /// that old with no result was interrupted. An id seen again (a branch
+ /// replay repeats its opener) becomes the newest, once.
+ fn remember_send_file_call(&mut self, tool_use_id: &str) {
+ self.send_file_calls.retain(|id| id != tool_use_id);
+ self.send_file_calls.push(tool_use_id.to_owned());
+ let excess = self.send_file_calls.len().saturating_sub(256);
+ self.send_file_calls.drain(..excess);
+ }
+
+ fn take_send_file_call(&mut self, tool_use_id: &str) -> bool {
+ let remembered = self.send_file_calls.len();
+ self.send_file_calls.retain(|id| id != tool_use_id);
+ self.send_file_calls.len() != remembered
+ }
+
fn remember_file_change_call(&mut self, tool_use_id: &str) {
self.file_change_calls.insert(tool_use_id.to_owned());
}
@@ -1026,6 +1050,9 @@ fn assistant_line(emit: &mut Emit, line: &AssistantLine) {
}
state.html_publications.insert(id.clone(), publication);
}
+ if name == "SendUserFile" {
+ emit.remember_send_file_call(id);
+ }
// A native name is not enough to establish canonical
// delegation. In particular, an unpinned writer is projected
// as Unknown; remembering that opener as a delegate would make
@@ -1754,6 +1781,25 @@ impl<'a> Emit<'a> {
.is_some_and(|state| state.take_delegate_call(tool_use_id))
}
+ fn remember_send_file_call(&mut self, tool_use_id: &str) {
+ if let Some(state) = self
+ .ctx
+ .state
+ .as_mut()
+ .and_then(|state| state.downcast_mut::())
+ {
+ state.remember_send_file_call(tool_use_id);
+ }
+ }
+
+ fn take_send_file_call(&mut self, tool_use_id: &str) -> bool {
+ self.ctx
+ .state
+ .as_mut()
+ .and_then(|state| state.downcast_mut::())
+ .is_some_and(|state| state.take_send_file_call(tool_use_id))
+ }
+
fn remember_file_change_call(&mut self, tool_use_id: &str) {
if let Some(state) = self
.ctx
@@ -2118,6 +2164,17 @@ impl<'a> Emit<'a> {
result.is_error
};
let failed = effective_error == Some(true);
+ // Sent files: only a remembered `SendUserFile` opener, a result that
+ // did not fail, and a line whose `toolUseResult` is this result's.
+ if self.take_send_file_call(result.tool_use_id) && !failed && single_result {
+ crate::presented_media::push_sent_files(
+ self.ctx,
+ result.tool_use_id,
+ result.tool_use_result,
+ at,
+ &mut self.ops,
+ );
+ }
let publication = self
.ctx
.state
diff --git a/crates/adapters/claude-code/src/presented_media.rs b/crates/adapters/claude-code/src/presented_media.rs
new file mode 100644
index 00000000..4d757b1a
--- /dev/null
+++ b/crates/adapters/claude-code/src/presented_media.rs
@@ -0,0 +1,168 @@
+//! Files a `SendUserFile` result lists as sent to the user.
+//!
+//! The canonical declares `toolUseResult.attachments[]` on that tool's result
+//! with `path` and `size` required and `media_type` optional (census
+//! evidence only; the writer is closed). Those three fields are everything
+//! read here: `isImage`, `pathValidated`, `file_uuid`, `caption` and
+//! `display` stay ledger-only. Nothing is opened. The transcript names a
+//! path, a size and a type, and the pipeline reads the bytes later.
+//!
+//! A narrowing failure never fails the record (`docs/groundtruth/claude-code.md`
+//! §3): an attachment of any other shape is an empty slot, which keeps its
+//! native position and projects nothing.
+
+use rosetta_adapter_support::NormalizeCtx;
+use rosetta_adapter_support::presented_media::{PresentedFile, push};
+use rosetta_core::IngestOp;
+use rosetta_core::presented_media::FILES_PER_RECORD;
+use rosetta_core::rows::Ms;
+use serde_json::Value;
+
+/// A slot the shared producer always skips: no path, no size, no type.
+const EMPTY: PresentedFile<'static> = PresentedFile {
+ path: "",
+ cwd: None,
+ len: 0,
+ media_type: "",
+};
+
+/// The files of one result, one slot per native attachment in native order,
+/// so a file's index here is its index in the transcript. Only the leading
+/// slots the shared producer examines are read.
+///
+/// The declared type is passed through as written: which types are presented
+/// is the shared producer's decision, not this reader's. `cwd` is never set:
+/// the canonical does not say what a relative `path` is relative to, so
+/// resolving one against the record's working directory would name a file
+/// the transcript did not, and the shared producer skips it instead.
+pub(crate) fn sent_files(tool_use_result: Option<&Value>) -> Vec> {
+ tool_use_result
+ .and_then(|result| result.get("attachments"))
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .take(FILES_PER_RECORD)
+ .map(|attachment| sent_file(attachment).unwrap_or(EMPTY))
+ .collect()
+}
+
+fn sent_file(attachment: &Value) -> Option> {
+ Some(PresentedFile {
+ path: attachment.get("path")?.as_str()?,
+ cwd: None,
+ // A positive integer that fits u64: `as_u64` is `None` for a
+ // negative, fractional or non-numeric size.
+ len: attachment.get("size")?.as_u64().filter(|size| *size > 0)?,
+ media_type: attachment.get("media_type")?.as_str()?,
+ })
+}
+
+/// Attach the presentable files of a proven, successful `SendUserFile`
+/// result to its tool row as path references, then state one capture fact
+/// per attached file. The caller proves the opener and the result.
+pub(crate) fn push_sent_files(
+ ctx: &NormalizeCtx,
+ tool_use_id: &str,
+ tool_use_result: Option<&Value>,
+ at: Option,
+ ops: &mut Vec,
+) {
+ let files = sent_files(tool_use_result);
+ if files.is_empty() {
+ return;
+ }
+ let facts = push(ctx, tool_use_id, tool_use_id, &files, at, ops);
+ ops.extend(facts.into_iter().map(IngestOp::PresentedMedia));
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+
+ fn read(result: &Value) -> Vec<(&str, u64, &str)> {
+ sent_files(Some(result))
+ .into_iter()
+ .map(|file| {
+ assert_eq!(file.cwd, None);
+ (file.path, file.len, file.media_type)
+ })
+ .collect()
+ }
+
+ #[test]
+ fn each_attachment_keeps_its_native_slot() {
+ let result = json!({"caption": "", "attachments": [
+ {"path": "/w/a.png", "size": 10, "media_type": "image/png", "isImage": true},
+ {"path": "/w/b.md", "size": 20, "media_type": "text/markdown", "isImage": false},
+ {"path": "/w/c.bin", "size": 30, "isImage": false},
+ {"path": "/w/d.mp4", "size": 40, "media_type": " Video/MP4 ", "isImage": false,
+ "pathValidated": true, "file_uuid": "uuid"},
+ ]});
+ assert_eq!(
+ read(&result),
+ [
+ ("/w/a.png", 10, "image/png"),
+ // The declared type is passed through, whatever it says.
+ ("/w/b.md", 20, "text/markdown"),
+ ("", 0, ""),
+ ("/w/d.mp4", 40, " Video/MP4 "),
+ ]
+ );
+ }
+
+ #[test]
+ fn any_other_shape_is_an_empty_slot_or_nothing() {
+ for attachment in [
+ json!({"path": 7, "size": 10, "media_type": "image/png"}),
+ json!({"path": null, "size": 10, "media_type": "image/png"}),
+ json!({"size": 10, "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": 0, "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": -4, "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": 10.5, "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": 10.0, "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": "10", "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": 1e30, "media_type": "image/png"}),
+ json!({"path": "/w/a.png", "size": 10, "media_type": 3}),
+ json!({"path": "/w/a.png", "size": 10, "media_type": null}),
+ json!("/w/a.png"),
+ json!(["/w/a.png"]),
+ json!(null),
+ ] {
+ let result = json!({"attachments": [attachment]});
+ assert_eq!(read(&result), [("", 0, "")], "{result}");
+ }
+ assert_eq!(
+ read(
+ &json!({"attachments": [{"path": "/w/a.png", "size": u64::MAX,
+ "media_type": "image/png"}]})
+ ),
+ [("/w/a.png", u64::MAX, "image/png")]
+ );
+ for result in [
+ json!("sent 1 file"),
+ json!(null),
+ json!([]),
+ json!({}),
+ json!({"attachments": "none"}),
+ json!({"attachments": {"path": "/w/a.png", "size": 1, "media_type": "image/png"}}),
+ json!({"attachments": []}),
+ ] {
+ assert!(sent_files(Some(&result)).is_empty(), "{result}");
+ }
+ assert!(sent_files(None).is_empty());
+ }
+
+ #[test]
+ fn only_the_examined_slots_are_read() {
+ let attachments: Vec = (0..FILES_PER_RECORD + 9)
+ .map(|index| json!({"path": format!("/w/{index}.png"), "size": 1, "media_type": "image/png"}))
+ .collect();
+ let result = json!({ "attachments": attachments });
+ let files = sent_files(Some(&result));
+ assert_eq!(files.len(), FILES_PER_RECORD);
+ assert_eq!(files.last().unwrap().path, "/w/31.png");
+ }
+}
diff --git a/crates/adapters/claude-code/tests/presented_media.rs b/crates/adapters/claude-code/tests/presented_media.rs
new file mode 100644
index 00000000..c1f10dda
--- /dev/null
+++ b/crates/adapters/claude-code/tests/presented_media.rs
@@ -0,0 +1,881 @@
+//! Sent files: a proven `SendUserFile` result attaches each sent image or
+//! video to its tool row as a path reference and states one capture fact per
+//! file. Synthetic transcripts only; the adapter opens nothing.
+
+use std::path::Path;
+
+use rosetta_adapter_claude_code::ClaudeCodeAdapter;
+use rosetta_adapter_support::{AgentAdapter, LineStatus, NormalizeCtx};
+use rosetta_core::presented_media::{
+ FILES_PER_RECORD, PresentedMedia, REFERENCE_MUTATION, upgrade_mutation,
+};
+use rosetta_core::timeline::{AttachmentKind, AttachmentRef, AttachmentSource, ResourceRef};
+use rosetta_core::{IngestOp, html_artifacts::HtmlArtifactPublication};
+use serde_json::{Value, json};
+
+const CALL: &str = "send-call";
+const OPENER_OFFSET: i64 = 10;
+const RESULT_OFFSET: i64 = 4096;
+const RESULT_AT: i64 = 1_790_856_001_000;
+
+fn opener_named(id: &str, name: &str, files: &[&str]) -> Value {
+ json!({"type":"assistant","parentUuid":null,"isSidechain":false,"uuid":format!("opener-{id}"),
+ "timestamp":"2026-10-01T12:00:00Z","sessionId":"native","version":"2.1.283","cwd":"/work",
+ "gitBranch":"main","userType":"external",
+ "message":{"id":format!("response-{id}"),"type":"message","role":"assistant",
+ "model":"claude-opus-5","stop_reason":"tool_use",
+ "content":[{"type":"tool_use","id":id,"name":name,
+ "input":{"files":files,"caption":"Here it is"}}]}})
+}
+
+fn opener(files: &[&str]) -> Value {
+ opener_named(CALL, "SendUserFile", files)
+}
+
+/// A user line holding one `tool_result` per id and one `toolUseResult`.
+fn result_line(ids: &[&str], failed: bool, tool_use_result: Value) -> Value {
+ let content: Vec = ids
+ .iter()
+ .map(|id| json!({"type":"tool_result","tool_use_id":id,"content":"sent","is_error":failed}))
+ .collect();
+ json!({"type":"user","parentUuid":"opener","isSidechain":false,"uuid":"result",
+ "timestamp":"2026-10-01T12:00:01Z","sessionId":"native","version":"2.1.283","cwd":"/work",
+ "gitBranch":"main","userType":"external",
+ "message":{"role":"user","content":content},
+ "toolUseResult":tool_use_result})
+}
+
+fn attachment(path: &str, size: u64, media_type: Option<&str>) -> Value {
+ let mut value = json!({"path":path,"size":size,"isImage":false,"pathValidated":true,
+ "file_uuid":"00000000-0000-4000-8000-000000000000"});
+ if let Some(media_type) = media_type {
+ value["media_type"] = json!(media_type);
+ value["isImage"] = json!(media_type.starts_with("image/"));
+ }
+ value
+}
+
+fn sent(attachments: Vec) -> Value {
+ json!({"attachments":attachments,"caption":"Here it is","display":"inline"})
+}
+
+fn new_ctx() -> NormalizeCtx {
+ let mut ctx = NormalizeCtx::new("runtime", "session");
+ ctx.state = ClaudeCodeAdapter.init_state();
+ ctx.source_identity = Some("source".into());
+ ctx.source_generation = 2;
+ ctx
+}
+
+fn ingest(ctx: &mut NormalizeCtx, offset: i64, record: &Value) -> Vec {
+ ctx.src_offset = Some(offset);
+ let output =
+ ClaudeCodeAdapter.ingest_line(ctx, "transcript", &serde_json::to_vec(record).unwrap());
+ assert!(
+ !matches!(output.status, LineStatus::HardError { .. }),
+ "{output:?}"
+ );
+ output.ops
+}
+
+fn save_and_restore(ctx: &mut NormalizeCtx) -> Value {
+ let saved = ClaudeCodeAdapter
+ .checkpoint_state(ctx.state.as_deref())
+ .unwrap()
+ .unwrap();
+ ctx.state = ClaudeCodeAdapter.restore_state(Some(&saved)).unwrap();
+ saved
+}
+
+/// The result line's ops after its opener, optionally across a checkpoint.
+fn run_with(opener: &Value, result: &Value, resume: bool) -> Vec {
+ let mut ctx = new_ctx();
+ let opened = ingest(&mut ctx, OPENER_OFFSET, opener);
+ assert!(sent_files(&opened).is_none(), "an opener presents nothing");
+ assert!(facts(&opened).is_empty(), "an opener states no fact");
+ if resume {
+ save_and_restore(&mut ctx);
+ }
+ ingest(&mut ctx, RESULT_OFFSET, result)
+}
+
+fn run(tool_use_result: Value) -> Vec {
+ run_with(
+ &opener(&["/work/out/unused.bin"]),
+ &result_line(&[CALL], false, tool_use_result),
+ false,
+ )
+}
+
+/// The one sent-file tool update, as its attachments, with its position.
+fn sent_files(ops: &[IngestOp]) -> Option<(usize, Vec)> {
+ let mut found = None;
+ for (position, op) in ops.iter().enumerate() {
+ let IngestOp::ToolUpdate {
+ session_id,
+ correlation_key,
+ evidence,
+ patch,
+ at,
+ } = op
+ else {
+ continue;
+ };
+ if evidence.mutation_index != REFERENCE_MUTATION {
+ continue;
+ }
+ assert!(found.is_none(), "one reference update per result");
+ assert_eq!(session_id, "session");
+ assert_eq!(correlation_key, CALL);
+ assert_eq!(*at, Some(RESULT_AT));
+ assert_eq!(evidence.record_offset, RESULT_OFFSET as u64);
+ assert_eq!(evidence.generation, 2);
+ assert_eq!(evidence.source_identity.0, "source");
+ assert!(
+ patch.request.is_none()
+ && patch.outcome.is_none()
+ && patch.status.is_none()
+ && patch.timing.is_none(),
+ "the reference update carries attachments only"
+ );
+ assert!(!patch.attachments.is_empty());
+ found = Some((position, patch.attachments.clone()));
+ }
+ found
+}
+
+fn facts(ops: &[IngestOp]) -> Vec {
+ ops.iter()
+ .filter_map(|op| match op {
+ IngestOp::PresentedMedia(fact) => Some(fact.clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+fn publications(ops: &[IngestOp]) -> Vec {
+ ops.iter()
+ .filter_map(|op| match op {
+ IngestOp::HtmlArtifact(row) => Some(row.clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+/// Every tool update that is not the sent-file reference: the result's own
+/// lifecycle patch, which sent files must leave as it was.
+fn other_updates(ops: &[IngestOp]) -> Vec<&IngestOp> {
+ ops.iter()
+ .filter(|op| {
+ matches!(op, IngestOp::ToolUpdate { evidence, .. }
+ if evidence.mutation_index != REFERENCE_MUTATION)
+ })
+ .collect()
+}
+
+fn assert_nothing_presented(ops: &[IngestOp], case: &str) {
+ assert!(sent_files(ops).is_none(), "{case}: no attachment");
+ assert!(facts(ops).is_empty(), "{case}: no fact");
+ assert!(
+ !ops.iter()
+ .any(|op| matches!(op, IngestOp::ProjectionAsset { .. })),
+ "{case}: the adapter never captures bytes"
+ );
+}
+
+fn slot(index: usize) -> String {
+ format!("{}:sent-{index}", blake3::hash(CALL.as_bytes()).to_hex())
+}
+
+fn reference(path: &str) -> AttachmentSource {
+ AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: path.to_owned(),
+ resolved: true,
+ },
+ }
+}
+
+/// What the transcript said about one presented file.
+#[derive(Clone, Copy)]
+struct Sent {
+ index: usize,
+ kind: AttachmentKind,
+ path: &'static str,
+ name: &'static str,
+ media_type: &'static str,
+ size: u64,
+}
+
+/// One attached slot and its fact, checked against what the transcript said.
+#[track_caller]
+fn assert_slot(attachment: &AttachmentRef, fact: &PresentedMedia, sent: Sent) {
+ let Sent {
+ index,
+ kind,
+ path,
+ name,
+ media_type,
+ size,
+ } = sent;
+ assert_eq!(attachment.id, slot(index));
+ assert_eq!(attachment.kind, kind);
+ assert_eq!(attachment.source, reference(path));
+ assert_eq!(attachment.name.as_deref(), Some(name));
+ assert_eq!(attachment.media_type.as_deref(), Some(media_type));
+ assert_eq!(attachment.byte_len, Some(size));
+ assert!(attachment.label.is_none() && attachment.dimensions.is_none());
+ assert!(attachment.message_ranges.is_empty());
+ attachment.validate().unwrap();
+
+ fact.validate().unwrap();
+ assert_eq!(&fact.attachment, attachment, "the slot exactly as attached");
+ assert_eq!(fact.session_id, "session");
+ assert_eq!(fact.correlation_key, CALL);
+ assert_eq!(fact.source_offset, RESULT_OFFSET as u64);
+ assert_eq!(fact.path, path);
+ assert_eq!(fact.expected_len, size);
+ assert_eq!(fact.media_type, media_type);
+ assert_eq!(fact.at, Some(RESULT_AT));
+ assert_eq!(fact.evidence.record_offset, RESULT_OFFSET as u64);
+ assert_eq!(fact.evidence.generation, 2);
+ assert_eq!(fact.evidence.source_identity.0, "source");
+ assert_eq!(
+ fact.evidence.mutation_index,
+ upgrade_mutation(u32::try_from(index).unwrap())
+ );
+ assert!(fact.evidence.mutation_index > REFERENCE_MUTATION);
+}
+
+#[test]
+fn one_sent_video_is_a_path_reference_and_one_fact() {
+ let ops = run(sent(vec![attachment(
+ "/work/out/screen recording.mp4",
+ 11_534_336,
+ Some("video/mp4"),
+ )]));
+ let (position, attachments) = sent_files(&ops).expect("one reference update");
+ let facts = facts(&ops);
+ assert_eq!((attachments.len(), facts.len()), (1, 1));
+ assert_slot(
+ &attachments[0],
+ &facts[0],
+ Sent {
+ index: 0,
+ kind: AttachmentKind::Video,
+ path: "/work/out/screen recording.mp4",
+ name: "screen recording.mp4",
+ media_type: "video/mp4",
+ size: 11_534_336,
+ },
+ );
+ // The fact follows the update it upgrades, directly.
+ assert!(matches!(&ops[position + 1], IngestOp::PresentedMedia(fact) if *fact == facts[0]));
+ // The result's own terminal patch is still there, and still below both.
+ let others = other_updates(&ops);
+ assert_eq!(others.len(), 1, "{ops:?}");
+ let IngestOp::ToolUpdate {
+ evidence, patch, ..
+ } = others[0]
+ else {
+ unreachable!()
+ };
+ assert_eq!(evidence.mutation_index, 0);
+ assert!(patch.status.is_some() && patch.attachments.is_empty());
+ assert!(
+ !ops.iter()
+ .any(|op| matches!(op, IngestOp::ProjectionAsset { .. })),
+ "the bytes are not in the transcript"
+ );
+}
+
+#[test]
+fn one_sent_image_is_a_path_reference_and_one_fact() {
+ let ops = run(sent(vec![attachment(
+ "/work/out/shot.PNG",
+ 48_213,
+ Some("image/png"),
+ )]));
+ let (_, attachments) = sent_files(&ops).expect("one reference update");
+ let facts = facts(&ops);
+ assert_eq!((attachments.len(), facts.len()), (1, 1));
+ assert_slot(
+ &attachments[0],
+ &facts[0],
+ Sent {
+ index: 0,
+ kind: AttachmentKind::Image,
+ path: "/work/out/shot.PNG",
+ name: "shot.PNG",
+ media_type: "image/png",
+ size: 48_213,
+ },
+ );
+}
+
+#[test]
+fn a_mixed_list_keeps_native_order_and_native_index_ids() {
+ let ops = run_with(
+ &opener(&["/work/out/report.html"]),
+ &result_line(
+ &[CALL],
+ false,
+ sent(vec![
+ attachment("/work/out/a.png", 100, Some("image/png")), // 0
+ attachment("/work/out/notes.md", 200, Some("text/markdown")), // 1
+ attachment("/work/out/report.html", 300, Some("text/html")), // 2
+ attachment("/work/out/untyped.bin", 400, None), // 3
+ attachment("/work/out/b.mov", 500, Some("video/quicktime")), // 4
+ attachment("/work/out/c.jpg", 600, Some("image/jpeg")), // 5
+ attachment("/work/out/d.mp4", 700, Some("video/mp4")), // 6
+ ]),
+ ),
+ false,
+ );
+ let (position, attachments) = sent_files(&ops).expect("one reference update");
+ let facts = facts(&ops);
+ let image = AttachmentKind::Image;
+ let video = AttachmentKind::Video;
+ let expected = [
+ (0, image, "/work/out/a.png", "a.png", "image/png", 100),
+ (4, video, "/work/out/b.mov", "b.mov", "video/quicktime", 500),
+ (5, image, "/work/out/c.jpg", "c.jpg", "image/jpeg", 600),
+ (6, video, "/work/out/d.mp4", "d.mp4", "video/mp4", 700),
+ ]
+ .map(|(index, kind, path, name, media_type, size)| Sent {
+ index,
+ kind,
+ path,
+ name,
+ media_type,
+ size,
+ });
+ assert_eq!((attachments.len(), facts.len()), (4, 4));
+ for ((attachment, fact), sent) in attachments.iter().zip(&facts).zip(expected) {
+ assert_slot(attachment, fact, sent);
+ }
+ // One update, then its facts in native order, with nothing between.
+ for (step, fact) in facts.iter().enumerate() {
+ assert!(
+ matches!(&ops[position + 1 + step], IngestOp::PresentedMedia(found) if found == fact)
+ );
+ }
+ // The HTML file went to the publication seam and nowhere else.
+ let published = publications(&ops);
+ assert_eq!(published.len(), 1);
+ assert_eq!(published[0].path.as_deref(), Some("/work/out/report.html"));
+}
+
+#[test]
+fn html_markdown_and_an_untyped_attachment_present_nothing() {
+ let ops = run_with(
+ &opener(&[
+ "/work/out/report.html",
+ "/work/out/notes.md",
+ "/work/out/blob",
+ ]),
+ &result_line(
+ &[CALL],
+ false,
+ sent(vec![
+ attachment("/work/out/report.html", 300, Some("text/html")),
+ attachment("/work/out/notes.md", 200, Some("text/markdown")),
+ attachment("/work/out/blob", 400, None),
+ // An image type cannot make an HTML path presentable.
+ attachment("/work/out/page.htm", 500, Some("image/png")),
+ ]),
+ ),
+ false,
+ );
+ assert_nothing_presented(&ops, "html, markdown, untyped");
+ let published = publications(&ops);
+ assert_eq!(
+ published
+ .iter()
+ .map(|row| row.path.as_deref())
+ .collect::>(),
+ [Some("/work/out/report.html")],
+ "the HTML file still produces its publication"
+ );
+ assert_eq!(other_updates(&ops).len(), 1, "the terminal patch survives");
+}
+
+#[test]
+fn an_unproven_result_presents_nothing() {
+ let video = || {
+ sent(vec![attachment(
+ "/work/out/clip.mp4",
+ 900,
+ Some("video/mp4"),
+ )])
+ };
+ let send = opener(&["/work/out/clip.mp4"]);
+
+ // The tool result is a string: there is no attachment list to read.
+ let ops = run_with(
+ &send,
+ &result_line(&[CALL], false, json!("Sent 1 file to the user")),
+ false,
+ );
+ assert_nothing_presented(&ops, "string toolUseResult");
+ assert_eq!(other_updates(&ops).len(), 1);
+
+ // No `toolUseResult` at all.
+ let mut bare = result_line(&[CALL], false, json!(null));
+ bare.as_object_mut().unwrap().remove("toolUseResult");
+ assert_nothing_presented(&run_with(&send, &bare, false), "no toolUseResult");
+
+ // A failed result sent nothing, whatever its payload lists.
+ let ops = run_with(&send, &result_line(&[CALL], true, video()), false);
+ assert_nothing_presented(&ops, "is_error");
+ let mut refused = video();
+ refused["success"] = json!(false);
+ let ops = run_with(&send, &result_line(&[CALL], false, refused), false);
+ assert_nothing_presented(&ops, "native failure");
+
+ // Two results on one line: the line's one `toolUseResult` is not
+ // provably this call's.
+ let mut ctx = new_ctx();
+ ingest(&mut ctx, OPENER_OFFSET, &send);
+ ingest(
+ &mut ctx,
+ OPENER_OFFSET + 1,
+ &opener_named("other-call", "SendUserFile", &["/work/out/other.mp4"]),
+ );
+ let ops = ingest(
+ &mut ctx,
+ RESULT_OFFSET,
+ &result_line(&[CALL, "other-call"], false, video()),
+ );
+ assert_nothing_presented(&ops, "two results in one line");
+ assert_eq!(other_updates(&ops).len(), 2);
+ // The ambiguous line consumed both openers: a later look-alike for
+ // either id proves nothing.
+ let ops = ingest(
+ &mut ctx,
+ RESULT_OFFSET + 100,
+ &result_line(&[CALL], false, video()),
+ );
+ assert_nothing_presented(&ops, "an opener is consumed once");
+
+ // A look-alike payload on another tool's result, and on no opener.
+ for name in ["Artifact", "Read", "Bash", "mcp__files__SendUserFile"] {
+ let other = opener_named(CALL, name, &["/work/out/clip.mp4"]);
+ let ops = run_with(&other, &result_line(&[CALL], false, video()), false);
+ assert_nothing_presented(&ops, name);
+ }
+ let mut ctx = new_ctx();
+ let ops = ingest(
+ &mut ctx,
+ RESULT_OFFSET,
+ &result_line(&[CALL], false, video()),
+ );
+ assert_nothing_presented(&ops, "no opener");
+ // A different call's opener does not prove this result.
+ let mut ctx = new_ctx();
+ ingest(
+ &mut ctx,
+ OPENER_OFFSET,
+ &opener_named("other-call", "SendUserFile", &["/work/out/clip.mp4"]),
+ );
+ let ops = ingest(
+ &mut ctx,
+ RESULT_OFFSET,
+ &result_line(&[CALL], false, video()),
+ );
+ assert_nothing_presented(&ops, "another call's opener");
+}
+
+#[test]
+fn a_malformed_attachment_is_skipped_without_a_hard_error() {
+ let good = || attachment("/work/out/good.png", 77, Some("image/png"));
+ let broken: Vec<(&str, Value)> = vec![
+ (
+ "fractional size",
+ json!({"path":"/work/out/a.png","size":10.5,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "float-typed size",
+ json!({"path":"/work/out/a.png","size":10.0,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "string size",
+ json!({"path":"/work/out/a.png","size":"10","media_type":"image/png","isImage":true}),
+ ),
+ (
+ "absent size",
+ json!({"path":"/work/out/a.png","media_type":"image/png","isImage":true}),
+ ),
+ (
+ "zero size",
+ json!({"path":"/work/out/a.png","size":0,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "negative size",
+ json!({"path":"/work/out/a.png","size":-10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "size past u64",
+ json!({"path":"/work/out/a.png","size":1e40,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "numeric path",
+ json!({"path":42,"size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "null path",
+ json!({"path":null,"size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "array path",
+ json!({"path":["/work/out/a.png"],"size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "absent path",
+ json!({"size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "relative path",
+ json!({"path":"out/a.png","size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "dot-relative path",
+ json!({"path":"./a.png","size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "empty path",
+ json!({"path":"","size":10,"media_type":"image/png","isImage":true}),
+ ),
+ (
+ "numeric media type",
+ json!({"path":"/work/out/a.png","size":10,"media_type":7,"isImage":true}),
+ ),
+ ("a string, not an object", json!("/work/out/a.png")),
+ ("null", json!(null)),
+ ];
+ for (case, bad) in &broken {
+ // Alone: nothing at all.
+ let ops = run(sent(vec![bad.clone()]));
+ assert_nothing_presented(&ops, case);
+ assert_eq!(other_updates(&ops).len(), 1, "{case}: terminal patch");
+
+ // Beside a good file: only the good one, at its own native index.
+ let ops = run(sent(vec![bad.clone(), good()]));
+ let (_, attachments) = sent_files(&ops).unwrap_or_else(|| panic!("{case}"));
+ let facts = facts(&ops);
+ assert_eq!((attachments.len(), facts.len()), (1, 1), "{case}");
+ assert_slot(
+ &attachments[0],
+ &facts[0],
+ Sent {
+ index: 1,
+ kind: AttachmentKind::Image,
+ path: "/work/out/good.png",
+ name: "good.png",
+ media_type: "image/png",
+ size: 77,
+ },
+ );
+ }
+ // The list itself in another shape.
+ for (case, payload) in [
+ ("attachments is a string", json!({"attachments":"none"})),
+ (
+ "attachments is an object",
+ json!({"attachments":{"path":"/work/out/a.png","size":10,"media_type":"image/png"}}),
+ ),
+ ("attachments is null", json!({"attachments":null})),
+ ("attachments is empty", json!({"attachments":[]})),
+ ("no attachments", json!({"caption":"nothing"})),
+ ("an array payload", json!([good()])),
+ ] {
+ assert_nothing_presented(&run(payload), case);
+ }
+}
+
+#[test]
+fn repeats_and_files_past_the_window_present_nothing() {
+ // The same file twice is one slot: the first native index.
+ let ops = run(sent(vec![
+ attachment("/work/out/a.png", 10, Some("image/png")),
+ attachment("/work/out/a.png", 10, Some("image/png")),
+ attachment("/work/out/b.png", 20, Some("image/png")),
+ ]));
+ let (_, attachments) = sent_files(&ops).unwrap();
+ assert_eq!(
+ attachments.iter().map(|a| a.id.clone()).collect::>(),
+ [slot(0), slot(2)]
+ );
+ assert_eq!(facts(&ops).len(), 2);
+
+ let many: Vec = (0..FILES_PER_RECORD + 8)
+ .map(|index| {
+ attachment(
+ &format!("/work/out/{index}.png"),
+ 1 + index as u64,
+ Some("image/png"),
+ )
+ })
+ .collect();
+ let ops = run(sent(many));
+ let (_, attachments) = sent_files(&ops).unwrap();
+ let facts = facts(&ops);
+ assert_eq!(attachments.len(), FILES_PER_RECORD);
+ assert_eq!(facts.len(), FILES_PER_RECORD);
+ assert_eq!(facts.last().unwrap().path, "/work/out/31.png");
+ for fact in &facts {
+ fact.validate().unwrap();
+ }
+}
+
+/// Capture opens the path the fact names. A recorded path that would have
+/// to be rewritten first (a backslash read as a separator, a `..` collapsed)
+/// names a file the transcript did not, so it presents nothing; and a device
+/// or process path is never opened.
+#[test]
+fn a_path_that_is_not_presentable_as_written_presents_nothing() {
+ let good = || attachment("/work/out/good.png", 77, Some("image/png"));
+ for path in [
+ // A backslash is a legal file-name byte, not a separator.
+ "/work/out/shot\\1.png",
+ "\\work\\out\\a.png",
+ "C:\\Users\\me\\a.png",
+ "\\\\server\\share\\a.png",
+ "/\\server/share/a.png",
+ // `link/..` is not `out` when `link` is a symlink.
+ "/work/out/link/../a.png",
+ "/work/out/./x/../a.png",
+ "/work/out/./a.png",
+ "/work/out//a.png",
+ "/work/out/a.png/",
+ "/../work/out/a.png",
+ "//server/share/a.png",
+ // Opening one of these acts on the reader itself.
+ "/dev/fd/3",
+ "/dev/null",
+ "/DEV/fd/3",
+ "/proc/self/fd/3",
+ ] {
+ let bad = attachment(path, 10, Some("image/png"));
+ let ops = run(sent(vec![bad.clone()]));
+ assert_nothing_presented(&ops, path);
+ assert_eq!(other_updates(&ops).len(), 1, "{path}: terminal patch");
+
+ // Beside a good file: only the good one, at its own native index.
+ let ops = run(sent(vec![bad, good()]));
+ let (_, attachments) = sent_files(&ops).unwrap_or_else(|| panic!("{path}"));
+ let facts = facts(&ops);
+ assert_eq!((attachments.len(), facts.len()), (1, 1), "{path}");
+ assert_slot(
+ &attachments[0],
+ &facts[0],
+ Sent {
+ index: 1,
+ kind: AttachmentKind::Image,
+ path: "/work/out/good.png",
+ name: "good.png",
+ media_type: "image/png",
+ size: 77,
+ },
+ );
+ }
+ // What is presented is the recorded path, byte for byte.
+ for path in [
+ "/work/out/..hidden/a b.png",
+ "/work/.cache/a.png",
+ "/devices/a.png",
+ "/work/dev/a.png",
+ ] {
+ let ops = run(sent(vec![attachment(path, 10, Some("image/png"))]));
+ let facts = facts(&ops);
+ assert_eq!(facts.len(), 1, "{path}");
+ assert_eq!(facts[0].path, path);
+ assert_eq!(sent_files(&ops).unwrap().1[0].source, reference(path));
+ }
+}
+
+#[test]
+fn a_split_run_equals_a_one_shot_run() {
+ let send = opener(&["/work/out/a.png", "/work/out/b.mp4"]);
+ let reply = result_line(
+ &[CALL],
+ false,
+ sent(vec![
+ attachment("/work/out/a.png", 100, Some("image/png")),
+ attachment("/work/out/notes.md", 200, Some("text/markdown")),
+ attachment("/work/out/b.mp4", 300, Some("video/mp4")),
+ ]),
+ );
+ let one_shot = run_with(&send, &reply, false);
+ let split = run_with(&send, &reply, true);
+ assert_eq!(one_shot, split);
+ assert_eq!(facts(&split).len(), 2);
+ assert_eq!(sent_files(&split).unwrap().1.len(), 2);
+
+ // The opener is what the checkpoint carries, and only until its result.
+ let mut ctx = new_ctx();
+ let empty = save_and_restore(&mut ctx);
+ assert!(empty.get("send_file_calls").is_none(), "{empty}");
+ ingest(&mut ctx, OPENER_OFFSET, &send);
+ let pending = save_and_restore(&mut ctx);
+ assert_eq!(pending["send_file_calls"], json!([CALL]));
+ let ops = ingest(&mut ctx, RESULT_OFFSET, &reply);
+ assert_eq!(ops, one_shot);
+ let settled = save_and_restore(&mut ctx);
+ assert!(settled.get("send_file_calls").is_none(), "{settled}");
+
+ // A checkpoint written before this state existed restores cleanly.
+ let mut legacy = pending.clone();
+ legacy.as_object_mut().unwrap().remove("send_file_calls");
+ let mut ctx = new_ctx();
+ ctx.state = ClaudeCodeAdapter.restore_state(Some(&legacy)).unwrap();
+ assert_nothing_presented(
+ &ingest(&mut ctx, RESULT_OFFSET, &reply),
+ "legacy checkpoint",
+ );
+}
+
+/// A `SendUserFile` opener under an id of its own.
+fn send(id: &str) -> Value {
+ opener_named(id, "SendUserFile", &["/work/out/clip.mp4"])
+}
+
+/// Whether a result for `id` presents its one file now.
+fn presents(ctx: &mut NormalizeCtx, id: &str) -> bool {
+ let video = sent(vec![attachment(
+ "/work/out/clip.mp4",
+ 900,
+ Some("video/mp4"),
+ )]);
+ let ops = ingest(ctx, RESULT_OFFSET, &result_line(&[id], false, video));
+ let facts = facts(&ops);
+ assert!(facts.len() <= 1, "{id}: {facts:?}");
+ facts.first().is_some_and(|fact| fact.correlation_key == id)
+}
+
+#[test]
+fn remembered_openers_are_bounded_and_the_oldest_go_first() {
+ // Ids in an order that is not their sort order, as real ids are: the
+ // 300 openers arrive as 0000, 0119, 0238, 0057, ...
+ let id = |index: usize| format!("toolu_{:04}", (index * 119) % 300);
+ let mut ctx = new_ctx();
+ for index in 0..300 {
+ ingest(&mut ctx, OPENER_OFFSET + index as i64, &send(&id(index)));
+ }
+ let saved = save_and_restore(&mut ctx);
+ // The newest 256, oldest first: the 44 dropped are the first 44 seen,
+ // whatever their ids sort as.
+ let newest: Vec = (44..300).map(|index| json!(id(index))).collect();
+ assert_eq!(saved["send_file_calls"], json!(newest));
+ assert!(
+ (44..300).map(id).min() < (0..44).map(id).max(),
+ "a kept id sorts below a dropped one"
+ );
+ for dropped in [0, 1, 43] {
+ assert!(!presents(&mut ctx, &id(dropped)), "opener {dropped}");
+ }
+ for kept in [44, 45, 299] {
+ assert!(presents(&mut ctx, &id(kept)), "opener {kept}");
+ assert!(!presents(&mut ctx, &id(kept)), "opener {kept}, again");
+ }
+ let saved = save_and_restore(&mut ctx);
+ assert_eq!(saved["send_file_calls"].as_array().unwrap().len(), 253);
+
+ // An opener seen again is the newest, and is still remembered once.
+ let mut ctx = new_ctx();
+ for index in 0..256 {
+ ingest(&mut ctx, OPENER_OFFSET + index as i64, &send(&id(index)));
+ }
+ ingest(&mut ctx, OPENER_OFFSET + 256, &send(&id(0)));
+ ingest(&mut ctx, OPENER_OFFSET + 257, &send(&id(256)));
+ let saved = save_and_restore(&mut ctx);
+ let remembered = saved["send_file_calls"].as_array().unwrap();
+ assert_eq!(remembered.len(), 256);
+ assert_eq!(remembered[0], json!(id(2)));
+ assert_eq!(remembered[254..], [json!(id(0)), json!(id(256))]);
+ assert!(!presents(&mut ctx, &id(1)), "the oldest went");
+ assert!(presents(&mut ctx, &id(0)), "seen again, so kept");
+ assert!(!presents(&mut ctx, &id(0)), "and kept once");
+}
+
+/// The remembered ids were a set before they were ordered by age. A
+/// checkpoint written then is a sorted array of ids, and still restores.
+#[test]
+fn a_checkpoint_from_the_set_form_restores() {
+ let mut ctx = new_ctx();
+ for id in ["toolu_b", "toolu_c", "toolu_a"] {
+ ingest(&mut ctx, OPENER_OFFSET, &send(id));
+ }
+ let mut saved = save_and_restore(&mut ctx);
+ assert_eq!(
+ saved["send_file_calls"],
+ json!(["toolu_b", "toolu_c", "toolu_a"]),
+ "oldest first"
+ );
+ saved["send_file_calls"] = json!(["toolu_a", "toolu_b", "toolu_c"]);
+ ctx.state = ClaudeCodeAdapter.restore_state(Some(&saved)).unwrap();
+ for id in ["toolu_c", "toolu_a", "toolu_b"] {
+ assert!(presents(&mut ctx, id), "{id}");
+ assert!(!presents(&mut ctx, id), "{id}, again");
+ }
+ let settled = save_and_restore(&mut ctx);
+ assert!(settled.get("send_file_calls").is_none(), "{settled}");
+
+ // Such a checkpoint at the bound: its sorted order stands in for age, so
+ // the next opener drops its first id and no other.
+ let full: Vec = (0..256).map(|index| format!("toolu_{index:04}")).collect();
+ saved["send_file_calls"] = json!(full);
+ ctx.state = ClaudeCodeAdapter.restore_state(Some(&saved)).unwrap();
+ ingest(&mut ctx, OPENER_OFFSET, &send("toolu_0000-later"));
+ let saved = save_and_restore(&mut ctx);
+ let remembered = saved["send_file_calls"].as_array().unwrap();
+ assert_eq!(remembered.len(), 256);
+ assert_eq!(remembered[0], json!("toolu_0001"));
+ assert_eq!(remembered[255], json!("toolu_0000-later"));
+ assert!(!presents(&mut ctx, "toolu_0000"));
+ assert!(presents(&mut ctx, "toolu_0255"));
+ assert!(presents(&mut ctx, "toolu_0000-later"));
+}
+
+/// What the adapter reads is what the canonical declares, and nothing else is
+/// read: `path` and `size` required, `media_type` optional.
+#[test]
+fn the_canonical_declares_the_three_fields_read() {
+ let path = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../../groundtruth/claude-code/groundtruth.json");
+ let canonical: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
+ let stream = canonical["streams"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|stream| stream["id"] == "transcript")
+ .expect("the transcript stream");
+ let facet = stream["facets"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|facet| facet["id"] == "tool-result")
+ .expect("the tool-result facet");
+ let record = &facet["record_types"]["SendUserFile"];
+ assert_eq!(record["evidence"], "observed");
+ // The result is an object or a bare string; the object carries the list.
+ let variants = record["shape"]["variants"].as_array().unwrap();
+ assert!(variants.iter().any(|variant| variant["kind"] == "string"));
+ let object = variants
+ .iter()
+ .find(|variant| variant["kind"] == "object")
+ .expect("the object variant");
+ let attachments = &object["fields"]["attachments"];
+ assert_eq!(attachments["shape"]["kind"], "array");
+ let item = &attachments["shape"]["items"];
+ assert_eq!(item["kind"], "object");
+ for (field, kind, required) in [
+ ("path", "string", true),
+ ("size", "number", true),
+ ("media_type", "string", false),
+ ] {
+ let declared = &item["fields"][field];
+ assert_eq!(declared["shape"]["kind"], kind, "attachments[].{field}");
+ assert_eq!(declared["required"], required, "attachments[].{field}");
+ }
+}
diff --git a/crates/capture/src/ledger.rs b/crates/capture/src/ledger.rs
index 786aba7c..a322ac37 100644
--- a/crates/capture/src/ledger.rs
+++ b/crates/capture/src/ledger.rs
@@ -37,7 +37,7 @@ mod read;
mod verify;
mod worker;
-pub use document::{CapturedDocument, DocumentCaptureStatus};
+pub use document::{CapturedDocument, DocumentCaptureStatus, MediaClaim};
pub use frames::PrebuiltFrames;
use frames::{frame_checksum, frame_head, seal_body};
#[cfg(test)]
@@ -224,6 +224,12 @@ impl Ledger {
})
}
+ /// The directory this ledger's segments are written to: where a reader
+ /// of the same runtime finds what this writer has appended.
+ pub fn dir(&self) -> &Path {
+ &self.dir
+ }
+
/// Snapshot segments that this writer can never append to again. The
/// projection lock must outlive maintenance of these paths. Reopened
/// writers always start above the existing maximum; the active segment
diff --git a/crates/capture/src/ledger/document.rs b/crates/capture/src/ledger/document.rs
index 6bc798c5..2b476622 100644
--- a/crates/capture/src/ledger/document.rs
+++ b/crates/capture/src/ledger/document.rs
@@ -17,18 +17,81 @@ pub enum DocumentCaptureStatus {
ChangedDuringRead,
}
+/// What a media observation was asked to capture: the type and size the
+/// transcript recorded for the file. Its presence marks the frame as media;
+/// a frame without one is an HTML document.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MediaClaim {
+ pub media_type: String,
+ pub expected_len: u64,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedDocument {
pub path: String,
pub captured_at: i64,
pub status: DocumentCaptureStatus,
pub recovered_from: Option,
+ pub media: Option,
pub bytes: Vec,
}
impl CapturedDocument {
+ /// The one frame contract. A checksum-valid frame that `decode` rejects
+ /// ends the walk of its whole segment, so `encode` refuses exactly the
+ /// frames `decode` rejects and the writer can never emit one.
+ pub fn validate(&self) -> Result<(), &'static str> {
+ if self.path.is_empty() || self.path.len() > 16384 || self.path.contains('\0') {
+ return Err("captured document path is empty, over-long or holds a NUL");
+ }
+ if self
+ .recovered_from
+ .as_ref()
+ .is_some_and(|from| from.is_empty() || from.len() > 32768 || from.contains('\0'))
+ {
+ return Err("captured document recovery source is empty, over-long or holds a NUL");
+ }
+ if self.status != DocumentCaptureStatus::Captured {
+ if !self.bytes.is_empty() {
+ return Err("an uncaptured document carries bytes");
+ }
+ if self.recovered_from.is_some() {
+ return Err("an uncaptured document names a recovery source");
+ }
+ }
+ if let Some(media) = &self.media {
+ if media.expected_len == 0 {
+ return Err("a media claim records no size");
+ }
+ if media.media_type.is_empty()
+ || media.media_type.len() > 255
+ || media.media_type.contains('\0')
+ {
+ return Err("a media claim's type is empty, over-long or holds a NUL");
+ }
+ if self.status == DocumentCaptureStatus::Captured
+ && self.bytes.len() as u64 != media.expected_len
+ {
+ return Err("captured media is not the size its claim recorded");
+ }
+ }
+ Ok(())
+ }
+
pub(super) fn encode(&self) -> Result, CaptureError> {
- let metadata = if self.recovered_from.is_some() {
+ self.validate()
+ .map_err(|reason| CaptureError::InvalidConfig(reason.to_string()))?;
+ // A frame with no media claim is written exactly as before the claim
+ // existed, so HTML frames stay byte-identical.
+ let metadata = if let Some(media) = &self.media {
+ serde_json::to_vec(&(
+ &self.path,
+ self.captured_at,
+ self.status,
+ &self.recovered_from,
+ media,
+ ))
+ } else if self.recovered_from.is_some() {
serde_json::to_vec(&(
&self.path,
self.captured_at,
@@ -59,33 +122,34 @@ impl CapturedDocument {
#[derive(Deserialize)]
#[serde(untagged)]
enum Metadata {
+ Media(
+ String,
+ i64,
+ DocumentCaptureStatus,
+ Option,
+ MediaClaim,
+ ),
Current(String, i64, DocumentCaptureStatus, Option),
Legacy(String, i64, DocumentCaptureStatus),
}
- let (path, captured_at, status, recovered_from) =
+ let (path, captured_at, status, recovered_from, media) =
match serde_json::from_slice(bytes.get(4..end)?).ok()? {
- Metadata::Current(path, at, status, from) => (path, at, status, from),
- Metadata::Legacy(path, at, status) => (path, at, status, None),
+ Metadata::Media(path, at, status, from, media) => {
+ (path, at, status, from, Some(media))
+ }
+ Metadata::Current(path, at, status, from) => (path, at, status, from, None),
+ Metadata::Legacy(path, at, status) => (path, at, status, None, None),
};
- let bytes = bytes.get(end..)?.to_vec();
- if path.is_empty()
- || path.len() > 16384
- || path.contains('\0')
- || recovered_from
- .as_ref()
- .is_some_and(|p| p.is_empty() || p.len() > 32768 || p.contains('\0'))
- || (status != DocumentCaptureStatus::Captured && !bytes.is_empty())
- || (status != DocumentCaptureStatus::Captured && recovered_from.is_some())
- {
- return None;
- }
- Some(Self {
+ let document = Self {
path,
captured_at,
status,
recovered_from,
- bytes,
- })
+ media,
+ bytes: bytes.get(end..)?.to_vec(),
+ };
+ document.validate().ok()?;
+ Some(document)
}
}
@@ -111,3 +175,379 @@ impl Ledger {
self.write_frames(key, frames)
}
}
+
+#[cfg(test)]
+mod tests {
+ use tempfile::TempDir;
+
+ use super::*;
+ use crate::ledger::{LedgerEntry, scan};
+
+ fn source() -> SourceDef {
+ SourceDef {
+ ledger_only: false,
+ relative_path: "media.jsonl".to_string(),
+ stream_id: "transcript".to_string(),
+ session_id: "s-canonical".to_string(),
+ native_session_id: "s-native".to_string(),
+ agent_version: Some("1.2.3".to_string()),
+ }
+ }
+
+ /// A document frame body written with no contract check: what a writer
+ /// without `validate` would have produced.
+ fn unchecked_frame(document: &CapturedDocument) -> Vec {
+ let metadata = match (&document.media, &document.recovered_from) {
+ (Some(media), from) => serde_json::to_vec(&(
+ &document.path,
+ document.captured_at,
+ document.status,
+ from,
+ media,
+ )),
+ (None, Some(from)) => serde_json::to_vec(&(
+ &document.path,
+ document.captured_at,
+ document.status,
+ Some(from),
+ )),
+ (None, None) => {
+ serde_json::to_vec(&(&document.path, document.captured_at, document.status))
+ }
+ }
+ .unwrap();
+ let mut frame = (metadata.len() as u32).to_be_bytes().to_vec();
+ frame.extend_from_slice(&metadata);
+ frame.extend_from_slice(&document.bytes);
+ frame
+ }
+
+ fn media_document(status: DocumentCaptureStatus, bytes: &[u8]) -> CapturedDocument {
+ CapturedDocument {
+ path: "/work/out/clip.mp4".into(),
+ captured_at: 1_790_000_000_123,
+ status,
+ recovered_from: None,
+ media: Some(MediaClaim {
+ media_type: "video/mp4".into(),
+ expected_len: 11,
+ }),
+ bytes: bytes.to_vec(),
+ }
+ }
+
+ const STATUSES: [DocumentCaptureStatus; 5] = [
+ DocumentCaptureStatus::Captured,
+ DocumentCaptureStatus::Missing,
+ DocumentCaptureStatus::TooLarge,
+ DocumentCaptureStatus::Unsupported,
+ DocumentCaptureStatus::ChangedDuringRead,
+ ];
+
+ #[test]
+ fn media_frames_round_trip_for_every_status() {
+ let dir = TempDir::new().unwrap();
+ let mut ledger = Ledger::open(dir.path()).unwrap();
+ let source = source();
+ let mut written = Vec::new();
+ for (index, status) in STATUSES.into_iter().enumerate() {
+ let bytes: &[u8] = if status == DocumentCaptureStatus::Captured {
+ b"eleven byte"
+ } else {
+ b""
+ };
+ let document = media_document(status, bytes);
+ assert_eq!(
+ CapturedDocument::decode(&document.encode().unwrap()),
+ Some(document.clone()),
+ "{status:?}"
+ );
+ ledger
+ .append_document(&source, 1, index as u64, document.clone())
+ .unwrap();
+ written.push((index as u64, document));
+ }
+ // A carried capture keeps its claim and names where it came from.
+ let mut carried = media_document(DocumentCaptureStatus::Captured, b"eleven byte");
+ carried.recovered_from = Some("ledger:1:0".into());
+ assert_eq!(
+ CapturedDocument::decode(&carried.encode().unwrap()),
+ Some(carried.clone())
+ );
+ ledger
+ .append_document(&source, 2, 0, carried.clone())
+ .unwrap();
+ ledger.flush().unwrap();
+ assert_eq!(ledger.dir(), dir.path());
+
+ let scanned = scan(dir.path()).unwrap();
+ assert_eq!(scanned.torn_segments, 0);
+ let documents: Vec<_> = scanned
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ LedgerEntry::Document {
+ generation,
+ start_offset,
+ document,
+ ..
+ } => Some((*generation, *start_offset, document.clone())),
+ _ => None,
+ })
+ .collect();
+ let mut expected: Vec<_> = written
+ .into_iter()
+ .map(|(offset, document)| (1, offset, document))
+ .collect();
+ expected.push((2, 0, carried));
+ assert_eq!(documents, expected);
+ }
+
+ #[test]
+ fn frames_without_a_media_claim_keep_their_exact_bytes() {
+ // The three-field form, as written before recovery provenance existed.
+ let plain = CapturedDocument {
+ path: "/tmp/review.html".into(),
+ captured_at: 99,
+ status: DocumentCaptureStatus::Captured,
+ recovered_from: None,
+ media: None,
+ bytes: b"Review ".to_vec(),
+ };
+ let mut expected = vec![0, 0, 0, 34];
+ expected.extend_from_slice(br#"["/tmp/review.html",99,"captured"]"#);
+ expected.extend_from_slice(b"Review ");
+ assert_eq!(plain.encode().unwrap(), expected);
+ assert_eq!(CapturedDocument::decode(&expected), Some(plain.clone()));
+
+ // The four-field form.
+ let recovered = CapturedDocument {
+ recovered_from: Some("/archive/review.html".into()),
+ ..plain.clone()
+ };
+ let mut expected = vec![0, 0, 0, 57];
+ expected.extend_from_slice(br#"["/tmp/review.html",99,"captured","/archive/review.html"]"#);
+ expected.extend_from_slice(b"Review ");
+ assert_eq!(recovered.encode().unwrap(), expected);
+ assert_eq!(CapturedDocument::decode(&expected), Some(recovered));
+
+ // An uncaptured observation has no body at all.
+ let missing = CapturedDocument {
+ status: DocumentCaptureStatus::Missing,
+ bytes: Vec::new(),
+ ..plain
+ };
+ let mut expected = vec![0, 0, 0, 33];
+ expected.extend_from_slice(br#"["/tmp/review.html",99,"missing"]"#);
+ assert_eq!(missing.encode().unwrap(), expected);
+
+ // The media form is the only one with a fifth element.
+ let media = media_document(DocumentCaptureStatus::Missing, b"");
+ let mut expected = vec![0, 0, 0, 96];
+ expected.extend_from_slice(
+ br#"["/work/out/clip.mp4",1790000000123,"missing",null,{"media_type":"video/mp4","expected_len":11}]"#,
+ );
+ assert_eq!(media.encode().unwrap(), expected);
+ }
+
+ /// Every way a document can break the frame contract, by name.
+ fn invalid_documents() -> Vec<(&'static str, CapturedDocument)> {
+ type Break = fn(&mut CapturedDocument);
+ let html = CapturedDocument {
+ path: "/tmp/review.html".into(),
+ captured_at: 99,
+ status: DocumentCaptureStatus::Captured,
+ recovered_from: Some("/archive/review.html".into()),
+ media: None,
+ bytes: b"Review ".to_vec(),
+ };
+ let media = media_document(DocumentCaptureStatus::Captured, b"eleven byte");
+ let html_breaks: [(&str, Break); 9] = [
+ ("empty path", |d| d.path.clear()),
+ ("over-long path", |d| d.path = "p".repeat(16385)),
+ ("nul in path", |d| d.path = "/tmp/\0.html".into()),
+ ("empty recovery source", |d| {
+ d.recovered_from = Some(String::new())
+ }),
+ ("over-long recovery source", |d| {
+ d.recovered_from = Some("r".repeat(32769))
+ }),
+ ("nul in recovery source", |d| {
+ d.recovered_from = Some("/archive/\0".into())
+ }),
+ ("uncaptured with bytes", |d| {
+ d.recovered_from = None;
+ d.status = DocumentCaptureStatus::Unsupported;
+ }),
+ ("uncaptured with a recovery source", |d| {
+ d.bytes.clear();
+ d.status = DocumentCaptureStatus::Missing;
+ }),
+ ("too large with bytes", |d| {
+ d.recovered_from = None;
+ d.status = DocumentCaptureStatus::TooLarge;
+ }),
+ ];
+ let media_breaks: [(&str, Break); 9] = [
+ ("media with no recorded size", |d| {
+ d.media.as_mut().unwrap().expected_len = 0
+ }),
+ ("media with an empty type", |d| {
+ d.media.as_mut().unwrap().media_type.clear()
+ }),
+ ("media with an over-long type", |d| {
+ d.media.as_mut().unwrap().media_type = "t".repeat(256)
+ }),
+ ("media with a nul in its type", |d| {
+ d.media.as_mut().unwrap().media_type = "video/\0".into()
+ }),
+ ("captured media shorter than its claim", |d| {
+ d.bytes.truncate(10)
+ }),
+ ("captured media longer than its claim", |d| {
+ d.bytes.push(b'!')
+ }),
+ ("captured media with no bytes", |d| d.bytes.clear()),
+ ("uncaptured media with bytes", |d| {
+ d.status = DocumentCaptureStatus::ChangedDuringRead
+ }),
+ ("uncaptured media with a recovery source", |d| {
+ d.bytes.clear();
+ d.status = DocumentCaptureStatus::Missing;
+ d.recovered_from = Some("ledger:1:0".into());
+ }),
+ ];
+ let mut broken = Vec::new();
+ for (base, breaks) in [(&html, &html_breaks[..]), (&media, &media_breaks[..])] {
+ for (name, break_it) in breaks {
+ let mut document = base.clone();
+ break_it(&mut document);
+ broken.push((*name, document));
+ }
+ }
+ broken
+ }
+
+ #[test]
+ fn encode_refuses_exactly_the_frames_decode_rejects() {
+ for (name, document) in invalid_documents() {
+ assert!(document.validate().is_err(), "{name} must not validate");
+ assert!(
+ matches!(document.encode(), Err(CaptureError::InvalidConfig(_))),
+ "{name} must not encode"
+ );
+ assert_eq!(
+ CapturedDocument::decode(&unchecked_frame(&document)),
+ None,
+ "{name} must not decode"
+ );
+ }
+
+ // The writer refuses it too, and writes no frame the walk would stop at.
+ let dir = TempDir::new().unwrap();
+ let mut ledger = Ledger::open(dir.path()).unwrap();
+ let source = source();
+ ledger
+ .append_raw(&source, 1, [(0, b"before".as_slice())])
+ .unwrap();
+ let (_, refused) = invalid_documents().pop().unwrap();
+ assert!(matches!(
+ ledger.append_document(&source, 1, 0, refused),
+ Err(CaptureError::InvalidConfig(_))
+ ));
+ ledger
+ .append_raw(&source, 1, [(7, b"after".as_slice())])
+ .unwrap();
+ ledger.flush().unwrap();
+ let scanned = scan(dir.path()).unwrap();
+ assert_eq!(scanned.torn_segments, 0);
+ assert_eq!(scanned.entries.len(), 2);
+ }
+
+ #[test]
+ fn every_frame_that_encodes_decodes_to_the_same_document() {
+ let longest_path = format!("/{}", "p".repeat(16383));
+ let longest_from = "r".repeat(32768);
+ let longest_type = "t".repeat(255);
+ let mut frames = Vec::new();
+ for status in STATUSES {
+ let captured = status == DocumentCaptureStatus::Captured;
+ for media in [
+ None,
+ Some(MediaClaim {
+ media_type: "video/quicktime".into(),
+ expected_len: 3,
+ }),
+ Some(MediaClaim {
+ media_type: longest_type.clone(),
+ expected_len: 3,
+ }),
+ Some(MediaClaim {
+ media_type: "image/png".into(),
+ expected_len: u64::MAX,
+ }),
+ ] {
+ for recovered_from in [
+ None,
+ Some("ledger:4:128".to_string()),
+ Some(longest_from.clone()),
+ ] {
+ for path in ["/a", "/tmp/\u{1F3AC} clip \"quoted\".mov", &longest_path] {
+ for bytes in [&b""[..], b"abc", b"\xff\xfe\0\0"] {
+ frames.push(CapturedDocument {
+ path: path.to_string(),
+ captured_at: if captured { i64::MIN } else { i64::MAX },
+ status,
+ recovered_from: recovered_from.clone(),
+ media: media.clone(),
+ bytes: bytes.to_vec(),
+ });
+ }
+ }
+ }
+ }
+ }
+ let (mut encoded, mut refused) = (0, 0);
+ for document in frames {
+ match document.encode() {
+ Ok(frame) => {
+ encoded += 1;
+ assert_eq!(document.validate(), Ok(()));
+ assert_eq!(
+ CapturedDocument::decode(&frame),
+ Some(document.clone()),
+ "{document:?}"
+ );
+ }
+ Err(CaptureError::InvalidConfig(_)) => {
+ refused += 1;
+ assert!(document.validate().is_err());
+ assert_eq!(CapturedDocument::decode(&unchecked_frame(&document)), None);
+ }
+ Err(error) => panic!("unexpected {error}"),
+ }
+ }
+ // The table exercises both halves of the contract, not one.
+ assert!(encoded > 50 && refused > 50, "{encoded} / {refused}");
+ }
+
+ #[test]
+ fn a_captured_video_at_the_size_limit_fits_one_frame() {
+ let len = 64 * 1024 * 1024;
+ let document = CapturedDocument {
+ path: "/work/out/long.mov".into(),
+ captured_at: 1,
+ status: DocumentCaptureStatus::Captured,
+ recovered_from: Some("ledger:1:0".into()),
+ media: Some(MediaClaim {
+ media_type: "video/quicktime".into(),
+ expected_len: len as u64,
+ }),
+ bytes: vec![0x5a; len],
+ };
+ let frame = document.encode().unwrap();
+ assert!(frame.len() > len && frame.len() < MAX_FRAME_BYTES - 20);
+ let decoded = CapturedDocument::decode(&frame).unwrap();
+ assert!(decoded == document);
+ }
+}
diff --git a/crates/capture/src/ledger/read.rs b/crates/capture/src/ledger/read.rs
index 7f035af3..5c08343e 100644
--- a/crates/capture/src/ledger/read.rs
+++ b/crates/capture/src/ledger/read.rs
@@ -1,7 +1,7 @@
//! The raw ledger's read side: every consumer walks segments through
-//! [`visit_segments`], whose frame loop resolves each raw, batch and seal frame
-//! through the defines of its own segment. A torn or corrupt frame ends only
-//! that segment's walk.
+//! [`visit_segments`], whose frame loop resolves each raw, batch, document and
+//! seal frame through the defines of its own segment. A torn or corrupt frame
+//! ends only that segment's walk.
use std::collections::HashMap;
use std::io::{ErrorKind, Read};
@@ -111,6 +111,28 @@ pub(crate) struct FramePrefix {
pub digest: blake3::Hash,
}
+/// Every frame tag a segment may hold: exactly the tags `scan_frames`
+/// decodes. The prefix walk stops at any other, so a tag missing here makes
+/// every segment holding such a frame read as torn — never verified clean,
+/// never compacted or recompressed.
+const fn is_frame_tag(tag: u8) -> bool {
+ matches!(
+ tag,
+ TAG_DEFINE
+ | TAG_RAW
+ | TAG_META
+ | TAG_GENERATION_COMPLETE
+ | TAG_META_VERSIONED
+ | TAG_RAW_BATCH
+ | TAG_DOCUMENT
+ )
+}
+
+/// Walk frames by head, length and checksum alone, in bounded memory whatever
+/// a frame holds (a document frame may carry a whole captured video). Bodies
+/// are not decoded: a checksum-valid frame `scan_frames` would reject still
+/// counts here, and the copy recompression proves against this walk keeps it
+/// byte for byte.
pub(crate) fn frame_prefix(reader: &mut impl Read) -> std::io::Result {
let mut digest = blake3::Hasher::new();
let mut bytes = 0_u64;
@@ -133,16 +155,7 @@ pub(crate) fn frame_prefix(reader: &mut impl Read) -> std::io::Result MAX_FRAME_BYTES {
+ if !is_frame_tag(head[0]) || body_len > MAX_FRAME_BYTES {
return Ok(torn(bytes, digest));
}
let mut hasher = blake3::Hasher::new();
@@ -406,3 +419,229 @@ pub(super) fn decode_define(body: &[u8]) -> Option<(u32, SourceDef)> {
},
))
}
+
+/// Document frames on the read side, and the one fixture segment the verify
+/// tests share with these.
+#[cfg(test)]
+pub(super) mod tests {
+ use std::path::PathBuf;
+
+ use tempfile::TempDir;
+
+ use super::*;
+ use crate::ledger::{DocumentCaptureStatus, Ledger, MediaClaim, scan};
+ use crate::segment::{MAGIC_PLAIN, SegmentCodec};
+ use crate::{RecompressOutcome, recompress_segment};
+
+ pub(in crate::ledger) const STATUSES: [DocumentCaptureStatus; 5] = [
+ DocumentCaptureStatus::Captured,
+ DocumentCaptureStatus::Missing,
+ DocumentCaptureStatus::TooLarge,
+ DocumentCaptureStatus::Unsupported,
+ DocumentCaptureStatus::ChangedDuringRead,
+ ];
+
+ const RECORD: &[u8] = b"{\"type\":\"assistant\",\"text\":\"a synthetic line\"}\n";
+ /// The fixture segment's three raw records, in payload bytes.
+ pub(in crate::ledger) const RAW_PAYLOAD_BYTES: u64 = 3 * RECORD.len() as u64;
+ /// One captured media frame's bytes: long enough to cross the prefix
+ /// walk's 64 KiB buffer several times.
+ pub(in crate::ledger) const CLIP_BYTES: u64 = 200_000;
+ pub(in crate::ledger) const HTML_BYTES: u64 = 15;
+
+ pub(in crate::ledger) fn html(recovered_from: Option<&str>) -> CapturedDocument {
+ CapturedDocument {
+ path: "/fixture/review.html".to_string(),
+ captured_at: 1_790_000_000_000,
+ status: DocumentCaptureStatus::Captured,
+ recovered_from: recovered_from.map(str::to_string),
+ media: None,
+ bytes: b"Review ".to_vec(),
+ }
+ }
+
+ /// A media frame as capture writes one: bytes only when captured, and
+ /// then exactly the claimed size. The bytes are synthetic; no file is read.
+ pub(in crate::ledger) fn media(
+ status: DocumentCaptureStatus,
+ media_type: &str,
+ expected_len: u64,
+ ) -> CapturedDocument {
+ let bytes = if status == DocumentCaptureStatus::Captured {
+ (0..expected_len as u32)
+ .map(|index| (index.wrapping_mul(2_654_435_761) >> 24) as u8)
+ .collect()
+ } else {
+ Vec::new()
+ };
+ CapturedDocument {
+ path: "/fixture/out/clip.mp4".to_string(),
+ captured_at: 1_790_000_000_123,
+ status,
+ recovered_from: None,
+ media: Some(MediaClaim {
+ media_type: media_type.to_string(),
+ expected_len,
+ }),
+ bytes,
+ }
+ }
+
+ /// One segment of 14 entries: a meta batch, then one source's raw
+ /// records, HTML documents in both metadata forms, a media frame of every
+ /// status, its seal, and a second generation that carries the capture
+ /// forward.
+ pub(in crate::ledger) fn write_document_segment(dir: &Path, codec: SegmentCodec) -> PathBuf {
+ let source = SourceDef {
+ ledger_only: false,
+ relative_path: "a.jsonl".to_string(),
+ stream_id: "transcript".to_string(),
+ session_id: "session:a".to_string(),
+ native_session_id: "native:a".to_string(),
+ agent_version: Some("2.0".to_string()),
+ };
+ let mut ledger = Ledger::open(dir).unwrap().with_codec(codec);
+ ledger.append_meta(7, "[{\"op\":\"runtime\"}]").unwrap();
+ ledger.append_raw(&source, 1, [(0, RECORD)]).unwrap();
+ ledger.append_document(&source, 1, 0, html(None)).unwrap();
+ ledger
+ .append_document(&source, 1, 0, html(Some("/archive/review.html")))
+ .unwrap();
+ ledger.append_raw(&source, 1, [(100, RECORD)]).unwrap();
+ for (status, media_type, expected_len) in [
+ (STATUSES[0], "video/mp4", CLIP_BYTES),
+ (STATUSES[1], "video/quicktime", 77),
+ (STATUSES[2], "video/mp4", 80 * 1024 * 1024),
+ (STATUSES[3], "image/png", 12),
+ (STATUSES[4], "image/jpeg", 34),
+ ] {
+ ledger
+ .append_document(&source, 1, 100, media(status, media_type, expected_len))
+ .unwrap();
+ }
+ ledger.complete_generation(&source, 1, 3, true).unwrap();
+ ledger.append_raw(&source, 2, [(0, RECORD)]).unwrap();
+ let mut carried = media(STATUSES[0], "video/mp4", CLIP_BYTES);
+ carried.recovered_from = Some("ledger:1:100".to_string());
+ ledger.append_document(&source, 2, 0, carried).unwrap();
+ ledger.complete_generation(&source, 2, 3, true).unwrap();
+ ledger.flush().unwrap();
+ drop(ledger);
+ let path = dir.join("00000000.rledger");
+ assert_eq!(segment_files(dir).unwrap(), vec![(0, path.clone())]);
+ path
+ }
+
+ /// Each frame of a plain segment's decoded bytes as `(tag, start)`.
+ fn frame_starts(decoded: &[u8]) -> Vec<(u8, usize)> {
+ let mut frames = Vec::new();
+ let mut at = 0;
+ while at < decoded.len() {
+ frames.push((decoded[at], at));
+ let body = u32::from_be_bytes(decoded[at + 1..at + 5].try_into().unwrap()) as usize;
+ at += 5 + body + CHECKSUM_BYTES;
+ }
+ assert_eq!(at, decoded.len());
+ frames
+ }
+
+ /// A segment holding document frames used to read torn at its first one.
+ /// The prefix walk now covers the whole segment, and still stops at the
+ /// last intact frame when a document frame is cut short.
+ #[test]
+ fn the_prefix_walk_covers_document_frames() {
+ let temp = TempDir::new().unwrap();
+ let path = write_document_segment(temp.path(), SegmentCodec::Plain);
+ let file = std::fs::read(&path).unwrap();
+ let decoded = &file[MAGIC_PLAIN.len()..];
+ let frames = frame_starts(decoded);
+ let documents: Vec = frames
+ .iter()
+ .filter(|(tag, _)| *tag == TAG_DOCUMENT)
+ .map(|(_, start)| *start)
+ .collect();
+ assert_eq!(documents.len(), 8);
+ assert!(frames.iter().all(|(tag, _)| is_frame_tag(*tag)));
+ assert!(frames.iter().any(|(tag, _)| *tag != TAG_DOCUMENT));
+
+ let mut reader = SegmentReader::open(&path).unwrap().unwrap();
+ let prefix = frame_prefix(&mut reader).unwrap();
+ assert!(prefix.clean && !reader.corrupt());
+ assert_eq!(prefix.bytes, decoded.len() as u64);
+ assert_eq!(prefix.digest, blake3::hash(decoded));
+
+ // The same frames through a compressed container.
+ let compressed = temp.path().join("compressed");
+ let path = write_document_segment(&compressed, SegmentCodec::zstd(3));
+ let mut reader = SegmentReader::open(&path).unwrap().unwrap();
+ let through = frame_prefix(&mut reader).unwrap();
+ assert!(through.clean && !reader.corrupt() && reader.is_compressed());
+ assert_eq!(through.bytes, prefix.bytes);
+ assert_eq!(through.digest, prefix.digest);
+
+ // Cut inside the first captured media frame's body (the third
+ // document frame): the walk ends at that frame's start, and the
+ // segment is still never rewritten.
+ let start = documents[2];
+ let torn_dir = temp.path().join("torn");
+ std::fs::create_dir_all(&torn_dir).unwrap();
+ let torn_path = torn_dir.join("00000000.rledger");
+ let cut = MAGIC_PLAIN.len() + start + CLIP_BYTES as usize / 2;
+ std::fs::write(&torn_path, &file[..cut]).unwrap();
+ let mut reader = SegmentReader::open(&torn_path).unwrap().unwrap();
+ let torn = frame_prefix(&mut reader).unwrap();
+ assert!(!torn.clean);
+ assert_eq!(torn.bytes, start as u64);
+ assert_eq!(torn.digest, blake3::hash(&decoded[..start]));
+ assert_eq!(
+ recompress_segment(&torn_path, SegmentCodec::default(), true).unwrap(),
+ RecompressOutcome::Torn { bytes: cut as u64 }
+ );
+ assert!(std::fs::read(&torn_path).unwrap() == file[..cut]);
+
+ // An unknown tag still ends the walk where it stands.
+ assert!(!is_frame_tag(0) && !is_frame_tag(8) && !is_frame_tag(u8::MAX));
+ }
+
+ /// Compaction and background recompression skip a segment the prefix walk
+ /// calls torn, so no segment with a document frame was ever rewritten.
+ #[test]
+ fn recompression_keeps_document_frames() {
+ let temp = TempDir::new().unwrap();
+ let path = write_document_segment(temp.path(), SegmentCodec::Plain);
+ let original = std::fs::read(&path).unwrap();
+ let entries = scan(temp.path()).unwrap().entries;
+ assert_eq!(entries.len(), 14);
+ let documents = entries
+ .iter()
+ .filter(|entry| matches!(entry, LedgerEntry::Document { .. }))
+ .count();
+ assert_eq!(documents, 8);
+
+ let outcome = recompress_segment(&path, SegmentCodec::default(), false).unwrap();
+ let RecompressOutcome::Rewritten { before, after } = outcome else {
+ panic!("a clean segment is rewritten, not {outcome:?}");
+ };
+ assert_eq!(before, original.len() as u64);
+ assert_eq!(after, std::fs::metadata(&path).unwrap().len());
+ assert!(!path.with_extension("rledger.compact").exists());
+ let mut reader = SegmentReader::open(&path).unwrap().unwrap();
+ assert!(reader.is_compressed());
+ let prefix = frame_prefix(&mut reader).unwrap();
+ assert!(prefix.clean && !reader.corrupt());
+ assert_eq!(prefix.digest, blake3::hash(&original[MAGIC_PLAIN.len()..]));
+ // A full visit of the result yields the same entries.
+ let scanned = scan(temp.path()).unwrap();
+ assert_eq!(scanned.torn_segments, 0);
+ assert!(scanned.entries == entries);
+
+ // And back out: the plain container holds exactly the original bytes.
+ let outcome = recompress_segment(&path, SegmentCodec::Plain, false).unwrap();
+ assert!(
+ matches!(outcome, RecompressOutcome::Rewritten { .. }),
+ "{outcome:?}"
+ );
+ assert!(std::fs::read(&path).unwrap() == original);
+ assert!(scan(temp.path()).unwrap().entries == entries);
+ }
+}
diff --git a/crates/capture/src/ledger/tests.rs b/crates/capture/src/ledger/tests.rs
index 69434494..87ae1fdf 100644
--- a/crates/capture/src/ledger/tests.rs
+++ b/crates/capture/src/ledger/tests.rs
@@ -23,6 +23,7 @@ fn document_observations_round_trip_through_async_and_compressed_ledgers() {
captured_at: 99,
recovered_from: Some("/archive/review.html".into()),
status: DocumentCaptureStatus::Captured,
+ media: None,
bytes: b"Exact review bytes ".to_vec(),
};
ledger
@@ -60,7 +61,34 @@ fn document_metadata_reads_legacy_frames_and_rejects_invalid_recovery_provenance
);
document.status = DocumentCaptureStatus::Missing;
document.bytes.clear();
- assert!(CapturedDocument::decode(&document.encode().unwrap()).is_none());
+ // The writer refuses the frame the reader would reject (the reader's
+ // half is pinned beside the frame contract, in `document.rs`).
+ assert!(matches!(
+ document.encode(),
+ Err(CaptureError::InvalidConfig(_))
+ ));
+}
+
+#[test]
+fn a_ledger_names_the_directory_its_segments_land_in() {
+ let root = TempDir::new().unwrap();
+ let dir = root.path().join("runtime").join("ledger");
+ let mut ledger = Ledger::open(&dir).unwrap();
+ assert_eq!(ledger.dir(), dir);
+ ledger
+ .append_raw(&source("a.jsonl"), 1, [(0, b"one".as_slice())])
+ .unwrap();
+ ledger.flush().unwrap();
+ assert_eq!(scan(ledger.dir()).unwrap().entries.len(), 1);
+ // The asynchronous handle answers the same, before and after its join.
+ let mut ledger = ledger.into_async_bounded(1024);
+ assert_eq!(ledger.dir(), dir);
+ ledger
+ .append_raw(&source("a.jsonl"), 1, [(4, b"two".as_slice())])
+ .unwrap();
+ ledger.flush().unwrap();
+ assert_eq!(ledger.dir(), dir);
+ assert_eq!(scan(ledger.dir()).unwrap().entries.len(), 2);
}
fn source(path: &str) -> SourceDef {
diff --git a/crates/capture/src/ledger/verify.rs b/crates/capture/src/ledger/verify.rs
index 33573524..0c314fc8 100644
--- a/crates/capture/src/ledger/verify.rs
+++ b/crates/capture/src/ledger/verify.rs
@@ -15,7 +15,10 @@
//! digest. The same encoding is therefore also split in two: a source digest
//! over the raw, document and seal entries — the captured content, which two
//! builds of read-only inputs must reproduce — and a meta digest over the
-//! metadata entries alone.
+//! metadata entries alone. A document entry's observation time is as
+//! run-dependent, so the entry digest alone sees it; the media claim of one
+//! that carries it (the type and size its transcript recorded) is source
+//! content.
//!
//! The source digest still depends on the order sources interleave in, which
//! only a path walk fixes. A root-tree walk captures trees newest first and
@@ -32,13 +35,21 @@ use crate::segment::SegmentReader;
use crate::segment_index::INDEX_FILE;
use crate::{CaptureError, io_error};
-use super::{FramePrefix, LedgerEntry, SourceDef, frame_prefix, scan_segment, segment_files};
+use super::{
+ DocumentCaptureStatus, FramePrefix, LedgerEntry, SourceDef, frame_prefix, scan_segment,
+ segment_files,
+};
// Entry kinds in the canonical encoding (not frame tags).
const ENTRY_RAW: u8 = 1;
const ENTRY_SEAL: u8 = 2;
const ENTRY_META: u8 = 3;
const ENTRY_DOCUMENT: u8 = 4;
+/// A document entry that carries a media claim. An HTML document keeps
+/// `ENTRY_DOCUMENT` and its whole earlier encoding, so a ledger with no media
+/// frame keeps its digests; the separate kind keeps the two forms apart
+/// whatever bytes either holds.
+const ENTRY_MEDIA: u8 = 5;
/// What one segment decodes to.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -66,10 +77,31 @@ pub struct EntryTally {
pub entries: u64,
pub raw_records: u64,
pub raw_payload_bytes: u64,
+ /// Every document frame, HTML and media alike.
pub documents: u64,
pub document_bytes: u64,
pub seals: u64,
pub metas: u64,
+ /// The document frames that carry a media claim, by capture status: what
+ /// became of each sent image or video, as counts and bytes only. They are
+ /// also counted in `documents` and `document_bytes`.
+ pub media_captured: u64,
+ pub media_captured_bytes: u64,
+ pub media_missing: u64,
+ pub media_too_large: u64,
+ pub media_unsupported: u64,
+ pub media_changed_during_read: u64,
+}
+
+impl EntryTally {
+ /// Document frames that carry a media claim, whatever their status.
+ pub fn media_frames(&self) -> u64 {
+ self.media_captured
+ + self.media_missing
+ + self.media_too_large
+ + self.media_unsupported
+ + self.media_changed_during_read
+ }
}
/// One runtime ledger's evidence.
@@ -166,8 +198,26 @@ impl EntryDigest {
} => {
tally.documents += 1;
tally.document_bytes += document.bytes.len() as u64;
+ if document.media.is_some() {
+ match document.status {
+ DocumentCaptureStatus::Captured => {
+ tally.media_captured += 1;
+ tally.media_captured_bytes += document.bytes.len() as u64;
+ }
+ DocumentCaptureStatus::Missing => tally.media_missing += 1,
+ DocumentCaptureStatus::TooLarge => tally.media_too_large += 1,
+ DocumentCaptureStatus::Unsupported => tally.media_unsupported += 1,
+ DocumentCaptureStatus::ChangedDuringRead => {
+ tally.media_changed_during_read += 1;
+ }
+ }
+ }
let mut out = Encoder::for_source(all, sources, per_source, source);
- out.put(&[ENTRY_DOCUMENT]);
+ out.put(&[if document.media.is_some() {
+ ENTRY_MEDIA
+ } else {
+ ENTRY_DOCUMENT
+ }]);
out.source(source);
out.put(&generation.to_be_bytes());
out.put(&start_offset.to_be_bytes());
@@ -179,6 +229,12 @@ impl EntryDigest {
.expect("enum")
.as_bytes(),
);
+ // The claim is what the transcript recorded, so it is source
+ // content: an uncaptured frame differs by it alone.
+ if let Some(media) = &document.media {
+ out.bytes(media.media_type.as_bytes());
+ out.put(&media.expected_len.to_be_bytes());
+ }
out.bytes(&document.bytes);
}
LedgerEntry::GenerationComplete {
diff --git a/crates/capture/src/ledger/verify/tests.rs b/crates/capture/src/ledger/verify/tests.rs
index 81e48242..d4773022 100644
--- a/crates/capture/src/ledger/verify/tests.rs
+++ b/crates/capture/src/ledger/verify/tests.rs
@@ -4,6 +4,9 @@ use std::io::Write;
use tempfile::TempDir;
use super::*;
+use crate::ledger::read::tests::{
+ CLIP_BYTES, HTML_BYTES, RAW_PAYLOAD_BYTES, STATUSES, html, media, write_document_segment,
+};
use crate::ledger::{
CHECKSUM_BYTES, CapturedDocument, DocumentCaptureStatus, Ledger, TAG_DEFINE,
TAG_GENERATION_COMPLETE, TAG_META_VERSIONED, TAG_RAW, serde_encode,
@@ -162,8 +165,7 @@ fn the_entry_digest_ignores_containers_segments_ids_and_packing() {
raw_payload_bytes: (0..20).map(|index| record(index).len() as u64).sum::() * 2,
seals: 2,
metas: 1,
- documents: 0,
- document_bytes: 0,
+ ..EntryTally::default()
}
);
assert_eq!(
@@ -408,6 +410,7 @@ fn pinned_entries() -> Vec {
captured_at: 1_790_000_000_000,
status: DocumentCaptureStatus::Captured,
recovered_from: None,
+ media: None,
bytes: b"doc
".to_vec(),
},
},
@@ -437,10 +440,14 @@ fn digest_of(entries: &[LedgerEntry]) -> EntryDigest {
/// The digests two builds are compared by keep their encoding (R1 I5): the
/// entry, source and meta values predate the per-source digests, which left
-/// them unchanged.
+/// them unchanged. They predate the media claim too: the pinned stream holds
+/// an HTML document frame, and a ledger with no media frame keeps every one
+/// of these values.
#[test]
fn the_entry_source_and_meta_digests_keep_their_encoding() {
let digest = digest_of(&pinned_entries());
+ assert_eq!(digest.tally().documents, 1);
+ assert_eq!(digest.tally().media_frames(), 0);
assert_eq!(
digest.finish().to_hex().as_str(),
"343b655e37d643f938846ec3606af5054cebcc8d42761e2e5526b7d83cd01e47"
@@ -569,6 +576,7 @@ fn source_set_digest_ignores_document_captured_at() {
captured_at,
status: DocumentCaptureStatus::Captured,
recovered_from: None,
+ media: None,
bytes: b"review
".to_vec(),
},
};
@@ -683,3 +691,263 @@ fn source_set_digest_sees_a_dropped_or_duplicated_frame() {
assert_eq!(swapped.tally, base.tally);
assert_ne!(swapped.source_set_digest, base.source_set_digest);
}
+
+/// A segment holding document frames used to verify torn at its first one.
+/// Now it verifies clean in every container, with what became of each sent
+/// file counted by status.
+#[test]
+fn a_segment_with_document_frames_verifies_clean() {
+ let temp = TempDir::new().unwrap();
+ let expected = EntryTally {
+ entries: 14,
+ raw_records: 3,
+ raw_payload_bytes: RAW_PAYLOAD_BYTES,
+ documents: 8,
+ document_bytes: HTML_BYTES * 2 + CLIP_BYTES * 2,
+ seals: 2,
+ metas: 1,
+ media_captured: 2,
+ media_captured_bytes: CLIP_BYTES * 2,
+ media_missing: 1,
+ media_too_large: 1,
+ media_unsupported: 1,
+ media_changed_during_read: 1,
+ };
+ let mut digests = Vec::new();
+ for (name, codec) in [
+ ("plain", SegmentCodec::Plain),
+ ("frames", SegmentCodec::default()),
+ ("stream", SegmentCodec::zstd(3)),
+ ] {
+ let dir = temp.path().join(name);
+ let path = write_document_segment(&dir, codec);
+ let report = verify(&dir).unwrap();
+ assert_eq!(report.torn_segments(), 0, "{name}");
+ assert_eq!(report.segments.len(), 1, "{name}");
+ let segment = &report.segments[0];
+ assert!(segment.clean, "{name}");
+ assert_eq!(segment.compressed, Some(codec.is_compressed()), "{name}");
+ assert_eq!(segment.entries, 14, "{name}");
+ assert_eq!(report.tally, expected, "{name}");
+ assert_eq!(report.tally.media_frames(), 6, "{name}");
+ if !codec.is_compressed() {
+ let file = std::fs::metadata(&path).unwrap().len();
+ assert_eq!(segment.decoded_bytes, file - MAGIC_PLAIN.len() as u64);
+ }
+ digests.push((
+ report.entry_digest,
+ report.source_digest,
+ report.source_set_digest,
+ ));
+ }
+ assert_eq!(digests[0], digests[1]);
+ assert_eq!(digests[0], digests[2]);
+
+ // Recompressed, the same evidence: nothing about the entries moved.
+ let dir = temp.path().join("plain");
+ let before = verify(&dir).unwrap();
+ let outcome =
+ recompress_segment(&before.segments[0].path, SegmentCodec::default(), false).unwrap();
+ assert!(
+ matches!(outcome, RecompressOutcome::Rewritten { .. }),
+ "{outcome:?}"
+ );
+ let after = verify(&dir).unwrap();
+ assert_eq!(after.segments[0].compressed, Some(true));
+ assert_eq!(after.torn_segments(), 0);
+ assert_eq!(after.tally, before.tally);
+ assert_eq!(after.entry_digest, before.entry_digest);
+ assert_eq!(after.source_set_digest, before.source_set_digest);
+ assert_eq!(after.frame_digest(), before.frame_digest());
+}
+
+fn document_entry(document: CapturedDocument) -> LedgerEntry {
+ LedgerEntry::Document {
+ source: source("a.jsonl"),
+ generation: 1,
+ start_offset: 6,
+ document,
+ }
+}
+
+#[test]
+fn the_tally_counts_media_frames_by_status() {
+ let base = EntryTally {
+ entries: 1,
+ documents: 1,
+ ..EntryTally::default()
+ };
+ let expected = [
+ EntryTally {
+ document_bytes: 9,
+ media_captured: 1,
+ media_captured_bytes: 9,
+ ..base
+ },
+ EntryTally {
+ media_missing: 1,
+ ..base
+ },
+ EntryTally {
+ media_too_large: 1,
+ ..base
+ },
+ EntryTally {
+ media_unsupported: 1,
+ ..base
+ },
+ EntryTally {
+ media_changed_during_read: 1,
+ ..base
+ },
+ ];
+ let mut all = EntryDigest::new();
+ for (status, expected) in STATUSES.into_iter().zip(expected) {
+ let entry = document_entry(media(status, "video/mp4", 9));
+ let tally = digest_of(std::slice::from_ref(&entry)).tally();
+ assert_eq!(tally, expected, "{status:?}");
+ assert_eq!(tally.media_frames(), 1, "{status:?}");
+ all.update(&entry);
+ }
+ // An HTML document, captured or not, is no media frame.
+ let mut missing = html(None);
+ missing.status = DocumentCaptureStatus::Missing;
+ missing.bytes.clear();
+ for document in [html(None), html(Some("/archive/review.html")), missing] {
+ let entry = document_entry(document);
+ let tally = digest_of(std::slice::from_ref(&entry)).tally();
+ assert_eq!(tally.documents, 1);
+ assert_eq!(tally.media_frames(), 0);
+ assert_eq!(tally.media_captured_bytes, 0);
+ all.update(&entry);
+ }
+ assert_eq!(
+ all.tally(),
+ EntryTally {
+ entries: 8,
+ documents: 8,
+ document_bytes: 9 + HTML_BYTES * 2,
+ media_captured: 1,
+ media_captured_bytes: 9,
+ media_missing: 1,
+ media_too_large: 1,
+ media_unsupported: 1,
+ media_changed_during_read: 1,
+ ..EntryTally::default()
+ }
+ );
+ assert_eq!(all.tally().media_frames(), 5);
+}
+
+/// The media claim is source content. It reaches the digests only through a
+/// frame that carries one, so the HTML-only stream pinned above keeps its
+/// values; a media frame changes its own source's digest and no other's.
+#[test]
+fn the_media_claim_feeds_the_source_digests() {
+ let with = |document: CapturedDocument| {
+ let mut entries = pinned_entries();
+ entries.push(document_entry(document));
+ digest_of(&entries)
+ };
+ let source_digest = |digest: &EntryDigest, path: &str| digest.per_source[path].finalize();
+ let every = |digest: &EntryDigest| {
+ [
+ digest.finish(),
+ digest.source_digest(),
+ digest.source_set_digest(),
+ source_digest(digest, "a.jsonl"),
+ ]
+ };
+ // Every digest that sees source a differs; source b's and the metadata's do not.
+ let differ = |left: &EntryDigest, right: &EntryDigest, what: &str| {
+ for (left, right) in every(left).into_iter().zip(every(right)) {
+ assert_ne!(left, right, "{what}");
+ }
+ assert_eq!(
+ source_digest(left, "b.jsonl"),
+ source_digest(right, "b.jsonl"),
+ "{what}"
+ );
+ assert_eq!(left.meta_digest(), right.meta_digest(), "{what}");
+ };
+
+ let html_only = digest_of(&pinned_entries());
+ let missing = with(media(DocumentCaptureStatus::Missing, "video/mp4", 77));
+ differ(&missing, &html_only, "a media frame");
+
+ // Two streams that differ only in the claim: its size, then its type.
+ let longer = with(media(DocumentCaptureStatus::Missing, "video/mp4", 78));
+ assert_eq!(missing.tally(), longer.tally());
+ differ(&missing, &longer, "expected_len");
+ let quicktime = with(media(DocumentCaptureStatus::Missing, "video/quicktime", 77));
+ assert_eq!(missing.tally(), quicktime.tally());
+ differ(&missing, &quicktime, "media_type");
+ // A neighbouring split of type and size is not the same claim.
+ let unsupported = |media_type: &str, expected_len: u64| {
+ with(media(
+ DocumentCaptureStatus::Unsupported,
+ media_type,
+ expected_len,
+ ))
+ };
+ differ(
+ &unsupported("video/mp4", u64::from_be_bytes(*b"0\0\0\0\0\0\0\x01")),
+ &unsupported("video/mp40", 1),
+ "the boundary between type and size",
+ );
+
+ // The same path, status and bytes with and without a claim.
+ let mut bare = media(DocumentCaptureStatus::Captured, "video/mp4", 9);
+ let claimed = with(bare.clone());
+ bare.media = None;
+ let unclaimed = with(bare);
+ differ(&claimed, &unclaimed, "the claim itself");
+ assert_eq!(claimed.tally().media_frames(), 1);
+ assert_eq!(unclaimed.tally().media_frames(), 0);
+
+ // The observation time stays run-dependent: only the entry digest sees it.
+ let mut observed_later = media(DocumentCaptureStatus::Missing, "video/mp4", 77);
+ observed_later.captured_at += 9_999;
+ let later = with(observed_later);
+ assert_ne!(later.finish(), missing.finish());
+ assert_eq!(later.source_digest(), missing.source_digest());
+ assert_eq!(later.source_set_digest(), missing.source_set_digest());
+
+ // Through real ledgers: only the media frame's `expected_len` differs.
+ let temp = TempDir::new().unwrap();
+ let write = |name: &str, expected_len: Option| {
+ let dir = temp.path().join(name);
+ let mut ledger = Ledger::open(&dir).unwrap();
+ ledger.append_meta(7, META).unwrap();
+ for path in ["a.jsonl", "b.jsonl"] {
+ let source = source(path);
+ ledger.append_raw(&source, 1, [(0, record(0))]).unwrap();
+ if path == "a.jsonl" {
+ ledger.append_document(&source, 1, 0, html(None)).unwrap();
+ if let Some(expected_len) = expected_len {
+ let document =
+ media(DocumentCaptureStatus::TooLarge, "video/mp4", expected_len);
+ ledger.append_document(&source, 1, 0, document).unwrap();
+ }
+ }
+ ledger.complete_generation(&source, 1, 3, true).unwrap();
+ }
+ ledger.flush().unwrap();
+ drop(ledger);
+ let report = verify(&dir).unwrap();
+ assert_eq!(report.torn_segments(), 0, "{name}");
+ report
+ };
+ let html_ledger = write("html", None);
+ let first = write("first", Some(70 * 1024 * 1024));
+ let second = write("second", Some(70 * 1024 * 1024 + 1));
+ assert_eq!(first.tally, second.tally);
+ assert_eq!(first.tally.media_too_large, 1);
+ assert_eq!(html_ledger.tally.media_frames(), 0);
+ for other in [&second, &html_ledger] {
+ assert_ne!(first.entry_digest, other.entry_digest);
+ assert_ne!(first.source_digest, other.source_digest);
+ assert_ne!(first.source_set_digest, other.source_set_digest);
+ assert_eq!(first.meta_digest, other.meta_digest);
+ }
+}
diff --git a/crates/capture/src/lib.rs b/crates/capture/src/lib.rs
index c6ab7eca..10570351 100644
--- a/crates/capture/src/lib.rs
+++ b/crates/capture/src/lib.rs
@@ -24,15 +24,15 @@ pub use discovery::{
pub use document::{DocumentOutcome, DocumentSnapshot, snapshot as snapshot_document};
pub use ledger::{
CapturedDocument, DocumentCaptureStatus, EntryDigest, EntryTally, Ledger, LedgerEntry,
- LedgerScan, LedgerVerify, LedgerVisit, LedgerWriteStats, MAX_RAW_RECORD_BYTES, PrebuiltFrames,
- SegmentVerify, SourceDef, scan as scan_ledger, verify as verify_ledger, visit as visit_ledger,
- visit_segments as visit_ledger_segments,
+ LedgerScan, LedgerVerify, LedgerVisit, LedgerWriteStats, MAX_RAW_RECORD_BYTES, MediaClaim,
+ PrebuiltFrames, SegmentVerify, SourceDef, scan as scan_ledger, verify as verify_ledger,
+ visit as visit_ledger, visit_segments as visit_ledger_segments,
};
pub use recompress::{RecompressOutcome, recompress_segment, recompress_segment_controlled};
pub use segment::{FrameCompressor, SMALLEST_COMPRESSED_BATCH, SegmentCodec, sniff_segment};
pub use segment_index::{
IndexedSource, SegmentFilter, SegmentIndexLine, candidate_segments, rebuild_segment_index,
- segment_filter,
+ segment_filter, segment_filter_through,
};
pub use sqlite::{
DbSnapshot, DbStamp, SnapshotOutcome, SqliteFileStatus, db_stamp, is_db_sidecar,
diff --git a/crates/capture/src/segment_index.rs b/crates/capture/src/segment_index.rs
index 81120fb7..47e2906a 100644
--- a/crates/capture/src/segment_index.rs
+++ b/crates/capture/src/segment_index.rs
@@ -112,6 +112,31 @@ pub fn segment_filter(
Ok(filter)
}
+/// Read the index for every generation of one source up to and including
+/// `through`: a segment is matched when the index lists the source with a
+/// lowest generation at or below it. Unknown segments are admitted exactly as
+/// by [`segment_filter`].
+pub fn segment_filter_through(
+ dir: &Path,
+ session_id: &str,
+ relative_path: &str,
+ through: u64,
+) -> Result {
+ let mut filter = SegmentFilter::default();
+ for line in read_lines(dir)? {
+ filter.indexed.insert(line.segment);
+ let holds = line.sources.iter().any(|source| {
+ source.session_id == session_id
+ && source.relative_path == relative_path
+ && source.generations[0] <= through
+ });
+ if holds {
+ filter.matched.insert(line.segment);
+ }
+ }
+ Ok(filter)
+}
+
/// The segments on disk right now that a read scoped to one source
/// generation must visit. A walk should prefer [`segment_filter`] against its
/// own listing; this is the same answer for a directory nobody is writing.
@@ -323,6 +348,64 @@ mod tests {
);
}
+ #[test]
+ fn a_through_filter_matches_every_generation_at_or_below_it() {
+ let dir = TempDir::new().unwrap();
+ let mut ledger = open_ledger(dir.path());
+ // segment 0: A gen 1; segment 1: B gen 1 and 2; segment 2: A gen 3;
+ // segment 3: A gen 4 and 5.
+ ledger
+ .append_raw(&source("A", "a.jsonl"), 1, [(0_u64, b"a1".as_slice())])
+ .unwrap();
+ ledger.flush().unwrap();
+ ledger
+ .append_raw(&source("B", "b.jsonl"), 1, [(0_u64, b"b1".as_slice())])
+ .unwrap();
+ ledger
+ .append_raw(&source("B", "b.jsonl"), 2, [(0_u64, b"b2".as_slice())])
+ .unwrap();
+ ledger.flush().unwrap();
+ ledger
+ .append_raw(&source("A", "a.jsonl"), 3, [(0_u64, b"a3".as_slice())])
+ .unwrap();
+ ledger.flush().unwrap();
+ ledger
+ .append_raw(&source("A", "a.jsonl"), 4, [(0_u64, b"a4".as_slice())])
+ .unwrap();
+ ledger
+ .append_raw(&source("A", "a.jsonl"), 5, [(0_u64, b"a5".as_slice())])
+ .unwrap();
+ drop(ledger);
+
+ let admitted = |session: &str, path: &str, through: u64| {
+ let filter = segment_filter_through(dir.path(), session, path, through).unwrap();
+ (0..4_u64)
+ .filter(|segment| filter.admits(*segment))
+ .collect::>()
+ };
+ assert_eq!(admitted("A", "a.jsonl", 0), Vec::::new());
+ assert_eq!(admitted("A", "a.jsonl", 1), [0]);
+ assert_eq!(admitted("A", "a.jsonl", 2), [0]);
+ assert_eq!(admitted("A", "a.jsonl", 3), [0, 2]);
+ // A range is matched by its lowest generation, never its highest.
+ assert_eq!(admitted("A", "a.jsonl", 4), [0, 2, 3]);
+ assert_eq!(admitted("A", "a.jsonl", u64::MAX), [0, 2, 3]);
+ // Another source, and another path of the same session, stay out.
+ assert_eq!(admitted("B", "b.jsonl", 1), [1]);
+ assert_eq!(admitted("A", "b.jsonl", 9), Vec::::new());
+ assert_eq!(admitted("B", "a.jsonl", 9), Vec::::new());
+
+ // The exact-generation filter still excludes what this one admits.
+ let exact = segment_filter(dir.path(), "A", "a.jsonl", 3).unwrap();
+ assert!(!exact.admits(0) && exact.admits(2) && !exact.admits(3));
+
+ // Unknown segments are admitted, and with no index every one is.
+ let filter = segment_filter_through(dir.path(), "A", "a.jsonl", 1).unwrap();
+ assert!(filter.admits(99));
+ std::fs::remove_file(dir.path().join(INDEX_FILE)).unwrap();
+ assert_eq!(admitted("A", "a.jsonl", 0), [0, 1, 2, 3]);
+ }
+
#[test]
fn rebuild_reproduces_the_writer_index() {
let dir = TempDir::new().unwrap();
diff --git a/crates/core/src/assets.rs b/crates/core/src/assets.rs
index c554703b..81b76888 100644
--- a/crates/core/src/assets.rs
+++ b/crates/core/src/assets.rs
@@ -118,20 +118,29 @@ impl fmt::Debug for ProjectionAsset {
}
}
+/// The closed projection-member allowlist: each media type with the file
+/// extension its member is stored and served under. One table, so the store's
+/// signature check and the server's extension map can be held to it.
+pub const MEMBER_TYPES: [(&str, &str); 12] = [
+ ("image/png", "png"),
+ ("image/jpeg", "jpg"),
+ ("image/gif", "gif"),
+ ("image/webp", "webp"),
+ ("image/bmp", "bmp"),
+ ("image/x-icon", "ico"),
+ ("video/mp4", "mp4"),
+ ("video/quicktime", "mov"),
+ ("application/pdf", "pdf"),
+ ("application/vnd.rosetta.canonical-document+json", "json"),
+ ("text/plain; charset=utf-8", "txt"),
+ ("text/html; charset=utf-8", "html"),
+];
+
fn extension(media_type: &str) -> Option<&'static str> {
- match media_type {
- "image/png" => Some("png"),
- "image/jpeg" => Some("jpg"),
- "image/gif" => Some("gif"),
- "image/webp" => Some("webp"),
- "image/bmp" => Some("bmp"),
- "image/x-icon" => Some("ico"),
- "application/pdf" => Some("pdf"),
- "application/vnd.rosetta.canonical-document+json" => Some("json"),
- "text/plain; charset=utf-8" => Some("txt"),
- "text/html; charset=utf-8" => Some("html"),
- _ => None,
- }
+ MEMBER_TYPES
+ .iter()
+ .find(|(known, _)| *known == media_type)
+ .map(|(_, extension)| *extension)
}
/// Compatibility name for callers that specifically construct image
@@ -242,6 +251,58 @@ mod tests {
}
}
+ #[test]
+ fn recorded_video_members_are_named_by_container() {
+ for (media_type, extension) in [("video/mp4", "mp4"), ("video/quicktime", "mov")] {
+ assert_eq!(
+ ProjectionAsset::supported_extension(media_type),
+ Some(extension)
+ );
+ let asset = ProjectionAsset::new(media_type, b"video bytes".to_vec()).unwrap();
+ assert!(asset.url().unwrap().ends_with(&format!(".{extension}")));
+ let reference = asset.reference();
+ assert_eq!(reference.image_variants, None);
+ reference.validate().unwrap();
+ }
+ assert_eq!(ProjectionAsset::supported_extension("video/webm"), None);
+ }
+
+ #[test]
+ fn the_member_table_names_each_type_and_extension_once() {
+ use std::collections::BTreeSet;
+ let types: BTreeSet<_> = super::MEMBER_TYPES.iter().map(|(t, _)| *t).collect();
+ let extensions: BTreeSet<_> = super::MEMBER_TYPES.iter().map(|(_, e)| *e).collect();
+ assert_eq!(types.len(), super::MEMBER_TYPES.len());
+ assert_eq!(extensions.len(), super::MEMBER_TYPES.len());
+ for (media_type, extension) in super::MEMBER_TYPES {
+ assert_eq!(
+ ProjectionAsset::supported_extension(media_type),
+ Some(extension)
+ );
+ }
+ // The types as they were before recorded video, extension for extension.
+ for (media_type, extension) in [
+ ("image/png", "png"),
+ ("image/jpeg", "jpg"),
+ ("image/gif", "gif"),
+ ("image/webp", "webp"),
+ ("image/bmp", "bmp"),
+ ("image/x-icon", "ico"),
+ ("application/pdf", "pdf"),
+ ("application/vnd.rosetta.canonical-document+json", "json"),
+ ("text/plain; charset=utf-8", "txt"),
+ ("text/html; charset=utf-8", "html"),
+ ] {
+ assert_eq!(
+ ProjectionAsset::supported_extension(media_type),
+ Some(extension)
+ );
+ }
+ for unsupported in ["", "image/svg+xml", "IMAGE/PNG", "text/plain", "video/webm"] {
+ assert_eq!(ProjectionAsset::supported_extension(unsupported), None);
+ }
+ }
+
#[test]
fn text_members_are_utf8_and_content_addressed() {
let asset = ProjectionAsset::text("hello");
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 4d10ba91..f918d0de 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -15,6 +15,7 @@ pub mod html_artifacts;
pub mod ids;
mod op_scope;
pub mod ops;
+pub mod presented_media;
pub mod rows;
pub mod timeline;
pub mod usage_totals;
diff --git a/crates/core/src/op_scope.rs b/crates/core/src/op_scope.rs
index e43ec582..f845be11 100644
--- a/crates/core/src/op_scope.rs
+++ b/crates/core/src/op_scope.rs
@@ -29,6 +29,7 @@ impl IngestOp {
}
Self::HtmlArtifact(publication) => Some(&publication.session_id),
Self::HtmlArtifactContent(content) => Some(&content.session_id),
+ Self::PresentedMedia(fact) => Some(&fact.session_id),
Self::SessionMeta(row) => Some(&row.id),
Self::SessionTitle(row) => Some(&row.session_id),
Self::Message(entry) => Some(&entry.session_id),
@@ -58,6 +59,7 @@ impl IngestOp {
.saturating_add(len(&asset.media_type)),
Self::HtmlArtifact(publication) => json_len(publication),
Self::HtmlArtifactContent(content) => json_len(content),
+ Self::PresentedMedia(fact) => json_len(fact),
Self::Runtime(row) => json_len(row),
Self::Project(row) => json_len(row),
Self::SessionMeta(row) => json_len(row),
diff --git a/crates/core/src/ops.rs b/crates/core/src/ops.rs
index a04c9a72..4effba16 100644
--- a/crates/core/src/ops.rs
+++ b/crates/core/src/ops.rs
@@ -32,6 +32,9 @@ pub enum IngestOp {
HtmlArtifact(crate::html_artifacts::HtmlArtifactPublication),
/// Captured bytes and availability for a publication's exact file locator.
HtmlArtifactContent(crate::html_artifacts::HtmlArtifactContent),
+ /// A file the agent sent the user by path. A transient fact for the
+ /// capture seam and replay; the store applies nothing for it.
+ PresentedMedia(crate::presented_media::PresentedMedia),
Runtime(RuntimeRow),
Project(ProjectRow),
/// Meta-phase upsert. Stream normalization never mutates session identity,
diff --git a/crates/core/src/presented_media.rs b/crates/core/src/presented_media.rs
new file mode 100644
index 00000000..af64ca49
--- /dev/null
+++ b/crates/core/src/presented_media.rs
@@ -0,0 +1,336 @@
+//! A file an agent sent to the user by path: the pure fact an adapter emits
+//! from the transcript alone. The bytes are not in the transcript, so the
+//! capture layer observes the file and ledgers it; replay joins that frame
+//! back to this fact by `(source_offset, path)` and never reads a filesystem.
+//!
+//! The fact is transient: the store applies nothing for it. What a reader
+//! sees is the tool row's attachment, a path reference first and an asset
+//! once a captured frame projects.
+
+use serde::{Deserialize, Serialize};
+
+use crate::rows::Ms;
+use crate::timeline::{
+ AttachmentKind, AttachmentRef, AttachmentSource, EvidenceOrder, ResourceRef,
+};
+
+/// Largest video the capture layer copies into the ledger.
+pub const VIDEO_MAX_BYTES: u64 = 64 * 1024 * 1024;
+/// Captured bytes one raw record may contribute across its files.
+pub const RECORD_MAX_BYTES: u64 = 128 * 1024 * 1024;
+/// Files examined on one raw record; later ones stay ledger-only.
+pub const FILES_PER_RECORD: usize = 32;
+/// The closed set of types a sent file may claim.
+pub const MEDIA_TYPES: [&str; 8] = [
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "image/bmp",
+ "image/x-icon",
+ "video/mp4",
+ "video/quicktime",
+];
+/// Mutation index of the record's one path-reference tool update. The
+/// per-file upgrades sit directly above it, so a captured asset always
+/// outranks the reference it replaces and both stay below the two indexes
+/// reserved at the top of the range.
+pub const REFERENCE_MUTATION: u32 = u32::MAX - 2 - FILES_PER_RECORD as u32;
+
+/// Mutation index of the upgrade for the file at native index `ordinal`.
+pub const fn upgrade_mutation(ordinal: u32) -> u32 {
+ REFERENCE_MUTATION + 1 + ordinal
+}
+
+const PATH_MAX_BYTES: usize = 16384;
+
+/// Whether an absolute path is the device or process tree, or lies under
+/// one. Opening such a path can act on the reader itself: `/dev/fd/N` is a
+/// duplicate of its own descriptor N. Letter case is ignored because the
+/// default macOS volume resolves `/DEV` to `/dev`.
+fn is_device_path(path: &str) -> bool {
+ let root = path
+ .strip_prefix('/')
+ .and_then(|rest| rest.split('/').next())
+ .unwrap_or_default();
+ root.eq_ignore_ascii_case("dev") || root.eq_ignore_ascii_case("proc")
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PresentedMedia {
+ pub session_id: String,
+ /// Raw-record coordinate: the `Document` frame's `start_offset`.
+ pub source_offset: u64,
+ /// The absolute path as the transcript recorded it, or a recorded
+ /// relative path joined to its working directory. Never rewritten:
+ /// capture opens exactly this path.
+ pub path: String,
+ /// The size the transcript recorded.
+ pub expected_len: u64,
+ /// One of [`MEDIA_TYPES`].
+ pub media_type: String,
+ pub correlation_key: String,
+ /// The reference slot exactly as it was attached to the tool row.
+ pub attachment: AttachmentRef,
+ /// Evidence of the upgrade that swaps the slot's source to an asset.
+ pub evidence: EvidenceOrder,
+ pub at: Option,
+}
+
+impl PresentedMedia {
+ /// Whether this fact can be captured and its upgrade applied. The one
+ /// producer drops a file whose fact fails, so nothing emitted can fail a
+ /// store batch.
+ pub fn validate(&self) -> Result<(), &'static str> {
+ if self.session_id.is_empty() {
+ return Err("presented media has no session");
+ }
+ if self.correlation_key.is_empty() {
+ return Err("presented media has no correlation key");
+ }
+ if !self.path.starts_with('/')
+ || self.path.starts_with("//")
+ || self.path.contains('\0')
+ || self.path.len() > PATH_MAX_BYTES
+ {
+ return Err("presented media path is not a bounded absolute path");
+ }
+ if is_device_path(&self.path) {
+ return Err("presented media path is a device or process path");
+ }
+ if self.expected_len == 0 {
+ return Err("presented media has no recorded size");
+ }
+ if !MEDIA_TYPES.contains(&self.media_type.as_str()) {
+ return Err("presented media type is not a presented type");
+ }
+ if self.attachment.validate().is_err() {
+ return Err("presented media attachment is invalid");
+ }
+ match &self.attachment.source {
+ AttachmentSource::Reference {
+ reference: ResourceRef::File { path, .. },
+ } if *path == self.path => {}
+ _ => return Err("presented media attachment is not a reference to its path"),
+ }
+ let kind = if self.media_type.starts_with("image/") {
+ AttachmentKind::Image
+ } else {
+ AttachmentKind::Video
+ };
+ if self.attachment.kind != kind {
+ return Err("presented media attachment kind disagrees with its type");
+ }
+ if self.evidence.record_offset != self.source_offset {
+ return Err("presented media evidence names another record");
+ }
+ let band = upgrade_mutation(0)..=upgrade_mutation(FILES_PER_RECORD as u32 - 1);
+ if !band.contains(&self.evidence.mutation_index) {
+ return Err("presented media evidence is outside the upgrade band");
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::IngestOp;
+ use crate::timeline::SourceIdentity;
+
+ fn fact(media_type: &str) -> PresentedMedia {
+ let path = "/work/out/clip.mp4".to_owned();
+ let kind = if media_type.starts_with("image/") {
+ AttachmentKind::Image
+ } else {
+ AttachmentKind::Video
+ };
+ let mut attachment = AttachmentRef::new(
+ "occurrence:sent-0",
+ kind,
+ AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: path.clone(),
+ resolved: true,
+ },
+ },
+ );
+ attachment.name = Some("clip.mp4".into());
+ attachment.media_type = Some(media_type.into());
+ attachment.byte_len = Some(42);
+ PresentedMedia {
+ session_id: "s1".into(),
+ source_offset: 128,
+ path,
+ expected_len: 42,
+ media_type: media_type.into(),
+ correlation_key: "tool-1".into(),
+ attachment,
+ evidence: EvidenceOrder {
+ source_identity: SourceIdentity("source".into()),
+ generation: 1,
+ record_offset: 128,
+ mutation_index: upgrade_mutation(0),
+ },
+ at: Some(7),
+ }
+ }
+
+ #[test]
+ fn a_well_formed_fact_validates_for_every_presented_type() {
+ for media_type in MEDIA_TYPES {
+ fact(media_type).validate().unwrap();
+ }
+ let mut last = fact("video/quicktime");
+ last.evidence.mutation_index = upgrade_mutation(FILES_PER_RECORD as u32 - 1);
+ last.validate().unwrap();
+ }
+
+ #[test]
+ fn each_broken_field_is_rejected() {
+ type Break = fn(&mut PresentedMedia);
+ let cases: [(&str, Break); 20] = [
+ ("empty session", |f| f.session_id.clear()),
+ ("empty correlation key", |f| f.correlation_key.clear()),
+ ("relative path", |f| f.path = "out/clip.mp4".into()),
+ ("empty path", |f| f.path.clear()),
+ ("network path", |f| f.path = "//host/clip.mp4".into()),
+ ("nul in path", |f| f.path = "/work/\0clip.mp4".into()),
+ ("over-long path", |f| {
+ f.path = format!("/{}", "a".repeat(PATH_MAX_BYTES))
+ }),
+ ("zero size", |f| f.expected_len = 0),
+ ("unknown type", |f| f.media_type = "video/webm".into()),
+ ("html type", |f| f.media_type = "text/html".into()),
+ ("invalid attachment", |f| f.attachment.id.clear()),
+ ("attachment names another path", |f| {
+ f.path = "/work/out/other.mp4".into()
+ }),
+ ("attachment is unavailable", |f| {
+ f.attachment.source = AttachmentSource::Unavailable {
+ reason: crate::timeline::AttachmentUnavailableReason::NotRecorded,
+ }
+ }),
+ ("attachment is a url", |f| {
+ f.attachment.source = AttachmentSource::Reference {
+ reference: ResourceRef::Url {
+ url: "https://example.test/clip.mp4".into(),
+ },
+ }
+ }),
+ ("attachment is a directory", |f| {
+ f.attachment.source = AttachmentSource::Reference {
+ reference: ResourceRef::Directory {
+ path: "/work/out/clip.mp4".into(),
+ resolved: true,
+ },
+ }
+ }),
+ ("kind disagrees", |f| {
+ f.attachment.kind = AttachmentKind::Image
+ }),
+ ("evidence names another record", |f| {
+ f.evidence.record_offset += 1
+ }),
+ ("reference mutation", |f| {
+ f.evidence.mutation_index = REFERENCE_MUTATION
+ }),
+ ("below the band", |f| f.evidence.mutation_index = 0),
+ ("above the band", |f| {
+ f.evidence.mutation_index = upgrade_mutation(FILES_PER_RECORD as u32)
+ }),
+ ];
+ for (name, break_it) in cases {
+ let mut broken = fact("video/mp4");
+ break_it(&mut broken);
+ assert!(broken.validate().is_err(), "{name} must be rejected");
+ }
+ // The kind rule cuts both ways.
+ let mut image = fact("image/png");
+ image.attachment.kind = AttachmentKind::Video;
+ assert!(image.validate().is_err());
+ // The longest accepted path is exactly the bound.
+ let mut longest = fact("video/mp4");
+ longest.path = format!("/{}", "a".repeat(PATH_MAX_BYTES - 1));
+ longest.attachment.source = AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: longest.path.clone(),
+ resolved: true,
+ },
+ };
+ longest.validate().unwrap();
+ }
+
+ /// The fact as it stands when its path and its slot both name `path`.
+ fn at_path(path: &str) -> PresentedMedia {
+ let mut fact = fact("video/mp4");
+ fact.path = path.to_owned();
+ fact.attachment.source = AttachmentSource::Reference {
+ reference: ResourceRef::File {
+ path: path.to_owned(),
+ resolved: true,
+ },
+ };
+ fact
+ }
+
+ #[test]
+ fn a_device_or_process_path_is_rejected() {
+ for path in [
+ "/dev",
+ "/dev/",
+ "/dev/null",
+ "/dev/fd/3",
+ "/dev/cu.usbmodem1",
+ "/proc",
+ "/proc/self/fd/3",
+ "/proc/1/mem",
+ // The default macOS volume folds case: `/DEV/fd/3` is `/dev/fd/3`.
+ "/DEV/fd/3",
+ "/Dev/null",
+ "/Proc/self/mem",
+ ] {
+ assert_eq!(
+ at_path(path).validate(),
+ Err("presented media path is a device or process path"),
+ "{path}"
+ );
+ }
+ // Only the first component decides, and only as a whole name.
+ for path in [
+ "/devices/clip.mp4",
+ "/dev.mp4",
+ "/developer/out/clip.mp4",
+ "/process/clip.mp4",
+ "/proc.mov",
+ "/work/dev/clip.mp4",
+ "/work/proc/clip.mp4",
+ "/Users/dev/proc/clip.mp4",
+ ] {
+ at_path(path)
+ .validate()
+ .unwrap_or_else(|error| panic!("{path}: {error}"));
+ }
+ }
+
+ #[test]
+ fn the_upgrade_band_sits_above_the_reference_and_below_the_reserved_top() {
+ assert_eq!(upgrade_mutation(0), REFERENCE_MUTATION + 1);
+ assert!(upgrade_mutation(31) < u32::MAX - 1);
+ assert_eq!(upgrade_mutation(31), u32::MAX - 2);
+ assert_eq!(FILES_PER_RECORD, 32);
+ }
+
+ #[test]
+ fn the_fact_round_trips_as_a_tagged_ingest_op() {
+ let op = IngestOp::PresentedMedia(fact("video/mp4"));
+ let value = serde_json::to_value(&op).unwrap();
+ assert_eq!(value["op"], "presented_media");
+ assert_eq!(value["path"], "/work/out/clip.mp4");
+ assert_eq!(value["expected_len"], 42);
+ let restored: IngestOp = serde_json::from_value(value).unwrap();
+ assert_eq!(restored, op);
+ assert_eq!(restored.named_session(), Some("s1"));
+ assert!(restored.approx_bytes() > 64);
+ }
+}
diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs
index 50749dfe..521650fd 100644
--- a/crates/media/src/lib.rs
+++ b/crates/media/src/lib.rs
@@ -8,6 +8,9 @@
//! cache. All three share one header stage and one encoder path, so a rendition
//! is byte-identical whichever made it.
//!
+//! [`probe_video`] classifies recorded MP4 and QuickTime video from its
+//! container header alone; no video is ever decoded.
+//!
//! @atlas layer kernels · domain media
mod animation;
@@ -16,6 +19,7 @@ mod pixels;
mod pool;
mod probe;
mod render;
+mod video;
use std::fmt;
use std::io::Cursor;
@@ -28,6 +32,7 @@ use rosetta_core::timeline::Dimensions;
pub use pool::{CodecPermit, Lane, admit, admitted_jobs, try_admit};
pub use probe::{Probe, probe};
+pub use video::{MP4_BRANDS, VideoProbe, probe_video, video_media_type};
pub const MAX_INPUT_BYTES: usize = 32 * 1024 * 1024;
pub const MAX_PIXELS: u64 = 32 * 1024 * 1024;
diff --git a/crates/media/src/tests.rs b/crates/media/src/tests.rs
index f1e3f5d1..f69d8953 100644
--- a/crates/media/src/tests.rs
+++ b/crates/media/src/tests.rs
@@ -6,6 +6,7 @@ use std::{io::Cursor, sync::Arc};
mod corpus;
mod parity;
mod pool;
+mod video;
fn png(width: u32, height: u32) -> Vec {
let image = RgbaImage::from_fn(width, height, |x, y| {
diff --git a/crates/media/src/tests/video.rs b/crates/media/src/tests/video.rs
new file mode 100644
index 00000000..aa2e061d
--- /dev/null
+++ b/crates/media/src/tests/video.rs
@@ -0,0 +1,748 @@
+//! The display size of recorded video, read from hand-assembled boxes. No
+//! real file, encoder or filesystem is involved.
+
+use rosetta_core::timeline::Dimensions;
+
+use crate::{MediaError, VideoProbe, probe_video, video_media_type};
+
+/// A box with a 32-bit size.
+fn boxed(kind: &[u8; 4], body: &[u8]) -> Vec {
+ let mut bytes = ((8 + body.len()) as u32).to_be_bytes().to_vec();
+ bytes.extend_from_slice(kind);
+ bytes.extend_from_slice(body);
+ bytes
+}
+
+/// A box whose 32-bit size is 1, with the real size in 64 bits after the type.
+fn large(kind: &[u8; 4], body: &[u8]) -> Vec {
+ let mut bytes = 1_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(kind);
+ bytes.extend_from_slice(&((16 + body.len()) as u64).to_be_bytes());
+ bytes.extend_from_slice(body);
+ bytes
+}
+
+/// A box whose size is 0: it runs to the end of whatever holds it.
+fn open(kind: &[u8; 4], body: &[u8]) -> Vec {
+ let mut bytes = 0_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(kind);
+ bytes.extend_from_slice(body);
+ bytes
+}
+
+/// A box header that claims `size` bytes, followed by `body`.
+fn claiming(size: u32, kind: &[u8; 4], body: &[u8]) -> Vec {
+ let mut bytes = size.to_be_bytes().to_vec();
+ bytes.extend_from_slice(kind);
+ bytes.extend_from_slice(body);
+ bytes
+}
+
+/// A 64-bit box header that claims `size` bytes, followed by `body`.
+fn claiming_large(size: u64, kind: &[u8; 4], body: &[u8]) -> Vec {
+ let mut bytes = 1_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(kind);
+ bytes.extend_from_slice(&size.to_be_bytes());
+ bytes.extend_from_slice(body);
+ bytes
+}
+
+fn join(parts: &[&[u8]]) -> Vec {
+ parts.concat()
+}
+
+fn ftyp(major: &[u8; 4], compatible: &[&[u8; 4]]) -> Vec {
+ let mut body = major.to_vec();
+ body.extend_from_slice(&[0, 0, 2, 0]);
+ for brand in compatible {
+ body.extend_from_slice(*brand);
+ }
+ boxed(b"ftyp", &body)
+}
+
+/// The `ftyp` box ffmpeg writes, as both local videos carry it.
+fn isom() -> Vec {
+ ftyp(b"isom", &[b"isom", b"iso2", b"avc1", b"mp41"])
+}
+
+/// Whole pixels as 16.16 fixed point.
+fn px(pixels: u32) -> u32 {
+ pixels << 16
+}
+
+/// One and minus one in the matrix's 16.16 fixed point.
+const ONE: u32 = 0x0001_0000;
+const NEG: u32 = 0xffff_0000;
+/// The matrix cells `[a, b, c, d]` of a track shown as stored, and of the
+/// three turns as ffmpeg 9 writes them for `-display_rotation 90`, `180` and
+/// `270` (read once from clips it made; it writes no translation).
+const UPRIGHT: [u32; 4] = [ONE, 0, 0, ONE];
+const TURN_90: [u32; 4] = [0, NEG, ONE, 0];
+const TURN_180: [u32; 4] = [NEG, 0, 0, NEG];
+const TURN_270: [u32; 4] = [0, ONE, NEG, 0];
+
+/// A `tkhd` body with an identity matrix.
+fn tkhd_body(version: u8, width: u32, height: u32) -> Vec {
+ tkhd_body_with(version, UPRIGHT, [0, 0], width, height)
+}
+
+/// A `tkhd` body whose matrix is `a b 0 / c d 0 / x y 1`. Every field before
+/// the size is non-zero where the format allows, so a read at the wrong
+/// offset cannot land on a plausible value.
+fn tkhd_body_with(
+ version: u8,
+ [a, b, c, d]: [u32; 4],
+ [x, y]: [u32; 2],
+ width: u32,
+ height: u32,
+) -> Vec {
+ let mut body = vec![version, 0, 0, 3];
+ if version == 1 {
+ body.extend_from_slice(&0x0000_0000_e1b2_c3d4_u64.to_be_bytes()); // creation
+ body.extend_from_slice(&0x0000_0000_e1b2_c3d5_u64.to_be_bytes()); // modification
+ body.extend_from_slice(&7_u32.to_be_bytes()); // track id
+ body.extend_from_slice(&[0; 4]);
+ body.extend_from_slice(&0x0000_0001_0002_0003_u64.to_be_bytes()); // duration
+ } else {
+ body.extend_from_slice(&0xe1b2_c3d4_u32.to_be_bytes());
+ body.extend_from_slice(&0xe1b2_c3d5_u32.to_be_bytes());
+ body.extend_from_slice(&7_u32.to_be_bytes());
+ body.extend_from_slice(&[0; 4]);
+ body.extend_from_slice(&0x0001_0203_u32.to_be_bytes());
+ }
+ body.extend_from_slice(&[0; 8]);
+ body.extend_from_slice(&[0, 0, 0, 0, 1, 0, 0, 0]); // layer, group, volume
+ for cell in [a, b, 0, c, d, 0, x, y, 0x4000_0000] {
+ body.extend_from_slice(&cell.to_be_bytes());
+ }
+ body.extend_from_slice(&width.to_be_bytes());
+ body.extend_from_slice(&height.to_be_bytes());
+ body
+}
+
+fn tkhd(version: u8, width: u32, height: u32) -> Vec {
+ boxed(b"tkhd", &tkhd_body(version, width, height))
+}
+
+/// A track: its header, then a media box the probe never opens.
+fn trak(header: &[u8]) -> Vec {
+ boxed(b"trak", &join(&[header, &boxed(b"mdia", &[0xab; 24])]))
+}
+
+fn video_track(version: u8, width: u32, height: u32) -> Vec {
+ trak(&tkhd(version, px(width), px(height)))
+}
+
+/// A video track stored `width` by `height` under this matrix.
+fn turned_track(version: u8, matrix: [u32; 4], width: u32, height: u32) -> Vec {
+ let body = tkhd_body_with(version, matrix, [0, 0], px(width), px(height));
+ trak(&boxed(b"tkhd", &body))
+}
+
+/// An audio track records no size.
+fn audio_track() -> Vec {
+ trak(&tkhd(0, 0, 0))
+}
+
+fn mvhd() -> Vec {
+ boxed(b"mvhd", &[0x11; 100])
+}
+
+fn moov(children: &[&[u8]]) -> Vec {
+ boxed(b"moov", &join(children))
+}
+
+fn size(width: u32, height: u32) -> Option {
+ Some(Dimensions { width, height })
+}
+
+/// The size of bytes that are a video, checked against the probe's two
+/// promises: the type is the type check's, and a size is never zero.
+fn probed(bytes: &[u8]) -> Option {
+ let probe = probe_video(bytes).expect("the bytes are a video");
+ assert_eq!(Some(probe.media_type), video_media_type(bytes));
+ if let Some(found) = probe.dimensions {
+ assert!(found.width > 0 && found.height > 0, "{found:?}");
+ }
+ probe.dimensions
+}
+
+#[test]
+fn the_size_is_read_when_moov_follows_ftyp() {
+ let video = join(&[
+ &isom(),
+ &moov(&[&mvhd(), &video_track(0, 1920, 1080)]),
+ &boxed(b"mdat", &[0xcd; 512]),
+ ]);
+ assert_eq!(
+ probe_video(&video),
+ Ok(VideoProbe {
+ media_type: "video/mp4",
+ dimensions: size(1920, 1080),
+ })
+ );
+}
+
+#[test]
+fn the_size_is_read_when_moov_follows_a_large_mdat() {
+ let video = join(&[
+ &isom(),
+ &boxed(b"free", &[]),
+ &boxed(b"mdat", &vec![0xcd; 1024 * 1024]),
+ &moov(&[&mvhd(), &video_track(0, 1280, 720)]),
+ ]);
+ assert_eq!(probed(&video), size(1280, 720));
+}
+
+#[test]
+fn a_64_bit_box_size_is_followed_at_every_depth() {
+ let track = video_track(0, 640, 360);
+ for (name, video) in [
+ (
+ "mdat before moov",
+ join(&[&isom(), &large(b"mdat", &[0xcd; 300]), &moov(&[&track])]),
+ ),
+ (
+ "moov",
+ join(&[&isom(), &large(b"moov", &join(&[&mvhd(), &track]))]),
+ ),
+ (
+ "trak",
+ join(&[
+ &isom(),
+ &moov(&[&large(b"trak", &tkhd(0, px(640), px(360)))]),
+ ]),
+ ),
+ (
+ "tkhd",
+ join(&[
+ &isom(),
+ &moov(&[&trak(&large(b"tkhd", &tkhd_body(0, px(640), px(360))))]),
+ ]),
+ ),
+ (
+ "a sibling before trak",
+ join(&[&isom(), &moov(&[&large(b"udta", &[0x22; 40]), &track])]),
+ ),
+ ] {
+ assert_eq!(probed(&video), size(640, 360), "{name}");
+ }
+}
+
+#[test]
+fn a_zero_size_box_runs_to_the_end_of_what_holds_it() {
+ let track = video_track(0, 1920, 1080);
+ // The last top-level box, holding the tracks.
+ let last_moov = join(&[
+ &isom(),
+ &boxed(b"mdat", &[0xcd; 64]),
+ &open(b"moov", &join(&[&mvhd(), &track])),
+ ]);
+ assert_eq!(probed(&last_moov), size(1920, 1080));
+ // The last top-level box, after the tracks.
+ let last_mdat = join(&[&isom(), &moov(&[&track]), &open(b"mdat", &[0xcd; 64])]);
+ assert_eq!(probed(&last_mdat), size(1920, 1080));
+ // It swallows everything after it, a moov included.
+ let swallowed = join(&[&isom(), &open(b"mdat", &moov(&[&track]))]);
+ assert_eq!(probed(&swallowed), None);
+ // Inside moov it ends with moov, not with the file: the track header is
+ // the open track's whole body, and the mdat after moov is not part of it.
+ let open_track = join(&[
+ &isom(),
+ &moov(&[&mvhd(), &open(b"trak", &tkhd(1, px(800), px(600)))]),
+ &boxed(b"mdat", &[0xcd; 64]),
+ ]);
+ assert_eq!(probed(&open_track), size(800, 600));
+ let open_header = join(&[
+ &isom(),
+ &moov(&[&boxed(
+ b"trak",
+ &open(b"tkhd", &tkhd_body(0, px(800), px(600))),
+ )]),
+ &boxed(b"mdat", &[0xcd; 64]),
+ ]);
+ assert_eq!(probed(&open_header), size(800, 600));
+ // An open box that is not the last one hides its later siblings.
+ let hidden = join(&[
+ &isom(),
+ &moov(&[&open(b"udta", &[0x22; 12]), &track]),
+ &boxed(b"mdat", &[0xcd; 64]),
+ ]);
+ assert_eq!(probed(&hidden), None);
+}
+
+#[test]
+fn a_quicktime_file_reports_its_size() {
+ let video = join(&[
+ &ftyp(b"qt ", &[b"qt "]),
+ &boxed(b"wide", &[]),
+ &boxed(b"mdat", &[0xcd; 128]),
+ &moov(&[&mvhd(), &video_track(0, 3024, 1964)]),
+ ]);
+ assert_eq!(
+ probe_video(&video),
+ Ok(VideoProbe {
+ media_type: "video/quicktime",
+ dimensions: size(3024, 1964),
+ })
+ );
+}
+
+#[test]
+fn both_track_header_versions_are_read() {
+ assert_eq!(tkhd_body(0, 0, 0).len(), 84);
+ assert_eq!(tkhd_body(1, 0, 0).len(), 96);
+ for (version, width, height) in [(0, 1920, 1080), (1, 1080, 1920), (0, 1, 1), (1, 65535, 2)] {
+ let video = join(&[&isom(), &moov(&[&video_track(version, width, height)])]);
+ assert_eq!(probed(&video), size(width, height), "version {version}");
+ }
+ // The fraction of a 16.16 value is dropped.
+ let fractional = join(&[
+ &isom(),
+ &moov(&[&trak(&tkhd(0, px(1920) | 0x8000, px(1080) | 0xffff))]),
+ ]);
+ assert_eq!(probed(&fractional), size(1920, 1080));
+}
+
+#[test]
+fn an_upright_or_half_turned_track_keeps_its_stored_size() {
+ for version in [0, 1] {
+ for (name, matrix) in [("upright", UPRIGHT), ("180 degrees", TURN_180)] {
+ let video = join(&[
+ &isom(),
+ &moov(&[&mvhd(), &turned_track(version, matrix, 1920, 1080)]),
+ ]);
+ assert_eq!(probed(&video), size(1920, 1080), "{name}, v{version}");
+ }
+ }
+ // A translation moves the picture and leaves its size alone: a half turn
+ // about the frame's centre carries one.
+ let about_the_centre = tkhd_body_with(0, TURN_180, [px(640), px(360)], px(640), px(360));
+ let video = join(&[&isom(), &moov(&[&trak(&boxed(b"tkhd", &about_the_centre))])]);
+ assert_eq!(probed(&video), size(640, 360));
+}
+
+#[test]
+fn a_quarter_turned_track_reports_its_size_as_displayed() {
+ for version in [0, 1] {
+ for (name, matrix) in [
+ ("90 degrees", TURN_90),
+ ("270 degrees", TURN_270),
+ // Not a pure turn, but rows still become columns.
+ ("a transpose", [0, ONE, ONE, 0]),
+ ("a turn and a scale", [0, 2 * ONE, NEG, 0]),
+ ("the smallest off-diagonal cells", [0, 1, 1, 0]),
+ ] {
+ let video = join(&[
+ &isom(),
+ &moov(&[&mvhd(), &turned_track(version, matrix, 1920, 1080)]),
+ ]);
+ assert_eq!(probed(&video), size(1080, 1920), "{name}, v{version}");
+ }
+ }
+ // A portrait phone recording as a camera app writes it: stored
+ // landscape, turned 90 degrees and moved back into view.
+ let phone = tkhd_body_with(0, [0, ONE, NEG, 0], [px(1080), 0], px(1920), px(1080));
+ let video = join(&[
+ &ftyp(b"qt ", &[b"qt "]),
+ &boxed(b"wide", &[]),
+ &boxed(b"mdat", &[0xcd; 64]),
+ &moov(&[&mvhd(), &audio_track(), &trak(&boxed(b"tkhd", &phone))]),
+ ]);
+ assert_eq!(
+ probe_video(&video),
+ Ok(VideoProbe {
+ media_type: "video/quicktime",
+ dimensions: size(1080, 1920),
+ })
+ );
+ // The fraction is dropped before the swap, and a side that is zero once
+ // dropped still means no size.
+ let fractional = tkhd_body_with(1, TURN_90, [0, 0], px(1920) | 0x8000, px(1080) | 0xffff);
+ let video = join(&[&isom(), &moov(&[&trak(&boxed(b"tkhd", &fractional))])]);
+ assert_eq!(probed(&video), size(1080, 1920));
+ let no_height = tkhd_body_with(0, TURN_90, [0, 0], px(1920), 0xffff);
+ let video = join(&[&isom(), &moov(&[&trak(&boxed(b"tkhd", &no_height))])]);
+ assert_eq!(probed(&video), None);
+}
+
+#[test]
+fn a_track_under_any_other_matrix_has_no_size() {
+ let cases: [(&str, [u32; 4]); 16] = [
+ ("a horizontal mirror", [NEG, 0, 0, ONE]),
+ ("a vertical mirror", [ONE, 0, 0, NEG]),
+ ("a scale by two", [2 * ONE, 0, 0, 2 * ONE]),
+ ("a scale by a half", [ONE / 2, 0, 0, ONE / 2]),
+ ("a stretch", [ONE, 0, 0, 2 * ONE]),
+ ("a hair off the identity", [ONE + 1, 0, 0, ONE]),
+ ("a hair off a half turn", [NEG, 0, 0, NEG + 1]),
+ ("a shear along x", [ONE, 0, ONE / 4, ONE]),
+ ("a shear along y", [ONE, ONE / 4, 0, ONE]),
+ ("a 45 degree turn", [0xb505, 0xb505, 0xffff_4afb, 0xb505]),
+ ("all zero", [0, 0, 0, 0]),
+ ("a quarter turn with no b", [0, 0, ONE, 0]),
+ ("a quarter turn with no c", [0, NEG, 0, 0]),
+ ("a quarter turn with a diagonal cell", [ONE, NEG, ONE, 0]),
+ (
+ "a quarter turn with the other diagonal cell",
+ [0, NEG, ONE, 1],
+ ),
+ ("every cell set", [ONE, ONE, ONE, ONE]),
+ ];
+ for version in [0, 1] {
+ for (name, matrix) in cases {
+ let odd = turned_track(version, matrix, 1920, 1080);
+ let alone = join(&[&isom(), &moov(&[&mvhd(), &odd])]);
+ assert_eq!(probed(&alone), None, "{name}, v{version}");
+ // The first sized track decides. A later one is not read in its
+ // place, or a thumbnail track's size would stand for the film's.
+ let followed = join(&[&isom(), &moov(&[&odd, &video_track(0, 1280, 720)])]);
+ assert_eq!(probed(&followed), None, "{name}, v{version}, then video");
+ // Acceptance never looks at the matrix.
+ assert_eq!(video_media_type(&alone), Some("video/mp4"), "{name}");
+ }
+ }
+ // A track with no size is still passed over, whatever its matrix says.
+ let unsized_odd = trak(&boxed(
+ b"tkhd",
+ &tkhd_body_with(0, [2 * ONE, 0, 0, 2 * ONE], [0, 0], 0, 0),
+ ));
+ let video = join(&[
+ &isom(),
+ &moov(&[&unsized_odd, &turned_track(0, TURN_270, 1280, 720)]),
+ ]);
+ assert_eq!(probed(&video), size(720, 1280));
+}
+
+#[test]
+fn a_track_with_no_size_before_the_video_track_is_passed_over() {
+ let video = join(&[
+ &isom(),
+ &moov(&[
+ &mvhd(),
+ &audio_track(),
+ &video_track(0, 1920, 1080),
+ &video_track(0, 320, 240),
+ ]),
+ ]);
+ assert_eq!(probed(&video), size(1920, 1080), "the first sized track");
+}
+
+#[test]
+fn a_file_with_no_video_track_has_no_size() {
+ let track = video_track(0, 1920, 1080);
+ let header = tkhd(0, px(1920), px(1080));
+ for (name, rest) in [
+ ("no moov", boxed(b"mdat", &[0xcd; 64])),
+ ("nothing after ftyp", Vec::new()),
+ ("an empty moov", moov(&[])),
+ ("a moov with no track", moov(&[&mvhd()])),
+ (
+ "audio tracks only",
+ moov(&[&mvhd(), &audio_track(), &audio_track()]),
+ ),
+ (
+ "a track with no header",
+ moov(&[&boxed(b"trak", &boxed(b"mdia", &[0xab; 24]))]),
+ ),
+ ("a header outside any track", moov(&[&mvhd(), &header])),
+ ("a track outside moov", track.clone()),
+ (
+ "a track below moov's children",
+ moov(&[&boxed(b"udta", &track)]),
+ ),
+ (
+ "a header below a track's children",
+ moov(&[&boxed(b"trak", &boxed(b"mdia", &header))]),
+ ),
+ // The first moov decides; a file has one.
+ (
+ "a sized track in a second moov",
+ join(&[&moov(&[&audio_track()]), &moov(&[&track])]),
+ ),
+ ] {
+ assert_eq!(probed(&join(&[&isom(), &rest])), None, "{name}");
+ }
+}
+
+#[test]
+fn a_track_header_that_proves_no_size_is_never_reported() {
+ let mut second_version = tkhd_body(0, px(1920), px(1080));
+ second_version[0] = 2;
+ let mut mislabeled = tkhd_body(0, px(1920), px(1080));
+ mislabeled[0] = 1;
+ let mut padded = tkhd_body(0, px(1920), px(1080));
+ padded.extend_from_slice(&[0; 4]);
+ let short = tkhd_body(1, px(1920), px(1080))[..92].to_vec();
+ let headers: Vec<(&str, Vec)> = vec![
+ ("zero width and height", tkhd(0, 0, 0)),
+ ("zero width", tkhd(0, 0, px(1080))),
+ ("zero height", tkhd(1, px(1920), 0)),
+ ("a width below one pixel", tkhd(0, 0x8000, px(1080))),
+ ("a height below one pixel", tkhd(1, px(1920), 0xffff)),
+ ("an empty header", boxed(b"tkhd", &[])),
+ ("version and flags only", boxed(b"tkhd", &[0, 0, 0, 3])),
+ (
+ "eight bytes that read as a size",
+ boxed(
+ b"tkhd",
+ &join(&[&px(240).to_be_bytes(), &px(160).to_be_bytes()]),
+ ),
+ ),
+ ("an unknown version", boxed(b"tkhd", &second_version)),
+ (
+ "version 1 with the version 0 length",
+ boxed(b"tkhd", &mislabeled),
+ ),
+ ("bytes after the height", boxed(b"tkhd", &padded)),
+ ("a header cut short", boxed(b"tkhd", &short)),
+ ];
+ for (name, header) in &headers {
+ let alone = join(&[&isom(), &moov(&[&mvhd(), &trak(header)])]);
+ assert_eq!(probed(&alone), None, "{name}");
+ // Such a track does not hide a sized one after it.
+ let followed = join(&[&isom(), &moov(&[&trak(header), &video_track(0, 1280, 720)])]);
+ assert_eq!(probed(&followed), size(1280, 720), "{name}, then video");
+ }
+}
+
+#[test]
+fn every_prefix_of_a_video_is_probed_without_a_panic() {
+ let head = isom();
+ let movie = moov(&[&mvhd(), &audio_track(), &video_track(1, 1920, 1080)]);
+ let video = join(&[&head, &movie, &boxed(b"mdat", &[0xcd; 64])]);
+ for end in 0..=video.len() {
+ let prefix = &video[..end];
+ match video_media_type(prefix) {
+ None => {
+ assert!(end < head.len(), "{end} bytes");
+ assert_eq!(probe_video(prefix), Err(MediaError::Unsupported));
+ }
+ Some(media_type) => {
+ // The size appears with the last byte of moov, never before.
+ let whole_moov = end >= head.len() + movie.len();
+ let dimensions = whole_moov.then_some(Dimensions {
+ width: 1920,
+ height: 1080,
+ });
+ assert_eq!(
+ probe_video(prefix),
+ Ok(VideoProbe {
+ media_type,
+ dimensions
+ }),
+ "{end} bytes"
+ );
+ }
+ }
+ }
+
+ // The same with moov last and open-ended: a cut can only lose the size.
+ let open_ended = join(&[
+ &head,
+ &large(b"mdat", &[0xcd; 64]),
+ &open(
+ b"moov",
+ &join(&[&mvhd(), &audio_track(), &video_track(0, 1920, 1080)]),
+ ),
+ ]);
+ for end in head.len()..open_ended.len() {
+ assert_eq!(probed(&open_ended[..end]), None, "{end} bytes");
+ }
+ assert_eq!(probed(&open_ended), size(1920, 1080));
+}
+
+#[test]
+fn every_one_byte_change_to_a_video_is_probed_without_a_panic() {
+ let video = join(&[
+ &isom(),
+ &large(b"free", &[0; 4]),
+ &moov(&[&mvhd(), &audio_track(), &video_track(1, 1920, 1080)]),
+ &open(b"mdat", &[0xcd; 16]),
+ ]);
+ assert_eq!(probed(&video), size(1920, 1080));
+ let mut bytes = video.clone();
+ for at in 0..video.len() {
+ for value in 0..=u8::MAX {
+ bytes[at] = value;
+ match probe_video(&bytes) {
+ Ok(probe) => {
+ assert_eq!(Some(probe.media_type), video_media_type(&bytes));
+ if let Some(found) = probe.dimensions {
+ assert!(
+ found.width > 0 && found.height > 0,
+ "byte {at} = {value}: {found:?}"
+ );
+ }
+ }
+ Err(error) => {
+ assert_eq!(error, MediaError::Unsupported);
+ assert_eq!(video_media_type(&bytes), None, "byte {at} = {value}");
+ }
+ }
+ }
+ bytes[at] = video[at];
+ }
+}
+
+#[test]
+fn a_box_size_that_overflows_or_lies_ends_the_walk() {
+ let track = video_track(0, 1920, 1080);
+ let movie = moov(&[&track]);
+ let head = isom();
+ let at = head.len() as u64;
+ let cases: Vec<(String, Vec)> = [
+ ("the largest 64-bit size", u64::MAX),
+ (
+ "a 64-bit size that wraps to the next box",
+ u64::MAX - at + 17,
+ ),
+ ("a 64-bit size that ends at the last offset", u64::MAX - at),
+ ("a 64-bit size past the slice", 1 << 40),
+ (
+ "a 64-bit size one byte past the slice",
+ 16 + 8 + movie.len() as u64 + 1,
+ ),
+ ]
+ .into_iter()
+ .map(|(name, claimed)| (name.to_string(), claiming_large(claimed, b"free", &[0; 8])))
+ .chain((0..16).map(|claimed| {
+ (
+ format!("a 64-bit size of {claimed}, inside its own header"),
+ claiming_large(claimed, b"free", &[0; 8]),
+ )
+ }))
+ .chain((2..8).map(|claimed| {
+ (
+ format!("a 32-bit size of {claimed}, inside its own header"),
+ claiming(claimed, b"free", &[0; 8]),
+ )
+ }))
+ .chain([
+ (
+ "the largest 32-bit size".to_string(),
+ claiming(u32::MAX, b"free", &[0; 8]),
+ ),
+ (
+ "a 32-bit size one byte past the slice".to_string(),
+ claiming(8 + 8 + movie.len() as u32 + 1, b"free", &[0; 8]),
+ ),
+ ])
+ .collect();
+ for (name, bad) in &cases {
+ // The moov after the bad box is whole, and is not reached.
+ let video = join(&[&head, bad, &movie]);
+ assert_eq!(probed(&video), None, "{name}");
+ }
+ // A box that fits exactly is still followed.
+ let exact = join(&[&head, &claiming_large(24, b"free", &[0; 8]), &movie]);
+ assert_eq!(probed(&exact), size(1920, 1080));
+
+ // The same inside moov and inside a track: a child may not run past its
+ // parent, even when the bytes it claims exist in the file.
+ let tail = boxed(b"mdat", &[0xcd; 256]);
+ let header = tkhd(0, px(1920), px(1080));
+ for (name, children) in [
+ (
+ "a moov child one byte past moov",
+ join(&[
+ &claiming(8 + 4 + track.len() as u32 + 1, b"udta", &[0; 4]),
+ &track,
+ ]),
+ ),
+ (
+ "a moov child with the largest 64-bit size",
+ join(&[&claiming_large(u64::MAX, b"udta", &[0; 4]), &track]),
+ ),
+ (
+ "a moov child smaller than its header",
+ join(&[&claiming(4, b"udta", &[0; 4]), &track]),
+ ),
+ (
+ "a track one byte past moov",
+ claiming(8 + header.len() as u32 + 1, b"trak", &header),
+ ),
+ (
+ "a track header one byte past its track",
+ boxed(
+ b"trak",
+ &claiming(8 + 84 + 1, b"tkhd", &tkhd_body(0, px(1920), px(1080))),
+ ),
+ ),
+ (
+ "a track child with a size that wraps",
+ boxed(
+ b"trak",
+ &join(&[&claiming_large(u64::MAX - 8, b"edts", &[0; 4]), &header]),
+ ),
+ ),
+ ] {
+ let video = join(&[&head, &boxed(b"moov", &children), &tail]);
+ assert_eq!(probed(&video), None, "{name}");
+ }
+}
+
+#[test]
+fn the_walk_stops_at_its_box_count() {
+ let track = video_track(0, 1920, 1080);
+ let filler = boxed(b"free", &[]);
+ let fill = |count: usize| filler.repeat(count);
+
+ // 64 top-level boxes, ftyp included.
+ let at_the_cap = join(&[&isom(), &fill(62), &moov(&[&track])]);
+ assert_eq!(probed(&at_the_cap), size(1920, 1080));
+ let past_the_cap = join(&[&isom(), &fill(63), &moov(&[&track])]);
+ assert_eq!(probed(&past_the_cap), None);
+
+ // 256 children of moov.
+ let at_the_cap = join(&[&isom(), &moov(&[&fill(255), &track])]);
+ assert_eq!(probed(&at_the_cap), size(1920, 1080));
+ let past_the_cap = join(&[&isom(), &moov(&[&fill(256), &track])]);
+ assert_eq!(probed(&past_the_cap), None);
+ // Tracks with no size count against it like any other child.
+ let audio = audio_track();
+ let many_tracks = join(&[&isom(), &moov(&[&audio.repeat(255), &track])]);
+ assert_eq!(probed(&many_tracks), size(1920, 1080));
+ let too_many_tracks = join(&[&isom(), &moov(&[&audio.repeat(256), &track])]);
+ assert_eq!(probed(&too_many_tracks), None);
+
+ // 64 children of one track; the next track starts a fresh count.
+ let header = tkhd(0, px(1920), px(1080));
+ let deep = boxed(b"trak", &join(&[&fill(63), &header]));
+ assert_eq!(probed(&join(&[&isom(), &moov(&[&deep])])), size(1920, 1080));
+ let too_deep = boxed(b"trak", &join(&[&fill(64), &header]));
+ assert_eq!(probed(&join(&[&isom(), &moov(&[&too_deep])])), None);
+ let next = video_track(0, 1280, 720);
+ assert_eq!(
+ probed(&join(&[&isom(), &moov(&[&too_deep, &next])])),
+ size(1280, 720)
+ );
+}
+
+#[test]
+fn input_that_is_not_a_video_is_unsupported_whatever_follows() {
+ let movie = moov(&[&mvhd(), &video_track(0, 1920, 1080)]);
+ let mut webm = vec![0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01];
+ webm.extend_from_slice(b"\x42\x82\x84webm");
+ for (name, bytes) in [
+ ("empty", Vec::new()),
+ ("png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec()),
+ ("webm", webm),
+ ("text", b"not a video".to_vec()),
+ ("moov with no ftyp", movie.clone()),
+ (
+ "a mov with no ftyp",
+ join(&[&boxed(b"wide", &[]), &boxed(b"mdat", &[0xcd; 8]), &movie]),
+ ),
+ ("heic", join(&[&ftyp(b"heic", &[b"mif1", b"heic"]), &movie])),
+ ("m4a", join(&[&ftyp(b"M4A ", &[b"M4A "]), &movie])),
+ (
+ "ftyp after another box",
+ join(&[&boxed(b"free", &[]), &isom(), &movie]),
+ ),
+ ("a cut ftyp", isom()[..20].to_vec()),
+ ] {
+ assert_eq!(video_media_type(&bytes), None, "{name}");
+ assert_eq!(probe_video(&bytes), Err(MediaError::Unsupported), "{name}");
+ }
+}
diff --git a/crates/media/src/video.rs b/crates/media/src/video.rs
new file mode 100644
index 00000000..25ab3a0b
--- /dev/null
+++ b/crates/media/src/video.rs
@@ -0,0 +1,405 @@
+//! Recorded video, classified from its container header alone. No codec, no
+//! decode, no filesystem and no allocation: every read is a checked slice of
+//! the caller's bytes.
+//!
+//! Acceptance is the `ftyp` box an ISO base media file starts with. A major
+//! brand of `qt ` is QuickTime; otherwise a major or compatible brand in
+//! [`MP4_BRANDS`] is MP4. Anything else is not a video here: HEIC and AVIF
+//! (image brands), WebM (no `ftyp`), a `.mov` written without one. The brand
+//! list cannot tell an MP4 that carries only audio from one with a picture:
+//! an `M4A ` file that lists `mp42` among its compatible brands reads as MP4.
+//! Callers accept a file only when this type equals the one it was declared
+//! with, which is what keeps audio declared as audio out.
+//!
+//! The display size is a layout hint, never part of acceptance. It comes
+//! from the first track header (`moov` > `trak` > `tkhd`) that records a
+//! width and a height above zero, found by a bounded walk of box headers,
+//! and is read through that header's matrix: unrotated or turned 180 degrees
+//! it is width by height, turned 90 or 270 degrees it is height by width,
+//! and under any other matrix (a scale, a mirror, a skew) there is no size,
+//! because the header alone does not prove what a player would show. A file
+//! whose `moov` is cut, missing or malformed still classifies, with no size.
+//! Sample descriptions and edit lists are not read.
+
+use rosetta_core::timeline::Dimensions;
+
+use crate::MediaError;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct VideoProbe {
+ pub media_type: &'static str,
+ /// The display size, when the header proves one.
+ pub dimensions: Option,
+}
+
+/// Brands that mark an ISO base media file as MP4 video.
+pub const MP4_BRANDS: [[u8; 4]; 10] = [
+ *b"isom", *b"iso2", *b"iso3", *b"iso4", *b"iso5", *b"iso6", *b"mp41", *b"mp42", *b"avc1",
+ *b"M4V ",
+];
+
+/// Smallest `ftyp` box: size, type, major brand and minor version.
+const FTYP_MIN_BYTES: usize = 16;
+/// Largest `ftyp` box read; real ones list a handful of brands.
+const FTYP_MAX_BYTES: usize = 4096;
+
+/// Top-level boxes read while looking for `moov`.
+const TOP_LEVEL_BOXES: usize = 64;
+/// Children of `moov` read while looking for tracks.
+const MOVIE_BOXES: usize = 256;
+/// Children of one `trak` read while looking for its `tkhd`.
+const TRACK_BOXES: usize = 64;
+/// A version 0 `tkhd` body: version and flags, 20 bytes of times and ids,
+/// then 60 bytes that end with width and height.
+const TKHD_V0_BYTES: u64 = 84;
+/// A version 1 `tkhd` body: the same, with 64-bit times and duration.
+const TKHD_V1_BYTES: u64 = 96;
+/// One in the matrix's 16.16 fixed point, and minus one.
+const ONE: u32 = 0x0001_0000;
+const MINUS_ONE: u32 = 0xffff_0000;
+
+/// The container type the leading `ftyp` box declares, or `None` when the
+/// bytes are not a video this crate accepts.
+pub fn video_media_type(bytes: &[u8]) -> Option<&'static str> {
+ let size = u32::from_be_bytes(bytes.get(..4)?.try_into().ok()?) as usize;
+ if bytes.get(4..8)? != b"ftyp" {
+ return None;
+ }
+ if !(FTYP_MIN_BYTES..=FTYP_MAX_BYTES).contains(&size)
+ || !size.is_multiple_of(4)
+ || size > bytes.len()
+ {
+ return None;
+ }
+ let major = bytes.get(8..12)?;
+ if major == b"qt " {
+ return Some("video/quicktime");
+ }
+ let is_mp4 = |brand: &[u8]| MP4_BRANDS.iter().any(|known| known == brand);
+ if is_mp4(major) || bytes.get(16..size)?.chunks_exact(4).any(is_mp4) {
+ return Some("video/mp4");
+ }
+ None
+}
+
+/// Classify recorded video bytes. Accepts exactly what [`video_media_type`]
+/// accepts. The size is present only when a track header proves one, and
+/// neither its width nor its height is ever zero.
+pub fn probe_video(bytes: &[u8]) -> Result {
+ let media_type = video_media_type(bytes).ok_or(MediaError::Unsupported)?;
+ Ok(VideoProbe {
+ media_type,
+ dimensions: track_dimensions(bytes),
+ })
+}
+
+/// The display size of the first track of the first `moov` whose header
+/// records a width and a height above zero. An audio track records zero for
+/// both and is passed over. The first sized track decides: when its matrix
+/// proves no display size, a later track is not read in its place.
+fn track_dimensions(bytes: &[u8]) -> Option {
+ let file = Span {
+ start: 0,
+ end: bytes.len() as u64,
+ };
+ let movie = Boxes::new(bytes, file, TOP_LEVEL_BOXES).find(|found| &found.kind == b"moov")?;
+ Boxes::new(bytes, movie.body, MOVIE_BOXES)
+ .filter(|found| &found.kind == b"trak")
+ .find_map(|track| {
+ let header =
+ Boxes::new(bytes, track.body, TRACK_BOXES).find(|found| &found.kind == b"tkhd")?;
+ header_dimensions(bytes, header.body)
+ })
+ .flatten()
+}
+
+/// Width and height are the final eight bytes of a `tkhd` body, each 16.16
+/// fixed point, and the 36 bytes before them are the track's matrix; the two
+/// versions differ only in the time fields before that. A body that is not
+/// exactly its version's layout is not read, and neither is a value whose
+/// whole part is zero: both are `None`, a track with no size.
+///
+/// A sized track answers `Some`, holding the size as displayed or `None`
+/// when its matrix is not one whose effect on the size is known.
+fn header_dimensions(bytes: &[u8], body: Span) -> Option> {
+ let layout = match *slice(bytes, body.start, 1)?.first()? {
+ 0 => TKHD_V0_BYTES,
+ 1 => TKHD_V1_BYTES,
+ _ => return None,
+ };
+ if body.end.checked_sub(body.start)? != layout {
+ return None;
+ }
+ let width = be_u32(bytes, body.end.checked_sub(8)?)? >> 16;
+ let height = be_u32(bytes, body.end.checked_sub(4)?)? >> 16;
+ if width == 0 || height == 0 {
+ return None;
+ }
+ // The matrix is nine cells, row by row: `a b u`, `c d v`, `x y w`. Only
+ // the four that turn and scale the picture matter to its size.
+ let cell = |index: u64| be_u32(bytes, body.end.checked_sub(44)?.checked_add(4 * index)?);
+ let (a, b, c, d) = (cell(0)?, cell(1)?, cell(3)?, cell(4)?);
+ Some(match (a, b, c, d) {
+ // As stored, or turned 180 degrees.
+ (ONE, 0, 0, ONE) | (MINUS_ONE, 0, 0, MINUS_ONE) => Some(Dimensions { width, height }),
+ // Turned 90 or 270 degrees: the stored rows become columns.
+ (0, b, c, 0) if b != 0 && c != 0 => Some(Dimensions {
+ width: height,
+ height: width,
+ }),
+ _ => None,
+ })
+}
+
+/// Byte offsets into the probed slice; `start <= end`.
+#[derive(Clone, Copy)]
+struct Span {
+ start: u64,
+ end: u64,
+}
+
+struct BoxHeader {
+ kind: [u8; 4],
+ /// The box's bytes after its size and type, wholly inside its parent.
+ body: Span,
+}
+
+/// The boxes laid end to end in one span, at most `left` of them. The walk
+/// ends for good at the first box that is malformed or runs past the span.
+struct Boxes<'a> {
+ bytes: &'a [u8],
+ at: u64,
+ end: u64,
+ left: usize,
+}
+
+impl<'a> Boxes<'a> {
+ fn new(bytes: &'a [u8], span: Span, limit: usize) -> Self {
+ Self {
+ bytes,
+ at: span.start,
+ end: span.end,
+ left: limit,
+ }
+ }
+
+ fn read(&mut self) -> Option {
+ self.left = self.left.checked_sub(1)?;
+ if self.end.checked_sub(self.at)? < 8 {
+ return None;
+ }
+ let kind: [u8; 4] = slice(self.bytes, self.at.checked_add(4)?, 4)?
+ .try_into()
+ .ok()?;
+ // A size of 1 names a 64-bit size after the type; 0 runs to the end
+ // of the enclosing span.
+ let (header, size) = match be_u32(self.bytes, self.at)? {
+ 0 => (8, self.end.checked_sub(self.at)?),
+ 1 => (16, be_u64(self.bytes, self.at.checked_add(8)?)?),
+ size => (8, u64::from(size)),
+ };
+ let start = self.at.checked_add(header)?;
+ let end = self.at.checked_add(size)?;
+ if start > end || end > self.end {
+ return None;
+ }
+ self.at = end;
+ Some(BoxHeader {
+ kind,
+ body: Span { start, end },
+ })
+ }
+}
+
+impl Iterator for Boxes<'_> {
+ type Item = BoxHeader;
+
+ fn next(&mut self) -> Option {
+ let found = self.read();
+ if found.is_none() {
+ self.left = 0;
+ }
+ found
+ }
+}
+
+fn slice(bytes: &[u8], at: u64, len: usize) -> Option<&[u8]> {
+ let start = usize::try_from(at).ok()?;
+ bytes.get(start..start.checked_add(len)?)
+}
+
+fn be_u32(bytes: &[u8], at: u64) -> Option {
+ Some(u32::from_be_bytes(slice(bytes, at, 4)?.try_into().ok()?))
+}
+
+fn be_u64(bytes: &[u8], at: u64) -> Option {
+ Some(u64::from_be_bytes(slice(bytes, at, 8)?.try_into().ok()?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// An `ftyp` box with these brands, followed by `rest`.
+ fn ftyp(major: &[u8; 4], compatible: &[&[u8; 4]], rest: &[u8]) -> Vec {
+ let size = 16 + 4 * compatible.len();
+ let mut bytes = (size as u32).to_be_bytes().to_vec();
+ bytes.extend_from_slice(b"ftyp");
+ bytes.extend_from_slice(major);
+ bytes.extend_from_slice(&[0, 0, 2, 0]);
+ for brand in compatible {
+ bytes.extend_from_slice(*brand);
+ }
+ bytes.extend_from_slice(rest);
+ bytes
+ }
+
+ fn cases() -> Vec<(&'static str, Vec, Option<&'static str>)> {
+ let moov = b"\0\0\0\x08moov";
+ let mut not_a_multiple = ftyp(b"isom", &[b"iso2"], moov);
+ not_a_multiple[..4].copy_from_slice(&18_u32.to_be_bytes());
+ let mut past_the_slice = ftyp(b"isom", &[b"iso2"], b"");
+ past_the_slice[..4].copy_from_slice(&24_u32.to_be_bytes());
+ let mut too_small = ftyp(b"isom", &[], moov);
+ too_small[..4].copy_from_slice(&12_u32.to_be_bytes());
+ let mut too_large = ftyp(b"isom", &[], &[0; 8192]);
+ too_large[..4].copy_from_slice(&4100_u32.to_be_bytes());
+ let mut largest = ftyp(b"heic", &[], &[0; 4096]);
+ largest[..4].copy_from_slice(&4096_u32.to_be_bytes());
+ largest[4092..4096].copy_from_slice(b"mp42");
+ let mut extended_size = ftyp(b"isom", &[b"iso2"], moov);
+ extended_size[..4].copy_from_slice(&1_u32.to_be_bytes());
+ let mut not_first = b"\0\0\0\x08free".to_vec();
+ not_first.extend_from_slice(&ftyp(b"isom", &[b"iso2"], moov));
+ let mut webm = vec![0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01];
+ webm.extend_from_slice(b"\x42\x82\x84webm");
+ vec![
+ (
+ "isom, as ffmpeg writes it",
+ ftyp(b"isom", &[b"isom", b"iso2", b"avc1", b"mp41"], moov),
+ Some("video/mp4"),
+ ),
+ (
+ "mp42 major",
+ ftyp(b"mp42", &[b"mp42", b"isom"], moov),
+ Some("video/mp4"),
+ ),
+ (
+ "mp42 major with no compatible brands",
+ ftyp(b"mp42", &[], b""),
+ Some("video/mp4"),
+ ),
+ (
+ "M4V major",
+ ftyp(b"M4V ", &[b"M4V ", b"M4A ", b"mp42", b"isom"], moov),
+ Some("video/mp4"),
+ ),
+ (
+ "compatible-only match",
+ ftyp(b"3gp4", &[b"3gp4", b"isom"], moov),
+ Some("video/mp4"),
+ ),
+ (
+ "compatible match in the last brand of the largest box",
+ largest,
+ Some("video/mp4"),
+ ),
+ (
+ "quicktime",
+ ftyp(b"qt ", &[b"qt "], b"\0\0\0\x08wide"),
+ Some("video/quicktime"),
+ ),
+ (
+ "quicktime wins over an MP4 compatible brand",
+ ftyp(b"qt ", &[b"isom"], moov),
+ Some("video/quicktime"),
+ ),
+ ("heic", ftyp(b"heic", &[b"mif1", b"heic"], moov), None),
+ (
+ "avif",
+ ftyp(b"avif", &[b"avif", b"mif1", b"miaf", b"MA1B"], moov),
+ None,
+ ),
+ // An M4A whose brands name no MP4 brand. One that lists `mp42`
+ // or `isom` is indistinguishable by brand; see the module doc.
+ ("m4a", ftyp(b"M4A ", &[b"M4A "], moov), None),
+ ("webm", webm, None),
+ (
+ "a mov with no ftyp",
+ b"\0\0\0\x08wide\0\0\0\x08mdat".to_vec(),
+ None,
+ ),
+ (
+ "a quicktime brand compatible only",
+ ftyp(b"mqt ", &[b"qt "], moov),
+ None,
+ ),
+ ("empty", Vec::new(), None),
+ ("three bytes", b"\0\0\0".to_vec(), None),
+ ("a size and nothing else", b"\0\0\0\x10".to_vec(), None),
+ (
+ "a header cut before the major brand",
+ b"\0\0\0\x10ftyp".to_vec(),
+ None,
+ ),
+ (
+ "a header cut inside the minor version",
+ b"\0\0\0\x10ftypisom\0\0".to_vec(),
+ None,
+ ),
+ ("size not a multiple of 4", not_a_multiple, None),
+ ("size past the slice", past_the_slice, None),
+ ("size below the smallest box", too_small, None),
+ ("size above the largest box read", too_large, None),
+ ("a 64-bit box size", extended_size, None),
+ ("ftyp is not the first box", not_first, None),
+ ("png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(), None),
+ ]
+ }
+
+ #[test]
+ fn the_container_type_is_read_from_the_leading_ftyp_box() {
+ for (name, bytes, expected) in cases() {
+ assert_eq!(video_media_type(&bytes), expected, "{name}");
+ }
+ }
+
+ #[test]
+ fn the_probe_accepts_exactly_what_the_type_check_accepts() {
+ for (name, bytes, expected) in cases() {
+ match expected {
+ Some(media_type) => assert_eq!(
+ probe_video(&bytes),
+ Ok(VideoProbe {
+ media_type,
+ dimensions: None
+ }),
+ "{name}"
+ ),
+ None => assert_eq!(probe_video(&bytes), Err(MediaError::Unsupported), "{name}"),
+ }
+ }
+ }
+
+ #[test]
+ fn every_prefix_of_a_video_is_classified_without_a_panic() {
+ let video = ftyp(
+ b"isom",
+ &[b"isom", b"iso2", b"avc1", b"mp41"],
+ b"\0\0\0\x08moov",
+ );
+ for end in 0..=video.len() {
+ let expected = (end >= 32).then_some("video/mp4");
+ assert_eq!(video_media_type(&video[..end]), expected, "{end} bytes");
+ }
+ // Every declared size over a short buffer is safe too.
+ let mut bytes = video;
+ for size in 0..=64_u32 {
+ bytes[..4].copy_from_slice(&size.to_be_bytes());
+ let _ = video_media_type(&bytes);
+ let _ = probe_video(&bytes);
+ }
+ bytes[..4].copy_from_slice(&u32::MAX.to_be_bytes());
+ assert_eq!(video_media_type(&bytes), None);
+ }
+}
diff --git a/crates/pipeline/src/capture/pool/tests.rs b/crates/pipeline/src/capture/pool/tests.rs
index e2f4fc8a..da1c8ed4 100644
--- a/crates/pipeline/src/capture/pool/tests.rs
+++ b/crates/pipeline/src/capture/pool/tests.rs
@@ -605,3 +605,368 @@ fn a_span_without_pool_units_registers_nothing() {
assert!(registered.is_none());
assert_eq!(pool.stats().runtimes, 0);
}
+
+/// An agent whose every record lists files it sent the user: the fact an
+/// adapter emits, with no tool row, since no store is involved here.
+struct SendsFiles;
+
+impl rosetta_adapter_support::AgentAdapter for SendsFiles {
+ fn kind(&self) -> &'static str {
+ "sends-files"
+ }
+
+ fn layout(&self) -> &rosetta_adapter_support::GroundTruthDoc {
+ ClaudeCodeAdapter.layout()
+ }
+
+ fn stream_driver(&self, _stream_id: &str) -> Option {
+ Some(StreamDriver::JsonLines)
+ }
+
+ fn normalizer_version(&self) -> u32 {
+ 1
+ }
+
+ fn skim(
+ &self,
+ _stream_id: &str,
+ _stat: &rosetta_adapter_support::FileStat,
+ _head: &[u8],
+ _tail: &[u8],
+ ) -> rosetta_adapter_support::SkimResult {
+ rosetta_adapter_support::SkimResult::default()
+ }
+
+ fn ingest_line(
+ &self,
+ ctx: &mut rosetta_adapter_support::NormalizeCtx,
+ _stream_id: &str,
+ line: &[u8],
+ ) -> rosetta_adapter_support::LineOutcome {
+ use rosetta_adapter_support::presented_media::{PresentedFile, push};
+ let record: serde_json::Value = serde_json::from_slice(line).unwrap_or_default();
+ let listed: Vec<(String, u64)> = record["files"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .map(|file| {
+ (
+ file["path"].as_str().unwrap_or_default().to_owned(),
+ file["size"].as_u64().unwrap_or_default(),
+ )
+ })
+ .collect();
+ let files: Vec> = listed
+ .iter()
+ .map(|(path, len)| PresentedFile {
+ path,
+ cwd: None,
+ len: *len,
+ media_type: "video/mp4",
+ })
+ .collect();
+ let occurrence = format!("record-{}", ctx.src_offset.unwrap_or_default());
+ let mut ops = Vec::new();
+ let facts = push(ctx, "call", &occurrence, &files, None, &mut ops);
+ ops.extend(
+ facts
+ .into_iter()
+ .map(rosetta_core::IngestOp::PresentedMedia),
+ );
+ rosetta_adapter_support::LineOutcome::parsed(ops)
+ }
+}
+
+const SENT_BYTES: u64 = 8 * MIB;
+const SENT_FILES: usize = 6;
+
+/// A root with `SENT_FILES` sparse 8 MiB videos and `sources` transcripts,
+/// each sending every one of them from its own record: one tail batch.
+fn sending_home(sources: usize) -> (tempfile::TempDir, Vec) {
+ let home = tempfile::TempDir::new().unwrap();
+ let mut records = String::new();
+ for index in 0..SENT_FILES {
+ let path = home.path().join(format!("clip-{index}.mp4"));
+ let mut header = 20_u32.to_be_bytes().to_vec();
+ header.extend_from_slice(b"ftypisom");
+ header.extend_from_slice(&[0, 0, 2, 0]);
+ header.extend_from_slice(b"mp41");
+ header.extend_from_slice(&u32::try_from(SENT_BYTES - 20).unwrap().to_be_bytes());
+ header.extend_from_slice(b"free");
+ std::fs::write(&path, header).unwrap();
+ std::fs::OpenOptions::new()
+ .write(true)
+ .open(&path)
+ .unwrap()
+ .set_len(SENT_BYTES)
+ .unwrap();
+ records.push_str(&format!(
+ "{}\n",
+ serde_json::json!({"files":[{"path":path,"size":SENT_BYTES}]})
+ ));
+ }
+ std::fs::create_dir(home.path().join("a")).unwrap();
+ let files = (0..sources)
+ .map(|index| {
+ let relative = format!("a/{index}.jsonl");
+ std::fs::write(home.path().join(&relative), &records).unwrap();
+ file(&relative, records.len() as u64)
+ })
+ .collect();
+ (home, files)
+}
+
+fn fresh_cursor(index: usize, file: &SessionFile) -> CursorState {
+ CursorState {
+ id: index as i64 + 1,
+ checkpoint: String::new(),
+ active_generation: 0,
+ highest_generation: 0,
+ session_id: file.projected_session_id(),
+ }
+}
+
+/// The captures a batch's ops show: its upgrades to an asset.
+fn captures(batch: &Batch) -> usize {
+ batch
+ .ops
+ .iter()
+ .filter(|op| match op {
+ rosetta_core::IngestOp::ToolUpdate { patch, .. } => {
+ patch.attachments.iter().any(|attachment| {
+ matches!(
+ attachment.source,
+ rosetta_core::timeline::AttachmentSource::Asset { .. }
+ )
+ })
+ }
+ _ => false,
+ })
+ .count()
+}
+
+/// Step one source to its end on this thread, as the serial driver does.
+fn step_serially(
+ root: &Path,
+ index: usize,
+ file: &SessionFile,
+) -> Vec> {
+ use crate::stream::{PassMode, PassStep};
+ let opts = IngestOptions::default();
+ let tailer = Tailer::new(rosetta_capture::TailConfig::json_lines()).unwrap();
+ let runtime = runtime_row();
+ let instrument = Instrument::new();
+ let mut pass = IngestPass::begin(
+ &SendsFiles,
+ &runtime,
+ root,
+ file,
+ &opts,
+ fresh_cursor(index, file),
+ PassMode::Normal,
+ )
+ .unwrap();
+ let mut batches = Vec::new();
+ loop {
+ let stepped = pass
+ .step(&SendsFiles, &runtime, file, &tailer, &opts, &instrument)
+ .unwrap();
+ let PassStep::Batch(output) = stepped else {
+ return batches;
+ };
+ let statuses: Vec<_> = output
+ .raw
+ .documents
+ .iter()
+ .map(|(_, document)| document.status)
+ .collect();
+ let shown = captures(&output.batch);
+ assert_eq!(
+ shown,
+ statuses
+ .iter()
+ .filter(|status| **status == rosetta_capture::DocumentCaptureStatus::Captured)
+ .count()
+ );
+ batches.push(statuses);
+ if pass.committed().is_some() {
+ return batches;
+ }
+ }
+}
+
+#[test]
+fn several_captures_in_one_tail_batch_complete_serially_and_pooled() {
+ use rosetta_capture::DocumentCaptureStatus::Captured;
+ let (home, files) = sending_home(2);
+
+ // Serially: one batch per source, every file captured in it.
+ for (index, file) in files.iter().enumerate() {
+ assert_eq!(
+ step_serially(home.path(), index, file),
+ [vec![Captured; SENT_FILES]]
+ );
+ }
+
+ // Pooled: the same batches, whatever the look-ahead bounds are. A batch
+ // of captures is far over every tight bound; the head still steps, and
+ // the look-ahead source parks until it is the head.
+ let tight = PoolBudgets {
+ look_ahead_bytes: 1,
+ runtime_floor_bytes: 1,
+ head_bytes: 1,
+ };
+ for (budgets, compress) in [
+ (PoolBudgets::DEFAULT, None),
+ (budgets(MIB), None),
+ (tight, None),
+ (tight, Some(1)),
+ ] {
+ // A pool that parked forever fails here instead of hanging the run.
+ let cancel = Arc::new(AtomicBool::new(false));
+ let done = Arc::new(AtomicBool::new(false));
+ let watchdog = std::thread::spawn({
+ let (cancel, done) = (Arc::clone(&cancel), Arc::clone(&done));
+ move || {
+ let started = Instant::now();
+ while !done.load(std::sync::atomic::Ordering::Relaxed) {
+ if started.elapsed() > Duration::from_secs(120) {
+ cancel.store(true, std::sync::atomic::Ordering::Relaxed);
+ return;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ }
+ });
+ let opts = IngestOptions {
+ cancel: Some(Arc::clone(&cancel)),
+ ..IngestOptions::default()
+ };
+ let pool = CapturePool::new(2, budgets);
+ let lane = pool.register_lane(
+ Shared {
+ adapter: &SendsFiles,
+ runtime: runtime_row(),
+ root: home.path().to_path_buf(),
+ opts: opts.clone(),
+ tailer: Tailer::new(rosetta_capture::TailConfig::json_lines()).unwrap(),
+ frames: true,
+ compress,
+ units: files
+ .iter()
+ .enumerate()
+ .map(|(index, file)| UnitSpec {
+ file: file.clone(),
+ cursor: fresh_cursor(index, file),
+ key: index as u32,
+ })
+ .collect(),
+ },
+ LaneClass::Shared,
+ );
+ let batches = pool.scoped(|| {
+ let mut batches = Vec::new();
+ for unit in 0..files.len() {
+ loop {
+ match lane.next(unit, &opts).expect("the head's step arrives") {
+ Event::Batch(output) => {
+ let held = output
+ .frames
+ .as_ref()
+ .map_or(0, rosetta_capture::PrebuiltFrames::held_bytes);
+ batches.push((unit, captures(&output.batch), held));
+ if output.ends.is_some() {
+ break;
+ }
+ }
+ Event::Finished { .. } => break,
+ Event::Failed(error) => panic!("{budgets:?}: {error}"),
+ }
+ }
+ lane.advance(unit);
+ }
+ batches
+ });
+ done.store(true, std::sync::atomic::Ordering::Relaxed);
+ watchdog.join().unwrap();
+ assert!(!cancel.load(std::sync::atomic::Ordering::Relaxed));
+
+ assert_eq!(
+ batches
+ .iter()
+ .map(|(unit, shown, _)| (*unit, *shown))
+ .collect::>(),
+ [(0, SENT_FILES), (1, SENT_FILES)],
+ "{budgets:?}"
+ );
+ let captured = SENT_BYTES * SENT_FILES as u64;
+ for (_, _, held) in &batches {
+ if compress.is_none() {
+ assert!(*held >= captured, "the frames carry every capture");
+ }
+ }
+ let stats = pool.stats();
+ assert!(
+ stats.peak_head_bytes >= captured,
+ "{budgets:?}: a batch of captures is counted against the bounds: {stats:?}"
+ );
+ assert_accounts(&pool.lock());
+ drop(lane);
+ }
+}
+
+#[test]
+fn a_pass_observes_no_sent_file_of_a_ledger_only_source_or_with_the_switch_off() {
+ use rosetta_capture::DocumentCaptureStatus::Captured;
+ let (home, files) = sending_home(1);
+ assert_eq!(
+ step_serially(home.path(), 0, &files[0]),
+ [vec![Captured; SENT_FILES]]
+ );
+ // An internal session is captured raw and never becomes canonical work:
+ // nothing it names is read.
+ let internal = SessionFile {
+ ledger_only: true,
+ ..files[0].clone()
+ };
+ assert_eq!(
+ step_serially(home.path(), 0, &internal),
+ [Vec::new()],
+ "a ledger-only source writes no frame"
+ );
+
+ // The switch leaves every fact a reference and writes no frame.
+ use crate::stream::{PassMode, PassStep};
+ let opts = IngestOptions {
+ observe_presented_media: false,
+ ..IngestOptions::default()
+ };
+ let tailer = Tailer::new(rosetta_capture::TailConfig::json_lines()).unwrap();
+ let mut pass = IngestPass::begin(
+ &SendsFiles,
+ &runtime_row(),
+ home.path(),
+ &files[0],
+ &opts,
+ fresh_cursor(0, &files[0]),
+ PassMode::Normal,
+ )
+ .unwrap();
+ let stepped = pass
+ .step(
+ &SendsFiles,
+ &runtime_row(),
+ &files[0],
+ &tailer,
+ &opts,
+ &Instrument::new(),
+ )
+ .unwrap();
+ let PassStep::Batch(output) = stepped else {
+ panic!("one batch")
+ };
+ assert!(output.raw.documents.is_empty());
+ assert_eq!(output.raw.records.len(), SENT_FILES);
+ assert_eq!(captures(&output.batch), 0);
+ assert!(crate::presented_media::has_fact(&output.batch.ops));
+}
diff --git a/crates/pipeline/src/checkpoint.rs b/crates/pipeline/src/checkpoint.rs
index dda0d625..720a42e8 100644
--- a/crates/pipeline/src/checkpoint.rs
+++ b/crates/pipeline/src/checkpoint.rs
@@ -49,6 +49,15 @@ pub struct PipeCheckpoint {
/// this session's content is unchanged when it matches.
#[serde(default)]
pub snapshot_revision: Option,
+ /// JSONL sources only: a pass that re-reads records the ledger already
+ /// holds for this generation (a re-frame) committed this checkpoint with
+ /// more of the file still to read. The pass that resumes from it is a
+ /// re-frame too, so it still asks the ledger for a sent file's capture
+ /// before it reads the filesystem. Cleared by the batch that reaches the
+ /// end of the file. Absent when false, so every other blob keeps its
+ /// exact bytes and an older binary reads this one.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub reframing: bool,
}
impl PipeCheckpoint {
@@ -67,6 +76,7 @@ impl PipeCheckpoint {
adapter_state: None,
snapshot_stamp: None,
snapshot_revision: None,
+ reframing: false,
}
}
@@ -110,4 +120,46 @@ mod tests {
// A fresh checkpoint has no tail to resume from.
assert!(checkpoint.tail_checkpoint().is_none());
}
+
+ /// The blob as it was written before `reframing` existed.
+ const BEFORE_REFRAMING: &str = concat!(
+ r#"{"v":2,"session_id":"s1","stream_id":"transcript","normalizer_version":7,"#,
+ r#""source_generation":3,"next_source_sequence":12,"generation_clean":true,"#,
+ r#""next_ordinal":42,"tail":"00ff","cumulative":null,"adapter_state":{"open":["a"]},"#,
+ r#""snapshot_stamp":null,"snapshot_revision":null}"#,
+ );
+
+ #[test]
+ fn a_checkpoint_that_is_not_reframing_keeps_its_exact_bytes() {
+ let mut checkpoint = PipeCheckpoint::fresh("s1", "transcript", 7);
+ checkpoint.source_generation = 3;
+ checkpoint.next_source_sequence = 12;
+ checkpoint.next_ordinal = 42;
+ checkpoint.tail = Some("00ff".to_string());
+ checkpoint.adapter_state = Some(serde_json::json!({"open": ["a"]}));
+
+ // The field is absent while false: the stored blob is byte for byte
+ // the one an earlier binary wrote, so its CAS expectation still holds
+ // and a quiet file is still recognised as unchanged.
+ assert!(!checkpoint.reframing);
+ assert_eq!(checkpoint.encode(), BEFORE_REFRAMING);
+ // And that earlier blob decodes as not re-framing.
+ assert_eq!(
+ PipeCheckpoint::decode(BEFORE_REFRAMING).unwrap(),
+ checkpoint
+ );
+
+ // Set, it is one more member at the end, with no version change.
+ checkpoint.reframing = true;
+ let marked = checkpoint.encode();
+ assert_eq!(
+ marked,
+ BEFORE_REFRAMING.replace(
+ r#""snapshot_revision":null}"#,
+ r#""snapshot_revision":null,"reframing":true}"#
+ )
+ );
+ assert_eq!(PipeCheckpoint::decode(&marked).unwrap(), checkpoint);
+ assert_eq!(PIPE_CHECKPOINT_VERSION, 2);
+ }
}
diff --git a/crates/pipeline/src/html_artifacts.rs b/crates/pipeline/src/html_artifacts.rs
index 7121ef93..95d64d81 100644
--- a/crates/pipeline/src/html_artifacts.rs
+++ b/crates/pipeline/src/html_artifacts.rs
@@ -70,7 +70,7 @@ pub(crate) fn capture(
documents
}
-fn now() -> i64 {
+pub(crate) fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
@@ -83,11 +83,68 @@ fn observe(path: String) -> CapturedDocument {
captured_at: now(),
status: Status::Unsupported,
recovered_from: None,
+ media: None,
bytes: Vec::new(),
};
- let path = Path::new(&document.path);
- if !path.is_absolute() || document.path.starts_with("//") {
- return document;
+ let file = match open_regular(&document.path) {
+ Ok(file) => file,
+ Err(status) => {
+ document.status = status;
+ return document;
+ }
+ };
+ document.status = read_stable(
+ &mut &file,
+ || FileStat::of(&file),
+ HTML_MAX_BYTES as u64,
+ None,
+ &mut document.bytes,
+ );
+ if document.status == Status::Captured && utf8(&document.bytes).is_none() {
+ document.status = Status::Unsupported;
+ }
+ if document.status != Status::Captured {
+ document.bytes.clear();
+ }
+ document
+}
+
+/// Whether an absolute path is the device or process tree, or lies under
+/// one. Letter case is ignored because the default macOS volume resolves
+/// `/DEV` to `/dev`.
+fn is_device_path(path: &str) -> bool {
+ let root = path
+ .strip_prefix('/')
+ .and_then(|rest| rest.split('/').next())
+ .unwrap_or_default();
+ root.eq_ignore_ascii_case("dev") || root.eq_ignore_ascii_case("proc")
+}
+
+/// Open the file a transcript names, for one bounded observation. `Err` is
+/// the status the observation ends with: `Missing` when nothing is there,
+/// `Unsupported` for a path that is not absolute (or is a network path), a
+/// device or process path, any other open error, and anything that is not a
+/// regular file.
+pub(crate) fn open_regular(path: &str) -> Result {
+ if !Path::new(path).is_absolute() || path.starts_with("//") {
+ return Err(Status::Unsupported);
+ }
+ if is_device_path(path) {
+ // Refused before any open: opening one can act on this process.
+ // `/dev/fd/N` duplicates its own descriptor N, which then reports
+ // the regular file behind it and shares its read offset, and a
+ // character device can act on being opened at all.
+ return Err(Status::Unsupported);
+ }
+ // The same refusal for a path that only reaches those trees through a
+ // symlinked directory: `O_NOFOLLOW` guards the last component alone, so
+ // the directory is resolved first. A directory that does not resolve is
+ // left to the open below, which reports what is wrong with it.
+ if let Some(parent) = Path::new(path).parent()
+ && let Ok(resolved) = std::fs::canonicalize(parent)
+ && is_device_path(&resolved.to_string_lossy())
+ {
+ return Err(Status::Unsupported);
}
let mut options = OpenOptions::new();
options.read(true);
@@ -98,56 +155,98 @@ fn observe(path: String) -> CapturedDocument {
// stall the ingest worker. Reject non-regular descriptors below.
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
- let mut file = match options.open(path) {
- Ok(file) => file,
- Err(error) => {
- document.status = if error.kind() == std::io::ErrorKind::NotFound {
- Status::Missing
- } else {
- Status::Unsupported
- };
- return document;
+ let file = options.open(path).map_err(|error| {
+ if error.kind() == std::io::ErrorKind::NotFound {
+ Status::Missing
+ } else {
+ Status::Unsupported
}
- };
- document.status = read_stable(&mut file, &mut document.bytes);
- if document.status != Status::Captured {
- document.bytes.clear();
+ })?;
+ match file.metadata() {
+ Ok(metadata) if metadata.is_file() => Ok(file),
+ _ => Err(Status::Unsupported),
+ }
+}
+
+/// What a stable read compares before and after it reads.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct FileStat {
+ pub(crate) is_file: bool,
+ pub(crate) len: u64,
+ pub(crate) modified: Option,
+}
+
+impl FileStat {
+ pub(crate) fn of(file: &File) -> Option {
+ let metadata = file.metadata().ok()?;
+ Some(Self {
+ is_file: metadata.is_file(),
+ len: metadata.len(),
+ modified: metadata.modified().ok(),
+ })
}
- document
}
-fn read_stable(file: &mut File, bytes: &mut Vec) -> Status {
- let Ok(before) = file.metadata() else {
+/// Read a whole file of at most `limit` bytes into `bytes`, and say whether
+/// it held still: `Captured` only when the length and modification time
+/// `stat` reports are the same before and after the read and the read
+/// returned exactly that many bytes.
+///
+/// `expected_len` is the size a transcript recorded for the file. With one,
+/// a file of any other length is `ChangedDuringRead` before a byte is read,
+/// the buffer is reserved up front, and a short or long read is
+/// `ChangedDuringRead` too. Without one, a file over `limit` is `TooLarge`.
+/// The reader and `stat` are separate so a test can move the file under the
+/// read; the caller clears `bytes` on any status but `Captured`.
+pub(crate) fn read_stable(
+ reader: &mut R,
+ mut stat: impl FnMut() -> Option,
+ limit: u64,
+ expected_len: Option,
+ bytes: &mut Vec,
+) -> Status {
+ let Some(before) = stat() else {
return Status::Unsupported;
};
- if !before.is_file() {
+ if !before.is_file {
return Status::Unsupported;
}
- if before.len() > HTML_MAX_BYTES as u64 {
+ if expected_len.is_some_and(|expected| before.len != expected) {
+ return Status::ChangedDuringRead;
+ }
+ if before.len > limit {
return Status::TooLarge;
}
- if file
- .take(HTML_MAX_BYTES as u64 + 1)
+ if let Some(expected) = expected_len {
+ // One allocation of the recorded size, refused instead of aborting
+ // the daemon when the memory is not there.
+ let reserved = usize::try_from(expected)
+ .ok()
+ .is_some_and(|len| bytes.try_reserve_exact(len).is_ok());
+ if !reserved {
+ return Status::Unsupported;
+ }
+ }
+ if reader
+ .take(limit.saturating_add(1))
.read_to_end(bytes)
.is_err()
{
return Status::Unsupported;
}
- if bytes.len() > HTML_MAX_BYTES {
+ let read = bytes.len() as u64;
+ if expected_len.is_some_and(|expected| read != expected) {
+ return Status::ChangedDuringRead;
+ }
+ if read > limit {
return Status::TooLarge;
}
- let Ok(after) = file.metadata() else {
+ let Some(after) = stat() else {
return Status::ChangedDuringRead;
};
- if before.len() != after.len()
- || after.len() != bytes.len() as u64
- || before.modified().ok() != after.modified().ok()
- {
+ if before.len != after.len || after.len != read || before.modified != after.modified {
return Status::ChangedDuringRead;
}
- if utf8(bytes).is_none() {
- return Status::Unsupported;
- }
Status::Captured
}
diff --git a/crates/pipeline/src/html_artifacts/recovery.rs b/crates/pipeline/src/html_artifacts/recovery.rs
index 1f20d8d1..ebd1ec0b 100644
--- a/crates/pipeline/src/html_artifacts/recovery.rs
+++ b/crates/pipeline/src/html_artifacts/recovery.rs
@@ -221,6 +221,7 @@ fn recovered(original: &str, bytes: Vec, from: String) -> CapturedDocument {
captured_at: super::now(),
status: Status::Captured,
recovered_from: Some(from),
+ media: None,
bytes,
}
}
diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs
index 53c02b41..b9b40961 100644
--- a/crates/pipeline/src/lib.rs
+++ b/crates/pipeline/src/lib.rs
@@ -33,6 +33,7 @@ pub mod meta;
mod meta_journal;
pub use meta_journal::LedgerMetadata;
pub mod plan;
+mod presented_media;
pub mod progress;
mod project;
pub mod replay;
@@ -233,6 +234,11 @@ pub struct IngestOptions {
pub cancel: Option>,
/// RAM-only cold progress; absent on the ordinary live path.
pub progress: Option>,
+ /// Whether capture observes the files an agent sent the user by path
+ /// (images and MP4/MOV video) and ledgers them. `true` by default.
+ /// `false` leaves every such file a path reference and writes no media
+ /// frame; it exists for frozen before/after pairs.
+ pub observe_presented_media: bool,
}
impl IngestOptions {
@@ -267,6 +273,7 @@ impl Default for IngestOptions {
cut_bounded: false,
cancel: None,
progress: None,
+ observe_presented_media: true,
}
}
}
diff --git a/crates/pipeline/src/presented_media.rs b/crates/pipeline/src/presented_media.rs
new file mode 100644
index 00000000..0526bc68
--- /dev/null
+++ b/crates/pipeline/src/presented_media.rs
@@ -0,0 +1,305 @@
+//! Files an agent sent the user by path: the capture seam and its replay.
+//!
+//! An adapter emits a pure [`rosetta_core::presented_media::PresentedMedia`]
+//! fact per sent image or video; the bytes are not in the transcript. IO
+//! belongs here, at capture, never in adapters or replay:
+//!
+//! - capture turns each fact into a `Document` ledger frame that carries a
+//! media claim, asking the ledger for an earlier capture before it reads
+//! the filesystem, or, on append growth, once the filesystem had nothing
+//! to capture ([`prior`]);
+//! - one pure projection turns a captured frame plus its fact into a
+//! projection asset and the tool update that swaps the attachment's source
+//! from a path reference to that asset;
+//! - replay joins frames to facts by `(source_offset, path)` and reads no
+//! filesystem.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::io::Read;
+
+use rosetta_adapter_support::media::{from_presented_bytes, presented_limit};
+use rosetta_capture::{CapturedDocument, DocumentCaptureStatus as Status, MediaClaim};
+use rosetta_core::presented_media::{PresentedMedia, RECORD_MAX_BYTES};
+use rosetta_core::timeline::AttachmentSource;
+use rosetta_core::{IngestOp, ToolUpdatePatch};
+
+use crate::html_artifacts::{FileStat, now, open_regular, read_stable};
+
+pub(crate) mod prior;
+mod slots;
+
+pub(crate) use slots::Slots;
+
+use prior::Loaded;
+
+/// Whether a record's ops hold a presented-media fact: what a pass checks
+/// before it pays for anything else here.
+pub(crate) fn has_fact(ops: &[IngestOp]) -> bool {
+ ops.iter()
+ .any(|op| matches!(op, IngestOp::PresentedMedia(_)))
+}
+
+fn claim(fact: &PresentedMedia) -> MediaClaim {
+ MediaClaim {
+ media_type: fact.media_type.clone(),
+ expected_len: fact.expected_len,
+ }
+}
+
+/// Pure reconstruction from ledger evidence, shared by capture and replay:
+/// a captured frame plus its fact become the member and the tool update that
+/// swaps the attachment's source to it. `false` pushes nothing: the frame
+/// captured nothing, answers another claim or path, or its bytes are not
+/// what the fact declared. Nothing pushed can fail a store batch: the fact,
+/// the member and the upgraded attachment are all validated first.
+pub(crate) fn project(
+ fact: &PresentedMedia,
+ document: &CapturedDocument,
+ ops: &mut Vec,
+) -> bool {
+ if document.status != Status::Captured
+ || document.path != fact.path
+ || document.media.as_ref() != Some(&claim(fact))
+ || document.bytes.len() as u64 != fact.expected_len
+ || fact.validate().is_err()
+ {
+ return false;
+ }
+ let Some((asset, dimensions)) = from_presented_bytes(&fact.media_type, document.bytes.clone())
+ else {
+ return false;
+ };
+ let mut upgraded = fact.attachment.clone();
+ upgraded.media_type = Some(asset.media_type.clone());
+ upgraded.byte_len = Some(asset.bytes.len() as u64);
+ upgraded.dimensions = dimensions;
+ upgraded.source = AttachmentSource::Asset {
+ asset: asset.reference(),
+ };
+ if upgraded.validate().is_err() {
+ return false;
+ }
+ ops.push(IngestOp::ProjectionAsset { asset });
+ IngestOp::push_tool_update(
+ ops,
+ fact.session_id.clone(),
+ fact.correlation_key.clone(),
+ fact.evidence.clone(),
+ ToolUpdatePatch {
+ attachments: vec![upgraded],
+ ..Default::default()
+ },
+ fact.at,
+ );
+ true
+}
+
+/// Replay's one entry point for a `Document` frame: an HTML frame projects
+/// as it always has; a media frame joins the fact noted at its coordinate.
+/// The first captured frame that projects takes the slot, so a later frame
+/// at the same coordinate is ignored; a frame that captured nothing emits
+/// nothing and leaves the slot for one that did; a frame no fact was noted
+/// for is ignored. No read of today's source file.
+pub(crate) fn project_document(
+ session_id: &str,
+ offset: u64,
+ document: &CapturedDocument,
+ slots: &mut Slots,
+ ops: &mut Vec,
+) {
+ if document.media.is_none() {
+ crate::html_artifacts::project(session_id, offset, document, ops);
+ return;
+ }
+ if slots
+ .get(offset, &document.path)
+ .is_some_and(|fact| project(fact, document, ops))
+ {
+ slots.remove(offset, &document.path);
+ }
+}
+
+/// The ledger as the second resort of a pass that reads the filesystem
+/// first: asked only for a file that could not be captured (missing,
+/// changed, unsupported), and walked at most once, at the first such file.
+/// `loaded` is where the pass keeps the answer, so later records of the
+/// pass reuse it.
+pub(crate) struct Fallback<'a> {
+ loaded: &'a mut Option,
+ load: &'a dyn Fn() -> Loaded,
+}
+
+impl<'a> Fallback<'a> {
+ pub(crate) fn new(loaded: &'a mut Option, load: &'a dyn Fn() -> Loaded) -> Self {
+ Self { loaded, load }
+ }
+
+ fn loaded(&mut self) -> &Loaded {
+ self.loaded.get_or_insert_with(|| (self.load)())
+ }
+}
+
+/// Observe the files one record's facts name and project every capture into
+/// `ops`. Returns the frames to ledger at each fact's record, like the HTML
+/// capture; a capture carried from the very coordinate it already sits at
+/// returns none.
+///
+/// Per fact at or above `ledgered_through`, once per `(source_offset, path)`:
+/// the ledger first (`prior`, when the pass can meet earlier frames), then
+/// the claim checks, then the filesystem, then the ledger after all
+/// (`fallback`, on append growth) when the file could not be captured. A
+/// pass that must not observe
+/// ([`crate::IngestOptions::observe_presented_media`]) does not call this,
+/// so every fact stays a path reference and no frame is written.
+pub(crate) fn capture(
+ ops: &mut Vec,
+ ledgered_through: u64,
+ generation: u64,
+ prior: Option<&Loaded>,
+ mut fallback: Option>,
+) -> Vec<(u64, CapturedDocument)> {
+ let facts: Vec = ops
+ .iter()
+ .filter_map(|op| match op {
+ IngestOp::PresentedMedia(fact)
+ if fact.source_offset >= ledgered_through && fact.validate().is_ok() =>
+ {
+ Some(fact.clone())
+ }
+ _ => None,
+ })
+ .collect();
+ let mut documents = Vec::new();
+ let mut seen = BTreeSet::new();
+ // Captured bytes per raw record, so one record's files stay within
+ // `RECORD_MAX_BYTES` however many it lists.
+ let mut captured: BTreeMap = BTreeMap::new();
+ for fact in facts {
+ if !seen.insert((fact.source_offset, fact.path.clone())) {
+ continue;
+ }
+ let total = captured.entry(fact.source_offset).or_default();
+ if let Some(prior) = prior
+ && let Some(frame) = carry(&fact, prior, generation, *total, ops)
+ {
+ *total += fact.expected_len;
+ documents.extend(frame.map(|document| (fact.source_offset, document)));
+ continue;
+ }
+ let document = observe_file(&fact, *total, ops);
+ if document.status == Status::Captured {
+ *total += fact.expected_len;
+ } else if document.status != Status::TooLarge
+ && let Some(fallback) = fallback.as_mut()
+ && let Some(frame) = carry(&fact, fallback.loaded(), generation, *total, ops)
+ {
+ // The source already holds a capture of this file: carried, it
+ // stands in for the observation that found nothing to keep. A
+ // claim that is too large is not looked up: no capture of it
+ // could be carried either, under the same two limits.
+ *total += fact.expected_len;
+ documents.extend(frame.map(|document| (fact.source_offset, document)));
+ continue;
+ }
+ if document.validate().is_ok() {
+ documents.push((fact.source_offset, document));
+ }
+ }
+ documents
+}
+
+/// Ledger first: the earlier capture of this claim, if it projects and fits
+/// the record's budget. `Some(None)` is a capture already ledgered at this
+/// exact coordinate: used, and nothing to append. `Some(Some(_))` is the
+/// copy to append here. `None` leaves the fact to the filesystem.
+fn carry(
+ fact: &PresentedMedia,
+ prior: &Loaded,
+ generation: u64,
+ total: u64,
+ ops: &mut Vec,
+) -> Option> {
+ let prior = prior.find(fact.source_offset, &fact.path, &claim(fact))?;
+ if total.saturating_add(prior.document.bytes.len() as u64) > RECORD_MAX_BYTES {
+ return None;
+ }
+ let here = prior.generation == generation && prior.offset == fact.source_offset;
+ let copy = (!here).then(|| CapturedDocument {
+ path: fact.path.clone(),
+ captured_at: prior.document.captured_at,
+ status: Status::Captured,
+ recovered_from: Some(format!("ledger:{}:{}", prior.generation, prior.offset)),
+ media: Some(claim(fact)),
+ bytes: prior.document.bytes.clone(),
+ });
+ // The frame is checked before anything is projected, so a capture is
+ // never shown that replay could not find again.
+ if copy.as_ref().is_some_and(|copy| copy.validate().is_err())
+ || !project(fact, &prior.document, ops)
+ {
+ return None;
+ }
+ Some(copy)
+}
+
+fn observation(fact: &PresentedMedia, status: Status) -> CapturedDocument {
+ CapturedDocument {
+ path: fact.path.clone(),
+ captured_at: now(),
+ status,
+ recovered_from: None,
+ media: Some(claim(fact)),
+ bytes: Vec::new(),
+ }
+}
+
+/// The claim checks, then one bounded observation of the file.
+fn observe_file(fact: &PresentedMedia, total: u64, ops: &mut Vec) -> CapturedDocument {
+ let Some(limit) = presented_limit(&fact.media_type) else {
+ return observation(fact, Status::Unsupported);
+ };
+ if fact.expected_len > limit || total.saturating_add(fact.expected_len) > RECORD_MAX_BYTES {
+ // Decided from the transcript's own claim: the file is not opened.
+ return observation(fact, Status::TooLarge);
+ }
+ match open_regular(&fact.path) {
+ Ok(file) => read_file(fact, &mut &file, || FileStat::of(&file), ops),
+ Err(status) => observation(fact, status),
+ }
+}
+
+/// Read an opened file under the identity check and project it. The reader
+/// and `stat` are the stable read's seam.
+fn read_file(
+ fact: &PresentedMedia,
+ reader: &mut R,
+ stat: impl FnMut() -> Option,
+ ops: &mut Vec,
+) -> CapturedDocument {
+ let mut document = observation(fact, Status::Unsupported);
+ document.status = read_stable(
+ reader,
+ stat,
+ fact.expected_len,
+ Some(fact.expected_len),
+ &mut document.bytes,
+ );
+ // The frame is checked before anything is projected, so a capture is
+ // never shown that replay could not find again.
+ if document.status == Status::Captured
+ && !(document.validate().is_ok() && project(fact, &document, ops))
+ {
+ document.status = Status::Unsupported;
+ }
+ if document.status != Status::Captured {
+ document.bytes = Vec::new();
+ }
+ document
+}
+
+#[cfg(test)]
+mod fallback_tests;
+#[cfg(test)]
+mod store_tests;
+#[cfg(test)]
+mod tests;
diff --git a/crates/pipeline/src/presented_media/fallback_tests.rs b/crates/pipeline/src/presented_media/fallback_tests.rs
new file mode 100644
index 00000000..5f11a7d2
--- /dev/null
+++ b/crates/pipeline/src/presented_media/fallback_tests.rs
@@ -0,0 +1,260 @@
+//! The ledger as the second resort of append growth: a sent file that
+//! cannot be captured today is carried from an earlier capture the source
+//! already holds, and the ledger is walked only then, once, and never for a
+//! claim that is too large to carry.
+
+use std::cell::Cell;
+
+use tempfile::TempDir;
+
+use super::prior::Prior;
+use super::tests::{captured, facts, members, mp4, record, statuses, upgrades, write};
+use super::*;
+
+const MP4: &str = "video/mp4";
+
+fn digest(bytes: &[u8]) -> String {
+ blake3::hash(bytes).to_hex().to_string()
+}
+
+/// One held capture: `(generation, offset, bytes)`.
+type Held<'a> = (u64, u64, &'a [u8]);
+
+/// What the source's ledger holds: one capture of `path` per frame.
+fn held(path: &str, frames: &[Held<'_>]) -> Loaded {
+ let mut loaded = Loaded::default();
+ for (generation, offset, bytes) in frames {
+ let ops = record(*offset, *generation, &[(path, bytes.len() as u64, MP4)]);
+ loaded.insert(Prior {
+ generation: *generation,
+ offset: *offset,
+ document: captured(&facts(&ops)[0], bytes.to_vec()),
+ });
+ }
+ loaded
+}
+
+fn shown(ops: &[IngestOp]) -> Vec {
+ assert_eq!(members(ops).len(), upgrades(ops).len());
+ members(ops)
+ .iter()
+ .map(|asset| asset.digest.clone())
+ .collect()
+}
+
+#[test]
+fn a_file_that_cannot_be_captured_is_carried_from_the_ledger_walked_once() {
+ let dir = TempDir::new().unwrap();
+ let gone = dir.path().join("gone.mp4").to_str().unwrap().to_owned();
+ let never = dir.path().join("never.mp4").to_str().unwrap().to_owned();
+ let earlier = mp4(64, 1);
+ let walks = Cell::new(0);
+ let load = || {
+ walks.set(walks.get() + 1);
+ held(&gone, &[(1, 10, &earlier)])
+ };
+ let mut kept = None;
+
+ // Two files on one record: one the source captured at an earlier
+ // record, one it never captured. Neither is on disk.
+ let mut ops = record(
+ 40,
+ 1,
+ &[(gone.as_str(), 64, MP4), (never.as_str(), 64, MP4)],
+ );
+ let frames = capture(&mut ops, 40, 1, None, Some(Fallback::new(&mut kept, &load)));
+ assert_eq!(statuses(&frames), [Status::Captured, Status::Missing]);
+ let (offset, carried) = &frames[0];
+ assert_eq!(*offset, 40);
+ assert_eq!(carried.path, gone);
+ assert_eq!(carried.recovered_from.as_deref(), Some("ledger:1:10"));
+ assert_eq!(carried.captured_at, 1234, "when it was first captured");
+ assert_eq!(carried.bytes, earlier);
+ carried.validate().unwrap();
+ assert_eq!(frames[1].1.path, never);
+ assert_eq!(frames[1].1.recovered_from, None);
+ assert_eq!(shown(&ops), [digest(&earlier)]);
+ assert_eq!(walks.get(), 1, "one walk for the record's two failures");
+
+ // A later record of the same pass reuses what the walk found.
+ let mut ops = record(90, 1, &[(gone.as_str(), 64, MP4)]);
+ let frames = capture(&mut ops, 40, 1, None, Some(Fallback::new(&mut kept, &load)));
+ assert_eq!(statuses(&frames), [Status::Captured]);
+ assert_eq!(frames[0].1.recovered_from.as_deref(), Some("ledger:1:10"));
+ assert_eq!(walks.get(), 1);
+ assert!(kept.is_some());
+}
+
+#[test]
+fn a_file_that_is_captured_never_asks_the_ledger() {
+ let dir = TempDir::new().unwrap();
+ let earlier = mp4(64, 1);
+ let today = mp4(64, 9);
+ let path = write(dir.path(), "clip.mp4", &today);
+ let walks = Cell::new(0);
+ let load = || {
+ walks.set(walks.get() + 1);
+ held(&path, &[(1, 10, &earlier)])
+ };
+ let mut kept = None;
+ let mut ops = record(40, 1, &[(path.as_str(), 64, MP4)]);
+ let frames = capture(&mut ops, 40, 1, None, Some(Fallback::new(&mut kept, &load)));
+ // Append growth reads the file first: a new send shows today's bytes.
+ assert_eq!(statuses(&frames), [Status::Captured]);
+ assert_eq!(frames[0].1.recovered_from, None);
+ assert_eq!(frames[0].1.bytes, today);
+ assert_eq!(shown(&ops), [digest(&today)]);
+ assert_eq!(walks.get(), 0);
+ assert!(kept.is_none());
+
+ // A record with no fact at or above the watermark asks nothing either.
+ std::fs::remove_file(&path).unwrap();
+ let mut ops = record(40, 1, &[(path.as_str(), 64, MP4)]);
+ assert!(capture(&mut ops, 41, 1, None, Some(Fallback::new(&mut kept, &load))).is_empty());
+ assert_eq!(walks.get(), 0);
+}
+
+#[test]
+fn every_failed_observation_falls_back_and_an_unusable_capture_is_passed_over() {
+ let dir = TempDir::new().unwrap();
+ let earlier = mp4(64, 1);
+ let missing = dir.path().join("gone.mp4").to_str().unwrap().to_owned();
+ let resized = write(dir.path(), "resized.mp4", &mp4(65, 2));
+ let rewritten = write(dir.path(), "rewritten.mp4", &[b'x'; 64]);
+ let folder = dir.path().join("folder.mp4");
+ std::fs::create_dir(&folder).unwrap();
+ let folder = folder.to_str().unwrap().to_owned();
+ // One record at offset 40 of generation 2 sends `path`; the ledger
+ // holds `frames` of `of`.
+ let run = |path: &str, of: &str, frames: &[Held<'_>]| {
+ let load = || held(of, frames);
+ let mut kept = None;
+ let mut ops = record(40, 2, &[(path, 64, MP4)]);
+ let mut unused = Vec::new();
+ let observed = observe_file(&facts(&ops)[0], 0, &mut unused).status;
+ assert!(unused.is_empty(), "{path}: the file itself is no capture");
+ let frames = capture(&mut ops, 0, 2, None, Some(Fallback::new(&mut kept, &load)));
+ (observed, frames, shown(&ops))
+ };
+
+ // Gone, another size, the recorded size with other content, not a file:
+ // each is carried from the capture at an earlier record.
+ for (path, status) in [
+ (missing.as_str(), Status::Missing),
+ (resized.as_str(), Status::ChangedDuringRead),
+ (rewritten.as_str(), Status::Unsupported),
+ (folder.as_str(), Status::Unsupported),
+ ] {
+ let (observed, frames, shown) = run(path, path, &[(2, 10, &earlier)]);
+ assert_eq!(observed, status, "{path}");
+ assert_eq!(statuses(&frames), [Status::Captured], "{path}");
+ assert_eq!(
+ frames[0].1.recovered_from.as_deref(),
+ Some("ledger:2:10"),
+ "{path}"
+ );
+ assert_eq!(frames[0].1.bytes, earlier, "{path}");
+ assert_eq!(shown, [digest(&earlier)], "{path}");
+ }
+
+ // Already ledgered at this very coordinate (the record was read once
+ // before its batch reached the store): shown, and nothing appended.
+ let (_, frames, shown) = run(&missing, &missing, &[(2, 40, &earlier)]);
+ assert!(frames.is_empty());
+ assert_eq!(shown, [digest(&earlier)]);
+
+ // The same offset in an earlier generation is a copy, like any other.
+ let (_, frames, shown) = run(&missing, &missing, &[(1, 40, &earlier)]);
+ assert_eq!(statuses(&frames), [Status::Captured]);
+ assert_eq!(frames[0].1.recovered_from.as_deref(), Some("ledger:1:40"));
+ assert_eq!(shown, [digest(&earlier)]);
+
+ // A held capture that does not project, a capture of another file, or
+ // nothing held at all: the observation stands.
+ let garbage = [b'x'; 64];
+ let cases: [(&str, &[Held<'_>]); 3] = [
+ (missing.as_str(), &[(2, 10, &garbage)]),
+ (resized.as_str(), &[(2, 10, &earlier)]),
+ (missing.as_str(), &[]),
+ ];
+ for (of, held_frames) in cases {
+ let (_, frames, shown) = run(&missing, of, held_frames);
+ assert_eq!(statuses(&frames), [Status::Missing]);
+ assert_eq!(frames[0].1.recovered_from, None);
+ assert!(shown.is_empty());
+ }
+}
+
+#[test]
+fn a_carried_capture_still_counts_against_the_record_budget() {
+ let dir = TempDir::new().unwrap();
+ let len = 48 * 1024 * 1024_usize;
+ // Three 48 MiB videos on one record; the source captured each before
+ // and none is on disk now. Two fit the record, the third does not.
+ let paths: Vec = (0..3)
+ .map(|index| {
+ dir.path()
+ .join(format!("big-{index}.mp4"))
+ .to_str()
+ .unwrap()
+ .to_owned()
+ })
+ .collect();
+ let mut bytes = super::tests::video_header(b"isom", len);
+ bytes.resize(len, 0);
+ let load = || {
+ let mut loaded = Loaded::default();
+ for (index, path) in paths.iter().enumerate() {
+ let ops = record(10 + index as u64, 1, &[(path.as_str(), len as u64, MP4)]);
+ loaded.insert(Prior {
+ generation: 1,
+ offset: 10 + index as u64,
+ document: captured(&facts(&ops)[0], bytes.clone()),
+ });
+ }
+ loaded
+ };
+ let mut kept = None;
+ let listed: Vec<(&str, u64, &str)> = paths
+ .iter()
+ .map(|path| (path.as_str(), len as u64, MP4))
+ .collect();
+ let mut ops = record(40, 1, &listed);
+ let frames = capture(&mut ops, 0, 1, None, Some(Fallback::new(&mut kept, &load)));
+ assert_eq!(
+ statuses(&frames),
+ [Status::Captured, Status::Captured, Status::TooLarge]
+ );
+ assert!(frames[2].1.bytes.is_empty());
+ assert_eq!(frames[2].1.recovered_from, None);
+ assert_eq!(upgrades(&ops).len(), 2);
+
+ // A claim over its type's limit is not looked up at all: no capture of
+ // it could be carried, so the walk would be for nothing.
+ let walks = Cell::new(0);
+ let counted = || {
+ walks.set(walks.get() + 1);
+ Loaded::default()
+ };
+ let mut kept = None;
+ let huge = dir.path().join("huge.mp4").to_str().unwrap().to_owned();
+ let mut ops = record(
+ 50,
+ 1,
+ &[(
+ huge.as_str(),
+ rosetta_core::presented_media::VIDEO_MAX_BYTES + 1,
+ MP4,
+ )],
+ );
+ let frames = capture(
+ &mut ops,
+ 0,
+ 1,
+ None,
+ Some(Fallback::new(&mut kept, &counted)),
+ );
+ assert_eq!(statuses(&frames), [Status::TooLarge]);
+ assert_eq!(walks.get(), 0);
+ assert!(kept.is_none());
+}
diff --git a/crates/pipeline/src/presented_media/prior.rs b/crates/pipeline/src/presented_media/prior.rs
new file mode 100644
index 00000000..45a92a36
--- /dev/null
+++ b/crates/pipeline/src/presented_media/prior.rs
@@ -0,0 +1,227 @@
+//! Earlier captures of one source, read back from the ledger so a pass that
+//! re-reads a file can carry a captured media frame forward instead of
+//! observing today's filesystem.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use rosetta_capture::{
+ CaptureError, CapturedDocument, DocumentCaptureStatus, LedgerEntry, MediaClaim,
+ segment_filter_through, visit_ledger_segments,
+};
+
+/// Earlier bytes one pass may hold while it carries captures forward.
+pub(crate) const PRIOR_MAX_BYTES: u64 = 256 * 1024 * 1024;
+
+/// One captured media frame of an earlier pass, at its ledger coordinate.
+#[derive(Debug, Clone)]
+pub(crate) struct Prior {
+ pub generation: u64,
+ pub offset: u64,
+ pub document: CapturedDocument,
+}
+
+/// `(offset, path, media type, expected length)`: one claim at one record.
+type Key = (u64, String, String, u64);
+
+/// The captured media frames of one source, newest generation per claim,
+/// within a byte budget that older generations give way in.
+#[derive(Debug)]
+pub(crate) struct Loaded {
+ entries: BTreeMap,
+ bytes: u64,
+ budget: u64,
+}
+
+impl Default for Loaded {
+ fn default() -> Self {
+ Self::with_budget(PRIOR_MAX_BYTES)
+ }
+}
+
+impl Loaded {
+ pub(crate) fn with_budget(bytes: u64) -> Self {
+ Self {
+ entries: BTreeMap::new(),
+ bytes: 0,
+ budget: bytes,
+ }
+ }
+
+ /// Keep a captured media frame. Anything else is ignored: an HTML
+ /// document, an observation that captured nothing. A higher generation
+ /// replaces the frame held for the same claim at the same offset; an
+ /// equal or lower one is ignored, so the first frame inserted within a
+ /// generation wins.
+ ///
+ /// The retained bytes never exceed the budget. A frame that would take
+ /// them past it makes room among the frames of strictly lower
+ /// generations, lowest generation first: frames arrive in ledger order,
+ /// so without this the copies each rewrite carried forward would spend
+ /// the budget before a newer generation's only capture of a file
+ /// arrived. A frame is refused only when that room is not enough, and
+ /// then nothing is given up for it.
+ pub(crate) fn insert(&mut self, prior: Prior) {
+ if prior.document.status != DocumentCaptureStatus::Captured {
+ return;
+ }
+ let Some(claim) = &prior.document.media else {
+ return;
+ };
+ let key = (
+ prior.offset,
+ prior.document.path.clone(),
+ claim.media_type.clone(),
+ claim.expected_len,
+ );
+ let incoming = prior.document.bytes.len() as u64;
+ let held = match self.entries.get(&key) {
+ Some(current) if current.generation >= prior.generation => return,
+ Some(current) => current.document.bytes.len() as u64,
+ None => 0,
+ };
+ let others = self.bytes - held;
+ let over = others.saturating_add(incoming).saturating_sub(self.budget);
+ let Some(evicted) = self.room_for(over, prior.generation, &key) else {
+ return;
+ };
+ for key in evicted {
+ if let Some(gone) = self.entries.remove(&key) {
+ self.bytes -= gone.document.bytes.len() as u64;
+ }
+ }
+ self.bytes = self.bytes - held + incoming;
+ self.entries.insert(key, prior);
+ }
+
+ /// The held frames to give up so `over` more bytes fit: frames of a
+ /// generation below `generation`, lowest generation first (then lowest
+ /// offset), never the frame at `replaced`, whose bytes the caller has
+ /// already counted as freed. `None` when they do not free enough.
+ fn room_for(&self, mut over: u64, generation: u64, replaced: &Key) -> Option> {
+ if over == 0 {
+ return Some(Vec::new());
+ }
+ let mut older: Vec<(u64, &Key, u64)> = self
+ .entries
+ .iter()
+ .filter(|(key, held)| {
+ held.generation < generation && *key != replaced && !held.document.bytes.is_empty()
+ })
+ .map(|(key, held)| (held.generation, key, held.document.bytes.len() as u64))
+ .collect();
+ older.sort();
+ let mut evicted = Vec::new();
+ for (_, key, len) in older {
+ if over == 0 {
+ break;
+ }
+ over = over.saturating_sub(len);
+ evicted.push(key.clone());
+ }
+ (over == 0).then_some(evicted)
+ }
+
+ /// The capture to carry for a claim met at `offset`: the frame at that
+ /// exact coordinate if one is held; otherwise, among the frames of the
+ /// same path and claim, the newest generation, then the lowest offset.
+ pub(crate) fn find(&self, offset: u64, path: &str, claim: &MediaClaim) -> Option<&Prior> {
+ let exact = (
+ offset,
+ path.to_owned(),
+ claim.media_type.clone(),
+ claim.expected_len,
+ );
+ if let Some(prior) = self.entries.get(&exact) {
+ return Some(prior);
+ }
+ self.entries
+ .values()
+ .filter(|prior| {
+ prior.document.path == path && prior.document.media.as_ref() == Some(claim)
+ })
+ .max_by(|a, b| {
+ a.generation
+ .cmp(&b.generation)
+ .then_with(|| b.offset.cmp(&a.offset))
+ })
+ }
+}
+
+/// Reads one source's earlier captures from a runtime's ledger directory.
+/// Read-only: it opens segments and the segment index for reading and writes
+/// neither, so the runtime's own writer may hold the open segment meanwhile.
+pub(crate) struct PriorCaptures {
+ dir: PathBuf,
+}
+
+impl PriorCaptures {
+ pub(crate) fn new(dir: &Path) -> Self {
+ Self {
+ dir: dir.to_path_buf(),
+ }
+ }
+
+ /// The captured media frames of one source in generations up to and
+ /// including `through`, within [`PRIOR_MAX_BYTES`].
+ ///
+ /// Never fails the pass. An unreadable index or segment gives an empty
+ /// answer, which only means the filesystem is observed; a partial one
+ /// could prefer an older frame over a newer one it did not reach.
+ pub(crate) fn load(&self, session_id: &str, relative_path: &str, through: u64) -> Loaded {
+ self.load_within(session_id, relative_path, through, PRIOR_MAX_BYTES)
+ }
+
+ /// [`PriorCaptures::load`] under an explicit byte budget.
+ ///
+ /// One scoped walk in ledger order. The index only narrows it: a segment
+ /// the index does not list (the open one, one closed by a crash, every
+ /// one when there is no index) is walked. A torn tail ends its own
+ /// segment's walk and hides nothing in the others, which is how the open
+ /// segment reads while its writer has frames still buffered.
+ fn load_within(
+ &self,
+ session_id: &str,
+ relative_path: &str,
+ through: u64,
+ budget: u64,
+ ) -> Loaded {
+ let Ok(filter) = segment_filter_through(&self.dir, session_id, relative_path, through)
+ else {
+ return Loaded::with_budget(budget);
+ };
+ let mut loaded = Loaded::with_budget(budget);
+ let walked = visit_ledger_segments::(
+ &self.dir,
+ |segment| filter.admits(segment),
+ |entry| {
+ if let LedgerEntry::Document {
+ source,
+ generation,
+ start_offset,
+ document,
+ } = entry
+ && generation <= through
+ && source.session_id == session_id
+ && source.relative_path == relative_path
+ && document.media.is_some()
+ && document.status == DocumentCaptureStatus::Captured
+ {
+ loaded.insert(Prior {
+ generation,
+ offset: start_offset,
+ document,
+ });
+ }
+ Ok(())
+ },
+ );
+ match walked {
+ Ok(_) => loaded,
+ Err(_) => Loaded::with_budget(budget),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/pipeline/src/presented_media/prior/tests.rs b/crates/pipeline/src/presented_media/prior/tests.rs
new file mode 100644
index 00000000..88b9cc1c
--- /dev/null
+++ b/crates/pipeline/src/presented_media/prior/tests.rs
@@ -0,0 +1,892 @@
+//! The held captures and their budget, then [`PriorCaptures::load`] against
+//! real ledgers in temp directories.
+
+use std::fs;
+
+use rosetta_capture::segment_index::INDEX_FILE;
+use rosetta_capture::{Ledger, SegmentCodec, SourceDef};
+
+use super::*;
+
+fn claim(expected_len: u64) -> MediaClaim {
+ MediaClaim {
+ media_type: "video/mp4".into(),
+ expected_len,
+ }
+}
+
+fn captured(path: &str, bytes: &[u8]) -> CapturedDocument {
+ CapturedDocument {
+ path: path.into(),
+ captured_at: 1,
+ status: DocumentCaptureStatus::Captured,
+ recovered_from: None,
+ media: Some(claim(bytes.len() as u64)),
+ bytes: bytes.to_vec(),
+ }
+}
+
+fn prior(generation: u64, offset: u64, path: &str, bytes: &[u8]) -> Prior {
+ Prior {
+ generation,
+ offset,
+ document: captured(path, bytes),
+ }
+}
+
+fn found(loaded: &Loaded, offset: u64, path: &str, len: u64) -> Option<(u64, u64, Vec)> {
+ loaded
+ .find(offset, path, &claim(len))
+ .map(|prior| (prior.generation, prior.offset, prior.document.bytes.clone()))
+}
+
+#[test]
+fn only_captured_media_frames_are_kept() {
+ let mut loaded = Loaded::default();
+ // An HTML document has no claim.
+ loaded.insert(Prior {
+ generation: 1,
+ offset: 0,
+ document: CapturedDocument {
+ media: None,
+ ..captured("/a.mp4", b"html")
+ },
+ });
+ // An observation that captured nothing has no bytes to carry.
+ for status in [
+ DocumentCaptureStatus::Missing,
+ DocumentCaptureStatus::TooLarge,
+ DocumentCaptureStatus::Unsupported,
+ DocumentCaptureStatus::ChangedDuringRead,
+ ] {
+ loaded.insert(Prior {
+ generation: 1,
+ offset: 0,
+ document: CapturedDocument {
+ status,
+ bytes: Vec::new(),
+ ..captured("/a.mp4", b"gone")
+ },
+ });
+ }
+ assert_eq!(found(&loaded, 0, "/a.mp4", 4), None);
+ assert_eq!(loaded.bytes, 0);
+
+ loaded.insert(prior(1, 0, "/a.mp4", b"kept"));
+ assert_eq!(
+ found(&loaded, 0, "/a.mp4", 4),
+ Some((1, 0, b"kept".to_vec()))
+ );
+ assert_eq!(loaded.bytes, 4);
+}
+
+#[test]
+fn the_newest_generation_wins_and_the_first_frame_within_one() {
+ let mut loaded = Loaded::default();
+ loaded.insert(prior(2, 10, "/a.mp4", b"two!"));
+ // Same generation: the first inserted stays.
+ loaded.insert(prior(2, 10, "/a.mp4", b"late"));
+ assert_eq!(
+ found(&loaded, 10, "/a.mp4", 4),
+ Some((2, 10, b"two!".to_vec()))
+ );
+ // A lower generation never replaces a higher one.
+ loaded.insert(prior(1, 10, "/a.mp4", b"old!"));
+ assert_eq!(
+ found(&loaded, 10, "/a.mp4", 4),
+ Some((2, 10, b"two!".to_vec()))
+ );
+ // A higher generation replaces, and the bytes are accounted once.
+ loaded.insert(prior(5, 10, "/a.mp4", b"five"));
+ assert_eq!(
+ found(&loaded, 10, "/a.mp4", 4),
+ Some((5, 10, b"five".to_vec()))
+ );
+ assert_eq!(loaded.bytes, 4);
+ assert_eq!(loaded.entries.len(), 1);
+}
+
+#[test]
+fn a_claim_is_matched_at_its_offset_first_then_anywhere() {
+ let mut loaded = Loaded::default();
+ loaded.insert(prior(1, 10, "/a.mp4", b"g1@10"));
+ loaded.insert(prior(3, 40, "/a.mp4", b"g3@40"));
+ loaded.insert(prior(3, 20, "/a.mp4", b"g3@20"));
+ loaded.insert(prior(2, 30, "/a.mp4", b"g2@30"));
+ // The exact coordinate wins even over a newer generation elsewhere.
+ assert_eq!(
+ found(&loaded, 10, "/a.mp4", 5),
+ Some((1, 10, b"g1@10".to_vec()))
+ );
+ assert_eq!(
+ found(&loaded, 30, "/a.mp4", 5),
+ Some((2, 30, b"g2@30".to_vec()))
+ );
+ // Elsewhere: the newest generation, then the lowest offset.
+ assert_eq!(
+ found(&loaded, 99, "/a.mp4", 5),
+ Some((3, 20, b"g3@20".to_vec()))
+ );
+ assert_eq!(
+ found(&loaded, 0, "/a.mp4", 5),
+ Some((3, 20, b"g3@20".to_vec()))
+ );
+
+ // Another path, size or type is another claim, at any offset.
+ assert_eq!(found(&loaded, 10, "/b.mp4", 5), None);
+ assert_eq!(found(&loaded, 10, "/a.mp4", 6), None);
+ let quicktime = MediaClaim {
+ media_type: "video/quicktime".into(),
+ expected_len: 5,
+ };
+ assert!(loaded.find(10, "/a.mp4", &quicktime).is_none());
+
+ // Two claims at one record are held side by side.
+ loaded.insert(prior(1, 10, "/b.mp4", b"b@10!"));
+ assert_eq!(
+ found(&loaded, 10, "/b.mp4", 5),
+ Some((1, 10, b"b@10!".to_vec()))
+ );
+ assert_eq!(
+ found(&loaded, 10, "/a.mp4", 5),
+ Some((1, 10, b"g1@10".to_vec()))
+ );
+ assert_eq!(loaded.entries.len(), 5);
+ assert_eq!(loaded.bytes, 25);
+}
+
+#[test]
+fn retained_bytes_never_exceed_the_budget() {
+ let mut loaded = Loaded::with_budget(10);
+ loaded.insert(prior(1, 0, "/a.mp4", b"12345"));
+ loaded.insert(prior(1, 1, "/b.mp4", b"12345"));
+ assert_eq!(loaded.bytes, 10, "exactly the budget is held");
+ // A new claim that would pass the budget is not retained: nothing held
+ // is of a lower generation than it.
+ loaded.insert(prior(1, 2, "/c.mp4", b"1"));
+ assert_eq!(found(&loaded, 2, "/c.mp4", 1), None);
+ assert_eq!(loaded.bytes, 10);
+
+ // A replacement gives its old bytes back first: same size fits.
+ loaded.insert(prior(2, 0, "/a.mp4", b"abcde"));
+ assert_eq!(
+ found(&loaded, 0, "/a.mp4", 5),
+ Some((2, 0, b"abcde".to_vec()))
+ );
+ assert_eq!(loaded.bytes, 10);
+
+ // A frame larger than the whole budget is never held.
+ let mut small = Loaded::with_budget(3);
+ small.insert(prior(1, 0, "/a.mp4", b"1234"));
+ assert_eq!(found(&small, 0, "/a.mp4", 4), None);
+ assert_eq!(small.bytes, 0);
+ small.insert(prior(1, 0, "/a.mp4", b"123"));
+ assert_eq!(small.bytes, 3);
+ assert!(Loaded::with_budget(0).entries.is_empty());
+ assert_eq!(Loaded::default().budget, PRIOR_MAX_BYTES);
+}
+
+#[test]
+fn a_newer_generation_takes_the_room_of_older_ones_lowest_first() {
+ // Two rewrites shifted every offset, so each generation's carried copy
+ // of `/a.mp4` is its own entry; the third generation also captured a
+ // new file. In ledger order the copies alone fill the budget.
+ let mut loaded = Loaded::with_budget(12);
+ loaded.insert(prior(1, 10, "/a.mp4", b"aaaa"));
+ loaded.insert(prior(2, 20, "/a.mp4", b"aaaa"));
+ loaded.insert(prior(3, 30, "/a.mp4", b"aaaa"));
+ assert_eq!(loaded.bytes, 12);
+ loaded.insert(prior(3, 31, "/new.mp4", b"yyyy"));
+ assert_eq!(
+ found(&loaded, 31, "/new.mp4", 4),
+ Some((3, 31, b"yyyy".to_vec())),
+ "the newest generation's only capture of a file is held"
+ );
+ // The lowest generation went, and only as much as was needed.
+ assert_eq!(
+ held(&loaded),
+ [
+ row(2, 20, "/a.mp4", MP4, b"aaaa"),
+ row(3, 30, "/a.mp4", MP4, b"aaaa"),
+ row(3, 31, "/new.mp4", MP4, b"yyyy"),
+ ]
+ );
+ assert_eq!(loaded.bytes, 12);
+ // `find` is unchanged: the coordinate first, then the newest generation.
+ assert_eq!(
+ found(&loaded, 20, "/a.mp4", 4),
+ Some((2, 20, b"aaaa".to_vec()))
+ );
+ assert_eq!(
+ found(&loaded, 10, "/a.mp4", 4),
+ Some((3, 30, b"aaaa".to_vec()))
+ );
+
+ // Several older frames go for one larger frame: generation 2 before
+ // generation 3, and within a generation the lowest offset first.
+ let mut loaded = Loaded::with_budget(12);
+ loaded.insert(prior(3, 5, "/c.mp4", b"333"));
+ loaded.insert(prior(2, 9, "/b.mp4", b"222"));
+ loaded.insert(prior(2, 7, "/a.mp4", b"222"));
+ loaded.insert(prior(3, 6, "/d.mp4", b"333"));
+ assert_eq!(loaded.bytes, 12);
+ loaded.insert(prior(4, 1, "/e.mp4", b"4444444"));
+ assert_eq!(
+ held(&loaded),
+ [
+ row(4, 1, "/e.mp4", MP4, b"4444444"),
+ row(3, 6, "/d.mp4", MP4, b"333"),
+ ]
+ );
+ assert_eq!(loaded.bytes, 10);
+
+ // An equal or higher generation is never given up, and a frame that
+ // still does not fit is refused with nothing given up for it.
+ let mut loaded = Loaded::with_budget(10);
+ loaded.insert(prior(1, 0, "/a.mp4", b"11111"));
+ loaded.insert(prior(5, 1, "/b.mp4", b"55555"));
+ let before = held(&loaded);
+ loaded.insert(prior(5, 2, "/c.mp4", b"555555"));
+ loaded.insert(prior(4, 2, "/c.mp4", b"444444"));
+ loaded.insert(prior(9, 2, "/c.mp4", b"99999999999"));
+ assert_eq!(held(&loaded), before);
+ assert_eq!(loaded.bytes, 10);
+ // Generation 1 alone is room enough for five bytes of generation 4.
+ loaded.insert(prior(4, 2, "/c.mp4", b"44444"));
+ assert_eq!(
+ held(&loaded),
+ [
+ row(5, 1, "/b.mp4", MP4, b"55555"),
+ row(4, 2, "/c.mp4", MP4, b"44444"),
+ ]
+ );
+
+ // A replacement counts its own old bytes as free before it asks the
+ // older generations for more.
+ let mut loaded = Loaded::with_budget(10);
+ loaded.insert(prior(1, 0, "/a.mp4", b"1111"));
+ loaded.insert(prior(2, 1, "/b.mp4", b"222222"));
+ loaded.insert(prior(3, 1, "/b.mp4", b"333333"));
+ assert_eq!(loaded.entries.len(), 2, "same size: nothing else moves");
+ loaded.insert(prior(4, 1, "/b.mp4", b"4444444444"));
+ assert_eq!(held(&loaded), [row(4, 1, "/b.mp4", MP4, b"4444444444")]);
+ assert_eq!(loaded.bytes, 10);
+}
+
+#[test]
+fn an_empty_ledger_directory_holds_no_earlier_capture() {
+ let dir = tempfile::tempdir().unwrap();
+ let loaded = PriorCaptures::new(dir.path()).load("session", "a.jsonl", u64::MAX);
+ assert!(loaded.entries.is_empty());
+ assert_eq!(loaded.bytes, 0);
+}
+
+// ---- `PriorCaptures::load` against real ledgers -------------------------
+
+const SESSION: &str = "session-a";
+const TRANSCRIPT: &str = "projects/a.jsonl";
+/// Above the segment cap below, so a flush after one such frame rotates.
+const LEN: usize = 600;
+
+fn source(session: &str, relative_path: &str) -> SourceDef {
+ SourceDef {
+ ledger_only: false,
+ relative_path: relative_path.to_string(),
+ stream_id: "transcript".to_string(),
+ session_id: session.to_string(),
+ native_session_id: format!("native:{session}"),
+ agent_version: Some("1.0".to_string()),
+ }
+}
+
+fn transcript() -> SourceDef {
+ source(SESSION, TRANSCRIPT)
+}
+
+/// Deterministic bytes no codec shrinks, distinct per seed.
+fn noise(seed: u64, len: usize) -> Vec {
+ let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
+ (0..len)
+ .map(|_| {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ (state >> 24) as u8
+ })
+ .collect()
+}
+
+/// Frames spread over many segments: the cap is below one media frame.
+fn open_ledger(dir: &Path) -> Ledger {
+ Ledger::open(dir).unwrap().with_max_segment_bytes(512)
+}
+
+/// Append one frame and cross a durable checkpoint, as a live pass does.
+fn append(
+ ledger: &mut Ledger,
+ source: &SourceDef,
+ generation: u64,
+ offset: u64,
+ document: CapturedDocument,
+) {
+ ledger
+ .append_document(source, generation, offset, document)
+ .unwrap();
+ ledger.flush().unwrap();
+}
+
+/// One captured frame of the transcript under test, then a checkpoint.
+fn put(ledger: &mut Ledger, generation: u64, offset: u64, path: &str, bytes: &[u8]) {
+ append(
+ ledger,
+ &transcript(),
+ generation,
+ offset,
+ captured(path, bytes),
+ );
+}
+
+fn segments(dir: &Path) -> Vec {
+ let mut paths: Vec = fs::read_dir(dir)
+ .unwrap()
+ .map(|entry| entry.unwrap().path())
+ .filter(|path| path.extension().is_some_and(|ext| ext == "rledger"))
+ .collect();
+ paths.sort();
+ paths
+}
+
+fn indexed_segments(dir: &Path) -> usize {
+ fs::read(dir.join(INDEX_FILE))
+ .map(|index| {
+ index
+ .split(|byte| *byte == b'\n')
+ .filter(|line| !line.is_empty())
+ .count()
+ })
+ .unwrap_or(0)
+}
+
+/// Every regular file of the directory with its bytes.
+fn files(dir: &Path) -> Vec<(PathBuf, Vec)> {
+ let mut files: Vec<_> = fs::read_dir(dir)
+ .unwrap()
+ .map(|entry| entry.unwrap().path())
+ .filter(|path| path.is_file())
+ .map(|path| {
+ let bytes = fs::read(&path).unwrap();
+ (path, bytes)
+ })
+ .collect();
+ files.sort();
+ files
+}
+
+const MP4: &str = "video/mp4";
+type Row = (u64, u64, String, String, Vec);
+
+fn row(generation: u64, offset: u64, path: &str, media_type: &str, bytes: &[u8]) -> Row {
+ (
+ generation,
+ offset,
+ path.to_string(),
+ media_type.to_string(),
+ bytes.to_vec(),
+ )
+}
+
+/// What a `Loaded` holds, in key order.
+fn held(loaded: &Loaded) -> Vec {
+ loaded
+ .entries
+ .values()
+ .map(|prior| {
+ (
+ prior.generation,
+ prior.offset,
+ prior.document.path.clone(),
+ prior.document.media.as_ref().unwrap().media_type.clone(),
+ prior.document.bytes.clone(),
+ )
+ })
+ .collect()
+}
+
+fn load(dir: &Path, through: u64) -> Loaded {
+ PriorCaptures::new(dir).load(SESSION, TRANSCRIPT, through)
+}
+
+#[test]
+fn load_keeps_the_newest_generation_and_the_first_frame_within_one() {
+ let dir = tempfile::tempdir().unwrap();
+ let (one, two, late, again) = (noise(1, LEN), noise(2, LEN), noise(3, LEN), noise(4, LEN));
+ let mut ledger = open_ledger(dir.path());
+ put(&mut ledger, 1, 10, "/out/a.mp4", &one);
+ put(&mut ledger, 2, 10, "/out/a.mp4", &two);
+ // The same coordinate again in generation 2: the first frame stays.
+ put(&mut ledger, 2, 10, "/out/a.mp4", &late);
+ drop(ledger);
+ assert!(
+ segments(dir.path()).len() >= 3,
+ "frames spread over segments"
+ );
+
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(
+ found(&loaded, 10, "/out/a.mp4", LEN as u64),
+ Some((2, 10, two.clone()))
+ );
+ assert_eq!(loaded.entries.len(), 1);
+ assert_eq!(loaded.bytes, LEN as u64);
+
+ // A lower generation appended later in the ledger never replaces a
+ // higher one, and within generation 1 the first frame still wins.
+ let mut ledger = open_ledger(dir.path());
+ put(&mut ledger, 1, 10, "/out/a.mp4", &again);
+ drop(ledger);
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(
+ found(&loaded, 10, "/out/a.mp4", LEN as u64),
+ Some((2, 10, two))
+ );
+ let loaded = load(dir.path(), 1);
+ assert_eq!(
+ found(&loaded, 10, "/out/a.mp4", LEN as u64),
+ Some((1, 10, one))
+ );
+ assert_eq!(loaded.entries.len(), 1);
+}
+
+#[test]
+fn load_excludes_every_frame_that_is_not_this_sources_captured_media() {
+ let dir = tempfile::tempdir().unwrap();
+ let wanted = noise(10, LEN);
+ let other_session = noise(11, LEN);
+ let other_transcript = noise(12, LEN);
+ let other_file = noise(13, LEN);
+ let other_size = noise(14, LEN + 100);
+ let other_type = noise(15, LEN);
+ let newer = noise(16, LEN);
+ let quicktime = MediaClaim {
+ media_type: "video/quicktime".into(),
+ expected_len: LEN as u64,
+ };
+
+ let mut ledger = open_ledger(dir.path());
+ // Another source that names the same file, before and after.
+ let elsewhere = source("session-b", TRANSCRIPT);
+ append(
+ &mut ledger,
+ &elsewhere,
+ 1,
+ 0,
+ captured("/out/a.mp4", &other_session),
+ );
+ // An HTML document at the coordinate, with and without provenance.
+ for recovered_from in [None, Some("/archive/a.mp4".to_string())] {
+ let html = CapturedDocument {
+ media: None,
+ recovered_from,
+ ..captured("/out/a.mp4", &noise(17, LEN))
+ };
+ append(&mut ledger, &transcript(), 1, 0, html);
+ }
+ // Observations that captured nothing.
+ for status in [
+ DocumentCaptureStatus::Missing,
+ DocumentCaptureStatus::TooLarge,
+ DocumentCaptureStatus::Unsupported,
+ DocumentCaptureStatus::ChangedDuringRead,
+ ] {
+ let empty = CapturedDocument {
+ status,
+ bytes: Vec::new(),
+ ..captured("/out/a.mp4", &wanted)
+ };
+ append(&mut ledger, &transcript(), 1, 0, empty.clone());
+ append(&mut ledger, &transcript(), 2, 5, empty);
+ }
+ put(&mut ledger, 1, 0, "/out/a.mp4", &wanted);
+ // The same session's other transcript.
+ let sibling = source(SESSION, "projects/b.jsonl");
+ append(
+ &mut ledger,
+ &sibling,
+ 1,
+ 0,
+ captured("/out/a.mp4", &other_transcript),
+ );
+ // Another file, another size and another type are other claims.
+ put(&mut ledger, 1, 0, "/out/b.mp4", &other_file);
+ put(&mut ledger, 1, 20, "/out/a.mp4", &other_size);
+ let retyped = CapturedDocument {
+ media: Some(quicktime.clone()),
+ ..captured("/out/a.mp4", &other_type)
+ };
+ append(&mut ledger, &transcript(), 1, 20, retyped);
+ // A generation above `through`.
+ put(&mut ledger, 3, 0, "/out/a.mp4", &newer);
+ append(
+ &mut ledger,
+ &elsewhere,
+ 2,
+ 0,
+ captured("/out/a.mp4", &other_session),
+ );
+ drop(ledger);
+ assert!(segments(dir.path()).len() > 8);
+
+ let loaded = load(dir.path(), 2);
+ assert_eq!(
+ held(&loaded),
+ [
+ row(1, 0, "/out/a.mp4", MP4, &wanted),
+ row(1, 0, "/out/b.mp4", MP4, &other_file),
+ row(1, 20, "/out/a.mp4", MP4, &other_size),
+ row(1, 20, "/out/a.mp4", "video/quicktime", &other_type),
+ ]
+ );
+ assert_eq!(loaded.bytes, (4 * LEN + 100) as u64);
+ // The claim is the path, the type and the size: the frames of another
+ // file, size or type never answer for this one, at any offset.
+ for offset in [0, 5, 20, 99] {
+ assert_eq!(
+ found(&loaded, offset, "/out/a.mp4", LEN as u64),
+ Some((1, 0, wanted.clone())),
+ "offset {offset}"
+ );
+ }
+ assert_eq!(
+ loaded
+ .find(0, "/out/a.mp4", &quicktime)
+ .map(|prior| (prior.offset, prior.document.bytes.clone())),
+ Some((20, other_type))
+ );
+ assert_eq!(found(&loaded, 0, "/out/c.mp4", LEN as u64), None);
+
+ // `through` is inclusive, and nothing below it is dropped.
+ let loaded = load(dir.path(), 3);
+ assert_eq!(
+ found(&loaded, 0, "/out/a.mp4", LEN as u64),
+ Some((3, 0, newer))
+ );
+ assert_eq!(loaded.entries.len(), 4);
+ assert!(load(dir.path(), 0).entries.is_empty());
+
+ // Each source sees only its own frames.
+ let captures = PriorCaptures::new(dir.path());
+ let theirs = captures.load("session-b", TRANSCRIPT, 1);
+ assert_eq!(
+ held(&theirs),
+ [row(1, 0, "/out/a.mp4", MP4, &other_session)]
+ );
+ let siblings = captures.load(SESSION, "projects/b.jsonl", u64::MAX);
+ assert_eq!(
+ held(&siblings),
+ [row(1, 0, "/out/a.mp4", MP4, &other_transcript)]
+ );
+ assert!(
+ captures
+ .load("session-c", TRANSCRIPT, u64::MAX)
+ .entries
+ .is_empty()
+ );
+ assert!(
+ captures
+ .load(SESSION, "projects/c.jsonl", u64::MAX)
+ .entries
+ .is_empty()
+ );
+}
+
+#[test]
+fn the_open_segment_is_read_and_the_index_only_narrows() {
+ let dir = tempfile::tempdir().unwrap();
+ let index = dir.path().join(INDEX_FILE);
+ let (first, second, open, buffered) =
+ (noise(20, LEN), noise(21, LEN), noise(22, 16), noise(23, 16));
+ let mut ledger = open_ledger(dir.path());
+ put(&mut ledger, 1, 0, "/out/a.mp4", &first);
+ put(&mut ledger, 1, 100, "/out/b.mp4", &second);
+ // A small frame: its segment stays under the cap, so it stays open.
+ put(&mut ledger, 2, 200, "/out/c.mp4", &open);
+ assert_eq!(segments(dir.path()).len(), 3);
+ assert_eq!(indexed_segments(dir.path()), 2, "the last segment is open");
+
+ let before = files(dir.path());
+ let loaded = load(dir.path(), u64::MAX);
+ let expected = [
+ row(1, 0, "/out/a.mp4", MP4, &first),
+ row(1, 100, "/out/b.mp4", MP4, &second),
+ row(2, 200, "/out/c.mp4", MP4, &open),
+ ];
+ assert_eq!(held(&loaded), expected);
+ assert_eq!(files(dir.path()), before, "the lookup writes nothing");
+
+ // The writer holds a frame it has not flushed: the reader stops at
+ // the open segment's tail and keeps everything before it.
+ ledger
+ .append_document(&transcript(), 2, 300, captured("/out/d.mp4", &buffered))
+ .unwrap();
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(held(&loaded)[..3], expected);
+ ledger.flush().unwrap();
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(
+ found(&loaded, 300, "/out/d.mp4", 16),
+ Some((2, 300, buffered))
+ );
+ let complete = held(&loaded);
+ assert_eq!(complete.len(), 4);
+
+ // No index at all: every segment is walked, and none is written.
+ fs::remove_file(&index).unwrap();
+ assert_eq!(held(&load(dir.path(), u64::MAX)), complete);
+ assert!(!index.exists());
+
+ // An index that knows only what closed since: the same answer.
+ drop(ledger);
+ assert!(indexed_segments(dir.path()) < segments(dir.path()).len());
+ assert_eq!(held(&load(dir.path(), u64::MAX)), complete);
+}
+
+#[test]
+fn a_truncated_tail_ends_only_its_own_segment() {
+ for codec in [SegmentCodec::default(), SegmentCodec::Plain] {
+ let dir = tempfile::tempdir().unwrap();
+ let (a, b, c, d) = (
+ noise(30, LEN),
+ noise(31, LEN),
+ noise(32, LEN),
+ noise(33, LEN),
+ );
+ let mut ledger = open_ledger(dir.path()).with_codec(codec);
+ put(&mut ledger, 1, 0, "/out/a.mp4", &a);
+ // Two frames in one segment: no checkpoint between them.
+ ledger
+ .append_document(&transcript(), 1, 100, captured("/out/b.mp4", &b))
+ .unwrap();
+ put(&mut ledger, 1, 200, "/out/c.mp4", &c);
+ put(&mut ledger, 1, 300, "/out/d.mp4", &d);
+ drop(ledger);
+ let paths = segments(dir.path());
+ assert_eq!(paths.len(), 3, "{codec:?}");
+ assert_eq!(load(dir.path(), u64::MAX).entries.len(), 4, "{codec:?}");
+
+ let torn = &paths[1];
+ let whole = fs::metadata(torn).unwrap().len();
+ // The tail cut inside the last frame, then the header cut, then
+ // nothing left: the neighbours' frames are found every time.
+ for (keep, label) in [(whole - 40, "tail"), (2, "header"), (0, "empty")] {
+ fs::OpenOptions::new()
+ .write(true)
+ .open(torn)
+ .unwrap()
+ .set_len(keep)
+ .unwrap();
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(
+ found(&loaded, 0, "/out/a.mp4", LEN as u64),
+ Some((1, 0, a.clone())),
+ "{codec:?} {label}"
+ );
+ assert_eq!(
+ found(&loaded, 300, "/out/d.mp4", LEN as u64),
+ Some((1, 300, d.clone())),
+ "{codec:?} {label}"
+ );
+ assert_eq!(
+ found(&loaded, 200, "/out/c.mp4", LEN as u64),
+ None,
+ "{codec:?} {label}"
+ );
+ // A plain segment keeps every whole frame before the cut.
+ if codec == SegmentCodec::Plain {
+ assert_eq!(
+ found(&loaded, 100, "/out/b.mp4", LEN as u64),
+ (label == "tail").then(|| (1, 100, b.clone())),
+ "{label}"
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn a_missing_ledger_directory_holds_nothing_and_is_not_created() {
+ let dir = tempfile::tempdir().unwrap();
+ let absent = dir.path().join("absent").join("ledger");
+ let loaded = PriorCaptures::new(&absent).load(SESSION, TRANSCRIPT, u64::MAX);
+ assert!(loaded.entries.is_empty());
+ assert_eq!(loaded.bytes, 0);
+ assert_eq!(loaded.budget, PRIOR_MAX_BYTES);
+ assert!(!dir.path().join("absent").exists());
+
+ // A path that is not a directory cannot be listed: still no panic.
+ let file = dir.path().join("not-a-directory");
+ fs::write(&file, b"x").unwrap();
+ let loaded = PriorCaptures::new(&file).load(SESSION, TRANSCRIPT, u64::MAX);
+ assert!(loaded.entries.is_empty());
+}
+
+#[test]
+fn an_unreadable_segment_gives_an_empty_answer_not_a_partial_one() {
+ let dir = tempfile::tempdir().unwrap();
+ let (a, b) = (noise(40, LEN), noise(41, LEN));
+ let elsewhere = source("session-b", "projects/b.jsonl");
+ let mut ledger = open_ledger(dir.path());
+ put(&mut ledger, 1, 0, "/out/a.mp4", &a);
+ append(
+ &mut ledger,
+ &elsewhere,
+ 1,
+ 0,
+ captured("/out/x.mp4", &noise(42, LEN)),
+ );
+ put(&mut ledger, 2, 0, "/out/a.mp4", &b);
+ drop(ledger);
+ let paths = segments(dir.path());
+ assert_eq!(paths.len(), 3);
+ assert_eq!(indexed_segments(dir.path()), 3);
+
+ // The other source's segment can be opened but not read.
+ fs::remove_file(&paths[1]).unwrap();
+ fs::create_dir(&paths[1]).unwrap();
+
+ // The index says that segment holds nothing of this source, so the
+ // scoped walk never opens it.
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(found(&loaded, 0, "/out/a.mp4", LEN as u64), Some((2, 0, b)));
+ // The source it does hold reads it, fails, and gets nothing.
+ let theirs = PriorCaptures::new(dir.path()).load("session-b", "projects/b.jsonl", u64::MAX);
+ assert!(theirs.entries.is_empty());
+
+ // With no index the walk reaches it after segment 0 gave a frame.
+ // Returning that frame alone would carry generation 1 over the
+ // generation 2 behind the failure: the answer is empty instead.
+ fs::remove_file(dir.path().join(INDEX_FILE)).unwrap();
+ let loaded = load(dir.path(), u64::MAX);
+ assert!(loaded.entries.is_empty());
+ assert_eq!(loaded.bytes, 0);
+
+ // An index that cannot be read is the same: nothing, no panic.
+ fs::remove_dir(&paths[1]).unwrap();
+ assert_eq!(load(dir.path(), u64::MAX).entries.len(), 1);
+ fs::create_dir(dir.path().join(INDEX_FILE)).unwrap();
+ assert!(load(dir.path(), u64::MAX).entries.is_empty());
+}
+
+#[test]
+fn load_retains_no_frame_past_the_byte_budget() {
+ let dir = tempfile::tempdir().unwrap();
+ let (a, b, c, d, newer) = (
+ noise(50, LEN),
+ noise(51, LEN),
+ noise(52, LEN),
+ noise(53, LEN),
+ noise(54, LEN),
+ );
+ let mut ledger = open_ledger(dir.path());
+ put(&mut ledger, 1, 0, "/out/a.mp4", &a);
+ put(&mut ledger, 1, 100, "/out/b.mp4", &b);
+ put(&mut ledger, 1, 200, "/out/c.mp4", &c);
+ put(&mut ledger, 1, 300, "/out/d.mp4", &d);
+ put(&mut ledger, 2, 0, "/out/a.mp4", &newer);
+ drop(ledger);
+ let captures = PriorCaptures::new(dir.path());
+ let within = |budget| captures.load_within(SESSION, TRANSCRIPT, u64::MAX, budget);
+ let len = LEN as u64;
+
+ // Room for two frames: the first two in ledger order are held, the
+ // later claims are not, and the held claim's newer generation still
+ // replaces it because it gives the old bytes back first.
+ for budget in [2 * len, 3 * len - 1] {
+ let loaded = within(budget);
+ assert_eq!(loaded.budget, budget);
+ assert_eq!(loaded.bytes, 2 * len);
+ assert_eq!(loaded.entries.len(), 2);
+ assert_eq!(
+ found(&loaded, 0, "/out/a.mp4", len),
+ Some((2, 0, newer.clone()))
+ );
+ assert_eq!(
+ found(&loaded, 100, "/out/b.mp4", len),
+ Some((1, 100, b.clone()))
+ );
+ assert_eq!(found(&loaded, 200, "/out/c.mp4", len), None);
+ assert_eq!(found(&loaded, 300, "/out/d.mp4", len), None);
+ }
+
+ // Too small for any frame, and exactly enough for all of them.
+ for budget in [0, len - 1] {
+ let loaded = within(budget);
+ assert!(loaded.entries.is_empty());
+ assert_eq!(loaded.bytes, 0);
+ }
+ let loaded = within(4 * len);
+ assert_eq!(loaded.entries.len(), 4);
+ assert_eq!(loaded.bytes, 4 * len);
+
+ // `load` itself runs under the pass's whole budget.
+ let loaded = captures.load(SESSION, TRANSCRIPT, u64::MAX);
+ assert_eq!(loaded.budget, PRIOR_MAX_BYTES);
+ assert_eq!(loaded.entries.len(), 4);
+ assert_eq!(found(&loaded, 300, "/out/d.mp4", len), Some((1, 300, d)));
+}
+
+#[test]
+fn find_after_load_matches_the_coordinate_first_then_the_claim_anywhere() {
+ let dir = tempfile::tempdir().unwrap();
+ let len = LEN as u64;
+ let frames = [
+ (1_u64, 10_u64, noise(60, LEN)),
+ (3, 40, noise(61, LEN)),
+ (3, 20, noise(62, LEN)),
+ (2, 30, noise(63, LEN)),
+ ];
+ let mut ledger = open_ledger(dir.path());
+ for (generation, offset, bytes) in &frames {
+ put(&mut ledger, *generation, *offset, "/out/a.mp4", bytes);
+ }
+ // Another file sent by the record at offset 10.
+ let beside = noise(64, LEN);
+ put(&mut ledger, 1, 10, "/out/b.mp4", &beside);
+ drop(ledger);
+ assert!(segments(dir.path()).len() >= 5);
+
+ let loaded = load(dir.path(), u64::MAX);
+ assert_eq!(loaded.entries.len(), 5);
+ // The exact coordinate wins, even over a newer generation elsewhere.
+ for (generation, offset, bytes) in &frames {
+ assert_eq!(
+ found(&loaded, *offset, "/out/a.mp4", len),
+ Some((*generation, *offset, bytes.clone()))
+ );
+ }
+ // Elsewhere: the newest generation, then the lowest offset.
+ for offset in [0, 11, 99] {
+ assert_eq!(
+ found(&loaded, offset, "/out/a.mp4", len),
+ Some((3, 20, frames[2].2.clone())),
+ "offset {offset}"
+ );
+ }
+ assert_eq!(
+ found(&loaded, 10, "/out/b.mp4", len),
+ Some((1, 10, beside.clone()))
+ );
+ assert_eq!(found(&loaded, 40, "/out/b.mp4", len), Some((1, 10, beside)));
+ assert_eq!(found(&loaded, 10, "/out/a.mp4", len + 1), None);
+ assert_eq!(found(&loaded, 10, "/out/c.mp4", len), None);
+
+ // A pass that may only see generations up to 2 carries from those.
+ let loaded = load(dir.path(), 2);
+ assert_eq!(
+ found(&loaded, 40, "/out/a.mp4", len),
+ Some((2, 30, frames[3].2.clone()))
+ );
+ assert_eq!(
+ found(&loaded, 10, "/out/a.mp4", len),
+ Some((1, 10, frames[0].2.clone()))
+ );
+}
diff --git a/crates/pipeline/src/presented_media/slots.rs b/crates/pipeline/src/presented_media/slots.rs
new file mode 100644
index 00000000..f69417be
--- /dev/null
+++ b/crates/pipeline/src/presented_media/slots.rs
@@ -0,0 +1,65 @@
+//! Replay's join state for one source: the presented-media facts its raw
+//! records normalized to, waiting for the frame that captured each file.
+//! RAM only; a rebuilt replay notes them again from the same records.
+
+use std::collections::BTreeMap;
+
+use rosetta_core::IngestOp;
+use rosetta_core::presented_media::PresentedMedia;
+
+/// Facts one source may hold at once.
+const SLOTS_MAX: usize = 65_536;
+
+/// Facts keyed `(source_offset, path)`: the coordinate a media frame names.
+#[derive(Debug)]
+pub(crate) struct Slots {
+ facts: BTreeMap<(u64, String), PresentedMedia>,
+ cap: usize,
+}
+
+impl Default for Slots {
+ fn default() -> Self {
+ Self::with_cap(SLOTS_MAX)
+ }
+}
+
+impl Slots {
+ pub(crate) fn with_cap(cap: usize) -> Self {
+ Self {
+ facts: BTreeMap::new(),
+ cap,
+ }
+ }
+
+ /// Keep every fact among a record's ops. The first fact at a coordinate
+ /// stays, as capture observes a coordinate once. Past the cap the lowest
+ /// coordinate goes: its frame, if any, has been met or will be ignored.
+ pub(crate) fn note(&mut self, ops: &[IngestOp]) {
+ for op in ops {
+ let IngestOp::PresentedMedia(fact) = op else {
+ continue;
+ };
+ self.facts
+ .entry((fact.source_offset, fact.path.clone()))
+ .or_insert_with(|| fact.clone());
+ while self.facts.len() > self.cap {
+ self.facts.pop_first();
+ }
+ }
+ }
+
+ pub(crate) fn get(&self, offset: u64, path: &str) -> Option<&PresentedMedia> {
+ // A borrowed lookup would need a second key type; a media frame is
+ // rare enough that one path copy is not worth it.
+ self.facts.get(&(offset, path.to_owned()))
+ }
+
+ pub(crate) fn remove(&mut self, offset: u64, path: &str) {
+ self.facts.remove(&(offset, path.to_owned()));
+ }
+
+ #[cfg(test)]
+ pub(crate) fn len(&self) -> usize {
+ self.facts.len()
+ }
+}
diff --git a/crates/pipeline/src/presented_media/store_tests.rs b/crates/pipeline/src/presented_media/store_tests.rs
new file mode 100644
index 00000000..9c558f72
--- /dev/null
+++ b/crates/pipeline/src/presented_media/store_tests.rs
@@ -0,0 +1,257 @@
+//! Every projection the capture seam can emit goes through a real store:
+//! names and paths at the fact's bounds, and video headers written to be
+//! hostile. A store validation error is fatal to the daemon, so nothing a
+//! projection emits may raise one.
+
+use std::path::Path;
+
+use rosetta_core::MessageRow;
+use rosetta_core::timeline::{AttachmentRef, Dimensions, TimelineBody};
+use rosetta_store::{Batch, CheckpointUpdate, SourceMode, SourceWrite, Store, queries};
+use serde_json::json;
+use tempfile::TempDir;
+
+use super::tests::{captured, facts, mp4, png, record, video_header};
+use super::*;
+
+/// `moov` > `trak` > `tkhd` (version 0) recording `width` x `height` under
+/// the identity matrix: a size is read only through a matrix that proves it.
+fn movie(width: u32, height: u32) -> Vec {
+ let mut tkhd = vec![0_u8; 84];
+ for (cell, value) in [(0, 0x0001_0000_u32), (4, 0x0001_0000), (8, 0x4000_0000)] {
+ tkhd[40 + 4 * cell..44 + 4 * cell].copy_from_slice(&value.to_be_bytes());
+ }
+ tkhd[76..80].copy_from_slice(&(width << 16).to_be_bytes());
+ tkhd[80..84].copy_from_slice(&(height << 16).to_be_bytes());
+ let boxed = |kind: &[u8; 4], body: &[u8]| {
+ let mut bytes = u32::try_from(body.len() + 8)
+ .unwrap()
+ .to_be_bytes()
+ .to_vec();
+ bytes.extend_from_slice(kind);
+ bytes.extend_from_slice(body);
+ bytes
+ };
+ boxed(b"moov", &boxed(b"trak", &boxed(b"tkhd", &tkhd)))
+}
+
+fn ftyp(major: &[u8; 4]) -> Vec {
+ video_header(major, 28)[..20].to_vec()
+}
+
+/// A store with the tool row every test fact upgrades, and its cursor.
+fn store_with_opener(projection: &Path) -> (Store, i64) {
+ let mut store = Store::open(projection).unwrap();
+ store
+ .apply(&Batch {
+ ops: vec![
+ IngestOp::Runtime(
+ serde_json::from_value(json!({
+ "id": "rt", "kind": "claude-code", "data_root": "/agent",
+ "enabled": true, "discovered_at": 1
+ }))
+ .unwrap(),
+ ),
+ IngestOp::SessionMeta(
+ serde_json::from_value(json!({
+ "id": "root", "native_session_id": "root", "runtime_id": "rt",
+ "root_session_id": "root", "role": "main",
+ "started_at": 1, "updated_at": 1, "stream_state": "streaming"
+ }))
+ .unwrap(),
+ ),
+ ],
+ checkpoints: vec![CheckpointUpdate {
+ runtime_id: "rt".into(),
+ stream: "transcript".into(),
+ path: "source.jsonl".into(),
+ session_id: Some("root".into()),
+ expected: None,
+ next: "fresh".into(),
+ updated_at: None,
+ }],
+ source: None,
+ })
+ .unwrap();
+ let cursor_id = queries::cursor_state(store.conn(), "rt", "source.jsonl")
+ .unwrap()
+ .unwrap()
+ .id;
+ (store, cursor_id)
+}
+
+fn opener(cursor_id: i64) -> Vec {
+ let row: MessageRow = serde_json::from_value(json!({
+ "timeline_id": "tool", "session_id": "root", "ordinal": 0, "kind": "tool_use",
+ "ts": 5, "attachments": [], "citations": [], "src_cursor": cursor_id, "src_offset": 0,
+ "tool": {
+ "correlation": {"key": "call", "derivation": "native_id"},
+ "native": {"id": "call", "name": "Bash"},
+ "status": {"value": "pending", "basis": "request_observed"},
+ "action": {"type": "shell", "command": {"type": "inline", "text": "true"},
+ "source_truncated": false, "background": false}
+ }
+ }))
+ .unwrap();
+ IngestOp::project_message(row)
+}
+
+fn tool_attachments(store: &Store) -> Vec {
+ let rows = queries::timeline_page(store.conn(), "root", None, 100).unwrap();
+ let [row] = &rows[..] else {
+ panic!("one tool row: {rows:?}")
+ };
+ let TimelineBody::ToolUse { tool } = &row.body else {
+ panic!("{row:?}")
+ };
+ tool.attachments.clone()
+}
+
+#[test]
+fn every_projection_applies_to_a_real_store() {
+ let dir = TempDir::new().unwrap();
+ let projection = dir.path().join("projection.sqlite");
+ let (mut store, cursor_id) = store_with_opener(&projection);
+
+ // Paths no filesystem would hold, at the fact's own bounds.
+ let long_name = format!("/{}.mp4", "n".repeat(16_384 - 5));
+ let deep = format!("/{}aclip.mp4", "d/".repeat((16_384 - 10) / 2));
+ assert_eq!((long_name.len(), deep.len()), (16_384, 16_384));
+ let padded = |mut bytes: Vec, kind: &[u8]| {
+ bytes.extend_from_slice(kind);
+ bytes
+ };
+ let cases: Vec<(&str, &str, &str, Vec)> = vec![
+ ("an mp4", "/work/a.mp4", "video/mp4", mp4(64, 1)),
+ (
+ "a quicktime file",
+ "/work/b.mov",
+ "video/quicktime",
+ [ftyp(b"qt "), movie(1920, 1080)].concat(),
+ ),
+ ("a png", "/work/c.png", "image/png", png()),
+ ("a name at the bound", &long_name, "video/mp4", mp4(64, 2)),
+ ("a path at the bound", &deep, "video/mp4", mp4(64, 3)),
+ (
+ "the largest size a track header can record",
+ "/work/d.mp4",
+ "video/mp4",
+ [ftyp(b"isom"), movie(65_535, 65_535)].concat(),
+ ),
+ (
+ "a track header of zero by zero",
+ "/work/e.mp4",
+ "video/mp4",
+ [ftyp(b"isom"), movie(0, 0)].concat(),
+ ),
+ (
+ "a moov that claims four gigabytes",
+ "/work/f.mp4",
+ "video/mp4",
+ padded(
+ [ftyp(b"isom"), 0xffff_ffff_u32.to_be_bytes().to_vec()].concat(),
+ b"moov",
+ ),
+ ),
+ (
+ "a box with a 64-bit size past the file",
+ "/work/g.mp4",
+ "video/mp4",
+ [
+ ftyp(b"mp42"),
+ padded(1_u32.to_be_bytes().to_vec(), b"moov"),
+ u64::MAX.to_be_bytes().to_vec(),
+ ]
+ .concat(),
+ ),
+ (
+ "a box that runs to the end of the file",
+ "/work/h.mp4",
+ "video/mp4",
+ [
+ ftyp(b"avc1"),
+ padded(0_u32.to_be_bytes().to_vec(), b"moov"),
+ vec![0xff; 40],
+ ]
+ .concat(),
+ ),
+ (
+ "a moov cut inside its track header",
+ "/work/i.mp4",
+ "video/mp4",
+ [ftyp(b"isom"), movie(640, 480)[..60].to_vec()].concat(),
+ ),
+ ("nothing but the ftyp", "/work/j.mp4", "video/mp4", {
+ ftyp(b"iso6")
+ }),
+ (
+ "an ftyp that claims more than the file",
+ "/work/k.mp4",
+ "video/mp4",
+ padded(4096_u32.to_be_bytes().to_vec(), b"ftypisom"),
+ ),
+ ];
+
+ let mut ops = opener(cursor_id);
+ let mut accepted = Vec::new();
+ for (index, (name, path, media_type, bytes)) in cases.iter().enumerate() {
+ let mut emitted = record(
+ 100 + index as u64,
+ 1,
+ &[(path, bytes.len() as u64, media_type)],
+ );
+ let fact = facts(&emitted).remove(0);
+ if project(&fact, &captured(&fact, bytes.clone()), &mut emitted) {
+ accepted.push((*name, fact.attachment.id.clone(), bytes.clone()));
+ }
+ ops.extend(emitted);
+ }
+ let names: Vec<&str> = accepted.iter().map(|(name, _, _)| *name).collect();
+ assert!(names.contains(&"a name at the bound") && names.contains(&"a path at the bound"));
+ assert!(names.contains(&"the largest size a track header can record"));
+ assert!(names.contains(&"a moov that claims four gigabytes"));
+ assert!(!names.contains(&"an ftyp that claims more than the file"));
+
+ store
+ .apply(&Batch {
+ ops,
+ checkpoints: Vec::new(),
+ source: Some(SourceWrite {
+ cursor_id,
+ session_id: "root".into(),
+ generation: 1,
+ first_sequence: 0,
+ mode: SourceMode::Activate,
+ }),
+ })
+ .expect("nothing a projection emits fails the store");
+
+ let attachments = tool_attachments(&store);
+ assert_eq!(attachments.len(), cases.len(), "every sent file has a slot");
+ let root = rosetta_store::assets::asset_root(&projection);
+ for attachment in &attachments {
+ attachment.validate().unwrap();
+ let captured = accepted.iter().find(|(_, id, _)| *id == attachment.id);
+ match (&attachment.source, captured) {
+ (AttachmentSource::Asset { asset }, Some((name, _, bytes))) => {
+ assert_eq!(asset.byte_len, bytes.len() as u64, "{name}");
+ let member = Path::new(&asset.url).file_name().unwrap();
+ assert_eq!(std::fs::read(root.join(member)).unwrap(), *bytes, "{name}");
+ }
+ (AttachmentSource::Reference { .. }, None) => {}
+ (source, _) => panic!("{}: {source:?}", attachment.id),
+ }
+ }
+ let sized: Vec> = attachments
+ .iter()
+ .filter_map(|attachment| attachment.dimensions.map(Some))
+ .collect();
+ assert!(sized.contains(&Some(Dimensions {
+ width: 65_535,
+ height: 65_535
+ })));
+ assert!(sized.contains(&Some(Dimensions {
+ width: 1920,
+ height: 1080
+ })));
+}
diff --git a/crates/pipeline/src/presented_media/tests.rs b/crates/pipeline/src/presented_media/tests.rs
new file mode 100644
index 00000000..c30f35a7
--- /dev/null
+++ b/crates/pipeline/src/presented_media/tests.rs
@@ -0,0 +1,892 @@
+//! The capture seam and its replay join, with temp files and hand-built
+//! facts: every status a file can end with, the ledger-first lookup, the
+//! reader seam that moves a file under its read, and the replay slots.
+
+use std::io::Cursor;
+use std::path::Path;
+use std::time::{Duration, SystemTime};
+
+use base64::Engine;
+use rosetta_adapter_support::NormalizeCtx;
+use rosetta_adapter_support::presented_media::{PresentedFile, push};
+use rosetta_core::ProjectionAsset;
+use rosetta_core::presented_media::VIDEO_MAX_BYTES;
+use rosetta_core::timeline::{AttachmentRef, Dimensions, EvidenceOrder};
+use tempfile::TempDir;
+
+use super::prior::Prior;
+use super::*;
+
+const MIB: u64 = 1024 * 1024;
+const PNG: &str =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
+
+pub(super) fn png() -> Vec {
+ base64::engine::general_purpose::STANDARD
+ .decode(PNG)
+ .unwrap()
+}
+
+/// The 28 bytes a padded video starts with: an `ftyp` box of `major` and
+/// one compatible brand, then the header of a `free` box that runs to `len`.
+pub(super) fn video_header(major: &[u8; 4], len: usize) -> Vec {
+ assert!(len >= 28);
+ let mut bytes = 20_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(b"ftyp");
+ bytes.extend_from_slice(major);
+ bytes.extend_from_slice(&[0, 0, 2, 0]);
+ bytes.extend_from_slice(b"mp41");
+ bytes.extend_from_slice(&u32::try_from(len - 20).unwrap().to_be_bytes());
+ bytes.extend_from_slice(b"free");
+ bytes
+}
+
+/// A well-formed MP4 of exactly `len` bytes; `seed` makes its content its own.
+pub(super) fn mp4(len: usize, seed: u8) -> Vec {
+ let mut bytes = video_header(b"isom", len);
+ bytes.resize(len, seed);
+ bytes
+}
+
+fn ctx(offset: u64, generation: u64) -> NormalizeCtx {
+ let mut ctx = NormalizeCtx::new("rt", "root");
+ ctx.src_offset = Some(offset as i64);
+ ctx.source_identity = Some("source.jsonl".into());
+ ctx.source_generation = generation;
+ ctx
+}
+
+/// One record's ops as an adapter emits them: the reference update, then one
+/// fact per presentable file.
+pub(super) fn record(offset: u64, generation: u64, files: &[(&str, u64, &str)]) -> Vec {
+ let files: Vec> = files
+ .iter()
+ .map(|(path, len, media_type)| PresentedFile {
+ path,
+ cwd: None,
+ len: *len,
+ media_type,
+ })
+ .collect();
+ let mut ops = Vec::new();
+ let facts = push(
+ &ctx(offset, generation),
+ "call",
+ &format!("result-{offset}"),
+ &files,
+ Some(7),
+ &mut ops,
+ );
+ assert_eq!(facts.len(), files.len(), "every test file is presentable");
+ ops.extend(facts.into_iter().map(IngestOp::PresentedMedia));
+ ops
+}
+
+pub(super) fn facts(ops: &[IngestOp]) -> Vec {
+ ops.iter()
+ .filter_map(|op| match op {
+ IngestOp::PresentedMedia(fact) => Some(fact.clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+pub(super) fn members(ops: &[IngestOp]) -> Vec<&ProjectionAsset> {
+ ops.iter()
+ .filter_map(|op| match op {
+ IngestOp::ProjectionAsset { asset } => Some(asset),
+ _ => None,
+ })
+ .collect()
+}
+
+/// The upgrades among `ops`: each tool update whose one attachment is an asset.
+pub(super) fn upgrades(ops: &[IngestOp]) -> Vec<(EvidenceOrder, AttachmentRef)> {
+ ops.iter()
+ .filter_map(|op| match op {
+ IngestOp::ToolUpdate {
+ evidence, patch, ..
+ } => match patch.attachments.as_slice() {
+ [attachment] if matches!(attachment.source, AttachmentSource::Asset { .. }) => {
+ Some((evidence.clone(), attachment.clone()))
+ }
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect()
+}
+
+pub(super) fn write(dir: &Path, name: &str, bytes: &[u8]) -> String {
+ let path = dir.join(name);
+ std::fs::write(&path, bytes).unwrap();
+ path.to_str().unwrap().to_owned()
+}
+
+pub(super) fn captured(fact: &PresentedMedia, bytes: Vec) -> CapturedDocument {
+ CapturedDocument {
+ path: fact.path.clone(),
+ captured_at: 1234,
+ status: Status::Captured,
+ recovered_from: None,
+ media: Some(claim(fact)),
+ bytes,
+ }
+}
+
+pub(super) fn statuses(frames: &[(u64, CapturedDocument)]) -> Vec {
+ frames.iter().map(|(_, document)| document.status).collect()
+}
+
+#[test]
+fn a_captured_frame_projects_the_member_and_the_upgrade_of_its_slot() {
+ let bytes = mp4(64, 1);
+ let ops = record(40, 2, &[("/work/clip.mp4", 64, "video/mp4")]);
+ let fact = facts(&ops).remove(0);
+ let mut projected = Vec::new();
+ assert!(project(
+ &fact,
+ &captured(&fact, bytes.clone()),
+ &mut projected
+ ));
+ assert_eq!(projected.len(), 2, "the member, then the upgrade");
+ let [member] = members(&projected)[..] else {
+ panic!("{projected:?}")
+ };
+ assert_eq!(member.bytes, bytes, "the member is the recorded bytes");
+ assert_eq!(member.media_type, "video/mp4");
+ let [(evidence, upgraded)] = &upgrades(&projected)[..] else {
+ panic!("{projected:?}")
+ };
+ assert_eq!(*evidence, fact.evidence);
+ assert_eq!(upgraded.id, fact.attachment.id);
+ assert_eq!(upgraded.kind, fact.attachment.kind);
+ assert_eq!(upgraded.name.as_deref(), Some("clip.mp4"));
+ assert_eq!(upgraded.media_type.as_deref(), Some("video/mp4"));
+ assert_eq!(upgraded.byte_len, Some(64));
+ assert_eq!(
+ upgraded.source,
+ AttachmentSource::Asset {
+ asset: member.reference()
+ }
+ );
+ upgraded.validate().unwrap();
+ let IngestOp::ToolUpdate {
+ session_id,
+ correlation_key,
+ at,
+ ..
+ } = &projected[1]
+ else {
+ panic!("{projected:?}")
+ };
+ assert_eq!(
+ (session_id.as_str(), correlation_key.as_str(), *at),
+ ("root", "call", Some(7))
+ );
+
+ // An image carries the size its header proves.
+ let ops = record(
+ 41,
+ 2,
+ &[("/work/shot.png", png().len() as u64, "image/png")],
+ );
+ let image = facts(&ops).remove(0);
+ let mut projected = Vec::new();
+ assert!(project(&image, &captured(&image, png()), &mut projected));
+ assert_eq!(
+ upgrades(&projected)[0].1.dimensions,
+ Some(Dimensions {
+ width: 1,
+ height: 1
+ })
+ );
+}
+
+#[test]
+fn a_frame_that_is_not_its_facts_capture_projects_nothing() {
+ let bytes = mp4(64, 1);
+ let ops = record(40, 2, &[("/work/clip.mp4", 64, "video/mp4")]);
+ let fact = facts(&ops).remove(0);
+ let good = captured(&fact, bytes.clone());
+ type Break = fn(&mut CapturedDocument);
+ let cases: [(&str, Break); 10] = [
+ ("missing", |d| {
+ d.status = Status::Missing;
+ d.bytes.clear()
+ }),
+ ("too large", |d| d.status = Status::TooLarge),
+ ("unsupported", |d| d.status = Status::Unsupported),
+ ("changed", |d| d.status = Status::ChangedDuringRead),
+ ("an html frame", |d| d.media = None),
+ ("another type", |d| {
+ d.media.as_mut().unwrap().media_type = "video/quicktime".into()
+ }),
+ ("another size", |d| {
+ d.media.as_mut().unwrap().expected_len = 65
+ }),
+ ("another path", |d| d.path = "/work/other.mp4".into()),
+ ("short bytes", |d| d.bytes.truncate(63)),
+ ("not a video", |d| d.bytes = vec![b'x'; 64]),
+ ];
+ for (name, break_it) in cases {
+ let mut document = good.clone();
+ break_it(&mut document);
+ let mut projected = Vec::new();
+ assert!(!project(&fact, &document, &mut projected), "{name}");
+ assert!(projected.is_empty(), "{name}");
+ }
+ // A fact that could not be applied is refused whatever the frame holds.
+ let mut broken = fact.clone();
+ broken.correlation_key.clear();
+ let mut projected = Vec::new();
+ assert!(!project(&broken, &good, &mut projected));
+ // The declared type decides: MP4 bytes are not the QuickTime they claim.
+ let ops = record(40, 2, &[("/work/clip.mov", 64, "video/quicktime")]);
+ let mov = facts(&ops).remove(0);
+ assert!(!project(&mov, &captured(&mov, bytes), &mut projected));
+ assert!(projected.is_empty());
+}
+
+#[test]
+fn every_file_ends_with_the_status_of_what_was_there() {
+ let dir = TempDir::new().unwrap();
+ let ok = write(dir.path(), "ok.mp4", &mp4(64, 1));
+ let image = write(dir.path(), "shot.png", &png());
+ let missing = dir.path().join("gone.mp4").to_str().unwrap().to_owned();
+ let grown = write(dir.path(), "grown.mp4", &mp4(65, 2));
+ let shrunk = write(dir.path(), "shrunk.mp4", &mp4(63, 3));
+ let text = write(dir.path(), "text.mp4", &[b'x'; 64]);
+ let mislabeled = write(dir.path(), "video.png", &mp4(64, 4));
+ let folder = dir.path().join("folder.mp4");
+ std::fs::create_dir(&folder).unwrap();
+ let folder = folder.to_str().unwrap().to_owned();
+ let link = dir.path().join("link.mp4");
+ std::os::unix::fs::symlink(&ok, &link).unwrap();
+ let link = link.to_str().unwrap().to_owned();
+ // Neither exists: a claim over the limit is decided before any open.
+ let huge_video = dir.path().join("huge.mp4").to_str().unwrap().to_owned();
+ let huge_image = dir.path().join("huge.png").to_str().unwrap().to_owned();
+
+ let png_len = png().len() as u64;
+ let files = [
+ (ok.as_str(), 64, "video/mp4", Status::Captured),
+ (image.as_str(), png_len, "image/png", Status::Captured),
+ (missing.as_str(), 64, "video/mp4", Status::Missing),
+ (grown.as_str(), 64, "video/mp4", Status::ChangedDuringRead),
+ (shrunk.as_str(), 64, "video/mp4", Status::ChangedDuringRead),
+ (text.as_str(), 64, "video/mp4", Status::Unsupported),
+ (mislabeled.as_str(), 64, "image/png", Status::Unsupported),
+ (folder.as_str(), 64, "video/mp4", Status::Unsupported),
+ (link.as_str(), 64, "video/mp4", Status::Unsupported),
+ (
+ huge_video.as_str(),
+ VIDEO_MAX_BYTES + 1,
+ "video/mp4",
+ Status::TooLarge,
+ ),
+ (
+ huge_image.as_str(),
+ 32 * MIB + 1,
+ "image/png",
+ Status::TooLarge,
+ ),
+ ];
+ let listed: Vec<(&str, u64, &str)> = files
+ .iter()
+ .map(|(path, len, media_type, _)| (*path, *len, *media_type))
+ .collect();
+ let mut ops = record(40, 1, &listed);
+ let before = ops.clone();
+ let frames = capture(&mut ops, 0, 1, None, None);
+
+ assert_eq!(
+ statuses(&frames),
+ files
+ .iter()
+ .map(|(_, _, _, status)| *status)
+ .collect::>()
+ );
+ for ((offset, document), (path, len, media_type, status)) in frames.iter().zip(&files) {
+ assert_eq!(*offset, 40);
+ assert_eq!(document.path, *path);
+ assert_eq!(
+ document.media,
+ Some(MediaClaim {
+ media_type: (*media_type).to_owned(),
+ expected_len: *len
+ })
+ );
+ assert_eq!(document.recovered_from, None);
+ assert!(document.captured_at > 0);
+ assert_eq!(
+ document.bytes.is_empty(),
+ *status != Status::Captured,
+ "{path}: only a capture carries bytes"
+ );
+ document.validate().unwrap();
+ }
+ assert_eq!(frames[0].1.bytes, mp4(64, 1));
+ assert_eq!(frames[1].1.bytes, png());
+
+ // What the adapter emitted is untouched; only the two captures add ops.
+ assert_eq!(ops[..before.len()], before[..]);
+ let all = facts(&ops);
+ let upgraded = upgrades(&ops);
+ assert_eq!(upgraded.len(), 2);
+ assert_eq!(members(&ops).len(), 2);
+ for (index, (evidence, attachment)) in upgraded.iter().enumerate() {
+ assert_eq!(*evidence, all[index].evidence);
+ assert_eq!(attachment.id, all[index].attachment.id);
+ }
+ assert_eq!(ops.len(), before.len() + 4);
+
+ // The device and process trees. The producer emits no fact for such a
+ // path and the seam skips one that names it; observed anyway, it is
+ // unsupported whatever the claim says, with nothing read or projected.
+ let fact = all[0].clone();
+ for path in ["/dev/null", "/dev/fd/0"] {
+ let device = PresentedMedia {
+ path: path.to_owned(),
+ ..fact.clone()
+ };
+ let mut ops = vec![IngestOp::PresentedMedia(device.clone())];
+ assert!(capture(&mut ops, 0, 1, None, None).is_empty(), "{path}");
+ assert_eq!(ops.len(), 1, "{path}");
+ let mut projected = Vec::new();
+ let document = observe_file(&device, 0, &mut projected);
+ assert_eq!(document.status, Status::Unsupported, "{path}");
+ assert!(document.bytes.is_empty() && projected.is_empty(), "{path}");
+ }
+}
+
+#[cfg(unix)]
+#[test]
+fn a_device_or_process_path_is_refused_before_any_open() {
+ use std::io::Seek;
+ use std::os::fd::AsRawFd;
+
+ let dir = TempDir::new().unwrap();
+ let bytes = mp4(64, 1);
+ let path = write(dir.path(), "held.mp4", &bytes);
+ // A descriptor this process holds on a regular file of the claimed
+ // size. Where `/dev/fd/N` duplicates descriptor N, opening it would
+ // pass for that file and reading it would move the holder's offset.
+ let mut held = std::fs::File::open(&path).unwrap();
+ let through = format!("/dev/fd/{}", held.as_raw_fd());
+ assert!(matches!(open_regular(&through), Err(Status::Unsupported)));
+ let fact = PresentedMedia {
+ path: through,
+ ..facts(&record(40, 1, &[(path.as_str(), 64, "video/mp4")])).remove(0)
+ };
+ let mut projected = Vec::new();
+ let document = observe_file(&fact, 0, &mut projected);
+ assert_eq!(document.status, Status::Unsupported);
+ assert!(document.bytes.is_empty() && projected.is_empty());
+ assert_eq!(
+ held.stream_position().unwrap(),
+ 0,
+ "the held descriptor was not read through"
+ );
+
+ // The same descriptor through a directory that is a symlink to the
+ // device tree: `O_NOFOLLOW` guards only the last component.
+ #[cfg(unix)]
+ {
+ let link = dir.path().join("d");
+ std::os::unix::fs::symlink("/dev", &link).unwrap();
+ let around = format!("{}/fd/{}", link.to_str().unwrap(), held.as_raw_fd());
+ assert!(matches!(open_regular(&around), Err(Status::Unsupported)));
+ let fact = PresentedMedia {
+ path: around,
+ ..facts(&record(40, 1, &[(path.as_str(), 64, "video/mp4")])).remove(0)
+ };
+ let mut projected = Vec::new();
+ let document = observe_file(&fact, 0, &mut projected);
+ assert_eq!(document.status, Status::Unsupported);
+ assert!(document.bytes.is_empty() && projected.is_empty());
+ assert_eq!(
+ held.stream_position().unwrap(),
+ 0,
+ "the held descriptor was not read through a symlinked directory"
+ );
+ }
+
+ for refused in [
+ "/dev",
+ "/dev/",
+ "/dev/null",
+ "/dev/fd/0",
+ "/dev/stdin",
+ "/proc",
+ "/proc/self/fd/0",
+ "/proc/1/mem",
+ // The default macOS volume folds case.
+ "/DEV/null",
+ "/Proc/self/fd/0",
+ ] {
+ assert!(
+ matches!(open_regular(refused), Err(Status::Unsupported)),
+ "{refused}"
+ );
+ }
+
+ // Only the root component decides: the same file by its own path, and
+ // a folder that is merely named like one, open as before.
+ assert!(open_regular(&path).is_ok());
+ for folder in ["dev", "proc"] {
+ std::fs::create_dir(dir.path().join(folder)).unwrap();
+ let inside = write(&dir.path().join(folder), "clip.mp4", &bytes);
+ assert!(open_regular(&inside).is_ok(), "{inside}");
+ }
+ // A root folder whose name only starts like one is looked for.
+ for absent in ["/devices-rosetta-test/clip.mp4", "/proc.rosetta-test"] {
+ assert!(
+ matches!(open_regular(absent), Err(Status::Missing)),
+ "{absent}"
+ );
+ }
+}
+
+#[test]
+fn a_fact_that_does_not_validate_is_not_observed_and_a_bad_claim_is_unsupported() {
+ let dir = TempDir::new().unwrap();
+ let ok = write(dir.path(), "ok.mp4", &mp4(64, 1));
+ let good = record(40, 1, &[(ok.as_str(), 64, "video/mp4")]);
+ let fact = facts(&good).remove(0);
+
+ type Break = fn(&mut PresentedMedia);
+ let cases: [(&str, Break); 3] = [
+ ("a relative path", |f| f.path = "work/ok.mp4".into()),
+ ("a network path", |f| f.path = "//host/share/ok.mp4".into()),
+ ("a type that is not presented", |f| {
+ f.media_type = "video/webm".into()
+ }),
+ ];
+ for (name, break_it) in cases {
+ let mut broken = fact.clone();
+ break_it(&mut broken);
+ // The seam skips it: no frame, no op.
+ let mut ops = vec![IngestOp::PresentedMedia(broken.clone())];
+ assert!(capture(&mut ops, 0, 1, None, None).is_empty(), "{name}");
+ assert_eq!(ops.len(), 1, "{name}");
+ // Observed anyway, it is unsupported and nothing is read.
+ let mut ops = Vec::new();
+ let document = observe_file(&broken, 0, &mut ops);
+ assert_eq!(document.status, Status::Unsupported, "{name}");
+ assert!(document.bytes.is_empty() && ops.is_empty(), "{name}");
+ }
+}
+
+#[test]
+fn one_record_captures_at_most_the_record_budget() {
+ let dir = TempDir::new().unwrap();
+ let ok = write(dir.path(), "ok.mp4", &mp4(64, 1));
+ let ops = record(40, 1, &[(ok.as_str(), 64, "video/mp4")]);
+ let fact = facts(&ops).remove(0);
+ // Exactly at the budget is captured; one byte past it is not read.
+ let mut projected = Vec::new();
+ let fits = observe_file(&fact, RECORD_MAX_BYTES - 64, &mut projected);
+ assert_eq!(fits.status, Status::Captured);
+ assert_eq!(projected.len(), 2);
+ let mut projected = Vec::new();
+ let over = observe_file(&fact, RECORD_MAX_BYTES - 63, &mut projected);
+ assert_eq!(over.status, Status::TooLarge);
+ assert!(over.bytes.is_empty() && projected.is_empty());
+
+ // Through the seam: three 48 MiB videos on one record, sparse on disk.
+ let len = 48 * MIB;
+ let paths: Vec = (0..3)
+ .map(|index| {
+ let path = dir.path().join(format!("big-{index}.mp4"));
+ std::fs::write(&path, video_header(b"isom", len as usize)).unwrap();
+ std::fs::OpenOptions::new()
+ .write(true)
+ .open(&path)
+ .unwrap()
+ .set_len(len)
+ .unwrap();
+ path.to_str().unwrap().to_owned()
+ })
+ .collect();
+ let listed: Vec<(&str, u64, &str)> = paths
+ .iter()
+ .map(|path| (path.as_str(), len, "video/mp4"))
+ .collect();
+ let mut ops = record(41, 1, &listed);
+ let frames = capture(&mut ops, 0, 1, None, None);
+ assert_eq!(
+ statuses(&frames),
+ [Status::Captured, Status::Captured, Status::TooLarge]
+ );
+ assert_eq!(frames[0].1.bytes.len() as u64, len);
+ assert!(frames[2].1.bytes.is_empty());
+ assert_eq!(upgrades(&ops).len(), 2);
+}
+
+/// A `stat` that answers from a script, then fails.
+fn scripted(answers: Vec>) -> impl FnMut() -> Option {
+ let mut answers = answers.into_iter();
+ move || answers.next().flatten()
+}
+
+fn still(len: u64) -> Option {
+ Some(FileStat {
+ is_file: true,
+ len,
+ modified: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(100)),
+ })
+}
+
+#[test]
+fn a_file_that_moves_under_the_read_is_changed_during_read() {
+ let bytes = mp4(64, 1);
+ let ops = record(40, 1, &[("/work/clip.mp4", 64, "video/mp4")]);
+ let fact = facts(&ops).remove(0);
+ let touched = Some(FileStat {
+ modified: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(101)),
+ ..still(64).unwrap()
+ });
+ let directory = Some(FileStat {
+ is_file: false,
+ ..still(64).unwrap()
+ });
+ let longer = [bytes.clone(), vec![0; 8]].concat();
+ type Case<'a> = (&'a str, &'a [u8], Vec>, Status);
+ let cases: [Case<'_>; 9] = [
+ (
+ "held still",
+ &bytes,
+ vec![still(64), still(64)],
+ Status::Captured,
+ ),
+ (
+ "rewritten in place",
+ &bytes,
+ vec![still(64), touched],
+ Status::ChangedDuringRead,
+ ),
+ (
+ "grew after the read",
+ &bytes,
+ vec![still(64), still(65)],
+ Status::ChangedDuringRead,
+ ),
+ (
+ "a short read",
+ &bytes[..50],
+ vec![still(64), still(64)],
+ Status::ChangedDuringRead,
+ ),
+ (
+ "a long read",
+ &longer,
+ vec![still(64), still(64)],
+ Status::ChangedDuringRead,
+ ),
+ (
+ "another size before any read",
+ &bytes,
+ vec![still(63)],
+ Status::ChangedDuringRead,
+ ),
+ (
+ "gone after the read",
+ &bytes,
+ vec![still(64), None],
+ Status::ChangedDuringRead,
+ ),
+ ("no metadata", &bytes, vec![None], Status::Unsupported),
+ ("not a file", &bytes, vec![directory], Status::Unsupported),
+ ];
+ for (name, content, answers, status) in cases {
+ let mut projected = Vec::new();
+ let document = read_file(
+ &fact,
+ &mut Cursor::new(content),
+ scripted(answers),
+ &mut projected,
+ );
+ assert_eq!(document.status, status, "{name}");
+ if status == Status::Captured {
+ assert_eq!(document.bytes, bytes);
+ assert_eq!(projected.len(), 2);
+ } else {
+ assert!(document.bytes.is_empty(), "{name}: no bytes are kept");
+ assert!(projected.is_empty(), "{name}: nothing is projected");
+ }
+ document.validate().unwrap();
+ }
+}
+
+#[test]
+fn the_stable_read_without_a_recorded_size_keeps_the_html_outcomes() {
+ let read = |content: &[u8], answers, limit| {
+ let mut bytes = Vec::new();
+ let status = read_stable(
+ &mut Cursor::new(content),
+ scripted(answers),
+ limit,
+ None,
+ &mut bytes,
+ );
+ (status, bytes.len())
+ };
+ assert_eq!(
+ read(b"12345", vec![still(5), still(5)], 5),
+ (Status::Captured, 5)
+ );
+ // Over the limit by its metadata: not read at all.
+ assert_eq!(read(b"123456", vec![still(6)], 5), (Status::TooLarge, 0));
+ // Over the limit by what the read returned: one byte past it, no more.
+ assert_eq!(
+ read(b"1234567890", vec![still(5), still(5)], 5),
+ (Status::TooLarge, 6)
+ );
+ // Shorter than its metadata, and the metadata did not move.
+ assert_eq!(
+ read(b"123", vec![still(5), still(5)], 5).0,
+ Status::ChangedDuringRead
+ );
+ assert_eq!(read(b"123", vec![None], 5).0, Status::Unsupported);
+ assert_eq!(
+ read(b"123", vec![still(3), None], 5).0,
+ Status::ChangedDuringRead
+ );
+}
+
+#[test]
+fn only_facts_at_or_above_the_ledger_watermark_are_observed_once_each() {
+ let dir = TempDir::new().unwrap();
+ let ok = write(dir.path(), "ok.mp4", &mp4(64, 1));
+ let mut ops = record(40, 1, &[(ok.as_str(), 64, "video/mp4")]);
+ let before = ops.clone();
+ // Already ledgered for this generation: not observed again.
+ assert!(capture(&mut ops, 41, 1, None, None).is_empty());
+ assert_eq!(ops, before);
+ assert!(has_fact(&ops));
+ assert!(!has_fact(&ops[..1]));
+
+ // The same coordinate twice is one observation.
+ let fact = facts(&ops).remove(0);
+ ops.push(IngestOp::PresentedMedia(fact));
+ let frames = capture(&mut ops, 40, 1, None, None);
+ assert_eq!(statuses(&frames), [Status::Captured]);
+ assert_eq!(upgrades(&ops).len(), 1);
+}
+
+#[test]
+fn the_ledger_is_asked_before_the_filesystem() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("clip.mp4").to_str().unwrap().to_owned();
+ let earlier = mp4(64, 1);
+ // Today's file has the recorded size and other bytes.
+ let today = mp4(64, 9);
+ std::fs::write(&path, &today).unwrap();
+ let digest = |bytes: &[u8]| blake3::hash(bytes).to_hex().to_string();
+ let prior = |generation, offset, bytes: &[u8]| {
+ let ops = record(offset, generation, &[(path.as_str(), 64, "video/mp4")]);
+ let mut loaded = Loaded::default();
+ loaded.insert(Prior {
+ generation,
+ offset,
+ document: captured(&facts(&ops)[0], bytes.to_vec()),
+ });
+ loaded
+ };
+ let run = |loaded: &Loaded| {
+ let mut ops = record(40, 3, &[(path.as_str(), 64, "video/mp4")]);
+ let frames = capture(&mut ops, 0, 3, Some(loaded), None);
+ let shown: Vec = members(&ops)
+ .iter()
+ .map(|asset| asset.digest.clone())
+ .collect();
+ assert_eq!(shown.len(), upgrades(&ops).len());
+ (frames, shown)
+ };
+
+ // The exact coordinate of this generation: shown, and nothing appended.
+ let (frames, shown) = run(&prior(3, 40, &earlier));
+ assert!(frames.is_empty());
+ assert_eq!(shown, [digest(&earlier)]);
+
+ // An earlier generation: a copy at this coordinate that says where it
+ // came from and when it was first captured.
+ let (frames, shown) = run(&prior(2, 40, &earlier));
+ let [(offset, copy)] = &frames[..] else {
+ panic!("{frames:?}")
+ };
+ assert_eq!(*offset, 40);
+ assert_eq!(copy.status, Status::Captured);
+ assert_eq!(copy.captured_at, 1234);
+ assert_eq!(copy.recovered_from.as_deref(), Some("ledger:2:40"));
+ assert_eq!(copy.path, path);
+ assert_eq!(
+ copy.media,
+ Some(MediaClaim {
+ media_type: "video/mp4".into(),
+ expected_len: 64
+ })
+ );
+ assert_eq!(copy.bytes, earlier);
+ copy.validate().unwrap();
+ assert_eq!(shown, [digest(&earlier)], "today's file was not read");
+
+ // The same path and claim at another record of this generation: a copy.
+ let (frames, shown) = run(&prior(3, 99, &earlier));
+ assert_eq!(statuses(&frames), [Status::Captured]);
+ assert_eq!(frames[0].1.recovered_from.as_deref(), Some("ledger:3:99"));
+ assert_eq!(frames[0].1.captured_at, 1234);
+ assert_eq!(shown, [digest(&earlier)]);
+
+ // A prior that does not project is passed over for the filesystem.
+ let (frames, shown) = run(&prior(2, 40, &[b'x'; 64]));
+ assert_eq!(statuses(&frames), [Status::Captured]);
+ assert_eq!(frames[0].1.recovered_from, None);
+ assert_eq!(frames[0].1.bytes, today);
+ assert_eq!(shown, [digest(&today)]);
+ std::fs::remove_file(&path).unwrap();
+ let (frames, shown) = run(&prior(2, 40, &[b'x'; 64]));
+ assert_eq!(statuses(&frames), [Status::Missing]);
+ assert!(shown.is_empty());
+
+ // Nothing held for this claim: the filesystem, as with no ledger at all.
+ let other = dir.path().join("other.mp4").to_str().unwrap().to_owned();
+ let mut loaded = Loaded::default();
+ let elsewhere = record(40, 2, &[(other.as_str(), 64, "video/mp4")]);
+ loaded.insert(Prior {
+ generation: 2,
+ offset: 40,
+ document: captured(&facts(&elsewhere)[0], earlier.clone()),
+ });
+ let (frames, _) = run(&loaded);
+ assert_eq!(statuses(&frames), [Status::Missing]);
+
+ // A carried capture counts against the record's budget like any other.
+ let ops = record(40, 3, &[(path.as_str(), 64, "video/mp4")]);
+ let fact = facts(&ops).remove(0);
+ let mut projected = Vec::new();
+ let loaded = prior(2, 40, &earlier);
+ assert!(carry(&fact, &loaded, 3, RECORD_MAX_BYTES - 63, &mut projected).is_none());
+ assert!(projected.is_empty());
+ assert!(carry(&fact, &loaded, 3, RECORD_MAX_BYTES - 64, &mut projected).is_some());
+ assert_eq!(projected.len(), 2);
+}
+
+#[test]
+fn slots_keep_the_first_fact_per_coordinate_and_evict_the_lowest_offset() {
+ let at = |offset| record(offset, 1, &[("/work/clip.mp4", 64, "video/mp4")]);
+ let mut slots = Slots::with_cap(2);
+ slots.note(&at(30));
+ slots.note(&at(10));
+ assert_eq!(slots.len(), 2);
+ slots.note(&at(20));
+ assert_eq!(slots.len(), 2);
+ assert!(slots.get(10, "/work/clip.mp4").is_none(), "the lowest goes");
+ assert!(slots.get(20, "/work/clip.mp4").is_some());
+ assert!(slots.get(30, "/work/clip.mp4").is_some());
+ // A fact below everything held is the one that goes.
+ slots.note(&at(5));
+ assert!(slots.get(5, "/work/clip.mp4").is_none());
+ assert_eq!(slots.len(), 2);
+
+ // One coordinate, two facts: the first stays.
+ let mut slots = Slots::default();
+ let first = record(
+ 40,
+ 1,
+ &[
+ ("/work/a.mp4", 64, "video/mp4"),
+ ("/work/b.mp4", 64, "video/mp4"),
+ ],
+ );
+ slots.note(&first);
+ assert_eq!(slots.len(), 2, "one record's files are separate slots");
+ let mut again = facts(&first).remove(0);
+ again.expected_len = 99;
+ slots.note(&[IngestOp::PresentedMedia(again)]);
+ assert_eq!(slots.get(40, "/work/a.mp4").unwrap().expected_len, 64);
+ assert!(slots.get(40, "/work/c.mp4").is_none());
+ assert!(slots.get(41, "/work/a.mp4").is_none());
+ slots.remove(40, "/work/a.mp4");
+ assert!(slots.get(40, "/work/a.mp4").is_none());
+ assert_eq!(slots.len(), 1);
+
+ // The default cap is the documented one.
+ let mut slots = Slots::default();
+ for offset in 0..65_540 {
+ slots.note(&at(offset));
+ }
+ assert_eq!(slots.len(), 65_536);
+ assert!(slots.get(3, "/work/clip.mp4").is_none());
+ assert!(slots.get(4, "/work/clip.mp4").is_some());
+}
+
+#[test]
+fn replay_takes_the_first_captured_frame_that_projects() {
+ let a = mp4(64, 1);
+ let b = mp4(64, 2);
+ let ops = record(40, 1, &[("/work/clip.mp4", 64, "video/mp4")]);
+ let fact = facts(&ops).remove(0);
+ let missing = CapturedDocument {
+ status: Status::Missing,
+ ..captured(&fact, Vec::new())
+ };
+ let garbage = captured(&fact, vec![b'x'; 64]);
+ let digest = |bytes: &[u8]| blake3::hash(bytes).to_hex().to_string();
+ let replay = |frames: &[&CapturedDocument]| {
+ let mut slots = Slots::default();
+ slots.note(&ops);
+ let mut projected = Vec::new();
+ for frame in frames {
+ project_document("root", 40, frame, &mut slots, &mut projected);
+ }
+ let shown: Vec = members(&projected)
+ .iter()
+ .map(|asset| asset.digest.clone())
+ .collect();
+ assert_eq!(shown.len(), upgrades(&projected).len());
+ assert_eq!(projected.len(), shown.len() * 2);
+ (shown, slots.len())
+ };
+ let a_frame = captured(&fact, a.clone());
+ let b_frame = captured(&fact, b);
+ assert_eq!(replay(&[&missing, &a_frame]), (vec![digest(&a)], 0));
+ assert_eq!(replay(&[&a_frame, &missing]), (vec![digest(&a)], 0));
+ assert_eq!(replay(&[&a_frame, &b_frame]), (vec![digest(&a)], 0));
+ // A captured frame that does not project leaves the slot for one that does.
+ assert_eq!(replay(&[&garbage, &a_frame]), (vec![digest(&a)], 0));
+ // Nothing captured: the slot stays and the row stays a reference.
+ assert_eq!(replay(&[&missing, &missing]), (Vec::new(), 1));
+
+ // A frame no fact was noted for is ignored: another record, another path.
+ let mut slots = Slots::default();
+ slots.note(&ops);
+ let mut projected = Vec::new();
+ project_document("root", 41, &a_frame, &mut slots, &mut projected);
+ let other = CapturedDocument {
+ path: "/work/other.mp4".into(),
+ ..a_frame.clone()
+ };
+ project_document("root", 40, &other, &mut slots, &mut projected);
+ project_document("root", 40, &a_frame, &mut Slots::default(), &mut projected);
+ assert!(projected.is_empty());
+ assert_eq!(slots.len(), 1);
+
+ // An HTML frame still projects as it always has, slots untouched.
+ let html = CapturedDocument {
+ path: "/work/page.html".into(),
+ captured_at: 5,
+ status: Status::Captured,
+ recovered_from: None,
+ media: None,
+ bytes: b"page ".to_vec(),
+ };
+ let mut by_seam = Vec::new();
+ project_document("root", 40, &html, &mut slots, &mut by_seam);
+ let mut direct = Vec::new();
+ crate::html_artifacts::project("root", 40, &html, &mut direct);
+ assert_eq!(by_seam, direct);
+ assert_eq!(by_seam.len(), 2);
+ assert_eq!(slots.len(), 1);
+}
diff --git a/crates/pipeline/src/replay.rs b/crates/pipeline/src/replay.rs
index 1d4bf620..771da6a9 100644
--- a/crates/pipeline/src/replay.rs
+++ b/crates/pipeline/src/replay.rs
@@ -415,6 +415,7 @@ struct FileReplay {
last_offset: Option,
generation_clean: bool,
activated: bool,
+ slots: crate::presented_media::Slots,
}
struct PreparedFile {
@@ -924,10 +925,11 @@ pub fn rebuild_runtime_with_options(
&& !already_projected.contains(&source.relative_path)
&& let Some(file) = files.get_mut(&source.relative_path)
{
- crate::html_artifacts::project(
+ crate::presented_media::project_document(
&source.session_id,
start_offset,
&document,
+ &mut file.slots,
&mut file.ops,
);
}
@@ -1124,6 +1126,7 @@ pub fn prepare_runtime(
last_offset: Option,
generation_clean: bool,
sealed: bool,
+ slots: crate::presented_media::Slots,
}
let mut report = RebuildReport {
@@ -1186,6 +1189,7 @@ pub fn prepare_runtime(
last_offset: None,
generation_clean: true,
sealed: false,
+ slots: Default::default(),
},
);
report.files += 1;
@@ -1230,6 +1234,7 @@ pub fn prepare_runtime(
report.hard_errors += 1;
}
}
+ file.slots.note(&outcome.ops);
file.ops.extend(outcome.ops);
}
LedgerEntry::Document {
@@ -1245,10 +1250,11 @@ pub fn prepare_runtime(
.is_some_and(|(elected, _)| generation == *elected)
&& let Some(file) = files.get_mut(&source.relative_path)
{
- crate::html_artifacts::project(
+ crate::presented_media::project_document(
&source.session_id,
start_offset,
&document,
+ &mut file.slots,
&mut file.ops,
);
}
@@ -1282,6 +1288,7 @@ pub fn prepare_runtime(
last_offset: None,
generation_clean: true,
sealed: true,
+ slots: Default::default(),
},
);
report.files += 1;
@@ -1335,6 +1342,7 @@ pub fn prepare_runtime(
.map_err(PipelineError::Config)?,
snapshot_stamp: None,
snapshot_revision: None,
+ reframing: false,
}
.encode();
let activate =
@@ -1830,6 +1838,7 @@ fn open_file(
last_offset: None,
generation_clean: true,
activated: false,
+ slots: Default::default(),
})
}
@@ -1870,6 +1879,7 @@ fn flush_chunk(
.map_err(PipelineError::Config)?,
snapshot_stamp: None,
snapshot_revision: None,
+ reframing: false,
}
.encode();
let update = CheckpointUpdate {
diff --git a/crates/pipeline/src/replay/stopped.rs b/crates/pipeline/src/replay/stopped.rs
index c4012bc8..5fcbf0cd 100644
--- a/crates/pipeline/src/replay/stopped.rs
+++ b/crates/pipeline/src/replay/stopped.rs
@@ -149,10 +149,11 @@ pub fn activate_stopped_source(
if !document_source.ledger_only
&& let Some(file) = file.as_mut()
{
- crate::html_artifacts::project(
+ crate::presented_media::project_document(
&document_source.session_id,
start_offset,
&document,
+ &mut file.slots,
&mut file.ops,
);
}
@@ -253,6 +254,7 @@ pub(super) fn project_raw(
report.hard_errors += 1;
}
}
+ file.slots.note(&outcome.ops);
file.ops.extend(outcome.ops);
}
true
diff --git a/crates/pipeline/src/snapshot.rs b/crates/pipeline/src/snapshot.rs
index 5276bf4d..b0e994e5 100644
--- a/crates/pipeline/src/snapshot.rs
+++ b/crates/pipeline/src/snapshot.rs
@@ -370,6 +370,9 @@ pub(crate) fn apply_snapshot_records(
let mut ops: Vec = Vec::new();
let mut conflicted = false;
let mut progress_bytes = 0;
+ // Earlier media captures of this source, read once at the pass's first
+ // fact when the generation can have any.
+ let mut prior_captures = None;
for (index, record) in records.iter().enumerate() {
opts.check_cancelled()?;
report.records += 1;
@@ -406,7 +409,7 @@ pub(crate) fn apply_snapshot_records(
}
let mut record_ops = outcome.ops;
if !file.ledger_only {
- let documents = crate::html_artifacts::capture(
+ let mut documents = crate::html_artifacts::capture(
&mut record_ops,
0,
adapter,
@@ -415,6 +418,26 @@ pub(crate) fn apply_snapshot_records(
None,
&opts.artifact_recovery,
);
+ if opts.observe_presented_media && crate::presented_media::has_fact(&record_ops) {
+ if generation > 1
+ && prior_captures.is_none()
+ && let Some(raw_ledger) = ledger.as_deref()
+ {
+ let ledgered =
+ crate::presented_media::prior::PriorCaptures::new(raw_ledger.dir());
+ prior_captures =
+ Some(ledgered.load(&file.session_id, &file.relative_path, generation));
+ }
+ documents.extend(crate::presented_media::capture(
+ &mut record_ops,
+ 0,
+ generation,
+ prior_captures.as_ref(),
+ // A snapshot pass is never append growth: every pass
+ // above the first generation asks the ledger first.
+ None,
+ ));
+ }
if !documents.is_empty()
&& let Some(raw_ledger) = ledger.as_deref_mut()
{
@@ -590,6 +613,7 @@ pub(crate) fn flush_chunk(
.map_err(PipelineError::Config)?,
snapshot_stamp: Some(stamp.to_string()),
snapshot_revision: Some(revision.to_string()),
+ reframing: false,
}
.encode();
let update = CheckpointUpdate {
diff --git a/crates/pipeline/src/stream.rs b/crates/pipeline/src/stream.rs
index 1bf87f25..734ef222 100644
--- a/crates/pipeline/src/stream.rs
+++ b/crates/pipeline/src/stream.rs
@@ -520,6 +520,14 @@ pub(crate) fn ingest_file(
) -> Result {
let cursor = cursor_row(adapter, store, runtime, file, instrument)?;
let mut pass = IngestPass::begin(adapter, runtime, root, file, opts, cursor, mode)?;
+ if let Some(ledger) = ledger.as_deref() {
+ // The serial driver holds the ledger, so a pass that re-reads this
+ // source can carry a captured media frame forward instead of
+ // observing today's filesystem.
+ pass.set_prior_captures(crate::presented_media::prior::PriorCaptures::new(
+ ledger.dir(),
+ ));
+ }
if mode == PassMode::Audit {
pass.audit(runtime, file, tailer, opts, store, instrument, report)?;
}
diff --git a/crates/pipeline/src/stream/pass.rs b/crates/pipeline/src/stream/pass.rs
index 3cc7438c..fae6ac11 100644
--- a/crates/pipeline/src/stream/pass.rs
+++ b/crates/pipeline/src/stream/pass.rs
@@ -27,6 +27,8 @@ use super::{
use crate::checkpoint::PipeCheckpoint;
use crate::instrument::{Instrument, Metric};
use crate::meta::SessionFile;
+use crate::presented_media::Fallback;
+use crate::presented_media::prior::{Loaded, PriorCaptures};
use crate::{IngestOptions, PipelineError, SliceBudget};
/// What one [`IngestPass::step`] produced.
@@ -140,6 +142,20 @@ pub(crate) struct IngestPass {
retries: u32,
force_replay: bool,
staged: Option,
+ /// The pass resumed a generation whose checkpoint has no tail (a rebuild
+ /// or a stopped-source activation wrote it), or one such a pass committed
+ /// before it reached the end of the file ([`PipeCheckpoint::reframing`]),
+ /// so it reads records the ledger already holds for this generation, and
+ /// their media frames.
+ reframes: bool,
+ /// Where this source's earlier media captures are read from: set by the
+ /// serial driver that holds a ledger, never on a pooled worker.
+ prior: Option,
+ /// Those captures, loaded once per generation of a pass: at the first
+ /// fact of a pass that can meet earlier frames, otherwise at the first
+ /// sent file append growth could not capture. Dropped when the
+ /// generation restarts.
+ loaded: Option,
}
impl IngestPass {
@@ -194,6 +210,13 @@ impl IngestPass {
let tail = resumed
.as_ref()
.and_then(|(checkpoint, _)| checkpoint.tail_checkpoint());
+ // A fresh cursor resumes too, at generation zero with no tail, but
+ // nothing was ledgered for it: only an existing generation re-frames.
+ // A re-frame that stopped between batches left a tail behind; its
+ // checkpoint says the rest of the file is still a re-frame.
+ let reframes = resumed.as_ref().is_some_and(|(checkpoint, _)| {
+ checkpoint.source_generation > 0 && (tail.is_none() || checkpoint.reframing)
+ });
// Ledger-first means everything at or below the resumed committed offset
// is already durable in the raw ledger for this generation. An audit (or
// sibling-heal) replay re-frames those records to verify the projection,
@@ -284,9 +307,18 @@ impl IngestPass {
retries: 0,
force_replay,
staged: None,
+ reframes,
+ prior: None,
+ loaded: None,
})
}
+ /// Let the pass ask the ledger for an earlier capture of a sent file
+ /// before it reads the filesystem.
+ pub(crate) fn set_prior_captures(&mut self, prior: PriorCaptures) {
+ self.prior = Some(prior);
+ }
+
/// The rotating audit: one sequential read of the committed range against
/// the checkpoint's content chain. Verified — the common case, a quiet
/// file — costs no generation, no ledger append, and no projection write;
@@ -534,6 +566,10 @@ impl IngestPass {
self.ctx.next_ordinal = 0;
self.ctx.cumulative = CumulativeUsage::new();
self.ctx.state = adapter.restore_state(None).map_err(PipelineError::Config)?;
+ // The new generation is a replacement, not a re-frame, and its
+ // earlier captures are read through its own number.
+ self.reframes = false;
+ self.loaded = None;
}
let mut ops: Vec = Vec::with_capacity(batch.items.len());
@@ -607,6 +643,70 @@ impl IngestPass {
Some(&self.path),
&opts.artifact_recovery,
));
+ if opts.observe_presented_media
+ && crate::presented_media::has_fact(&outcome.ops)
+ {
+ // Only a pass that can meet earlier frames of
+ // this source asks the ledger first: a re-frame,
+ // or a replacement generation. One scoped walk,
+ // at its first fact.
+ let wants = self.reframes
+ || (self.source_generation > 1
+ && self.source_generation != self.active_generation);
+ if wants
+ && self.loaded.is_none()
+ && let Some(prior) = &self.prior
+ {
+ self.loaded = Some(prior.load(
+ &file.session_id,
+ &file.relative_path,
+ self.source_generation,
+ ));
+ }
+ let generation = self.source_generation;
+ let frames = match &self.prior {
+ // Append growth of a generation that existed
+ // before this pass reads the file first. Its
+ // source may still hold an earlier capture of
+ // the same file (the record repeated, or a
+ // later one sent it again), so a file that
+ // cannot be captured falls back to the ledger:
+ // one walk, paid only then. A first read
+ // never looks: a first generation, and a
+ // sliced pass, whose early activation makes
+ // each later slice of a file never read
+ // before look like append growth and would
+ // pay a walk per slice.
+ Some(prior)
+ if !wants
+ && self.active_generation > 0
+ && self.budget.is_none() =>
+ {
+ let load = || {
+ prior.load(
+ &file.session_id,
+ &file.relative_path,
+ generation,
+ )
+ };
+ crate::presented_media::capture(
+ &mut outcome.ops,
+ self.ledgered_through,
+ generation,
+ None,
+ Some(Fallback::new(&mut self.loaded, &load)),
+ )
+ }
+ _ => crate::presented_media::capture(
+ &mut outcome.ops,
+ self.ledgered_through,
+ generation,
+ self.loaded.as_ref().filter(|_| wants),
+ None,
+ ),
+ };
+ documents.extend(frames);
+ }
}
ops.extend(outcome.ops);
}
@@ -690,6 +790,11 @@ impl IngestPass {
adapter_state,
snapshot_stamp: self.input.as_ref().map(|input| input.stamp.clone()),
snapshot_revision: None,
+ // A re-frame with more to read: the pass that resumes from this
+ // tail still meets records the ledger holds. The batch that
+ // reaches the end of the file clears it, and a generation
+ // restart has already left the re-frame.
+ reframing: self.reframes && batch.more_available,
}
.encode();
diff --git a/crates/pipeline/tests/presented_media.rs b/crates/pipeline/tests/presented_media.rs
new file mode 100644
index 00000000..4a27aa41
--- /dev/null
+++ b/crates/pipeline/tests/presented_media.rs
@@ -0,0 +1,576 @@
+//! A file an agent sent the user by path is copied into the ledger when its
+//! record is captured, shown as an asset, and replayed from the ledger alone:
+//! through every replay entry point, the snapshot driver, a pass that
+//! re-reads a source after the file is gone, and append growth that sends a
+//! captured file again.
+//!
+//! The adapters here are fakes: Codex plus two synthetic record types, and a
+//! database-first agent. No real agent's sent-file shape is involved.
+//!
+//! This file holds the shared fixture; the tests are in `presented_media/`,
+//! one module per subject.
+
+use std::any::Any;
+use std::collections::BTreeMap;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, OnceLock};
+
+use rosetta_adapter_codex::CodexAdapter;
+use rosetta_adapter_support::presented_media::{PresentedFile, push};
+use rosetta_adapter_support::{
+ AgentAdapter, ColdAdmission, FileStat, GroundTruthDoc, LineOutcome, LineStatus, NormalizeCtx,
+ SkimResult, SnapshotPlan, SnapshotQuery, StreamDriver,
+};
+use rosetta_capture::{
+ CapturedDocument, DocumentCaptureStatus as Status, Ledger, LedgerEntry, MediaClaim, SourceDef,
+ scan_ledger,
+};
+use rosetta_core::rows::{MessageRow, RuntimeRow};
+use rosetta_core::timeline::{AttachmentKind, AttachmentRef, AttachmentSource, TimelineBody};
+use rosetta_core::{IngestOp, ids};
+use rosetta_pipeline::meta::SessionFile;
+use rosetta_pipeline::progress::ColdProgressObserver;
+use rosetta_pipeline::{
+ IngestOptions, Instrument, PipeCheckpoint, RebuildOptions, activate_stopped_source,
+ apply_prepared_runtime, discover, expand_data_root, meta_ingest_into, prepare_runtime,
+ rebuild_runtime_with_options, stream_backfill_into,
+};
+use rosetta_store::html_artifacts::{self, ArtifactListRequest};
+use rosetta_store::{Store, assets, queries};
+use serde_json::{Value, json};
+use tempfile::TempDir;
+
+const SESSION: &str = "media-session";
+
+/// A tool row the synthetic `sent_files` record attaches to.
+fn opener(ctx: &mut NormalizeCtx, call: &str) -> Vec {
+ let row: MessageRow = serde_json::from_value(json!({
+ "timeline_id": ids::timeline_id(&ctx.session_id, &format!("open-{call}")),
+ "session_id": ctx.session_id, "ordinal": ctx.take_ordinal(), "kind": "tool_use",
+ "ts": 10, "attachments": [], "citations": [],
+ "src_cursor": ctx.src_cursor, "src_offset": ctx.src_offset,
+ "tool": {
+ "correlation": {"key": call, "derivation": "native_id"},
+ "native": {"id": call, "name": "Send"},
+ "status": {"value": "succeeded", "basis": "native_terminal_record"},
+ "action": {"type": "shell", "command": {"type": "inline", "text": "send"},
+ "source_truncated": false, "background": false}
+ }
+ }))
+ .unwrap();
+ IngestOp::project_message(row)
+}
+
+/// What an adapter does with a proven sent-files result: the reference
+/// update, then one fact per presentable file. Pure.
+fn sent(ctx: &NormalizeCtx, call: &str, occurrence: &str, files: &Value) -> Vec {
+ let listed: Vec<(String, u64, String)> = files
+ .as_array()
+ .into_iter()
+ .flatten()
+ .map(|file| {
+ (
+ file["path"].as_str().unwrap_or_default().to_owned(),
+ file["size"].as_u64().unwrap_or_default(),
+ file["media_type"].as_str().unwrap_or_default().to_owned(),
+ )
+ })
+ .collect();
+ let files: Vec> = listed
+ .iter()
+ .map(|(path, len, media_type)| PresentedFile {
+ path,
+ cwd: None,
+ len: *len,
+ media_type,
+ })
+ .collect();
+ let mut ops = Vec::new();
+ let facts = push(ctx, call, occurrence, &files, Some(20), &mut ops);
+ ops.extend(facts.into_iter().map(IngestOp::PresentedMedia));
+ ops
+}
+
+/// Codex, plus `sent_open` (a tool row) and `sent_files` (its sent files).
+struct Sender;
+
+impl AgentAdapter for Sender {
+ fn kind(&self) -> &'static str {
+ CodexAdapter.kind()
+ }
+
+ fn layout(&self) -> &GroundTruthDoc {
+ CodexAdapter.layout()
+ }
+
+ fn stream_driver(&self, stream_id: &str) -> Option {
+ CodexAdapter.stream_driver(stream_id)
+ }
+
+ fn census_session_streams(&self) -> Option<&'static [&'static str]> {
+ CodexAdapter.census_session_streams()
+ }
+
+ fn cold_admission(&self) -> ColdAdmission {
+ CodexAdapter.cold_admission()
+ }
+
+ fn normalizer_version(&self) -> u32 {
+ CodexAdapter.normalizer_version()
+ }
+
+ fn html_artifact_hints(
+ &self,
+ ctx: &NormalizeCtx,
+ publication: &rosetta_core::html_artifacts::HtmlArtifactPublication,
+ data_root: &str,
+ ) -> rosetta_adapter_support::html_artifacts::CaptureHints {
+ CodexAdapter.html_artifact_hints(ctx, publication, data_root)
+ }
+
+ fn html_artifact_record(&self, record: &[u8], path: &str) -> Option> {
+ CodexAdapter.html_artifact_record(record, path)
+ }
+
+ fn snapshot_plan(&self, stream_id: &str) -> Option {
+ CodexAdapter.snapshot_plan(stream_id)
+ }
+
+ fn snapshot_session(&self, stream_id: &str, row: &Value) -> SkimResult {
+ CodexAdapter.snapshot_session(stream_id, row)
+ }
+
+ fn document_sessions(
+ &self,
+ stream_id: &str,
+ stat: &FileStat,
+ document: &[u8],
+ ) -> Vec {
+ CodexAdapter.document_sessions(stream_id, stat, document)
+ }
+
+ fn document_size_limit(&self, stream_id: &str, pipeline_limit: usize) -> usize {
+ CodexAdapter.document_size_limit(stream_id, pipeline_limit)
+ }
+
+ fn document_records(
+ &self,
+ stream_id: &str,
+ native_session_id: &str,
+ document: &[u8],
+ ) -> Vec> {
+ CodexAdapter.document_records(stream_id, native_session_id, document)
+ }
+
+ fn init_state(&self) -> Option> {
+ CodexAdapter.init_state()
+ }
+
+ fn checkpoint_state(&self, state: Option<&(dyn Any + Send)>) -> Result, String> {
+ CodexAdapter.checkpoint_state(state)
+ }
+
+ fn restore_state(
+ &self,
+ checkpoint: Option<&Value>,
+ ) -> Result >, String> {
+ CodexAdapter.restore_state(checkpoint)
+ }
+
+ fn skim(&self, stream_id: &str, stat: &FileStat, head: &[u8], tail: &[u8]) -> SkimResult {
+ CodexAdapter.skim(stream_id, stat, head, tail)
+ }
+
+ fn wants_skim_fallback(&self, stream_id: &str, skim: &SkimResult) -> bool {
+ CodexAdapter.wants_skim_fallback(stream_id, skim)
+ }
+
+ fn absorb_skim_fallback_record(
+ &self,
+ stream_id: &str,
+ record: &[u8],
+ skim: &mut SkimResult,
+ ) -> bool {
+ CodexAdapter.absorb_skim_fallback_record(stream_id, record, skim)
+ }
+
+ fn ingest_line(&self, ctx: &mut NormalizeCtx, stream_id: &str, line: &[u8]) -> LineOutcome {
+ let record = serde_json::from_slice::(line).ok();
+ let kind = record.as_ref().and_then(|record| record["type"].as_str());
+ match (kind, &record) {
+ (Some("sent_open"), Some(record)) => {
+ LineOutcome::parsed(opener(ctx, record["call"].as_str().unwrap_or_default()))
+ }
+ (Some("sent_files"), Some(record)) => LineOutcome::parsed(sent(
+ ctx,
+ record["call"].as_str().unwrap_or_default(),
+ record["id"].as_str().unwrap_or_default(),
+ &record["files"],
+ )),
+ _ => CodexAdapter.ingest_line(ctx, stream_id, line),
+ }
+ }
+}
+
+/// A well-formed MP4 of exactly `len` bytes: an `ftyp` box of major brand
+/// `isom`, then one `free` box of padding. `seed` makes its content its own.
+fn mp4(len: usize, seed: u8) -> Vec {
+ assert!(len >= 28);
+ let mut bytes = 20_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(b"ftypisom");
+ bytes.extend_from_slice(&[0, 0, 2, 0]);
+ bytes.extend_from_slice(b"mp41");
+ bytes.extend_from_slice(&u32::try_from(len - 20).unwrap().to_be_bytes());
+ bytes.extend_from_slice(b"free");
+ bytes.resize(len, seed);
+ bytes
+}
+
+fn png() -> Vec {
+ use base64::Engine;
+ base64::engine::general_purpose::STANDARD
+ .decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=",
+ )
+ .unwrap()
+}
+
+fn options() -> IngestOptions {
+ IngestOptions {
+ audit_files_per_run: 0,
+ ..IngestOptions::default()
+ }
+}
+
+struct Fixture {
+ temp: TempDir,
+ root: PathBuf,
+ transcript: PathBuf,
+ db: PathBuf,
+ ledger_dir: PathBuf,
+ runtime: RuntimeRow,
+ store: Store,
+ ledger: Ledger,
+}
+
+fn header(cwd: &Path) -> Value {
+ json!({"timestamp":"2026-10-01T00:00:00Z","type":"session_meta","payload":{"id":SESSION,"timestamp":"2026-10-01T00:00:00Z","cwd":cwd,"originator":"codex_exec","cli_version":"0.150.1","source":"cli","model_provider":"openai"}})
+}
+
+impl Fixture {
+ fn new() -> Self {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().join("codex");
+ let transcript = root.join("sessions/2026/10/01/rollout-media.jsonl");
+ std::fs::create_dir_all(transcript.parent().unwrap()).unwrap();
+ std::fs::create_dir_all(temp.path().join("media")).unwrap();
+ let db = temp.path().join("projection.sqlite");
+ let ledger_dir = temp.path().join("ledger");
+ let runtime = RuntimeRow {
+ id: ids::runtime_id("codex", root.to_str().unwrap()),
+ kind: "codex".into(),
+ data_root: root.to_str().unwrap().into(),
+ enabled: true,
+ account: None,
+ config: None,
+ skills: None,
+ versions_seen: None,
+ discovered_at: 1,
+ label: None,
+ provenance: Default::default(),
+ };
+ std::fs::write(&transcript, format!("{}\n", header(temp.path()))).unwrap();
+ let store = Store::open(&db).unwrap();
+ let ledger = Ledger::open(&ledger_dir).unwrap();
+ Self {
+ temp,
+ root,
+ transcript,
+ db,
+ ledger_dir,
+ runtime,
+ store,
+ ledger,
+ }
+ }
+
+ /// Append one record; returns the offset it starts at.
+ fn append(&self, record: Value) -> u64 {
+ let offset = std::fs::metadata(&self.transcript).unwrap().len();
+ writeln!(
+ std::fs::OpenOptions::new()
+ .append(true)
+ .open(&self.transcript)
+ .unwrap(),
+ "{record}"
+ )
+ .unwrap();
+ offset
+ }
+
+ fn open_call(&self, call: &str) -> u64 {
+ self.append(json!({"type":"sent_open","call":call}))
+ }
+
+ /// A sent-files result for `call`; `files` are `(path, size, type)`.
+ fn send(&self, call: &str, id: &str, files: &[(&str, u64, &str)]) -> u64 {
+ let files: Vec = files
+ .iter()
+ .map(
+ |(path, size, media_type)| json!({"path":path,"size":size,"media_type":media_type}),
+ )
+ .collect();
+ self.append(json!({"type":"sent_files","call":call,"id":id,"files":files}))
+ }
+
+ fn media(&self, name: &str, bytes: &[u8]) -> String {
+ let path = self.temp.path().join("media").join(name);
+ std::fs::write(&path, bytes).unwrap();
+ path.to_str().unwrap().to_owned()
+ }
+
+ fn ingest(&mut self) {
+ self.ingest_with(&options());
+ }
+
+ fn ingest_with(&mut self, options: &IngestOptions) {
+ ingest(
+ &self.runtime,
+ &self.root,
+ &mut self.store,
+ &mut self.ledger,
+ options,
+ );
+ }
+
+ /// A fresh projection rebuilt from the ledger alone.
+ fn rebuild(&self, name: &str, options: RebuildOptions) -> (Store, PathBuf) {
+ rebuild(
+ &self.runtime,
+ &self.ledger_dir,
+ self.temp.path(),
+ name,
+ options,
+ )
+ }
+}
+
+fn ingest(
+ runtime: &RuntimeRow,
+ root: &Path,
+ store: &mut Store,
+ ledger: &mut Ledger,
+ options: &IngestOptions,
+) {
+ let instrument = Instrument::new();
+ let (_, files) = meta_ingest_into(
+ &Sender,
+ runtime,
+ root,
+ store,
+ options,
+ &instrument,
+ Some(&mut *ledger),
+ )
+ .unwrap();
+ let report = stream_backfill_into(
+ &Sender,
+ runtime,
+ root,
+ store,
+ &files,
+ options,
+ &instrument,
+ Some(&mut *ledger),
+ )
+ .unwrap();
+ assert_eq!(report.hard_errors, 0);
+ ledger.flush().unwrap();
+}
+
+fn rebuild(
+ runtime: &RuntimeRow,
+ ledger_dir: &Path,
+ temp: &Path,
+ name: &str,
+ options: RebuildOptions,
+) -> (Store, PathBuf) {
+ let db = temp.join(format!("{name}.sqlite"));
+ let mut store = Store::open(&db).unwrap();
+ rebuild_runtime_with_options(
+ &Sender,
+ runtime,
+ ledger_dir,
+ &mut store,
+ &Instrument::new(),
+ options,
+ )
+ .unwrap();
+ (store, db)
+}
+
+fn session_id(runtime: &RuntimeRow) -> String {
+ ids::session_id(&runtime.id, SESSION)
+}
+
+/// Every tool row's attachments, in timeline order.
+fn attachments(store: &Store, session: &str) -> Vec {
+ queries::timeline_page(store.conn(), session, None, 100)
+ .unwrap()
+ .into_iter()
+ .flat_map(|row| match row.body {
+ TimelineBody::ToolUse { tool } => tool.attachments,
+ _ => Vec::new(),
+ })
+ .collect()
+}
+
+/// The stored `tool_detail` of every tool row, in timeline order.
+fn tool_details(store: &Store, session: &str) -> Vec {
+ store
+ .conn()
+ .prepare(
+ "SELECT tool_detail FROM message
+ WHERE session_id = ?1 AND tool_detail IS NOT NULL ORDER BY ordinal",
+ )
+ .unwrap()
+ .query_map([session], |row| row.get(0))
+ .unwrap()
+ .map(Result::unwrap)
+ .collect()
+}
+
+/// Every member of a projection, by file name.
+fn members(db: &Path) -> BTreeMap> {
+ let Ok(entries) = std::fs::read_dir(assets::asset_root(db)) else {
+ return BTreeMap::new();
+ };
+ entries
+ .map(Result::unwrap)
+ .filter(|entry| entry.file_type().unwrap().is_file())
+ .map(|entry| {
+ (
+ entry.file_name().to_str().unwrap().to_owned(),
+ std::fs::read(entry.path()).unwrap(),
+ )
+ })
+ .collect()
+}
+
+/// The media frames of a ledger: `(generation, offset, frame)` in ledger order.
+fn media_frames(ledger_dir: &Path) -> Vec<(u64, u64, CapturedDocument)> {
+ scan_ledger(ledger_dir)
+ .unwrap()
+ .entries
+ .into_iter()
+ .filter_map(|entry| match entry {
+ LedgerEntry::Document {
+ generation,
+ start_offset,
+ document,
+ ..
+ } if document.media.is_some() => Some((generation, start_offset, document)),
+ _ => None,
+ })
+ .collect()
+}
+
+fn asset_digest(attachment: &AttachmentRef) -> Option<&str> {
+ match &attachment.source {
+ AttachmentSource::Asset { asset } => Some(&asset.digest),
+ _ => None,
+ }
+}
+
+fn digest(bytes: &[u8]) -> String {
+ blake3::hash(bytes).to_hex().to_string()
+}
+
+fn frame(path: &str, status: Status, bytes: &[u8], expected_len: u64) -> CapturedDocument {
+ CapturedDocument {
+ path: path.to_owned(),
+ captured_at: 77,
+ status,
+ recovered_from: None,
+ media: Some(MediaClaim {
+ media_type: "video/mp4".into(),
+ expected_len,
+ }),
+ bytes: bytes.to_vec(),
+ }
+}
+
+/// Copy a ledger entry by entry. `after_raw` adds frames right behind a raw
+/// record, where capture writes them; `seal` keeps the generation markers.
+fn copy_ledger(
+ from: &Path,
+ to: &Path,
+ seal: bool,
+ after_raw: &dyn Fn(u64) -> Vec,
+) -> Ledger {
+ let mut ledger = Ledger::open(to).unwrap();
+ for entry in scan_ledger(from).unwrap().entries {
+ match entry {
+ LedgerEntry::Meta {
+ schema_version,
+ payload,
+ } => ledger.append_meta(schema_version, &payload).unwrap(),
+ LedgerEntry::Raw {
+ source,
+ generation,
+ start_offset,
+ payload,
+ } => {
+ ledger
+ .append_raw(&source, generation, [(start_offset, payload.as_slice())])
+ .unwrap();
+ for document in after_raw(start_offset) {
+ ledger
+ .append_document(&source, generation, start_offset, document)
+ .unwrap();
+ }
+ }
+ LedgerEntry::Document {
+ source,
+ generation,
+ start_offset,
+ document,
+ } => ledger
+ .append_document(&source, generation, start_offset, document)
+ .unwrap(),
+ LedgerEntry::GenerationComplete {
+ source,
+ generation,
+ normalizer_version,
+ normalized_clean,
+ } => {
+ if seal {
+ ledger
+ .complete_generation(
+ &source,
+ generation,
+ normalizer_version,
+ normalized_clean,
+ )
+ .unwrap();
+ }
+ }
+ }
+ }
+ ledger.flush().unwrap();
+ ledger
+}
+
+#[path = "presented_media/append_growth.rs"]
+mod append_growth;
+
+#[path = "presented_media/replay.rs"]
+mod replay;
+
+#[path = "presented_media/reread.rs"]
+mod reread;
+
+#[path = "presented_media/snapshot.rs"]
+mod snapshot;
diff --git a/crates/pipeline/tests/presented_media/append_growth.rs b/crates/pipeline/tests/presented_media/append_growth.rs
new file mode 100644
index 00000000..9406341c
--- /dev/null
+++ b/crates/pipeline/tests/presented_media/append_growth.rs
@@ -0,0 +1,246 @@
+//! Append growth reads the file first, and falls back to the source's own
+//! earlier capture when the file cannot be captured: a repeated opener and
+//! result, and a later call that sends the same file again. A first
+//! generation has nothing earlier and never looks.
+
+use super::*;
+
+/// `(generation, offset, status, recovered_from)` of every media frame.
+fn frame_rows(ledger_dir: &Path) -> Vec<(u64, u64, Status, Option)> {
+ media_frames(ledger_dir)
+ .into_iter()
+ .map(|(generation, offset, frame)| (generation, offset, frame.status, frame.recovered_from))
+ .collect()
+}
+
+/// A rebuild shows `live`'s rows, and the catch-up pass that follows it
+/// (a re-frame from byte zero) changes no row and ledgers no frame.
+fn assert_rebuild_and_catch_up_agree(f: &mut Fixture, name: &str, video: &[u8]) {
+ let session = session_id(&f.runtime);
+ let live = attachments(&f.store, &session);
+ let details = tool_details(&f.store, &session);
+ let frames = frame_rows(&f.ledger_dir);
+ let member = BTreeMap::from([(format!("{}.mp4", digest(video)), video.to_vec())]);
+
+ let (mut rebuilt, rebuilt_db) = f.rebuild(name, RebuildOptions::default());
+ assert_eq!(attachments(&rebuilt, &session), live, "rebuild");
+ assert_eq!(tool_details(&rebuilt, &session), details, "rebuild");
+ assert_eq!(members(&rebuilt_db), member, "rebuild");
+
+ ingest(&f.runtime, &f.root, &mut rebuilt, &mut f.ledger, &options());
+ assert_eq!(attachments(&rebuilt, &session), live, "catch-up");
+ assert_eq!(tool_details(&rebuilt, &session), details, "catch-up");
+ assert_eq!(members(&rebuilt_db), member, "catch-up");
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ frames,
+ "catch-up ledgers no frame"
+ );
+}
+
+#[test]
+fn a_repeated_opener_and_result_keep_the_capture_after_the_file_is_gone() {
+ let mut f = Fixture::new();
+ let video = mp4(4096, 11);
+ let clip = f.media("clip.mp4", &video);
+ f.open_call("send-1");
+ let first = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ f.ingest();
+ let session = session_id(&f.runtime);
+ let shown = attachments(&f.store, &session);
+ assert_eq!(shown.len(), 1);
+ assert_eq!(asset_digest(&shown[0]), Some(digest(&video).as_str()));
+
+ // The file is gone, and the transcript appends the same opener and the
+ // same result again: the same tool row, the same attachment slot, at a
+ // later record.
+ std::fs::remove_file(&clip).unwrap();
+ f.open_call("send-1");
+ let again = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ assert!(again > first);
+ f.ingest();
+
+ let live = attachments(&f.store, &session);
+ assert_eq!(live.len(), 1, "one slot: {live:?}");
+ assert_eq!(live[0].id, shown[0].id);
+ assert_eq!(
+ asset_digest(&live[0]),
+ Some(digest(&video).as_str()),
+ "the repeat did not turn the capture back into a path reference"
+ );
+ assert_eq!(
+ members(&f.db),
+ BTreeMap::from([(format!("{}.mp4", digest(&video)), video.clone())])
+ );
+
+ // The capture was carried to the repeated record from the ledger.
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ [
+ (1, first, Status::Captured, None),
+ (
+ 1,
+ again,
+ Status::Captured,
+ Some(format!("ledger:1:{first}"))
+ ),
+ ]
+ );
+ let frames = media_frames(&f.ledger_dir);
+ assert_eq!(frames[1].2.bytes, video);
+ assert_eq!(frames[1].2.captured_at, frames[0].2.captured_at);
+
+ // The row does not depend on history: a rebuild shows it, and the
+ // re-frame after the rebuild adds nothing.
+ assert_rebuild_and_catch_up_agree(&mut f, "repeat", &video);
+}
+
+#[test]
+fn a_second_call_that_sends_the_same_file_after_it_is_gone_shows_the_capture() {
+ let mut f = Fixture::new();
+ let video = mp4(4096, 12);
+ let clip = f.media("clip.mp4", &video);
+ f.open_call("send-1");
+ let first = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ f.ingest();
+ let session = session_id(&f.runtime);
+
+ // Another call, another tool row, the same path and size; no file.
+ std::fs::remove_file(&clip).unwrap();
+ f.open_call("send-2");
+ let second = f.send("send-2", "result-2", &[(&clip, 4096, "video/mp4")]);
+ f.ingest();
+
+ let live = attachments(&f.store, &session);
+ assert_eq!(live.len(), 2, "one slot per call: {live:?}");
+ assert_ne!(live[0].id, live[1].id);
+ assert_eq!(asset_digest(&live[0]), Some(digest(&video).as_str()));
+ assert_eq!(
+ asset_digest(&live[1]),
+ Some(digest(&video).as_str()),
+ "the second call shows the source's own earlier capture"
+ );
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ [
+ (1, first, Status::Captured, None),
+ (
+ 1,
+ second,
+ Status::Captured,
+ Some(format!("ledger:1:{first}"))
+ ),
+ ]
+ );
+ assert_rebuild_and_catch_up_agree(&mut f, "second-call", &video);
+}
+
+#[test]
+fn a_first_generation_whose_file_is_missing_does_not_walk_the_ledger() {
+ let mut f = Fixture::new();
+ let video = mp4(4096, 13);
+ let clip = f.media("clip.mp4", &video);
+ f.open_call("send-1");
+ let offset = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ f.ingest();
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ [(1, offset, Status::Captured, None)]
+ );
+ std::fs::remove_file(&clip).unwrap();
+
+ // A projection that has never seen this source reads it as a first
+ // generation. This ledger happens to hold a capture at the very
+ // coordinate, which is what makes a walk visible here: a pass that
+ // looked would show it. A first generation does not look.
+ let fresh_db = f.temp.path().join("fresh.sqlite");
+ let mut fresh = Store::open(&fresh_db).unwrap();
+ ingest(&f.runtime, &f.root, &mut fresh, &mut f.ledger, &options());
+ let session = session_id(&f.runtime);
+ let shown = attachments(&fresh, &session);
+ assert_eq!(shown.len(), 1);
+ assert!(
+ matches!(shown[0].source, AttachmentSource::Reference { .. }),
+ "{shown:?}"
+ );
+ assert!(members(&fresh_db).is_empty());
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ [
+ (1, offset, Status::Captured, None),
+ (1, offset, Status::Missing, None),
+ ],
+ "the file was observed, and nothing was carried"
+ );
+
+ // The generation is active now, so growth of it does fall back.
+ f.open_call("send-2");
+ let later = f.send("send-2", "result-2", &[(&clip, 4096, "video/mp4")]);
+ ingest(&f.runtime, &f.root, &mut fresh, &mut f.ledger, &options());
+ let shown = attachments(&fresh, &session);
+ assert_eq!(shown.len(), 2);
+ assert_eq!(asset_digest(&shown[1]), Some(digest(&video).as_str()));
+ assert_eq!(
+ frame_rows(&f.ledger_dir)[2],
+ (
+ 1,
+ later,
+ Status::Captured,
+ Some(format!("ledger:1:{offset}"))
+ )
+ );
+}
+
+#[test]
+fn a_sliced_first_read_does_not_walk_the_ledger() {
+ // A sliced pass activates its first slice early, so every later slice of
+ // a file never read before is append growth of an active generation. It
+ // is still the first read: nothing earlier can exist, and a walk per
+ // slice would be pure cost on the writer thread.
+ let mut f = Fixture::new();
+ let video = mp4(4096, 17);
+ let clip = f.media("clip.mp4", &video);
+ f.open_call("send-1");
+ let first = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ f.open_call("send-2");
+ let second = f.send("send-2", "result-2", &[(&clip, 4096, "video/mp4")]);
+ let sliced = IngestOptions {
+ starter_budget: Some(rosetta_pipeline::SliceBudget {
+ max_bytes: 1,
+ min_records: 3,
+ }),
+ ..options()
+ };
+
+ // Slice one: header, opener, result. The file is there and is captured.
+ f.ingest_with(&sliced);
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ [(1, first, Status::Captured, None)]
+ );
+ std::fs::remove_file(&clip).unwrap();
+
+ // The rest of the first read. The second send finds no file; a pass that
+ // looked would carry the first capture to it.
+ for _ in 0..4 {
+ f.ingest_with(&sliced);
+ }
+ assert_eq!(
+ frame_rows(&f.ledger_dir),
+ [
+ (1, first, Status::Captured, None),
+ (1, second, Status::Missing, None),
+ ],
+ "the second send was observed, and nothing was carried"
+ );
+ let session = session_id(&f.runtime);
+ let shown = attachments(&f.store, &session);
+ assert_eq!(shown.len(), 2);
+ assert_eq!(asset_digest(&shown[0]), Some(digest(&video).as_str()));
+ assert!(
+ matches!(shown[1].source, AttachmentSource::Reference { .. }),
+ "{shown:?}"
+ );
+ let (rebuilt, _) = f.rebuild("sliced", RebuildOptions::default());
+ assert_eq!(attachments(&rebuilt, &session), shown, "rebuild");
+}
diff --git a/crates/pipeline/tests/presented_media/replay.rs b/crates/pipeline/tests/presented_media/replay.rs
new file mode 100644
index 00000000..a88e8825
--- /dev/null
+++ b/crates/pipeline/tests/presented_media/replay.rs
@@ -0,0 +1,411 @@
+//! Capture at the record, the row it shows, and replay from the ledger alone
+//! through every entry point: which frame a replay joins, and what a ledger
+//! with no frame rebuilds (moved from `presented_media.rs`, which keeps the
+//! shared fixture).
+
+use super::*;
+
+#[test]
+fn sent_files_are_captured_shown_as_assets_and_replayed_by_every_entry_point() {
+ let mut f = Fixture::new();
+ let video = mp4(4096, 1);
+ let clip = f.media("clip.mp4", &video);
+ let shot = f.media("shot.png", &png());
+ let gone = f.temp.path().join("media/gone.mp4");
+ let gone = gone.to_str().unwrap();
+ f.open_call("send-1");
+ let offset = f.send(
+ "send-1",
+ "result-1",
+ &[
+ (&clip, 4096, "video/mp4"),
+ (&shot, png().len() as u64, "image/png"),
+ (gone, 512, "video/mp4"),
+ ],
+ );
+ f.ingest();
+ let session = session_id(&f.runtime);
+
+ // The row: two captured assets and one honest path reference.
+ let shown = attachments(&f.store, &session);
+ assert_eq!(shown.len(), 3);
+ assert_eq!(asset_digest(&shown[0]), Some(digest(&video).as_str()));
+ assert_eq!(
+ (shown[0].kind, shown[0].name.as_deref(), shown[0].byte_len),
+ (AttachmentKind::Video, Some("clip.mp4"), Some(4096))
+ );
+ assert_eq!(shown[0].media_type.as_deref(), Some("video/mp4"));
+ assert_eq!(asset_digest(&shown[1]), Some(digest(&png()).as_str()));
+ assert_eq!(shown[1].kind, AttachmentKind::Image);
+ assert!(shown[1].dimensions.is_some());
+ assert!(matches!(
+ shown[2].source,
+ AttachmentSource::Reference { .. }
+ ));
+ assert_eq!(
+ (
+ shown[2].name.as_deref(),
+ shown[2].media_type.as_deref(),
+ shown[2].byte_len
+ ),
+ (Some("gone.mp4"), Some("video/mp4"), Some(512)),
+ "a file that was not captured keeps what the transcript recorded"
+ );
+ let details = tool_details(&f.store, &session);
+ assert_eq!(details.len(), 1);
+ assert!(details[0].contains(&digest(&video)));
+
+ // The members are the recorded bytes.
+ let captured = members(&f.db);
+ assert_eq!(
+ captured,
+ BTreeMap::from([
+ (format!("{}.mp4", digest(&video)), video.clone()),
+ (format!("{}.png", digest(&png())), png()),
+ ])
+ );
+
+ // The ledger: one frame per sent file, at the record that sent it.
+ let frames = media_frames(&f.ledger_dir);
+ assert_eq!(
+ frames
+ .iter()
+ .map(|(generation, at, frame)| (*generation, *at, frame.status, frame.path.as_str()))
+ .collect::>(),
+ [
+ (1, offset, Status::Captured, clip.as_str()),
+ (1, offset, Status::Captured, shot.as_str()),
+ (1, offset, Status::Missing, gone),
+ ]
+ );
+ assert_eq!(frames[0].2.bytes, video);
+ assert_eq!(
+ frames[0].2.media,
+ Some(MediaClaim {
+ media_type: "video/mp4".into(),
+ expected_len: 4096
+ })
+ );
+ assert!(frames[2].2.bytes.is_empty());
+
+ // Replay reads no filesystem: the files, the agent's data and the
+ // projection's members are all gone.
+ std::fs::remove_dir_all(f.temp.path().join("media")).unwrap();
+ std::fs::remove_dir_all(&f.root).unwrap();
+ std::fs::remove_dir_all(assets::asset_root(&f.db)).unwrap();
+
+ // 1. The ledger-order rebuild, whole and with a flush after every op.
+ let cache = f.temp.path().join("election.json");
+ let (rebuilt, rebuilt_db) = f.rebuild("rebuilt", RebuildOptions::default());
+ assert_eq!(tool_details(&rebuilt, &session), details);
+ assert_eq!(attachments(&rebuilt, &session), shown);
+ assert_eq!(members(&rebuilt_db), captured);
+ let (chunked, chunked_db) = f.rebuild(
+ "chunked",
+ RebuildOptions {
+ chunk_ops: 1,
+ election_cache: Some(cache.clone()),
+ ..RebuildOptions::default()
+ },
+ );
+ assert_eq!(tool_details(&chunked, &session), details);
+ assert_eq!(members(&chunked_db), captured);
+
+ // 2. Prepared in RAM, then applied.
+ let prepared = prepare_runtime(
+ &Sender,
+ &f.runtime,
+ &f.ledger_dir,
+ &Instrument::new(),
+ RebuildOptions {
+ election_cache: Some(cache),
+ ..RebuildOptions::default()
+ },
+ )
+ .unwrap();
+ let prepared_db = f.temp.path().join("prepared.sqlite");
+ let mut applied = Store::open_in_memory_for_rebuild_with_assets(&prepared_db).unwrap();
+ apply_prepared_runtime(
+ &Sender,
+ &f.runtime,
+ prepared,
+ &mut applied,
+ &Instrument::new(),
+ )
+ .unwrap();
+ applied.flush_bulk_reconciliation().unwrap();
+ applied.finish_bulk_load().unwrap();
+ assert_eq!(tool_details(&applied, &session), details);
+ assert_eq!(attachments(&applied, &session), shown);
+ assert_eq!(members(&prepared_db), captured);
+
+ // 3. An interrupted generation whose file is gone, closed from its
+ // staged frames.
+ let staged_dir = f.temp.path().join("staged-ledger");
+ let mut staged = copy_ledger(&f.ledger_dir, &staged_dir, false, &|_| Vec::new());
+ let stopped_db = f.temp.path().join("stopped.sqlite");
+ let mut stopped = Store::open(&stopped_db).unwrap();
+ let instrument = Instrument::new();
+ let report = rebuild_runtime_with_options(
+ &Sender,
+ &f.runtime,
+ &staged_dir,
+ &mut stopped,
+ &instrument,
+ RebuildOptions {
+ allow_unsealed_sources: true,
+ ..RebuildOptions::default()
+ },
+ )
+ .unwrap();
+ assert!(attachments(&stopped, &session).is_empty());
+ let [(generation, source)] = report
+ .unsealed_sources
+ .into_values()
+ .collect::>()
+ .try_into()
+ .unwrap();
+ let closed = activate_stopped_source(
+ &Sender,
+ &f.runtime,
+ &staged_dir,
+ &mut stopped,
+ &source,
+ generation,
+ &mut staged,
+ &instrument,
+ None,
+ )
+ .unwrap();
+ assert!(closed.generation_clean);
+ assert_eq!(tool_details(&stopped, &session), details);
+ assert_eq!(attachments(&stopped, &session), shown);
+ assert_eq!(members(&stopped_db), captured);
+}
+
+#[test]
+fn html_and_media_frames_interleave_in_one_source() {
+ let mut f = Fixture::new();
+ let video = mp4(2048, 3);
+ let clip = f.media("clip.mp4", &video);
+ let pages_on_disk = [1, 2].map(|n| {
+ let page = f.temp.path().join(format!("review-{n}.html"));
+ std::fs::write(&page, format!("Review {n} ")).unwrap();
+ page
+ });
+ let present = |n: usize, f: &Fixture| {
+ let marker = format!(
+ "\u{e200}visualize\u{e202}{}\u{e201}",
+ json!({"path":pages_on_disk[n - 1],"title":format!("Review {n}")})
+ );
+ f.append(json!({"timestamp":format!("2026-10-01T00:00:{n:02}Z"),"type":"response_item","payload":{"type":"message","id":format!("reply-{n}"),"role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":marker}]}}));
+ };
+ present(1, &f);
+ f.open_call("send-1");
+ f.send("send-1", "result-1", &[(&clip, 2048, "video/mp4")]);
+ present(2, &f);
+ f.open_call("send-2");
+ f.send("send-2", "result-2", &[(&clip, 2048, "video/mp4")]);
+ f.ingest();
+ let session = session_id(&f.runtime);
+
+ let pages = |store: &Store| {
+ html_artifacts::list(store.conn(), &ArtifactListRequest::default(), false)
+ .unwrap()
+ .entries
+ };
+ let shown = attachments(&f.store, &session);
+ assert_eq!(shown.len(), 2, "one slot per send");
+ assert_ne!(shown[0].id, shown[1].id);
+ for attachment in &shown {
+ assert_eq!(asset_digest(attachment), Some(digest(&video).as_str()));
+ }
+ let listed = pages(&f.store);
+ assert_eq!(listed.len(), 2);
+ assert!(listed.iter().all(|page| page.asset.is_some()));
+ let kinds: Vec = scan_ledger(&f.ledger_dir)
+ .unwrap()
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ LedgerEntry::Document { document, .. } => Some(document.media.is_some()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(kinds, [false, true, false, true]);
+ let captured = members(&f.db);
+ assert_eq!(captured.len(), 3, "two pages and one video");
+
+ std::fs::remove_dir_all(f.temp.path().join("media")).unwrap();
+ for page in &pages_on_disk {
+ std::fs::remove_file(page).unwrap();
+ }
+ let (rebuilt, rebuilt_db) = f.rebuild(
+ "rebuilt",
+ RebuildOptions {
+ chunk_ops: 1,
+ ..RebuildOptions::default()
+ },
+ );
+ assert_eq!(attachments(&rebuilt, &session), shown);
+ assert_eq!(
+ tool_details(&rebuilt, &session),
+ tool_details(&f.store, &session)
+ );
+ assert_eq!(pages(&rebuilt), listed);
+ assert_eq!(members(&rebuilt_db), captured);
+}
+
+#[test]
+fn replay_joins_the_first_captured_frame_at_a_coordinate_and_ignores_strays() {
+ // Raw records and no frame: capture with the switch off.
+ let mut f = Fixture::new();
+ let path = f.temp.path().join("media/clip.mp4");
+ let path = path.to_str().unwrap().to_owned();
+ let opened = f.open_call("send-1");
+ let sent = f.send("send-1", "result-1", &[(&path, 64, "video/mp4")]);
+ f.ingest_with(&IngestOptions {
+ observe_presented_media: false,
+ ..options()
+ });
+ assert!(media_frames(&f.ledger_dir).is_empty());
+ let session = session_id(&f.runtime);
+
+ let a = mp4(64, 1);
+ let b = mp4(64, 2);
+ let captured = |bytes: &[u8]| frame(&path, Status::Captured, bytes, 64);
+ let missing = || frame(&path, Status::Missing, &[], 64);
+ type Case = (&'static str, Vec, Option>);
+ let cases: Vec = vec![
+ ("no frame", vec![], None),
+ ("missing", vec![missing()], None),
+ (
+ "missing then captured",
+ vec![missing(), captured(&a)],
+ Some(a.clone()),
+ ),
+ (
+ "captured then missing",
+ vec![captured(&a), missing()],
+ Some(a.clone()),
+ ),
+ (
+ "two captures",
+ vec![captured(&a), captured(&b)],
+ Some(a.clone()),
+ ),
+ (
+ "a capture that is not a video, then one that is",
+ vec![captured(&[b'x'; 64]), captured(&b)],
+ Some(b.clone()),
+ ),
+ (
+ "a capture of another claim",
+ vec![frame(&path, Status::Captured, &mp4(65, 1), 65)],
+ None,
+ ),
+ (
+ "a capture of another path",
+ vec![frame("/elsewhere/clip.mp4", Status::Captured, &a, 64)],
+ None,
+ ),
+ ];
+ for (index, (name, frames, expected)) in cases.into_iter().enumerate() {
+ let dir = f.temp.path().join(format!("ledger-{index}"));
+ drop(copy_ledger(&f.ledger_dir, &dir, true, &|offset| {
+ if offset == sent {
+ frames.clone()
+ } else {
+ Vec::new()
+ }
+ }));
+ let (store, db) = rebuild(
+ &f.runtime,
+ &dir,
+ f.temp.path(),
+ &format!("case-{index}"),
+ RebuildOptions::default(),
+ );
+ let shown = attachments(&store, &session);
+ assert_eq!(shown.len(), 1, "{name}");
+ match expected {
+ Some(bytes) => {
+ assert_eq!(
+ asset_digest(&shown[0]),
+ Some(digest(&bytes).as_str()),
+ "{name}"
+ );
+ assert_eq!(
+ members(&db),
+ BTreeMap::from([(format!("{}.mp4", digest(&bytes)), bytes)]),
+ "{name}"
+ );
+ }
+ None => {
+ assert!(
+ matches!(shown[0].source, AttachmentSource::Reference { .. }),
+ "{name}: {shown:?}"
+ );
+ assert!(members(&db).is_empty(), "{name}");
+ }
+ }
+ }
+
+ // A captured frame at a record that sent nothing has no slot.
+ let dir = f.temp.path().join("ledger-stray");
+ drop(copy_ledger(&f.ledger_dir, &dir, true, &|offset| {
+ if offset == opened {
+ vec![captured(&a)]
+ } else {
+ Vec::new()
+ }
+ }));
+ let (store, db) = rebuild(
+ &f.runtime,
+ &dir,
+ f.temp.path(),
+ "stray",
+ RebuildOptions::default(),
+ );
+ let shown = attachments(&store, &session);
+ assert!(matches!(
+ shown[0].source,
+ AttachmentSource::Reference { .. }
+ ));
+ assert!(members(&db).is_empty());
+}
+
+#[test]
+fn a_ledger_with_no_frame_rebuilds_a_reference_whatever_is_on_disk_today() {
+ let mut f = Fixture::new();
+ let video = mp4(1024, 5);
+ let clip = f.media("clip.mp4", &video);
+ f.open_call("send-1");
+ f.send("send-1", "result-1", &[(&clip, 1024, "video/mp4")]);
+ f.ingest_with(&IngestOptions {
+ observe_presented_media: false,
+ ..options()
+ });
+ let session = session_id(&f.runtime);
+
+ // The switch: a reference, no member, no frame.
+ let shown = attachments(&f.store, &session);
+ assert_eq!(shown.len(), 1);
+ assert!(matches!(
+ shown[0].source,
+ AttachmentSource::Reference { .. }
+ ));
+ assert_eq!(shown[0].byte_len, Some(1024));
+ assert!(members(&f.db).is_empty());
+ assert!(media_frames(&f.ledger_dir).is_empty());
+
+ // The file is still there, and replay does not look.
+ assert_eq!(std::fs::read(&clip).unwrap(), video);
+ let (rebuilt, rebuilt_db) = f.rebuild("rebuilt", RebuildOptions::default());
+ assert_eq!(attachments(&rebuilt, &session), shown);
+ assert_eq!(
+ tool_details(&rebuilt, &session),
+ tool_details(&f.store, &session)
+ );
+ assert!(members(&rebuilt_db).is_empty());
+}
diff --git a/crates/pipeline/tests/presented_media/reread.rs b/crates/pipeline/tests/presented_media/reread.rs
new file mode 100644
index 00000000..ca7a83a8
--- /dev/null
+++ b/crates/pipeline/tests/presented_media/reread.rs
@@ -0,0 +1,210 @@
+//! A pass that re-reads records the ledger already holds asks the ledger
+//! before the filesystem: a re-frame after a rebuild, one that stopped
+//! between batches, and a replacement generation (the first test moved from
+//! `presented_media.rs`, which keeps the shared fixture).
+
+use super::*;
+
+#[test]
+fn a_pass_that_re_reads_a_source_asks_the_ledger_before_the_filesystem() {
+ let mut f = Fixture::new();
+ let video = mp4(4096, 7);
+ let clip = f.media("clip.mp4", &video);
+ // A long record a later rewrite drops, so the rewrite is shorter.
+ f.append(json!({"timestamp":"2026-10-01T00:00:01Z","type":"response_item","payload":{"type":"message","id":"filler","role":"assistant","content":[{"type":"output_text","text":"filler ".repeat(400)}]}}));
+ f.open_call("send-1");
+ let offset = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ f.ingest();
+ let session = session_id(&f.runtime);
+ let shown = attachments(&f.store, &session);
+ assert_eq!(asset_digest(&shown[0]), Some(digest(&video).as_str()));
+ let details = tool_details(&f.store, &session);
+ assert_eq!(media_frames(&f.ledger_dir).len(), 1);
+
+ // A rebuilt projection's cursors carry no tail, so the next pass
+ // re-reads each transcript from byte zero in the same generation.
+ for today in [None, Some(mp4(4096, 8))] {
+ match &today {
+ // The file is gone,
+ None => std::fs::remove_file(&clip).unwrap(),
+ // or it has the recorded size and other bytes.
+ Some(bytes) => std::fs::write(&clip, bytes).unwrap(),
+ }
+ let name = if today.is_some() {
+ "changed"
+ } else {
+ "deleted"
+ };
+ let (mut rebuilt, rebuilt_db) = f.rebuild(name, RebuildOptions::default());
+ assert_eq!(tool_details(&rebuilt, &session), details);
+ ingest(&f.runtime, &f.root, &mut rebuilt, &mut f.ledger, &options());
+ assert_eq!(tool_details(&rebuilt, &session), details, "{name}");
+ assert_eq!(attachments(&rebuilt, &session), shown, "{name}");
+ assert_eq!(
+ members(&rebuilt_db),
+ BTreeMap::from([(format!("{}.mp4", digest(&video)), video.clone())]),
+ "{name}"
+ );
+ let frames = media_frames(&f.ledger_dir);
+ assert_eq!(frames.len(), 1, "{name}: the capture is not ledgered twice");
+ assert_eq!((frames[0].0, frames[0].1), (1, offset));
+ }
+
+ // A replacement generation: the transcript is rewritten shorter, the
+ // record that sent the file moves, and the file is gone.
+ std::fs::remove_file(&clip).unwrap();
+ std::fs::write(&f.transcript, format!("{}\n", header(f.temp.path()))).unwrap();
+ f.open_call("send-1");
+ let moved = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ assert_ne!(moved, offset);
+ f.ingest();
+ let replaced = attachments(&f.store, &session);
+ assert_eq!(replaced.len(), 1);
+ assert_eq!(
+ asset_digest(&replaced[0]),
+ Some(digest(&video).as_str()),
+ "the capture is carried into the new generation"
+ );
+ let frames = media_frames(&f.ledger_dir);
+ let [first, carried] = &frames[..] else {
+ panic!("{frames:?}")
+ };
+ assert_eq!((first.0, first.1), (1, offset));
+ assert_eq!(
+ (carried.0, carried.1, carried.2.status),
+ (2, moved, Status::Captured)
+ );
+ assert_eq!(carried.2.recovered_from, Some(format!("ledger:1:{offset}")));
+ assert_eq!(carried.2.captured_at, first.2.captured_at);
+ assert_eq!(carried.2.bytes, video);
+
+ // The replacement replays from the ledger like any other generation.
+ let (rebuilt, rebuilt_db) = f.rebuild("replaced", RebuildOptions::default());
+ assert_eq!(attachments(&rebuilt, &session), replaced);
+ assert_eq!(
+ tool_details(&rebuilt, &session),
+ tool_details(&f.store, &session)
+ );
+ assert_eq!(members(&rebuilt_db).len(), 1);
+}
+
+/// Sets a pass's cancel flag as soon as the pass has framed one batch, so
+/// that batch is ledgered and committed and the next one is never read.
+#[derive(Debug)]
+struct CancelAfterFirstBatch(Arc);
+
+impl ColdProgressObserver for CancelAfterFirstBatch {
+ fn source_bytes(&self, _file: &SessionFile, _bytes: u64) {
+ self.0.store(true, Ordering::Relaxed);
+ }
+
+ fn reading_finished(&self) {}
+}
+
+/// The transcript's stored checkpoint.
+fn checkpoint(f: &Fixture, store: &Store) -> PipeCheckpoint {
+ let path = f.transcript.strip_prefix(&f.root).unwrap();
+ let cursor = queries::cursor_state(store.conn(), &f.runtime.id, path.to_str().unwrap())
+ .unwrap()
+ .unwrap();
+ PipeCheckpoint::decode(&cursor.checkpoint).unwrap()
+}
+
+#[test]
+fn a_re_frame_that_stops_between_batches_still_asks_the_ledger_when_it_resumes() {
+ let mut f = Fixture::new();
+ let video = mp4(4096, 21);
+ let clip = f.media("clip.mp4", &video);
+ // Two records to a batch below: the header and the opener are the first
+ // batch, the record that sent the file is in the second.
+ f.open_call("send-1");
+ let offset = f.send("send-1", "result-1", &[(&clip, 4096, "video/mp4")]);
+ f.append(json!({"timestamp":"2026-10-01T00:00:09Z","type":"response_item","payload":{"type":"message","id":"after","role":"assistant","content":[{"type":"output_text","text":"sent"}]}}));
+ f.ingest();
+ let session = session_id(&f.runtime);
+ let shown = attachments(&f.store, &session);
+ assert_eq!(asset_digest(&shown[0]), Some(digest(&video).as_str()));
+ let details = tool_details(&f.store, &session);
+ let captured = BTreeMap::from([(format!("{}.mp4", digest(&video)), video.clone())]);
+ assert!(!checkpoint(&f, &f.store).reframing);
+
+ for today in [None, Some(mp4(4096, 22))] {
+ match &today {
+ // The file is gone,
+ None => std::fs::remove_file(&clip).unwrap(),
+ // or it has the recorded size and other bytes.
+ Some(bytes) => std::fs::write(&clip, bytes).unwrap(),
+ }
+ let name = if today.is_some() {
+ "stopped-changed"
+ } else {
+ "stopped-deleted"
+ };
+ let (mut rebuilt, rebuilt_db) = f.rebuild(name, RebuildOptions::default());
+ assert_eq!(tool_details(&rebuilt, &session), details, "{name}");
+ let after_rebuild = checkpoint(&f, &rebuilt);
+ assert!(after_rebuild.tail.is_none() && !after_rebuild.reframing);
+
+ // The catch-up re-frame, stopped once its first batch is committed.
+ let cancel = Arc::new(AtomicBool::new(false));
+ let mut stopping = options();
+ stopping.tail.max_records_per_batch = 2;
+ stopping.cancel = Some(cancel.clone());
+ stopping.progress = Some(Arc::new(CancelAfterFirstBatch(cancel)));
+ let instrument = Instrument::new();
+ let (_, files) = meta_ingest_into(
+ &Sender,
+ &f.runtime,
+ &f.root,
+ &mut rebuilt,
+ &stopping,
+ &instrument,
+ Some(&mut f.ledger),
+ )
+ .unwrap();
+ let stopped = stream_backfill_into(
+ &Sender,
+ &f.runtime,
+ &f.root,
+ &mut rebuilt,
+ &files,
+ &stopping,
+ &instrument,
+ Some(&mut f.ledger),
+ );
+ assert!(stopped.is_err(), "{name}: the pass was cancelled");
+ f.ledger.flush().unwrap();
+ let interrupted = checkpoint(&f, &rebuilt);
+ let committed = interrupted.tail_checkpoint().unwrap().committed_offset;
+ assert!(
+ 0 < committed && committed <= offset,
+ "{name}: stopped before the record that sent the file"
+ );
+ assert_eq!(interrupted.source_generation, 1);
+ assert!(
+ interrupted.reframing,
+ "{name}: the rest is still a re-frame"
+ );
+
+ // The ordinary pass that resumes from that tail.
+ ingest(&f.runtime, &f.root, &mut rebuilt, &mut f.ledger, &options());
+ assert_eq!(tool_details(&rebuilt, &session), details, "{name}");
+ assert_eq!(attachments(&rebuilt, &session), shown, "{name}");
+ assert_eq!(members(&rebuilt_db), captured, "{name}");
+ let frames = media_frames(&f.ledger_dir);
+ assert_eq!(frames.len(), 1, "{name}: no new media frame");
+ assert_eq!((frames[0].0, frames[0].1), (1, offset));
+ let finished = checkpoint(&f, &rebuilt);
+ assert!(
+ !finished.reframing,
+ "{name}: cleared at the end of the file"
+ );
+ assert!(finished.tail.is_some());
+
+ // Live equals a rebuild of the ledger as it now stands.
+ let (again, again_db) = f.rebuild(&format!("{name}-again"), RebuildOptions::default());
+ assert_eq!(tool_details(&again, &session), details, "{name}");
+ assert_eq!(attachments(&again, &session), shown, "{name}");
+ assert_eq!(members(&again_db), captured, "{name}");
+ }
+}
diff --git a/crates/pipeline/tests/presented_media/snapshot.rs b/crates/pipeline/tests/presented_media/snapshot.rs
new file mode 100644
index 00000000..fe86be8d
--- /dev/null
+++ b/crates/pipeline/tests/presented_media/snapshot.rs
@@ -0,0 +1,257 @@
+//! The snapshot driver: a database-first agent's sent file is captured,
+//! replayed and carried into a later generation (moved from
+//! `presented_media.rs`, which keeps the shared fixture).
+
+use super::*;
+
+/// A database-first agent whose `sent` rows list files a tool row sent.
+struct SenderDb;
+
+impl AgentAdapter for SenderDb {
+ fn kind(&self) -> &'static str {
+ "senderdb"
+ }
+
+ fn layout(&self) -> &GroundTruthDoc {
+ static LAYOUT: OnceLock = OnceLock::new();
+ LAYOUT.get_or_init(|| {
+ GroundTruthDoc::parse(
+ r#"{"agent":"senderdb","data_root":"~/.senderdb","streams":[
+ {"id":"db","glob":"agent.db*","file_format":"sqlite",
+ "discipline":"database","evidence":"fixture"}]}"#,
+ )
+ .unwrap()
+ })
+ }
+
+ fn stream_driver(&self, stream_id: &str) -> Option {
+ (stream_id == "db").then_some(StreamDriver::SqliteSnapshot)
+ }
+
+ fn normalizer_version(&self) -> u32 {
+ 1
+ }
+
+ fn snapshot_plan(&self, stream_id: &str) -> Option {
+ (stream_id == "db").then(|| SnapshotPlan {
+ sessions_sql: "SELECT id AS native_session_id, title, cwd, updated_at
+ FROM sessions ORDER BY id"
+ .to_string(),
+ record_queries: vec![SnapshotQuery {
+ name: "messages".to_string(),
+ sql: "SELECT id, role, body FROM messages WHERE session = ?1 ORDER BY id"
+ .to_string(),
+ }],
+ })
+ }
+
+ fn skim(&self, _stream_id: &str, _stat: &FileStat, _head: &[u8], _tail: &[u8]) -> SkimResult {
+ SkimResult::default()
+ }
+
+ fn ingest_line(&self, ctx: &mut NormalizeCtx, _stream_id: &str, line: &[u8]) -> LineOutcome {
+ let Ok(value) = serde_json::from_slice::(line) else {
+ return LineOutcome {
+ ops: vec![],
+ status: LineStatus::HardError {
+ code: "invalid_json".to_string(),
+ },
+ };
+ };
+ let row = &value["row"];
+ let id = row["id"].as_i64().unwrap_or_default();
+ let body = row["body"].as_str().unwrap_or_default();
+ match row["role"].as_str() {
+ Some("tool") => LineOutcome::parsed(opener(ctx, body)),
+ Some("sent") => {
+ let sent_files: Value = serde_json::from_str(body).unwrap_or_default();
+ LineOutcome::parsed(sent(
+ ctx,
+ sent_files["call"].as_str().unwrap_or_default(),
+ &format!("sent-{id}"),
+ &sent_files["files"],
+ ))
+ }
+ _ => {
+ let message: MessageRow = serde_json::from_value(json!({
+ "timeline_id": ids::timeline_id(&ctx.session_id, &format!("msg-{id}")),
+ "session_id": ctx.session_id, "ordinal": ctx.take_ordinal(),
+ "kind": "agent", "ts": id * 1_000, "content": body,
+ "attachments": [], "citations": [],
+ "src_cursor": ctx.src_cursor, "src_offset": ctx.src_offset,
+ }))
+ .unwrap();
+ LineOutcome::parsed(IngestOp::project_message(message))
+ }
+ }
+ }
+}
+
+#[test]
+fn the_snapshot_driver_captures_replays_and_carries_a_sent_file() {
+ let home = TempDir::new().unwrap();
+ let root = expand_data_root(&SenderDb.layout().data_root, home.path());
+ std::fs::create_dir_all(&root).unwrap();
+ let video = mp4(4096, 9);
+ let clip = home.path().join("clip.mp4");
+ std::fs::write(&clip, &video).unwrap();
+ let files =
+ json!({"call":"send-1","files":[{"path":clip,"size":4096,"media_type":"video/mp4"}]});
+ let agent_db = root.join("agent.db");
+ let conn = rusqlite::Connection::open(&agent_db).unwrap();
+ conn.execute_batch(
+ "CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, cwd TEXT, updated_at INTEGER);
+ CREATE TABLE messages (id INTEGER PRIMARY KEY, session TEXT, role TEXT, body TEXT);
+ INSERT INTO sessions VALUES ('sess-a', 'Sent a clip', '/w/alpha', 1000);
+ INSERT INTO messages VALUES (1, 'sess-a', 'agent', 'here is the recording');
+ INSERT INTO messages VALUES (2, 'sess-a', 'tool', 'send-1');",
+ )
+ .unwrap();
+ conn.execute(
+ "INSERT INTO messages VALUES (3, 'sess-a', 'sent', ?1)",
+ [files.to_string()],
+ )
+ .unwrap();
+ drop(conn);
+
+ let opts = options();
+ let instrument = Instrument::new();
+ let mut runtime = discover(&[&SenderDb], home.path(), &opts, &instrument)
+ .unwrap()
+ .remove(0)
+ .runtime;
+ runtime.enabled = true;
+ let db = home.path().join("projection.sqlite");
+ let ledger_dir = home.path().join("ledger");
+ let mut store = Store::open(&db).unwrap();
+ let mut ledger = Ledger::open(&ledger_dir).unwrap();
+ let pass = |store: &mut Store, ledger: &mut Ledger| {
+ let (_, files) = meta_ingest_into(
+ &SenderDb,
+ &runtime,
+ &root,
+ store,
+ &opts,
+ &instrument,
+ Some(&mut *ledger),
+ )
+ .unwrap();
+ let report = stream_backfill_into(
+ &SenderDb,
+ &runtime,
+ &root,
+ store,
+ &files,
+ &opts,
+ &instrument,
+ Some(&mut *ledger),
+ )
+ .unwrap();
+ assert_eq!(report.hard_errors, 0);
+ ledger.flush().unwrap();
+ };
+ let replay = |name: &str| {
+ let db = home.path().join(format!("{name}.sqlite"));
+ let mut store = Store::open(&db).unwrap();
+ rebuild_runtime_with_options(
+ &SenderDb,
+ &runtime,
+ &ledger_dir,
+ &mut store,
+ &Instrument::new(),
+ RebuildOptions::default(),
+ )
+ .unwrap();
+ (store, db)
+ };
+
+ // The switch: a reference, no member and no frame, on this driver too.
+ {
+ let quiet = IngestOptions {
+ observe_presented_media: false,
+ ..options()
+ };
+ let quiet_db = home.path().join("quiet.sqlite");
+ let quiet_dir = home.path().join("quiet-ledger");
+ let mut quiet_store = Store::open(&quiet_db).unwrap();
+ let mut quiet_ledger = Ledger::open(&quiet_dir).unwrap();
+ let (_, files) = meta_ingest_into(
+ &SenderDb,
+ &runtime,
+ &root,
+ &mut quiet_store,
+ &quiet,
+ &instrument,
+ Some(&mut quiet_ledger),
+ )
+ .unwrap();
+ stream_backfill_into(
+ &SenderDb,
+ &runtime,
+ &root,
+ &mut quiet_store,
+ &files,
+ &quiet,
+ &instrument,
+ Some(&mut quiet_ledger),
+ )
+ .unwrap();
+ quiet_ledger.flush().unwrap();
+ let shown = attachments(&quiet_store, &ids::session_id(&runtime.id, "sess-a"));
+ assert_eq!(shown.len(), 1);
+ assert!(matches!(
+ shown[0].source,
+ AttachmentSource::Reference { .. }
+ ));
+ assert!(members(&quiet_db).is_empty());
+ assert!(media_frames(&quiet_dir).is_empty());
+ }
+
+ // Generation 1: captured at the record's index among the session's rows.
+ pass(&mut store, &mut ledger);
+ let session = ids::session_id(&runtime.id, "sess-a");
+ let shown = attachments(&store, &session);
+ assert_eq!(shown.len(), 1);
+ assert_eq!(asset_digest(&shown[0]), Some(digest(&video).as_str()));
+ let captured = BTreeMap::from([(format!("{}.mp4", digest(&video)), video.clone())]);
+ assert_eq!(members(&db), captured);
+ let frames = media_frames(&ledger_dir);
+ assert_eq!(frames.len(), 1);
+ assert_eq!(
+ (frames[0].0, frames[0].1, frames[0].2.status),
+ (1, 2, Status::Captured)
+ );
+ assert_eq!(frames[0].2.recovered_from, None);
+
+ // Rebuilt from the ledger with the file gone.
+ std::fs::remove_file(&clip).unwrap();
+ let (rebuilt, rebuilt_db) = replay("rebuilt");
+ assert_eq!(attachments(&rebuilt, &session), shown);
+ assert_eq!(
+ tool_details(&rebuilt, &session),
+ tool_details(&store, &session)
+ );
+ assert_eq!(members(&rebuilt_db), captured);
+
+ // Generation 2: the database changed, the file is still gone, and the
+ // capture is carried from the ledger to the record's new coordinate.
+ let conn = rusqlite::Connection::open(&agent_db).unwrap();
+ conn.execute_batch("DELETE FROM messages WHERE id = 1;")
+ .unwrap();
+ drop(conn);
+ pass(&mut store, &mut ledger);
+ let carried = attachments(&store, &session);
+ assert_eq!(carried.len(), 1);
+ assert_eq!(asset_digest(&carried[0]), Some(digest(&video).as_str()));
+ let frames = media_frames(&ledger_dir);
+ assert_eq!(frames.len(), 2);
+ assert_eq!(
+ (frames[1].0, frames[1].1, frames[1].2.status),
+ (2, 1, Status::Captured)
+ );
+ assert_eq!(frames[1].2.recovered_from.as_deref(), Some("ledger:1:2"));
+ assert_eq!(frames[1].2.captured_at, frames[0].2.captured_at);
+ let (rebuilt, rebuilt_db) = replay("rebuilt-again");
+ assert_eq!(attachments(&rebuilt, &session), carried);
+ assert_eq!(members(&rebuilt_db), captured);
+}
diff --git a/crates/pipeline/tests/presented_media_e2e.rs b/crates/pipeline/tests/presented_media_e2e.rs
new file mode 100644
index 00000000..9a975363
--- /dev/null
+++ b/crates/pipeline/tests/presented_media_e2e.rs
@@ -0,0 +1,515 @@
+//! Files Claude Code sent the user by path (`SendUserFile`), end to end with
+//! the real Claude adapter: captured into the ledger when their record is,
+//! shown as assets, and replayed from the ledger alone, through every pass
+//! that can meet a sent file again.
+//!
+//! The transcripts are synthetic and every sent file is a temp file; nothing
+//! here reads a live home. `presented_media.rs` covers the same seam with
+//! fake adapters and hand-built ledgers. This file keeps the shared fixture;
+//! the tests are in `presented_media_e2e/`, by the kind of pass they drive.
+
+use std::any::Any;
+use std::collections::BTreeMap;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::Duration;
+
+use base64::Engine;
+use rosetta_adapter_claude_code::ClaudeCodeAdapter;
+use rosetta_adapter_support::{
+ AgentAdapter, FileStat, GroundTruthDoc, LineOutcome, NormalizeCtx, SkimResult, StreamDriver,
+};
+use rosetta_capture::{
+ CapturedDocument, DocumentCaptureStatus as Status, Ledger, LedgerEntry, MediaClaim,
+ ScriptedEvents, scan_ledger, verify_ledger,
+};
+use rosetta_core::ids;
+use rosetta_core::rows::RuntimeRow;
+use rosetta_core::timeline::AttachmentKind::{self, Image, Video};
+use rosetta_core::timeline::{AttachmentRef, AttachmentSource, TimelineBody};
+use rosetta_pipeline::meta::SessionFile;
+use rosetta_pipeline::progress::ColdProgressObserver;
+use rosetta_pipeline::{
+ BlockingEmit, CapturePool, ColdWalkOptions, CwdExclusions, IngestOptions, Instrument,
+ LiveOptions, LiveRunner, PipeCheckpoint, PipelineError, PoolBudgets, RebuildOptions,
+ StreamedPreparer, StreamedRuntime, apply_prepared_runtime, meta_ingest_into, prepare_runtime,
+ rebuild_runtime_with_options, stream_backfill_into, stream_closed_sources_into,
+ stream_closed_sources_pooled, streamed_setup,
+};
+use rosetta_store::{Store, assets, queries};
+use serde_json::{Value, json};
+use tempfile::TempDir;
+
+const NATIVE: &str = "native";
+const MP4: &str = "video/mp4";
+const PNG: &str =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
+/// A one-pixel GIF: an image whose bytes are not the PNG's.
+const GIF: &str = "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
+
+fn decoded(base64: &str) -> Vec {
+ base64::engine::general_purpose::STANDARD
+ .decode(base64)
+ .unwrap()
+}
+
+/// A well-formed MP4 of exactly `len` bytes: an `ftyp` box of major brand
+/// `isom`, then one `free` box of padding. `seed` makes its content its own.
+fn mp4(len: usize, seed: u8) -> Vec {
+ assert!(len >= 28);
+ let mut bytes = 20_u32.to_be_bytes().to_vec();
+ bytes.extend_from_slice(b"ftypisom");
+ bytes.extend_from_slice(&[0, 0, 2, 0]);
+ bytes.extend_from_slice(b"mp41");
+ bytes.extend_from_slice(&u32::try_from(len - 20).unwrap().to_be_bytes());
+ bytes.extend_from_slice(b"free");
+ bytes.resize(len, seed);
+ bytes
+}
+
+fn options() -> IngestOptions {
+ IngestOptions {
+ audit_files_per_run: 0,
+ ..IngestOptions::default()
+ }
+}
+
+/// One file a `SendUserFile` result lists: `(path, size, media type)`.
+type Sent<'a> = (&'a str, u64, &'a str);
+
+fn line(kind: &str, native: &str, cwd: &Path, uuid: String, message: Value) -> Value {
+ json!({"type":kind,"parentUuid":null,"isSidechain":false,"uuid":uuid,
+ "timestamp":"2026-10-01T12:00:00Z","sessionId":native,"version":"2.1.283","cwd":cwd,
+ "gitBranch":"main","userType":"external","message":message})
+}
+
+/// The assistant line that calls `SendUserFile`.
+fn opener(native: &str, cwd: &Path, call: &str, files: &[Sent<'_>]) -> Value {
+ let paths: Vec<&str> = files.iter().map(|(path, _, _)| *path).collect();
+ let message = json!({"id":format!("response-{call}"),"type":"message","role":"assistant",
+ "model":"claude-opus-5","stop_reason":"tool_use",
+ "content":[{"type":"tool_use","id":call,"name":"SendUserFile",
+ "input":{"files":paths,"caption":"Here it is"}}]});
+ line("assistant", native, cwd, format!("open-{call}"), message)
+}
+
+/// The user line that carries the call's result: `content` is the
+/// `tool_result` block's own content, `files` the `toolUseResult` list.
+fn result(native: &str, cwd: &Path, call: &str, content: Value, files: &[Sent<'_>]) -> Value {
+ let attachments: Vec = files
+ .iter()
+ .map(|(path, size, media_type)| {
+ json!({"path":path,"size":size,"media_type":media_type,
+ "isImage":media_type.starts_with("image/"),"pathValidated":true,
+ "file_uuid":"00000000-0000-4000-8000-000000000000"})
+ })
+ .collect();
+ let message = json!({"role":"user","content":[
+ {"type":"tool_result","tool_use_id":call,"content":content,"is_error":false}]});
+ let mut record = line("user", native, cwd, format!("result-{call}"), message);
+ record["toolUseResult"] =
+ json!({"attachments":attachments,"caption":"Here it is","display":"inline"});
+ record
+}
+
+/// Append one record to a transcript; returns the offset it starts at.
+fn append_line(path: &Path, record: &Value) -> u64 {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ let mut file = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(path)
+ .unwrap();
+ let offset = file.metadata().unwrap().len();
+ writeln!(file, "{record}").unwrap();
+ offset
+}
+
+struct Fixture {
+ temp: TempDir,
+ root: PathBuf,
+ db: PathBuf,
+ ledger_dir: PathBuf,
+ runtime: RuntimeRow,
+ store: Store,
+ ledger: Ledger,
+}
+
+impl Fixture {
+ fn new() -> Self {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().join("claude");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::create_dir_all(temp.path().join("media")).unwrap();
+ let db = temp.path().join("projection.sqlite");
+ let ledger_dir = temp.path().join("ledger");
+ let runtime = RuntimeRow {
+ id: ids::runtime_id("claude-code", root.to_str().unwrap()),
+ kind: "claude-code".into(),
+ data_root: root.to_str().unwrap().into(),
+ enabled: true,
+ account: None,
+ config: None,
+ skills: None,
+ versions_seen: None,
+ discovered_at: 1,
+ label: None,
+ provenance: Default::default(),
+ };
+ let store = Store::open(&db).unwrap();
+ let ledger = Ledger::open(&ledger_dir).unwrap();
+ Self {
+ temp,
+ root,
+ db,
+ ledger_dir,
+ runtime,
+ store,
+ ledger,
+ }
+ }
+
+ fn transcript(&self, native: &str) -> PathBuf {
+ self.root.join(format!("projects/synthetic/{native}.jsonl"))
+ }
+
+ fn session(&self, native: &str) -> String {
+ ids::session_id(&self.runtime.id, native)
+ }
+
+ fn open_call(&self, native: &str, call: &str, files: &[Sent<'_>]) -> u64 {
+ let record = opener(native, self.temp.path(), call, files);
+ append_line(&self.transcript(native), &record)
+ }
+
+ fn finish_call(&self, native: &str, call: &str, content: Value, files: &[Sent<'_>]) -> u64 {
+ let record = result(native, self.temp.path(), call, content, files);
+ append_line(&self.transcript(native), &record)
+ }
+
+ /// A user line of plain prose; returns the offset it starts at.
+ fn prompt(&self, native: &str, uuid: &str, text: &str) -> u64 {
+ let message = json!({"role":"user","content":text});
+ let record = line("user", native, self.temp.path(), uuid.into(), message);
+ append_line(&self.transcript(native), &record)
+ }
+
+ /// One whole `SendUserFile` call; returns the offset of its result.
+ fn send(&self, native: &str, call: &str, files: &[Sent<'_>]) -> u64 {
+ self.open_call(native, call, files);
+ self.finish_call(native, call, json!("sent"), files)
+ }
+
+ /// A file an agent could send, under the fixture's own directory.
+ fn media(&self, name: &str, bytes: &[u8]) -> String {
+ let path = self.temp.path().join("media").join(name);
+ std::fs::write(&path, bytes).unwrap();
+ path.to_str().unwrap().to_owned()
+ }
+
+ fn ingest(&mut self) {
+ self.ingest_with(&options());
+ }
+
+ fn ingest_with(&mut self, options: &IngestOptions) {
+ let (runtime, root, ledger) = (&self.runtime, &self.root, Some(&mut self.ledger));
+ try_ingest(
+ &ClaudeCodeAdapter,
+ runtime,
+ root,
+ &mut self.store,
+ ledger,
+ options,
+ )
+ .unwrap();
+ }
+
+ /// One ordinary pass of this root into another projection, such as a
+ /// rebuilt one, over the fixture's own ledger handle.
+ fn catch_up(&mut self, store: &mut Store) {
+ let (runtime, root, ledger) = (&self.runtime, &self.root, Some(&mut self.ledger));
+ try_ingest(&ClaudeCodeAdapter, runtime, root, store, ledger, &options()).unwrap();
+ }
+
+ /// The daemon's live runner over this root, started as a restart starts
+ /// it: on a projection, with its own handle on a ledger and these hints.
+ fn runner(&self, store: &mut Store, ledger_dir: &Path, hints: &[&str]) -> LiveRunner<'static> {
+ let mut events = ScriptedEvents::default();
+ for hint in hints {
+ events.push_changed(hint);
+ }
+ let live = LiveOptions {
+ tick: Duration::ZERO,
+ poll_interval: Duration::from_secs(3600),
+ max_coalesced: 16,
+ };
+ LiveRunner::start_with_ledger(
+ &ClaudeCodeAdapter,
+ self.runtime.clone(),
+ self.root.clone(),
+ Box::new(events),
+ store,
+ options(),
+ live,
+ Some(Ledger::open(ledger_dir).unwrap()),
+ &Instrument::new(),
+ )
+ .unwrap()
+ }
+
+ /// Close the projection, change its files, and open it again.
+ fn reopen(&mut self, between: impl FnOnce(&Path)) {
+ self.store = Store::open_in_memory().unwrap();
+ between(&self.db);
+ self.store = Store::open(&self.db).unwrap();
+ }
+
+ /// A fresh projection rebuilt from a ledger alone.
+ fn rebuild(&self, name: &str, ledger_dir: &Path, options: RebuildOptions) -> (Store, PathBuf) {
+ let db = self.temp.path().join(format!("{name}.sqlite"));
+ let mut store = Store::open(&db).unwrap();
+ rebuild_runtime_with_options(
+ &ClaudeCodeAdapter,
+ &self.runtime,
+ ledger_dir,
+ &mut store,
+ &Instrument::new(),
+ options,
+ )
+ .unwrap();
+ (store, db)
+ }
+
+ /// Rebuild this fixture's ledger and return what the projection shows.
+ fn rebuilt(&self, name: &str) -> Shown {
+ let (store, db) = self.rebuild(name, &self.ledger_dir, RebuildOptions::default());
+ shown(&store, &db)
+ }
+
+ /// Remove everything a replay must not read: the sent files, the agent's
+ /// data and the live projection's members.
+ fn remove_every_source(&self) {
+ std::fs::remove_dir_all(self.temp.path().join("media")).unwrap();
+ std::fs::remove_dir_all(&self.root).unwrap();
+ std::fs::remove_dir_all(assets::asset_root(&self.db)).unwrap();
+ }
+}
+
+/// The metadata pass, then one stream pass over every source it listed.
+fn try_ingest(
+ adapter: &dyn AgentAdapter,
+ runtime: &RuntimeRow,
+ root: &Path,
+ store: &mut Store,
+ mut ledger: Option<&mut Ledger>,
+ options: &IngestOptions,
+) -> Result<(), PipelineError> {
+ let instrument = Instrument::new();
+ let (_, files) = meta_ingest_into(
+ adapter,
+ runtime,
+ root,
+ store,
+ options,
+ &instrument,
+ ledger.as_deref_mut(),
+ )?;
+ let streamed = stream_backfill_into(
+ adapter,
+ runtime,
+ root,
+ store,
+ &files,
+ options,
+ &instrument,
+ ledger.as_deref_mut(),
+ );
+ if let Some(ledger) = ledger {
+ ledger.flush().unwrap();
+ }
+ assert_eq!(streamed?.hard_errors, 0);
+ Ok(())
+}
+
+/// The stored checkpoint of a projection's one source.
+fn checkpoint(store: &Store) -> PipeCheckpoint {
+ let sql = "SELECT checkpoint FROM ingest_cursor";
+ let blob: String = store.conn().query_row(sql, [], |row| row.get(0)).unwrap();
+ PipeCheckpoint::decode(&blob).unwrap()
+}
+
+/// Every tool row's attachments, in timeline order.
+fn attachments(store: &Store, session: &str) -> Vec {
+ queries::timeline_page(store.conn(), session, None, 100)
+ .unwrap()
+ .into_iter()
+ .flat_map(|row| match row.body {
+ TimelineBody::ToolUse { tool } => tool.attachments,
+ _ => Vec::new(),
+ })
+ .collect()
+}
+
+/// What a projection shows of sent files: the stored `tool_detail` of every
+/// tool row (its attachments included), in session and timeline order, and
+/// every member as `name → blake3 of its bytes`.
+type Shown = (Vec, BTreeMap);
+
+fn shown(store: &Store, db: &Path) -> Shown {
+ let details = store
+ .conn()
+ .prepare(
+ "SELECT tool_detail FROM message
+ WHERE tool_detail IS NOT NULL ORDER BY session_id, ordinal",
+ )
+ .unwrap()
+ .query_map([], |row| row.get(0))
+ .unwrap()
+ .map(Result::unwrap)
+ .collect();
+ let members = std::fs::read_dir(assets::asset_root(db))
+ .into_iter()
+ .flatten()
+ .map(Result::unwrap)
+ .filter(|entry| entry.file_type().unwrap().is_file())
+ .map(|entry| {
+ (
+ entry.file_name().to_str().unwrap().to_owned(),
+ digest(&std::fs::read(entry.path()).unwrap()),
+ )
+ })
+ .collect();
+ (details, members)
+}
+
+fn digest(bytes: &[u8]) -> String {
+ blake3::hash(bytes).to_hex().to_string()
+}
+
+/// The members a projection holds when exactly these files were captured.
+fn members(files: &[(&str, &[u8])]) -> BTreeMap {
+ files
+ .iter()
+ .map(|(extension, bytes)| (format!("{}.{extension}", digest(bytes)), digest(bytes)))
+ .collect()
+}
+
+/// A ledger's document frames that carry a media claim, in ledger order.
+struct Frame {
+ source: String,
+ generation: u64,
+ offset: u64,
+ document: CapturedDocument,
+}
+
+fn media_frames(ledger_dir: &Path) -> Vec {
+ let entries = scan_ledger(ledger_dir).unwrap().entries;
+ let documents = entries.into_iter().filter_map(|entry| match entry {
+ LedgerEntry::Document {
+ source,
+ generation,
+ start_offset,
+ document,
+ } => Some(Frame {
+ source: source.relative_path,
+ generation,
+ offset: start_offset,
+ document,
+ }),
+ _ => None,
+ });
+ let frames: Vec = documents.collect();
+ assert!(
+ frames.iter().all(|frame| frame.document.media.is_some()),
+ "these transcripts publish no page, so every document frame is a media frame"
+ );
+ frames
+}
+
+/// Each media frame as `(generation, record offset, status, recovered_from)`.
+fn frame_rows(ledger_dir: &Path) -> Vec<(u64, u64, Status, Option)> {
+ let row = |frame: Frame| {
+ let document = frame.document;
+ let (status, recovered_from) = (document.status, document.recovered_from);
+ (frame.generation, frame.offset, status, recovered_from)
+ };
+ media_frames(ledger_dir).into_iter().map(row).collect()
+}
+
+/// Each media frame as `(generation, record offset, status)`.
+fn frame_statuses(ledger_dir: &Path) -> Vec<(u64, u64, Status)> {
+ media_frames(ledger_dir)
+ .iter()
+ .map(|frame| (frame.generation, frame.offset, frame.document.status))
+ .collect()
+}
+
+/// An attachment as `(kind, member digest, name, media type, size)`; the
+/// digest is absent for a path reference.
+type Slot = (
+ AttachmentKind,
+ Option