diff --git a/doc/series.md b/doc/series.md index b9f56b0..e1eab15 100644 --- a/doc/series.md +++ b/doc/series.md @@ -11,24 +11,30 @@ Komendy kart pracy dzielimy na trzy poziomy: - `series cards` - operacje na kartach w ramach serii - `series cards tasks` - operacje na zadaniach w ramach karty -`rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii -i kart pochodzi docelowo z publicznego manifestu -`~/dev/workspace/rv/meta/workspace-info/workspace.json`. Jeżeli manifestu nie -ma, komendy zależne od serii automatycznie klonują repo -`edu-workspace/workspace-info`. Jeżeli klonowanie nie jest możliwe, narzędzie -używa starego fallbacku przez `original_series_root`. +`stemctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Gdy +`workspace-info` jest dostępny, manifest jest jedynym źródłem listy serii i +kart. Przypadkowe katalogi robocze nie pojawiają się w katalogu. Fallback przez +`original_series_root` działa wyłącznie dla starego workspace bez manifestu. + +Każda operacyjna seria jawnie deklaruje `source_org`. Docelowa konwencja to +`edu-`, 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 ```bash -./rvctl tokens compare -./rvctl series list -./rvctl series use inf -./rvctl series cards list inf -./rvctl card use bss -./rvctl series cards fetch inf bss -./rvctl tasks list -./rvctl tasks switch 1 +./stemctl workspace audit +./stemctl tokens compare +./stemctl series list +./stemctl series use freertos-c +./stemctl series cards list freertos-c +./stemctl card use FC02 +./stemctl series cards fetch freertos-c FC02 +./stemctl tasks list +./stemctl tasks switch 1 ``` 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` -Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już -pobranych do lokalnego workspace. Jeżeli manifestu jeszcze nie ma, komenda -pobiera go automatycznie. Jeżeli `~/dev/workspace/rv/series` nie istnieje, -komenda tworzy ten katalog. +Listuje wyłącznie operacyjne serie z manifestu `workspace-info` oraz liczbę +kart już obecnych w lokalnym workspace. Logiczna seria może wskazywać wspólny, +przejściowy katalog fizyczny przez `workspace_dir`; na przykład `freertos-c` +jest obecnie mapowane na `series/freertos`. ```bash ./rvctl series list @@ -49,8 +55,9 @@ komenda tworzy ten katalog. Typowy wynik: ```text -fiz 3 0 -inf 3 0 +fiz 3 0 +inf 9 5 +freertos-c 11 11 ``` Kolumny oznaczają: seria, liczba kart w źródłach, liczba kart pobranych do diff --git a/doc/stemctl.md b/doc/stemctl.md index c9d441e..bcef9b8 100644 --- a/doc/stemctl.md +++ b/doc/stemctl.md @@ -16,6 +16,23 @@ wybrany kontener └── 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-`. 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**. Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem podpinanym przez launcher. diff --git a/doc/workspace.md b/doc/workspace.md index 35277f6..62ccfbd 100644 --- a/doc/workspace.md +++ b/doc/workspace.md @@ -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 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 -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: ```text -~/dev/workspace/rv/meta/workspace-info +~/dev/workspace/stem/meta/workspace-info ``` Typowy układ: ```text -~/dev/workspace/rv +~/dev/workspace/stem ├── meta │ └── workspace-info ├── tools @@ -38,8 +50,20 @@ Typowy układ: └── series ``` -`workspace-info` opisuje serie, karty, repo źródłowe, repo odpowiedzi i branche. -Nie przechowuje tokenów ani lokalnych plików roboczych ucznia. +`workspace-info` opisuje rejestr organizacji, operacyjne serie, karty, repo +ź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 seria–organizacja oraz zduplikowane identyfikatory kart i repo. +Ostrzeżenie o wspólnym `workspace_dir` jest dopuszczalne podczas migracji. Aktualizacja manifestu: diff --git a/rvctl.py b/rvctl.py index 5f89cab..4976e00 100755 --- a/rvctl.py +++ b/rvctl.py @@ -326,10 +326,130 @@ def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict 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: if workspace_series_manifest(config, series_name) is not None: 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: @@ -358,7 +478,13 @@ def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[ if not manifest: 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)) source_remote = str(manifest.get("source_remote", config.git.source_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() branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch 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( CardInfo( 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]: 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]: - 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]: if not config.original_series_root.is_dir(): 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]: - source_series_path = config.original_series_root / series_name - if not source_series_path.is_dir(): + series_path = source_series_path(config, series_name) + if not series_path.is_dir(): 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: @@ -450,14 +576,18 @@ def card_label_from_repo_name(repo_name: str) -> str: 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) def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo: cards = source_card_infos(config, series_name) 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(): source_hint = config.workspace_info_path 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) if 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: cards = source_card_directories(config, series_name) 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") matches = [] @@ -535,7 +665,7 @@ def resolve_selector( if not series_default_card: raise SystemExit("Missing default card in config.") 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 if default_series: try: @@ -553,7 +683,7 @@ def resolve_selector( 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(): return card_path 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: config.series_root.mkdir(parents=True, exist_ok=True) 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)} 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)): @@ -583,8 +721,15 @@ def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None: config.series_root.mkdir(parents=True, exist_ok=True) source_cards = {card.repo: card for card in source_card_infos(config, series_name)} - workspace_series_path = config.series_root / series_name - workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {} + series_path = workspace_series_path(config, series_name) + 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: 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: - 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: - 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: @@ -3176,8 +3321,8 @@ def switch_card_task( def print_series_details(config: WorkspaceConfig, series_name: str) -> None: config.series_root.mkdir(parents=True, exist_ok=True) - source_path = config.original_series_root / series_name - workspace_path = config.series_root / series_name + source_path = source_series_path(config, series_name) + workspace_path = workspace_series_path(config, series_name) cards = source_card_infos(config, series_name) if not cards and not workspace_path.is_dir(): 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: - names = sorted( - set(workspace_series_manifests(config)) - | {path.name for path in source_series_directories(config)} - | {path.name for path in series_directories(config)} - ) + manifest_names = list(workspace_series_manifests(config)) + if manifest_names: + names = sorted(manifest_names) + else: + names = sorted({path.name for path in source_series_directories(config)} | {path.name for path in series_directories(config)}) return ", ".join(names) @@ -4157,6 +4302,8 @@ SESSION_STATUS_MISSING = "brak" def ordered_series_names(config: WorkspaceConfig) -> list[str]: names = list(workspace_series_manifests(config)) + if names: + return names for path in [*series_directories(config), *source_series_directories(config)]: if not path.name.startswith(".") and path.name not in names: names.append(path.name) @@ -5484,6 +5631,16 @@ def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None: print(f"status\tready") 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": migrated = 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_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True) 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.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.") workspace_migrate_parser = workspace_subparsers.add_parser( diff --git a/tests/test_stemctl.py b/tests/test_stemctl.py index 716d0ac..97c065d 100644 --- a/tests/test_stemctl.py +++ b/tests/test_stemctl.py @@ -175,6 +175,128 @@ class StemctlContractTests(unittest.TestCase): self.assertIn("runtime\tFAIL\tdocker is not supported", 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: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/workspace.json b/workspace.json index 033b4ad..e896628 100644 --- a/workspace.json +++ b/workspace.json @@ -4,7 +4,7 @@ "original_series_root": "~/dev/edu/repos/rv/series", "workspace_root": "~/dev/workspace/stem", "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", "tools_root_candidates": [ "~/dev/workspace/stem/tools/rv32i-hazard3-student-env", @@ -14,17 +14,17 @@ "~/dev/edu/repos/rv/rv32i-hazard3-env" ], "git": { - "base_url": "http://77.90.8.171:3001", - "source_org": "edu-inf", + "base_url": "https://zsl-gitea.mpabi.pl", + "source_org": "edu", "answer_org": "c2025-1a-inf", "source_remote": "r1", "answer_remote": "r1a", "origin_remote": "origin", - "fallback_branch": "build" + "fallback_branch": "main" }, "defaults": { - "series": "inf", - "card": "pointers", + "series": "freertos-c", + "card": "FC02", "task": "task1", "editor": "nvim", "instance": "shell",