feat: support one Gitea organization per series

This commit is contained in:
2026-07-19 16:40:05 +02:00
parent 4dda6de387
commit b0548f0e56
6 changed files with 388 additions and 58 deletions
+26 -19
View File
@@ -11,24 +11,30 @@ Komendy kart pracy dzielimy na trzy poziomy:
- `series cards` - operacje na kartach w ramach serii - `series cards` - operacje na kartach w ramach serii
- `series cards tasks` - operacje na zadaniach w ramach karty - `series cards tasks` - operacje na zadaniach w ramach karty
`rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii `stemctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Gdy
i kart pochodzi docelowo z publicznego manifestu `workspace-info` jest dostępny, manifest jest jedynym źródłem listy serii i
`~/dev/workspace/rv/meta/workspace-info/workspace.json`. Jeżeli manifestu nie kart. Przypadkowe katalogi robocze nie pojawiają się w katalogu. Fallback przez
ma, komendy zależne od serii automatycznie klonują repo `original_series_root` działa wyłącznie dla starego workspace bez manifestu.
`edu-workspace/workspace-info`. Jeżeli klonowanie nie jest możliwe, narzędzie
używa starego fallbacku przez `original_series_root`. Każda operacyjna seria jawnie deklaruje `source_org`. Docelowa konwencja to
`edu-<series-id>`, na przykład `freertos-c``edu-freertos-c`. Jedno repo
odpowiada jednej karcie; kolejnych wydań karty nie zapisujemy jako osobnych
repozytoriów ani trwałych branchy, tylko jako historię, tagi i wydania tego
repozytorium. Organizacja `edu` przechowuje control plane i katalog, nie setki
repozytoriów kart.
## Szybki przepływ ## Szybki przepływ
```bash ```bash
./rvctl tokens compare ./stemctl workspace audit
./rvctl series list ./stemctl tokens compare
./rvctl series use inf ./stemctl series list
./rvctl series cards list inf ./stemctl series use freertos-c
./rvctl card use bss ./stemctl series cards list freertos-c
./rvctl series cards fetch inf bss ./stemctl card use FC02
./rvctl tasks list ./stemctl series cards fetch freertos-c FC02
./rvctl tasks switch 1 ./stemctl tasks list
./stemctl tasks switch 1
``` ```
Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`. Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
@@ -37,10 +43,10 @@ Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
### `series list` ### `series list`
Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już Listuje wyłącznie operacyjne serie z manifestu `workspace-info` oraz liczbę
pobranych do lokalnego workspace. Jeżeli manifestu jeszcze nie ma, komenda kart już obecnych w lokalnym workspace. Logiczna seria może wskazywać wspólny,
pobiera go automatycznie. Jeżeli `~/dev/workspace/rv/series` nie istnieje, przejściowy katalog fizyczny przez `workspace_dir`; na przykład `freertos-c`
komenda tworzy ten katalog. jest obecnie mapowane na `series/freertos`.
```bash ```bash
./rvctl series list ./rvctl series list
@@ -50,7 +56,8 @@ Typowy wynik:
```text ```text
fiz 3 0 fiz 3 0
inf 3 0 inf 9 5
freertos-c 11 11
``` ```
Kolumny oznaczają: seria, liczba kart w źródłach, liczba kart pobranych do Kolumny oznaczają: seria, liczba kart w źródłach, liczba kart pobranych do
+17
View File
@@ -16,6 +16,23 @@ wybrany kontener
└── wewnętrzny tmux → Neovim + Termdebug/GDB + symulator lub RP2350 └── wewnętrzny tmux → Neovim + Termdebug/GDB + symulator lub RP2350
``` ```
## Katalog i organizacje Gitea
`workspace-info/workspace.json` jest źródłem prawdy. Organizacja `edu` pełni
rolę control plane, a stabilne serie mają osobne organizacje o nazwie
`edu-<series-id>`. Karta jest repozytorium wewnątrz organizacji swojej serii.
```bash
stemctl workspace audit
stemctl series list
stemctl series cards list freertos-c
stemctl series cards show freertos-c FC02
```
`workspace audit` jest lokalny i deterministyczny: sprawdza kontrakt katalogu,
nie modyfikuje Gitea. Dzięki temu brak sieci nie uniemożliwia przeprowadzenia
zajęć z wcześniej zsynchronizowanego workspace.
Obiektem operacji `attach` i `detach` jest **połączenie pane z kontenerem**. Obiektem operacji `attach` i `detach` jest **połączenie pane z kontenerem**.
Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem
podpinanym przez launcher. podpinanym przez launcher.
+30 -6
View File
@@ -13,22 +13,34 @@ Implementacja CLI znajduje się w `rvctl.py`. Plik `workspace.json` przechowuje
lokalne ścieżki workspace, series, socketów, tokenów i adres publicznego lokalne ścieżki workspace, series, socketów, tokenów i adres publicznego
manifestu. manifestu.
Publiczny manifest wspólnego workspace jest w repo: Publiczny manifest wspólnego workspace jest częścią control plane w
organizacji `edu`. Katalog jest źródłem prawdy dla narzędzi i interfejsu;
lista organizacji widoczna w Gitea pozostaje widokiem administracyjnym.
Model własności:
```text ```text
edu-workspace/workspace-info edu
└── workspace-info control plane i katalog
edu-inf seria legacy
edu-fiz seria
edu-freertos-c seria
├── lab-rv32i-freertos-heap4 jedna karta = jedno repo
├── lab-rv32i-freertos-c-first-task
└── ...
``` ```
Lokalna kopia manifestu znajduje się w: Lokalna kopia manifestu znajduje się w:
```text ```text
~/dev/workspace/rv/meta/workspace-info ~/dev/workspace/stem/meta/workspace-info
``` ```
Typowy układ: Typowy układ:
```text ```text
~/dev/workspace/rv ~/dev/workspace/stem
├── meta ├── meta
│ └── workspace-info │ └── workspace-info
├── tools ├── tools
@@ -38,8 +50,20 @@ Typowy układ:
└── series └── series
``` ```
`workspace-info` opisuje serie, karty, repo źródłowe, repo odpowiedzi i branche. `workspace-info` opisuje rejestr organizacji, operacyjne serie, karty, repo
Nie przechowuje tokenów ani lokalnych plików roboczych ucznia. źródłowe, repo odpowiedzi i branche. Każda seria musi jawnie podać
`source_org`; globalne `git.source_org` nie zastępuje tej deklaracji.
`workspace-info` nie przechowuje tokenów ani lokalnych plików roboczych ucznia.
Sprawdzenie spójności bez połączenia z Gitea:
```bash
./stemctl workspace audit
```
Audyt wykrywa brak organizacji, brak jawnego `source_org`, niespójne
przypisanie seriaorganizacja oraz zduplikowane identyfikatory kart i repo.
Ostrzeżenie o wspólnym `workspace_dir` jest dopuszczalne podczas migracji.
Aktualizacja manifestu: Aktualizacja manifestu:
+186 -26
View File
@@ -326,10 +326,130 @@ def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict
return workspace_series_manifests(config).get(series_name) return workspace_series_manifests(config).get(series_name)
def workspace_organizations(config: WorkspaceConfig) -> dict[str, dict]:
if not config.workspace_info:
return {}
organizations: dict[str, dict] = {}
for entry in config.workspace_info.get("organizations", []):
if not isinstance(entry, dict):
continue
organization_id = str(entry.get("id", "")).strip()
if organization_id:
organizations[organization_id] = entry
return organizations
def audit_workspace_catalog(config: WorkspaceConfig) -> list[tuple[str, str, str]]:
"""Validate the catalog-to-Gitea ownership contract without network access."""
checks: list[tuple[str, str, str]] = []
if not config.workspace_info:
return [("catalog", "FAIL", f"missing {config.workspace_info_path}")]
raw_organizations = config.workspace_info.get("organizations", [])
organizations = workspace_organizations(config)
if not isinstance(raw_organizations, list) or not raw_organizations:
checks.append(("organizations", "FAIL", "workspace.json must declare the organization registry"))
else:
raw_ids = [str(entry.get("id", "")).strip() for entry in raw_organizations if isinstance(entry, dict)]
duplicates = sorted({value for value in raw_ids if value and raw_ids.count(value) > 1})
if duplicates:
checks.append(("organizations", "FAIL", f"duplicate ids: {', '.join(duplicates)}"))
else:
checks.append(("organizations", "ok", f"{len(organizations)} registered"))
control_plane = organizations.get("edu")
if not control_plane or control_plane.get("role") != "control-plane":
checks.append(("control-plane", "FAIL", "organization 'edu' must have role 'control-plane'"))
else:
checks.append(("control-plane", "ok", "edu"))
manifests = workspace_series_manifests(config)
if not manifests:
checks.append(("series", "FAIL", "no operational series declared"))
return checks
physical_workspace_dirs: dict[str, list[str]] = {}
for series_id, manifest in manifests.items():
source_org = manifest.get("source_org")
if not isinstance(source_org, str) or not source_org.strip():
checks.append((f"series:{series_id}", "FAIL", "missing explicit source_org"))
continue
source_org = source_org.strip()
organization = organizations.get(source_org)
if organization is None:
checks.append((f"series:{series_id}", "FAIL", f"source_org '{source_org}' is not registered"))
else:
assigned_series = organization.get("series", [])
if not isinstance(assigned_series, list) or series_id not in assigned_series:
checks.append(
(f"series:{series_id}", "FAIL", f"organization '{source_org}' does not assign this series")
)
else:
checks.append((f"series:{series_id}", "ok", f"source_org={source_org}"))
expected_org = f"edu-{series_id.replace('_', '-')}"
if source_org != expected_org and not bool(manifest.get("legacy")):
checks.append(
(f"naming:{series_id}", "WARN", f"expected '{expected_org}', declare legacy=true if intentional")
)
raw_cards = manifest.get("cards", [])
cards = [card for card in raw_cards if isinstance(card, dict)] if isinstance(raw_cards, list) else []
card_ids = [str(card.get("id", "")).strip() for card in cards]
repos = [str(card.get("repo", "")).strip() for card in cards]
duplicate_ids = sorted({value for value in card_ids if value and card_ids.count(value) > 1})
duplicate_repos = sorted({value for value in repos if value and repos.count(value) > 1})
missing = sum(not card_id or not repo for card_id, repo in zip(card_ids, repos))
if duplicate_ids or duplicate_repos or missing:
details = []
if duplicate_ids:
details.append(f"duplicate card ids: {', '.join(duplicate_ids)}")
if duplicate_repos:
details.append(f"duplicate repos: {', '.join(duplicate_repos)}")
if missing:
details.append(f"{missing} cards missing id/repo")
checks.append((f"cards:{series_id}", "FAIL", "; ".join(details)))
else:
checks.append((f"cards:{series_id}", "ok", f"{len(cards)} cards"))
try:
physical_dir = series_path_component(config, series_id, "workspace_dir")
except SystemExit as error:
checks.append((f"path:{series_id}", "FAIL", str(error)))
else:
physical_workspace_dirs.setdefault(physical_dir, []).append(series_id)
for physical_dir, series_ids in physical_workspace_dirs.items():
if len(series_ids) > 1:
checks.append(
("workspace-dir", "WARN", f"'{physical_dir}' is shared by: {', '.join(series_ids)}")
)
return checks
def series_path_component(config: WorkspaceConfig, series_name: str, field: str) -> str:
"""Return a safe physical directory name for one logical series."""
manifest = workspace_series_manifest(config, series_name)
raw_value = manifest.get(field, series_name) if manifest else series_name
value = str(raw_value).strip()
if not value or value in {".", ".."} or Path(value).name != value:
raise SystemExit(f"Invalid {field} for series '{series_name}': {raw_value!r}")
return value
def workspace_series_path(config: WorkspaceConfig, series_name: str) -> Path:
return config.series_root / series_path_component(config, series_name, "workspace_dir")
def source_series_path(config: WorkspaceConfig, series_name: str) -> Path:
return config.original_series_root / series_path_component(config, series_name, "source_dir")
def series_exists(config: WorkspaceConfig, series_name: str) -> bool: def series_exists(config: WorkspaceConfig, series_name: str) -> bool:
if workspace_series_manifest(config, series_name) is not None: if workspace_series_manifest(config, series_name) is not None:
return True return True
return (config.series_root / series_name).is_dir() or (config.original_series_root / series_name).is_dir() return workspace_series_path(config, series_name).is_dir() or source_series_path(config, series_name).is_dir()
def default_card_for_series(config: WorkspaceConfig, series_name: str) -> str | None: def default_card_for_series(config: WorkspaceConfig, series_name: str) -> str | None:
@@ -358,7 +478,13 @@ def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[
if not manifest: if not manifest:
return [] return []
source_org = str(manifest.get("source_org", config.git.source_org)) raw_source_org = manifest.get("source_org")
if not isinstance(raw_source_org, str) or not raw_source_org.strip():
raise SystemExit(
f"Series '{series_name}' must declare source_org explicitly in workspace-info; "
"the global git.source_org is only a legacy fallback for directory-only workspaces."
)
source_org = raw_source_org.strip()
answer_org = str(manifest.get("answer_org", config.git.answer_org)) answer_org = str(manifest.get("answer_org", config.git.answer_org))
source_remote = str(manifest.get("source_remote", config.git.source_remote)) source_remote = str(manifest.get("source_remote", config.git.source_remote))
answer_remote = str(manifest.get("answer_remote", config.git.answer_remote)) answer_remote = str(manifest.get("answer_remote", config.git.answer_remote))
@@ -373,7 +499,7 @@ def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[
repo_name = str(raw_card.get("repo", card_id)).strip() repo_name = str(raw_card.get("repo", card_id)).strip()
branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch
title = str(raw_card.get("title", "")).strip() title = str(raw_card.get("title", "")).strip()
source_path = config.original_series_root / series_name / repo_name source_path = source_series_path(config, series_name) / repo_name
cards.append( cards.append(
CardInfo( CardInfo(
series_id=series_name, series_id=series_name,
@@ -420,24 +546,24 @@ def source_card_infos(config: WorkspaceConfig, series_name: str) -> list[CardInf
def series_directories(config: WorkspaceConfig) -> list[Path]: def series_directories(config: WorkspaceConfig) -> list[Path]:
config.series_root.mkdir(parents=True, exist_ok=True) config.series_root.mkdir(parents=True, exist_ok=True)
return sorted(path for path in config.series_root.iterdir() if path.is_dir()) return sorted(path for path in config.series_root.iterdir() if path.is_dir() and not path.name.startswith("."))
def card_directories(series_path: Path) -> list[Path]: def card_directories(series_path: Path) -> list[Path]:
return sorted(path for path in series_path.iterdir() if path.is_dir()) return sorted(path for path in series_path.iterdir() if path.is_dir() and not path.name.startswith("."))
def source_series_directories(config: WorkspaceConfig) -> list[Path]: def source_series_directories(config: WorkspaceConfig) -> list[Path]:
if not config.original_series_root.is_dir(): if not config.original_series_root.is_dir():
return [] return []
return sorted(path for path in config.original_series_root.iterdir() if path.is_dir()) return sorted(path for path in config.original_series_root.iterdir() if path.is_dir() and not path.name.startswith("."))
def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]: def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]:
source_series_path = config.original_series_root / series_name series_path = source_series_path(config, series_name)
if not source_series_path.is_dir(): if not series_path.is_dir():
return [] return []
return sorted(path for path in source_series_path.iterdir() if path.is_dir()) return sorted(path for path in series_path.iterdir() if path.is_dir() and not path.name.startswith("."))
def card_label_from_repo_name(repo_name: str) -> str: def card_label_from_repo_name(repo_name: str) -> str:
@@ -450,14 +576,18 @@ def card_label_from_repo_name(repo_name: str) -> str:
def card_info_matches(card_info: CardInfo, normalized: str) -> bool: def card_info_matches(card_info: CardInfo, normalized: str) -> bool:
candidates = {card_info.card_id, card_info.repo, card_label_from_repo_name(card_info.repo)} candidates = {
str(card_info.card_id).lower(),
str(card_info.repo).lower(),
card_label_from_repo_name(card_info.repo).lower(),
}
return normalized in candidates or any(normalized in candidate for candidate in candidates) return normalized in candidates or any(normalized in candidate for candidate in candidates)
def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo: def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo:
cards = source_card_infos(config, series_name) cards = source_card_infos(config, series_name)
if not cards: if not cards:
source_hint = config.original_series_root / series_name source_hint = source_series_path(config, series_name)
if config.workspace_info_path.is_file(): if config.workspace_info_path.is_file():
source_hint = config.workspace_info_path source_hint = config.workspace_info_path
raise SystemExit(f"Missing source series: {source_hint}") raise SystemExit(f"Missing source series: {source_hint}")
@@ -477,13 +607,13 @@ def resolve_source_card_path(config: WorkspaceConfig, series_name: str, card_nam
manifest_card = resolve_card_info(config, series_name, card_name) manifest_card = resolve_card_info(config, series_name, card_name)
if manifest_card.source_path: if manifest_card.source_path:
return manifest_card.source_path return manifest_card.source_path
return config.original_series_root / series_name / manifest_card.repo return source_series_path(config, series_name) / manifest_card.repo
def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
cards = source_card_directories(config, series_name) cards = source_card_directories(config, series_name)
if not cards: if not cards:
raise SystemExit(f"Missing source series: {config.original_series_root / series_name}") raise SystemExit(f"Missing source series: {source_series_path(config, series_name)}")
normalized = slug_value(card_name, "card") normalized = slug_value(card_name, "card")
matches = [] matches = []
@@ -535,7 +665,7 @@ def resolve_selector(
if not series_default_card: if not series_default_card:
raise SystemExit("Missing default card in config.") raise SystemExit("Missing default card in config.")
return series_arg, series_default_card return series_arg, series_default_card
if default_series and (config.series_root / default_series / series_arg).is_dir(): if default_series and (workspace_series_path(config, default_series) / series_arg).is_dir():
return default_series, series_arg return default_series, series_arg
if default_series: if default_series:
try: try:
@@ -553,7 +683,7 @@ def resolve_selector(
def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
card_path = config.series_root / series_name / card_name card_path = workspace_series_path(config, series_name) / card_name
if card_path.is_dir(): if card_path.is_dir():
return card_path return card_path
card_info = resolve_card_info(config, series_name, card_name) card_info = resolve_card_info(config, series_name, card_name)
@@ -566,6 +696,14 @@ def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str)
def print_series(config: WorkspaceConfig) -> None: def print_series(config: WorkspaceConfig) -> None:
config.series_root.mkdir(parents=True, exist_ok=True) config.series_root.mkdir(parents=True, exist_ok=True)
manifest_by_name = workspace_series_manifests(config) manifest_by_name = workspace_series_manifests(config)
if manifest_by_name:
for series_name in manifest_by_name:
workspace_path = workspace_series_path(config, series_name)
cards = source_card_infos(config, series_name)
workspace_count = sum((workspace_path / card.repo).is_dir() for card in cards)
print(f"{series_name}\t{len(cards)}\t{workspace_count}\t{workspace_path if workspace_path.is_dir() else ''}")
return
source_by_name = {path.name: path for path in source_series_directories(config)} source_by_name = {path.name: path for path in source_series_directories(config)}
workspace_by_name = {path.name: path for path in series_directories(config)} workspace_by_name = {path.name: path for path in series_directories(config)}
for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)): for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)):
@@ -583,8 +721,15 @@ def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None:
config.series_root.mkdir(parents=True, exist_ok=True) config.series_root.mkdir(parents=True, exist_ok=True)
source_cards = {card.repo: card for card in source_card_infos(config, series_name)} source_cards = {card.repo: card for card in source_card_infos(config, series_name)}
workspace_series_path = config.series_root / series_name series_path = workspace_series_path(config, series_name)
workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {} if workspace_series_manifest(config, series_name):
workspace_cards = {
card.repo: series_path / card.repo
for card in source_cards.values()
if (series_path / card.repo).is_dir()
}
else:
workspace_cards = {path.name: path for path in card_directories(series_path)} if series_path.is_dir() else {}
if not source_cards and not workspace_cards: if not source_cards and not workspace_cards:
raise SystemExit(f"Missing series: {series_name}") raise SystemExit(f"Missing series: {series_name}")
@@ -2859,11 +3004,11 @@ def ensure_answer_token_from_source(
def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> Path: def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> Path:
return config.series_root / series_name / source_card_path.name return workspace_series_path(config, series_name) / source_card_path.name
def workspace_card_path_for_info(config: WorkspaceConfig, card_info: CardInfo) -> Path: def workspace_card_path_for_info(config: WorkspaceConfig, card_info: CardInfo) -> Path:
return config.series_root / card_info.series_id / card_info.repo return workspace_series_path(config, card_info.series_id) / card_info.repo
def resolve_workspace_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: def resolve_workspace_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
@@ -3176,8 +3321,8 @@ def switch_card_task(
def print_series_details(config: WorkspaceConfig, series_name: str) -> None: def print_series_details(config: WorkspaceConfig, series_name: str) -> None:
config.series_root.mkdir(parents=True, exist_ok=True) config.series_root.mkdir(parents=True, exist_ok=True)
source_path = config.original_series_root / series_name source_path = source_series_path(config, series_name)
workspace_path = config.series_root / series_name workspace_path = workspace_series_path(config, series_name)
cards = source_card_infos(config, series_name) cards = source_card_infos(config, series_name)
if not cards and not workspace_path.is_dir(): if not cards and not workspace_path.is_dir():
raise SystemExit(f"Missing series: {series_name}") raise SystemExit(f"Missing series: {series_name}")
@@ -3209,11 +3354,11 @@ def print_card_details(config: WorkspaceConfig, series_name: str, card_name: str
def available_series_hint(config: WorkspaceConfig) -> str: def available_series_hint(config: WorkspaceConfig) -> str:
names = sorted( manifest_names = list(workspace_series_manifests(config))
set(workspace_series_manifests(config)) if manifest_names:
| {path.name for path in source_series_directories(config)} names = sorted(manifest_names)
| {path.name for path in series_directories(config)} else:
) names = sorted({path.name for path in source_series_directories(config)} | {path.name for path in series_directories(config)})
return ", ".join(names) return ", ".join(names)
@@ -4157,6 +4302,8 @@ SESSION_STATUS_MISSING = "brak"
def ordered_series_names(config: WorkspaceConfig) -> list[str]: def ordered_series_names(config: WorkspaceConfig) -> list[str]:
names = list(workspace_series_manifests(config)) names = list(workspace_series_manifests(config))
if names:
return names
for path in [*series_directories(config), *source_series_directories(config)]: for path in [*series_directories(config), *source_series_directories(config)]:
if not path.name.startswith(".") and path.name not in names: if not path.name.startswith(".") and path.name not in names:
names.append(path.name) names.append(path.name)
@@ -5484,6 +5631,16 @@ def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None:
print(f"status\tready") print(f"status\tready")
return return
if args.workspace_command == "audit":
ensure_workspace_info(config)
checks = audit_workspace_catalog(config)
print("check\tstatus\tdetail")
for name, status, detail in checks:
print(f"{name}\t{status}\t{detail}")
if any(status == "FAIL" for _, status, _ in checks):
raise SystemExit(1)
return
if args.workspace_command == "migrate": if args.workspace_command == "migrate":
migrated = 0 migrated = 0
already_ready = 0 already_ready = 0
@@ -5775,6 +5932,9 @@ def build_parser() -> argparse.ArgumentParser:
workspace_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.") workspace_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.")
workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True) workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True)
workspace_subparsers.add_parser("show", help="Show workspace-info configuration.") workspace_subparsers.add_parser("show", help="Show workspace-info configuration.")
workspace_subparsers.add_parser(
"audit", help="Validate series ownership, organization registry and card identities."
)
workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.") workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.")
workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.") workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
workspace_migrate_parser = workspace_subparsers.add_parser( workspace_migrate_parser = workspace_subparsers.add_parser(
+122
View File
@@ -175,6 +175,128 @@ class StemctlContractTests(unittest.TestCase):
self.assertIn("runtime\tFAIL\tdocker is not supported", rendered) self.assertIn("runtime\tFAIL\tdocker is not supported", rendered)
self.assertIn("rootless\tFAIL\trootless Podman is required", rendered) self.assertIn("rootless\tFAIL\trootless Podman is required", rendered)
def test_manifest_is_authoritative_and_maps_logical_series_directory(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
workspace = root / "workspace"
info = workspace / "meta" / "workspace-info"
(info / "series").mkdir(parents=True)
physical_series = workspace / "series" / "freertos"
(physical_series / "card-one").mkdir(parents=True)
(physical_series / "unregistered-extra").mkdir()
(workspace / "series" / ".hidden").mkdir()
source_series = root / "original" / "series" / "freertos"
(source_series / "card-one").mkdir(parents=True)
(info / "series" / "freertos-c.json").write_text(
json.dumps(
{
"id": "freertos-c",
"source_org": "edu-freertos-c",
"workspace_dir": "freertos",
"source_dir": "freertos",
"cards": [{"id": "FC01", "repo": "card-one", "title": "First card"}],
}
),
encoding="utf-8",
)
(info / "workspace.json").write_text(
json.dumps(
{
"organizations": [
{"id": "edu", "role": "control-plane"},
{"id": "edu-freertos-c", "role": "series", "series": ["freertos-c"]},
],
"series": [{"id": "freertos-c", "file": "series/freertos-c.json"}],
}
),
encoding="utf-8",
)
config_path = root / "config.json"
config_path.write_text(
json.dumps(
{
"workspace_root": str(workspace),
"original_root": str(root / "original"),
"original_series_root": str(root / "original" / "series"),
}
),
encoding="utf-8",
)
config = rvctl.load_config(config_path)
series_output = io.StringIO()
cards_output = io.StringIO()
with redirect_stdout(series_output):
rvctl.print_series(config)
with redirect_stdout(cards_output):
rvctl.print_cards(config, "freertos-c")
self.assertIn("freertos-c\t1\t1", series_output.getvalue())
self.assertNotIn(".hidden", series_output.getvalue())
self.assertIn("FC01\tcard-one\tworkspace\tFirst card", cards_output.getvalue())
self.assertNotIn("unregistered-extra", cards_output.getvalue())
self.assertEqual(rvctl.workspace_series_path(config, "freertos-c"), physical_series)
self.assertEqual(rvctl.source_series_path(config, "freertos-c"), source_series)
self.assertEqual(rvctl.resolve_card_info(config, "freertos-c", "FC01").repo, "card-one")
def test_workspace_catalog_audit_accepts_registered_series_org(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
workspace = root / "workspace"
info = workspace / "meta" / "workspace-info"
info.mkdir(parents=True)
(info / "workspace.json").write_text(
json.dumps(
{
"organizations": [
{"id": "edu", "role": "control-plane"},
{"id": "edu-freertos-c", "role": "series", "series": ["freertos-c"]},
],
"series": [
{
"id": "freertos-c",
"source_org": "edu-freertos-c",
"cards": [{"id": "FC01", "repo": "card-one"}],
}
],
}
),
encoding="utf-8",
)
config_path = root / "config.json"
config_path.write_text(
json.dumps({"workspace_root": str(workspace), "original_root": str(root / "original")}),
encoding="utf-8",
)
config = rvctl.load_config(config_path)
checks = rvctl.audit_workspace_catalog(config)
self.assertFalse(any(status == "FAIL" for _, status, _ in checks), checks)
self.assertIn(("series:freertos-c", "ok", "source_org=edu-freertos-c"), checks)
def test_workspace_catalog_audit_rejects_implicit_source_org(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
workspace = root / "workspace"
info = workspace / "meta" / "workspace-info"
info.mkdir(parents=True)
(info / "workspace.json").write_text(
json.dumps(
{
"organizations": [{"id": "edu", "role": "control-plane"}],
"series": [{"id": "freertos-c", "cards": [{"id": "FC01", "repo": "card-one"}]}],
}
),
encoding="utf-8",
)
config_path = root / "config.json"
config_path.write_text(
json.dumps({"workspace_root": str(workspace), "original_root": str(root / "original")}),
encoding="utf-8",
)
config = rvctl.load_config(config_path)
checks = rvctl.audit_workspace_catalog(config)
self.assertIn(("series:freertos-c", "FAIL", "missing explicit source_org"), checks)
def test_existing_legacy_workspace_is_detected(self) -> None: def test_existing_legacy_workspace_is_detected(self) -> None:
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
+6 -6
View File
@@ -4,7 +4,7 @@
"original_series_root": "~/dev/edu/repos/rv/series", "original_series_root": "~/dev/edu/repos/rv/series",
"workspace_root": "~/dev/workspace/stem", "workspace_root": "~/dev/workspace/stem",
"legacy_workspace_root": "~/dev/workspace/rv", "legacy_workspace_root": "~/dev/workspace/rv",
"workspace_info_url": "http://77.90.8.171:3001/edu-workspace/workspace-info.git", "workspace_info_url": "https://zsl-gitea.mpabi.pl/edu/workspace-info.git",
"env_tool_url": "http://77.90.8.171:3001/edu-tools/rv32i-hazard3-student-env.git", "env_tool_url": "http://77.90.8.171:3001/edu-tools/rv32i-hazard3-student-env.git",
"tools_root_candidates": [ "tools_root_candidates": [
"~/dev/workspace/stem/tools/rv32i-hazard3-student-env", "~/dev/workspace/stem/tools/rv32i-hazard3-student-env",
@@ -14,17 +14,17 @@
"~/dev/edu/repos/rv/rv32i-hazard3-env" "~/dev/edu/repos/rv/rv32i-hazard3-env"
], ],
"git": { "git": {
"base_url": "http://77.90.8.171:3001", "base_url": "https://zsl-gitea.mpabi.pl",
"source_org": "edu-inf", "source_org": "edu",
"answer_org": "c2025-1a-inf", "answer_org": "c2025-1a-inf",
"source_remote": "r1", "source_remote": "r1",
"answer_remote": "r1a", "answer_remote": "r1a",
"origin_remote": "origin", "origin_remote": "origin",
"fallback_branch": "build" "fallback_branch": "main"
}, },
"defaults": { "defaults": {
"series": "inf", "series": "freertos-c",
"card": "pointers", "card": "FC02",
"task": "task1", "task": "task1",
"editor": "nvim", "editor": "nvim",
"instance": "shell", "instance": "shell",