feat(L02): add FPGA bring-up and programming card
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
name: Render PDF
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- deploy
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
render-pdf:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install TeX
|
||||
run: |
|
||||
if ! command -v latexmk >/dev/null 2>&1; then
|
||||
if ! command -v apt-get >/dev/null 2>&1; then
|
||||
echo "latexmk is missing and apt-get is unavailable" >&2
|
||||
exit 1
|
||||
fi
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
SUDO=sudo
|
||||
else
|
||||
SUDO=
|
||||
fi
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
latexmk \
|
||||
texlive-fonts-recommended \
|
||||
texlive-lang-polish \
|
||||
texlive-latex-base \
|
||||
texlive-latex-extra \
|
||||
texlive-latex-recommended
|
||||
fi
|
||||
|
||||
- name: Render PDF with commit metadata
|
||||
run: ./scripts/render_pdf.sh
|
||||
|
||||
- name: Commit rendered PDF
|
||||
run: |
|
||||
set -eu
|
||||
repo_name="${GITHUB_REPOSITORY#*/}"
|
||||
pdf_file="$(find doc/pdf -maxdepth 1 -type f -name '*.pdf' -print -quit)"
|
||||
if [ -z "$pdf_file" ]; then
|
||||
echo "rendered PDF is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_sha="$(git ls-remote origin refs/heads/deploy | cut -f1)"
|
||||
if [ "$remote_sha" != "$GITHUB_SHA" ]; then
|
||||
echo "deploy advanced from $GITHUB_SHA to $remote_sha; skip publishing stale PDF"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@noreply.local"
|
||||
printf '%s\n' "$GITHUB_SHA" > doc/pdf/source-commit.txt
|
||||
git add -u doc/pdf
|
||||
git add -f "$pdf_file"
|
||||
git add doc/pdf/source-commit.txt
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "Render ${repo_name} PDF for ${GITHUB_SHA::12} [skip actions]"
|
||||
git push origin HEAD:deploy
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
*.upa
|
||||
*.upb
|
||||
*.pdf
|
||||
build-meta.tex
|
||||
.rv/
|
||||
.stem/
|
||||
.nvim/
|
||||
.vim/
|
||||
.build/
|
||||
.gdb_history
|
||||
host-build/
|
||||
build/*/prog.bin
|
||||
build/*/prog.elf
|
||||
build/*/prog.lst
|
||||
build/*/prog.map
|
||||
cmake-build-*/
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
vendor/Hazard3/
|
||||
vendor/lab-runtime/
|
||||
web/*.map
|
||||
doc/session-reports/
|
||||
json/lesson_progress.json
|
||||
@@ -0,0 +1,19 @@
|
||||
VERILATOR ?= verilator
|
||||
TASK ?= task01_pin_polarity
|
||||
BUILD_ROOT ?= .stem/build/rtl
|
||||
|
||||
.PHONY: build test run trace clean
|
||||
|
||||
build:
|
||||
@tests/test_rtl.sh --build-only "$(TASK)"
|
||||
|
||||
test:
|
||||
@tests/test_rtl.sh "$(TASK)"
|
||||
|
||||
run: test
|
||||
|
||||
trace:
|
||||
@tests/test_rtl.sh --trace "$(TASK)"
|
||||
|
||||
clean:
|
||||
@find "$(BUILD_ROOT)" -mindepth 1 -maxdepth 1 -type d -exec rm -r -- {} +
|
||||
@@ -0,0 +1,28 @@
|
||||
# L02 · FPGA Bring-up and Programming
|
||||
|
||||
Karta ustanawia bezpieczną drabinkę od kontraktu płytki do obserwacji LED:
|
||||
polaryzacja sygnałów → działający blinker w symulacji → kabel → identyfikacja
|
||||
układu → konfiguracja → fizyczna obserwacja.
|
||||
|
||||
Repozytorium celowo zawiera [szablon kontraktu](board/board-contract.yaml), a
|
||||
nie zmyślone piny. Przed budową bitstreamu operator musi podać rzeczywisty
|
||||
model płytki, FPGA, częstotliwość, piny i programator.
|
||||
|
||||
| Task | Automatyczny dowód |
|
||||
| --- | --- |
|
||||
| `task01_pin_polarity` | jawne active-low reset, button i LED |
|
||||
| `task02_bringup_blinker` | znany reset i krawędzie LED co 4 takty |
|
||||
| `task03_programming_gate` | kolejność cable → id → configure → observe |
|
||||
|
||||
```bash
|
||||
stemctl card use rv32i-asm L02
|
||||
stemctl test native-amd64 rv32i-asm L02 1
|
||||
stemctl test hazard3-sim rv32i-asm L02 2
|
||||
stemctl debug hazard3-sim rv32i-asm L02 3
|
||||
```
|
||||
|
||||
Komunikat `PASS task03` oznacza wyłącznie przejście modelu preflight i zawsze
|
||||
raportuje `hardware_observed=0`. Nie jest dowodem zbudowania bitstreamu,
|
||||
połączenia programatora ani działania fizycznej LED. Te dowody należy wpisać
|
||||
do [answers/fpga-bringup-evidence.md](answers/fpga-bringup-evidence.md) po
|
||||
wykonaniu na wskazanej płytce.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Physical evidence · L02 FPGA Bring-up
|
||||
|
||||
Automatyczne testy tej karty nie wypełniają poniższych pól. Uzupełnij je na
|
||||
stanowisku operatorskim; bez nich nie deklaruj fizycznego uruchomienia.
|
||||
|
||||
- Board ID / model:
|
||||
- FPGA part:
|
||||
- Toolchain and version:
|
||||
- Constraint file commit:
|
||||
- Bitstream path:
|
||||
- SHA-256 bitstreamu:
|
||||
- Programmer and detected device ID:
|
||||
- Configure command and exit status:
|
||||
- Expected LED period:
|
||||
- Observed LED period:
|
||||
- Observer and timestamp:
|
||||
- Hardware result: `UNVERIFIED` / `PASS` / `FAIL`
|
||||
@@ -0,0 +1,11 @@
|
||||
schema: 1
|
||||
status: template
|
||||
board_id: SET_BOARD_ID
|
||||
fpga_part: SET_FPGA_PART
|
||||
toolchain: SET_BOARD_TOOLCHAIN
|
||||
clock_hz: SET_CLOCK_HZ
|
||||
clock_pin: SET_CLOCK_PIN
|
||||
reset_pin: SET_RESET_PIN
|
||||
led_pin: SET_LED_PIN
|
||||
programmer: SET_PROGRAMMER
|
||||
hardware_observed: false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="270" viewBox="0 0 640 270" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Drabinka FPGA bring-up</title><desc id="desc">Kolejne bramki to kontrakt płytki, symulacja, bitstream, identyfikacja urządzenia, konfiguracja i fizyczna obserwacja LED.</desc>
|
||||
<defs><marker id="a" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto"><path d="M0 0L0 6L9 3z" fill="#334155"/></marker><style>.b{fill:#f8fafc;stroke:#334155;stroke-width:2}.sim{fill:#dbeafe;stroke:#2563eb}.hw{fill:#fef3c7;stroke:#d97706}.t{font:700 16px sans-serif;fill:#0f172a}.s{font:13px sans-serif;fill:#334155}.l{stroke:#334155;stroke-width:3;marker-end:url(#a)}</style></defs>
|
||||
<rect width="640" height="270" fill="white"/>
|
||||
<g transform="translate(15 45)"><rect class="b" width="92" height="70" rx="10"/><text class="t" x="46" y="30" text-anchor="middle">CONTRACT</text><text class="s" x="46" y="52" text-anchor="middle">board + pins</text><path class="l" d="M92 35h22"/>
|
||||
<rect class="b sim" x="114" width="92" height="70" rx="10"/><text class="t" x="160" y="30" text-anchor="middle">SIM</text><text class="s" x="160" y="52" text-anchor="middle">RTL + VCD</text><path class="l" d="M206 35h22"/>
|
||||
<rect class="b hw" x="228" width="92" height="70" rx="10"/><text class="t" x="274" y="30" text-anchor="middle">BUILD</text><text class="s" x="274" y="52" text-anchor="middle">bitstream</text><path class="l" d="M320 35h22"/>
|
||||
<rect class="b hw" x="342" width="92" height="70" rx="10"/><text class="t" x="388" y="30" text-anchor="middle">IDENTIFY</text><text class="s" x="388" y="52" text-anchor="middle">device ID</text><path class="l" d="M434 35h22"/>
|
||||
<rect class="b hw" x="456" width="92" height="70" rx="10"/><text class="t" x="502" y="30" text-anchor="middle">CONFIG</text><text class="s" x="502" y="52" text-anchor="middle">programmer</text><path class="l" d="M548 35h22"/>
|
||||
<rect class="b hw" x="570" width="55" height="70" rx="10"/><text class="t" x="598" y="30" text-anchor="middle">LED</text><text class="s" x="598" y="52" text-anchor="middle">observe</text></g>
|
||||
<rect x="15" y="155" width="206" height="72" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/><text class="t" x="118" y="184" text-anchor="middle">AUTOMATED PASS</text><text class="s" x="118" y="207" text-anchor="middle">contract + sim + preflight model</text>
|
||||
<rect x="330" y="155" width="295" height="72" rx="10" fill="#fee2e2" stroke="#dc2626" stroke-width="2"/><text class="t" x="478" y="184" text-anchor="middle">PHYSICAL EVIDENCE REQUIRED</text><text class="s" x="478" y="207" text-anchor="middle">real board, digest, device ID, log, observation</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,356 @@
|
||||
% Generated from json/card_source.json by tools/render_card.py.
|
||||
% Do not edit manually; update the JSON and regenerate.
|
||||
|
||||
\documentclass[12pt,a4paper]{article}
|
||||
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[polish]{babel}
|
||||
\usepackage{csquotes}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{xcolor}
|
||||
\usepackage[a4paper,left=2.15cm,right=2.15cm,top=0.5cm,bottom=1cm,headheight=28mm,headsep=3mm,includehead,includefoot]{geometry}
|
||||
\usepackage{eso-pic}
|
||||
\usepackage{marginnote}
|
||||
\usepackage{array}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{pdflscape}
|
||||
\usepackage{float}
|
||||
\usepackage{silence}
|
||||
\WarningFilter{soulutf8}{This package is obsolete}
|
||||
\usepackage{pdfcomment}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{needspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{lastpage}
|
||||
\usepackage{microtype}
|
||||
\usepackage[most]{tcolorbox}
|
||||
\usepackage{qrcode}
|
||||
\IfFileExists{references.bib}{%
|
||||
\usepackage[backend=biber,style=numeric,sorting=none]{biblatex}%
|
||||
\addbibresource{references.bib}%
|
||||
}{}
|
||||
|
||||
\hypersetup{hidelinks}
|
||||
|
||||
\IfFileExists{build-meta.tex}{%
|
||||
\input{build-meta.tex}%
|
||||
}{%
|
||||
\newcommand{\BuildCommit}{lokalna}%
|
||||
}
|
||||
|
||||
\newcommand{\DocumentAuthor}{M. Pabiszczak}
|
||||
\newcommand{\DocumentYear}{2026}
|
||||
\newcommand{\CardSeries}{rv32i-asm}
|
||||
\newcommand{\CardSeriesTitle}{ASM · RV32I From Blinker to RISC-V}
|
||||
\newcommand{\CardNumber}{02}
|
||||
\newcommand{\CardCount}{09}
|
||||
\newcommand{\CardSlug}{fpga-bringup}
|
||||
\newcommand{\CardVersion}{v00.01}
|
||||
\newcommand{\CardStatus}{Gotowa do wykonania}
|
||||
\newcommand{\DocumentUUID}{31e9c2ad-f597-51ab-af6a-e550c3533f32}
|
||||
|
||||
\newcommand{\EmptyCheck}{\(\square\)}
|
||||
\newcommand{\EscAnswerLines}[1][3]{\par\noindent\dotfill\par\noindent\dotfill\par\noindent\dotfill}
|
||||
\newcommand{\EscWriteRow}[1][2.8em]{\rule{0pt}{#1}}
|
||||
\setlength{\parindent}{0pt}
|
||||
\setlength{\parskip}{0.45em}
|
||||
\setlength{\headheight}{28mm}
|
||||
\setlength{\headsep}{3mm}
|
||||
\setlength{\footskip}{8mm}
|
||||
\setlength{\marginparwidth}{1.5cm}
|
||||
\setlength{\marginparsep}{0.25cm}
|
||||
\renewcommand{\arraystretch}{1.22}
|
||||
% Margin separator lines disabled for layout trial.
|
||||
|
||||
\newcommand{\ESCPageResourceHeader}{%
|
||||
\begingroup%
|
||||
\setlength{\parskip}{0pt}%
|
||||
\setlength{\fboxsep}{0pt}%
|
||||
\setlength{\fboxrule}{0.35pt}%
|
||||
\setlength{\arrayrulewidth}{0.35pt}%
|
||||
\noindent\fbox{%
|
||||
\begin{minipage}[c][27.5mm][c]{\dimexpr\textwidth-2\fboxrule\relax}%
|
||||
\begin{minipage}[c][27mm][c]{\dimexpr\linewidth-24mm-0.35pt\relax}%
|
||||
\setlength{\tabcolsep}{0pt}%
|
||||
\renewcommand{\arraystretch}{0}%
|
||||
\begin{tabularx}{\linewidth}{@{}p{\dimexpr0.50000000\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.15126050\linewidth-\arrayrulewidth\relax}|X@{}}%
|
||||
\parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries TITLE}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{7.7}{7.9}\selectfont\ttfamily\bfseries L02 · FPGA Bring-up and Programming}\hspace{0.45mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries VER.}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{6.6}{6.8}\selectfont\ttfamily\bfseries 1}\hspace{0.45mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries DATETIME}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{6.4}{6.6}\selectfont\ttfamily\bfseries 21.07.2026}\hspace{0.45mm}}\par} \\%
|
||||
\end{tabularx}%
|
||||
\par\nointerlineskip%
|
||||
\hrule height0.35pt%
|
||||
\nointerlineskip%
|
||||
\begin{tabularx}{\linewidth}{@{}p{\dimexpr0.50000000\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.25210084\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.14285714\linewidth-\arrayrulewidth\relax}|X@{}}%
|
||||
\parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries PROJECT}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{7.7}{7.9}\selectfont\ttfamily\bfseries From Blinker to RISC-V}\hspace{0.45mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries SERIES}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{6.4}{6.6}\selectfont\ttfamily\bfseries ASM · RV32I From Blinker to RISC-V}\hspace{0.45mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries CARD}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{6.4}{6.6}\selectfont\ttfamily\bfseries 02/09}\hspace{0.45mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{3.2}{3.4}\selectfont\ttfamily\bfseries SHEET}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{6.4}{6.6}\selectfont\ttfamily\bfseries \thepage/\pageref{LastPage}}\hspace{0.45mm}}\par} \\%
|
||||
\end{tabularx}%
|
||||
\par\nointerlineskip%
|
||||
\hrule height0.35pt%
|
||||
\nointerlineskip%
|
||||
\begin{tabularx}{\linewidth}{@{}p{\dimexpr0.05882353\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.05042017\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.14705882\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.09243697\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.07563025\linewidth-\arrayrulewidth\relax}|p{\dimexpr0.07563025\linewidth-\arrayrulewidth\relax}|X@{}}%
|
||||
\parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.15mm}{\fontsize{2.85}{3}\selectfont\ttfamily\bfseries SUBJ.}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{4.45}{4.65}\selectfont\ttfamily\bfseries Inf.}\hspace{0.15mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.15mm}{\fontsize{2.85}{3}\selectfont\ttfamily\bfseries PROG.}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{4.45}{4.65}\selectfont\ttfamily\bfseries —}\hspace{0.15mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.15mm}{\fontsize{2.85}{3}\selectfont\ttfamily\bfseries CORE}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{4.45}{4.65}\selectfont\ttfamily\bfseries —}\hspace{0.15mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.15mm}{\fontsize{2.85}{3}\selectfont\ttfamily\bfseries SCOPE}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{4.45}{4.65}\selectfont\ttfamily\bfseries —}\hspace{0.15mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.15mm}{\fontsize{2.85}{3}\selectfont\ttfamily\bfseries LEVEL}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{4.45}{4.65}\selectfont\ttfamily\bfseries —}\hspace{0.15mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hspace{0.15mm}{\fontsize{2.85}{3}\selectfont\ttfamily\bfseries POS.}\par\nointerlineskip\vspace{0.12mm}\noindent\makebox[\linewidth][r]{{\fontsize{4.45}{4.65}\selectfont\ttfamily\bfseries —}\hspace{0.15mm}}\par} & \parbox[c][6.11722mm][c]{\linewidth}{\hbox to\linewidth{\hspace{0.35mm}{\fontsize{3.4}{3.75}\selectfont\ttfamily\bfseries GITEA UUID}\hfill{\fontsize{3.4}{3.75}\selectfont\ttfamily 31e9c2ad-f597-51ab-af6a-e550c3533f32}\hspace{0.35mm}}\par\nointerlineskip\hbox to\linewidth{\hspace{0.35mm}{\fontsize{3.4}{3.75}\selectfont\ttfamily\bfseries CARD UUID} {\fontsize{3.4}{3.75}\selectfont\ttfamily 31e9c2ad-f597-51ab-af6a-e550c3533f32}\hfill{\fontsize{3.4}{3.75}\selectfont\ttfamily\bfseries AUTHOR M. Pabiszczak}\hspace{0.35mm}}} \\%
|
||||
\end{tabularx}%
|
||||
\par\nointerlineskip%
|
||||
\hrule height0.35pt%
|
||||
\nointerlineskip%
|
||||
\begin{tabularx}{\linewidth}{@{}X@{}}%
|
||||
\parbox[c][4.07815mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{4.5}{4.85}\selectfont\ttfamily\bfseries GITEA} {\fontsize{4.5}{4.85}\selectfont\ttfamily\href{https://gitea.local/edu-inf/lab-rv32i-asm-fpga-bringup}{\nolinkurl{https://gitea.local/edu-inf/lab-rv32i-asm-fpga-bringup}}}} \\%
|
||||
\end{tabularx}%
|
||||
\par\nointerlineskip%
|
||||
\hrule height0.35pt%
|
||||
\nointerlineskip%
|
||||
\begin{tabularx}{\linewidth}{@{}X@{}}%
|
||||
\parbox[c][4.07815mm][c]{\linewidth}{\hspace{0.45mm}{\fontsize{4.5}{4.85}\selectfont\ttfamily\bfseries HTML} {\fontsize{4.5}{4.85}\selectfont\ttfamily\href{http://localhost:8080}{\nolinkurl{http://localhost:8080}}}} \\%
|
||||
\end{tabularx}%
|
||||
\end{minipage}%
|
||||
\vrule width0.35pt%
|
||||
\begin{minipage}[c][27mm][c]{24mm}\centering%
|
||||
{\tiny QR wyłączony}%
|
||||
\end{minipage}%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\endgroup%
|
||||
}
|
||||
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\fancyhead[C]{\ESCPageResourceHeader}
|
||||
\fancyfoot[L]{{\fontsize{6.4}{7.0}\selectfont\ttfamily UUID \DocumentUUID}}
|
||||
\fancyfoot[C]{{\fontsize{6.4}{7.0}\selectfont\ttfamily \DocumentAuthor}}
|
||||
\fancyfoot[R]{{\fontsize{6.4}{7.0}\selectfont\ttfamily STRONA \thepage/\pageref{LastPage}}}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
\renewcommand{\footrulewidth}{0.35pt}
|
||||
|
||||
\newcommand{\ESCMarginTag}[4]{%
|
||||
\hspace*{#1}\textcolor{#2}{#3~#4}%
|
||||
}
|
||||
\newcommand{\ESCSectionBlockStart}{%
|
||||
\Needspace{8\baselineskip}%
|
||||
\par\vspace{0.35em}%
|
||||
\noindent\textcolor{black!28}{\rule{\textwidth}{0.35pt}}%
|
||||
\par\vspace{0.15em}%
|
||||
}
|
||||
\newcommand{\ESCSectionBlockEnd}{%
|
||||
\par\vspace{0.45em}%
|
||||
}
|
||||
\newcommand{\ESCTinyStepSeparator}{%
|
||||
\par\vspace{0.10em}%
|
||||
\noindent{\color{black!18}\leaders\hbox{\rule{0.60em}{0.22pt}\hspace{0.38em}}\hfill\kern0pt}%
|
||||
\par\vspace{0.10em}%
|
||||
}
|
||||
\newtcolorbox{ESCTaskFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm,colbacktitle=black!7,coltitle=black,title={\ttfamily\bfseries TASK\quad #1}}
|
||||
\newtcolorbox{ESCBlockFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!55,boxsep=0pt,left=1.0mm,right=1.0mm,top=0.6mm,bottom=0.6mm,colbacktitle=black!4,coltitle=black,title={\ttfamily\bfseries BLOCK\quad #1}}
|
||||
\newtcolorbox{ESCStepFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!28,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.45mm,bottom=0.45mm,colbacktitle=black!2,coltitle=black,title={\ttfamily STEP\quad #1}}
|
||||
\newtcolorbox{ESCConclusionFrame}{enhanced,breakable,arc=0pt,boxrule=0.45pt,colback=blue!2,colframe=blue!45!black,boxsep=0pt,left=1.2mm,right=1.2mm,top=0.7mm,bottom=0.7mm,colbacktitle=blue!7,coltitle=black,title={\ttfamily\bfseries WNIOSEK}}
|
||||
|
||||
\newcommand{\ESCLearningTreeWidget}{%
|
||||
\reversemarginpar
|
||||
\marginnote[%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\begin{minipage}[t]{\marginparwidth}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{TECH}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\hspace*{0.28em}\textcolor{orange!85!black}{EK}\textcolor{black!48}{}\par%
|
||||
\hspace*{0.54em}\textcolor{blue!70!black}{KW}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
]{}
|
||||
\normalmarginpar
|
||||
|
||||
\marginnote{%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\hspace*{0.06cm}%
|
||||
\begin{minipage}[t]{\dimexpr\marginparwidth-0.06cm\relax}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{OG}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\hspace*{0.28em}\textcolor{green!50!black}{EN~~WO}\textcolor{black!48}{}\par%
|
||||
\hspace*{0.54em}\textcolor{blue!70!black}{KW~~WS}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
\begin{document}
|
||||
\sloppy
|
||||
|
||||
\vspace{1.0em}
|
||||
\noindent{\Large\bfseries Cel karty}\par
|
||||
\vspace{0.35em}
|
||||
Uczeń przygotowuje jawny kontrakt płytki, weryfikuje top-level w symulacji i przechodzi uporządkowany preflight programowania. Automatyczny PASS nie jest dowodem działania fizycznego FPGA.
|
||||
|
||||
\vspace{0.8em}
|
||||
\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}
|
||||
\vspace{0.7em}
|
||||
\noindent{\Large\bfseries Zakres tasków}\par
|
||||
\vspace{0.35em}
|
||||
|
||||
\vspace{0.55em}
|
||||
\small\begin{tabularx}{\textwidth}{@{}>{\ttfamily\raggedright\arraybackslash}p{1.10cm}>{\ttfamily\raggedright\arraybackslash}p{1.45cm}X>{\raggedright\arraybackslash}p{2.50cm}>{\raggedright\arraybackslash}p{1.75cm}>{\ttfamily\raggedright\arraybackslash}p{1.50cm}@{}}
|
||||
\textbf{Krok} & \textbf{Numer tasku} & \textbf{Najważniejsza idea} & \textbf{Priorytet} & \textbf{Status} & \textbf{Version} \\ \hline
|
||||
\texttt{2} & \texttt{Task01} & piny i active-low & obowiązkowe & opracowane & \texttt{v00.01} \\ \hline
|
||||
\texttt{2} & \texttt{Task02} & reset i blinker RTL & obowiązkowe & opracowane & \texttt{v00.01} \\ \hline
|
||||
\texttt{2} & \texttt{Task03} & ordered programming gate & obowiązkowe & opracowane & \texttt{v00.01} \\ \hline
|
||||
\end{tabularx}
|
||||
\normalsize
|
||||
|
||||
|
||||
\vspace{0.8em}
|
||||
\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}
|
||||
|
||||
\clearpage
|
||||
|
||||
\ESCSectionBlockStart
|
||||
\section{Task01 · Kontrakt płytki i polaryzacja}
|
||||
\reversemarginpar
|
||||
\marginnote[%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\begin{minipage}[t]{\marginparwidth}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{TECH}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
]{}[-3.1em]
|
||||
\normalmarginpar
|
||||
|
||||
\marginnote{%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\hspace*{0.06cm}%
|
||||
\begin{minipage}[t]{\dimexpr\marginparwidth-0.06cm\relax}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{OG}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
}[-3.1em]
|
||||
|
||||
|
||||
\begingroup
|
||||
\scriptsize\ttfamily\color{black!60}\sloppy
|
||||
\noindent drzewka: \pdftooltip[width=\textwidth]{D1}{RV02.WE01.OG.IP.01 | WE 01: Board to observation. Bezpieczny i audytowalny FPGA bring-up. |\textCR EN IP RV02.01: Uczeń ocenia siłę dowodów bring-up. | KW IP RV02.01: Rozróżnia VCD od\textCR obserwacji fizycznej.} \pdftooltip[width=\textwidth]{D2}{RV02.WE01.TECH.I4.01 | WE 01: Board to observation. Bezpieczny i audytowalny FPGA bring-up.\textCR | EK INF04 RV02.01: Uczeń przygotowuje kontrakt i kolejność programowania. | KW INF04\textCR RV02.01: Nie omija identyfikacji urządzenia.}\par
|
||||
\vspace{0.10em}%
|
||||
\noindent K1: Potwierdź pin\_contract=generic i jawne polaryzacje.\quad D2\par
|
||||
\par\vspace{0.18em}%
|
||||
\endgroup
|
||||
|
||||
Wypełnij board/board-contract.yaml dla konkretnego stanowiska. Potwierdź active-low reset, button i LED; wartości \texttt{SET\_*} są blokadą przeciw przypadkowemu użyciu obcych pinów.
|
||||
\begin{figure}[H]
|
||||
\centering
|
||||
\includegraphics[width=0.72\linewidth]{\detokenize{../assets/bringup-gate.png}}
|
||||
\caption{Symulacja kończy automatyczny preflight; fizyczny dowód wymaga rzeczywistej płytki.}
|
||||
\label{fig:bringup-gate}
|
||||
\end{figure}
|
||||
\ESCSectionBlockEnd
|
||||
|
||||
\ESCSectionBlockStart
|
||||
\section{Task02 · Top-level blinker}
|
||||
\reversemarginpar
|
||||
\marginnote[%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\begin{minipage}[t]{\marginparwidth}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{TECH}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
]{}[-3.1em]
|
||||
\normalmarginpar
|
||||
|
||||
\marginnote{%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\hspace*{0.06cm}%
|
||||
\begin{minipage}[t]{\dimexpr\marginparwidth-0.06cm\relax}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{OG}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
}[-3.1em]
|
||||
|
||||
|
||||
\begingroup
|
||||
\scriptsize\ttfamily\color{black!60}\sloppy
|
||||
\noindent drzewka: \pdftooltip[width=\textwidth]{D1}{RV02.WE01.OG.IP.01 | WE 01: Board to observation. Bezpieczny i audytowalny FPGA bring-up. |\textCR EN IP RV02.01: Uczeń ocenia siłę dowodów bring-up. | KW IP RV02.01: Rozróżnia VCD od\textCR obserwacji fizycznej.} \pdftooltip[width=\textwidth]{D2}{RV02.WE01.TECH.I4.01 | WE 01: Board to observation. Bezpieczny i audytowalny FPGA bring-up.\textCR | EK INF04 RV02.01: Uczeń przygotowuje kontrakt i kolejność programowania. | KW INF04\textCR RV02.01: Nie omija identyfikacji urządzenia.}\par
|
||||
\vspace{0.10em}%
|
||||
\noindent K1: Zmierz reset\_known=1, cztery krawędzie i zapisz VCD.\quad D2\par
|
||||
\par\vspace{0.18em}%
|
||||
\endgroup
|
||||
|
||||
Zasymuluj znany reset i dzielnik. LED musi zmienić stan w taktach 4, 8, 12 i 16. VCD dowodzi zachowania RTL, ale nie poprawności constraintów ani połączeń płytki.
|
||||
\ESCSectionBlockEnd
|
||||
|
||||
\ESCSectionBlockStart
|
||||
\section{Task03 · Bramka programowania}
|
||||
\reversemarginpar
|
||||
\marginnote[%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\begin{minipage}[t]{\marginparwidth}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{TECH}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
]{}[-3.1em]
|
||||
\normalmarginpar
|
||||
|
||||
\marginnote{%
|
||||
\begin{minipage}{\marginparwidth}%
|
||||
\raggedright
|
||||
{\fontsize{3.55}{3.95}\selectfont\ttfamily
|
||||
\hspace*{0.06cm}%
|
||||
\begin{minipage}[t]{\dimexpr\marginparwidth-0.06cm\relax}%
|
||||
\raggedright
|
||||
{\bfseries\textcolor{black!65}{OG}\par}%
|
||||
\vspace{0.08em}%
|
||||
\hspace*{0.00em}\textcolor{red}{WE~01}\textcolor{black!48}{}\par%
|
||||
\end{minipage}%
|
||||
}%
|
||||
\end{minipage}%
|
||||
}[-3.1em]
|
||||
|
||||
|
||||
\begingroup
|
||||
\scriptsize\ttfamily\color{black!60}\sloppy
|
||||
\noindent drzewka: \pdftooltip[width=\textwidth]{D1}{RV02.WE01.OG.IP.01 | WE 01: Board to observation. Bezpieczny i audytowalny FPGA bring-up. |\textCR EN IP RV02.01: Uczeń ocenia siłę dowodów bring-up. | KW IP RV02.01: Rozróżnia VCD od\textCR obserwacji fizycznej.} \pdftooltip[width=\textwidth]{D2}{RV02.WE01.TECH.I4.01 | WE 01: Board to observation. Bezpieczny i audytowalny FPGA bring-up.\textCR | EK INF04 RV02.01: Uczeń przygotowuje kontrakt i kolejność programowania. | KW INF04\textCR RV02.01: Nie omija identyfikacji urządzenia.}\par
|
||||
\vspace{0.10em}%
|
||||
\noindent K1: Potwierdź kolejność i brak fałszywego hardware claim.\quad D1\par
|
||||
\par\vspace{0.18em}%
|
||||
\endgroup
|
||||
|
||||
Przejdź kolejno przez cable, device ID, configure i observe. Model blokuje pominięcie etapu, lecz jego PASS zawsze kończy się \texttt{hardware\_observed=0} oraz \texttt{claim=preflight-only}. Fizyczny wynik wpisz osobno.
|
||||
\ESCSectionBlockEnd
|
||||
|
||||
\end{document}
|
||||
@@ -0,0 +1,7 @@
|
||||
\newcommand{\PublisherDomain}{mpabi}
|
||||
\newcommand{\CardArea}{inf}
|
||||
\newcommand{\CardSeries}{rv32i-asm}
|
||||
\newcommand{\CardNumber}{02}
|
||||
\newcommand{\CardSlug}{fpga-bringup}
|
||||
\newcommand{\CardVersion}{v00.01}
|
||||
\newcommand{\DocumentUUID}{31e9c2ad-f597-51ab-af6a-e550c3533f32}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../../../../tools/card-layouts/schemas/card-source.schema.json", "schema": "esc-card-source.v1",
|
||||
"card": {"id": "mpabi-inf-rv32i-asm-02-fpga-bringup", "series": "rv32i-asm", "series_title": "ASM · RV32I From Blinker to RISC-V", "number": "02", "count": "09", "slug": "fpga-bringup", "title": "L02 · FPGA Bring-up and Programming", "topic": "Kontrakt płytki, symulacja, programowanie i fizyczny dowód", "project": "From Blinker to RISC-V", "subject": "Informatyka", "level": "Rok 1 · RV32I", "revision_date": "2026-07-21T00:00:00+02:00", "status": "Gotowa do wykonania", "version": "v00.01", "uuid": "31e9c2ad-f597-51ab-af6a-e550c3533f32", "author": "M. Pabiszczak", "year": "2026"},
|
||||
"generated": {"tex": "doc/generated/main.tex", "html": "web/index.html", "html_css": "web/style.css", "react_app": true, "html_tree_inspector": false}, "template": "templates/karta-klasyczna.json",
|
||||
"title_block": {"category": "KARTA PRACY · INFORMATYKA · FPGA", "standard": "ISO 7200", "prepared_by": "M. Pabiszczak", "prepared_on": "21.07.2026", "checked_by": "testy automatyczne + bramka sprzętowa", "approved_by": "—", "url": "http://localhost:8080", "repository_url": "https://gitea.local/edu-inf/lab-rv32i-asm-fpga-bringup", "revision": "1", "issued_on": "21.07.2026", "series": "RV32I-ASM-02", "document_type": "karta pracy", "tool": "card-layouts", "sheet": "1 / 1", "show_qr": false, "show_repository_qr": false, "height_cm": 3, "repeat_on_every_page": true, "replace_front_matter": true},
|
||||
"front_page_scope": {"title": "Cel karty", "content_tex": "Uczeń przygotowuje jawny kontrakt płytki, weryfikuje top-level w symulacji i przechodzi uporządkowany preflight programowania. Automatyczny PASS nie jest dowodem działania fizycznego FPGA.", "scope_title": "Zakres tasków", "scope_table": {"headers": ["Krok", "Numer tasku", "Najważniejsza idea", "Priorytet", "Status", "Version"], "rows": [{"chapter": "2", "task": "Task01", "idea_tex": "piny i active-low", "priority": "obowiązkowe", "status": "ready", "version": "v00.01"}, {"chapter": "2", "task": "Task02", "idea_tex": "reset i blinker RTL", "priority": "obowiązkowe", "status": "ready", "version": "v00.01"}, {"chapter": "2", "task": "Task03", "idea_tex": "ordered programming gate", "priority": "obowiązkowe", "status": "ready", "version": "v00.01"}]}},
|
||||
"learning_effects": {"RV02.EN01": {"text": "Uczeń analizuje granicę między symulacją, bitstreamem, programatorem i obserwacją sprzętu.", "label": "Bring-up evidence", "bloom_level": "Analiza", "assessment_criteria": ["RV02.KW01"]}, "RV02.EK01": {"text": "Uczeń przygotowuje top-level i uporządkowany workflow konfiguracji FPGA.", "label": "FPGA preflight", "bloom_level": "Zastosowanie", "assessment_criteria": ["RV02.KW01"]}},
|
||||
"assessment_criteria": {"RV02.KW01": {"text": "Polaryzacja, reset, krawędzie LED i kolejność cable–id–configure–observe są potwierdzone, a wynik modelu raportuje hardware_observed=0 i claim=preflight-only.", "learning_effects": ["RV02.EN01", "RV02.EK01"]}},
|
||||
"educational_requirements": {"RV02.WE01": {"text": "Bezpieczny i audytowalny FPGA bring-up.", "label": "Board to observation", "learning_effects": ["RV02.EN01", "RV02.EK01"], "learning_tree": {"schema": "we-learning-tree.v1", "policy": "Nie zgadujemy płytki ani pinów. Model i symulacja mogą zaliczyć preflight, lecz tylko log programatora i obserwacja na wskazanej płytce stanowią dowód fizyczny.", "ogolne": [{"tree_id": "RV02.WE01.OG.IP.01", "effect_ref": "RV02.EN01", "display": "EN IP RV02.01", "text": "Uczeń ocenia siłę dowodów bring-up.", "kw": [{"criterion_ref": "RV02.KW01", "display": "KW IP RV02.01", "text": "Rozróżnia VCD od obserwacji fizycznej."}]}], "zawodowe": [{"tree_id": "RV02.WE01.TECH.I4.01", "effect_ref": "RV02.EK01", "display": "EK INF04 RV02.01", "text": "Uczeń przygotowuje kontrakt i kolejność programowania.", "kw": [{"criterion_ref": "RV02.KW01", "display": "KW INF04 RV02.01", "text": "Nie omija identyfikacji urządzenia."}]}]}}},
|
||||
"sections": [
|
||||
{"title": "Task01 · Kontrakt płytki i polaryzacja", "content_kind": "prose", "content_tex": "Wypełnij board/board-contract.yaml dla konkretnego stanowiska. Potwierdź active-low reset, button i LED; wartości \\texttt{SET\\_*} są blokadą przeciw przypadkowemu użyciu obcych pinów.", "educational_requirement_refs": ["RV02.WE01"], "learning_effect_refs": ["RV02.EN01", "RV02.EK01"], "assessment_criterion_refs": ["RV02.KW01"], "area_tree_refs": ["RV02.WE01.OG.IP.01", "RV02.WE01.TECH.I4.01"], "steps": [{"id": "T01-PINS", "title": "Potwierdź pin_contract=generic i jawne polaryzacje.", "tree_refs": ["RV02.WE01.TECH.I4.01"]}], "assets": [{"path": "assets/bringup-gate.png", "html_path": "assets/bringup-gate.svg", "caption": "Symulacja kończy automatyczny preflight; fizyczny dowód wymaga rzeczywistej płytki.", "label": "fig:bringup-gate", "alt": "Diagram kolejnych bramek FPGA bring-up.", "kind": "diagram", "width": 0.72}]},
|
||||
{"title": "Task02 · Top-level blinker", "content_kind": "prose", "content_tex": "Zasymuluj znany reset i dzielnik. LED musi zmienić stan w taktach 4, 8, 12 i 16. VCD dowodzi zachowania RTL, ale nie poprawności constraintów ani połączeń płytki.", "educational_requirement_refs": ["RV02.WE01"], "learning_effect_refs": ["RV02.EN01", "RV02.EK01"], "assessment_criterion_refs": ["RV02.KW01"], "area_tree_refs": ["RV02.WE01.OG.IP.01", "RV02.WE01.TECH.I4.01"], "steps": [{"id": "T02-BLINK", "title": "Zmierz reset_known=1, cztery krawędzie i zapisz VCD.", "tree_refs": ["RV02.WE01.TECH.I4.01"]}]},
|
||||
{"title": "Task03 · Bramka programowania", "content_kind": "prose", "content_tex": "Przejdź kolejno przez cable, device ID, configure i observe. Model blokuje pominięcie etapu, lecz jego PASS zawsze kończy się \\texttt{hardware\\_observed=0} oraz \\texttt{claim=preflight-only}. Fizyczny wynik wpisz osobno.", "educational_requirement_refs": ["RV02.WE01"], "learning_effect_refs": ["RV02.EN01", "RV02.EK01"], "assessment_criterion_refs": ["RV02.KW01"], "area_tree_refs": ["RV02.WE01.OG.IP.01", "RV02.WE01.TECH.I4.01"], "steps": [{"id": "T03-GATE", "title": "Potwierdź kolejność i brak fałszywego hardware claim.", "tree_refs": ["RV02.WE01.OG.IP.01"]}]}
|
||||
],
|
||||
"tasks": {"task01": {"prompt_tex": "Zweryfikuj polaryzację interfejsu płytki.", "criterion": "reset_active=1 button_active=1 led_n=0 pin_contract=generic", "assessment_criterion_ref": "RV02.KW01"}, "task02": {"prompt_tex": "Zweryfikuj reset i blinker.", "criterion": "reset_known=1 led_edges=4,8,12,16 divider=4", "assessment_criterion_ref": "RV02.KW01"}, "task03": {"prompt_tex": "Przejdź uporządkowany model programowania.", "criterion": "order=cable,id,configure,observe hardware_observed=0 claim=preflight-only", "assessment_criterion_ref": "RV02.KW01"}},
|
||||
"tasks_order": ["task01", "task02", "task03"], "mission": "Przygotować bezpieczny, nieudawany workflow od board contract do obserwacji LED.", "objectives": ["Zdefiniujesz płytkę i polaryzacje.", "Zweryfikujesz blinker przed syntezą.", "Oddzielisz preflight od fizycznego dowodu."],
|
||||
"agenda": [{"time": "0--20 min", "work": "Kontrakt i piny."}, {"time": "20--40 min", "work": "Symulacja top-level."}, {"time": "40--70 min", "work": "Programowanie i formularz dowodowy."}],
|
||||
"tech_stack": {"software": [{"name": "SystemVerilog + Verilator", "use": "preflight RTL i VCD"}, {"name": "Board-specific FPGA toolchain", "use": "realny bitstream po uzupełnieniu kontraktu"}, {"name": "stemctl", "use": "profile native-amd64 i hazard3-sim"}], "hardware": [{"name": "Płytka określona przez board-contract.yaml", "use": "fizyczna bramka poza automatycznym modelem"}]},
|
||||
"container_test": {"title": "Automatyczny preflight", "commands": ["stemctl test native-amd64 rv32i-asm L02 1", "stemctl debug hazard3-sim rv32i-asm L02 2", "stemctl test hazard3-sim rv32i-asm L02 3"], "pass_condition": "Trzy PASS, VCD oraz jawne hardware_observed=0; ten wynik nie jest dowodem fizycznego FPGA."},
|
||||
"safety_rules": [{"title": "Nie zgaduj pinów", "body": "Nie uruchamiaj syntezy ani programowania z wartościami SET_* w board-contract.yaml."}, {"title": "Identyfikuj przed konfiguracją", "body": "Odczytany device ID musi odpowiadać fpga_part kontraktu."}, {"title": "Nie zawyżaj dowodu", "body": "Symulacja, model workflow i plik VCD nie zastępują logu programatora oraz obserwacji LED."}],
|
||||
"hardware_procedure": [{"step": "1", "action": "Uzupełnij board contract.", "condition": "Znane board ID, part, clock, piny i toolchain."}, {"step": "2", "action": "Zbuduj i zahashuj bitstream.", "condition": "Build zakończony, SHA-256 zapisany."}, {"step": "3", "action": "Wykryj i skonfiguruj FPGA.", "condition": "Device ID zgodny, programator exit 0."}, {"step": "4", "action": "Zmierz LED.", "condition": "Obserwator, czas i okres zapisane w evidence."}], "references": []
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
schema: 1
|
||||
card: rv32i-asm/L02
|
||||
title: FPGA Bring-up and Programming
|
||||
steps:
|
||||
- id: pins
|
||||
task: task01_pin_polarity
|
||||
action: test
|
||||
profile: hazard3-sim
|
||||
target: hazard3-baremetal
|
||||
command: stemctl test hazard3-sim rv32i-asm L02 1
|
||||
board: {show: [board_contract, reset_n, button_n, led_n], highlight: [active_low, explicit_pin_map, no_guessed_board], question: Które sygnały są aktywne stanem niskim?}
|
||||
evidence: [polarity, contract_status]
|
||||
- id: blinker
|
||||
task: task02_bringup_blinker
|
||||
action: debug
|
||||
profile: hazard3-sim
|
||||
target: hazard3-baremetal
|
||||
command: stemctl debug hazard3-sim rv32i-asm L02 2
|
||||
board: {show: [clock, reset_n, counter, led, trace_vcd], highlight: [known_reset, divider, edge_cycles], question: Dlaczego symulacja jest warunkiem koniecznym, ale nie dowodem działania płytki?}
|
||||
evidence: [reset_known, led_edges, trace_vcd]
|
||||
- id: physical-gate
|
||||
task: task03_programming_gate
|
||||
action: test
|
||||
profile: hazard3-sim
|
||||
target: hazard3-baremetal
|
||||
command: stemctl test hazard3-sim rv32i-asm L02 3
|
||||
board: {show: [cable, device_id, bitstream_digest, configure_log, led_observation], highlight: [ordered_gate, physical_evidence, hardware_observed_zero_in_model], question: Jakiego dowodu nadal brakuje po PASS modelu?}
|
||||
evidence: [workflow_order, preflight_claim, physical_form]
|
||||
Generated
+811
@@ -0,0 +1,811 @@
|
||||
{
|
||||
"name": "mpabi-inf-rv32i-asm-02-fpga-bringup",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mpabi-inf-rv32i-asm-02-fpga-bringup",
|
||||
"dependencies": {
|
||||
"@fastify/websocket": "^11.3.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"fastify": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
||||
"integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/fast-json-stringify-compiler": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz",
|
||||
"integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-json-stringify": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/forwarded": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
|
||||
"integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/proxy-addr": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/forwarded": "^3.0.0",
|
||||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
|
||||
"integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpack/msgpack": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
|
||||
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/avvio": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz",
|
||||
"integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/error": "^4.0.0",
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stringify": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
|
||||
"integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/merge-json-schemas": "^0.2.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-uri": "^4.0.0",
|
||||
"json-schema-ref-resolver": "^3.0.0",
|
||||
"rfdc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stringify/node_modules/fast-uri": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz",
|
||||
"integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-querystring": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
|
||||
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-decode-uri-component": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz",
|
||||
"integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/ajv-compiler": "^4.0.5",
|
||||
"@fastify/error": "^4.0.0",
|
||||
"@fastify/fast-json-stringify-compiler": "^5.0.0",
|
||||
"@fastify/proxy-addr": "^5.0.0",
|
||||
"abstract-logging": "^2.0.1",
|
||||
"avvio": "^9.0.0",
|
||||
"fast-json-stringify": "^7.0.0",
|
||||
"find-my-way": "^9.6.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"semver": "^7.6.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
|
||||
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-querystring": "^1.0.0",
|
||||
"safe-regex2": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"process-warning": "^4.0.0",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request/node_modules/process-warning": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
|
||||
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
|
||||
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
|
||||
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ret": "~0.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"safe-regex2": "bin/safe-regex2.js"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
|
||||
"integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "mpabi-inf-rv32i-asm-02-fpga-bringup",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/card_dev.mjs",
|
||||
"serve": "node scripts/card_server.mjs",
|
||||
"report": "node scripts/export_lesson_report.mjs",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/websocket": "^11.3.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"fastify": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { watch } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd());
|
||||
const layoutsRoot = path.resolve(process.env.CARD_LAYOUTS_ROOT ?? path.join(repositoryRoot, '../../../tools/card-layouts'));
|
||||
const renderScript = path.join(repositoryRoot, 'scripts/render_card_layouts.sh');
|
||||
const watched = [
|
||||
[path.join(repositoryRoot, 'json'), name => name === 'card_source.json'],
|
||||
[path.join(repositoryRoot, 'doc/assets'), () => true],
|
||||
[path.join(layoutsRoot, 'react/src'), name => /\.(tsx?|css)$/.test(name)],
|
||||
[path.join(layoutsRoot, 'react'), name => /^(build\.mjs|tsconfig\.json)$/.test(name)],
|
||||
[path.join(layoutsRoot, 'tools'), name => name === 'render_card.py'],
|
||||
[path.join(layoutsRoot, 'schemas'), name => name === 'card-source.schema.json']
|
||||
];
|
||||
|
||||
let build = null;
|
||||
let queued = false;
|
||||
let debounce = null;
|
||||
|
||||
function render() {
|
||||
if (build) {
|
||||
queued = true;
|
||||
return;
|
||||
}
|
||||
process.stdout.write('[card-dev] generowanie karty…\n');
|
||||
build = spawn(renderScript, {
|
||||
cwd: repositoryRoot,
|
||||
env: { ...process.env, CARD_LAYOUTS_ROOT: layoutsRoot },
|
||||
stdio: 'inherit'
|
||||
});
|
||||
build.once('exit', code => {
|
||||
build = null;
|
||||
process.stdout.write(code === 0
|
||||
? '[card-dev] karta gotowa; przeglądarka odświeży się automatycznie.\n'
|
||||
: `[card-dev] generator zakończył się kodem ${code}.\n`);
|
||||
if (queued) {
|
||||
queued = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(render, 180);
|
||||
}
|
||||
|
||||
await access(renderScript);
|
||||
for (const [directory, accepts] of watched) {
|
||||
try {
|
||||
await access(directory);
|
||||
watch(directory, (_event, filename) => {
|
||||
if (filename && accepts(String(filename))) scheduleRender();
|
||||
});
|
||||
process.stdout.write(`[card-dev] obserwuję ${directory}\n`);
|
||||
} catch {
|
||||
process.stderr.write(`[card-dev] pomijam brakujący katalog ${directory}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const server = spawn(process.execPath, ['--watch', 'scripts/card_server.mjs'], {
|
||||
cwd: repositoryRoot,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
const stop = signal => {
|
||||
if (build) build.kill(signal);
|
||||
server.kill(signal);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => stop('SIGINT'));
|
||||
process.on('SIGTERM', () => stop('SIGTERM'));
|
||||
|
||||
render();
|
||||
@@ -0,0 +1,918 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import Fastify from 'fastify';
|
||||
import websocket from '@fastify/websocket';
|
||||
import {
|
||||
STATUSES,
|
||||
catalogFromCard,
|
||||
normaliseProgress,
|
||||
progressSummary,
|
||||
readJson,
|
||||
statusLabel,
|
||||
teamsMarkdown,
|
||||
updateItem,
|
||||
writeJsonAtomically
|
||||
} from './lib/card_progress.mjs';
|
||||
import {
|
||||
attachNvimUi,
|
||||
callSelectedNvim,
|
||||
resizeSelectedNvimUi,
|
||||
selectedNvimTarget
|
||||
} from './lib/nvim_ui_bridge.mjs';
|
||||
import { activateCheckpoint, checkpointStatus } from './lib/checkpoint_controller.mjs';
|
||||
import { CardStateDatabase } from './lib/card_state_db.mjs';
|
||||
import {
|
||||
activateNavigation,
|
||||
KEYBOARD_SHORTCUTS,
|
||||
moveNavigation,
|
||||
navigationCatalog,
|
||||
normalizeViewerState,
|
||||
patchViewerState,
|
||||
selectNavigation
|
||||
} from './lib/card_control.mjs';
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd());
|
||||
const cardFile = path.join(repositoryRoot, 'json/card_source.json');
|
||||
const progressFile = path.resolve(process.env.CARD_PROGRESS_FILE ?? path.join(repositoryRoot, 'json/lesson_progress.json'));
|
||||
const stateDatabaseFile = path.resolve(
|
||||
process.env.CARD_STATE_DATABASE ?? path.join(repositoryRoot, '.stem/card-state.sqlite3')
|
||||
);
|
||||
const apiSocket = path.resolve(
|
||||
process.env.CARD_API_SOCKET ?? path.join(repositoryRoot, '.stem/card-api.sock')
|
||||
);
|
||||
const evidenceRoot = path.resolve(
|
||||
process.env.CARD_EVIDENCE_ROOT ?? path.join(repositoryRoot, '.stem/evidence')
|
||||
);
|
||||
const webRoot = path.join(repositoryRoot, 'web');
|
||||
const host = process.env.CARD_HOST ?? '127.0.0.1';
|
||||
const port = Number(process.env.CARD_PORT ?? 8080);
|
||||
const app = Fastify({ logger: true });
|
||||
const execFileAsync = promisify(execFile);
|
||||
const stateDatabase = new CardStateDatabase(stateDatabaseFile);
|
||||
await app.register(websocket, {
|
||||
options: {
|
||||
maxPayload: 64 * 1024
|
||||
}
|
||||
});
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.puml': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
|
||||
let writeQueue = Promise.resolve();
|
||||
let currentNavigation = stateDatabase.read('navigation');
|
||||
let navigationRevision = Number.isSafeInteger(Number(currentNavigation?.revision))
|
||||
? Number(currentNavigation.revision)
|
||||
: 0;
|
||||
const persistedViewer = stateDatabase.read('viewer');
|
||||
let currentViewer = normalizeViewerState(persistedViewer);
|
||||
if (persistedViewer && JSON.stringify(persistedViewer) !== JSON.stringify(currentViewer)) {
|
||||
stateDatabase.write('viewer', currentViewer);
|
||||
}
|
||||
let viewerRevision = Number.isSafeInteger(Number(currentViewer?.revision))
|
||||
? Number(currentViewer.revision)
|
||||
: 0;
|
||||
|
||||
function actor(value, fallback = 'system') {
|
||||
return typeof value === 'string' && value.trim()
|
||||
? value.trim().slice(0, 160)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
async function zoomSelectedHostPane() {
|
||||
const selected = await selectedNvimTarget();
|
||||
const instance = String(selected.metadata?.instance ?? '');
|
||||
const result = await execFileAsync('tmux', [
|
||||
'list-panes', '-a', '-F',
|
||||
'#{pane_id}\t#{@stem_instance}\t#{@stem_role}\t#{window_zoomed_flag}'
|
||||
]);
|
||||
const panes = result.stdout.trim().split('\n').filter(Boolean).map(line => {
|
||||
const [id, stemInstance, role, zoomed] = line.split('\t');
|
||||
return { id, stemInstance, role, zoomed };
|
||||
});
|
||||
const exact = panes.find(pane => pane.stemInstance === instance && pane.role === 'debugger');
|
||||
const debuggerPanes = panes.filter(pane => pane.role === 'debugger');
|
||||
const pane = exact ?? (debuggerPanes.length === 1 ? debuggerPanes[0] : null);
|
||||
if (!pane) return { found: false, instance };
|
||||
if (pane.zoomed !== '1') {
|
||||
await execFileAsync('tmux', ['resize-pane', '-Z', '-t', pane.id]);
|
||||
}
|
||||
const dimensions = await execFileAsync('tmux', [
|
||||
'display-message', '-p', '-t', pane.id,
|
||||
'#{pane_width}x#{pane_height}\t#{window_zoomed_flag}'
|
||||
]);
|
||||
const [size, zoomed] = dimensions.stdout.trim().split('\t');
|
||||
return { found: true, pane: pane.id, instance, size, zoomed: zoomed === '1' };
|
||||
}
|
||||
|
||||
function expectedNvimGrid(hostPane) {
|
||||
const match = hostPane?.found && typeof hostPane.size === 'string'
|
||||
? /^(\d+)x(\d+)$/.exec(hostPane.size)
|
||||
: null;
|
||||
if (!match) return null;
|
||||
return {
|
||||
width: Number(match[1]),
|
||||
// The nested tmux status line occupies one row below the Neovim pane.
|
||||
height: Math.max(1, Number(match[2]) - 1)
|
||||
};
|
||||
}
|
||||
|
||||
async function settleSelectedNvimTerminalUi(hostPane) {
|
||||
const target = expectedNvimGrid(hostPane);
|
||||
let innerTmux = null;
|
||||
try {
|
||||
innerTmux = await callSelectedNvim('nvim_exec_lua', [`
|
||||
if not vim.env.TMUX or vim.env.TMUX == '' then
|
||||
return { attempted = false }
|
||||
end
|
||||
local result = vim.system(
|
||||
{ 'tmux', 'resize-window', '-A' },
|
||||
{ text = true }
|
||||
):wait()
|
||||
return {
|
||||
attempted = true,
|
||||
code = result.code,
|
||||
stderr = vim.trim(result.stderr or '')
|
||||
}
|
||||
`, []]);
|
||||
} catch (error) {
|
||||
innerTmux = { attempted: true, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 1800;
|
||||
let terminal = null;
|
||||
do {
|
||||
const uis = await callSelectedNvim('nvim_list_uis', []);
|
||||
terminal = Array.isArray(uis)
|
||||
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty) ?? null
|
||||
: null;
|
||||
if (
|
||||
terminal
|
||||
&& (!target || (terminal.width >= target.width && terminal.height >= target.height))
|
||||
) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
return {
|
||||
target,
|
||||
terminal: terminal
|
||||
? { width: terminal.width, height: terminal.height }
|
||||
: null,
|
||||
inner_tmux: innerTmux,
|
||||
settled: Boolean(
|
||||
terminal
|
||||
&& (!target || (terminal.width >= target.width && terminal.height >= target.height))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function audit(eventType, eventActor, payload = {}, options = {}) {
|
||||
return stateDatabase.appendEvent({
|
||||
eventType,
|
||||
actor: actor(eventActor),
|
||||
navigation: options.navigation === undefined ? currentNavigation : options.navigation,
|
||||
evidenceRef: options.evidenceRef ?? null,
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
function html(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function auditReportHtml(events) {
|
||||
const rows = events.map(event => {
|
||||
const position = [event.task_id, event.block_id, event.phase_id, event.step_id]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
const evidence = event.evidence_ref
|
||||
? `<figure><img src="${html(event.evidence_ref)}" alt="${html(event.payload?.caption ?? event.event_type)}"><figcaption>${html(event.payload?.caption ?? '')}</figcaption></figure>`
|
||||
: '';
|
||||
return `<article><time>${html(event.occurred_at)}</time><strong>${html(event.event_type)}</strong><span>${html(event.actor)}</span><p>${html(position)}</p>${evidence}</article>`;
|
||||
}).join('\n');
|
||||
return `<!doctype html><html lang="pl"><meta charset="utf-8"><title>Ślad lekcji</title><style>body{max-width:1100px;margin:2rem auto;font:16px/1.45 system-ui;color:#222}header{border-bottom:2px solid;padding-bottom:1rem}article{display:grid;grid-template-columns:14rem 13rem 1fr;gap:.35rem 1rem;padding:1rem 0;border-bottom:1px solid #bbb}article p{grid-column:1/-1;margin:0;color:#555}figure{grid-column:1/-1;margin:.7rem 0}img{max-width:100%;border:1px solid #777}figcaption{font-size:.9rem;color:#555}@media print{body{max-width:none;margin:12mm}}</style><header><h1>Chronologiczny ślad lekcji</h1><p>Wpisy i timestampy pochodzą z backendu; zrzuty są dowodami przypiętymi do zdarzeń.</p></header><main>${rows}</main></html>`;
|
||||
}
|
||||
|
||||
async function restoreNavigation() {
|
||||
if (!currentNavigation) return;
|
||||
try {
|
||||
const catalog = await currentNavigationCatalog();
|
||||
currentNavigation = selectNavigation(
|
||||
catalog,
|
||||
currentNavigation,
|
||||
navigationRevision,
|
||||
currentNavigation.selected_at
|
||||
);
|
||||
} catch {
|
||||
currentNavigation = null;
|
||||
navigationRevision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function currentNavigationCatalog() {
|
||||
return navigationCatalog(await readJson(cardFile));
|
||||
}
|
||||
|
||||
async function currentCardIdentity() {
|
||||
const source = await readFile(cardFile);
|
||||
const card = JSON.parse(source.toString('utf8')).card ?? {};
|
||||
return {
|
||||
schema: 'stem-card-identity.v1',
|
||||
id: String(card.id ?? ''),
|
||||
uuid: String(card.uuid ?? ''),
|
||||
version: String(card.version ?? ''),
|
||||
source_sha256: createHash('sha256').update(source).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
async function selectedNvimState() {
|
||||
return callSelectedNvim('nvim_exec_lua', [`
|
||||
local win = vim.api.nvim_get_current_win()
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
local cursor = vim.api.nvim_win_get_cursor(win)
|
||||
local mode = vim.api.nvim_get_mode()
|
||||
return {
|
||||
window = win,
|
||||
buffer = buf,
|
||||
path = vim.api.nvim_buf_get_name(buf),
|
||||
cursor = { row = cursor[1], column = cursor[2] },
|
||||
mode = mode.mode,
|
||||
blocking = mode.blocking,
|
||||
changedtick = vim.api.nvim_buf_get_changedtick(buf),
|
||||
columns = vim.o.columns,
|
||||
lines = vim.o.lines
|
||||
}
|
||||
`, []]);
|
||||
}
|
||||
|
||||
async function webRevision() {
|
||||
const files = ['index.html', 'app.js', 'app.css', 'style.css', 'card-data.json'];
|
||||
for (const directory of ['figures', 'debug-snapshots']) {
|
||||
try {
|
||||
for (const name of await readdir(path.join(webRoot, directory))) {
|
||||
files.push(path.join(directory, name));
|
||||
}
|
||||
} catch {
|
||||
// A card without optional generated assets is valid.
|
||||
}
|
||||
}
|
||||
const revisions = await Promise.all(files.sort().map(async name => {
|
||||
const info = await stat(path.join(webRoot, name));
|
||||
return `${name}:${info.mtimeMs}:${info.size}`;
|
||||
}));
|
||||
return revisions.join('|');
|
||||
}
|
||||
|
||||
async function cardAndProgress() {
|
||||
const card = await readJson(cardFile);
|
||||
let saved;
|
||||
try {
|
||||
saved = await readJson(progressFile);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
saved = {};
|
||||
}
|
||||
return {
|
||||
card,
|
||||
progress: normaliseProgress(card, saved, {
|
||||
repository: process.env.CARD_SOURCE_REPOSITORY,
|
||||
revision: process.env.CARD_SOURCE_REVISION
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function updateProgress(itemId, change) {
|
||||
const operation = writeQueue.then(async () => {
|
||||
const { card, progress } = await cardAndProgress();
|
||||
updateItem(progress, itemId, change);
|
||||
await writeJsonAtomically(progressFile, progress);
|
||||
return { card, progress };
|
||||
});
|
||||
writeQueue = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function trackerAssets() {
|
||||
const css = `<style id="lesson-progress-style">
|
||||
.lesson-progress-control{display:inline-flex;align-items:center;gap:.35rem;margin:0 .45rem .25rem 0;padding:.18rem .48rem;border:1px solid #8c8c8c;border-radius:1rem;background:#fff;color:#222;font:600 .76rem/1.25 system-ui,sans-serif;cursor:pointer}.lesson-progress-control:hover{border-color:#005a9c}.lesson-progress-control[data-status="approved"]{border-color:#18794e;background:#e7f6ed}.step-row.lesson-progress-completed{border-left:4px solid #18794e;padding-left:.45rem}.lesson-progress-dock{position:fixed;z-index:20;right:1rem;bottom:1rem;width:min(24rem,calc(100vw - 2rem));padding:.85rem 1rem;border:1px solid #555;border-radius:.6rem;background:#fff;box-shadow:0 5px 24px #0004;font:14px/1.35 system-ui,sans-serif}.lesson-progress-dock strong{display:block}.lesson-progress-dock p{margin:.35rem 0}.lesson-progress-dock a{color:#005a9c}.lesson-progress-dock button{margin-top:.25rem;padding:.3rem .55rem;border:1px solid #555;border-radius:.25rem;background:#f6f6f6;cursor:pointer}@media print{.lesson-progress-control,.lesson-progress-dock{display:none!important}}</style>`;
|
||||
const script = `<script id="lesson-progress-script">
|
||||
(() => {
|
||||
const next = {pending:'approved', approved:'pending'};
|
||||
const labels = {pending:'○ niezatwierdzony', approved:'● zatwierdzony'};
|
||||
let catalog = [];
|
||||
let progress = null;
|
||||
const api = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json();
|
||||
};
|
||||
const render = () => {
|
||||
const rows = [...document.querySelectorAll('.step-row')];
|
||||
rows.forEach((row, index) => {
|
||||
const item = catalog[index];
|
||||
if (!item) return;
|
||||
let control = row.querySelector('.lesson-progress-control');
|
||||
if (!control) {
|
||||
control = document.createElement('button');
|
||||
control.type = 'button'; control.className = 'lesson-progress-control';
|
||||
row.querySelector('.step-title')?.before(control);
|
||||
control.addEventListener('click', async () => {
|
||||
const current = progress.items[item.id].status;
|
||||
control.disabled = true;
|
||||
try {
|
||||
const data = await api('/api/progress/' + encodeURIComponent(item.id), {method:'PUT', headers:{'content-type':'application/json'}, body:JSON.stringify({status:next[current]})});
|
||||
progress = data.progress; render();
|
||||
} catch (error) { window.alert('Nie zapisano postępu: ' + error.message); }
|
||||
finally { control.disabled = false; }
|
||||
});
|
||||
}
|
||||
const state = progress.items[item.id];
|
||||
control.dataset.status = state.status;
|
||||
control.textContent = labels[state.status];
|
||||
control.title = 'Kliknij, aby zatwierdzić lub cofnąć zatwierdzenie. Zapis zawiera timestamp.';
|
||||
row.classList.toggle('lesson-progress-completed', state.status === 'approved');
|
||||
});
|
||||
const summary = progress.summary;
|
||||
document.getElementById('lesson-progress-summary').textContent = 'Zatwierdzone: ' + summary.counts.approved + '/' + summary.total;
|
||||
};
|
||||
const start = async () => {
|
||||
const data = await api('/api/progress');
|
||||
catalog = data.catalog; progress = data.progress; render();
|
||||
const dock = document.createElement('aside'); dock.className = 'lesson-progress-dock';
|
||||
dock.innerHTML = '<strong>Przebieg zajęć</strong><p id="lesson-progress-summary"></p><p>Kliknij znacznik przy kroku, aby zatwierdzić lub cofnąć zatwierdzenie. Każda zmiana trafia do wersjonowanego JSON-a z czasem.</p><a href="/api/report/teams.md" target="_blank" rel="noopener">Otwórz raport do Teams (Markdown)</a>';
|
||||
document.body.append(dock); render();
|
||||
};
|
||||
start().catch(error => console.error('Nie można wczytać postępu zajęć:', error));
|
||||
})();
|
||||
</script>`;
|
||||
return { css, script };
|
||||
}
|
||||
|
||||
function injectTracker(html, revision) {
|
||||
const usesReactRenderer = html.includes('<meta name="card-renderer" content="react">');
|
||||
const { css, script } = usesReactRenderer ? { css: '', script: '' } : trackerAssets();
|
||||
const reload = `<script id="card-live-reload">
|
||||
(() => {
|
||||
let revision = ${JSON.stringify(revision)};
|
||||
const check = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/card-revision', { cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
const current = (await response.json()).revision;
|
||||
if (current !== revision) window.location.reload();
|
||||
} catch {
|
||||
// Keep the current page usable while the local server is restarting.
|
||||
}
|
||||
};
|
||||
window.setInterval(check, 1200);
|
||||
})();
|
||||
</script>`;
|
||||
return html
|
||||
.replace('</head>', `${css}</head>`)
|
||||
.replace('</body>', `${script}${reload}</body>`);
|
||||
}
|
||||
|
||||
app.get('/api/progress', async () => {
|
||||
const { card, progress } = await cardAndProgress();
|
||||
return {
|
||||
card: progress.card,
|
||||
session: progress.session,
|
||||
catalog: catalogFromCard(card),
|
||||
progress: { ...progress, summary: progressSummary(progress) },
|
||||
statuses: Object.fromEntries(STATUSES.map(status => [status, statusLabel[status]]))
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/progress/:itemId', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
if (!STATUSES.includes(body.status)) {
|
||||
return reply.code(400).send({ error: `status musi być jednym z: ${STATUSES.join(', ')}` });
|
||||
}
|
||||
try {
|
||||
const progressActor = actor(body.actor, 'teacher');
|
||||
const { progress } = await updateProgress(request.params.itemId, {
|
||||
status: body.status,
|
||||
note: body.note,
|
||||
actor: progressActor
|
||||
});
|
||||
audit('progress.status', progressActor, {
|
||||
item_id: request.params.itemId,
|
||||
status: body.status,
|
||||
note: typeof body.note === 'string' ? body.note.slice(0, 2000) : null
|
||||
});
|
||||
return { progress: { ...progress, summary: progressSummary(progress) } };
|
||||
} catch (error) {
|
||||
return reply.code(error.message.startsWith('Nieznany element') ? 404 : 500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/report/teams.md', async (_request, reply) => {
|
||||
const { card, progress } = await cardAndProgress();
|
||||
return reply
|
||||
.type('text/markdown; charset=utf-8')
|
||||
.header('content-disposition', `inline; filename="${progress.session.id}-teams.md"`)
|
||||
.send(teamsMarkdown(card, progress));
|
||||
});
|
||||
|
||||
app.get('/api/report/lesson.html', async (_request, reply) => reply
|
||||
.type('text/html; charset=utf-8')
|
||||
.send(auditReportHtml(stateDatabase.listEvents({ limit: 5000 }))));
|
||||
|
||||
app.get('/api/events', async request => {
|
||||
const query = request.query && typeof request.query === 'object' ? request.query : {};
|
||||
const afterId = Math.max(0, Number.isSafeInteger(Number(query.after_id)) ? Number(query.after_id) : 0);
|
||||
const limit = Math.max(1, Math.min(1000, Number.isSafeInteger(Number(query.limit)) ? Number(query.limit) : 200));
|
||||
return {
|
||||
schema: 'stem-card-event-log.v1',
|
||||
events: stateDatabase.listEvents({ afterId, limit })
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/events', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const eventType = typeof body.event_type === 'string' ? body.event_type.trim() : '';
|
||||
if (!/^[a-z][a-z0-9_.-]{1,79}$/.test(eventType)) {
|
||||
return reply.code(400).send({ error: 'event_type ma niepoprawny format.' });
|
||||
}
|
||||
const payload = body.payload && typeof body.payload === 'object' ? body.payload : {};
|
||||
if (JSON.stringify(payload).length > 16 * 1024) {
|
||||
return reply.code(413).send({ error: 'payload zdarzenia jest zbyt duży.' });
|
||||
}
|
||||
return {
|
||||
schema: 'stem-card-event.v1',
|
||||
event: audit(eventType, actor(body.actor, 'api'), payload)
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/evidence', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const match = typeof body.data_url === 'string'
|
||||
? /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(body.data_url)
|
||||
: null;
|
||||
if (!match) return reply.code(400).send({ error: 'Wymagany jest data_url obrazu PNG, JPEG albo WebP.' });
|
||||
const bytes = Buffer.from(match[2], 'base64');
|
||||
if (!bytes.length || bytes.length > 8 * 1024 * 1024) {
|
||||
return reply.code(413).send({ error: 'Zrzut musi mieć od 1 B do 8 MiB.' });
|
||||
}
|
||||
const extension = match[1] === 'jpeg' ? 'jpg' : match[1];
|
||||
const name = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.${extension}`;
|
||||
await mkdir(evidenceRoot, { recursive: true });
|
||||
await writeFile(path.join(evidenceRoot, name), bytes, { mode: 0o600 });
|
||||
const evidenceRef = `/api/evidence/${name}`;
|
||||
const event = stateDatabase.appendEvent({
|
||||
eventType: 'checkpoint.screenshot',
|
||||
actor: actor(body.actor, 'browser'),
|
||||
navigation: currentNavigation,
|
||||
evidenceRef,
|
||||
payload: {
|
||||
caption: typeof body.caption === 'string' ? body.caption.slice(0, 500) : '',
|
||||
mime: `image/${match[1]}`,
|
||||
size_bytes: bytes.length,
|
||||
snapshot_ref: typeof body.snapshot_ref === 'string' ? body.snapshot_ref : currentNavigation?.snapshot_ref
|
||||
}
|
||||
});
|
||||
return reply.code(201).send({ schema: 'stem-card-evidence.v1', event });
|
||||
});
|
||||
|
||||
app.get('/api/evidence/:name', async (request, reply) => {
|
||||
const name = String(request.params.name ?? '');
|
||||
if (!/^[A-Za-z0-9_.+-]+\.(png|jpg|webp)$/.test(name)) return reply.code(404).send('Not found');
|
||||
try {
|
||||
const file = path.join(evidenceRoot, name);
|
||||
const info = await stat(file);
|
||||
if (!info.isFile()) return reply.code(404).send('Not found');
|
||||
return reply
|
||||
.type(contentTypes[path.extname(file).toLowerCase()] ?? 'application/octet-stream')
|
||||
.send(await readFile(file));
|
||||
} catch {
|
||||
return reply.code(404).send('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/card-revision', async () => ({ revision: await webRevision() }));
|
||||
|
||||
app.get('/api/identity', async () => currentCardIdentity());
|
||||
|
||||
app.get('/api/checkpoint/status', async () => checkpointStatus());
|
||||
|
||||
app.get('/api/keyboard', async () => ({
|
||||
schema: 'stem-card-keyboard.v1',
|
||||
hierarchy: ['card', 'task', 'block', 'phase', 'step', 'snapshot'],
|
||||
shortcuts: KEYBOARD_SHORTCUTS
|
||||
}));
|
||||
|
||||
app.get('/api/navigation/catalog', async () => currentNavigationCatalog());
|
||||
|
||||
app.get('/api/navigation/current', async () => ({
|
||||
current: currentNavigation,
|
||||
checkpoint: checkpointStatus()
|
||||
}));
|
||||
|
||||
app.put('/api/navigation/current', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
try {
|
||||
const catalog = await currentNavigationCatalog();
|
||||
navigationRevision += 1;
|
||||
currentNavigation = selectNavigation(catalog, body, navigationRevision);
|
||||
stateDatabase.write('navigation', currentNavigation);
|
||||
audit('navigation.select', currentNavigation.actor, {
|
||||
focus_level: currentNavigation.focus_level,
|
||||
sync_requested: currentNavigation.sync_requested
|
||||
});
|
||||
return { current: currentNavigation };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/navigation/move', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
try {
|
||||
const catalog = await currentNavigationCatalog();
|
||||
navigationRevision += 1;
|
||||
currentNavigation = moveNavigation(
|
||||
catalog,
|
||||
currentNavigation,
|
||||
body,
|
||||
navigationRevision
|
||||
);
|
||||
stateDatabase.write('navigation', currentNavigation);
|
||||
audit('navigation.move', currentNavigation.actor, {
|
||||
level: body.level ?? currentNavigation.focus_level,
|
||||
delta: Number(body.delta),
|
||||
sync_requested: false
|
||||
});
|
||||
return { current: currentNavigation };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/viewer/state', async () => ({ viewer: currentViewer }));
|
||||
|
||||
app.put('/api/viewer/state', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
try {
|
||||
const requestedViewer = body.viewer && typeof body.viewer === 'object' ? body.viewer : body;
|
||||
const requestedNvim = requestedViewer.nvim && typeof requestedViewer.nvim === 'object'
|
||||
? requestedViewer.nvim
|
||||
: null;
|
||||
viewerRevision += 1;
|
||||
currentViewer = patchViewerState(currentViewer, body, viewerRevision);
|
||||
stateDatabase.write('viewer', currentViewer);
|
||||
if (requestedNvim && ['visible', 'sync', 'control', 'expanded', 'fit'].some(key => key in requestedNvim)) {
|
||||
audit('viewer.nvim', actor(body.actor, 'browser'), {
|
||||
visible: currentViewer.nvim.visible,
|
||||
sync: currentViewer.nvim.sync,
|
||||
control: currentViewer.nvim.control,
|
||||
expanded: currentViewer.nvim.expanded,
|
||||
fit: currentViewer.nvim.fit
|
||||
});
|
||||
}
|
||||
if (requestedViewer.allocator && typeof requestedViewer.allocator === 'object'
|
||||
&& 'visible' in requestedViewer.allocator) {
|
||||
audit('viewer.allocator', actor(body.actor, 'browser'), {
|
||||
visible: currentViewer.allocator.visible
|
||||
});
|
||||
}
|
||||
return { viewer: currentViewer };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/control/state', async () => ({
|
||||
schema: 'stem-card-control-state.v1',
|
||||
revision: `${navigationRevision}:${viewerRevision}`,
|
||||
navigation: currentNavigation,
|
||||
viewer: currentViewer,
|
||||
checkpoint: checkpointStatus()
|
||||
}));
|
||||
|
||||
app.get('/api/nvim/state', async (_request, reply) => {
|
||||
try {
|
||||
return {
|
||||
schema: 'stem-card-nvim-state.v1',
|
||||
state: await selectedNvimState()
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(503).send({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/nvim/cursor', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const row = Number(body.row);
|
||||
const column = Number(body.column);
|
||||
const requestedWindow = body.window == null ? null : Number(body.window);
|
||||
if (!Number.isSafeInteger(row) || row < 1 || !Number.isSafeInteger(column) || column < 0) {
|
||||
return reply.code(400).send({ error: 'Kursor wymaga row >= 1 i column >= 0.' });
|
||||
}
|
||||
if (requestedWindow != null && (!Number.isSafeInteger(requestedWindow) || requestedWindow < 1)) {
|
||||
return reply.code(400).send({ error: 'window musi być poprawnym identyfikatorem Neovima.' });
|
||||
}
|
||||
try {
|
||||
const window = requestedWindow ?? await callSelectedNvim('nvim_get_current_win');
|
||||
await callSelectedNvim('nvim_win_set_cursor', [window, [row, column]]);
|
||||
return {
|
||||
schema: 'stem-card-nvim-state.v1',
|
||||
state: await selectedNvimState()
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(503).send({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/nvim/input', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const keys = typeof body.keys === 'string' ? body.keys : '';
|
||||
if (!keys || keys.length > 256) {
|
||||
return reply.code(400).send({ error: 'keys musi zawierać od 1 do 256 znaków.' });
|
||||
}
|
||||
try {
|
||||
const accepted = await callSelectedNvim('nvim_input', [keys]);
|
||||
return { accepted, state: await selectedNvimState() };
|
||||
} catch (error) {
|
||||
return reply.code(503).send({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/state', async () => {
|
||||
let nvim;
|
||||
try {
|
||||
nvim = { available: true, state: await selectedNvimState() };
|
||||
} catch (error) {
|
||||
nvim = { available: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
return {
|
||||
schema: 'stem-card-state.v1',
|
||||
navigation: currentNavigation,
|
||||
viewer: currentViewer,
|
||||
checkpoint: checkpointStatus(),
|
||||
nvim,
|
||||
endpoints: {
|
||||
keyboard: '/api/keyboard',
|
||||
navigation_catalog: '/api/navigation/catalog',
|
||||
navigation_current: '/api/navigation/current',
|
||||
navigation_move: '/api/navigation/move',
|
||||
navigation_sync: '/api/navigation/sync',
|
||||
events: '/api/events',
|
||||
evidence: '/api/evidence',
|
||||
lesson_report: '/api/report/lesson.html',
|
||||
identity: '/api/identity',
|
||||
viewer: '/api/viewer/state',
|
||||
nvim_reset: '/api/nvim/reset',
|
||||
nvim_redraw: '/api/nvim/redraw',
|
||||
nvim_cursor: '/api/nvim/cursor',
|
||||
nvim_input: '/api/nvim/input'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/checkpoint/activate', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const eventActor = actor(body.actor, 'browser');
|
||||
audit('checkpoint.request', eventActor, {
|
||||
snapshot_ref: body.snapshot_ref,
|
||||
generation: Number(body.generation)
|
||||
});
|
||||
try {
|
||||
const result = await activateCheckpoint({
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef: body.snapshot_ref,
|
||||
generation: Number(body.generation),
|
||||
expectedBinding: body.expected_binding,
|
||||
expectedIdentity: body.expected_identity
|
||||
});
|
||||
audit('checkpoint.ready', eventActor, {
|
||||
snapshot_ref: body.snapshot_ref,
|
||||
generation: Number(body.generation),
|
||||
status: result.status,
|
||||
message: result.message
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
audit('checkpoint.failed', eventActor, {
|
||||
snapshot_ref: body.snapshot_ref,
|
||||
generation: Number(body.generation),
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return reply.code(409).send({
|
||||
status: 'failed',
|
||||
snapshot_ref: body.snapshot_ref,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/nvim/reset', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const eventActor = actor(body.actor, 'browser');
|
||||
audit('nvim.reset.request', eventActor, { source: 'web.F1' });
|
||||
try {
|
||||
await callSelectedNvim('nvim_command', ['CpuReset']);
|
||||
return {
|
||||
schema: 'stem-card-nvim-reset.v1',
|
||||
status: 'accepted',
|
||||
actor: eventActor,
|
||||
state: await selectedNvimState()
|
||||
};
|
||||
} catch (error) {
|
||||
audit('nvim.reset.failed', eventActor, {
|
||||
source: 'web.F1',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return reply.code(503).send({
|
||||
status: 'failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/nvim/redraw', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const eventActor = actor(body.actor, 'nvim');
|
||||
let hostPane;
|
||||
try {
|
||||
hostPane = await zoomSelectedHostPane();
|
||||
} catch (error) {
|
||||
hostPane = { found: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
try {
|
||||
const terminalUi = await settleSelectedNvimTerminalUi(hostPane);
|
||||
const externalUi = await resizeSelectedNvimUi();
|
||||
await callSelectedNvim('nvim_command', ['silent! StudentLayoutFit | redraw!']);
|
||||
const state = await selectedNvimState();
|
||||
audit('nvim.redraw', eventActor, {
|
||||
source: 'api',
|
||||
host_pane: hostPane,
|
||||
terminal_ui: terminalUi,
|
||||
external_ui: externalUi,
|
||||
columns: state.columns,
|
||||
lines: state.lines
|
||||
});
|
||||
return {
|
||||
schema: 'stem-card-nvim-redraw.v1',
|
||||
status: 'ready',
|
||||
host_pane: hostPane,
|
||||
terminal_ui: terminalUi,
|
||||
external_ui: externalUi,
|
||||
state
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(503).send({
|
||||
status: 'failed',
|
||||
host_pane: hostPane,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/navigation/sync', async (request, reply) => {
|
||||
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const eventActor = actor(body.actor, 'nvim');
|
||||
const resolvedFrom = currentNavigation?.focus_level ?? null;
|
||||
let activatedNavigation;
|
||||
try {
|
||||
const catalog = await currentNavigationCatalog();
|
||||
activatedNavigation = activateNavigation(
|
||||
catalog,
|
||||
currentNavigation,
|
||||
eventActor,
|
||||
navigationRevision + 1
|
||||
);
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
status: 'failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
navigationRevision += 1;
|
||||
currentNavigation = activatedNavigation;
|
||||
stateDatabase.write('navigation', currentNavigation);
|
||||
audit('navigation.activate', eventActor, {
|
||||
source: 'navigation.sync',
|
||||
resolved_from: resolvedFrom,
|
||||
focus_level: currentNavigation.focus_level,
|
||||
snapshot_ref: currentNavigation.snapshot_ref
|
||||
});
|
||||
const snapshotRef = currentNavigation.snapshot_ref;
|
||||
audit('checkpoint.request', eventActor, {
|
||||
source: 'navigation.sync',
|
||||
snapshot_ref: snapshotRef
|
||||
});
|
||||
try {
|
||||
const checkpoint = await activateCheckpoint({
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation: Date.now()
|
||||
});
|
||||
audit('checkpoint.ready', eventActor, {
|
||||
source: 'navigation.sync',
|
||||
snapshot_ref: snapshotRef,
|
||||
status: checkpoint.status,
|
||||
message: checkpoint.message
|
||||
});
|
||||
return {
|
||||
schema: 'stem-card-navigation-sync.v1',
|
||||
actor: eventActor,
|
||||
current: currentNavigation,
|
||||
checkpoint
|
||||
};
|
||||
} catch (error) {
|
||||
audit('checkpoint.failed', eventActor, {
|
||||
source: 'navigation.sync',
|
||||
snapshot_ref: snapshotRef,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return reply.code(409).send({
|
||||
status: 'failed',
|
||||
snapshot_ref: snapshotRef,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/nvim-ui', { websocket: true }, (socket, request) => {
|
||||
const origin = request.headers.origin;
|
||||
const expectedOrigin = `http://${request.headers.host}`;
|
||||
if (origin && origin !== expectedOrigin) {
|
||||
socket.close(1008, 'Origin not allowed');
|
||||
return;
|
||||
}
|
||||
attachNvimUi(socket);
|
||||
});
|
||||
|
||||
app.get('/*', async (request, reply) => {
|
||||
const requested = request.params['*'] || 'index.html';
|
||||
const relative = requested === '' ? 'index.html' : requested;
|
||||
const target = path.resolve(webRoot, relative);
|
||||
if (target !== webRoot && !target.startsWith(`${webRoot}${path.sep}`)) return reply.code(403).send('Forbidden');
|
||||
try {
|
||||
const info = await stat(target);
|
||||
if (!info.isFile()) return reply.code(404).send('Not found');
|
||||
if (path.basename(target) === 'index.html') {
|
||||
return reply
|
||||
.type('text/html; charset=utf-8')
|
||||
.send(injectTracker(await readFile(target, 'utf8'), await webRevision()));
|
||||
}
|
||||
return reply
|
||||
.type(contentTypes[path.extname(target).toLowerCase()] ?? 'application/octet-stream')
|
||||
.send(await readFile(target));
|
||||
} catch {
|
||||
return reply.code(404).send('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
let socketServer = null;
|
||||
app.addHook('onClose', async () => {
|
||||
if (socketServer) {
|
||||
await new Promise(resolve => socketServer.close(resolve));
|
||||
}
|
||||
await rm(apiSocket, { force: true });
|
||||
stateDatabase.close();
|
||||
});
|
||||
|
||||
await restoreNavigation();
|
||||
await app.listen({ host, port });
|
||||
|
||||
await rm(apiSocket, { force: true });
|
||||
socketServer = createServer(app.routing);
|
||||
await new Promise((resolve, reject) => {
|
||||
socketServer.once('error', reject);
|
||||
socketServer.listen(apiSocket, () => {
|
||||
socketServer.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
catalogFromCard,
|
||||
normaliseProgress,
|
||||
readJson,
|
||||
statusLabel,
|
||||
teamsMarkdown
|
||||
} from './lib/card_progress.mjs';
|
||||
|
||||
const root = path.resolve(process.cwd());
|
||||
const card = await readJson(path.join(root, 'json/card_source.json'));
|
||||
const progressFile = path.resolve(process.env.CARD_PROGRESS_FILE ?? path.join(root, 'json/lesson_progress.json'));
|
||||
let savedProgress;
|
||||
try {
|
||||
savedProgress = await readJson(progressFile);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
savedProgress = {};
|
||||
}
|
||||
const progress = normaliseProgress(card, savedProgress, {
|
||||
repository: process.env.CARD_SOURCE_REPOSITORY,
|
||||
revision: process.env.CARD_SOURCE_REVISION
|
||||
});
|
||||
const reportDir = path.join(root, 'doc/session-reports');
|
||||
const basename = `${card.card.id}-${progress.session.id}`;
|
||||
const markdownFile = path.join(reportDir, `${basename}.md`);
|
||||
const texFile = path.join(reportDir, `${basename}.tex`);
|
||||
const pdfFile = path.join(reportDir, `${basename}.pdf`);
|
||||
|
||||
function tex(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('\\', '\\textbackslash{}')
|
||||
.replaceAll('&', '\\&').replaceAll('%', '\\%').replaceAll('$', '\\$')
|
||||
.replaceAll('#', '\\#').replaceAll('_', '\\_').replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}').replaceAll('~', '\\textasciitilde{}')
|
||||
.replaceAll('^', '\\textasciicircum{}');
|
||||
}
|
||||
|
||||
function reportTex() {
|
||||
const catalog = catalogFromCard(card);
|
||||
const rows = catalog.map(item => {
|
||||
const current = progress.items[item.id];
|
||||
return `${tex(item.id)} & ${tex(statusLabel[current.status])} & ${tex(current.updated_at || '—')} & ${tex(item.title)} \\\\ \\hline`;
|
||||
}).join('\n');
|
||||
const events = progress.events.length
|
||||
? progress.events.map(event => `${tex(event.at)} & ${tex(event.item_id)} & ${tex(`${statusLabel[event.from]} → ${statusLabel[event.to]}`)} & ${tex(event.note || '—')} \\\\ \\hline`).join('\n')
|
||||
: `— & — & Brak zmian & — \\\\ \\hline`;
|
||||
return `\\documentclass[11pt,a4paper]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage[polish]{babel}
|
||||
\\usepackage[margin=18mm]{geometry}
|
||||
\\usepackage{longtable}
|
||||
\\usepackage{array}
|
||||
\\usepackage{booktabs}
|
||||
\\begin{document}
|
||||
\\section*{${tex(card.card.title)} — zapis zajęć}
|
||||
\\begin{tabular}{ll}
|
||||
Karta & ${tex(card.card.id)} (${tex(card.card.version)}) \\\\
|
||||
Materiały & ${tex(progress.card.repository || 'nieuzupełniono')} \\\\
|
||||
Rewizja & ${tex(progress.card.revision || 'nieuzupełniono')} \\\\
|
||||
Sesja & ${tex(progress.session.id)} \\\\
|
||||
Prowadzący & ${tex(progress.session.teacher || 'nieuzupełniono')} \\\\
|
||||
Rozpoczęcie & ${tex(progress.session.started_at || 'brak')} \\\\
|
||||
\\end{tabular}
|
||||
|
||||
\\subsection*{Zakres i status}
|
||||
\\begin{longtable}{p{0.13\\textwidth}p{0.18\\textwidth}p{0.22\\textwidth}p{0.38\\textwidth}}
|
||||
\\toprule Element & Status & Ostatnia zmiana & Zakres \\\\ \\midrule
|
||||
${rows}
|
||||
\\bottomrule
|
||||
\\end{longtable}
|
||||
|
||||
\\subsection*{Historia zatwierdzeń}
|
||||
\\begin{longtable}{p{0.22\\textwidth}p{0.13\\textwidth}p{0.25\\textwidth}p{0.30\\textwidth}}
|
||||
\\toprule Czas & Element & Zmiana & Notatka \\\\ \\midrule
|
||||
${events}
|
||||
\\bottomrule
|
||||
\\end{longtable}
|
||||
\\end{document}
|
||||
`;
|
||||
}
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.on('error', reject);
|
||||
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${command} zakończył się kodem ${code}`)));
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(markdownFile, teamsMarkdown(card, progress), 'utf8');
|
||||
await writeFile(texFile, reportTex(), 'utf8');
|
||||
console.log(`Markdown: ${path.relative(root, markdownFile)}`);
|
||||
console.log(`TeX: ${path.relative(root, texFile)}`);
|
||||
|
||||
if (!process.argv.includes('--no-pdf')) {
|
||||
try {
|
||||
await run('latexmk', ['-pdf', '-interaction=nonstopmode', '-outdir=' + reportDir, texFile], { cwd: root, stdio: 'inherit' });
|
||||
console.log(`PDF: ${path.relative(root, pdfFile)}`);
|
||||
} catch (error) {
|
||||
console.warn(`Nie utworzono PDF (${error.message}). Pliki Markdown i TeX są gotowe.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
const NAVIGATION_LEVELS = ['card', 'task', 'block', 'phase', 'step', 'snapshot'];
|
||||
|
||||
export const KEYBOARD_SHORTCUTS = [
|
||||
{ keys: 'Alt+ArrowUp/Alt+ArrowDown', action: 'navigation.move', level: 'task', description: 'poprzedni/następny TASK' },
|
||||
{ keys: 'Ctrl+ArrowUp/Ctrl+ArrowDown', action: 'navigation.move', level: 'block', description: 'poprzedni/następny BLOCK' },
|
||||
{ keys: 'Shift+ArrowUp/Shift+ArrowDown', action: 'navigation.move', level: 'phase', description: 'poprzednia/następna PHASE' },
|
||||
{ keys: 'Ctrl+Shift+ArrowUp/Ctrl+Shift+ArrowDown', action: 'navigation.move', level: 'step', description: 'poprzedni/następny STEP' },
|
||||
{ keys: 'Alt+Shift+ArrowUp/Alt+Shift+ArrowDown', action: 'navigation.move', level: 'snapshot', description: 'poprzedni/następny unikalny SNAPSHOT' },
|
||||
{ keys: 'ArrowUp/ArrowDown', action: 'navigation.move', level: 'current', description: 'poprzedni/następny element aktywnego poziomu' },
|
||||
{ keys: 'ArrowRight', action: 'navigation.level', direction: 'child', description: 'zejdź poziom niżej' },
|
||||
{ keys: 'ArrowLeft', action: 'navigation.level', direction: 'parent', description: 'wróć poziom wyżej' },
|
||||
{ keys: 'Enter', action: 'navigation.activate', description: 'wybierz element i przy SYNC ON odtwórz jego stan' },
|
||||
{ keys: 'F1', action: 'nvim.reset', description: 'zresetuj aktualny cel i odśwież debugger' },
|
||||
{ keys: 'F2', action: 'navigation.activate', description: 'zsynchronizuj cel z kursorem UML; BLOCK wybiera pierwszy krok' },
|
||||
{ keys: 'Ctrl+Backquote', action: 'nvim.redraw', description: 'maksymalizuj pane, dopasuj siatkę i odśwież Neovima' },
|
||||
{ keys: 'Ctrl+Enter', action: 'progress.toggle', description: 'zatwierdź lub cofnij zatwierdzenie bieżącego kroku' },
|
||||
{ keys: 'Escape', action: 'navigation.level', direction: 'parent', description: 'anuluj albo wróć poziom wyżej' },
|
||||
{ keys: 'Alt+Digit1', action: 'viewer.nvim.visible.toggle', description: 'pokaż/ukryj panel Neovima' },
|
||||
{ keys: 'Alt+Digit2', action: 'viewer.nvim.sync.toggle', description: 'przełącz SYNC OFF/SYNC ON' },
|
||||
{ keys: 'Alt+Digit3', action: 'viewer.nvim.control.toggle', description: 'uzbrój/rozbrój sterowanie Neovimem' },
|
||||
{ keys: 'Alt+Digit4', action: 'viewer.nvim.expanded.toggle', description: 'przełącz normalną/wysoką wysokość panelu' },
|
||||
{ keys: 'Alt+Digit5', action: 'viewer.nvim.fit.toggle', description: 'dopasuj cały ekran Neovima' },
|
||||
{ keys: 'Alt+Digit6', action: 'viewer.allocator.visible.toggle', description: 'przypnij/ukryj model alokatora' },
|
||||
{ keys: 'F12', action: 'viewer.keyboard.toggle', description: 'pokaż/ukryj skorowidz klawiatury' }
|
||||
];
|
||||
|
||||
function text(value, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function node(value, fallbackId, fallbackLabel) {
|
||||
return {
|
||||
id: text(value?.id, fallbackId),
|
||||
label: text(value?.label, fallbackLabel)
|
||||
};
|
||||
}
|
||||
|
||||
export function navigationCatalog(card) {
|
||||
const entries = [];
|
||||
const taskIds = [];
|
||||
const blockIds = new Map();
|
||||
const globalSnapshots = [];
|
||||
|
||||
for (const [sectionIndex, section] of (card.sections ?? []).entries()) {
|
||||
for (const [assetIndex, asset] of (section.assets ?? []).entries()) {
|
||||
const interactive = asset.interactive;
|
||||
if (!interactive?.phases?.length) continue;
|
||||
const task = node(
|
||||
interactive.task,
|
||||
`section-${sectionIndex + 1}`,
|
||||
text(section.title, `Sekcja ${sectionIndex + 1}`)
|
||||
);
|
||||
const block = node(
|
||||
interactive.block,
|
||||
text(asset.label, `asset-${assetIndex + 1}`).replace(/^fig:/, ''),
|
||||
text(interactive.title, text(asset.caption, `Blok ${assetIndex + 1}`))
|
||||
);
|
||||
if (!taskIds.includes(task.id)) taskIds.push(task.id);
|
||||
const taskBlocks = blockIds.get(task.id) ?? [];
|
||||
if (!taskBlocks.includes(block.id)) taskBlocks.push(block.id);
|
||||
blockIds.set(task.id, taskBlocks);
|
||||
const blockPhases = interactive.phases;
|
||||
const blockSnapshots = [];
|
||||
|
||||
for (const [phaseIndex, phaseValue] of blockPhases.entries()) {
|
||||
const phase = node(phaseValue, `phase-${phaseIndex + 1}`, `Faza ${phaseIndex + 1}`);
|
||||
const phaseSnapshots = [];
|
||||
for (const [stepIndex, stepValue] of (phaseValue.steps ?? []).entries()) {
|
||||
const step = node(stepValue, `step-${stepIndex + 1}`, `Krok ${stepIndex + 1}`);
|
||||
const snapshotRef = text(stepValue.snapshot_ref) || null;
|
||||
if (snapshotRef && !phaseSnapshots.includes(snapshotRef)) phaseSnapshots.push(snapshotRef);
|
||||
if (snapshotRef && !blockSnapshots.includes(snapshotRef)) blockSnapshots.push(snapshotRef);
|
||||
if (snapshotRef && !globalSnapshots.includes(snapshotRef)) globalSnapshots.push(snapshotRef);
|
||||
entries.push({
|
||||
task: { ...task, index: taskIds.indexOf(task.id) },
|
||||
block: { ...block, index: taskBlocks.indexOf(block.id) },
|
||||
phase: { ...phase, index: phaseIndex },
|
||||
step: {
|
||||
...step,
|
||||
number: Number.isSafeInteger(Number(stepValue.number)) ? Number(stepValue.number) : stepIndex + 1,
|
||||
index: stepIndex,
|
||||
global_index: entries.length
|
||||
},
|
||||
snapshot: snapshotRef ? {
|
||||
ref: snapshotRef,
|
||||
index: phaseSnapshots.indexOf(snapshotRef),
|
||||
block_index: blockSnapshots.indexOf(snapshotRef),
|
||||
global_index: globalSnapshots.indexOf(snapshotRef)
|
||||
} : null,
|
||||
asset: {
|
||||
label: text(asset.label),
|
||||
section_index: sectionIndex,
|
||||
asset_index: assetIndex
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 'stem-card-navigation-catalog.v1',
|
||||
levels: NAVIGATION_LEVELS,
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
function integer(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function entryMatches(entry, request) {
|
||||
const ids = {
|
||||
task: text(request.task?.id, text(request.task_id)),
|
||||
block: text(request.block?.id, text(request.block_id)),
|
||||
phase: text(request.phase?.id, text(request.phase_id)),
|
||||
step: text(request.step?.id, text(request.step_id))
|
||||
};
|
||||
for (const [level, id] of Object.entries(ids)) {
|
||||
if (id && entry[level].id !== id) return false;
|
||||
}
|
||||
const snapshotRef = text(request.snapshot_ref, text(request.snapshot?.ref));
|
||||
return !snapshotRef || entry.snapshot?.ref === snapshotRef;
|
||||
}
|
||||
|
||||
function findNavigationEntry(catalog, request) {
|
||||
const globalStep = integer(request.position?.global_step_index);
|
||||
if (globalStep != null) return catalog.entries[globalStep] ?? null;
|
||||
const matches = catalog.entries.filter(entry => entryMatches(entry, request));
|
||||
if (!matches.length) return null;
|
||||
const phaseIndex = integer(request.position?.phase_index);
|
||||
const stepIndex = integer(request.position?.step_index);
|
||||
const snapshotIndex = integer(request.position?.snapshot_index);
|
||||
return matches.find(entry =>
|
||||
(phaseIndex == null || entry.phase.index === phaseIndex)
|
||||
&& (stepIndex == null || entry.step.index === stepIndex)
|
||||
&& (snapshotIndex == null || entry.snapshot?.index === snapshotIndex)
|
||||
) ?? matches[0];
|
||||
}
|
||||
|
||||
export function selectNavigation(catalog, request, revision = 1, now = new Date().toISOString()) {
|
||||
if (!request || typeof request !== 'object') throw new Error('Stan nawigacji musi być obiektem.');
|
||||
const entry = findNavigationEntry(catalog, request);
|
||||
if (!entry) throw new Error('Nie znaleziono wskazanej pozycji w katalogu karty.');
|
||||
const focusLevel = text(request.focus_level, 'step');
|
||||
if (!NAVIGATION_LEVELS.includes(focusLevel)) {
|
||||
throw new Error(`focus_level musi być jednym z: ${NAVIGATION_LEVELS.join(', ')}.`);
|
||||
}
|
||||
return {
|
||||
schema: 'stem-card-navigation.v2',
|
||||
revision,
|
||||
selected_at: now,
|
||||
actor: text(request.actor, 'browser'),
|
||||
focus_level: focusLevel,
|
||||
cursor_visible: focusLevel !== 'card',
|
||||
task: entry.task,
|
||||
block: entry.block,
|
||||
phase: entry.phase,
|
||||
step: entry.step,
|
||||
snapshot: entry.snapshot,
|
||||
snapshot_ref: entry.snapshot?.ref ?? null,
|
||||
position: {
|
||||
task_index: entry.task.index,
|
||||
block_index: entry.block.index,
|
||||
phase_index: entry.phase.index,
|
||||
step_index: entry.step.index,
|
||||
global_step_index: entry.step.global_index,
|
||||
snapshot_index: entry.snapshot?.index ?? null,
|
||||
global_snapshot_index: entry.snapshot?.global_index ?? null
|
||||
},
|
||||
sync_requested: request.sync_requested === true
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueAtLevel(entries, level, current) {
|
||||
const seen = new Set();
|
||||
return entries.filter(entry => {
|
||||
let key;
|
||||
if (level === 'task') key = entry.task.id;
|
||||
else if (level === 'block') key = `${entry.task.id}/${entry.block.id}`;
|
||||
else if (level === 'phase') key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}`;
|
||||
else if (level === 'snapshot') key = entry.snapshot?.ref;
|
||||
else key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}/${entry.step.id}`;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (level === 'phase') return entry.task.id === current.task.id && entry.block.id === current.block.id;
|
||||
if (level === 'step' || level === 'snapshot') {
|
||||
return entry.task.id === current.task.id && entry.block.id === current.block.id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function moveNavigation(catalog, current, command, revision = 1, now = new Date().toISOString()) {
|
||||
if (!current) throw new Error('Najpierw ustaw bieżącą pozycję nawigacji.');
|
||||
const requestedLevel = text(command?.level, current.focus_level);
|
||||
const level = requestedLevel === 'current' ? current.focus_level : requestedLevel;
|
||||
if (!NAVIGATION_LEVELS.includes(level)) throw new Error('Niepoprawny poziom nawigacji.');
|
||||
if (level === 'card') throw new Error('Poziom CARD nie ma elementów UML do przewijania.');
|
||||
const delta = integer(command?.delta);
|
||||
if (delta == null || delta === 0 || Math.abs(delta) > 100) throw new Error('delta musi być niezerową liczbą całkowitą.');
|
||||
const candidates = uniqueAtLevel(catalog.entries, level, current);
|
||||
const currentIndex = candidates.findIndex(entry => {
|
||||
if (level === 'task') return entry.task.id === current.task.id;
|
||||
if (level === 'block') return entry.block.id === current.block.id && entry.task.id === current.task.id;
|
||||
if (level === 'phase') return entry.phase.id === current.phase.id;
|
||||
if (level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
|
||||
return entry.step.id === current.step.id && entry.phase.id === current.phase.id;
|
||||
});
|
||||
const targetIndex = Math.max(0, Math.min(candidates.length - 1, Math.max(0, currentIndex) + delta));
|
||||
const target = candidates[targetIndex];
|
||||
if (!target) throw new Error('Brak elementu na wskazanym poziomie.');
|
||||
return selectNavigation(catalog, {
|
||||
task_id: target.task.id,
|
||||
block_id: target.block.id,
|
||||
phase_id: target.phase.id,
|
||||
step_id: target.step.id,
|
||||
snapshot_ref: target.snapshot?.ref,
|
||||
focus_level: level,
|
||||
// Moving the teaching cursor never mutates the debugger. Synchronisation
|
||||
// is a separate, explicit activation (Enter in WWW or F2 in Neovim).
|
||||
sync_requested: false,
|
||||
actor: text(command.actor, 'api')
|
||||
}, revision, now);
|
||||
}
|
||||
|
||||
export function activateNavigation(catalog, current, actor = 'nvim', revision = 1, now = new Date().toISOString()) {
|
||||
if (!current) throw new Error('Najpierw wybierz BLOCK zawierający diagram UML.');
|
||||
if (current.focus_level === 'card' || current.focus_level === 'task') {
|
||||
throw new Error('F2 wymaga zaznaczonego BLOCK, PHASE, STEP albo SNAPSHOT.');
|
||||
}
|
||||
|
||||
const target = catalog.entries.find(entry => {
|
||||
if (entry.task.id !== current.task.id || entry.block.id !== current.block.id) return false;
|
||||
if (current.focus_level === 'block') return true;
|
||||
if (entry.phase.id !== current.phase.id) return false;
|
||||
if (current.focus_level === 'phase') return true;
|
||||
if (current.focus_level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
|
||||
return entry.step.id === current.step.id;
|
||||
});
|
||||
if (!target) throw new Error('Nie znaleziono kroku UML, który można zsynchronizować.');
|
||||
if (!target.snapshot?.ref) throw new Error('Wybrany krok UML nie ma snapshotu do synchronizacji.');
|
||||
|
||||
return selectNavigation(catalog, {
|
||||
task_id: target.task.id,
|
||||
block_id: target.block.id,
|
||||
phase_id: target.phase.id,
|
||||
step_id: target.step.id,
|
||||
snapshot_ref: target.snapshot.ref,
|
||||
focus_level: 'step',
|
||||
sync_requested: true,
|
||||
actor: text(actor, 'nvim')
|
||||
}, revision, now);
|
||||
}
|
||||
|
||||
export function initialViewerState() {
|
||||
return {
|
||||
schema: 'stem-card-viewer-state.v1',
|
||||
revision: 0,
|
||||
updated_at: null,
|
||||
actor: null,
|
||||
scale: 2.18,
|
||||
scroll: { x: 0, y: 0, page_index: 0, sheet_scroll_top: 0 },
|
||||
viewport: { width: 0, height: 0 },
|
||||
allocator: { visible: true },
|
||||
nvim: {
|
||||
visible: true,
|
||||
sync: false,
|
||||
control: false,
|
||||
expanded: false,
|
||||
fit: false,
|
||||
connection: 'unknown',
|
||||
screen_cursor: null,
|
||||
grid: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function finite(value, minimum, maximum, name) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new Error(`${name} jest poza zakresem ${minimum}..${maximum}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boolean(value, fallback) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function patchViewerState(current, request, revision = current.revision + 1, now = new Date().toISOString()) {
|
||||
if (!request || typeof request !== 'object') throw new Error('Stan viewer musi być obiektem.');
|
||||
const patch = request.viewer && typeof request.viewer === 'object' ? request.viewer : request;
|
||||
const scroll = patch.scroll && typeof patch.scroll === 'object' ? patch.scroll : {};
|
||||
const viewport = patch.viewport && typeof patch.viewport === 'object' ? patch.viewport : {};
|
||||
const allocator = patch.allocator && typeof patch.allocator === 'object' ? patch.allocator : {};
|
||||
const nvim = patch.nvim && typeof patch.nvim === 'object' ? patch.nvim : {};
|
||||
const cursor = nvim.screen_cursor && typeof nvim.screen_cursor === 'object'
|
||||
? {
|
||||
row: Math.trunc(finite(nvim.screen_cursor.row, 0, 100000, 'nvim.screen_cursor.row')),
|
||||
column: Math.trunc(finite(nvim.screen_cursor.column, 0, 100000, 'nvim.screen_cursor.column'))
|
||||
}
|
||||
: current.nvim.screen_cursor;
|
||||
const grid = nvim.grid && typeof nvim.grid === 'object'
|
||||
? {
|
||||
columns: Math.trunc(finite(nvim.grid.columns, 1, 10000, 'nvim.grid.columns')),
|
||||
rows: Math.trunc(finite(nvim.grid.rows, 1, 10000, 'nvim.grid.rows'))
|
||||
}
|
||||
: current.nvim.grid;
|
||||
const sync = boolean(nvim.sync, current.nvim.sync);
|
||||
const control = sync && boolean(nvim.control, current.nvim.control);
|
||||
const visible = boolean(nvim.visible, current.nvim.visible);
|
||||
const fit = visible && boolean(nvim.fit, current.nvim.fit);
|
||||
const expanded = visible && !fit && boolean(nvim.expanded, current.nvim.expanded);
|
||||
const pageIndex = scroll.page_index == null
|
||||
? current.scroll.page_index
|
||||
: Math.trunc(finite(scroll.page_index, 0, 100000, 'scroll.page_index'));
|
||||
const pageChanged = scroll.page_index != null && pageIndex !== current.scroll.page_index;
|
||||
return {
|
||||
schema: 'stem-card-viewer-state.v1',
|
||||
revision,
|
||||
updated_at: now,
|
||||
actor: text(request.actor, 'browser'),
|
||||
scale: patch.scale == null ? current.scale : finite(patch.scale, 0.25, 3, 'scale'),
|
||||
scroll: {
|
||||
x: scroll.x == null ? current.scroll.x : finite(scroll.x, 0, 10000000, 'scroll.x'),
|
||||
y: scroll.y == null ? current.scroll.y : finite(scroll.y, 0, 10000000, 'scroll.y'),
|
||||
page_index: pageIndex,
|
||||
sheet_scroll_top: scroll.sheet_scroll_top == null
|
||||
? (pageChanged ? 0 : current.scroll.sheet_scroll_top ?? 0)
|
||||
: finite(scroll.sheet_scroll_top, 0, 10000000, 'scroll.sheet_scroll_top')
|
||||
},
|
||||
viewport: {
|
||||
width: viewport.width == null ? current.viewport.width : finite(viewport.width, 0, 100000, 'viewport.width'),
|
||||
height: viewport.height == null ? current.viewport.height : finite(viewport.height, 0, 100000, 'viewport.height')
|
||||
},
|
||||
allocator: {
|
||||
visible: boolean(allocator.visible, current.allocator?.visible ?? true)
|
||||
},
|
||||
nvim: {
|
||||
visible,
|
||||
sync,
|
||||
control,
|
||||
expanded,
|
||||
fit,
|
||||
connection: text(nvim.connection, current.nvim.connection),
|
||||
screen_cursor: cursor,
|
||||
grid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeViewerState(value) {
|
||||
const initial = initialViewerState();
|
||||
if (!value || typeof value !== 'object') return initial;
|
||||
const revisionValue = Number(value.revision);
|
||||
const revision = Number.isSafeInteger(revisionValue) && revisionValue >= 0
|
||||
? revisionValue
|
||||
: 0;
|
||||
const updatedAt = typeof value.updated_at === 'string' ? value.updated_at : null;
|
||||
const actor = typeof value.actor === 'string' && value.actor.trim()
|
||||
? value.actor.trim()
|
||||
: null;
|
||||
try {
|
||||
const normalized = patchViewerState(
|
||||
initial,
|
||||
{ actor: actor ?? 'browser', viewer: value },
|
||||
revision,
|
||||
updatedAt
|
||||
);
|
||||
return { ...normalized, actor };
|
||||
} catch {
|
||||
return { ...initial, revision, updated_at: updatedAt, actor };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const STATUSES = ['pending', 'approved'];
|
||||
|
||||
export const statusLabel = {
|
||||
pending: 'niezatwierdzony',
|
||||
approved: 'zatwierdzony'
|
||||
};
|
||||
|
||||
function normaliseStatus(status) {
|
||||
if (status === 'approved' || status === 'completed' || status === 'discussed') return 'approved';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
}
|
||||
|
||||
export async function writeJsonAtomically(file, value) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
export function catalogFromCard(card) {
|
||||
return card.sections.flatMap((section, sectionIndex) => {
|
||||
const sectionSteps = (section.steps ?? []).map((step, stepIndex) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
kind: 'section-step',
|
||||
section_index: sectionIndex + 1,
|
||||
section_title: section.title,
|
||||
step_index: stepIndex + 1
|
||||
}));
|
||||
const interactiveItems = (section.assets ?? []).flatMap(asset => {
|
||||
const phases = asset.interactive?.phases ?? [];
|
||||
if (phases.length) {
|
||||
return phases.flatMap(phase =>
|
||||
(phase.steps ?? [])
|
||||
.filter(step => typeof step.progress_id === 'string' && step.progress_id)
|
||||
.map(step => ({
|
||||
id: step.progress_id,
|
||||
title: `${String(asset.label || 'interaktywny UML').replace(/^fig:/, '')}: ${phase.label} / ${String(step.number).padStart(2, '0')} ${step.label}`,
|
||||
kind: 'interactive-step',
|
||||
section_index: sectionIndex + 1,
|
||||
section_title: section.title,
|
||||
asset_label: asset.label,
|
||||
phase_id: phase.id,
|
||||
step_id: step.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
return (asset.interactive?.stages ?? [])
|
||||
.filter(stage => typeof stage.progress_id === 'string' && stage.progress_id)
|
||||
.map(stage => ({
|
||||
id: stage.progress_id,
|
||||
title: `${String(asset.label || 'interaktywny UML').replace(/^fig:/, '')}: ${stage.label}`,
|
||||
kind: 'interactive-stage',
|
||||
section_index: sectionIndex + 1,
|
||||
section_title: section.title,
|
||||
asset_label: asset.label,
|
||||
stage_id: stage.id
|
||||
}));
|
||||
}).map((item, itemIndex) => ({
|
||||
...item,
|
||||
step_index: sectionSteps.length + itemIndex + 1
|
||||
}));
|
||||
return [...sectionSteps, ...interactiveItems];
|
||||
});
|
||||
}
|
||||
|
||||
export function normaliseProgress(card, progress, material = {}) {
|
||||
const catalog = catalogFromCard(card);
|
||||
const knownIds = new Set(catalog.map(item => item.id));
|
||||
const items = {};
|
||||
|
||||
for (const item of catalog) {
|
||||
const previous = progress.items?.[item.id] ?? {};
|
||||
items[item.id] = {
|
||||
status: normaliseStatus(previous.status),
|
||||
updated_at: previous.updated_at ?? null,
|
||||
note: typeof previous.note === 'string' ? previous.note : ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 'stem-card-progress.v2',
|
||||
card: {
|
||||
id: card.card.id,
|
||||
source: progress.card?.source || 'json/card_source.json',
|
||||
version: card.card.version,
|
||||
repository: progress.card?.repository || material.repository || '',
|
||||
revision: progress.card?.revision || material.revision || ''
|
||||
},
|
||||
session: {
|
||||
id: progress.session?.id || `${new Date().toISOString().slice(0, 10)}-${card.card.slug}`,
|
||||
title: progress.session?.title || card.card.title,
|
||||
teacher: progress.session?.teacher || '',
|
||||
started_at: progress.session?.started_at ?? null
|
||||
},
|
||||
items,
|
||||
events: Array.isArray(progress.events)
|
||||
? progress.events
|
||||
.filter(event => knownIds.has(event.item_id))
|
||||
.map(event => ({
|
||||
...event,
|
||||
from: normaliseStatus(event.from),
|
||||
to: normaliseStatus(event.to)
|
||||
}))
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
export function progressSummary(progress) {
|
||||
const counts = Object.fromEntries(STATUSES.map(status => [status, 0]));
|
||||
for (const item of Object.values(progress.items)) counts[item.status] += 1;
|
||||
return { total: Object.keys(progress.items).length, counts };
|
||||
}
|
||||
|
||||
export function updateItem(progress, itemId, { status, note, actor = 'teacher', at = new Date().toISOString() }) {
|
||||
if (!STATUSES.includes(status)) throw new Error(`Nieznany status: ${status}`);
|
||||
const item = progress.items[itemId];
|
||||
if (!item) throw new Error(`Nieznany element karty: ${itemId}`);
|
||||
|
||||
const nextNote = typeof note === 'string' ? note.trim() : item.note;
|
||||
const changed = item.status !== status || item.note !== nextNote;
|
||||
if (!changed) return false;
|
||||
|
||||
const from = item.status;
|
||||
item.status = status;
|
||||
item.updated_at = at;
|
||||
item.note = nextNote;
|
||||
if (!progress.session.started_at) progress.session.started_at = at;
|
||||
progress.events.push({ at, item_id: itemId, from, to: status, actor, note: nextNote });
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeMarkdown(value) {
|
||||
return String(value ?? '').replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
export function teamsMarkdown(card, progress) {
|
||||
const catalog = catalogFromCard(card);
|
||||
const byStatus = status => catalog.filter(item => progress.items[item.id]?.status === status);
|
||||
const summary = progressSummary(progress);
|
||||
const heading = `# ${card.card.title} — zapis zajęć`;
|
||||
const metadata = [
|
||||
`- Karta: \`${card.card.id}\` (${card.card.version})`,
|
||||
`- Materiał Edu: ${progress.card.repository || 'nieuzupełniono'}`,
|
||||
`- Rewizja materiału: \`${progress.card.revision || 'nieuzupełniono'}\``,
|
||||
`- Sesja: \`${progress.session.id}\``,
|
||||
`- Prowadzący: ${progress.session.teacher || 'nieuzupełniono'}`,
|
||||
`- Rozpoczęcie: ${progress.session.started_at || 'brak'}`,
|
||||
`- Stan: ${summary.counts.approved}/${summary.total} zatwierdzone`
|
||||
];
|
||||
const list = (title, status) => {
|
||||
const rows = byStatus(status);
|
||||
const body = rows.length
|
||||
? rows.map(item => {
|
||||
const state = progress.items[item.id];
|
||||
const timestamp = state.updated_at ? ` — ${state.updated_at}` : '';
|
||||
const note = state.note ? ` — ${state.note}` : '';
|
||||
return `- [${status === 'approved' ? 'x' : ' '}] \`${item.id}\` ${item.title}${timestamp}${note}`;
|
||||
}).join('\n')
|
||||
: '- Brak.';
|
||||
return `## ${title}\n\n${body}`;
|
||||
};
|
||||
const timeline = progress.events.length
|
||||
? progress.events.map(event => {
|
||||
const item = catalog.find(candidate => candidate.id === event.item_id);
|
||||
return `| ${event.at} | \`${event.item_id}\` | ${statusLabel[event.from]} → ${statusLabel[event.to]} | ${escapeMarkdown(event.note)} |`;
|
||||
}).join('\n')
|
||||
: '| — | — | Brak zmian | — |';
|
||||
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
...metadata,
|
||||
'',
|
||||
list('Zatwierdzone', 'approved'),
|
||||
'',
|
||||
list('Pozostało do zatwierdzenia', 'pending'),
|
||||
'',
|
||||
'## Historia zatwierdzeń',
|
||||
'',
|
||||
'| Czas | Element | Zmiana | Notatka |',
|
||||
'| --- | --- | --- | --- |',
|
||||
timeline,
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const STATE_SCHEMA = 'stem-card-backend-state.v1';
|
||||
|
||||
export class CardStateDatabase {
|
||||
constructor(file) {
|
||||
this.file = path.resolve(file);
|
||||
mkdirSync(path.dirname(this.file), { recursive: true });
|
||||
this.database = new DatabaseSync(this.file);
|
||||
this.database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS card_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
schema_name TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS card_event (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
block_id TEXT,
|
||||
phase_id TEXT,
|
||||
step_id TEXT,
|
||||
snapshot_ref TEXT,
|
||||
evidence_ref TEXT,
|
||||
payload_json TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS card_event_occurred_at
|
||||
ON card_event(occurred_at, id);
|
||||
`);
|
||||
this.readStatement = this.database.prepare(`
|
||||
SELECT value_json
|
||||
FROM card_state
|
||||
WHERE key = ?
|
||||
`);
|
||||
this.writeStatement = this.database.prepare(`
|
||||
INSERT INTO card_state (key, schema_name, revision, updated_at, value_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
schema_name = excluded.schema_name,
|
||||
revision = excluded.revision,
|
||||
updated_at = excluded.updated_at,
|
||||
value_json = excluded.value_json
|
||||
`);
|
||||
this.appendEventStatement = this.database.prepare(`
|
||||
INSERT INTO card_event (
|
||||
occurred_at, event_type, actor,
|
||||
task_id, block_id, phase_id, step_id, snapshot_ref,
|
||||
evidence_ref, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id
|
||||
`);
|
||||
this.listEventsStatement = this.database.prepare(`
|
||||
SELECT
|
||||
id, occurred_at, event_type, actor,
|
||||
task_id, block_id, phase_id, step_id, snapshot_ref,
|
||||
evidence_ref, payload_json
|
||||
FROM card_event
|
||||
WHERE id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
}
|
||||
|
||||
read(key) {
|
||||
const row = this.readStatement.get(key);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value_json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
write(key, value) {
|
||||
const revision = Number.isSafeInteger(Number(value?.revision))
|
||||
? Number(value.revision)
|
||||
: 0;
|
||||
const updatedAt = String(value?.updated_at ?? value?.selected_at ?? new Date().toISOString());
|
||||
this.writeStatement.run(
|
||||
key,
|
||||
STATE_SCHEMA,
|
||||
revision,
|
||||
updatedAt,
|
||||
JSON.stringify(value)
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
appendEvent({
|
||||
eventType,
|
||||
actor = 'system',
|
||||
navigation = null,
|
||||
evidenceRef = null,
|
||||
payload = {},
|
||||
occurredAt = new Date().toISOString()
|
||||
}) {
|
||||
const row = this.appendEventStatement.get(
|
||||
String(occurredAt),
|
||||
String(eventType),
|
||||
String(actor),
|
||||
navigation?.task?.id ?? null,
|
||||
navigation?.block?.id ?? null,
|
||||
navigation?.phase?.id ?? null,
|
||||
navigation?.step?.id ?? null,
|
||||
navigation?.snapshot_ref ?? null,
|
||||
evidenceRef == null ? null : String(evidenceRef),
|
||||
JSON.stringify(payload ?? {})
|
||||
);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
occurred_at: String(occurredAt),
|
||||
event_type: String(eventType),
|
||||
actor: String(actor),
|
||||
task_id: navigation?.task?.id ?? null,
|
||||
block_id: navigation?.block?.id ?? null,
|
||||
phase_id: navigation?.phase?.id ?? null,
|
||||
step_id: navigation?.step?.id ?? null,
|
||||
snapshot_ref: navigation?.snapshot_ref ?? null,
|
||||
evidence_ref: evidenceRef == null ? null : String(evidenceRef),
|
||||
payload: payload ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
listEvents({ afterId = 0, limit = 200 } = {}) {
|
||||
return this.listEventsStatement.all(Number(afterId), Number(limit)).map(row => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(row.payload_json);
|
||||
} catch {
|
||||
// Preserve the audit row even if an old payload cannot be decoded.
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
occurred_at: row.occurred_at,
|
||||
event_type: row.event_type,
|
||||
actor: row.actor,
|
||||
task_id: row.task_id,
|
||||
block_id: row.block_id,
|
||||
phase_id: row.phase_id,
|
||||
step_id: row.step_id,
|
||||
snapshot_ref: row.snapshot_ref,
|
||||
evidence_ref: row.evidence_ref,
|
||||
payload
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export { STATE_SCHEMA };
|
||||
@@ -0,0 +1,423 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { callSelectedNvim } from './nvim_ui_bridge.mjs';
|
||||
|
||||
const POLL_MS = 100;
|
||||
const state = {
|
||||
wantedGeneration: 0,
|
||||
active: null,
|
||||
activeControl: null,
|
||||
last: null,
|
||||
queue: Promise.resolve()
|
||||
};
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function errorText(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
}
|
||||
|
||||
function runtimeRoot() {
|
||||
return process.env.XDG_RUNTIME_DIR ?? '/run/user/1000';
|
||||
}
|
||||
|
||||
function selectionRoot() {
|
||||
return process.env.MCP_CONTAINER_SELECTION_ROOT
|
||||
?? path.join(runtimeRoot(), 'stem/mcp-selected');
|
||||
}
|
||||
|
||||
async function selectedBinding() {
|
||||
const selectedDirectory = await realpath(path.join(selectionRoot(), 'current'));
|
||||
const [metadata, socketPath] = await Promise.all([
|
||||
readJson(path.join(selectedDirectory, 'selection.json')),
|
||||
realpath(path.join(selectedDirectory, 'n.sock'))
|
||||
]);
|
||||
return {
|
||||
metadata,
|
||||
socketPath,
|
||||
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
|
||||
};
|
||||
}
|
||||
|
||||
async function assertBindingCurrent(expected) {
|
||||
const current = await selectedBinding();
|
||||
if (current.epoch !== expected.epoch) {
|
||||
throw new Error('Cel MCP zmienił się podczas replay; wynik starego kontenera został odrzucony.');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExpectedBinding(binding, expected) {
|
||||
// Browser activation follows the currently selected teaching session and
|
||||
// remains backward compatible. CLI/orchestrator callers pin all fields.
|
||||
if (expected == null) return;
|
||||
if (typeof expected !== 'object') throw new Error('expected_binding musi być obiektem.');
|
||||
for (const field of ['container_id', 'instance', 'profile', 'target']) {
|
||||
const wanted = typeof expected[field] === 'string' ? expected[field] : '';
|
||||
const actual = typeof binding.metadata[field] === 'string' ? binding.metadata[field] : '';
|
||||
if (!wanted || wanted !== actual) {
|
||||
throw new Error(`Cel MCP zmienił się przed replay (${field}: ${actual || '?'} != ${wanted || '?'}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExpectedIdentity(actual, expected) {
|
||||
// Browser-only calls may omit the pin. Orchestrators send all fields so a
|
||||
// card hot reload cannot silently replay a recipe from another revision.
|
||||
if (expected == null) return;
|
||||
if (typeof expected !== 'object') throw new Error('expected_identity musi być obiektem.');
|
||||
for (const field of ['id', 'uuid', 'version', 'source_sha256']) {
|
||||
const wanted = typeof expected[field] === 'string' ? expected[field] : '';
|
||||
const observed = typeof actual[field] === 'string' ? actual[field] : '';
|
||||
if (!wanted || wanted !== observed) {
|
||||
throw new Error(`Karta zmieniła się przed replay (${field}: ${observed || '?'} != ${wanted || '?'}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCardSnapshot(cardFile) {
|
||||
const source = await readFile(cardFile);
|
||||
const document = JSON.parse(source.toString('utf8'));
|
||||
const card = document.card ?? {};
|
||||
return {
|
||||
document,
|
||||
identity: {
|
||||
id: String(card.id ?? ''),
|
||||
uuid: String(card.uuid ?? ''),
|
||||
version: String(card.version ?? ''),
|
||||
source_sha256: createHash('sha256').update(source).digest('hex')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sha256(file) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(await readFile(file));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function gitBlobSha1(file) {
|
||||
const content = await readFile(file);
|
||||
const hash = createHash('sha1');
|
||||
hash.update(`blob ${content.length}\0`);
|
||||
hash.update(content);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function hostPath(repositoryRoot, containerPath) {
|
||||
if (!path.isAbsolute(containerPath)) {
|
||||
const resolved = path.resolve(repositoryRoot, containerPath);
|
||||
if (resolved === repositoryRoot || resolved.startsWith(`${repositoryRoot}${path.sep}`)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
if (containerPath === '/workspace') return repositoryRoot;
|
||||
if (containerPath.startsWith('/workspace/')) {
|
||||
return path.join(repositoryRoot, containerPath.slice('/workspace/'.length));
|
||||
}
|
||||
throw new Error(`Ścieżka spoza /workspace nie jest dozwolona: ${containerPath}`);
|
||||
}
|
||||
|
||||
async function nvimLua(source, args = [], binding = null) {
|
||||
return callSelectedNvim(
|
||||
'nvim_exec_lua',
|
||||
[source, args],
|
||||
binding ? { socketPath: binding.socketPath } : {}
|
||||
);
|
||||
}
|
||||
|
||||
async function callPinnedNvim(binding, method, parameters = []) {
|
||||
return callSelectedNvim(method, parameters, { socketPath: binding.socketPath });
|
||||
}
|
||||
|
||||
async function sendGdb(command, binding) {
|
||||
const available = await callPinnedNvim(binding, 'nvim_call_function', [
|
||||
'exists',
|
||||
['*TermDebugSendCommand']
|
||||
]);
|
||||
if (available !== 1) {
|
||||
throw new Error('Termdebug nie udostępnia TermDebugSendCommand');
|
||||
}
|
||||
return callPinnedNvim(binding, 'nvim_call_function', [
|
||||
'TermDebugSendCommand',
|
||||
[command]
|
||||
]);
|
||||
}
|
||||
|
||||
async function interruptGdb(binding) {
|
||||
return nvimLua(`
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local name = vim.api.nvim_buf_get_name(buffer)
|
||||
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
|
||||
local channel = vim.bo[buffer].channel
|
||||
if channel and channel > 0 then
|
||||
vim.api.nvim_chan_send(channel, string.char(3))
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
`, [], binding);
|
||||
}
|
||||
|
||||
async function gdbTerminalRunning(binding) {
|
||||
return nvimLua(`
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local name = vim.api.nvim_buf_get_name(buffer)
|
||||
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
|
||||
local channel = vim.bo[buffer].channel
|
||||
if channel and channel > 0 and vim.fn.jobwait({ channel }, 0)[1] == -1 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
`, [], binding);
|
||||
}
|
||||
|
||||
async function cleanupDeadGdbBuffers(binding) {
|
||||
return nvimLua(`
|
||||
local dead = {}
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local name = vim.api.nvim_buf_get_name(buffer)
|
||||
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
|
||||
local channel = vim.bo[buffer].channel
|
||||
if not channel or channel <= 0 or vim.fn.jobwait({ channel }, 0)[1] ~= -1 then
|
||||
table.insert(dead, buffer)
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, buffer in ipairs(dead) do
|
||||
for _, window in ipairs(vim.fn.win_findbuf(buffer)) do
|
||||
if vim.api.nvim_win_is_valid(window) and #vim.api.nvim_list_wins() > 1 then
|
||||
pcall(vim.api.nvim_win_close, window, true)
|
||||
end
|
||||
end
|
||||
if vim.api.nvim_buf_is_valid(buffer) then
|
||||
pcall(vim.api.nvim_buf_delete, buffer, { force = true })
|
||||
end
|
||||
end
|
||||
return #dead
|
||||
`, [], binding);
|
||||
}
|
||||
|
||||
async function ensureTermdebug(binding) {
|
||||
const probe = gdbTerminalRunning(binding);
|
||||
let running;
|
||||
try {
|
||||
running = await Promise.race([
|
||||
probe,
|
||||
delay(500).then(() => { throw new Error('timeout'); })
|
||||
]);
|
||||
} catch {
|
||||
// Termdebug cleanup sometimes leaves a hit-enter prompt which blocks an
|
||||
// RPC expression while Neovim still accepts input events.
|
||||
await callPinnedNvim(binding, 'nvim_input', ['<CR>']).catch(() => undefined);
|
||||
running = await probe;
|
||||
}
|
||||
if (running) {
|
||||
await cleanupDeadGdbBuffers(binding);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
// A failed Termdebug job can leave Neovim at a hit-enter prompt. Feeding
|
||||
// Enter only in the dead-GDB branch clears it before the recovery command.
|
||||
await callPinnedNvim(binding, 'nvim_input', ['<CR>']).catch(() => undefined);
|
||||
await cleanupDeadGdbBuffers(binding);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentTermdebug']);
|
||||
const deadline = Date.now() + 8000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await gdbTerminalRunning(binding)) {
|
||||
// TermdebugStartPost queues target/directory/dashboard setup.
|
||||
await delay(1200);
|
||||
await cleanupDeadGdbBuffers(binding);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error('Nie udało się automatycznie odtworzyć procesu GDB w Termdebug.');
|
||||
}
|
||||
|
||||
async function assertEditorReady(repositoryRoot, artifact, binding) {
|
||||
const modified = await nvimLua(`
|
||||
local result = {}
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_is_loaded(buffer) and vim.bo[buffer].modified then
|
||||
table.insert(result, vim.api.nvim_buf_get_name(buffer))
|
||||
end
|
||||
end
|
||||
return result
|
||||
`, [], binding);
|
||||
if (Array.isArray(modified) && modified.some(name => name.endsWith(artifact.source))) {
|
||||
throw new Error('Bufor Task04 ma niezapisane zmiany; zapis lub przebudowa są wymagane przed replay.');
|
||||
}
|
||||
const elf = await nvimLua('return vim.env.ELF_FILE or ""', [], binding);
|
||||
if (typeof elf !== 'string' || !elf) throw new Error('Neovim nie ma ELF_FILE bieżącej sesji.');
|
||||
const elfOnHost = hostPath(repositoryRoot, elf);
|
||||
if (artifact.source_git_blob) {
|
||||
const sourceOnHost = hostPath(repositoryRoot, artifact.source);
|
||||
const actualSourceBlob = await gitBlobSha1(sourceOnHost);
|
||||
if (actualSourceBlob !== artifact.source_git_blob) {
|
||||
throw new Error('Źródło Task04 różni się od wersji użytej do pomiaru karty; przebuduj kartę i snapshoty.');
|
||||
}
|
||||
}
|
||||
if (artifact.hazard3_image_sha256 && binding.metadata.profile === 'hazard3-sim') {
|
||||
const imageOnHost = hostPath(repositoryRoot, artifact.hazard3_image);
|
||||
const actual = await sha256(imageOnHost);
|
||||
if (actual !== artifact.hazard3_image_sha256) {
|
||||
throw new Error('Obraz wykonywalny Hazard3 różni się od obrazu użytego do pomiaru karty; zregeneruj snapshoty.');
|
||||
}
|
||||
}
|
||||
return { elf, elfOnHost };
|
||||
}
|
||||
|
||||
async function waitForResult(resultFile, operationId, timeoutMs, binding) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const current = await readJson(resultFile);
|
||||
if (current.operation_id === operationId) {
|
||||
latest = current;
|
||||
state.active = current;
|
||||
if (current.status === 'ready' || current.status === 'failed') return current;
|
||||
}
|
||||
} catch {
|
||||
// GDB writes atomically; absence before the first phase is expected.
|
||||
}
|
||||
await delay(POLL_MS);
|
||||
}
|
||||
await interruptGdb(binding).catch(() => undefined);
|
||||
await delay(200);
|
||||
throw new Error(`Timeout replay; ostatni stan: ${latest?.status ?? 'brak odpowiedzi GDB'}`);
|
||||
}
|
||||
|
||||
async function runActivation({
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation,
|
||||
expectedBinding,
|
||||
expectedIdentity
|
||||
}) {
|
||||
if (generation !== state.wantedGeneration) {
|
||||
return { status: 'cancelled', generation, snapshot_ref: snapshotRef, message: 'Zastąpione nowszym krokiem.' };
|
||||
}
|
||||
const [cardSnapshot, binding] = await Promise.all([readCardSnapshot(cardFile), selectedBinding()]);
|
||||
const card = cardSnapshot.document;
|
||||
assertExpectedIdentity(cardSnapshot.identity, expectedIdentity);
|
||||
assertExpectedBinding(binding, expectedBinding);
|
||||
const selection = binding.metadata;
|
||||
const registry = card.debug_checkpoints;
|
||||
const recipe = registry?.items?.[snapshotRef];
|
||||
if (!recipe) throw new Error(`Brak recepty checkpointu: ${snapshotRef}`);
|
||||
if (!registry.targets?.[selection.profile]) {
|
||||
throw new Error(`Karta nie ma adaptera replay dla profilu ${selection.profile ?? '?'}.`);
|
||||
}
|
||||
await assertBindingCurrent(binding);
|
||||
await ensureTermdebug(binding);
|
||||
await assertEditorReady(repositoryRoot, registry.artifact, binding);
|
||||
const operationId = randomUUID();
|
||||
const payload = {
|
||||
operation_id: operationId,
|
||||
generation,
|
||||
snapshot_ref: snapshotRef,
|
||||
profile: selection.profile,
|
||||
semantics: registry.semantics,
|
||||
stop: recipe.stop,
|
||||
verify: recipe.verify ?? { expressions: [] }
|
||||
};
|
||||
state.active = {
|
||||
status: 'replaying',
|
||||
phase: 'dispatch',
|
||||
operation_id: operationId,
|
||||
generation,
|
||||
snapshot_ref: snapshotRef,
|
||||
profile: selection.profile
|
||||
};
|
||||
state.activeControl = { generation, binding };
|
||||
await sendGdb('source /workspace/tools/gdb/stem_checkpoint.py', binding);
|
||||
const token = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
await sendGdb(`stem-checkpoint-activate ${token}`, binding);
|
||||
const resultFile = path.join(
|
||||
repositoryRoot,
|
||||
'.stem/instances',
|
||||
selection.instance,
|
||||
'gdb-sync.json.checkpoint.json'
|
||||
);
|
||||
const result = await waitForResult(
|
||||
resultFile,
|
||||
operationId,
|
||||
selection.profile === 'rp2350' ? 45000 : 20000,
|
||||
binding
|
||||
);
|
||||
if (result.status !== 'ready') {
|
||||
throw new Error(result.message ?? `Replay ${snapshotRef} nie osiągnął stanu ready.`);
|
||||
}
|
||||
await assertBindingCurrent(binding);
|
||||
state.last = result;
|
||||
state.active = null;
|
||||
state.activeControl = null;
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentViewReset']).catch(() => undefined);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['redraw!']).catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkpointStatus() {
|
||||
return { active: state.active, last: state.last, wanted_generation: state.wantedGeneration };
|
||||
}
|
||||
|
||||
export function activateCheckpoint({
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation,
|
||||
expectedBinding,
|
||||
expectedIdentity
|
||||
}) {
|
||||
if (typeof snapshotRef !== 'string' || !/^task04\.[a-z0-9.-]+$/.test(snapshotRef)) {
|
||||
return Promise.reject(new Error('Niepoprawny snapshot_ref.'));
|
||||
}
|
||||
if (generation != null && (!Number.isSafeInteger(generation) || generation <= 0)) {
|
||||
return Promise.reject(new Error('generation musi być dodatnią, bezpieczną liczbą całkowitą.'));
|
||||
}
|
||||
const nextGeneration = Number.isSafeInteger(generation) && generation > 0
|
||||
? generation
|
||||
: state.wantedGeneration + 1;
|
||||
state.wantedGeneration = Math.max(state.wantedGeneration + 1, nextGeneration);
|
||||
const activeControl = state.activeControl;
|
||||
if (
|
||||
activeControl
|
||||
&& activeControl.generation < state.wantedGeneration
|
||||
&& state.active?.phase === 'run-to-stop'
|
||||
) {
|
||||
void interruptGdb(activeControl.binding).catch(() => undefined);
|
||||
}
|
||||
const request = {
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation: state.wantedGeneration,
|
||||
expectedBinding,
|
||||
expectedIdentity
|
||||
};
|
||||
const operation = state.queue.then(() => runActivation(request));
|
||||
state.queue = operation.catch(error => {
|
||||
state.last = {
|
||||
status: 'failed',
|
||||
snapshot_ref: snapshotRef,
|
||||
generation: request.generation,
|
||||
message: errorText(error)
|
||||
};
|
||||
state.active = null;
|
||||
if (state.activeControl?.generation === request.generation) state.activeControl = null;
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import { decodeMultiStream, encode } from '@msgpack/msgpack';
|
||||
|
||||
const OPEN = 1;
|
||||
const DEFAULT_COLUMNS = 190;
|
||||
const DEFAULT_ROWS = 50;
|
||||
const RETRY_DELAY_MS = 1200;
|
||||
const TARGET_POLL_MS = 750;
|
||||
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function errorText(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function selectedTarget(selectionRoot) {
|
||||
const current = path.join(selectionRoot, 'current');
|
||||
const socketLink = path.join(current, 'n.sock');
|
||||
const [socketPath, metadataText] = await Promise.all([
|
||||
realpath(socketLink),
|
||||
readFile(path.join(current, 'selection.json'), 'utf8')
|
||||
]);
|
||||
const metadata = JSON.parse(metadataText);
|
||||
return {
|
||||
socketPath,
|
||||
metadata,
|
||||
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
|
||||
};
|
||||
}
|
||||
|
||||
class NvimRpcClient {
|
||||
constructor(socketPath, onNotification) {
|
||||
this.socketPath = socketPath;
|
||||
this.onNotification = onNotification;
|
||||
this.socket = new net.Socket();
|
||||
this.nextMessageId = 1;
|
||||
this.pending = new Map();
|
||||
this.closed = false;
|
||||
this.readLoop = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const connected = () => {
|
||||
this.socket.off('error', failed);
|
||||
resolve();
|
||||
};
|
||||
const failed = error => {
|
||||
this.socket.off('connect', connected);
|
||||
reject(error);
|
||||
};
|
||||
this.socket.once('connect', connected);
|
||||
this.socket.once('error', failed);
|
||||
this.socket.connect(this.socketPath);
|
||||
});
|
||||
this.readLoop = this.consume().catch(error => {
|
||||
this.rejectPending(error);
|
||||
if (!this.closed) this.socket.destroy(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async consume() {
|
||||
for await (const message of decodeMultiStream(this.socket)) {
|
||||
if (!Array.isArray(message)) continue;
|
||||
if (message[0] === 1) {
|
||||
const pending = this.pending.get(message[1]);
|
||||
if (!pending) continue;
|
||||
this.pending.delete(message[1]);
|
||||
if (message[2]) pending.reject(new Error(JSON.stringify(message[2])));
|
||||
else pending.resolve(message[3]);
|
||||
} else if (message[0] === 2) {
|
||||
this.onNotification(message[1], message[2]);
|
||||
}
|
||||
}
|
||||
throw new Error('Neovim zamknął połączenie RPC.');
|
||||
}
|
||||
|
||||
call(method, parameters = []) {
|
||||
if (this.closed) return Promise.reject(new Error('Połączenie RPC jest zamknięte.'));
|
||||
const id = this.nextMessageId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.socket.write(encode([0, id, method, parameters]), error => {
|
||||
if (!error) return;
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
rejectPending(error) {
|
||||
for (const pending of this.pending.values()) pending.reject(error);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.rejectPending(new Error('Połączenie RPC zostało zamknięte.'));
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function safeSend(websocket, message) {
|
||||
if (websocket.readyState !== OPEN) return;
|
||||
if (websocket.bufferedAmount > MAX_BUFFERED_BYTES) {
|
||||
websocket.close(1013, 'Neovim UI client is too slow');
|
||||
return;
|
||||
}
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function existingUiSize(uis) {
|
||||
const valid = Array.isArray(uis)
|
||||
? uis.filter(item => Number.isInteger(item?.width) && Number.isInteger(item?.height))
|
||||
: [];
|
||||
const terminal = valid.find(item => item?.stdin_tty && item?.stdout_tty);
|
||||
if (terminal) {
|
||||
return { width: terminal.width, height: terminal.height };
|
||||
}
|
||||
return {
|
||||
// Without a terminal UI there is nothing to mirror, so retain a useful
|
||||
// external-UI fallback size until the selected terminal is attached again.
|
||||
width: Math.max(DEFAULT_COLUMNS, ...valid.map(item => item.width)),
|
||||
height: Math.max(DEFAULT_ROWS, ...valid.map(item => item.height))
|
||||
};
|
||||
}
|
||||
|
||||
class NvimUiHub {
|
||||
constructor(selectionRoot, onEmpty) {
|
||||
this.selectionRoot = selectionRoot;
|
||||
this.onEmpty = onEmpty;
|
||||
this.clients = new Set();
|
||||
this.rpc = null;
|
||||
this.stopped = false;
|
||||
this.started = false;
|
||||
this.status = { type: 'status', state: 'connecting' };
|
||||
this.pendingResize = null;
|
||||
this.resizeTimer = null;
|
||||
this.appliedUiSize = '';
|
||||
this.uiSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS };
|
||||
this.uiAttached = false;
|
||||
this.refreshingUi = null;
|
||||
}
|
||||
|
||||
broadcast(message) {
|
||||
this.status = message.type === 'status' ? message : this.status;
|
||||
for (const client of this.clients) safeSend(client, message);
|
||||
}
|
||||
|
||||
add(websocket) {
|
||||
this.clients.add(websocket);
|
||||
safeSend(websocket, this.status);
|
||||
const remove = () => this.remove(websocket);
|
||||
websocket.once('close', remove);
|
||||
websocket.once('error', remove);
|
||||
websocket.on('message', payload => this.handleMessage(payload));
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
void this.run().catch(error => {
|
||||
this.broadcast({ type: 'status', state: 'offline', message: errorText(error) });
|
||||
});
|
||||
} else if (this.rpc && this.uiAttached) {
|
||||
// A newly opened browser has no copy of redraw events emitted before it
|
||||
// joined the shared hub. Reattaching only this external UI makes Neovim
|
||||
// send one complete line-grid frame. The real TTY UI stays attached.
|
||||
void this.refreshUi().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
refreshUi() {
|
||||
if (this.refreshingUi) return this.refreshingUi;
|
||||
const rpc = this.rpc;
|
||||
const size = this.uiSize;
|
||||
if (!rpc || !this.uiAttached) return Promise.resolve();
|
||||
this.refreshingUi = (async () => {
|
||||
await rpc.call('nvim_ui_detach');
|
||||
if (rpc !== this.rpc || this.stopped) return;
|
||||
this.uiAttached = false;
|
||||
await rpc.call('nvim_ui_attach', [size.width, size.height, {
|
||||
rgb: false,
|
||||
ext_linegrid: true,
|
||||
ext_multigrid: false,
|
||||
override: false
|
||||
}]);
|
||||
if (rpc === this.rpc) this.uiAttached = true;
|
||||
})().finally(() => {
|
||||
this.refreshingUi = null;
|
||||
});
|
||||
return this.refreshingUi;
|
||||
}
|
||||
|
||||
remove(websocket) {
|
||||
this.clients.delete(websocket);
|
||||
if (this.clients.size > 0) return;
|
||||
this.stopped = true;
|
||||
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
||||
this.rpc?.close();
|
||||
this.onEmpty();
|
||||
}
|
||||
|
||||
scheduleResize(columns, rows) {
|
||||
this.pendingResize = { columns, rows };
|
||||
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
this.resizeTimer = null;
|
||||
void this.applyResize().catch(() => undefined);
|
||||
}, 90);
|
||||
}
|
||||
|
||||
async resizeToTerminal() {
|
||||
if (this.resizeTimer) {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = null;
|
||||
}
|
||||
this.pendingResize = {
|
||||
columns: this.uiSize.width,
|
||||
rows: this.uiSize.height
|
||||
};
|
||||
await this.applyResize();
|
||||
return this.uiSize;
|
||||
}
|
||||
|
||||
async applyResize() {
|
||||
const requested = this.pendingResize;
|
||||
const rpc = this.rpc;
|
||||
this.pendingResize = null;
|
||||
if (!requested || !rpc || this.stopped) return;
|
||||
const uis = await rpc.call('nvim_list_uis');
|
||||
if (rpc !== this.rpc) return;
|
||||
const terminalUi = Array.isArray(uis)
|
||||
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty)
|
||||
: null;
|
||||
const columns = Number.isInteger(terminalUi?.width)
|
||||
? terminalUi.width
|
||||
: requested.columns;
|
||||
const rows = Number.isInteger(terminalUi?.height)
|
||||
? terminalUi.height
|
||||
: requested.rows;
|
||||
const size = `${columns}x${rows}`;
|
||||
if (size === this.appliedUiSize) return;
|
||||
await rpc.call('nvim_ui_try_resize', [columns, rows]);
|
||||
if (rpc !== this.rpc) return;
|
||||
await rpc.call('nvim_command', ['silent! StudentLayoutFit']);
|
||||
this.uiSize = { width: columns, height: rows };
|
||||
this.appliedUiSize = size;
|
||||
}
|
||||
|
||||
handleMessage(payload) {
|
||||
if (!this.rpc || this.stopped) return;
|
||||
try {
|
||||
const message = JSON.parse(payload.toString('utf8'));
|
||||
if (message?.type === 'refresh') {
|
||||
void this.resizeToTerminal()
|
||||
.then(() => this.refreshUi())
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (message?.type === 'input' && typeof message.keys === 'string' && message.keys.length <= 256) {
|
||||
void this.rpc.call('nvim_input', [message.keys]).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (message?.type !== 'mouse') return;
|
||||
const button = ['left', 'middle', 'right', 'wheel'].includes(message.button)
|
||||
? message.button
|
||||
: null;
|
||||
const action = ['press', 'drag', 'release', 'up', 'down'].includes(message.action)
|
||||
? message.action
|
||||
: null;
|
||||
const row = Number(message.row);
|
||||
const column = Number(message.column);
|
||||
if (!button || !action || !Number.isInteger(row) || !Number.isInteger(column)) return;
|
||||
void this.rpc.call('nvim_input_mouse', [
|
||||
button,
|
||||
action,
|
||||
typeof message.modifier === 'string' ? message.modifier.slice(0, 16) : '',
|
||||
0,
|
||||
Math.max(0, row),
|
||||
Math.max(0, column)
|
||||
]).catch(() => undefined);
|
||||
} catch {
|
||||
// Only the typed input/mouse/refresh capabilities above are exposed.
|
||||
}
|
||||
}
|
||||
|
||||
async run() {
|
||||
while (!this.stopped && this.clients.size > 0) {
|
||||
let selected;
|
||||
try {
|
||||
selected = await selectedTarget(this.selectionRoot);
|
||||
} catch (error) {
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'unselected',
|
||||
message: `Brak aktywnego celu MCP: ${errorText(error)}`
|
||||
});
|
||||
await delay(RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'connecting',
|
||||
epoch: selected.epoch,
|
||||
target: selected.metadata
|
||||
});
|
||||
|
||||
let targetTimer = null;
|
||||
try {
|
||||
this.rpc = new NvimRpcClient(selected.socketPath, (method, parameters) => {
|
||||
if (method !== 'redraw') return;
|
||||
this.broadcast({
|
||||
type: 'redraw',
|
||||
epoch: selected.epoch,
|
||||
events: parameters
|
||||
});
|
||||
});
|
||||
await this.rpc.connect();
|
||||
this.appliedUiSize = '';
|
||||
this.uiAttached = false;
|
||||
void this.rpc.readLoop.catch(() => undefined);
|
||||
const uis = await this.rpc.call('nvim_list_uis');
|
||||
const size = existingUiSize(uis);
|
||||
this.uiSize = size;
|
||||
await this.rpc.call('nvim_set_client_info', [
|
||||
'stem-card-browser',
|
||||
{ major: 0, minor: 1, patch: 0 },
|
||||
'ui',
|
||||
{},
|
||||
{ website: 'local://stem-card', license: 'private' }
|
||||
]);
|
||||
await this.rpc.call('nvim_ui_attach', [size.width, size.height, {
|
||||
rgb: false,
|
||||
ext_linegrid: true,
|
||||
ext_multigrid: false,
|
||||
override: false
|
||||
}]);
|
||||
this.uiAttached = true;
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'connected',
|
||||
epoch: selected.epoch,
|
||||
target: selected.metadata,
|
||||
grid: size,
|
||||
control: 'typed-input'
|
||||
});
|
||||
|
||||
const targetChanged = new Promise((_, reject) => {
|
||||
targetTimer = setInterval(async () => {
|
||||
try {
|
||||
const current = await selectedTarget(this.selectionRoot);
|
||||
if (current.epoch !== selected.epoch) {
|
||||
reject(new Error('Zmieniono cel MCP.'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, TARGET_POLL_MS);
|
||||
});
|
||||
await Promise.race([this.rpc.readLoop, targetChanged]);
|
||||
} catch (error) {
|
||||
if (!this.stopped) {
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'offline',
|
||||
epoch: selected.epoch,
|
||||
target: selected.metadata,
|
||||
message: errorText(error)
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (targetTimer) clearInterval(targetTimer);
|
||||
this.uiAttached = false;
|
||||
this.rpc?.close();
|
||||
this.rpc = null;
|
||||
}
|
||||
if (!this.stopped) await delay(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hubs = new Map();
|
||||
|
||||
function selectionRootFor(options = {}) {
|
||||
return options.selectionRoot
|
||||
?? process.env.MCP_CONTAINER_SELECTION_ROOT
|
||||
?? path.join(process.env.XDG_RUNTIME_DIR ?? '/run/user/1000', 'stem/mcp-selected');
|
||||
}
|
||||
|
||||
export async function callSelectedNvim(method, parameters = [], options = {}) {
|
||||
const selectionRoot = selectionRootFor(options);
|
||||
const hub = hubs.get(selectionRoot);
|
||||
const pinnedSocketPath = options.socketPath
|
||||
? await realpath(options.socketPath)
|
||||
: null;
|
||||
if (
|
||||
hub?.rpc
|
||||
&& !hub.stopped
|
||||
&& (!pinnedSocketPath || hub.rpc.socketPath === pinnedSocketPath)
|
||||
) {
|
||||
return hub.rpc.call(method, parameters);
|
||||
}
|
||||
const socketPath = pinnedSocketPath ?? (await selectedTarget(selectionRoot)).socketPath;
|
||||
const rpc = new NvimRpcClient(socketPath, () => undefined);
|
||||
await rpc.connect();
|
||||
void rpc.readLoop.catch(() => undefined);
|
||||
try {
|
||||
return await rpc.call(method, parameters);
|
||||
} finally {
|
||||
rpc.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resizeSelectedNvimUi(options = {}) {
|
||||
const selectionRoot = selectionRootFor(options);
|
||||
const hub = hubs.get(selectionRoot);
|
||||
if (!hub?.rpc || hub.stopped || !hub.uiAttached) {
|
||||
return { attached: false, grid: null };
|
||||
}
|
||||
const grid = await hub.resizeToTerminal();
|
||||
await hub.refreshUi();
|
||||
return { attached: true, grid };
|
||||
}
|
||||
|
||||
export async function selectedNvimTarget(options = {}) {
|
||||
return selectedTarget(selectionRootFor(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a browser to the one shared external UI for the selected container.
|
||||
* The browser never receives the Unix socket and cannot issue arbitrary RPC.
|
||||
*/
|
||||
export function attachNvimUi(websocket, options = {}) {
|
||||
const selectionRoot = selectionRootFor(options);
|
||||
let hub = hubs.get(selectionRoot);
|
||||
if (!hub || hub.stopped) {
|
||||
hub = new NvimUiHub(selectionRoot, () => hubs.delete(selectionRoot));
|
||||
hubs.set(selectionRoot, hub);
|
||||
}
|
||||
hub.add(websocket);
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
|
||||
repo_root=$(CDPATH= cd "$script_dir/.." && pwd)
|
||||
|
||||
if [ -n "${CARD_LAYOUTS_ROOT:-}" ]; then
|
||||
layouts_root=$CARD_LAYOUTS_ROOT
|
||||
else
|
||||
layouts_root=$repo_root/../../../tools/card-layouts
|
||||
fi
|
||||
|
||||
renderer=$layouts_root/tools/render_card.py
|
||||
if [ ! -f "$renderer" ]; then
|
||||
echo "Nie znaleziono generatora card-layouts: $renderer" >&2
|
||||
echo "Ustaw CARD_LAYOUTS_ROOT na checkout edu-tools/card-layouts." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 "$renderer" "$repo_root" "$@"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
DOC_DIR="doc"
|
||||
TEX_FILE="${CARD_TEX_FILE:-generated/main.tex}"
|
||||
METADATA_TEX_FILE="${CARD_PDF_METADATA_TEX_FILE:-main.tex}"
|
||||
|
||||
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
|
||||
repo_root=$(CDPATH= cd "$script_dir/.." && pwd)
|
||||
repo_name="${REPO_NAME:-$(basename "$repo_root")}"
|
||||
tex_path="$repo_root/$DOC_DIR/$TEX_FILE"
|
||||
metadata_tex_path="$repo_root/$DOC_DIR/$METADATA_TEX_FILE"
|
||||
tex_dir=$(dirname "$tex_path")
|
||||
tex_name=$(basename "$tex_path")
|
||||
|
||||
read_tex_command() {
|
||||
sed -n "s/^\\\\newcommand{\\\\$1}{\\([^}]*\\)}$/\\1/p" "$metadata_tex_path" | head -n 1
|
||||
}
|
||||
|
||||
publisher_domain=$(read_tex_command PublisherDomain)
|
||||
card_area=$(read_tex_command CardArea)
|
||||
card_series=$(read_tex_command CardSeries)
|
||||
card_number=$(read_tex_command CardNumber)
|
||||
card_slug=$(read_tex_command CardSlug)
|
||||
card_version=$(read_tex_command CardVersion)
|
||||
document_uuid=$(read_tex_command DocumentUUID)
|
||||
|
||||
if [ -z "$publisher_domain" ] || [ -z "$card_area" ] || [ -z "$card_series" ] || \
|
||||
[ -z "$card_number" ] || [ -z "$card_slug" ] || [ -z "$card_version" ] || \
|
||||
[ -z "$document_uuid" ]; then
|
||||
echo "missing PDF filename metadata in $metadata_tex_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$tex_path" ]; then
|
||||
echo "missing generated card TeX: $tex_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
commit="${GITEA_SHA:-${GITHUB_SHA:-}}"
|
||||
if [ -z "$commit" ]; then
|
||||
commit=$(git -C "$repo_root" rev-parse HEAD)
|
||||
fi
|
||||
short_commit=$(printf '%s' "$commit" | cut -c1-12)
|
||||
|
||||
meta_file="$tex_dir/build-meta.tex"
|
||||
tmp_meta="$meta_file.tmp.$$"
|
||||
cleanup() {
|
||||
rm -f \
|
||||
"$meta_file" \
|
||||
"$tmp_meta" \
|
||||
"$tex_dir/${tex_name%.tex}.upa" \
|
||||
"$tex_dir/${tex_name%.tex}.upb"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
printf '\\newcommand{\\BuildCommit}{%s}\n' "$short_commit" > "$tmp_meta"
|
||||
mv "$tmp_meta" "$meta_file"
|
||||
|
||||
pdf_out_dir="${PDF_OUT_DIR:-$repo_root/doc/pdf}"
|
||||
mkdir -p "$pdf_out_dir"
|
||||
out_dir=$(CDPATH= cd "$pdf_out_dir" && pwd)
|
||||
pdf_basename="$publisher_domain-$card_area-$card_series-$card_number-$card_slug-$card_version-$document_uuid.pdf"
|
||||
find "$out_dir" -maxdepth 1 -type f -name '*.pdf' -delete
|
||||
|
||||
(
|
||||
cd "$tex_dir"
|
||||
latexmk -pdf -interaction=nonstopmode -halt-on-error -outdir="$out_dir" "$tex_name"
|
||||
)
|
||||
|
||||
source_pdf="$out_dir/${tex_name%.tex}.pdf"
|
||||
target_pdf="$out_dir/$pdf_basename"
|
||||
if [ "$source_pdf" != "$target_pdf" ]; then
|
||||
mv "$source_pdf" "$target_pdf"
|
||||
fi
|
||||
|
||||
find "$out_dir" -maxdepth 1 -type f \( \
|
||||
-name '*.aux' -o \
|
||||
-name '*.log' -o \
|
||||
-name '*.out' -o \
|
||||
-name '*.fls' -o \
|
||||
-name '*.fdb_latexmk' -o \
|
||||
-name '*.upa' -o \
|
||||
-name '*.upb' \
|
||||
\) -delete
|
||||
@@ -0,0 +1,3 @@
|
||||
module pin_polarity(input logic reset_n, input logic button_n, input logic led_on, output logic reset, output logic button, output logic led_n);
|
||||
assign reset = ~reset_n; assign button = ~button_n; assign led_n = ~led_on;
|
||||
endmodule
|
||||
@@ -0,0 +1,7 @@
|
||||
module bringup_blinker #(parameter integer DIVIDER=3)(input logic clk, input logic reset_n, output logic led, output logic [3:0] counter);
|
||||
always_ff @(posedge clk) begin
|
||||
if (!reset_n) begin counter <= 0; led <= 0; end
|
||||
else if (counter == DIVIDER) begin counter <= 0; led <= ~led; end
|
||||
else counter <= counter + 1'b1;
|
||||
end
|
||||
endmodule
|
||||
@@ -0,0 +1,13 @@
|
||||
module programming_gate(input logic clk, input logic reset, input logic cable_ok, input logic id_ok, input logic configure_ok, input logic observe_ok, output logic [2:0] state, output logic evidence_ready);
|
||||
always_ff @(posedge clk) begin
|
||||
if (reset) state <= 0;
|
||||
else case (state)
|
||||
0: if (cable_ok) state <= 1;
|
||||
1: if (id_ok) state <= 2;
|
||||
2: if (configure_ok) state <= 3;
|
||||
3: if (observe_ok) state <= 4;
|
||||
default: state <= state;
|
||||
endcase
|
||||
end
|
||||
assign evidence_ready = state == 4;
|
||||
endmodule
|
||||
@@ -0,0 +1,10 @@
|
||||
schema: 1
|
||||
targets:
|
||||
native: {profile: native-amd64, actions: [build, test, run]}
|
||||
hazard3-baremetal: {profile: hazard3-sim, actions: [build, test, run, debug]}
|
||||
actions:
|
||||
build: [bash, tools/card-action.sh, build]
|
||||
test: [bash, tools/card-action.sh, test]
|
||||
run: [bash, tools/card-action.sh, run]
|
||||
debug: [bash, tools/card-action.sh, debug]
|
||||
artifacts: {directory: .stem/artifacts}
|
||||
@@ -0,0 +1,5 @@
|
||||
import assert from 'node:assert/strict'; import { access, readFile } from 'node:fs/promises'; import test from 'node:test';
|
||||
const source=JSON.parse(await readFile(new URL('../json/card_source.json',import.meta.url),'utf8'));
|
||||
test('L02 identity and three tasks are stable',()=>{assert.equal(source.card.series,'rv32i-asm');assert.equal(source.card.number,'02');assert.equal(source.card.uuid,'31e9c2ad-f597-51ab-af6a-e550c3533f32');assert.deepEqual(source.tasks_order,['task01','task02','task03']);});
|
||||
test('simulation never claims physical FPGA success',()=>{const text=JSON.stringify(source);for(const token of ['hardware_observed=0','preflight-only','board-contract.yaml','bitstream','device ID','VCD'])assert.match(text,new RegExp(token,'i'));assert.match(text,/nie jest dowodem/i);});
|
||||
test('all task sources and board template exist',async()=>{for(const name of ['task01_pin_polarity','task02_bringup_blinker','task03_programming_gate'])await access(new URL(`../src/tasks/${name}.sv`,import.meta.url));await access(new URL('../board/board-contract.yaml',import.meta.url));});
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { CardStateDatabase } from '../scripts/lib/card_state_db.mjs';
|
||||
|
||||
test('card state survives closing and reopening SQLite', async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'stem-card-state-'));
|
||||
const file = path.join(directory, 'state.sqlite3');
|
||||
const state = {
|
||||
schema: 'stem-card-navigation.v2',
|
||||
revision: 7,
|
||||
selected_at: '2026-07-17T12:00:00.000Z',
|
||||
snapshot_ref: 'task04.alloc5.cursor'
|
||||
};
|
||||
|
||||
try {
|
||||
const first = new CardStateDatabase(file);
|
||||
first.write('navigation', state);
|
||||
first.close();
|
||||
|
||||
const second = new CardStateDatabase(file);
|
||||
assert.deepEqual(second.read('navigation'), state);
|
||||
assert.equal(second.read('missing'), null);
|
||||
second.close();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('audit log is append-only, ordered and keeps navigation timestamps', async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'stem-card-audit-'));
|
||||
const file = path.join(directory, 'state.sqlite3');
|
||||
try {
|
||||
const database = new CardStateDatabase(file);
|
||||
const navigation = {
|
||||
task: { id: 'task04' },
|
||||
block: { id: 'allocator-flow' },
|
||||
phase: { id: 'alloc5' },
|
||||
step: { id: 'alloc5-commit' },
|
||||
snapshot_ref: 'task04.alloc5.commit'
|
||||
};
|
||||
const first = database.appendEvent({
|
||||
eventType: 'navigation.activate',
|
||||
actor: 'nvim',
|
||||
navigation,
|
||||
occurredAt: '2026-07-17T12:00:00.000Z',
|
||||
payload: { source: 'F2' }
|
||||
});
|
||||
const second = database.appendEvent({
|
||||
eventType: 'checkpoint.ready',
|
||||
actor: 'nvim',
|
||||
navigation,
|
||||
occurredAt: '2026-07-17T12:00:01.000Z'
|
||||
});
|
||||
assert.equal(second.id, first.id + 1);
|
||||
assert.deepEqual(
|
||||
database.listEvents({ afterId: first.id, limit: 10 }).map(event => event.event_type),
|
||||
['checkpoint.ready']
|
||||
);
|
||||
assert.equal(database.listEvents()[0].snapshot_ref, navigation.snapshot_ref);
|
||||
database.close();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent preflight models for L02; they never claim physical FPGA execution."""
|
||||
import sys
|
||||
|
||||
|
||||
def task01():
|
||||
reset_n, button_n, led_on = 0, 0, 1
|
||||
assert (not reset_n, not button_n, not led_on) == (True, True, False)
|
||||
print("PASS task01 reset_active=1 button_active=1 led_n=0 pin_contract=generic")
|
||||
|
||||
|
||||
def task02():
|
||||
counter = 0; led = 0; edges = []
|
||||
for cycle in range(1, 17):
|
||||
if counter == 3: counter = 0; led ^= 1; edges.append(cycle)
|
||||
else: counter += 1
|
||||
assert edges == [4, 8, 12, 16]
|
||||
print("PASS task02 reset_known=1 led_edges=4,8,12,16 divider=4")
|
||||
|
||||
|
||||
def task03():
|
||||
states = ["idle", "cable", "identified", "configured", "observed"]
|
||||
assert states == ["idle", "cable", "identified", "configured", "observed"]
|
||||
print("PASS task03 order=cable,id,configure,observe workflow_model=complete hardware_observed=0 claim=preflight-only")
|
||||
|
||||
|
||||
TASKS = {"task01_pin_polarity": task01, "task02_bringup_blinker": task02, "task03_programming_gate": task03}
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in TASKS: raise SystemExit(f"usage: {sys.argv[0]} {'|'.join(TASKS)}")
|
||||
TASKS[sys.argv[1]]()
|
||||
@@ -0,0 +1,4 @@
|
||||
module tb;
|
||||
logic reset_n, button_n, led_on, reset, button, led_n; string trace_file; pin_polarity dut(.*);
|
||||
initial begin if ($value$plusargs("trace=%s",trace_file)) begin $dumpfile(trace_file);$dumpvars(0,tb);end reset_n=0;button_n=0;led_on=1;#1;if(!reset||!button||led_n)$fatal(1,"active-low mismatch");reset_n=1;button_n=1;led_on=0;#1;if(reset||button||!led_n)$fatal(1,"inactive mismatch");$display("PASS task01 reset_active=1 button_active=1 led_n=0 pin_contract=generic");$finish;end
|
||||
endmodule
|
||||
@@ -0,0 +1,4 @@
|
||||
module tb;
|
||||
logic clk=0,reset_n,led;logic[3:0]counter;integer cycle;integer edges;logic previous;string trace_file;bringup_blinker dut(.*);always #5 clk=~clk;
|
||||
initial begin if($value$plusargs("trace=%s",trace_file))begin $dumpfile(trace_file);$dumpvars(0,tb);end reset_n=0;edges=0;previous=0;@(posedge clk);#1;if(counter!==0||led!==0)$fatal(1,"reset mismatch");reset_n=1;for(cycle=1;cycle<=16;cycle=cycle+1)begin @(posedge clk);#1;if(led!=previous)begin edges=edges+1;if(cycle%4!=0)$fatal(1,"edge cycle mismatch");end previous=led;end if(edges!=4)$fatal(1,"edge count mismatch");$display("PASS task02 reset_known=1 led_edges=4,8,12,16 divider=4");$finish;end
|
||||
endmodule
|
||||
@@ -0,0 +1,5 @@
|
||||
module tb;
|
||||
logic clk=0,reset,cable_ok,id_ok,configure_ok,observe_ok,evidence_ready;logic[2:0]state;string trace_file;programming_gate dut(.*);always #5 clk=~clk;
|
||||
task step; input logic c,i,p,o; begin cable_ok=c;id_ok=i;configure_ok=p;observe_ok=o;@(posedge clk);#1;end endtask
|
||||
initial begin if($value$plusargs("trace=%s",trace_file))begin $dumpfile(trace_file);$dumpvars(0,tb);end reset=1;cable_ok=0;id_ok=0;configure_ok=0;observe_ok=0;@(posedge clk);#1;reset=0;step(0,0,1,1);if(state!=0)$fatal(1,"gate skipped cable");step(1,0,0,0);if(state!=1)$fatal(1,"cable");step(0,1,0,0);if(state!=2)$fatal(1,"id");step(0,0,1,0);if(state!=3)$fatal(1,"configure");step(0,0,0,1);if(state!=4||!evidence_ready)$fatal(1,"observe");$display("PASS task03 order=cable,id,configure,observe workflow_model=complete hardware_observed=0 claim=preflight-only");$finish;end
|
||||
endmodule
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"; mode=test
|
||||
if [[ "${1:-}" == --build-only || "${1:-}" == --trace ]]; then mode="${1#--}"; shift; fi
|
||||
task="${1:?task required}"; case "$task" in task01_pin_polarity|task02_bringup_blinker|task03_programming_gate) ;; *) exit 2 ;; esac
|
||||
state="${STEM_STATE_DIR:-$root/.stem/instances/local}"; profile="${STEM_PROFILE:-hazard3-sim}"; target="${STEM_TARGET:-hazard3-baremetal}"; build_dir="$state/build/rtl/$task"; artifact_dir="$root/.stem/artifacts/$profile/$target/$task"; binary="$build_dir/sim"; mkdir -p "$build_dir" "$artifact_dir"
|
||||
args=(--binary --timing --assert -Wall -Wno-fatal -Wno-DECLFILENAME -Wno-BLKSEQ -Wno-UNUSEDSIGNAL -Wno-WIDTH --top-module tb --Mdir "$build_dir/obj" -o "$binary" "$root/src/tasks/$task.sv" "$root/tests/${task}_tb.sv"); [[ "$mode" == trace ]] && args+=(--trace)
|
||||
verilator "${args[@]}" >/dev/null; cp -f "$root/src/tasks/$task.sv" "$artifact_dir/"
|
||||
[[ "$mode" == build-only ]] && { printf 'BUILD %s binary=%s\n' "$task" "$binary"; exit 0; }
|
||||
if [[ "$mode" == trace ]]; then trace="$artifact_dir/$task.vcd"; "$binary" "+trace=$trace"; test -s "$trace"; printf 'TRACE %s vcd=%s\n' "$task" "$trace"; else "$binary"; fi
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate that the repository carries an explicit, unclaimed board template."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
text = Path(sys.argv[1]).read_text()
|
||||
required = ["status: template", "board_id: SET_BOARD_ID", "clock_pin: SET_CLOCK_PIN", "led_pin: SET_LED_PIN", "hardware_observed: false"]
|
||||
missing = [item for item in required if item not in text]
|
||||
if missing: raise SystemExit("invalid board contract: " + ", ".join(missing))
|
||||
print("BOARD-CONTRACT status=template hardware_observed=0")
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
action="${1:?action required}"; root="${STEM_REPO:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}"; profile="${STEM_PROFILE:-native-amd64}"; target="${STEM_TARGET:-native}"; selector="${STEM_TASK:-task1}"
|
||||
tasks=(task01_pin_polarity task02_bringup_blinker task03_programming_gate)
|
||||
resolve_task(){ local value="${1,,}" name; for name in "${tasks[@]}"; do [[ "$value" == "$name" ]] && { printf '%s\n' "$name"; return; }; done; [[ "$value" =~ ^(task|t)?[-_]?0*([1-3])($|[-_].*) ]] && { printf '%s\n' "${tasks[${BASH_REMATCH[2]} - 1]}"; return; }; exit 2; }
|
||||
task="$(resolve_task "$selector")"
|
||||
case "$profile:$target:$action" in
|
||||
native-amd64:native:build) python3 -m py_compile "$root/tests/reference_model.py"; python3 "$root/tests/validate_board_contract.py" "$root/board/board-contract.yaml" ;;
|
||||
native-amd64:native:test|native-amd64:native:run) exec python3 "$root/tests/reference_model.py" "$task" ;;
|
||||
hazard3-sim:hazard3-baremetal:build) exec "$root/tests/test_rtl.sh" --build-only "$task" ;;
|
||||
hazard3-sim:hazard3-baremetal:test|hazard3-sim:hazard3-baremetal:run) exec "$root/tests/test_rtl.sh" "$task" ;;
|
||||
hazard3-sim:hazard3-baremetal:debug) exec "$root/tests/test_rtl.sh" --trace "$task" ;;
|
||||
*) printf 'Unsupported profile/target/action: %s/%s/%s\n' "$profile" "$target" "$action" >&2; exit 2 ;;
|
||||
esac
|
||||
File diff suppressed because one or more lines are too long
+61
File diff suppressed because one or more lines are too long
+2764
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="270" viewBox="0 0 640 270" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Drabinka FPGA bring-up</title><desc id="desc">Kolejne bramki to kontrakt płytki, symulacja, bitstream, identyfikacja urządzenia, konfiguracja i fizyczna obserwacja LED.</desc>
|
||||
<defs><marker id="a" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto"><path d="M0 0L0 6L9 3z" fill="#334155"/></marker><style>.b{fill:#f8fafc;stroke:#334155;stroke-width:2}.sim{fill:#dbeafe;stroke:#2563eb}.hw{fill:#fef3c7;stroke:#d97706}.t{font:700 16px sans-serif;fill:#0f172a}.s{font:13px sans-serif;fill:#334155}.l{stroke:#334155;stroke-width:3;marker-end:url(#a)}</style></defs>
|
||||
<rect width="640" height="270" fill="white"/>
|
||||
<g transform="translate(15 45)"><rect class="b" width="92" height="70" rx="10"/><text class="t" x="46" y="30" text-anchor="middle">CONTRACT</text><text class="s" x="46" y="52" text-anchor="middle">board + pins</text><path class="l" d="M92 35h22"/>
|
||||
<rect class="b sim" x="114" width="92" height="70" rx="10"/><text class="t" x="160" y="30" text-anchor="middle">SIM</text><text class="s" x="160" y="52" text-anchor="middle">RTL + VCD</text><path class="l" d="M206 35h22"/>
|
||||
<rect class="b hw" x="228" width="92" height="70" rx="10"/><text class="t" x="274" y="30" text-anchor="middle">BUILD</text><text class="s" x="274" y="52" text-anchor="middle">bitstream</text><path class="l" d="M320 35h22"/>
|
||||
<rect class="b hw" x="342" width="92" height="70" rx="10"/><text class="t" x="388" y="30" text-anchor="middle">IDENTIFY</text><text class="s" x="388" y="52" text-anchor="middle">device ID</text><path class="l" d="M434 35h22"/>
|
||||
<rect class="b hw" x="456" width="92" height="70" rx="10"/><text class="t" x="502" y="30" text-anchor="middle">CONFIG</text><text class="s" x="502" y="52" text-anchor="middle">programmer</text><path class="l" d="M548 35h22"/>
|
||||
<rect class="b hw" x="570" width="55" height="70" rx="10"/><text class="t" x="598" y="30" text-anchor="middle">LED</text><text class="s" x="598" y="52" text-anchor="middle">observe</text></g>
|
||||
<rect x="15" y="155" width="206" height="72" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/><text class="t" x="118" y="184" text-anchor="middle">AUTOMATED PASS</text><text class="s" x="118" y="207" text-anchor="middle">contract + sim + preflight model</text>
|
||||
<rect x="330" y="155" width="295" height="72" rx="10" fill="#fee2e2" stroke="#dc2626" stroke-width="2"/><text class="t" x="478" y="184" text-anchor="middle">PHYSICAL EVIDENCE REQUIRED</text><text class="s" x="478" y="207" text-anchor="middle">real board, digest, device ID, log, observation</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="card-renderer" content="react">
|
||||
<title>Karta pracy</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="resource-header-enabled">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
:root{--ink:#111;--muted:#666;--line:#b9b9b9;--line-soft:#dddddd;--paper:#fff;--desk:#eeeeee;--og:#008000;--tech:#d36b00;--kw:#0018c8;--we:#e00000;--viewer-canvas-scale:1;--viewer-content-scale:1}
|
||||
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--desk);color:var(--ink);font-family:"Latin Modern Roman","LM Roman 12",Georgia,"Times New Roman",serif;line-height:1.25}
|
||||
.topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:3px 0;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
|
||||
.topbar-inner{width:calc(100% - 8px);max-width:none;margin:0 4px;display:flex;align-items:center;justify-content:space-between;gap:6px}.topbar details{min-width:0;flex:1}.topbar summary{cursor:pointer;font-size:16px;line-height:1.35;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:5px}.toc-links a{font-size:16px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:3px;padding:2px 5px}.viewer-zoom-controls{display:flex;align-items:center;gap:7px;flex:none}.viewer-zoom-status{font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:#444;white-space:nowrap}.viewer-zoom-reset{border:1px solid #bbb;border-radius:3px;background:#fff;color:#333;padding:1px 6px;font:600 16px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;cursor:pointer}.viewer-zoom-reset:hover{background:#eee}.viewer-zoom-reset:focus-visible{outline:2px solid #555;outline-offset:1px}
|
||||
.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{position:relative;width:210mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:block;padding:0;overflow:visible;zoom:var(--viewer-canvas-scale)}.sheet-content{position:relative;width:100%;min-height:297mm;padding:13mm 21.5mm 15mm;transform:scale(var(--sheet-effective-scale,1));transform-origin:top left}.resource-header-enabled .sheet-content{padding-top:5mm}.sheet-content>.page-resource-header{margin-bottom:3mm}
|
||||
@media(max-width:900px){.sheet{height:auto}}@media print{.sheet{height:auto}}
|
||||
.pdf-main{min-width:0;width:100%}.running-header{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;border-bottom:1px solid #111;padding-bottom:3px;margin-bottom:26px;font-size:15px}.running-header span{white-space:nowrap}
|
||||
.hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px}
|
||||
.meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.front-scope{padding:0 0 3mm;border-bottom:1px solid var(--line-soft)}.front-scope h2{font-size:24px;line-height:1.15;margin:0 0 3mm}.front-scope p{font-size:16px}
|
||||
.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:7%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:11%}.scope-table--task th:nth-child(4),.scope-table--task td:nth-child(4){width:19%}.scope-table--task th:nth-child(5),.scope-table--task td:nth-child(5){width:14%}.scope-table--task th:nth-child(6),.scope-table--task td:nth-child(6){width:10%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit}.scope-status{display:inline-block;white-space:nowrap;border:1px solid #aaa;border-radius:2px;padding:1px 4px;font-weight:600;background:#f5f5f5}.scope-status[data-status="in-progress"]{border-color:#307da1;background:#eaf5fa;color:#174f69}.scope-status[data-status="ready"]{border-color:#4c7d56;background:#edf6ee;color:#295b33}.scope-status[data-status="draft"],.scope-status[data-status="review"]{border-color:#9a7932;background:#faf4e5;color:#694f16}.scope-status[data-status="blocked"]{border-color:#9c4b4b;background:#faeded;color:#712f2f}
|
||||
.card-section h2{font-size:24px;line-height:1.15;margin:0 0 4mm}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;margin-right:12mm;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777}
|
||||
p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm;padding:0}.pdf-main li{margin:1.2mm 0}
|
||||
.pdf-margin{position:absolute;top:34mm;width:18mm;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:5px;line-height:1.12;color:#333;max-height:245mm;overflow:hidden}.pdf-margin.left{left:2mm;text-align:left}.pdf-margin.right{right:2mm;text-align:left}
|
||||
.margin-title{font-weight:700;color:#666;margin-bottom:3px;letter-spacing:.02em}.margin-list{display:flex;flex-direction:column;align-items:flex-start}.margin-tag{display:block;margin:0 0 1px;padding:0;border:0;background:transparent;font:inherit;line-height:inherit;text-align:left;cursor:pointer;margin-left:var(--indent)}.margin-tag.we{color:var(--we)}.margin-tag.og,.margin-tag.tech{color:var(--og)}.margin-tag.kw{color:var(--kw)}.margin-tag .local{color:#777}.margin-tag:hover{text-decoration:underline}.margin-empty{color:#aaa}
|
||||
.step-map{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.3;color:#5c5c5c;background:transparent;border:0;border-top:1px solid #cfcfcf;border-bottom:1px solid #e0e0e0;border-radius:0;padding:8px 0;margin:0 0 16px}.step-map summary{cursor:pointer;font-weight:400;color:#555}
|
||||
.tree-legend{display:flex;flex-wrap:wrap;gap:4px;margin:7px 0}.steps{display:grid;gap:6px}.step-row{border-top:1px dashed #c9c9c9;padding-top:6px}.step-row:first-child{border-top:0}.step-title span{font-weight:700;margin-right:7px}.step-trees{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}.step-refs{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}.step-refs a{font-size:10px;color:#666}
|
||||
.tree-token{font:10px/1 "Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #c8c8c8;background:#fff;border-radius:3px;padding:3px 5px;cursor:pointer}.tree-token.active{background:#111;color:white;border-color:#111}
|
||||
.equation{display:grid;grid-template-columns:1fr auto;align-items:center;gap:14px;margin:18px 0;padding:4px 0;background:transparent;border:0}.equation .display{font-size:17px}.equation-number{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:15px}
|
||||
figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}.card-asset.asset-screenshot img{border:1px solid #aaa}.card-asset a{display:block;cursor:zoom-in}
|
||||
.table-wrap{overflow:auto;margin:14px 0}table{border-collapse:collapse;width:100%;font-size:16px}th,td{border:1px solid #111;padding:5px 8px;text-align:left;vertical-align:top}th{font-weight:400;background:white}
|
||||
.empty-check{display:inline-block;width:1em;height:1em;border:1px solid #111;vertical-align:-.12em}.answer-line,.dotfill{display:block;border-bottom:1px dotted #111;height:1.45em;min-width:10em}.write-row{display:block;min-height:2.8em}
|
||||
code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #222;margin:1mm 0 2mm;background:#fff}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:6px;padding:5px 7px;background:#eaeaea;border-bottom:1px solid #222}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#222}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#555}.task-block{margin:6px 7px;padding:5px 6px;border:1px solid #777;background:#fff}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:5px;margin-bottom:3px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#555}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{margin:4px 0 0 16px!important;padding:0;display:grid;gap:3px}.block-steps li{margin:0;border:1px solid #b9b9b9;padding:4px 5px;background:#fff}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace;font-weight:700}.block-steps li p:last-child{margin-bottom:0}.task-exercise{margin:6px 7px;border:1px solid #777;border-left:1px solid #222;padding:5px 6px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:5px;padding-top:4px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:4px 0 0}.task-flow>footer{padding:5px 7px;border-top:1px solid #222;font-size:12px;background:#f6f6f6}.task-conclusion{margin:8px 0;padding:7px 9px;border:1px solid #315f78;background:#f3f9fc}.task-conclusion>strong{display:block;margin-bottom:3px;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#315f78}.task-conclusion p{margin:0}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-margin{display:none}
|
||||
.inspector{position:fixed;right:14px;bottom:14px;width:min(360px,calc(100vw - 28px));max-height:46vh;overflow:auto;background:white;border:1px solid #cfcfcf;box-shadow:0 4px 18px rgba(0,0,0,.18);border-radius:6px;padding:12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;z-index:30}.inspector h2{font-size:14px;margin:0 0 8px}.inspector .empty{color:#777}.tree-card h3{font-size:15px;margin:6px 0}.tree-card code{display:inline-block;margin:4px 0}.tree-card .line{font-weight:700}.tree-card .line.OG{color:var(--og)}.tree-card .line.TECH{color:var(--tech)}.kw-list{padding-left:18px}.kw-list code{color:var(--kw)}
|
||||
@media(max-width:900px){.sheet{display:block;min-height:0}.sheet-content{min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:sticky;top:0}.viewer-zoom-status{font-size:16px}}
|
||||
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,.block-steps li,figure{break-inside:avoid}}
|
||||
|
||||
.page-resource-header{width:100%;height:28mm;min-height:28mm;max-height:28mm;background:#fff;color:#111;overflow:hidden;font-family:ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;line-height:1.08}
|
||||
.resource-header-table{width:100%;height:100%;border-collapse:collapse;table-layout:fixed;border:1px solid #111}.resource-header-table>tbody>tr{height:100%}.resource-header-center{height:100%;padding:0;vertical-align:middle;overflow:hidden}.resource-header-grid{display:grid;grid-template-rows:3fr 3fr 3fr 2fr 2fr;width:100%;height:100%}.resource-header-row{display:grid;min-width:0;min-height:0}.resource-header-field{display:flex;flex-direction:column;align-items:stretch;justify-content:space-between;gap:.08mm;min-width:0;padding:.28mm .55mm;border-right:1px solid #888;border-bottom:1px solid #aaa;overflow:hidden;white-space:nowrap}.resource-header-row .resource-header-field:last-child{border-right:0}.resource-header-row:last-child .resource-header-field{border-bottom:0}.resource-header-field a{display:flex;flex-direction:column;align-items:flex-start;gap:.08mm;min-width:0;color:#111;text-decoration:none}.resource-label{align-self:flex-start;font:800 3.2pt/1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;text-transform:uppercase;flex:none}.resource-value{align-self:flex-end;max-width:100%;text-align:right;font:700 6.4pt/1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.resource-header-field--title .resource-value{font-size:7.7pt;font-weight:800}.resource-header-field--emphasis .resource-value{font-size:6.6pt;font-weight:800}.resource-header-field--compact{gap:.05mm;padding:.2mm .2mm}.resource-header-field--compact .resource-label{font-size:2.85pt}.resource-header-field--compact .resource-value{font-size:4.45pt}.resource-header-field--identity{display:flex;flex-direction:column;align-items:stretch;justify-content:center;gap:.15mm;padding:.12mm .45mm;font:700 3.55pt/1.1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace}.resource-header-field--identity>span{display:flex;align-items:baseline;gap:.3mm;min-width:0}.resource-header-field--identity b{font-weight:800;flex:none}.resource-header-field--identity .resource-identity-value{margin-left:auto;text-align:right}.resource-header-field--identity .resource-author{margin-left:auto;text-align:right}.resource-header-field--url-row{display:flex;align-items:stretch;justify-content:center;padding:.2mm .55mm}.resource-header-field--url-row a{display:block;overflow:hidden;text-overflow:clip;font:700 4.9pt/1.05 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace}.resource-header-field--url-row b{font-weight:800}
|
||||
.resource-qr-cell{height:100%;padding:0;vertical-align:middle;overflow:hidden}.resource-qr-cell--left{border-right:1px solid #777}.resource-qr-cell--right{border-left:1px solid #777}.resource-qr{box-sizing:border-box;width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:.4mm;color:#333;text-decoration:none}.resource-qr .title-qr{display:block;width:22mm;height:22mm;max-width:22mm;max-height:22mm;flex:none}.resource-qr-empty{text-align:center}.resource-qr-empty span,.resource-qr-empty strong{display:block;font:600 5pt/1.15 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.page-resource-footer{position:absolute;right:21.5mm;bottom:3mm;left:21.5mm;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;min-height:5mm;border-top:.35mm solid #252525;padding-top:1.2mm;color:#68747a;font:650 7px/1.15 ui-monospace,SFMono-Regular,Consolas,monospace}.page-resource-footer__uuid{overflow:hidden;color:#4d5d65;text-align:left;text-overflow:ellipsis;white-space:nowrap;font:inherit}.page-resource-footer__author{color:#4d5d65;text-align:center;white-space:nowrap}.page-resource-footer__page{color:#283940;text-align:right;white-space:nowrap}
|
||||
|
||||
@page{
|
||||
size:A4;
|
||||
margin:33mm 21.5mm 10mm;
|
||||
@top-left{content:" TITLE VER. DATETIME \A \A L02 · FPGA Bring-up and Programming 1 21.07.2026 \A PROJECT SERIES CARD SHEET \A \A From Blinker to RISC-V ASM · RV32I From Blinker to RISC-V 02/09 " counter(page) "/" counter(pages) " \A SUBJ. PROG. CORE SCOPE LEVEL POS. GITEA UUID 31e9c2ad-f597-51ab-af6a-e550c3533f32\A CARD UUID 31e9c2ad-f597-51ab-af6a-e550c3533f32 AUTHOR M. Pabiszczak\A Inf. — — — — — \A \A GITEA https://gitea.local/edu-inf/lab-rv32i-asm-fpga-bringup \A \A HTML http://localhost:8080 ";width:143mm;height:28mm;box-sizing:border-box;padding:.2mm .7mm 0;border-block:1px solid #111;border-left:1px solid #111;background-color:#fff;background-image:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMTkgMjgiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiPjxyZWN0IHdpZHRoPSIxMTkiIGhlaWdodD0iMjgiIGZpbGw9IndoaXRlIi8+PHBhdGggZD0iTTAgNi40NjE1NEgxMTkgTTAgMTIuOTIzMUgxMTkgTTAgMTkuMzg0NkgxMTkgTTAgMjMuNjkyM0gxMTkgTTU5LjUgMFY2LjQ2MTU0IE03Ny41IDBWNi40NjE1NCBNNTkuNSA2LjQ2MTU0VjEyLjkyMzEgTTg5LjUgNi40NjE1NFYxMi45MjMxIE0xMDYuNSA2LjQ2MTU0VjEyLjkyMzEgTTcgMTIuOTIzMVYxOS4zODQ2IE0xMyAxMi45MjMxVjE5LjM4NDYgTTMwLjUgMTIuOTIzMVYxOS4zODQ2IE00MS41IDEyLjkyMzFWMTkuMzg0NiBNNTAuNSAxMi45MjMxVjE5LjM4NDYgTTU5LjUgMTIuOTIzMVYxOS4zODQ2IiBmaWxsPSJub25lIiBzdHJva2U9IiM4ODgiIHN0cm9rZS13aWR0aD0iMC4xNCIvPjwvc3ZnPg==");background-repeat:no-repeat;background-position:center;background-size:100% 100%;white-space:pre;vertical-align:top;text-align:left;font:700 5.2px/2.12308mm ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:-.02px;color:#111;overflow:hidden}
|
||||
@top-right{content:"";width:24mm;height:28mm;box-sizing:border-box;padding:0;border:1px solid #111;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:22mm 22mm;vertical-align:middle;text-align:center;font:0/0 sans-serif;color:transparent;background-image:none}
|
||||
@bottom-left{content:"UUID 31e9c2ad-f597-51ab-af6a-e550c3533f32";border-top:.35mm solid #252525;padding-top:1mm;color:#68747a;font:650 7px/1.15 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
@bottom-center{content:"M. Pabiszczak";border-top:.35mm solid #252525;padding-top:1mm;color:#4d5d65;font:650 7px/1.15 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
@bottom-right{content:"STRONA " counter(page) "/" counter(pages);border-top:.35mm solid #252525;padding-top:1mm;color:#283940;font:650 7px/1.15 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
}
|
||||
Reference in New Issue
Block a user