feat: add three-profile STEM container workflow

This commit is contained in:
mpabi
2026-07-14 17:49:36 +02:00
parent 9c0b833371
commit 61d00a6992
12 changed files with 1819 additions and 882 deletions
+99 -455
View File
@@ -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/<seria>/<karta>/
├── 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/<thread12>/<instance12>/<container12>/t.sock
$XDG_RUNTIME_DIR/stem/<thread12>/<instance12>/<container12>/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`
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 `<login>T<numer_zadania>a` i ustawia mu upstream
na `r1a/<branch>`:
```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)
+50 -41
View File
@@ -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/<instance>/`.
- stan sesji zapisany pod `.stem/instances/<instance>/`.
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/<instance>/`,
- katalog stanu `.stem/instances/<instance>/`, 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/<instance>/`,
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
<card>/.rv/<instance>/
$XDG_RUNTIME_DIR/stem/<thread-key>/<instance-key>/<cid12>/
```
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/<instance>/`.
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
+488
View File
@@ -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/<instance>`;
- 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/<profile>/<target>/<task>/<run-id>/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/<thread-key>/<instance-key>/<cid12>/t.sock
$XDG_RUNTIME_DIR/stem/<thread-key>/<instance-key>/<cid12>/n.sock
$XDG_RUNTIME_DIR/stem/<thread-key>/<instance-key>/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.
+250 -129
View File
@@ -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/<instance>
STEM_ARTIFACT_DIR=/workspace/.stem/artifacts/<profile>/<target>/<task>
```
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
<profile>-<series>-<card>-<task>
```
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/<thread-key>/<instance-key>/<cid12>/t.sock
$XDG_RUNTIME_DIR/stem/<thread-key>/<instance-key>/<cid12>/n.sock
$XDG_RUNTIME_DIR/stem/<thread-key>/<instance-key>/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 |
+109
View File
@@ -0,0 +1,109 @@
# Migracja `rv-launcher` do `stem-launcher`
Status: M1M3 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/<instance>` 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.
+150
View File
@@ -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/<thread-key>/<instance-key>/<cid12>/t.sock
$XDG_RUNTIME_DIR/stem/<thread-key>/<instance-key>/<cid12>/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<N>`;
- 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 <repo-karty> 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.
+5 -2
View File
@@ -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`
+4
View File
@@ -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" "$@"
+372 -248
View File
@@ -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 <command> [options]")
print(" ./rvctl tokens <command> [options]")
print(" ./rvctl <command> --help")
print(" ./stemctl <command> [options]")
print(" ./stemctl tokens <command> [options]")
print(" ./stemctl <command> --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)
Executable
+5
View File
@@ -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" "$@"
+282
View File
@@ -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()
+5 -7
View File
@@ -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",