diff --git a/packages/cultcache-py/PARITY.md b/packages/cultcache-py/PARITY.md index 4d304524..852b145e 100644 --- a/packages/cultcache-py/PARITY.md +++ b/packages/cultcache-py/PARITY.md @@ -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 diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 63bde028..9fea50b2 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -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") diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml index 979a01e3..66cf8c60 100644 --- a/packages/cultcache-py/pyproject.toml +++ b/packages/cultcache-py/pyproject.toml @@ -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"] diff --git a/packages/cultcache-py/src/cultcache_py/verify.py b/packages/cultcache-py/src/cultcache_py/verify.py index 345f2a47..7ec4e87d 100644 --- a/packages/cultcache-py/src/cultcache_py/verify.py +++ b/packages/cultcache-py/src/cultcache_py/verify.py @@ -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", diff --git a/packages/cultcache-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py index 6ab3d2ea..a3f549aa 100644 --- a/packages/cultcache-py/src/cultmesh_py/__init__.py +++ b/packages/cultcache-py/src/cultmesh_py/__init__.py @@ -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", diff --git a/packages/cultcache-py/src/cultmesh_py/daemon.py b/packages/cultcache-py/src/cultmesh_py/daemon.py new file mode 100644 index 00000000..46746190 --- /dev/null +++ b/packages/cultcache-py/src/cultmesh_py/daemon.py @@ -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:])) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 55479b22..d1779560 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -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",