diff --git a/.github/scripts/assert-openhuman-pin.sh b/.github/scripts/assert-openhuman-pin.sh new file mode 100755 index 00000000..75f62c9b --- /dev/null +++ b/.github/scripts/assert-openhuman-pin.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Assert the `rev` of the git `openhuman-embed` dependency in Cargo.toml equals +# the commit recorded for the `vendor/openhuman` submodule. The `[patch]` in +# Cargo.toml builds this workspace against the submodule; a consumer without +# that patch builds against the rev. If they drift, the two build different +# OpenHuman trees and nothing else would say so. +set -eu +cd "$(dirname "$0")/../.." +rev=$(sed -n 's/^openhuman-embed = { git = "https:\/\/github.com\/tinyhumansai\/openhuman", rev = "\([0-9a-f]*\)".*/\1/p' Cargo.toml) +sub=$(git ls-tree HEAD vendor/openhuman | awk '{print $3}') +if [ -z "$rev" ] || [ -z "$sub" ]; then + echo "assert-openhuman-pin: could not read the rev ($rev) or the submodule pointer ($sub)" >&2 + exit 1 +fi +if [ "$rev" != "$sub" ]; then + echo "assert-openhuman-pin: Cargo.toml pins openhuman-embed at $rev but vendor/openhuman records $sub" >&2 + exit 1 +fi +echo "assert-openhuman-pin: $rev" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5fc91f2..cad82f8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,13 @@ jobs: - name: Assert the pure crates stay pure run: .github/scripts/assert-pure.sh + # `openhuman-embed` is a git dependency pinned by rev, redirected by + # `[patch]` onto `vendor/openhuman` for this workspace. A host without + # that patch builds the rev; this build builds the submodule. They must + # be the same commit or the two are silently different trees. + - name: Assert the openhuman-embed rev matches the vendored submodule + run: .github/scripts/assert-openhuman-pin.sh + - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 coverage.json diff --git a/Cargo.toml b/Cargo.toml index bc4b3ee9..aa0fd9c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,15 @@ tinyhivemind-typesafe = { path = "crates/tinyhivemind-typesafe" } # Canonical OpenHuman agent handles for the first-class embedding adapter. The # adapter keeps default features off because it only binds already-built agents; # the consuming host chooses the runtime capabilities it enables. -openhuman-embed = { path = "vendor/openhuman/crates/openhuman-embed", default-features = false } +# A git dependency on purpose, not the vendored path. A host that vendors this +# repository as a submodule (OpenCompany) carries its own `vendor/openhuman` +# and must link ONE copy of OpenHuman: `openhuman_embed::Agent` in a binding +# here has to be the same type the host built, and OpenHuman's process-wide +# state must exist once. Cargo `[patch]` can redirect a git or registry source +# onto a path but cannot redirect a path source, so a path here would leave a +# host no way to unify the two trees. The `[patch]` table below points this +# same source at the vendored checkout for this repository's own build. +openhuman-embed = { git = "https://github.com/tinyhumansai/openhuman", rev = "1ecf1b0bc4bc4d80b0f56f1e01e47d2c0f981c5b", default-features = false } # Derive macros for the crate-wide error type in `crates/tinyhivemind-core/src/error/`. # Every dependency entry should carry a comment like this one saying why it is # here. @@ -130,3 +138,11 @@ private_intra_doc_links = "warn" lto = "thin" codegen-units = 1 strip = "debuginfo" + +# Resolve the git `openhuman-embed` above onto the vendored submodule so this +# workspace builds against exactly the tree it records (and CI, which checks +# out submodules recursively, needs no network for it). The `rev` above and +# the submodule pointer must agree; `.github/scripts/assert-openhuman-pin.sh` +# fails the build when they drift. +[patch."https://github.com/tinyhumansai/openhuman"] +openhuman-embed = { path = "vendor/openhuman/crates/openhuman-embed" } diff --git a/deny.toml b/deny.toml index 845f2f6d..b02f3e7a 100644 --- a/deny.toml +++ b/deny.toml @@ -44,6 +44,15 @@ wildcards = "deny" # manifest sets `publish = false`. A wildcard on anything from a registry is # still denied, which is what this check exists for. allow-wildcard-paths = true +# `openhuman` is a revision-pinned git dependency patched to the recorded +# submodule. Its public manifest uses `workspace = true` for its internal +# crates, which cargo-deny classifies as wildcard dependencies even though the +# resolved graph is fixed by Cargo.lock and assert-openhuman-pin.sh. Skip only +# this exact package's manifest-level wildcard finding; its dependency tree +# remains subject to every ban check. +skip = [ + { name = "openhuman", version = "0.63.29" }, +] # Crates that must never enter the dependency graph. deny = [] @@ -51,10 +60,23 @@ deny = [] unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -# Examples take OpenHuman, tinyinference, tinytools, and tinyjevclient as -# revision-pinned git dependencies rather than published versions. Nothing under -# crates/* depends on their transports; `.github/scripts/assert-pure.sh` checks -# the normal/build graph. See AGENTS.md's supply-chain boundary. +# Examples take tinyinference, tinytools, and tinyjevclient as revision-pinned +# git dependencies rather than published versions; nothing under crates/* +# depends on their transports, and `.github/scripts/assert-pure.sh` checks the +# normal/build graph for the pure crates. See AGENTS.md's supply-chain +# boundary. +# +# OpenHuman is the one exception: `openhuman-embed` is also a revision-pinned +# git dependency of `tinyhivemind-openhuman`, a library crate, per +# `docs/adr/0020-openhuman-embed-is-a-git-dependency-patched-locally.md`. +# `[patch]` in the root `Cargo.toml` redirects that source onto the +# `vendor/openhuman` submodule this repository already vendors and tests +# against, so every build here still compiles the vendored tree, not a fresh +# git fetch. `.github/scripts/assert-openhuman-pin.sh`, run in the `rust` CI +# job, reads the `rev` pinned in `Cargo.toml` and the `vendor/openhuman` +# submodule's commit and fails the build if they disagree — the supply-chain +# guarantee this exemption relies on is that check, not an absence of library +# dependents. allow-git = [ "https://github.com/tinyhumansai/openhuman", "https://github.com/tinyhumansai/tinyinference", diff --git a/docs/adr/0013-a-vendored-crate-is-an-example-dependency.md b/docs/adr/0013-a-vendored-crate-is-an-example-dependency.md index 78939e12..6abe1941 100644 --- a/docs/adr/0013-a-vendored-crate-is-an-example-dependency.md +++ b/docs/adr/0013-a-vendored-crate-is-an-example-dependency.md @@ -1,6 +1,6 @@ # 13. A vendored crate may back an example and never a library crate -- **Status:** Accepted — implemented +- **Status:** Accepted — implemented; amended by [ADR 0020](0020-openhuman-embed-is-a-git-dependency-patched-locally.md) - **Date:** 2026-09-09 ## Context diff --git a/docs/adr/0020-openhuman-embed-is-a-git-dependency-patched-locally.md b/docs/adr/0020-openhuman-embed-is-a-git-dependency-patched-locally.md new file mode 100644 index 00000000..c8b70b38 --- /dev/null +++ b/docs/adr/0020-openhuman-embed-is-a-git-dependency-patched-locally.md @@ -0,0 +1,107 @@ +# 20. `openhuman-embed` is a git dependency, patched locally + +- **Status:** Accepted +- **Date:** 2026-09-20 +- **Amends:** [ADR 0013](0013-a-vendored-crate-is-an-example-dependency.md) + +## Context + +[ADR 0013](0013-a-vendored-crate-is-an-example-dependency.md) drew the line at +library crates: a vendored, outside-the-workspace crate may back an example and +never a dependency of `tinyhivemind-core`, `tinyhivemind-hive`, or +`tinyhivemind`, because this repository is itself vendored — a consumer takes +`crates/*` as path dependencies — and a vendored dependency of a vendored +dependency becomes a nested submodule in every consumer. + +`tinyhivemind-openhuman` is the exception that decision named without solving: +it is a library crate, and it must name `openhuman_embed::Agent` to bind +already-built OpenHuman agents into a hive. `openhuman-embed` cannot be an +example-only dev-dependency the way `tinytools` and `tinyinference` are, +because the type it names has to be the same type a consuming host's own agent +handles are built with — a host binding one of its own `Agent` values into a +`tinyhivemind-openhuman` adapter is exactly the point of the crate. + +A consumer of this repository — OpenCompany is the first — vendors this +repository as a submodule *and* independently vendors its own OpenHuman +checkout, because OpenCompany's own runtime is built against it directly, not +only through this adapter. Two checkouts of the same crate compiled into one +binary are two `Agent` types and two copies of OpenHuman's process-wide state, +which is a linker-level bug, not a style objection. + +`openhuman-embed = { path = "vendor/openhuman/crates/openhuman-embed" }` — the +form ADR 0013 would otherwise require — cannot be resolved onto a different +tree by a downstream consumer. Cargo's `[patch]` table can redirect a git or +registry source onto a local path; it has no mechanism to redirect a path +source onto a different path. A path dependency here is therefore not just the +form ADR 0013 asks for done slightly wrong: it is the one form that forecloses +the fix a consumer would need. + +## Decision + +`openhuman-embed` is a **git dependency pinned by revision** in the root +`Cargo.toml`'s `[workspace.dependencies]`, consumed by `tinyhivemind-openhuman` +— a library crate — same as any other dependency there. This repository's own +build patches that source onto the vendored `vendor/openhuman` submodule it +already carries and tests against: + +```toml +[workspace.dependencies] +openhuman-embed = { git = "https://github.com/tinyhumansai/openhuman", rev = "…", default-features = false } + +[patch."https://github.com/tinyhumansai/openhuman"] +openhuman-embed = { path = "vendor/openhuman/crates/openhuman-embed" } +``` + +`.github/scripts/assert-openhuman-pin.sh`, run from the `rust` CI job, fails +the build when the pinned `rev` and the `vendor/openhuman` submodule pointer +disagree — the two must name the same tree, or this repository's own tests +would pass against a different OpenHuman than the `rev` promises to a +consumer. + +A consumer that vendors its own OpenHuman checkout adds the identical shape of +`[patch]` entry in its own root manifest, pointed at its own vendored path. +That entry is what actually unifies the `Agent` type across the two trees — +the git dependency only names *which* OpenHuman revision both sides agree to +patch onto their own copy of. This repository's own `examples/openhuman` +integration target needed the same entry for the same reason once +`openhuman-embed` stopped being a path dependency: it is a separate Cargo +workspace (its own `[workspace]` table) that reaches `openhuman-embed` +transitively through `tinyhivemind-openhuman`'s `openhuman-embed.workspace = +true`, and `field.workspace = true` copies the dependency *spec* — now a git +source — across a workspace boundary without carrying the root workspace's +`[patch]` table with it. Its own `Cargo.toml` now repeats the same patch entry +this repository's root manifest declares, at the relative path its own +directory needs. Any consumer with the same shape of transitive reach to +`openhuman-embed` — a path dependency on a crate that inherits the dependency +from a *different* workspace's `[workspace.dependencies]` — needs the same +patch in that workspace's own manifest, not only in the workspace that first +declared the dependency spec. + +## Consequences + +- **This repository's own build is unaffected in substance.** The `[patch]` + table means every `cargo` invocation here still compiles + `vendor/openhuman/crates/openhuman-embed`, the same tree it compiled when + the dependency was a path. `cargo metadata --locked` and the existing + `Cargo.lock` needed no update for this change, because a patched-to-path + package resolves and locks identically to a plain path dependency. +- **A consumer gets a real choice instead of a forced fork.** Without this + change, a host vendoring both this repository and OpenHuman had no supported + way to make `tinyhivemind-openhuman`'s `openhuman_embed::Agent` the same type + as its own. With it, the host's `[patch]` table is the whole fix, and no + change to this repository is required to add one. +- **The dependency-policy line ADR 0013 drew — git dependencies only in + example dev-dependencies — has a second exception now, and it is the last + one this reasoning permits.** The exception is not "git dependencies are + fine in library crates"; it is narrowly this: a library crate that must + share a type with a consumer's own vendored copy of the same upstream crate + cannot take that dependency as a path, because `[patch]` cannot retarget a + path. Any future case must show the same shared-type, patch-only-fixes-it + shape or it is a plain policy violation, not a second exception. +- **A silent rev/submodule drift becomes a CI failure, not a silent split + build.** `.github/scripts/assert-openhuman-pin.sh` is the enforcement; it + runs in the `rust` job on every push. +- **`assert-pure.sh` is unaffected.** `tinyhivemind-openhuman` was never in + `pure_crates`; it already carries a transport-shaped dependency + (`openhuman-embed` binds a running agent), and this change does not move it + across that boundary. diff --git a/docs/adr/README.md b/docs/adr/README.md index 052780e6..66a67c2a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,6 +44,7 @@ link any earlier ADR it amends. | [0017](0017-validate-semantic-routing-at-the-port.md) | Validate semantic routing at the port | Accepted | | [0018](0018-require-host-supplied-conversation-kinds.md) | Require host-supplied conversation kinds | Accepted | | [0019](0019-complete-episodes-with-explicit-agent-events.md) | Complete episodes with explicit agent events | Accepted | +| [0020](0020-openhuman-embed-is-a-git-dependency-patched-locally.md) | `openhuman-embed` is a git dependency, patched locally | Accepted — amends 0013 | ## Reading order diff --git a/examples/openhuman/Cargo.toml b/examples/openhuman/Cargo.toml index 6a8facfb..e0c13e92 100644 --- a/examples/openhuman/Cargo.toml +++ b/examples/openhuman/Cargo.toml @@ -31,3 +31,15 @@ tempfile = "3" # The inspector must terminate a hung Docker CLI from the host side. wait-timeout = "0.2" wiremock = "0.6" + +# `tinyhivemind-openhuman` above is a member of the root workspace and inherits +# `openhuman-embed.workspace = true` from there, which now names the git source +# (see the root `Cargo.toml` comment). `field.workspace = true` only copies the +# dependency *spec* across the workspace boundary; it does not carry the root +# workspace's own `[patch]` table with it. Without a patch here too, this +# standalone workspace would fetch `openhuman-embed` fresh from git instead of +# reusing the vendored submodule the `openhuman`/`openhuman-embed` path +# dependencies above already point at — two copies of OpenHuman's +# process-wide state in one binary. Keep this in sync with the root `[patch]`. +[patch."https://github.com/tinyhumansai/openhuman"] +openhuman-embed = { path = "../../vendor/openhuman/crates/openhuman-embed" }