187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
# ============================================================
|
||
# APS 用户主目录(moduleId: server-aps-home, 可重生 ✅)
|
||
# 对标 Codex ~/.codex:桌面端默认 ~/.aps/{skills,sessions,data,…}
|
||
# Web 部署默认仍用仓库内 server/data(可用 APS_HOME 覆盖)
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
|
||
# 与 Codex 类似的子目录约定
|
||
_LAYOUT = (
|
||
"skills", # 算法 / Agent Skill(每 skill 一目录 + manifest.json)
|
||
"sessions", # 会话落盘(桌面端持久化)
|
||
"cache",
|
||
"tmp",
|
||
"logs",
|
||
"data", # world / sqlite / knowledge / checkpoints
|
||
"packs", # 可选:用户级数据包
|
||
)
|
||
|
||
|
||
def mode() -> str:
|
||
"""运行形态:desktop | web(默认 web,便于仓库内开发与测试)。"""
|
||
m = (os.environ.get("APS_MODE") or "web").strip().lower()
|
||
return "desktop" if m in ("desktop", "desk", "electron", "app") else "web"
|
||
|
||
|
||
def aps_home() -> Path:
|
||
"""解析 APS 根目录。
|
||
|
||
优先级:
|
||
1. APS_HOME(显式,任意形态)
|
||
2. desktop → 用户主目录/.aps(对标 Codex ~/.codex;Windows=%USERPROFILE%\\.aps)
|
||
3. web → APS_DATA_DIR 或 仓库 server/data
|
||
"""
|
||
raw = os.environ.get("APS_HOME")
|
||
if raw:
|
||
return Path(raw).expanduser().resolve()
|
||
if mode() == "desktop":
|
||
# 安装到电脑后的唯一默认:用户根目录下的 .aps(不可落到安装目录)
|
||
return (Path.home() / ".aps").resolve()
|
||
data = os.environ.get("APS_DATA_DIR")
|
||
if data:
|
||
return Path(data).expanduser().resolve()
|
||
# 相对仓库:server/data(与历史路径兼容)
|
||
return Path("server/data").resolve()
|
||
|
||
|
||
def ensure_aps_home(home: Path | None = None) -> Path:
|
||
"""创建主目录与标准子目录;写入 config.json(若不存在)。"""
|
||
root = home or aps_home()
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
for name in _LAYOUT:
|
||
(root / name).mkdir(parents=True, exist_ok=True)
|
||
cfg = root / "config.json"
|
||
if not cfg.exists():
|
||
cfg.write_text(
|
||
json.dumps({
|
||
"version": 1,
|
||
"mode": mode(),
|
||
"layout": list(_LAYOUT),
|
||
"note": "对标 Codex ~/.codex:skills / sessions / data 等本地落盘",
|
||
}, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
return root
|
||
|
||
|
||
def skills_dir() -> Path:
|
||
return ensure_aps_home() / "skills"
|
||
|
||
|
||
def sessions_dir() -> Path:
|
||
return ensure_aps_home() / "sessions"
|
||
|
||
|
||
def default_projects_path() -> str:
|
||
"""工作区元数据(项目/会话/消息):落在 ~/.aps/sessions/workspace.json。"""
|
||
if os.environ.get("APS_PROJECTS_PATH"):
|
||
return os.environ["APS_PROJECTS_PATH"]
|
||
return str(sessions_dir() / "workspace.json")
|
||
|
||
|
||
def data_dir() -> Path:
|
||
"""世界状态 / DB / 知识库等运行数据目录。"""
|
||
root = ensure_aps_home()
|
||
# web 默认 APS_HOME 就是 server/data,数据平铺在根下以保持兼容
|
||
if mode() == "web" and not os.environ.get("APS_HOME"):
|
||
return root
|
||
d = root / "data"
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def path_under_data(*parts: str) -> Path:
|
||
return data_dir().joinpath(*parts)
|
||
|
||
|
||
def default_world_path() -> str:
|
||
if os.environ.get("APS_WORLD_PATH"):
|
||
return os.environ["APS_WORLD_PATH"]
|
||
return str(path_under_data("world.json"))
|
||
|
||
|
||
def default_db_path() -> str:
|
||
if os.environ.get("APS_DB_PATH"):
|
||
return os.environ["APS_DB_PATH"]
|
||
return str(path_under_data("master.db"))
|
||
|
||
|
||
def default_checkpoint_path() -> str:
|
||
if os.environ.get("APS_CHECKPOINT_PATH"):
|
||
return os.environ["APS_CHECKPOINT_PATH"]
|
||
return str(path_under_data("checkpoints.json"))
|
||
|
||
|
||
def default_knowledge_path() -> str:
|
||
if os.environ.get("APS_KNOWLEDGE_PATH"):
|
||
return os.environ["APS_KNOWLEDGE_PATH"]
|
||
return str(path_under_data("knowledge.json"))
|
||
|
||
|
||
def default_embeddings_path() -> str:
|
||
if os.environ.get("APS_EMBEDDINGS_PATH"):
|
||
return os.environ["APS_EMBEDDINGS_PATH"]
|
||
return str(path_under_data("embeddings.json"))
|
||
|
||
|
||
def project_upload_dir(project_key: str, tenant_uuid: str | None = None) -> Path:
|
||
"""项目上传文件目录(随数据目录持久化:Web=PVC /app/server/data,桌面=~/.aps)。
|
||
|
||
布局与世界状态同口径:platform 租户平铺在 projects/<key>/uploads,
|
||
其他租户在 tenants/<租户>/projects/<key>/uploads。
|
||
"""
|
||
import re
|
||
|
||
def _safe(value: str, fallback: str) -> str:
|
||
cleaned = re.sub(r"[^\w\-]+", "_", (value or fallback).strip())[:64]
|
||
return cleaned or fallback
|
||
|
||
key = _safe(project_key, "default")
|
||
tenant = _safe(tenant_uuid or "platform", "platform")
|
||
if tenant == "platform":
|
||
folder = data_dir() / "projects" / key / "uploads"
|
||
else:
|
||
folder = data_dir() / "tenants" / tenant / "projects" / key / "uploads"
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
return folder
|
||
|
||
|
||
def storage_status() -> dict:
|
||
"""数据目录可写性自检(Web 容器 PVC 挂载排障用)。
|
||
|
||
writable=False 时创建项目/排产等一切写入都会失败,常见原因是
|
||
K8s 挂载卷属 root 而容器以非 root 运行——用 securityContext.fsGroup 修复。
|
||
"""
|
||
root = ensure_aps_home()
|
||
d = data_dir()
|
||
writable = os.access(d, os.W_OK)
|
||
return {
|
||
"mode": mode(),
|
||
"home": str(root),
|
||
"dataDir": str(d),
|
||
"writable": writable,
|
||
"remedy": None if writable else (
|
||
"数据目录不可写:请为挂载卷配置 securityContext.fsGroup(与容器运行用户同组),"
|
||
"或调整 PVC 挂载点属主;详见 packaging/k8s/deployment.yaml"
|
||
),
|
||
}
|
||
|
||
|
||
def describe() -> dict:
|
||
"""供 /api/system/paths 与桌面关于页展示。"""
|
||
root = ensure_aps_home()
|
||
return {
|
||
"mode": mode(),
|
||
"home": str(root),
|
||
"skills": str(skills_dir()),
|
||
"sessions": str(sessions_dir()),
|
||
"workspace": default_projects_path(),
|
||
"data": str(data_dir()),
|
||
"world": default_world_path(),
|
||
"db": default_db_path(),
|
||
}
|