feat: preview run artifact changes

This commit is contained in:
4pmtong 2026-08-06 23:19:09 +08:00
parent 101befd9a8
commit b04f7887ea
7 changed files with 424 additions and 60 deletions

View file

@ -22,10 +22,19 @@ from pathlib import Path
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile
from fastapi import (
APIRouter,
Depends,
File,
Header,
HTTPException,
Query,
UploadFile,
)
from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from app.auth import require_local_control_principal
from app.component.environment import env
from app.utils.file_utils import list_files, resolve_under_base
from app.utils.workspace_paths import runtime_owner_key, task_dir_name
@ -206,6 +215,87 @@ def _resolve_file_root(
return _resolve_project_root(email, project_id, user_id)
def _task_change_roots(snapshot) -> list[tuple[Path, bool]]:
"""Return task roots as ``(path, include_all)`` tuples.
The output root is Run-owned, so every file there is an artifact even when
a copy operation preserves an old mtime. A distinct working directory may
contain user files; only files modified after Run admission are surfaced.
"""
output_root = Path(snapshot.task_output_root).expanduser().resolve()
working_root = Path(snapshot.working_directory).expanduser().resolve()
roots: list[tuple[Path, bool]] = []
if output_root.is_dir():
roots.append((output_root, True))
if working_root != output_root and working_root.is_dir():
roots.append((working_root, False))
return roots
def _list_task_changed_files(snapshot, max_entries: int = 500) -> list[dict]:
"""List files generated or modified by one Run without uploading them."""
result: list[dict] = []
seen_paths: set[str] = set()
remaining = max_entries
# Account for filesystems whose mtimes have one-second resolution.
modified_after = snapshot.task_start_time - 1.0
for root, include_all in _task_change_roots(snapshot):
if remaining <= 0:
break
paths = list_files(
str(root),
base=str(root),
max_entries=remaining,
modified_after=None if include_all else modified_after,
)
for abs_path in paths:
try:
path = Path(abs_path).resolve()
if not path.is_file():
continue
identity = str(path)
if identity in seen_paths:
continue
relative_path = path.relative_to(root).as_posix()
except (OSError, ValueError):
continue
seen_paths.add(identity)
remaining -= 1
result.append(
{
"filename": path.name,
"path": identity,
"relativePath": relative_path,
"changeType": "generated" if include_all else "changed",
}
)
if remaining <= 0:
break
return sorted(result, key=lambda item: item["relativePath"])
@router.get(
"/files/changes",
dependencies=[Depends(require_local_control_principal)],
)
async def list_task_changed_files(
task_id: str = Query(..., description="Run/task ID"),
project_id: str = Query(..., description="Project ID"),
email: str = Query(..., description="User email"),
user_id: str | None = Query(None, description="Optional canonical user ID"),
) -> list[dict]:
"""Return the Desktop-local preview index for one Run's changed files."""
snapshot = get_workspace_resolver().store.get_snapshot(
email, task_id, user_id
)
if snapshot is None or snapshot.project_id != project_id:
raise HTTPException(status_code=404, detail="Task workspace not found")
return await run_in_threadpool(_list_task_changed_files, snapshot)
@router.get("/files")
async def list_project_files(
project_id: str = Query(..., description="Project ID"),

View file

@ -197,6 +197,7 @@ def list_files(
skip_dirs: set[str] | None = None,
skip_extensions: tuple[str, ...] = DEFAULT_SKIP_EXTENSIONS,
skip_prefix: str = ".",
modified_after: float | None = None,
stats: dict[str, float | int] | None = None,
) -> list[str]:
"""List files under dir_path with optional base confinement and filters.
@ -209,6 +210,9 @@ def list_files(
skip_dirs (set[str] | None): Directory names to skip (default: DEFAULT_SKIP_DIRS).
skip_extensions (tuple[str, ...]): File extensions to skip (default: DEFAULT_SKIP_EXTENSIONS).
skip_prefix (str): Skip dirs/files whose name starts with this prefix.
modified_after (float | None): If set, only include files whose mtime
is at or after this Unix timestamp. Filtering happens before the
result limit so recent artifacts are not hidden by older files.
Returns:
List of real absolute file paths under dir_path (subject to filters and max_entries).
@ -256,6 +260,12 @@ def list_files(
continue
try:
file_path = os.path.join(root, name)
if (
modified_after is not None
and os.stat(file_path, follow_symlinks=False).st_mtime
< modified_after
):
continue
if os.path.islink(file_path):
symlink_count += 1
realpath_started = time.perf_counter()

View file

@ -12,6 +12,15 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import os
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth.local_control import LOCAL_CONTROL_CAPABILITY_HEADER
from app.controller import file_controller
@ -87,3 +96,85 @@ def test_resolve_project_root_without_user_id_stays_email_scoped(
)
assert resolved == expected
def test_task_changes_include_all_outputs_but_only_recent_workspace_edits(
tmp_path,
):
output_root = tmp_path / "outputs"
working_root = tmp_path / "workspace"
output_root.mkdir()
working_root.mkdir()
started_at = time.time()
copied_output = output_root / "copied-report.csv"
copied_output.write_text("output", encoding="utf-8")
old_workspace_file = working_root / "existing.md"
old_workspace_file.write_text("old", encoding="utf-8")
old_time = started_at - 60
os.utime(copied_output, (old_time, old_time))
os.utime(old_workspace_file, (old_time, old_time))
# Old files must be filtered before the 500-item result bound. Otherwise
# a large selected folder can hide a recent artifact later in the walk.
for index in range(510):
old_file = working_root / f"old-{index:03d}.txt"
old_file.write_text("old", encoding="utf-8")
os.utime(old_file, (old_time, old_time))
edited_workspace_file = working_root / "reports" / "final.md"
edited_workspace_file.parent.mkdir()
edited_workspace_file.write_text("new", encoding="utf-8")
files = file_controller._list_task_changed_files(
SimpleNamespace(
task_output_root=str(output_root),
working_directory=str(working_root),
task_start_time=started_at,
)
)
assert {item["path"] for item in files} == {
str(copied_output.resolve()),
str(edited_workspace_file.resolve()),
}
assert {item["relativePath"] for item in files} == {
"copied-report.csv",
"reports/final.md",
}
assert {item["path"]: item["changeType"] for item in files} == {
str(copied_output.resolve()): "generated",
str(edited_workspace_file.resolve()): "changed",
}
def test_task_changes_endpoint_requires_local_capability(monkeypatch, tmp_path):
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
snapshot = SimpleNamespace(
project_id="project-1",
task_output_root=str(tmp_path),
working_directory=str(tmp_path),
task_start_time=time.time(),
)
resolver = MagicMock()
resolver.store.get_snapshot.return_value = snapshot
monkeypatch.setattr(
file_controller, "get_workspace_resolver", lambda: resolver
)
app = FastAPI()
app.include_router(file_controller.router)
client = TestClient(app, client=("127.0.0.1", 50000))
params = {
"task_id": "task-1",
"project_id": "project-1",
"email": "user@example.com",
}
assert client.get("/files/changes", params=params).status_code == 401
response = client.get(
"/files/changes",
params=params,
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
)
assert response.status_code == 200
assert response.json() == []

View file

@ -79,6 +79,63 @@ const AgentResultCard: React.FC<{
);
};
/** Run-scoped artifact delta. Every item opens in the existing preview panel. */
const ArtifactChangeList: React.FC<{
files?: FileInfo[];
onOpen: (file: FileInfo) => void;
}> = ({ files, onOpen }) => {
if (!files?.length) return null;
return (
<section className="my-3 rounded-xl border border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default p-3">
<div className="mb-2 flex items-center gap-2 text-label-sm font-semibold text-ds-text-neutral-default-default">
<FileText size={15} aria-hidden />
<span>Files changed</span>
<span className="bg-ds-bg-neutral-secondary-default rounded-full px-2 py-0.5 text-label-xs font-medium text-ds-text-neutral-muted-default">
{files.length}
</span>
</div>
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto sm:grid-cols-2">
{files.map((file, fileIndex) => {
const detail = file.relativePath || file.path || file.name;
const changeLabel =
file.artifactChange === 'generated'
? 'Generated'
: file.artifactChange === 'changed'
? 'Changed'
: file.type || 'File';
return (
<button
type="button"
key={`artifact-${detail}-${fileIndex}`}
title={detail}
onClick={() => onOpen(file)}
className="bg-ds-bg-neutral-primary-default flex min-w-0 items-center gap-3 rounded-lg border border-ds-border-neutral-default-default px-3 py-2 text-left transition-colors hover:bg-ds-bg-neutral-default-hover"
>
<FileText
size={18}
aria-hidden
className="shrink-0 text-ds-icon-neutral-default-default"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-semibold text-ds-text-neutral-default-default">
{file.name}
</span>
<span className="block truncate text-label-xs text-ds-text-neutral-muted-default">
{file.relativePath || changeLabel}
</span>
</span>
<span className="shrink-0 text-label-xs font-medium text-ds-text-neutral-muted-default">
{changeLabel}
</span>
</button>
);
})}
</div>
</section>
);
};
/** Typewriter only for the agent message currently being produced (latest agent row while task is running). */
function shouldUseLiveAgentTypewriter(
task: {
@ -429,35 +486,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
onTyping={() => {}}
deferredFooter={
message.fileList?.length ? (
<div className="my-2 flex flex-wrap gap-2">
{message.fileList.map(
(file: any, fileIndex: number) => (
<motion.div
key={`file-${message.id}-${file.name}-${fileIndex}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.05 }}
onClick={() => {
openFilePreview(file);
}}
className="flex w-[140px] cursor-pointer items-center gap-2 rounded-lg bg-ds-bg-neutral-default-default px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-hover"
>
<FileText
size={16}
className="flex-shrink-0 text-ds-icon-neutral-default-default"
/>
<div className="flex flex-col">
<div className="max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap text-body-sm font-bold text-ds-text-neutral-default-default">
{file.name.split('.')[0]}
</div>
<div className="text-label-xs font-medium text-ds-text-neutral-muted-default">
{file.type}
</div>
</div>
</motion.div>
)
)}
</div>
<ArtifactChangeList
files={message.fileList}
onOpen={openFilePreview}
/>
) : undefined
}
/>
@ -527,35 +559,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
transition={{ delay: 0.2 }}
className="flex flex-col gap-4"
>
{message.fileList && (
<div className="flex flex-wrap gap-2">
{message.fileList.map((file: any, fileIndex: number) => (
<motion.div
key={`file-${message.id}-${file.name}-${fileIndex}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
onClick={() => {
openFilePreview(file);
}}
className="flex w-[120px] cursor-pointer items-center gap-2 rounded-2xl bg-ds-bg-neutral-default-default px-2 py-1 transition-colors hover:bg-ds-bg-neutral-default-hover"
>
<FileText
size={16}
className="flex-shrink-0 text-ds-icon-neutral-default-default"
/>
<div className="flex flex-col">
<div className="text-body max-w-48 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-bold text-ds-text-neutral-default-default">
{file.name.split('.')[0]}
</div>
<div className="text-xs font-medium leading-29 text-ds-text-neutral-default-default">
{file.type}
</div>
</div>
</motion.div>
))}
</div>
)}
<ArtifactChangeList
files={message.fileList}
onOpen={openFilePreview}
/>
</motion.div>
);
}

View file

@ -14,6 +14,7 @@
import {
fetchDelete,
fetchGet,
fetchPost,
fetchPut,
getBaseURL,
@ -1033,6 +1034,81 @@ export function extractFinalOutputFileList(
return fileInfos;
}
type TaskArtifactChange = {
filename?: unknown;
path?: unknown;
relativePath?: unknown;
changeType?: unknown;
};
/** Convert Brain's capability-protected local artifact index into preview cards. */
export function normalizeTaskArtifactFileList(value: unknown): FileInfo[] {
if (!Array.isArray(value)) return [];
const files: FileInfo[] = [];
const seen = new Set<string>();
for (const candidate of value as TaskArtifactChange[]) {
const path =
typeof candidate.path === 'string'
? normalizeOutputPath(candidate.path)
: '';
const name =
typeof candidate.filename === 'string'
? candidate.filename.trim()
: getOutputFileNameFromPath(path);
const relativePath =
typeof candidate.relativePath === 'string'
? normalizeOutputPath(candidate.relativePath)
: undefined;
const type = getFileTypeFromName(name);
const identity = (relativePath || path).toLowerCase();
if (!path || !name || !identity || seen.has(identity)) continue;
seen.add(identity);
files.push({
name,
type,
path,
relativePath,
icon: FileText,
isRemote: false,
artifactChange:
candidate.changeType === 'generated' ? 'generated' : 'changed',
});
}
return files;
}
async function loadTaskArtifactFileList({
taskId,
projectId,
email,
userId,
}: {
taskId: string;
projectId?: string;
email?: string;
userId?: string | number | null;
}): Promise<FileInfo[]> {
// The index contains absolute local paths and is intentionally Desktop-only.
if (!getHostIpcRenderer()?.invoke || !projectId || !email) return [];
try {
const changes = await fetchGet('/files/changes', {
task_id: taskId,
project_id: projectId,
email,
...(userId ? { user_id: userId } : {}),
});
return normalizeTaskArtifactFileList(changes);
} catch (error) {
// Older Brain versions and cloud-only history do not expose the local
// artifact index. Existing WRITE_FILE/final-answer projections remain.
console.info(`[Artifacts] No local changes for task ${taskId}`, error);
return [];
}
}
function getFileInfoIdentities(file: FileInfo): string[] {
return [
file.relativePath,
@ -1082,6 +1158,15 @@ export function mergeFileInfoLists(
...file,
};
mergedIdentities[existingIndex] = identities;
return;
}
if (file.artifactChange && !existingFile.artifactChange) {
merged[existingIndex] = {
...existingFile,
artifactChange: file.artifactChange,
relativePath: existingFile.relativePath || file.relativePath,
};
}
});
@ -4152,6 +4237,12 @@ const chatStore = (initial?: Partial<ChatStore>) =>
const outputProjectId =
project_id || projectStore.activeProjectId || undefined;
const taskArtifactFileList = await loadTaskArtifactFileList({
taskId: currentTaskId,
projectId: outputProjectId,
email: email || undefined,
userId: user_id,
});
const outputBaseURL = await getBaseURL().catch(() => '');
const finalOutputFileList = extractFinalOutputFileList(
endMessage,
@ -4160,7 +4251,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
outputBaseURL || undefined
);
const mergedFileList = mergeFileInfoLists(
fileList,
mergeFileInfoLists(fileList, taskArtifactFileList),
finalOutputFileList
);

View file

@ -34,6 +34,7 @@ declare global {
isFolder?: boolean;
isRemote?: boolean;
relativePath?: string;
artifactChange?: 'generated' | 'changed';
}
interface ProjectInfo {

View file

@ -31,6 +31,7 @@ vi.mock('@/api/http', async () => {
const getBaseURL = vi.fn(() => Promise.resolve('http://localhost:8000'));
return {
fetchGet: vi.fn(),
fetchPost: vi.fn(),
fetchPut: vi.fn(),
getBaseURL,
@ -135,6 +136,7 @@ import {
extractFinalOutputFileList,
getCloudModelPlatform,
mergeFileInfoLists,
normalizeTaskArtifactFileList,
resolveConfirmedUserMessageContent,
resolveEndMessageText,
useChatStore,
@ -255,6 +257,78 @@ describe('ChatStore - Core Functionality', () => {
});
describe('Final output file extraction', () => {
it('normalizes the local artifact change index into previewable files', () => {
const files = normalizeTaskArtifactFileList([
{
filename: 'final_report.md',
path: '/Users/test/project/reports/final_report.md',
relativePath: 'reports/final_report.md',
changeType: 'changed',
},
{
filename: 'chart.png',
path: '/Users/test/outputs/chart.png',
relativePath: 'chart.png',
changeType: 'generated',
},
]);
expect(files).toMatchObject([
{
name: 'final_report.md',
type: 'md',
relativePath: 'reports/final_report.md',
artifactChange: 'changed',
isRemote: false,
},
{
name: 'chart.png',
type: 'png',
artifactChange: 'generated',
isRemote: false,
},
]);
});
it('drops malformed and duplicate artifact index rows', () => {
expect(
normalizeTaskArtifactFileList([
{ filename: 'a.csv', path: '/tmp/a.csv', relativePath: 'a.csv' },
{ filename: 'a.csv', path: '/tmp/other.csv', relativePath: 'a.csv' },
{ filename: 'missing.txt' },
])
).toHaveLength(1);
});
it('keeps an existing preview path while adding artifact change metadata', () => {
const [file] = mergeFileInfoLists(
[
{
name: 'report.md',
path: 'http://localhost/files/stream?path=report.md',
type: 'md',
isRemote: true,
},
],
[
{
name: 'report.md',
path: '/Users/test/project/report.md',
type: 'md',
relativePath: 'report.md',
artifactChange: 'changed',
},
]
);
expect(file).toMatchObject({
path: 'http://localhost/files/stream?path=report.md',
relativePath: 'report.md',
artifactChange: 'changed',
isRemote: true,
});
});
it('extracts sandbox paths without treating the scheme suffix as a drive', () => {
const files = extractFinalOutputFileList(
'Created [CSV](sandbox:/Users/test/eigent/space_123/report.csv).'