Skip to content

Commit 38748f1

Browse files
author
Aegis Stack
committed
Plugin Stuff
1 parent 021e440 commit 38748f1

5 files changed

Lines changed: 544 additions & 235 deletions

File tree

aegis/core/components.py

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
used for project generation and validation.
66
"""
77

8-
from dataclasses import dataclass
8+
from dataclasses import dataclass, field
99
from enum import Enum
1010

11+
from .file_manifest import FileManifest
12+
1113

1214
class ComponentType(Enum):
1315
"""Component type classifications."""
@@ -35,27 +37,17 @@ class ComponentSpec:
3537
name: str
3638
type: ComponentType
3739
description: str
38-
requires: list[str] | None = None # Hard dependencies
39-
recommends: list[str] | None = None # Soft dependencies
40-
conflicts: list[str] | None = None # Mutual exclusions
41-
docker_services: list[str] | None = None
42-
pyproject_deps: list[str] | None = None
43-
template_files: list[str] | None = None
44-
45-
def __post_init__(self) -> None:
46-
"""Ensure all list fields are initialized."""
47-
if self.requires is None:
48-
self.requires = []
49-
if self.recommends is None:
50-
self.recommends = []
51-
if self.conflicts is None:
52-
self.conflicts = []
53-
if self.docker_services is None:
54-
self.docker_services = []
55-
if self.pyproject_deps is None:
56-
self.pyproject_deps = []
57-
if self.template_files is None:
58-
self.template_files = []
40+
requires: list[str] = field(default_factory=list) # Hard dependencies
41+
recommends: list[str] = field(default_factory=list) # Soft dependencies
42+
conflicts: list[str] = field(default_factory=list) # Mutual exclusions
43+
docker_services: list[str] = field(default_factory=list)
44+
pyproject_deps: list[str] = field(default_factory=list)
45+
template_files: list[str] = field(default_factory=list)
46+
# R1 file manifest used by cleanup_components(). The legacy
47+
# post_gen_tasks.get_component_file_mapping() dict is still maintained
48+
# separately, so this manifest must be kept aligned with it by hand
49+
# until R2 derives the mapping from manifests. See file_manifest.py.
50+
files: FileManifest = field(default_factory=FileManifest)
5951

6052

6153
# Component registry - single source of truth
@@ -66,20 +58,28 @@ def __post_init__(self) -> None:
6658
description="FastAPI backend server",
6759
pyproject_deps=["fastapi==0.116.1", "uvicorn==0.35.0"],
6860
template_files=["app/components/backend/"],
61+
# backend is a CORE component; never cleaned up.
6962
),
7063
"frontend": ComponentSpec(
7164
name="frontend",
7265
type=ComponentType.CORE,
7366
description="Flet frontend interface",
7467
pyproject_deps=["flet==0.28.3"],
7568
template_files=["app/components/frontend/"],
69+
# frontend is a CORE component; never cleaned up.
7670
),
7771
"redis": ComponentSpec(
7872
name="redis",
7973
type=ComponentType.INFRASTRUCTURE,
8074
description="Redis cache and message broker",
8175
docker_services=["redis"],
8276
pyproject_deps=["redis==5.0.8"],
77+
files=FileManifest(
78+
primary=[
79+
"app/components/frontend/dashboard/cards/redis_card.py",
80+
"app/components/frontend/dashboard/modals/redis_modal.py",
81+
],
82+
),
8383
),
8484
"worker": ComponentSpec(
8585
name="worker",
@@ -89,6 +89,26 @@ def __post_init__(self) -> None:
8989
pyproject_deps=["arq==0.25.0"],
9090
docker_services=["worker-system", "worker-load-test"],
9191
template_files=["app/components/worker/"],
92+
files=FileManifest(
93+
# Mirrors cleanup_components() lines 316-333 (worker NOT enabled).
94+
# task_history_section.py is intentionally NOT here — cleanup
95+
# leaves it. worker_taskiq.py IS here — cleanup removes it.
96+
primary=[
97+
"app/components/worker",
98+
"app/cli/load_test.py",
99+
"app/services/load_test.py",
100+
"app/services/load_test_models.py",
101+
"app/services/load_test_workloads.py",
102+
"tests/services/test_load_test_models.py",
103+
"tests/services/test_load_test_service.py",
104+
"tests/services/test_worker_health_registration.py",
105+
"app/components/backend/api/worker.py",
106+
"app/components/backend/api/worker_taskiq.py",
107+
"tests/api/test_worker_endpoints.py",
108+
"app/components/frontend/dashboard/cards/worker_card.py",
109+
"app/components/frontend/dashboard/modals/worker_modal.py",
110+
],
111+
),
92112
),
93113
"scheduler": ComponentSpec(
94114
name="scheduler",
@@ -97,6 +117,22 @@ def __post_init__(self) -> None:
97117
pyproject_deps=["apscheduler==3.10.4"],
98118
docker_services=["scheduler"],
99119
template_files=["app/components/scheduler.py", "app/entrypoints/scheduler.py"],
120+
files=FileManifest(
121+
primary=[
122+
"app/entrypoints/scheduler.py",
123+
"app/components/scheduler",
124+
"tests/components/test_scheduler.py",
125+
"docs/components/scheduler.md",
126+
"app/components/backend/api/scheduler.py",
127+
"tests/api/test_scheduler_endpoints.py",
128+
"app/components/frontend/dashboard/cards/scheduler_card.py",
129+
"app/components/frontend/dashboard/modals/scheduler_modal.py",
130+
"tests/services/test_scheduled_task_manager.py",
131+
],
132+
# scheduler persistence cleanup is option-driven
133+
# (scheduler_backend == MEMORY), not a simple AnswerKey toggle —
134+
# it stays inline in cleanup_components() for R1.
135+
),
100136
),
101137
"database": ComponentSpec(
102138
name="database",
@@ -105,20 +141,41 @@ def __post_init__(self) -> None:
105141
pyproject_deps=["sqlmodel>=0.0.14", "sqlalchemy>=2.0.0"],
106142
# Note: async driver (aiosqlite or asyncpg) selected based on database_type in copier.yml
107143
template_files=["app/core/db.py"],
144+
files=FileManifest(
145+
primary=[
146+
"app/core/db.py",
147+
"app/components/frontend/dashboard/cards/database_card.py",
148+
"app/components/frontend/dashboard/modals/database_modal.py",
149+
],
150+
),
108151
),
109152
"ingress": ComponentSpec(
110153
name="ingress",
111154
type=ComponentType.INFRASTRUCTURE,
112155
description="Traefik reverse proxy and load balancer",
113156
docker_services=["traefik"],
114157
recommends=["backend"],
158+
files=FileManifest(
159+
primary=[
160+
"traefik",
161+
"app/components/frontend/dashboard/cards/ingress_card.py",
162+
"app/components/frontend/dashboard/modals/ingress_modal.py",
163+
],
164+
),
115165
),
116166
"observability": ComponentSpec(
117167
name="observability",
118168
type=ComponentType.INFRASTRUCTURE,
119169
description="Logfire observability, tracing, and metrics",
120170
pyproject_deps=["logfire[fastapi,httpx]"],
121171
template_files=["app/components/backend/middleware/logfire_tracing.py"],
172+
files=FileManifest(
173+
primary=[
174+
"app/components/backend/middleware/logfire_tracing.py",
175+
"app/components/frontend/dashboard/cards/observability_card.py",
176+
"app/components/frontend/dashboard/modals/observability_modal.py",
177+
],
178+
),
122179
),
123180
}
124181

aegis/core/file_manifest.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
Declarative file manifest per service / component spec.
3+
4+
R1 of the plugin system refactor. Centralises the per-spec file
5+
ownership needed for declarative cleanup, replacing the **Pattern A**
6+
imperative blocks ("if X is not selected, remove these files") that
7+
were scattered through ``aegis/core/post_gen_tasks.py``.
8+
9+
What R1 does NOT do (deliberately deferred to R2):
10+
11+
* ``post_gen_tasks.get_component_file_mapping()`` is still a hard-coded
12+
dict. ``compute_file_mapping()`` below is a forward-compat helper for
13+
the eventual migration; it is not currently called by core code. The
14+
legacy dict and each spec's ``files.primary`` can therefore drift —
15+
keep them aligned by hand until R2 derives the mapping from manifests.
16+
* Sub-feature, cross-spec, and worker-backend cleanups remain inline in
17+
``cleanup_components`` (their gating predicates are non-uniform).
18+
19+
Everything else stays inline in ``cleanup_components`` for R1:
20+
21+
* Pattern B sub-feature cleanup (``ai_rag``, ``ai_voice``, ``auth_org``,
22+
AI memory backend, ollama mode, scheduler memory backend). Each has
23+
its own gating predicate; uniform reducer rules don't fit yet.
24+
* Pattern C cross-spec aggregations (``alembic`` only when no service
25+
needs migrations; ``services_card.py`` only when no services;
26+
``docs/components`` only when no components).
27+
* Pattern D worker backend-variant rename + delete-others.
28+
29+
The ``extras`` field on ``FileManifest`` is captured here for
30+
documentation / future use (``get_component_file_mapping`` legacy keys
31+
like ``ai_rag``, ``ai_voice``, ``scheduler_persistence``), but the R1
32+
reducer ignores it. R2 generalises the spec model and lights up
33+
``extras``-driven cleanup uniformly.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import shutil
39+
from collections.abc import Iterable
40+
from dataclasses import dataclass, field
41+
from pathlib import Path
42+
from typing import Any
43+
44+
45+
@dataclass
46+
class FileManifest:
47+
"""File ownership for a single service or component spec.
48+
49+
See module docstring for the role this plays in R1.
50+
"""
51+
52+
primary: list[str] = field(default_factory=list)
53+
"""Files cleaned up when the parent spec is not selected.
54+
55+
Paths are relative to the project root. Each entry may be a file path
56+
or a directory path; directories are removed recursively.
57+
"""
58+
59+
extras: dict[str, list[str]] = field(default_factory=dict)
60+
"""Sub-feature file groups, keyed by their legacy mapping identifier.
61+
62+
Captured on the spec for documentation and forward-compatibility
63+
with ``get_component_file_mapping()``. **Not** consumed by the R1
64+
cleanup reducer — sub-feature cleanup remains inline in
65+
``cleanup_components`` because each sub-feature has its own gating
66+
predicate (some by AnswerKey toggle, some by option value, some
67+
only when the parent is also on). R2 unifies the spec model and
68+
lights up uniform extras-driven cleanup.
69+
70+
Example: ``{"ai_rag": [...], "ai_voice": [...]}`` on the AI service.
71+
"""
72+
73+
74+
def compute_file_mapping(specs: Iterable[Any]) -> dict[str, list[str]]:
75+
"""Build the legacy component-file mapping from spec manifests.
76+
77+
Output shape matches ``post_gen_tasks.get_component_file_mapping()``:
78+
``{spec_name: [files...], extra_key: [files...], ...}``.
79+
80+
Specs without a populated ``FileManifest`` are skipped — non-migrated
81+
specs do not break the reducer.
82+
"""
83+
mapping: dict[str, list[str]] = {}
84+
for spec in specs:
85+
manifest = getattr(spec, "files", None)
86+
if not isinstance(manifest, FileManifest):
87+
continue
88+
if manifest.primary:
89+
mapping[spec.name] = list(manifest.primary)
90+
for extra_key, extra_files in manifest.extras.items():
91+
if extra_files:
92+
mapping[extra_key] = list(extra_files)
93+
return mapping
94+
95+
96+
def iter_cleanup_paths(spec: Any, *, selected: bool) -> Iterable[str]:
97+
"""Yield project-relative paths to remove for one spec when it is off.
98+
99+
R1 scope: only Pattern A primary cleanup. When ``selected`` is True,
100+
yields nothing (sub-feature cleanup is handled inline). When False,
101+
yields the spec's ``primary`` paths.
102+
103+
Args:
104+
spec: A spec object exposing ``.files: FileManifest``. Specs
105+
without a manifest yield nothing.
106+
selected: Whether this spec is selected (its ``include_*`` flag
107+
is truthy in the context).
108+
109+
Yields:
110+
Relative paths to remove (file or directory; caller decides how).
111+
"""
112+
if selected:
113+
return
114+
manifest = getattr(spec, "files", None)
115+
if not isinstance(manifest, FileManifest):
116+
return
117+
yield from manifest.primary
118+
119+
120+
def apply_cleanup_path(project_path: Path, rel_path: str) -> None:
121+
"""Remove a single project-relative path. File or directory; no-op if absent.
122+
123+
Mirrors the union of ``post_gen_tasks.remove_file`` and
124+
``post_gen_tasks.remove_dir`` so callers don't need to know the kind.
125+
"""
126+
full = project_path / rel_path
127+
if full.is_file() or full.is_symlink():
128+
full.unlink()
129+
elif full.is_dir():
130+
shutil.rmtree(full)
131+
# else: missing -> silent no-op, matches existing behaviour.

0 commit comments

Comments
 (0)