Persistence
Everything the runtime needs to remember — agent definitions, sessions, messages, working memory, summaries, todos, LLM-call traces — is stored through a small set of repository ports. Two implementations ship: the default writes plain files to disk, the other keeps everything in Postgres. One setting switches between them; nothing else in the runtime, the tools or the agent definitions knows the difference.
Ports and adapters
The runtime starter (mc-agent-starter-runtime) builds every store of the runtime from the
Persistence a persistence starter declares — the starters themselves keep only what is
installation-wide: the users and their API tokens.
Each domain module also ships a repository factory for its ports —
AgentRepositoryFactory (agent runtime), MessageRepositoryFactory (messages),
LlmRepositoryFactory (LLM configs and their prices) — with one implementation per backend:
FileAgentRepositoryFactory, InMemoryAgentRepositoryFactory,
PgAgentRepositoryFactory, and so on. Whoever assembles a runtime picks the
factory once for the backend it wants; the repositories come from it.
The runtime follows a hexagonal (ports-and-adapters) design. Each persisted
concept is an interface in port/out, and the file-based default is the
matching adapter in adapter/file:
Port (port/out) | Stores | File adapter (adapter/file) | Postgres adapter (adapter/pg) |
|---|---|---|---|
AgentDefinitionRepository | Agent definitions | FileAgentDefinitionRepository | PgAgentDefinitionRepository |
AgentSessionRepository | Sessions (incl. parent/child links) | FileAgentSessionRepository | PgAgentSessionRepository |
MessageRepository | Conversation messages | FileMessageRepository | PgMessageRepository |
ConversationRepository | Conversation metadata | FileConversationRepository | PgConversationRepository |
WorkingMemoryRepository | Per-turn working-memory snapshots | FileWorkingMemoryRepository | PgWorkingMemoryRepository |
ConversationSummaryRepository | Compaction summaries | FileConversationSummaryRepository | PgConversationSummaryRepository |
TodoListRepository | Session todo lists | FileTodoListRepository | PgTodoListRepository |
LlmCallTraceRepository | LLM request/response traces | FileLlmCallTraceRepository | PgLlmCallTraceRepository |
LlmConfigRepository | LLM configs (credentials encrypted) | FileLlmConfigRepository | PgLlmConfigRepository |
LlmPriceRepository | LLM prices, one per config, model and period | FileLlmPriceRepository | PgLlmPriceRepository |
FileStore | Uploaded files (ports in mc-file-store-core) | FilesystemFileStore | PgFileStore |
WorkflowDataRepository | Workflow definitions (workflow area) | FileWorkflowDataRepository | PgWorkflowDataRepository |
WorkflowInstanceRepository | Suspended workflow runs | FileWorkflowInstanceRepository | PgWorkflowInstanceRepository |
A port is a plain interface — for example:
public interface AgentSessionRepository {
AgentSession save(AgentSession session);
Optional<AgentSession> findById(SessionId id);
List<AgentSession> findByAgent(AgentId agentDefinitionId, UserId userId);
// …
}
The default: file persistence
In the Spring apps the file adapters come from the mc-agent-starter-file
starter (agents/springstarter/), all rooted at one directory
(mindconnect.data.base-dir, default data/) and, below it, one directory per
namespace — so the data of a default install lives under data/local/, and a
namespace acme under data/acme/. Users, API tokens and the namespaces
themselves are installation-wide and live under data/system/. The LLM-config repository is
wrapped in an EncryptingLlmConfigRepository when the app has an
EncryptionHelper, and LLM call traces live under conversations/ with a
mindconnect.agent.trace.max-per-session retention cap (default 50).
Every adapter — file or Postgres — is bound to one namespace when it is built;
the starters hand out a routing proxy per port that keeps one adapter per
namespace and forwards each call to the one the current Scope names. The
scope is bound per request (from the /ns/{namespace}/ prefix, the
X-Mindconnect-Namespace header, or the namespace the user chose in the Admin
UI) and per queued task; nothing above the repositories names a namespace.
Start-up work — the runtime build, the tool warm-up, the seed loaders — binds
mindconnect.namespace (default local) explicitly; a thread that binds
nothing and touches a store fails instead of working there quietly. That is
also the namespace every
signed-in user may work in without an invitation.
This makes the runtime zero-dependency: it boots and persists with nothing but a writable folder — no database, no migrations, ideal for local development and single-node deployments.
Besides the file adapters there is a full in-memory family
(InMemoryAgentSessionRepository, InMemoryMessageRepository, …) used by the
Spring-free AgentRuntimeBuilder and in tests. Two of them are bounded so that a
long-lived embedded runtime does not grow with every turn: the trace store keeps
at most 50 LLM call traces per conversation (the file store's cap), and the
builder tells its task queue to forget finished task trees at the next
maintenance tick — taskRetention(Duration) keeps them longer (it reaches the
task-queue feature too, when that is installed), null for the
life of the process. Deleting a session takes its conversation, messages and
traces with it, in every persistence mode.
Postgres
Postgres persistence ships from version 0.3.0 on. Earlier releases have the file and in-memory adapters only.
Set one variable and every port above is served by its Postgres adapter:
MC_PERSISTENCE=postgres # default: file
MC_POSTGRES_URL=jdbc:postgresql://localhost:5432/mindconnect
MC_POSTGRES_USER=mindconnect
MC_POSTGRES_PASSWORD=…
The start.sh scripts read these from the git-ignored mc.env; as Spring
properties they are mindconnect.persistence and mindconnect.postgres.url /
username / password / pool-size. The Admin UI, the agent server and the
standalone workflow admin app all honour the same switch. Behind it stand two
Spring Boot starters in agents/springstarter/ — mc-agent-starter-file and
mc-agent-starter-postgres; an application has both on the classpath and the
property decides which one configures the repositories.
What happens on start:
- One pooled
DataSource(HikariCP) is opened and shared by every store. - Each store creates its tables if they are missing —
CREATE TABLE IF NOT EXISTS, plusADD COLUMN IF NOT EXISTSfor columns a later version declares. There is no migration tool and nothing to run by hand; a plain Postgres without extensions is enough. - The bundled initial data — agent definitions, LLM
configs, skills, example workflows — is seeded into the database exactly as
it would be seeded into
data/: into the start-up namespace at start, into every other namespace on its first use, and only what the namespace never had (mc_installed_seedremembers what it had). - Uploaded files go to the database as well (
mc_file, content asbytea) unlessmindconnect.file-store.backendnames another backend — keeping records in Postgres and files on a volume is a valid pairing. - The file adapters step back automatically.
mindconnect.data.base-dirstill exists for what has no database form: workflow scratch files, vector-store files of thememorybackend, code-execution mounts.
How the data is stored
Each domain object is one row: the whole object as a JSONB document, with
the few values a query filters, sorts or lists by as ordinary columns beside
it — the id, a namespace, a name, a sequence number, a timestamp. The document
is the truth and the columns are the index; both are written in the same
statement. The namespace column is part of every primary key and every query,
so two processes bound to different namespaces can share one database. A document in the database is the same JSON the file adapter would
have written, rendered by the application's ObjectMapper.
The tables are named mc_agent_definition, mc_agent_session,
mc_conversation, mc_message, mc_working_memory, mc_conversation_summary,
mc_todo_list, mc_llm_call_trace, mc_llm_config, mc_llm_price,
mc_workflow, mc_workflow_instance, mc_file, mc_vector_store_template,
mc_vector_store_instance and mc_vector_store_member, plus the embedding
index mc_embedding and mc_embedding_field when the database has pgvector. The small JDBC layer
underneath — Sql, Row, DocumentTable — lives in common/mc-jdbc; the
adapters are the agents/adapter/postgres/*-pg and
workflow/mc-workflow-persistence-pg modules, the Spring wiring is
mc-agent-starter-postgres.
Two lists are read without touching a document at all:
AgentSessionRepository.findHeadersByUser (the chat sidebar) and
LlmCallTraceRepository.findHeadersByConversation return AgentSessionHeader
and LlmCallTraceHeader — views the aggregates implement themselves — straight
from the columns.
What stays on disk
The vector stores keep nothing on disk in Postgres mode — as long as the
database has the pgvector extension. Their registry of templates and instances
is in mc_vector_store_template and mc_vector_store_instance (filled once
per namespace from vector-stores/templates|instances), and their vectors are
in one pgvector table per store, in the application's own database: pgvector
is the default backend there and needs no URL of its own. A plain
postgres:16-alpine has no pgvector; then the vectors stay on the memory
backend in <data-dir>/<namespace>/vector-stores/*.jsonl, with a warning at
start. Install pgvector into the image — for an existing Alpine data directory
an Alpine build, since a Debian image changes the text collation — and the
stores move into the database on first use — see
Vector stores.
Embedding without Spring
AgentRuntimeBuilder.usePostgres(dataSource, dataDir) wires the same adapters
for a plain-Java host; dataDir roots the file-based side channels as before.
Workflows and uploads follow when their modules are on the classpath.
Moving an installation
There is no automatic migration from data/ to Postgres. A fresh Postgres
start seeds the bundled defaults; your own agents, sessions and conversations
would need to be re-created or copied by a script through the two adapter
families — both speak the same JSON, so that script is a read-and-save loop.
Workflows are the exception: the first time a namespace is used on Postgres,
the definitions and suspended runs the file stores kept under
<data dir>/<namespace>/workflows are imported, when mc_workflow and
mc_workflow_instance have nothing for the namespace yet. Once — a row in
mc_workflow_import records it, so what is deleted later stays deleted — and
the files are left where they are.
Testing against Postgres
The adapter tests run against a real Postgres and skip themselves when none
answers on localhost:5433:
podman run -d -p 5433:5432 -e POSTGRES_PASSWORD=test pgvector/pgvector:pg17-trixie
Override with MC_JDBC_TEST_URL, MC_JDBC_TEST_USER, MC_JDBC_TEST_PASSWORD.
Other databases
Every storage concept is a port, so a third backend means implementing those
interfaces — the Postgres adapters are the template: a few dozen lines each
on top of mc-jdbc, with the same @ConditionalOnProperty switch. Because
each port is independent you can also migrate incrementally, one repository
at a time.