How one instance lays out its data root: the embedded runtime's own root, the agent sandboxes, how the root is chosen, and what a legacy doubled install looks like.
Split out of storage.md, which was over the repository's 500-line
ceiling. That file covers backend selection and the shipped backends; this one
covers the layout inside the root.
OPENCOMPANY_DATA_DIR (default $HOME/.opencompany; /data in a hosted tenant
container) is the per-instance workspace root — everything one running
instance owns. DataLayout names the canonical
subdirectories under it so stores, agents, and tools resolve well-known
locations instead of ad-hoc paths:
<OPENCOMPANY_DATA_DIR>/
companies/ ← per-company bundles (companies/<slug>/, owned by the fs store)
memory/ ← instance-shared memory artifacts
store/ ← instance-shared durable-store artifacts
files/ ← instance-shared files (exports, attachments)
logs/ ← instance logs
tmp/ ← ephemeral scratch, cleared on startup by default
openhuman/ ← the embedded OpenHuman runtime's own root (see below)
Two runtime trees are not DataLayout directories and are absent from this
tree: the agent + workflow sandboxes (<home>/harness, see below) and the MCP
registry (<home>/mcp). They hang off the resolved home, which coincides with
this root whenever <home> and OPENCOMPANY_DATA_DIR agree — as they do by
default and in the hosted shape — and splits from it when --home diverges
(see “--home moves the bundles and the runtime trees” below).
Per-company state (each bundle's own memory//context/) lives under
companies/<slug>/; the top-level memory//store//files/ are the shared,
instance-level locations. serve calls DataLayout::ensure at boot: it creates
the shared subdirectories and — unless [workspace].clear_tmp_on_startup is
false — empties tmp/ so no stale scratch survives a restart. Because the hosting model runs one container per tenant with its
own OPENCOMPANY_DATA_DIR, this root is the per-tenant workspace — no separate
per-tenant path prefix is needed.
The vendored OpenHuman runtime resolves its own workspace, and its default is a
subdirectory of the user's home directory — which in a tenant container is
the read-only root filesystem. Its durable agent journal lives at
<root>/workspace/tinyagents_store/, where <root> is the value handed to it
as OPENHUMAN_WORKSPACE and the vendored resolver nests a workspace/ level
under that root. It therefore failed to create its store root on
every append, and the vendored append worker reported that to stderr once per
event with no dedup or backoff, burying every other line in the container log
(issue #446).
serve closes that gap before any agent exists.
app::journal::prepare resolves the root, proves
it is writable, and exports it as OPENHUMAN_WORKSPACE — the one seam the
vendored config loader consults ahead of its home-directory default:
<OPENCOMPANY_DATA_DIR>/openhuman/ ← exported as OPENHUMAN_WORKSPACE
<OPENCOMPANY_DATA_DIR>/openhuman/workspace/tinyagents_store/ ← the journal
| Precedence | Source | Root |
|---|---|---|
| 1 | OPENHUMAN_WORKSPACE (non-blank) |
its value verbatim — a self-hoster keeps an existing workspace |
| 2 | OPENCOMPANY_DATA_DIR |
<data-dir>/openhuman, exported so the vendored loader finds it |
serve prints the resolved store path at startup (agent journal: … (root …, from …)) so this class of problem is one line to diagnose rather than a code
trace. Only the path is printed; no other environment value is read or echoed.
An unwritable root aborts boot — the same precedent as a
selected-but-unavailable storage backend. A tenant that answers but records
nothing produces confident, unrecoverable work and reports the loss only at
debug level; a tenant that refuses to start is one loud, attributable line.
Because the check runs after DataLayout::ensure has already created the shared
subdirectories, it can only fail on a genuinely broken mount or an explicitly
misconfigured OPENHUMAN_WORKSPACE, so it cannot turn a healthy tenant into one
that will not wake.
Boot proving the root writable does not make the sink infallible — a volume can
fill, go read-only or disappear under a running tenant. The vendored append
worker no longer prints its one-line-per-event flood described above; since
tinyagents 73e6f5d it keeps a failure-run state machine and reports through
tracing on the target tinyagents::observability. Only the first failure of
a run is error. The three lines that say how bad it got are warn:
| Line | Level | Says |
|---|---|---|
durable append failed; the observation is lost… |
error |
a failure run started |
…still failing after N consecutive observations |
warn |
it is still going (rate-limited reminder) |
durable append recovered; N observation(s) lost |
warn |
it ended, and the cost |
…never recovered before shutdown, N observation(s) lost |
warn |
it outlived the process |
The binary's default EnvFilter is error and nothing in the container images,
compose files or deploy workflows sets RUST_LOG, so those three warn lines
were dropped in every deployed tenant: an operator saw a degraded run begin and
never learned that it continued, that it ended, or how many observations went
missing. DEFAULT_LOG_FILTER in src/bin/opencompany.rs is therefore
error,tinyagents::observability=warn — today's default plus that one target.
RUST_LOG, when set, still replaces the whole thing — and is parsed lossily,
carrying the same error default the binary had before this constant existed, so
one unparseable directive drops itself rather than the operator's whole
configuration, and an empty value still reports errors rather than silencing the
binary outright. The desktop shell
(crates/opencompany-app/src/lib.rs) names the same target for a sharper reason: its fallback
has no global directive at all, so an unnamed target is dropped at every level
including error.
Known limitation. The worker's own subscriber-independent fallback,
AppendWorker::append_failures() — a counter of appends attempted and rejected,
documented there as "the only subscriber-free signal of durable-log loss" — is
pub(crate) in tinyagents. OpenCompany cannot read it, and there is no other
seam that exposes it: the worker is constructed inside OpenHuman's agent journal,
which hands out no handle to it. So the log filter is the whole mechanism here,
and a tenant whose operator overrides RUST_LOG with something that excludes
tinyagents::observability has no second channel. Surfacing the count — as a
health field, a metric, or simply a pub accessor — needs an upstream tinyagents
change first; do not try to plumb it from this repo.
The tree an agent's file tools actually act in hangs off the home, not off a
bundle, and is deliberately not pre-created by DataLayout::ensure — the
same rule companies/ follows: whoever owns a subtree mints it on demand.
<home>/harness/<company>/
<agent>/workspace/ ← one agent's sandbox
<agent>/skill-catalog/ ← its materialized skill bundles
_workflow/<workflow>/<run>/workspace/ ← one workflow run's sandbox
<company> and <agent> are the ids under the workspace naming rule —
lowercase and dashed, so a roster id page_builder lands at page-builder/
(workspace-names.md). A sandbox left at the pre-rule
path is moved onto the canonical one, once, by ensure_agent_workspace: unlike
the note tree, this directory is private scratch that nothing outside the
process addresses, so an in-place move is invisible — and losing an agent's
half-finished work to a silent path change would not be.
An agent's sandbox is named by harness::build::agent_workspace and created by
harness::build::ensure_agent_workspace; a workflow run's is named and created
by workflows::caps. It must exist before the agent acts, not merely by the
time it finishes. OpenHuman's validate_parent_path resolves a relative write
against the sandbox, then walks up to the deepest existing ancestor to
canonicalize it; with the sandbox absent that walk climbs past it and lands
outside, so the write is refused as Resolved parent path escapes workspace for
a path that is plainly inside. The runtime writes its own bookkeeping there
(session transcripts, the TinyAgents journal), creating the directory as a side
effect — but only at the end of a turn, so it never helps the turn that needed
it. An agent holding shell never saw this: its first command creates the
directory before anything validates a path (issue #409).
Hence two creation points, not one. build_agent mints the sandbox before any
SecurityPolicy is constructed over it; the dispatch path re-ensures it every
turn. The second is not redundant — a roster is built once, cached behind
fingerprints and handed across an in-place rebuild, so a sandbox removed under a
live host (a restored data dir, a boot that raced an unmounted volume) would
otherwise stay missing for the life of the process.
OPENCOMPANY_DATA_DIR is the only environment knob that places an instance.
opencompany serve (and export / import) resolve the root every company
bundle hangs off through store::resolve_home, in this order:
| Precedence | Source | Resolves to |
|---|---|---|
| 1 | --home <DIR> |
<DIR> verbatim — an explicit flag is never overridden by the environment |
| 2 | OPENCOMPANY_DATA_DIR |
its value verbatim, so bundles land at <root>/companies/<slug> — exactly the layout above |
| 3 | neither | $HOME/.opencompany (a relative .opencompany when $HOME is unset) |
An empty OPENCOMPANY_DATA_DIR counts as unset — it would otherwise root the
instance at the process working directory.
All three branches resolve the home to the workspace root, so Bundle's own
companies/ segment puts bundles at <root>/companies/<slug> in every case —
exactly the layout above, and exactly DataLayout::companies_dir().
One consequence worth knowing:
--homemoves the bundles and the runtime trees, but not the shared workspace. It places company bundles (<home>/companies/) and — since the harness and MCP trees hang off the same resolved home — the agent sandboxes and MCP registry (<home>/harness,<home>/mcp);memory/,store/,files/,logs/andtmp/always followOPENCOMPANY_DATA_DIR. So two hosts isolated by--homealone still share one workspace.serveprints an operator-visible warning naming both roots whenever they are not aligned. PreferOPENCOMPANY_DATA_DIR, which moves the whole instance. A hosted tenant sets both to the same value (deploy/entrypoint.shpasses--home "$OPENCOMPANY_DATA_DIR"), so it never warns — nor does the local default, whose home and data root are now the same path. Passing--home ~/.opencompany/companiesby hand recreates the legacy doubled shape below and does warn, correctly.
The default home used to append a companies leaf of its own, so a default local
install's bundles were nested one level too deep at
~/.opencompany/companies/companies/<slug> while DataLayout materialized
~/.opencompany/{memory,store,files,logs,tmp} beside the first companies/. A
local sqlite database was orphaned the same way, at
~/.opencompany/companies/opencompany.db, because serve hands the resolved
home to open_storage. So were the two runtime trees that hang off the home
rather than off a bundle: the harness agent workspaces (<home>/harness) and the
MCP runtime registry (<home>/mcp, whose persisted installs and stored
environment values are reconnected on boot).
Dropping the leaf without moving that data would leave every existing local
company invisible, so serve, export, and import all run
store::migrate::migrate_legacy_nest against the resolved home before reading
anything:
- No
<home>/companies/companiesdirectory is a no-op. A hosted tenant takes this branch on every boot: twostats that find nothing. - A nest that is bundle-shaped — holding any of the top-level files
(
company.toml,meta.json,events.jsonl,ledger.jsonl,tasks.json, …) or subdirectories (keys/,secrets/,memory/,context/, …) that only a company owns — is a real bundle sluggedcompaniesand is left exactly as it is. A manifest is deliberately not the test:Bundle::ensure_dirscreates a bundle with neither marker at ~20 call sites, and underOPENCOMPANY_STORAGE=sqlite|mongodbthe manifest never reaches the filesystem at all while the keys, secrets and task board still do — so a marker test would have dissolved exactly the installs that have no manifest to find. - Only entries that are themselves bundle-shaped directories are relocated, the same test one level down. Anything else stays where it is, silently: the legacy nest holds nothing but bundles, so an entry that does not look like one is not something the migration knows where to put.
- Any
opencompany.db(with its-wal/-shmsiblings, as a set) moves from<home>/companies/to<home>/. Only regular files count as the database: a company sluggedopencompany.dbowns the directory at that exact path, and relocating it would delete the company. <home>/companies/{harness,mcp}move up to<home>/{harness,mcp}under the same shape guard — a company really can be sluggedharness, and its canonical bundle sits at exactly the path the legacy tree occupied.- An occupied destination is skipped, never merged: two copies of one company hold two event logs and two signing keys, which cannot be interleaved. Both copies stay put and a warning names both paths.
- Files move by
link+unlink, never byrename. A rename replaces a regular file silently, and a "is the destination free?" check taken beforehand is stale the instant it is read — aservethat has already migrated is writing a live-walat that path, and a rename over it drops every committed transaction the log still holds. A hard link fails when the destination exists, so the check and the move are one indivisible step. A crash between the link and the unlink leaves one file reachable under both names, which the next run recognises by device and inode and finishes rather than reporting as two databases. Directories keeprename, which cannot replace a populated directory (ENOTEMPTY) or a regular file (ENOTDIR) at all. - The nest directory is removed only once emptied, so a crash mid-migration resumes on the next boot. Re-running a migrated install is silent.
- The database set resumes the same way. It is detected from any surviving
member, not from
opencompany.dbalone, so a run that moved the database and then died is finished by the next boot rather than being read as complete — which would have paired a relocated database with a stranded write-ahead log and lost whatever that log still held. - A source another process moved first is a success, not a failure. Running
opencompany exportagainst a home aserveprocess is booting is ordinary and both migrate; the loser of that race must not abort on aNotFoundthat means "already done". Note that this is race tolerance, not a concurrency guarantee: two processes sharing one home is unsupported for the same reason the runtime journal is single-writer, and this migration does not change that. What the no-replace moves above do guarantee is that losing such a race can never cost data, whatever the interleaving.
An install whose migration genuinely cannot complete — EXDEV because
companies/ is a mount point, a root-owned or read-only nest — still boots:
--home ~/.opencompany/companies resolves every bundle exactly where it already
sits and finds no nest beneath it to migrate. That shape warns about the split
workspace, correctly, and is the supported way to run an install this migration
cannot move.
Moves are printed on stderr rather than logged through warn!, which the default
EnvFilter drops unless RUST_LOG is set.
OPENCOMPANY_HOME is not a synonym and is not supported. It was never
wired to anything, so setting it used to be ignored silently. The resolver now
reads it solely to reject it: serve, export, and import abort with an error
naming OPENCOMPANY_DATA_DIR instead. The rejection is checked before --home,
so passing the flag does not suppress it — a stale entrypoint that still exports
the variable fails loudly rather than half-placing a store.
A data root has exactly one writer, enforced by an advisory lock — a second
serve over the same root is refused at boot. Give each host its own root; see
data-root.md for the recipe and the rules.
The [workspace] section of config.toml (in the data dir) tunes the lifecycle:
[workspace]
git_enabled = false # opt in to automatic Git checkpoints per agent workspace
clear_tmp_on_startup = true # default; set false to preserve tmp/ across restarts
storage_quota_gb = 5 # soft whole-workspace quota; omit or <= 0 = unlimited
tmp_quota_gb = 1 # soft tmp/ quota; omit or <= 0 = unlimited
tree_quota_gb = 2 # HARD cap on the note tree's binary payloads (#553)
max_blob_mb = 64 # HARD cap on ONE binary write (default 64)When git_enabled = true, every private agent filesystem workspace under
harness/<company>/<agent>/workspace is initialized as a Git working tree.
OpenCompany creates a baseline commit and then commits changed files after each
tool call, including shell commands, so redirects and generated files are not
missed. Calls that leave the tree unchanged add no commit. Git history lives in
the sibling workspace.git/ directory; the working tree contains only Git's
small .git pointer file, which keeps ordinary Git commands usable from inside
the workspace. The pointer file is write-only scaffolding: a .git an agent
plants is ignored for the checkpointer's own commands (which pass an explicit
--git-dir) and rewritten to name the real repository, and checkpoint Git
invocations are isolated from inherited config and hooks. Checkpoint failures
are warned about but never replace a tool's successful result. The setting
defaults to false, preserving existing workspaces unless an operator
explicitly opts in. See sandbox.md for
the security rationale.
Checkpoint history retains everything a workspace ever contained. Because the
checkpoint repository records the workspace tree state at each tool call, a file
an agent writes and later deletes survives in workspace.git/ history long after
it leaves the working tree. Content an agent downloads, generates, or is handed
by the operator can therefore accumulate there with no size bound — there are no
ignore rules and [workspace] quotas do not apply to Git objects. Treat the
feature as suitable for workspaces whose contents are not secrets, or purge
history deliberately on the same schedule such data would otherwise be rotated.
The supported purge path is to drop the checkpoint history from the host,
because every checkpoint is committed to the checkpoints branch and remains
reachable from it even after data leaves the working tree — reflog expiry and
gc alone do not remove it. The clean purge is to delete the whole
<workspace>.git/ directory, after which the next tool call re-initializes the
checkpointer from a fresh baseline. To keep a workspace but drop its prior
history without fully resetting, delete the branch ref first so its commits
become unreachable, then garbage-collect them: git --git-dir=<workspace>.git update-ref -d refs/heads/checkpoints followed by git --git-dir=<workspace>.git gc --prune=now. Either way, <workspace>.git must be removed or the checkpoints ref must be deleted before gc --prune=now will actually free the blobs. Deleting
<workspace>.git/ resets a workspace to its next checkpoint baseline. No
retention is automatic: the checkpointer never rewrites history and leaves the
repository alone between checkpoints.
The first two quotas are soft/advisory in the binary. At boot serve
measures the workspace (and tmp/) and emits an operator-visible
tracing::warn when either exceeds its configured quota. Hard enforcement
of a whole data directory is the container/StorageClass layer's job (an EFS
access point cap or a k8s ResourceQuota), which is where the deploy manifests
wire it; the binary surfaces the condition rather than intercepting every write.
tree_quota_gb and max_blob_mb are hard, and enforced at the store. They
can be, because since #553 the runtime knows the size of every payload it is
asked to keep: QuotaEnforcedWorkspace wraps the workspace store at the single
assembly site and refuses an over-limit binary write before anything is
stored, so a refusal leaves no partial blob, no node and no orphan. Only
payloads are counted — prose notes are not, because the threat model is media.
Refusals answer 413 and name what was attempted alongside what is allowed; a
write is never truncated to fit, because a truncated binary is a corrupt binary
carrying a digest computed over bytes nobody has.
max_blob_mb is the per-write cap and matters most on MongoDB. Before
GridFS the 16 MB BSON document limit was an accidental brake on how much an
agent could write; GridFS removes it by chunking, so the cap is what replaces
it deliberately. It defaults to 64 MiB — an order of magnitude above a
generated image or document, and above a short generated video, so no
deliverable this feature exists to make durable is near it.
The console's upload route holds a separate DefaultBodyLimit of 256 MiB
(4× the default cap), and the gap is deliberate (#647). One shared number made
the store's refusal unreachable there: the body limit fired mid-parse, a body
that stops mid-part is indistinguishable from a malformed one, so an oversized
upload answered 400 malformed multipart — a correct request called broken —
and max_blob_mb above 64 bought only a different way to fail. With headroom
the store speaks first. The route limit is a backstop against buffering an
unbounded body; when it does fire the handler classifies axum's parse error via
MultipartError::status() and answers 413 naming the 256 MiB ceiling but never
a size, since a truncated body has no knowable total. Both share the
workspace_quota_exceeded code, so callers need not know which limit noticed.
One decorator covers every writer: the console's REST surface, the agent
workspace tools and the publish drain all hold the same ops.workspace
handle, so there is one check rather than three that could drift.
There is deliberately no per-company running total by default.
tree_quota_gb is unset unless an operator sets it, so out of the box the
exposure is N writes × max_blob_mb, bounded per write and not in aggregate.
That is a considered trade — per-company accounting, eviction and an admin
policy surface are a larger feature than this one — and it is stated here
rather than left to be discovered.
Large-file S3 offload remains a follow-up (needs an S3 client + credentials).