From 52550c9e694e69f72405f59f3f0d099dcce12179 Mon Sep 17 00:00:00 2001 From: ymq Date: Tue, 1 Sep 2026 11:54:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(workspace):=20=E9=A1=B9=E7=9B=AE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=BB=9F=E4=B8=80=E6=9C=BA=E5=88=B6=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E6=97=B6=E5=AE=9A=E6=AD=BB=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=86=99=E5=BA=93=EF=BC=8C=E8=A7=A3=E6=9E=90=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=BA=93=E4=B8=8D=E6=8E=A8=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:4个创建入口只有1个写workspace_dir,其余靠解析时推导且口径不一 (上传落盘走旧平铺{space}/{名},角色agent走新结构{space}/projects/{名}), 导致AgentIO上传文件与项目工作目录错位,文件在工作控件里找不到。 - workspace.py: 新增 alloc_project_dir(统一分配,用名字不用id,重名加短id避让) - workspace.py: get_project_dir_by_id/get_workspace_dir 兜底改新结构口径 - project_capability.create_project: 创建时写 workspace_dir+directory_name - agent_loop_v2.create_project: 改用 alloc_project_dir(原自拼slug逻辑收敛) --- pipeline_service/agent_loop_v2.py | 20 ++------- pipeline_service/project_capability.py | 7 ++++ pipeline_service/workspace.py | 56 +++++++++++++++++++++----- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/pipeline_service/agent_loop_v2.py b/pipeline_service/agent_loop_v2.py index 50197ec..acc84a6 100644 --- a/pipeline_service/agent_loop_v2.py +++ b/pipeline_service/agent_loop_v2.py @@ -934,23 +934,11 @@ class AgentExecutor: if _u: org_id = getattr(_u[0], "orgid", "0") or "0" - # 项目专属工作空间目录(新结构:{base}/{org}/{space}/projects/{英文slug}/, - # 与 apps/、modules/ 平级;工作空间控件按此结构读取)。 - # 英文 slug:项目名保留 [A-Za-z0-9_-],且须含字母才有辨识度(避免纯数字如 "7"); - # 纯中文或纯数字名兑底 proj_{短ID}。 - from .workspace import get_workspace_base, build_space_path - workspace_base = await get_workspace_base(sor) + # 项目专属工作空间目录(统一机制:{base}/{org}/{space}/projects/{项目名}/, + # 用名字不用 id;创建时定死并写库,一切解析只读库不推导)。 + from .workspace import alloc_project_dir pid_val = getID() - space_dir = build_space_path(workspace_base, org_id, self.space) - slug = re.sub(r'[^A-Za-z0-9_-]', '', name).strip('_-') - if not slug or not re.search(r'[A-Za-z]', slug): - slug = 'proj_' + pid_val[-8:] - project_dir = os.path.join(space_dir, 'projects', slug) - if os.path.isdir(project_dir): - # 同 slug 已存在(不同项目重名)→ 追加短 ID,避免共享目录互相覆盖 - slug = f"{slug}_{pid_val[-8:]}" - project_dir = os.path.join(space_dir, 'projects', slug) - os.makedirs(project_dir, exist_ok=True) + project_dir, slug = await alloc_project_dir(sor, org_id, self.space, name, pid_val) await sor.C("sd_projects", { "id": pid_val, "name": name, "description": desc, diff --git a/pipeline_service/project_capability.py b/pipeline_service/project_capability.py index 3da2f32..029c296 100644 --- a/pipeline_service/project_capability.py +++ b/pipeline_service/project_capability.py @@ -88,6 +88,11 @@ async def create_project(name, project_type="web_app", description="", db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: pid = getID() + # 创建时定死项目目录并写库(统一机制:解析只读库不推导, + # 保证上传落盘=agent工作目录=工作控件显示目录) + from .workspace import alloc_project_dir + ws_path, dname = await alloc_project_dir( + sor, org_id or '0', 'general', name.strip(), pid) await sor.C(TABLE, { 'id': pid, 'name': name.strip(), @@ -96,6 +101,8 @@ async def create_project(name, project_type="web_app", description="", 'status': S_DRAFT, 'org_id': org_id or '0', 'created_by': created_by or '', + 'workspace_dir': ws_path, + 'directory_name': dname, }) await record_audit(pid, TABLE, pid, 'create', to_state=S_DRAFT, who=_normalize_role(who), diff --git a/pipeline_service/workspace.py b/pipeline_service/workspace.py index acfe6f2..74f185f 100644 --- a/pipeline_service/workspace.py +++ b/pipeline_service/workspace.py @@ -33,7 +33,7 @@ def build_workspace_path(workspace_base, org_id, space, name, project_id=''): def build_space_path(workspace_base, org_id, space): - """构建产线工作空间路径(机构工作空间层):{base}/{org_id}/{space}。 + """构建产线工作空间路径(机构工作空间层):{base}/{org}/{space}。 开发产线的机构工作空间(projects/apps/modules 三目录所在层)。 角色 agent 的工具路径基准用这个,从而能访问 projects/、apps/、modules/。 @@ -41,6 +41,29 @@ def build_space_path(workspace_base, org_id, space): space = (space or GENERAL_SPACE).strip() or GENERAL_SPACE return os.path.join(workspace_base, str(org_id or '0'), space) + +async def alloc_project_dir(sor, org_id, space, name, project_id=''): + """创建项目时定死项目目录(所有创建入口统一用这个,禁止各自推导)。 + + 目录 = {base}/{org}/{space}/projects/{项目名}(用名字,不用 id); + 同空间重名时追加 _{project_id 后 8 位} 避让。 + 返回 (dir_path, dir_name)——调用方必须把两者写进 + sd_projects.workspace_dir / directory_name,此后一切解析只读库不推导, + 保证「上传放的位置 = agent 读的位置 = 工作控件显示的位置」。 + (2026-09-01 根治:旧代码创建时不写库、解析时按名字推导且口径不一, + 导致上传落盘与项目目录错位,文件在工作控件里找不到) + """ + workspace_base = await get_workspace_base(sor) + space_dir = build_space_path(workspace_base, org_id or '0', space or GENERAL_SPACE) + dname = (name or '').strip() or 'unnamed' + path = os.path.join(space_dir, 'projects', dname) + if os.path.isdir(path) and project_id: + short = project_id[-8:] if len(project_id) >= 8 else project_id + dname = f"{dname}_{short}" + path = os.path.join(space_dir, 'projects', dname) + os.makedirs(path, exist_ok=True) + return path, dname + # 可编辑的文本文件扩展名 TEXT_EXTENSIONS = { '.md', '.py', '.js', '.html', '.css', '.json', '.txt', @@ -231,7 +254,7 @@ async def get_workspace_dir(sor, uid, session_id=''): return '', workspace_base proj = await sor.sqlExe( - "SELECT name, org_id, workspace_dir, pipeline_id FROM sd_projects WHERE id=${p}$", + "SELECT name, directory_name, org_id, workspace_dir, pipeline_id FROM sd_projects WHERE id=${p}$", {"p": pid}) if not proj: return '', workspace_base @@ -242,7 +265,13 @@ async def get_workspace_dir(sor, uid, session_id=''): space = getattr(proj[0], 'pipeline_id', '') or GENERAL_SPACE if ws.startswith('/'): return ws, workspace_base - return build_workspace_path(workspace_base, org_id, space, pname, pid), workspace_base + # 兜底与项目目录口径一致:{space}/projects/{项目名}(旧平铺 {space}/{项目名} + # 已废弃——与上传落盘/角色 agent 工作目录错位,2026-09-01 根治) + dname = (getattr(proj[0], 'directory_name', '') or '').strip() or (pname or '').strip() + if not dname: + return '', workspace_base + return os.path.join(build_space_path(workspace_base, org_id, space), + 'projects', dname), workspace_base async def get_space_dir(sor, uid, session_id=''): @@ -352,14 +381,18 @@ async def get_project_apps_modules(sor, uid, session_id=''): async def get_project_dir_by_id(sor, project_id): """项目根目录(按项目 id 直接解析,不依赖会话上下文)。 - 解析顺序与 agent 工作目录完全一致,保证「上传放的位置 = agent 读的位置」: - 1. sd_projects.workspace_dir 非空(新项目)→ 直接用({space}/projects/{slug}) - 2. 为空(存量项目)→ 兜底旧平铺结构 {base}/{org}/{space}/{项目名} + 解析顺序与角色 agent 工作目录(agent_loop._get_project_dir)完全一致, + 保证「上传放的位置 = agent 读的位置 = 工作控件显示的位置」: + 1. sd_projects.workspace_dir 非空 → 直接用 + 2. 为空(存量项目)→ 新结构 {base}/{org}/{space}/projects/{directory_name or name} + (历史教训:曾兜底旧平铺 {space}/{项目名},导致上传落盘与项目目录错位, + 文件在项目控件里找不到——2026-09-01 实测复现并根治) """ if not project_id: return '', '' recs = await sor.sqlExe( - "SELECT name, org_id, pipeline_id, workspace_dir FROM sd_projects WHERE id=${p}$ LIMIT 1", + "SELECT name, directory_name, org_id, pipeline_id, workspace_dir " + "FROM sd_projects WHERE id=${p}$ LIMIT 1", {"p": project_id}) if not recs: return '', '' @@ -368,13 +401,14 @@ async def get_project_dir_by_id(sor, project_id): ws = (getattr(r, 'workspace_dir', '') or '').strip() if ws.startswith('/'): return ws, workspace_base - name = (getattr(r, 'name', '') or '').strip() - if not name: + dname = (getattr(r, 'directory_name', '') or '').strip() or \ + (getattr(r, 'name', '') or '').strip() + if not dname: return '', workspace_base org_id = getattr(r, 'org_id', '0') or '0' space = getattr(r, 'pipeline_id', '') or GENERAL_SPACE - # 不传 project_id:查询语义下禁用重名后缀逻辑,返回的就是平铺目录本身 - return build_workspace_path(workspace_base, org_id, space, name), workspace_base + space_dir = build_space_path(workspace_base, org_id, space) + return os.path.join(space_dir, 'projects', dname), workspace_base def copy_uploads_to_project(project_dir, uploads):