Annotai.Store (Annotai v0.1.0)

Copy Markdown View Source

Ephemeral in-memory annotation store.

A trivial GenServer owns a public, named ETS table so the MCP and HTTP handlers (running in web-server processes) can read and write directly without a GenServer round-trip. The owner holds no logic, so a handler crash can never take the table down with it. Annotations live only in memory and are wiped when the host app restarts — by design for a dev-only feedback tool.

Every stored value is a Annotai.Annotation struct.

Summary

Functions

Append a human reply to an annotation's thread.

Append a message to an annotation's reply thread.

All annotations, oldest first.

Returns a specification to start this module under a supervisor.

Remove all annotations.

Delete one annotation.

Fetch one annotation by id, or nil.

Fetch a single attached image (with its base64 data) by annotation + image id, or nil.

Epoch-ms of the last agent request, or nil if none yet.

Annotations still awaiting the agent (:pending or :acknowledged), oldest first.

Store an annotation, filling in id and inserted_at when absent.

Bulk-insert annotations verbatim, used to hydrate the table from persistence.

Record that an agent just made an MCP request.

Apply a guarded lifecycle transition.

Merge changes (a map of struct fields) into an annotation.

Functions

add_human_reply(id, content)

@spec add_human_reply(String.t(), String.t()) ::
  {:ok, Annotai.Annotation.t()} | {:error, :not_found}

Append a human reply to an annotation's thread.

On an open (:pending/:acknowledged) annotation this just appends. On a terminal (:resolved/:dismissed) one it also reopens the thread — status back to :pending, resolved_at/resolved_by cleared — so the agent picks the work up again (a reply the agent never sees would be a dead end). The prior resolution summary stays in the thread as context for the next pass. This is the browser-facing counterpart to the agent's add_thread_message/3.

add_thread_message(id, role, content)

@spec add_thread_message(String.t(), String.t(), String.t()) ::
  {:ok, Annotai.Annotation.t()} | {:error, :not_found}

Append a message to an annotation's reply thread.

Returns {:ok, annotation} or {:error, :not_found}.

all()

@spec all() :: [Annotai.Annotation.t()]

All annotations, oldest first.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear()

@spec clear() :: :ok

Remove all annotations.

delete(id)

@spec delete(String.t()) :: :ok

Delete one annotation.

get(id)

@spec get(String.t()) :: Annotai.Annotation.t() | nil

Fetch one annotation by id, or nil.

get_image(annotation_id, image_id)

@spec get_image(String.t(), String.t()) :: map() | nil

Fetch a single attached image (with its base64 data) by annotation + image id, or nil.

last_agent()

@spec last_agent() :: integer() | nil

Epoch-ms of the last agent request, or nil if none yet.

pending()

@spec pending() :: [Annotai.Annotation.t()]

Annotations still awaiting the agent (:pending or :acknowledged), oldest first.

put(annotation)

Store an annotation, filling in id and inserted_at when absent.

restore(annotations)

@spec restore([Annotai.Annotation.t()]) :: :ok

Bulk-insert annotations verbatim, used to hydrate the table from persistence.

Unlike put/1, ids and timestamps are preserved as-is (never regenerated), and no change notification is emitted — hydration is loading what was already persisted, not a new mutation to snapshot back.

touch_agent()

@spec touch_agent() :: :ok

Record that an agent just made an MCP request.

transition(id, allowed_from, changes)

@spec transition(String.t(), [Annotai.Annotation.status()], map()) ::
  {:ok, Annotai.Annotation.t()}
  | {:error, :not_found}
  | {:error,
     {:invalid_transition, Annotai.Annotation.status(),
      [Annotai.Annotation.status()]}}

Apply a guarded lifecycle transition.

The annotation's current status must be one of allowed_from, otherwise it is left untouched — this is what makes acknowledge/resolve/dismiss idempotent. resolved_at is stamped automatically when the new status is terminal (:resolved/:dismissed).

update(id, changes)

@spec update(String.t(), map()) ::
  {:ok, Annotai.Annotation.t()} | {:error, :not_found}

Merge changes (a map of struct fields) into an annotation.

Returns {:ok, annotation} or {:error, :not_found}. Used for plain edits (e.g. the developer editing a comment); lifecycle changes go through transition/3.