feat(plugins): add source-aware evidence and recording timelines

This commit is contained in:
chenmingxuan committed 2026-10-16 10:20:11 +08:00
1 parent 72ff342605
commit ef7af9988e
61 files changed
+3847 -108

No files matched your search

+391
View File
@@ -0,0 +1,391 @@
"""Recorder lifecycle and archive limits use fake processes, never a live desktop."""
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from artemis.config.agent import RecordingConfig
from artemis.plugins.models import EvidenceSource
from artemis.plugins import recording
from artemis.plugins.recording import PluginRecordings, build_capture_command, capture_rectangle
def source(**updates):
return EvidenceSource(
**{
"kind": "computer",
"target_id": "123",
"label": "Fixture window",
"width": 800,
"height": 600,
"window": {"left": 40, "top": 50, "width": 800, "height": 600},
"screen": {"left": -1920, "top": 0, "width": 1920, "height": 1080},
**updates,
}
)
def context(root, *, enabled=True, **config):
return SimpleNamespace(
data_engine=SimpleNamespace(base_dir=root, session_start_time=100),
agent_config=SimpleNamespace(recording=RecordingConfig(**config)),
execution_setup=SimpleNamespace(
record_video_enabled=enabled, video_recording_tools_enabled=False
),
)
class FakeRecorderProcess:
def __init__(self, *, produces_frames=True):
self.pid = 123456
self.returncode = None if produces_frames else 1
self.stdout = asyncio.StreamReader()
if produces_frames:
self.stdout.feed_data(b"frame=1\n")
self.stdout.feed_eof()
self.stdin = SimpleNamespace(write=self._quit, drain=AsyncMock())
self.killed = False
def _quit(self, _data):
self.returncode = 0
async def wait(self):
return self.returncode
def kill(self):
self.killed = True
self.returncode = -9
@pytest.fixture
def fake_capture(monkeypatch):
state = SimpleNamespace(commands=[], processes=[], fail_hevc=False)
async def spawn(*command, **_kwargs):
state.commands.append(command)
encoder = command[command.index("-c:v") + 1]
process = FakeRecorderProcess(
produces_frames=not (state.fail_hevc and encoder.startswith("hevc"))
)
state.processes.append(process)
Path(command[-1]).write_bytes(b"fake mp4")
return process
local_asyncio = SimpleNamespace(**vars(asyncio))
local_asyncio.create_subprocess_exec = spawn
monkeypatch.setattr(recording, "asyncio", local_asyncio)
monkeypatch.setattr(recording, "os", SimpleNamespace(name="nt", getpid=lambda: 123))
monkeypatch.setattr(
recording,
"CaptureProcessJob",
lambda: SimpleNamespace(assign=lambda _pid: None, close=lambda: None),
)
monkeypatch.setattr(
recording, "shutil", SimpleNamespace(disk_usage=lambda _path: SimpleNamespace(free=10**10))
)
monkeypatch.setattr(recording, "time", SimpleNamespace(time=lambda: 123.0))
monkeypatch.setattr(recording, "get_ffmpeg_path", lambda: "fake-ffmpeg")
monkeypatch.setattr(
recording,
"probe_video_segment",
AsyncMock(return_value={"duration": 2.5, "width": 1280, "height": 720}),
)
return state
def argument(command, option):
return command[command.index(option) + 1]
def test_archive_command_keeps_physical_capture_and_limits_encoded_output(tmp_path):
command = build_capture_command(
"ffmpeg", source(), RecordingConfig(), tmp_path / "video.mp4", "libx264"
)
assert argument(command, "-offset_x") == "-1920"
assert argument(command, "-video_size") == "1920x1080"
assert argument(command, "-vf") == "scale=1280:720"
assert argument(command, "-framerate") == "15"
assert argument(command, "-b:v") == "1500k"
assert argument(command, "-g") == "30"
assert "-an" in command
def test_small_window_is_not_upscaled_and_hwnd_capture_is_explicit(tmp_path):
command = build_capture_command(
"ffmpeg",
source(),
RecordingConfig(computer_scope="window", codec="h265"),
tmp_path / "video.mp4",
"hevc_nvenc",
)
assert argument(command, "-i") == "hwnd=123"
assert argument(command, "-vf") == "scale=800:600"
assert argument(command, "-b:v") == "1000k"
assert argument(command, "-tag:v") == "hvc1"
assert "-offset_x" not in command
@pytest.mark.parametrize(
"area",
[
None,
{"left": 0},
{"left": 0, "top": 0, "width": 0, "height": 600},
{"left": 65537, "top": 0, "width": 800, "height": 600},
],
)
def test_invalid_capture_geometry_is_not_sent_to_ffmpeg(area):
with pytest.raises(ValueError):
capture_rectangle(source(screen=area), "screen")
def test_window_recording_rejects_non_hwnd_targets(tmp_path):
with pytest.raises(ValueError, match="HWND"):
build_capture_command(
"ffmpeg",
source(target_id="window-title"),
RecordingConfig(computer_scope="window"),
tmp_path / "video.mp4",
"libx264",
)
@pytest.mark.asyncio
async def test_disabled_recording_does_not_spawn_or_create_evidence(tmp_path, fake_capture):
recorder = PluginRecordings(context(tmp_path, enabled=False))
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
assert fake_capture.commands == []
assert not (tmp_path / "evidence").exists()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"codec,hevc_playback,expected_attempts",
[("auto", False, 1), ("auto", True, 4), ("h265", False, 4)],
)
async def test_hardware_hevc_failure_falls_back_to_compatible_archive(
tmp_path, fake_capture, codec, hevc_playback, expected_attempts
):
fake_capture.fail_hevc = True
recorder = PluginRecordings(context(tmp_path, codec=codec, hevc_playback=hevc_playback))
try:
await recorder.observe("source-1", "visual-test", source())
assert len(fake_capture.commands) == expected_attempts
assert argument(fake_capture.commands[-1], "-c:v") == "libx264"
assert len(recorder.active) == 1
finally:
await recorder.stop()
track = next(iter(recorder.sources.values()))
assert track["status"] == "ready"
assert len(track["segments"]) == 1
assert track["segments"][0]["codec"] == "h264"
assert track["segments"][0]["duration_ms"] == 2500
assert all(process.returncode is not None for process in fake_capture.processes)
@pytest.mark.asyncio
async def test_same_screen_reuses_recording_and_keeps_both_target_ids(tmp_path, fake_capture):
recorder = PluginRecordings(context(tmp_path))
try:
await recorder.observe("window-1", "visual-test", source())
await recorder.observe("window-2", "visual-test", source(target_id="456"))
assert len(fake_capture.commands) == 1
finally:
await recorder.stop()
assert next(iter(recorder.sources.values()))["target_ids"] == ["window-1", "window-2"]
@pytest.mark.asyncio
async def test_low_disk_space_records_failure_without_starting_capture(
tmp_path, fake_capture, monkeypatch
):
monkeypatch.setattr(recording.shutil, "disk_usage", lambda _path: SimpleNamespace(free=1))
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
assert fake_capture.commands == []
track = next(iter(recorder.sources.values()))
assert track["status"] == "failed"
assert "磁盘" in track["message"]
@pytest.mark.asyncio
async def test_index_write_failure_does_not_leave_capture_processes(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
def unavailable():
raise OSError("disk full")
monkeypatch.setattr(recorder, "_save", unavailable)
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
assert not recorder.active
assert all(process.returncode is not None for process in fake_capture.processes)
assert next(iter(recorder.sources.values()))["status"] == "failed"
@pytest.mark.asyncio
async def test_probe_failure_during_stop_is_recorded_and_capture_is_reaped(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
monkeypatch.setattr(
recording, "probe_video_segment", AsyncMock(side_effect=OSError("probe unavailable"))
)
await recorder.stop()
assert not recorder.active
assert all(process.returncode is not None for process in fake_capture.processes)
track = next(iter(recorder.sources.values()))
assert track["status"] == "failed"
assert "probe unavailable" in track["message"]
@pytest.mark.asyncio
async def test_stop_retries_process_cleanup_interrupted_in_watchdog(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
recorder._watchdog.cancel()
await asyncio.gather(recorder._watchdog, return_exceptions=True)
key = next(iter(recorder.active))
entered = asyncio.Event()
attempts = 0
terminate = recorder._terminate
async def interrupted_first(process):
nonlocal attempts
attempts += 1
if attempts == 1:
entered.set()
await asyncio.Future()
await terminate(process)
monkeypatch.setattr(recorder, "_terminate", interrupted_first)
recorder._watchdog = asyncio.create_task(recorder._finish(key))
await entered.wait()
await recorder.stop()
assert attempts == 2
assert not recorder.active
assert all(process.returncode is not None for process in fake_capture.processes)
@pytest.mark.asyncio
async def test_stop_preserves_clip_when_watchdog_is_cancelled_during_probe(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
recorder._watchdog.cancel()
await asyncio.gather(recorder._watchdog, return_exceptions=True)
key = next(iter(recorder.active))
entered = asyncio.Event()
attempts = 0
async def interrupted_probe(_output):
nonlocal attempts
attempts += 1
if attempts == 1:
entered.set()
await asyncio.Future()
return {"duration": 2.5, "width": 1280, "height": 720}
monkeypatch.setattr(recording, "probe_video_segment", interrupted_probe)
recorder._watchdog = asyncio.create_task(recorder._finish(key))
await entered.wait()
await recorder.stop()
assert attempts == 2
assert not recorder.active
track = recorder.sources[key]
assert track["status"] == "ready"
assert track["segments"][0]["duration_ms"] == 2500
@pytest.mark.asyncio
async def test_resumed_recorder_appends_and_does_not_overwrite_previous_clips(
tmp_path, fake_capture
):
first = PluginRecordings(context(tmp_path))
await first.observe("source-1", "visual-test", source())
await first.stop()
original = next(iter(first.sources.values()))["segments"][0].copy()
original_path = tmp_path / original["file"]
original_path.write_bytes(b"immutable original clip")
second = PluginRecordings(context(tmp_path))
try:
await second.observe("source-1", "visual-test", source())
finally:
await second.stop()
segments = next(iter(second.sources.values()))["segments"]
assert len(segments) == 2
assert segments[0] == original
assert segments[1]["file"] != original["file"]
assert original_path.read_bytes() == b"immutable original clip"
manifest = json.loads(
(tmp_path / "evidence/recordings/recordings.json").read_text(encoding="utf-8")
)
assert len(manifest["sources"][0]["segments"]) == 2
@pytest.mark.asyncio
async def test_resized_window_seals_old_geometry_before_new_capture(tmp_path, fake_capture):
recorder = PluginRecordings(context(tmp_path, computer_scope="window"))
try:
await recorder.observe("source-1", "visual-test", source())
await recorder.observe(
"source-2",
"visual-test",
source(window={"left": 40, "top": 50, "width": 1000, "height": 700}),
)
assert len(fake_capture.commands) == 2
assert fake_capture.processes[0].returncode is not None
assert len(recorder.active) == 1
finally:
await recorder.stop()
assert len(recorder.sources) == 2
assert all(track["status"] == "ready" for track in recorder.sources.values())
@pytest.mark.asyncio
async def test_dead_worker_recovery_keeps_partial_clip_and_does_not_restart(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
track = next(iter(recorder.sources.values()))
track.update(status="recording", owner_pid=987654321, owner_created_at=1)
track["segments"][0].update(duration=0, duration_ms=0)
recorder._save()
count = len(fake_capture.commands)
assert await recording.recover_interrupted_recordings(tmp_path)
recovered = PluginRecordings(context(tmp_path))
result = next(iter(recovered.sources.values()))
assert result["status"] == "failed"
assert result["segments"][0]["duration"] == 2.5
assert len(fake_capture.commands) == count
@pytest.mark.asyncio
async def test_live_worker_recovery_never_finalizes_active_capture(tmp_path, fake_capture):
import os
import psutil
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
track = next(iter(recorder.sources.values()))
track.update(owner_pid=os.getpid(), owner_created_at=psutil.Process().create_time())
recorder._save()
try:
assert not await recording.recover_interrupted_recordings(tmp_path)
assert track["status"] == "recording"
finally:
await recorder.stop()