Skip to content

Commit 8513ab3

Browse files
committed
feat: add workspace file explorer and runbook file APIs
1 parent 5e409d2 commit 8513ab3

File tree

7 files changed

+421
-12
lines changed

7 files changed

+421
-12
lines changed

docs/DEEPCODE_TODO.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
### 1) 数据模型与存储(Run / Step / Artifact / Snapshot)
2626

2727
- [x] `RunbookStepModel`(runbook_steps)表:step 生命周期与配置
28-
- [ ] 新增 `ArtifactModel` 表(产物索引)
28+
- [x] 新增 `ArtifactModel` 表(产物索引)
2929
- [ ] 字段:`run_id``step_id`(可空)、`type`(log/metric/report/file/zip)、
3030
`path_or_uri``mime``size_bytes``sha256``metadata_json``created_at`
3131
- [ ] DoD:可记录 outputDir、报告、曲线图、导出的 evidence 包
@@ -52,12 +52,13 @@
5252

5353
- [ ] 后端:文件系统 API(严格限制在允许目录)
5454
- [ ] `GET /api/runbook/projects`(可选:列出最近项目/输出目录)
55-
- [ ] `GET /api/runbook/projects/{id}/files`(树
56-
- [ ] `GET /api/runbook/projects/{id}/file?path=...`(读)
57-
- [ ] `POST /api/runbook/projects/{id}/file`(写:只允许 workspace 内)
55+
- [x] `GET /api/runbook/files?project_dir=...`(索引
56+
- [x] `GET /api/runbook/file?project_dir=...&path=...`(读)
57+
- [x] `POST /api/runbook/file`(写:只允许 workspace 内)
5858
- [ ] 安全:路径规范化、防穿越、白名单根目录
59-
- [ ] 前端:文件树组件(左侧或 Workspace 顶部)
60-
- [ ] 支持搜索/最近文件/仅显示改动
59+
- [x] 前端:文件树组件(Workspace 左侧)
60+
- [x] 支持搜索 + 刷新索引 + 打开文件到 Editor
61+
- [x] Save Active(写回后端文件)
6162
- [ ] Diff Staging(必须)
6263
- [ ] 生成 diff(基于“原始快照 vs 当前”)
6364
- [ ] Apply/Reject(按文件/按 hunk)
@@ -126,3 +127,4 @@
126127
## Progress Log(每次完成请追加)
127128

128129
- YYYY-MM-DD: ...
130+
- 2025-12-22: Add ArtifactModel + Runbook file APIs + Workspace real file explorer (open/save) + panel collapse controls.

src/paperbot/api/routes/runbook.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from pathlib import Path
2222
from typing import Any, Dict, List, Literal, Optional
2323

24-
from fastapi import APIRouter, HTTPException
24+
from fastapi import APIRouter, HTTPException, Query
2525
from pydantic import BaseModel, Field
2626

2727
from paperbot.application.collaboration.message_schema import new_run_id
@@ -153,6 +153,125 @@ async def start_smoke(body: SmokeRequest) -> SmokeStartResponse:
153153
return SmokeStartResponse(run_id=run_id, status="running")
154154

155155

156+
@router.get("/runbook/files")
157+
async def list_project_files(
158+
project_dir: str = Query(..., description="Project directory on the API host"),
159+
recursive: bool = Query(True, description="List files recursively"),
160+
max_files: int = Query(2000, ge=1, le=20000),
161+
):
162+
"""
163+
List files under a project directory (best-effort).
164+
165+
Notes:
166+
- This endpoint is intentionally restrictive and will only serve allowed roots.
167+
- Large directories (e.g. node_modules) are skipped by default.
168+
"""
169+
root = Path(project_dir)
170+
if not root.exists() or not root.is_dir():
171+
raise HTTPException(status_code=400, detail="project_dir must be an existing directory")
172+
if not _allowed_workdir(root):
173+
raise HTTPException(status_code=403, detail="project_dir is not allowed")
174+
175+
ignore_dirs = {".git", ".next", "node_modules", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache"}
176+
177+
files: List[str] = []
178+
directories: List[str] = []
179+
180+
if not recursive:
181+
for p in root.iterdir():
182+
if p.is_dir():
183+
directories.append(p.name)
184+
elif p.is_file():
185+
files.append(p.name)
186+
return {"project_dir": str(root), "files": sorted(files), "directories": sorted(directories)}
187+
188+
for dirpath, dirnames, filenames in os.walk(root):
189+
# prune
190+
dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
191+
rel_dir = os.path.relpath(dirpath, root)
192+
if rel_dir != ".":
193+
directories.append(rel_dir)
194+
195+
for name in filenames:
196+
if len(files) >= max_files:
197+
break
198+
rel = os.path.relpath(os.path.join(dirpath, name), root)
199+
files.append(rel)
200+
if len(files) >= max_files:
201+
break
202+
203+
return {
204+
"project_dir": str(root),
205+
"files": sorted(files),
206+
"directories": sorted(set(directories)),
207+
"truncated": len(files) >= max_files,
208+
"max_files": max_files,
209+
}
210+
211+
212+
class ReadFileResponse(BaseModel):
213+
path: str
214+
content: str
215+
216+
217+
@router.get("/runbook/file", response_model=ReadFileResponse)
218+
async def read_project_file(
219+
project_dir: str = Query(..., description="Project directory on the API host"),
220+
path: str = Query(..., description="Relative file path within project_dir"),
221+
max_bytes: int = Query(2_000_000, ge=1, le=20_000_000),
222+
):
223+
"""Read a single file under project_dir (UTF-8 best effort)."""
224+
root = Path(project_dir)
225+
if not root.exists() or not root.is_dir():
226+
raise HTTPException(status_code=400, detail="project_dir must be an existing directory")
227+
if not _allowed_workdir(root):
228+
raise HTTPException(status_code=403, detail="project_dir is not allowed")
229+
230+
target = (root / path).resolve()
231+
root_resolved = root.resolve()
232+
if not (target == root_resolved or str(target).startswith(str(root_resolved) + os.sep)):
233+
raise HTTPException(status_code=400, detail="invalid path")
234+
if not target.exists() or not target.is_file():
235+
raise HTTPException(status_code=404, detail="file not found")
236+
237+
size = target.stat().st_size
238+
if size > max_bytes:
239+
raise HTTPException(status_code=413, detail=f"file too large ({size} bytes)")
240+
241+
try:
242+
content = target.read_text(encoding="utf-8")
243+
except Exception:
244+
content = target.read_text(errors="ignore")
245+
246+
return ReadFileResponse(path=path, content=content)
247+
248+
249+
class WriteFileRequest(BaseModel):
250+
project_dir: str
251+
path: str
252+
content: str
253+
254+
255+
@router.post("/runbook/file")
256+
async def write_project_file(body: WriteFileRequest):
257+
"""Write a file under project_dir (creates parents)."""
258+
root = Path(body.project_dir)
259+
if not root.exists() or not root.is_dir():
260+
raise HTTPException(status_code=400, detail="project_dir must be an existing directory")
261+
if not _allowed_workdir(root):
262+
raise HTTPException(status_code=403, detail="project_dir is not allowed")
263+
264+
target = (root / body.path).resolve()
265+
root_resolved = root.resolve()
266+
if not (target == root_resolved or str(target).startswith(str(root_resolved) + os.sep)):
267+
raise HTTPException(status_code=400, detail="invalid path")
268+
269+
target.parent.mkdir(parents=True, exist_ok=True)
270+
target.write_text(body.content, encoding="utf-8")
271+
272+
return {"ok": True, "path": body.path}
273+
274+
156275
@router.get("/runbook/runs/{run_id}", response_model=RunStatusResponse)
157276
async def get_run_status(run_id: str) -> RunStatusResponse:
158277
with _provider.session() as session:

src/paperbot/infrastructure/stores/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class AgentRunModel(Base):
3434
logs = relationship("ExecutionLogModel", back_populates="run", cascade="all, delete-orphan")
3535
metrics = relationship("ResourceMetricModel", back_populates="run", cascade="all, delete-orphan")
3636
runbook_steps = relationship("RunbookStepModel", back_populates="run", cascade="all, delete-orphan")
37+
artifacts = relationship("ArtifactModel", back_populates="run", cascade="all, delete-orphan")
3738

3839
def set_metadata(self, data: Dict[str, Any]) -> None:
3940
self.metadata_json = json.dumps(data or {}, ensure_ascii=False)
@@ -157,3 +158,27 @@ class RunbookStepModel(Base):
157158
run = relationship("AgentRunModel", back_populates="runbook_steps")
158159

159160

161+
class ArtifactModel(Base):
162+
"""
163+
Artifact index for evidence tracking.
164+
165+
This table stores references (paths/URIs) to outputs produced during a run/step:
166+
- logs (raw/filtered), metrics snapshots, reports, figures, exported evidence packs, etc.
167+
"""
168+
__tablename__ = "artifacts"
169+
170+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
171+
run_id: Mapped[str] = mapped_column(String(64), ForeignKey("agent_runs.run_id"), index=True)
172+
step_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("runbook_steps.id"), nullable=True, index=True)
173+
174+
type: Mapped[str] = mapped_column(String(32), index=True) # log/metric/report/file/zip/...
175+
path_or_uri: Mapped[str] = mapped_column(Text, default="")
176+
mime: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
177+
size_bytes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
178+
sha256: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
179+
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
180+
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
181+
182+
run = relationship("AgentRunModel", back_populates="artifacts")
183+
step = relationship("RunbookStepModel")
184+
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export const runtime = "nodejs"
2+
3+
function apiBaseUrl() {
4+
return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000"
5+
}
6+
7+
export async function GET(req: Request) {
8+
const url = new URL(req.url)
9+
const upstream = await fetch(`${apiBaseUrl()}/api/runbook/file?${url.searchParams.toString()}`, {
10+
method: "GET",
11+
headers: { Accept: "application/json" },
12+
})
13+
const text = await upstream.text()
14+
return new Response(text, {
15+
status: upstream.status,
16+
headers: {
17+
"Content-Type": upstream.headers.get("content-type") || "application/json",
18+
"Cache-Control": "no-cache",
19+
},
20+
})
21+
}
22+
23+
export async function POST(req: Request) {
24+
const body = await req.text()
25+
const upstream = await fetch(`${apiBaseUrl()}/api/runbook/file`, {
26+
method: "POST",
27+
headers: {
28+
"Content-Type": req.headers.get("content-type") || "application/json",
29+
Accept: "application/json",
30+
},
31+
body,
32+
})
33+
const text = await upstream.text()
34+
return new Response(text, {
35+
status: upstream.status,
36+
headers: {
37+
"Content-Type": upstream.headers.get("content-type") || "application/json",
38+
"Cache-Control": "no-cache",
39+
},
40+
})
41+
}
42+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const runtime = "nodejs"
2+
3+
function apiBaseUrl() {
4+
return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000"
5+
}
6+
7+
export async function GET(req: Request) {
8+
const url = new URL(req.url)
9+
const upstream = await fetch(`${apiBaseUrl()}/api/runbook/files?${url.searchParams.toString()}`, {
10+
method: "GET",
11+
headers: { Accept: "application/json" },
12+
})
13+
const text = await upstream.text()
14+
return new Response(text, {
15+
status: upstream.status,
16+
headers: {
17+
"Content-Type": upstream.headers.get("content-type") || "application/json",
18+
"Cache-Control": "no-cache",
19+
},
20+
})
21+
}
22+

web/src/app/studio/page.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { TasksPanel } from "@/components/studio/TasksPanel"
77
import { ExecutionLog } from "@/components/studio/ExecutionLog"
88
import { PromptInput } from "@/components/studio/PromptInput"
99
import { MCPSettings } from "@/components/studio/MCPSettings"
10-
import { DeepCodeEditor } from "@/components/studio/DeepCodeEditor"
10+
import { WorkspacePanel } from "@/components/studio/WorkspacePanel"
1111
import { RunbookPanel } from "@/components/studio/RunbookPanel"
1212
import { BlueprintPanel } from "@/components/studio/BlueprintPanel"
1313
import { MCPProvider } from "@/lib/mcp"
@@ -340,9 +340,7 @@ function StudioContent() {
340340
minSize="30"
341341
onResize={({ inPixels }) => setCollapsedKey("workspace", inPixels < 2)}
342342
>
343-
<div className="h-full min-w-0 min-h-0 bg-background">
344-
<DeepCodeEditor />
345-
</div>
343+
<WorkspacePanel />
346344
</ResizablePanel>
347345

348346
<ResizableHandle withHandle />
@@ -472,7 +470,7 @@ function StudioContent() {
472470
</TabsTrigger>
473471
</TabsList>
474472
<TabsContent value="workspace" className="flex-1 min-h-0 m-0 overflow-hidden">
475-
<DeepCodeEditor />
473+
<WorkspacePanel />
476474
</TabsContent>
477475
<TabsContent value="runbook" className="flex-1 min-h-0 m-0 overflow-hidden">
478476
<RunbookPanel />

0 commit comments

Comments
 (0)