diff --git a/README.md b/README.md index 2c0c1cbb61..7abd4f1196 100644 --- a/README.md +++ b/README.md @@ -2598,6 +2598,11 @@ Image bytes loaded for a vision-model call are transient: DeerFlow removes the h After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes, and stdio MCP temporary/debug files under the DeerFlow-owned `.mcp/` namespace are excluded because they are process-internal state (like `.git/` and `node_modules/`, any directory named `.mcp` is excluded at any depth). Large, binary, or sensitive-looking files are shown as metadata only. +If a file disappears while being hashed, the scan omits that file and continues +recording the others. A concurrent scratch-file removal therefore does not +discard the whole snapshot or suppress detection of other produced outputs. +This remains a best-effort scan, not an atomic filesystem snapshot. + Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback. Unsaved artifact edits survive switching chats, workspace pages, or visiting the diff --git a/backend/packages/harness/deerflow/workspace_changes/scanner.py b/backend/packages/harness/deerflow/workspace_changes/scanner.py index 33bfe6750d..002041a189 100644 --- a/backend/packages/harness/deerflow/workspace_changes/scanner.py +++ b/backend/packages/harness/deerflow/workspace_changes/scanner.py @@ -219,7 +219,10 @@ def _snapshot_file( return None binary = host_file.suffix.lower() in BINARY_EXTENSIONS or _looks_binary(sample) - sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None + try: + sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None + except FileNotFoundError: + return None text: str | None = None text_path: str | None = None reason: DiffUnavailableReason | None = None diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index a39676c308..9a7d218266 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -302,7 +302,11 @@ secrets, shell/Compose dotenv overrides, readable read-only secrets, and teardow without secret exports. `down` must not probe, read, or generate secrets. Writable-directory cases remain portable. -## Workspace text cache +## Workspace snapshots and text cache + +`test_workspace_changes.py` and `test_run_worker_delivery.py` remove real files +at the hash-read boundary. A vanished source must not discard other file records +or produced-output detection; permission and other hash I/O errors still propagate. `test_scanner_text_cache_atomic.py` verifies complete publication and preservation of an existing entry on failure. A cleanup failure must preserve the original diff --git a/backend/tests/test_run_worker_delivery.py b/backend/tests/test_run_worker_delivery.py index c7edb0ac95..39e461e798 100644 --- a/backend/tests/test_run_worker_delivery.py +++ b/backend/tests/test_run_worker_delivery.py @@ -19,6 +19,7 @@ from deerflow.runtime.runs.schemas import RunStatus from deerflow.runtime.runs.store.memory import MemoryRunStore from deerflow.runtime.runs.worker import RunContext, _delivery_content_with_outputs, run_agent from deerflow.runtime.user_context import get_effective_user_id +from deerflow.workspace_changes.api import get_workspace_changes_response def _make_bridge(): @@ -52,6 +53,64 @@ def test_delivery_verification_treats_presented_directory_as_covering_produced_f assert delivery["satisfied"] is True +@pytest.mark.anyio +@pytest.mark.parametrize("disappear_during", ["before-run", "after-run"]) +async def test_vanished_scratch_file_does_not_discard_run_file_changes(tmp_path, monkeypatch, disappear_during): + import deerflow.workspace_changes.scanner as scanner + + paths = Paths(base_dir=tmp_path) + monkeypatch.setattr("deerflow.workspace_changes.recorder.get_paths", lambda: paths) + user_id = get_effective_user_id() + workspace = paths.sandbox_work_dir("thread-1", user_id=user_id) + outputs = paths.sandbox_outputs_dir("thread-1", user_id=user_id) + workspace.mkdir(parents=True) + outputs.mkdir(parents=True) + scratch = workspace / "scratch.txt" + if disappear_during == "before-run": + scratch.write_text("temporary\n", encoding="utf-8") + removed = [] + real_hash = scanner._sha256_file + + def remove_before_hash(path): + if path == scratch: + path.unlink() + removed.append(path) + return real_hash(path) + + monkeypatch.setattr(scanner, "_sha256_file", remove_before_hash) + run_manager = RunManager() + record = await run_manager.create("thread-1") + store = MemoryRunEventStore() + + class ProducingAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + if disappear_during == "after-run": + scratch.write_text("temporary\n", encoding="utf-8") + (outputs / "report.md").write_text("Report ready\n", encoding="utf-8") + yield {"messages": [AIMessage(content="Created report.md.")]} + + await run_agent( + _make_bridge(), + run_manager, + record, + ctx=RunContext(checkpointer=None, event_store=store), + agent_factory=lambda *, config: ProducingAgent(), + graph_input={}, + config={}, + ) + + assert removed == [scratch] + changes = await get_workspace_changes_response(store, "thread-1", record.run_id) + assert changes["available"] is True + assert [(change["path"], change["status"]) for change in changes["files"]] == [("/mnt/user-data/outputs/report.md", "created")] + assert "+Report ready" in changes["files"][0]["diff"] + delivery = await _delivery_events(store, "thread-1", record.run_id) + assert delivery[0]["content"]["produced_paths"] == ["/mnt/user-data/outputs/report.md"] + assert delivery[0]["content"]["satisfied"] is False + assert record.status == RunStatus.error + assert record.error == "Artifact delivery incomplete: no produced output artifact was presented" + + @pytest.mark.anyio async def test_delivery_event_records_present_files_paths_on_success(): run_manager = RunManager() diff --git a/backend/tests/test_workspace_changes.py b/backend/tests/test_workspace_changes.py index 5c9f366248..40834f6715 100644 --- a/backend/tests/test_workspace_changes.py +++ b/backend/tests/test_workspace_changes.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +import deerflow.workspace_changes.scanner as scanner from deerflow.config.paths import Paths from deerflow.runtime.events.store.memory import MemoryRunEventStore from deerflow.runtime.runs.manager import RunManager @@ -76,6 +77,48 @@ def test_compare_snapshots_reports_text_file_changes(tmp_path): assert changes["/mnt/user-data/workspace/old.txt"].status == "deleted" +@pytest.mark.parametrize("include_text", [False, True]) +@pytest.mark.parametrize("size", [16, SAMPLE_BYTES + 1]) +def test_scan_keeps_other_files_when_a_file_disappears_before_hashing(tmp_path, monkeypatch, include_text, size): + roots = _roots(tmp_path) + workspace, outputs = [root.host_path for root in roots] + (workspace / "a-kept.txt").write_text("kept\n", encoding="utf-8") + vanished = workspace / "scratch.txt" + vanished.write_text("x" * size, encoding="utf-8") + (outputs / "report.md").write_text("report\n", encoding="utf-8") + real_hash = scanner._sha256_file + removed = [] + + def remove_before_hash(path): + if path == vanished: + path.unlink() + removed.append(path) + return real_hash(path) + + monkeypatch.setattr(scanner, "_sha256_file", remove_before_hash) + + snapshot = scan_workspace_roots(roots, include_text=include_text) + + assert removed == [vanished] + assert set(snapshot.files) == {"/mnt/user-data/workspace/a-kept.txt", "/mnt/user-data/outputs/report.md"} + assert snapshot.files["/mnt/user-data/outputs/report.md"].sha256 is not None + assert snapshot.files["/mnt/user-data/outputs/report.md"].text == ("report\n" if include_text else None) + + +@pytest.mark.parametrize("error", [PermissionError("denied"), OSError("disk read failed")]) +def test_scan_does_not_hide_operational_hash_errors(tmp_path, monkeypatch, error): + roots = _roots(tmp_path) + (roots[0].host_path / "report.md").write_text("report\n", encoding="utf-8") + + def fail_hash(path): + raise error + + monkeypatch.setattr(scanner, "_sha256_file", fail_hash) + + with pytest.raises(type(error), match=str(error)): + scan_workspace_roots(roots) + + def test_get_changed_output_paths_returns_only_created_or_modified_regular_outputs(tmp_path): roots = _roots(tmp_path) workspace = roots[0].host_path