diff --git a/README.md b/README.md index f2b879f..38ddfb4 100644 --- a/README.md +++ b/README.md @@ -1,495 +1,139 @@ -# RV Launcher +# STEM Launcher -Repo `rv-launcher` zawiera narzędzie do pracy ze wspólnym workspace EDU. -W pierwszej kolejności służy ono do zarządzania tokenami; pozostałe funkcje -obejmują pracę z kartami pracy i uruchamianie środowiska kontenerowego. +`stemctl` jest hostowym wejściem do kart pracy, Git i trzech źródłowo +budowanych kontenerów STEM. Historyczna nazwa zdalnego repo może nadal brzmieć +`rv-launcher`; `rvctl` pozostaje ostrzegającym wrapperem zgodności. -Właściwym skryptem CLI jest wykonywalny plik `rvctl.py`. To on implementuje -zarządzanie tokenami, listowanie serii i kart, przygotowanie repo odpowiedzi i -start kontenera. +Launcher nie pobiera i nie publikuje gotowych obrazów środowiska. Klonuje repo +narzędzi zawierające `Dockerfile` i `docker-compose.yml`, a brakujący profil +buduje lokalnie rootless Podmanem. Tag `localhost/stem/...:local` jest tylko +lokalnym wpisem cache runtime. -Publicznym entrypointem dla użytkownika jest krótki wrapper `rvctl`: +## Model + +| Profil | Targety | Zastosowanie | +| --- | --- | --- | +| `native-amd64` | `native` | C/C++, golden tests, ASan/UBSan, GDB | +| `hazard3-sim` | `hazard3-baremetal`; `hazard3-freertos` po BSP timer/IRQ | RTL Hazard3/Verilator bez płytki | +| `rp2350` | `rp2350-rv`, `rp2350-arm` | Pico 2/2 W, FreeRTOS, OpenOCD/picotool | + +W każdym profilu interfejs pozostaje taki sam: tmux z prefixem `Ctrl-s`, +Neovim, Termdebug, nvim-dap oraz MCP tmuxa i Neovima. Kod karty, wyniki i +artefakty są bind-mountem na hoście. Kontenery nie dostają kluczy SSH, tokenów, +socketu Podmana/Dockera ani całego `/dev`. + +## Szybki start ```bash -./rvctl +./stemctl workspace sync +./stemctl series list +./stemctl series cards fetch inf bss + +./stemctl test native-amd64 inf bss 1 +./stemctl test hazard3-sim inf bss 1 +./stemctl debug hazard3-sim inf bss 1 + +./stemctl probe list +./stemctl deploy rp2350 inf bss 1 \ + --target rp2350-rv --device /dev/bus/usb/001/006 ``` -Wrapper uruchamia: +Pierwsze wywołanie danego profilu może potrwać, ponieważ buduje go ze +źródłowego Dockerfile. Kolejne korzystają z lokalnych warstw cache. + +Wymuszenie samego przygotowania środowiska: ```bash -python3 rvctl.py "$@" +./stemctl env sync +./stemctl env build native-amd64 +./stemctl env build hazard3-sim +./stemctl env build rp2350 ``` -`rvctl.py` można też uruchomić bezpośrednio: +`env sync` klonuje lub aktualizuje +`edu-tools/rv32i-hazard3-student-env`. Repo środowiska zawiera źródłowy +Dockerfile i pliki Compose. -```bash -./rvctl.py -``` +## Workspace -Lokalne ścieżki i domyślne ustawienia są trzymane w `workspace.json`. -Publiczny opis wspólnego workspace, czyli serie, karty i wersje repozytoriów, -jest w osobnym repo `edu-workspace/workspace-info`. - -## Co robi narzędzie - -`rvctl` porządkuje pracę w trzech obszarach. - -1. Zarządzanie tokenami - - Narzędzie obsługuje lokalny store `tokens/tokens.json`, porównuje go z git - remotes i potrafi synchronizować token w obie strony. Szczegóły modelu, - format pliku i opis komend są w `doc/tokens.md`. - -2. Listowanie i pobieranie kart pracy - - `rvctl` czyta serie i karty z `meta/workspace-info`, zadania z pobranych - kart, pomaga wybrać materiał do pracy oraz przygotowuje remotes potrzebne do - repo odpowiedzi. Docelowy model namespace `series`, `cards` i `tasks` jest w - `doc/series.md`. - -3. Uruchamianie środowiska programistycznego w kontenerach - - Dla wybranej karty i zadania `rvctl` uruchamia osobne profile kontenerów: - `rv32i` dla Hazard3/RISC-V oraz `host` dla natywnego debugowania C. Oba - profile opierają się na `nvim`, `tmux` i debuggerze. Model komend jest w - `doc/containers.md`. - -## Model katalogów - -Podstawowym miejscem pracy użytkownika jest workspace: - -```bash -~/dev/workspace/rv -``` - -Najpierw utwórz tylko katalog bazowy workspace i wejdź do niego: - -```bash -mkdir -p ~/dev/workspace/rv -cd ~/dev/workspace/rv -``` - -Po tym kroku workspace istnieje, ale nie ma jeszcze katalogów narzędzi, -tokenów ani kart: +Domyślny układ: ```text -~/dev/workspace/rv +~/dev/workspace/stem/ +├── meta/workspace-info/ +├── series/// +├── tools/ +│ ├── stem-launcher/ +│ └── rv32i-hazard3-student-env/ +└── tokens/tokens.json ``` -Potem wybierz jedną z dwóch dróg startu. - -### Bez `tokens.json` - -Ten wariant jest dla sytuacji, w której token jest podany w URL-u git remota, -a plik `tokens.json` ma powstać dopiero lokalnie. - -Etap 1: utwórz katalog launchera i wejdź do niego: +Jeżeli istnieje tylko starszy `~/dev/workspace/rv`, launcher wykrywa go bez +niszczenia danych. Jawna migracja: ```bash -mkdir -p ~/dev/workspace/rv/tools/rv-launcher -cd ~/dev/workspace/rv/tools/rv-launcher +./stemctl workspace migrate +./stemctl workspace doctor ``` -Etap 2: utwórz puste repo, dodaj remote `r1` z tokenem, pobierz branch i -ustaw lokalny `main`: +## Akcje i tożsamość instancji -```bash -git init -git remote add r1 http://u1:TOKEN@77.90.8.171:3001/edu-tools/rv-launcher.git -git fetch r1 main -git switch --track -c main r1/main -``` - -`r1` jest nazwą remota, z którego startujemy. `--track` ustawia lokalny branch -`main` tak, aby śledził `r1/main`, dzięki czemu późniejsze `git pull` i -`git push` wiedzą, z którym branchem zdalnym pracują. - -Po tym kroku w workspace jest już repo launchera: +Jedna logiczna instancja zachowuje nazwę między `build`, `test`, `run`, +`debug` i `attach`. Bieżący ID kontenera wchodzi natomiast do krótkiej ścieżki +socketu: ```text -~/dev/workspace/rv -└── tools - └── rv-launcher +$XDG_RUNTIME_DIR/stem////t.sock +$XDG_RUNTIME_DIR/stem////n.sock ``` -Repo `workspace-info` nie jest częścią `tools`. `rvctl` pobierze je -automatycznie do `meta/workspace-info` przy pierwszej komendzie zależnej od -manifestu, na przykład `series list`. +Resolver wykonuje `podman inspect` przed połączeniem i odrzuca osierocony +socket. Pozwala to jednemu Codexowi obsługiwać kilka kontenerów bez kolizji. -Etap 3: wczytaj token z remota do lokalnego store: +Stabilne wejścia MCP wymagają jawnej instancji i uruchamiają serwer znajdujący +się w wybranym kontenerze: ```bash -cd ~/dev/workspace/rv/tools/rv-launcher -./rvctl tokens sync remote r1 -./rvctl tokens update r1 +./stemctl mcp tmux hazard3-sim-inf-bss-t1 +./stemctl mcp nvim hazard3-sim-inf-bss-t1 ``` -`tokens sync remote r1` przepisuje token z URL-a remota `r1` do lokalnego -store. `tokens update r1` odpytuje Gitea API i uzupełnia metadane tokena: -ważność, zakresy oraz uprawnienia w organizacji i repo. +Oddzielne wpisy klienta MCP mogą wskazywać inne nazwy instancji, więc jeden +Codex obsługuje kilka kontenerów bez globalnego „ostatniego socketu”. -`rvctl` sam tworzy katalog `~/dev/workspace/rv/tokens`, jeżeli jeszcze go nie -ma. Plik `tokens.json` dostaje prawa `600`, a katalog store prawa `700`. +## Komputer zdalny -Po tym kroku workspace ma już `tokens.json`: +Na komputer ucznia wchodzimy wyłącznie SSH z parą kluczy. Launcher, Git, +rootless Podman i sockety działają na tym komputerze: + +```bash +ssh uczen-lab +cd ~/dev/workspace/stem/tools/stem-launcher +./stemctl status hazard3-sim --instance lekcja-1 +./stemctl debug hazard3-sim inf bss 1 --instance lekcja-1 +``` + +Nie kopiujemy prywatnego klucza do kontenera. Jeżeli potrzebny jest zdalny +GDB/MCP, używamy jawnego tunelu SSH albo wykonujemy klienta po stronie zdalnej. + +## Zgodność + +Aliasy nadal działają: ```text -~/dev/workspace/rv -├── tools -│ └── rv-launcher -└── tokens - └── tokens.json +host -> native-amd64 +rv32i -> hazard3-sim +rvctl -> stemctl +RV_* -> fallback dla STEM_* ``` -Etap 4: sprawdź, czy git remote i lokalny store widzą ten sam token: - -```bash -./rvctl tokens compare -``` - -Przykładowy wydruk: - -```text -tokens -item server proto host org repo user remote token_ref token valid scope org repo ----- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------- --------- ------ ------ -1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 * e59cc...13be forever wwwwwwwww +++++ ++++ -``` - -Najważniejsze pola: - -- `remote` - nazwa git remota, tutaj `r1` -- `token_ref` - lokalna nazwa tokena i marker zgodności po prawej stronie -- `*` - token w remote i w `tokens.json` jest zgodny -- `S` - token jest tylko w `tokens.json`; to jest normalne w trybie store-only -- `R` - token jest tylko w git remote -- `token` - zamaskowany sekret; `rvctl` nie wypisuje całego tokena -- `valid`, `scope`, `org`, `repo` - metadane i uprawnienia pobrane przez - `tokens update` - -Pełny opis tabeli tokenów jest w `doc/tokens.md`. - -### Z `tokens.json` - -Ten wariant jest dla sytuacji, w której masz już gotowy plik `tokens.json`. - -Etap 1: skopiuj token store do workspace: - -```bash -mkdir -p ~/dev/workspace/rv/tokens -cd ~/dev/workspace/rv -cp /ścieżka/do/tokens.json tokens/tokens.json -chmod 600 tokens/tokens.json -``` - -Po tym kroku workspace ma store tokenów, ale nie ma jeszcze launchera: - -```text -~/dev/workspace/rv -└── tokens - └── tokens.json -``` - -Etap 2: wejdź do katalogu narzędzi i sklonuj `rv-launcher`: - -```bash -mkdir -p ~/dev/workspace/rv/tools -cd ~/dev/workspace/rv/tools -git clone http://77.90.8.171:3001/edu-tools/rv-launcher.git -cd rv-launcher -``` - -Po `git clone` workspace wygląda tak: - -```text -~/dev/workspace/rv -├── tools -│ └── rv-launcher -└── tokens - └── tokens.json -``` - -Etap 3: sprawdź store i wpisz credentials ze store do remota `r1`: - -```bash -./rvctl tokens list store -./rvctl tokens sync store r1 -``` - -`tokens list store` sprawdza, czy skopiowany `tokens.json` zawiera wpis dla -remota `r1`. `tokens sync store r1` bierze token ze store i wpisuje credentials -do git remota `r1`, tak aby kolejne operacje Git mogły używać tego tokena. -Jeżeli po `git clone` repo ma tylko remote `origin` wskazujący to samo repo, -`rvctl` automatycznie przemianuje go na `r1`. - -`list store` powinien pokazać token ze store: - -```text -tokens -item source kind server proto host org repo user remote token valid ----- ------ ---- ------ ----- ---------------- --------- ----------- ---- ------ ------------ ------- -1 store auth gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 e59cc...13be forever -``` - -`sync store r1` wpisuje credentials do git remota: - -```text -repo_root /home/user/dev/workspace/rv/tools/rv-launcher -remote r1 -renamed_from origin -server http://77.90.8.171:3001 -user u1 -id r1 -status updated -url http://77.90.8.171:3001/edu-tools/rv-launcher.git -``` - -Etap 4: odśwież metadane tokena i porównaj store z remote: - -```bash -./rvctl tokens update r1 -./rvctl tokens compare -``` - -`tokens update r1` odpytuje Gitea API dla tokena ze store i odświeża jego -metadane: ważność, zakresy oraz uprawnienia w organizacji i repo. `tokens -compare` sprawdza potem, czy store i git remote wskazują ten sam token. - -`compare` powinien pokazać `*` przy `r1`, jeżeli remote i `tokens.json` są -zgodne: - -```text -tokens -item server proto host org repo user remote token_ref token valid ----- ------ ----- ---------------- --------- ----------- ---- ------ --------- ------------ ------- -1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 * e59cc...13be forever -``` - -Etap 5: po operacjach Git możesz usunąć sekret z `.git/config`, zostawiając -token tylko w store: - -```bash -./rvctl tokens rm remote r1 -./rvctl tokens compare -``` - -Po usunięciu remota `compare` pokazuje marker `S`, czyli token jest tylko w -store: - -```text -tokens -item server proto host org repo user remote token_ref token valid ----- ------ ----- ---------------- --------- ----------- ---- ------ --------- ------------ ------- -1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 S e59cc...13be forever -``` - -## Kolejny krok: serie i karty pracy - -Po konfiguracji tokenów następnym elementem jest publiczny manifest wspólnego -workspace. `rvctl` szuka go lokalnie w: - -```text -~/dev/workspace/rv/meta/workspace-info -``` - -Manifest zawiera listę serii, kart, repozytoriów źródłowych, repozytoriów -odpowiedzi i branchy. Nie zawiera tokenów ani lokalnych plików ucznia. - -Nie musisz pobierać go ręcznie. Pierwsza komenda zależna od manifestu, na -przykład `series list`, automatycznie sklonuje repo -`edu-workspace/workspace-info`, jeżeli lokalnej kopii jeszcze nie ma: - -```bash -./rvctl series list -``` - -Jeżeli chcesz jawnie odświeżyć istniejący manifest, użyj: - -```bash -./rvctl workspace sync -``` - -Przed pobieraniem kart warto jeszcze sprawdzić stan tokenów: - -```bash -./rvctl tokens compare -``` - -Przykładowy wynik: - -```text -fiz 3 0 -inf 3 0 -``` - -Po wybraniu serii `inf` wylistuj karty: - -```bash -./rvctl series use inf -./rvctl series cards list inf -``` - -Przykładowy wynik zawiera krótką nazwę karty, pełną nazwę repo, status i tytuł: - -```text -bss lab-rv32i-strlen-bss-data-stack source rv32i-c / bss-data-stack -``` - -Przykładowa karta `bss` odpowiada repo `lab-rv32i-strlen-bss-data-stack`. -Ustaw ją jako domyślną i pobierz do workspace: - -```bash -./rvctl card use bss -./rvctl series cards fetch inf bss -``` - -Jeżeli chcesz jedną komendą ustawić serię i kartę, użyj: - -```bash -./rvctl card use inf bss -``` - -Karty trafiają do `series` w workspace: - -```text -~/dev/workspace/rv -├── meta -│ └── workspace-info -├── tools -│ └── rv-launcher -├── tokens -│ └── tokens.json -└── series - └── inf - └── lab-rv32i-strlen-bss-data-stack -``` - -`rvctl` pracuje na kartach z `series_root`, czyli na katalogu -`~/dev/workspace/rv/series`. - -Po pobraniu karty `rvctl` przygotowuje też remote odpowiedzi `r1a`. Remote -powstaje z tokena `r1` i wskazuje na repo pracy: - -```text -answer_remote r1a -answer_org c2025-1a-inf -answer_repo lab-rv32i-strlen-bss-data-stack -answer_url http://77.90.8.171:3001/c2025-1a-inf/lab-rv32i-strlen-bss-data-stack.git -``` - -Następnie wylistuj zadania w karcie. Skrót `tasks` działa na domyślnej serii i -karcie ustawionej w `workspace.json`, czyli tutaj na `inf bss`: - -```bash -./rvctl tasks list -``` - -Przykładowy wynik: - -```text -inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.c -inf bss task2_data ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task2_data.c -inf bss task3_stack_unused ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task3_stack_unused.c -inf bss task4_stack_strlen ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task4_stack_strlen.c -``` - -Przełącz się na wybrane zadanie. `rvctl` bierze login ucznia ze store tokenów, -tworzy branch pracy w formacie `Ta` i ustawia mu upstream -na `r1a/`: - -```bash -./rvctl tasks switch 4 -``` - -Dla użytkownika `u1` i zadania `task4_stack_strlen` branch będzie miał nazwę: - -```text -u1T4a -``` - -## Kolejny krok: kontenery i debugowanie - -Kontenery są potrzebne dopiero po pobraniu karty pracy, kiedy chcesz uruchomić -albo debugować konkretne zadanie. `rvctl` korzysta z narzędzia -`rv32i-hazard3-student-env`, które trafia do workspace pod: - -```text -~/dev/workspace/rv/tools/rv32i-hazard3-student-env -``` - -Najpierw sprawdź dostępne profile i pobierz albo odśwież repo środowiska: - -```bash -./rvctl env list -./rvctl env sync -``` - -Po synchronizacji workspace ma dodatkowe narzędzie pod `tools`: - -```text -~/dev/workspace/rv -├── meta -│ └── workspace-info -├── tools -│ ├── rv-launcher -│ └── rv32i-hazard3-student-env -├── tokens -│ └── tokens.json -└── series - └── inf - └── lab-rv32i-strlen-bss-data-stack -``` - -Środowisko ma dwa profile: - -- `rv32i` - Hazard3/RISC-V, `gdb-multiarch`, `nvim` i `tmux`, -- `host` - natywne uruchamianie i debugowanie C przez `clang` albo `gcc`, - `gdb`, `nvim` i `tmux`. - -Zbuduj potrzebne obrazy: - -```bash -./rvctl env build rv32i -./rvctl env build host -``` - -Dla pobranej karty `inf bss` możesz uruchomić debugowanie w obu profilach: - -```bash -./rvctl debug rv32i 4 -./rvctl debug host 4 -``` - -Jeżeli pracujesz już w tmuksie na hoście, możesz wysłać start kontenera do -konkretnego panelu: - -```bash -./rvctl debug rv32i 4 pane 0 -./rvctl debug rv32i 4 --pane node:0 -``` - -`pane 0` oznacza panel `0` w bieżącym oknie tmuxa. `node:0` oznacza okno -`node`, panel `0`. - -Profil `host` ma też szybkie uruchomienie bez sesji debuggera: - -```bash -./rvctl run host 4 -``` - -Szczegóły komend, warianty `--dry-run`, `--instance`, `--editor` i ograniczenia -profili są w `doc/containers.md`. +Nowe materiały powinny używać nazw kanonicznych. ## Dokumentacja -- `doc/rvctl.md` - techniczna dokumentacja CLI: wszystkie komendy, argumenty - i przełączniki -- `doc/tokens.md` - model tokenów, format `tokens/tokens.json` i - synchronizacja remote <-> store -- `doc/series.md` - docelowy model komend dla serii i kart pracy -- `doc/containers.md` - docelowy model komend dla środowisk kontenerowych -- `doc/agents.md` - wnioski i plan późniejszej integracji agentów z kontenerami -- `doc/workspace.md` - krótki opis konfiguracji workspace - -README jest tylko mapą projektu. Szczegóły operacyjne trzymamy w `doc/`, żeby -nie dublować instrukcji w kilku miejscach. +- [plan architektury](doc/architecture-plan.md) +- [kontenery i interfejs](doc/containers.md) +- [migracja nazw i workspace](doc/migration-stem-launcher.md) +- [serie i karty](doc/series.md) +- [tokeny Gitea](doc/tokens.md) +- [przegląd Claude](doc/review-claude-2026-07-14.md) diff --git a/doc/agents.md b/doc/agents.md index 5f7b41f..2a103ea 100644 --- a/doc/agents.md +++ b/doc/agents.md @@ -1,8 +1,8 @@ # Agenci w środowisku kontenerowym -Ten dokument zapisuje wnioski do przyszłej integracji agentów, takich jak -Codex, Gemini i inne narzędzia asystujące. Integrację robimy po domknięciu -kontenerów `rv32i` i `host`. +Ten dokument zapisuje wnioski do integracji agentów, takich jak Codex, Claude, +Gemini i inne narzędzia asystujące. Docelowo integracja obejmuje trzy profile: +`native-amd64`, `hazard3-sim` i `rp2350`. ## Założenie @@ -13,39 +13,40 @@ mieć wgląd w tę samą sesję, w której pracuje uczeń: - `nvim` - edycja plików i nawigacja po kodzie, - `gdb` lub `gdb-multiarch` - stan debuggera, - katalog karty pracy zamontowany w kontenerze, -- stan sesji zapisany pod `.rv//`. +- stan sesji zapisany pod `.stem/instances//`. Dzięki temu agent widzi środowisko debugowania, a nie tylko statyczne pliki. ## Aktualny fundament -`rv32i-hazard3-student-env` ma już elementy potrzebne do takiego modelu: +`rv32i-hazard3-student-env` dostarcza źródła wspólnego modelu: -- profil `rv32i` jako usługa `env` w `docker compose`, -- profil `host` jako osobna usługa `host`, +- profile `native-amd64`, `hazard3-sim` i `rp2350` jako trzy usługi Compose; +- jeden wieloetapowy Dockerfile budowany lokalnie, bez dystrybucji obrazów; - `tmux` jako warstwa sesji terminalowej, - `nvim` uruchamiany ze stabilnym socketem, - `gdb-multiarch` dla profilu `rv32i`, - `gdb` i opcjonalnie `lldb` dla profilu `host`, -- katalog stanu `.rv//`, +- katalog stanu `.stem/instances//`, z fallbackiem `.rv`; - skrypty MCP dla `tmux` i `nvim`: - `scripts/mcp-tmux.sh`, - `scripts/mcp-nvim.sh`, - `scripts/nvim-in-container.sh`. -To oznacza, że kontenery są dobrym miejscem do podłączenia agentów. Brakuje -jeszcze spójnej warstwy komend w `rvctl`. +Serwery MCP są instalowane w kontenerze, a hostowe wrappery weryfikują label i +bieżący ID Podmana przed `podman exec`. `stemctl` oraz wspólny kontrakt trzech +profili są wdrożone; osobne komendy wyższego poziomu `agent start/attach` +pozostają rozszerzeniem późniejszym. ## Docelowy model komend -Docelowo `rvctl` powinien ukrywać szczegóły socketów, kontenerów i providerów. +Docelowo `stemctl` powinien ukrywać szczegóły socketów, kontenerów i providerów. Przykładowy kierunek: ```bash -./rvctl agent start codex rv32i inf bss 4 -./rvctl agent start codex host inf bss 4 -./rvctl agent start gemini rv32i inf bss 4 -./rvctl agent start gemini host inf bss 4 +./stemctl agent start codex native-amd64 inf bss 4 +./stemctl agent start codex hazard3-sim inf bss 4 +./stemctl agent start codex rp2350 inf bss 4 --target rp2350-rv ``` Skróty mogą powstać później, ale podstawowy model powinien zostać jawny: @@ -54,28 +55,27 @@ agent, profil środowiska, seria, karta i zadanie. Możliwy wariant dla już uruchomionej sesji: ```bash -./rvctl agent attach codex --instance rv32i-inf-bss-t4-debug -./rvctl agent attach gemini --instance host-inf-bss-t4-debug +./stemctl agent attach codex --instance hazard3-sim-inf-bss-t4 ``` -## Co powinien robić `rvctl` +## Co powinien robić `stemctl` Przy `agent start` narzędzie powinno: 1. rozwiązać serię, kartę i zadanie tak samo jak `debug`, -2. wybrać profil `rv32i` albo `host`, +2. wybrać jeden z trzech profili i właściwy target, 3. nadać stabilną nazwę instancji, na przykład - `rv32i-inf-bss-t4-debug`, + `hazard3-sim-inf-bss-t4`, 4. uruchomić kontener i sesję `tmux`, 5. włączyć tryb agentowy przez zmienne środowiskowe, na przykład: ```text -RV_AGENT=codex -RV_CODEX=1 -RV_INSTANCE=rv32i-inf-bss-t4-debug +STEM_AGENT=codex +STEM_MCP=1 +STEM_INSTANCE=hazard3-sim-inf-bss-t4 ``` -6. zapisać albo odczytać sockety z `.rv//`, +6. rozwiązać bieżący container ID i sockety z katalogu instancji, 7. uruchomić bridge MCP dla `tmux` i `nvim`, 8. przekazać agentowi minimalny kontekst: - ścieżka repo karty, @@ -87,51 +87,60 @@ RV_INSTANCE=rv32i-inf-bss-t4-debug ## Sockety i stan sesji -Dla każdej instancji powinniśmy konsekwentnie używać katalogu: +Dla każdej instancji używamy dwóch poziomów tożsamości: ```text -/.rv// +$XDG_RUNTIME_DIR/stem//// ``` W nim mogą znajdować się: ```text -tmux.sock -nvim.sock +t.sock +n.sock gdb-sync.json -vim-mcp.sock -vim-mcp-registry.json +container.json ``` -`rvctl` powinien traktować te pliki jako szczegóły implementacyjne. Użytkownik -i agent powinni dostawać komendy wyższego poziomu. +Pełna nazwa instancji pozostaje w label i registry. Krótkie klucze oraz +12-znakowy prefiks container ID utrzymują ścieżkę AF_UNIX poniżej 100 bajtów. +Aktualny ID uniemożliwia użycie socketu pozostałego po odtworzeniu kontenera. +`stemctl` traktuje te pliki jako szczegóły implementacyjne. Użytkownik i agent +dostają komendy wyższego poziomu. ## Role profili -Profil `rv32i`: +Profil `hazard3-sim`: - debugowanie kodu dla RISC-V/Hazard3, - `gdb-multiarch`, - symulator, - przykłady asemblerowe i mieszane C/ASM. -Profil `host`: +Profil `native-amd64`: - natywne uruchomienie i debugowanie kodu C, - szybkie testowanie algorytmów, - `clang` albo `gcc`, - `gdb`, opcjonalnie `lldb` i `valgrind`. -Taski czysto asemblerowe pozostają w profilu `rv32i`. +Profil `rp2350`: + +- debugowanie fizycznego Pico 2/Pico 2 W; +- targety RISC-V Hazard3 i ARM Cortex-M33; +- OpenOCD, probe, flash, serial i FreeRTOS; +- dostęp tylko do jawnie wybranego urządzenia USB. + +Taski czysto asemblerowe RISC-V pozostają w `hazard3-sim` albo `rp2350-rv`. ## Kolejność wdrożenia -1. Domknąć komendy kontenerowe `env`, `debug`, `run` i `shell`. -2. Ustabilizować nazwy instancji i katalog `.rv//`. -3. Opisać kontrakt socketów dla `tmux` i `nvim`. -4. Dodać `rvctl agent list`. -5. Dodać `rvctl agent start`. -6. Dodać `rvctl agent attach`. +1. Domknąć trzy profile i komendy `build`, `test`, `run`, `debug`, `deploy`. +2. Ustabilizować labels instancji i katalogi socketów z container ID. +3. Zaimplementować resolver socketów dla `tmux` i `nvim`. +4. Dodać `stemctl agent list`. +5. Dodać `stemctl agent start`. +6. Dodać `stemctl agent attach`. 7. Dopiero potem podpinać konkretne providery: Codex, Gemini i kolejne. ## Zasada projektowa diff --git a/doc/architecture-plan.md b/doc/architecture-plan.md new file mode 100644 index 0000000..096fce1 --- /dev/null +++ b/doc/architecture-plan.md @@ -0,0 +1,488 @@ +# Plan architektury STEM Launcher i środowisk wykonawczych + +Status: wdrożony fundament P0/P1; `fast-dm`, BSP FreeRTOS symulatora i pełne +bramki sprzętowe pozostają kolejnymi etapami +Data: 2026-07-14 + +Role w procesie: + +- Codex — architekt i wykonawca planu; +- Claude — senior reviewer; +- użytkownik — właściciel produktu i programu nauczania. + +## 1. Decyzja architektoniczna + +System składa się z jednego launchera uruchamianego na hoście, jednego wspólnego +stage'a bazowego Dockerfile oraz dokładnie trzech kontenerów roboczych: + +| Profil launchera | Target Dockerfile | Rola | +| --- | --- | --- | +| `native-amd64` | `native-amd64-final` | szybkie testy algorytmów C/C++ na AMD64 | +| `hazard3-sim` | `hazard3-sim-final` | RV32 bare metal i FreeRTOS na rzeczywistym RTL Hazard3 w Verilatorze | +| `rp2350` | `rp2350-final` | fizyczny Pico 2/Pico 2 W: RISC-V Hazard3 i ARM Cortex-M33 | + +Stage `dev-ui-base` jest wspólną warstwą budowania, a nie czwartym stale +uruchomionym kontenerem. Hoverboard nie otrzymuje teraz osobnego kontenera. +Dodamy go dopiero po ustaleniu mikrokontrolera, BSP i interfejsu probe. + +Nie tworzymy osobnych kontenerów dla: + +- bare metal i FreeRTOS; +- RP2350 RISC-V i RP2350 ARM; +- C i C++/STL; +- Termdebug, nvim-dap, tmuxa ani MCP. + +Są to targety, tryby albo narzędzia wewnątrz jednego z trzech profili. + +## 2. Granice odpowiedzialności + +### Host i `stem-launcher` + +Launcher działa jako zwykły użytkownik hosta i odpowiada za: + +1. synchronizację publicznego manifestu serii; +2. listowanie serii, kart i zadań; +3. pobieranie kodu karty z Git do workspace na hoście; +4. przygotowanie repo odpowiedzi, brancha ucznia i remotes; +5. wybór profilu oraz targetu; +6. lokalny build właściwego targetu ze źródłowego Dockerfile albo użycie jego + lokalnego cache; +7. utworzenie, wznowienie, zatrzymanie i usunięcie kontenera; +8. wywołanie `build`, `test`, `run`, `debug` i `deploy`; +9. rejestrację artefaktów, logów, wyników i socketów MCP; +10. wykrywanie probe i przekazanie wyłącznie potrzebnego urządzenia do profilu + `rp2350`. + +Klucze SSH, tokeny Gitea i konfiguracja Git pozostają na hoście. Prywatnego +klucza SSH nie montujemy do kontenera. Kontenery nie otrzymują socketu Podmana +ani Dockera i nie zarządzają innymi kontenerami. + +### Kontener roboczy + +Kontener odpowiada wyłącznie za powtarzalne środowisko kompilacji, +uruchamiania i debugowania. Otrzymuje: + +- repo karty pod `/workspace` jako bind mount; +- katalog stanu instancji pod `/workspace/.stem/instances/`; +- deklarację targetu i akcji; +- opcjonalnie konkretne urządzenie USB dla RP2350; +- katalog wymiany socketów MCP widoczny również na hoście. + +Kod źródłowy i artefakty pozostają na hoście. Usunięcie kontenera nie usuwa +pracy ucznia. + +## 3. Przepływ ucznia + +Docelowy przepływ jest jeden dla domu, pracowni i zdalnego komputera: + +```bash +stemctl workspace sync +stemctl series list +stemctl series cards fetch inf bss + +stemctl env ensure native-amd64 +stemctl test native-amd64 +stemctl run native-amd64 + +stemctl env ensure hazard3-sim +stemctl test hazard3-sim +stemctl debug hazard3-sim + +stemctl env ensure rp2350 +stemctl probe list +stemctl deploy rp2350 inf bss 1 --target rp2350-rv --device /dev/bus/usb/001/006 +stemctl debug rp2350 inf bss 1 --target rp2350-rv --device /dev/bus/usb/001/006 +``` + +Pełne selektory pozostają dostępne, na przykład: + +```bash +stemctl test hazard3-sim inf bss 1 +stemctl deploy rp2350 inf bss 1 --target rp2350-arm +``` + +Launcher wykonuje następujący pipeline: + +```text +workspace-info + │ + ▼ +wybór serii/karty/zadania + │ + ▼ +clone/fetch Git na hoście ──► branch/remotes odpowiedzi + │ + ▼ +wybór profilu i targetu + │ + ▼ +rootless Podman + bind mount repo + │ + ├──► build ─► test ─► run/debug + │ + └──► artefakt ─► deploy, tylko gdy target to obsługuje +``` + +### Znaczenie akcji + +| Akcja | `native-amd64` | `hazard3-sim` | `rp2350` | +| --- | --- | --- | --- | +| `build` | binarka ELF hosta | ELF/BIN RV32 | ELF/UF2 dla RV albo ARM | +| `test` | golden tests, ASan/UBSan | te same wektory, RV32/RTL | testy na płytce, gdy karta je deklaruje | +| `run` | proces natywny | program w Verilatorze | uruchomienie wcześniej wgranego programu i serial/RTT | +| `debug` | GDB/LLDB | obecnie `fast-rsp`, docelowo `fast-dm`, kontrolnie JTAG | GDB przez OpenOCD/probe | +| `deploy` | nieobsługiwane | nieobsługiwane; artefakt ładuje `run`/`debug` | flash przez probe | + +Komenda nie może udawać sukcesu dla niewspieranej możliwości. Launcher czyta +capabilities profilu i zwraca czytelny komunikat, na przykład +`deploy is not supported by native-amd64`. + +`run rp2350` nie wykonuje ukrytego flashowania. Wymaga rekordu, że wybrany +artefakt został wcześniej wdrożony na wybraną płytkę; w przeciwnym razie +odmawia startu albo wymaga jawnego `--allow-existing-firmware`. Artefakt +powinien zawierać build ID możliwy do potwierdzenia przez UART/RTT lub metadata +RP2350, aby nie testować przypadkiem starego firmware. + +## 4. Kontrakt karty pracy + +Karta nie może zależeć od zaszytych w launcherze nazw plików. Jej wersjonowany +manifest powinien deklarować co najmniej: + +```yaml +schema: 1 +card: inf/bss +default_task: task1 +profiles: + native-amd64: + default_target: native + hazard3-sim: + default_target: hazard3-baremetal + rp2350: + default_target: rp2350-rv +targets: + native: + profile: native-amd64 + actions: [build, test, run, debug] + hazard3-baremetal: + profile: hazard3-sim + runtime: baremetal + actions: [build, test, run, debug] + hazard3-freertos: + profile: hazard3-sim + runtime: freertos + tick_hz: 1000 + actions: [build, test, run, debug] + rp2350-rv: + profile: rp2350 + arch: riscv + actions: [build, test, run, debug, deploy] + requires: + debug: [probe] + deploy: [probe] + rp2350-arm: + profile: rp2350 + arch: arm + actions: [build, test, run, debug, deploy] + requires: + debug: [probe] + deploy: [probe] +actions: + build: ./tools/card build + test: ./tools/card test + run: ./tools/card run + debug: ./tools/card debug + deploy: ./tools/card deploy +artifacts: + directory: .stem/artifacts +``` + +Nazwa pliku manifestu i dokładny schemat zostaną zatwierdzone w implementacji. +Ważny jest podział: `workspace-info` mówi, skąd pobrać kartę, manifest karty +mówi, jak ją wykonać, a profil środowiska dostarcza narzędzia. + +Każda akcja zapisuje maszynowo czytelny wynik do: + +```text +.stem/results/////result.json +``` + +Wynik zawiera co najmniej commit karty, lokalny identyfikator wyniku builda, +wersję toolchainu, +polecenie, kod wyjścia, czas, testy i skróty artefaktów. Dzięki temu porównanie +AMD64 → Hazard3 → RP2350 nie opiera się na ręcznym oglądaniu terminala. + +## 5. Trzy kontenery + +### 5.1 `native-amd64` + +Cel: najszybsza pętla dla algorytmów z podręcznika, matury i ZPE. + +Narzędzia specyficzne: + +- GCC i Clang; +- GDB i opcjonalnie LLDB; +- ASan, UBSan, coverage i Valgrind; +- CMake, Ninja i Make; +- testy jednostkowe C/C++. + +Profil nie zawiera SDK RP2350 ani Verilatora. Testuje algorytm i kontrakt API, +nie emuluje zachowania 32-bitowego MCU. Testy przenośności muszą dodatkowo +przejść w `hazard3-sim`. + +### 5.2 `hazard3-sim` + +Cel: praca domowa bez płytki i dydaktyczny wgląd w procesor. + +Narzędzia specyficzne: + +- przypięty toolchain `riscv64-unknown-elf`; +- przypięty Verilator; +- Hazard3 RTL oraz testbench; +- runtime bare-metal i osobny BSP symulatora dla FreeRTOS; +- szybki RSP oraz planowany transport `fast-dm` do wbudowanego DM Hazard3; +- OpenOCD/remote-bitbang wyłącznie jako okresowy gate zgodności; +- opcjonalne VCD i narzędzia do zajęć z Veriloga. + +Dla FreeRTOS 1 kHz timer symulatora zwiększa `mtime` dokładnie raz na cykl. +Przy deklarowanym zegarze 150 MHz tick przypada co 150 000 cykli RTL. Jest to +1 kHz czasu gościa, nie obietnica czasu rzeczywistego hosta. Kod aplikacyjny i +wektory testowe są wspólne z RP2350, natomiast BSP, startup i linker są osobne. + +Bieżący port FreeRTOS RP2350 ustawia `SIO MTIME` w tryb `FULLSPEED`, dlatego +przy zegarze systemowym 150 MHz również używa 150 000 zliczeń na tick. Nie +traktujemy jednak tej stałej jako wspólnego API BSP: profil sam wylicza ją z +rzeczywistego zegara i trybu timera. + +Testy czasowe mają dwa poziomy. Smoke test PR wykonuje co najmniej 100 ticków z +produkcyjnym dzielnikiem 150 000. Długi test schedulera wykonuje 10 000 ticków +na jawnie przyspieszonym zegarze testowym i nie jest dowodem częstotliwości. +Nightly/release może wykonać dłuższy test produkcyjnego dzielnika w ustalonym +budżecie czasu. + +### 5.3 `rp2350` + +Cel: pracownia z fizycznym Pico 2/Pico 2 W. + +Narzędzia specyficzne: + +- przypięty Pico SDK i FreeRTOS Kernel; +- toolchain RISC-V dla Hazard3; +- toolchain ARM dla Cortex-M33; +- CMake/Ninja; +- OpenOCD zgodny z RP2350, picotool i obsługa wybranego probe; +- serial/RTT oraz reguły diagnostyczne USB. + +Target `rp2350-rv` i `rp2350-arm` wybiera konfigurację CMake, startup, linker, +GDB i backend OpenOCD. Nie wymaga kolejnego Dockerfile. Bare metal i FreeRTOS są wariantami +manifestu projektu, nie oddzielnymi kontenerami. + +Kontener działa rootless, bez `--privileged`. Launcher przekazuje tylko jawnie +wybrane urządzenia. Host otrzymuje regułę udev lub ACL, która pozwala grupie +laboratoryjnej korzystać z probe bez `sudo`. + +Debug probe i natywne USB płytki to dwie różne capabilities. Domyślny +debug/deploy przez zewnętrzny probe używa stabilnego urządzenia probe; reset +targetu nie powinien go odłączać. Opcjonalny BOOTSEL/picotool powoduje +re-enumerację targetu i zmianę adresu USB. Taki deploy jest osobną, krótkotrwałą +akcją z tego samego profilu `rp2350`: launcher ponownie wykrywa urządzenie po +każdej fazie i uruchamia akcję z aktualnym węzłem. Nie przekazujemy całego +`/dev`; jeżeli backend wymaga dostępu na poziomie portu/busa USB, zakres jest +jawnie raportowany i zatwierdzany dla tej instancji. + +## 6. Wspólny kontrakt UI + +Każdy profil dziedziczy z `dev-ui-base`: + +- tmux z prefiksem `Ctrl-s`; +- Neovim i identyczny zestaw skrótów; +- Termdebug, nvim-dap i dashboard GDB; +- serwery/bridge MCP dla tmuxa i Neovima; +- `ripgrep`, `fd`, Git, Python i podstawowe narzędzia diagnostyczne; +- użytkownika bez roota i ten sam katalog roboczy `/workspace`. + +Domyślny układ jednego widocznego okna tmuxa: + +```text +┌────────────────────────────────────────────────────────────┐ +│ Neovim — jeden pane tmuxa │ +│ ┌─ Termdebug + GDB dashboard ─┬─ edytor/nvim-dap ───────┐ │ +│ └──────────────────────────────┴──────────────────────────┘ │ +├────────────────────────────────────────────────────────────┤ +│ bash, cwd=/workspace — drugi pane tmuxa │ +└────────────────────────────────────────────────────────────┘ +``` + +Termdebug i edytor są oknami jednego Neovima. Hazard3 uruchamia symulator w +drugim, domyślnie ukrytym oknie tmuxa `tb`. + +Termdebug i nvim-dap są dwiema nakładkami na ten sam opis targetu GDB. Karta +nie przechowuje osobnych adresów i poleceń dla każdego frontendu. + +## 7. Tożsamość instancji i MCP + +Każda sesja ma stabilną tożsamość logiczną, na przykład: + +```text +hazard3-sim-inf-bss-t1 +``` + +Kontener otrzymuje label `edu.stem.instance`. Aktualny identyfikator Podmana +jest tożsamością procesu. Pełne nazwy pozostają w labels/registry, natomiast +ścieżka Unix socketu używa krótkich kluczy, aby zmieścić się w limicie +`sun_path` (maksymalnie 107 użytecznych bajtów na Linuksie): + +```text +$XDG_RUNTIME_DIR/stem////t.sock +$XDG_RUNTIME_DIR/stem////n.sock +$XDG_RUNTIME_DIR/stem///current +``` + +`thread-key` i `instance-key` są 12-znakowymi skrótami kryptograficznymi, a +`cid12` to jednoznaczny na danym hoście prefiks bieżącego container ID. Registry +mapuje je na pełne wartości i jest weryfikowane przez `podman inspect`. Ta sama +krótka ścieżka jest widoczna na hoście i w kontenerze. Test startowy odrzuca +konfigurację przekraczającą budżet 100 bajtów. + +Wrapper MCP wybiera najpierw jawną instancję, przez `podman inspect` sprawdza +jej aktualny container ID, a dopiero potem otwiera socket. `current` jest +wskaźnikiem pomocniczym ograniczonym do wątku agenta, nie globalnym wyborem dla +wszystkich kontenerów. Stary socket nie może zostać użyty po odtworzeniu +kontenera. + +Agent widzi dokładnie tę samą sesję tmuxa i Neovima co uczeń. MCP nie jest +osobnym kontenerem. + +## 8. Rootless Podman i SSH + +Podman jest domyślnym runtime. Docker może pozostać przejściowym backendem dla +istniejących komputerów, ale kontrakt nie może wymagać członkostwa ucznia w +grupie `docker`. + +Na zdalnym komputerze ucznia nauczyciel lub agent: + +1. łączy się wyłącznie przez SSH z parą kluczy; +2. uruchamia `stemctl` na tym zdalnym hoście; +3. tamtejszy launcher zarządza lokalnym rootless Podmanem i lokalnym + workspace; +4. w razie potrzeby sockety MCP/GDB są udostępniane przez tunel SSH, nigdy + przez nasłuch na `0.0.0.0`. + +Nie montujemy zdalnego katalogu przez SSHFS jako podstawowego modelu i nie +przekazujemy prywatnego klucza do kontenera. Repo i kontener mają znajdować się +na tym samym hoście wykonawczym. + +## 9. Budowanie kontenerów ze źródeł i cache + +Repo przechowuje jeden wieloetapowy `Dockerfile` i pliki Compose. Nie +przechowuje ani nie publikuje gotowych obrazów. `dev-ui-base` zawiera +przypięte binaria i zależności UI, ale nie zawiera często zmienianych +`configs/` ani entrypointów. Graf stage'ów: + +```text +os-base +└─ dev-ui-base (tmux/nvim/MCP i przypięte zależności, bez configs/) + ├─ ui-node-deps (przypięte zależności serwerów MCP) + ├─ native-toolchain + │ └─ native-amd64-final + COPY configs/ i entrypointów + ├─ hazard3-toolchain + │ └─ hazard3-sim-final + COPY configs/ i entrypointów + └─ rp2350-toolchain + └─ rp2350-final + COPY configs/ i entrypointów +``` + +Duże toolchainy, SDK, Verilator, Hazard3 i OpenOCD są instalowane w stage'ach +toolchain przed skopiowaniem konfiguracji UI. Zmiana lockfile zależności UI +może celowo przebudować potomków; zwykła zmiana mapowania klawisza, dashboardu +albo entrypointu nie może tego zrobić. + +Kod karty nigdy nie jest kopiowany do warstw kontenera. Zmiana konfiguracji końcowej nie +może ponownie pobierać toolchainów. W CI sprawdzamy drugi build bez zmian i +raportujemy wykorzystanie cache. + +Uczeń wykonuje `stemctl env ensure`, które używa lokalnego cache lub buduje +profil ze źródłowego Dockerfile. Nic nie jest pobierane z rejestru obrazów. + +## 10. Migracja `rv-launcher` do `stem-launcher` + +Nazwa RV jest zbyt wąska, ponieważ launcher obsługuje serie informatyczne, +fizyczne, AMD64, RISC-V i ARM. Docelowe nazwy to: + +- repo: `edu-tools/stem-launcher`; +- publiczne CLI: `stemctl`; +- workspace: `~/dev/workspace/stem`; +- zmienne: `STEM_*`; +- stan karty: `.stem/`. + +Migracja musi zachować działające workspace uczniów: + +1. dodać `stemctl` jako nowy entrypoint do obecnej implementacji; +2. akceptować `rvctl` jako wrapper kompatybilności z ostrzeżeniem; +3. czytać nowe `STEM_*`, a następnie stare `RV_*` jako fallback; +4. domyślnie używać `~/dev/workspace/stem`, ale automatycznie wykrywać + istniejące `~/dev/workspace/rv`; +5. zmigrować `.rv/` do `.stem/` bez usuwania stanu; +6. dopiero wtedy zmienić nazwę repo na Gitea i zaktualizować manifest; +7. pozostawić alias lub repo informacyjne pod starą nazwą na co najmniej jeden + cykl zajęć; +8. usunąć kompatybilność dopiero po telemetrycznym/audytowym potwierdzeniu, że + materiały nie wywołują starych nazw. + +Szczegóły i kryteria wycofania opisuje `doc/migration-stem-launcher.md`. + +## 11. Kolejność wdrożenia + +### P0 — kontrakt i bezpieczeństwo + +1. Zatwierdzić nazwy profili, capabilities i schemat manifestu karty. +2. Dodać `stemctl` oraz zgodność `rvctl`. +3. Przenieść runtime z Docker Compose na abstrakcję z rootless Podmanem jako + backendem domyślnym. +4. Rozdzielić sekrety hosta od kontenerów i usunąć tokeny z URL-i remotes po + operacji Git. +5. Ustabilizować tożsamość instancji, labels i sockety MCP. + +### P1 — trzy targety jednego Dockerfile + +1. Wyodrębnić `dev-ui-base`. +2. Przenieść istniejący profil `host` do `native-amd64`. +3. Przenieść istniejący `rv32i` do `hazard3-sim` i dodać simulatorowy BSP + FreeRTOS 1 kHz. +4. Zbudować `rp2350` z targetami RV i ARM oraz bezpiecznym przekazywaniem probe. +5. Ujednolicić layout tmuxa, Termdebug, DAP i MCP. + +### P2 — pełny pipeline karty + +1. Zaimplementować manifest karty i akcje `build/test/run/debug/deploy`. +2. Zapisywać wyniki i metadane artefaktów. +3. Porównywać golden tests pomiędzy trzema profilami. +4. Dodać `fast-dm` oraz okresowy gate JTAG. +5. Dodać zdalny smoke test przez SSH bez otwierania portów debuggera. + +## 12. Bramki akceptacyjne + +| Obszar | Kryterium | +| --- | --- | +| Fetch | czysty host pobiera manifest i kartę wyłącznie przez `stemctl` | +| Persistence | usunięcie kontenera nie zmienia ani nie usuwa repo ucznia | +| Native | golden tests, ASan i UBSan przechodzą na AMD64 | +| Emulator | te same wektory przechodzą jako RV32 bare metal i FreeRTOS | +| FreeRTOS | co najmniej 100 ticków z produkcyjnym dzielnikiem 150 000 oraz 10 000 ticków przyspieszonego testu schedulera bez zgubionego cyklu | +| RP2350 RV | build, flash, reset, UART i debug przez probe przechodzą bez `sudo` | +| RP2350 ARM | ten sam zestaw operacji przechodzi w tym samym obrazie | +| USB | probe pozostaje dostępny po resecie; BOOTSEL/picotool przechodzi kontrolowaną re-enumerację bez `--privileged` | +| UI | layout tmux/Nvim, skróty i dashboard są identyczne w trzech profilach | +| MCP | agent steruje wskazaną instancją i nie łączy się ze starym socketem | +| Isolation | brak socketu runtime, prywatnych kluczy i `--privileged` w kontenerze | +| Reproducibility | wynik zapisuje commit karty, lokalny ID builda i wersje toolchainów | +| SSH | praca zdalna działa przez klucze i tunele na loopback, bez publicznego GDB | +| Cache | zmiana entrypointu lub konfiguracji UI nie powoduje ponownego pobrania toolchainów | +| Compatibility | stare `rvctl` i workspace `/rv` działają w okresie migracji | + +## 13. Poza zakresem tej iteracji + +- czwarty kontener dla hoverboardu; +- orkiestracja klastra albo Kubernetes; +- przechowywanie pracy ucznia wewnątrz warstwy kontenera; +- udostępnianie GDB, UART lub MCP na publicznym interfejsie; +- automatyczne podawanie agentowi prywatnych kluczy i tokenów; +- gwarancja działania symulacji RTL w czasie rzeczywistym. diff --git a/doc/containers.md b/doc/containers.md index 01c9d0c..ab142c0 100644 --- a/doc/containers.md +++ b/doc/containers.md @@ -1,162 +1,283 @@ # Kontenery i debug -`rvctl` integruje workspace z narzędziem `rv32i-hazard3-student-env`. -Środowisko ma dwa profile kontenerów: +Ten dokument opisuje wdrożony kontrakt kontenerów i jawnie wskazuje pozostałe +etapy. Pełne +uzasadnienie, przepływ ucznia i kolejność wdrożenia są w +`doc/architecture-plan.md`. -- `rv32i` - docelowe środowisko RISC-V/Hazard3 z `nvim`, `tmux`, - `gdb-multiarch`, toolchainem RISC-V i symulatorem Hazard3. -- `host` - natywne środowisko na architekturze hosta z `clang`, `gcc`, `gdb`, - opcjonalnie `lldb`, `valgrind`, `nvim` i `tmux`. +Stan obecny: -Profile są uruchamiane jako oddzielne usługi `docker compose`, więc zależności -RISC-V i zależności natywnego debugowania nie mieszają się ze sobą. +- `stemctl` uruchamia trzy profile przez rootless Podman; +- repo środowiska zawiera wieloetapowy `Dockerfile` i Compose z dokładnie + trzema usługami; gotowych obrazów nie publikujemy; +- profil RP2350 zawiera oba toolchainy, Pico SDK, FreeRTOS, OpenOCD i picotool; +- `rvctl`, `host` i `rv32i` pozostają aliasami okresu zgodności. -## Szybki przepływ +## Profile docelowe + +| Profil | Alias przejściowy | Service | Target Dockerfile | Targety | +| --- | --- | --- | --- | --- | +| `native-amd64` | `host` | `native-amd64` | `native-amd64-final` | `native` | +| `hazard3-sim` | `rv32i` | `hazard3-sim` | `hazard3-sim-final` | `hazard3-baremetal`; `hazard3-freertos` po BSP timer/IRQ | +| `rp2350` | brak | `rp2350` | `rp2350-final` | `rp2350-rv`, `rp2350-arm` | + +Wszystkie targety dziedziczą stage `dev-ui-base`. Nie jest on osobnym +kontenerem roboczym. + +## Jednolity kontrakt CLI ```bash -./rvctl env list -./rvctl env sync -./rvctl env build rv32i -./rvctl env build host +stemctl env list +stemctl env ensure native-amd64 +stemctl env ensure hazard3-sim +stemctl env ensure rp2350 -./rvctl debug rv32i inf bss 1 -./rvctl debug host inf bss 1 -./rvctl run host inf bss 1 -./rvctl shell rv32i inf bss -./rvctl shell host inf bss +stemctl build native-amd64 inf bss 1 +stemctl test native-amd64 inf bss 1 +stemctl run native-amd64 inf bss 1 +stemctl debug native-amd64 inf bss 1 + +stemctl build hazard3-sim inf bss 1 --target hazard3-baremetal +stemctl test hazard3-sim inf bss 1 --target hazard3-baremetal +stemctl run hazard3-sim inf bss 1 +stemctl debug hazard3-sim inf bss 1 + +stemctl build rp2350 inf bss 1 --target rp2350-rv +stemctl deploy rp2350 inf bss 1 --target rp2350-rv --device /dev/bus/usb/001/006 +stemctl debug rp2350 inf bss 1 --target rp2350-rv --device /dev/bus/usb/001/006 ``` -Jeżeli `workspace.json` ma ustawione domyślne wartości `defaults.series`, -`defaults.card` i `defaults.task`, można używać krótszych komend: - -```bash -./rvctl debug rv32i -./rvctl debug host -./rvctl debug rv32i 4 -./rvctl debug host 4 -``` - -## `env list` - -Pokazuje dostępne profile i bieżący katalog narzędzia: - -```bash -./rvctl env list -``` - -Typowy wynik: +Selektory `series card task` są opcjonalne, jeśli ustawiono wartości domyślne. +Każda akcja obsługuje: ```text -profile service image status purpose -rv32i env edu-inf/rv32i-hazard3-env:latest ready RV32I/Hazard3 nvim + gdb-multiarch -host host edu-inf/rv32i-hazard3-host-env:latest ready native host clang/gcc + gdb/lldb +--instance NAME +--target NAME +--editor nvim|vim +--dry-run ``` -## `env sync` +`debug` dodatkowo może przyjąć `--pane TARGET` i `--device PATH`. `deploy` +przyjmuje `--device PATH` oraz `--backend probe|bootsel`. `probe list --json` +ma stabilny format maszynowy, a akcje zapisują maszynowy `result.json`. -Klonuje albo aktualizuje repo środowiska do: +`deploy` jest capability wyłącznie targetów sprzętowych RP2350. W +`hazard3-sim` artefakt ładuje `run` albo `debug`; osobne `deploy` zwraca +`unsupported`. + +## Capabilities zamiast wyjątków w kodzie + +Launcher nie powinien zawierać warunków typu „jeśli profil rv32i, uruchom ten +konkretny skrypt”. Profil publikuje capabilities i adaptery: + +```json +{ + "profile": "rp2350", + "targets": ["rp2350-rv", "rp2350-arm"], + "actions": { + "build": {"requires": []}, + "test": {"requires": []}, + "run": {"requires": ["matching-deploy-record"]}, + "debug": {"requires": ["probe"]}, + "deploy": {"requires": ["probe"]}, + "shell": {"requires": []} + }, + "debug_backends": ["openocd"], + "devices": ["debug-probe", "target-usb-optional"] +} +``` + +Karta deklaruje target, a profil dostarcza implementację. Brak capability jest +normalnym, maszynowo czytelnym wynikiem `unsupported`, nie awarią launchera. + +## Wspólny interfejs w kontenerze + +Każdy profil zapewnia kanoniczny dispatcher: ```text -~/dev/workspace/rv/tools/rv32i-hazard3-student-env +/usr/local/bin/stem-entry +/usr/local/bin/stem-card ``` -Komenda: +Dispatcher przyjmuje `build/test/run/debug/deploy`; manifest może zwrócić +`unsupported`, jeżeli akcja nie ma sensu dla targetu. +Skrypty korzystają wyłącznie z manifestu karty i zmiennych kontraktowych: + +```text +STEM_WORKSPACE=/workspace +STEM_PROFILE=hazard3-sim +STEM_TARGET=hazard3-freertos +STEM_CARD=inf/bss +STEM_TASK=task1 +STEM_INSTANCE=hazard3-sim-inf-bss-t1 +STEM_STATE_DIR=/workspace/.stem/instances/ +STEM_ARTIFACT_DIR=/workspace/.stem/artifacts/// +``` + +Stare `RV_*` są czytane wyłącznie jako fallback w okresie migracji. + +## Montowania + +Minimalny zestaw: + +| Źródło hosta | Cel | Tryb | +| --- | --- | --- | +| repo karty | `/workspace` | `rw` | +| cache profilu | `/cache` | `rw`, osobny named volume | +| `$XDG_RUNTIME_DIR/stem` | ta sama krótka ścieżka | `rw` | +| konkretne urządzenie probe | urządzenie | tylko profil `rp2350` | + +Nie montujemy: + +- `$HOME/.ssh`; +- pliku tokenów; +- całego `/dev`; +- socketu Docker/Podman; +- katalogu domowego hosta; +- repo innych uczniów. + +Repo jest własnością użytkownika hosta. Rootless Podman uruchamia kontener z +mapowaniem UID/GID (`keep-id` albo równoważnym), żeby artefakty nie powstawały +jako root. + +## Layout tmuxa i Neovima + +W każdym profilu po `debug` powstaje ten sam układ: + +```text +work +├─ pane 0 (góra): jeden Neovim +│ ├─ okno lewe: Termdebug/GDB dashboard +│ └─ okno prawe: edytor źródeł i nvim-dap +└─ pane 1 (dół): bash w /workspace +``` + +Lewa i prawa część są oknami Neovima, nie osobnymi pane'ami tmuxa. Hazard3 +trzyma symulator w ukrytym oknie tmuxa `tb`. Prefix tmuxa to `Ctrl-s`. Neovim +ma identyczne mapowania Termdebug i nvim-dap. +Opis backendu GDB pochodzi z jednego target descriptor, dzięki czemu oba +frontendy nie rozjeżdżają się. + +## Debugger według profilu + +### `native-amd64` + +- GDB jest backendem domyślnym; +- LLDB pozostaje dostępny ręcznie w shellu; wspólny interfejs Termdebug używa GDB; +- przed debugowaniem powstaje build `-g3 -O0`; +- `test` uruchamia co najmniej ASan i UBSan. + +### `hazard3-sim` + +- obecny codzienny backend: `fast-rsp`, jawnie opisany jako uproszczony; +- docelowy, planowany backend: `fast-dm`, czyli GDB RSP -> APB/DMI -> + prawdziwy DM Hazard3; +- gate zgodności: OpenOCD + JTAG remote-bitbang; +- planowany simulatorowy BSP FreeRTOS ma używać timera 1 kHz, czyli ticka co + 150 000 cykli przy zegarze gościa 150 MHz; nie jest jeszcze wdrożony. + +### `rp2350` + +- OpenOCD/probe dla targetów RISC-V i ARM; +- picotool dla informacji, resetu i operacji wspieranych przez RP2350; +- `deploy` zawsze raportuje probe, target, artefakt i jego SHA-256; +- launcher nie wybiera pierwszego przypadkowego probe, gdy widocznych jest + kilka urządzeń. + +Zewnętrzny debug probe i natywne USB płytki są rozdzielone. Standardowy +debug/flash używa stabilnego probe, który nie re-enumeruje się przy resecie +targetu. BOOTSEL/picotool jest opcjonalną, krótkotrwałą akcją z tego samego +profilu: launcher wykrywa bieżący węzeł USB przed każdą fazą i ponownie po +re-enumeracji. Nie przekazuje całego `/dev`. Build i test offline nie wymagają +żadnego urządzenia. + +`run rp2350` wymaga zgodnego rekordu ostatniego deploy dla wybranej płytki i +artefaktu. Bez niego odmawia wykonania, chyba że użytkownik jawnie zaakceptuje +istniejący firmware. Zapobiega to testowaniu starego programu. + +## Instancje i sockety MCP + +Stabilna nazwa logiczna: + +```text +--- +``` + +Aktualny container ID jest pobierany z `podman inspect`. Pełne nazwy logiczne +są w labels i registry, a sockety używają krótkich kluczy: + +```text +$XDG_RUNTIME_DIR/stem////t.sock +$XDG_RUNTIME_DIR/stem////n.sock +$XDG_RUNTIME_DIR/stem///current +``` + +Klucze wątku i instancji mają 12 znaków, `cid12` jest prefiksem bieżącego ID. +Registry przechowuje pełne wartości. Host i kontener używają tej samej ścieżki, +a całkowita długość musi być mniejsza lub równa 100 bajtów, pozostawiając +zapas względem linuksowego limitu `sun_path`. + +Wrapper MCP otrzymuje `instance`, sprawdza label kontenera, aktualny ID i typ +socketu. Dopiero wtedy łączy się z tmuxem albo Neovimem. Pozwala to Codexowi +obsługiwać kilka kontenerów bez przypadkowego wejścia do starej sesji. + +Publiczne wejścia launchera to `stemctl mcp tmux INSTANCE` oraz +`stemctl mcp nvim INSTANCE`. Hostowy wrapper wykonuje wyłącznie resolver i +`podman exec`; sam serwer wraz z przypiętymi zależnościami działa wewnątrz +zweryfikowanego kontenera. Neovim MCP publikuje również stan i komendy +Termdebug/nvim-dap. + +MCP nie jest czwartym kontenerem. Bridge jest częścią `dev-ui-base`, a hostowy +resolver jest częścią `stem-launcher`. + +## Cykl życia ```bash -./rvctl env sync -./rvctl env sync --dry-run +stemctl start hazard3-sim inf bss 1 +stemctl status hazard3-sim --instance hazard3-sim-inf-bss-t1 +stemctl attach hazard3-sim --instance hazard3-sim-inf-bss-t1 +stemctl stop hazard3-sim --instance hazard3-sim-inf-bss-t1 +stemctl rm hazard3-sim --instance hazard3-sim-inf-bss-t1 ``` -Jeżeli tego repo jeszcze nie ma w workspace, `rvctl` używa fallbacku z -`~/dev/edu/repos/rv/rv32i-hazard3-env`, o ile jest dostępny. +`start` jest idempotentne dla zgodnej instancji. Jeżeli zmienił się lokalny ID +wyniku builda albo konfiguracja targetu, launcher odtwarza kontener. +`stop/start` zachowuje container ID; `rm/start` tworzy nowy ID i nowy katalog +socketów. Stan karty pozostaje na hoście. -## `env build` +## Zdalny komputer -Buduje wybrany profil kontenera: +Na komputer ucznia wchodzimy przez SSH z kluczem i uruchamiamy `stemctl` tam: ```bash -./rvctl env build rv32i -./rvctl env build host +ssh uczen-lab +cd ~/dev/workspace/stem/tools/stem-launcher +./stemctl status hazard3-sim --instance hazard3-sim-inf-bss-t1 +./stemctl debug hazard3-sim inf bss 1 ``` -Wariant `--dry-run` pokazuje dokładną komendę bez uruchamiania Dockera: +GDB, UART i MCP nasłuchują na loopback albo Unix socketach. Jeżeli potrzebny +jest dostęp z komputera nauczyciela, launcher generuje jawne polecenie tunelu +SSH. Nie otwiera portów na `0.0.0.0`. -```bash -./rvctl env build host --dry-run -``` +## Budowanie i cache -## `debug` +`dev-ui-base` zawiera przypięte binaria/dependencies UI, lecz nie zawiera +często zmienianych `configs/` i entrypointów. Trzy toolchainowe stage'e +dziedziczą z niego, instalują ciężkie SDK/kompilatory, a dopiero finalne stage'e +kopiują konfigurację UI i skrypty. Kod karty jest montowany, nie kopiowany. +Zmiana entrypointu, dashboardu albo mapowania klawisza nie może invalidować +warstwy pobierającej toolchain. -Uruchamia debugowanie zadania w wybranym profilu. +## Status implementacji -```bash -./rvctl debug rv32i inf bss 1 -./rvctl debug host inf bss 1 -``` - -W profilu `rv32i` startuje środowisko Hazard3: symulator, `gdb-multiarch` i -`nvim` w sesji `tmux`. - -W profilu `host` wybrane źródło C jest kompilowane natywnie z debug info, a -potem uruchamiane w `gdb` obok `nvim`. -Ten profil jest przeznaczony dla tasków C z funkcją `main`; taski czysto -asemblerskie nadal debugujemy w profilu `rv32i`. - -Przydatne warianty: - -```bash -./rvctl debug rv32i bss 4 -./rvctl debug host bss 4 -./rvctl debug host 4 --editor vim -./rvctl debug host 4 --instance host-bss-t4 -./rvctl debug rv32i 4 --pane 0 -./rvctl debug rv32i 4 pane node:0 -./rvctl debug host 4 --dry-run -``` - -`--pane TARGET` oraz forma `pane TARGET` wysyłają wygenerowaną komendę do -istniejącego panelu tmuxa na hoście przez `tmux send-keys`. Skróty targetów: - -- `0` - panel `0` w bieżącym oknie, -- `node:0` - okno `node`, panel `0`, -- `%3` albo `:node.0` - natywny target tmuxa. - -## `run` - -Na razie `run` jest wdrożone dla profilu `host`. Buduje wybrany task natywnie i -uruchamia wynikowy program: - -```bash -./rvctl run host inf bss 1 -./rvctl run host 4 -``` - -Dla profilu `rv32i` używamy obecnie `debug rv32i`, bo przepływ RISC-V zakłada -symulator Hazard3 i GDB. - -## `shell` - -Otwiera shell w wybranym profilu kontenera z podmontowaną kartą: - -```bash -./rvctl shell rv32i inf bss -./rvctl shell host inf bss -``` - -## Selektory - -Komendy `debug`, `run` i `shell` używają tych samych skrótów co `tasks`: - -```bash -./rvctl debug host 1 -./rvctl debug host bss 1 -./rvctl debug host inf bss 1 -``` - -`rvctl` rozwiązuje `1` do właściwego zadania, na przykład -`task1_bss`, na podstawie plików w `src/tasks`. - -## Starsza komenda - -`tmux-container` zostaje jako alias kompatybilności dla starszego trybu -shellowego. Nowe przykłady powinny używać `debug`, `run`, `shell` i `env`. +| Element | Status | +| --- | --- | +| `native-amd64` | wdrożony i sprawdzony z ASan/UBSan | +| `hazard3-sim` | wdrożony dla bare metal; `fast-dm` i BSP FreeRTOS są następne | +| `rp2350` | wdrożony; FreeRTOS 1 kHz buduje ELF/UF2 dla RV i ARM, hardware gate wymaga ACL probe | +| `stemctl` | wdrożony; `rvctl` jest wrapperem zgodności | +| pobieranie kart z Git | istnieje w `rvctl` | +| `build/test/run/debug/deploy` | wdrożony dispatcher manifestu i `result.json` | +| sockety tmux/nvim | wdrożone z `thread12/instance12/container12` i resolverem | +| zdalny SSH | kontrakt zaprojektowany, smoke test do dodania | diff --git a/doc/migration-stem-launcher.md b/doc/migration-stem-launcher.md new file mode 100644 index 0000000..615ffe4 --- /dev/null +++ b/doc/migration-stem-launcher.md @@ -0,0 +1,109 @@ +# Migracja `rv-launcher` do `stem-launcher` + +Status: M1–M3 wdrożone lokalnie; M4 (zmiana nazwy zdalnego repo) celowo +odłożone do zakończenia okresu zgodności +Data: 2026-07-14 + +## Dlaczego zmieniamy nazwę + +Launcher obsługuje już kod natywny AMD64 i Hazard3, a plan obejmuje RP2350 +RISC-V, RP2350 ARM, fizykę i kolejne serie STEM. Nazwy `rv-launcher`, `rvctl`, +`RV_*` i `~/dev/workspace/rv` błędnie sugerują narzędzie ograniczone do RISC-V. + +Docelowy słownik: + +| Obecnie | Docelowo | +| --- | --- | +| `rv-launcher` | `stem-launcher` | +| `rvctl` | `stemctl` | +| `rvctl.py` | wewnętrzny moduł launchera; nazwa nie jest publicznym API | +| `RV_*` | `STEM_*` | +| `.rv/` | `.stem/` | +| `~/dev/workspace/rv` | `~/dev/workspace/stem` | +| profil `host` | `native-amd64` | +| profil `rv32i` | `hazard3-sim` | + +## Zasada migracji + +Najpierw dostarczamy zgodność w kodzie, a dopiero później zmieniamy nazwę repo +na serwerze. Odwrotna kolejność zepsułaby bootstrap, token records, remotes, +ścieżki dokumentacji i istniejące workspace uczniów. + +## Etapy + +### M1 — alias CLI (wdrożone) + +- dodać wykonywalny `stemctl` wskazujący tę samą implementację; +- pozostawić `rvctl` jako wrapper; +- pomoc i nowe materiały pokazują wyłącznie `stemctl`; +- `rvctl` drukuje jednorazowe ostrzeżenie o wycofaniu na stderr, ale zachowuje + format stdout potrzebny skryptom. + +### M2 — konfiguracja i ścieżki (wdrożone) + +- dodać wersjonowaną migrację `workspace.json`; +- nowe zmienne `STEM_*` mają pierwszeństwo, stare `RV_*` są fallbackiem; +- jeśli istnieje `/workspace/.stem`, użyć go; +- jeśli istnieje tylko `/workspace/.rv`, użyć go i zaproponować migrację; +- komenda `stemctl workspace doctor` pokazuje użyte ścieżki i runtime; +- komenda `stemctl workspace migrate --dry-run` nie zmienia danych, tylko + pokazuje plan. + +Migracja katalogu stanu musi być atomowa na pojedynczym filesystemie i nigdy +nie usuwa starego katalogu przed zweryfikowaniem nowego. + +### M3 — profile i kontenery (wdrożone) + +- wprowadzić `native-amd64`, `hazard3-sim`, `rp2350`; +- zachować aliasy `host -> native-amd64` oraz `rv32i -> hazard3-sim`; +- kontenery otrzymują labels z nazwą kanoniczną, nie aliasem; +- wyniki zapisują nazwę kanoniczną oraz opcjonalne `requested_alias`. + +### M4 — repo Gitea (odłożone) + +Po wydaniu kompatybilnego launchera: + +1. utworzyć lub zmienić nazwę na `edu-tools/stem-launcher`; +2. zaktualizować `workspace-info`, bootstrap i token records; +3. sprawdzić clone/fetch/push przez nowe URL; +4. pozostawić pod `edu-tools/rv-launcher` przekierowanie albo małe repo z + komunikatem migracyjnym, zależnie od możliwości Gitea; +5. nie usuwać starej nazwy podczas trwającego semestru. + +Zmiana repo na Gitea jest operacją administracyjną i nie jest wykonywana przez +samą aktualizację dokumentacji. + +### M5 — wycofanie kompatybilności + +Stare nazwy można usunąć dopiero, gdy: + +- wszystkie serie w `workspace-info` wskazują nowe CLI i repo; +- CI nie używa `rvctl`, `RV_*`, `.rv` ani profili `host`/`rv32i`; +- obraz pracowni został odświeżony; +- co najmniej jeden pełny cykl zajęć przeszedł na `stemctl`; +- dostępna jest instrukcja ręcznego odzyskania starego workspace. + +## Testy migracji + +1. Nowa instalacja bez istniejącego workspace. +2. Istniejący workspace `/rv` z kartami i bez aktywnych kontenerów. +3. Workspace z `.rv/` i zatrzymanym kontenerem. +4. Store tokenów wskazujący repo `rv-launcher`. +5. Remotes bez sekretu oraz remotes z historycznym tokenem w URL. +6. Równoległe instancje profili `host` i `rv32i`. +7. Ponowne wykonanie migracji — wynik musi być idempotentny. +8. Rollback konfiguracji bez utraty repo, branchy, artefaktów i logów. + +## Kryterium zakończenia + +Uczeń po migracji wykonuje: + +```bash +stemctl series cards fetch inf bss +stemctl test native-amd64 inf bss 1 +stemctl debug hazard3-sim inf bss 1 +stemctl deploy rp2350 inf bss 1 --target rp2350-rv --device /dev/bus/usb/001/006 +``` + +Istniejący skrypt używający `rvctl debug rv32i` nadal działa w zadeklarowanym +okresie kompatybilności i trafia do tego samego kanonicznego profilu. diff --git a/doc/review-claude-2026-07-14.md b/doc/review-claude-2026-07-14.md new file mode 100644 index 0000000..1d5deb9 --- /dev/null +++ b/doc/review-claude-2026-07-14.md @@ -0,0 +1,150 @@ +# Senior review Claude — 2026-07-14 + +Zakres recenzji: + +- `stem-launcher`: plan architektury, kontenery, agenci i migracja nazw; +- `rv32i-hazard3-student-env`: plan ewaluacji, Containerfile/Dockerfile, + Compose, integracja launchera i specyfikacja trzech obrazów; +- zgodność planu z bieżącym `rvctl.py` i `workspace.json`. + +## Werdykt + +Claude wydał akceptację warunkową. Zaakceptował: + +- dokładnie trzy kontenery robocze i wspólną warstwę UI; +- profile `native-amd64`, `hazard3-sim`, `rp2350` oraz targety wewnątrz + profilu; +- Git, klucze i tokeny wyłącznie na hoście; +- rootless Podmana bez socketu runtime i bez `--privileged`; +- capabilities i wynik `unsupported` zamiast warunków zaszytych w launcherze; +- migrację zgodności przed zmianą nazwy repo Gitea; +- `fast-rsp` jako obecny backend, `fast-dm` jako kierunek i JTAG jako gate; +- tę samą sesję tmux/Neovim dla ucznia i agenta. + +## Findings i rozstrzygnięcia + +### P1 — graf cache Containerfile + +Problem: częsta konfiguracja UI znajdowała się w stage'u będącym przodkiem +ciężkich toolchainów. Jej zmiana przebudowałaby SDK i Verilator. + +Rozstrzygnięcie: `dev-ui-base` zawiera przypięte binaria/dependencies UI, ale +nie `configs/` ani entrypointy. Toolchainowe stage'e dziedziczą z niego, a +konfiguracja jest kopiowana dopiero do finalnych stage'ów. Test cache obejmuje +zmianę entrypointu i konfiguracji UI. + +### P1 — limit Unix socketów + +Problem: pełne `thread/instance/container-id` w ścieżce workspace przekraczały +linuksowy limit `sun_path`. + +Rozstrzygnięcie: + +```text +$XDG_RUNTIME_DIR/stem////t.sock +$XDG_RUNTIME_DIR/stem////n.sock +``` + +Pełne wartości są w registry/labels. Budżet ścieżki wynosi 100 bajtów. + +### P1 — `deploy hazard3-sim` + +Problem: jeden dokument utożsamiał deploy z załadowaniem symulatora, pozostałe +nie definiowały tej operacji. + +Rozstrzygnięcie: `deploy` dla `hazard3-sim` jest `unsupported`. `run` i `debug` +ładują artefakt. Deploy pozostaje operacją targetu sprzętowego. + +### P1 — status `fast-dm` + +Problem: niezaimplementowany backend był w jednym miejscu opisany jako +codzienny. + +Rozstrzygnięcie: dokumenty jawnie mówią: + +- teraz: `fast-rsp`; +- docelowo: `fast-dm`; +- okresowo: `fidelity-jtag`. + +### P1 — USB RP2350 + +Problem: statyczne `--device` nie obsługuje hotplug i re-enumeracji BOOTSEL. + +Rozstrzygnięcie: zewnętrzny debug probe jest stabilnym, domyślnym backendem. +Natywne USB targetu/picotool jest osobną capability i krótkotrwałą akcją z tego +samego obrazu; launcher ponownie rozwiązuje węzeł po każdej fazie. Build/test +offline nie wymagają probe. + +### P2 — koszt testu 10 000 ticków + +Problem: 10 000 × 150 000 daje 1,5 miliarda cykli RTL. + +Rozstrzygnięcie: PR wykonuje co najmniej 100 ticków z produkcyjnym dzielnikiem, +a długi test schedulera 10 000 ticków działa z jawnie przyspieszonym zegarem. +Nightly/release otrzymuje oddzielny budżet testu produkcyjnego. + +### P2 — pozostałe + +- wymaganie `probe` przeniesiono z profilu na akcje `debug/deploy`; +- format instancji ujednolicono do `task`; +- manifest otrzymał `default_target` per profil; +- `run rp2350` wymaga pasującego rekordu deploy albo jawnej zgody na istniejący + firmware; +- bramka przenośności rozróżnia wektory niezależne od ABI. + +## Korekta jednej sugestii recenzenta + +Claude zasugerował, że realny RP2350 może taktować `MTIME` inną częstotliwością +niż symulator. Jest to prawdziwe dla domyślnego trybu SIO, ale nie dla badanego +portu FreeRTOS. Lokalny +`FreeRTOS-Kernel/portable/ThirdParty/GCC/RP2350_RISC-V/port.c` wywołuje: + +```c +riscv_timer_set_fullspeed(true); +uxTimerIncrementsForOneTick = clock_get_hz(clk_sys) / configTICK_RATE_HZ; +``` + +Przy 150 MHz i 1 kHz oba środowiska używają więc 150 000 zliczeń. Plan nadal +wymaga osobnych BSP i niezależnego wyliczania dzielnika, ponieważ tryb timera i +zegar mogą się zmienić. + +## Wynik etapu projektu + +Wszystkie findings P1 oraz zasadne P2 zostały naniesione do planu. Ten fragment +review odbył się przed implementacją i dlatego nie stanowił jeszcze dowodu +działania środowiska. + +## Review implementacji + +Po wdrożeniu Claude wykonał dwa pełne oraz zawężone przeglądy diffów. Wykryte +problemy i ich rozstrzygnięcia: + +- logi GDB/JTAG/UART sugerowały `0.0.0.0`, mimo planowanego loopbacku — bind i + logi są teraz jednoznacznie `127.0.0.1`; +- Compose zawierał martwe zmienne MCP — Compose służy wyłącznie do lokalnego + buildu i niezarządzanej powłoki, a sesje MCP tworzy kanonicznie `stem`; +- zgodnościowy `tmux-container` omijał rootless runtime — deleguje teraz do + `stem shell`; +- `status/attach/stop/rm --instance` zależały od bieżącej domyślnej karty — + operują teraz po stabilnej nazwie instancji bez checkoutu karty; +- `attach` zakładał jedną błędną nazwę sesji — rozpoznaje sesje `stem-*`, + `rv32i-*`, `host-*` lub jedyną sesję z dedykowanego socketu; +- osierocony socket tmux kończył `attach` bez diagnostyki — zwracany jest teraz + komunikat nakazujący ponowne uruchomienie debug UI; +- rekord deployu pobierał commit z katalogu launchera — używa teraz + `git -C rev-parse HEAD`. + +Końcowe, zawężone review obu repozytoriów zakończyło się wynikiem +`NO_P0_P1`. Potwierdzone zostały także: + +- build wszystkich trzech lokalnych profili z warstwami toolchainów w cache; +- 12/12 testów kontraktowych środowiska i 12/12 testów launchera; +- FreeRTOS Blink 1 kHz dla RP2350 RISC-V oraz ARM; +- wykonanie karty bare-metal w Hazard3; +- Termdebug i dashboard po lewej oraz źródło po prawej, w górnym pane tmuxa; +- działanie MCP dla tmuxa i Neovima w wybranym kontenerze; +- nasłuch symulatora GDB wyłącznie na loopbacku. + +Poza bieżącym wdrożeniem pozostają jawnie oznaczone etapy badawcze: +`fast-dm` wykorzystujący Debug Module Hazard3 oraz BSP FreeRTOS dla symulatora +RTL. Nie są one przedstawiane jako gotowe. diff --git a/doc/rvctl.md b/doc/rvctl.md index 069e9b3..35cf17a 100644 --- a/doc/rvctl.md +++ b/doc/rvctl.md @@ -1164,7 +1164,10 @@ Przykłady: ## `tmux-container [series] [card]` -Tworzy nową sesję `tmux` i uruchamia kontener w `pane 0`. +Komenda zgodności. Tworzy nową sesję hostowego `tmux`, a w `pane 0` uruchamia +`stem shell` dla karty. Tworzenie kontenera, bind-mount `/workspace`, labels, +registry i sockety pozostają w jednym kanonicznym runtime rootless Podmana; +komenda nie wywołuje bezpośrednio Docker Compose. Argumenty pozycyjne: @@ -1180,7 +1183,7 @@ Przełączniki: - `--window NAME` Nadpisuje nazwę okna `tmux`. - `--instance NAME` - Ustawia `RV_INSTANCE` dla wrappera `rv`. + Ustawia stabilne `STEM_INSTANCE` dla runtime `stem`. - `--attach` Po utworzeniu sesji robi `tmux attach`. - `--dry-run` diff --git a/rvctl b/rvctl index 4eddf61..11af75e 100755 --- a/rvctl +++ b/rvctl @@ -1,4 +1,8 @@ #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${STEM_RVCTL_SILENCE_DEPRECATION:-0}" != "1" ]]; then + printf '%s\n' 'warning: rvctl is deprecated; use stemctl (compatibility mode remains enabled).' >&2 +fi +export STEM_CLI_NAME=rvctl exec python3 "$SCRIPT_DIR/rvctl.py" "$@" diff --git a/rvctl.py b/rvctl.py index d11cb1e..c5f6d98 100755 --- a/rvctl.py +++ b/rvctl.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""rvctl command-line implementation.""" +"""STEM workspace launcher (``stemctl`` with ``rvctl`` compatibility).""" from __future__ import annotations @@ -10,6 +10,7 @@ import json import os import re import shlex +import shutil import subprocess import sys from dataclasses import dataclass, replace @@ -111,30 +112,79 @@ REPO_PERMISSION_FIELDS = [ ] ENV_PROFILES = { - "rv32i": { - "service": "env", - "image": "edu-inf/rv32i-hazard3-env:latest", - "purpose": "RV32I/Hazard3 nvim + gdb-multiarch", + "native-amd64": { + "service": "native-amd64", + "image": "localhost/stem/dev-native-amd64:local", + "purpose": "native AMD64 C/C++ tests, sanitizers and GDB/LLDB", }, - "host": { - "service": "host", - "image": "edu-inf/rv32i-hazard3-host-env:latest", - "purpose": "native host clang/gcc + gdb/lldb", + "hazard3-sim": { + "service": "hazard3-sim", + "image": "localhost/stem/dev-hazard3-sim:local", + "purpose": "Hazard3 RTL, RV32 bare metal/FreeRTOS and GDB", + }, + "rp2350": { + "service": "rp2350", + "image": "localhost/stem/dev-rp2350:local", + "purpose": "physical RP2350 RISC-V or ARM build, deploy and debug", }, } +ENV_PROFILE_ALIASES = { + "host": "native-amd64", + "native": "native-amd64", + "amd64": "native-amd64", + "rv32i": "hazard3-sim", + "riscv": "hazard3-sim", + "riscv32": "hazard3-sim", + "hazard3": "hazard3-sim", +} + +ENV_PROFILE_CHOICES = sorted(set(ENV_PROFILES) | set(ENV_PROFILE_ALIASES)) +PROFILE_DEFAULT_TARGETS = { + "native-amd64": "native", + "hazard3-sim": "hazard3-baremetal", + "rp2350": "rp2350-rv", +} + + +def default_runtime_socket_root(workspace_root: Path) -> Path: + xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", "").strip() + if xdg_runtime: + return Path(xdg_runtime).expanduser().resolve() / "stem" + run_user = Path(f"/run/user/{os.getuid()}") + if run_user.is_dir(): + return run_user / "stem" + return workspace_root / ".runtime" / "stem" + def load_config(config_path: Path) -> WorkspaceConfig: raw = json.loads(config_path.read_text(encoding="utf-8")) base_dir = config_path.parent - original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/rv"), base_dir) - workspace_root = expand_path(raw.get("workspace_root", "~/dev/workspace/rv"), base_dir) + original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/stem"), base_dir) + configured_workspace = os.environ.get("STEM_WORKSPACE_ROOT") or raw.get( + "workspace_root", "~/dev/workspace/stem" + ) + workspace_root = expand_path(str(configured_workspace), base_dir) + legacy_workspace_root = expand_path( + str(raw.get("legacy_workspace_root", "~/dev/workspace/rv")), base_dir + ) + if ( + "STEM_WORKSPACE_ROOT" not in os.environ + and not workspace_root.exists() + and legacy_workspace_root.exists() + ): + workspace_root = legacy_workspace_root git_raw = raw.get("git", {}) base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/") original_series_root = expand_path(raw.get("original_series_root", str(original_root / "series")), base_dir) series_root = expand_path(raw.get("series_root", str(workspace_root / "series")), base_dir) - socket_root = expand_path(raw.get("socket_root", str(workspace_root / "sockets")), base_dir) + configured_socket_root = os.environ.get("STEM_SOCKET_ROOT") or raw.get("socket_root") + socket_root = ( + expand_path(str(configured_socket_root), base_dir) + if configured_socket_root + else default_runtime_socket_root(workspace_root) + ) token_path = expand_path(raw.get("token_file", str(workspace_root / "tokens" / "tokens.json")), base_dir) workspace_info_path = expand_path( raw.get("workspace_info_path", str(workspace_root / "meta" / "workspace-info" / "workspace.json")), @@ -146,13 +196,18 @@ def load_config(config_path: Path) -> WorkspaceConfig: candidate_values = [ raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-student-env")), str(workspace_root / "tools" / "rv32i-hazard3-env"), + str(Path.home() / "dev" / "workspace" / "stem" / "tools" / "rv32i-hazard3-student-env"), + str(Path.home() / "dev" / "workspace" / "rv" / "tools" / "rv32i-hazard3-student-env"), str(original_root / "rv32i-hazard3-student-env"), str(original_root / "rv32i-hazard3-env"), ] tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values] + adjacent_tools = (base_dir.parent / "rv32i-hazard3-student-env").resolve() + if adjacent_tools.is_dir() and adjacent_tools not in tools_root_candidates: + tools_root_candidates.insert(0, adjacent_tools) tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0]) - env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-inf/rv32i-hazard3-student-env.git") + env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-tools/rv32i-hazard3-student-env.git") defaults = raw.get("defaults", {}) workspace_info_url = raw.get("workspace_info_url", f"{base_url}/edu-workspace/workspace-info.git") git = GitConfig( @@ -3347,152 +3402,15 @@ def tmux_target_exists(session_name: str) -> bool: return result.returncode == 0 -def current_user_slug() -> str: - home_dir = Path(os.environ.get("HOME") or str(Path.home())) - return slug_value(os.environ.get("USER") or home_dir.name or f"uid{os.getuid()}", "user name") - - -def display_user_slug(raw_user: str) -> str: - match = re.fullmatch(r"u(\d+)", raw_user) - if match: - return f"u{int(match.group(1)):02d}" - return raw_user - - -def runtime_name_slug(raw_value: str, field_name: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "_", raw_value.strip().lower()).strip("_") - if not slug: - raise SystemExit(f"Invalid {field_name}: {raw_value!r}") - return slug - - -def display_profile_slug(profile: str) -> str: - if profile == "host": - return "_host" - return profile - - -def runtime_name(user_name: str, profile: str, selector: str, window_name: str) -> str: - selector_parts = selector.split("/") - name_parts = [display_user_slug(user_name), display_profile_slug(profile)] - name_parts.extend(runtime_name_slug(part, "card selector") for part in selector_parts) - name_parts.append(runtime_name_slug(window_name, "tmux window name")) - return "_".join(name_parts) - - -def build_container_command( - config: WorkspaceConfig, - selector: str, - card_path: Path, - instance: str, - profile: str, - session_name: str, - window_name: str, -) -> str: - profile = normalize_env_profile(profile) - service = ENV_PROFILES[profile]["service"] - tool_root = config.tools_root - compose_file = tool_root / "docker-compose.yml" - if not (tool_root / "rv").is_file(): - candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates) - raise SystemExit(f"Missing rv launcher. Checked:\n{candidates}") - if not compose_file.is_file(): - raise SystemExit(f"Missing docker compose file: {compose_file}") - - home_dir = Path(os.environ.get("HOME") or str(Path.home())) - card_path_text = str(card_path) - user_name = current_user_slug() - host_uid = str(os.getuid()) - host_gid = str(os.getgid()) - container_name = runtime_name(user_name, profile, selector, window_name) - shell_rcfile = tool_root / "configs" / "rv-shell.bashrc" - env_parts = [ - f"WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}", - f"RV_CARDS_ROOT={shlex.quote(str(config.series_root))}", - f"RV_PROFILE={profile}", - f"RV_REPO={shlex.quote(card_path_text)}", - f"RV_REPO_PATH={shlex.quote(str(card_path))}", - f"RV_REPO_HOST={shlex.quote(str(card_path))}", - f"RV_CARD={shlex.quote(selector)}", - f"RV_INSTANCE={shlex.quote(instance)}", - f"RV_HOST_USER={shlex.quote(user_name)}", - f"RV_HOST_UID={host_uid}", - f"RV_HOST_GID={host_gid}", - "RV_CODEX=0", - ] - compose_args = [ - "docker", - "compose", - "-f", - str(compose_file), - "run", - "--rm", - "--name", - container_name, - "--user", - f"{os.getuid()}:{os.getgid()}", - "--workdir", - card_path_text, - "-e", - f"HOME={home_dir}", - "-e", - f"USER={user_name}", - "-e", - f"LOGNAME={user_name}", - "-e", - f"RV_PROFILE={profile}", - "-e", - f"RV_HOST_USER={user_name}", - "-e", - f"RV_HOST_UID={host_uid}", - "-e", - f"RV_HOST_GID={host_gid}", - "-e", - f"RV_REPO={card_path_text}", - "-e", - f"WORKSPACE_ROOT={config.workspace_root}", - "-e", - f"RV_CARDS_ROOT={config.series_root}", - "-e", - f"RV_REPO_PATH={card_path_text}", - "-e", - f"RV_REPO_HOST={card_path_text}", - "-e", - f"RV_CARD={selector}", - "-e", - f"RV_INSTANCE={instance}", - "-e", - "RV_CODEX=0", - "-v", - f"{card_path_text}:{card_path_text}", - ] - for identity_file in ["/etc/passwd", "/etc/group"]: - if Path(identity_file).is_file(): - compose_args.extend(["-v", f"{identity_file}:{identity_file}:ro"]) - shell_args = [service, "shell"] - if shell_rcfile.is_file(): - compose_args.extend(["-v", f"{shell_rcfile}:/tmp/rv-shell.bashrc:ro"]) - shell_args.extend(["--rcfile", "/tmp/rv-shell.bashrc"]) - compose_args.extend(shell_args) - return " && ".join( - [ - f"cd {shlex.quote(str(tool_root))}", - " ".join(env_parts + [shlex.join(compose_args)]), - ] - ) - - def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> None: series_name, card_name = resolve_selector(config, args.series, args.card) selector = f"{series_name}/{card_name}" card_path = resolve_card_path(config, series_name, card_name) - profile = normalize_env_profile(args.profile) - user_name = current_user_slug() - default_session_name = runtime_name(user_name, profile, selector, args.window or config.defaults.get("tmux_window", "rv")) - session_name = args.session or default_session_name + task_name = resolve_submission_task(config, card_path, None) + instance = args.instance or default_env_instance(profile, series_name, card_name, task_name, "shell") window_name = args.window or config.defaults.get("tmux_window", "rv") - instance = args.instance or config.defaults.get("instance", "shell") + session_name = args.session or slug_value(f"stem-{instance}", "tmux session") if tmux_target_exists(session_name): raise SystemExit( @@ -3500,7 +3418,18 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non "Use another --session name or remove the existing session first." ) - shell_command = build_container_command(config, selector, card_path, instance, profile, session_name, window_name) + # Compatibility command: keep the outer host tmux session, but delegate + # container creation/mounts/labels entirely to the canonical rootless + # ./stem runtime. Do not maintain a second Docker Compose lifecycle here. + shell_command, details = env_action_command( + config, + profile, + "shell", + series_name=series_name, + card_name=card_name, + task_name=task_name, + instance=instance, + ) tmux_command = [ "tmux", "new-session", @@ -3516,10 +3445,13 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non print("Selector:", selector) print("Card path:", card_path) print("Tools root:", config.tools_root) + print("Runtime: rootless stem/Podman") + print("Instance:", instance) print("Pane 0 command:") print(shell_command) print("tmux command:") print(shlex.join(tmux_command)) + print_env_plan(details) return subprocess.run(tmux_command, check=True) @@ -3535,12 +3467,7 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non def normalize_env_profile(profile: str) -> str: - aliases = { - "riscv": "rv32i", - "riscv32": "rv32i", - "native": "host", - } - normalized = aliases.get(profile, profile) + normalized = ENV_PROFILE_ALIASES.get(profile, profile) if normalized not in ENV_PROFILES: available = ", ".join(sorted(ENV_PROFILES)) raise SystemExit(f"Unsupported environment profile '{profile}'. Available: {available}") @@ -3548,18 +3475,19 @@ def normalize_env_profile(profile: str) -> str: def env_tool_command_path(config: WorkspaceConfig) -> Path: - return config.tools_root / "rv" + stem_script = config.tools_root / "stem" + return stem_script if stem_script.is_file() else config.tools_root / "rv" def ensure_env_tool(config: WorkspaceConfig) -> Path: - rv_script = env_tool_command_path(config) - if rv_script.is_file(): - return rv_script + env_script = env_tool_command_path(config) + if env_script.is_file(): + return env_script candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates) raise SystemExit( - "Missing rv32i-hazard3-student-env launcher script 'rv'.\n" + "Missing environment launcher script 'stem' (or compatible 'rv').\n" f"Checked:\n{candidates}\n" - "Run: ./rvctl env sync" + "Run: ./stemctl env sync" ) @@ -3701,7 +3629,8 @@ def send_command_to_tmux_pane(shell_command: str, raw_target: str, dry_run: bool def default_env_instance(profile: str, series_name: str, card_name: str, task_name: str, action: str) -> str: task_part = task_branch_part(task_name).lower() - return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}-{action}", "environment instance") + del action + return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}", "environment instance") def env_action_command( @@ -3713,15 +3642,25 @@ def env_action_command( task_name: str | None = None, instance: str | None = None, editor: str | None = None, + target: str | None = None, + device: str | None = None, + deploy_backend: str | None = None, + allow_existing_firmware: bool = False, ) -> tuple[str, dict[str, str]]: profile = normalize_env_profile(profile) - rv_script = ensure_env_tool(config) + env_script = ensure_env_tool(config) + selected_target = target or PROFILE_DEFAULT_TARGETS[profile] env_vars = { + "STEM_WORKSPACE_ROOT": str(config.workspace_root), + "STEM_CARDS_ROOT": str(config.series_root), + "STEM_SOCKET_ROOT": str(config.socket_root), + "STEM_PROFILE": profile, + "STEM_TARGET": selected_target, "WORKSPACE_ROOT": str(config.workspace_root), "RV_CARDS_ROOT": str(config.series_root), "RV_PROFILE": profile, } - command_args = ["bash", "./rv"] + command_args = ["bash", f"./{env_script.name}"] card_path: Path | None = None selector = "" @@ -3730,6 +3669,9 @@ def env_action_command( selector = f"{series_name}/{card_name}" env_vars.update( { + "STEM_REPO_PATH": str(card_path), + "STEM_REPO_HOST": str(card_path), + "STEM_CARD": card_path.name, "RV_REPO_PATH": str(card_path), "RV_REPO_HOST": str(card_path), "RV_CARD": card_path.name, @@ -3737,48 +3679,55 @@ def env_action_command( ) if instance: + env_vars["STEM_INSTANCE"] = instance env_vars["RV_INSTANCE"] = instance elif series_name and card_name and task_name: - env_vars["RV_INSTANCE"] = default_env_instance(profile, series_name, card_name, task_name, action) + generated_instance = default_env_instance(profile, series_name, card_name, task_name, action) + env_vars["STEM_INSTANCE"] = generated_instance + env_vars["RV_INSTANCE"] = generated_instance if editor: + env_vars["STEM_EDITOR"] = editor env_vars["RV_EDITOR"] = editor - if action == "build": - command_args.append("build") - elif action == "shell": - command_args.append("shell") - elif action == "debug": + if device: + env_vars["STEM_PROBE_DEVICE"] = device + if deploy_backend: + env_vars["STEM_DEPLOY_BACKEND"] = deploy_backend + + if action in {"build", "test", "run", "debug", "deploy"}: if not task_name: - raise SystemExit("debug requires a task selector.") - if profile == "rv32i": - command_args.extend(["nvim", task_name]) - else: - command_args.extend(["debug", task_name]) - elif action == "run": - if not task_name: - raise SystemExit("run requires a task selector.") - if profile == "rv32i": - raise SystemExit("rv32i run is not separate yet. Use: ./rvctl debug rv32i ...") - command_args.extend(["run", task_name]) + raise SystemExit(f"{action} requires a task selector.") + command_args.extend([action, "--target", selected_target, task_name]) + if device: + command_args.extend(["--device", device]) + if action == "deploy" and deploy_backend: + command_args.extend(["--backend", deploy_backend]) + if allow_existing_firmware: + command_args.append("--allow-existing-firmware") + elif action in {"shell", "start", "status", "attach", "stop", "rm"}: + command_args.extend([action, "--target", selected_target]) else: raise SystemExit(f"Unsupported environment action: {action}") env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env_vars.items()) shell_command = " && ".join( [ - f"cd {shlex.quote(str(rv_script.parent))}", + f"cd {shlex.quote(str(env_script.parent))}", " ".join([env_prefix, *[shlex.quote(part) for part in command_args]]), ] ) details = { "profile": profile, "service": ENV_PROFILES[profile]["service"], + "target": selected_target, "selector": selector, "task": task_name or "", "card_path": str(card_path or ""), "tools_root": str(config.tools_root), "instance": env_vars.get("RV_INSTANCE", ""), + "device": device or "", + "backend": deploy_backend or "", "command": shell_command, } return shell_command, details @@ -3788,11 +3737,14 @@ def print_env_plan(details: dict[str, str]) -> None: keys = [ "profile", "service", + "target", "selector", "task", "card_path", "tools_root", "instance", + "device", + "backend", "pane", "tmux_target", "tmux_resolved", @@ -3820,19 +3772,97 @@ def run_env(config: WorkspaceConfig, args: argparse.Namespace) -> None: return if args.env_command == "build": profile = normalize_env_profile(args.profile) - shell_command, details = env_action_command(config, profile, "build") + env_script = ensure_env_tool(config) + shell_command = " && ".join( + [ + f"cd {shlex.quote(str(env_script.parent))}", + " ".join( + [ + f"STEM_PROFILE={shlex.quote(profile)}", + f"STEM_WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}", + f"STEM_SOCKET_ROOT={shlex.quote(str(config.socket_root))}", + "bash", + shlex.quote(f"./{env_script.name}"), + "profile-build", + shlex.quote(profile), + ] + ), + ] + ) if args.dry_run: - print_env_plan(details) + print(f"profile\t{profile}") + print(f"command\t{shell_command}") return subprocess.run(["bash", "-lc", shell_command], check=True) return + if args.env_command == "ensure": + profile = normalize_env_profile(args.profile) + env_script = ensure_env_tool(config) + env_vars = os.environ.copy() + env_vars.update( + { + "STEM_WORKSPACE_ROOT": str(config.workspace_root), + "STEM_SOCKET_ROOT": str(config.socket_root), + "STEM_PROFILE": profile, + } + ) + command = ["bash", str(env_script), "ensure", profile] + if args.dry_run: + print(f"profile\t{profile}") + print(f"command\t{shlex.join(command)}") + return + subprocess.run(command, cwd=env_script.parent, env=env_vars, check=True) + return raise SystemExit(f"Unsupported env command: {args.env_command}") +def run_probe(config: WorkspaceConfig, args: argparse.Namespace) -> None: + env_script = ensure_env_tool(config) + command = ["bash", str(env_script), "probe", "list"] + if args.json: + command.append("--json") + env = os.environ.copy() + env.update( + { + "STEM_WORKSPACE_ROOT": str(config.workspace_root), + "STEM_SOCKET_ROOT": str(config.socket_root), + "STEM_PROFILE": "rp2350", + } + ) + subprocess.run(command, cwd=env_script.parent, env=env, check=True) + + +def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None: + """Replace stemctl with the MCP server bound to one verified instance.""" + env_script = ensure_env_tool(config) + wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh" + if not wrapper.is_file(): + raise SystemExit(f"Missing MCP wrapper in environment sources: {wrapper}") + env = os.environ.copy() + env.update( + { + "STEM_WORKSPACE_ROOT": str(config.workspace_root), + "STEM_SOCKET_ROOT": str(config.socket_root), + } + ) + os.execvpe(str(wrapper), [str(wrapper), args.instance], env) + + def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None: profile = normalize_env_profile(args.profile) selector_values, pane_target = split_pane_selector(args.selector, getattr(args, "pane", None)) - series_name, card_name, task_name = resolve_env_target(config, selector_values, getattr(args, "task", None)) + instance = getattr(args, "instance", None) + instance_only_actions = {"status", "attach", "stop", "rm"} + if instance and action in instance_only_actions and not selector_values: + # A stable logical instance is sufficient for lifecycle operations on + # an existing container. Resolving workspace defaults here would make + # the operation depend on the currently selected (or still checked + # out) card, even though the environment launcher does not need it. + series_name = card_name = task_name = None + else: + series_name, card_name, task_name = resolve_env_target( + config, selector_values, getattr(args, "task", None) + ) shell_command, details = env_action_command( config, profile, @@ -3840,8 +3870,12 @@ def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action series_name=series_name, card_name=card_name, task_name=task_name, - instance=getattr(args, "instance", None), + instance=instance, editor=getattr(args, "editor", None), + target=getattr(args, "target", None), + device=getattr(args, "device", None), + deploy_backend=getattr(args, "backend", None), + allow_existing_firmware=getattr(args, "allow_existing_firmware", False), ) if pane_target: pane_details = send_command_to_tmux_pane(shell_command, pane_target, dry_run=args.dry_run) @@ -3921,6 +3955,61 @@ def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None: print(f"status\tready") return + if args.workspace_command == "migrate": + migrated = 0 + already_ready = 0 + candidates = sorted(path for path in config.series_root.glob("*/*") if path.is_dir()) + for card_path in candidates: + legacy_state = card_path / ".rv" + stem_state = card_path / ".stem" + if stem_state.exists() or stem_state.is_symlink(): + already_ready += 1 + continue + if not legacy_state.is_dir(): + continue + print(f"state\t{stem_state} -> .rv") + migrated += 1 + if not args.dry_run: + stem_state.symlink_to(".rv", target_is_directory=True) + print(f"migrated\t{migrated}") + print(f"already_ready\t{already_ready}") + print(f"mode\t{'dry-run' if args.dry_run else 'applied'}") + return + + if args.workspace_command == "doctor": + requested_runtime = os.environ.get("STEM_RUNTIME", "").strip() + runtime = requested_runtime or ("podman" if shutil.which("podman") else "") + runtime_ok = runtime == "podman" and shutil.which("podman") is not None + if requested_runtime and requested_runtime != "podman": + runtime_detail = f"{requested_runtime} is not supported for the rootless student workflow" + else: + runtime_detail = runtime or "missing podman" + checks: list[tuple[str, bool, str]] = [ + ("workspace", config.workspace_root.is_dir(), str(config.workspace_root)), + ("series", config.series_root.is_dir(), str(config.series_root)), + ("env-tool", env_tool_command_path(config).is_file(), str(env_tool_command_path(config))), + ("runtime", runtime_ok, runtime_detail), + ] + socket_probe = config.socket_root / ("t" * 12) / ("i" * 12) / ("c" * 12) / "n.sock" + checks.append(("socket-path", len(os.fsencode(socket_probe)) <= 100, f"{len(os.fsencode(socket_probe))} bytes")) + if runtime_ok: + probe = subprocess.run( + ["podman", "info", "--format", "{{.Host.Security.Rootless}}"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + checks.append(("rootless", probe.returncode == 0 and probe.stdout.strip() == "true", probe.stdout.strip() or probe.stderr.strip())) + else: + checks.append(("rootless", False, "rootless Podman is required")) + print("check\tstatus\tdetail") + for name, ok, detail in checks: + print(f"{name}\t{'ok' if ok else 'FAIL'}\t{detail}") + if not all(ok for _, ok, _ in checks): + raise SystemExit(1) + return + raise SystemExit(f"Unsupported workspace command: {args.workspace_command}") @@ -4001,29 +4090,31 @@ def print_config_summary(config_path: Path) -> None: def print_main_overview(config_path: Path) -> None: - print("RVCTL - launcher workspace RV") + print("STEMCTL - launcher kart pracy STEM") print() print("Usage:") - print(" ./rvctl [options]") - print(" ./rvctl tokens [options]") - print(" ./rvctl --help") + print(" ./stemctl [options]") + print(" ./stemctl tokens [options]") + print(" ./stemctl --help") print() print("Commands:") print_table( ["command", "arguments", "purpose"], [ ["show-config", "", "show resolved paths and Git defaults"], - ["workspace", "show|sync", "show or update meta/workspace-info"], + ["workspace", "show|sync|migrate|doctor", "manage and diagnose workspace"], ["series", "list|use|cards|fetch|tasks", "work with series, cards and tasks"], ["card", "use [series] card", "set the default card, optionally with the default series"], ["tasks", "list|show|switch", "shortcut for tasks in the default card"], - ["env", "list|sync|build", "manage rv32i/host container environment"], - ["debug", "rv32i|host [selector]", "open nvim + gdb in the selected container profile"], - ["run", "host [selector]", "build and run selected task natively in host container"], - ["shell", "rv32i|host [selector]", "open a shell in the selected container profile"], + ["env", "list|sync|ensure|build", "build three container profiles from Dockerfile"], + ["build/test/run/debug", "PROFILE [selector]", "execute a card action"], + ["deploy", "rp2350 [selector] --target TARGET", "flash a physical RP2350"], + ["start/status/attach/stop/rm", "PROFILE [selector]", "manage a persistent rootless container"], + ["probe", "list", "discover RP2350 probes and BOOTSEL targets"], + ["mcp", "tmux|nvim INSTANCE", "serve one verified container session over stdio"], ["list-series", "", "compat alias for series list"], ["list-cards", "[series]", "compat alias for series cards list"], - ["tmux-container", "[series] [card]", "start tmux with container in pane 0"], + ["tmux-container", "[series] [card]", "compat: run canonical rootless stem shell in pane 0"], ["submission", "[series] [card] --class K [--task T]", "prepare answer repo and student branch"], ["tokens", "list|compare|cmp|sync|update|remove|rm|add|read|stats|write", "manage tokens.json and Git remote credentials"], ], @@ -4033,17 +4124,13 @@ def print_main_overview(config_path: Path) -> None: print_table( ["step", "command", "result"], [ - ["1", "./rvctl tokens compare", "compare repo remotes with tokens.json"], - ["2", "./rvctl workspace sync", "clone or update workspace-info"], - ["3", "./rvctl series list", "choose a series"], - ["4", "./rvctl series use inf", "set the default series"], - ["5", "./rvctl series cards list inf", "choose a card"], - ["6", "./rvctl card use bss", "set the default card"], - ["7", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"], - ["8", "./rvctl tasks list", "list tasks from the default card"], - ["9", "./rvctl tasks switch 4", "switch to the answer branch for task 4"], - ["10", "./rvctl debug rv32i 4", "debug task 4 in RV32I/Hazard3"], - ["11", "./rvctl debug host 4", "debug task 4 natively in a host container"], + ["1", "./stemctl workspace sync", "clone or update workspace-info"], + ["2", "./stemctl series cards fetch inf bss", "fetch the card on the host"], + ["3", "./stemctl env ensure native-amd64", "build or reuse the local native profile"], + ["4", "./stemctl test native-amd64 1", "run native golden/sanitizer tests"], + ["5", "./stemctl test hazard3-sim 1", "run the same vectors on Hazard3"], + ["6", "./stemctl debug hazard3-sim 1", "open common tmux/nvim/GDB UI"], + ["7", "./stemctl deploy rp2350 1 --target rp2350-rv --device /dev/bus/usb/001/006", "flash during a practical class"], ], ) print() @@ -4126,8 +4213,8 @@ def extract_config_path(argv: list[str]) -> tuple[Path, list[str]]: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="rvctl", - description="rvctl for rv cards and tools.", + prog=os.environ.get("STEM_CLI_NAME", Path(sys.argv[0]).name), + description="STEM card launcher for native AMD64, Hazard3 simulation and RP2350.", ) parser.add_argument( "--config", @@ -4149,40 +4236,76 @@ def build_parser() -> argparse.ArgumentParser: workspace_subparsers.add_parser("show", help="Show workspace-info configuration.") 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( + "migrate", help="Expose legacy .rv state as .stem without deleting data." + ) + workspace_migrate_parser.add_argument("--dry-run", action="store_true", help="Only list state directories.") + workspace_subparsers.add_parser("doctor", help="Check workspace, rootless runtime and MCP socket budget.") - env_parser = subparsers.add_parser("env", help="Manage rv32i/host container environments.") + env_parser = subparsers.add_parser("env", help="Manage the three canonical container environments.") env_subparsers = env_parser.add_subparsers(dest="env_command", required=True) env_subparsers.add_parser("list", help="List supported environment profiles.") env_sync_parser = env_subparsers.add_parser("sync", help="Clone or update rv32i-hazard3-student-env into tools/.") env_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.") env_build_parser = env_subparsers.add_parser("build", help="Build one container profile.") - env_build_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile.") + env_build_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.") env_build_parser.add_argument("--dry-run", action="store_true", help="Print command without building.") + env_ensure_parser = env_subparsers.add_parser("ensure", help="Reuse or locally build a profile from Dockerfile.") + env_ensure_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.") + env_ensure_parser.add_argument("--dry-run", action="store_true", help="Print command without building.") - debug_parser = subparsers.add_parser("debug", help="Open selected task in nvim + debugger inside a container profile.") - debug_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile: rv32i or host.") - debug_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].") - debug_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.") - debug_parser.add_argument("--instance", help="Container/tmux instance name.") - debug_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.") - debug_parser.add_argument("--pane", help="Send the command to a host tmux pane, for example 0, node:0, :node.0 or %3.") - debug_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.") + for action, help_text in ( + ("build", "Build the selected task inside a profile."), + ("test", "Run the selected task tests inside a profile."), + ("run", "Run the selected task without implicit deployment."), + ("debug", "Open the selected task in the common tmux/nvim debugger UI."), + ("deploy", "Deploy an RP2350 artifact using the explicitly selected probe."), + ("shell", "Open a shell in the persistent profile container."), + ("start", "Create or start the persistent profile container."), + ("status", "Show status of the selected profile container."), + ("attach", "Attach to the selected profile container UI."), + ("stop", "Stop the selected profile container."), + ("rm", "Remove the selected profile container; sources remain on the host."), + ): + action_parser = subparsers.add_parser(action, help=help_text) + action_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.") + action_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].") + action_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.") + action_parser.add_argument("--target", help="Target override, for example rp2350-rv or rp2350-arm.") + action_parser.add_argument("--instance", help="Stable logical container instance name.") + action_parser.add_argument("--pane", help="Send command to a host tmux pane (for example 0 or %%3).") + action_parser.add_argument("--dry-run", action="store_true", help="Print command without changing state.") + if action in {"debug", "shell", "attach"}: + action_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.") + if action in {"debug", "deploy"}: + action_parser.add_argument( + "--device", + help="Exact host device path reported by 'stemctl probe list'.", + ) + if action == "deploy": + action_parser.add_argument( + "--backend", + choices=["probe", "bootsel"], + default="probe", + help="Deployment transport (default: probe).", + ) + if action == "run": + action_parser.add_argument( + "--allow-existing-firmware", + action="store_true", + help="For RP2350 only: run firmware whose deployment cannot be verified.", + ) - run_parser = subparsers.add_parser("run", help="Build and run selected task inside a container profile.") - run_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile. Currently host supports run.") - run_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].") - run_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.") - run_parser.add_argument("--instance", help="Container instance name.") - run_parser.add_argument("--pane", help="Send the command to a host tmux pane, for example 0, node:0, :node.0 or %3.") - run_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.") + probe_parser = subparsers.add_parser("probe", help="Discover RP2350 debug probes and BOOTSEL devices.") + probe_subparsers = probe_parser.add_subparsers(dest="probe_command", required=True) + probe_list_parser = probe_subparsers.add_parser("list", help="List usable probe/target devices without sudo.") + probe_list_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") - shell_parser = subparsers.add_parser("shell", help="Open a shell inside a container profile.") - shell_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile: rv32i or host.") - shell_parser.add_argument("selector", nargs="*", help="Optional selector: [card] or [series card].") - shell_parser.add_argument("--task", help="Task used only for instance naming.") - shell_parser.add_argument("--instance", help="Container instance name.") - shell_parser.add_argument("--pane", help="Send the command to a host tmux pane, for example 0, node:0, :node.0 or %3.") - shell_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.") + mcp_parser = subparsers.add_parser("mcp", help="Run the container-bundled MCP server for one logical instance.") + mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_kind", required=True) + for kind in ("tmux", "nvim"): + kind_parser = mcp_subparsers.add_parser(kind, help=f"Serve {kind} tools over MCP stdio.") + kind_parser.add_argument("instance", help="Stable logical instance name (required to avoid cross-container selection).") tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.") tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True) @@ -4205,14 +4328,14 @@ def build_parser() -> argparse.ArgumentParser: tmux_parser = subparsers.add_parser( "tmux-container", - help="Create a tmux session with pane 0 running the container shell for a selected card.", + help="Compatibility wrapper: create host tmux with the canonical rootless stem shell in pane 0.", ) tmux_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/bss'.") tmux_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.") - tmux_parser.add_argument("--profile", choices=sorted(ENV_PROFILES), default="rv32i", help="Container profile: rv32i or host.") + tmux_parser.add_argument("--profile", choices=ENV_PROFILE_CHOICES, default="hazard3-sim", help="Container profile.") tmux_parser.add_argument("--session", help="tmux session name.") tmux_parser.add_argument("--window", help="tmux window name.") - tmux_parser.add_argument("--instance", help="RV_INSTANCE passed to the tool launcher.") + tmux_parser.add_argument("--instance", help="Stable STEM_INSTANCE passed to the canonical runtime.") tmux_parser.add_argument("--attach", action="store_true", help="Attach to the session after creation.") tmux_parser.add_argument("--dry-run", action="store_true", help="Print commands instead of running tmux.") @@ -4431,7 +4554,8 @@ def main() -> int: parser = build_parser() args = parser.parse_args() config = load_config(args.config) - if args.command in {"list-series", "list-cards", "series", "card", "tasks", "tmux-container", "submission", "debug", "run", "shell"}: + profile_actions = {"build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm"} + if args.command in {"list-series", "list-cards", "series", "card", "tasks", "tmux-container", "submission"} | profile_actions: ensure_workspace_info(config) if args.command == "show-config": @@ -4449,14 +4573,14 @@ def main() -> int: if args.command == "env": run_env(config, args) return 0 - if args.command == "debug": - run_profile_action(config, args, "debug") + if args.command == "probe": + run_probe(config, args) return 0 - if args.command == "run": - run_profile_action(config, args, "run") + if args.command == "mcp": + run_mcp(config, args) return 0 - if args.command == "shell": - run_profile_action(config, args, "shell") + if args.command in profile_actions: + run_profile_action(config, args, args.command) return 0 if args.command == "tasks": run_tasks(config, args) diff --git a/stemctl b/stemctl new file mode 100755 index 0000000..b3a6f90 --- /dev/null +++ b/stemctl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +export STEM_CLI_NAME=stemctl +exec python3 "$SCRIPT_DIR/rvctl.py" "$@" diff --git a/tests/test_stemctl.py b/tests/test_stemctl.py new file mode 100644 index 0000000..3602bdc --- /dev/null +++ b/tests/test_stemctl.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import json +import io +import os +import subprocess +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import rvctl # noqa: E402 + + +class StemctlContractTests(unittest.TestCase): + def test_new_command_help_never_crashes(self) -> None: + script = Path(__file__).resolve().parents[1] / "rvctl.py" + commands = ["env", "build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm", "probe", "mcp"] + for command in commands: + with self.subTest(command=command): + completed = subprocess.run( + [sys.executable, str(script), command, "--help"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + + def test_profile_aliases_are_canonical(self) -> None: + self.assertEqual(rvctl.normalize_env_profile("host"), "native-amd64") + self.assertEqual(rvctl.normalize_env_profile("rv32i"), "hazard3-sim") + self.assertEqual(rvctl.normalize_env_profile("rp2350"), "rp2350") + + def test_instance_is_stable_across_actions(self) -> None: + debug = rvctl.default_env_instance("hazard3-sim", "inf", "bss", "task1", "debug") + stop = rvctl.default_env_instance("hazard3-sim", "inf", "bss", "task1", "stop") + self.assertEqual(debug, stop) + self.assertEqual(debug, "hazard3-sim-inf-bss-t1") + + def test_lifecycle_with_explicit_instance_needs_no_card_or_defaults(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + workspace.mkdir() + tools = root / "tools" + tools.mkdir() + (tools / "stem").write_text("#!/bin/sh\n", encoding="utf-8") + config_path = root / "workspace.json" + config_path.write_text( + json.dumps( + { + "workspace_root": str(workspace), + "original_root": str(root / "original"), + "tools_root_candidates": [str(tools)], + } + ), + encoding="utf-8", + ) + config = rvctl.load_config(config_path) + for action in ("status", "attach", "stop", "rm"): + with self.subTest(action=action): + args = SimpleNamespace( + profile="hazard3-sim", + selector=[], + pane=None, + task=None, + instance="lesson-1", + editor=None, + target=None, + device=None, + backend=None, + allow_existing_firmware=False, + dry_run=True, + ) + output = io.StringIO() + with ( + mock.patch.object(rvctl, "resolve_env_target") as resolve, + redirect_stdout(output), + ): + rvctl.run_profile_action(config, args, action) + resolve.assert_not_called() + rendered = output.getvalue() + self.assertIn("instance\tlesson-1", rendered) + self.assertIn(f"./stem {action} --target hazard3-baremetal", rendered) + + def test_tmux_container_delegates_to_canonical_rootless_runtime(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + card = workspace / "series" / "inf" / "card" + (card / "src/tasks").mkdir(parents=True) + (card / "src/tasks/task1.c").write_text("int main(void) { return 0; }\n", encoding="utf-8") + tools = root / "tools" + tools.mkdir() + (tools / "stem").write_text("#!/bin/sh\n", encoding="utf-8") + config_path = root / "workspace.json" + config_path.write_text( + json.dumps( + { + "workspace_root": str(workspace), + "original_root": str(root / "original"), + "tools_root_candidates": [str(tools)], + "defaults": {"series": "inf", "card": "card", "task": "task1"}, + } + ), + encoding="utf-8", + ) + config = rvctl.load_config(config_path) + args = SimpleNamespace( + series="inf", + card="card", + profile="hazard3-sim", + session="test-shell", + window="shell", + instance=None, + attach=False, + dry_run=True, + ) + output = io.StringIO() + with ( + mock.patch.object(rvctl, "tmux_target_exists", return_value=False), + mock.patch.object(rvctl, "resolve_workspace_card_path", return_value=card), + redirect_stdout(output), + ): + rvctl.run_tmux_container(config, args) + rendered = output.getvalue() + self.assertIn("./stem shell", rendered) + self.assertIn(f"STEM_REPO_PATH={card}", rendered) + self.assertIn("STEM_SOCKET_ROOT=", rendered) + self.assertNotIn("docker compose", rendered) + + def test_workspace_doctor_rejects_docker_runtime(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + (workspace / "series").mkdir(parents=True) + tools = root / "tools" + tools.mkdir() + (tools / "stem").write_text("#!/bin/sh\n", encoding="utf-8") + config_path = root / "workspace.json" + config_path.write_text( + json.dumps( + { + "workspace_root": str(workspace), + "original_root": str(root / "original"), + "tools_root_candidates": [str(tools)], + } + ), + encoding="utf-8", + ) + config = rvctl.load_config(config_path) + output = io.StringIO() + with mock.patch.dict(os.environ, {"STEM_RUNTIME": "docker"}), redirect_stdout(output): + with self.assertRaisesRegex(SystemExit, "1"): + rvctl.run_workspace(config, SimpleNamespace(workspace_command="doctor")) + rendered = output.getvalue() + self.assertIn("runtime\tFAIL\tdocker is not supported", rendered) + self.assertIn("rootless\tFAIL\trootless Podman is required", rendered) + + def test_existing_legacy_workspace_is_detected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + legacy = root / "rv" + legacy.mkdir() + config_path = root / "workspace.json" + config_path.write_text( + json.dumps( + { + "workspace_root": str(root / "stem"), + "legacy_workspace_root": str(legacy), + "original_root": str(root / "original"), + } + ), + encoding="utf-8", + ) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("STEM_WORKSPACE_ROOT", None) + config = rvctl.load_config(config_path) + self.assertEqual(config.workspace_root, legacy.resolve()) + self.assertEqual(config.series_root, (legacy / "series").resolve()) + + def test_xdg_socket_path_stays_inside_budget(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": str(root)}): + socket_root = rvctl.default_runtime_socket_root(root / "workspace") + path = socket_root / ("t" * 12) / ("i" * 12) / ("c" * 12) / "n.sock" + self.assertLessEqual(len(os.fsencode(path)), 100) + + def test_action_exports_new_and_legacy_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + card = workspace / "series" / "inf" / "card" + card.mkdir(parents=True) + tools = root / "tools" + tools.mkdir() + (tools / "stem").write_text("#!/bin/sh\n", encoding="utf-8") + config_path = root / "workspace.json" + config_path.write_text( + json.dumps( + { + "workspace_root": str(workspace), + "original_root": str(root / "original"), + "tools_root_candidates": [str(tools)], + } + ), + encoding="utf-8", + ) + config = rvctl.load_config(config_path) + with mock.patch.object(rvctl, "resolve_workspace_card_path", return_value=card): + command, details = rvctl.env_action_command( + config, + "rv32i", + "test", + "inf", + "card", + "task1", + target="hazard3-baremetal", + ) + self.assertEqual(details["profile"], "hazard3-sim") + self.assertIn("STEM_PROFILE=hazard3-sim", command) + self.assertIn("RV_PROFILE=hazard3-sim", command) + self.assertIn("./stem test --target hazard3-baremetal task1", command) + + def test_deploy_passes_explicit_device_and_backend(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + card = workspace / "series" / "inf" / "card" + card.mkdir(parents=True) + tools = root / "tools" + tools.mkdir() + (tools / "stem").write_text("#!/bin/sh\n", encoding="utf-8") + config_path = root / "workspace.json" + config_path.write_text( + json.dumps( + { + "workspace_root": str(workspace), + "original_root": str(root / "original"), + "tools_root_candidates": [str(tools)], + } + ), + encoding="utf-8", + ) + config = rvctl.load_config(config_path) + with mock.patch.object(rvctl, "resolve_workspace_card_path", return_value=card): + command, details = rvctl.env_action_command( + config, + "rp2350", + "deploy", + "inf", + "card", + "task1", + target="rp2350-rv", + device="/dev/bus/usb/001/006", + deploy_backend="probe", + ) + self.assertEqual(details["device"], "/dev/bus/usb/001/006") + self.assertEqual(details["backend"], "probe") + self.assertIn("--device /dev/bus/usb/001/006", command) + self.assertIn("--backend probe", command) + + def test_env_build_uses_source_profile_command(self) -> None: + source = (Path(__file__).resolve().parents[1] / "rvctl.py").read_text(encoding="utf-8") + self.assertIn('"profile-build"', source) + self.assertNotIn('"image-build",', source) + + def test_mcp_requires_explicit_instance(self) -> None: + parser = rvctl.build_parser() + args = parser.parse_args(["mcp", "nvim", "hazard3-inf-bss-task1"]) + self.assertEqual(args.mcp_kind, "nvim") + self.assertEqual(args.instance, "hazard3-inf-bss-task1") + + +if __name__ == "__main__": + unittest.main() diff --git a/workspace.json b/workspace.json index 168a33b..cb7a2e4 100644 --- a/workspace.json +++ b/workspace.json @@ -1,15 +1,13 @@ { - "version": 1, + "version": 2, "original_root": "~/dev/edu/repos/rv", "original_series_root": "~/dev/edu/repos/rv/series", - "workspace_root": "~/dev/workspace/rv", - "workspace_info_path": "~/dev/workspace/rv/meta/workspace-info/workspace.json", + "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", - "series_root": "~/dev/workspace/rv/series", - "socket_root": "~/dev/workspace/rv/sockets", - "token_file": "~/dev/workspace/rv/tokens/tokens.json", - "env_tool_url": "http://77.90.8.171:3001/edu-inf/rv32i-hazard3-student-env.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", "~/dev/workspace/rv/tools/rv32i-hazard3-student-env", "~/dev/workspace/rv/tools/rv32i-hazard3-env", "~/dev/edu/repos/rv/rv32i-hazard3-student-env",