diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7e66f..e891dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.59.6 — 2026-07-27 + +- 现在可以直接在对话里让助手修改当前工作目录名。改名会在本轮回复结束后安全执行,并同步更新所有共享该目录的任务关联,不会再因直接移动目录导致后续对话找不到文件。 + ## 0.59.5 — 2026-07-27 - 视频生成现在支持让已有图片直接“动起来”:既可以指定一张图片作为视频开场画面,也可以同时提供多张人物、产品、场景或风格参考图(最多 9 张),生成时更容易保持主体和视觉风格一致。 diff --git a/DESIGN.md b/DESIGN.md index 5b9e4aa..40b8480 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -197,7 +197,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) - working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。 - **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。 - **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。 -- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409)。 +- **DB-aware service 是顶层 working_dir mutation 的唯一原语,DB-FS 一致性服务端内化**:文件面板 rename 与对话 `rename_working_dir` 共用同一服务(事务锁关联 task、running→409、DB UPDATE 先于 FS);对话工具只登记本轮内存动作,等 agent 正常回复完、当前 task 退出 running 后执行,避免 executor/system prompt 仍握旧 cwd。服务在收尾前退出时动作丢失但目录不变,不引持久队列或 migration。delete 仍仅走 files API,被 task 引用时 409。 - **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。 ### 7.5 沙盒:Per-user 容器 + Per-tool exec diff --git a/PROGRESS.md b/PROGRESS.md index 9d5ee30..2030b19 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-27(Seedance 图生视频:单图首帧 + 最多 9 张多图参考,bump 0.59.5) +最后更新:2026-07-27(对话内安全重命名 working_dir,bump 0.59.6) --- @@ -23,6 +23,7 @@ ### 2026-07 +- **07-27 / 0.59.6 / 对话内安全重命名 working_dir**:新增 `rename_working_dir` 受控工具,agent run 内只登记目标 leaf 名,正常回复结束并把当前 task 退出 running 后再落地,避免 executor/system prompt/宿主工具仍握旧 cwd;取消或失败不执行。文件面板与对话路径共用 `core/working_dirs.py` DB-aware 原语:锁定并同步更新共享目录的全部 task、活跃邻居与 no-subtask 冲突安全拒绝、DB UPDATE 后再做 FS rename。system prompt 明确当前 task_dir 禁走 shell/run_python 直接改名,定时 run 不挂该工具;无新增 schema/migration。全量 357 测试通过(17 项按环境跳过),DB 路由测试未设 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。 - **07-27 / 0.59.5 / Seedance 图生视频(单图首帧 + 最多 9 张多图参考)**:`seedance` 在原文生视频契约上向后兼容增加 `image`(唯一 `first_frame`)与 `reference_images`(0-9 张 `reference_image`),两者合计最多 9 张;复用 image_ref 的 task/user_root 三形态路径解析、越界防护与 base64 内联,新增单图 10MB / 合计 30MB 请求体闸。请求按有无图片切图生/文生单价,banner/meta/usage snapshot 记录 mode、首帧及参考图;videogen skill、工具系统提示、RUN 与能力清单同步。5 个离线单测覆盖单图、多图、文生兼容、越界与超量拒绝。 - **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview` 走 `.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。 - **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。 diff --git a/core/__init__.py b/core/__init__.py index 6ab3522..789a948 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.59.5" +__version__ = "0.59.6" diff --git a/core/agent_builder.py b/core/agent_builder.py index 418d717..8da5e0e 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -40,6 +40,7 @@ from core.sinks import ConsoleEventSink from core.skills import SkillRegistry from core.storage import check_no_subtask from core.task import TaskState +from core.task_actions import DeferredTaskActions from core.ark_client import ArkConfig from core.tool_registry import ToolContext, build_tools @@ -308,6 +309,7 @@ def _build_system_prompt( task_name: str, task_skill: str = "", media_block: str = "", + allow_working_dir_rename: bool = True, ) -> str: """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。 @@ -379,6 +381,12 @@ def _build_system_prompt( f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n" f"- **task_dir(所有产物写到这里)**: `{wd_path}`\n" ) + rename_line = ( + "用户要求修改当前 task_dir 名称时,必须调用 `rename_working_dir`;" + "不要用 shell / run_python 直接移动或改名当前 task_dir。该工具会在本轮回复" + "完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n" + if allow_working_dir_rename else "" + ) prompt += ( f"\n\n## 工作目录与 task 上下文\n" f"{loc_lines}" @@ -391,6 +399,7 @@ def _build_system_prompt( f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;" f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" + f"{rename_line}" f"\n## 生成 Word 报告 / 文档(验收 / 技术 / 评审报告等自由长文)\n" f"**优先**把正文写成 Markdown(`/sections/*.md`,纯文本、零转义 / 零语法风险)," f"再调平台渲染器出 docx —— **别在 run_python 里手撸 python-docx 内联大段中文正文**" @@ -540,6 +549,7 @@ def build_agent( cfg, skills, workspace_dir, tool_base, working_dir_path, uid, task_id, task_state.name, task_state.skill, media_block=_media_tools_block(ark_cfg is not None, image_tool_name), + allow_working_dir_rename=not scheduled_run, ) meta = { @@ -563,11 +573,13 @@ def build_agent( # user_root 传给 tool 让 fs 输出渲染成相对路径(不泄漏 user_id / 部署根, # 同时让 web SPA artifact chip 抽取稳定锚定 / 前缀) ur_path = user_root(workspace_dir, uid) + deferred_actions = DeferredTaskActions() tools = build_tools(ToolContext( tool_base=tool_base, ur_path=ur_path, working_dir_path=working_dir_path, task_id=task_id, uid=uid, cfg=cfg, caps=caps, skills=skills, cancel_check=cancel_check, scheduled_run=scheduled_run, + deferred_actions=deferred_actions, ark_cfg=ark_cfg, img_provider=img_provider, img_key=img_key, img_cfg=img_cfg, img_provider_cfg=img_provider_cfg, video_variant=video_variant, @@ -618,6 +630,7 @@ def build_agent( llm, executor, session, caps, user_id=uid, working_dir=working_dir_path, sink=sink, skill_model_switch=_skill_model_switch, + deferred_actions=deferred_actions, ) if cancel_check is not None: agent.cancel_check = cancel_check diff --git a/core/loop.py b/core/loop.py index 54b3033..870f29f 100644 --- a/core/loop.py +++ b/core/loop.py @@ -40,6 +40,7 @@ from .llm_transport import ( ) from .salvage import salvage_tool_arguments from .session import Session +from .task_actions import DeferredTaskActions from .storage import ( record_chat_usage, record_salvaged_tool_call, @@ -196,6 +197,7 @@ class AgentLoop: max_iterations: Optional[int] = None, cancel_check: Optional[Callable[[], bool]] = None, skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None, + deferred_actions: Optional[DeferredTaskActions] = None, ) -> None: self.llm = llm self.executor = executor @@ -216,6 +218,9 @@ class AgentLoop: # 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。 # None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。 self.skill_model_switch = skill_model_switch + # rename_working_dir 等不能在 executor 仍握旧 cwd 时落地的动作,由 web worker + # 在 agent.run 正常结束后消费。CLI/旧测试不传时保留空容器。 + self.deferred_actions = deferred_actions or DeferredTaskActions() # 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。 self._repeat_guard = _RepeatGuard() # 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。 diff --git a/core/task_actions.py b/core/task_actions.py new file mode 100644 index 0000000..4e8b845 --- /dev/null +++ b/core/task_actions.py @@ -0,0 +1,19 @@ +"""一次 agent run 结束后执行的轻量 task 动作。 + +这类动作不能在工具调用当下执行:例如重命名当前 working_dir 会让本轮 executor +缓存的 cwd、system prompt 路径和宿主工具 base_dir 同时失效。工具只在这里登记 +意图,由 web run worker 在 agent.run 正常结束、run_status 退出 running 后落地。 + +动作只活在当前进程内,不是持久任务队列。服务在 run 收尾前退出时请求自然丢失, +但不会留下 DB / FS 半更新状态;用户下次可重新发起。 +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class DeferredTaskActions: + """当前 run 已登记、等待 worker 收尾执行的动作。""" + + rename_working_dir_to: str = "" diff --git a/core/tool_registry.py b/core/tool_registry.py index f319a5c..a6da17e 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -34,6 +34,7 @@ from tools.materials_project import ( MaterialsProjectSearchSummaryTool, ) from tools.read_document import ReadDocumentTool +from tools.rename_working_dir import RenameWorkingDirTool from tools.run_python import RunPythonTool from tools.schedule import ( ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool, @@ -52,6 +53,7 @@ from tools.wechat_bot import WechatPushTool, wechat_push_available from core.asr_lfasr import is_configured as lfasr_configured from core.bocha_client import BochaConfig +from core.task_actions import DeferredTaskActions @dataclass @@ -67,6 +69,7 @@ class ToolContext: skills: Any # SkillRegistry cancel_check: Optional[Callable[[], bool]] scheduled_run: bool + deferred_actions: DeferredTaskActions # 媒体(上游已 load 一次,避免重复读 yaml): ark_cfg: Any # ArkConfig | None(豆包;None=ARK_API_KEY 缺) img_provider: str # "doubao" / "unifyllm" / "" @@ -115,6 +118,15 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: WebFetchTool(**base), ] + def _task_actions() -> list: + return [ + RenameWorkingDirTool( + ctx.deferred_actions, + working_dir=ctx.working_dir_path, + **base, + ) + ] + def _document_search() -> list: return [ DocumentListKbTool(**base), @@ -216,6 +228,8 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: # ── 注册表:(组名, gate, factory)。gate 判定统一零参 bool;新工具在此加行 ── registry: list[tuple[str, Callable[[], bool], Callable[[], list]]] = [ ("core", lambda: True, _core), + # 当前 working_dir 只能延迟到交互 run 收尾后改名;定时 run 不允许自行改目录。 + ("task_actions", lambda: not ctx.scheduled_run, _task_actions), # Secret-bearing 域工具一律 host-side、仅对应 env 存在才注册(§7.5 #7): # key 绝不进 run_python / 沙箱。 ("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search), diff --git a/core/working_dirs.py b/core/working_dirs.py new file mode 100644 index 0000000..6b31707 --- /dev/null +++ b/core/working_dirs.py @@ -0,0 +1,106 @@ +"""working_dir 的 DB-aware 文件系统变更。 + +网页 files API 与对话内延迟动作共用这里,保证顶层工作目录改名始终同时更新所有 +关联 task,且不会与活跃 run 或 no-subtask 约束打架。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from uuid import UUID + +from sqlalchemy import select, update + +from .paths import to_db_path +from .storage import NoSubtaskError, check_no_subtask, session_scope +from .storage.models import Task + + +class WorkingDirRenameError(RuntimeError): + """working_dir 无法安全改名。""" + + +class WorkingDirConflictError(WorkingDirRenameError): + """目录状态或 task 约束冲突,可由用户调整后重试。""" + + +class WorkingDirBusyError(WorkingDirConflictError): + """至少一个关联 task 仍有活跃 run。""" + + def __init__(self, task_ids: list[str]) -> None: + self.task_ids = task_ids + super().__init__( + f"folder has active run(s) on task(s) {task_ids}; cancel before renaming" + ) + + +@dataclass(frozen=True) +class WorkingDirRenameResult: + old_path: Path + new_path: Path + tasks_updated: int + + +def rename_working_dir( + *, + user_id: UUID, + old_path: Path, + new_path: Path, +) -> WorkingDirRenameResult: + """原子地平移一个顶层 working_dir 的 DB 引用并重命名目录。 + + 调用方负责用户边界与 leaf 名校验;本函数仍校验同级目录、源/目标状态和并发 + task,作为网页路由与延迟动作共用的最后一道一致性闸。 + + 顺序为 DB UPDATE → FS rename → transaction commit。FS 失败会回滚 DB;仍存在 + 极小的“FS 已成功但 PG commit 失败”窗口,与原 files API 的既有语义一致。 + """ + old = Path(old_path).resolve() + new = Path(new_path).resolve() + if old.parent != new.parent: + raise WorkingDirRenameError("working_dir rename must stay under the same parent") + if old == new: + raise WorkingDirRenameError("new working_dir equals the old path") + if not old.exists(): + raise WorkingDirRenameError(f"working_dir not found: {old}") + if not old.is_dir(): + raise WorkingDirRenameError(f"working_dir is not a directory: {old}") + if new.exists(): + raise WorkingDirConflictError(f"target already exists: {new}") + + old_db = to_db_path(old) + new_db = to_db_path(new) + with session_scope() as s: + rows = s.execute( + select(Task.task_id, Task.run_status) + .where(Task.user_id == user_id, Task.working_dir == old_db) + .with_for_update() + ).all() + tids = [r.task_id for r in rows] + active = [ + str(r.task_id)[:8] + for r in rows + if r.run_status in ("running", "cancelling") + ] + if active: + raise WorkingDirBusyError(active) + try: + check_no_subtask(new_db, user_id=user_id, exclude_task_ids=tids) + except NoSubtaskError as e: + raise WorkingDirConflictError(str(e)) from e + if tids: + s.execute( + update(Task) + .where(Task.task_id.in_(tids)) + .values(working_dir=new_db) + ) + try: + old.rename(new) + except OSError as e: + raise WorkingDirRenameError(f"FS rename failed: {e}") from e + + return WorkingDirRenameResult( + old_path=old, + new_path=new, + tasks_updated=len(tids), + ) diff --git a/tests/test_rename_working_dir.py b/tests/test_rename_working_dir.py new file mode 100644 index 0000000..46e9b41 --- /dev/null +++ b/tests/test_rename_working_dir.py @@ -0,0 +1,214 @@ +"""对话内 working_dir 延迟改名:工具登记 + worker 收尾时序。""" +from __future__ import annotations + +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from core.task_actions import DeferredTaskActions +from core.working_dirs import ( + WorkingDirBusyError, + WorkingDirRenameResult, + rename_working_dir, +) +from tools.rename_working_dir import RenameWorkingDirTool + + +class RenameWorkingDirToolTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.wd = self.root / "旧目录" + self.wd.mkdir() + self.actions = DeferredTaskActions() + self.tool = RenameWorkingDirTool( + self.actions, + working_dir=self.wd, + base_dir=self.wd, + user_root=self.root, + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_registers_and_replaces_deferred_name(self) -> None: + out = self.tool.execute(" 新目录 ") + self.assertIn("[OK]", out) + self.assertEqual(self.actions.rename_working_dir_to, "新目录") + + out = self.tool.execute("最终目录") + self.assertIn("更新为", out) + self.assertEqual(self.actions.rename_working_dir_to, "最终目录") + self.assertTrue(self.wd.is_dir(), "工具调用阶段不应立即改动文件系统") + + def test_rejects_invalid_same_or_existing_name(self) -> None: + for bad in ("", ".memory", "a/b", "a\\b", "旧目录"): + with self.subTest(name=bad): + self.actions.rename_working_dir_to = "" + self.assertIn("[Error]", self.tool.execute(bad)) + self.assertEqual(self.actions.rename_working_dir_to, "") + + (self.root / "已存在").mkdir() + self.assertIn("[Error]", self.tool.execute("已存在")) + self.assertEqual(self.actions.rename_working_dir_to, "") + + +class DeferredRenameWorkerTests(unittest.TestCase): + def test_normal_run_renames_after_status_commit_before_done(self) -> None: + from web import runs + + tid = uuid4() + uid = uuid4() + with tempfile.TemporaryDirectory() as tmp: + old = Path(tmp) / "旧目录" + old.mkdir() + new = old.parent / "新目录" + actions = DeferredTaskActions(rename_working_dir_to="新目录") + agent = SimpleNamespace( + run=MagicMock(return_value="ok"), + deferred_actions=actions, + sink=None, + ) + order: list[str] = [] + + @contextmanager + def fake_scope(): + yield SimpleNamespace(execute=MagicMock()) + order.append("status_committed") + + broker = MagicMock() + + def fake_rename(**kwargs): + order.append("renamed") + self.assertEqual(kwargs["old_path"], old) + self.assertEqual(kwargs["new_path"], new) + return WorkingDirRenameResult(old, new, 1) + + with ( + patch("core.agent_builder.build_agent", return_value=( + agent, MagicMock(), str(tid), MagicMock(), old, + )), + patch("core.agent_builder.sync_task_tokens"), + patch.object(runs, "session_scope", fake_scope), + patch.object(runs, "broker", broker), + patch("core.working_dirs.rename_working_dir", side_effect=fake_rename), + ): + runs.run_agent_bg(tid, uid, "把目录改名") + + self.assertEqual(order, ["status_committed", "renamed"]) + broker.clear_cancel.assert_called_once_with(tid) + broker.close.assert_called_once_with(tid) + broker.emit.assert_any_call( + tid, + { + "type": "warn", + "level": "info", + "msg": "工作目录已重命名为 新目录", + }, + ) + + def test_cancelled_run_does_not_apply_deferred_rename(self) -> None: + from web import runs + + tid = uuid4() + uid = uuid4() + actions = DeferredTaskActions(rename_working_dir_to="新目录") + agent = SimpleNamespace( + run=MagicMock(return_value="[cancelled]"), + deferred_actions=actions, + sink=None, + ) + + @contextmanager + def fake_scope(): + yield SimpleNamespace(execute=MagicMock()) + + with tempfile.TemporaryDirectory() as tmp: + old = Path(tmp) / "旧目录" + old.mkdir() + with ( + patch("core.agent_builder.build_agent", return_value=( + agent, MagicMock(), str(tid), MagicMock(), old, + )), + patch("core.agent_builder.sync_task_tokens"), + patch.object(runs, "session_scope", fake_scope), + patch.object(runs, "broker", MagicMock()), + patch("core.working_dirs.rename_working_dir") as rename, + ): + runs.run_agent_bg(tid, uid, "停止") + rename.assert_not_called() + + +class WorkingDirServiceTests(unittest.TestCase): + def test_updates_all_associated_tasks_and_renames_fs(self) -> None: + uid = uuid4() + tids = [uuid4(), uuid4()] + rows = [ + SimpleNamespace(task_id=tid, run_status="idle") + for tid in tids + ] + session = MagicMock() + session.execute.side_effect = [ + SimpleNamespace(all=lambda: rows), + MagicMock(), + ] + + @contextmanager + def fake_scope(): + yield session + + with tempfile.TemporaryDirectory() as tmp: + old = Path(tmp) / "旧目录" + new = Path(tmp) / "新目录" + old.mkdir() + (old / "产物.txt").write_text("ok", encoding="utf-8") + with ( + patch("core.working_dirs.session_scope", fake_scope), + patch("core.working_dirs.check_no_subtask"), + patch("core.working_dirs.to_db_path", side_effect=["old-db", "new-db"]), + ): + result = rename_working_dir( + user_id=uid, + old_path=old, + new_path=new, + ) + + self.assertEqual(result.tasks_updated, 2) + self.assertFalse(old.exists()) + self.assertEqual((new / "产物.txt").read_text(encoding="utf-8"), "ok") + self.assertEqual(session.execute.call_count, 2) + + def test_active_associated_task_blocks_before_fs_change(self) -> None: + uid = uuid4() + rows = [SimpleNamespace(task_id=uuid4(), run_status="running")] + session = MagicMock() + session.execute.return_value = SimpleNamespace(all=lambda: rows) + + @contextmanager + def fake_scope(): + yield session + + with tempfile.TemporaryDirectory() as tmp: + old = Path(tmp) / "旧目录" + new = Path(tmp) / "新目录" + old.mkdir() + with ( + patch("core.working_dirs.session_scope", fake_scope), + patch("core.working_dirs.to_db_path", side_effect=["old-db", "new-db"]), + ): + with self.assertRaises(WorkingDirBusyError): + rename_working_dir( + user_id=uid, + old_path=old, + new_path=new, + ) + self.assertTrue(old.is_dir()) + self.assertFalse(new.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system_prompt_paths.py b/tests/test_system_prompt_paths.py index bd9c4e6..3c5a17a 100644 --- a/tests/test_system_prompt_paths.py +++ b/tests/test_system_prompt_paths.py @@ -26,7 +26,7 @@ _TID = UUID("ab063233-7e90-4f57-9afe-3f7e47b13522") _HOST_CWD = Path("/home/ubuntu/zcbot") # 模拟 prod 宿主启动目录 -def _build(backend: str) -> tuple[str, Path, Path]: +def _build(backend: str, *, allow_rename: bool = True) -> tuple[str, Path, Path]: cfg = load_config() skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) tmp = tempfile.mkdtemp() @@ -35,7 +35,8 @@ def _build(backend: str) -> tuple[str, Path, Path]: wd.mkdir(parents=True, exist_ok=True) with patch.dict("os.environ", {"ZCBOT_SANDBOX_BACKEND": backend}, clear=False): prompt = _build_system_prompt( - cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents" + cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents", + allow_working_dir_rename=allow_rename, ) return prompt, ws, wd @@ -59,6 +60,12 @@ class TestSystemPromptPaths(unittest.TestCase): self.assertIn(str(wd), prompt) self.assertNotIn("/workspace/数据资源展示", prompt) + def test_rename_guard_only_when_tool_is_available(self): + interactive, _, _ = _build("docker", allow_rename=True) + scheduled, _, _ = _build("docker", allow_rename=False) + self.assertIn("必须调用 `rename_working_dir`", interactive) + self.assertNotIn("必须调用 `rename_working_dir`", scheduled) + if __name__ == "__main__": unittest.main() diff --git a/tools/rename_working_dir.py b/tools/rename_working_dir.py new file mode 100644 index 0000000..55218cd --- /dev/null +++ b/tools/rename_working_dir.py @@ -0,0 +1,65 @@ +"""对话内重命名当前 task 工作目录:登记意图,run 收尾后执行。""" +from __future__ import annotations + +from pathlib import Path + +from core.task_actions import DeferredTaskActions + +from .base import Tool + + +class RenameWorkingDirTool(Tool): + name = "rename_working_dir" + description = ( + "Rename the current task's top-level working directory. " + "The rename is safely deferred until the current response finishes, so all tasks " + "sharing this directory keep their association. Use this instead of shell mv/rename " + "for the current working directory." + ) + parameters = { + "type": "object", + "properties": { + "new_name": { + "type": "string", + "description": "新的目录 leaf 名,不是路径;不能含 / 或 \\,不能以 . 开头。", + }, + }, + "required": ["new_name"], + } + + def __init__( + self, + actions: DeferredTaskActions, + working_dir: Path, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.actions = actions + self.working_dir = Path(working_dir) + + def execute(self, new_name: str) -> str: + # 延迟 import,避免 agent_builder -> tool_registry -> 本工具 -> agent_builder + # 的模块初始化环;名称规则仍只有 validate_task_name 一个事实源。 + from core.agent_builder import InvalidTaskName, validate_task_name + + try: + safe = validate_task_name(new_name) + except InvalidTaskName as e: + return f"[Error] new_name 不合法: {e}" + if safe == self.working_dir.name: + return f"[Error] new_name 与当前工作目录名相同: {safe!r}" + target = self.working_dir.parent / safe + if target.exists(): + return f"[Error] 目标目录已存在: {safe!r}" + + replaced = self.actions.rename_working_dir_to + self.actions.rename_working_dir_to = safe + if replaced and replaced != safe: + return ( + f"[OK] 已把本轮待执行的工作目录改名从 {replaced!r} 更新为 {safe!r}。" + "将在本轮回复完成后安全执行。" + ) + return ( + f"[OK] 已登记工作目录改名为 {safe!r},将在本轮回复完成后安全执行;" + "所有共享该目录的 task 关联会同步更新。" + ) diff --git a/web/routers/files.py b/web/routers/files.py index 79b6d7d..34ecfe2 100644 --- a/web/routers/files.py +++ b/web/routers/files.py @@ -1,7 +1,7 @@ """Files 路由(user-rooted,不绑 task)+ 用户磁盘用量 + pptx 在线预览。 -目录树唯一 mutation 入口(DESIGN §7.4):顶层目录 rename/delete/move 走 DB-aware -分支(事务锁关联 task、running→409、DB UPDATE 先于 FS)。路径安全原语在 +文件面板的目录树 mutation 入口(DESIGN §7.4):顶层目录 rename 复用 +core.working_dirs 的 DB-aware 服务,delete/move 走本路由状态闸。路径安全原语在 web/userfiles.py。 """ from __future__ import annotations @@ -12,11 +12,16 @@ from uuid import UUID from fastapi import Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse -from sqlalchemy import func, select, update +from sqlalchemy import func, select from core.paths import to_db_path -from core.storage import NoSubtaskError, check_no_subtask, session_scope +from core.storage import session_scope from core.storage.models import Task +from core.working_dirs import ( + WorkingDirConflictError, + WorkingDirRenameError, + rename_working_dir, +) from ..common import norm_path from ..schemas import FileDeleteRequest, FileRenameRequest, FileTransferRequest @@ -319,47 +324,24 @@ def register_file_routes(app, *, require_user) -> None: "tasks_updated": 0, } - # 顶层目录:DB-aware - old_db = to_db_path(target) - new_db = to_db_path(new_target) - with session_scope() as s: - rows = s.execute( - select(Task.task_id, Task.run_status) - .where(Task.user_id == user_id, Task.working_dir == old_db) - .with_for_update() - ).all() - tids = [r.task_id for r in rows] - active = [ - str(r.task_id)[:8] for r in rows - if r.run_status in ("running", "cancelling") - ] - if active: - raise HTTPException( - 409, - f"folder has active run(s) on task(s) {active}; " - f"cancel before renaming", - ) - try: - check_no_subtask(new_db, user_id=user_id, exclude_task_ids=tids) - except NoSubtaskError as e: - raise HTTPException(409, str(e)) - if tids: - s.execute( - update(Task) - .where(Task.task_id.in_(tids)) - .values(working_dir=new_db) - ) - try: - target.rename(new_target) - except OSError as e: - # 抛 HTTPException 也会让 session_scope 走 except 分支回滚 UPDATE - raise HTTPException(400, f"FS rename failed: {e}") + # 顶层目录:网页和对话内延迟动作共用同一个 DB-aware 服务。 + try: + renamed = rename_working_dir( + user_id=user_id, + old_path=target, + new_path=new_target, + ) + except WorkingDirConflictError as e: + raise HTTPException(409, str(e)) + except WorkingDirRenameError as e: + # 保持原 files API 的普通 FS 失败 400 语义。 + raise HTTPException(400, str(e)) return { "ok": True, "old": body.path, "new": rel_to(root, new_target), - "tasks_updated": len(tids), + "tasks_updated": renamed.tasks_updated, } @app.post("/v1/files/copy", tags=["files"]) diff --git a/web/runs.py b/web/runs.py index 792cef4..189ed60 100644 --- a/web/runs.py +++ b/web/runs.py @@ -66,6 +66,38 @@ def run_agent_bg( run_status=final_status, run_error=None, ) ) + # 当前 working_dir 不能在 agent 仍执行工具时改名(executor/system prompt 都握 + # 旧 cwd)。rename_working_dir 工具只登记意图;正常完成并退出 running 后, + # 在 done 事件前统一走 DB-aware 服务。取消时不执行,避免用户停止后仍发生变更。 + rename_to = agent.deferred_actions.rename_working_dir_to + if final_status == "idle" and rename_to: + from core.working_dirs import WorkingDirRenameError, rename_working_dir + try: + renamed = rename_working_dir( + user_id=user_id, + old_path=task_dir, + new_path=task_dir.parent / rename_to, + ) + broker.emit(task_id, { + "type": "warn", + "level": "info", + "msg": f"工作目录已重命名为 {renamed.new_path.name}", + }) + except WorkingDirRenameError as e: + broker.emit(task_id, { + "type": "warn", + "msg": f"工作目录改名未执行:{e}", + }) + except Exception as e: + # 延迟附加动作失败不应把已经成功的对话 run 改标 error;保留目录原状, + # 给当前订阅者明确提示,下轮可重试。 + broker.emit(task_id, { + "type": "warn", + "msg": ( + "工作目录改名未执行:" + f"{type(e).__name__}: {e}" + ), + }) except Exception as e: err = f"{type(e).__name__}: {e}" broker.emit(task_id, {"type": "error", "msg": err}) diff --git a/web/static/js/media.js b/web/static/js/media.js index 2d17154..73e3b65 100644 --- a/web/static/js/media.js +++ b/web/static/js/media.js @@ -33,6 +33,7 @@ export function toolActivityLabel(name, args) { case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`; case "web_search": return `联网搜索: ${clip(a.query, 60)}`; case "load_skill": return `加载技能: ${clip(a.name, 40)}`; + case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`; case "seedream": return `生成图像: ${clip(a.prompt, 60)}`; case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`; case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;