Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .misc/video/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
out/
.DS_Store
78 changes: 78 additions & 0 deletions .misc/video/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# README video: a real session

A real recording of the reference agent, in two acts:

1. **An ordinary turn.** A casual request for a weekend plan. The model
writes a program whose three web searches run at once, saves `lisbon.md`
in its sandbox, and is steered while it works.
2. **Kill it mid-turn.** A follow-up waits on a 30-second durable timer. The
service is killed with `kill -9`, started again, and Restate replays the
turn's journal, so the turn reads the file back and answers.

The script records the whole thing unattended, so it can be recorded again
when the UI or the runtime changes.

| File | Does |
| --- | --- |
| `record.mjs` | Runs the scenario: starts, kills and restarts the core service, types into the web UI, polls the turn's journal from the Admin API, and captures frames of the stage |
| `director.html` | The 1920脳1080 stage: the web UI, the service terminal, the journal panel, captions and title cards |
| `cdp.mjs` | A tiny Chrome DevTools Protocol client (Node 22+, no npm packages) |
| `encode.sh` | Turns the captured frames into `out/agent-demo.mp4` |

Everything on screen comes from the running system except the titles and
captions: the UI is streamed from its own tab, the terminal shows the
service's real log lines, and the journal panel is `sys_journal`. The quiet
stretches (model calls after the steer, the service down, the timer
running) play faster, shown by the badge in the header.

## Record

You need Docker (or `restate-server`), Google Chrome, ffmpeg, and an
`OPENAI_API_KEY`. Start from a fresh Restate server, so the agent ID `demo`
is new:

```sh
pnpm build
docker run -d --name video-restate --rm -p 8080:8080 -p 9070:9070 \
-e RESTATE_EXPERIMENTAL_ENABLE_PROTOCOL_V7=true docker.restate.dev/restatedev/restate:latest

# Register the service once, then stop it: record.mjs starts it on camera.
(cd packages/libs/core && node dist/app.js) &
curl localhost:9070/deployments --json '{"uri":"http://host.docker.internal:9080"}'
kill %1

# The web UI.
(cd packages/apps/web && npx next start --hostname 127.0.0.1) &

AGENT_ID=demo node .misc/video/record.mjs # about two minutes
.misc/video/encode.sh # out/agent-demo.mp4
```

With `restate-server` instead of Docker, register `http://localhost:9080`.

## Check it

Look at frames before using a take, for example one every three seconds:

```sh
ffmpeg -i .misc/video/out/agent-demo.mp4 -vf fps=1/3 /tmp/frames/%02d.png
```

Check that each ask went through at once (the prompt must not sit in the
composer), that the steer landed while the turn ran (the script stops if it
did not), that the kill happened while only the timer was open, that the
journal marks only finished steps as replayed, and that the Restate UI scene
shows the 30-second `sleep` row. The model's wording differs between takes,
so read its answers too.

A take runs about 75 seconds. If it runs much longer, look for leftover
headless Chrome processes (`pgrep -f readme-video-chrome`): they slow the UI
down enough to delay the ask.

## Put it in the README

GitHub plays an MP4 inline only from a `user-attachments` URL: drag
`agent-demo.mp4` into the README editor on github.com (or into a PR
comment) and use the URL it inserts. A committed `.mp4` file only renders as
a link. The README uses it under the intro; replace that URL when you record
a new take.
167 changes: 167 additions & 0 deletions .misc/video/cdp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// A minimal Chrome DevTools Protocol client: launches headless Chrome, opens
// tabs and sends commands to them. Node 22+ only (built-in WebSocket), so the
// recorder needs no npm packages.
import {spawn} from "node:child_process";
import {mkdtempSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";

const CHROME =
process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";

export async function launchChrome({port = 9333} = {}) {
const profile = mkdtempSync(join(tmpdir(), "readme-video-chrome-"));
const chrome = spawn(
CHROME,
[
"--headless=new",
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
"--hide-scrollbars",
"--force-color-profile=srgb",
"--no-proxy-server",
"--no-first-run",
"--no-default-browser-check",
"about:blank",
],
{stdio: "ignore"},
);
const url = await waitForDebugger(port);
const browser = new Browser(new WebSocket(url), chrome);
await browser.opened;
return browser;
}

async function waitForDebugger(port) {
for (let attempt = 0; attempt < 100; attempt++) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
return (await response.json()).webSocketDebuggerUrl;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error("Chrome did not start its debugger");
}

class Browser {
constructor(socket, process) {
this.socket = socket;
this.process = process;
this.nextId = 1;
this.waiting = new Map();
this.listeners = new Map();
this.opened = new Promise((resolve) => socket.addEventListener("open", resolve));
socket.addEventListener("message", (message) => this.receive(JSON.parse(message.data)));
}

receive(message) {
if (message.id !== undefined) {
const waiter = this.waiting.get(message.id);
this.waiting.delete(message.id);
if (message.error) waiter.reject(new Error(message.error.message));
else waiter.resolve(message.result);
return;
}
const key = `${message.sessionId ?? ""}:${message.method}`;
for (const listener of this.listeners.get(key) ?? []) listener(message.params);
}

send(method, params = {}, sessionId) {
const id = this.nextId++;
this.socket.send(JSON.stringify({id, method, params, sessionId}));
return new Promise((resolve, reject) => this.waiting.set(id, {resolve, reject}));
}

on(sessionId, method, listener) {
const key = `${sessionId}:${method}`;
this.listeners.set(key, [...(this.listeners.get(key) ?? []), listener]);
}

async newTab({url, width, height, scale = 1}) {
const {targetId} = await this.send("Target.createTarget", {url: "about:blank", newWindow: true});
const {sessionId} = await this.send("Target.attachToTarget", {targetId, flatten: true});
const tab = new Tab(this, sessionId);
await tab.send("Page.enable");
await tab.send("Runtime.enable");
await tab.send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: scale,
mobile: false,
});
if (url) await tab.goto(url);
return tab;
}

/** Closes Chrome and waits for it to exit, so no renderers are left behind. */
async close() {
const exited = new Promise((resolve) => this.process.once("exit", resolve));
await this.send("Browser.close").catch(() => {});
this.socket.close();
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 3_000))]);
this.process.kill("SIGKILL");
}
}

class Tab {
constructor(browser, sessionId) {
this.browser = browser;
this.sessionId = sessionId;
}

send(method, params) {
return this.browser.send(method, params, this.sessionId);
}

on(method, listener) {
this.browser.on(this.sessionId, method, listener);
}

async goto(url) {
const loaded = new Promise((resolve) => this.on("Page.loadEventFired", resolve));
await this.send("Page.navigate", {url});
await loaded;
}

async evaluate(expression) {
const {result, exceptionDetails} = await this.send("Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true,
});
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? expression);
return result.value;
}

/** Polls a page expression until it is truthy. */
async waitFor(expression, {timeoutMs = 120_000, intervalMs = 200} = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await this.evaluate(expression);
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out waiting for: ${expression}`);
}

async screenshot() {
const {data} = await this.send("Page.captureScreenshot", {format: "png"});
return Buffer.from(data, "base64");
}

/** Types text into the focused element, one key event per character. */
async type(text, {delayMs = 35} = {}) {
for (const char of text) {
await this.send("Input.insertText", {text: char});
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

async press(key) {
const codes = {Enter: 13};
const event = {key, code: key, windowsVirtualKeyCode: codes[key], text: key === "Enter" ? "\r" : undefined};
await this.send("Input.dispatchKeyEvent", {type: "keyDown", ...event});
await this.send("Input.dispatchKeyEvent", {type: "keyUp", ...event});
}
}
Loading
Loading