Build MeshScale bare metal controller
Browse files- Dockerfile +18 -0
- README.md +55 -5
- key_farm/__init__.py +29 -0
- key_farm/__pycache__/__init__.cpython-310.pyc +0 -0
- key_farm/__pycache__/amalgam_bridge.cpython-310.pyc +0 -0
- key_farm/__pycache__/contracts.cpython-310.pyc +0 -0
- key_farm/__pycache__/controller.cpython-310.pyc +0 -0
- key_farm/__pycache__/evaluator.cpython-310.pyc +0 -0
- key_farm/__pycache__/faculty.cpython-310.pyc +0 -0
- key_farm/__pycache__/regulation.cpython-310.pyc +0 -0
- key_farm/__pycache__/scaler.cpython-310.pyc +0 -0
- key_farm/__pycache__/serialization.cpython-310.pyc +0 -0
- key_farm/__pycache__/store.cpython-310.pyc +0 -0
- key_farm/__pycache__/symbiotic_trace.cpython-310.pyc +0 -0
- key_farm/amalgam_bridge.py +33 -0
- key_farm/cli.py +235 -0
- key_farm/contracts.py +266 -0
- key_farm/controller.py +276 -0
- key_farm/evaluator.py +201 -0
- key_farm/faculty.py +156 -0
- key_farm/regulation.py +37 -0
- key_farm/scaler.py +271 -0
- key_farm/serialization.py +52 -0
- key_farm/store.py +339 -0
- key_farm/symbiotic_trace.py +231 -0
- key_farm/worker.py +215 -0
- meshscale/__init__.py +6 -0
- meshscale/cli.py +7 -0
- meshscale_space/Dockerfile +18 -0
- meshscale_space/README.md +60 -0
- meshscale_space/__init__.py +1 -0
- meshscale_space/__pycache__/__init__.cpython-310.pyc +0 -0
- meshscale_space/__pycache__/app.cpython-310.pyc +0 -0
- meshscale_space/__pycache__/os_routes.cpython-310.pyc +0 -0
- meshscale_space/app.py +201 -0
- meshscale_space/os_routes.py +156 -0
- meshscale_space/requirements.txt +5 -0
- meshscale_space/static/app.js +288 -0
- meshscale_space/static/index.html +84 -0
- meshscale_space/static/styles.css +377 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PORT=7860
|
| 5 |
+
ENV KEY_FARM_ROOT=/data/farm
|
| 6 |
+
ENV MESHSCALE_ENABLE_APPLY=0
|
| 7 |
+
ENV MESHSCALE_FULL_KEY_REQUIREMENTS=0
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY . /app
|
| 11 |
+
|
| 12 |
+
RUN python -m pip install --upgrade pip \
|
| 13 |
+
&& python -m pip install --no-cache-dir -r meshscale_space/requirements.txt \
|
| 14 |
+
&& if [ "$MESHSCALE_FULL_KEY_REQUIREMENTS" = "1" ] && [ -f requirements.txt ]; then \
|
| 15 |
+
python -m pip install --no-cache-dir -r requirements.txt; \
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
CMD ["python", "-m", "uvicorn", "meshscale_space.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,60 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MeshScale
|
| 3 |
+
emoji: 🔩
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# MeshScale
|
| 12 |
+
|
| 13 |
+
Bare-metal search surface for the MeshScale CPU worker mesh.
|
| 14 |
+
|
| 15 |
+
Required Space variable:
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
KEY_FARM_ROOT=/data/farm
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Safe default:
|
| 22 |
+
|
| 23 |
+
```text
|
| 24 |
+
MESHSCALE_ENABLE_APPLY=0
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
Set `MESHSCALE_ENABLE_APPLY=1` only on the controller Space that is allowed to
|
| 28 |
+
start or pause Hugging Face CPU worker Spaces.
|
| 29 |
+
|
| 30 |
+
Common variables:
|
| 31 |
+
|
| 32 |
+
```text
|
| 33 |
+
MESHSCALE_TEMPLATE_SPACE=tostido/meshscale-worker-template
|
| 34 |
+
MESHSCALE_NAMESPACE=tostido
|
| 35 |
+
MESHSCALE_WORKER_PREFIX=meshscale-worker
|
| 36 |
+
MESHSCALE_HARDWARE=cpu-upgrade
|
| 37 |
+
MESHSCALE_MAX_WORKERS=24
|
| 38 |
+
MESHSCALE_BUDGET_HOURLY_USD=0.72
|
| 39 |
+
MESHSCALE_KEY_SPACE_URL=https://huggingface.co/spaces/tostido/Ouroboros
|
| 40 |
+
MESHSCALE_BUCKET_URL=https://huggingface.co/buckets/tostido/Ouroboros-storage
|
| 41 |
+
MESHSCALE_WORKER_TEMPLATE_URL=https://huggingface.co/spaces/tostido/meshscale-worker-template
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
The UI accepts search commands:
|
| 45 |
+
|
| 46 |
+
```text
|
| 47 |
+
guide
|
| 48 |
+
status
|
| 49 |
+
trace
|
| 50 |
+
plan 420
|
| 51 |
+
scale dry
|
| 52 |
+
scale apply
|
| 53 |
+
run
|
| 54 |
+
drain 4
|
| 55 |
+
shutdown
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
The OS route layer returns contextual next steps before acting. For example,
|
| 59 |
+
`scale apply` stops early when the controller pulse is quiet and routes the user
|
| 60 |
+
to KEY instead of blindly scaling workers.
|
key_farm/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU farm primitives for KEY evolution runs.
|
| 2 |
+
|
| 3 |
+
The farm keeps NEAT generation ownership in KEY and distributes only
|
| 4 |
+
fitness-evaluation shards to CPU workers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .contracts import FarmPolicy, RegulationIntent, ShardJob, ShardResult
|
| 8 |
+
from .controller import FarmController
|
| 9 |
+
from .evaluator import get_handler, register_handler
|
| 10 |
+
from .faculty import farm_faculty_manifest, load_cocoon_faculty
|
| 11 |
+
from .scaler import MeshScaleScaler, ScalePolicy
|
| 12 |
+
from .store import FarmStore
|
| 13 |
+
from .symbiotic_trace import symbiotic_trace
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"FarmController",
|
| 17 |
+
"FarmPolicy",
|
| 18 |
+
"FarmStore",
|
| 19 |
+
"MeshScaleScaler",
|
| 20 |
+
"RegulationIntent",
|
| 21 |
+
"ScalePolicy",
|
| 22 |
+
"ShardJob",
|
| 23 |
+
"ShardResult",
|
| 24 |
+
"get_handler",
|
| 25 |
+
"farm_faculty_manifest",
|
| 26 |
+
"load_cocoon_faculty",
|
| 27 |
+
"register_handler",
|
| 28 |
+
"symbiotic_trace",
|
| 29 |
+
]
|
key_farm/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (868 Bytes). View file
|
|
|
key_farm/__pycache__/amalgam_bridge.cpython-310.pyc
ADDED
|
Binary file (1.05 kB). View file
|
|
|
key_farm/__pycache__/contracts.cpython-310.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
key_farm/__pycache__/controller.cpython-310.pyc
ADDED
|
Binary file (7.24 kB). View file
|
|
|
key_farm/__pycache__/evaluator.cpython-310.pyc
ADDED
|
Binary file (5.71 kB). View file
|
|
|
key_farm/__pycache__/faculty.cpython-310.pyc
ADDED
|
Binary file (4.92 kB). View file
|
|
|
key_farm/__pycache__/regulation.cpython-310.pyc
ADDED
|
Binary file (1.06 kB). View file
|
|
|
key_farm/__pycache__/scaler.cpython-310.pyc
ADDED
|
Binary file (8.95 kB). View file
|
|
|
key_farm/__pycache__/serialization.cpython-310.pyc
ADDED
|
Binary file (2.23 kB). View file
|
|
|
key_farm/__pycache__/store.cpython-310.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
key_farm/__pycache__/symbiotic_trace.cpython-310.pyc
ADDED
|
Binary file (6.65 kB). View file
|
|
|
key_farm/amalgam_bridge.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def observe_event(model_id: str, event_type: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
| 7 |
+
"""Record a farm event through Amalgam/Cascade when available.
|
| 8 |
+
|
| 9 |
+
This is intentionally optional. KEY farm execution must not fail just
|
| 10 |
+
because a receipt backend is not installed in a worker Space.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
envelope = {
|
| 14 |
+
"event_type": event_type,
|
| 15 |
+
"payload": payload,
|
| 16 |
+
"surface": "key_farm",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from amalgam_agency import AmalgamAgency
|
| 21 |
+
|
| 22 |
+
agency = AmalgamAgency(model_id=model_id)
|
| 23 |
+
return agency.record(event_type, payload, surface="key_farm")
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
from cascade.store import observe
|
| 29 |
+
|
| 30 |
+
receipt = observe(model_id=model_id, data=envelope, sync=False)
|
| 31 |
+
return receipt.to_dict()
|
| 32 |
+
except Exception:
|
| 33 |
+
return None
|
key_farm/cli.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import shutil
|
| 6 |
+
import tempfile
|
| 7 |
+
import threading
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from .contracts import FarmPolicy, RegulationIntent
|
| 11 |
+
from .controller import FarmController
|
| 12 |
+
from .faculty import farm_faculty_manifest, load_cocoon_faculty
|
| 13 |
+
from .scaler import MeshScaleScaler, ScalePolicy
|
| 14 |
+
from .store import FarmStore
|
| 15 |
+
from .symbiotic_trace import symbiotic_trace
|
| 16 |
+
from .worker import worker_loop
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def simulate(args: argparse.Namespace) -> None:
|
| 20 |
+
from node import create_population
|
| 21 |
+
from run import load_config
|
| 22 |
+
|
| 23 |
+
root = Path(args.root) if args.root else Path(tempfile.mkdtemp(prefix="key-farm-"))
|
| 24 |
+
if args.clean and root.exists():
|
| 25 |
+
shutil.rmtree(root)
|
| 26 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
config = load_config()
|
| 29 |
+
config["population"] = {
|
| 30 |
+
**config.get("population", {}),
|
| 31 |
+
"size": args.nodes,
|
| 32 |
+
"dimensions": args.dimensions,
|
| 33 |
+
"trait_keys": [f"x{i}" for i in range(args.dimensions)],
|
| 34 |
+
"use_brains": False,
|
| 35 |
+
}
|
| 36 |
+
config["brain"] = {**config.get("brain", {}), "enabled": False}
|
| 37 |
+
config["embedding_brain"] = {**config.get("embedding_brain", {}), "enabled": False}
|
| 38 |
+
config["dreamer_brain"] = {**config.get("dreamer_brain", {}), "enabled": False}
|
| 39 |
+
config["scarecrow_brain"] = {**config.get("scarecrow_brain", {}), "enabled": False}
|
| 40 |
+
config["council_brain"] = {**config.get("council_brain", {}), "enabled": False}
|
| 41 |
+
config["fitness"] = {**config.get("fitness", {}), "function": args.fitness}
|
| 42 |
+
|
| 43 |
+
policy = FarmPolicy(
|
| 44 |
+
target_batch_size=args.shard_size,
|
| 45 |
+
max_workers=args.workers,
|
| 46 |
+
budget_hourly_usd=args.workers * 0.03,
|
| 47 |
+
)
|
| 48 |
+
store = FarmStore(root)
|
| 49 |
+
controller = FarmController(store, policy)
|
| 50 |
+
population = create_population(args.nodes, config["population"]["trait_keys"], brain_factory=None)
|
| 51 |
+
run_id, jobs = controller.submit_generation(population, config, generation=0)
|
| 52 |
+
|
| 53 |
+
threads = [
|
| 54 |
+
threading.Thread(
|
| 55 |
+
target=worker_loop,
|
| 56 |
+
args=(store, f"local_{idx}", policy),
|
| 57 |
+
kwargs={"poll_seconds": 0.05, "once": False},
|
| 58 |
+
daemon=True,
|
| 59 |
+
)
|
| 60 |
+
for idx in range(args.workers)
|
| 61 |
+
]
|
| 62 |
+
for thread in threads:
|
| 63 |
+
thread.start()
|
| 64 |
+
|
| 65 |
+
expected = {node.id for node in population}
|
| 66 |
+
controller.collect_generation(run_id, 0, expected, timeout_seconds=args.timeout)
|
| 67 |
+
fitness_by_id = controller.merge_into_population(population, run_id, 0)
|
| 68 |
+
best = max(fitness_by_id.values()) if fitness_by_id else None
|
| 69 |
+
controller.shutdown_workers(reason="simulation_complete", run_id=run_id, generation=0)
|
| 70 |
+
for thread in threads:
|
| 71 |
+
thread.join(timeout=1.0)
|
| 72 |
+
|
| 73 |
+
print(json.dumps({
|
| 74 |
+
"root": str(root),
|
| 75 |
+
"run_id": run_id,
|
| 76 |
+
"jobs": len(jobs),
|
| 77 |
+
"nodes": len(population),
|
| 78 |
+
"results": len(fitness_by_id),
|
| 79 |
+
"best_fitness": best,
|
| 80 |
+
}, indent=2))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def regulate(args: argparse.Namespace) -> None:
|
| 84 |
+
store = FarmStore(args.root)
|
| 85 |
+
intent = RegulationIntent(
|
| 86 |
+
mode=args.mode,
|
| 87 |
+
reason=args.reason,
|
| 88 |
+
target_workers=args.target_workers,
|
| 89 |
+
formation=args.formation or None,
|
| 90 |
+
run_id=args.run_id or None,
|
| 91 |
+
generation=args.generation,
|
| 92 |
+
issued_by=args.issued_by,
|
| 93 |
+
)
|
| 94 |
+
store.write_regulation(intent)
|
| 95 |
+
print(json.dumps(intent.to_dict(), indent=2))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def plan(args: argparse.Namespace) -> None:
|
| 99 |
+
policy = FarmPolicy(
|
| 100 |
+
max_workers=args.max_workers,
|
| 101 |
+
budget_hourly_usd=args.budget_hourly_usd,
|
| 102 |
+
cpu_space_hourly_usd=args.cpu_space_hourly_usd,
|
| 103 |
+
target_batch_size=args.shard_size,
|
| 104 |
+
)
|
| 105 |
+
needed_for_jobs = max(1, (args.pending_jobs + args.jobs_per_worker - 1) // args.jobs_per_worker)
|
| 106 |
+
requested = max(0, min(policy.effective_max_workers(), needed_for_jobs) - args.active_workers)
|
| 107 |
+
print(json.dumps({
|
| 108 |
+
"pending_jobs": args.pending_jobs,
|
| 109 |
+
"active_workers": args.active_workers,
|
| 110 |
+
"requested_workers": requested,
|
| 111 |
+
"effective_max_workers": policy.effective_max_workers(),
|
| 112 |
+
"hourly_budget_usd": policy.budget_hourly_usd,
|
| 113 |
+
"cpu_space_hourly_usd": policy.cpu_space_hourly_usd,
|
| 114 |
+
}, indent=2))
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def faculty(args: argparse.Namespace) -> None:
|
| 118 |
+
if args.cocoon_capabilities:
|
| 119 |
+
manifest = load_cocoon_faculty(args.cocoon_capabilities)
|
| 120 |
+
else:
|
| 121 |
+
manifest = farm_faculty_manifest()
|
| 122 |
+
print(json.dumps(manifest, indent=2))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def trace(args: argparse.Namespace) -> None:
|
| 126 |
+
policy = FarmPolicy(
|
| 127 |
+
max_workers=args.max_workers,
|
| 128 |
+
budget_hourly_usd=args.budget_hourly_usd,
|
| 129 |
+
cpu_space_hourly_usd=args.cpu_space_hourly_usd,
|
| 130 |
+
target_batch_size=args.shard_size,
|
| 131 |
+
)
|
| 132 |
+
report = symbiotic_trace(FarmStore(args.root), policy, receipt=not args.no_receipt)
|
| 133 |
+
print(json.dumps(report, indent=2))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def scale_once(args: argparse.Namespace) -> None:
|
| 137 |
+
policy = ScalePolicy(
|
| 138 |
+
template_space=args.template_space,
|
| 139 |
+
namespace=args.namespace,
|
| 140 |
+
worker_prefix=args.worker_prefix,
|
| 141 |
+
formation=args.formation,
|
| 142 |
+
hardware=args.hardware,
|
| 143 |
+
root_in_space=args.root_in_space,
|
| 144 |
+
max_workers=args.max_workers,
|
| 145 |
+
max_starts_per_tick=args.max_starts_per_tick,
|
| 146 |
+
start_cooldown_seconds=args.start_cooldown_seconds,
|
| 147 |
+
sleep_time_seconds=args.sleep_time_seconds,
|
| 148 |
+
budget_hourly_usd=args.budget_hourly_usd,
|
| 149 |
+
cpu_space_hourly_usd=args.cpu_space_hourly_usd,
|
| 150 |
+
private=not args.public,
|
| 151 |
+
dry_run=not args.apply,
|
| 152 |
+
pause_on_controller_lost=not args.keep_workers_without_controller,
|
| 153 |
+
)
|
| 154 |
+
report = MeshScaleScaler(FarmStore(args.root), policy).tick()
|
| 155 |
+
print(json.dumps(report, indent=2))
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def main() -> None:
|
| 159 |
+
parser = argparse.ArgumentParser(description="KEY CPU farm tools")
|
| 160 |
+
sub = parser.add_subparsers(dest="command", required=True)
|
| 161 |
+
|
| 162 |
+
sim = sub.add_parser("simulate", help="Run a local CPU farm smoke simulation")
|
| 163 |
+
sim.add_argument("--root", default="")
|
| 164 |
+
sim.add_argument("--clean", action="store_true")
|
| 165 |
+
sim.add_argument("--nodes", type=int, default=32)
|
| 166 |
+
sim.add_argument("--dimensions", type=int, default=4)
|
| 167 |
+
sim.add_argument("--workers", type=int, default=4)
|
| 168 |
+
sim.add_argument("--shard-size", type=int, default=8)
|
| 169 |
+
sim.add_argument("--fitness", default="rastrigin")
|
| 170 |
+
sim.add_argument("--timeout", type=float, default=60.0)
|
| 171 |
+
sim.set_defaults(func=simulate)
|
| 172 |
+
|
| 173 |
+
plan_cmd = sub.add_parser("plan", help="Plan CPU Space capacity")
|
| 174 |
+
plan_cmd.add_argument("--pending-jobs", type=int, required=True)
|
| 175 |
+
plan_cmd.add_argument("--active-workers", type=int, default=0)
|
| 176 |
+
plan_cmd.add_argument("--jobs-per-worker", type=int, default=2)
|
| 177 |
+
plan_cmd.add_argument("--max-workers", type=int, default=24)
|
| 178 |
+
plan_cmd.add_argument("--budget-hourly-usd", type=float, default=0.72)
|
| 179 |
+
plan_cmd.add_argument("--cpu-space-hourly-usd", type=float, default=0.03)
|
| 180 |
+
plan_cmd.add_argument("--shard-size", type=int, default=24)
|
| 181 |
+
plan_cmd.set_defaults(func=plan)
|
| 182 |
+
|
| 183 |
+
regulate_cmd = sub.add_parser("regulate", help="Publish farm pulse regulation intent")
|
| 184 |
+
regulate_cmd.add_argument("--root", default="data/farm")
|
| 185 |
+
regulate_cmd.add_argument("--mode", choices=["run", "drain", "shutdown"], required=True)
|
| 186 |
+
regulate_cmd.add_argument("--reason", default="manual_regulation")
|
| 187 |
+
regulate_cmd.add_argument("--target-workers", type=int, default=None)
|
| 188 |
+
regulate_cmd.add_argument("--formation", default="")
|
| 189 |
+
regulate_cmd.add_argument("--run-id", default="")
|
| 190 |
+
regulate_cmd.add_argument("--generation", type=int, default=None)
|
| 191 |
+
regulate_cmd.add_argument("--issued-by", default="key-controller")
|
| 192 |
+
regulate_cmd.set_defaults(func=regulate)
|
| 193 |
+
|
| 194 |
+
faculty_cmd = sub.add_parser("faculty", help="Print CPU farm faculty manifest")
|
| 195 |
+
faculty_cmd.add_argument(
|
| 196 |
+
"--cocoon-capabilities",
|
| 197 |
+
default="",
|
| 198 |
+
help="Optional cocoon_capabilities.py path to extract faculty-only resources from.",
|
| 199 |
+
)
|
| 200 |
+
faculty_cmd.set_defaults(func=faculty)
|
| 201 |
+
|
| 202 |
+
trace_cmd = sub.add_parser("symbiotic-trace", help="Trace CPU farm traffic and pulse health")
|
| 203 |
+
trace_cmd.add_argument("--root", default="data/farm")
|
| 204 |
+
trace_cmd.add_argument("--max-workers", type=int, default=24)
|
| 205 |
+
trace_cmd.add_argument("--budget-hourly-usd", type=float, default=0.72)
|
| 206 |
+
trace_cmd.add_argument("--cpu-space-hourly-usd", type=float, default=0.03)
|
| 207 |
+
trace_cmd.add_argument("--shard-size", type=int, default=24)
|
| 208 |
+
trace_cmd.add_argument("--no-receipt", action="store_true")
|
| 209 |
+
trace_cmd.set_defaults(func=trace)
|
| 210 |
+
|
| 211 |
+
scale_cmd = sub.add_parser("scale-once", help="Honor one tick of MeshScale CPU Space scaling")
|
| 212 |
+
scale_cmd.add_argument("--root", default="data/farm")
|
| 213 |
+
scale_cmd.add_argument("--template-space", required=True)
|
| 214 |
+
scale_cmd.add_argument("--namespace", required=True)
|
| 215 |
+
scale_cmd.add_argument("--worker-prefix", default="meshscale-worker")
|
| 216 |
+
scale_cmd.add_argument("--formation", default="cpu-worker")
|
| 217 |
+
scale_cmd.add_argument("--hardware", default="cpu-upgrade")
|
| 218 |
+
scale_cmd.add_argument("--root-in-space", default="/data/farm")
|
| 219 |
+
scale_cmd.add_argument("--max-workers", type=int, default=24)
|
| 220 |
+
scale_cmd.add_argument("--max-starts-per-tick", type=int, default=2)
|
| 221 |
+
scale_cmd.add_argument("--start-cooldown-seconds", type=int, default=120)
|
| 222 |
+
scale_cmd.add_argument("--sleep-time-seconds", type=int, default=300)
|
| 223 |
+
scale_cmd.add_argument("--budget-hourly-usd", type=float, default=0.72)
|
| 224 |
+
scale_cmd.add_argument("--cpu-space-hourly-usd", type=float, default=0.03)
|
| 225 |
+
scale_cmd.add_argument("--public", action="store_true")
|
| 226 |
+
scale_cmd.add_argument("--apply", action="store_true", help="Actually call the Hugging Face API")
|
| 227 |
+
scale_cmd.add_argument("--keep-workers-without-controller", action="store_true")
|
| 228 |
+
scale_cmd.set_defaults(func=scale_once)
|
| 229 |
+
|
| 230 |
+
args = parser.parse_args()
|
| 231 |
+
args.func(args)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
main()
|
key_farm/contracts.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import asdict, dataclass, field
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import Any, Literal
|
| 6 |
+
import math
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
SCHEMA_VERSION = 1
|
| 11 |
+
JobState = Literal["pending", "claimed", "done", "failed", "refracted"]
|
| 12 |
+
RegulationMode = Literal["run", "drain", "shutdown"]
|
| 13 |
+
ResourceKind = Literal["cpu"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def utc_now() -> str:
|
| 17 |
+
return datetime.now(timezone.utc).isoformat()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def new_id(prefix: str) -> str:
|
| 21 |
+
return f"{prefix}_{uuid.uuid4().hex[:16]}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class FarmPolicy:
|
| 26 |
+
"""Budget and safety boundary for dynamic CPU Space expansion."""
|
| 27 |
+
|
| 28 |
+
target_batch_size: int = 24
|
| 29 |
+
min_batch_size: int = 4
|
| 30 |
+
max_depth: int = 4
|
| 31 |
+
max_workers: int = 24
|
| 32 |
+
max_outstanding_jobs: int = 512
|
| 33 |
+
job_ttl_seconds: int = 3600
|
| 34 |
+
claim_ttl_seconds: int = 900
|
| 35 |
+
pulse_ttl_seconds: int = 120
|
| 36 |
+
controller_pulse_ttl_seconds: int = 180
|
| 37 |
+
cpu_space_hourly_usd: float = 0.03
|
| 38 |
+
budget_hourly_usd: float = 0.72
|
| 39 |
+
expansion_cooldown_seconds: int = 180
|
| 40 |
+
exit_when_pulse_lost: bool = True
|
| 41 |
+
pulse_rate: float = 1.0
|
| 42 |
+
allowed_formations: tuple[str, ...] = ("cpu-worker",)
|
| 43 |
+
|
| 44 |
+
def effective_max_workers(self) -> int:
|
| 45 |
+
budget_workers = math.floor(self.budget_hourly_usd / self.cpu_space_hourly_usd)
|
| 46 |
+
return max(1, min(self.max_workers, budget_workers))
|
| 47 |
+
|
| 48 |
+
def to_dict(self) -> dict[str, Any]:
|
| 49 |
+
data = asdict(self)
|
| 50 |
+
data["effective_max_workers"] = self.effective_max_workers()
|
| 51 |
+
return data
|
| 52 |
+
|
| 53 |
+
@classmethod
|
| 54 |
+
def from_dict(cls, data: dict[str, Any] | None) -> "FarmPolicy":
|
| 55 |
+
if not data:
|
| 56 |
+
return cls()
|
| 57 |
+
data = dict(data)
|
| 58 |
+
aliases = {
|
| 59 |
+
"heartbeat_ttl_seconds": "pulse_ttl_seconds",
|
| 60 |
+
"controller_heartbeat_ttl_seconds": "controller_pulse_ttl_seconds",
|
| 61 |
+
"exit_when_controller_lost": "exit_when_pulse_lost",
|
| 62 |
+
}
|
| 63 |
+
for old_key, new_key in aliases.items():
|
| 64 |
+
if old_key in data and new_key not in data:
|
| 65 |
+
data[new_key] = data[old_key]
|
| 66 |
+
allowed = {field.name for field in cls.__dataclass_fields__.values()}
|
| 67 |
+
return cls(**{key: value for key, value in data.items() if key in allowed})
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass(frozen=True)
|
| 71 |
+
class NodePayload:
|
| 72 |
+
node_id: str
|
| 73 |
+
encoding: str
|
| 74 |
+
payload: str
|
| 75 |
+
sha256: str
|
| 76 |
+
|
| 77 |
+
def to_dict(self) -> dict[str, Any]:
|
| 78 |
+
return asdict(self)
|
| 79 |
+
|
| 80 |
+
@classmethod
|
| 81 |
+
def from_dict(cls, data: dict[str, Any]) -> "NodePayload":
|
| 82 |
+
return cls(**data)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass(frozen=True)
|
| 86 |
+
class ShardJob:
|
| 87 |
+
run_id: str
|
| 88 |
+
generation: int
|
| 89 |
+
task_type: str
|
| 90 |
+
task_name: str
|
| 91 |
+
config: dict[str, Any]
|
| 92 |
+
nodes: list[NodePayload]
|
| 93 |
+
formation: str = "cpu-worker"
|
| 94 |
+
shard_id: str = field(default_factory=lambda: new_id("shard"))
|
| 95 |
+
parent_shard_id: str | None = None
|
| 96 |
+
depth: int = 0
|
| 97 |
+
created_at: str = field(default_factory=utc_now)
|
| 98 |
+
schema_version: int = SCHEMA_VERSION
|
| 99 |
+
|
| 100 |
+
def to_dict(self) -> dict[str, Any]:
|
| 101 |
+
data = asdict(self)
|
| 102 |
+
data["nodes"] = [node.to_dict() for node in self.nodes]
|
| 103 |
+
return data
|
| 104 |
+
|
| 105 |
+
@classmethod
|
| 106 |
+
def from_dict(cls, data: dict[str, Any]) -> "ShardJob":
|
| 107 |
+
data = dict(data)
|
| 108 |
+
if "task_name" not in data and "fitness_name" in data:
|
| 109 |
+
data["task_name"] = data.pop("fitness_name")
|
| 110 |
+
if "task_type" not in data:
|
| 111 |
+
data["task_type"] = "key_fitness"
|
| 112 |
+
data["nodes"] = [NodePayload.from_dict(node) for node in data.get("nodes", [])]
|
| 113 |
+
allowed = {field.name for field in cls.__dataclass_fields__.values()}
|
| 114 |
+
return cls(**{key: value for key, value in data.items() if key in allowed})
|
| 115 |
+
|
| 116 |
+
def split(self, policy: FarmPolicy) -> list["ShardJob"]:
|
| 117 |
+
if len(self.nodes) <= policy.target_batch_size:
|
| 118 |
+
return [self]
|
| 119 |
+
if self.depth >= policy.max_depth:
|
| 120 |
+
return [self]
|
| 121 |
+
|
| 122 |
+
batch_size = max(policy.min_batch_size, policy.target_batch_size)
|
| 123 |
+
children = []
|
| 124 |
+
for start in range(0, len(self.nodes), batch_size):
|
| 125 |
+
children.append(
|
| 126 |
+
ShardJob(
|
| 127 |
+
run_id=self.run_id,
|
| 128 |
+
generation=self.generation,
|
| 129 |
+
task_type=self.task_type,
|
| 130 |
+
task_name=self.task_name,
|
| 131 |
+
config=self.config,
|
| 132 |
+
nodes=self.nodes[start : start + batch_size],
|
| 133 |
+
formation=self.formation,
|
| 134 |
+
parent_shard_id=self.shard_id,
|
| 135 |
+
depth=self.depth + 1,
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
return children
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def fitness_name(self) -> str:
|
| 142 |
+
return self.task_name
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@dataclass(frozen=True)
|
| 146 |
+
class NodeResult:
|
| 147 |
+
node_id: str
|
| 148 |
+
fitness: float | None
|
| 149 |
+
duration_ms: float
|
| 150 |
+
error: str | None = None
|
| 151 |
+
output: Any | None = None
|
| 152 |
+
|
| 153 |
+
def to_dict(self) -> dict[str, Any]:
|
| 154 |
+
return asdict(self)
|
| 155 |
+
|
| 156 |
+
@classmethod
|
| 157 |
+
def from_dict(cls, data: dict[str, Any]) -> "NodeResult":
|
| 158 |
+
return cls(**data)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@dataclass(frozen=True)
|
| 162 |
+
class ShardResult:
|
| 163 |
+
run_id: str
|
| 164 |
+
generation: int
|
| 165 |
+
shard_id: str
|
| 166 |
+
worker_id: str
|
| 167 |
+
status: JobState
|
| 168 |
+
node_results: list[NodeResult]
|
| 169 |
+
started_at: str
|
| 170 |
+
finished_at: str = field(default_factory=utc_now)
|
| 171 |
+
message: str = ""
|
| 172 |
+
schema_version: int = SCHEMA_VERSION
|
| 173 |
+
|
| 174 |
+
def to_dict(self) -> dict[str, Any]:
|
| 175 |
+
data = asdict(self)
|
| 176 |
+
data["node_results"] = [result.to_dict() for result in self.node_results]
|
| 177 |
+
return data
|
| 178 |
+
|
| 179 |
+
@classmethod
|
| 180 |
+
def from_dict(cls, data: dict[str, Any]) -> "ShardResult":
|
| 181 |
+
data = dict(data)
|
| 182 |
+
data["node_results"] = [
|
| 183 |
+
NodeResult.from_dict(result) for result in data.get("node_results", [])
|
| 184 |
+
]
|
| 185 |
+
return cls(**data)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@dataclass(frozen=True)
|
| 189 |
+
class ExpansionRequest:
|
| 190 |
+
run_id: str
|
| 191 |
+
generation: int
|
| 192 |
+
reason: str
|
| 193 |
+
requested_workers: int
|
| 194 |
+
active_workers: int
|
| 195 |
+
pending_jobs: int
|
| 196 |
+
policy: dict[str, Any]
|
| 197 |
+
formation: str = "cpu-worker"
|
| 198 |
+
resource_kind: ResourceKind = "cpu"
|
| 199 |
+
hardware: str = "cpu-upgrade"
|
| 200 |
+
space_template: str | None = None
|
| 201 |
+
request_id: str = field(default_factory=lambda: new_id("expand"))
|
| 202 |
+
created_at: str = field(default_factory=utc_now)
|
| 203 |
+
schema_version: int = SCHEMA_VERSION
|
| 204 |
+
|
| 205 |
+
def to_dict(self) -> dict[str, Any]:
|
| 206 |
+
return asdict(self)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@dataclass(frozen=True)
|
| 210 |
+
class WorkerPulse:
|
| 211 |
+
worker_id: str
|
| 212 |
+
run_id: str | None
|
| 213 |
+
generation: int | None
|
| 214 |
+
status: str
|
| 215 |
+
current_shard_id: str | None = None
|
| 216 |
+
formation: str = "cpu-worker"
|
| 217 |
+
pulse: dict[str, float] = field(default_factory=dict)
|
| 218 |
+
timestamp: str = field(default_factory=utc_now)
|
| 219 |
+
schema_version: int = SCHEMA_VERSION
|
| 220 |
+
|
| 221 |
+
def to_dict(self) -> dict[str, Any]:
|
| 222 |
+
return asdict(self)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@dataclass(frozen=True)
|
| 226 |
+
class ControllerPulse:
|
| 227 |
+
controller_id: str
|
| 228 |
+
status: str
|
| 229 |
+
run_id: str | None = None
|
| 230 |
+
generation: int | None = None
|
| 231 |
+
pulse: dict[str, float] = field(default_factory=dict)
|
| 232 |
+
timestamp: str = field(default_factory=utc_now)
|
| 233 |
+
schema_version: int = SCHEMA_VERSION
|
| 234 |
+
|
| 235 |
+
def to_dict(self) -> dict[str, Any]:
|
| 236 |
+
return asdict(self)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@dataclass(frozen=True)
|
| 240 |
+
class RegulationIntent:
|
| 241 |
+
mode: RegulationMode = "run"
|
| 242 |
+
reason: str = "controller_running"
|
| 243 |
+
target_workers: int | None = None
|
| 244 |
+
formation: str | None = None
|
| 245 |
+
run_id: str | None = None
|
| 246 |
+
generation: int | None = None
|
| 247 |
+
issued_by: str = "key-controller"
|
| 248 |
+
issued_at: str = field(default_factory=utc_now)
|
| 249 |
+
schema_version: int = SCHEMA_VERSION
|
| 250 |
+
|
| 251 |
+
def to_dict(self) -> dict[str, Any]:
|
| 252 |
+
return asdict(self)
|
| 253 |
+
|
| 254 |
+
@classmethod
|
| 255 |
+
def from_dict(cls, data: dict[str, Any] | None) -> "RegulationIntent":
|
| 256 |
+
if not data:
|
| 257 |
+
return cls()
|
| 258 |
+
allowed = {field.name for field in cls.__dataclass_fields__.values()}
|
| 259 |
+
return cls(**{key: value for key, value in data.items() if key in allowed})
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
WorkerHeartbeat = WorkerPulse
|
| 263 |
+
ControllerHeartbeat = ControllerPulse
|
| 264 |
+
FarmControl = RegulationIntent
|
| 265 |
+
WorkItem = NodePayload
|
| 266 |
+
WorkResult = NodeResult
|
key_farm/controller.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from .contracts import (
|
| 7 |
+
ControllerPulse,
|
| 8 |
+
ExpansionRequest,
|
| 9 |
+
FarmPolicy,
|
| 10 |
+
RegulationIntent,
|
| 11 |
+
ShardJob,
|
| 12 |
+
new_id,
|
| 13 |
+
)
|
| 14 |
+
from .evaluator import apply_results_to_population
|
| 15 |
+
from .regulation import create_pulse, tick_pulse
|
| 16 |
+
from .serialization import pack_item, pack_node
|
| 17 |
+
from .store import FarmStore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class FarmController:
|
| 21 |
+
"""Controller-side API for dispatching fitness evaluation shards."""
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
store: FarmStore,
|
| 26 |
+
policy: FarmPolicy | None = None,
|
| 27 |
+
controller_id: str = "key-controller",
|
| 28 |
+
):
|
| 29 |
+
self.store = store
|
| 30 |
+
self.policy = policy or FarmPolicy()
|
| 31 |
+
self.controller_id = controller_id
|
| 32 |
+
self._pulse = create_pulse(self.policy.pulse_rate)
|
| 33 |
+
|
| 34 |
+
def pulse(
|
| 35 |
+
self,
|
| 36 |
+
*,
|
| 37 |
+
status: str = "running",
|
| 38 |
+
run_id: str | None = None,
|
| 39 |
+
generation: int | None = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
self.store.write_controller_pulse(
|
| 42 |
+
ControllerPulse(
|
| 43 |
+
controller_id=self.controller_id,
|
| 44 |
+
status=status,
|
| 45 |
+
run_id=run_id,
|
| 46 |
+
generation=generation,
|
| 47 |
+
pulse=tick_pulse(self._pulse),
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def heartbeat(
|
| 52 |
+
self,
|
| 53 |
+
*,
|
| 54 |
+
status: str = "running",
|
| 55 |
+
run_id: str | None = None,
|
| 56 |
+
generation: int | None = None,
|
| 57 |
+
) -> None:
|
| 58 |
+
self.pulse(status=status, run_id=run_id, generation=generation)
|
| 59 |
+
|
| 60 |
+
def resume_workers(
|
| 61 |
+
self,
|
| 62 |
+
*,
|
| 63 |
+
reason: str = "controller_running",
|
| 64 |
+
formation: str | None = None,
|
| 65 |
+
) -> None:
|
| 66 |
+
self.store.write_regulation(
|
| 67 |
+
RegulationIntent(
|
| 68 |
+
mode="run",
|
| 69 |
+
reason=reason,
|
| 70 |
+
formation=formation,
|
| 71 |
+
issued_by=self.controller_id,
|
| 72 |
+
)
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
def drain_workers(
|
| 76 |
+
self,
|
| 77 |
+
target_workers: int = 0,
|
| 78 |
+
*,
|
| 79 |
+
reason: str = "controller_downshift",
|
| 80 |
+
formation: str | None = None,
|
| 81 |
+
run_id: str | None = None,
|
| 82 |
+
generation: int | None = None,
|
| 83 |
+
) -> None:
|
| 84 |
+
self.store.write_regulation(
|
| 85 |
+
RegulationIntent(
|
| 86 |
+
mode="drain",
|
| 87 |
+
reason=reason,
|
| 88 |
+
target_workers=max(0, target_workers),
|
| 89 |
+
formation=formation,
|
| 90 |
+
run_id=run_id,
|
| 91 |
+
generation=generation,
|
| 92 |
+
issued_by=self.controller_id,
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def shutdown_workers(
|
| 97 |
+
self,
|
| 98 |
+
*,
|
| 99 |
+
reason: str = "key_space_shutdown",
|
| 100 |
+
formation: str | None = None,
|
| 101 |
+
run_id: str | None = None,
|
| 102 |
+
generation: int | None = None,
|
| 103 |
+
) -> None:
|
| 104 |
+
self.store.write_regulation(
|
| 105 |
+
RegulationIntent(
|
| 106 |
+
mode="shutdown",
|
| 107 |
+
reason=reason,
|
| 108 |
+
target_workers=0,
|
| 109 |
+
formation=formation,
|
| 110 |
+
run_id=run_id,
|
| 111 |
+
generation=generation,
|
| 112 |
+
issued_by=self.controller_id,
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
def create_run(self, config: dict[str, Any], run_id: str | None = None) -> str:
|
| 117 |
+
run_id = run_id or new_id("run")
|
| 118 |
+
self.resume_workers(reason="run_created")
|
| 119 |
+
self.pulse(run_id=run_id)
|
| 120 |
+
self.store.create_run(run_id, config, self.policy)
|
| 121 |
+
return run_id
|
| 122 |
+
|
| 123 |
+
def submit_generation(
|
| 124 |
+
self,
|
| 125 |
+
population: list[Any],
|
| 126 |
+
config: dict[str, Any],
|
| 127 |
+
*,
|
| 128 |
+
run_id: str | None = None,
|
| 129 |
+
generation: int = 0,
|
| 130 |
+
fitness_name: str | None = None,
|
| 131 |
+
formation: str = "cpu-worker",
|
| 132 |
+
) -> tuple[str, list[ShardJob]]:
|
| 133 |
+
run_id = run_id or self.create_run(config)
|
| 134 |
+
fitness_name = fitness_name or config.get("fitness", {}).get("function", "unknown")
|
| 135 |
+
self.resume_workers(reason="generation_submitted", formation=formation)
|
| 136 |
+
self.pulse(run_id=run_id, generation=generation)
|
| 137 |
+
packed = [pack_node(node) for node in population]
|
| 138 |
+
jobs = []
|
| 139 |
+
batch_size = max(self.policy.min_batch_size, self.policy.target_batch_size)
|
| 140 |
+
|
| 141 |
+
for start in range(0, len(packed), batch_size):
|
| 142 |
+
jobs.append(
|
| 143 |
+
ShardJob(
|
| 144 |
+
run_id=run_id,
|
| 145 |
+
generation=generation,
|
| 146 |
+
task_type="key_fitness",
|
| 147 |
+
task_name=fitness_name,
|
| 148 |
+
config=config,
|
| 149 |
+
nodes=packed[start : start + batch_size],
|
| 150 |
+
formation=formation,
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
self.store.put_jobs(jobs)
|
| 155 |
+
self.store.append_event(
|
| 156 |
+
"farm_generation_submitted",
|
| 157 |
+
{
|
| 158 |
+
"run_id": run_id,
|
| 159 |
+
"generation": generation,
|
| 160 |
+
"nodes": len(packed),
|
| 161 |
+
"jobs": len(jobs),
|
| 162 |
+
"fitness_name": fitness_name,
|
| 163 |
+
},
|
| 164 |
+
)
|
| 165 |
+
self._request_initial_capacity(run_id, generation, len(jobs), formation)
|
| 166 |
+
return run_id, jobs
|
| 167 |
+
|
| 168 |
+
def submit_work(
|
| 169 |
+
self,
|
| 170 |
+
items: list[Any],
|
| 171 |
+
config: dict[str, Any],
|
| 172 |
+
*,
|
| 173 |
+
task_type: str = "python_callable",
|
| 174 |
+
task_name: str = "default",
|
| 175 |
+
formation: str = "cpu-worker",
|
| 176 |
+
run_id: str | None = None,
|
| 177 |
+
generation: int = 0,
|
| 178 |
+
) -> tuple[str, list[ShardJob]]:
|
| 179 |
+
run_id = run_id or self.create_run(config)
|
| 180 |
+
self.resume_workers(reason="work_submitted", formation=formation)
|
| 181 |
+
self.pulse(run_id=run_id, generation=generation)
|
| 182 |
+
packed = [pack_item(item) for item in items]
|
| 183 |
+
jobs = []
|
| 184 |
+
batch_size = max(self.policy.min_batch_size, self.policy.target_batch_size)
|
| 185 |
+
|
| 186 |
+
for start in range(0, len(packed), batch_size):
|
| 187 |
+
jobs.append(
|
| 188 |
+
ShardJob(
|
| 189 |
+
run_id=run_id,
|
| 190 |
+
generation=generation,
|
| 191 |
+
task_type=task_type,
|
| 192 |
+
task_name=task_name,
|
| 193 |
+
config=config,
|
| 194 |
+
nodes=packed[start : start + batch_size],
|
| 195 |
+
formation=formation,
|
| 196 |
+
)
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
self.store.put_jobs(jobs)
|
| 200 |
+
self.store.append_event(
|
| 201 |
+
"farm_work_submitted",
|
| 202 |
+
{
|
| 203 |
+
"run_id": run_id,
|
| 204 |
+
"generation": generation,
|
| 205 |
+
"items": len(packed),
|
| 206 |
+
"jobs": len(jobs),
|
| 207 |
+
"task_type": task_type,
|
| 208 |
+
"task_name": task_name,
|
| 209 |
+
"formation": formation,
|
| 210 |
+
},
|
| 211 |
+
)
|
| 212 |
+
self._request_initial_capacity(run_id, generation, len(jobs), formation)
|
| 213 |
+
return run_id, jobs
|
| 214 |
+
|
| 215 |
+
def collect_generation(
|
| 216 |
+
self,
|
| 217 |
+
run_id: str,
|
| 218 |
+
generation: int,
|
| 219 |
+
expected_node_ids: set[str],
|
| 220 |
+
*,
|
| 221 |
+
timeout_seconds: float = 300.0,
|
| 222 |
+
poll_seconds: float = 1.0,
|
| 223 |
+
) -> dict[str, float]:
|
| 224 |
+
deadline = time.time() + timeout_seconds
|
| 225 |
+
while True:
|
| 226 |
+
self.pulse(status="collecting", run_id=run_id, generation=generation)
|
| 227 |
+
fitness_by_id = self.result_map(run_id, generation)
|
| 228 |
+
if expected_node_ids.issubset(fitness_by_id.keys()):
|
| 229 |
+
return fitness_by_id
|
| 230 |
+
if time.time() >= deadline:
|
| 231 |
+
missing = sorted(expected_node_ids - fitness_by_id.keys())
|
| 232 |
+
raise TimeoutError(f"missing {len(missing)} fitness results: {missing[:10]}")
|
| 233 |
+
time.sleep(poll_seconds)
|
| 234 |
+
|
| 235 |
+
def result_map(self, run_id: str, generation: int) -> dict[str, float]:
|
| 236 |
+
fitness_by_id: dict[str, float] = {}
|
| 237 |
+
for shard_result in self.store.load_results(run_id, generation):
|
| 238 |
+
for node_result in shard_result.node_results:
|
| 239 |
+
if node_result.error is None and node_result.fitness is not None:
|
| 240 |
+
fitness_by_id[node_result.node_id] = node_result.fitness
|
| 241 |
+
return fitness_by_id
|
| 242 |
+
|
| 243 |
+
def merge_into_population(
|
| 244 |
+
self,
|
| 245 |
+
population: list[Any],
|
| 246 |
+
run_id: str,
|
| 247 |
+
generation: int,
|
| 248 |
+
) -> dict[str, float]:
|
| 249 |
+
results = self.store.load_results(run_id, generation)
|
| 250 |
+
return apply_results_to_population(population, results)
|
| 251 |
+
|
| 252 |
+
def _request_initial_capacity(
|
| 253 |
+
self,
|
| 254 |
+
run_id: str,
|
| 255 |
+
generation: int,
|
| 256 |
+
pending_jobs: int,
|
| 257 |
+
formation: str,
|
| 258 |
+
) -> None:
|
| 259 |
+
self.pulse(run_id=run_id, generation=generation)
|
| 260 |
+
active = self.store.active_worker_count(self.policy.pulse_ttl_seconds, formation)
|
| 261 |
+
if active >= min(pending_jobs, self.policy.effective_max_workers()):
|
| 262 |
+
return
|
| 263 |
+
request = ExpansionRequest(
|
| 264 |
+
run_id=run_id,
|
| 265 |
+
generation=generation,
|
| 266 |
+
reason="generation_submitted",
|
| 267 |
+
requested_workers=min(
|
| 268 |
+
pending_jobs,
|
| 269 |
+
self.policy.effective_max_workers() - active,
|
| 270 |
+
),
|
| 271 |
+
active_workers=active,
|
| 272 |
+
pending_jobs=pending_jobs,
|
| 273 |
+
policy=self.policy.to_dict(),
|
| 274 |
+
formation=formation,
|
| 275 |
+
)
|
| 276 |
+
self.store.write_expansion_request(request)
|
key_farm/evaluator.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib
|
| 4 |
+
import inspect
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from collections.abc import Callable
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .contracts import NodeResult, ShardJob, ShardResult, utc_now
|
| 11 |
+
from .serialization import unpack_node
|
| 12 |
+
|
| 13 |
+
TaskHandler = Callable[[ShardJob, str], ShardResult]
|
| 14 |
+
_HANDLERS: dict[str, TaskHandler] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _fitness_function(config: dict[str, Any]):
|
| 18 |
+
from run import create_fitness_function
|
| 19 |
+
|
| 20 |
+
return create_fitness_function(config)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def register_handler(task_type: str, handler: TaskHandler) -> None:
|
| 24 |
+
_HANDLERS[task_type] = handler
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_handler(task_type: str) -> TaskHandler:
|
| 28 |
+
if task_type in _HANDLERS:
|
| 29 |
+
return _HANDLERS[task_type]
|
| 30 |
+
if task_type == "python_callable":
|
| 31 |
+
return evaluate_python_callable
|
| 32 |
+
raise KeyError(f"no CPU farm task handler registered for {task_type!r}")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def evaluate_job(job: ShardJob, worker_id: str) -> ShardResult:
|
| 36 |
+
"""Evaluate a CPU shard through the registered task handler."""
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
handler = get_handler(job.task_type)
|
| 40 |
+
return handler(job, worker_id)
|
| 41 |
+
except Exception as exc:
|
| 42 |
+
return ShardResult(
|
| 43 |
+
run_id=job.run_id,
|
| 44 |
+
generation=job.generation,
|
| 45 |
+
shard_id=job.shard_id,
|
| 46 |
+
worker_id=worker_id,
|
| 47 |
+
status="failed",
|
| 48 |
+
node_results=[],
|
| 49 |
+
started_at=utc_now(),
|
| 50 |
+
message=f"{type(exc).__name__}: {exc}",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def evaluate_key_fitness(job: ShardJob, worker_id: str) -> ShardResult:
|
| 55 |
+
"""Evaluate a KEY fitness shard and return fitness results keyed by node id."""
|
| 56 |
+
|
| 57 |
+
started_at = utc_now()
|
| 58 |
+
fitness_fn = _fitness_function(job.config)
|
| 59 |
+
node_results: list[NodeResult] = []
|
| 60 |
+
|
| 61 |
+
for node_payload in job.nodes:
|
| 62 |
+
start = time.time()
|
| 63 |
+
try:
|
| 64 |
+
node = unpack_node(node_payload)
|
| 65 |
+
fitness = float(fitness_fn(node))
|
| 66 |
+
node_results.append(
|
| 67 |
+
NodeResult(
|
| 68 |
+
node_id=node_payload.node_id,
|
| 69 |
+
fitness=fitness,
|
| 70 |
+
duration_ms=(time.time() - start) * 1000,
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
node_results.append(
|
| 75 |
+
NodeResult(
|
| 76 |
+
node_id=node_payload.node_id,
|
| 77 |
+
fitness=None,
|
| 78 |
+
duration_ms=(time.time() - start) * 1000,
|
| 79 |
+
error=f"{type(exc).__name__}: {exc}",
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
status = "done" if all(result.error is None for result in node_results) else "failed"
|
| 84 |
+
return ShardResult(
|
| 85 |
+
run_id=job.run_id,
|
| 86 |
+
generation=job.generation,
|
| 87 |
+
shard_id=job.shard_id,
|
| 88 |
+
worker_id=worker_id,
|
| 89 |
+
status=status,
|
| 90 |
+
node_results=node_results,
|
| 91 |
+
started_at=started_at,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _load_callable(path: str) -> Callable[..., Any]:
|
| 96 |
+
module_name, _, attr = path.partition(":")
|
| 97 |
+
if not module_name or not attr:
|
| 98 |
+
raise ValueError("callable path must be 'module:function'")
|
| 99 |
+
module = importlib.import_module(module_name)
|
| 100 |
+
target = module
|
| 101 |
+
for part in attr.split("."):
|
| 102 |
+
target = getattr(target, part)
|
| 103 |
+
if not callable(target):
|
| 104 |
+
raise TypeError(f"{path!r} is not callable")
|
| 105 |
+
return target
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _safe_output(value: Any) -> Any:
|
| 109 |
+
try:
|
| 110 |
+
json.dumps(value)
|
| 111 |
+
return value
|
| 112 |
+
except TypeError:
|
| 113 |
+
return repr(value)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def evaluate_python_callable(job: ShardJob, worker_id: str) -> ShardResult:
|
| 117 |
+
"""Generic CPU handler for worker templates that ship a local callable.
|
| 118 |
+
|
| 119 |
+
Configure with:
|
| 120 |
+
{"cpu_farm": {"callable": "package.module:function"}}
|
| 121 |
+
|
| 122 |
+
The callable receives either `(item, config, job)` or the leading subset of
|
| 123 |
+
those arguments, based on its signature. Numeric returns become `fitness`;
|
| 124 |
+
dict returns are stored in `output` and may include a numeric `fitness`.
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
started_at = utc_now()
|
| 128 |
+
handler_path = (
|
| 129 |
+
job.config.get("cpu_farm", {}).get("callable")
|
| 130 |
+
or job.config.get("farm", {}).get("callable")
|
| 131 |
+
)
|
| 132 |
+
if not handler_path:
|
| 133 |
+
raise ValueError("python_callable task requires cpu_farm.callable")
|
| 134 |
+
|
| 135 |
+
fn = _load_callable(str(handler_path))
|
| 136 |
+
params = inspect.signature(fn).parameters
|
| 137 |
+
node_results: list[NodeResult] = []
|
| 138 |
+
|
| 139 |
+
for node_payload in job.nodes:
|
| 140 |
+
start = time.time()
|
| 141 |
+
try:
|
| 142 |
+
item = unpack_node(node_payload)
|
| 143 |
+
args = [item, job.config, job][: len(params)]
|
| 144 |
+
value = fn(*args)
|
| 145 |
+
fitness = None
|
| 146 |
+
output = value
|
| 147 |
+
if isinstance(value, (int, float)):
|
| 148 |
+
fitness = float(value)
|
| 149 |
+
output = {"value": fitness}
|
| 150 |
+
elif isinstance(value, dict) and isinstance(value.get("fitness"), (int, float)):
|
| 151 |
+
fitness = float(value["fitness"])
|
| 152 |
+
node_results.append(
|
| 153 |
+
NodeResult(
|
| 154 |
+
node_id=node_payload.node_id,
|
| 155 |
+
fitness=fitness,
|
| 156 |
+
duration_ms=(time.time() - start) * 1000,
|
| 157 |
+
output=_safe_output(output),
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
+
except Exception as exc:
|
| 161 |
+
node_results.append(
|
| 162 |
+
NodeResult(
|
| 163 |
+
node_id=node_payload.node_id,
|
| 164 |
+
fitness=None,
|
| 165 |
+
duration_ms=(time.time() - start) * 1000,
|
| 166 |
+
error=f"{type(exc).__name__}: {exc}",
|
| 167 |
+
)
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
status = "done" if all(result.error is None for result in node_results) else "failed"
|
| 171 |
+
return ShardResult(
|
| 172 |
+
run_id=job.run_id,
|
| 173 |
+
generation=job.generation,
|
| 174 |
+
shard_id=job.shard_id,
|
| 175 |
+
worker_id=worker_id,
|
| 176 |
+
status=status,
|
| 177 |
+
node_results=node_results,
|
| 178 |
+
started_at=started_at,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def apply_results_to_population(population: list[Any], results: list[ShardResult]) -> dict[str, float]:
|
| 183 |
+
"""Merge remote fitness values back into live nodes."""
|
| 184 |
+
|
| 185 |
+
node_by_id = {str(getattr(node, "id", "")): node for node in population}
|
| 186 |
+
fitness_by_id: dict[str, float] = {}
|
| 187 |
+
for shard_result in results:
|
| 188 |
+
for node_result in shard_result.node_results:
|
| 189 |
+
if node_result.error is not None or node_result.fitness is None:
|
| 190 |
+
continue
|
| 191 |
+
fitness_by_id[node_result.node_id] = float(node_result.fitness)
|
| 192 |
+
|
| 193 |
+
for node_id, fitness in fitness_by_id.items():
|
| 194 |
+
node = node_by_id.get(node_id)
|
| 195 |
+
if node is not None:
|
| 196 |
+
node.fitness = fitness
|
| 197 |
+
|
| 198 |
+
return fitness_by_id
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
register_handler("key_fitness", evaluate_key_fitness)
|
key_farm/faculty.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
import sys
|
| 5 |
+
from dataclasses import asdict, dataclass, field
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ASSOCIATION_KEYS = {
|
| 11 |
+
"after",
|
| 12 |
+
"before",
|
| 13 |
+
"edges",
|
| 14 |
+
"followups",
|
| 15 |
+
"graph",
|
| 16 |
+
"links",
|
| 17 |
+
"routes",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class FarmFaculty:
|
| 23 |
+
key: str
|
| 24 |
+
group: str
|
| 25 |
+
label: str
|
| 26 |
+
teaches: str
|
| 27 |
+
payload: dict[str, Any] = field(default_factory=dict)
|
| 28 |
+
formation: str = "cpu-worker"
|
| 29 |
+
mutates: bool = False
|
| 30 |
+
risk_class: str = "inspect"
|
| 31 |
+
timeout_seconds: float = 15.0
|
| 32 |
+
expected_outputs: tuple[str, ...] = ()
|
| 33 |
+
|
| 34 |
+
def to_dict(self) -> dict[str, Any]:
|
| 35 |
+
data = asdict(self)
|
| 36 |
+
data["expected_outputs"] = list(self.expected_outputs)
|
| 37 |
+
return data
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
CPU_FARM_FACULTIES: tuple[FarmFaculty, ...] = (
|
| 41 |
+
FarmFaculty(
|
| 42 |
+
"health",
|
| 43 |
+
"diagnostics",
|
| 44 |
+
"Farm Health",
|
| 45 |
+
"Read pulse, worker, backlog, and regulation status.",
|
| 46 |
+
expected_outputs=("pulse", "workers", "traffic", "regulation"),
|
| 47 |
+
),
|
| 48 |
+
FarmFaculty(
|
| 49 |
+
"faculty",
|
| 50 |
+
"diagnostics",
|
| 51 |
+
"Faculty Manifest",
|
| 52 |
+
"Expose CPU-farm faculties without graph links or followup routes.",
|
| 53 |
+
expected_outputs=("capabilities", "groups"),
|
| 54 |
+
),
|
| 55 |
+
FarmFaculty(
|
| 56 |
+
"symbiotic_trace",
|
| 57 |
+
"diagnostics",
|
| 58 |
+
"Symbiotic Trace",
|
| 59 |
+
"Interpret CPU-farm traffic, pressure, pulse health, and failure signals.",
|
| 60 |
+
expected_outputs=("summary", "signals", "recommendations", "cascade"),
|
| 61 |
+
),
|
| 62 |
+
FarmFaculty(
|
| 63 |
+
"plan",
|
| 64 |
+
"regulation",
|
| 65 |
+
"Capacity Plan",
|
| 66 |
+
"Estimate CPU worker count from backlog, budget, and active pulses.",
|
| 67 |
+
payload={"pending_jobs": 120, "active_workers": 4, "budget_hourly_usd": 0.72},
|
| 68 |
+
expected_outputs=("requested_workers", "effective_max_workers"),
|
| 69 |
+
),
|
| 70 |
+
FarmFaculty(
|
| 71 |
+
"regulate",
|
| 72 |
+
"regulation",
|
| 73 |
+
"Pulse Regulation",
|
| 74 |
+
"Publish run, drain, or shutdown intent through the farm pulse ledger.",
|
| 75 |
+
payload={"mode": "drain", "target_workers": 4},
|
| 76 |
+
mutates=True,
|
| 77 |
+
risk_class="mutating",
|
| 78 |
+
expected_outputs=("mode", "reason", "target_workers"),
|
| 79 |
+
),
|
| 80 |
+
FarmFaculty(
|
| 81 |
+
"submit_work",
|
| 82 |
+
"work",
|
| 83 |
+
"Submit CPU Work",
|
| 84 |
+
"Create CPU-only shard jobs for a registered task handler.",
|
| 85 |
+
payload={"task_type": "python_callable", "formation": "cpu-worker"},
|
| 86 |
+
mutates=True,
|
| 87 |
+
risk_class="mutating",
|
| 88 |
+
timeout_seconds=45.0,
|
| 89 |
+
expected_outputs=("run_id", "jobs"),
|
| 90 |
+
),
|
| 91 |
+
FarmFaculty(
|
| 92 |
+
"worker",
|
| 93 |
+
"work",
|
| 94 |
+
"Worker Loop",
|
| 95 |
+
"Claim CPU shards, evaluate through a task handler, and write receipts.",
|
| 96 |
+
payload={"formation": "cpu-worker", "poll_seconds": 2},
|
| 97 |
+
mutates=True,
|
| 98 |
+
risk_class="mutating",
|
| 99 |
+
timeout_seconds=90.0,
|
| 100 |
+
expected_outputs=("worker_id", "pulse", "results"),
|
| 101 |
+
),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _strip_associations(data: dict[str, Any]) -> dict[str, Any]:
|
| 106 |
+
clean = {key: value for key, value in data.items() if key not in ASSOCIATION_KEYS}
|
| 107 |
+
affordance = clean.get("agent_affordance")
|
| 108 |
+
if isinstance(affordance, dict):
|
| 109 |
+
clean["agent_affordance"] = {
|
| 110 |
+
key: value for key, value in affordance.items() if key not in ASSOCIATION_KEYS
|
| 111 |
+
}
|
| 112 |
+
return clean
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def farm_faculty_manifest() -> dict[str, Any]:
|
| 116 |
+
capabilities = [faculty.to_dict() for faculty in CPU_FARM_FACULTIES]
|
| 117 |
+
groups: dict[str, list[dict[str, Any]]] = {}
|
| 118 |
+
for capability in capabilities:
|
| 119 |
+
groups.setdefault(str(capability.get("group", "")), []).append(capability)
|
| 120 |
+
return {
|
| 121 |
+
"surface": "hf_cpu_farm",
|
| 122 |
+
"count": len(capabilities),
|
| 123 |
+
"groups": groups,
|
| 124 |
+
"capabilities": capabilities,
|
| 125 |
+
"links_included": False,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def load_cocoon_faculty(capability_file: str | Path) -> dict[str, Any]:
|
| 130 |
+
"""Load only the authority faculty/capability contract.
|
| 131 |
+
|
| 132 |
+
This intentionally strips graph links, followups, and route associations so
|
| 133 |
+
the CPU farm can align to the authority OS substrate without importing that
|
| 134 |
+
topology.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
path = Path(capability_file)
|
| 138 |
+
spec = importlib.util.spec_from_file_location("cocoon_capabilities_faculty_only", path)
|
| 139 |
+
if spec is None or spec.loader is None:
|
| 140 |
+
raise ImportError(f"cannot load capability file: {path}")
|
| 141 |
+
module = importlib.util.module_from_spec(spec)
|
| 142 |
+
sys.modules[spec.name] = module
|
| 143 |
+
spec.loader.exec_module(module)
|
| 144 |
+
manifest = module.capability_manifest()
|
| 145 |
+
capabilities = [_strip_associations(dict(item)) for item in manifest.get("capabilities", [])]
|
| 146 |
+
groups: dict[str, list[dict[str, Any]]] = {}
|
| 147 |
+
for capability in capabilities:
|
| 148 |
+
groups.setdefault(str(capability.get("group", "")), []).append(capability)
|
| 149 |
+
return {
|
| 150 |
+
"surface": "cocoon_authority_faculty_only",
|
| 151 |
+
"source": str(path),
|
| 152 |
+
"count": len(capabilities),
|
| 153 |
+
"groups": groups,
|
| 154 |
+
"capabilities": capabilities,
|
| 155 |
+
"links_included": False,
|
| 156 |
+
}
|
key_farm/regulation.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def create_pulse(rate: float = 1.0) -> Any | None:
|
| 8 |
+
"""Create KEY's Pulse when available without making the farm depend on UI code."""
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from pulse import Pulse
|
| 12 |
+
|
| 13 |
+
return Pulse(rate=rate)
|
| 14 |
+
except Exception:
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def tick_pulse(pulse: Any | None) -> dict[str, float]:
|
| 19 |
+
if pulse is None:
|
| 20 |
+
return {
|
| 21 |
+
"depth": 0.0,
|
| 22 |
+
"phase": 0.0,
|
| 23 |
+
"cycle": 0.0,
|
| 24 |
+
"intensity": 1.0,
|
| 25 |
+
"timestamp": time.time(),
|
| 26 |
+
}
|
| 27 |
+
try:
|
| 28 |
+
state = pulse.tick()
|
| 29 |
+
return {
|
| 30 |
+
"depth": float(state.get("depth", 0.0)),
|
| 31 |
+
"phase": float(state.get("phase", 0.0)),
|
| 32 |
+
"cycle": float(state.get("cycle", 0.0)),
|
| 33 |
+
"intensity": float(state.get("intensity", 1.0)),
|
| 34 |
+
"timestamp": float(state.get("timestamp", time.time())),
|
| 35 |
+
}
|
| 36 |
+
except Exception:
|
| 37 |
+
return tick_pulse(None)
|
key_farm/scaler.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import asdict, dataclass, field
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .contracts import FarmPolicy, utc_now
|
| 11 |
+
from .store import FarmStore
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class ScalePolicy:
|
| 16 |
+
"""Hard boundary for starting and pausing MeshScale CPU worker Spaces."""
|
| 17 |
+
|
| 18 |
+
template_space: str
|
| 19 |
+
namespace: str
|
| 20 |
+
worker_prefix: str = "meshscale-worker"
|
| 21 |
+
formation: str = "cpu-worker"
|
| 22 |
+
hardware: str = "cpu-upgrade"
|
| 23 |
+
root_in_space: str = "/data/farm"
|
| 24 |
+
max_workers: int = 24
|
| 25 |
+
max_starts_per_tick: int = 2
|
| 26 |
+
start_cooldown_seconds: int = 120
|
| 27 |
+
sleep_time_seconds: int = 300
|
| 28 |
+
budget_hourly_usd: float = 0.72
|
| 29 |
+
cpu_space_hourly_usd: float = 0.03
|
| 30 |
+
private: bool = True
|
| 31 |
+
dry_run: bool = True
|
| 32 |
+
token_env: str = "HF_TOKEN"
|
| 33 |
+
pause_on_controller_lost: bool = True
|
| 34 |
+
|
| 35 |
+
def effective_max_workers(self) -> int:
|
| 36 |
+
if self.cpu_space_hourly_usd <= 0:
|
| 37 |
+
return max(1, self.max_workers)
|
| 38 |
+
budget_workers = int(self.budget_hourly_usd // self.cpu_space_hourly_usd)
|
| 39 |
+
return max(1, min(self.max_workers, budget_workers))
|
| 40 |
+
|
| 41 |
+
def to_dict(self) -> dict[str, Any]:
|
| 42 |
+
data = asdict(self)
|
| 43 |
+
data["effective_max_workers"] = self.effective_max_workers()
|
| 44 |
+
return data
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class ScalerState:
|
| 49 |
+
workers: list[str] = field(default_factory=list)
|
| 50 |
+
processed_requests: list[str] = field(default_factory=list)
|
| 51 |
+
last_start_at: float = 0.0
|
| 52 |
+
last_tick_at: str = ""
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def from_dict(cls, data: dict[str, Any] | None) -> "ScalerState":
|
| 56 |
+
if not data:
|
| 57 |
+
return cls()
|
| 58 |
+
return cls(
|
| 59 |
+
workers=list(data.get("workers", [])),
|
| 60 |
+
processed_requests=list(data.get("processed_requests", [])),
|
| 61 |
+
last_start_at=float(data.get("last_start_at", 0.0)),
|
| 62 |
+
last_tick_at=str(data.get("last_tick_at", "")),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def to_dict(self) -> dict[str, Any]:
|
| 66 |
+
return {
|
| 67 |
+
"workers": sorted(set(self.workers)),
|
| 68 |
+
"processed_requests": sorted(set(self.processed_requests)),
|
| 69 |
+
"last_start_at": self.last_start_at,
|
| 70 |
+
"last_tick_at": self.last_tick_at,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class MeshScaleScaler:
|
| 75 |
+
"""Optional Hugging Face Space scaler for MeshScale workers.
|
| 76 |
+
|
| 77 |
+
The scaler is intentionally outside the KEY generation path. It only reads
|
| 78 |
+
expansion requests, starts CPU worker Spaces under policy, and pauses known
|
| 79 |
+
workers when the controller is no longer pulsing.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
def __init__(self, store: FarmStore, policy: ScalePolicy):
|
| 83 |
+
self.store = store
|
| 84 |
+
self.policy = policy
|
| 85 |
+
self.state_path = self.store.root / "scaler" / "state.json"
|
| 86 |
+
|
| 87 |
+
def tick(self) -> dict[str, Any]:
|
| 88 |
+
state = self._load_state()
|
| 89 |
+
state.last_tick_at = utc_now()
|
| 90 |
+
|
| 91 |
+
regulation = self.store.read_regulation()
|
| 92 |
+
controller_alive = self.store.controller_pulse_alive(
|
| 93 |
+
FarmPolicy().controller_pulse_ttl_seconds
|
| 94 |
+
)
|
| 95 |
+
actions: list[dict[str, Any]] = []
|
| 96 |
+
|
| 97 |
+
should_pause = regulation.mode == "shutdown" or (
|
| 98 |
+
self.policy.pause_on_controller_lost and not controller_alive
|
| 99 |
+
)
|
| 100 |
+
if should_pause:
|
| 101 |
+
actions.extend(self._pause_workers(state))
|
| 102 |
+
self._save_state(state)
|
| 103 |
+
return self._report(state, actions, controller_alive, reason="pause")
|
| 104 |
+
|
| 105 |
+
requests = self._pending_expansion_requests(state)
|
| 106 |
+
requested = sum(max(0, int(req.get("requested_workers", 0))) for req in requests)
|
| 107 |
+
if requested <= 0:
|
| 108 |
+
self._save_state(state)
|
| 109 |
+
return self._report(state, actions, controller_alive, reason="idle")
|
| 110 |
+
|
| 111 |
+
active = self.store.active_worker_count(FarmPolicy().pulse_ttl_seconds, self.policy.formation)
|
| 112 |
+
known = len(set(state.workers))
|
| 113 |
+
allowed = self.policy.effective_max_workers()
|
| 114 |
+
available = max(0, allowed - max(active, known))
|
| 115 |
+
to_start = min(requested, available, self.policy.max_starts_per_tick)
|
| 116 |
+
|
| 117 |
+
cooldown_remaining = self._cooldown_remaining(state)
|
| 118 |
+
if cooldown_remaining > 0:
|
| 119 |
+
actions.append(
|
| 120 |
+
{
|
| 121 |
+
"action": "cooldown",
|
| 122 |
+
"seconds_remaining": round(cooldown_remaining, 3),
|
| 123 |
+
"requested_workers": requested,
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
self._mark_requests_processed(state, requests)
|
| 127 |
+
self._save_state(state)
|
| 128 |
+
return self._report(state, actions, controller_alive, reason="cooldown")
|
| 129 |
+
|
| 130 |
+
for _ in range(to_start):
|
| 131 |
+
repo_id = self._next_worker_repo_id(state)
|
| 132 |
+
action = self._start_worker(repo_id)
|
| 133 |
+
actions.append(action)
|
| 134 |
+
state.workers.append(repo_id)
|
| 135 |
+
state.last_start_at = time.time()
|
| 136 |
+
|
| 137 |
+
self._mark_requests_processed(state, requests)
|
| 138 |
+
self._save_state(state)
|
| 139 |
+
return self._report(state, actions, controller_alive, reason="scaled")
|
| 140 |
+
|
| 141 |
+
def _load_state(self) -> ScalerState:
|
| 142 |
+
try:
|
| 143 |
+
return ScalerState.from_dict(json.loads(self.state_path.read_text(encoding="utf-8")))
|
| 144 |
+
except Exception:
|
| 145 |
+
return ScalerState()
|
| 146 |
+
|
| 147 |
+
def _save_state(self, state: ScalerState) -> None:
|
| 148 |
+
self.store._write_json_atomic(self.state_path, state.to_dict())
|
| 149 |
+
|
| 150 |
+
def _pending_expansion_requests(self, state: ScalerState) -> list[dict[str, Any]]:
|
| 151 |
+
processed = set(state.processed_requests)
|
| 152 |
+
requests: list[dict[str, Any]] = []
|
| 153 |
+
expansion_dir = self.store.expansion_dir()
|
| 154 |
+
if not expansion_dir.exists():
|
| 155 |
+
return requests
|
| 156 |
+
for path in sorted(expansion_dir.glob("*.json")):
|
| 157 |
+
try:
|
| 158 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 159 |
+
except Exception:
|
| 160 |
+
continue
|
| 161 |
+
request_id = str(data.get("request_id") or path.stem)
|
| 162 |
+
if request_id in processed:
|
| 163 |
+
continue
|
| 164 |
+
if data.get("resource_kind", "cpu") != "cpu":
|
| 165 |
+
continue
|
| 166 |
+
if data.get("formation", self.policy.formation) != self.policy.formation:
|
| 167 |
+
continue
|
| 168 |
+
requests.append(data)
|
| 169 |
+
return requests
|
| 170 |
+
|
| 171 |
+
def _mark_requests_processed(self, state: ScalerState, requests: list[dict[str, Any]]) -> None:
|
| 172 |
+
processed = set(state.processed_requests)
|
| 173 |
+
for request in requests:
|
| 174 |
+
request_id = request.get("request_id")
|
| 175 |
+
if request_id:
|
| 176 |
+
processed.add(str(request_id))
|
| 177 |
+
state.processed_requests = sorted(processed)[-2048:]
|
| 178 |
+
|
| 179 |
+
def _cooldown_remaining(self, state: ScalerState) -> float:
|
| 180 |
+
if state.last_start_at <= 0:
|
| 181 |
+
return 0.0
|
| 182 |
+
elapsed = time.time() - state.last_start_at
|
| 183 |
+
return max(0.0, self.policy.start_cooldown_seconds - elapsed)
|
| 184 |
+
|
| 185 |
+
def _next_worker_repo_id(self, state: ScalerState) -> str:
|
| 186 |
+
existing = set(state.workers)
|
| 187 |
+
for idx in range(1, self.policy.effective_max_workers() + 1):
|
| 188 |
+
repo_id = f"{self.policy.namespace}/{self.policy.worker_prefix}-{idx:03d}"
|
| 189 |
+
if repo_id not in existing:
|
| 190 |
+
return repo_id
|
| 191 |
+
raise RuntimeError("no worker repo id available under policy cap")
|
| 192 |
+
|
| 193 |
+
def _start_worker(self, repo_id: str) -> dict[str, Any]:
|
| 194 |
+
variables = [
|
| 195 |
+
{"key": "KEY_FARM_ROOT", "value": self.policy.root_in_space},
|
| 196 |
+
{"key": "KEY_FARM_WORKER_ID", "value": repo_id.rsplit("/", 1)[-1]},
|
| 197 |
+
{"key": "KEY_FARM_FORMATION", "value": self.policy.formation},
|
| 198 |
+
]
|
| 199 |
+
payload = {
|
| 200 |
+
"action": "start_worker",
|
| 201 |
+
"repo_id": repo_id,
|
| 202 |
+
"template_space": self.policy.template_space,
|
| 203 |
+
"hardware": self.policy.hardware,
|
| 204 |
+
"formation": self.policy.formation,
|
| 205 |
+
"dry_run": self.policy.dry_run,
|
| 206 |
+
}
|
| 207 |
+
if self.policy.dry_run:
|
| 208 |
+
self.store.append_event("farm_scaler_dry_run_start", payload)
|
| 209 |
+
return payload
|
| 210 |
+
|
| 211 |
+
api = self._api()
|
| 212 |
+
api.duplicate_space(
|
| 213 |
+
from_id=self.policy.template_space,
|
| 214 |
+
to_id=repo_id,
|
| 215 |
+
private=self.policy.private,
|
| 216 |
+
exist_ok=True,
|
| 217 |
+
hardware=self.policy.hardware,
|
| 218 |
+
sleep_time=self.policy.sleep_time_seconds,
|
| 219 |
+
variables=variables,
|
| 220 |
+
)
|
| 221 |
+
api.request_space_hardware(
|
| 222 |
+
repo_id=repo_id,
|
| 223 |
+
hardware=self.policy.hardware,
|
| 224 |
+
sleep_time=self.policy.sleep_time_seconds,
|
| 225 |
+
)
|
| 226 |
+
api.restart_space(repo_id=repo_id)
|
| 227 |
+
self.store.append_event("farm_scaler_started_worker", payload)
|
| 228 |
+
return payload
|
| 229 |
+
|
| 230 |
+
def _pause_workers(self, state: ScalerState) -> list[dict[str, Any]]:
|
| 231 |
+
actions = []
|
| 232 |
+
for repo_id in sorted(set(state.workers)):
|
| 233 |
+
payload = {
|
| 234 |
+
"action": "pause_worker",
|
| 235 |
+
"repo_id": repo_id,
|
| 236 |
+
"dry_run": self.policy.dry_run,
|
| 237 |
+
}
|
| 238 |
+
if not self.policy.dry_run:
|
| 239 |
+
self._api().pause_space(repo_id=repo_id)
|
| 240 |
+
actions.append(payload)
|
| 241 |
+
if actions:
|
| 242 |
+
self.store.append_event("farm_scaler_pause_workers", {"actions": actions})
|
| 243 |
+
state.workers = []
|
| 244 |
+
return actions
|
| 245 |
+
|
| 246 |
+
def _api(self):
|
| 247 |
+
token = os.environ.get(self.policy.token_env)
|
| 248 |
+
if not token:
|
| 249 |
+
raise RuntimeError(f"{self.policy.token_env} is required when dry_run is false")
|
| 250 |
+
from huggingface_hub import HfApi
|
| 251 |
+
|
| 252 |
+
return HfApi(token=token)
|
| 253 |
+
|
| 254 |
+
def _report(
|
| 255 |
+
self,
|
| 256 |
+
state: ScalerState,
|
| 257 |
+
actions: list[dict[str, Any]],
|
| 258 |
+
controller_alive: bool,
|
| 259 |
+
*,
|
| 260 |
+
reason: str,
|
| 261 |
+
) -> dict[str, Any]:
|
| 262 |
+
return {
|
| 263 |
+
"reason": reason,
|
| 264 |
+
"controller_alive": controller_alive,
|
| 265 |
+
"policy": self.policy.to_dict(),
|
| 266 |
+
"state": state.to_dict(),
|
| 267 |
+
"actions": actions,
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
__all__ = ["MeshScaleScaler", "ScalePolicy", "ScalerState"]
|
key_farm/serialization.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import gzip
|
| 5 |
+
import hashlib
|
| 6 |
+
import pickle
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from .contracts import NodePayload
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ENCODING = "pickle+gzip+base64"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _encode_bytes(raw: bytes) -> str:
|
| 16 |
+
return base64.b64encode(gzip.compress(raw)).decode("ascii")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _decode_bytes(payload: str) -> bytes:
|
| 20 |
+
return gzip.decompress(base64.b64decode(payload.encode("ascii")))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def pack_object(obj: Any) -> tuple[str, str]:
|
| 24 |
+
raw = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
| 25 |
+
return _encode_bytes(raw), hashlib.sha256(raw).hexdigest()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def unpack_object(payload: str, sha256: str | None = None) -> Any:
|
| 29 |
+
raw = _decode_bytes(payload)
|
| 30 |
+
digest = hashlib.sha256(raw).hexdigest()
|
| 31 |
+
if sha256 and digest != sha256:
|
| 32 |
+
raise ValueError(f"payload digest mismatch: {digest} != {sha256}")
|
| 33 |
+
return pickle.loads(raw)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def pack_node(node: Any) -> NodePayload:
|
| 37 |
+
node_id = str(getattr(node, "id", ""))
|
| 38 |
+
if not node_id:
|
| 39 |
+
raise ValueError("node must expose a stable id before KEY generation dispatch")
|
| 40 |
+
return pack_item(node, node_id)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def pack_item(item: Any, item_id: str | None = None) -> NodePayload:
|
| 44 |
+
payload, digest = pack_object(item)
|
| 45 |
+
stable_id = item_id or str(getattr(item, "id", "")) or digest[:16]
|
| 46 |
+
return NodePayload(node_id=stable_id, encoding=ENCODING, payload=payload, sha256=digest)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def unpack_node(node_payload: NodePayload) -> Any:
|
| 50 |
+
if node_payload.encoding != ENCODING:
|
| 51 |
+
raise ValueError(f"unsupported node encoding: {node_payload.encoding}")
|
| 52 |
+
return unpack_object(node_payload.payload, node_payload.sha256)
|
key_farm/store.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Iterable
|
| 8 |
+
|
| 9 |
+
from .amalgam_bridge import observe_event
|
| 10 |
+
from .contracts import (
|
| 11 |
+
ControllerPulse,
|
| 12 |
+
ExpansionRequest,
|
| 13 |
+
FarmPolicy,
|
| 14 |
+
RegulationIntent,
|
| 15 |
+
ShardJob,
|
| 16 |
+
ShardResult,
|
| 17 |
+
WorkerPulse,
|
| 18 |
+
new_id,
|
| 19 |
+
utc_now,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FarmStore:
|
| 24 |
+
"""Filesystem-backed farm ledger.
|
| 25 |
+
|
| 26 |
+
The root can be a local directory for tests or a bucket volume mount in
|
| 27 |
+
Hugging Face Spaces. The layout avoids a database so many CPU Spaces can
|
| 28 |
+
participate through ordinary file IO.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(self, root: str | Path):
|
| 32 |
+
self.root = Path(root)
|
| 33 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
@classmethod
|
| 36 |
+
def from_env(cls) -> "FarmStore":
|
| 37 |
+
return cls(os.environ.get("KEY_FARM_ROOT", "/data/farm"))
|
| 38 |
+
|
| 39 |
+
def run_dir(self, run_id: str) -> Path:
|
| 40 |
+
return self.root / "runs" / run_id
|
| 41 |
+
|
| 42 |
+
def generation_dir(self, run_id: str, generation: int) -> Path:
|
| 43 |
+
return self.run_dir(run_id) / "generations" / str(generation)
|
| 44 |
+
|
| 45 |
+
def jobs_dir(self, run_id: str, generation: int, state: str) -> Path:
|
| 46 |
+
return self.generation_dir(run_id, generation) / "jobs" / state
|
| 47 |
+
|
| 48 |
+
def results_dir(self, run_id: str, generation: int) -> Path:
|
| 49 |
+
return self.generation_dir(run_id, generation) / "results"
|
| 50 |
+
|
| 51 |
+
def pulses_dir(self) -> Path:
|
| 52 |
+
return self.root / "pulses"
|
| 53 |
+
|
| 54 |
+
def worker_pulses_dir(self) -> Path:
|
| 55 |
+
return self.pulses_dir() / "workers"
|
| 56 |
+
|
| 57 |
+
def heartbeats_dir(self) -> Path:
|
| 58 |
+
return self.worker_pulses_dir()
|
| 59 |
+
|
| 60 |
+
def regulation_dir(self) -> Path:
|
| 61 |
+
return self.root / "regulation"
|
| 62 |
+
|
| 63 |
+
def control_dir(self) -> Path:
|
| 64 |
+
return self.regulation_dir()
|
| 65 |
+
|
| 66 |
+
def regulation_path(self) -> Path:
|
| 67 |
+
return self.regulation_dir() / "intent.json"
|
| 68 |
+
|
| 69 |
+
def control_path(self) -> Path:
|
| 70 |
+
return self.regulation_path()
|
| 71 |
+
|
| 72 |
+
def controller_pulse_path(self) -> Path:
|
| 73 |
+
return self.pulses_dir() / "controller.json"
|
| 74 |
+
|
| 75 |
+
def controller_heartbeat_path(self) -> Path:
|
| 76 |
+
return self.controller_pulse_path()
|
| 77 |
+
|
| 78 |
+
def expansion_dir(self) -> Path:
|
| 79 |
+
return self.root / "expansion_requests"
|
| 80 |
+
|
| 81 |
+
def event_log(self) -> Path:
|
| 82 |
+
return self.root / "events.jsonl"
|
| 83 |
+
|
| 84 |
+
def _write_json_atomic(self, path: Path, data: dict[str, Any]) -> None:
|
| 85 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
temp = path.with_name(f".{path.name}.{new_id('tmp')}")
|
| 87 |
+
temp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
| 88 |
+
last_error: OSError | None = None
|
| 89 |
+
for attempt in range(6):
|
| 90 |
+
try:
|
| 91 |
+
temp.replace(path)
|
| 92 |
+
return
|
| 93 |
+
except PermissionError as exc:
|
| 94 |
+
last_error = exc
|
| 95 |
+
time.sleep(0.025 * (attempt + 1))
|
| 96 |
+
if last_error is not None:
|
| 97 |
+
temp.unlink(missing_ok=True)
|
| 98 |
+
raise last_error
|
| 99 |
+
temp.replace(path)
|
| 100 |
+
|
| 101 |
+
def append_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
| 102 |
+
event = {
|
| 103 |
+
"timestamp": utc_now(),
|
| 104 |
+
"event_type": event_type,
|
| 105 |
+
"payload": payload,
|
| 106 |
+
}
|
| 107 |
+
self.event_log().parent.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
with self.event_log().open("a", encoding="utf-8") as handle:
|
| 109 |
+
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
| 110 |
+
observe_event("key-cpu-farm", event_type, payload)
|
| 111 |
+
|
| 112 |
+
def create_run(self, run_id: str, config: dict[str, Any], policy: FarmPolicy) -> None:
|
| 113 |
+
data = {
|
| 114 |
+
"run_id": run_id,
|
| 115 |
+
"created_at": utc_now(),
|
| 116 |
+
"config": config,
|
| 117 |
+
"policy": policy.to_dict(),
|
| 118 |
+
}
|
| 119 |
+
self._write_json_atomic(self.run_dir(run_id) / "run.json", data)
|
| 120 |
+
self.append_event("farm_run_created", {"run_id": run_id, "policy": policy.to_dict()})
|
| 121 |
+
|
| 122 |
+
def put_job(self, job: ShardJob) -> Path:
|
| 123 |
+
path = self.jobs_dir(job.run_id, job.generation, "pending") / f"{job.shard_id}.json"
|
| 124 |
+
self._write_json_atomic(path, job.to_dict())
|
| 125 |
+
return path
|
| 126 |
+
|
| 127 |
+
def put_jobs(self, jobs: Iterable[ShardJob]) -> int:
|
| 128 |
+
count = 0
|
| 129 |
+
for job in jobs:
|
| 130 |
+
self.put_job(job)
|
| 131 |
+
count += 1
|
| 132 |
+
return count
|
| 133 |
+
|
| 134 |
+
def list_jobs(self, run_id: str, generation: int, state: str) -> list[Path]:
|
| 135 |
+
path = self.jobs_dir(run_id, generation, state)
|
| 136 |
+
if not path.exists():
|
| 137 |
+
return []
|
| 138 |
+
return sorted(path.glob("*.json"))
|
| 139 |
+
|
| 140 |
+
def pending_jobs(self, formation: str | None = None) -> list[Path]:
|
| 141 |
+
paths = sorted((self.root / "runs").glob("*/generations/*/jobs/pending/*.json"))
|
| 142 |
+
if formation is None:
|
| 143 |
+
return paths
|
| 144 |
+
filtered = []
|
| 145 |
+
for path in paths:
|
| 146 |
+
try:
|
| 147 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 148 |
+
if data.get("formation", "cpu-worker") == formation:
|
| 149 |
+
filtered.append(path)
|
| 150 |
+
except Exception:
|
| 151 |
+
continue
|
| 152 |
+
return filtered
|
| 153 |
+
|
| 154 |
+
def claimed_jobs(self) -> list[Path]:
|
| 155 |
+
return sorted((self.root / "runs").glob("*/generations/*/jobs/claimed/*.json"))
|
| 156 |
+
|
| 157 |
+
def claim_next(
|
| 158 |
+
self,
|
| 159 |
+
worker_id: str,
|
| 160 |
+
formations: tuple[str, ...] | None = None,
|
| 161 |
+
) -> tuple[ShardJob, Path] | None:
|
| 162 |
+
allowed_formations = set(formations or ())
|
| 163 |
+
for path in self.pending_jobs():
|
| 164 |
+
try:
|
| 165 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 166 |
+
job = ShardJob.from_dict(data)
|
| 167 |
+
if allowed_formations and job.formation not in allowed_formations:
|
| 168 |
+
continue
|
| 169 |
+
claimed = self.jobs_dir(job.run_id, job.generation, "claimed") / path.name
|
| 170 |
+
claimed.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
path.rename(claimed)
|
| 172 |
+
claim_data = job.to_dict()
|
| 173 |
+
claim_data["claimed_by"] = worker_id
|
| 174 |
+
claim_data["claimed_at"] = utc_now()
|
| 175 |
+
self._write_json_atomic(claimed, claim_data)
|
| 176 |
+
return job, claimed
|
| 177 |
+
except FileNotFoundError:
|
| 178 |
+
continue
|
| 179 |
+
except OSError:
|
| 180 |
+
continue
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
def complete_job(self, job: ShardJob, result: ShardResult, claimed_path: Path | None) -> None:
|
| 184 |
+
result_path = self.results_dir(job.run_id, job.generation) / f"{job.shard_id}.json"
|
| 185 |
+
try:
|
| 186 |
+
self._write_json_atomic(result_path, result.to_dict())
|
| 187 |
+
except PermissionError:
|
| 188 |
+
if not result_path.exists():
|
| 189 |
+
raise
|
| 190 |
+
if claimed_path and claimed_path.exists():
|
| 191 |
+
done_path = self.jobs_dir(job.run_id, job.generation, result.status) / claimed_path.name
|
| 192 |
+
done_path.parent.mkdir(parents=True, exist_ok=True)
|
| 193 |
+
try:
|
| 194 |
+
claimed_path.rename(done_path)
|
| 195 |
+
except OSError:
|
| 196 |
+
self._write_json_atomic(done_path, job.to_dict())
|
| 197 |
+
self.append_event(
|
| 198 |
+
"farm_job_completed",
|
| 199 |
+
{
|
| 200 |
+
"run_id": job.run_id,
|
| 201 |
+
"generation": job.generation,
|
| 202 |
+
"shard_id": job.shard_id,
|
| 203 |
+
"status": result.status,
|
| 204 |
+
"worker_id": result.worker_id,
|
| 205 |
+
"node_results": len(result.node_results),
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
def fail_job(self, job: ShardJob, worker_id: str, message: str, claimed_path: Path | None) -> None:
|
| 210 |
+
result = ShardResult(
|
| 211 |
+
run_id=job.run_id,
|
| 212 |
+
generation=job.generation,
|
| 213 |
+
shard_id=job.shard_id,
|
| 214 |
+
worker_id=worker_id,
|
| 215 |
+
status="failed",
|
| 216 |
+
node_results=[],
|
| 217 |
+
started_at=utc_now(),
|
| 218 |
+
message=message,
|
| 219 |
+
)
|
| 220 |
+
self.complete_job(job, result, claimed_path)
|
| 221 |
+
|
| 222 |
+
def load_results(self, run_id: str, generation: int) -> list[ShardResult]:
|
| 223 |
+
results = []
|
| 224 |
+
path = self.results_dir(run_id, generation)
|
| 225 |
+
if not path.exists():
|
| 226 |
+
return results
|
| 227 |
+
for result_path in sorted(path.glob("*.json")):
|
| 228 |
+
try:
|
| 229 |
+
results.append(ShardResult.from_dict(json.loads(result_path.read_text(encoding="utf-8"))))
|
| 230 |
+
except Exception:
|
| 231 |
+
continue
|
| 232 |
+
return results
|
| 233 |
+
|
| 234 |
+
def write_worker_pulse(self, pulse: WorkerPulse) -> None:
|
| 235 |
+
path = self.worker_pulses_dir() / f"{pulse.worker_id}.json"
|
| 236 |
+
self._write_json_atomic(path, pulse.to_dict())
|
| 237 |
+
|
| 238 |
+
def write_heartbeat(self, heartbeat: WorkerPulse) -> None:
|
| 239 |
+
self.write_worker_pulse(heartbeat)
|
| 240 |
+
|
| 241 |
+
def write_controller_pulse(self, pulse: ControllerPulse) -> None:
|
| 242 |
+
self._write_json_atomic(self.controller_pulse_path(), pulse.to_dict())
|
| 243 |
+
|
| 244 |
+
def write_controller_heartbeat(self, heartbeat: ControllerPulse) -> None:
|
| 245 |
+
self.write_controller_pulse(heartbeat)
|
| 246 |
+
|
| 247 |
+
def controller_pulse_alive(self, ttl_seconds: int) -> bool:
|
| 248 |
+
path = self.controller_pulse_path()
|
| 249 |
+
if not path.exists():
|
| 250 |
+
return False
|
| 251 |
+
try:
|
| 252 |
+
return path.stat().st_mtime >= time.time() - ttl_seconds
|
| 253 |
+
except OSError:
|
| 254 |
+
return False
|
| 255 |
+
|
| 256 |
+
def controller_alive(self, ttl_seconds: int) -> bool:
|
| 257 |
+
return self.controller_pulse_alive(ttl_seconds)
|
| 258 |
+
|
| 259 |
+
def active_worker_count(self, ttl_seconds: int, formation: str | None = None) -> int:
|
| 260 |
+
cutoff = time.time() - ttl_seconds
|
| 261 |
+
count = 0
|
| 262 |
+
pulse_dir = self.worker_pulses_dir()
|
| 263 |
+
for path in pulse_dir.glob("*.json") if pulse_dir.exists() else []:
|
| 264 |
+
try:
|
| 265 |
+
if path.stat().st_mtime < cutoff:
|
| 266 |
+
continue
|
| 267 |
+
if formation is not None:
|
| 268 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 269 |
+
if data.get("formation", "cpu-worker") != formation:
|
| 270 |
+
continue
|
| 271 |
+
count += 1
|
| 272 |
+
except OSError:
|
| 273 |
+
continue
|
| 274 |
+
except Exception:
|
| 275 |
+
continue
|
| 276 |
+
return count
|
| 277 |
+
|
| 278 |
+
def active_worker_ids(self, ttl_seconds: int, formation: str | None = None) -> list[str]:
|
| 279 |
+
cutoff = time.time() - ttl_seconds
|
| 280 |
+
worker_ids: list[str] = []
|
| 281 |
+
pulse_dir = self.worker_pulses_dir()
|
| 282 |
+
for path in pulse_dir.glob("*.json") if pulse_dir.exists() else []:
|
| 283 |
+
try:
|
| 284 |
+
if path.stat().st_mtime < cutoff:
|
| 285 |
+
continue
|
| 286 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 287 |
+
if formation is not None and data.get("formation", "cpu-worker") != formation:
|
| 288 |
+
continue
|
| 289 |
+
worker_id = data.get("worker_id")
|
| 290 |
+
if isinstance(worker_id, str):
|
| 291 |
+
worker_ids.append(worker_id)
|
| 292 |
+
except Exception:
|
| 293 |
+
continue
|
| 294 |
+
return sorted(worker_ids)
|
| 295 |
+
|
| 296 |
+
def write_regulation(self, intent: RegulationIntent) -> Path:
|
| 297 |
+
self._write_json_atomic(self.regulation_path(), intent.to_dict())
|
| 298 |
+
self.append_event("farm_regulation_changed", intent.to_dict())
|
| 299 |
+
return self.regulation_path()
|
| 300 |
+
|
| 301 |
+
def write_control(self, control: RegulationIntent) -> Path:
|
| 302 |
+
return self.write_regulation(control)
|
| 303 |
+
|
| 304 |
+
def read_regulation(self) -> RegulationIntent:
|
| 305 |
+
path = self.regulation_path()
|
| 306 |
+
if not path.exists():
|
| 307 |
+
return RegulationIntent()
|
| 308 |
+
try:
|
| 309 |
+
return RegulationIntent.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
| 310 |
+
except Exception:
|
| 311 |
+
return RegulationIntent(mode="drain", reason="invalid_regulation_state")
|
| 312 |
+
|
| 313 |
+
def read_control(self) -> RegulationIntent:
|
| 314 |
+
return self.read_regulation()
|
| 315 |
+
|
| 316 |
+
def write_expansion_request(self, request: ExpansionRequest) -> Path:
|
| 317 |
+
path = self.expansion_dir() / f"{request.request_id}.json"
|
| 318 |
+
self._write_json_atomic(path, request.to_dict())
|
| 319 |
+
self.append_event("farm_expansion_requested", request.to_dict())
|
| 320 |
+
return path
|
| 321 |
+
|
| 322 |
+
def requeue_expired_claims(self, policy: FarmPolicy) -> int:
|
| 323 |
+
cutoff = time.time() - policy.claim_ttl_seconds
|
| 324 |
+
requeued = 0
|
| 325 |
+
for path in self.claimed_jobs():
|
| 326 |
+
try:
|
| 327 |
+
if path.stat().st_mtime > cutoff:
|
| 328 |
+
continue
|
| 329 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 330 |
+
job = ShardJob.from_dict(data)
|
| 331 |
+
pending = self.jobs_dir(job.run_id, job.generation, "pending") / path.name
|
| 332 |
+
pending.parent.mkdir(parents=True, exist_ok=True)
|
| 333 |
+
path.rename(pending)
|
| 334 |
+
requeued += 1
|
| 335 |
+
except Exception:
|
| 336 |
+
continue
|
| 337 |
+
if requeued:
|
| 338 |
+
self.append_event("farm_claims_requeued", {"count": requeued})
|
| 339 |
+
return requeued
|
key_farm/symbiotic_trace.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from collections import Counter, defaultdict
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from .amalgam_bridge import observe_event
|
| 10 |
+
from .contracts import FarmPolicy, ShardJob, ShardResult
|
| 11 |
+
from .store import FarmStore
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
JOB_STATES = ("pending", "claimed", "done", "failed", "refracted")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _read_json(path: Path) -> dict[str, Any] | None:
|
| 18 |
+
try:
|
| 19 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 20 |
+
except Exception:
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _event_to_dict(event: Any) -> dict[str, Any] | None:
|
| 25 |
+
if event is None:
|
| 26 |
+
return None
|
| 27 |
+
if hasattr(event, "to_dict"):
|
| 28 |
+
try:
|
| 29 |
+
return event.to_dict()
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
if isinstance(event, dict):
|
| 33 |
+
return event
|
| 34 |
+
return {"repr": repr(event)}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _interpret_with_cascade(signal: dict[str, Any]) -> dict[str, Any]:
|
| 38 |
+
result: dict[str, Any] = {"available": False}
|
| 39 |
+
try:
|
| 40 |
+
from cascade import Monitor, SymbioticAdapter
|
| 41 |
+
|
| 42 |
+
interpreted = SymbioticAdapter().interpret(signal)
|
| 43 |
+
monitor_event = Monitor("hf_cpu_farm").observe(signal)
|
| 44 |
+
result.update(
|
| 45 |
+
{
|
| 46 |
+
"available": True,
|
| 47 |
+
"symbiotic_event": _event_to_dict(interpreted),
|
| 48 |
+
"monitor_event": _event_to_dict(monitor_event),
|
| 49 |
+
}
|
| 50 |
+
)
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
result["error"] = f"{type(exc).__name__}: {exc}"
|
| 53 |
+
return result
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _job_counts(store: FarmStore) -> dict[str, Any]:
|
| 57 |
+
counts: dict[str, int] = {}
|
| 58 |
+
by_formation: dict[str, Counter] = defaultdict(Counter)
|
| 59 |
+
by_task: dict[str, Counter] = defaultdict(Counter)
|
| 60 |
+
by_generation: dict[str, Counter] = defaultdict(Counter)
|
| 61 |
+
sample_failures = []
|
| 62 |
+
|
| 63 |
+
for state in JOB_STATES:
|
| 64 |
+
paths = sorted((store.root / "runs").glob(f"*/generations/*/jobs/{state}/*.json"))
|
| 65 |
+
counts[state] = len(paths)
|
| 66 |
+
for path in paths:
|
| 67 |
+
data = _read_json(path) or {}
|
| 68 |
+
try:
|
| 69 |
+
job = ShardJob.from_dict(data)
|
| 70 |
+
formation = job.formation
|
| 71 |
+
task = f"{job.task_type}:{job.task_name}"
|
| 72 |
+
generation = f"{job.run_id}:{job.generation}"
|
| 73 |
+
except Exception:
|
| 74 |
+
formation = str(data.get("formation", "unknown"))
|
| 75 |
+
task = str(data.get("task_type", "unknown"))
|
| 76 |
+
generation = "unknown"
|
| 77 |
+
by_formation[formation][state] += 1
|
| 78 |
+
by_task[task][state] += 1
|
| 79 |
+
by_generation[generation][state] += 1
|
| 80 |
+
if state == "failed" and len(sample_failures) < 8:
|
| 81 |
+
sample_failures.append(
|
| 82 |
+
{
|
| 83 |
+
"path": str(path),
|
| 84 |
+
"task": task,
|
| 85 |
+
"message": str(data.get("message") or data.get("error") or "")[:500],
|
| 86 |
+
}
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"counts": counts,
|
| 91 |
+
"by_formation": {key: dict(value) for key, value in by_formation.items()},
|
| 92 |
+
"by_task": {key: dict(value) for key, value in by_task.items()},
|
| 93 |
+
"by_generation": {key: dict(value) for key, value in by_generation.items()},
|
| 94 |
+
"sample_failures": sample_failures,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _result_stats(store: FarmStore) -> dict[str, Any]:
|
| 99 |
+
durations = []
|
| 100 |
+
errored = 0
|
| 101 |
+
total = 0
|
| 102 |
+
by_worker: Counter = Counter()
|
| 103 |
+
|
| 104 |
+
for path in sorted((store.root / "runs").glob("*/generations/*/results/*.json")):
|
| 105 |
+
data = _read_json(path)
|
| 106 |
+
if not data:
|
| 107 |
+
continue
|
| 108 |
+
try:
|
| 109 |
+
result = ShardResult.from_dict(data)
|
| 110 |
+
except Exception:
|
| 111 |
+
continue
|
| 112 |
+
by_worker[result.worker_id] += 1
|
| 113 |
+
for node_result in result.node_results:
|
| 114 |
+
total += 1
|
| 115 |
+
durations.append(float(node_result.duration_ms))
|
| 116 |
+
if node_result.error:
|
| 117 |
+
errored += 1
|
| 118 |
+
|
| 119 |
+
durations.sort()
|
| 120 |
+
p95 = durations[int(len(durations) * 0.95) - 1] if durations else 0.0
|
| 121 |
+
return {
|
| 122 |
+
"result_files": sum(by_worker.values()),
|
| 123 |
+
"node_results": total,
|
| 124 |
+
"node_errors": errored,
|
| 125 |
+
"avg_node_ms": (sum(durations) / len(durations)) if durations else 0.0,
|
| 126 |
+
"p95_node_ms": p95,
|
| 127 |
+
"by_worker": dict(by_worker),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _pulse_stats(store: FarmStore, policy: FarmPolicy) -> dict[str, Any]:
|
| 132 |
+
now = time.time()
|
| 133 |
+
workers = []
|
| 134 |
+
active = 0
|
| 135 |
+
stale = 0
|
| 136 |
+
pulse_dir = store.worker_pulses_dir()
|
| 137 |
+
for path in sorted(pulse_dir.glob("*.json")) if pulse_dir.exists() else []:
|
| 138 |
+
data = _read_json(path) or {}
|
| 139 |
+
age = max(0.0, now - path.stat().st_mtime)
|
| 140 |
+
is_active = age <= policy.pulse_ttl_seconds
|
| 141 |
+
active += int(is_active)
|
| 142 |
+
stale += int(not is_active)
|
| 143 |
+
workers.append(
|
| 144 |
+
{
|
| 145 |
+
"worker_id": data.get("worker_id") or path.stem,
|
| 146 |
+
"formation": data.get("formation", "cpu-worker"),
|
| 147 |
+
"status": data.get("status", "unknown"),
|
| 148 |
+
"age_seconds": round(age, 3),
|
| 149 |
+
"active": is_active,
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
controller_path = store.controller_pulse_path()
|
| 154 |
+
controller_age = None
|
| 155 |
+
if controller_path.exists():
|
| 156 |
+
controller_age = max(0.0, now - controller_path.stat().st_mtime)
|
| 157 |
+
|
| 158 |
+
return {
|
| 159 |
+
"controller_alive": store.controller_pulse_alive(policy.controller_pulse_ttl_seconds),
|
| 160 |
+
"controller_age_seconds": None if controller_age is None else round(controller_age, 3),
|
| 161 |
+
"active_workers": active,
|
| 162 |
+
"stale_workers": stale,
|
| 163 |
+
"workers": workers,
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _expansion_stats(store: FarmStore) -> dict[str, Any]:
|
| 168 |
+
requests = []
|
| 169 |
+
for path in sorted(store.expansion_dir().glob("*.json")) if store.expansion_dir().exists() else []:
|
| 170 |
+
data = _read_json(path)
|
| 171 |
+
if data:
|
| 172 |
+
requests.append(data)
|
| 173 |
+
return {
|
| 174 |
+
"count": len(requests),
|
| 175 |
+
"latest": requests[-8:],
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _recommendations(signal: dict[str, Any], policy: FarmPolicy) -> list[str]:
|
| 180 |
+
traffic = signal["traffic"]["counts"]
|
| 181 |
+
pulse = signal["pulse"]
|
| 182 |
+
results = signal["results"]
|
| 183 |
+
recommendations = []
|
| 184 |
+
|
| 185 |
+
if not pulse["controller_alive"]:
|
| 186 |
+
recommendations.append("Publish a controller pulse or let workers exit by pulse-loss policy.")
|
| 187 |
+
if traffic.get("failed", 0):
|
| 188 |
+
recommendations.append("Inspect failed shard samples before adding more workers.")
|
| 189 |
+
if traffic.get("pending", 0) > max(1, pulse["active_workers"] * 3):
|
| 190 |
+
recommendations.append("Backlog exceeds active CPU pulse capacity; request expansion within budget.")
|
| 191 |
+
if pulse["active_workers"] > policy.effective_max_workers():
|
| 192 |
+
recommendations.append("Active workers exceed budget-derived ceiling; publish drain intent.")
|
| 193 |
+
if results["node_errors"]:
|
| 194 |
+
recommendations.append("Node-level errors are present; trace handler payload compatibility.")
|
| 195 |
+
if not recommendations:
|
| 196 |
+
recommendations.append("Farm traffic is balanced under current pulse and budget policy.")
|
| 197 |
+
return recommendations
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def symbiotic_trace(
|
| 201 |
+
store: FarmStore,
|
| 202 |
+
policy: FarmPolicy | None = None,
|
| 203 |
+
*,
|
| 204 |
+
receipt: bool = True,
|
| 205 |
+
) -> dict[str, Any]:
|
| 206 |
+
policy = policy or FarmPolicy()
|
| 207 |
+
signal = {
|
| 208 |
+
"surface": "hf_cpu_farm",
|
| 209 |
+
"policy": policy.to_dict(),
|
| 210 |
+
"regulation": store.read_regulation().to_dict(),
|
| 211 |
+
"pulse": _pulse_stats(store, policy),
|
| 212 |
+
"traffic": _job_counts(store),
|
| 213 |
+
"results": _result_stats(store),
|
| 214 |
+
"expansion": _expansion_stats(store),
|
| 215 |
+
}
|
| 216 |
+
signal["recommendations"] = _recommendations(signal, policy)
|
| 217 |
+
cascade = _interpret_with_cascade(signal)
|
| 218 |
+
report = {
|
| 219 |
+
"summary": {
|
| 220 |
+
"pending": signal["traffic"]["counts"].get("pending", 0),
|
| 221 |
+
"claimed": signal["traffic"]["counts"].get("claimed", 0),
|
| 222 |
+
"failed": signal["traffic"]["counts"].get("failed", 0),
|
| 223 |
+
"active_workers": signal["pulse"]["active_workers"],
|
| 224 |
+
"controller_alive": signal["pulse"]["controller_alive"],
|
| 225 |
+
},
|
| 226 |
+
"signal": signal,
|
| 227 |
+
"cascade": cascade,
|
| 228 |
+
}
|
| 229 |
+
if receipt:
|
| 230 |
+
report["receipt"] = observe_event("hf-cpu-farm", "symbiotic_trace", report["summary"])
|
| 231 |
+
return report
|
key_farm/worker.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from .contracts import (
|
| 8 |
+
ExpansionRequest,
|
| 9 |
+
FarmPolicy,
|
| 10 |
+
RegulationIntent,
|
| 11 |
+
ShardResult,
|
| 12 |
+
WorkerPulse,
|
| 13 |
+
utc_now,
|
| 14 |
+
)
|
| 15 |
+
from .evaluator import evaluate_job
|
| 16 |
+
from .regulation import create_pulse, tick_pulse
|
| 17 |
+
from .store import FarmStore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def maybe_request_expansion(
|
| 21 |
+
store: FarmStore,
|
| 22 |
+
job_run_id: str,
|
| 23 |
+
generation: int,
|
| 24 |
+
policy: FarmPolicy,
|
| 25 |
+
reason: str,
|
| 26 |
+
formation: str,
|
| 27 |
+
) -> None:
|
| 28 |
+
pending = len(store.pending_jobs(formation))
|
| 29 |
+
active = store.active_worker_count(policy.pulse_ttl_seconds, formation)
|
| 30 |
+
allowed = policy.effective_max_workers()
|
| 31 |
+
if active >= allowed:
|
| 32 |
+
return
|
| 33 |
+
if pending <= max(1, active * 2):
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
requested = min(allowed - active, max(1, pending // 4))
|
| 37 |
+
request = ExpansionRequest(
|
| 38 |
+
run_id=job_run_id,
|
| 39 |
+
generation=generation,
|
| 40 |
+
reason=reason,
|
| 41 |
+
requested_workers=requested,
|
| 42 |
+
active_workers=active,
|
| 43 |
+
pending_jobs=pending,
|
| 44 |
+
policy=policy.to_dict(),
|
| 45 |
+
formation=formation,
|
| 46 |
+
)
|
| 47 |
+
store.write_expansion_request(request)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def primary_formation(policy: FarmPolicy) -> str:
|
| 51 |
+
formations = tuple(policy.allowed_formations or ("cpu-worker",))
|
| 52 |
+
return formations[0] if formations else "cpu-worker"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def worker_tick(
|
| 56 |
+
store: FarmStore,
|
| 57 |
+
worker_id: str,
|
| 58 |
+
policy: FarmPolicy,
|
| 59 |
+
pulse_state: dict[str, float],
|
| 60 |
+
) -> bool:
|
| 61 |
+
formation = primary_formation(policy)
|
| 62 |
+
claimed = store.claim_next(worker_id, tuple(policy.allowed_formations))
|
| 63 |
+
if claimed is None:
|
| 64 |
+
store.write_worker_pulse(
|
| 65 |
+
WorkerPulse(worker_id, None, None, "idle", formation=formation, pulse=pulse_state)
|
| 66 |
+
)
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
job, claimed_path = claimed
|
| 70 |
+
store.write_worker_pulse(
|
| 71 |
+
WorkerPulse(
|
| 72 |
+
worker_id,
|
| 73 |
+
job.run_id,
|
| 74 |
+
job.generation,
|
| 75 |
+
"claimed",
|
| 76 |
+
job.shard_id,
|
| 77 |
+
formation=formation,
|
| 78 |
+
pulse=pulse_state,
|
| 79 |
+
)
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
children = job.split(policy)
|
| 83 |
+
if len(children) > 1:
|
| 84 |
+
store.put_jobs(children)
|
| 85 |
+
result = ShardResult(
|
| 86 |
+
run_id=job.run_id,
|
| 87 |
+
generation=job.generation,
|
| 88 |
+
shard_id=job.shard_id,
|
| 89 |
+
worker_id=worker_id,
|
| 90 |
+
status="refracted",
|
| 91 |
+
node_results=[],
|
| 92 |
+
started_at=utc_now(),
|
| 93 |
+
message=f"split into {len(children)} child shards",
|
| 94 |
+
)
|
| 95 |
+
store.complete_job(job, result, claimed_path)
|
| 96 |
+
maybe_request_expansion(
|
| 97 |
+
store, job.run_id, job.generation, policy, "job_refraction", job.formation
|
| 98 |
+
)
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
result = evaluate_job(job, worker_id)
|
| 102 |
+
store.complete_job(job, result, claimed_path)
|
| 103 |
+
maybe_request_expansion(store, job.run_id, job.generation, policy, "pending_backlog", job.formation)
|
| 104 |
+
return True
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _drain_applies_to_worker(
|
| 108 |
+
store: FarmStore,
|
| 109 |
+
worker_id: str,
|
| 110 |
+
control: RegulationIntent,
|
| 111 |
+
policy: FarmPolicy,
|
| 112 |
+
worker_formation: str,
|
| 113 |
+
) -> bool:
|
| 114 |
+
if control.target_workers is None:
|
| 115 |
+
return False
|
| 116 |
+
if control.target_workers <= 0:
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
active = store.active_worker_ids(policy.pulse_ttl_seconds, worker_formation)
|
| 120 |
+
if len(active) <= control.target_workers:
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
keepers = set(active[: control.target_workers])
|
| 124 |
+
return worker_id not in keepers
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def worker_exit_reason(store: FarmStore, worker_id: str, policy: FarmPolicy) -> str | None:
|
| 128 |
+
control = store.read_regulation()
|
| 129 |
+
worker_formation = primary_formation(policy)
|
| 130 |
+
if control.formation is not None and control.formation != worker_formation:
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
if control.mode == "shutdown":
|
| 134 |
+
return f"shutdown:{control.reason}"
|
| 135 |
+
|
| 136 |
+
if control.mode == "drain" and _drain_applies_to_worker(
|
| 137 |
+
store, worker_id, control, policy, worker_formation
|
| 138 |
+
):
|
| 139 |
+
return f"drain:{control.reason}"
|
| 140 |
+
|
| 141 |
+
if policy.exit_when_pulse_lost and not store.controller_pulse_alive(
|
| 142 |
+
policy.controller_pulse_ttl_seconds
|
| 143 |
+
):
|
| 144 |
+
return "controller_pulse_lost"
|
| 145 |
+
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def worker_loop(
|
| 150 |
+
store: FarmStore,
|
| 151 |
+
worker_id: str,
|
| 152 |
+
policy: FarmPolicy,
|
| 153 |
+
*,
|
| 154 |
+
poll_seconds: float = 2.0,
|
| 155 |
+
once: bool = False,
|
| 156 |
+
) -> None:
|
| 157 |
+
pulse = create_pulse(policy.pulse_rate)
|
| 158 |
+
formation = primary_formation(policy)
|
| 159 |
+
while True:
|
| 160 |
+
pulse_state = tick_pulse(pulse)
|
| 161 |
+
reason = worker_exit_reason(store, worker_id, policy)
|
| 162 |
+
if reason is not None:
|
| 163 |
+
store.write_worker_pulse(
|
| 164 |
+
WorkerPulse(
|
| 165 |
+
worker_id,
|
| 166 |
+
None,
|
| 167 |
+
None,
|
| 168 |
+
f"exiting:{reason}",
|
| 169 |
+
formation=formation,
|
| 170 |
+
pulse=pulse_state,
|
| 171 |
+
)
|
| 172 |
+
)
|
| 173 |
+
store.append_event("farm_worker_exiting", {"worker_id": worker_id, "reason": reason})
|
| 174 |
+
return
|
| 175 |
+
store.requeue_expired_claims(policy)
|
| 176 |
+
did_work = worker_tick(store, worker_id, policy, pulse_state)
|
| 177 |
+
if once:
|
| 178 |
+
return
|
| 179 |
+
if not did_work:
|
| 180 |
+
time.sleep(poll_seconds)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def main() -> None:
|
| 184 |
+
parser = argparse.ArgumentParser(description="KEY CPU farm worker")
|
| 185 |
+
parser.add_argument("--root", default=os.environ.get("KEY_FARM_ROOT", "/data/farm"))
|
| 186 |
+
parser.add_argument("--worker-id", default=os.environ.get("KEY_FARM_WORKER_ID"))
|
| 187 |
+
parser.add_argument("--once", action="store_true")
|
| 188 |
+
parser.add_argument("--poll-seconds", type=float, default=2.0)
|
| 189 |
+
parser.add_argument("--target-batch-size", type=int, default=None)
|
| 190 |
+
parser.add_argument("--max-workers", type=int, default=None)
|
| 191 |
+
parser.add_argument("--budget-hourly-usd", type=float, default=None)
|
| 192 |
+
parser.add_argument("--formation", default=os.environ.get("KEY_FARM_FORMATION", "cpu-worker"))
|
| 193 |
+
args = parser.parse_args()
|
| 194 |
+
|
| 195 |
+
worker_id = args.worker_id or f"worker_{os.getpid()}"
|
| 196 |
+
policy_kwargs = {}
|
| 197 |
+
if args.target_batch_size is not None:
|
| 198 |
+
policy_kwargs["target_batch_size"] = args.target_batch_size
|
| 199 |
+
if args.max_workers is not None:
|
| 200 |
+
policy_kwargs["max_workers"] = args.max_workers
|
| 201 |
+
if args.budget_hourly_usd is not None:
|
| 202 |
+
policy_kwargs["budget_hourly_usd"] = args.budget_hourly_usd
|
| 203 |
+
policy_kwargs["allowed_formations"] = (args.formation,)
|
| 204 |
+
|
| 205 |
+
worker_loop(
|
| 206 |
+
FarmStore(args.root),
|
| 207 |
+
worker_id,
|
| 208 |
+
FarmPolicy(**policy_kwargs),
|
| 209 |
+
poll_seconds=args.poll_seconds,
|
| 210 |
+
once=args.once,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
main()
|
meshscale/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MeshScale public package.
|
| 2 |
+
|
| 3 |
+
MeshScale is the CPU worker mesh built from the KEY farm primitives.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from key_farm import * # noqa: F401,F403
|
meshscale/cli.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from key_farm.cli import main
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
main()
|
meshscale_space/Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PORT=7860
|
| 5 |
+
ENV KEY_FARM_ROOT=/data/farm
|
| 6 |
+
ENV MESHSCALE_ENABLE_APPLY=0
|
| 7 |
+
ENV MESHSCALE_FULL_KEY_REQUIREMENTS=0
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY . /app
|
| 11 |
+
|
| 12 |
+
RUN python -m pip install --upgrade pip \
|
| 13 |
+
&& python -m pip install --no-cache-dir -r meshscale_space/requirements.txt \
|
| 14 |
+
&& if [ "$MESHSCALE_FULL_KEY_REQUIREMENTS" = "1" ] && [ -f requirements.txt ]; then \
|
| 15 |
+
python -m pip install --no-cache-dir -r requirements.txt; \
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
CMD ["python", "-m", "uvicorn", "meshscale_space.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
meshscale_space/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MeshScale
|
| 3 |
+
emoji: M
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# MeshScale
|
| 12 |
+
|
| 13 |
+
Bare-metal search surface for the MeshScale CPU worker mesh.
|
| 14 |
+
|
| 15 |
+
Required Space variable:
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
KEY_FARM_ROOT=/data/farm
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Safe default:
|
| 22 |
+
|
| 23 |
+
```text
|
| 24 |
+
MESHSCALE_ENABLE_APPLY=0
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
Set `MESHSCALE_ENABLE_APPLY=1` only on the controller Space that is allowed to
|
| 28 |
+
start or pause Hugging Face CPU worker Spaces.
|
| 29 |
+
|
| 30 |
+
Common variables:
|
| 31 |
+
|
| 32 |
+
```text
|
| 33 |
+
MESHSCALE_TEMPLATE_SPACE=tostido/meshscale-worker-template
|
| 34 |
+
MESHSCALE_NAMESPACE=tostido
|
| 35 |
+
MESHSCALE_WORKER_PREFIX=meshscale-worker
|
| 36 |
+
MESHSCALE_HARDWARE=cpu-upgrade
|
| 37 |
+
MESHSCALE_MAX_WORKERS=24
|
| 38 |
+
MESHSCALE_BUDGET_HOURLY_USD=0.72
|
| 39 |
+
MESHSCALE_KEY_SPACE_URL=https://huggingface.co/spaces/tostido/Ouroboros
|
| 40 |
+
MESHSCALE_BUCKET_URL=https://huggingface.co/buckets/tostido/Ouroboros-storage
|
| 41 |
+
MESHSCALE_WORKER_TEMPLATE_URL=https://huggingface.co/spaces/tostido/meshscale-worker-template
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
The UI accepts search commands:
|
| 45 |
+
|
| 46 |
+
```text
|
| 47 |
+
guide
|
| 48 |
+
status
|
| 49 |
+
trace
|
| 50 |
+
plan 420
|
| 51 |
+
scale dry
|
| 52 |
+
scale apply
|
| 53 |
+
run
|
| 54 |
+
drain 4
|
| 55 |
+
shutdown
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
The OS route layer returns contextual next steps before acting. For example,
|
| 59 |
+
`scale apply` stops early when the controller pulse is quiet and routes the user
|
| 60 |
+
to KEY instead of blindly scaling workers.
|
meshscale_space/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MeshScale controller Space."""
|
meshscale_space/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (194 Bytes). View file
|
|
|
meshscale_space/__pycache__/app.cpython-310.pyc
ADDED
|
Binary file (6.85 kB). View file
|
|
|
meshscale_space/__pycache__/os_routes.cpython-310.pyc
ADDED
|
Binary file (5.46 kB). View file
|
|
|
meshscale_space/app.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, Request
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
|
| 11 |
+
from key_farm.contracts import FarmPolicy, RegulationIntent
|
| 12 |
+
from key_farm.scaler import MeshScaleScaler, ScalePolicy
|
| 13 |
+
from key_farm.store import FarmStore
|
| 14 |
+
from key_farm.symbiotic_trace import symbiotic_trace
|
| 15 |
+
from .os_routes import default_links, route_query
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 19 |
+
STATIC_DIR = APP_DIR / "static"
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title="MeshScale Bare Metal")
|
| 22 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _int_env(name: str, default: int) -> int:
|
| 26 |
+
try:
|
| 27 |
+
return int(os.environ.get(name, str(default)))
|
| 28 |
+
except ValueError:
|
| 29 |
+
return default
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _float_env(name: str, default: float) -> float:
|
| 33 |
+
try:
|
| 34 |
+
return float(os.environ.get(name, str(default)))
|
| 35 |
+
except ValueError:
|
| 36 |
+
return default
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _bool_env(name: str, default: bool = False) -> bool:
|
| 40 |
+
raw = os.environ.get(name)
|
| 41 |
+
if raw is None:
|
| 42 |
+
return default
|
| 43 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _farm_root() -> str:
|
| 47 |
+
return os.environ.get("KEY_FARM_ROOT", "/data/farm")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _store() -> FarmStore:
|
| 51 |
+
return FarmStore(_farm_root())
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _links() -> dict[str, str]:
|
| 55 |
+
links = default_links()
|
| 56 |
+
overrides = {
|
| 57 |
+
"key_space": os.environ.get("MESHSCALE_KEY_SPACE_URL"),
|
| 58 |
+
"bucket": os.environ.get("MESHSCALE_BUCKET_URL"),
|
| 59 |
+
"worker_template": os.environ.get("MESHSCALE_WORKER_TEMPLATE_URL"),
|
| 60 |
+
"storage_docs": os.environ.get("MESHSCALE_STORAGE_DOCS_URL"),
|
| 61 |
+
"space_docs": os.environ.get("MESHSCALE_SPACE_DOCS_URL"),
|
| 62 |
+
}
|
| 63 |
+
return {key: value or links[key] for key, value in overrides.items()}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _farm_policy(payload: dict[str, Any] | None = None) -> FarmPolicy:
|
| 67 |
+
payload = payload or {}
|
| 68 |
+
return FarmPolicy(
|
| 69 |
+
target_batch_size=int(payload.get("target_batch_size") or _int_env("KEY_FARM_TARGET_BATCH_SIZE", 24)),
|
| 70 |
+
max_workers=int(payload.get("max_workers") or _int_env("KEY_FARM_MAX_WORKERS", 24)),
|
| 71 |
+
budget_hourly_usd=float(
|
| 72 |
+
payload.get("budget_hourly_usd") or _float_env("KEY_FARM_BUDGET_HOURLY_USD", 0.72)
|
| 73 |
+
),
|
| 74 |
+
cpu_space_hourly_usd=float(
|
| 75 |
+
payload.get("cpu_space_hourly_usd") or _float_env("KEY_FARM_CPU_SPACE_HOURLY_USD", 0.03)
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _scale_policy(payload: dict[str, Any]) -> ScalePolicy:
|
| 81 |
+
apply_requested = bool(payload.get("apply", False))
|
| 82 |
+
apply_allowed = _bool_env("MESHSCALE_ENABLE_APPLY", False)
|
| 83 |
+
return ScalePolicy(
|
| 84 |
+
template_space=str(
|
| 85 |
+
payload.get("template_space")
|
| 86 |
+
or os.environ.get("MESHSCALE_TEMPLATE_SPACE", "tostido/meshscale-worker-template")
|
| 87 |
+
),
|
| 88 |
+
namespace=str(payload.get("namespace") or os.environ.get("MESHSCALE_NAMESPACE", "tostido")),
|
| 89 |
+
worker_prefix=str(
|
| 90 |
+
payload.get("worker_prefix") or os.environ.get("MESHSCALE_WORKER_PREFIX", "meshscale-worker")
|
| 91 |
+
),
|
| 92 |
+
formation=str(payload.get("formation") or os.environ.get("KEY_FARM_FORMATION", "cpu-worker")),
|
| 93 |
+
hardware=str(payload.get("hardware") or os.environ.get("MESHSCALE_HARDWARE", "cpu-upgrade")),
|
| 94 |
+
max_workers=int(payload.get("max_workers") or _int_env("MESHSCALE_MAX_WORKERS", 24)),
|
| 95 |
+
max_starts_per_tick=int(
|
| 96 |
+
payload.get("max_starts_per_tick") or _int_env("MESHSCALE_MAX_STARTS_PER_TICK", 2)
|
| 97 |
+
),
|
| 98 |
+
start_cooldown_seconds=int(
|
| 99 |
+
payload.get("start_cooldown_seconds") or _int_env("MESHSCALE_START_COOLDOWN_SECONDS", 120)
|
| 100 |
+
),
|
| 101 |
+
budget_hourly_usd=float(
|
| 102 |
+
payload.get("budget_hourly_usd") or _float_env("MESHSCALE_BUDGET_HOURLY_USD", 0.72)
|
| 103 |
+
),
|
| 104 |
+
cpu_space_hourly_usd=float(
|
| 105 |
+
payload.get("cpu_space_hourly_usd") or _float_env("MESHSCALE_CPU_SPACE_HOURLY_USD", 0.03)
|
| 106 |
+
),
|
| 107 |
+
dry_run=not (apply_requested and apply_allowed),
|
| 108 |
+
private=not bool(payload.get("public", False)),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.get("/")
|
| 113 |
+
def index() -> FileResponse:
|
| 114 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.get("/api/health")
|
| 118 |
+
def health() -> dict[str, Any]:
|
| 119 |
+
root = _farm_root()
|
| 120 |
+
return {
|
| 121 |
+
"ok": True,
|
| 122 |
+
"farm_root": root,
|
| 123 |
+
"apply_enabled": _bool_env("MESHSCALE_ENABLE_APPLY", False),
|
| 124 |
+
"links": _links(),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/api/status")
|
| 129 |
+
def status() -> dict[str, Any]:
|
| 130 |
+
report = symbiotic_trace(_store(), _farm_policy(), receipt=False)
|
| 131 |
+
report["farm_root"] = _farm_root()
|
| 132 |
+
report["apply_enabled"] = _bool_env("MESHSCALE_ENABLE_APPLY", False)
|
| 133 |
+
report["links"] = _links()
|
| 134 |
+
return report
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@app.post("/api/route")
|
| 138 |
+
async def route(request: Request) -> dict[str, Any]:
|
| 139 |
+
payload = await request.json()
|
| 140 |
+
query = str(payload.get("query") or "")
|
| 141 |
+
report = symbiotic_trace(_store(), _farm_policy(), receipt=False)
|
| 142 |
+
report["farm_root"] = _farm_root()
|
| 143 |
+
report["apply_enabled"] = _bool_env("MESHSCALE_ENABLE_APPLY", False)
|
| 144 |
+
report["links"] = _links()
|
| 145 |
+
return {
|
| 146 |
+
"query": query,
|
| 147 |
+
"route": route_query(query, report, _links()),
|
| 148 |
+
"summary": report.get("summary", {}),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.post("/api/plan")
|
| 153 |
+
async def plan(request: Request) -> dict[str, Any]:
|
| 154 |
+
payload = await request.json()
|
| 155 |
+
pending = int(payload.get("pending_jobs") or 0)
|
| 156 |
+
active = int(payload.get("active_workers") or 0)
|
| 157 |
+
jobs_per_worker = max(1, int(payload.get("jobs_per_worker") or 2))
|
| 158 |
+
policy = _farm_policy(payload)
|
| 159 |
+
needed = max(1, (pending + jobs_per_worker - 1) // jobs_per_worker) if pending else 0
|
| 160 |
+
requested = max(0, min(policy.effective_max_workers(), needed) - active)
|
| 161 |
+
return {
|
| 162 |
+
"pending_jobs": pending,
|
| 163 |
+
"active_workers": active,
|
| 164 |
+
"jobs_per_worker": jobs_per_worker,
|
| 165 |
+
"requested_workers": requested,
|
| 166 |
+
"effective_max_workers": policy.effective_max_workers(),
|
| 167 |
+
"hourly_budget_usd": policy.budget_hourly_usd,
|
| 168 |
+
"cpu_space_hourly_usd": policy.cpu_space_hourly_usd,
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@app.post("/api/regulate")
|
| 173 |
+
async def regulate(request: Request) -> dict[str, Any]:
|
| 174 |
+
payload = await request.json()
|
| 175 |
+
mode = str(payload.get("mode") or "run")
|
| 176 |
+
if mode not in {"run", "drain", "shutdown"}:
|
| 177 |
+
return {"ok": False, "error": f"invalid regulation mode: {mode}"}
|
| 178 |
+
target_workers = payload.get("target_workers")
|
| 179 |
+
if target_workers in ("", None):
|
| 180 |
+
target_workers = None
|
| 181 |
+
elif target_workers is not None:
|
| 182 |
+
target_workers = int(target_workers)
|
| 183 |
+
intent = RegulationIntent(
|
| 184 |
+
mode=mode,
|
| 185 |
+
reason=str(payload.get("reason") or "meshscale_operator"),
|
| 186 |
+
target_workers=target_workers,
|
| 187 |
+
formation=str(payload.get("formation") or "") or None,
|
| 188 |
+
issued_by="meshscale-space",
|
| 189 |
+
)
|
| 190 |
+
_store().write_regulation(intent)
|
| 191 |
+
return {"ok": True, "intent": intent.to_dict()}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@app.post("/api/scale-once")
|
| 195 |
+
async def scale_once(request: Request) -> dict[str, Any]:
|
| 196 |
+
payload = await request.json()
|
| 197 |
+
policy = _scale_policy(payload)
|
| 198 |
+
report = MeshScaleScaler(_store(), policy).tick()
|
| 199 |
+
report["apply_requested"] = bool(payload.get("apply", False))
|
| 200 |
+
report["apply_enabled"] = _bool_env("MESHSCALE_ENABLE_APPLY", False)
|
| 201 |
+
return report
|
meshscale_space/os_routes.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _action(kind: str, label: str, target: str, description: str) -> dict[str, str]:
|
| 7 |
+
return {
|
| 8 |
+
"kind": kind,
|
| 9 |
+
"label": label,
|
| 10 |
+
"target": target,
|
| 11 |
+
"description": description,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def default_links() -> dict[str, str]:
|
| 16 |
+
return {
|
| 17 |
+
"key_space": "https://huggingface.co/spaces/tostido/Ouroboros",
|
| 18 |
+
"bucket": "https://huggingface.co/buckets/tostido/Ouroboros-storage",
|
| 19 |
+
"worker_template": "https://huggingface.co/spaces/tostido/meshscale-worker-template",
|
| 20 |
+
"storage_docs": "https://huggingface.co/docs/hub/storage-buckets",
|
| 21 |
+
"space_docs": "https://huggingface.co/docs/huggingface_hub/guides/manage-spaces",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def route_query(query: str, status: dict[str, Any], links: dict[str, str] | None = None) -> dict[str, Any]:
|
| 26 |
+
"""Return a human OS route for a MeshScale search query."""
|
| 27 |
+
|
| 28 |
+
links = {**default_links(), **(links or {})}
|
| 29 |
+
q = query.strip().lower()
|
| 30 |
+
summary = status.get("summary", {})
|
| 31 |
+
signal = status.get("signal", {})
|
| 32 |
+
pulse = signal.get("pulse", {})
|
| 33 |
+
traffic = signal.get("traffic", {}).get("counts", {})
|
| 34 |
+
regulation = signal.get("regulation", {})
|
| 35 |
+
|
| 36 |
+
controller_alive = bool(summary.get("controller_alive"))
|
| 37 |
+
pending = int(summary.get("pending") or traffic.get("pending") or 0)
|
| 38 |
+
failed = int(summary.get("failed") or traffic.get("failed") or 0)
|
| 39 |
+
workers = int(summary.get("active_workers") or pulse.get("active_workers") or 0)
|
| 40 |
+
mode = regulation.get("mode", "run")
|
| 41 |
+
|
| 42 |
+
if not q:
|
| 43 |
+
q = "status"
|
| 44 |
+
|
| 45 |
+
if any(token in q for token in ("help", "guide", "where", "what", "?")):
|
| 46 |
+
return {
|
| 47 |
+
"title": "OS guide",
|
| 48 |
+
"message": "Start with status, then follow the next route that matches the pulse and backlog state.",
|
| 49 |
+
"severity": "info",
|
| 50 |
+
"actions": [
|
| 51 |
+
_action("command", "Check status", "status", "Read pulse, workers, backlog, and errors."),
|
| 52 |
+
_action("command", "Plan capacity", "plan 420", "Estimate CPU worker count before scaling."),
|
| 53 |
+
_action("link", "Open KEY Space", links["key_space"], "Start or inspect the controller run."),
|
| 54 |
+
_action("link", "Open bucket", links["bucket"], "Inspect farm files and run artifacts."),
|
| 55 |
+
],
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if any(token in q for token in ("scale", "worker", "expand", "farm")):
|
| 59 |
+
if not controller_alive:
|
| 60 |
+
return {
|
| 61 |
+
"title": "Not ready to scale",
|
| 62 |
+
"message": "The controller pulse is quiet. MeshScale will not start workers until KEY is alive.",
|
| 63 |
+
"severity": "warn",
|
| 64 |
+
"actions": [
|
| 65 |
+
_action("link", "Open KEY Space", links["key_space"], "Start or refresh the KEY controller."),
|
| 66 |
+
_action("command", "Check status", "status", "Confirm the controller pulse is live."),
|
| 67 |
+
_action("command", "Dry-run scale", "scale dry", "See the guarded scaler decision."),
|
| 68 |
+
],
|
| 69 |
+
}
|
| 70 |
+
if pending <= 0:
|
| 71 |
+
return {
|
| 72 |
+
"title": "No backlog yet",
|
| 73 |
+
"message": "Scaling is premature because there are no pending shard jobs in the bucket.",
|
| 74 |
+
"severity": "info",
|
| 75 |
+
"actions": [
|
| 76 |
+
_action("link", "Open bucket", links["bucket"], "Look for pending generation shards."),
|
| 77 |
+
_action("command", "Check trace", "trace", "Inspect traffic and expansion requests."),
|
| 78 |
+
_action("command", "Plan 420", "plan 420", "Model capacity before the run arrives."),
|
| 79 |
+
],
|
| 80 |
+
}
|
| 81 |
+
return {
|
| 82 |
+
"title": "Scale path ready",
|
| 83 |
+
"message": f"{pending} pending shards are visible with {workers} active workers.",
|
| 84 |
+
"severity": "ok",
|
| 85 |
+
"actions": [
|
| 86 |
+
_action("command", "Plan backlog", f"plan {pending}", "Estimate workers from current backlog."),
|
| 87 |
+
_action("command", "Dry-run scale", "scale dry", "Preview worker starts under policy caps."),
|
| 88 |
+
_action("link", "Worker template", links["worker_template"], "Inspect the CPU worker image."),
|
| 89 |
+
],
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if any(token in q for token in ("start", "run", "pulse", "controller")):
|
| 93 |
+
if controller_alive and mode == "run":
|
| 94 |
+
return {
|
| 95 |
+
"title": "Controller is live",
|
| 96 |
+
"message": "The pulse is active and regulation is already in run mode.",
|
| 97 |
+
"severity": "ok",
|
| 98 |
+
"actions": [
|
| 99 |
+
_action("command", "Trace traffic", "trace", "Inspect current farm movement."),
|
| 100 |
+
_action("command", "Plan capacity", f"plan {max(pending, 120)}", "Size the next worker move."),
|
| 101 |
+
],
|
| 102 |
+
}
|
| 103 |
+
return {
|
| 104 |
+
"title": "Go to controller",
|
| 105 |
+
"message": "You are not at the run step yet. Start the KEY Space or publish run regulation.",
|
| 106 |
+
"severity": "warn",
|
| 107 |
+
"actions": [
|
| 108 |
+
_action("link", "Open KEY Space", links["key_space"], "Start the KEY population run."),
|
| 109 |
+
_action("command", "Set run mode", "run", "Publish MeshScale run regulation."),
|
| 110 |
+
_action("command", "Check status", "status", "Verify the pulse after starting."),
|
| 111 |
+
],
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if any(token in q for token in ("error", "fail", "broken", "logs", "debug")):
|
| 115 |
+
if failed > 0:
|
| 116 |
+
return {
|
| 117 |
+
"title": "Failures detected",
|
| 118 |
+
"message": f"{failed} failed shard files are visible. Inspect samples before adding workers.",
|
| 119 |
+
"severity": "warn",
|
| 120 |
+
"actions": [
|
| 121 |
+
_action("command", "Trace failures", "trace", "Show failed shard samples."),
|
| 122 |
+
_action("link", "Open bucket", links["bucket"], "Inspect jobs/failed and result payloads."),
|
| 123 |
+
],
|
| 124 |
+
}
|
| 125 |
+
return {
|
| 126 |
+
"title": "No shard failures",
|
| 127 |
+
"message": "The current farm trace does not report failed shard jobs.",
|
| 128 |
+
"severity": "ok",
|
| 129 |
+
"actions": [
|
| 130 |
+
_action("command", "Trace", "trace", "Inspect detailed traffic."),
|
| 131 |
+
_action("link", "Bucket", links["bucket"], "Open raw farm storage."),
|
| 132 |
+
],
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if any(token in q for token in ("bucket", "storage", "files", "jobs")):
|
| 136 |
+
return {
|
| 137 |
+
"title": "Bucket route",
|
| 138 |
+
"message": "MeshScale state lives in the bucket: pulses, regulation, expansion requests, runs, jobs, and results.",
|
| 139 |
+
"severity": "info",
|
| 140 |
+
"actions": [
|
| 141 |
+
_action("link", "Open bucket", links["bucket"], "Inspect farm IO directly."),
|
| 142 |
+
_action("link", "Storage docs", links["storage_docs"], "Review read-write bucket volumes."),
|
| 143 |
+
_action("command", "Trace", "trace", "Summarize bucket state from here."),
|
| 144 |
+
],
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"title": "Route not matched",
|
| 149 |
+
"message": "The OS route did not find a direct match. Start with status or ask for a guide.",
|
| 150 |
+
"severity": "info",
|
| 151 |
+
"actions": [
|
| 152 |
+
_action("command", "Status", "status", "Read the current farm state."),
|
| 153 |
+
_action("command", "Guide", "guide", "Show the operator route map."),
|
| 154 |
+
_action("command", "Trace", "trace", "Inspect traffic, pulse, and recommendations."),
|
| 155 |
+
],
|
| 156 |
+
}
|
meshscale_space/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn[standard]>=0.27
|
| 3 |
+
huggingface-hub>=0.34.0,<1.0
|
| 4 |
+
numpy>=1.24,<1.27
|
| 5 |
+
simpleeval>=0.9.13
|
meshscale_space/static/app.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const output = document.querySelector("#output");
|
| 2 |
+
const title = document.querySelector("#result-title");
|
| 3 |
+
const mode = document.querySelector("#result-mode");
|
| 4 |
+
const form = document.querySelector("#command-form");
|
| 5 |
+
const input = document.querySelector("#command");
|
| 6 |
+
const strip = document.querySelector("#status-strip");
|
| 7 |
+
const routeTitle = document.querySelector("#route-title");
|
| 8 |
+
const routeMessage = document.querySelector("#route-message");
|
| 9 |
+
const routeActions = document.querySelector("#route-actions");
|
| 10 |
+
|
| 11 |
+
const state = {
|
| 12 |
+
lastStatus: null,
|
| 13 |
+
defaults: {
|
| 14 |
+
template_space: "tostido/meshscale-worker-template",
|
| 15 |
+
namespace: "tostido",
|
| 16 |
+
worker_prefix: "meshscale-worker",
|
| 17 |
+
hardware: "cpu-upgrade",
|
| 18 |
+
formation: "cpu-worker",
|
| 19 |
+
max_workers: 24,
|
| 20 |
+
budget_hourly_usd: 0.72,
|
| 21 |
+
cpu_space_hourly_usd: 0.03,
|
| 22 |
+
max_starts_per_tick: 2,
|
| 23 |
+
},
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
function show(name, data, statusText = "") {
|
| 27 |
+
title.textContent = name;
|
| 28 |
+
mode.textContent = statusText || (data?.apply_enabled ? "apply enabled" : "dry-run guarded");
|
| 29 |
+
output.textContent = JSON.stringify(data, null, 2);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function showError(err) {
|
| 33 |
+
show("Error", { error: String(err?.message || err) }, "check logs");
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function getJson(url) {
|
| 37 |
+
const res = await fetch(url);
|
| 38 |
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
| 39 |
+
return res.json();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
async function postJson(url, body) {
|
| 43 |
+
const res = await fetch(url, {
|
| 44 |
+
method: "POST",
|
| 45 |
+
headers: { "content-type": "application/json" },
|
| 46 |
+
body: JSON.stringify(body),
|
| 47 |
+
});
|
| 48 |
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
| 49 |
+
return res.json();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function updateStrip(report) {
|
| 53 |
+
state.lastStatus = report;
|
| 54 |
+
const summary = report.summary || {};
|
| 55 |
+
const signal = report.signal || {};
|
| 56 |
+
const pulse = signal.pulse || {};
|
| 57 |
+
const root = report.farm_root || "unknown";
|
| 58 |
+
strip.innerHTML = "";
|
| 59 |
+
[
|
| 60 |
+
`root: ${root}`,
|
| 61 |
+
`pulse: ${summary.controller_alive ? "live" : "quiet"}`,
|
| 62 |
+
`workers: ${summary.active_workers ?? 0}`,
|
| 63 |
+
`pending: ${summary.pending ?? 0}`,
|
| 64 |
+
].forEach((item) => {
|
| 65 |
+
const span = document.createElement("span");
|
| 66 |
+
span.textContent = item;
|
| 67 |
+
if (item.includes("quiet")) span.style.color = "#dfbd62";
|
| 68 |
+
if ((pulse.stale_workers || 0) > 0 && item.startsWith("workers")) span.style.color = "#dfbd62";
|
| 69 |
+
strip.appendChild(span);
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function runCommandFromButton(command) {
|
| 74 |
+
input.value = command;
|
| 75 |
+
return runCommand(command).catch(showError);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function renderRoute(route) {
|
| 79 |
+
if (!route) return;
|
| 80 |
+
routeTitle.textContent = route.title || "OS route";
|
| 81 |
+
routeMessage.textContent = route.message || "";
|
| 82 |
+
routeActions.innerHTML = "";
|
| 83 |
+
(route.actions || []).forEach((action) => {
|
| 84 |
+
if (action.kind === "link") {
|
| 85 |
+
const link = document.createElement("a");
|
| 86 |
+
link.href = action.target;
|
| 87 |
+
link.target = "_blank";
|
| 88 |
+
link.rel = "noreferrer";
|
| 89 |
+
link.textContent = action.label;
|
| 90 |
+
link.title = action.description || "";
|
| 91 |
+
routeActions.appendChild(link);
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
const button = document.createElement("button");
|
| 95 |
+
button.type = "button";
|
| 96 |
+
button.textContent = action.label;
|
| 97 |
+
button.dataset.tip = action.description || "";
|
| 98 |
+
button.addEventListener("click", () => runCommandFromButton(action.target));
|
| 99 |
+
routeActions.appendChild(button);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
async function fetchRoute(query) {
|
| 104 |
+
const routed = await postJson("/api/route", { query });
|
| 105 |
+
renderRoute(routed.route);
|
| 106 |
+
return routed;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function numberAfter(parts, fallback) {
|
| 110 |
+
for (const part of parts) {
|
| 111 |
+
const value = Number(part);
|
| 112 |
+
if (Number.isFinite(value)) return value;
|
| 113 |
+
}
|
| 114 |
+
return fallback;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
async function runCommand(raw) {
|
| 118 |
+
const command = raw.trim().toLowerCase();
|
| 119 |
+
if (!command) return runCommand("status");
|
| 120 |
+
const parts = command.split(/\s+/);
|
| 121 |
+
const head = parts[0];
|
| 122 |
+
const routed = await fetchRoute(command);
|
| 123 |
+
|
| 124 |
+
if (head === "status" || head === "trace" || head === "inspect" || head === "search") {
|
| 125 |
+
const report = await getJson("/api/status");
|
| 126 |
+
updateStrip(report);
|
| 127 |
+
show(head === "trace" ? "Trace" : "Status", report);
|
| 128 |
+
return;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if (head === "guide" || head === "help" || head === "where") {
|
| 132 |
+
show("Guide", routed, "next route");
|
| 133 |
+
return;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (head === "plan") {
|
| 137 |
+
const pending = numberAfter(parts.slice(1), state.lastStatus?.summary?.pending || 120);
|
| 138 |
+
const active = state.lastStatus?.summary?.active_workers || 0;
|
| 139 |
+
const data = await postJson("/api/plan", {
|
| 140 |
+
pending_jobs: pending,
|
| 141 |
+
active_workers: active,
|
| 142 |
+
jobs_per_worker: 2,
|
| 143 |
+
max_workers: state.defaults.max_workers,
|
| 144 |
+
budget_hourly_usd: state.defaults.budget_hourly_usd,
|
| 145 |
+
cpu_space_hourly_usd: state.defaults.cpu_space_hourly_usd,
|
| 146 |
+
});
|
| 147 |
+
show("Plan", data, "capacity");
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if (head === "scale") {
|
| 152 |
+
const apply = parts.includes("apply");
|
| 153 |
+
if (apply && routed.route?.severity !== "ok") {
|
| 154 |
+
show("Route blocked", routed, "not there yet");
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
const data = await postJson("/api/scale-once", { ...state.defaults, apply });
|
| 158 |
+
show("Scale", data, apply ? "apply requested" : "dry run");
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
if (head === "run") {
|
| 163 |
+
const data = await postJson("/api/regulate", {
|
| 164 |
+
mode: "run",
|
| 165 |
+
reason: "meshscale_search_run",
|
| 166 |
+
formation: state.defaults.formation,
|
| 167 |
+
});
|
| 168 |
+
show("Regulation", data, "run");
|
| 169 |
+
await refreshStatus();
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (head === "drain") {
|
| 174 |
+
const target = numberAfter(parts.slice(1), 0);
|
| 175 |
+
const data = await postJson("/api/regulate", {
|
| 176 |
+
mode: "drain",
|
| 177 |
+
target_workers: target,
|
| 178 |
+
reason: "meshscale_search_drain",
|
| 179 |
+
formation: state.defaults.formation,
|
| 180 |
+
});
|
| 181 |
+
show("Regulation", data, `drain ${target}`);
|
| 182 |
+
await refreshStatus();
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if (head === "shutdown" || head === "stop") {
|
| 187 |
+
const data = await postJson("/api/regulate", {
|
| 188 |
+
mode: "shutdown",
|
| 189 |
+
reason: "meshscale_search_shutdown",
|
| 190 |
+
formation: state.defaults.formation,
|
| 191 |
+
});
|
| 192 |
+
show("Regulation", data, "shutdown");
|
| 193 |
+
await refreshStatus();
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const report = await getJson("/api/status");
|
| 198 |
+
updateStrip(report);
|
| 199 |
+
show("Search", {
|
| 200 |
+
query: raw,
|
| 201 |
+
matched: false,
|
| 202 |
+
commands: ["status", "trace", "plan 420", "scale dry", "scale apply", "run", "drain 4", "shutdown"],
|
| 203 |
+
status: report.summary,
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
async function refreshStatus() {
|
| 208 |
+
try {
|
| 209 |
+
const report = await getJson("/api/status");
|
| 210 |
+
updateStrip(report);
|
| 211 |
+
return report;
|
| 212 |
+
} catch (err) {
|
| 213 |
+
showError(err);
|
| 214 |
+
return null;
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
form.addEventListener("submit", async (event) => {
|
| 219 |
+
event.preventDefault();
|
| 220 |
+
try {
|
| 221 |
+
await runCommand(input.value);
|
| 222 |
+
} catch (err) {
|
| 223 |
+
showError(err);
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
document.querySelectorAll("[data-command]").forEach((button) => {
|
| 228 |
+
button.addEventListener("click", async () => {
|
| 229 |
+
await runCommandFromButton(button.dataset.command || "");
|
| 230 |
+
});
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
function drawMetal() {
|
| 234 |
+
const canvas = document.querySelector("#metal");
|
| 235 |
+
const ctx = canvas.getContext("2d");
|
| 236 |
+
const ratio = Math.max(1, window.devicePixelRatio || 1);
|
| 237 |
+
let width = 0;
|
| 238 |
+
let height = 0;
|
| 239 |
+
|
| 240 |
+
function resize() {
|
| 241 |
+
width = Math.floor(window.innerWidth);
|
| 242 |
+
height = Math.floor(window.innerHeight);
|
| 243 |
+
canvas.width = width * ratio;
|
| 244 |
+
canvas.height = height * ratio;
|
| 245 |
+
canvas.style.width = `${width}px`;
|
| 246 |
+
canvas.style.height = `${height}px`;
|
| 247 |
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function frame(t) {
|
| 251 |
+
ctx.clearRect(0, 0, width, height);
|
| 252 |
+
ctx.globalAlpha = 0.42;
|
| 253 |
+
ctx.strokeStyle = "rgba(210, 220, 220, 0.08)";
|
| 254 |
+
ctx.lineWidth = 1;
|
| 255 |
+
for (let y = 22; y < height; y += 42) {
|
| 256 |
+
ctx.beginPath();
|
| 257 |
+
ctx.moveTo(0, y + Math.sin((t / 1800) + y) * 2);
|
| 258 |
+
ctx.lineTo(width, y + Math.cos((t / 2200) + y) * 2);
|
| 259 |
+
ctx.stroke();
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
ctx.globalAlpha = 0.55;
|
| 263 |
+
for (let i = 0; i < 7; i += 1) {
|
| 264 |
+
const y = (height * (0.18 + i * 0.11) + t * (0.012 + i * 0.0015)) % height;
|
| 265 |
+
const x0 = (width * (0.08 + i * 0.13)) % width;
|
| 266 |
+
ctx.strokeStyle = i % 2 ? "rgba(223, 189, 98, 0.2)" : "rgba(159, 169, 172, 0.14)";
|
| 267 |
+
ctx.lineWidth = i % 2 ? 1.4 : 1;
|
| 268 |
+
ctx.beginPath();
|
| 269 |
+
ctx.moveTo(x0, y);
|
| 270 |
+
ctx.lineTo(Math.min(width, x0 + width * 0.22), y);
|
| 271 |
+
ctx.lineTo(Math.min(width, x0 + width * 0.28), y + 38);
|
| 272 |
+
ctx.stroke();
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
ctx.globalAlpha = 1;
|
| 276 |
+
requestAnimationFrame(frame);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
resize();
|
| 280 |
+
window.addEventListener("resize", resize);
|
| 281 |
+
requestAnimationFrame(frame);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
drawMetal();
|
| 285 |
+
refreshStatus().then((report) => {
|
| 286 |
+
show("Ready", report || {}, "bare metal search");
|
| 287 |
+
input.focus();
|
| 288 |
+
});
|
meshscale_space/static/index.html
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>MeshScale</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<canvas id="metal" aria-hidden="true"></canvas>
|
| 11 |
+
<main class="shell">
|
| 12 |
+
<section class="search-stage" aria-label="MeshScale command surface">
|
| 13 |
+
<div class="mark">
|
| 14 |
+
<span class="mark-dot"></span>
|
| 15 |
+
<span>MeshScale</span>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<form id="command-form" class="search-box" autocomplete="off">
|
| 19 |
+
<input
|
| 20 |
+
id="command"
|
| 21 |
+
name="command"
|
| 22 |
+
type="search"
|
| 23 |
+
spellcheck="false"
|
| 24 |
+
placeholder="Search, inspect, plan, regulate, scale"
|
| 25 |
+
aria-label="MeshScale OS search command"
|
| 26 |
+
/>
|
| 27 |
+
<button type="submit" aria-label="Run command">
|
| 28 |
+
<span>Run</span>
|
| 29 |
+
</button>
|
| 30 |
+
</form>
|
| 31 |
+
|
| 32 |
+
<div class="quick-row" aria-label="Quick commands">
|
| 33 |
+
<button data-command="status" data-tip="Read pulse, workers, backlog, and errors.">Status</button>
|
| 34 |
+
<button data-command="guide" data-tip="Ask the OS route layer where to go next.">Guide</button>
|
| 35 |
+
<button data-command="trace" data-tip="Inspect traffic, results, pulse, and recommendations.">Trace</button>
|
| 36 |
+
<button data-command="plan 120" data-tip="Estimate workers for a pending shard count.">Plan 120</button>
|
| 37 |
+
<button data-command="scale dry" data-tip="Preview scaling without starting paid workers.">Scale dry</button>
|
| 38 |
+
<button data-command="drain 4" data-tip="Downshift to a target worker count.">Drain 4</button>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="status-strip" id="status-strip">
|
| 42 |
+
<span>root: checking</span>
|
| 43 |
+
<span>pulse: checking</span>
|
| 44 |
+
<span>workers: checking</span>
|
| 45 |
+
<span>pending: checking</span>
|
| 46 |
+
</div>
|
| 47 |
+
</section>
|
| 48 |
+
|
| 49 |
+
<section class="result-grid" aria-label="MeshScale results">
|
| 50 |
+
<article class="panel output-panel">
|
| 51 |
+
<div class="panel-head">
|
| 52 |
+
<h1 id="result-title">Ready</h1>
|
| 53 |
+
<span id="result-mode">dry-run guarded</span>
|
| 54 |
+
</div>
|
| 55 |
+
<pre id="output">{}</pre>
|
| 56 |
+
</article>
|
| 57 |
+
|
| 58 |
+
<aside class="panel command-panel">
|
| 59 |
+
<div class="panel-head">
|
| 60 |
+
<h2>OS route</h2>
|
| 61 |
+
<span>OS search</span>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="route-card" id="route-card">
|
| 64 |
+
<h3 id="route-title">Guide idle</h3>
|
| 65 |
+
<p id="route-message">Search for a goal and MeshScale will route the next step.</p>
|
| 66 |
+
<div id="route-actions" class="route-actions"></div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="command-list">
|
| 69 |
+
<button data-command="status" data-tip="Read current farm state.">status</button>
|
| 70 |
+
<button data-command="guide" data-tip="Show route suggestions.">guide</button>
|
| 71 |
+
<button data-command="trace" data-tip="Show full diagnostic trace.">trace</button>
|
| 72 |
+
<button data-command="plan 420" data-tip="Plan capacity for 420 pending jobs.">plan 420</button>
|
| 73 |
+
<button data-command="scale dry" data-tip="Run guarded scale decision.">scale dry</button>
|
| 74 |
+
<button data-command="scale apply" data-tip="Requests real HF actions only when apply is enabled.">scale apply</button>
|
| 75 |
+
<button data-command="run" data-tip="Publish run regulation.">run</button>
|
| 76 |
+
<button data-command="drain 0" data-tip="Stop new work and wind workers down.">drain 0</button>
|
| 77 |
+
<button data-command="shutdown" data-tip="Publish shutdown regulation.">shutdown</button>
|
| 78 |
+
</div>
|
| 79 |
+
</aside>
|
| 80 |
+
</section>
|
| 81 |
+
</main>
|
| 82 |
+
<script src="/static/app.js"></script>
|
| 83 |
+
</body>
|
| 84 |
+
</html>
|
meshscale_space/static/styles.css
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: dark;
|
| 3 |
+
--bg: #08090a;
|
| 4 |
+
--panel: rgba(18, 19, 19, 0.86);
|
| 5 |
+
--panel-strong: rgba(26, 27, 26, 0.94);
|
| 6 |
+
--line: rgba(229, 197, 112, 0.26);
|
| 7 |
+
--line-cold: rgba(170, 184, 191, 0.22);
|
| 8 |
+
--text: #f4f1e8;
|
| 9 |
+
--muted: #a8aba6;
|
| 10 |
+
--gold: #dfbd62;
|
| 11 |
+
--gold-hot: #f4d98b;
|
| 12 |
+
--steel: #9fa9ac;
|
| 13 |
+
--black: #090a0b;
|
| 14 |
+
font-family:
|
| 15 |
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
| 16 |
+
sans-serif;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
* {
|
| 20 |
+
box-sizing: border-box;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
html,
|
| 24 |
+
body {
|
| 25 |
+
min-height: 100%;
|
| 26 |
+
margin: 0;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
body {
|
| 30 |
+
min-width: 320px;
|
| 31 |
+
background:
|
| 32 |
+
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px) 0 0 / 76px 76px,
|
| 33 |
+
repeating-linear-gradient(
|
| 34 |
+
102deg,
|
| 35 |
+
#070808 0,
|
| 36 |
+
#0d0e0e 2px,
|
| 37 |
+
#090a0a 6px,
|
| 38 |
+
#111211 8px
|
| 39 |
+
);
|
| 40 |
+
color: var(--text);
|
| 41 |
+
overflow-x: hidden;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
#metal {
|
| 45 |
+
position: fixed;
|
| 46 |
+
inset: 0;
|
| 47 |
+
width: 100vw;
|
| 48 |
+
height: 100vh;
|
| 49 |
+
pointer-events: none;
|
| 50 |
+
opacity: 0.62;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.shell {
|
| 54 |
+
position: relative;
|
| 55 |
+
z-index: 1;
|
| 56 |
+
width: min(1120px, calc(100% - 28px));
|
| 57 |
+
margin: 0 auto;
|
| 58 |
+
padding: clamp(28px, 7vh, 76px) 0 40px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.search-stage {
|
| 62 |
+
min-height: min(540px, calc(100vh - 40px));
|
| 63 |
+
display: flex;
|
| 64 |
+
flex-direction: column;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: center;
|
| 67 |
+
gap: 18px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.mark {
|
| 71 |
+
display: inline-flex;
|
| 72 |
+
align-items: center;
|
| 73 |
+
gap: 10px;
|
| 74 |
+
min-height: 32px;
|
| 75 |
+
color: var(--text);
|
| 76 |
+
font-size: clamp(25px, 5vw, 54px);
|
| 77 |
+
font-weight: 680;
|
| 78 |
+
letter-spacing: 0;
|
| 79 |
+
text-shadow: 0 1px 0 #000, 0 0 24px rgba(244, 217, 139, 0.18);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.mark-dot {
|
| 83 |
+
width: 14px;
|
| 84 |
+
height: 14px;
|
| 85 |
+
display: inline-block;
|
| 86 |
+
border: 1px solid rgba(255, 231, 163, 0.8);
|
| 87 |
+
background:
|
| 88 |
+
linear-gradient(135deg, rgba(255, 237, 174, 0.96), rgba(165, 121, 34, 0.96));
|
| 89 |
+
box-shadow: 0 0 20px rgba(223, 189, 98, 0.42);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.search-box {
|
| 93 |
+
width: min(760px, 100%);
|
| 94 |
+
min-height: 58px;
|
| 95 |
+
display: grid;
|
| 96 |
+
grid-template-columns: 1fr auto;
|
| 97 |
+
align-items: center;
|
| 98 |
+
padding: 6px;
|
| 99 |
+
border: 1px solid var(--line);
|
| 100 |
+
background:
|
| 101 |
+
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 28%),
|
| 102 |
+
rgba(12, 13, 13, 0.9);
|
| 103 |
+
box-shadow:
|
| 104 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
| 105 |
+
0 22px 80px rgba(0, 0, 0, 0.46),
|
| 106 |
+
0 0 0 1px rgba(0, 0, 0, 0.7);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.search-box:focus-within {
|
| 110 |
+
border-color: rgba(244, 217, 139, 0.7);
|
| 111 |
+
box-shadow:
|
| 112 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
| 113 |
+
0 24px 90px rgba(0, 0, 0, 0.54),
|
| 114 |
+
0 0 0 3px rgba(223, 189, 98, 0.14);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.search-box input {
|
| 118 |
+
min-width: 0;
|
| 119 |
+
width: 100%;
|
| 120 |
+
border: 0;
|
| 121 |
+
outline: 0;
|
| 122 |
+
background: transparent;
|
| 123 |
+
color: var(--text);
|
| 124 |
+
padding: 0 18px;
|
| 125 |
+
font: inherit;
|
| 126 |
+
font-size: 17px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.search-box input::placeholder {
|
| 130 |
+
color: rgba(231, 226, 212, 0.46);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
button {
|
| 134 |
+
position: relative;
|
| 135 |
+
min-height: 40px;
|
| 136 |
+
border: 1px solid rgba(255, 232, 165, 0.22);
|
| 137 |
+
background:
|
| 138 |
+
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent),
|
| 139 |
+
#151615;
|
| 140 |
+
color: var(--text);
|
| 141 |
+
font: inherit;
|
| 142 |
+
font-size: 13px;
|
| 143 |
+
letter-spacing: 0;
|
| 144 |
+
cursor: pointer;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
button:hover {
|
| 148 |
+
border-color: rgba(244, 217, 139, 0.7);
|
| 149 |
+
color: #fff8dc;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
button:active {
|
| 153 |
+
transform: translateY(1px);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
button[data-tip]::after {
|
| 157 |
+
content: attr(data-tip);
|
| 158 |
+
position: absolute;
|
| 159 |
+
left: 50%;
|
| 160 |
+
bottom: calc(100% + 8px);
|
| 161 |
+
z-index: 8;
|
| 162 |
+
width: min(260px, 80vw);
|
| 163 |
+
padding: 8px 10px;
|
| 164 |
+
border: 1px solid rgba(223, 189, 98, 0.34);
|
| 165 |
+
background: rgba(8, 9, 9, 0.96);
|
| 166 |
+
color: #e9e2cf;
|
| 167 |
+
font-size: 12px;
|
| 168 |
+
line-height: 1.35;
|
| 169 |
+
text-align: left;
|
| 170 |
+
white-space: normal;
|
| 171 |
+
box-shadow: 0 12px 38px rgba(0, 0, 0, 0.42);
|
| 172 |
+
opacity: 0;
|
| 173 |
+
transform: translate(-50%, 4px);
|
| 174 |
+
pointer-events: none;
|
| 175 |
+
transition: opacity 140ms ease, transform 140ms ease;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
button[data-tip]:hover::after,
|
| 179 |
+
button[data-tip]:focus-visible::after {
|
| 180 |
+
opacity: 1;
|
| 181 |
+
transform: translate(-50%, 0);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.search-box button {
|
| 185 |
+
min-width: 72px;
|
| 186 |
+
padding: 0 18px;
|
| 187 |
+
background:
|
| 188 |
+
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 44%),
|
| 189 |
+
linear-gradient(135deg, #725719, #d7b75e 54%, #6b4c11);
|
| 190 |
+
color: #100d06;
|
| 191 |
+
font-weight: 720;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.quick-row,
|
| 195 |
+
.status-strip {
|
| 196 |
+
width: min(760px, 100%);
|
| 197 |
+
display: flex;
|
| 198 |
+
align-items: center;
|
| 199 |
+
justify-content: center;
|
| 200 |
+
gap: 8px;
|
| 201 |
+
flex-wrap: wrap;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.quick-row button {
|
| 205 |
+
min-height: 34px;
|
| 206 |
+
padding: 0 14px;
|
| 207 |
+
color: var(--muted);
|
| 208 |
+
background: rgba(14, 15, 15, 0.74);
|
| 209 |
+
border-color: rgba(170, 184, 191, 0.18);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.quick-row button:hover {
|
| 213 |
+
color: var(--text);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.status-strip {
|
| 217 |
+
justify-content: center;
|
| 218 |
+
color: var(--muted);
|
| 219 |
+
font-size: 12px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.status-strip span {
|
| 223 |
+
min-height: 28px;
|
| 224 |
+
display: inline-flex;
|
| 225 |
+
align-items: center;
|
| 226 |
+
padding: 0 10px;
|
| 227 |
+
border: 1px solid rgba(170, 184, 191, 0.14);
|
| 228 |
+
background: rgba(6, 7, 7, 0.46);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.result-grid {
|
| 232 |
+
display: grid;
|
| 233 |
+
grid-template-columns: minmax(0, 1fr) 280px;
|
| 234 |
+
gap: 14px;
|
| 235 |
+
align-items: start;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.panel {
|
| 239 |
+
border: 1px solid var(--line-cold);
|
| 240 |
+
background:
|
| 241 |
+
linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 120px),
|
| 242 |
+
var(--panel);
|
| 243 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.panel-head {
|
| 247 |
+
min-height: 48px;
|
| 248 |
+
display: flex;
|
| 249 |
+
align-items: center;
|
| 250 |
+
justify-content: space-between;
|
| 251 |
+
gap: 12px;
|
| 252 |
+
padding: 12px 14px;
|
| 253 |
+
border-bottom: 1px solid rgba(170, 184, 191, 0.14);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.panel-head h1,
|
| 257 |
+
.panel-head h2 {
|
| 258 |
+
margin: 0;
|
| 259 |
+
font-size: 14px;
|
| 260 |
+
font-weight: 720;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.panel-head span {
|
| 264 |
+
color: var(--gold);
|
| 265 |
+
font-size: 12px;
|
| 266 |
+
white-space: nowrap;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
pre {
|
| 270 |
+
min-height: 320px;
|
| 271 |
+
max-height: 560px;
|
| 272 |
+
margin: 0;
|
| 273 |
+
overflow: auto;
|
| 274 |
+
padding: 16px;
|
| 275 |
+
color: #dce2dc;
|
| 276 |
+
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 277 |
+
font-size: 12px;
|
| 278 |
+
line-height: 1.52;
|
| 279 |
+
white-space: pre-wrap;
|
| 280 |
+
word-break: break-word;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.command-list {
|
| 284 |
+
display: grid;
|
| 285 |
+
gap: 8px;
|
| 286 |
+
padding: 12px;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.route-card {
|
| 290 |
+
margin: 12px 12px 0;
|
| 291 |
+
padding: 12px;
|
| 292 |
+
border: 1px solid rgba(223, 189, 98, 0.2);
|
| 293 |
+
background: rgba(8, 9, 9, 0.68);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.route-card h3 {
|
| 297 |
+
margin: 0 0 7px;
|
| 298 |
+
color: var(--gold-hot);
|
| 299 |
+
font-size: 13px;
|
| 300 |
+
font-weight: 720;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.route-card p {
|
| 304 |
+
margin: 0;
|
| 305 |
+
color: var(--muted);
|
| 306 |
+
font-size: 12px;
|
| 307 |
+
line-height: 1.45;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.route-actions {
|
| 311 |
+
display: grid;
|
| 312 |
+
gap: 7px;
|
| 313 |
+
margin-top: 12px;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.route-actions a,
|
| 317 |
+
.route-actions button {
|
| 318 |
+
min-height: 34px;
|
| 319 |
+
display: flex;
|
| 320 |
+
align-items: center;
|
| 321 |
+
padding: 0 10px;
|
| 322 |
+
border: 1px solid rgba(170, 184, 191, 0.18);
|
| 323 |
+
background: rgba(14, 15, 15, 0.8);
|
| 324 |
+
color: var(--text);
|
| 325 |
+
font-size: 12px;
|
| 326 |
+
text-decoration: none;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.route-actions a:hover,
|
| 330 |
+
.route-actions button:hover {
|
| 331 |
+
border-color: rgba(244, 217, 139, 0.62);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.command-list button {
|
| 335 |
+
width: 100%;
|
| 336 |
+
text-align: left;
|
| 337 |
+
padding: 0 12px;
|
| 338 |
+
background: rgba(9, 10, 10, 0.72);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
@media (max-width: 820px) {
|
| 342 |
+
.shell {
|
| 343 |
+
width: min(100% - 18px, 760px);
|
| 344 |
+
padding-top: 24px;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.search-stage {
|
| 348 |
+
min-height: auto;
|
| 349 |
+
padding: 34px 0 24px;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.search-box {
|
| 353 |
+
grid-template-columns: 1fr;
|
| 354 |
+
gap: 6px;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.search-box input {
|
| 358 |
+
min-height: 46px;
|
| 359 |
+
padding: 0 12px;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.search-box button {
|
| 363 |
+
width: 100%;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.result-grid {
|
| 367 |
+
grid-template-columns: 1fr;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.command-panel {
|
| 371 |
+
order: -1;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.command-list {
|
| 375 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 376 |
+
}
|
| 377 |
+
}
|