fix(workspace): 项目目录统一机制——创建时定死目录写库,解析只读库不推导

根因: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逻辑收敛)
This commit is contained in:
ymq 2026-09-01 11:54:58 +08:00
parent 2dcd771533
commit 52550c9e69
3 changed files with 56 additions and 27 deletions

View File

@ -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,

View File

@ -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),

View File

@ -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):