core: add orchestration and service definitions
This commit is contained in:
@@ -12,7 +12,9 @@ from .discovery import discover_contexts, get_project_stats
|
|||||||
from .graph import build_graph, default_graph_path, write_graph
|
from .graph import build_graph, default_graph_path, write_graph
|
||||||
from .manager import AFSManager
|
from .manager import AFSManager
|
||||||
from .models import MountType
|
from .models import MountType
|
||||||
|
from .orchestration import Orchestrator, TaskRequest
|
||||||
from .plugins import discover_plugins, load_plugins
|
from .plugins import discover_plugins, load_plugins
|
||||||
|
from .services import ServiceManager
|
||||||
from .schema import AFSConfig, GeneralConfig, WorkspaceDirectory
|
from .schema import AFSConfig, GeneralConfig, WorkspaceDirectory
|
||||||
from .validator import AFSValidator
|
from .validator import AFSValidator
|
||||||
|
|
||||||
@@ -161,6 +163,40 @@ def _plugins_command(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _services_list_command(args: argparse.Namespace) -> int:
|
||||||
|
manager = ServiceManager()
|
||||||
|
for definition in manager.list_definitions():
|
||||||
|
print(f"{definition.name}\t{definition.label}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _services_render_command(args: argparse.Namespace) -> int:
|
||||||
|
manager = ServiceManager()
|
||||||
|
print(manager.render_unit(args.name))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _orchestrator_list_command(args: argparse.Namespace) -> int:
|
||||||
|
orchestrator = Orchestrator()
|
||||||
|
for agent in orchestrator.list_agents():
|
||||||
|
tags = ",".join(agent.tags) if agent.tags else "-"
|
||||||
|
print(f"{agent.name}\t{agent.role}\t{agent.backend}\t{tags}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _orchestrator_plan_command(args: argparse.Namespace) -> int:
|
||||||
|
orchestrator = Orchestrator()
|
||||||
|
request = TaskRequest(summary=args.summary, tags=args.tag or [], role=args.role)
|
||||||
|
plan = orchestrator.plan(request)
|
||||||
|
if plan.notes:
|
||||||
|
for note in plan.notes:
|
||||||
|
print(f"note: {note}")
|
||||||
|
for agent in plan.agents:
|
||||||
|
tags = ",".join(agent.tags) if agent.tags else "-"
|
||||||
|
print(f"{agent.name}\t{agent.role}\t{agent.backend}\t{tags}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _status_command(args: argparse.Namespace) -> int:
|
def _status_command(args: argparse.Namespace) -> int:
|
||||||
start_dir = Path(args.start_dir).expanduser().resolve() if args.start_dir else None
|
start_dir = Path(args.start_dir).expanduser().resolve() if args.start_dir else None
|
||||||
root = find_root(start_dir)
|
root = find_root(start_dir)
|
||||||
@@ -522,6 +558,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
plugins_parser.add_argument("--load", action="store_true", help="Attempt to import plugins.")
|
plugins_parser.add_argument("--load", action="store_true", help="Attempt to import plugins.")
|
||||||
plugins_parser.set_defaults(func=_plugins_command)
|
plugins_parser.set_defaults(func=_plugins_command)
|
||||||
|
|
||||||
|
services_parser = subparsers.add_parser("services", help="Service definitions.")
|
||||||
|
services_sub = services_parser.add_subparsers(dest="services_command")
|
||||||
|
|
||||||
|
services_list = services_sub.add_parser("list", help="List service definitions.")
|
||||||
|
services_list.set_defaults(func=_services_list_command)
|
||||||
|
|
||||||
|
services_render = services_sub.add_parser("render", help="Render service unit.")
|
||||||
|
services_render.add_argument("name", help="Service name.")
|
||||||
|
services_render.set_defaults(func=_services_render_command)
|
||||||
|
|
||||||
|
orch_parser = subparsers.add_parser("orchestrator", help="Orchestrator helpers.")
|
||||||
|
orch_sub = orch_parser.add_subparsers(dest="orchestrator_command")
|
||||||
|
|
||||||
|
orch_list = orch_sub.add_parser("list", help="List configured agents.")
|
||||||
|
orch_list.set_defaults(func=_orchestrator_list_command)
|
||||||
|
|
||||||
|
orch_plan = orch_sub.add_parser("plan", help="Plan agent routing.")
|
||||||
|
orch_plan.add_argument("summary", help="Task summary.")
|
||||||
|
orch_plan.add_argument("--tag", action="append", help="Tag to match.")
|
||||||
|
orch_plan.add_argument("--role", help="Role to match.")
|
||||||
|
orch_plan.set_defaults(func=_orchestrator_plan_command)
|
||||||
|
|
||||||
status_parser = subparsers.add_parser("status", help="Show context root status.")
|
status_parser = subparsers.add_parser("status", help="Show context root status.")
|
||||||
status_parser.add_argument("--start-dir", help="Directory to search from.")
|
status_parser.add_argument("--start-dir", help="Directory to search from.")
|
||||||
status_parser.set_defaults(func=_status_command)
|
status_parser.set_defaults(func=_status_command)
|
||||||
@@ -713,6 +771,12 @@ def main(argv: Iterable[str] | None = None) -> int:
|
|||||||
if args.command == "graph" and not getattr(args, "graph_command", None):
|
if args.command == "graph" and not getattr(args, "graph_command", None):
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return 1
|
return 1
|
||||||
|
if args.command == "services" and not getattr(args, "services_command", None):
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
if args.command == "orchestrator" and not getattr(args, "orchestrator_command", None):
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
126
src/afs/orchestration.py
Normal file
126
src/afs/orchestration.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Minimal orchestration helpers for routing tasks to agents."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .config import load_config_model
|
||||||
|
from .schema import AgentConfig, OrchestratorConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskRequest:
|
||||||
|
summary: str
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrchestrationPlan:
|
||||||
|
summary: str
|
||||||
|
agents: list[AgentConfig]
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Orchestrator:
|
||||||
|
def __init__(self, config: OrchestratorConfig | None = None) -> None:
|
||||||
|
self.config = config or load_config_model().orchestrator
|
||||||
|
|
||||||
|
def list_agents(self) -> list[AgentConfig]:
|
||||||
|
return list(self.config.default_agents)
|
||||||
|
|
||||||
|
def plan(self, request: TaskRequest) -> OrchestrationPlan:
|
||||||
|
if not self.config.enabled:
|
||||||
|
return OrchestrationPlan(
|
||||||
|
summary=request.summary,
|
||||||
|
agents=[],
|
||||||
|
notes=["orchestrator disabled"],
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = list(self.config.default_agents)
|
||||||
|
if request.role:
|
||||||
|
candidates = [a for a in candidates if a.role == request.role]
|
||||||
|
|
||||||
|
if request.tags:
|
||||||
|
tagged = [
|
||||||
|
agent
|
||||||
|
for agent in candidates
|
||||||
|
if set(request.tags) & set(agent.tags)
|
||||||
|
]
|
||||||
|
if tagged:
|
||||||
|
candidates = tagged
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return OrchestrationPlan(
|
||||||
|
summary=request.summary,
|
||||||
|
agents=[],
|
||||||
|
notes=["no matching agents"],
|
||||||
|
)
|
||||||
|
|
||||||
|
selected = candidates[: self.config.max_agents]
|
||||||
|
notes = []
|
||||||
|
if len(candidates) > len(selected):
|
||||||
|
notes.append("truncated agent list to max_agents")
|
||||||
|
|
||||||
|
return OrchestrationPlan(
|
||||||
|
summary=request.summary,
|
||||||
|
agents=selected,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_command(args: argparse.Namespace) -> int:
|
||||||
|
orchestrator = Orchestrator()
|
||||||
|
agents = orchestrator.list_agents()
|
||||||
|
for agent in agents:
|
||||||
|
tags = ",".join(agent.tags) if agent.tags else "-"
|
||||||
|
print(f"{agent.name}\t{agent.role}\t{agent.backend}\t{tags}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_command(args: argparse.Namespace) -> int:
|
||||||
|
orchestrator = Orchestrator()
|
||||||
|
request = TaskRequest(
|
||||||
|
summary=args.summary,
|
||||||
|
tags=args.tag or [],
|
||||||
|
role=args.role,
|
||||||
|
)
|
||||||
|
plan = orchestrator.plan(request)
|
||||||
|
if plan.notes:
|
||||||
|
for note in plan.notes:
|
||||||
|
print(f"note: {note}")
|
||||||
|
for agent in plan.agents:
|
||||||
|
tags = ",".join(agent.tags) if agent.tags else "-"
|
||||||
|
print(f"{agent.name}\t{agent.role}\t{agent.backend}\t{tags}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="afs-orchestrator")
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
list_cmd = sub.add_parser("list", help="List configured agents.")
|
||||||
|
list_cmd.set_defaults(func=_list_command)
|
||||||
|
|
||||||
|
plan_cmd = sub.add_parser("plan", help="Plan a routing decision.")
|
||||||
|
plan_cmd.add_argument("summary", help="Task summary.")
|
||||||
|
plan_cmd.add_argument("--tag", action="append", help="Tag to match.")
|
||||||
|
plan_cmd.add_argument("--role", help="Role to match.")
|
||||||
|
plan_cmd.set_defaults(func=_plan_command)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Iterable[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not getattr(args, "command", None):
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -182,6 +182,110 @@ class PluginsConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
|
name: str
|
||||||
|
role: str = "general"
|
||||||
|
backend: str = "local"
|
||||||
|
description: str = ""
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
auto_start: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "AgentConfig":
|
||||||
|
tags = data.get("tags", [])
|
||||||
|
if isinstance(tags, list):
|
||||||
|
tags = [tag for tag in tags if isinstance(tag, str)]
|
||||||
|
else:
|
||||||
|
tags = []
|
||||||
|
return cls(
|
||||||
|
name=str(data.get("name", "")).strip(),
|
||||||
|
role=str(data.get("role", "general")).strip() or "general",
|
||||||
|
backend=str(data.get("backend", "local")).strip() or "local",
|
||||||
|
description=str(data.get("description", "")).strip(),
|
||||||
|
tags=tags,
|
||||||
|
auto_start=bool(data.get("auto_start", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrchestratorConfig:
|
||||||
|
enabled: bool = False
|
||||||
|
max_agents: int = 5
|
||||||
|
default_agents: list[AgentConfig] = field(default_factory=list)
|
||||||
|
auto_routing: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "OrchestratorConfig":
|
||||||
|
agents_raw = data.get("default_agents", [])
|
||||||
|
agents = [
|
||||||
|
AgentConfig.from_dict(item)
|
||||||
|
for item in agents_raw
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
max_agents = data.get("max_agents", cls().max_agents)
|
||||||
|
return cls(
|
||||||
|
enabled=bool(data.get("enabled", False)),
|
||||||
|
max_agents=int(max_agents) if isinstance(max_agents, int) else cls().max_agents,
|
||||||
|
default_agents=agents,
|
||||||
|
auto_routing=bool(data.get("auto_routing", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceConfig:
|
||||||
|
name: str
|
||||||
|
enabled: bool = True
|
||||||
|
auto_start: bool = False
|
||||||
|
command: list[str] = field(default_factory=list)
|
||||||
|
working_directory: Path | None = None
|
||||||
|
environment: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "ServiceConfig":
|
||||||
|
command = data.get("command", [])
|
||||||
|
if isinstance(command, list):
|
||||||
|
command = [str(item) for item in command]
|
||||||
|
else:
|
||||||
|
command = []
|
||||||
|
env = data.get("environment", {})
|
||||||
|
if isinstance(env, dict):
|
||||||
|
environment = {str(key): str(value) for key, value in env.items()}
|
||||||
|
else:
|
||||||
|
environment = {}
|
||||||
|
working_directory = data.get("working_directory")
|
||||||
|
return cls(
|
||||||
|
name=str(data.get("name", "")).strip(),
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
auto_start=bool(data.get("auto_start", False)),
|
||||||
|
command=command,
|
||||||
|
working_directory=_as_path(working_directory)
|
||||||
|
if working_directory
|
||||||
|
else None,
|
||||||
|
environment=environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServicesConfig:
|
||||||
|
enabled: bool = False
|
||||||
|
services: dict[str, ServiceConfig] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "ServicesConfig":
|
||||||
|
enabled = bool(data.get("enabled", False))
|
||||||
|
raw_services = data.get("services", {})
|
||||||
|
parsed: dict[str, ServiceConfig] = {}
|
||||||
|
if isinstance(raw_services, dict):
|
||||||
|
for name, payload in raw_services.items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
payload = dict(payload)
|
||||||
|
payload.setdefault("name", name)
|
||||||
|
parsed[name] = ServiceConfig.from_dict(payload)
|
||||||
|
return cls(enabled=enabled, services=parsed)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CognitiveConfig:
|
class CognitiveConfig:
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
@@ -207,6 +311,8 @@ class AFSConfig:
|
|||||||
plugins: PluginsConfig = field(default_factory=PluginsConfig)
|
plugins: PluginsConfig = field(default_factory=PluginsConfig)
|
||||||
directories: list[DirectoryConfig] = field(default_factory=default_directory_configs)
|
directories: list[DirectoryConfig] = field(default_factory=default_directory_configs)
|
||||||
cognitive: CognitiveConfig = field(default_factory=CognitiveConfig)
|
cognitive: CognitiveConfig = field(default_factory=CognitiveConfig)
|
||||||
|
orchestrator: OrchestratorConfig = field(default_factory=OrchestratorConfig)
|
||||||
|
services: ServicesConfig = field(default_factory=ServicesConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> "AFSConfig":
|
def from_dict(cls, data: dict[str, Any] | None) -> "AFSConfig":
|
||||||
@@ -215,11 +321,15 @@ class AFSConfig:
|
|||||||
plugins = PluginsConfig.from_dict(data.get("plugins", {}))
|
plugins = PluginsConfig.from_dict(data.get("plugins", {}))
|
||||||
directories = _parse_directory_config(data)
|
directories = _parse_directory_config(data)
|
||||||
cognitive = CognitiveConfig.from_dict(data.get("cognitive", {}))
|
cognitive = CognitiveConfig.from_dict(data.get("cognitive", {}))
|
||||||
|
orchestrator = OrchestratorConfig.from_dict(data.get("orchestrator", {}))
|
||||||
|
services = ServicesConfig.from_dict(data.get("services", {}))
|
||||||
return cls(
|
return cls(
|
||||||
general=general,
|
general=general,
|
||||||
plugins=plugins,
|
plugins=plugins,
|
||||||
directories=directories,
|
directories=directories,
|
||||||
cognitive=cognitive,
|
cognitive=cognitive,
|
||||||
|
orchestrator=orchestrator,
|
||||||
|
services=services,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
12
src/afs/services/__init__.py
Normal file
12
src/afs/services/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""AFS service management primitives."""
|
||||||
|
|
||||||
|
from .manager import ServiceManager
|
||||||
|
from .models import ServiceDefinition, ServiceState, ServiceStatus, ServiceType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ServiceManager",
|
||||||
|
"ServiceDefinition",
|
||||||
|
"ServiceState",
|
||||||
|
"ServiceStatus",
|
||||||
|
"ServiceType",
|
||||||
|
]
|
||||||
198
src/afs/services/manager.py
Normal file
198
src/afs/services/manager.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Minimal service manager for AFS background tasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from ..config import load_config_model
|
||||||
|
from ..schema import AFSConfig, ServiceConfig
|
||||||
|
from .models import ServiceDefinition, ServiceState, ServiceStatus, ServiceType
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceManager:
|
||||||
|
"""Build and render service definitions without mutating the system."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: AFSConfig | None = None,
|
||||||
|
*,
|
||||||
|
service_root: Path | None = None,
|
||||||
|
platform_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config or load_config_model()
|
||||||
|
self.service_root = service_root or Path.home() / ".config" / "afs" / "services"
|
||||||
|
self.platform_name = platform_name or platform.system().lower()
|
||||||
|
|
||||||
|
def list_definitions(self) -> list[ServiceDefinition]:
|
||||||
|
definitions = self._builtin_definitions()
|
||||||
|
merged = self._merge_config(definitions)
|
||||||
|
return sorted(merged.values(), key=lambda item: item.name)
|
||||||
|
|
||||||
|
def get_definition(self, name: str) -> ServiceDefinition | None:
|
||||||
|
merged = self._merge_config(self._builtin_definitions())
|
||||||
|
return merged.get(name)
|
||||||
|
|
||||||
|
def render_unit(self, name: str) -> str:
|
||||||
|
definition = self.get_definition(name)
|
||||||
|
if not definition:
|
||||||
|
raise KeyError(f"Unknown service: {name}")
|
||||||
|
|
||||||
|
if self.platform_name.startswith("darwin"):
|
||||||
|
payload = render_launchd_plist(definition)
|
||||||
|
return json.dumps(payload, indent=2)
|
||||||
|
return render_systemd_unit(definition)
|
||||||
|
|
||||||
|
def status(self, name: str) -> ServiceStatus:
|
||||||
|
definition = self.get_definition(name)
|
||||||
|
if not definition:
|
||||||
|
return ServiceStatus(name=name, state=ServiceState.UNKNOWN, enabled=False)
|
||||||
|
return ServiceStatus(name=definition.name, state=ServiceState.UNKNOWN, enabled=False)
|
||||||
|
|
||||||
|
def _merge_config(
|
||||||
|
self, definitions: dict[str, ServiceDefinition]
|
||||||
|
) -> dict[str, ServiceDefinition]:
|
||||||
|
merged = dict(definitions)
|
||||||
|
for name, config in self.config.services.services.items():
|
||||||
|
if not config.enabled:
|
||||||
|
merged.pop(name, None)
|
||||||
|
continue
|
||||||
|
base = merged.get(name)
|
||||||
|
if base:
|
||||||
|
merged[name] = _merge_definition(base, config)
|
||||||
|
elif config.command:
|
||||||
|
merged[name] = ServiceDefinition(
|
||||||
|
name=config.name,
|
||||||
|
label=config.name,
|
||||||
|
command=list(config.command),
|
||||||
|
working_directory=config.working_directory,
|
||||||
|
environment=dict(config.environment),
|
||||||
|
service_type=ServiceType.DAEMON,
|
||||||
|
keep_alive=True,
|
||||||
|
run_at_load=config.auto_start,
|
||||||
|
)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _builtin_definitions(self) -> dict[str, ServiceDefinition]:
|
||||||
|
python = self._resolve_python_executable()
|
||||||
|
repo_root = self._find_repo_root()
|
||||||
|
environment = self._service_environment()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"orchestrator": ServiceDefinition(
|
||||||
|
name="orchestrator",
|
||||||
|
label="AFS Orchestrator",
|
||||||
|
description="Routing and coordination for local agents",
|
||||||
|
command=[python, "-m", "afs.orchestration", "--daemon"],
|
||||||
|
working_directory=repo_root,
|
||||||
|
environment=environment,
|
||||||
|
service_type=ServiceType.DAEMON,
|
||||||
|
keep_alive=True,
|
||||||
|
run_at_load=False,
|
||||||
|
),
|
||||||
|
"context-discovery": ServiceDefinition(
|
||||||
|
name="context-discovery",
|
||||||
|
label="AFS Context Discovery",
|
||||||
|
description="Discover and index AFS contexts",
|
||||||
|
command=[python, "-m", "afs", "context", "discover"],
|
||||||
|
working_directory=repo_root,
|
||||||
|
environment=environment,
|
||||||
|
service_type=ServiceType.ONESHOT,
|
||||||
|
keep_alive=False,
|
||||||
|
run_at_load=False,
|
||||||
|
),
|
||||||
|
"context-graph-export": ServiceDefinition(
|
||||||
|
name="context-graph-export",
|
||||||
|
label="AFS Context Graph Export",
|
||||||
|
description="Export AFS context graph JSON",
|
||||||
|
command=[python, "-m", "afs", "graph", "export"],
|
||||||
|
working_directory=repo_root,
|
||||||
|
environment=environment,
|
||||||
|
service_type=ServiceType.ONESHOT,
|
||||||
|
keep_alive=False,
|
||||||
|
run_at_load=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_python_executable(self) -> str:
|
||||||
|
if self.config.general.python_executable:
|
||||||
|
return str(self.config.general.python_executable)
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
|
def _find_repo_root(self) -> Path | None:
|
||||||
|
for parent in Path(__file__).resolve().parents:
|
||||||
|
if (parent / "pyproject.toml").exists():
|
||||||
|
return parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _service_environment(self) -> dict[str, str]:
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
repo_root = self._find_repo_root()
|
||||||
|
if repo_root and (repo_root / "src").exists():
|
||||||
|
env["PYTHONPATH"] = str(repo_root / "src")
|
||||||
|
user_config = Path.home() / ".config" / "afs" / "config.toml"
|
||||||
|
if user_config.exists():
|
||||||
|
env["AFS_CONFIG_PATH"] = str(user_config)
|
||||||
|
env["AFS_PREFER_USER_CONFIG"] = "1"
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_definition(
|
||||||
|
base: ServiceDefinition, override: ServiceConfig
|
||||||
|
) -> ServiceDefinition:
|
||||||
|
command = list(override.command) if override.command else list(base.command)
|
||||||
|
environment = dict(base.environment)
|
||||||
|
environment.update(override.environment)
|
||||||
|
return ServiceDefinition(
|
||||||
|
name=base.name,
|
||||||
|
label=base.label,
|
||||||
|
description=base.description,
|
||||||
|
command=command,
|
||||||
|
working_directory=override.working_directory or base.working_directory,
|
||||||
|
environment=environment,
|
||||||
|
service_type=base.service_type,
|
||||||
|
keep_alive=base.keep_alive,
|
||||||
|
run_at_load=override.auto_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_launchd_plist(definition: ServiceDefinition) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"Label": f"afs.{definition.name}",
|
||||||
|
"ProgramArguments": list(definition.command),
|
||||||
|
"RunAtLoad": bool(definition.run_at_load),
|
||||||
|
"KeepAlive": bool(definition.keep_alive),
|
||||||
|
}
|
||||||
|
if definition.working_directory:
|
||||||
|
payload["WorkingDirectory"] = str(definition.working_directory)
|
||||||
|
if definition.environment:
|
||||||
|
payload["EnvironmentVariables"] = dict(definition.environment)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def render_systemd_unit(definition: ServiceDefinition) -> str:
|
||||||
|
lines = [
|
||||||
|
"[Unit]",
|
||||||
|
f"Description={definition.label}",
|
||||||
|
"",
|
||||||
|
"[Service]",
|
||||||
|
f"ExecStart={' '.join(definition.command)}",
|
||||||
|
]
|
||||||
|
if definition.working_directory:
|
||||||
|
lines.append(f"WorkingDirectory={definition.working_directory}")
|
||||||
|
if definition.environment:
|
||||||
|
for key, value in definition.environment.items():
|
||||||
|
lines.append(f"Environment={key}={value}")
|
||||||
|
if definition.service_type == ServiceType.ONESHOT:
|
||||||
|
lines.append("Type=oneshot")
|
||||||
|
if definition.keep_alive:
|
||||||
|
lines.append("Restart=on-failure")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"[Install]",
|
||||||
|
"WantedBy=default.target",
|
||||||
|
])
|
||||||
|
return "\n".join(lines)
|
||||||
73
src/afs/services/models.py
Normal file
73
src/afs/services/models.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Data models for AFS service management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceState(str, Enum):
|
||||||
|
STOPPED = "stopped"
|
||||||
|
RUNNING = "running"
|
||||||
|
FAILED = "failed"
|
||||||
|
STARTING = "starting"
|
||||||
|
STOPPING = "stopping"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceType(str, Enum):
|
||||||
|
DAEMON = "daemon"
|
||||||
|
ONESHOT = "oneshot"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceDefinition:
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
description: str = ""
|
||||||
|
command: list[str] = field(default_factory=list)
|
||||||
|
working_directory: Path | None = None
|
||||||
|
environment: dict[str, str] = field(default_factory=dict)
|
||||||
|
service_type: ServiceType = ServiceType.DAEMON
|
||||||
|
keep_alive: bool = True
|
||||||
|
run_at_load: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"label": self.label,
|
||||||
|
"description": self.description,
|
||||||
|
"command": list(self.command),
|
||||||
|
"working_directory": str(self.working_directory)
|
||||||
|
if self.working_directory
|
||||||
|
else None,
|
||||||
|
"environment": dict(self.environment),
|
||||||
|
"service_type": self.service_type.value,
|
||||||
|
"keep_alive": self.keep_alive,
|
||||||
|
"run_at_load": self.run_at_load,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceStatus:
|
||||||
|
name: str
|
||||||
|
state: ServiceState
|
||||||
|
pid: int | None = None
|
||||||
|
enabled: bool = False
|
||||||
|
last_started: datetime | None = None
|
||||||
|
last_stopped: datetime | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"state": self.state.value,
|
||||||
|
"pid": self.pid,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"last_started": self.last_started.isoformat() if self.last_started else None,
|
||||||
|
"last_stopped": self.last_stopped.isoformat() if self.last_stopped else None,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
}
|
||||||
27
tests/test_orchestration.py
Normal file
27
tests/test_orchestration.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from afs.orchestration import Orchestrator, TaskRequest
|
||||||
|
from afs.schema import AgentConfig, OrchestratorConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_disabled_returns_note() -> None:
|
||||||
|
config = OrchestratorConfig(enabled=False)
|
||||||
|
orchestrator = Orchestrator(config=config)
|
||||||
|
plan = orchestrator.plan(TaskRequest(summary="Test"))
|
||||||
|
assert not plan.agents
|
||||||
|
assert "orchestrator disabled" in plan.notes
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_matches_tags() -> None:
|
||||||
|
config = OrchestratorConfig(
|
||||||
|
enabled=True,
|
||||||
|
max_agents=2,
|
||||||
|
default_agents=[
|
||||||
|
AgentConfig(name="planner", role="planner", tags=["plan"]),
|
||||||
|
AgentConfig(name="builder", role="coder", tags=["build"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
orchestrator = Orchestrator(config=config)
|
||||||
|
plan = orchestrator.plan(TaskRequest(summary="Build", tags=["build"]))
|
||||||
|
assert len(plan.agents) == 1
|
||||||
|
assert plan.agents[0].name == "builder"
|
||||||
28
tests/test_services.py
Normal file
28
tests/test_services.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from afs.schema import AFSConfig, ServiceConfig, ServicesConfig
|
||||||
|
from afs.services.manager import ServiceManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_manager_lists_builtins() -> None:
|
||||||
|
manager = ServiceManager(config=AFSConfig(), platform_name="linux")
|
||||||
|
names = [definition.name for definition in manager.list_definitions()]
|
||||||
|
assert "orchestrator" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_config_can_disable_service() -> None:
|
||||||
|
services = ServicesConfig(
|
||||||
|
enabled=True,
|
||||||
|
services={
|
||||||
|
"orchestrator": ServiceConfig(name="orchestrator", enabled=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
manager = ServiceManager(config=AFSConfig(services=services), platform_name="linux")
|
||||||
|
names = [definition.name for definition in manager.list_definitions()]
|
||||||
|
assert "orchestrator" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_render_contains_execstart() -> None:
|
||||||
|
manager = ServiceManager(config=AFSConfig(), platform_name="linux")
|
||||||
|
unit = manager.render_unit("orchestrator")
|
||||||
|
assert "ExecStart=" in unit
|
||||||
Reference in New Issue
Block a user