Files
openhuman/docs/plans/pluggable-core/README.md
T

238 lines
20 KiB
Markdown

# Pluggable Core — `openhuman_core` as an Embeddable Library
**Status:** In progress — `CoreBuilder`, `CoreRuntime`, `CoreContext`, the
first per-context store plumbing, and the `openhuman-fleet` MVP are implemented
in this branch. Remaining production work is tracked in the phase docs.
**Goal:** make the Rust core pluggable into arbitrary hosts — the Tauri shell
(today), a plain CLI, a stdio MCP server, cloud/team servers managing many
users, and other programs consuming it as a library — via a first-class
`CoreBuilder``CoreRuntime` API instead of the current "library entry is a
CLI arg vector" surface.
**Why:** teams and programmatic use both need the same thing: the ability to
compose the core's pieces (dispatcher, stores, background services,
transports) differently per host. Today that composition is fixed inside one
700-line function. The client side already solved this problem with the
`CoreTransport` strategy layer (`app/src/services/transport/`); the core side
has no equivalent seam.
**Anchor precedents:** [`docs/tinyagents-port-plan.md`](../../tinyagents-port-plan.md)
and [`docs/tinycortex-migration-spec.md`](../../tinycortex-migration-spec.md) —
the established "seam + staged migration + drift ledger" doctrine. This plan
restructures the _host_, not the engines: `tinyagents` / `tinycortex` seams
are untouched.
---
## 1. Where we are
### 1.1 What is already right
The hard parts of pluggability are, surprisingly, already done:
| Asset | Where | Why it matters |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| Transport-agnostic dispatch | `ControllerSchema` + `invoke_method` (`src/core/jsonrpc.rs:230`) shared by HTTP `/rpc`, CLI `call`, generic namespace dispatch, and MCP stdio | The _contract_ is host-neutral; only the bootstrap is not |
| Pluggable client | `app/src/services/transport/``CoreTransport` interface, `LocalTransport` / `LanHttpTransport` / `TunnelTransport` / `CloudHttpTransport`, `ConnectionProfile`, `TransportManager` | Any backend that answers `POST /rpc` JSON-RPC with a Bearer token is already reachable from every client, including cloud |
| Agent loop as a crate | `tinyagents` (vendored, `vendor/tinyagents`) via the seam `src/openhuman/tinyagents/` (`run_turn_via_tinyagents_shared`) | Programmatic harness use does not require extracting the loop — it's extracted |
| Memory engine as a crate | `tinycortex` via `src/openhuman/tinycortex/` | Same |
| Headless server mode | `openhuman-core run/serve` (`src/core/cli.rs:66`), `--jsonrpc-only`, Bearer token via `OPENHUMAN_CORE_TOKEN` (`src/core/auth.rs:160`) | Cloud deployment of a _single_ core already works |
| Host discrimination | `HostKind { TauriShell, Cli, Docker }` (`src/core/types.rs:167`) threaded into `bootstrap_core_runtime` | The natural seam for the refactor already exists |
| Tools as trait objects | `Box<dyn Tool>` on `Agent` (`src/openhuman/agent/harness/session/types.rs:31`) | No handler-style fn-pointer problem in the tool layer |
### 1.2 The coupling points (what blocks embedding)
| # | Concern | Location | Nature |
| --- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Bootstrap fused to transport | `run_server_inner` (`src/core/jsonrpc.rs:1738`) + `bootstrap_core_runtime` (`:2428`) | One function: controller registration, master key, token seeding, `Config::load_or_init`, ~10 global store inits (`:1807-1892`), port bind, env mutation, router, ready signal, inline `tokio::spawn` of every background service. You cannot get "just the dispatcher" |
| 2 | Handlers are bare `fn` pointers | `ControllerHandler` (`src/core/all.rs:21`) | No captured state possible; every handler reads process globals |
| 3 | Three hand-maintained registries | `src/core/all.rs` — handlers (`:105-344`), schemas (`:375-509`), namespace descriptions (`:530-690`) | Parallel lists in `OnceLock` statics, panic-on-drift validation |
| 4 | Process singletons | `RPC_TOKEN` (`src/core/auth.rs:75`), `GLOBAL_BUS` (`src/core/event_bus/bus.rs:20`), `NativeRegistry` (`src/core/event_bus/native_request.rs:329`), every `*::global::init(workspace_dir)` store | One of each per process |
| 5 | Single active user per process | `~/.openhuman/active_user.toml` (`src/openhuman/config/schema/load_user_state.rs:21`), resolution chain in `dirs.rs:299` | One workspace resolved once; all global stores bound to it |
| 6 | Background services spawned inline | cron (`jsonrpc.rs:~2124`), channels (`:~2162`), heartbeat/subconscious (`:~2083`), update scheduler | Flags exist (`config.cron.enabled`, `OPENHUMAN_DISABLE_CHANNEL_LISTENERS`, `--jsonrpc-only`) but the caller cannot compose a service set |
| 7 | Runtime env mutation | `set_var OPENHUMAN_CORE_RPC_URL` (`jsonrpc.rs:2010`) | Process-global side effect; needed by spawned child tools |
| 8 | `Once`-guarded subscribers | `register_domain_subscribers` (`jsonrpc.rs:2232`, `std::sync::Once`) | Second context in one process cannot re-register |
| 9 | Dead dispatch tier | `src/rpc/dispatch.rs:10` always returns `None` | Removable noise |
### 1.3 Teams today
`src/openhuman/team/` is a **pure thin proxy** to `tinyhumansai/backend`
(session JWT via `crate::api::jwt`, URL via
`effective_backend_api_url`, `src/api/config.rs:154`). All membership, roles,
invites, and authorization are enforced server-side. That is the right
division and this plan keeps it: **we make the core _hostable by_ a team
server; we do not reimplement team logic locally.**
---
## 2. Target architecture
Bootstrap splits into three layers that are fused today:
```text
CoreBuilder::build() ── layer 1: context init (pure — no
└─ CoreContext sockets, no spawns): config,
stores · event bus · registries workspace, master key, stores,
security policy · approval gate subscribers, policy
CoreRuntime::serve() ── layer 2: services (each opt-in):
├─ rpc_http (axum /rpc + Bearer) selected by ServiceSet
├─ socketio
├─ cron · channels · heartbeat · update
runtime.invoke(method, params) ── layer 3: transports are thin
CLI `call` · MCP stdio · HTTP handler adapters over the same dispatch
```
### 2.1 Public API (sketch)
```rust
pub struct ServiceSet {
pub rpc_http: bool,
pub socketio: bool,
pub cron: bool,
pub channels: bool,
pub heartbeat: bool,
pub update_scheduler: bool,
}
impl ServiceSet {
pub fn desktop() -> Self; // everything on (Tauri today)
pub fn headless_api() -> Self; // rpc_http only (cloud single-core)
pub fn none() -> Self; // library / harness-only
}
pub struct CoreBuilder { /* config, host_kind, token, bind, services */ }
impl CoreBuilder {
pub fn new(host_kind: HostKind) -> Self;
pub fn token(self, t: TokenSource) -> Self; // Fixed | EnvOrFile
pub fn services(self, set: ServiceSet) -> Self;
pub async fn build(self) -> anyhow::Result<CoreRuntime>; // init only, no spawns
}
pub struct CoreRuntime { /* Arc<CoreContext>, CancellationToken, bound_addr */ }
impl CoreRuntime {
pub async fn serve(&self, ready: Option<oneshot::Sender<EmbeddedReadySignal>>, shutdown: Option<CancellationToken>)
-> anyhow::Result<()>; // spawn selected services
pub async fn invoke(&self, method: &str, params: Map<String, Value>)
-> Result<Value, RpcError>; // same path as /rpc
pub fn events(&self) -> broadcast::Receiver<CoreEvent>;
pub fn agent_runtime(&self) -> AgentRuntime; // harness-only slice
pub async fn shutdown(self) -> anyhow::Result<()>;
}
```
`src/lib.rs` re-exports `CoreBuilder`, `CoreRuntime`, `ServiceSet`,
`HostKind`, `AgentRuntime` as the documented public surface;
`run_core_from_args` remains for the binary.
### 2.2 Existing entry points become thin consumers
| Entry | Today | After |
| --------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `run_server` (`jsonrpc.rs:1682`) | wraps `run_server_inner` | shim over `CoreBuilder` (deprecated, kept one release) |
| `run_server_embedded_with_ready` (`:1717`) | same | Tauri `core_process.rs` calls `CoreBuilder` directly: `TokenSource::Fixed(in-memory)`, `ServiceSet::desktop()`; readiness is passed to `CoreRuntime::serve` |
| CLI `call` / namespace dispatch (`src/core/cli.rs`) | `invoke_method(default_state(), …)` | `ServiceSet::none()` build → `runtime.invoke()` — no port bound for one-shot calls |
| MCP stdio (`src/core/cli.rs` `mcp`) | same funnel | transport adapter over `runtime.invoke()` |
| Cloud/team host | n/a | fleet supervisor composing one core per user (phase 4) |
### 2.3 Multi-tenancy: fleet of processes first
**Decision: one supervised core process per user/workspace first — not
in-process multi-tenancy.** Rationale:
- It is the shape the architecture already has (Tauri = one embedded core for
one user); a supervisor reuses it with zero correctness risk.
- The blockers to in-process tenancy are real and slow: env-var mutation for
child tools (#7), keyring/master-key process scope, `Once`-guarded
subscribers (#8), Sentry. Separate processes improve blast-radius and
lifecycle control, but production multi-tenant security still requires
distinct OS users or containers because agents run arbitrary tools.
- The wire contract is unchanged, so `CloudHttpTransport` works as-is against
a per-user base URL.
In-process multi-workspace (a `CoreContext` per tenant) is deferred behind
phase 3 and is an optimization, not a prerequisite, for the team story.
### 2.4 Globals strategy: staged, bounded
Full DI through ~110 handler registration sites in one PR is unreviewable.
Three stages (detail in [phase-2](phase-2-corecontext.md) /
[phase-3](phase-3-multi-context.md)):
- **Stage A (facade):** `CoreContext` _owns initialization order_ and hands
out handles; existing `*::global::init` calls move inside it; handlers keep
reading globals. Zero behavior change.
- **Stage B (mechanical):** registered RPC dispatch installs an ambient
task-local `CoreContext` while handlers remain plain `fn` pointers. Domains
then migrate off globals opportunistically by reading `CoreContext::current()`,
tracked in a drift ledger.
- **Stage C (bounded):** only what multi-context isolation actually needs.
**Exit criterion: two `CoreContext`s in one test process serve
memory/people/config reads without cross-talk — not "zero `OnceLock`s".**
Keyring, Sentry, `NativeRegistry`, and env vars stay process-scoped and are
documented as such.
### 2.5 Storage abstraction
Backend-level pluggability also requires the _stores_ to be swappable, not
just the transports. Today every store is constructed from a workspace
directory (`*::global::init(workspace_dir)`) and is concretely
SQLite/JSON-on-disk under `~/.openhuman/users/<id>/workspace`. That is
invisible in the desktop app but load-bearing for other backends:
- **Fleet hosting (phase 4)** works _without_ abstraction — each per-user core
process gets its own workspace volume. This is why storage abstraction is
not on the phase-4 critical path.
- **Managed/cloud storage** (Postgres, object store for attachments,
shared-nothing replicas) and **in-process multi-tenancy** both require
stores behind traits.
The seam is the `CoreContext` handle layer introduced in Stage A/B: handlers
stop touching `*::global()` and go through `ctx.<store>()`. Those accessors
return **trait objects** (`Arc<dyn MemoryStore>`, `Arc<dyn PeopleStore>`, …)
rather than concrete types, and `CoreBuilder` gains a `StorageBackend`
selector whose only shipped implementation is `WorkspaceFs` (the current
behavior, byte-for-byte). Store traits are carved per domain _as that domain
migrates onto the context_ (phase 2 drift ledger gets a "trait extracted?"
column) — not as one big up-front storage rewrite. Note the engines already
model this: `tinyagents` has `StoreRegistry`/checkpointer abstractions and
`tinycortex` owns its own store interfaces; the traits here cover the
_host-owned_ stores (people, attachments, cost/x402 ledgers, config state,
run ledgers) that sit outside those crates.
Remote implementations (e.g. `Postgres`) are explicitly out of scope for this
plan; the deliverable is that adding one is a new `StorageBackend` impl, not
a refactor.
---
## 3. Non-goals
- **Wire contract**: `POST /rpc` JSON-RPC, `openhuman.<ns>_<fn>` naming,
Bearer auth — unchanged. Client transports untouched.
- **tinyagents / tinycortex seams**: unchanged; this plan restructures the
host around them.
- **Security policy semantics**: `security::live_policy`, approval gate,
autonomy tiers — unchanged (only _who installs them_ moves into
`CoreBuilder::build`).
- **Local team logic**: membership truth stays in `tinyhumansai/backend`.
- **Not attempted**: replacing axum, changing controller schema format,
in-process tenant _security_ isolation, `inventory`/linkme distributed
registration (explicit registration lists fit this repo's ledger culture).
## 4. Phases
| Phase | File | Deliverable | Depends on |
| ----- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| 0 | [phase-0-defusion.md](phase-0-defusion.md) | Delete dead tier-3 shim; extract inline service spawns + store-init block into named fns. Pure motion | — |
| 1 | [phase-1-corebuilder.md](phase-1-corebuilder.md) | `CoreBuilder`/`CoreRuntime`/`ServiceSet`; `run_server*` become shims; Tauri + CLI + MCP ported; embed examples | 0 |
| 2 | [phase-2-corecontext.md](phase-2-corecontext.md) | `CoreContext` Stage A + B; registry collapse to per-domain `DomainRegistration`; store traits + `StorageBackend::WorkspaceFs`; drift ledger | 1 |
| 3 | [phase-3-multi-context.md](phase-3-multi-context.md) | Bounded Stage C; two-context isolation test; process-scoped inventory | 2 |
| 4 | [phase-4-fleet-host.md](phase-4-fleet-host.md) | `openhuman-fleet` supervisor: per-user cores, token minting, `/:user/rpc` proxy, backend membership sync | 1 (not 2/3) |
**Value ordering:** phases 0+1 alone deliver the headline goal — embeddable
builder, programmatic harness, thin CLI/Tauri. Phase 4 can start immediately
after phase 1, in parallel with 2/3, because the fleet model needs
process-per-user, not context threading.