Raxol.Agent.Journal.FileStore (Raxol Journal v2.8.0)

Copy Markdown View Source

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-256

The 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

t()

@type t() :: %Raxol.Agent.Journal.FileStore{
  dir: Path.t(),
  owner?: boolean(),
  session_id: String.t(),
  writer: pid()
}

Functions

append_checked(file_store, event, check)

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

append_many(file_store, events, timeout \\ 5000)

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

high_watermark(session_id, opts \\ [])

@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(session_id, opts \\ [])

Open (creating if needed) the journal for session_id.

Options:

  • :base_dir — base directory (defaults to $RAXOL_SESSIONS_DIR or ~/.raxol/sessions).
  • :segment_cap — segment size cap in bytes before rotation (default 8 MiB).
  • :immediate_sync_types — event types that force an immediate datasync (default ["tool_result", "approval"]).
  • :schema_version, :cwd, :git_branch, :title — recorded in meta.json on first open.
  • :chain — true to create a hash-chained journal (see "Chained journals").
  • :private — true for 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 — true to refuse a journal whose cross-process writer lock cannot be taken (no kill, 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/1 names the lock file.

read_meta(file_store)

@spec read_meta(t()) :: {:ok, map()} | {:error, term()}

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_records(session_id, opts \\ [])

@spec read_records(term(), keyword()) ::
  {:ok, [map()]} | {:error, :damaged | :invalid_session_id}

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.

session_dir(session_id, opts \\ [])

@spec session_dir(String.t(), keyword()) :: Path.t()

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.

verify(file_store)

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_session(session_id, opts \\ [])

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