Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 117 additions & 21 deletions .claude/skills/termlens/SKILL.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions .github/scripts/check-skill-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# The vendored termlens skill must name the version this workspace depends on.
#
# `.claude/skills/termlens/SKILL.md` is a copy of the file termlens ships for
# coding agents. It is refreshed by hand, and the failure mode is silent: the
# dependency gets bumped, the copy does not, and every agent working in this
# repository is then handed guidance for a version that is no longer here —
# wrong signatures, absent APIs, advice that was true one release ago. That
# is not hypothetical here: the copy sat at 0.9.0 while `docs/` still said
# 0.6, three releases behind the dependency.
#
# Nothing can diff it against upstream: the published crate does not ship the
# skill, so there is no registry copy to compare with. What *is* checkable is
# that the two versions agree, which is exactly the drift that happens.
#
# Compares major.minor only. A termlens patch release does not rewrite the
# skill, and demanding a re-copy for every one of them would make this noise.
#
# Usage: check-skill-version.sh [SKILL.md] [Cargo.toml]
set -euo pipefail
cd "$(dirname "$0")/../.."

skill="${1:-.claude/skills/termlens/SKILL.md}"
manifest="${2:-Cargo.toml}"

[ -f "$skill" ] || { echo "::error::$skill does not exist"; exit 1; }
[ -f "$manifest" ] || { echo "::error::$manifest does not exist"; exit 1; }

# "Written against **termlens 0.10.1**." -> 0.10.1
skill_version="$(sed -n 's/.*Written against \*\*termlens \([0-9][0-9.]*\)\*\*.*/\1/p' "$skill" | head -1)"
[ -n "$skill_version" ] || {
echo "::error::$skill has no 'Written against **termlens X.Y.Z**' line to check"
exit 1
}
skill_minor="$(echo "$skill_version" | cut -d. -f1,2)"

# In [workspace.dependencies], either spelling:
# termlens = "0.10"
# termlens = { version = "0.10", features = [...] }
dep_version="$(sed -n 's/^termlens = .*version = "\([0-9][0-9.]*\)".*/\1/p;s/^termlens = "\([0-9][0-9.]*\)".*/\1/p' "$manifest" | head -1)"
[ -n "$dep_version" ] || {
echo "::error::no termlens dependency with a version found in $manifest"
exit 1
}
dep_minor="$(echo "$dep_version" | cut -d. -f1,2)"

if [ "$skill_minor" != "$dep_minor" ]; then
echo "::error::the vendored termlens skill is written against ${skill_version} but this workspace depends on ${dep_version}."
echo "::error::Refresh it: cp ../termlens/skills/termlens/SKILL.md ${skill}"
exit 1
fi

echo "the vendored termlens skill (${skill_version}) matches the dependency (${dep_version})"
2 changes: 1 addition & 1 deletion .github/scripts/ci-policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


RUST_JOBS = {"fmt", "clippy", "test", "metal", "msrv", "docs", "deny"}
ALWAYS_JOBS = {"changes", "release-scripts", "zizmor"}
ALWAYS_JOBS = {"changes", "release-scripts", "skill-version", "zizmor"}
ROOT_DOCS = {"CHANGELOG.md", "CONTRIBUTING.md", "AGENTS.md", "CODE_OF_CONDUCT.md", "SECURITY.md"}


Expand Down
44 changes: 44 additions & 0 deletions .github/scripts/test_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,50 @@ def test_empty_fields_wrong_docs_and_missing_readme_fail(self):
self.assertNotEqual(self.run_metadata(packaged=False).returncode, 0)


class SkillVersionTests(unittest.TestCase):
"""The vendored termlens skill must name the dependency's major.minor."""

def run_check(self, skill_version, dep_line):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
scripts = root / ".github/scripts"
scripts.mkdir(parents=True)
script = scripts / "check-skill-version.sh"
script.write_text((SCRIPTS / script.name).read_text())
skill = root / ".claude/skills/termlens/SKILL.md"
skill.parent.mkdir(parents=True)
header = f"Written against **termlens {skill_version}**.\n" if skill_version else ""
skill.write_text("# Testing terminal programs with termlens\n\n" + header)
(root / "Cargo.toml").write_text(f"[workspace.dependencies]\n{dep_line}\n")
return subprocess.run(["/bin/bash", str(script)], text=True, capture_output=True, cwd=directory)

def test_a_matching_skill_passes_and_a_patch_release_is_not_drift(self):
for skill, dep in (("0.10.1", 'termlens = { version = "0.10", features = ["serde"] }'),
("0.10.0", 'termlens = "0.10"'),
("0.10.7", 'termlens = { version = "0.10.1" }')):
with self.subTest(skill=skill, dep=dep):
run = self.run_check(skill, dep)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)

def test_drift_fails_in_both_directions_and_says_so(self):
for skill, dep in (("0.9.0", 'termlens = { version = "0.10", features = ["serde"] }'),
("0.11.0", 'termlens = "0.10"'),
("1.0.0", 'termlens = "0.10"')):
with self.subTest(skill=skill, dep=dep):
run = self.run_check(skill, dep)
self.assertNotEqual(run.returncode, 0)
self.assertIn("::error::", run.stdout)

def test_an_unreadable_claim_is_a_failure_not_a_pass(self):
self.assertNotEqual(self.run_check("", 'termlens = "0.10"').returncode, 0)
self.assertNotEqual(self.run_check("0.10.1", "insta = \"1\"").returncode, 0)

def test_the_committed_skill_matches_the_committed_manifest(self):
run = subprocess.run(["/bin/bash", str(SCRIPTS / "check-skill-version.sh")],
text=True, capture_output=True, cwd=ROOT)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)


class ToolchainTests(unittest.TestCase):
def test_msrv_uses_rustup_even_when_path_cargo_is_not_a_proxy(self):
with tempfile.TemporaryDirectory() as directory:
Expand Down
50 changes: 49 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,23 @@ jobs:
- name: Test the selected feature configuration
env:
TARGET: ${{ matrix.lane.target }}
# Every screen a failing termlens wait carries is also written
# here, so the `report` step below can put it in the job summary
# instead of leaving it in a log someone has to scroll. The TUI
# PTY suite is a flake target (that is what stress.yml is for) and
# a flake in CI used to leave nothing but scrollback (termlens
# 0.10).
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
run: make "$TARGET"
# `#[ignore]`d so a plain `cargo test` never installs anything behind a
# contributor's back — these crates are published. CI is where they
# should run, so CI asks for them by name. The install is the version
# Cargo.lock names, so the tool and the library are one release.
- name: termlens-cli against the committed screens
if: matrix.lane.name == 'default'
env:
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
run: make test-termlens-cli
- name: predict adds no second CUDA stack
if: matrix.lane.name == 'predict'
run: make check-predict-no-second-cuda
Expand All @@ -153,6 +169,14 @@ jobs:
- name: dependency chain is coherent
if: matrix.lane.name == 'default'
run: make coherence
# A PTY failure is a picture, and a log is the worst place to read one.
# This renders every screen the suite left behind — and every insta
# `.snap.new` — into the job summary, with SVG and HTML uploaded. It
# installs termlens-cli itself, so nothing else here has to.
- uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1
if: failure()
with:
name: termlens-report-linux-${{ matrix.lane.name }}
- name: Upload Cargo build timings
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down Expand Up @@ -186,9 +210,19 @@ jobs:
cache-workspace-crates: false
- run: make lint-metal
- name: Metal package tests and macOS PTY suite
env:
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
run: make test-metal
- name: conformance suite on the Metal device
env:
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
run: make test-metal-device
# macOS is the leg where the PTY suite times out first, and the one
# nobody can reproduce locally on a Linux box.
- uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1
if: failure()
with:
name: termlens-report-metal
- name: Upload Cargo build timings
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down Expand Up @@ -268,6 +302,20 @@ jobs:
toolchain: stable
- run: ./.github/scripts/check-crate-metadata.sh

# The vendored termlens skill is refreshed by hand, and a stale copy is
# silent: agents working here get guidance for a version that is no longer
# the dependency. Nothing can diff it against upstream — the published
# crate does not ship the skill — but the two versions agreeing is
# checkable, and that is the drift that happens (`make skill-version`).
skill-version:
name: skill version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- run: make skill-version

# Workflow security audit: unpinned actions, credential persistence,
# template injection. Accepted findings live in .github/zizmor.yml.
zizmor:
Expand All @@ -287,7 +335,7 @@ jobs:
required-green:
name: required-green
if: always()
needs: [changes, fmt, clippy, test, metal, msrv, docs, deny, release-scripts, zizmor]
needs: [changes, fmt, clippy, test, metal, msrv, docs, deny, release-scripts, skill-version, zizmor]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,16 @@ jobs:
- name: Build once and exercise every thread count
env:
ITERS: ${{ inputs.iterations || '100' }}
# The whole point of this workflow is the one iteration in a hundred
# that fails, and a screen in a 45-minute log is the hardest place
# to read one. Every screen a failing wait carries is written here
# for the report step below (termlens 0.10).
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
run: make stress-tui
- uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1
if: failure()
with:
name: termlens-report-stress-${{ matrix.os }}
- name: Upload Cargo build timings
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
/data/
/spill/

# Python bytecode from the scripts under .github/scripts
__pycache__/
*.pyc

# editors / OS
.DS_Store
.idea/
Expand Down
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ lockfile.
- **The TUI emits DEC 2026 synchronized updates**, which most ratatui apps do
not — so its PTY tests use termlens's `wait_frame`, not `snapshot_after`.
A snapshot taken any other way can catch a half-painted screen, and did.
- **`.claude/skills/termlens/SKILL.md` is a vendored copy, and `make gate`
checks its version against the dependency.** Bumping `termlens` in
`Cargo.toml` means copying the skill over in the same change
(`cp ../termlens/skills/termlens/SKILL.md .claude/skills/termlens/SKILL.md`);
the `skill-version` job fails otherwise. Guidance for a version that is no
longer here is worse than none.
- **The three text screen snapshots are text on purpose.** termlens 0.10
records styles by default; `tui_pty_test.rs` passes `styles = false` there
and keeps the colours in one styled snapshot plus direct cell assertions,
so a colour change does not rewrite three large files. `emulation.rs` pins
what the emulator could not render — read it before trusting a screen
assertion that suddenly went green.

## The rules that will fail CI

Expand Down
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,56 @@ versions (0.x) may contain breaking changes; they are always listed under a

## [Unreleased]

### Changed

- **The PTY test harness moved to termlens 0.10.1** (from 0.9). The three
committed screen snapshots are unchanged: `assert_screen_snapshot!` records
styles by default in 0.10, and the text snapshots opt out with
`styles = false` so a colour change lands in one new styled snapshot rather
than rewriting three large files. The vendored agent skill
(`.claude/skills/termlens/SKILL.md`) was refreshed to match and is now
checked against the dependency by `make skill-version`, a CI job — it had
drifted two releases behind without anything noticing.

### Added

- **The dashboard's colours are tested.** Nothing asserted them before:
`TestBackend::to_string()` is text-only and the PTY snapshots were plain,
so the focused panel's yellow border and the green/blue backend tags were
invisible to the whole suite. `[CUDA]` is now green and `[CPU]` blue at
*every* occurrence (`Screen::find_all`), `Tab` is asserted to move the
highlight without changing one character of text, and `↓` is asserted to
leave the telemetry gauges and the Describe table untouched
(`Screen::diff`).

- **`crates/oxidelake-tui/tests/emulation.rs`**: the invariant the rest of the
PTY suite rests on. `Screen::unsupported()` is pinned to exactly
`["^[[59m"]` — ratatui's underline-colour reset, which changes no cell — so
a sequence the emulator silently drops can no longer make every screen
assertion true against a wrong grid. Insert mode, bells, wrapped rows and
mouse modes are pinned beside it, and a dashboard screen is round-tripped
through the snapshot text format and through JSON.

- **`crates/oxidelake-runtime/tests/oxide_tui_pty.rs`**: `oxide tui` — the
dashboard as users install it — in a real PTY. It renders the same frame
`oxidelake-tui` snapshots, it gives the terminal back, and the `tracing`
subscriber writing to stderr (the dashboard's own stream in a terminal)
puts nothing on the grid, `RUST_LOG=info` included. `assert_cmd` captures
pipes and could see none of that.

- **`crates/oxidelake-tui/tests/termlens_cli.rs`**: the committed `.snap`
files read back with `termlens-cli` — `render --text/--svg/--html` keeping
the palette, and `diff`'s 0/1/2 exit codes on this repository's own
screens. `#[ignore]`d, because a published crate's `cargo test` must not
install a tool behind a contributor's back; CI runs it as
`make test-termlens-cli`.

- **CI renders a failing PTY screen instead of logging it.** The Linux and
macOS test lanes run with `TERMLENS_ARTIFACT_DIR` set, and on failure the
pinned `vyncint/termlens` report action writes every screen the suite left
behind — and every insta `.snap.new` — into the job summary, with SVG and
HTML uploaded.

### Fixed

- **The dashboard's Describe panel no longer prints a truncated percentile as
Expand Down
10 changes: 8 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,15 @@ safetensors = "0.8" # reading a model's header to recover its s

# test-only
insta = "1"
termlens = "0.9"
# `serde` is on because the PTY suite round-trips a screen through JSON — the
# exact bytes `TERMLENS_ARTIFACT_DIR` writes when a wait fails in CI, so the
# artefact a failure leaves behind is a format this suite has read back. It
# also makes those artefacts `.screen.json` rather than `.screen.txt`.
termlens = { version = "0.10", features = ["serde"] }
assert_cmd = "2"
tempfile = "3"
rand = "0.10"
serde_json = "1" # reading a serialized termlens Screen back

[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
Expand Down
Loading
Loading