mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(browser): never replace an existing output file with browser_screenshot browser_screenshot published straight onto outputs/<name> through the replacing atomic publisher, so a screenshot saved as report.png silently replaced the user's own report.png, an earlier capture, or another default-named capture taken in the same second (the default stem has second resolution). The sibling web_capture tool already keeps existing files and takes a -1, -2, ... suffix. Stage the complete capture under a hidden sibling and hard-link it onto the first free candidate name. The link is the whole no-overwrite guard: it fails with FileExistsError when the name exists as anything, so no name is checked before it is claimed and a concurrent writer cannot be replaced; the final name never shows a partial file. Staging shares the outputs directory, so the link is same-filesystem, and like Gateway uploads other link failures propagate instead of falling back to replacement. New files keep the publisher's normal creation mode. Hidden per-action progress frames keep the replacing _write_screenshot: their names carry microsecond timestamps in a server-owned directory. * docs(changelog): reference #6913 for the browser_screenshot no-overwrite fix * fix(browser): refuse a staged screenshot swapped before it is linked The sandbox can write outputs/, so it can see the hidden staged capture and replace it with a symlink before the hard link. os.link follows symlinks by default, which would publish a hard link to the host file as the screenshot artifact (the replacing publisher this PR moved away from never followed one). Stage the capture with an exclusively created descriptor and keep its identity from fstat, link without following symlinks where the platform supports it (decided once at import), and require the published name to be that inode; anything else is removed and the capture fails, like Gateway uploads' identity check. A failed staging write still leaves no files behind. * docs(readme): note that browser_screenshot never replaces an output file * fix(browser): keep the staged screenshot open until its link is verified Linux filesystems reuse a freed inode number at once. Closing the staged capture before linking let a file swapped in at the staged name take the same number and pass the samestat check, so the symlink and planted-file swap tests failed on Linux CI while passing on macOS. Hold the staged descriptor through the link and identity check, as Gateway uploads do, and require the published name to be a regular file. * test(browser): race the peer claim against the real publication step The claimed-name test patched _publish_bytes_atomically, which the screenshot path no longer calls, so its hook never fired and the race assertions passed vacuously. Plant the peer file right before os.link or os.replace lands bytes on a visible name, and assert that the claim happened, the capture moved to report-1.png, and the peer file survived. * refactor(capture): share one output-name candidate sequence browser_screenshot hand-copied web_capture's probe bound and suffix order, and the copies had already drifted: Browserless retried a held timestamped name five times, the screenshot tool once. Move the bound, the retry count and the candidate order into utils/output_names.py and have both tools iterate it, so the naming cannot drift again. Browserless behavior is unchanged; its tests now patch the shared module. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
79 lines
3.3 KiB
Python
79 lines
3.3 KiB
Python
# SPDX-License-Identifier: MIT
|
|
"""Both capture tools take output names from one shared candidate sequence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from datetime import UTC, datetime
|
|
from itertools import islice
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.utils import output_names
|
|
|
|
browser_automation_tools = importlib.import_module("deerflow.community.browser_automation.tools")
|
|
browserless_tools = importlib.import_module("deerflow.community.browserless.tools")
|
|
|
|
|
|
class _StepClock(datetime):
|
|
"""Each call advances one microsecond, so every fallback stamp is distinct."""
|
|
|
|
ticks = 0
|
|
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
cls.ticks += 1
|
|
return datetime(2026, 10, 18, 12, 0, 0, cls.ticks, tzinfo=UTC)
|
|
|
|
|
|
def test_candidates_keep_the_name_then_number_then_timestamp(monkeypatch):
|
|
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 2)
|
|
monkeypatch.setattr(_StepClock, "ticks", 0)
|
|
monkeypatch.setattr(output_names, "datetime", _StepClock)
|
|
|
|
assert list(output_names.output_name_candidates("report.png")) == [
|
|
"report.png",
|
|
"report-1.png",
|
|
"report-2.png",
|
|
*(f"report-20261018-120000-{tick:06d}.png" for tick in range(1, output_names.MAX_TIMESTAMP_ATTEMPTS + 1)),
|
|
]
|
|
|
|
|
|
def test_candidates_read_the_clock_only_when_a_fallback_is_reached(monkeypatch):
|
|
monkeypatch.setattr(_StepClock, "ticks", 0)
|
|
monkeypatch.setattr(output_names, "datetime", _StepClock)
|
|
|
|
assert len(list(islice(output_names.output_name_candidates("a.png"), output_names.MAX_COLLISION_PROBES + 1))) == output_names.MAX_COLLISION_PROBES + 1
|
|
assert _StepClock.ticks == 0
|
|
|
|
|
|
def test_both_capture_tools_share_the_candidate_sequence():
|
|
for module in (browser_automation_tools, browserless_tools):
|
|
assert module.output_name_candidates is output_names.output_name_candidates
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_retries_a_held_timestamp_fallback(tmp_path, monkeypatch):
|
|
# Browserless already retried a held timestamped name; the screenshot tool
|
|
# now shares that bound instead of giving up after one stamp.
|
|
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 1)
|
|
monkeypatch.setattr(_StepClock, "ticks", 0)
|
|
monkeypatch.setattr(output_names, "datetime", _StepClock)
|
|
held = ["report.png", "report-1.png", "report-20261018-120000-000001.png"]
|
|
for name in held:
|
|
(tmp_path / name).write_bytes(b"earlier")
|
|
|
|
session = MagicMock()
|
|
session.screenshot_bytes = AsyncMock(return_value=b"shot")
|
|
manager = MagicMock()
|
|
manager.get_session.return_value = session
|
|
runtime = SimpleNamespace(context={"thread_id": "thread-1"}, state={"thread_data": {"outputs_path": str(tmp_path)}})
|
|
with patch.object(browser_automation_tools, "get_browser_session_manager", return_value=manager), patch.object(browser_automation_tools, "_get_tool_config", return_value={}):
|
|
result = await browser_automation_tools.browser_screenshot_tool.coroutine(runtime=runtime, tool_call_id="t1", filename="report.png")
|
|
|
|
assert result.update["artifacts"] == ["/mnt/user-data/outputs/report-20261018-120000-000002.png"]
|
|
assert (tmp_path / "report-20261018-120000-000002.png").read_bytes() == b"shot"
|
|
assert all((tmp_path / name).read_bytes() == b"earlier" for name in held)
|