Compare commits

...

10 Commits

Author SHA1 Message Date
mpabi b0548f0e56 feat: support one Gitea organization per series 2026-07-19 16:40:05 +02:00
user 4dda6de387 fix: keep debugger pane maximized 2026-07-17 17:07:08 +02:00
user 8c7a67c471 docs: define container attach semantics 2026-07-17 14:25:01 +02:00
user 319e8b84f4 feat: orchestrate lesson sessions from stemctl 2026-07-17 13:31:36 +02:00
user 8d1379ec66 docs: document pinned workspace sources 2026-07-17 09:22:59 +02:00
user 45257057df feat: prepare pinned card workspace sources 2026-07-17 08:26:53 +02:00
user aa5335aca0 chore: select pointers workspace default 2026-07-17 08:25:20 +02:00
user 1d8f16303e docs: define host workspace and MCP access 2026-07-17 08:25:07 +02:00
user 1e1c124c9f feat: switch dynamic MCP container targets 2026-07-17 08:24:37 +02:00
mpabi 1a98c14654 chore: complete stem-launcher rename 2026-07-14 18:08:40 +02:00
16 changed files with 2945 additions and 107 deletions
+90 -2
View File
@@ -1,8 +1,9 @@
# STEM Launcher
`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.
budowanych kontenerów STEM. Zdalne repo nosi kanoniczną nazwę
`edu-tools/stem-launcher`; Gitea przekierowuje historyczny URL `rv-launcher`,
a `rvctl` pozostaje cichym wrapperem zgodności.
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
@@ -22,6 +23,23 @@ 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`.
## Pierwsza instalacja
Pierwszy klon launchera również należy do workspace. Nie uruchamiamy
`stemctl` z przypadkowego katalogu domowego ani z kontenera:
```bash
mkdir -p ~/dev/workspace/stem/tools
git clone http://77.90.8.171:3001/edu-tools/stem-launcher.git \
~/dev/workspace/stem/tools/stem-launcher
cd ~/dev/workspace/stem/tools/stem-launcher
./stemctl workspace sync
```
Od tego momentu wszystkie komendy `stemctl`, karty i repozytoria odpowiedzi
pozostają pod `~/dev/workspace/stem`. Kontener dostaje wybraną kartę jako
`/workspace`, ale nie jest miejscem przechowywania źródeł.
## Szybki start
```bash
@@ -41,6 +59,23 @@ socketu Podmana/Dockera ani całego `/dev`.
Pierwsze wywołanie danego profilu może potrwać, ponieważ buduje go ze
źródłowego Dockerfile. Kolejne korzystają z lokalnych warstw cache.
Pełną sesję lekcji można wybrać i kontrolować numerami z CLI:
```bash
./stemctl session choices --series inf --card 7 --task 4
./stemctl session reset hazard3-sim --series inf --card 7 --task 4 --step 12
./stemctl session refresh hazard3-sim --series inf --card 7 --task 4
./stemctl session attach hazard3-sim --series inf --card 7 --task 4
./stemctl session detach hazard3-sim --series inf --card 7 --task 4
```
`current:0` jest domyślnym pane: pane `0` okna tmuxa, w którym działa
wywołujący Codex. `session stage`/`session checkpoint` ustawia dowolny etap UML
online w Termdebug albo tylko na stronie z `--offline`. Semantykę poleceń,
w tym podpinanie kontenera przez `attach/detach`, opisuje
[referencja stemctl](doc/stemctl.md), a wybór checkpointów
[dokumentacja sesji](doc/session.md).
Wymuszenie samego przygotowania środowiska:
```bash
@@ -68,6 +103,33 @@ Domyślny układ:
└── tokens/tokens.json
```
Pierwszym repozytorium w `tools/` jest `stem-launcher`; to ono pobiera
workspace-info, źródła kart oraz repo środowiska. Pozostałe repozytoria są
zarządzane przez `stemctl`, a nie klonowane wewnątrz kontenera.
Karta jest montowana jako całe `/workspace`, łącznie z przypiętymi źródłami
symulatora i trwałymi artefaktami:
```text
~/dev/workspace/stem/series/<seria>/<karta>/
├── src/
├── vendor/Hazard3/
├── vendor/lab-runtime/
└── .stem/instances/.../build/
```
Przygotowanie i kontrola bez uruchamiania kontenera:
```bash
./stemctl env sources inf pointers
./stemctl env sources inf pointers --check
```
Każda zwykła akcja `build/test/run/debug` wykonuje przygotowanie automatycznie.
Launcher nie klonuje źródeł w kontenerze, nie tworzy symlinków do `/opt` i nie
nadpisuje zmodyfikowanego katalogu `vendor/`. Przypięcie pochodzi z
`sources.lock.json` repo środowiska.
Jeżeli istnieje tylko starszy `~/dev/workspace/rv`, launcher wykrywa go bez
niszczenia danych. Jawna migracja:
@@ -101,6 +163,30 @@ się w wybranym kontenerze:
Oddzielne wpisy klienta MCP mogą wskazywać inne nazwy instancji, więc jeden
Codex obsługuje kilka kontenerów bez globalnego „ostatniego socketu”.
Klient MCP działający stale na hoście może także przełączać oba narzędzia
atomowo przez wspólny wskaźnik `current`:
```bash
./stemctl mcp list
./stemctl mcp select rp2350-pointers-final
./stemctl mcp status
./stemctl mcp select 3aca2c1c4c7a
```
Selektor przyjmuje nazwę instancji, nazwę kontenera albo co najmniej 12 znaków
ID. Weryfikuje ID i label przez `podman inspect`, a także aktywnie sprawdza oba
serwery. Dopiero wtedy atomowo przełącza:
```text
$XDG_RUNTIME_DIR/stem/mcp-selected/current/n.sock
$XDG_RUNTIME_DIR/stem/mcp-selected/current/t.sock
```
Wskaźniki prowadzą do katalogu zawierającego aktualny `container12`, więc
odtworzony kontener nie może przejąć socketów poprzednika. Neovim i tmux
otwierają nowe połączenie przy każdym wywołaniu narzędzia MCP, dlatego zmiana
działa bez restartowania klienta.
## Komputer zdalny
Na komputer ucznia wchodzimy wyłącznie SSH z parą kluczy. Launcher, Git,
@@ -133,6 +219,8 @@ Nowe materiały powinny używać nazw kanonicznych.
- [plan architektury](doc/architecture-plan.md)
- [kontenery i interfejs](doc/containers.md)
- [wytyczne dokumentowania debugowania](doc/debug-documentation-guidelines.md)
- [sesja karty, UML, MCP i tmux z CLI](doc/session.md)
- [migracja nazw i workspace](doc/migration-stem-launcher.md)
- [serie i karty](doc/series.md)
- [tokeny Gitea](doc/tokens.md)
+89
View File
@@ -17,6 +17,10 @@ mieć wgląd w tę samą sesję, w której pracuje uczeń:
Dzięki temu agent widzi środowisko debugowania, a nie tylko statyczne pliki.
Punkt wejścia `stemctl` jest także projektem hostowym: pierwszy klon znajduje
się w `~/dev/workspace/stem/tools/stem-launcher`. Agent ani użytkownik nie
klonują launchera, kart lub repozytoriów odpowiedzi do kontenera.
## Aktualny fundament
`rv32i-hazard3-student-env` dostarcza źródła wspólnego modelu:
@@ -108,6 +112,91 @@ 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.
### Kto tworzy sockety
Hostowy launcher `stem` najpierw tworzy katalog runtime i przekazuje go
Podmanowi jako bind-mount pod **tą samą bezwzględną ścieżką**. Po uruchomieniu
interfejsu debuggera procesy wewnątrz kontenera tworzą sockety:
- `tmux` tworzy `t.sock`,
- `nvim` tworzy `n.sock`.
Nie są one kopiowane ani przekazywane przez sieć: host i kontener widzą ten sam
plik Unix socket w zamontowanym katalogu. ID kontenera jest częścią ścieżki,
więc nowy kontener po `rm/start` dostaje nowy katalog i nie może przypadkiem
obsłużyć socketu poprzednika.
### Dynamiczny wybór kontenera
Stały provider MCP na hoście używa dwóch krótkich ścieżek:
```text
$XDG_RUNTIME_DIR/stem/mcp-selected/current/n.sock
$XDG_RUNTIME_DIR/stem/mcp-selected/current/t.sock
```
Polecenie `stemctl mcp select INSTANCE_OR_CONTAINER_ID` weryfikuje registry,
pełny ID i label kontenera oraz oba żywe serwery. Następnie atomowo przełącza
symlink `current` dla obu socketów. Dzięki temu kolejna operacja MCP trafia do
wybranej sesji tmuxa i Neovima bez restartowania Codexa. `stemctl mcp list`
pokazuje tylko rekordy registry, a `stemctl mcp status` potwierdza bieżący
wybór.
Wybór pozostaje jawny: samo stworzenie kontenera tworzy jego katalog socketów,
ale nie przejmuje automatycznie aktywnego MCP innej sesji.
### Wiele kont użytkowników i pomoc nauczyciela
Sockety muszą pozostać prywatne dla unixowego konta, które uruchamia
kontener, np.:
```text
/run/user/<uid-ucznia>/stem/<thread>/<instance>/<container-id>/
```
Nie montujemy ich do wspólnego `/tmp`, nie zmieniamy grup socketów tmuxa i nie
udostępniamy ich przez port sieciowy. Codex nauczyciela łączy się przez SSH na
konkretne konto ucznia; po drugiej stronie ograniczony wrapper MCP łączy się
lokalnie z jego `n.sock` i `t.sock`. Stdio SSH jest transportem MCP — socket
Unix nie opuszcza komputera ucznia.
Każdy uczeń ma własny tmux i Neovim. Można uruchomić wiele serwerów MCP dla
tej samej sesji (np. ucznia i nauczyciela), ale oba mogą równocześnie edytować
bufor lub wysyłać klawisze, więc nie ma gwarancji arbitrażu zmian.
Dostęp nauczyciela rozdzielamy na dwa klucze SSH:
- zwykły klucz nauczycielski z pełną powłoką, używany wyłącznie do świadomej,
ręcznej interwencji na koncie ucznia;
- osobny klucz automatyzacji Codexa z forced command, bez TTY, forwardingu i
powłoki, ograniczony do `nvim`, `tmux`, `status` i dozwolonych kontenerów.
Pełny klucz nauczyciela nie trafia do kontenera ani do konfiguracji MCP.
### Poziomy uprawnień MCP
Każdy wpis MCP otrzymuje jawny poziom dostępu. Poziom jest własnością klucza
SSH i wrappera po stronie konta ucznia, a nie ustawieniem przekazywanym przez
model lub klienta MCP:
| Poziom | Przeznaczenie | Dozwolone działania |
| --- | --- | --- |
| `observe` | podgląd postępów | stan nvim, lista i capture pane’ów tmuxa, logi i metadane; bez edycji i wysyłania klawiszy |
| `assist` | wspólne rozwiązywanie problemu | działania debuggera i jawnie dozwolona edycja/panele; bez ogólnego terminala oraz bez poleceń powłoki |
| `full` | interwencja nauczyciela | pełne sterowanie nvimem i tmuxem, w tym terminalem w wybranym kontenerze, jako konto ucznia |
`full` jest równoważny interaktywnej pracy na koncie ucznia w granicach
wybranego kontenera. W szczególności arbitralne `nvim-remote-expr`, `nvim-ex`
lub wysyłanie poleceń do panea tmuxa mogą uruchomić kod. Taki wpis tworzymy
wyłącznie dla nauczyciela i zapisujemy w audycie konto, instancję, container ID,
czas oraz użyty poziom.
Nie wystarczy przekazać `MCP_ACCESS_LEVEL=observe` do tego samego pełnego
serwera: niższe poziomy wymagają osobnych providerów z allowlistą narzędzi.
W przeciwnym razie użytkownik nadal mógłby użyć ogólnego Ex/Vimscriptu albo
terminala do obejścia ograniczenia. Klucz `full` może korzystać z obecnego
providera STEM, ponieważ jego możliwości są celowo pełne.
## Role profili
Profil `hazard3-sim`:
+59
View File
@@ -195,6 +195,38 @@ re-enumeracji. Nie przekazuje całego `/dev`. Build i test offline nie wymagają
artefaktu. Bez niego odmawia wykonania, chyba że użytkownik jawnie zaakceptuje
istniejący firmware. Zapobiega to testowaniu starego programu.
## Źródła projektu
Źródła kart i projektów pobiera wyłącznie `stemctl` na hoście, zawsze pod
`~/dev/workspace` danego konta Unix. Kanoniczny workspace STEM ma postać
`~/dev/workspace/stem`; nie używamy do pracy katalogów kontenera ani
przypadkowych klonów poza `~/dev/workspace`. Pierwszy klon launchera trafia do
`~/dev/workspace/stem/tools/stem-launcher` i od niego zaczyna się cały
bootstrap workspace. Typowy przepływ to:
```bash
stemctl workspace sync
stemctl series cards fetch inf pointers
stemctl debug rp2350 inf pointers 4
```
Katalog karty pozostaje na hoście, na przykład
`~/dev/workspace/stem/series/inf/pointers/`, i jest przekazywany kontenerowi
jako bind-mount `/workspace`. Nie klonujemy repozytoriów źródłowych wewnątrz
kontenera: kontenery są odtwarzalne i mogą zostać usunięte, natomiast host
zachowuje Git, zmiany ucznia, odpowiedzi i historię pracy. W obrazie lub
osobnym cache volume mogą znajdować się wyłącznie narzędzia, zależności i
odtwarzalne cache budowania.
Dla Hazard3 po przygotowaniu przez `stemctl env sources SERIES CARD` ten sam
bind-mount zawiera również rzeczywiste (niebędące symlinkami) katalogi
`vendor/Hazard3` i `vendor/lab-runtime`. Program, testbench oraz GDB korzystają
wyłącznie z nich. Build testbencha i ELF trafia do
`.stem/instances/<instance>/build`, a DWARF zachowuje ścieżki
`/workspace/vendor/Hazard3/...` i `/workspace/src/...`. `/opt` pozostaje
miejscem toolchainów i programów obrazu; nie jest źródłem kodu wyświetlanego w
Neovimie ani GDB.
## Instancje i sockety MCP
Stabilna nazwa logiczna:
@@ -227,6 +259,33 @@ Publiczne wejścia launchera to `stemctl mcp tmux INSTANCE` oraz
zweryfikowanego kontenera. Neovim MCP publikuje również stan i komendy
Termdebug/nvim-dap.
Dla jednego stale działającego klienta hostowego dostępny jest również jawny,
atomowy przełącznik obu socketów:
```bash
stemctl mcp list
stemctl mcp select INSTANCE_OR_CONTAINER_ID
stemctl mcp status
```
`mcp select` sprawdza registry, pełny ID i label kontenera, aktywnie testuje
serwery Neovima i tmuxa, a następnie jednym `rename(2)` przełącza symlink
`$XDG_RUNTIME_DIR/stem/mcp-selected/current`. Jeśli choć jeden serwer nie
odpowiada, poprzedni wybór pozostaje bez zmian.
Katalog socketów jest tworzony przez launcher na hoście i bind-mountowany do
kontenera pod identyczną ścieżką. Dopiero `nvim` i `tmux` działające w
kontenerze tworzą odpowiednio `n.sock` i `t.sock`; hostowy Codex łączy się z
tym samym plikiem przez widok hosta. Sockety nie są przekazywane przez sieć ani
nie dają kontenerowi dostępu do socketu Podmana.
Połączenia MCP mają profil `observe`, `assist` albo `full`. `full` jest
przeznaczony dla nauczyciela: może wykonywać dowolne działania dostępne w
Neovimie, tmuxie i terminalu wybranego kontenera jako konto ucznia. Profile
`observe` i `assist` muszą używać oddzielnych, ograniczonych providerów;
nie wolno udawać ograniczenia przez samą zmienną środowiskową przy providerze
udostępniającym ogólny Vimscript lub polecenia panea.
MCP nie jest czwartym kontenerem. Bridge jest częścią `dev-ui-base`, a hostowy
resolver jest częścią `stem-launcher`.
+46
View File
@@ -0,0 +1,46 @@
# Wytyczne dokumentowania debugowania
## Cel
Zrzut debuggera jest dowodem pomiaru, nie dekoracją. Ma pozwolić uczniowi
połączyć jedną operację w C z instrukcjami RISC-V, rejestrami, ramką stosu i
konkretnymi bajtami w RAM.
## Kadr dowodowy
- Używaj rzeczywistej sesji `stemctl debug` z karty, symulatora albo płytki.
- Zatrzymaj program w jednym nazwanym punkcie: breakpoint, checkpoint albo
instrukcja bezpośrednio przed obserwowaną operacją.
- Zachowaj wspólny układ: dashboard Termdebug/GDB po lewej, źródło C po
prawej, listing `.lst` pod źródłem. Listing musi być zsynchronizowany z PC.
- Pokaż tylko dane potrzebne do tezy: wywołanie w C, odpowiadającą instrukcję,
argument w rejestrze, fragment stosu albo pamięć w RAM.
- Dołącz podpis z platformą, taskiem i stanem pomiaru, na przykład „przed
pierwszym przydziałem pamięci”.
## Oznaczenia
- Stosuj najwyżej cztery krótkie oznaczenia numeryczne na jednym kadrze.
- Każdy numer ma odpowiadać jednemu zdaniu w podpisie lub legendzie.
- Bieżący kolor oznaczeń to czerwony: oznacza „zatrzymaj się i sprawdź”.
Paleta może później ulec zmianie, ale numeracja i podpis muszą pozostać
zrozumiałe bez koloru.
- Nie zasłaniaj kodu ani nie zmieniaj jego treści. Zachowaj surowy zrzut jako
źródło, a adnotowany obraz zapisz jako osobny plik.
## Wstawienie do karty
- Zasoby zapisuj w `doc/assets/`, np.
`task04-first-allocation-annotated.png`.
- W `doc/main.tex` umieść obraz blisko instrukcji, której dotyczy, oraz dodaj
zwięzły podpis wyjaśniający numery.
- Po zmianie zbuduj PDF skryptem `scripts/render_pdf.sh` i sprawdź stronę
wynikową w rozmiarze A4.
- W materiałach dla ucznia używaj słowa **RAM**, nie skrótu „SRAM”.
## Przykład referencyjny
Karta `inf/pointers`, Task04: `alloc_local(5)` przed pierwszym przydziałem.
Kadr pokazuje wywołanie C, `li a0,5` i `jal alloc_local`, wartość `a0=5` oraz
pusty `allocbuf`. Taki obraz dokumentuje związek źródła, ABI i pamięci bez
zastępowania go opisem narracyjnym.
+14 -15
View File
@@ -1,7 +1,6 @@
# 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
Status: M1M4 wdrożone; M5 pozostaje okresem zgodności
Data: 2026-07-14
## Dlaczego zmieniamy nazwę
@@ -34,10 +33,10 @@ na serwerze. Odwrotna kolejność zepsułaby bootstrap, token records, remotes,
### M1 — alias CLI (wdrożone)
- dodać wykonywalny `stemctl` wskazujący tę samą implementację;
- pozostawić `rvctl` jako wrapper;
- pozostawić `rvctl` jako cichy 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.
- `rvctl` nie dopisuje ostrzeżeń do stderr i zachowuje format stdout/stderr
potrzebny istniejącym skryptom.
### M2 — konfiguracja i ścieżki (wdrożone)
@@ -59,19 +58,19 @@ nie usuwa starego katalogu przed zweryfikowaniem nowego.
- kontenery otrzymują labels z nazwą kanoniczną, nie aliasem;
- wyniki zapisują nazwę kanoniczną oraz opcjonalne `requested_alias`.
### M4 — repo Gitea (odłożone)
### M4 — repo Gitea (wdrożone 2026-07-14)
Po wydaniu kompatybilnego launchera:
Po wydaniu kompatybilnego launchera wykonano:
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.
1. zmianę nazwy na `edu-tools/stem-launcher` przez API Gitea;
2. aktualizację lokalnego `origin`, bootstrapu, dokumentacji i przykładów
token records;
3. weryfikację clone/fetch/push przez nowy URL;
4. weryfikację przekierowania starej nazwy przez Gitea;
5. zachowanie wrappera `rvctl` i pozostałych aliasów na okres zgodności.
Zmiana repo na Gitea jest operacją administracyjną i nie jest wykonywana przez
samą aktualizację dokumentacji.
Operację administracyjną wykonano po SSH do VPS i przez ograniczony token API;
sekret nie znajduje się w repo ani w konfiguracji launchera.
### M5 — wycofanie kompatybilności
+14 -14
View File
@@ -19,11 +19,11 @@ Workspace służy do klonów roboczych i ćwiczeń:
Typowy układ:
```text
~/dev/edu/repos/rv/rv-launcher
~/dev/edu/repos/stem/stem-launcher
~/dev/edu/repos/rv/rv32i-hazard3-env
~/dev/edu/repos/rv/series/<seria>/<karta>
~/dev/workspace/rv/meta/workspace-info
~/dev/workspace/rv/tools/rv-launcher
~/dev/workspace/stem/tools/stem-launcher
~/dev/workspace/rv/tools/rv32i-hazard3-env
~/dev/workspace/rv/series/<seria>/<karta>
```
@@ -46,7 +46,7 @@ Komendy launchera pracują na `series_root`, czyli na klonach roboczych.
Repo treningowe launchera trzymaj pod:
```bash
~/dev/workspace/rv/tools/rv-launcher
~/dev/workspace/stem/tools/stem-launcher
```
Podstawowy bootstrap wygląda tak:
@@ -54,8 +54,8 @@ Podstawowy bootstrap wygląda tak:
```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
git clone http://77.90.8.171:3001/edu-tools/stem-launcher.git
cd stem-launcher
```
Główne pliki CLI:
@@ -89,7 +89,7 @@ tokenem do zdalnego endpointu. Jeśli launcher zobaczy URL w formacie
Przykład:
```bash
git remote add r1 http://u1:TOKEN@77.90.8.171:3001/edu-tools/rv-launcher.git
git remote add r1 http://u1:TOKEN@77.90.8.171:3001/edu-tools/stem-launcher.git
git fetch r1 main
git switch --track -c main r1/main
```
@@ -120,7 +120,7 @@ Minimalny format pliku:
},
"user": "u1",
"org": "edu-tools",
"repo": "rv-launcher"
"repo": "stem-launcher"
}
]
}
@@ -757,7 +757,7 @@ Typowy wynik:
remotes
item remote kind server proto host org repo user token result url
---- ------ ----------- ------ ----- ------------------ --------- ----------- ---- ------------ -------------- --------------------------------------
1 r1 auth gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 e59cc...13be found http://77.90.8.171:3001/edu-tools/...
1 r1 auth gitea http 77.90.8.171:3001 edu-tools stem-launcher u1 e59cc...13be found http://77.90.8.171:3001/edu-tools/...
```
Przykład:
@@ -784,7 +784,7 @@ Typowy wynik:
tokens
item server proto host org repo user remote token_ref token valid scope org repo
---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- aAimnopru oawrc- oawr--
1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 * e59cc...13be forever wwwwwwwww +++++ ++++
1 gitea http 77.90.8.171:3001 edu-tools stem-launcher u1 r1 r1 * e59cc...13be forever wwwwwwwww +++++ ++++
```
`token_ref` jest komórką stałej szerokości: nazwa tokena jest po lewej, a marker
@@ -827,8 +827,8 @@ Typowy wynik:
tokens
item source kind server proto host org repo user remote token valid scope org repo
---- ------ ----- ------ ----- ------------------ --------- ----------- ---- ------ ------------ ------------------- aAimnopru oawrc- oawr--
1 store auth gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 e59cc...13be forever --------- +++++ ++++
2 remote auth gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 e59cc...13be
1 store auth gitea http 77.90.8.171:3001 edu-tools stem-launcher u1 r1 e59cc...13be forever --------- +++++ ++++
2 remote auth gitea http 77.90.8.171:3001 edu-tools stem-launcher u1 r1 e59cc...13be
```
Przykłady:
@@ -866,7 +866,7 @@ Przykłady:
```bash
./rvctl tokens add r1
./rvctl tokens add r1 --user u1 --org edu-tools --repo rv-launcher
./rvctl tokens add r1 --user u1 --org edu-tools --repo stem-launcher
```
## `tokens sync remote REMOTE_ID`
@@ -885,7 +885,7 @@ Przykład:
```bash
./rvctl tokens sync remote r1
./rvctl tokens sync remote r1 --repo ~/dev/workspace/rv/tools/rv-launcher
./rvctl tokens sync remote r1 --repo ~/dev/workspace/stem/tools/stem-launcher
```
## `tokens sync store REMOTE_ID`
@@ -996,7 +996,7 @@ token_path<TAB>...
tokens
item server proto host org repo user remote token_ref token valid scope org repo
---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- aAimnopru oawrc- oawr--
1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 * e59cc...13be forever -----w--- +++++ ++++
1 gitea http 77.90.8.171:3001 edu-tools stem-launcher u1 r1 r1 * e59cc...13be forever -----w--- +++++ ++++
status
item<TAB>value
+27 -20
View File
@@ -11,24 +11,30 @@ Komendy kart pracy dzielimy na trzy poziomy:
- `series cards` - operacje na kartach w ramach serii
- `series cards tasks` - operacje na zadaniach w ramach karty
`rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii
i kart pochodzi docelowo z publicznego manifestu
`~/dev/workspace/rv/meta/workspace-info/workspace.json`. Jeżeli manifestu nie
ma, komendy zależne od serii automatycznie klonują repo
`edu-workspace/workspace-info`. Jeżeli klonowanie nie jest możliwe, narzędzie
używa starego fallbacku przez `original_series_root`.
`stemctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Gdy
`workspace-info` jest dostępny, manifest jest jedynym źródłem listy serii i
kart. Przypadkowe katalogi robocze nie pojawiają się w katalogu. Fallback przez
`original_series_root` działa wyłącznie dla starego workspace bez manifestu.
Każda operacyjna seria jawnie deklaruje `source_org`. Docelowa konwencja to
`edu-<series-id>`, na przykład `freertos-c``edu-freertos-c`. Jedno repo
odpowiada jednej karcie; kolejnych wydań karty nie zapisujemy jako osobnych
repozytoriów ani trwałych branchy, tylko jako historię, tagi i wydania tego
repozytorium. Organizacja `edu` przechowuje control plane i katalog, nie setki
repozytoriów kart.
## Szybki przepływ
```bash
./rvctl tokens compare
./rvctl series list
./rvctl series use inf
./rvctl series cards list inf
./rvctl card use bss
./rvctl series cards fetch inf bss
./rvctl tasks list
./rvctl tasks switch 1
./stemctl workspace audit
./stemctl tokens compare
./stemctl series list
./stemctl series use freertos-c
./stemctl series cards list freertos-c
./stemctl card use FC02
./stemctl series cards fetch freertos-c FC02
./stemctl tasks list
./stemctl tasks switch 1
```
Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
@@ -37,10 +43,10 @@ Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`.
### `series list`
Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już
pobranych do lokalnego workspace. Jeżeli manifestu jeszcze nie ma, komenda
pobiera go automatycznie. Jeżeli `~/dev/workspace/rv/series` nie istnieje,
komenda tworzy ten katalog.
Listuje wyłącznie operacyjne serie z manifestu `workspace-info` oraz liczbę
kart już obecnych w lokalnym workspace. Logiczna seria może wskazywać wspólny,
przejściowy katalog fizyczny przez `workspace_dir`; na przykład `freertos-c`
jest obecnie mapowane na `series/freertos`.
```bash
./rvctl series list
@@ -49,8 +55,9 @@ komenda tworzy ten katalog.
Typowy wynik:
```text
fiz 3 0
inf 3 0
fiz 3 0
inf 9 5
freertos-c 11 11
```
Kolumny oznaczają: seria, liczba kart w źródłach, liczba kart pobranych do
+114
View File
@@ -0,0 +1,114 @@
# Sesja karty z wiersza poleceń
`stemctl session` łączy pięć selektorów w jeden kontrakt:
```text
seria → karta → Task → blok/faza/krok/snapshot UML → instancja kontenera
```
Każdy selektor przyjmuje stabilne ID. Seria, karta, Task, blok, faza, krok i
snapshot przyjmują także numery pokazywane przez `choices`. Numer karty jest
jednobazową pozycją w manifeście serii, więc nie zależy od tego, czy repo karty
zostało już pobrane. W przypadku kroków najpierw sprawdzany jest globalny numer
strzałki z diagramu, a przy jawnie wybranej fazie także lokalny numer kroku.
## Lista wyboru
```bash
./stemctl session choices --series inf --card 7 --task 4
./stemctl session choices --series inf --card pointers --json
```
Tabele zawierają kolumnę `status`:
- `opracowane` — materiał UML ma komplet recept replay/checkpoint;
- `robocze` — istnieje źródło albo częściowe metadane;
- `brak` — karta lub materiał nie znajduje się jeszcze w workspace.
## Sterowanie środowiskiem
```bash
# Utwórz debugger Task04 w pane 0 okna, z którego uruchomiono Codexa.
./stemctl session start hazard3-sim --series inf --card 7 --task 4
# Usuń i odtwórz kontener, po czym zatrzymaj maszynę na strzałce 12.
./stemctl session reset hazard3-sim \
--series inf --card 7 --task 4 --step 12
# Zachowaj kontener i ponownie podepnij istniejący debugger.
./stemctl session refresh hazard3-sim --series inf --card 7 --task 4
# Podepnij/odepnij kontener od pane zewnętrznego tmuxa. Procesy wewnątrz żyją dalej.
./stemctl session attach hazard3-sim --series inf --card 7 --task 4
./stemctl session detach hazard3-sim --series inf --card 7 --task 4
```
Domyślny cel `--pane current:0` oznacza pane `0` tego samego okna tmuxa, w
którym działa wywołujący Codex. Można podać jawne `%ID`, `sesja:okno.pane` albo
`--pane none`. Launcher odmawia zastąpienia pane, z którego sam został
uruchomiony, aby nie zakończyć Codexa. Pane Codexa jest dodatkowo oznaczone
PID-em pane w opcjach tmuxa; jawne `%ID` podane z innego terminala również
zostanie odrzucone. Świadome obejście tej drugiej ochrony wymaga
`--force-pane`; nie da się nim zastąpić pane wykonującego bieżącą komendę.
`attach` i `detach` operują na połączeniu pane z kontenerem. Neovim i
Termdebug są zawartością jego sesji. `detach` nie zatrzymuje kontenera,
symulatora, GDB, Neovima ani wewnętrznej sesji tmuxa.
## Wybór stanu UML
Równoważne przykłady:
```bash
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 --step 12
./stemctl session checkpoint hazard3-sim --series inf --card pointers --task 4 \
--snapshot task04.alloc5.commit
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 --stage alloc-5/7
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 \
--stage task04/allocator-flow/alloc-5/alloc5-commit
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 \
--phase 2 --step 3
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 --first
```
W ostatnim przykładzie `3` oznacza trzeci krok wewnątrz drugiej fazy, jeżeli
w tej fazie nie istnieje globalna strzałka numer 3.
Tryb online najpierw sprawdza pełną tożsamość karty (`id`, UUID, wersję i hash
źródłowego JSON-a), receptę checkpointu, profil, target oraz bind mount
`/workspace`. Następnie wybiera sockety MCP i przekazuje kontrolerowi
oczekiwane `instance/container_id/profile/target`. Replay zostaje odrzucony,
jeżeli globalny wybór MCP zmieni się przed aktywacją albo podczas niej. Dopiero
po udanym zatrzymaniu GDB publikowane są pozycja strony i `SYNC ON`.
`--offline` zmienia wyłącznie stan strony i wyłącza SYNC/sterowanie Neovimem.
`--control` dodatkowo uzbraja sterowanie klawiaturą Neovima; samo SYNC nie robi
tego automatycznie. `stage/checkpoint` wymaga jawnego selektora; pierwszy etap
wybiera się świadomie przez `--first`.
```bash
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 \
--step 12 --offline
./stemctl session status hazard3-sim --series inf --card 7 --task 4
./stemctl session status hazard3-sim --series inf --card 7 --task 4 \
--json --strict
```
Adres serwera karty można zmienić przez `--card-url` albo `STEM_CARD_URL`.
Limit oczekiwania na Neovima/GDB/MCP ustawia `--timeout`.
## Przyszłe strategie i adnotacje
Kluczem rozszerzeń pozostaje pełna pozycja
`task/block/phase/step/snapshot`. Pod tym kluczem będzie można później
przechowywać wiele strategii debugowania i adnotacji bez zmiany obecnego CLI.
Planowany kontrakt rozdziela treść od widoczności:
```text
strategy: id, label, commands, expected_observations
annotation: id, strategy_id, target, geometry, style, text, visible
```
`target` może wskazywać diagram, bufor/wiersz Neovima, rejestr, ramkę stosu
albo zakres pamięci. Operacje `show/hide/toggle` mają zmieniać widoczność bez
usuwania adnotacji; `add/remove` będą osobnymi, audytowalnymi operacjami.
+105
View File
@@ -0,0 +1,105 @@
# `stemctl` — sterowanie sesją zajęć
`stemctl session` łączy wybór materiału, kontenera, stanu UML i zewnętrznego
pane tmuxa. Szczegółowy opis wyboru checkpointów znajduje się w
[`session.md`](session.md); ten dokument definiuje znaczenie poleceń cyklu
życia.
## Model połączenia
```text
pane tmuxa na hoście
│ attach / detach
wybrany kontener
└── wewnętrzny tmux → Neovim + Termdebug/GDB + symulator lub RP2350
```
## Katalog i organizacje Gitea
`workspace-info/workspace.json` jest źródłem prawdy. Organizacja `edu` pełni
rolę control plane, a stabilne serie mają osobne organizacje o nazwie
`edu-<series-id>`. Karta jest repozytorium wewnątrz organizacji swojej serii.
```bash
stemctl workspace audit
stemctl series list
stemctl series cards list freertos-c
stemctl series cards show freertos-c FC02
```
`workspace audit` jest lokalny i deterministyczny: sprawdza kontrakt katalogu,
nie modyfikuje Gitea. Dzięki temu brak sieci nie uniemożliwia przeprowadzenia
zajęć z wcześniej zsynchronizowanego workspace.
Obiektem operacji `attach` i `detach` jest **połączenie pane z kontenerem**.
Neovim i Termdebug są zawartością sesji kontenera, a nie osobnym obiektem
podpinanym przez launcher.
- `attach` podpina wybrany, działający kontener do wskazanego pane i pokazuje
jego istniejącą sesję Neovim/Termdebug;
- `detach` zastępuje widok kontenera zwykłą powłoką hosta w tym pane;
- `detach` nie zatrzymuje kontenera, wewnętrznego tmuxa, Neovima, GDB,
symulatora ani połączenia z RP2350;
- ponowne `attach` wraca do tej samej działającej sesji;
- `stop` i `rm`, a nie `detach`, zmieniają cykl życia kontenera.
## Polecenia sesji
| Polecenie | Znaczenie |
| --- | --- |
| `session choices` | Wyświetla numerowane serie, karty, Taski i etapy UML. |
| `session status` | Sprawdza kontener, MCP, tożsamość karty i bieżący etap. |
| `session start` | Uruchamia kontener i jego środowisko pracy. |
| `session reset` | Usuwa i odtwarza kontener, następnie opcjonalnie odtwarza checkpoint. |
| `session refresh` | Zachowuje kontener i odnawia jego podpięcie oraz wybór MCP. |
| `session attach` | Podpina istniejący kontener do pane tmuxa na hoście. |
| `session detach` | Odpina kontener od pane bez zatrzymywania czegokolwiek wewnątrz. |
| `session stage` | Ustawia wybrany etap UML; alias: `session checkpoint`. |
## Wybór celu
Serię, kartę, Task i elementy UML można wskazywać stabilnym ID albo numerem
pokazanym przez `choices`:
```bash
stemctl session choices --series inf --card 7 --task 4
stemctl session reset hazard3-sim \
--series inf --card 7 --task 4 \
--step 12 --pane current:0
```
Najważniejsze selektory:
- środowisko: `profile`, `--target`, `--instance`;
- materiał: `--series`, `--card`, `--task`;
- UML: `--stage`, `--block`, `--phase`, `--step`, `--snapshot`, `--first`;
- widok: `--pane`, `--focus`, `--offline`, `--control`;
- wykonanie: `--card-url`, `--timeout`, `--force-pane`, `--dry-run`.
`--pane current:0` oznacza pane `0` tego okna tmuxa, w którym uruchomiono
Codexa. Można także podać `%ID` albo `sesja:okno.pane`. Launcher chroni pane
wykonujące bieżącą komendę i pane oznaczone jako należące do innej sesji
Codexa.
## Przykłady attach i detach
```bash
# Podepnij działający kontener Task04 do pane 0 bieżącego okna.
stemctl session attach hazard3-sim \
--series inf --card 7 --task 4 --pane current:0
# Wróć w pane 0 do powłoki hosta. Kontener i debugowanie nadal działają.
stemctl session detach hazard3-sim \
--series inf --card 7 --task 4 --pane current:0
```
## Status materiału
`session choices` pokazuje jedną z trzech wartości:
- `opracowane` — kompletny materiał wraz z receptą checkpointu;
- `robocze` — źródło lub metadane istnieją, ale nie są kompletne;
- `brak` — materiał nie jest jeszcze dostępny w workspace.
+7 -7
View File
@@ -9,7 +9,7 @@ Rekord w `tokens.json` zawiera token, endpoint serwera, login oraz docelowe
`org/repo`. Git remote służy tylko do operacji Git (`fetch`, `push`) albo do
pierwszego wczytania tokena do store.
Bez `--repo` komendy tokenów działają na repo `rv-launcher`. Dla kart pracy albo
Bez `--repo` komendy tokenów działają na repo `stem-launcher`. Dla kart pracy albo
innych repo podaj `--repo PATH`.
## Szybki przepływ
@@ -17,7 +17,7 @@ innych repo podaj `--repo PATH`.
Startujemy od git remota z tokenem w URL-u:
```bash
git remote add r1 http://u1:TOKEN@77.90.8.171:3001/edu-tools/rv-launcher.git
git remote add r1 http://u1:TOKEN@77.90.8.171:3001/edu-tools/stem-launcher.git
```
Sprawdzamy, co jest zapisane w remote i store:
@@ -133,7 +133,7 @@ Jeśli remote `r1` nie istnieje, `rvctl` buduje URL z pól `server.endpoint`,
`org` i `repo` w `tokens.json`, na przykład:
```text
http://77.90.8.171:3001/edu-tools/rv-launcher.git
http://77.90.8.171:3001/edu-tools/stem-launcher.git
```
Jeżeli w repo istnieje tylko `origin` wskazujący ten sam URL, `sync store r1`
@@ -142,7 +142,7 @@ automatycznie przemianuje `origin` na `r1`, a potem wpisze credentials.
Opcjonalnie można podać URL ręcznie:
```bash
./rvctl tokens sync store r1 --url http://77.90.8.171:3001/edu-tools/rv-launcher.git
./rvctl tokens sync store r1 --url http://77.90.8.171:3001/edu-tools/stem-launcher.git
```
Jeśli remote ma już inne credentials, użyj:
@@ -265,7 +265,7 @@ domyślnie pochodzi z `workspace.json`.
```bash
./rvctl tokens add r1
./rvctl tokens add r1 --server http://77.90.8.171:3001 --user u1 --org edu-tools --repo rv-launcher
./rvctl tokens add r1 --server http://77.90.8.171:3001 --user u1 --org edu-tools --repo stem-launcher
```
Najczęściej pusty szkielet ma sens wtedy, gdy chcesz ręcznie wpisać token w
@@ -279,7 +279,7 @@ Pokazuje kontekst, tabelę `tokens` i podsumowanie statusów endpointów.
```bash
./rvctl tokens stats
./rvctl tokens stats --repo ~/dev/workspace/rv/tools/rv-launcher
./rvctl tokens stats --repo ~/dev/workspace/stem/tools/stem-launcher
```
## Uprawnienia
@@ -384,7 +384,7 @@ Minimalny przykład:
},
"user": "u1",
"org": "edu-tools",
"repo": "rv-launcher"
"repo": "stem-launcher"
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://edu-tools.local/rv-launcher/tokens.schema.json",
"title": "RV launcher tokens",
"$id": "https://edu-tools.local/stem-launcher/tokens.schema.json",
"title": "STEM launcher tokens",
"type": "object",
"additionalProperties": false,
"required": ["version", "tokens"],
+31 -7
View File
@@ -13,33 +13,57 @@ Implementacja CLI znajduje się w `rvctl.py`. Plik `workspace.json` przechowuje
lokalne ścieżki workspace, series, socketów, tokenów i adres publicznego
manifestu.
Publiczny manifest wspólnego workspace jest w repo:
Publiczny manifest wspólnego workspace jest częścią control plane w
organizacji `edu`. Katalog jest źródłem prawdy dla narzędzi i interfejsu;
lista organizacji widoczna w Gitea pozostaje widokiem administracyjnym.
Model własności:
```text
edu-workspace/workspace-info
edu
└── workspace-info control plane i katalog
edu-inf seria legacy
edu-fiz seria
edu-freertos-c seria
├── lab-rv32i-freertos-heap4 jedna karta = jedno repo
├── lab-rv32i-freertos-c-first-task
└── ...
```
Lokalna kopia manifestu znajduje się w:
```text
~/dev/workspace/rv/meta/workspace-info
~/dev/workspace/stem/meta/workspace-info
```
Typowy układ:
```text
~/dev/workspace/rv
~/dev/workspace/stem
├── meta
│ └── workspace-info
├── tools
│ └── rv-launcher
│ └── stem-launcher
├── tokens
│ └── tokens.json
└── series
```
`workspace-info` opisuje serie, karty, repo źródłowe, repo odpowiedzi i branche.
Nie przechowuje tokenów ani lokalnych plików roboczych ucznia.
`workspace-info` opisuje rejestr organizacji, operacyjne serie, karty, repo
źródłowe, repo odpowiedzi i branche. Każda seria musi jawnie podać
`source_org`; globalne `git.source_org` nie zastępuje tej deklaracji.
`workspace-info` nie przechowuje tokenów ani lokalnych plików roboczych ucznia.
Sprawdzenie spójności bez połączenia z Gitea:
```bash
./stemctl workspace audit
```
Audyt wykrywa brak organizacji, brak jawnego `source_org`, niespójne
przypisanie seriaorganizacja oraz zduplikowane identyfikatory kart i repo.
Ostrzeżenie o wspólnym `workspace_dir` jest dopuszczalne podczas migracji.
Aktualizacja manifestu:
-3
View File
@@ -1,8 +1,5 @@
#!/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" "$@"
+1838 -29
View File
File diff suppressed because it is too large Load Diff
+503 -2
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
import json
import io
import os
import socket
import subprocess
import tempfile
import unittest
from contextlib import redirect_stdout
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
@@ -18,9 +19,21 @@ import rvctl # noqa: E402
class StemctlContractTests(unittest.TestCase):
def test_rvctl_compatibility_wrapper_is_silent(self) -> None:
wrapper = Path(__file__).resolve().parents[1] / "rvctl"
completed = subprocess.run(
[str(wrapper), "--help"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertNotIn("deprecated", completed.stderr.lower())
self.assertNotIn("warning", completed.stderr.lower())
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"]
commands = ["env", "build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm", "probe", "mcp", "session"]
for command in commands:
with self.subTest(command=command):
completed = subprocess.run(
@@ -162,6 +175,128 @@ class StemctlContractTests(unittest.TestCase):
self.assertIn("runtime\tFAIL\tdocker is not supported", rendered)
self.assertIn("rootless\tFAIL\trootless Podman is required", rendered)
def test_manifest_is_authoritative_and_maps_logical_series_directory(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
workspace = root / "workspace"
info = workspace / "meta" / "workspace-info"
(info / "series").mkdir(parents=True)
physical_series = workspace / "series" / "freertos"
(physical_series / "card-one").mkdir(parents=True)
(physical_series / "unregistered-extra").mkdir()
(workspace / "series" / ".hidden").mkdir()
source_series = root / "original" / "series" / "freertos"
(source_series / "card-one").mkdir(parents=True)
(info / "series" / "freertos-c.json").write_text(
json.dumps(
{
"id": "freertos-c",
"source_org": "edu-freertos-c",
"workspace_dir": "freertos",
"source_dir": "freertos",
"cards": [{"id": "FC01", "repo": "card-one", "title": "First card"}],
}
),
encoding="utf-8",
)
(info / "workspace.json").write_text(
json.dumps(
{
"organizations": [
{"id": "edu", "role": "control-plane"},
{"id": "edu-freertos-c", "role": "series", "series": ["freertos-c"]},
],
"series": [{"id": "freertos-c", "file": "series/freertos-c.json"}],
}
),
encoding="utf-8",
)
config_path = root / "config.json"
config_path.write_text(
json.dumps(
{
"workspace_root": str(workspace),
"original_root": str(root / "original"),
"original_series_root": str(root / "original" / "series"),
}
),
encoding="utf-8",
)
config = rvctl.load_config(config_path)
series_output = io.StringIO()
cards_output = io.StringIO()
with redirect_stdout(series_output):
rvctl.print_series(config)
with redirect_stdout(cards_output):
rvctl.print_cards(config, "freertos-c")
self.assertIn("freertos-c\t1\t1", series_output.getvalue())
self.assertNotIn(".hidden", series_output.getvalue())
self.assertIn("FC01\tcard-one\tworkspace\tFirst card", cards_output.getvalue())
self.assertNotIn("unregistered-extra", cards_output.getvalue())
self.assertEqual(rvctl.workspace_series_path(config, "freertos-c"), physical_series)
self.assertEqual(rvctl.source_series_path(config, "freertos-c"), source_series)
self.assertEqual(rvctl.resolve_card_info(config, "freertos-c", "FC01").repo, "card-one")
def test_workspace_catalog_audit_accepts_registered_series_org(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
workspace = root / "workspace"
info = workspace / "meta" / "workspace-info"
info.mkdir(parents=True)
(info / "workspace.json").write_text(
json.dumps(
{
"organizations": [
{"id": "edu", "role": "control-plane"},
{"id": "edu-freertos-c", "role": "series", "series": ["freertos-c"]},
],
"series": [
{
"id": "freertos-c",
"source_org": "edu-freertos-c",
"cards": [{"id": "FC01", "repo": "card-one"}],
}
],
}
),
encoding="utf-8",
)
config_path = root / "config.json"
config_path.write_text(
json.dumps({"workspace_root": str(workspace), "original_root": str(root / "original")}),
encoding="utf-8",
)
config = rvctl.load_config(config_path)
checks = rvctl.audit_workspace_catalog(config)
self.assertFalse(any(status == "FAIL" for _, status, _ in checks), checks)
self.assertIn(("series:freertos-c", "ok", "source_org=edu-freertos-c"), checks)
def test_workspace_catalog_audit_rejects_implicit_source_org(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
workspace = root / "workspace"
info = workspace / "meta" / "workspace-info"
info.mkdir(parents=True)
(info / "workspace.json").write_text(
json.dumps(
{
"organizations": [{"id": "edu", "role": "control-plane"}],
"series": [{"id": "freertos-c", "cards": [{"id": "FC01", "repo": "card-one"}]}],
}
),
encoding="utf-8",
)
config_path = root / "config.json"
config_path.write_text(
json.dumps({"workspace_root": str(workspace), "original_root": str(root / "original")}),
encoding="utf-8",
)
config = rvctl.load_config(config_path)
checks = rvctl.audit_workspace_catalog(config)
self.assertIn(("series:freertos-c", "FAIL", "missing explicit source_org"), checks)
def test_existing_legacy_workspace_is_detected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -277,6 +412,372 @@ class StemctlContractTests(unittest.TestCase):
self.assertEqual(args.mcp_kind, "nvim")
self.assertEqual(args.instance, "hazard3-inf-bss-task1")
def test_mcp_select_accepts_instance_or_container_id(self) -> None:
parser = rvctl.build_parser()
by_instance = parser.parse_args(["mcp", "select", "rp2350-pointers-final"])
by_container = parser.parse_args(["mcp", "select", "3aca2c1c4c7a"])
self.assertEqual(by_instance.mcp_kind, "select")
self.assertEqual(by_instance.selector, "rp2350-pointers-final")
self.assertEqual(by_container.selector, "3aca2c1c4c7a")
def test_env_sources_selects_a_card_and_supports_read_only_check(self) -> None:
parser = rvctl.build_parser()
args = parser.parse_args(["env", "sources", "inf", "pointers", "--check"])
self.assertEqual(args.env_command, "sources")
self.assertEqual(args.series, "inf")
self.assertEqual(args.card, "pointers")
self.assertTrue(args.check)
def test_mcp_select_switches_both_sockets_with_one_current_symlink(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
socket_dir = root / "source"
socket_dir.mkdir()
nvim_server = socket.socket(socket.AF_UNIX)
tmux_server = socket.socket(socket.AF_UNIX)
try:
nvim_server.bind(str(socket_dir / "n.sock"))
tmux_server.bind(str(socket_dir / "t.sock"))
config = SimpleNamespace(socket_root=root)
record = {
"runtime": "podman",
"container_name": "stem-rp2350-example",
"container_id": "3aca2c1c4c7a" + "0" * 52,
"profile": "rp2350",
"target": "rp2350-rv",
"instance": "rp2350-example",
"instance_key": "example000001",
"thread_key": "thread000001",
"socket_dir": str(socket_dir),
}
with mock.patch.object(rvctl, "resolve_mcp_record", return_value=record), mock.patch.object(
rvctl,
"validated_mcp_record",
return_value=(record, socket_dir / "n.sock", socket_dir / "t.sock"),
), redirect_stdout(io.StringIO()):
rvctl.select_mcp_instance(config, "rp2350-example")
current = root / "mcp-selected" / "current"
self.assertEqual(os.readlink(current), "targets/3aca2c1c4c7a")
self.assertEqual((current / "n.sock").resolve(), socket_dir / "n.sock")
self.assertEqual((current / "t.sock").resolve(), socket_dir / "t.sock")
finally:
nvim_server.close()
tmux_server.close()
def test_session_parser_exposes_numbered_target_and_stage_controls(self) -> None:
parser = rvctl.build_parser()
args = parser.parse_args(
[
"session",
"reset",
"hazard3-sim",
"--series",
"1",
"--card",
"7",
"--task",
"4",
"--phase",
"2",
"--step",
"3",
"--pane",
"current:0",
]
)
self.assertEqual(args.session_command, "reset")
self.assertEqual(args.card, "7")
self.assertEqual(args.task, "4")
self.assertEqual(args.phase, "2")
self.assertEqual(args.step, "3")
self.assertEqual(args.pane, "current:0")
def test_session_phase_local_step_falls_back_after_global_number(self) -> None:
entries = []
for phase_index, numbers in enumerate(((1, 2), (6, 7, 8))):
for step_index, number in enumerate(numbers):
entries.append(
{
"task": {"id": "task04", "label": "Task04", "index": 0},
"block": {"id": "flow", "label": "Flow", "index": 0},
"phase": {
"id": f"phase-{phase_index + 1}",
"label": f"Phase {phase_index + 1}",
"index": phase_index,
},
"step": {
"id": f"step-{number}",
"label": f"Step {number}",
"number": number,
"index": step_index,
"global_index": len(entries),
},
"snapshot": {"ref": f"task04.step-{number}", "index": step_index},
}
)
args = SimpleNamespace(
block=None,
phase="2",
step="3",
snapshot=None,
stage=None,
)
selected = rvctl.resolve_navigation_entry({"entries": entries}, "task04_address_arithmetic_alloc", args)
self.assertEqual(selected["phase"]["id"], "phase-2")
self.assertEqual(selected["step"]["number"], 8)
def test_session_material_status_has_three_polish_values(self) -> None:
entry = {
"step": {"id": "one"},
"snapshot": {"ref": "task04.one"},
}
self.assertEqual(
rvctl.navigation_entry_status(
entry,
{
"task04.one": {
"stop": {"symbol": "main", "offset": 0},
"verify": {"expressions": []},
}
},
),
"opracowane",
)
self.assertEqual(rvctl.navigation_entry_status(entry, {}), "robocze")
self.assertEqual(rvctl.development_status([]), "brak")
def test_session_card_number_uses_manifest_order(self) -> None:
cards = [
rvctl.CardInfo("inf", "first", "first", "main", "", "a", "b", "r1", "r1a", "main"),
rvctl.CardInfo("inf", "second", "second", "main", "", "a", "b", "r1", "r1a", "main"),
]
config = SimpleNamespace(defaults={})
with mock.patch.object(rvctl, "source_card_infos", return_value=cards):
self.assertEqual(rvctl.resolve_session_card(config, "inf", "2"), "second")
def test_session_rejects_destructive_online_reset_without_pane_in_preflight(self) -> None:
parser = rvctl.build_parser()
args = parser.parse_args(
[
"session",
"reset",
"hazard3-sim",
"--series",
"inf",
"--card",
"7",
"--task",
"4",
"--pane",
"none",
"--step",
"12",
]
)
with self.assertRaisesRegex(SystemExit, "requires a debugger pane"):
rvctl.validate_session_command_args(args)
def test_session_rejects_profile_target_pair_before_mutation(self) -> None:
args = SimpleNamespace(
series="inf",
card="7",
task="4",
profile="hazard3-sim",
target="rp2350-rv",
instance=None,
)
with (
mock.patch.object(rvctl, "resolve_session_series", return_value="inf"),
mock.patch.object(rvctl, "resolve_session_card", return_value="pointers"),
mock.patch.object(rvctl, "resolve_workspace_card_path", return_value=Path("/tmp/card")),
mock.patch.object(rvctl, "resolve_submission_task", return_value="task04"),
self.assertRaisesRegex(SystemExit, "does not belong to profile hazard3-sim"),
):
rvctl.resolve_session_target(SimpleNamespace(), args)
def test_session_validates_pane_before_container_commands(self) -> None:
parser = rvctl.build_parser()
args = parser.parse_args(
["session", "reset", "hazard3-sim", "--series", "inf", "--card", "7", "--task", "4"]
)
target = rvctl.SessionTarget(
"inf", "pointers", Path("/tmp/card"), "task04_example", "hazard3-sim",
"hazard3-baremetal", "hazard3-sim-inf-pointers-t4"
)
with (
mock.patch.object(rvctl, "resolve_session_target", return_value=target),
mock.patch.object(rvctl, "resolve_tmux_session_pane", side_effect=SystemExit("protected")),
mock.patch.object(subprocess, "run") as run,
self.assertRaisesRegex(SystemExit, "protected"),
):
rvctl.run_session(SimpleNamespace(), args)
run.assert_not_called()
def test_session_start_pane_none_applies_offline_stage(self) -> None:
parser = rvctl.build_parser()
args = parser.parse_args(
[
"session", "start", "hazard3-sim", "--series", "inf", "--card", "7",
"--task", "4", "--pane", "none", "--step", "12", "--offline"
]
)
target = rvctl.SessionTarget(
"inf", "pointers", Path("/tmp/card"), "task04_example", "hazard3-sim",
"hazard3-baremetal", "hazard3-sim-inf-pointers-t4"
)
prepared = {
"task": {"id": "task04"},
"block": {"id": "flow"},
"phase": {"id": "alloc-5"},
"step": {"id": "commit", "number": 12},
"snapshot": {"ref": "task04.alloc5.commit"},
}
with (
mock.patch.object(rvctl, "resolve_session_target", return_value=target),
mock.patch.object(rvctl, "prepare_session_stage", return_value=prepared),
mock.patch.object(rvctl, "session_env_command", return_value=("true", {"command": "true"})),
mock.patch.object(rvctl, "select_session_stage") as select_stage,
mock.patch.object(subprocess, "run"),
redirect_stdout(io.StringIO()),
):
rvctl.run_session(SimpleNamespace(), args)
select_stage.assert_called_once_with(
mock.ANY, target, args, online=False, prepared_entry=prepared
)
def test_session_stage_requires_explicit_selector_and_finite_timeout(self) -> None:
parser = rvctl.build_parser()
args = parser.parse_args(
["session", "stage", "hazard3-sim", "--series", "inf", "--card", "7", "--task", "4"]
)
with self.assertRaisesRegex(SystemExit, "requires a UML selector"):
rvctl.validate_session_command_args(args)
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
parser.parse_args(
[
"session", "stage", "hazard3-sim", "--series", "inf", "--card", "7",
"--task", "4", "--first", "--timeout", "nan"
]
)
def test_session_protects_codex_owned_pane_by_pid_marker(self) -> None:
completed = subprocess.CompletedProcess(
[], 0, stdout="1:0.0\t%3\t/tmp\t999\t999\tthread-1\n", stderr=""
)
with (
mock.patch.object(rvctl, "mark_invoking_codex_pane"),
mock.patch.object(subprocess, "run", return_value=completed),
mock.patch.dict(os.environ, {"TMUX_PANE": "%33"}),
):
with self.assertRaisesRegex(SystemExit, "owned by Codex"):
rvctl.resolve_tmux_session_pane("%3")
self.assertEqual(rvctl.resolve_tmux_session_pane("%3", force=True)[1], "%3")
def test_session_identity_is_fail_closed_on_uuid_or_hash_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
card_path = Path(temporary)
(card_path / "json").mkdir()
source = {
"card": {
"id": "card-1",
"uuid": "uuid-1",
"version": "v1",
}
}
(card_path / "json/card_source.json").write_text(json.dumps(source), encoding="utf-8")
target = rvctl.SessionTarget(
"inf", "card", card_path, "task04", "hazard3-sim",
"hazard3-baremetal", "instance"
)
served = {
"id": "card-1",
"uuid": "different",
"version": "v1",
"source_sha256": "different",
}
with mock.patch.object(rvctl, "card_api_json", return_value=served):
with self.assertRaisesRegex(SystemExit, "uuid, source_sha256"):
rvctl.verify_card_api_identity("http://127.0.0.1:8080", target, 1)
def test_session_cancelled_checkpoint_is_not_published(self) -> None:
target = rvctl.SessionTarget(
"inf", "pointers", Path("/tmp/card"), "task04", "hazard3-sim",
"hazard3-baremetal", "hazard3-sim-inf-pointers-t4"
)
args = SimpleNamespace(
card_url="http://127.0.0.1:8080",
timeout=1,
focus="step",
control=False,
)
entry = {
"task": {"id": "task04"},
"block": {"id": "allocator-flow"},
"phase": {"id": "alloc-5"},
"step": {"id": "alloc5-commit", "number": 12},
"snapshot": {"ref": "task04.alloc5.commit"},
}
record = {
"container_id": "abc123",
"instance": target.instance,
"profile": target.profile,
"target": target.target,
}
api = mock.Mock(return_value={"status": "cancelled", "message": "newer request"})
with (
mock.patch.object(rvctl, "resolve_mcp_record", return_value=record),
mock.patch.object(rvctl, "validated_mcp_record"),
mock.patch.object(rvctl, "validate_mcp_card_mount"),
mock.patch.object(rvctl, "select_mcp_instance"),
mock.patch.object(
rvctl,
"verify_card_api_identity",
return_value={
"id": "card-1",
"uuid": "uuid-1",
"version": "v1",
"source_sha256": "abc123",
},
),
mock.patch.object(rvctl, "card_api_json", api),
self.assertRaisesRegex(SystemExit, "did not reach ready: cancelled"),
):
rvctl.select_session_stage(SimpleNamespace(), target, args, True, entry)
self.assertEqual(api.call_count, 1)
payload = api.call_args.kwargs["payload"]
self.assertEqual(payload["expected_identity"]["source_sha256"], "abc123")
def test_mcp_registry_profile_and_target_must_match_container_labels(self) -> None:
record = {
"runtime": "podman",
"container_name": "container",
"container_id": "abc123",
"instance_key": "instance-key",
"profile": "hazard3-sim",
"target": "hazard3-baremetal",
"socket_dir": "/tmp/not-used",
}
inspection = [{
"Id": "abc123",
"Config": {"Labels": {
"edu.stem.instance-key": "instance-key",
"edu.stem.profile": "hazard3-sim",
"edu.stem.target": "rp2350-rv",
}},
}]
completed = subprocess.CompletedProcess([], 0, stdout=json.dumps(inspection), stderr="")
with (
mock.patch.object(rvctl.shutil, "which", return_value="/usr/bin/podman"),
mock.patch.object(subprocess, "run", return_value=completed),
self.assertRaisesRegex(SystemExit, "Stale MCP registry"),
):
rvctl.validated_mcp_record(record)
def test_session_task_matching_is_anchored(self) -> None:
self.assertTrue(rvctl.same_task("task04", "task04_address_arithmetic_alloc"))
self.assertFalse(rvctl.same_task("notask04", "task04_address_arithmetic_alloc"))
self.assertFalse(rvctl.same_task("figure2026", "task04_address_arithmetic_alloc"))
if __name__ == "__main__":
unittest.main()
+6 -6
View File
@@ -4,7 +4,7 @@
"original_series_root": "~/dev/edu/repos/rv/series",
"workspace_root": "~/dev/workspace/stem",
"legacy_workspace_root": "~/dev/workspace/rv",
"workspace_info_url": "http://77.90.8.171:3001/edu-workspace/workspace-info.git",
"workspace_info_url": "https://zsl-gitea.mpabi.pl/edu/workspace-info.git",
"env_tool_url": "http://77.90.8.171:3001/edu-tools/rv32i-hazard3-student-env.git",
"tools_root_candidates": [
"~/dev/workspace/stem/tools/rv32i-hazard3-student-env",
@@ -14,17 +14,17 @@
"~/dev/edu/repos/rv/rv32i-hazard3-env"
],
"git": {
"base_url": "http://77.90.8.171:3001",
"source_org": "edu-inf",
"base_url": "https://zsl-gitea.mpabi.pl",
"source_org": "edu",
"answer_org": "c2025-1a-inf",
"source_remote": "r1",
"answer_remote": "r1a",
"origin_remote": "origin",
"fallback_branch": "build"
"fallback_branch": "main"
},
"defaults": {
"series": "inf",
"card": "bss",
"series": "freertos-c",
"card": "FC02",
"task": "task1",
"editor": "nvim",
"instance": "shell",