aaax 0.0.1a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aaax/__init__.py +20 -0
- aaax/__main__.py +5 -0
- aaax/_vendor.py +16 -0
- aaax/action_gate.py +77 -0
- aaax/bootstrap.py +13 -0
- aaax/capability.py +125 -0
- aaax/cli.py +13 -0
- aaax/cli_kernel.py +71 -0
- aaax/config.py +52 -0
- aaax/constellation.py +84 -0
- aaax/kernel.py +291 -0
- aaax/libos/__init__.py +5 -0
- aaax/libos/action_mixin.py +27 -0
- aaax/libos/bridge.py +51 -0
- aaax/libos/capability_mixin.py +23 -0
- aaax/lifecycle.py +89 -0
- aaax/module_loader.py +115 -0
- aaax/policy.py +123 -0
- aaax/protocols/__init__.py +28 -0
- aaax/protocols/action.py +36 -0
- aaax/protocols/capability.py +28 -0
- aaax/protocols/lifecycle.py +22 -0
- aaax/protocols/module.py +26 -0
- aaax-0.0.1a0.dist-info/METADATA +160 -0
- aaax-0.0.1a0.dist-info/RECORD +29 -0
- aaax-0.0.1a0.dist-info/WHEEL +5 -0
- aaax-0.0.1a0.dist-info/entry_points.txt +2 -0
- aaax-0.0.1a0.dist-info/licenses/LICENSE +201 -0
- aaax-0.0.1a0.dist-info/top_level.txt +1 -0
aaax/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from aaax.bootstrap import bootstrap_kernel
|
|
4
|
+
from aaax.config import AAAXConfig, LibOSConfig, ModuleConfig, NetworkConfig
|
|
5
|
+
from aaax.kernel import AAAXKernel
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("aaax")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"AAAXConfig",
|
|
15
|
+
"LibOSConfig",
|
|
16
|
+
"ModuleConfig",
|
|
17
|
+
"NetworkConfig",
|
|
18
|
+
"AAAXKernel",
|
|
19
|
+
"bootstrap_kernel",
|
|
20
|
+
]
|
aaax/__main__.py
ADDED
aaax/_vendor.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ensure_vendor_paths() -> None:
|
|
8
|
+
"""Prefer vendored local framework repos when they exist in the workspace."""
|
|
9
|
+
root = Path(__file__).resolve().parents[1]
|
|
10
|
+
for name in ("sssn", "lllm"):
|
|
11
|
+
candidate = root / name
|
|
12
|
+
if not candidate.is_dir():
|
|
13
|
+
continue
|
|
14
|
+
path = str(candidate)
|
|
15
|
+
if path not in sys.path:
|
|
16
|
+
sys.path.insert(0, path)
|
aaax/action_gate.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from aaax.capability import CapabilityManager
|
|
6
|
+
from aaax.policy import PolicyEngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _message_content_data(msg) -> dict[str, Any]:
|
|
10
|
+
content = getattr(msg, "content", None)
|
|
11
|
+
data = getattr(content, "data", None)
|
|
12
|
+
return data if isinstance(data, dict) else {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _requester_id(msg, payload: dict[str, Any]) -> str:
|
|
16
|
+
return str(payload.get("from") or payload.get("system_id") or msg.sender_id)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ActionGate:
|
|
20
|
+
"""Authorize and classify side-effecting operations."""
|
|
21
|
+
|
|
22
|
+
async def process(
|
|
23
|
+
self,
|
|
24
|
+
msg,
|
|
25
|
+
policy: PolicyEngine,
|
|
26
|
+
capabilities: CapabilityManager,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
content = _message_content_data(msg)
|
|
29
|
+
system_id = _requester_id(msg, content)
|
|
30
|
+
action = str(content["action"])
|
|
31
|
+
executor = str(content["executor"])
|
|
32
|
+
target = str(content["target"])
|
|
33
|
+
payload = content.get("payload", {})
|
|
34
|
+
cap_token = content.get("capability")
|
|
35
|
+
risk_level = str(content.get("risk_level", "medium"))
|
|
36
|
+
|
|
37
|
+
if not cap_token:
|
|
38
|
+
return {
|
|
39
|
+
"type": "action_denied",
|
|
40
|
+
"request_id": msg.id,
|
|
41
|
+
"reason": "Missing execute capability token",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if not capabilities.validate(system_id, cap_token, executor, "execute"):
|
|
45
|
+
return {
|
|
46
|
+
"type": "action_denied",
|
|
47
|
+
"request_id": msg.id,
|
|
48
|
+
"reason": "Invalid or expired capability token",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
decision = await policy.evaluate_action(
|
|
52
|
+
system_id=system_id,
|
|
53
|
+
action=action,
|
|
54
|
+
executor=executor,
|
|
55
|
+
target=target,
|
|
56
|
+
payload=payload,
|
|
57
|
+
risk_level=risk_level,
|
|
58
|
+
)
|
|
59
|
+
if decision.escalate:
|
|
60
|
+
return {
|
|
61
|
+
"type": "action_escalated",
|
|
62
|
+
"request_id": msg.id,
|
|
63
|
+
"reason": decision.reason,
|
|
64
|
+
"escalated_to": decision.escalate_to,
|
|
65
|
+
}
|
|
66
|
+
if not decision.allowed:
|
|
67
|
+
return {
|
|
68
|
+
"type": "action_denied",
|
|
69
|
+
"request_id": msg.id,
|
|
70
|
+
"reason": decision.reason,
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
"type": "action_approved",
|
|
74
|
+
"request_id": msg.id,
|
|
75
|
+
"executor": executor,
|
|
76
|
+
"modified_payload": decision.modified_payload or payload,
|
|
77
|
+
}
|
aaax/bootstrap.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from aaax.config import AAAXConfig
|
|
4
|
+
from aaax.kernel import AAAXKernel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def bootstrap_kernel(config: AAAXConfig, *, start_channels: bool = False) -> AAAXKernel:
|
|
8
|
+
kernel = AAAXKernel(config)
|
|
9
|
+
await kernel.setup()
|
|
10
|
+
kernel._setup_done = True
|
|
11
|
+
if start_channels:
|
|
12
|
+
await kernel.start_owned_channels()
|
|
13
|
+
return kernel
|
aaax/capability.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from aaax.policy import PolicyEngine
|
|
9
|
+
|
|
10
|
+
ACCESS_LEVELS = {"read": 0, "write": 1, "execute": 2}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _message_content_data(msg) -> dict[str, Any]:
|
|
14
|
+
content = getattr(msg, "content", None)
|
|
15
|
+
data = getattr(content, "data", None)
|
|
16
|
+
return data if isinstance(data, dict) else {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _requester_id(msg, payload: dict[str, Any]) -> str:
|
|
20
|
+
return str(payload.get("from") or payload.get("system_id") or msg.sender_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Capability:
|
|
25
|
+
token: str
|
|
26
|
+
system_id: str
|
|
27
|
+
resource: str
|
|
28
|
+
access: str
|
|
29
|
+
issued_at: float
|
|
30
|
+
expires_at: float
|
|
31
|
+
scope: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CapabilityManager:
|
|
35
|
+
"""AAAX-local capability tokens for mediated resources."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._capabilities: dict[str, Capability] = {}
|
|
39
|
+
self._by_system: dict[str, set[str]] = {}
|
|
40
|
+
|
|
41
|
+
async def process_request(self, msg, policy: PolicyEngine) -> dict[str, Any]:
|
|
42
|
+
content = _message_content_data(msg)
|
|
43
|
+
system_id = _requester_id(msg, content)
|
|
44
|
+
resource = str(content["resource"])
|
|
45
|
+
access = str(content["access"])
|
|
46
|
+
scope = content.get("scope", {})
|
|
47
|
+
context = content.get("context", {})
|
|
48
|
+
|
|
49
|
+
decision = await policy.evaluate_capability(
|
|
50
|
+
system_id=system_id,
|
|
51
|
+
resource=resource,
|
|
52
|
+
access=access,
|
|
53
|
+
context=context,
|
|
54
|
+
)
|
|
55
|
+
if not decision.allowed:
|
|
56
|
+
return {
|
|
57
|
+
"type": "capability_deny",
|
|
58
|
+
"resource": resource,
|
|
59
|
+
"reason": decision.reason,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return await self.issue(
|
|
63
|
+
system_id=system_id,
|
|
64
|
+
resource=resource,
|
|
65
|
+
access=access,
|
|
66
|
+
scope=scope,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def issue(
|
|
70
|
+
self,
|
|
71
|
+
system_id: str,
|
|
72
|
+
resource: str,
|
|
73
|
+
access: str,
|
|
74
|
+
scope: dict[str, Any] | None = None,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
scope = scope or {}
|
|
77
|
+
ttl = float(scope.get("ttl", 3600.0))
|
|
78
|
+
token = uuid.uuid4().hex
|
|
79
|
+
now = time.time()
|
|
80
|
+
capability = Capability(
|
|
81
|
+
token=token,
|
|
82
|
+
system_id=system_id,
|
|
83
|
+
resource=resource,
|
|
84
|
+
access=access,
|
|
85
|
+
issued_at=now,
|
|
86
|
+
expires_at=now + ttl,
|
|
87
|
+
scope=scope,
|
|
88
|
+
)
|
|
89
|
+
self._capabilities[token] = capability
|
|
90
|
+
self._by_system.setdefault(system_id, set()).add(token)
|
|
91
|
+
return {
|
|
92
|
+
"type": "capability_grant",
|
|
93
|
+
"resource": resource,
|
|
94
|
+
"token": token,
|
|
95
|
+
"expires": capability.expires_at,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def validate(self, system_id: str, token: str, resource: str, access: str) -> bool:
|
|
99
|
+
capability = self._capabilities.get(token)
|
|
100
|
+
if capability is None:
|
|
101
|
+
return False
|
|
102
|
+
if capability.expires_at < time.time():
|
|
103
|
+
self._capabilities.pop(token, None)
|
|
104
|
+
self._by_system.get(capability.system_id, set()).discard(token)
|
|
105
|
+
return False
|
|
106
|
+
if capability.system_id != system_id:
|
|
107
|
+
return False
|
|
108
|
+
if capability.resource != resource:
|
|
109
|
+
return False
|
|
110
|
+
return ACCESS_LEVELS.get(capability.access, -1) >= ACCESS_LEVELS.get(access, 0)
|
|
111
|
+
|
|
112
|
+
def revoke_all(self, system_id: str) -> None:
|
|
113
|
+
tokens = self._by_system.pop(system_id, set())
|
|
114
|
+
for token in tokens:
|
|
115
|
+
self._capabilities.pop(token, None)
|
|
116
|
+
|
|
117
|
+
def expire_stale(self) -> None:
|
|
118
|
+
now = time.time()
|
|
119
|
+
expired = [token for token, cap in self._capabilities.items() if cap.expires_at < now]
|
|
120
|
+
for token in expired:
|
|
121
|
+
cap = self._capabilities.pop(token)
|
|
122
|
+
self._by_system.get(cap.system_id, set()).discard(token)
|
|
123
|
+
|
|
124
|
+
def active_count(self) -> int:
|
|
125
|
+
return len(self._capabilities)
|
aaax/cli.py
ADDED
aaax/cli_kernel.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from aaax.bootstrap import bootstrap_kernel
|
|
8
|
+
from aaax.config import AAAXConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _print_kernel_snapshot(kernel, *, host: str | None = None, port: int | None = None) -> None:
|
|
12
|
+
click.echo(f"kernel_id={kernel.id}")
|
|
13
|
+
click.echo(f"docked_systems={kernel._constellation.system_ids()}")
|
|
14
|
+
click.echo("channels:")
|
|
15
|
+
for channel in kernel.all_channels:
|
|
16
|
+
click.echo(f" - {channel.id}")
|
|
17
|
+
if host is not None and port is not None:
|
|
18
|
+
click.echo(f"http_transport=http://{host}:{port} (PUBLIC channels only)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command()
|
|
22
|
+
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False))
|
|
23
|
+
@click.option("--publish", is_flag=True, help="Publish public channels over HTTP.")
|
|
24
|
+
@click.option(
|
|
25
|
+
"--once",
|
|
26
|
+
is_flag=True,
|
|
27
|
+
help="Bootstrap the kernel, run one step, print a summary, and exit.",
|
|
28
|
+
)
|
|
29
|
+
def launch(config_path: str, publish: bool, once: bool) -> None:
|
|
30
|
+
"""Launch an AAAX kernel from a TOML config."""
|
|
31
|
+
|
|
32
|
+
if publish and once:
|
|
33
|
+
raise click.UsageError("--once cannot be combined with --publish.")
|
|
34
|
+
|
|
35
|
+
async def _run() -> None:
|
|
36
|
+
config = AAAXConfig.from_file(config_path)
|
|
37
|
+
kernel = await bootstrap_kernel(config, start_channels=once)
|
|
38
|
+
if once:
|
|
39
|
+
await kernel.step()
|
|
40
|
+
_print_kernel_snapshot(kernel)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
click.echo(f"Starting AAAX runtime for '{config.name}' ({kernel.id}).")
|
|
44
|
+
if publish:
|
|
45
|
+
_print_kernel_snapshot(
|
|
46
|
+
kernel,
|
|
47
|
+
host=config.network.host,
|
|
48
|
+
port=config.network.port,
|
|
49
|
+
)
|
|
50
|
+
click.echo("Press Ctrl+C to stop.")
|
|
51
|
+
await kernel.publish(host=config.network.host, port=config.network.port)
|
|
52
|
+
else:
|
|
53
|
+
_print_kernel_snapshot(kernel)
|
|
54
|
+
click.echo("Press Ctrl+C to stop.")
|
|
55
|
+
await kernel.launch()
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
asyncio.run(_run())
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
click.echo("\nStopped AAAX runtime.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.group()
|
|
64
|
+
def modules() -> None:
|
|
65
|
+
"""Module management commands."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@modules.command("list")
|
|
69
|
+
def list_modules() -> None:
|
|
70
|
+
"""Placeholder until persistent module registry commands are implemented."""
|
|
71
|
+
click.echo("Module registry commands require a running kernel instance.")
|
aaax/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _load_toml(path: str | Path) -> dict[str, Any]:
|
|
10
|
+
try:
|
|
11
|
+
import tomllib # type: ignore[attr-defined]
|
|
12
|
+
except ModuleNotFoundError:
|
|
13
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
14
|
+
|
|
15
|
+
with open(path, "rb") as handle:
|
|
16
|
+
return tomllib.load(handle)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NetworkConfig(BaseModel):
|
|
20
|
+
publish: bool = False
|
|
21
|
+
host: str = "0.0.0.0"
|
|
22
|
+
port: int = 8100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LibOSConfig(BaseModel):
|
|
26
|
+
name: str = "lllm"
|
|
27
|
+
strict_boot: bool = True
|
|
28
|
+
discover_shared_packages: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ModuleConfig(BaseModel):
|
|
32
|
+
id: str
|
|
33
|
+
framework: str = "lllm"
|
|
34
|
+
lllm_toml: str | None = None
|
|
35
|
+
channels: list[str] = Field(default_factory=list)
|
|
36
|
+
executors: list[str] = Field(default_factory=list)
|
|
37
|
+
remote_channels: list[str] = Field(default_factory=list)
|
|
38
|
+
manifest: dict[str, Any] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AAAXConfig(BaseModel):
|
|
42
|
+
id: str = "aaax-main"
|
|
43
|
+
name: str = "AAAX Kernel"
|
|
44
|
+
policy: str | dict[str, Any] | None = "default"
|
|
45
|
+
libos: LibOSConfig = Field(default_factory=LibOSConfig)
|
|
46
|
+
modules: list[ModuleConfig] = Field(default_factory=list)
|
|
47
|
+
network: NetworkConfig = Field(default_factory=NetworkConfig)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_file(cls, path: str | Path) -> "AAAXConfig":
|
|
51
|
+
raw = _load_toml(path)
|
|
52
|
+
return cls(**raw.get("aaax", {}))
|
aaax/constellation.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from aaax._vendor import ensure_vendor_paths
|
|
6
|
+
|
|
7
|
+
ensure_vendor_paths()
|
|
8
|
+
|
|
9
|
+
from sssn.core.channel import BaseChannel
|
|
10
|
+
from sssn.core.system import BaseSystem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class DockRecord:
|
|
15
|
+
system_id: str
|
|
16
|
+
system: BaseSystem
|
|
17
|
+
granted_channels: set[str] = field(default_factory=set)
|
|
18
|
+
provided_channels: set[str] = field(default_factory=set)
|
|
19
|
+
privileged: bool = False
|
|
20
|
+
status: str = "docked"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConstellationManager:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._systems: dict[str, DockRecord] = {}
|
|
26
|
+
self._channels: dict[str, BaseChannel] = {}
|
|
27
|
+
|
|
28
|
+
def register(
|
|
29
|
+
self,
|
|
30
|
+
system: BaseSystem,
|
|
31
|
+
channels: list[str] | None = None,
|
|
32
|
+
*,
|
|
33
|
+
privileged: bool = False,
|
|
34
|
+
) -> DockRecord:
|
|
35
|
+
if system.id in self._systems:
|
|
36
|
+
raise ValueError(f"System '{system.id}' is already docked.")
|
|
37
|
+
|
|
38
|
+
provided_channels: set[str] = set()
|
|
39
|
+
for channel in system.all_channels:
|
|
40
|
+
existing = self._channels.get(channel.id)
|
|
41
|
+
if existing is not None and existing is not channel:
|
|
42
|
+
raise ValueError(f"Channel '{channel.id}' already registered by another system.")
|
|
43
|
+
self._channels[channel.id] = channel
|
|
44
|
+
provided_channels.add(channel.id)
|
|
45
|
+
|
|
46
|
+
record = DockRecord(
|
|
47
|
+
system_id=system.id,
|
|
48
|
+
system=system,
|
|
49
|
+
granted_channels=set(channels or []),
|
|
50
|
+
provided_channels=provided_channels,
|
|
51
|
+
privileged=privileged,
|
|
52
|
+
)
|
|
53
|
+
self._systems[system.id] = record
|
|
54
|
+
return record
|
|
55
|
+
|
|
56
|
+
def unregister(self, system_id: str) -> DockRecord | None:
|
|
57
|
+
record = self._systems.pop(system_id, None)
|
|
58
|
+
if record is None:
|
|
59
|
+
return None
|
|
60
|
+
for channel_id in record.provided_channels:
|
|
61
|
+
channel = self._channels.get(channel_id)
|
|
62
|
+
if channel is not None and channel in record.system.all_channels:
|
|
63
|
+
self._channels.pop(channel_id, None)
|
|
64
|
+
return record
|
|
65
|
+
|
|
66
|
+
def get(self, system_id: str) -> DockRecord | None:
|
|
67
|
+
return self._systems.get(system_id)
|
|
68
|
+
|
|
69
|
+
def set_status(self, system_id: str, status: str) -> None:
|
|
70
|
+
record = self._systems.get(system_id)
|
|
71
|
+
if record is not None:
|
|
72
|
+
record.status = status
|
|
73
|
+
|
|
74
|
+
def resolve_channel(self, channel_id: str) -> BaseChannel | None:
|
|
75
|
+
return self._channels.get(channel_id)
|
|
76
|
+
|
|
77
|
+
def systems(self) -> list[DockRecord]:
|
|
78
|
+
return list(self._systems.values())
|
|
79
|
+
|
|
80
|
+
def system_ids(self) -> list[str]:
|
|
81
|
+
return sorted(self._systems.keys())
|
|
82
|
+
|
|
83
|
+
def active_count(self) -> int:
|
|
84
|
+
return len(self._systems)
|