OpenProgram Docs

Data model#

On-Disk Layout#

<state>/sessions/
├── index.json                         # registry (summary cache)
├── locations.json                     # durable session-id → repository path map
├── <session_id_1>/                    # default and pre-existing home-root layout
│   ├── .git/                          # session repository metadata
│   ├── meta.json                      # session metadata and active DAG head
│   ├── history/                       # one JSON file per DAG node
│   ├── context/                       # persisted context artifacts
│   └── workdir/attachments/           # uploaded files for this session
├── projects/<project_id>/              # new bound-project storage
│   ├── <session_id>/                   # session Git repository
│   └── .file-recovery/<session_id>/<turn>/ # per-turn file undo recovery
├── .file-recovery/<session_id>/<turn>/ # default-root per-turn recovery
├── .migration/                        # migration journal and staging state
├── .deleted/                          # durable delete intents
└── .locks/ / .session-locks/          # inter-process coordination

The default root is <state>/sessions, where <state> is OpenProgram's state directory. A newly created session bound to a non-default project is placed under projects/<project_id>/<session_id>. The default project and sessions created before the centralized placement migration remain at the root-level <session_id> path. locations.json is the durable authority when a session is being migrated or relocated. A working folder is never used as the primary conversation repository; the old <project>/.openprogram/sessions/<id>/ location is read only as a legacy migration source.

The history/ files are the conversation DAG. meta.json stores the active DAG head_id, while the repository's Git HEAD records the latest storage commit. Those are different pointers: changing the conversation branch does not mean checking out the Git branch, and session_commits() exposes Git's per-turn commits separately from DAG nodes.

Persistent Fields (meta.json)#

Field Type Registry Description
id str Yes unique session identifier
agent_id str Yes the bound agent
title str Yes display name
created_at float Yes creation timestamp
updated_at float Yes last-activity timestamp
project_id str? No the bound project (supplemented with the project name by project_map when listing)
source str? Yes origin: "tui" / "web" / "wechat" / ...
channel str? Yes channel type
account_id str? Yes channel account
peer_display str? Yes peer display name
peer_id str? Yes peer ID
pinned bool Yes pinned
archived bool Yes archived
group str? Yes group label
status str Yes lifecycle status (see below)
unread bool Yes unread marker
_auto_titled bool No auto-naming idempotency marker (internal control; not stored in the registry, not returned to the frontend)

The "Registry" column indicates whether the field is cached in index.json. _auto_titled and project_id are not stored in the registry: the former is an internal marker, and the latter is supplemented from the project directory mapping when listing.

Registry-Only Fields#

The following fields exist only in the registry, not in meta.json:

Field Description
preview the first 80 characters of the last user message, maintained by truncation when a message is written

status Enum#

Value Meaning Frontend Display
idle idle, no turn executing no indicator
running a turn is executing running animation
needs_input the agent is waiting for user input amber dot
done background task finished blue dot shown together with unread
failed turn execution failed red dot
interrupted the worker died mid-turn no indicator (not run-active)

running is stamped by the dispatcher when a turn starts and cleared when it ends. A worker killed mid-turn (SIGKILL, crash) never runs that clear, so the row would stay running forever and pin the chat container at data-run-active="true" with no way out short of editing state on disk. reconcile_interrupted_runs() therefore resets any row still at running to interrupted on worker startup — a fresh worker has nothing running by definition. It resets the row independently of the DAG-node sweep in the same function, because a worker killed between the status write and the placeholder insert leaves a running row with no running node.

Moving HEAD: _set_active_head#

webui/server.py keeps a per-session mirror in _sessions[sid] holding head_id and messages, and _save_session flushes both straight back into SessionStore. A path that moves HEAD in the store but leaves the mirror behind is therefore not merely stale — the next save actively reverts the move.

_set_active_head(session_id, head_id) is the single correct way to move HEAD. It writes SessionStore, re-reads the new branch into the mirror's head_id and messages, and drops the message cache, in that order. Every mutating path routes through it: retry, edit, sibling checkout, deepest-leaf jump, branch checkout, branch delete, attach, and rewind.

Mutations that move HEAD are refused while a run is in flight (_is_run_active), returning RUN_ACTIVE_ERROR with code: "run_active". Without that guard, an in-flight reply lands with its predecessor pointing at a branch the user already left; branch delete is worse still, since the tail being deleted may be the one the turn is writing into.

In-Process Cache (_sessions dict)#

SessionStore holds lazy (GitSession, SessionMemoryIndex) pairs. GitSession owns file and Git operations; the memory index is rebuilt from history/ and meta.json and tracks DAG nodes, edges, and the active head. It is evicted by the bounded LRU cache and can be invalidated when a subprocess writes the repository.

Uploaded browser attachments are copied into <session repository>/workdir/attachments/ before dispatch. Their message markers retain the saved absolute path, and the copy is committed with the session repository at turn end. Inbound channel files use the channel attachment roots under <state>/channels/*/accounts/*/attachments; those roots are separately readable by the attachment path policy. The .file-recovery/<session_id>/<turn>/ tree is ordinary per-turn file undo recovery and is kept outside the session Git repository. During legacy project migration, session data and any existing recovery tree are staged under <state>/sessions/.migration/staging/, verified, then published into the central destination's sibling .file-recovery tree; locations.json is updated only after publication.

Historical attachment markers retain their original absolute path. The attachment UI, file reader, and send_file path resolution may rebase only the exact legacy shape <project>/.openprogram/sessions/<session_id>/workdir/attachments/<relative> to the unique current repository for that same session id. The rebased path must remain under an allowed attachment root and cannot escape through .. or a symlink; arbitrary old paths are never remapped.

Interface#

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

See operations.md for the full behavior of each method.

Last updated · 2026-10-10