Add Python CultMesh daemon entrypoint

This commit is contained in:
Meta Vens 2026-06-14 15:28:25 +01:00
commit cf151fa67a
7 changed files with 186 additions and 3 deletions

View file

@ -97,7 +97,10 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
raw CultNet client construction, local nodes, catalogs, sessions, streams,
authority leases, local TCP node serving, simulation fact commits, and the
durable shard-log node option that attaches a file-backed authoritative log
store beside the cache file by default.
store beside the cache file by default. The `cultmesh-py-daemon` /
`python -m cultmesh_py.daemon` entrypoint launches that same local TCP server
body and emits a bounded readiness document for process supervisors while the
served CultNet endpoint remains the capability owner.
- Python CultMesh authority leases:
Lease objects own validity and peer/role/shard coverage checks; the catalog
owns sorted listing, lookup, upsert, signature verification policy, and
@ -215,8 +218,8 @@ node --test packages\cultnet-ts\dist-test\test\interop\cultnet-interop.test.js
- Python does not own LiteNetLib security/session behavior; it speaks the
schema-v0 interop lane used by the package harness.
- Python does not implement the full C# daemon/server stack. Its current role is
package runtime, raw interop peer, local CultMesh session facade, and typed
state participant.
package runtime, raw interop peer, launchable local CultMesh endpoint, local
CultMesh session facade, and typed state participant.
- Python does not claim the full C# shard mutation-log service surface. It can
create/apply raw snapshots, create/apply package-local raw shard-log
responses, consume remote shard-log responses, and run pull-once replica

View file

@ -309,6 +309,19 @@ server = CultMesh.serve_node(
max_snapshot_documents=1000,
max_snapshot_bytes=4 * 1024 * 1024,
)
```
The same local server can be launched as a package daemon when another runtime
or operator process needs a CultNet endpoint without embedding Python glue:
```powershell
python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --ready-file mesh.ready.json
```
The ready file and stdout line are process readiness hints. The served state and
capability truth still live on the framed CultNet/CultMesh endpoint.
```python
client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075)
verses = client.fetch_verses(transport_version="cultmesh.v0")

View file

@ -27,6 +27,7 @@ cultnet-py-interop = "cultnet_py.interop_peer:main"
cultcache-py-benchmark = "cultcache_py.benchmark:main"
cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main"
cultcache-py-verify = "cultcache_py.verify:main"
cultmesh-py-daemon = "cultmesh_py.daemon:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -121,11 +121,13 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = {
"CultMeshVerseDiscoveryClient",
"PEER_EXCHANGE_REQUEST",
"PEER_EXCHANGE_RESPONSE",
"READY_SCHEMA_VERSION",
"SIMULATION_FACT_DOCUMENT_TYPE",
"SIMULATION_FACT_SCHEMA_VERSION",
"VERSE_CATALOG_REQUEST",
"VERSE_CATALOG_RESPONSE",
"create_node",
"daemon_ready_document",
"peer_exchange_request",
"peer_from_wire",
"simulation_fact_document",

View file

@ -7,6 +7,7 @@ from .client import (
CultMeshSnapshotFanout,
CultMeshVerseDiscoveryClient,
)
from .daemon import READY_SCHEMA_VERSION, daemon_ready_document
from .facade import CultMesh
from .node import CultMeshDatabase, CultMeshDatabaseChange, CultMeshNode, CultMeshNodeOptions, create_node
from .server import CultMeshLocalServer
@ -84,6 +85,7 @@ __all__ = [
"CultMeshVerseCatalog",
"CultMeshVerseCompatibility",
"CultMeshVerseDescriptor",
"READY_SCHEMA_VERSION",
"PEER_EXCHANGE_REQUEST",
"PEER_EXCHANGE_RESPONSE",
"SIMULATION_FACT_DOCUMENT_TYPE",
@ -91,6 +93,7 @@ __all__ = [
"VERSE_CATALOG_REQUEST",
"VERSE_CATALOG_RESPONSE",
"create_node",
"daemon_ready_document",
"peer_from_wire",
"peer_exchange_request",
"simulation_fact_document",

View file

@ -0,0 +1,95 @@
from __future__ import annotations
import argparse
import json
import signal
import sys
import threading
from pathlib import Path
from typing import Any
from .facade import CultMesh
from .server import CultMeshLocalServer
READY_SCHEMA_VERSION = "cultmesh.daemon_ready.v0"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="cultmesh-py-daemon")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=3075)
parser.add_argument("--runtime-id", default="python-runtime")
parser.add_argument("--display-name")
parser.add_argument("--cache-file")
parser.add_argument("--enable-durable-shard-logs", action="store_true")
parser.add_argument("--shard-log-file")
parser.add_argument("--max-snapshot-documents", type=int)
parser.add_argument("--max-snapshot-bytes", type=int)
parser.add_argument("--ready-file")
args = parser.parse_args(argv)
stop = threading.Event()
install_signal_handlers(stop)
server = start_server(args)
try:
ready = daemon_ready_document(server)
publish_ready(ready, ready_file=args.ready_file)
while not stop.wait(0.2):
pass
finally:
server.stop()
return 0
def start_server(args: argparse.Namespace) -> CultMeshLocalServer:
node = CultMesh.create_node(
args.cache_file,
runtime_id=args.runtime_id,
enable_durable_shard_logs=args.enable_durable_shard_logs,
shard_log_path=args.shard_log_file,
)
return CultMesh.serve_node(
node,
host=args.host,
port=args.port,
display_name=args.display_name,
max_snapshot_documents=args.max_snapshot_documents,
max_snapshot_bytes=args.max_snapshot_bytes,
)
def daemon_ready_document(server: CultMeshLocalServer) -> dict[str, Any]:
return {
"schemaVersion": READY_SCHEMA_VERSION,
"runtimeId": server.node.runtime_id,
"runtimeKind": server.runtime_kind,
"host": server.host,
"port": server.port,
"endpoint": f"cultnet://{server.host}:{server.port}",
"supportedDocumentTypes": [document.type for document in server.node.documents],
}
def publish_ready(document: dict[str, Any], *, ready_file: str | None = None) -> None:
text = json.dumps(document, separators=(",", ":"), sort_keys=True)
if ready_file:
Path(ready_file).write_text(text, encoding="utf-8")
print(text, flush=True)
def install_signal_handlers(stop: threading.Event) -> None:
def request_stop(signum: int, frame: Any) -> None:
stop.set()
for signal_name in ("SIGINT", "SIGTERM"):
signal_value = getattr(signal, signal_name, None)
if signal_value is None:
continue
try:
signal.signal(signal_value, request_stop)
except (OSError, ValueError):
continue
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
@ -108,6 +111,7 @@ from cultmesh_py import (
CultMeshVerseCompatibility,
CultMeshVerseDescriptor,
CultMeshVerseDiscoveryClient,
READY_SCHEMA_VERSION,
peer_exchange_request,
simulation_fact_document,
verse_catalog_request,
@ -3015,6 +3019,68 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(latest[0].peer_id, "health-loop-peer")
self.assertTrue(latest[0].is_reachable)
def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None:
with tempfile.TemporaryDirectory() as temp:
ready_path = Path(temp) / "ready.json"
package_src = Path(__file__).resolve().parents[1] / "src"
env = dict(os.environ)
existing_pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = (
str(package_src)
if not existing_pythonpath
else f"{package_src}{os.pathsep}{existing_pythonpath}"
)
process = subprocess.Popen(
[
sys.executable,
"-m",
"cultmesh_py.daemon",
"--runtime-id",
"daemon-test-peer",
"--display-name",
"Daemon Test Peer",
"--port",
"0",
"--ready-file",
str(ready_path),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
try:
deadline = time.monotonic() + 5.0
while not ready_path.exists() and time.monotonic() < deadline:
if process.poll() is not None:
break
time.sleep(0.05)
if not ready_path.exists():
_, stderr = process.communicate(timeout=2.0)
self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
ready = json.loads(ready_path.read_text(encoding="utf-8"))
self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION)
self.assertEqual(ready["runtimeId"], "daemon-test-peer")
self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}")
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
hello_response = client.request(
hello(runtime_id="daemon-test-prober"),
expected_schema_version="cultnet.hello.v0",
)
self.assertEqual(hello_response["runtimeId"], "daemon-test-peer")
self.assertEqual(hello_response["displayName"], "Daemon Test Peer")
self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"])
self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"])
finally:
process.terminate()
try:
process.communicate(timeout=5.0)
except subprocess.TimeoutExpired:
process.kill()
process.communicate(timeout=5.0)
def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None:
document = define_database_entry_type(
"mesh.server_note",