OpenProgram Docs

数据模型#

磁盘布局#

<state>/sessions/
├── index.json                         # 注册表(摘要缓存)
├── locations.json                     # session id → 仓库路径的持久映射
├── <session_id_1>/                    # 默认项目和既有 home-root 布局
│   ├── .git/                          # 会话仓库元数据
│   ├── meta.json                      # 会话元数据和 DAG 当前 head
│   ├── history/                       # 每个 DAG 节点一个 JSON 文件
│   ├── context/                       # 持久化上下文产物
│   └── workdir/attachments/           # 该会话上传的文件
├── projects/<project_id>/              # 新建的项目绑定存储
│   ├── <session_id>/                   # 会话 Git 仓库
│   └── .file-recovery/<session_id>/<turn>/ # 该会话的每轮恢复文件
├── .file-recovery/<session_id>/<turn>/ # 默认根目录的每轮恢复文件
├── .migration/                        # 迁移日志和 staging 状态
├── .deleted/                          # 持久化删除意图
└── .locks/ / .session-locks/          # 进程间协调

默认根目录是 <state>/sessions,其中 <state> 是 OpenProgram 状态目录。 新建且绑定非默认项目的 session 放在 projects/<project_id>/<session_id>。 默认项目和集中存储迁移前创建的 session 仍留在根目录下的 <session_id>。迁移或重定位期间,locations.json 是持久路径权威来源。 工作目录不会作为主要会话仓库;旧的 <project>/.openprogram/sessions/<id>/ 只作为 legacy 迁移源读取。

history/ 文件构成对话 DAG。meta.json 保存 DAG 的活动 head_id,而仓库 的 Git HEAD 表示最近一次存储提交。这两个指针含义不同:切换对话分支不会 执行 Git 分支 checkout,session_commits() 也单独暴露每轮 Git 提交,而不是 DAG 节点列表。

持久字段(meta.json)#

字段 类型 注册表 说明
id str 是 session 唯一标识
agent_id str 是 绑定的 agent
title str 是 显示名称
created_at float 是 创建时间戳
updated_at float 是 最后活动时间戳
project_id str? 否 绑定的项目(列举时由 project_map 补充为 project 名称)
source str? 是 来源:"tui" / "web" / "wechat" / ...
channel str? 是 渠道类型
account_id str? 是 渠道账号
peer_display str? 是 对方显示名
peer_id str? 是 对方 ID
pinned bool 是 置顶
archived bool 是 归档
group str? 是 分组标签
status str 是 生命周期状态(见下方)
unread bool 是 未读标记
_auto_titled bool 否 自动命名幂等标记(内部控制,不进注册表、不返回前端)

"注册表"列标记该字段是否缓存到 index.json。_auto_titled 和 project_id 不进注册表:前者是内部标记,后者在列举时由项目目录映射补充。

注册表独有字段#

以下字段只在注册表中,不在 meta.json 中:

字段 说明
preview 最后一条用户消息前 80 字符,由写消息时截取维护

status 枚举#

值 含义 前端显示
idle 空闲,无 turn 在执行 无指示
running 有 turn 正在执行 运行动画
needs_input agent 等待用户输入 琥珀点
done 后台任务完成 配合 unread 显示蓝点
failed turn 执行失败 红点
interrupted worker 在 turn 中途死掉 无指示(不算 run-active)

running 由 dispatcher 在 turn 开始时写入、结束时清除。worker 中途被杀 (SIGKILL、崩溃)就跑不到清除那一步,会话行会永远停在 running,把聊天容器 钉在 data-run-active="true" 上,除非手改磁盘状态否则出不来。因此 reconcile_interrupted_runs() 在 worker 启动时把仍是 running 的行重置为 interrupted——新起的 worker 按定义没有任何东西在跑。这一步与同一函数里的 DAG 节点扫描相互独立:worker 若在写 status 和插入 placeholder 之间被杀,就会留下一 个 running 的行却没有 running 的节点。

移动 HEAD:_set_active_head#

webui/server.py 按会话持有一份内存镜像 _sessions[sid],含 head_id 与 messages,而 _save_session 会把两者原样写回 SessionStore。所以只改 store 的 HEAD、不同步镜像的路径不只是"数据过期"——下一次保存会主动把这次移动撤销。

_set_active_head(session_id, head_id) 是移动 HEAD 的唯一正确入口。它依次完成: 写 SessionStore、把新分支读回镜像的 head_id 与 messages、清消息缓存。所有会 改动的路径都走它:retry、edit、兄弟节点 checkout、deepest-leaf 跳转、分支 checkout、删分支、attach、rewind。

有 turn 在运行时(_is_run_active),所有移动 HEAD 的操作一律拒绝,返回 RUN_ACTIVE_ERROR 并带 code: "run_active"。没有这道保护,在飞的回复落地时 predecessor 会指向用户已经离开的分支;删分支更糟——要删的那条尾巴可能正是当前 turn 正在写入的。

进程内缓存(_sessions dict)#

SessionStore 缓存延迟加载的 (GitSession, SessionMemoryIndex) 对。GitSession 负责文件和 Git 操作;内存索引从 history/ 与 meta.json 重建,记录 DAG 节点、边和活动 head。索引采用有上限 的 LRU 缓存,也可以在子进程写入仓库后主动失效并重建。

浏览器上传的附件在 dispatch 前复制到 <session repository>/workdir/attachments/。消息标记保留保存后的绝对路径, 该副本在 turn 结束时与会话仓库一起提交。渠道入站文件使用 <state>/channels/*/accounts/*/attachments 下的渠道附件根目录,由附件路径 策略单独允许读取。.file-recovery/<session_id>/<turn>/ 是普通的每轮文件撤销 恢复目录,位于会话 Git 仓库之外。legacy 项目迁移时,会话数据和已有恢复目录 先暂存到 <state>/sessions/.migration/staging/ 并校验,再发布到中央目标路径的 .file-recovery 兄弟目录;发布完成后才更新 locations.json。

历史附件标记会保留原始绝对路径。附件界面、文件读取和 send_file 的路径解析 只允许把精确的旧路径形式 <project>/.openprogram/sessions/<session_id>/workdir/attachments/<relative> 按同一个 session id 重新定位到唯一的当前仓库。重新定位后的路径必须仍在允许的 附件根目录内,不能通过 .. 或符号链接越界;系统不会任意重映射旧路径。

接口#

class SessionStore:
    def create_session(session_id, agent_id, *, title="", source=None, **meta) -> None
    def get_session(session_id) -> dict | None
    def update_session(session_id, **fields) -> None
    def delete_session(session_id) -> None
    def list_sessions(*, limit=100, offset=0, **filters) -> list[dict]
    def get_branch(session_id, head_id=None) -> list[dict]
    def append_message(session_id, msg) -> None
    def latest_user_text(session_id) -> str | None

每个方法的完整行为见 operations.md。

Last updated · 2026-10-10