Implement worker coordination runtime and finish WP-0003
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07b5b-ea58-7ad2-bdbb-0b1c995cfc35
This commit is contained in:
parent
214964ccb8
commit
628f984a10
23 changed files with 3025 additions and 544 deletions
168
src/coordination_engine/config.py
Normal file
168
src/coordination_engine/config.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import tomllib
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
state_dir: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.environ.get(
|
||||
"COORDINATION_STATE_DIR",
|
||||
str(
|
||||
Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
|
||||
/ "coordination-engine"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
socket: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.environ.get(
|
||||
"COORDINATION_SOCKET",
|
||||
str(
|
||||
Path(
|
||||
os.environ.get(
|
||||
"XDG_RUNTIME_DIR", f"/tmp/coordination-{os.getuid()}"
|
||||
)
|
||||
)
|
||||
/ "coordination-engine.sock"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
tamq_socket: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.environ.get(
|
||||
"TAMQ_SOCKET",
|
||||
str(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "tamq.sock"),
|
||||
)
|
||||
)
|
||||
)
|
||||
api_base: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"STATEHUB_API_BASE", "http://127.0.0.1:8000"
|
||||
)
|
||||
)
|
||||
repos: list[str] = field(default_factory=list)
|
||||
endpoints: dict[str, str] = field(default_factory=dict)
|
||||
poll_interval: float = 15
|
||||
lease_seconds: float = 30
|
||||
renew_interval: float = 10
|
||||
timeout: float = 5
|
||||
busy_timeout: float = 5
|
||||
max_attempts: int = 4
|
||||
retry_backoff: list[float] = field(default_factory=lambda: [5, 15, 60, 300])
|
||||
policy_profile: str = "default"
|
||||
# Only these classes may be requested automatically. Unknown classes stop.
|
||||
allow: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
"repo_inspect",
|
||||
"repo_edit",
|
||||
"local_checks",
|
||||
"state_updates",
|
||||
"tmux_wake",
|
||||
]
|
||||
)
|
||||
|
||||
def validate(self):
|
||||
for key in (
|
||||
"poll_interval",
|
||||
"lease_seconds",
|
||||
"renew_interval",
|
||||
"timeout",
|
||||
"busy_timeout",
|
||||
):
|
||||
value = getattr(self, key)
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (float, int))
|
||||
or not math.isfinite(value)
|
||||
or value <= 0
|
||||
):
|
||||
raise ValueError(f"invalid {key}")
|
||||
if self.renew_interval >= self.lease_seconds:
|
||||
raise ValueError("renew_interval must be shorter than lease_seconds")
|
||||
if type(self.max_attempts) is not int or not 0 <= self.max_attempts <= 9:
|
||||
raise ValueError("max_attempts must be 0..9")
|
||||
if not self.retry_backoff or any(
|
||||
type(v) not in (int, float) or not math.isfinite(v) or v <= 0
|
||||
for v in self.retry_backoff
|
||||
):
|
||||
raise ValueError("retry_backoff must contain positive finite delays")
|
||||
if self.policy_profile != "default" or not set(self.allow) <= {
|
||||
"repo_inspect",
|
||||
"repo_edit",
|
||||
"local_checks",
|
||||
"state_updates",
|
||||
"tmux_wake",
|
||||
}:
|
||||
raise ValueError("unsupported policy profile or action grant")
|
||||
if not isinstance(self.repos, list) or any(
|
||||
not isinstance(r, str)
|
||||
or not r
|
||||
or any(
|
||||
c
|
||||
not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._"
|
||||
for c in r
|
||||
)
|
||||
for r in self.repos
|
||||
):
|
||||
raise ValueError("repos must contain exact repository slugs")
|
||||
if not isinstance(self.endpoints, dict) or any(
|
||||
k not in self.repos or not isinstance(v, str) or not v
|
||||
for k, v in self.endpoints.items()
|
||||
):
|
||||
raise ValueError("endpoints must map configured repos to endpoint IDs")
|
||||
url = urlsplit(self.api_base)
|
||||
if (
|
||||
url.scheme not in {"http", "https"}
|
||||
or not url.hostname
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
):
|
||||
raise ValueError("invalid State Hub URL; credentials are not configuration")
|
||||
for name in ("state_dir", "socket", "tamq_socket"):
|
||||
setattr(self, name, Path(getattr(self, name)).expanduser().absolute())
|
||||
return self
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return hashlib.sha256(
|
||||
json.dumps(asdict(self), default=str, sort_keys=True).encode()
|
||||
).hexdigest()[:16]
|
||||
|
||||
@classmethod
|
||||
def load(cls, path=None):
|
||||
path = Path(
|
||||
path
|
||||
or os.environ.get(
|
||||
"COORDINATION_CONFIG",
|
||||
str(
|
||||
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
/ "coordination-engine/config.toml"
|
||||
),
|
||||
)
|
||||
)
|
||||
data = (
|
||||
tomllib.loads(path.read_text()).get("coordination", {})
|
||||
if path.exists()
|
||||
else {}
|
||||
)
|
||||
for env, key in [
|
||||
("COORDINATION_STATE_DIR", "state_dir"),
|
||||
("COORDINATION_SOCKET", "socket"),
|
||||
("TAMQ_SOCKET", "tamq_socket"),
|
||||
("STATEHUB_API_BASE", "api_base"),
|
||||
]:
|
||||
if env in os.environ:
|
||||
data[key] = os.environ[env]
|
||||
return cls(**data).validate()
|
||||
Loading…
Add table
Add a link
Reference in a new issue