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
5 changes: 5 additions & 0 deletions .changeset/run-scoped-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": minor
---

Write screenshots and recordings beneath run- and restart-scoped media directories so named outputs cannot overwrite earlier runs.
2 changes: 1 addition & 1 deletion packages/drive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ If you installed the skill file, OpenCode will be able to see and interact with

Install the skill file above and ask the agent to test various flows with the app. Start with `--record` when you want a video; `opencode-drive stop` then exports the complete session and prints its path.

Screenshots and videos are written to `<system temp>/opencode-drive/output` with unique filenames. Set `OPENCODE_DRIVE_MEDIA_DIR` to use a different directory.
Screenshots and videos are written beneath `<system temp>/opencode-drive/output/<run-id>/<generation-id>`, so named outputs cannot overwrite media from earlier runs or restarts. Set `OPENCODE_DRIVE_MEDIA_DIR` to use a different media root.

Captured frames use the official full Commit Mono v1.143 faces at 16px with bundled Noto Symbols, Symbols 2, and Math fallbacks in a fixed 10x20 cell grid. Set `OPENCODE_DRIVE_FONT` to a comma-separated list of font files (for example regular, bold, italic, and bold-italic faces) to use a different primary capture font without changing the symbol fallback or cell geometry.

Expand Down
2 changes: 1 addition & 1 deletion packages/drive/src/instance/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function artifactDirectory() {

export async function initializeInstance(name?: string) {
const artifacts = resolve(
join(artifactDirectory(), `run-${crypto.randomUUID().slice(0, 6)}`),
join(artifactDirectory(), `run-${crypto.randomUUID()}`),
)
const logs = join(artifacts, "logs")
const drive = join(artifacts, "drive")
Expand Down
18 changes: 9 additions & 9 deletions packages/drive/src/instance/media.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { mkdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { basename, join, resolve } from "node:path"
import { artifactDirectory } from "./instance.js"

export function mediaDirectory() {
return resolve(
process.env.OPENCODE_DRIVE_MEDIA_DIR ??
join(tmpdir(), "opencode-drive", "output"),
join(artifactDirectory(), "output"),
)
}

export async function ensureMediaDirectory() {
const directory = mediaDirectory()
await mkdir(directory, { recursive: true })
return directory
}
export const runMediaDirectory = (artifacts: string, generation: number) =>
join(
mediaDirectory(),
basename(resolve(artifacts)),
`generation-${generation}`,
)
16 changes: 9 additions & 7 deletions packages/drive/src/instance/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as Scope from "effect/Scope"
import { ChildProcessSpawner } from "effect/unstable/process"
import { prepareDev } from "./dev.js"
import { instanceError, OpenCodeInstanceError } from "./error.js"
import { ensureMediaDirectory } from "./media.js"
import { runMediaDirectory } from "./media.js"
import { prepareInstanceProject } from "./instance.js"
import * as Process from "./process.js"
import { freePort, waitForWebSocket } from "./readiness.js"
Expand Down Expand Up @@ -111,10 +111,8 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
const logs = join(artifacts, "logs")
const drive = join(artifacts, "drive")
const files = join(artifacts, "files")
const media = yield* Effect.tryPromise({
try: () => ensureMediaDirectory(),
catch: (cause) => instanceError("prepare media", cause),
})
let mediaGeneration = 0
let media = runMediaDirectory(artifacts, mediaGeneration)
const endpoints = {
ui: `ws://127.0.0.1:${yield* freePort}`,
backend: `ws://127.0.0.1:${yield* freePort}`,
Expand Down Expand Up @@ -148,7 +146,6 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
OPENCODE_DRIVE_SCRIPTED: options.scripted ? "1" : undefined,
DRIVE_REGISTRY_DIR: drive,
OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
OPENCODE_DRIVE_MEDIA_DIR: media,
OPENCODE_CONFIG_DIR: join(files, ".opencode"),
OPENCODE_DB: database,
OPENCODE_LOG_LEVEL: !options.visible ? "DEBUG" : process.env.OPENCODE_LOG_LEVEL,
Expand Down Expand Up @@ -209,7 +206,11 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
options.log?.(`launching ${logName}`)
return yield* Process.spawn(appCommand, {
cwd: files,
env: { ...environment, OPENCODE_DRIVE: driveName },
env: {
...environment,
OPENCODE_DRIVE: driveName,
OPENCODE_DRIVE_MEDIA_DIR: media,
},
stdin: visible ? "inherit" : "ignore",
stdout: visible ? "inherit" : { path: join(logs, `${logName}.stdout.log`) },
stderr: visible ? "inherit" : { path: join(logs, `${logName}.stderr.log`) },
Expand Down Expand Up @@ -484,6 +485,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* (
return yield* Effect.failCause(combined.cause).pipe(
Effect.mapError((cause) => instanceError("restart", cause)),
)
media = runMediaDirectory(artifacts, ++mediaGeneration)
const recording = options.record ? recordingPaths(media) : undefined
yield* Ref.set(state, State.Running({
recording,
Expand Down
70 changes: 53 additions & 17 deletions packages/drive/test/cli/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"
import { mkdir, mkdtemp, readdir, realpath, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { basename, dirname, join, resolve } from "node:path"
import {
controlPath,
listInstances,
Expand Down Expand Up @@ -126,7 +126,8 @@ describe("opencode-drive", () => {
const screenshot = spawn(["send", "--name", name, "--command.ui.screenshot"], root)
expect(await screenshot.exited).toBe(0)
const screenshotPath = (await new Response(screenshot.stdout).text()).trim()
expect(screenshotPath.startsWith(`${join(root, "output")}/screenshot-`)).toBe(true)
expect(dirname(screenshotPath)).toBe(runOutputDirectory(root, manifest.artifacts, 0))
expect(basename(screenshotPath).startsWith("screenshot-")).toBe(true)
expect(screenshotPath.endsWith(".png")).toBe(true)

const listed = spawn(["dir", "--name", name], root)
Expand All @@ -145,7 +146,8 @@ describe("opencode-drive", () => {
const restarted = spawn(["restart", "--name", name], root)
expect(await restarted.exited).toBe(0)
const restartedRecording = (await new Response(restarted.stdout).text()).trim()
expect(restartedRecording).toMatch(/\/output\/recording-.*\.mp4$/)
expect(dirname(restartedRecording)).toBe(runOutputDirectory(root, manifest.artifacts, 0))
expect(basename(restartedRecording)).toMatch(/^recording-.*\.mp4$/)
expect(await Bun.file(restartedRecording).exists()).toBe(true)
await waitForLines(join(manifest.artifacts, "launches.txt"), 2)
expect(await spawn(["send", "--name", name, "--command.ui.state"], root).exited).toBe(0)
Expand All @@ -160,7 +162,8 @@ describe("opencode-drive", () => {
expect(stoppedOutput).toBe("")
const stoppedRecording = stoppedError.match(/Video successfully created: (.+\.mp4)/)?.[1]
expect(stoppedRecording).toBeDefined()
expect(stoppedRecording).toMatch(/\/output\/recording-.*\.mp4$/)
expect(dirname(stoppedRecording!)).toBe(runOutputDirectory(root, manifest.artifacts, 1))
expect(basename(stoppedRecording!)).toMatch(/^recording-.*\.mp4$/)
expect(await Bun.file(stoppedRecording!).exists()).toBe(true)
expect(stoppedError).toContain("Rendering video: 10%")
expect(stoppedError).toContain("Rendering video: 100%")
Expand Down Expand Up @@ -191,10 +194,11 @@ describe("opencode-drive", () => {
if (Date.now() >= deadline) throw new Error("timed out waiting for exited instance cleanup")
await Bun.sleep(25)
}
const files = await readdir(join(root, "output"))
const directory = runOutputDirectory(root, artifacts!, 0)
const files = await readdir(directory)
const video = files.find((file) => file.endsWith(".mp4"))
expect(video).toBeDefined()
expect(await Bun.file(join(root, "output", video!)).size).toBeGreaterThan(500)
expect(await Bun.file(join(directory, video!)).size).toBeGreaterThan(500)
}, 15_000)

test("does not record unless start receives --record", async () => {
Expand All @@ -212,7 +216,7 @@ describe("opencode-drive", () => {
const stopped = spawn(["stop", "--name", name], root)
expect(await stopped.exited).toBe(0)
expect(await new Response(stopped.stdout).text()).toBe("success\n")
expect(await readdir(join(root, "output"))).toEqual([])
expect(await Bun.file(join(root, "output")).exists()).toBe(false)
expect(await Bun.file(manifest.artifacts).exists()).toBe(false)
instances.pop()
})
Expand Down Expand Up @@ -503,18 +507,36 @@ describe("opencode-drive", () => {

test("runs multiple named instances concurrently", async () => {
const root = await temporary()
for (const name of ["first", "second"]) {
expect(
await spawn(["start", "--name", name, "--", process.execPath, fixture("fake-opencode.ts")], root).exited,
).toBe(0)
instances.push({ root, name })
}
const names = ["first", "second"] as const
const starts = names.map((name) =>
spawn(["start", "--name", name, "--", process.execPath, fixture("fake-opencode.ts")], root),
)
expect(await Promise.all(starts.map((child) => child.exited))).toEqual([0, 0])
instances.push(...names.map((name) => ({ root, name })))
const first = await Bun.file(join(root, "registry", "first.json")).json()
const second = await Bun.file(join(root, "registry", "second.json")).json()
roots.push(first.artifacts, second.artifacts)
expect(first.endpoints.ui).not.toBe(second.endpoints.ui)
expect(await spawn(["send", "--name", "first", "--command.ui.state"], root).exited).toBe(0)
expect(await spawn(["send", "--name", "second", "--command.ui.state"], root).exited).toBe(0)
const screenshots = await Promise.all(
[first, second].map(async (manifest, index) => {
const name = index === 0 ? "first" : "second"
const screenshot = spawn([
"send",
"--name",
name,
"--command.ui.screenshot",
'{"name":"shared"}',
], root)
expect(await screenshot.exited).toBe(0)
return (await new Response(screenshot.stdout).text()).trim()
}),
)
expect(screenshots).toEqual([
join(runOutputDirectory(root, first.artifacts, 0), "shared.png"),
join(runOutputDirectory(root, second.artifacts, 0), "shared.png"),
])

const unnamed = spawn(["dir"], root)
const [status, stderr] = await Promise.all([unnamed.exited, new Response(unnamed.stderr).text()])
Expand Down Expand Up @@ -828,8 +850,8 @@ describe("opencode-drive", () => {
bobMatches: true,
tuiBeforeServer: "launch the script server before launching TUIs",
duplicateServer: "the script server has already been launched",
aliceScreenshot: join(root, "output", "alice.png"),
bobScreenshot: join(root, "output", "bob.png"),
aliceScreenshot: join(runOutputDirectory(root, artifacts, 0), "alice.png"),
bobScreenshot: join(runOutputDirectory(root, artifacts, 0), "bob.png"),
})
expect((await Bun.file(join(artifacts, "launches.txt")).text()).trim().split("\n")).toHaveLength(2)
}, 60_000)
Expand Down Expand Up @@ -907,7 +929,8 @@ describe("opencode-drive", () => {
expect(Number.isInteger(result.firstServer)).toBe(true)
expect(Number.isInteger(result.secondServer)).toBe(true)
expect(result.secondServer).not.toBe(result.firstServer)
expect(result.aliceRecording).toMatch(/\/output\/recording-.*\.mp4$/)
expect(dirname(result.aliceRecording)).toBe(runOutputDirectory(root, artifacts, 0))
expect(basename(result.aliceRecording)).toMatch(/^recording-.*\.mp4$/)
expect(await Bun.file(result.aliceRecording).exists()).toBe(true)
const recordings = [...error.matchAll(/opencode-drive: recording (.+\.mp4)/g)].map((match) => match[1]!)
expect(recordings).toHaveLength(2)
Expand Down Expand Up @@ -1184,6 +1207,13 @@ describe("opencode-drive", () => {

expect(await spawn(["restart", "--name", name], root).exited).toBe(0)
await waitForLines(join(manifest.artifacts, "script-runs.txt"), 2)
const screenshots = (await Bun.file(join(manifest.artifacts, "script-screenshots.txt")).text())
.trim()
.split("\n")
expect(screenshots).toEqual([
join(runOutputDirectory(root, manifest.artifacts, 0), "restart-shared.png"),
join(runOutputDirectory(root, manifest.artifacts, 1), "restart-shared.png"),
])
expect(await spawn(["stop", "--name", name], root).exited).toBe(0)
expect(await owner.exited).toBe(0)
})
Expand Down Expand Up @@ -1269,7 +1299,9 @@ describe("opencode-drive", () => {
process.kill(child.pid, "SIGTERM")
await child.exited

expect((await readdir(join(root, "output"))).some((file) => file.endsWith(".mp4"))).toBe(true)
expect(
(await readdir(runOutputDirectory(root, manifest.artifacts, 0))).some((file) => file.endsWith(".mp4")),
).toBe(true)
expect(await Bun.file(join(root, "registry", `${name}.json`)).exists()).toBe(false)
expect(await Bun.file(join(root, "registry", `${name}.sock`)).exists()).toBe(false)
}, 60_000)
Expand Down Expand Up @@ -1476,6 +1508,10 @@ function fixture(name: string) {
return resolve("test", "fixtures", name)
}

function runOutputDirectory(root: string, artifacts: string, generation: number) {
return join(root, "output", basename(artifacts), `generation-${generation}`)
}

async function temporary() {
const root = await mkdtemp(join(tmpdir(), "opencode-drive-test-"))
roots.push(root)
Expand Down
10 changes: 9 additions & 1 deletion packages/drive/test/fixtures/restart-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@ import * as Effect from "effect/Effect"
import * as Stream from "effect/Stream"

export default defineScript({
run: ({ artifacts, llm }) =>
run: ({ artifacts, llm, ui }) =>
Effect.gen(function* () {
yield* llm.serve(() =>
Stream.fromEffect(
Effect.sleep(500).pipe(Effect.as(Llm.text("late response"))),
),
)
const file = `${artifacts}/script-runs.txt`
const screenshotFile = `${artifacts}/script-screenshots.txt`
const previous = yield* Effect.promise(() =>
Bun.file(file).text().catch(() => ""),
)
const screenshots = yield* Effect.promise(() =>
Bun.file(screenshotFile).text().catch(() => ""),
)
const screenshot = yield* ui.screenshot("restart-shared")
yield* Effect.tryPromise(() =>
Bun.write(screenshotFile, `${screenshots}${screenshot}\n`),
)
yield* Effect.tryPromise(() => Bun.write(file, `${previous}run\n`))
yield* Effect.never
}),
Expand Down