Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ jobs:
name: distributions
path: dist/*
if-no-files-found: error
# push.yaml publishes each distribution from its own artifact, the
# interface before the emitter that requires it.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: interface-distribution
path: dist/microcosm_emitter_interface-*
if-no-files-found: error
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: emitter-distribution
path: dist/microcosm_emitter-*
if-no-files-found: error

collector:
needs: build-packages
Expand Down
23 changes: 19 additions & 4 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ permissions:
# remaining jobs verify, tag, then publish, each only after the one before.
#
# PyPI trusts this file by name, in the pypi environment, which only main can
# use. Renaming it stops publication.
# use, for both distributions (microcosm-emitter and microcosm-emitter-interface).
# Renaming it stops publication.
jobs:
versioning:
name: Update versioning
Expand Down Expand Up @@ -68,11 +69,25 @@ jobs:
environment: pypi
permissions:
id-token: write
# The emitter requires the interface of the same version, so the interface
# is uploaded first: PyPI never offers an emitter whose interface is
# missing. Files already on PyPI are skipped, so rerunning this job after
# a failed or partial upload uploads only the missing files instead of
# stopping on "File already exists".
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: distributions
path: dist
name: interface-distribution
path: dist/interface
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
packages-dir: dist/
packages-dir: dist/interface/
skip-existing: true
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: emitter-distribution
path: dist/emitter
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
packages-dir: dist/emitter/
skip-existing: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- Use uv, Python 3.13+, pytest, and Ruff. Keep workflows as single-command calls to Bash scripts when shell logic has multiple lines.
- The generic runtime must not import telemetry or Microcosm. The telemetry client must not import service implementation, database, or authentication modules.
- Graph adapters must not import service, database, authentication or HTTP code. Inventory hashing, durable enqueue, receipt lookup and retries run in the separate local emitter service; no direct-database fallback.
- Publish only the `microcosm-emitter` distribution. Keep the generic host and required telemetry library internally separate under `microcosm_emitter`; do not introduce standalone telemetry packages or optional telemetry installation.
- Publish exactly two distributions, always together and at the same version: `microcosm-emitter` (the separately installed emitter, with its generic host and required telemetry) and `microcosm-emitter-interface` (the dependency-free package Microcosm builds import to start and call the emitter). `microcosm-emitter==X` requires `microcosm-emitter-interface==X`. Keep the generic host and required telemetry library internally separate under `microcosm_emitter`; do not introduce standalone telemetry packages, optional telemetry installation, or further distributions.
- Python databases use SQLAlchemy declarative ORM and Alembic exclusively. No handwritten SQL, direct-driver persistence, create_all(), schema repair, or pre-Alembic adoption. TypeScript database work, if introduced, must use Drizzle ORM and Drizzle Kit migrations.
- Tests use temporary databases created through real migrations, synthetic credentials, and loopback network services. Never use ambient credentials or contact the production collector in tests.
- Preserve the existing collector protocol; do not add a general hosted events API.
Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Microcosm emitter

`microcosm-emitter` is one Python distribution containing the process host, required telemetry library and completed-graph publication library:
This repository publishes two Python distributions with one shared version:

- `microcosm-emitter-interface` (`packages/microcosm-emitter-interface/`) is the small, dependency-free package a Microcosm build imports. It starts the separately installed emitter as the build's child process and offers one typed call per kind of hand-over. See its README for the compatibility rules and protocol fixtures.
- `microcosm-emitter` is the emitter itself: the process host, required telemetry library and completed-graph publication library, installed on its own with `uv tool install microcosm-emitter`. It depends on the interface release of the same version.

The `microcosm-emitter` distribution contains:

- `microcosm_emitter.host` owns subprocess startup, a private Unix socket, explicit module loading, parent-process monitoring, and shutdown. Its code does not import telemetry, database, authentication, or Microcosm modules.
- `microcosm_emitter.telemetry.client` is the thin build adapter. It starts the local telemetry emitter service and sends build requests, without importing the service implementation, database, or authentication libraries.
Expand Down Expand Up @@ -67,13 +72,17 @@ The collector compatibility job tests the installed wheel against a pinned dashb

## Publication

Every merge to `main` releases the Python distribution `microcosm-emitter` to PyPI. Each pull request adds a changelog fragment named `changelog.d/<name>.<type>.md`, where the type is `breaking`, `added`, `changed`, `fixed` or `removed`; `tools/check-changelog.sh` refuses a pull request without one.
Every merge to `main` releases both Python distributions, `microcosm-emitter` and `microcosm-emitter-interface`, to PyPI with one shared version. Each pull request adds a changelog fragment named `changelog.d/<name>.<type>.md`, where the type is `breaking`, `added`, `changed`, `fixed` or `removed`; `tools/check-changelog.sh` refuses a pull request without one.

`push.yaml` runs twice for each merge:

1. On the merge commit, it bumps the version in `pyproject.toml` and `uv.lock`: a major bump for a breaking fragment, a minor bump for an added or removed fragment, and a patch bump otherwise. It collects the fragments into `CHANGELOG.md` and commits the result as "Update microcosm-emitter" using the PolicyEngine GitHub App, which may bypass the `main` ruleset.
2. On that commit, it runs every CI check, tags the commit with the bare version (for example `0.2.0`), and publishes the tested wheel and source archive to PyPI. Each step starts only after the one before succeeds.
1. On the merge commit, it bumps the version in both `pyproject.toml` files, the emitter's exact `microcosm-emitter-interface` pin and `uv.lock`: a major bump for a breaking fragment, a minor bump for an added or removed fragment, and a patch bump otherwise. It collects the fragments into `CHANGELOG.md` and commits the result as "Update microcosm-emitter" using the PolicyEngine GitHub App, which may bypass the `main` ruleset.
2. On that commit, it runs every CI check, tags the commit with the bare version (for example `0.2.0`), and publishes the tested wheels and source archives of both distributions to PyPI. Each step starts only after the one before succeeds.

The publish job uploads `microcosm-emitter-interface` before `microcosm-emitter`, because the emitter requires the interface of the same version: if the interface upload fails, the emitter is not uploaded. Both uploads skip files PyPI already holds, so rerunning a failed publish job uploads only the missing files.

PyPI accepts uploads only through trusted publishing from `push.yaml` in the `pypi` environment, which only `main` can use; each of the two PyPI projects needs its own trusted-publisher entry for that workflow and environment. CI holds no long-lived registry token. Renaming the workflow or the environment stops publication until PyPI's trusted-publisher settings change to match.

PyPI accepts uploads only through trusted publishing from `push.yaml` in the `pypi` environment, which only `main` can use. CI holds no long-lived registry token. Renaming the workflow or the environment stops publication until PyPI's trusted-publisher settings change to match.
The artifact check (`tools/check-artifacts.sh`) builds both distributions, installs the emitter with its interface into one clean environment, as `uv tool install` would, and the interface alone into another, as a build's environment would. From the second, it starts the first's `microcosm-emitter` command and sends telemetry and an Orrery hand-over.

No hosted-service deployment or consumer activation follows from publishing this package.
No hosted-service deployment or consumer activation follows from publishing these packages.
1 change: 1 addition & 0 deletions changelog.d/emitter-interface-package.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the `microcosm-emitter-interface` distribution: a dependency-free package that a Microcosm build depends on with a version range. `EmitterClient.start` runs the installed `microcosm-emitter` command as the build's child process, which inherits the build's environment and finds the same Hugging Face credential, and refuses an emitter that does not accept its socket protocol version. It offers typed calls for telemetry events and Orrery input hand-overs, and never raises into the build: not because the emitter is missing or refusing a request, and not because an argument cannot be converted to JSON. A refused call reports why in `EmitterClient.last_failure`. The emitter's in-process telemetry and Orrery clients now build their messages with the interface's code, and the in-process Orrery client reports an argument it cannot send (`invalid_arguments`) instead of raising.
21 changes: 21 additions & 0 deletions packages/microcosm-emitter-interface/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 PolicyEngine

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
35 changes: 35 additions & 0 deletions packages/microcosm-emitter-interface/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Microcosm emitter interface

`microcosm-emitter-interface` is the small, dependency-free package a Microcosm build imports to hand work to the separately installed `microcosm-emitter`. Microcosm decides what to hand over; the emitter decides where it goes.

Install the emitter on its own, outside the build's environment:

```bash
uv tool install microcosm-emitter
```

The build then depends on this package with a version range and starts the emitter through it:

```python
from microcosm_emitter_interface import EmitterClient, TelemetryRun, TelemetrySettings

client = EmitterClient.start(
telemetry=TelemetrySettings(run=TelemetryRun("run-id", "UK", "uk-build")),
)
client.telemetry.transition_stage("calibrating")
client.telemetry.complete()
client.close()
```

`EmitterClient.start` runs the `microcosm-emitter` command as the build's own child process. The command comes from the `command` argument, then the `MICROCOSM_EMITTER_COMMAND` environment variable, then `PATH`. The child inherits the build's environment variables and user, so it finds the same Hugging Face credential: `HF_TOKEN`, then `HUGGINGFACE_TOKEN`, then the token saved by `hf auth login`.

No call raises into the build: not because the emitter is missing, slow or refusing a request, and not because an argument cannot be converted to JSON (for example a multi-element NumPy array or a self-referencing mapping). Each call converts its arguments inside its error handling and reports whether the emitter accepted it; after a refusal, `client.last_failure` says why (`emitter_unavailable`, `invalid_arguments` or `not_accepted`). Startup settings that cannot be converted start an unavailable client (`invalid_configuration`). The build continues in every case.

## Compatibility

Both packages are released together with one version number, and `microcosm-emitter==X` requires `microcosm-emitter-interface==X`. A build, though, may run with a different emitter from the interface it depends on: it keeps the emitter it started with, and the next build uses whichever emitter is installed then. So the socket protocol carries its own version:

- `PROTOCOL_VERSION` names the shape of every message this package sends. The emitter lists the versions it accepts in its `hello` answer, and the client refuses an emitter that does not list its version (`unsupported_protocol_version`); the build continues without it.
- **Every emitter release must accept every protocol version that interface releases still in use send.** Adding a module or a message kind keeps the version; changing the shape of an existing message needs a new version, which emitters then accept alongside the old one.
- `fixtures/protocol-v<N>/` holds one JSON file per message of protocol version `N`, including how the command is started (`host-launch.json`: its arguments and the configuration object on standard input) and each socket request's whole envelope (`host-ping.json`, `host-message-envelope.json`, `host-query-envelope.json`, `host-close.json`, and `host-hello.json`). This package's tests check that each typed call, the launcher and the socket transport send exactly their fixture, and the emitter's contract tests check that it accepts every fixture of every version it supports, so a change to the command's arguments or the envelope cannot pass by changing both sides at once. An emitter's `hello` answer must contain the recorded keys and may add more. A released fixture is never edited or removed: the emitter's tests pin each one's SHA-256.
- An emitter that lacks a module this package asks for starts without it (`--skip-unavailable-modules`); calls for that module report that they were not accepted.
18 changes: 18 additions & 0 deletions packages/microcosm-emitter-interface/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[project]
name = "microcosm-emitter-interface"
version = "0.4.0"
description = "Typed hand-over calls from Microcosm builds to the separately installed microcosm-emitter"
readme = "README.md"
requires-python = ">=3.13"
license = "MIT"
dependencies = []

[project.urls]
Source = "https://github.com/PolicyEngine/microcosm-emitter"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/microcosm_emitter_interface"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Typed hand-overs from Microcosm builds to the separately installed emitter.

Importing this package starts no process and opens no socket or file.
"""

from microcosm_emitter_interface.client import EmitterClient, RequestFailure
from microcosm_emitter_interface.launcher import (
COMMAND_ENVIRONMENT_VARIABLE,
DEFAULT_COMMAND,
EmitterStartupError,
)
from microcosm_emitter_interface.orrery import OrrerySettings
from microcosm_emitter_interface.protocol import PROTOCOL_VERSION
from microcosm_emitter_interface.spool import default_spool_path
from microcosm_emitter_interface.telemetry import TelemetryRun, TelemetrySettings

__all__ = [
"COMMAND_ENVIRONMENT_VARIABLE",
"DEFAULT_COMMAND",
"PROTOCOL_VERSION",
"EmitterClient",
"EmitterStartupError",
"OrrerySettings",
"RequestFailure",
"TelemetryRun",
"TelemetrySettings",
"default_spool_path",
]
Loading
Loading