File-backed Raxol.Agent.Journal — one directory per session, the directory
is the session (portable, tar-able, rsync-able, grep-able).
<base>/<session_id>/
├── meta.json # created_at, cwd, git branch, title, schema_version
├── HEAD # sidecar: current durable offset + config
├── writer.lock # the live Writer's OS pid + BEAM start time
├── journal/
│ ├── 000001.jsonl # framed JSONL, size-capped ascending segments
│ └── 000002.jsonl
└── snapshots/ # checkpoint payloads, named by SHA-256The base directory defaults to ~/.raxol/sessions/, overridable via the
:base_dir option or the RAXOL_SESSIONS_DIR env var.
Storage splits into a single-writer GenServer
(Raxol.Agent.Journal.FileStore.Writer) and a tolerant replay reader
(Raxol.Agent.Journal.FileStore.Reader). See those modules and
Raxol.Agent.Journal for the durability and torn-tail-recovery contract.
Chained journals
open(session_id, chain: true) creates a hash-chained journal (see
Raxol.Agent.Journal.Chain): every record carries prev_hash and hash,
meta.json records "chain": true, and HEAD anchors the tip hash. The
mode is fixed when the journal is created; a chained journal stays chained
whatever later openers pass, and chain: true on an existing unchained
journal is refused with {:error, :unchained_journal}. read/2, status/1
and verify/1 all walk the chain, so a broken one is never surfaced.
Removing the flag from meta.json does not unchain a journal: one that
still carries a chain marker (HEAD's tip_hash, the first record's
prev_hash/hash) reads as damaged from offset 1 and its Writer refuses
every append (see Raxol.Agent.Journal.FileStore.Reader).
Private journals
open(session_id, private: true) keeps the journal to the account that
writes it: the session directory, journal/ and snapshots/ are 0700 and
every file the Writer creates is 0600 before a byte is written to it. The
base and session directories must be real directories (not symlinks), owned
by this account and writable by no one else; otherwise the open is refused
with {:error, {:untrusted_dir, {path, reason}}} and nothing is created.
reason is :symlink, {:not_a_directory, type}, {:stat_failed, posix},
{:not_owned, uid} (the directory's owner) or
{:group_or_other_writable, mode}. A Windows host reports neither a uid nor
POSIX mode bits, so the owner and mode rules pass there.
Summary
Functions
Atomic check-and-append: check runs against the freshest on-disk records
INSIDE the single Writer, and event is appended only if it returns :ok —
one atomic step, so no concurrent append (e.g. through a shared joiner
handle) can interleave between validation and commit. See
Raxol.Agent.Journal.FileStore.Writer.append_checked/3.
Append events as consecutive records in one Writer call: no other append
can land between them. Returns their offsets in order; nothing is appended
when any event fails to encode or seal.
The current durable high-watermark (highest offset) for session_id, read
read-side via the tolerant Reader — 0 for an empty/absent/damaged journal,
{:error, :invalid_session_id} for an id that names no session directory.
Open (creating if needed) the journal for session_id.
The decoded meta.json of an open journal: what its first opener recorded
("cwd", "title", "created_at", ...). {:error, reason} when the file
is missing or is not a JSON object.
Read session_id's durable records read-side via the tolerant Reader
(writerless-safe — no Writer needed). {:error, :damaged} on interior
corruption (the damaged content is never surfaced),
{:error, :invalid_session_id} for an id that names no session directory.
Read by Raxol.Agent.McpTools and Raxol.Broker.Journal.Replay.
Resolve session_id to its on-disk session directory under the configured
base (:base_dir option, $RAXOL_SESSIONS_DIR, or ~/.raxol/sessions).
Walk the hash chain of an open journal: :ok, {:broken, offset} for the
first record that does not verify, or {:error, :unchained} for a journal
created without chain: true and carrying no chain marker. A chained
journal whose meta.json flag was removed is {:broken, 1}.
verify/1 read-side, without a Writer: safe against a journal another OS
process is appending to, and on read-only media. Used by replay tooling.
Types
Functions
@spec append_checked(t(), map(), ([map()] -> :ok | {:error, term()})) :: {:ok, non_neg_integer()} | {:error, term()}
Atomic check-and-append: check runs against the freshest on-disk records
INSIDE the single Writer, and event is appended only if it returns :ok —
one atomic step, so no concurrent append (e.g. through a shared joiner
handle) can interleave between validation and commit. See
Raxol.Agent.Journal.FileStore.Writer.append_checked/3.
@spec append_many(t(), [map()], timeout()) :: {:ok, [non_neg_integer()]} | {:error, term()}
Append events as consecutive records in one Writer call: no other append
can land between them. Returns their offsets in order; nothing is appended
when any event fails to encode or seal.
timeout bounds the Writer call (default 5_000 ms, :infinity to wait as
long as the Writer takes). A call that times out returns {:error, {:writer_down, :timeout}}, but the request stays queued in the Writer and
the events land later: a caller that must not treat a slow fsync as a
failure should pass :infinity.
@spec high_watermark(term(), keyword()) :: non_neg_integer() | {:error, :invalid_session_id}
The current durable high-watermark (highest offset) for session_id, read
read-side via the tolerant Reader — 0 for an empty/absent/damaged journal,
{:error, :invalid_session_id} for an id that names no session directory.
Raxol.Agent.McpTools reads it as an agent's event count.
Open (creating if needed) the journal for session_id.
Options:
:base_dir— base directory (defaults to$RAXOL_SESSIONS_DIRor~/.raxol/sessions).:segment_cap— segment size cap in bytes before rotation (default 8 MiB).:immediate_sync_types— eventtypes that force an immediate datasync (default["tool_result", "approval"]).:schema_version,:cwd,:git_branch,:title— recorded inmeta.jsonon first open.:chain—trueto create a hash-chained journal (see "Chained journals").:private—truefor a journal only this account can read (see "Private journals"): directories 0700, files 0600, and an untrusted base or session directory refused with{:error, {:untrusted_dir, {path, reason}}}.:require_lock—trueto refuse a journal whose cross-process writer lock cannot be taken (nokill, a non-Unix host, the lock file cannot be created) with{:error, {:journal_locked, :lock_unavailable}}, instead of writing without one. A live holder is refused either way, as{:error, {:journal_locked, os_pid}};Raxol.Agent.Journal.FileStore.Writer.lock_path/1names the lock file.
The decoded meta.json of an open journal: what its first opener recorded
("cwd", "title", "created_at", ...). {:error, reason} when the file
is missing or is not a JSON object.
Read session_id's durable records read-side via the tolerant Reader
(writerless-safe — no Writer needed). {:error, :damaged} on interior
corruption (the damaged content is never surfaced),
{:error, :invalid_session_id} for an id that names no session directory.
Read by Raxol.Agent.McpTools and Raxol.Broker.Journal.Replay.
Resolve session_id to its on-disk session directory under the configured
base (:base_dir option, $RAXOL_SESSIONS_DIR, or ~/.raxol/sessions).
This is the single resolution point open/2 and the read-side functions
share, so a session opened for writing and one read without a Writer always
name the same directory.
Walk the hash chain of an open journal: :ok, {:broken, offset} for the
first record that does not verify, or {:error, :unchained} for a journal
created without chain: true and carrying no chain marker. A chained
journal whose meta.json flag was removed is {:broken, 1}.
@spec verify_session(String.t(), keyword()) :: :ok | {:broken, non_neg_integer()} | {:error, :unchained}
verify/1 read-side, without a Writer: safe against a journal another OS
process is appending to, and on read-only media. Used by replay tooling.