Add safe conversational workspace rename
This commit is contained in:
parent
98713bb7fa
commit
85c918dc2f
|
|
@ -5,6 +5,10 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.59.6 — 2026-07-27
|
||||
|
||||
- 现在可以直接在对话里让助手修改当前工作目录名。改名会在本轮回复结束后安全执行,并同步更新所有共享该目录的任务关联,不会再因直接移动目录导致后续对话找不到文件。
|
||||
|
||||
## 0.59.5 — 2026-07-27
|
||||
|
||||
- 视频生成现在支持让已有图片直接“动起来”:既可以指定一张图片作为视频开场画面,也可以同时提供多张人物、产品、场景或风格参考图(最多 9 张),生成时更容易保持主体和视觉风格一致。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.59.5"
|
||||
__version__ = "0.59.6"
|
||||
|
|
|
|||
|
|
@ -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(`<task_dir>/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 抽取稳定锚定 <wd>/ 前缀)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 熔断。
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 关联会同步更新。"
|
||||
)
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
32
web/runs.py
32
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})
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue