chore: import upstream snapshot with attribution
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:36 +08:00
commit f99010fae1
1834 changed files with 662985 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
from __future__ import annotations
"""Build Python wheels for PyPI distribution from pre-built agentsview binaries.
Takes release archives (tar.gz/zip) and packages them into platform-specific
Python wheels that can be uploaded to PyPI.
"""
import argparse
import base64
import hashlib
import io
import os
import re
import stat
import sys
import tarfile
import zipfile
from pathlib import Path
# ---------------------------------------------------------------------------
# Platform constants
# ---------------------------------------------------------------------------
PLATFORM_MAP: dict[str, dict[str, str]] = {
"linux_amd64": {
"wheel_tag": "manylinux_2_28_x86_64",
"binary_name": "agentsview",
},
"linux_arm64": {
"wheel_tag": "manylinux_2_28_aarch64",
"binary_name": "agentsview",
},
"darwin_amd64": {
"wheel_tag": "macosx_11_0_x86_64",
"binary_name": "agentsview",
},
"darwin_arm64": {
"wheel_tag": "macosx_11_0_arm64",
"binary_name": "agentsview",
},
"windows_amd64": {
"wheel_tag": "win_amd64",
"binary_name": "agentsview.exe",
},
"windows_arm64": {
"wheel_tag": "win_arm64",
"binary_name": "agentsview.exe",
},
}
_ARCHIVE_RE = re.compile(
r"^agentsview_(?P<version>[^_]+)_(?P<platform>[^.]+)\.(?:tar\.gz|zip)$"
)
# ---------------------------------------------------------------------------
# Filename parsing
# ---------------------------------------------------------------------------
def parse_archive_filename(filename: str) -> tuple[str, str] | None:
"""Parse a release archive filename into (platform_key, version).
Recognizes filenames of the form:
agentsview_<version>_<platform>.(tar.gz|zip)
Returns None for unrecognized filenames or unknown platforms.
"""
m = _ARCHIVE_RE.match(filename)
if m is None:
return None
platform_key = m.group("platform")
if platform_key not in PLATFORM_MAP:
return None
return (platform_key, m.group("version"))
# ---------------------------------------------------------------------------
# Archive extraction
# ---------------------------------------------------------------------------
def extract_binary(archive_path: Path, binary_name: str) -> bytes:
"""Extract a named binary from a .tar.gz or .zip archive.
Searches for an entry whose basename matches binary_name (handles
nested paths inside the archive).
Raises FileNotFoundError if the binary is not found.
"""
suffix = archive_path.name
if suffix.endswith(".tar.gz"):
return _extract_from_targz(archive_path, binary_name)
if suffix.endswith(".zip"):
return _extract_from_zip(archive_path, binary_name)
raise ValueError(f"Unsupported archive format: {archive_path}")
def _extract_from_targz(archive_path: Path, binary_name: str) -> bytes:
with tarfile.open(archive_path, "r:gz") as tf:
for member in tf.getmembers():
if os.path.basename(member.name) == binary_name:
f = tf.extractfile(member)
if f is not None:
return f.read()
raise FileNotFoundError(
f"Binary '{binary_name}' not found in {archive_path}"
)
def _extract_from_zip(archive_path: Path, binary_name: str) -> bytes:
with zipfile.ZipFile(archive_path) as zf:
for name in zf.namelist():
if os.path.basename(name) == binary_name:
return zf.read(name)
raise FileNotFoundError(
f"Binary '{binary_name}' not found in {archive_path}"
)
# ---------------------------------------------------------------------------
# Wheel assembly
# ---------------------------------------------------------------------------
_INIT_PY_UNIX = """\
from __future__ import annotations
import os
import stat
import sys
from pathlib import Path
def main() -> None:
bin_path = str(Path(__file__).parent / "bin" / "agentsview")
mode = os.stat(bin_path).st_mode
if not (mode & stat.S_IXUSR):
os.chmod(bin_path, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.execvp(bin_path, [bin_path] + sys.argv[1:])
"""
_INIT_PY_WINDOWS = """\
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def main() -> None:
bin_path = Path(__file__).parent / "bin" / "agentsview.exe"
sys.exit(subprocess.call([str(bin_path)] + sys.argv[1:]))
"""
_MAIN_PY = """\
from agentsview import main
main()
"""
def _sha256_record_hash(data: bytes) -> str:
"""Return a url-safe base64 sha256 hash in RECORD format."""
digest = hashlib.sha256(data).digest()
return "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
def build_wheel(
binary_content: bytes,
output_dir: Path,
version: str,
platform_key: str,
readme: str | None = None,
) -> Path:
"""Build a Python wheel containing the agentsview binary.
Args:
binary_content: Raw bytes of the platform binary.
output_dir: Directory where the wheel file will be written.
version: Package version string (e.g. "0.15.0").
platform_key: One of the keys in PLATFORM_MAP.
readme: Optional README content to embed in METADATA.
Returns:
Path to the created .whl file.
"""
platform_info = PLATFORM_MAP[platform_key]
wheel_tag = platform_info["wheel_tag"]
binary_name = platform_info["binary_name"]
is_windows = platform_key.startswith("windows")
dist_info = f"agentsview-{version}.dist-info"
whl_name = f"agentsview-{version}-py3-none-{wheel_tag}.whl"
output_dir.mkdir(parents=True, exist_ok=True)
whl_path = output_dir / whl_name
init_py = _INIT_PY_WINDOWS if is_windows else _INIT_PY_UNIX
main_py = _MAIN_PY
metadata = _build_metadata(version, readme)
wheel_meta = _build_wheel_file(wheel_tag)
entry_points = "[console_scripts]\nagentsview = agentsview:main\n"
# Collect (arcname, data) pairs to write, then build RECORD
entries: list[tuple[str, bytes, int]] = []
def _add(arcname: str, data: bytes, unix_mode: int = 0o644) -> None:
entries.append((arcname, data, unix_mode))
_add("agentsview/__init__.py", init_py.encode())
_add("agentsview/__main__.py", main_py.encode())
_add(f"agentsview/bin/{binary_name}", binary_content, 0o755)
_add(f"{dist_info}/METADATA", metadata.encode())
_add(f"{dist_info}/WHEEL", wheel_meta.encode())
_add(f"{dist_info}/entry_points.txt", entry_points.encode())
# Build RECORD content (RECORD itself is listed as empty)
record_lines: list[str] = []
for arcname, data, _ in entries:
record_lines.append(f"{arcname},{_sha256_record_hash(data)},{len(data)}")
record_lines.append(f"{dist_info}/RECORD,,")
record_content = "\n".join(record_lines) + "\n"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for arcname, data, unix_mode in entries:
info = zipfile.ZipInfo(arcname)
info.compress_type = zipfile.ZIP_DEFLATED
# Include S_IFREG so pip recognizes the file type and
# applies permissions (including +x) during installation
info.external_attr = (stat.S_IFREG | unix_mode) << 16
zf.writestr(info, data)
# Write RECORD last
record_info = zipfile.ZipInfo(f"{dist_info}/RECORD")
record_info.compress_type = zipfile.ZIP_DEFLATED
record_info.external_attr = (stat.S_IFREG | 0o644) << 16
zf.writestr(record_info, record_content.encode())
whl_path.write_bytes(buf.getvalue())
return whl_path
def _build_metadata(version: str, readme: str | None) -> str:
lines = [
"Metadata-Version: 2.1",
"Name: agentsview",
f"Version: {version}",
"Summary: Local web viewer for AI agent sessions",
"Home-page: https://github.com/kenn-io/agentsview",
"Author: Kenn Software LLC",
"License: MIT",
"Requires-Python: >=3.9",
"Classifier: License :: OSI Approved :: MIT License",
"Classifier: Programming Language :: Python :: 3",
]
if readme is not None:
lines.append("Description-Content-Type: text/markdown")
lines.append("")
lines.append(readme)
return "\n".join(lines) + "\n"
def _build_wheel_file(wheel_tag: str) -> str:
return (
"Wheel-Version: 1.0\n"
"Generator: agentsview-build-wheels\n"
"Root-Is-Purelib: false\n"
f"Tag: py3-none-{wheel_tag}\n"
)
# ---------------------------------------------------------------------------
# Top-level build orchestration
# ---------------------------------------------------------------------------
EXPECTED_PLATFORMS = frozenset(PLATFORM_MAP.keys())
def build_all_wheels(
input_dir: Path,
output_dir: Path,
version: str,
readme: str | None = None,
require_all: bool = False,
) -> list[Path]:
"""Scan input_dir for release archives and build a wheel for each.
Args:
input_dir: Directory containing release archives.
output_dir: Directory where wheel files will be written.
version: Version string to embed in wheels (overrides archive version).
readme: Optional README content to embed in METADATA.
require_all: If True, raise if any expected platform is missing.
Returns:
List of paths to the created wheel files.
"""
output_dir.mkdir(parents=True, exist_ok=True)
wheels: list[Path] = []
found_platforms: set[str] = set()
for archive_path in sorted(input_dir.iterdir()):
parsed = parse_archive_filename(archive_path.name)
if parsed is None:
continue
platform_key, archive_version = parsed
if archive_version != version:
raise RuntimeError(
f"{archive_path.name}: archive version {archive_version}"
f" does not match --version {version}"
)
found_platforms.add(platform_key)
binary_name = PLATFORM_MAP[platform_key]["binary_name"]
binary_content = extract_binary(archive_path, binary_name)
whl = build_wheel(binary_content, output_dir, version, platform_key, readme)
wheels.append(whl)
if require_all:
missing = EXPECTED_PLATFORMS - found_platforms
if missing:
raise RuntimeError(
f"Missing archives for platforms: {', '.join(sorted(missing))}"
)
return wheels
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build Python wheels from pre-built agentsview binaries."
)
parser.add_argument("--version", required=True, help="Package version (e.g. 0.15.0)")
parser.add_argument(
"--input-dir",
required=True,
type=Path,
help="Directory containing release archives",
)
parser.add_argument(
"--output-dir",
default=Path("dist"),
type=Path,
help="Directory to write wheels to (default: ./dist)",
)
parser.add_argument(
"--readme",
type=Path,
default=None,
help="Path to README.md to embed in wheel METADATA",
)
parser.add_argument(
"--require-all",
action="store_true",
help="Fail if any expected platform archive is missing",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = _parse_args(argv)
readme: str | None = None
if args.readme is not None:
readme = args.readme.read_text(encoding="utf-8")
wheels = build_all_wheels(
args.input_dir,
args.output_dir,
args.version,
readme,
require_all=args.require_all,
)
if not wheels:
print("Error: no wheels were built", file=sys.stderr)
sys.exit(1)
for whl in wheels:
print(whl)
print(f"Built {len(wheels)} wheel(s).")
if __name__ == "__main__":
main()
+415
View File
@@ -0,0 +1,415 @@
from __future__ import annotations
import io
import os
import stat
import tarfile
import zipfile
from pathlib import Path
import pytest
from build_wheels import (
PLATFORM_MAP,
build_all_wheels,
build_wheel,
extract_binary,
parse_archive_filename,
)
# ---------------------------------------------------------------------------
# Platform mapping
# ---------------------------------------------------------------------------
class TestPlatformMap:
def test_all_required_platforms_present(self) -> None:
required = {
"linux_amd64",
"linux_arm64",
"darwin_amd64",
"darwin_arm64",
"windows_amd64",
"windows_arm64",
}
assert set(PLATFORM_MAP.keys()) == required
def test_each_entry_has_wheel_tag(self) -> None:
for key, entry in PLATFORM_MAP.items():
assert "wheel_tag" in entry, f"{key} missing wheel_tag"
assert isinstance(entry["wheel_tag"], str)
assert entry["wheel_tag"]
def test_each_entry_has_binary_name(self) -> None:
for key, entry in PLATFORM_MAP.items():
assert "binary_name" in entry, f"{key} missing binary_name"
assert isinstance(entry["binary_name"], str)
assert entry["binary_name"]
def test_windows_binary_has_exe_extension(self) -> None:
assert PLATFORM_MAP["windows_amd64"]["binary_name"] == "agentsview.exe"
assert PLATFORM_MAP["windows_arm64"]["binary_name"] == "agentsview.exe"
def test_unix_binaries_have_no_extension(self) -> None:
for key in ("linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"):
assert PLATFORM_MAP[key]["binary_name"] == "agentsview"
def test_manylinux_wheel_tags(self) -> None:
assert PLATFORM_MAP["linux_amd64"]["wheel_tag"] == "manylinux_2_28_x86_64"
assert PLATFORM_MAP["linux_arm64"]["wheel_tag"] == "manylinux_2_28_aarch64"
def test_macos_wheel_tags(self) -> None:
assert PLATFORM_MAP["darwin_amd64"]["wheel_tag"] == "macosx_11_0_x86_64"
assert PLATFORM_MAP["darwin_arm64"]["wheel_tag"] == "macosx_11_0_arm64"
def test_windows_wheel_tag(self) -> None:
assert PLATFORM_MAP["windows_amd64"]["wheel_tag"] == "win_amd64"
assert PLATFORM_MAP["windows_arm64"]["wheel_tag"] == "win_arm64"
# ---------------------------------------------------------------------------
# Archive filename parsing
# ---------------------------------------------------------------------------
class TestParseArchiveFilename:
def test_parse_linux_amd64_tar_gz(self) -> None:
result = parse_archive_filename("agentsview_0.15.0_linux_amd64.tar.gz")
assert result == ("linux_amd64", "0.15.0")
def test_parse_darwin_arm64_tar_gz(self) -> None:
result = parse_archive_filename("agentsview_1.2.3_darwin_arm64.tar.gz")
assert result == ("darwin_arm64", "1.2.3")
def test_parse_windows_amd64_zip(self) -> None:
result = parse_archive_filename("agentsview_0.15.0_windows_amd64.zip")
assert result == ("windows_amd64", "0.15.0")
def test_parse_windows_arm64_zip(self) -> None:
result = parse_archive_filename("agentsview_0.15.0_windows_arm64.zip")
assert result == ("windows_arm64", "0.15.0")
def test_parse_darwin_amd64_tar_gz(self) -> None:
result = parse_archive_filename("agentsview_2.0.0_darwin_amd64.tar.gz")
assert result == ("darwin_amd64", "2.0.0")
def test_unrecognized_filename_returns_none(self) -> None:
assert parse_archive_filename("somethingelse_0.1.0_linux_amd64.tar.gz") is None
def test_unknown_platform_returns_none(self) -> None:
assert parse_archive_filename("agentsview_0.1.0_freebsd_amd64.tar.gz") is None
def test_no_extension_returns_none(self) -> None:
assert parse_archive_filename("agentsview_0.1.0_linux_amd64") is None
def test_sha256sums_returns_none(self) -> None:
assert parse_archive_filename("agentsview_0.15.0_SHA256SUMS") is None
def test_path_with_directory_uses_basename(self) -> None:
result = parse_archive_filename(
"releases/agentsview_0.15.0_linux_arm64.tar.gz"
)
# parse_archive_filename only accepts basenames, so paths return None
assert result is None
# The caller is responsible for passing just the filename
result = parse_archive_filename("agentsview_0.15.0_linux_arm64.tar.gz")
assert result == ("linux_arm64", "0.15.0")
# ---------------------------------------------------------------------------
# Archive extraction
# ---------------------------------------------------------------------------
def _make_targz(binary_name: str, content: bytes) -> bytes:
"""Create an in-memory .tar.gz with a single binary file."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
info = tarfile.TarInfo(name=binary_name)
info.size = len(content)
tf.addfile(info, io.BytesIO(content))
return buf.getvalue()
def _make_zip(binary_name: str, content: bytes) -> bytes:
"""Create an in-memory .zip with a single binary file."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(binary_name, content)
return buf.getvalue()
class TestExtractBinary:
def test_extract_from_tar_gz(self, tmp_path: Path) -> None:
content = b"fake-binary-content"
archive = tmp_path / "agentsview_0.15.0_linux_amd64.tar.gz"
archive.write_bytes(_make_targz("agentsview", content))
result = extract_binary(archive, "agentsview")
assert result == content
def test_extract_from_zip(self, tmp_path: Path) -> None:
content = b"fake-binary-exe"
archive = tmp_path / "agentsview_0.15.0_windows_amd64.zip"
archive.write_bytes(_make_zip("agentsview.exe", content))
result = extract_binary(archive, "agentsview.exe")
assert result == content
def test_missing_binary_raises_file_not_found(self, tmp_path: Path) -> None:
archive = tmp_path / "agentsview_0.15.0_linux_amd64.tar.gz"
archive.write_bytes(_make_targz("wrong_name", b"data"))
with pytest.raises(FileNotFoundError, match="agentsview"):
extract_binary(archive, "agentsview")
def test_missing_binary_in_zip_raises_file_not_found(self, tmp_path: Path) -> None:
archive = tmp_path / "agentsview_0.15.0_windows_amd64.zip"
archive.write_bytes(_make_zip("wrong.exe", b"data"))
with pytest.raises(FileNotFoundError, match="agentsview.exe"):
extract_binary(archive, "agentsview.exe")
def test_nested_path_in_tar_gz(self, tmp_path: Path) -> None:
"""Binary may be inside a subdirectory in the archive."""
content = b"nested-binary"
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
info = tarfile.TarInfo(name="agentsview_0.15.0_linux_amd64/agentsview")
info.size = len(content)
tf.addfile(info, io.BytesIO(content))
archive = tmp_path / "agentsview_0.15.0_linux_amd64.tar.gz"
archive.write_bytes(buf.getvalue())
result = extract_binary(archive, "agentsview")
assert result == content
# ---------------------------------------------------------------------------
# Wheel assembly
# ---------------------------------------------------------------------------
class TestBuildWheel:
def test_wheel_filename_matches_convention(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
assert whl.name == "agentsview-0.15.0-py3-none-manylinux_2_28_x86_64.whl"
def test_wheel_is_valid_zip(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
assert zipfile.is_zipfile(whl)
def test_wheel_contains_expected_files(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
names = set(zf.namelist())
assert "agentsview/__init__.py" in names
assert "agentsview/__main__.py" in names
assert "agentsview/bin/agentsview" in names
assert "agentsview-0.15.0.dist-info/METADATA" in names
assert "agentsview-0.15.0.dist-info/WHEEL" in names
assert "agentsview-0.15.0.dist-info/entry_points.txt" in names
assert "agentsview-0.15.0.dist-info/RECORD" in names
def test_binary_has_executable_permissions(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
info = zf.getinfo("agentsview/bin/agentsview")
unix_mode = (info.external_attr >> 16) & 0xFFFF
assert oct(unix_mode & 0o777) == oct(0o755)
def test_wheel_files_are_compressed(self, tmp_path: Path) -> None:
whl = build_wheel(b"x" * 10000, tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
for info in zf.infolist():
assert info.compress_type == zipfile.ZIP_DEFLATED, (
f"{info.filename} uses {info.compress_type}, expected DEFLATED"
)
def test_binary_has_regular_file_type_bit(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
info = zf.getinfo("agentsview/bin/agentsview")
# S_IFREG (0o100000) must be set so pip applies permissions
unix_mode = (info.external_attr >> 16) & 0xFFFF
assert unix_mode & stat.S_IFREG, "S_IFREG must be set"
def test_windows_wheel_uses_exe_binary(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "windows_amd64")
with zipfile.ZipFile(whl) as zf:
names = set(zf.namelist())
assert "agentsview/bin/agentsview.exe" in names
assert "agentsview/bin/agentsview" not in names
def test_metadata_required_fields(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
metadata = zf.read("agentsview-0.15.0.dist-info/METADATA").decode()
assert "Metadata-Version: 2.1" in metadata
assert "Name: agentsview" in metadata
assert "Version: 0.15.0" in metadata
assert "Requires-Python: >=3.9" in metadata
assert "License: MIT" in metadata
assert "Author: Kenn Software LLC" in metadata
def test_wheel_file_has_root_is_purelib_false(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
wheel_meta = zf.read("agentsview-0.15.0.dist-info/WHEEL").decode()
assert "Root-Is-Purelib: false" in wheel_meta
assert "Generator: agentsview-build-wheels" in wheel_meta
def test_entry_points_correct(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
ep = zf.read("agentsview-0.15.0.dist-info/entry_points.txt").decode()
assert "[console_scripts]" in ep
assert "agentsview = agentsview:main" in ep
def test_record_contains_hashes(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
record = zf.read("agentsview-0.15.0.dist-info/RECORD").decode()
# Each non-RECORD entry should have a sha256 hash
lines = [ln for ln in record.splitlines() if ln.strip()]
record_line = None
for line in lines:
if "RECORD" in line:
record_line = line
else:
assert "sha256=" in line, f"Missing hash in RECORD line: {line}"
# RECORD itself listed as empty
assert record_line is not None
assert record_line.endswith(",,")
def test_readme_included_in_metadata(self, tmp_path: Path) -> None:
readme = "# agentsview\nA great tool."
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64", readme=readme)
with zipfile.ZipFile(whl) as zf:
metadata = zf.read("agentsview-0.15.0.dist-info/METADATA").decode()
assert "A great tool." in metadata
def test_init_py_uses_execvp_on_unix(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "linux_amd64")
with zipfile.ZipFile(whl) as zf:
init = zf.read("agentsview/__init__.py").decode()
assert "os.execvp" in init
def test_init_py_uses_subprocess_on_windows(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "0.15.0", "windows_amd64")
with zipfile.ZipFile(whl) as zf:
init = zf.read("agentsview/__init__.py").decode()
assert "subprocess.call" in init
def test_wheel_filename_darwin_arm64(self, tmp_path: Path) -> None:
whl = build_wheel(b"fake", tmp_path, "1.0.0", "darwin_arm64")
assert whl.name == "agentsview-1.0.0-py3-none-macosx_11_0_arm64.whl"
# ---------------------------------------------------------------------------
# End-to-end: build_all_wheels
# ---------------------------------------------------------------------------
class TestBuildAllWheels:
def _make_fake_archives(self, input_dir: Path, version: str) -> None:
"""Create fake release archives for every supported platform."""
platforms = [
("linux_amd64", "agentsview", ".tar.gz"),
("linux_arm64", "agentsview", ".tar.gz"),
("darwin_amd64", "agentsview", ".tar.gz"),
("darwin_arm64", "agentsview", ".tar.gz"),
("windows_amd64", "agentsview.exe", ".zip"),
("windows_arm64", "agentsview.exe", ".zip"),
]
for platform_key, binary_name, ext in platforms:
content = f"binary-for-{platform_key}".encode()
filename = f"agentsview_{version}_{platform_key}{ext}"
archive_path = input_dir / filename
if ext == ".tar.gz":
archive_path.write_bytes(_make_targz(binary_name, content))
else:
archive_path.write_bytes(_make_zip(binary_name, content))
# Also add a SHA256SUMS file that should be skipped
(input_dir / f"agentsview_{version}_SHA256SUMS").write_text("checksums here")
def test_produces_a_wheel_per_platform(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
self._make_fake_archives(input_dir, "0.15.0")
wheels = build_all_wheels(input_dir, output_dir, "0.15.0")
assert len(wheels) == len(PLATFORM_MAP)
def test_correct_wheel_names(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
self._make_fake_archives(input_dir, "0.15.0")
wheels = build_all_wheels(input_dir, output_dir, "0.15.0")
names = {w.name for w in wheels}
expected = {
"agentsview-0.15.0-py3-none-manylinux_2_28_x86_64.whl",
"agentsview-0.15.0-py3-none-manylinux_2_28_aarch64.whl",
"agentsview-0.15.0-py3-none-macosx_11_0_x86_64.whl",
"agentsview-0.15.0-py3-none-macosx_11_0_arm64.whl",
"agentsview-0.15.0-py3-none-win_amd64.whl",
"agentsview-0.15.0-py3-none-win_arm64.whl",
}
assert names == expected
def test_unknown_platforms_skipped(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
self._make_fake_archives(input_dir, "0.15.0")
# Add an unknown platform archive
unknown = input_dir / "agentsview_0.15.0_freebsd_amd64.tar.gz"
unknown.write_bytes(_make_targz("agentsview", b"fake"))
wheels = build_all_wheels(input_dir, output_dir, "0.15.0")
assert len(wheels) == len(PLATFORM_MAP) # unknown skipped
def test_output_dir_created_if_missing(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output" / "nested"
input_dir.mkdir()
self._make_fake_archives(input_dir, "0.15.0")
build_all_wheels(input_dir, output_dir, "0.15.0")
assert output_dir.exists()
def test_version_mismatch_raises(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
self._make_fake_archives(input_dir, "0.15.0")
with pytest.raises(RuntimeError, match="does not match"):
build_all_wheels(input_dir, output_dir, "0.16.0")
def test_all_wheels_are_valid_zips(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
self._make_fake_archives(input_dir, "0.15.0")
wheels = build_all_wheels(input_dir, output_dir, "0.15.0")
for whl in wheels:
assert zipfile.is_zipfile(whl), f"{whl.name} is not a valid zip"
def test_require_all_fails_on_missing_platform(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
# Only create linux_amd64
(input_dir / "agentsview_1.0.0_linux_amd64.tar.gz").write_bytes(
_make_targz("agentsview", b"fake")
)
with pytest.raises(RuntimeError, match="Missing archives"):
build_all_wheels(
input_dir, output_dir, "1.0.0", require_all=True
)
def test_require_all_passes_with_all_platforms(self, tmp_path: Path) -> None:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
input_dir.mkdir()
self._make_fake_archives(input_dir, "1.0.0")
wheels = build_all_wheels(
input_dir, output_dir, "1.0.0", require_all=True
)
assert len(wheels) == len(PLATFORM_MAP)
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# Generate a changelog since the last release using an AI agent
# Usage: ./scripts/changelog.sh [version] [start_tag] [extra_instructions]
# Set CHANGELOG_AGENT=claude to use claude instead of codex (default)
# If version is not provided, uses "NEXT" as placeholder
# If start_tag is "-" or empty, auto-detects the previous tag
set -euo pipefail
VERSION="${1:-NEXT}"
START_TAG="${2:-}"
EXTRA_INSTRUCTIONS="${3:-}"
AGENT="${CHANGELOG_AGENT:-codex}"
# Determine the starting point
if [ -n "$START_TAG" ] && [ "$START_TAG" != "-" ]; then
RANGE="$START_TAG..HEAD"
echo "Generating changelog from $START_TAG to HEAD..." >&2
else
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
RANGE=""
echo "No previous release found. Generating changelog for all commits..." >&2
else
RANGE="$PREV_TAG..HEAD"
echo "Generating changelog from $PREV_TAG to HEAD..." >&2
fi
fi
if [ -n "$RANGE" ]; then
COMMITS=$(git log "$RANGE" --pretty=format:"- %s (%h)" --no-merges)
DIFF_STAT=$(git diff --stat "$RANGE")
else
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges)
EMPTY_TREE=$(git hash-object -t tree /dev/null)
DIFF_STAT=$(git diff --stat "$EMPTY_TREE" HEAD)
fi
if [ -z "$COMMITS" ]; then
echo "No commits since last release" >&2
exit 0
fi
echo "Using $AGENT to generate changelog..." >&2
TMPFILE=$(mktemp)
PROMPTFILE=$(mktemp)
ERRFILE=$(mktemp)
trap 'rm -f "$TMPFILE" "$PROMPTFILE" "$ERRFILE"' EXIT
cat > "$PROMPTFILE" <<EOF
You are generating a changelog for agentsview version $VERSION.
IMPORTANT: Do NOT use any tools. Do NOT run any shell commands. Do NOT search or read any files.
All the information you need is provided below. Simply analyze the commit messages and output the changelog.
Here are the commits since the last release:
$COMMITS
Here is the diff summary:
$DIFF_STAT
Please generate a concise, user-focused changelog. Group changes into sections like:
- New Features
- Improvements
- Bug Fixes
Focus on user-visible changes. Skip internal refactoring unless it affects users.
Do NOT mention documentation-only changes, including docs, changelog, release
note, README, website copy, screenshot, or generated docs asset updates, unless
the documentation is the user-facing product being released.
Keep descriptions brief (one line each). Use present tense.
Do NOT mention bugs that were introduced and fixed within this same release cycle.
${EXTRA_INSTRUCTIONS:+
When writing the changelog, look for these features or improvements in the commit log above: $EXTRA_INSTRUCTIONS
Do NOT search files, read code, or do any analysis outside of the commit log provided above.}
Output ONLY the changelog content, no preamble.
EOF
AGENT_EXIT=0
case "$AGENT" in
codex)
CODEX_RUST_LOG="${CHANGELOG_CODEX_RUST_LOG:-${RUST_LOG:-error,codex_core::rollout::list=off}}"
set +e
RUST_LOG="$CODEX_RUST_LOG" codex exec --json --skip-git-repo-check --sandbox read-only -c reasoning_effort=high -o "$TMPFILE" - >/dev/null < "$PROMPTFILE" 2>"$ERRFILE"
AGENT_EXIT=$?
set -e
;;
claude)
set +e
claude --print < "$PROMPTFILE" > "$TMPFILE" 2>"$ERRFILE"
AGENT_EXIT=$?
set -e
;;
*)
echo "Error: unknown CHANGELOG_AGENT '$AGENT' (expected 'codex' or 'claude')" >&2
exit 1
;;
esac
if [ "$AGENT_EXIT" -ne 0 ] || [ ! -s "$TMPFILE" ]; then
echo "Error: $AGENT failed to generate changelog." >&2
if [ "${CHANGELOG_DEBUG:-0}" = "1" ]; then
cat "$ERRFILE" >&2
else
FILTERED_ERR=$(grep -E -v 'rollout path for thread|failed to record rollout items: failed to queue rollout items: channel closed|^mcp startup: no servers$|^WARNING: proceeding, even though we could not update PATH:' "$ERRFILE" || true)
if [ -n "$FILTERED_ERR" ]; then
echo "$FILTERED_ERR" >&2
else
echo "Set CHANGELOG_DEBUG=1 to print full agent logs." >&2
fi
fi
exit 1
fi
if [ "${CHANGELOG_DEBUG:-0}" = "1" ] && [ -s "$ERRFILE" ]; then
cat "$ERRFILE" >&2
fi
cat "$TMPFILE"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
failed=0
fail() {
printf '%s\n' "$1" >&2
failed=1
}
if [[ -e "zensical.toml" ]]; then
fail 'Zensical config must live under docs/: zensical.toml'
fi
if [[ -e "vercel.json" ]]; then
fail 'Vercel config must live under docs/: vercel.json'
fi
tracked_media="$(
git ls-files docs 2>/dev/null | grep -E '\.(png|svg|jpg|jpeg|webp|gif)$' || true
)"
if [[ -n "$tracked_media" ]]; then
printf 'docs image media must live in docs asset branches, not main:\n%s\n' "$tracked_media" >&2
failed=1
fi
tracked_hydrated_assets="$(
git ls-files docs/assets/static docs/assets/generated 2>/dev/null || true
)"
if [[ -n "$tracked_hydrated_assets" ]]; then
printf 'hydrated docs assets must be ignored, not tracked:\n%s\n' "$tracked_hydrated_assets" >&2
failed=1
fi
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
python_bin="${PYTHON:-}"
if [[ -z "$python_bin" ]]; then
if command -v python3 >/dev/null 2>&1; then
python_bin="python3"
elif command -v python >/dev/null 2>&1; then
python_bin="python"
else
printf 'python not found; cannot validate docs markdown sources\n' >&2
exit 127
fi
fi
"$python_bin" docs/scripts/check_markdown_sources.py
if ! command -v rg >/dev/null 2>&1; then
printf 'rg not found; cannot validate docs media references\n' >&2
exit 127
fi
root_media_refs="$(
(rg -n '(<img[^>]+src="/|!\[[^]]*\]\(/)[^)" >]+\.(png|svg|jpg|jpeg|webp|gif)' docs README.md || true) \
| grep -v '/assets/static/' \
| grep -v '/assets/generated/' \
|| true
)"
if [[ -n "$root_media_refs" ]]; then
printf 'docs media references must use /assets/static or /assets/generated:\n%s\n' "$root_media_refs" >&2
exit 1
fi
source_media_refs="$(
(rg -n '(/screenshots/[^)" '"'"'`>]+\.(png|svg|jpg|jpeg|webp|gif)|/agents/[^)" '"'"'`>]+\.(png|svg|jpg|jpeg|webp|gif)|/architecture\.svg|https://agentsview\.io/og-image\.png|/og-image\.png)' docs README.md || true) \
| grep -v '/assets/static/' \
| grep -v '/assets/generated/' \
|| true
)"
if [[ -n "$source_media_refs" ]]; then
printf 'docs source media references must use /assets/static or /assets/generated:\n%s\n' "$source_media_refs" >&2
exit 1
fi
bash docs/assets/hydrate-assets.sh
if command -v uv >/dev/null 2>&1; then
(
cd docs
uv run --frozen bash ./zensical-docs.sh build
uv run --frozen python scripts/check_built_site.py
uv run --frozen python scripts/check_vercel_redirects.py
)
else
printf 'uv not found; skipping docs build validation\n' >&2
fi
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# Verify that a tag's desktop release and updater manifest are in sync.
set -euo pipefail
tag="${1:-}"
repo="${GITHUB_REPOSITORY:-kenn-io/agentsview}"
workflow_conclusion="${DESKTOP_RELEASE_HEALTH_WORKFLOW_CONCLUSION-success}"
manifest_file="${DESKTOP_RELEASE_HEALTH_MANIFEST_FILE:-}"
error() {
echo "::error::$*" >&2
}
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
error "expected release tag like v0.34.5 or v0.34.5-rc.1, got '${tag:-<empty>}'"
exit 1
fi
version="${tag#v}"
if [ "$workflow_conclusion" != "success" ]; then
error "Desktop Release workflow concluded ${workflow_conclusion:-<empty>} for ${tag}"
exit 1
fi
if [ -n "$manifest_file" ]; then
manifest="$(cat "$manifest_file")"
else
manifest="$(curl -fsSL "https://github.com/${repo}/releases/download/updater/latest.json")"
fi
if ! jq -e . >/dev/null 2>&1 <<<"$manifest"; then
error "updater manifest is not valid JSON"
exit 1
fi
manifest_version="$(jq -r '.version // ""' <<<"$manifest")"
if [ "$manifest_version" != "$version" ]; then
error "updater manifest version ${manifest_version:-<empty>} does not match expected $version"
exit 1
fi
expected_platforms=(
"darwin-aarch64"
"darwin-x86_64"
"windows-x86_64"
"linux-x86_64"
)
expected_platforms_list() {
local label="" platform
for platform in "${expected_platforms[@]}"; do
if [ -n "$label" ]; then
label+=", "
fi
label+="$platform"
done
printf "%s" "$label"
}
if ! jq -e '.platforms | type == "object"' >/dev/null <<<"$manifest"; then
error "updater manifest platforms must be an object with signatures for $(expected_platforms_list)"
exit 1
fi
for platform in "${expected_platforms[@]}"; do
if ! jq -e --arg platform "$platform" '.platforms[$platform] | type == "object"' >/dev/null <<<"$manifest"; then
error "updater manifest missing platform $platform"
exit 1
fi
if ! jq -e --arg platform "$platform" '.platforms[$platform].url | select(type == "string" and length > 0)' >/dev/null <<<"$manifest"; then
error "updater manifest missing url for $platform"
exit 1
fi
if ! jq -e --arg platform "$platform" '.platforms[$platform].signature | type == "string" and length > 0' >/dev/null <<<"$manifest"; then
error "updater manifest missing signature for $platform"
exit 1
fi
signature="$(jq -r --arg platform "$platform" '.platforms[$platform].signature' <<<"$manifest")"
if [ -z "$signature" ] ||
! [[ "$signature" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] ||
[ $(( ${#signature} % 4 )) -ne 0 ]; then
error "updater manifest signature for $platform is not a base64 payload"
exit 1
fi
done
echo "Desktop release health OK for $tag"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Derive desktop release health inputs from the GitHub event payload.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
event_name="${GITHUB_EVENT_NAME:-}"
event_path="${GITHUB_EVENT_PATH:-}"
if [ -z "$event_path" ] || [ ! -f "$event_path" ]; then
echo "::error::GITHUB_EVENT_PATH is not set or does not exist" >&2
exit 1
fi
case "$event_name" in
workflow_dispatch)
tag="$(jq -r '.inputs.tag // ""' "$event_path")"
conclusion="success"
;;
workflow_run)
tag="$(jq -r '.workflow_run.head_branch // ""' "$event_path")"
conclusion="$(jq -r '.workflow_run.conclusion // ""' "$event_path")"
;;
*)
echo "::error::unsupported event for desktop release health: ${event_name:-<empty>}" >&2
exit 1
;;
esac
DESKTOP_RELEASE_HEALTH_WORKFLOW_CONCLUSION="$conclusion" \
"$SCRIPT_DIR/check_desktop_release_health.sh" "$tag"
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Tests for deriving desktop release health inputs from GitHub event payloads.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WRAPPER="$SCRIPT_DIR/check_desktop_release_health_from_event.sh"
PASS=0
FAIL=0
assert_success() {
local desc="$1"
shift
if "$@" >/tmp/check-desktop-release-health-event-test.out 2>&1; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
cat /tmp/check-desktop-release-health-event-test.out
FAIL=$((FAIL + 1))
fi
}
assert_failure_contains() {
local desc="$1" expected="$2"
shift 2
if "$@" >/tmp/check-desktop-release-health-event-test.out 2>&1; then
echo " FAIL: $desc"
echo " expected failure containing: $expected"
FAIL=$((FAIL + 1))
return
fi
if grep -Fq "$expected" /tmp/check-desktop-release-health-event-test.out; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
echo " expected output containing: $expected"
cat /tmp/check-desktop-release-health-event-test.out
FAIL=$((FAIL + 1))
fi
}
write_fixture() {
local dir="$1" version="$2"
cat >"$dir/latest.json" <<EOF
{
"version": "${version}",
"platforms": {
"darwin-aarch64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_aarch64.app.tar.gz",
"signature": "YWJjCg=="
},
"darwin-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_x86_64.app.tar.gz",
"signature": "YWJjCg=="
},
"windows-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_x64-setup.nsis.zip",
"signature": "YWJjCg=="
},
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "YWJjCg=="
}
}
}
EOF
}
run_wrapper() {
local dir="$1" event_name="$2" event_file="$3"
GITHUB_EVENT_NAME="$event_name" \
GITHUB_EVENT_PATH="$event_file" \
DESKTOP_RELEASE_HEALTH_MANIFEST_FILE="$dir/latest.json" \
"$WRAPPER"
}
echo "=== desktop release health event wrapper ==="
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp" /tmp/check-desktop-release-health-event-test.out' EXIT
write_fixture "$tmp" "0.34.5"
cat >"$tmp/workflow-run.json" <<'EOF'
{"workflow_run":{"event":"push","head_branch":"v0.34.5","conclusion":"success"}}
EOF
assert_success \
"workflow_run tag event passes validated tag" \
run_wrapper "$tmp" "workflow_run" "$tmp/workflow-run.json"
write_fixture "$tmp" "0.0.1-staging.1"
cat >"$tmp/workflow-run-prerelease.json" <<'EOF'
{"workflow_run":{"event":"push","head_branch":"v0.0.1-staging.1","conclusion":"success"}}
EOF
assert_success \
"workflow_run prerelease tag passes validated tag" \
run_wrapper "$tmp" "workflow_run" "$tmp/workflow-run-prerelease.json"
write_fixture "$tmp" "0.34.5"
cat >"$tmp/manual.json" <<'EOF'
{"inputs":{"tag":"v0.34.5"}}
EOF
assert_success \
"manual dispatch input passes validated tag" \
run_wrapper "$tmp" "workflow_dispatch" "$tmp/manual.json"
cat >"$tmp/malicious.json" <<'EOF'
{"workflow_run":{"event":"push","head_branch":"v0.34.5; echo injected","conclusion":"success"}}
EOF
assert_failure_contains \
"malformed event tag is rejected" \
"expected release tag like v0.34.5" \
run_wrapper "$tmp" "workflow_run" "$tmp/malicious.json"
cat >"$tmp/failed.json" <<'EOF'
{"workflow_run":{"event":"push","head_branch":"v0.34.5","conclusion":"failure"}}
EOF
assert_failure_contains \
"failed workflow_run conclusion is propagated" \
"Desktop Release workflow concluded failure for v0.34.5" \
run_wrapper "$tmp" "workflow_run" "$tmp/failed.json"
cat >"$tmp/missing-conclusion.json" <<'EOF'
{"workflow_run":{"event":"push","head_branch":"v0.34.5"}}
EOF
assert_failure_contains \
"missing workflow_run conclusion fails closed" \
"Desktop Release workflow concluded <empty> for v0.34.5" \
run_wrapper "$tmp" "workflow_run" "$tmp/missing-conclusion.json"
cat >"$tmp/unsupported.json" <<'EOF'
{"inputs":{"tag":"v0.34.5"}}
EOF
assert_failure_contains \
"unsupported event fails" \
"unsupported event for desktop release health: schedule" \
run_wrapper "$tmp" "schedule" "$tmp/unsupported.json"
assert_failure_contains \
"missing event path fails" \
"GITHUB_EVENT_PATH is not set or does not exist" \
run_wrapper "$tmp" "workflow_dispatch" "$tmp/missing.json"
echo
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
+256
View File
@@ -0,0 +1,256 @@
#!/bin/bash
# Tests for the desktop release health checker.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CHECKER="$SCRIPT_DIR/check_desktop_release_health.sh"
PASS=0
FAIL=0
assert_success() {
local desc="$1"
shift
if "$@" >/tmp/check-desktop-release-health-test.out 2>&1; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
cat /tmp/check-desktop-release-health-test.out
FAIL=$((FAIL + 1))
fi
}
assert_failure_contains() {
local desc="$1" expected="$2"
shift 2
if "$@" >/tmp/check-desktop-release-health-test.out 2>&1; then
echo " FAIL: $desc"
echo " expected failure containing: $expected"
FAIL=$((FAIL + 1))
return
fi
if grep -Fq "$expected" /tmp/check-desktop-release-health-test.out; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
echo " expected output containing: $expected"
cat /tmp/check-desktop-release-health-test.out
FAIL=$((FAIL + 1))
fi
}
write_fixture() {
local dir="$1" manifest_version="$2"
cat >"$dir/latest.json" <<EOF
{
"version": "${manifest_version}",
"platforms": {
"darwin-aarch64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_aarch64.app.tar.gz",
"signature": "YWJjCg=="
},
"darwin-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_x86_64.app.tar.gz",
"signature": "YWJjCg=="
},
"windows-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_x64-setup.nsis.zip",
"signature": "YWJjCg=="
},
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "YWJjCg=="
}
}
}
EOF
}
run_checker() {
local dir="$1" tag="$2" conclusion="${3:-success}"
DESKTOP_RELEASE_HEALTH_MANIFEST_FILE="$dir/latest.json" \
DESKTOP_RELEASE_HEALTH_WORKFLOW_CONCLUSION="$conclusion" \
"$CHECKER" "$tag"
}
echo "=== desktop release health ==="
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp" /tmp/check-desktop-release-health-test.out' EXIT
write_fixture "$tmp" "0.34.5"
assert_success "healthy desktop release passes" run_checker "$tmp" "v0.34.5"
write_fixture "$tmp" "0.0.1-staging.1"
assert_success \
"semver prerelease desktop release passes" \
run_checker "$tmp" "v0.0.1-staging.1"
assert_failure_contains \
"failed desktop workflow is loud" \
"Desktop Release workflow concluded failure for v0.34.5" \
run_checker "$tmp" "v0.34.5" "failure"
write_fixture "$tmp" "0.34.4"
assert_failure_contains \
"stale updater manifest fails" \
"updater manifest version 0.34.4 does not match expected 0.34.5" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{"version":"0.34.5"}
EOF
assert_failure_contains \
"manifest without updater platforms fails" \
"updater manifest platforms must be an object with signatures for darwin-aarch64, darwin-x86_64, windows-x86_64, linux-x86_64" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{"version":"0.34.5","platforms":{}}
EOF
assert_failure_contains \
"manifest with empty updater platforms fails" \
"updater manifest missing platform darwin-aarch64" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{"version":"0.34.5","platforms":[]}
EOF
assert_failure_contains \
"manifest with non-object updater platforms fails" \
"updater manifest platforms must be an object with signatures for darwin-aarch64, darwin-x86_64, windows-x86_64, linux-x86_64" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{
"version": "0.34.5",
"platforms": {
"darwin-aarch64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_aarch64.app.tar.gz"
},
"darwin-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_x86_64.app.tar.gz",
"signature": "YWJjCg=="
},
"windows-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_x64-setup.nsis.zip",
"signature": "YWJjCg=="
},
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "YWJjCg=="
}
}
}
EOF
assert_failure_contains \
"manifest with missing updater signature fails" \
"updater manifest missing signature for darwin-aarch64" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{
"version": "0.34.5",
"platforms": {
"darwin-aarch64": {
"signature": "YWJjCg=="
},
"darwin-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_x86_64.app.tar.gz",
"signature": "YWJjCg=="
},
"windows-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_x64-setup.nsis.zip",
"signature": "YWJjCg=="
},
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "YWJjCg=="
}
}
}
EOF
assert_failure_contains \
"manifest with missing updater URL fails" \
"updater manifest missing url for darwin-aarch64" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{
"version": "0.34.5",
"platforms": {
"darwin-aarch64": {
"url": "",
"signature": "YWJjCg=="
},
"darwin-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_x86_64.app.tar.gz",
"signature": "YWJjCg=="
},
"windows-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_x64-setup.nsis.zip",
"signature": "YWJjCg=="
},
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "YWJjCg=="
}
}
}
EOF
assert_failure_contains \
"manifest with empty updater URL fails" \
"updater manifest missing url for darwin-aarch64" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{
"version": "0.34.5",
"platforms": {
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "
Public signature:
YWJjCg=="
}
}
}
EOF
assert_failure_contains \
"invalid updater manifest JSON fails" \
"updater manifest is not valid JSON" \
run_checker "$tmp" "v0.34.5"
cat >"$tmp/latest.json" <<'EOF'
{
"version": "0.34.5",
"platforms": {
"darwin-aarch64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_aarch64.app.tar.gz",
"signature": "YWJjCg=="
},
"darwin-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_x86_64.app.tar.gz",
"signature": "YWJjCg=="
},
"windows-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_x64-setup.nsis.zip",
"signature": "YWJjCg=="
},
"linux-x86_64": {
"url": "https://github.com/kenn-io/agentsview/releases/download/updater/AgentsView_0.34.5_amd64.AppImage.tar.gz",
"signature": "Public signature: YWJjCg=="
}
}
}
EOF
assert_failure_contains \
"non-base64 updater signature fails" \
"updater manifest signature for linux-x86_64 is not a base64 payload" \
run_checker "$tmp" "v0.34.5"
echo
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
+166
View File
@@ -0,0 +1,166 @@
<#
.SYNOPSIS
Build and launch the AgentsView Tauri desktop app in dev mode on Windows.
.DESCRIPTION
Builds the frontend SPA, compiles the Go sidecar binary, copies it into
the Tauri binaries directory, and runs `cargo tauri dev`.
.PARAMETER SkipBuild
Skip the frontend and Go sidecar build steps. Use this when iterating
on Rust/Tauri code only and the sidecar binary is already up to date.
#>
param(
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
function ConvertTo-Semver {
param([string]$Raw)
# Strip leading 'v'
$Raw = $Raw -replace '^v', ''
# git-describe: 0.10.0-3-gabcdef -> 0.10.0-dev.3
if ($Raw -match '^(\d+\.\d+\.\d+)-(\d+)-g[0-9a-f]+(-dirty)?$') {
return "$($Matches[1])-dev.$($Matches[2])"
}
# Already semver, with optional prerelease (strip -dirty)
if ($Raw -match '^\d+\.\d+\.\d+(-[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?(-dirty)?$') {
return ($Raw -replace '-dirty$', '')
}
# Looks like a version but isn't valid semver
if ($Raw -match '^\d+\.') {
Write-Error "Malformed version tag: $Raw"
exit 1
}
# Non-tag fallback (bare hash, "dev", etc.)
return ""
}
function Update-TauriVersion {
param([string]$Version, [string]$ConfPath)
$semver = ConvertTo-Semver $Version
if (-not $semver) {
Write-Host "Skipping tauri.conf.json version patch (non-tag build: $Version)"
return
}
$origPath = "$ConfPath.orig"
if (-not (Test-Path $origPath)) {
Copy-Item $ConfPath $origPath
}
$content = Get-Content $ConfPath -Raw
$content = $content -replace '"version":\s*"[^"]*"', "`"version`": `"$semver`""
Set-Content -Path $ConfPath -Value $content -NoNewline
Write-Host "Patched tauri.conf.json version to $semver" -ForegroundColor Green
}
function Restore-TauriVersion {
param([string]$ConfPath)
$origPath = "$ConfPath.orig"
if (Test-Path $origPath) {
Move-Item -Force $origPath $ConfPath
}
}
# Ensure fnm-managed Node.js is on PATH. fnm requires an `fnm env`
# eval in each new PowerShell session; without it, node/npm are not
# found even though fnm itself is on PATH via WinGet links.
if ((Get-Command fnm -ErrorAction SilentlyContinue) -and -not (Get-Command node -ErrorAction SilentlyContinue)) {
Write-Host "Activating fnm environment..." -ForegroundColor Yellow
fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression
}
$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
if (-not (Test-Path "$RepoRoot\go.mod")) {
# Script may live at repo/scripts/ directly
$RepoRoot = Split-Path -Parent $PSScriptRoot
}
if (-not (Test-Path "$RepoRoot\go.mod")) {
Write-Error "Could not locate repository root (no go.mod found)"
exit 1
}
$DesktopDir = Join-Path $RepoRoot "desktop"
$FrontendDir = Join-Path $RepoRoot "frontend"
$EmbedDir = Join-Path $RepoRoot "internal\web\dist"
$BinDir = Join-Path $DesktopDir "src-tauri\binaries"
# Detect Rust host triple for the sidecar binary name
$Triple = (rustc -vV | Select-String "^host:").ToString().Split(" ", 2)[1].Trim()
if (-not $Triple) {
Write-Error "Could not detect Rust host triple. Is rustc installed?"
exit 1
}
$SidecarBin = Join-Path $BinDir "agentsview-$Triple.exe"
if (-not $SkipBuild) {
# --- Build frontend ---
Write-Host "Building frontend..." -ForegroundColor Cyan
Push-Location $FrontendDir
try {
npm ci
npm run build
} finally {
Pop-Location
}
# Copy frontend dist into Go embed directory
if (Test-Path $EmbedDir) {
Remove-Item -Recurse -Force $EmbedDir
}
Copy-Item -Recurse (Join-Path $FrontendDir "dist") $EmbedDir
# --- Build Go sidecar ---
Write-Host "Building Go sidecar..." -ForegroundColor Cyan
$version = git -C $RepoRoot describe --tags --always --dirty 2>$null
if (-not $version) { $version = "dev" }
$commit = git -C $RepoRoot rev-parse --short HEAD 2>$null
if (-not $commit) { $commit = "unknown" }
$buildDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$ldflags = "-X main.version=$version -X main.commit=$commit -X main.buildDate=$buildDate"
$env:CGO_ENABLED = "1"
Push-Location $RepoRoot
try {
go run ./internal/pricing/cmd/litellm-snapshot -restore
if ($LASTEXITCODE -ne 0) {
throw "pricing snapshot restore failed with exit code $LASTEXITCODE"
}
go build -tags fts5 -ldflags $ldflags -o agentsview.exe ./cmd/agentsview
} finally {
Pop-Location
}
# Copy sidecar binary
if (-not (Test-Path $BinDir)) {
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
}
Copy-Item (Join-Path $RepoRoot "agentsview.exe") $SidecarBin -Force
Write-Host "Sidecar ready: $SidecarBin" -ForegroundColor Green
# Patch tauri.conf.json version to match the git-derived version
$TauriConf = Join-Path $DesktopDir "src-tauri\tauri.conf.json"
Update-TauriVersion -Version $version -ConfPath $TauriConf
} else {
if (-not (Test-Path $SidecarBin)) {
Write-Error "Sidecar binary not found at $SidecarBin. Run without -SkipBuild first."
exit 1
}
Write-Host "Skipping build (using existing sidecar binary)" -ForegroundColor Yellow
}
# --- Launch Tauri dev ---
# Use `cargo run` directly instead of `npx tauri dev` to avoid Tauri's
# built-in dev server opening a browser tab (port 1430) that shows a
# stale "Preparing your workspace" page. The app manages its own
# webview navigation from the splash screen to the Go backend.
$TauriConf = Join-Path $DesktopDir "src-tauri\tauri.conf.json"
Write-Host "Launching Tauri dev..." -ForegroundColor Cyan
Push-Location (Join-Path $DesktopDir "src-tauri")
try {
cargo run
} finally {
Pop-Location
Restore-TauriVersion -ConfPath $TauriConf
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
#
# Build helper invoked by air on every Go file change. Keeps the
# build command in one place so air config stays minimal.
set -eu
mkdir -p tmp
mkdir -p internal/web/dist
[ -f internal/web/dist/.keep ] \
|| printf '%s\n' \
'keep embed dir for generated frontend assets' \
> internal/web/dist/.keep
VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
LDFLAGS="-X main.version=${VERSION} \
-X main.commit=${COMMIT} \
-X main.buildDate=${BUILD_DATE}"
go run ./internal/pricing/cmd/litellm-snapshot -restore
CGO_ENABLED=1 go build -tags fts5 -ldflags="${LDFLAGS}" \
-o ./tmp/agentsview ./cmd/agentsview
+630
View File
@@ -0,0 +1,630 @@
package scripts
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHydrateAssetsForceFetchesRemoteAssetBranches(t *testing.T) {
tempDir := t.TempDir()
remoteRepo := filepath.Join(tempDir, "remote")
localRepo := filepath.Join(tempDir, "local")
require.NoError(t, os.MkdirAll(localRepo, 0o755))
git(t, tempDir, "init", "--bare", remoteRepo)
oldStaticDir := filepath.Join(tempDir, "old-static")
writeStaticAssets(t, oldStaticDir, "old static")
oldStaticCommit := commitBareAssetTree(
t, remoteRepo, oldStaticDir, "old static assets",
)
updateBareBranch(t, remoteRepo, "docs-assets", oldStaticCommit)
git(t, localRepo, "init")
git(t, localRepo, "remote", "add", "origin", remoteRepo)
git(t, localRepo, "fetch", "origin", "docs-assets:refs/remotes/origin/docs-assets")
newStaticDir := filepath.Join(tempDir, "new-static")
writeStaticAssets(t, newStaticDir, "new static")
newStaticCommit := commitBareAssetTree(
t, remoteRepo, newStaticDir, "new static assets",
)
updateBareBranch(t, remoteRepo, "docs-assets", newStaticCommit)
generatedDir := filepath.Join(tempDir, "generated")
writeGeneratedAssets(t, generatedDir, "generated")
generatedCommit := commitBareAssetTree(
t, remoteRepo, generatedDir, "generated assets",
)
updateBareBranch(t, remoteRepo, "docs-generated-assets", generatedCommit)
docsAssetsDir := filepath.Join(localRepo, "docs", "assets")
require.NoError(t, os.MkdirAll(docsAssetsDir, 0o755))
writeStaticAssets(t, filepath.Join(docsAssetsDir, "static"), "stale local static")
writeAssetFiles(
t, filepath.Join(docsAssetsDir, "generated"),
[]string{"screenshots/dashboard.png"}, "stale local generated",
)
script, err := os.ReadFile(filepath.Join("..", "docs", "assets", "hydrate-assets.sh"))
require.NoError(t, err)
scriptPath := filepath.Join(docsAssetsDir, "hydrate-assets.sh")
require.NoError(t, os.WriteFile(scriptPath, script, 0o755))
cmd := exec.Command("bash", scriptPath)
cmd.Dir = localRepo
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
logo, err := os.ReadFile(filepath.Join(localRepo, "docs", "assets", "static", "og-image.png"))
require.NoError(t, err)
assert.Equal(t, "new static", strings.TrimRight(string(logo), "\r\n"))
screenshot, err := os.ReadFile(filepath.Join(localRepo, "docs", "assets", "generated", "screenshots", "dashboard.png"))
require.NoError(t, err)
assert.Equal(t, "generated", strings.TrimRight(string(screenshot), "\r\n"))
}
func TestAssetPublishersRejectUnexpectedFiles(t *testing.T) {
cases := []struct {
name string
scriptRel string
write func(*testing.T, string, string)
}{
{
name: "static",
scriptRel: filepath.Join("docs", "assets", "update-static-assets-branch.sh"),
write: writeStaticAssets,
},
{
name: "generated",
scriptRel: filepath.Join("docs", "screenshots", "update-generated-assets-branch.sh"),
write: writeGeneratedAssets,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tempDir := t.TempDir()
repo := filepath.Join(tempDir, "repo")
sourceDir := filepath.Join(tempDir, "source")
require.NoError(t, os.MkdirAll(repo, 0o755))
git(t, repo, "init")
tc.write(t, sourceDir, "asset")
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, ".env.local"), []byte("TOKEN=secret\n"), 0o600))
scriptPath := installScript(t, repo, tc.scriptRel)
cmd := exec.Command("bash", scriptPath, "--source", sourceDir)
cmd.Dir = repo
output, err := cmd.CombinedOutput()
require.Error(t, err, string(output))
assert.Contains(t, string(output), "unexpected")
assert.Contains(t, string(output), ".env.local")
})
}
}
func TestCheckDocsRejectsCorruptedMarkdownSyntax(t *testing.T) {
tempDir := t.TempDir()
repo := filepath.Join(tempDir, "repo")
require.NoError(t, os.MkdirAll(repo, 0o755))
checkScript := installScript(t, repo, filepath.Join("scripts", "check-docs.sh"))
installScript(t, repo, filepath.Join("docs", "scripts", "check_markdown_sources.py"))
require.NoError(t, os.MkdirAll(filepath.Join(repo, "docs", "assets"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(repo, "docs", "assets", "hydrate-assets.sh"),
[]byte("#!/usr/bin/env bash\nset -euo pipefail\n"),
0o755,
))
require.NoError(t, os.WriteFile(
filepath.Join(repo, "docs", "activity.md"),
[]byte(strings.Join([]string{
"______________________________________________________________________",
"",
"## title: Activity description: Activity, concurrency, and session-time reporting in AgentsView",
"",
"!!! warning \"Experimental\" This warning was collapsed by a formatter.",
"",
}, "\n")),
0o644,
))
cmd := exec.Command("bash", checkScript)
cmd.Dir = repo
pythonPath := requireRunnablePython3(t)
cmd.Env = append(envWithout("PATH", "PYTHON"), "PYTHON="+pythonPath, "PATH=/usr/bin:/bin")
output, err := cmd.CombinedOutput()
require.Error(t, err, string(output))
assert.Contains(t, string(output), "docs markdown")
assert.Contains(t, string(output), "activity.md")
}
func TestCheckDocsRequiresRipgrepForMediaReferenceChecks(t *testing.T) {
tempDir := t.TempDir()
repo := filepath.Join(tempDir, "repo")
require.NoError(t, os.MkdirAll(repo, 0o755))
checkScript := installScript(t, repo, filepath.Join("scripts", "check-docs.sh"))
installScript(t, repo, filepath.Join("docs", "scripts", "check_markdown_sources.py"))
require.NoError(t, os.MkdirAll(filepath.Join(repo, "docs", "assets"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(repo, "docs", "assets", "hydrate-assets.sh"),
[]byte("#!/usr/bin/env bash\nset -euo pipefail\n"),
0o755,
))
require.NoError(t, os.WriteFile(
filepath.Join(repo, "docs", "activity.md"),
[]byte(strings.Join([]string{
"---",
"title: Activity",
"description: Activity docs",
"---",
"",
"Valid docs page.",
"",
}, "\n")),
0o644,
))
bashPath, err := exec.LookPath("bash")
require.NoError(t, err)
cmd := exec.Command(bashPath, checkScript)
cmd.Dir = repo
pythonPath := requireRunnablePython3(t)
emptyBin := filepath.Join(tempDir, "empty-bin")
require.NoError(t, os.MkdirAll(emptyBin, 0o755))
cmd.Env = append(envWithout("PATH", "PYTHON"), "PYTHON="+pythonPath, "PATH="+emptyBin)
output, err := cmd.CombinedOutput()
require.Error(t, err, string(output))
assert.Contains(t, string(output), "rg not found")
}
func TestBuiltSiteCheckRequiresMarkdownCompanions(t *testing.T) {
tempDir := t.TempDir()
repo := filepath.Join(tempDir, "repo")
require.NoError(t, os.MkdirAll(repo, 0o755))
checkScript := installScript(t, repo, filepath.Join("docs", "scripts", "check_built_site.py"))
writeMinimalBuiltDocsSite(t, filepath.Join(repo, "docs", "site"))
pythonPath := requireRunnablePython3(t)
cmd := exec.Command(pythonPath, checkScript)
cmd.Dir = filepath.Join(repo, "docs")
output, err := cmd.CombinedOutput()
require.Error(t, err, string(output))
assert.Contains(t, string(output), "missing route markdown /")
}
func TestBuiltSiteCheckRejectsSvgUsePlainHref(t *testing.T) {
tests := []struct {
name string
useTag string
wantPass bool
}{
{
name: "plain href breaks instant navigation",
useTag: `<svg viewBox="0 0 24 24"><use href="#icon"/></svg>`,
wantPass: false,
},
{
name: "xlink href is allowed",
useTag: `<svg viewBox="0 0 24 24"><use xlink:href="#icon"/></svg>`,
wantPass: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
repo := filepath.Join(tempDir, "repo")
require.NoError(t, os.MkdirAll(repo, 0o755))
checkScript := installScript(t, repo, filepath.Join("docs", "scripts", "check_built_site.py"))
siteDir := filepath.Join(repo, "docs", "site")
writeMinimalBuiltDocsSite(t, siteDir)
writeBuiltSiteMarkdownCompanions(t, siteDir)
indexPath := filepath.Join(siteDir, "index.html")
page, err := os.ReadFile(indexPath)
require.NoError(t, err)
patched := strings.Replace(string(page), "</body>", tt.useTag+"</body>", 1)
require.NoError(t, os.WriteFile(indexPath, []byte(patched), 0o644))
pythonPath := requireRunnablePython3(t)
cmd := exec.Command(pythonPath, checkScript)
cmd.Dir = filepath.Join(repo, "docs")
output, err := cmd.CombinedOutput()
if tt.wantPass {
require.NoError(t, err, string(output))
assert.Contains(t, string(output), "built site checks passed")
} else {
require.Error(t, err, string(output))
assert.Contains(t, string(output), "Use xlink:href instead")
}
})
}
}
func TestZensicalDocsBuildExcludesScreenshotToolchain(t *testing.T) {
tempDir := t.TempDir()
repo := filepath.Join(tempDir, "repo")
docsDir := filepath.Join(repo, "docs")
require.NoError(t, os.MkdirAll(docsDir, 0o755))
scriptPath := installScript(t, repo,
filepath.Join("docs", "zensical-docs.sh"))
require.NoError(t, os.WriteFile(
filepath.Join(docsDir, "zensical.toml"),
[]byte("[project]\ndocs_dir = \"docs\"\nsite_dir = \"site\"\n"),
0o644,
))
require.NoError(t, os.WriteFile(
filepath.Join(docsDir, "index.md"),
[]byte("---\ntitle: Home\ndescription: Home page\n---\n"),
0o644,
))
toolchainFile := filepath.Join(
docsDir, "screenshots", "node_modules", "playwright-core",
"trace-viewer.html",
)
require.NoError(t, os.MkdirAll(filepath.Dir(toolchainFile), 0o755))
require.NoError(t, os.WriteFile(
toolchainFile, []byte("private screenshot toolchain\n"), 0o644,
))
testResultFile := filepath.Join(
docsDir, "screenshots", "test-results", "trace.zip",
)
require.NoError(t, os.MkdirAll(filepath.Dir(testResultFile), 0o755))
require.NoError(t, os.WriteFile(
testResultFile, []byte("private test trace\n"), 0o644,
))
publicScreenshot := filepath.Join(
docsDir, "assets", "generated", "screenshots", "dashboard.png",
)
require.NoError(t, os.MkdirAll(filepath.Dir(publicScreenshot), 0o755))
require.NoError(t, os.WriteFile(
publicScreenshot, []byte("public screenshot\n"), 0o644,
))
fakeZensical := filepath.Join(docsDir, ".venv", "bin", "zensical")
require.NoError(t, os.MkdirAll(filepath.Dir(fakeZensical), 0o755))
require.NoError(t, os.WriteFile(fakeZensical, []byte(`#!/usr/bin/env bash
set -euo pipefail
public_docs="$(find . -maxdepth 1 -type d -name 'zensical-public-docs.*' -print -quit)"
mkdir -p site
cp -R "$public_docs"/. site/
`), 0o755))
cmd := exec.Command("bash", scriptPath, "build")
cmd.Dir = docsDir
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
assert.FileExists(t, filepath.Join(docsDir, "site", "index.md"))
assert.FileExists(t, filepath.Join(
docsDir, "site", "assets", "generated", "screenshots",
"dashboard.png",
))
assert.NoFileExists(t, filepath.Join(
docsDir, "site", "screenshots", "node_modules", "playwright-core",
"trace-viewer.html",
))
assert.NoFileExists(t, filepath.Join(
docsDir, "site", "screenshots", "test-results", "trace.zip",
))
}
func installScript(t *testing.T, repo, scriptRel string) string {
t.Helper()
script, err := os.ReadFile(filepath.Join("..", scriptRel))
require.NoError(t, err)
scriptPath := filepath.Join(repo, scriptRel)
require.NoError(t, os.MkdirAll(filepath.Dir(scriptPath), 0o755))
require.NoError(t, os.WriteFile(scriptPath, script, 0o755))
return scriptPath
}
func requireRunnablePython3(t *testing.T) string {
t.Helper()
pythonPath, err := exec.LookPath("python3")
if err != nil {
t.Skipf("python3 not available on PATH: %v", err)
}
cmd := exec.Command(pythonPath, "--version")
if out, err := cmd.CombinedOutput(); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("python3 is not runnable: %v\n%s", err, out)
}
require.NoError(t, err, "python3 --version\n%s", out)
}
return pythonPath
}
var builtDocsRoutes = []string{
"/",
"/quickstart/",
"/usage/",
"/activity/",
"/recent-edits/",
"/session-intelligence/",
"/mcp/",
"/token-usage/",
"/chat-import/",
"/insights/",
"/commands/",
"/stats/",
"/session-api/",
"/configuration/",
"/remote-access/",
"/pg-sync/",
"/duckdb/",
"/changelog/",
}
func writeMinimalBuiltDocsSite(t *testing.T, siteDir string) {
t.Helper()
for _, route := range builtDocsRoutes {
path := filepath.Join(siteDir, strings.Trim(route, "/"), "index.html")
if route == "/" {
path = filepath.Join(siteDir, "index.html")
}
ids := []string{}
switch route {
case "/configuration/":
ids = append(ids, "session-discovery")
case "/token-usage/":
ids = append(ids, "how-it-compares-to-ccusage")
case "/session-api/":
ids = append(ids, "agentsview-session-usage")
}
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(minimalDocsHTML(ids)), 0o644))
}
require.NoError(t, os.WriteFile(filepath.Join(siteDir, "404.html"), []byte(minimalDocsHTML(nil)), 0o644))
require.NoError(t, os.WriteFile(
filepath.Join(siteDir, "sitemap.xml"),
[]byte("<urlset><url><loc>https://agentsview.io/</loc></url></urlset>\n"),
0o644,
))
}
func writeBuiltSiteMarkdownCompanions(t *testing.T, siteDir string) {
t.Helper()
for _, route := range builtDocsRoutes {
path := filepath.Join(siteDir, strings.Trim(route, "/")+".md")
if route == "/" {
path = filepath.Join(siteDir, "index.md")
}
require.NoError(t, os.WriteFile(path, []byte("# Page\n"), 0o644))
}
}
func minimalDocsHTML(ids []string) string {
var b strings.Builder
b.WriteString(`<!doctype html><html><head>`)
b.WriteString(`<meta property="og:image" content="https://agentsview.io/assets/static/og-image.png">`)
b.WriteString(`<meta property="og:image:width" content="1200">`)
b.WriteString(`<meta property="og:image:height" content="630">`)
b.WriteString(`<meta property="og:type" content="website">`)
b.WriteString(`<meta property="og:site_name" content="AgentsView">`)
b.WriteString(`<meta name="twitter:card" content="summary_large_image">`)
b.WriteString(`<meta name="twitter:image" content="https://agentsview.io/assets/static/og-image.png">`)
b.WriteString(`</head><body>`)
b.WriteString(`<a class="agentsview-discord-link" aria-label="Join Discord" href="https://discord.gg/fDnmxB8Wkq">Discord</a>`)
for _, id := range ids {
b.WriteString(`<h2 id="`)
b.WriteString(id)
b.WriteString(`">Heading</h2>`)
}
b.WriteString(`</body></html>`)
return b.String()
}
func envWithout(names ...string) []string {
blocked := make(map[string]struct{}, len(names))
for _, name := range names {
blocked[name] = struct{}{}
}
env := os.Environ()
filtered := env[:0]
for _, entry := range env {
name, _, _ := strings.Cut(entry, "=")
if _, ok := blocked[name]; !ok {
filtered = append(filtered, entry)
}
}
return filtered
}
func writeStaticAssets(t *testing.T, dir, content string) {
t.Helper()
files := []string{
"architecture.svg",
"og-image.png",
}
writeAssetFiles(t, dir, files, content)
}
func writeGeneratedAssets(t *testing.T, dir, content string) {
t.Helper()
files := []string{
"screenshots/about-dialog.png",
"screenshots/activity-breakdowns.png",
"screenshots/activity-concurrency.png",
"screenshots/activity-insight.png",
"screenshots/activity-page.png",
"screenshots/activity-sessions.png",
"screenshots/activity-timeline.png",
"screenshots/activity-week.png",
"screenshots/agent-comparison.png",
"screenshots/analytics-model-filter.png",
"screenshots/block-filter.png",
"screenshots/code-block-copy-btn.png",
"screenshots/command-palette.png",
"screenshots/dashboard.png",
"screenshots/date-range.png",
"screenshots/focused-transcript.png",
"screenshots/follow-latest-toggle.png",
"screenshots/grade-badge.png",
"screenshots/heatmap-filtered.png",
"screenshots/heatmap.png",
"screenshots/hour-of-week.png",
"screenshots/import-button.png",
"screenshots/import-modal-chatgpt.png",
"screenshots/import-modal-claude.png",
"screenshots/in-session-search.png",
"screenshots/insight-content.png",
"screenshots/insights.png",
"screenshots/layout-compact.png",
"screenshots/layout-stream.png",
"screenshots/machine-labels.png",
"screenshots/message-copy-btn.png",
"screenshots/message-viewer.png",
"screenshots/project-breakdown.png",
"screenshots/publish-modal.png",
"screenshots/recent-edits.png",
"screenshots/resync-modal.png",
"screenshots/search-grouped.png",
"screenshots/search-results.png",
"screenshots/session-filtered.png",
"screenshots/session-filters-active.png",
"screenshots/session-filters.png",
"screenshots/session-health.png",
"screenshots/session-insight-action.png",
"screenshots/session-list.png",
"screenshots/session-shape.png",
"screenshots/session-vital-signs.png",
"screenshots/settings-remote.png",
"screenshots/settings.png",
"screenshots/shortcuts-modal.png",
"screenshots/signal-panel.png",
"screenshots/starred-session.png",
"screenshots/subagent-tree.png",
"screenshots/summary-cards.png",
"screenshots/theme-dark.png",
"screenshots/theme-light.png",
"screenshots/thinking-blocks.png",
"screenshots/token-usage.png",
"screenshots/tool-block-copy-btn.png",
"screenshots/tool-blocks.png",
"screenshots/tool-groups.png",
"screenshots/tool-usage.png",
"screenshots/top-sessions.png",
"screenshots/top-skills.png",
"screenshots/trends.png",
"screenshots/usage-attribution.png",
"screenshots/usage-cache-efficiency.png",
"screenshots/usage-cost-trend.png",
"screenshots/usage-filter-dropdown.png",
"screenshots/usage-page.png",
"screenshots/usage-summary-cards.png",
"screenshots/usage-toolbar.png",
"screenshots/usage-top-sessions.png",
"screenshots/velocity.png",
"screenshots/vital-signs-panel.png",
"screenshots/worktree-mappings.png",
}
writeAssetFiles(t, dir, files, content)
}
func writeAssetFiles(t *testing.T, dir string, files []string, content string) {
t.Helper()
for _, file := range files {
path := filepath.Join(dir, file)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content+"\n"), 0o644))
}
}
func commitBareAssetTree(
t *testing.T, bareRepo, workTree, message string,
) string {
t.Helper()
indexPath := filepath.Join(t.TempDir(), "index")
env := gitCommitEnv("GIT_INDEX_FILE=" + indexPath)
gitBareWorkTree(t, bareRepo, workTree, env, "add", "-A", ".")
tree := gitBareWorkTreeOutput(t, bareRepo, workTree, env, "write-tree")
return gitBareOutput(t, bareRepo, env, "commit-tree", tree, "-m", message)
}
func updateBareBranch(t *testing.T, bareRepo, branch, commit string) {
t.Helper()
gitBare(t, bareRepo, nil, "update-ref", "refs/heads/"+branch, commit)
}
func git(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
}
func gitBareWorkTree(
t *testing.T, bareRepo, workTree string, env []string, args ...string,
) {
t.Helper()
output, err := gitBareCmd(bareRepo, workTree, env, args...).CombinedOutput()
require.NoError(t, err, string(output))
}
func gitBareWorkTreeOutput(
t *testing.T, bareRepo, workTree string, env []string, args ...string,
) string {
t.Helper()
output, err := gitBareCmd(bareRepo, workTree, env, args...).Output()
require.NoError(t, err)
return strings.TrimSpace(string(output))
}
func gitBare(t *testing.T, bareRepo string, env []string, args ...string) {
t.Helper()
output, err := gitBareCmd(bareRepo, "", env, args...).CombinedOutput()
require.NoError(t, err, string(output))
}
func gitBareOutput(t *testing.T, bareRepo string, env []string, args ...string) string {
t.Helper()
output, err := gitBareCmd(bareRepo, "", env, args...).Output()
require.NoError(t, err)
return strings.TrimSpace(string(output))
}
func gitBareCmd(
bareRepo, workTree string, env []string, args ...string,
) *exec.Cmd {
fullArgs := []string{"--git-dir", bareRepo}
if workTree != "" {
fullArgs = append(fullArgs, "--work-tree", workTree)
}
fullArgs = append(fullArgs, args...)
cmd := exec.Command("git", fullArgs...)
if workTree != "" {
cmd.Dir = workTree
}
cmd.Env = append(os.Environ(), env...)
return cmd
}
func gitCommitEnv(extra ...string) []string {
env := []string{
"GIT_AUTHOR_NAME=Test User",
"GIT_AUTHOR_EMAIL=test@example.invalid",
"GIT_COMMITTER_NAME=Test User",
"GIT_COMMITTER_EMAIL=test@example.invalid",
}
return append(env, extra...)
}
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
DB_PATH="$TMPDIR/sessions.db"
DUCKDB_PATH="$TMPDIR/sessions.duckdb"
EMPTY_DIR="$TMPDIR/empty"
BACKEND="${AGENTSVIEW_E2E_BACKEND:-sqlite}"
mkdir -p "$EMPTY_DIR"
# Use pre-built binaries if available (CI sets these),
# otherwise build from source (local dev).
FIXTURE="${E2E_PREBUILT_FIXTURE:-}"
SERVER="${E2E_PREBUILT_SERVER:-}"
PRICING_SNAPSHOT_RESTORED=0
restore_pricing_snapshot() {
if [ "$PRICING_SNAPSHOT_RESTORED" -eq 1 ]; then
return
fi
(cd "$ROOT" && go run ./internal/pricing/cmd/litellm-snapshot -restore)
PRICING_SNAPSHOT_RESTORED=1
}
if [ -n "$FIXTURE" ] && [ -f "$FIXTURE" ] && [ -x "$FIXTURE" ]; then
echo "Using pre-built fixture: $FIXTURE"
else
echo "Building test fixture..."
restore_pricing_snapshot
FIXTURE="$TMPDIR/testfixture"
CGO_ENABLED=1 go build -tags "fts5,kit_posthog_disabled" \
-o "$FIXTURE" "$ROOT/cmd/testfixture"
fi
fixture_args=(-out "$DB_PATH")
if [ "$BACKEND" = "duckdb" ]; then
fixture_args+=(-duckdb-out "$DUCKDB_PATH")
fi
"$FIXTURE" "${fixture_args[@]}"
if [ -n "$SERVER" ] && [ -f "$SERVER" ] && [ -x "$SERVER" ]; then
echo "Using pre-built server: $SERVER"
else
echo "Building server..."
restore_pricing_snapshot
SERVER="$TMPDIR/agentsview"
cd "$ROOT/frontend" && npm run build
rm -rf "$ROOT/internal/web/dist"
cp -r "$ROOT/frontend/dist" "$ROOT/internal/web/dist"
printf '%s\n' \
'keep embed dir for generated frontend assets' \
> "$ROOT/internal/web/dist/.keep"
CGO_ENABLED=1 go build -tags "fts5,kit_posthog_disabled" \
-o "$SERVER" "$ROOT/cmd/agentsview"
fi
# Run server with test DB, no sync dirs, fixed port. Every agent dir override
# must point at EMPTY_DIR so the server never discovers real sessions on the
# host. The list is derived from the parser registry (the EnvVar fields in
# internal/parser/types.go), so registering a new agent cannot silently
# reintroduce a discovery leak by leaving its dir env var pointed at the host.
agent_env=("AGENTSVIEW_DATA_DIR=$TMPDIR")
while IFS= read -r agent_var; do
agent_env+=("$agent_var=$EMPTY_DIR")
done < <(grep -oE 'EnvVar:[[:space:]]*"[A-Z0-9_]+"' "$ROOT/internal/parser/types.go" \
| grep -oE '"[A-Z0-9_]+"' | tr -d '"' | sort -u)
# Sanity floor: a stale grep (e.g. types.go reformatted) would yield an empty
# list and silently re-leak host sessions. The registry has dozens of agents;
# far fewer than this means the parse broke, so fail loudly instead.
min_agent_dirs=10
if [ "${#agent_env[@]}" -le "$min_agent_dirs" ]; then
echo "e2e isolation: derived only $(( ${#agent_env[@]} - 1 )) agent dir" \
"overrides from internal/parser/types.go; registry parsing is stale" >&2
exit 1
fi
case "$BACKEND" in
sqlite)
echo "Starting sqlite e2e server on :8090..."
exec env "${agent_env[@]}" "$SERVER" serve \
--port 8090 \
--no-browser
;;
duckdb)
echo "Starting duckdb e2e server on :8090..."
exec env "${agent_env[@]}" \
AGENTSVIEW_DUCKDB_PATH="$DUCKDB_PATH" \
"$SERVER" duckdb serve \
--port 8090 \
--no-browser
;;
*)
echo "unsupported AGENTSVIEW_E2E_BACKEND=$BACKEND" >&2
exit 1
;;
esac
+626
View File
@@ -0,0 +1,626 @@
package scripts
import (
"database/sql"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
avdb "go.kenn.io/agentsview/internal/db"
)
// TestExtractDBBlocksTermsAcrossCoveredColumns exercises the screenshot
// blocked-terms filter end to end: it seeds a source database with sessions
// in a screenshot-whitelisted project, runs extract-db.sh with a blocked
// term, and asserts that every session referencing the term is dropped while
// clean sessions survive. Each blocked session hides the term in a different
// covered column so the test pins the full coverage surface, including the
// git_branch, tool_calls.file_path, and tool_calls.skill_name columns.
func TestExtractDBBlocksTermsAcrossCoveredColumns(t *testing.T) {
if _, err := exec.LookPath("sqlite3"); err != nil {
t.Skip("sqlite3 CLI not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.db")
// Build the source database with the real schema so the test never
// drifts from the production DDL (FTS table, triggers, all tables).
d, err := avdb.Open(srcPath)
require.NoError(t, err)
require.NoError(t, d.Close())
conn, err := sql.Open("sqlite3", srcPath)
require.NoError(t, err)
defer conn.Close()
// All sessions share one timestamp so every row sits inside the
// extract window (which spans 60 days back from the newest session).
const ts = "2026-06-01T12:00:00.000Z"
insertSession := func(id, branch, firstMsg string) {
_, err := conn.Exec(
`INSERT INTO sessions
(id, project, created_at, started_at, message_count,
user_message_count, git_branch, first_message)
VALUES (?, 'agentsview', ?, '', 1, 1, ?, ?)`,
id, ts, branch, firstMsg,
)
require.NoError(t, err)
}
insertMessage := func(id int, sessionID, content string) {
_, err := conn.Exec(
`INSERT INTO messages (id, session_id, ordinal, role, content)
VALUES (?, ?, 0, 'user', ?)`,
id, sessionID, content,
)
require.NoError(t, err)
}
insertToolCall := func(sessionID, filePath, skillName, inputJSON string) {
_, err := conn.Exec(
`INSERT INTO tool_calls
(message_id, session_id, tool_name, category,
file_path, skill_name, input_json)
VALUES (0, ?, 'Edit', 'edit', ?, ?, ?)`,
sessionID, filePath, skillName, inputJSON,
)
require.NoError(t, err)
}
// Clean session: nothing references the blocked term -> survives.
insertSession("s_keep", "main", "ordinary refactoring work")
insertMessage(1, "s_keep", "nothing sensitive in this transcript")
// Term hidden in message content.
insertSession("s_msg", "main", "ordinary prompt")
insertMessage(2, "s_msg", "today I integrated with blocklist-demo-service")
// Term hidden in git_branch (newly covered column).
insertSession("s_branch", "feat/blocklist-demo-service-sync", "branch work")
insertMessage(3, "s_branch", "clean message body")
// Term hidden in a tool call's file_path (newly covered column).
insertSession("s_tcpath", "main", "file edits")
insertMessage(4, "s_tcpath", "clean message body")
insertToolCall("s_tcpath", "/Users/dev/code/blocklist-demo-service/main.go", "", `{"a":1}`)
// Term hidden in a tool call's skill_name (newly covered column).
insertSession("s_skill", "main", "skill run")
insertMessage(5, "s_skill", "clean message body")
insertToolCall("s_skill", "/tmp/clean.go", "blocklist-demo-service-deploy", `{"b":2}`)
// Flush the WAL so the sqlite3 CLI sees every committed row.
_, err = conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
require.NoError(t, err)
require.NoError(t, conn.Close())
outPath := filepath.Join(tempDir, "out.db")
scriptPath := filepath.Join("..", "docs", "screenshots", "extract-db.sh")
cmd := exec.Command("bash", scriptPath, srcPath, outPath)
cmd.Env = append(
os.Environ(),
"SCREENSHOT_BLOCKED_TERMS=blocklist-demo-service",
// Pin the file source to a path that does not exist so the test is
// hermetic and ignores any real blocked-terms file on the host.
"SCREENSHOT_BLOCKED_TERMS_FILE="+filepath.Join(tempDir, "absent.txt"),
)
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "extract-db.sh failed: %s", out)
outConn, err := sql.Open("sqlite3", outPath)
require.NoError(t, err)
defer outConn.Close()
rows, err := outConn.Query("SELECT id FROM sessions ORDER BY id")
require.NoError(t, err)
defer rows.Close()
var ids []string
for rows.Next() {
var id string
require.NoError(t, rows.Scan(&id))
ids = append(ids, id)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"s_keep"}, ids,
"only the clean session should survive; sessions hiding the term in "+
"message content, git_branch, tool file_path, and skill_name must "+
"all be dropped")
}
// TestExtractDBUsesPrivateTermsFileByDefault protects the default screenshot
// scrub path. The screenshot runner should pick up the canonical private terms
// file without requiring SCREENSHOT_BLOCKED_TERMS_FILE for every local run.
func TestExtractDBUsesPrivateTermsFileByDefault(t *testing.T) {
if _, err := exec.LookPath("sqlite3"); err != nil {
t.Skip("sqlite3 CLI not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.db")
d, err := avdb.Open(srcPath)
require.NoError(t, err)
require.NoError(t, d.Close())
conn, err := sql.Open("sqlite3", srcPath)
require.NoError(t, err)
defer conn.Close()
const ts = "2026-06-01T12:00:00.000Z"
seed := func(id, content string) {
_, err := conn.Exec(
`INSERT INTO sessions
(id, project, created_at, started_at, message_count,
user_message_count)
VALUES (?, 'agentsview', ?, '', 1, 1)`,
id, ts,
)
require.NoError(t, err)
_, err = conn.Exec(
`INSERT INTO messages (session_id, ordinal, role, content)
VALUES (?, 0, 'user', ?)`,
id, content,
)
require.NoError(t, err)
}
seed("s_keep", "ordinary screenshot-safe notes")
seed("s_drop", "mentions blocklist-demo-service in the transcript")
_, err = conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
require.NoError(t, err)
require.NoError(t, conn.Close())
privateTermsDir := filepath.Join(tempDir, ".config", "kenn")
require.NoError(t, os.MkdirAll(privateTermsDir, 0o700))
require.NoError(t, os.WriteFile(
filepath.Join(privateTermsDir, "private-terms.txt"),
[]byte("blocklist-demo-service\n"),
0o600,
))
outPath := filepath.Join(tempDir, "out.db")
scriptPath := filepath.Join("..", "docs", "screenshots", "extract-db.sh")
cmd := exec.Command("bash", scriptPath, srcPath, outPath)
cmd.Env = filteredEnv(
"SCREENSHOT_BLOCKED_TERMS_FILE",
"KENN_PRIVATE_TERMS_FILE",
)
cmd.Env = append(
cmd.Env,
"HOME="+tempDir,
"SCREENSHOT_BLOCKED_TERMS=",
)
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "extract-db.sh failed: %s", out)
outConn, err := sql.Open("sqlite3", outPath)
require.NoError(t, err)
defer outConn.Close()
rows, err := outConn.Query("SELECT id FROM sessions ORDER BY id")
require.NoError(t, err)
defer rows.Close()
var ids []string
for rows.Next() {
var id string
require.NoError(t, rows.Scan(&id))
ids = append(ids, id)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"s_keep"}, ids)
}
func TestExtractDBRedactsHomePathByDefault(t *testing.T) {
if _, err := exec.LookPath("sqlite3"); err != nil {
t.Skip("sqlite3 CLI not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.db")
d, err := avdb.Open(srcPath)
require.NoError(t, err)
require.NoError(t, d.Close())
conn, err := sql.Open("sqlite3", srcPath)
require.NoError(t, err)
defer conn.Close()
homePath := filepath.Join(tempDir, "home", "local-user")
require.NoError(t, os.MkdirAll(homePath, 0o700))
const ts = "2026-06-01T12:00:00.000Z"
seed := func(id, firstMessage, content string) {
_, err := conn.Exec(
`INSERT INTO sessions
(id, project, created_at, started_at, message_count,
user_message_count, first_message)
VALUES (?, 'agentsview', ?, '', 1, 1, ?)`,
id, ts, firstMessage,
)
require.NoError(t, err)
_, err = conn.Exec(
`INSERT INTO messages (session_id, ordinal, role, content)
VALUES (?, 0, 'user', ?)`,
id, content,
)
require.NoError(t, err)
}
seed("s_keep", "ordinary screenshot-safe notes", "clean transcript")
seed(
"s_redact",
"Review work under "+filepath.Join(homePath, "code", "project-a"),
"Inspect "+filepath.Join(homePath, "code", "project-a", "main.go"),
)
_, err = conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
require.NoError(t, err)
require.NoError(t, conn.Close())
outPath := filepath.Join(tempDir, "out.db")
scriptPath := filepath.Join("..", "docs", "screenshots", "extract-db.sh")
cmd := exec.Command("bash", scriptPath, srcPath, outPath)
cmd.Env = append(
filteredEnv(
"SCREENSHOT_BLOCKED_TERMS_FILE",
"KENN_PRIVATE_TERMS_FILE",
),
"HOME="+homePath,
"SCREENSHOT_BLOCKED_TERMS=",
"SCREENSHOT_BLOCKED_TERMS_FILE="+filepath.Join(tempDir, "absent.txt"),
"KENN_PRIVATE_TERMS_FILE="+filepath.Join(tempDir, "absent-private.txt"),
)
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "extract-db.sh failed: %s", out)
outConn, err := sql.Open("sqlite3", outPath)
require.NoError(t, err)
defer outConn.Close()
rows, err := outConn.Query("SELECT id FROM sessions ORDER BY id")
require.NoError(t, err)
defer rows.Close()
var ids []string
for rows.Next() {
var id string
require.NoError(t, rows.Scan(&id))
ids = append(ids, id)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"s_keep", "s_redact"}, ids)
var firstMessage, content string
require.NoError(t, outConn.QueryRow(
`SELECT s.first_message, m.content
FROM sessions s
JOIN messages m ON m.session_id = s.id
WHERE s.id = 's_redact'`,
).Scan(&firstMessage, &content))
assert.Equal(t, "Review work under ~/code/project-a", firstMessage)
assert.Equal(t, "Inspect ~/code/project-a/main.go", content)
assert.NotContains(t, firstMessage, homePath)
assert.NotContains(t, content, homePath)
}
func TestExtractDBUsesPrivateTermsFileWithScreenshotFileOverride(t *testing.T) {
if _, err := exec.LookPath("sqlite3"); err != nil {
t.Skip("sqlite3 CLI not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.db")
d, err := avdb.Open(srcPath)
require.NoError(t, err)
require.NoError(t, d.Close())
conn, err := sql.Open("sqlite3", srcPath)
require.NoError(t, err)
defer conn.Close()
const ts = "2026-06-01T12:00:00.000Z"
seed := func(id, content string) {
_, err := conn.Exec(
`INSERT INTO sessions
(id, project, created_at, started_at, message_count,
user_message_count)
VALUES (?, 'agentsview', ?, '', 1, 1)`,
id, ts,
)
require.NoError(t, err)
_, err = conn.Exec(
`INSERT INTO messages (session_id, ordinal, role, content)
VALUES (?, 0, 'user', ?)`,
id, content,
)
require.NoError(t, err)
}
seed("s_keep", "ordinary screenshot-safe notes")
seed("s_drop_private", "mentions private-only-demo in the transcript")
seed("s_drop_screenshot", "mentions screenshot-only-demo in the transcript")
_, err = conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
require.NoError(t, err)
require.NoError(t, conn.Close())
privateTerms := filepath.Join(tempDir, "private-terms.txt")
require.NoError(t, os.WriteFile(privateTerms, []byte("private-only-demo\n"), 0o600))
screenshotTerms := filepath.Join(tempDir, "screenshot-terms.txt")
require.NoError(t, os.WriteFile(screenshotTerms, []byte("screenshot-only-demo\n"), 0o600))
outPath := filepath.Join(tempDir, "out.db")
scriptPath := filepath.Join("..", "docs", "screenshots", "extract-db.sh")
cmd := exec.Command("bash", scriptPath, srcPath, outPath)
cmd.Env = append(
filteredEnv("KENN_PRIVATE_TERMS_FILE"),
"SCREENSHOT_BLOCKED_TERMS=",
"SCREENSHOT_BLOCKED_TERMS_FILE="+screenshotTerms,
"KENN_PRIVATE_TERMS_FILE="+privateTerms,
)
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "extract-db.sh failed: %s", out)
outConn, err := sql.Open("sqlite3", outPath)
require.NoError(t, err)
defer outConn.Close()
rows, err := outConn.Query("SELECT id FROM sessions ORDER BY id")
require.NoError(t, err)
defer rows.Close()
var ids []string
for rows.Next() {
var id string
require.NoError(t, rows.Scan(&id))
ids = append(ids, id)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"s_keep"}, ids)
}
// TestExtractDBKeepsOnlyRootTrees exercises the screenshot fixture's sidebar
// hygiene end to end. Child sessions only survive when their parent tree is
// exported, old descendants of an exported root stay available, and automated
// sessions remain in the fixture so the app can classify/filter them itself.
func TestExtractDBKeepsOnlyRootTrees(t *testing.T) {
if _, err := exec.LookPath("sqlite3"); err != nil {
t.Skip("sqlite3 CLI not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.db")
d, err := avdb.Open(srcPath)
require.NoError(t, err)
require.NoError(t, d.Close())
conn, err := sql.Open("sqlite3", srcPath)
require.NoError(t, err)
defer conn.Close()
insertSession := func(
id, parentID, relationship, createdAt string,
automated, messageCount, userMessageCount int,
) {
_, err := conn.Exec(
`INSERT INTO sessions
(id, project, created_at, started_at, message_count,
user_message_count, parent_session_id, relationship_type,
is_automated, first_message)
VALUES (?, 'agentsview', ?, '', ?, ?, NULLIF(?, ''), ?, ?, ?)`,
id, createdAt, messageCount, userMessageCount,
parentID, relationship, automated, id+" prompt",
)
require.NoError(t, err)
_, err = conn.Exec(
`INSERT INTO messages (session_id, ordinal, role, content)
VALUES (?, 0, 'user', ?)`,
id, id+" message",
)
require.NoError(t, err)
}
const recent = "2026-06-01T12:00:00.000Z"
const old = "2026-01-01T12:00:00.000Z"
insertSession("root_keep", "", "", recent, 0, 3, 2)
insertSession("child_keep", "root_keep", "subagent", recent, 0, 1, 1)
insertSession("old_child_keep", "root_keep", "subagent", old, 0, 1, 1)
insertSession("automated_root_keep", "", "", recent, 1, 1, 1)
insertSession("automated_child_keep", "root_keep", "subagent", recent, 1, 1, 1)
insertSession("child_of_automated_root_keep", "automated_root_keep", "subagent", recent, 0, 1, 1)
insertSession("orphan_subagent_drop", "missing_parent", "subagent", recent, 0, 1, 1)
insertSession("orphan_fork_drop", "missing_parent", "fork", recent, 0, 1, 1)
insertSession("old_root_drop", "", "", old, 0, 3, 2)
insertSession("old_thinking_keep", "", "", old, 0, 3, 2)
insertSession("automated_thinking_keep", "", "", recent, 1, 1, 1)
_, err = conn.Exec(
`UPDATE messages
SET thinking_text = 'private reasoning', has_thinking = 1
WHERE session_id IN ('old_thinking_keep', 'automated_thinking_keep')`,
)
require.NoError(t, err)
_, err = conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
require.NoError(t, err)
require.NoError(t, conn.Close())
outPath := filepath.Join(tempDir, "out.db")
scriptPath := filepath.Join("..", "docs", "screenshots", "extract-db.sh")
cmd := exec.Command("bash", scriptPath, srcPath, outPath)
cmd.Env = append(
os.Environ(),
"SCREENSHOT_BLOCKED_TERMS=",
"SCREENSHOT_BLOCKED_TERMS_FILE="+filepath.Join(tempDir, "absent.txt"),
)
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "extract-db.sh failed: %s", out)
outConn, err := sql.Open("sqlite3", outPath)
require.NoError(t, err)
defer outConn.Close()
rows, err := outConn.Query("SELECT id FROM sessions ORDER BY id")
require.NoError(t, err)
defer rows.Close()
var ids []string
for rows.Next() {
var id string
require.NoError(t, rows.Scan(&id))
ids = append(ids, id)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{
"automated_child_keep",
"automated_root_keep",
"automated_thinking_keep",
"child_keep",
"child_of_automated_root_keep",
"old_child_keep",
"old_thinking_keep",
"root_keep",
}, ids)
var automatedCount int
require.NoError(t, outConn.QueryRow(
"SELECT COUNT(*) FROM sessions WHERE is_automated = 1",
).Scan(&automatedCount))
assert.Equal(t, 3, automatedCount)
var orphanChildCount int
require.NoError(t, outConn.QueryRow(
`SELECT COUNT(*)
FROM sessions child
WHERE child.relationship_type IN ('subagent', 'fork', 'continuation')
AND NOT EXISTS (
SELECT 1 FROM sessions parent
WHERE parent.id = child.parent_session_id
)`,
).Scan(&orphanChildCount))
assert.Zero(t, orphanChildCount)
}
// TestExtractDBTermsFileSkipsCommentsAndBlanks pins the terms-file format:
// comment lines (leading '#') and blank lines are ignored. A bare '#' must
// not become the pattern '%#%', which would match nearly every transcript and
// drop almost all sessions.
func TestExtractDBTermsFileSkipsCommentsAndBlanks(t *testing.T) {
if _, err := exec.LookPath("sqlite3"); err != nil {
t.Skip("sqlite3 CLI not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.db")
d, err := avdb.Open(srcPath)
require.NoError(t, err)
require.NoError(t, d.Close())
conn, err := sql.Open("sqlite3", srcPath)
require.NoError(t, err)
defer conn.Close()
const ts = "2026-06-01T12:00:00.000Z"
seed := func(id, content string) {
_, err := conn.Exec(
`INSERT INTO sessions
(id, project, created_at, started_at, message_count,
user_message_count)
VALUES (?, 'agentsview', ?, '', 1, 1)`,
id, ts,
)
require.NoError(t, err)
_, err = conn.Exec(
`INSERT INTO messages (session_id, ordinal, role, content)
VALUES (?, 0, 'user', ?)`,
id, content,
)
require.NoError(t, err)
}
// Clean session whose transcript contains '#' (a markdown heading). If a
// bare '#' comment line leaked through as the pattern '%#%', this session
// would be dropped.
seed("s_keep", "## Heading\nordinary notes, nothing blocked here")
// Session referencing the one real blocked term.
seed("s_drop", "spent the day on blocklist-demo-service internals")
_, err = conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
require.NoError(t, err)
require.NoError(t, conn.Close())
termsFile := filepath.Join(tempDir, "terms.txt")
require.NoError(t, os.WriteFile(termsFile, []byte(
"# a comment line, ignored\n"+
"#\n"+
" # indented comment, ignored\n"+
"\n"+
"blocklist-demo-service\n",
), 0o600))
outPath := filepath.Join(tempDir, "out.db")
scriptPath := filepath.Join("..", "docs", "screenshots", "extract-db.sh")
cmd := exec.Command("bash", scriptPath, srcPath, outPath)
cmd.Env = append(
os.Environ(),
"SCREENSHOT_BLOCKED_TERMS=",
"SCREENSHOT_BLOCKED_TERMS_FILE="+termsFile,
)
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "extract-db.sh failed: %s", out)
outConn, err := sql.Open("sqlite3", outPath)
require.NoError(t, err)
defer outConn.Close()
rows, err := outConn.Query("SELECT id FROM sessions ORDER BY id")
require.NoError(t, err)
defer rows.Close()
var ids []string
for rows.Next() {
var id string
require.NoError(t, rows.Scan(&id))
ids = append(ids, id)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"s_keep"}, ids,
"comment and blank lines must be skipped: the clean session containing "+
"'#' must survive and only the real term may drop a session")
}
func filteredEnv(dropKeys ...string) []string {
drops := make(map[string]struct{}, len(dropKeys))
for _, key := range dropKeys {
drops[key] = struct{}{}
}
env := os.Environ()
filtered := make([]string, 0, len(env))
for _, entry := range env {
key, _, ok := strings.Cut(entry, "=")
if !ok {
filtered = append(filtered, entry)
continue
}
if _, drop := drops[key]; drop {
continue
}
filtered = append(filtered, entry)
}
return filtered
}
+352
View File
@@ -0,0 +1,352 @@
# agentsview installer for Windows
# Usage: powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/kenn-io/agentsview/main/scripts/install.ps1 | iex"
$ErrorActionPreference = 'Stop'
$repo = 'kenn-io/agentsview'
$binaryName = 'agentsview.exe'
function Write-Info($msg) { Write-Host $msg -ForegroundColor Green }
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host $msg -ForegroundColor Red }
function Test-EnvBool($name) {
$val = [Environment]::GetEnvironmentVariable($name)
return ($val -match '^(1|true|yes)$')
}
function Get-Architecture {
if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') {
return 'arm64'
}
try {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
switch ($arch.ToString()) {
'X64' { return 'amd64' }
'X86' { return '386' }
'Arm64' { return 'arm64' }
default { return 'amd64' }
}
} catch {
if ([System.Environment]::Is64BitOperatingSystem) {
return 'amd64'
} else {
return '386'
}
}
}
function Invoke-WebRequestCompat {
param([string]$Uri, [string]$OutFile)
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
$params = @{ Uri = $Uri }
if ($OutFile) { $params.OutFile = $OutFile }
if ($PSVersionTable.PSVersion.Major -lt 6) {
$params.UseBasicParsing = $true
}
if ($OutFile) {
Invoke-WebRequest @params
} else {
Invoke-RestMethod @params
}
}
function Get-FinalUrl {
# Returns the URL that ultimately responded to a request, after any
# redirects were followed. The property differs by PowerShell edition:
# Windows PowerShell 5.x exposes HttpWebResponse.ResponseUri, while
# PowerShell 7+ exposes HttpResponseMessage.RequestMessage.RequestUri.
param($Response)
try {
if ($Response.BaseResponse.ResponseUri) {
return $Response.BaseResponse.ResponseUri.AbsoluteUri
}
} catch {}
try {
if ($Response.BaseResponse.RequestMessage.RequestUri) {
return $Response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
}
} catch {}
return $null
}
function Get-LatestVersion {
# Resolve the latest release tag by following the /releases/latest
# 302 redirect to /releases/tag/<version> and reading the final URL.
# Using the HTML endpoint (not api.github.com) avoids the 60 req/hr
# per-IP rate limit, so users behind shared NAT / VPN don't get 403.
#
# We let Invoke-WebRequest follow the redirect rather than inspecting
# the Location header with MaximumRedirection=0: in Windows PowerShell
# 5.x that throws a System.InvalidOperationException (not a WebException
# with a usable .Response), which broke the install. A HEAD request
# follows the redirect without downloading the release page body.
$url = "https://github.com/$repo/releases/latest"
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
$params = @{
Uri = $url
Method = 'Head'
ErrorAction = 'Stop'
}
if ($PSVersionTable.PSVersion.Major -lt 6) {
$params.UseBasicParsing = $true
}
$finalUrl = $null
try {
$response = Invoke-WebRequest @params
$finalUrl = Get-FinalUrl $response
} catch {
throw "Failed to fetch latest version: $_"
}
if (-not $finalUrl) {
throw "Failed to fetch latest version: could not resolve release URL from $url"
}
if ($finalUrl -notmatch '/releases/tag/([^/]+)/?$') {
throw "Failed to fetch latest version: unexpected release URL $finalUrl"
}
return $Matches[1]
}
function Test-ReleaseAsset {
# Returns $true when the given release asset URL exists (HTTP 2xx),
# following the redirect to the storage backend. Used to detect
# whether a native build is published for the detected architecture.
param([string]$Url)
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
$params = @{
Uri = $Url
Method = 'Head'
ErrorAction = 'Stop'
}
if ($PSVersionTable.PSVersion.Major -lt 6) {
$params.UseBasicParsing = $true
}
try {
$response = Invoke-WebRequest @params
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
} catch {
return $false
}
}
function Resolve-ReleaseArch {
# Returns the release arch to install for the detected CPU arch.
# Prefers a native build, but falls back to amd64 on arm64 because
# Windows on ARM transparently runs x64 binaries under emulation and
# no native windows/arm64 asset is published for every release.
# Returns $null when no usable asset exists.
param([string]$DetectedArch, [string]$Version)
$candidates = @($DetectedArch)
if ($DetectedArch -eq 'arm64') {
$candidates += 'amd64'
}
$versionNum = $Version.TrimStart('v')
foreach ($candidate in $candidates) {
$name = "agentsview_${versionNum}_windows_${candidate}.zip"
$url = "https://github.com/$repo/releases/download/$Version/$name"
if (Test-ReleaseAsset $url) {
return $candidate
}
}
return $null
}
function Get-InstallDir {
if ($env:AGENTSVIEW_INSTALL_DIR) {
return $env:AGENTSVIEW_INSTALL_DIR
}
return Join-Path $env:USERPROFILE '.agentsview\bin'
}
function Add-ToPath($dir) {
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$normalizedDir = $dir.TrimEnd('\', '/')
$alreadyInPath = $currentPath -split ';' | Where-Object {
$_.TrimEnd('\', '/') -ieq $normalizedDir
}
if ($alreadyInPath) {
Write-Info "Directory already in PATH"
return $false
}
$newPath = "$currentPath;$dir"
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
$env:Path = "$env:Path;$dir"
return $true
}
function Install-Agentsview {
Write-Info "Installing agentsview..."
Write-Host ""
$arch = Get-Architecture
Write-Info "Platform: windows/$arch"
if ($arch -eq '386') {
Write-Err "Error: 32-bit Windows is not supported."
Write-Err "agentsview requires 64-bit Windows (amd64 or arm64)."
exit 1
}
$version = Get-LatestVersion
Write-Info "Latest version: $version"
$resolvedArch = Resolve-ReleaseArch -DetectedArch $arch -Version $version
if (-not $resolvedArch) {
Write-Err "Error: No Windows release asset found for $version (detected windows/$arch)."
Write-Err "See https://github.com/$repo for build-from-source instructions."
exit 1
}
if ($resolvedArch -ne $arch) {
Write-Warn "No native windows/$arch build for $version; installing windows/$resolvedArch (runs under emulation)."
$arch = $resolvedArch
}
$versionNum = $version.TrimStart('v')
$archiveName = "agentsview_${versionNum}_windows_${arch}.zip"
$downloadUrl = "https://github.com/$repo/releases/download/$version/$archiveName"
$installDir = Get-InstallDir
Write-Info "Install directory: $installDir"
Write-Host ""
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
}
$tmpDir = Join-Path $env:TEMP "agentsview-install-$(Get-Random)"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
try {
$archivePath = Join-Path $tmpDir $archiveName
Write-Info "Downloading $archiveName..."
Invoke-WebRequestCompat -Uri $downloadUrl -OutFile $archivePath
$checksumUrl = "https://github.com/$repo/releases/download/$version/SHA256SUMS"
$checksumFile = Join-Path $tmpDir "SHA256SUMS"
if (Test-EnvBool 'AGENTSVIEW_SKIP_CHECKSUM') {
Write-Warn "Warning: Skipping checksum verification (AGENTSVIEW_SKIP_CHECKSUM is set)"
} else {
Write-Info "Verifying checksum..."
try {
Invoke-WebRequestCompat -Uri $checksumUrl -OutFile $checksumFile
} catch {
Write-Err "Error: Could not download checksums file: $_"
Write-Err "Set AGENTSVIEW_SKIP_CHECKSUM=1 to bypass verification (not recommended)"
exit 1
}
$matchingLines = @()
foreach ($line in Get-Content $checksumFile) {
if ($line -match '^\s*$') { continue }
$parts = $line -split '\s+', 2
if ($parts.Count -lt 2) { continue }
$hash = $parts[0]
$filename = $parts[1]
$filename = $filename -replace '^[\*]', ''
$filename = $filename -replace '^\.\/', ''
$filename = $filename -replace '^\.\\', ''
if ($filename -eq $archiveName) {
$matchingLines += $hash
}
}
if ($matchingLines.Count -eq 0) {
Write-Err "Error: Could not find checksum for $archiveName in SHA256SUMS"
Write-Err "Set AGENTSVIEW_SKIP_CHECKSUM=1 to bypass verification (not recommended)"
exit 1
}
if ($matchingLines.Count -gt 1) {
Write-Err "Error: Multiple checksum entries found for $archiveName"
exit 1
}
$expectedHash = $matchingLines[0]
$actualHash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower()
if ($actualHash -ne $expectedHash.ToLower()) {
Write-Err "Error: Checksum verification failed!"
Write-Err "Expected: $expectedHash"
Write-Err "Got: $actualHash"
exit 1
}
Write-Info "Checksum verified."
}
Write-Info "Extracting..."
if ($PSVersionTable.PSVersion.Major -lt 5) {
Write-Err "Error: PowerShell 5.0 or later is required for Expand-Archive."
Write-Err "Please upgrade PowerShell or download the release manually from GitHub."
exit 1
}
try {
Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force
} catch {
Write-Err "Error: Failed to extract archive: $_"
exit 1
}
$binaryFile = Get-ChildItem -Path $tmpDir -Recurse -Filter $binaryName | Select-Object -First 1
if (-not $binaryFile) {
Write-Err "Error: Could not find $binaryName in extracted archive"
exit 1
}
$destPath = Join-Path $installDir $binaryName
if (Test-Path $destPath) {
Remove-Item $destPath -Force
}
Move-Item $binaryFile.FullName $destPath -Force
Write-Host ""
Write-Info "Installation complete!"
Write-Host ""
if (-not (Test-EnvBool 'AGENTSVIEW_NO_MODIFY_PATH')) {
$pathUpdated = Add-ToPath $installDir
if ($pathUpdated) {
Write-Info "Added $installDir to PATH"
Write-Warn "Restart your terminal for PATH changes to take effect."
Write-Host ""
}
}
Write-Host "Get started:"
Write-Host " agentsview serve # Start the server and open browser"
Write-Host " agentsview update # Check for and install updates"
} finally {
if (Test-Path $tmpDir) {
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Install-Agentsview
+213
View File
@@ -0,0 +1,213 @@
#!/bin/bash
# agentsview installer
# Usage: curl -fsSL https://raw.githubusercontent.com/kenn-io/agentsview/main/scripts/install.sh | bash
set -euo pipefail
REPO="kenn-io/agentsview"
BINARY_NAME="agentsview"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
error() { echo -e "${RED}$1${NC}" >&2; exit 1; }
detect_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) error "Unsupported OS: $(uname -s). agentsview supports macOS and Linux." ;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
*) error "Unsupported architecture: $(uname -m)" ;;
esac
}
find_install_dir() {
if [ -w "/usr/local/bin" ]; then
echo "/usr/local/bin"
else
mkdir -p "$HOME/.local/bin"
echo "$HOME/.local/bin"
fi
}
download() {
local url="$1"
local output="$2"
if command -v curl &>/dev/null; then
curl -fsSL "$url" -o "$output"
elif command -v wget &>/dev/null; then
wget -q "$url" -O "$output"
else
error "Neither curl nor wget found"
fi
}
get_latest_version() {
# Use the HTML /releases/latest endpoint, which 302-redirects to
# /releases/tag/<version>. Unlike api.github.com it is not rate-limited
# at 60 req/hr per IP, so users behind shared NAT / VPN don't get 403.
local url="https://github.com/${REPO}/releases/latest"
local final_url=""
if command -v curl &>/dev/null; then
final_url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$url") || return 1
elif command -v wget &>/dev/null; then
final_url=$(wget --spider -S "$url" 2>&1 \
| awk 'tolower($1)=="location:" {print $2}' \
| tail -1 \
| tr -d '\r\n') || return 1
else
return 1
fi
case "$final_url" in
*/releases/tag/*) echo "${final_url##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
verify_checksum() {
local file="$1"
local checksums_file="$2"
local filename="$3"
if [ "${AGENTSVIEW_SKIP_CHECKSUM:-0}" = "1" ]; then
warn "Checksum verification skipped (AGENTSVIEW_SKIP_CHECKSUM=1)"
return 0
fi
if [ ! -f "$checksums_file" ]; then
error "Checksum file not available. Set AGENTSVIEW_SKIP_CHECKSUM=1 to bypass."
fi
local expected
expected=$(awk -v f="$filename" '{gsub(/^\*/, "", $2); if ($2==f) {print $1; exit}}' "$checksums_file")
if [ -z "$expected" ]; then
error "No checksum found for $filename in SHA256SUMS"
fi
local actual
if command -v sha256sum &>/dev/null; then
actual=$(sha256sum "$file" | cut -d' ' -f1)
elif command -v shasum &>/dev/null; then
actual=$(shasum -a 256 "$file" | cut -d' ' -f1)
else
error "No sha256 tool available. Install coreutils or set AGENTSVIEW_SKIP_CHECKSUM=1 to bypass."
fi
if [ "$expected" != "$actual" ]; then
error "Checksum verification failed!\n Expected: $expected\n Actual: $actual"
fi
info "Checksum verified"
}
install_from_release() {
local os="$1"
local arch="$2"
local install_dir="$3"
info "Fetching latest release..."
local version
version=$(get_latest_version)
if [ -z "$version" ]; then
return 1
fi
info "Found version: $version"
local platform="${os}_${arch}"
local filename="${BINARY_NAME}_${version#v}_${platform}.tar.gz"
local base_url="https://github.com/${REPO}/releases/download/${version}"
local tmpdir
tmpdir=$(mktemp -d)
trap "rm -rf $tmpdir" EXIT
info "Downloading ${filename}..."
if ! download "${base_url}/${filename}" "$tmpdir/release.tar.gz"; then
return 1
fi
if [ "${AGENTSVIEW_SKIP_CHECKSUM:-0}" != "1" ]; then
if ! download "${base_url}/SHA256SUMS" "$tmpdir/SHA256SUMS"; then
error "Failed to download SHA256SUMS. Cannot verify binary integrity."
fi
verify_checksum "$tmpdir/release.tar.gz" "$tmpdir/SHA256SUMS" "$filename"
else
warn "Checksum verification skipped (AGENTSVIEW_SKIP_CHECKSUM=1)"
fi
info "Extracting..."
tar -xzf "$tmpdir/release.tar.gz" -C "$tmpdir"
if [ -f "$tmpdir/${BINARY_NAME}" ]; then
if [ -w "$install_dir" ]; then
mv "$tmpdir/${BINARY_NAME}" "$install_dir/"
else
sudo mv "$tmpdir/${BINARY_NAME}" "$install_dir/"
fi
chmod +x "$install_dir/${BINARY_NAME}"
else
error "Binary not found in archive"
fi
if [ "$os" = "darwin" ] && [ -f "$install_dir/${BINARY_NAME}" ]; then
codesign -s - "$install_dir/${BINARY_NAME}" 2>/dev/null || true
fi
return 0
}
main() {
info "Installing agentsview..."
echo
local os
os=$(detect_os)
local arch
arch=$(detect_arch)
local install_dir
install_dir=$(find_install_dir)
info "Platform: ${os}/${arch}"
info "Install directory: ${install_dir}"
echo
if install_from_release "$os" "$arch" "$install_dir"; then
info "Installed from GitHub release"
else
error "Installation failed. Please check https://github.com/${REPO}/releases for available builds."
fi
echo
info "Installation complete!"
echo
if ! echo "$PATH" | grep -q "$install_dir"; then
warn "Add this to your shell profile:"
echo " export PATH=\"\$PATH:$install_dir\""
echo
fi
echo "Get started:"
echo " agentsview serve # Start the server and open browser"
echo " agentsview update # Check for and install updates"
}
# Guard: only run main when executed directly, not when sourced.
# ${BASH_SOURCE[0]-} defaults to empty when piped via stdin
# (curl ... | bash), which we treat as direct execution.
if [[ "${BASH_SOURCE[0]-}" == "${0}" || -z "${BASH_SOURCE[0]-}" ]]; then
main "$@"
fi
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# Tests for install.sh version parsing logic.
# Sources install.sh directly so the test exercises the real
# get_latest_version function (with curl mocked out).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source install.sh to get access to get_latest_version.
# The main() call is guarded so nothing runs on source.
# shellcheck source=install.sh
source "$SCRIPT_DIR/install.sh"
PASS=0
FAIL=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
echo " expected: '$expected'"
echo " actual: '$actual'"
FAIL=$((FAIL + 1))
fi
}
# Mock curl to simulate the redirect-follow behavior of the real install
# script. get_latest_version uses `curl ... -w '%{url_effective}'` so we
# intercept that and emit $MOCK_FINAL_URL (the URL after following 302s).
MOCK_FINAL_URL=""
MOCK_CURL_EXIT=0
curl() {
if [ "$MOCK_CURL_EXIT" != "0" ]; then
return "$MOCK_CURL_EXIT"
fi
printf '%s' "$MOCK_FINAL_URL"
}
export -f curl
echo "=== get_latest_version parsing ==="
# Normal release tag
MOCK_FINAL_URL="https://github.com/kenn-io/agentsview/releases/tag/v0.8.0"
assert_eq "release tag" "v0.8.0" "$(get_latest_version)"
# Newer release tag
MOCK_FINAL_URL="https://github.com/kenn-io/agentsview/releases/tag/v0.30.1"
assert_eq "two-digit minor" "v0.30.1" "$(get_latest_version)"
# Pre-release version
MOCK_FINAL_URL="https://github.com/kenn-io/agentsview/releases/tag/v0.9.0-rc1"
assert_eq "pre-release version" "v0.9.0-rc1" "$(get_latest_version)"
# No releases: GitHub returns the /releases page itself instead of a 302.
MOCK_FINAL_URL="https://github.com/kenn-io/agentsview/releases"
assert_eq "no releases returns empty" "" "$(get_latest_version || true)"
# Redirect to latest sentinel (no follow / no tag in URL).
MOCK_FINAL_URL="https://github.com/kenn-io/agentsview/releases/latest"
assert_eq "unresolved latest returns empty" "" "$(get_latest_version || true)"
# Network failure: curl exits non-zero, function should fail.
MOCK_FINAL_URL=""
MOCK_CURL_EXIT=22
assert_eq "curl failure returns empty" "" "$(get_latest_version || true)"
MOCK_CURL_EXIT=0
echo
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
bash "$SCRIPT_DIR/make_install_test.sh"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# Behavioral tests for the Makefile install recipe.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
home="$tmpdir/home"
work="$tmpdir/work"
fakebin="$tmpdir/bin"
install_dir="$home/.local/bin"
mkdir -p "$install_dir" "$work" "$fakebin"
printf 'old binary\n' > "$install_dir/agentsview"
printf 'new binary\n' > "$work/agentsview"
cat > "$fakebin/go" <<'EOF'
#!/bin/sh
if [ "$1" = "env" ] && [ "$2" = "GOPATH" ]; then
printf '%s\n' "$HOME/go"
exit 0
fi
if [ "$1" = "env" ] && [ "$2" = "GOBIN" ]; then
exit 0
fi
echo "unexpected go command: $*" >&2
exit 1
EOF
chmod +x "$fakebin/go"
render_install_recipe() {
PATH="$fakebin:$PATH" HOME="$1" make -C "$REPO_ROOT" -n install |
sed -n '/^if \[ -d /,$p'
}
recipe="$(render_install_recipe "$home")"
[ -n "$recipe" ] || fail "could not extract install recipe"
set +e
(
cd "$work" || exit 1
cp() {
printf 'partial binary\n' > "$2"
return 1
}
eval "$recipe"
)
status=$?
set -e
[ "$status" -ne 0 ] || fail "install recipe succeeded after cp failed"
installed="$(cat "$install_dir/agentsview")"
[ "$installed" = "old binary" ] ||
fail "failed copy replaced installed binary with: $installed"
leftovers="$(find "$install_dir" -maxdepth 1 -type f -name 'agentsview.*' -print)"
[ -z "$leftovers" ] || fail "temporary install files were not cleaned up: $leftovers"
echo "PASS: install recipe keeps existing binary when copy fails"
success_home="$tmpdir/success-home"
success_work="$tmpdir/success-work"
success_install_dir="$success_home/.local/bin"
mkdir -p "$success_install_dir" "$success_work"
printf 'old binary\n' > "$success_install_dir/agentsview"
printf 'new binary\n' > "$success_work/agentsview"
chmod 755 "$success_work/agentsview"
success_recipe="$(render_install_recipe "$success_home")"
[ -n "$success_recipe" ] || fail "could not extract success install recipe"
(
cd "$success_work" || exit 1
eval "$success_recipe"
)
success_installed="$(cat "$success_install_dir/agentsview")"
[ "$success_installed" = "new binary" ] ||
fail "successful install wrote unexpected content: $success_installed"
[ -x "$success_install_dir/agentsview" ] ||
fail "successful install did not leave agentsview executable"
success_leftovers="$(find "$success_install_dir" -maxdepth 1 -type f -name 'agentsview.*' -print)"
[ -z "$success_leftovers" ] ||
fail "successful install left temporary files: $success_leftovers"
echo "PASS: install recipe keeps installed binary executable"
+74
View File
@@ -0,0 +1,74 @@
# QwenPaw Test Fixtures
Synthetic, sanitized QwenPaw session files for agentsview regression
testing. Covers the four on-disk layouts the parser must handle:
| Fixture | Layout | What it exercises |
| -------------------------------------------------------- | ----------------------- | --------------------------------------------------- |
| `default/sessions/default_1700000000000.json` | root sessions/ | basic user/assistant exchange + thinking block |
| `default/sessions/main_main.json` | root sessions/ | tool_use + tool_result round-trip via system role |
| `default/sessions/console/default_1700000000001.json` | sessions/console/ | subdir layout, exercises the ID collision fix |
| `note_keeper/sessions/user@example.com_1700000000002.json` | root sessions/ | channel-scoped filename (`@`, dots) — ID validation |
| `researcher/sessions/empty.json` | root sessions/ | empty `agent.memory.content` edge case |
All user identifiers, agent names, file paths, and tool call IDs are
synthetic. No PII, no live tokens, no real session UUIDs.
## Regenerating
The fixtures are produced by `gen.py`, which hand-authors synthetic
QwenPaw session payloads that match the on-disk shape documented in
QwenPaw's `session.py`. The output is pretty-printed (`indent=2`)
for readability — it is **not** byte-identical to a live runtime's
compact JSON, but the parser is whitespace-insensitive so this is
fine for fixtures. From the agentsview repo root:
```bash
python3 scripts/qwenpaw-fixtures/gen.py \
--qwenpaw-src ~/develop/QwenPaw \
--out internal/parser/testdata
```
Requirements: QwenPaw source tree checked out locally. The script
imports `qwenpaw.app.runner.session` to sanity-check the source path
but writes fixtures directly (no QwenPaw runtime needed).
## Source-of-Truth Reference
The on-disk shape is defined in QwenPaw itself:
- **File**: `QwenPaw/src/qwenpaw/app/runner/session.py`
- **Serializer**: `SessionManager.save_session_state` (~line 314)
- **Path derivation**: `SessionManager._get_save_path` (~line 258)
- **Atomic write**: module-level `_atomic_write_json`
Shape (per `state_dicts` written by `save_session_state`):
```jsonc
{
"agent": {
"memory": {
"content": [[message, []], [message, []], ...]
},
"toolkit": {"active_groups": []},
"name": "<AgentName>",
"_sys_prompt": "..."
}
}
```
Each `message` is an Anthropic-style content block envelope:
```jsonc
{
"id": "msg_..." | "<short-alphanum>",
"name": "user" | "<AgentName>" | "system",
"role": "user" | "assistant" | "system",
"content": [text|thinking|tool_use|tool_result],
"metadata": {},
"timestamp": "YYYY-MM-DD HH:MM:SS.fff" // local time, ms
}
```
System-role messages carry `tool_result` blocks (QwenPaw's equivalent
of Anthropic's user-side tool_result).
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""Regenerate the agentsview QwenPaw fixtures.
This script hand-authors synthetic QwenPaw session payloads that
match the on-disk shape documented in
``QwenPaw/src/qwenpaw/app/runner/session.py``. The output is written
as pretty-printed JSON (``indent=2``) for readability — it is NOT
byte-identical to what a live QwenPaw runtime writes (which uses
``json.dumps(..., ensure_ascii=False)`` without indent). The parser
treats whitespace-insensitive JSON, so test fixtures do not need
byte-exact fidelity; they only need the right shape.
Requirements
------------
- The QwenPaw source tree checked out locally. The script reads
``src/qwenpaw/app/runner/session.py`` from that tree and inspects
it with the ``ast`` module purely to sanity-check the source path
(expected symbols: ``SessionManager``, ``save_session_state``,
``_atomic_write_json``). It never imports the ``qwenpaw`` package,
so a malicious or compromised QwenPaw checkout cannot execute
arbitrary top-level code during fixture regeneration. No QwenPaw
runtime is needed.
Usage
-----
python3 scripts/qwenpaw-fixtures/gen.py \\
--qwenpaw-src ~/develop/QwenPaw \\
--out internal/parser/testdata
The qwenpaw/ subtree under --out is replaced wholesale to avoid
stale fixtures. All synthesized data is non-real — no PII,
no live tokens, no real user identifiers.
Serializer reference
--------------------
Source of truth for the on-disk shape:
QwenPaw/src/qwenpaw/app/runner/session.py
SessionManager.save_session_state (line ~314)
SessionManager._get_save_path (line ~258)
_atomic_write_json (module-level helper)
"""
from __future__ import annotations
import argparse
import ast
import json
import shutil
from pathlib import Path
from typing import Any
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--qwenpaw-src",
required=True,
type=Path,
help="Path to a checked-out QwenPaw source tree.",
)
p.add_argument(
"--out",
required=True,
type=Path,
help="Output directory (the qwenpaw/ subtree is rebuilt here).",
)
return p.parse_args()
# _verify_source_shape sanity-checks the QwenPaw source tree by reading
# session.py and walking its AST — it deliberately does NOT import the
# package, so a compromised QwenPaw checkout cannot run arbitrary code
# during fixture regeneration. Returns True iff session.py defines a
# SessionManager class with a save_session_state method AND a module-
# level _atomic_write_json function. Rejecting "right symbol, wrong
# owner" avoids a degenerate file that defines an unrelated
# SessionManager alongside an unrelated save_session_state free function.
def _verify_source_shape(qwenpaw_src: Path) -> bool:
session_py = (
qwenpaw_src / "src" / "qwenpaw" / "app" / "runner" / "session.py"
)
if not session_py.is_file():
return False
try:
tree = ast.parse(session_py.read_text(encoding="utf-8"))
except (OSError, SyntaxError):
return False
module_funcs = {
node.name for node in tree.body
if isinstance(node, ast.FunctionDef)
}
session_manager_methods: set[str] = set()
for node in tree.body:
if (
isinstance(node, ast.ClassDef)
and node.name == "SessionManager"
):
session_manager_methods = {
child.name for child in node.body
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
}
break
return (
"_atomic_write_json" in module_funcs
and "save_session_state" in session_manager_methods
)
def _msg(
mid: str, role: str, name: str, content: list[dict], ts: str,
metadata: dict | None = None,
) -> dict[str, Any]:
return {
"id": mid,
"name": name,
"role": role,
"content": content,
"metadata": metadata or {},
"timestamp": ts,
}
def _pair(m: dict) -> list:
return [m, []]
def _wrap(messages: list[dict], agent_name: str) -> dict:
return {
"agent": {
"memory": {"content": [_pair(m) for m in messages]},
"toolkit": {"active_groups": []},
"name": agent_name,
"_sys_prompt": "redacted",
}
}
def _write(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _build_default_basic(out_root: Path) -> None:
msgs = [
_msg(
"msg_0001", "user", "user",
[{"type": "text", "text": "QwenPaw 的默认会话布局长什么样?"}],
"2026-03-15 10:00:00.000",
),
_msg(
"tID1AAAAA", "assistant", "Friday",
[
{"type": "thinking", "thinking": "User is asking about the session file format. Let me show them the on-disk shape."},
{"type": "text", "text": "默认会话写在 <workspace>/sessions/<stem>.json,顶层是 {\"agent\":{\"memory\":{\"content\":[[msg, []], ...]}}}。"},
],
"2026-03-15 10:00:02.500",
),
]
_write(
out_root / "default" / "sessions" / "default_1700000000000.json",
_wrap(msgs, "Friday"),
)
def _build_default_tool_use(out_root: Path) -> None:
msgs = [
_msg(
"msg_0010", "user", "user",
[{"type": "text", "text": "读一下 NOTES.md 的内容"}],
"2026-03-16 09:00:00.000",
),
_msg(
"aID0BBBBB", "assistant", "Friday",
[{
"type": "tool_use",
"id": "call_aaaaaaaaaaaa",
"name": "read_file",
"input": {"file_path": "NOTES.md"},
"raw_input": "{\"file_path\":\"NOTES.md\"}",
}],
"2026-03-16 09:00:01.250",
),
_msg(
"sID0CCCCC", "system", "system",
[{
"type": "tool_result",
"id": "call_aaaaaaaaaaaa",
"name": "read_file",
"output": [{
"type": "text",
"text": "# NOTES\n\n- synthetic fixture for agentsview tests\n- no real user data",
}],
}],
"2026-03-16 09:00:01.500",
),
_msg(
"aID1DDDDD", "assistant", "Friday",
[{"type": "text", "text": "NOTES.md 是一个合成 fixture,无真实用户数据。"}],
"2026-03-16 09:00:02.000",
),
]
_write(
out_root / "default" / "sessions" / "main_main.json",
_wrap(msgs, "Friday"),
)
def _build_default_console(out_root: Path) -> None:
msgs = [
_msg(
"msg_console_0001", "user", "user",
[{"type": "text", "text": "这是从 console 启动的会话"}],
"2026-03-17 14:30:00.000",
),
_msg(
"aID0ConAAAA", "assistant", "Default",
[{"type": "text", "text": "Console 会话写入 sessions/console/<stem>.json,与根布局同名文件互不冲突。"}],
"2026-03-17 14:30:01.000",
),
]
_write(
out_root / "default" / "sessions" / "console" / "default_1700000000001.json",
_wrap(msgs, "Default"),
)
def _build_note_keeper_channel_scoped(out_root: Path) -> None:
msgs = [
_msg(
"msg_chan_0001", "user", "user",
[{"type": "text", "text": "Channel-scoped 会话:文件名形如 <user_id>_<session_id>.json,可包含 @ 与点号。"}],
"2026-03-18 08:15:30.123",
metadata={"source": "im_wechat"},
),
_msg(
"aID0ChanAAA", "assistant", "Keeper",
[{"type": "text", "text": "记下来了。这种文件名 challenge agentsview 的 ID 校验,需要放宽到 IsValidQwenPawIDPart。"}],
"2026-03-18 08:15:31.456",
),
]
_write(
out_root / "note_keeper" / "sessions" / "user@example.com_1700000000002.json",
_wrap(msgs, "Keeper"),
)
def _build_researcher_empty(out_root: Path) -> None:
_write(
out_root / "researcher" / "sessions" / "empty.json",
_wrap([], "Researcher"),
)
def _reject_symlink(path: Path) -> None:
"""Abort if path is a symlink, so rmtree cannot escape the tree."""
if path.is_symlink():
raise SystemExit(
f"error: refusing to operate on symlink {path}; the fixture "
"output path and its qwenpaw/ subtree must be real directories"
)
def main() -> int:
args = _parse_args()
if not _verify_source_shape(args.qwenpaw_src):
raise SystemExit(
f"error: {args.qwenpaw_src} does not look like a QwenPaw "
"source tree (missing src/qwenpaw/app/runner/session.py "
"with SessionManager.save_session_state and "
"_atomic_write_json)"
)
# Do NOT resolve() the deletion target: resolve() follows symlinks,
# so a `testdata/qwenpaw` symlink would make rmtree delete whatever
# it points at. Operate on the literal path and refuse symlinks for
# the output dir and the qwenpaw/ subtree before removing anything.
out_root = args.out / "qwenpaw"
_reject_symlink(args.out)
_reject_symlink(out_root)
if out_root.exists():
shutil.rmtree(out_root)
out_root.mkdir(parents=True)
_build_default_basic(out_root)
_build_default_tool_use(out_root)
_build_default_console(out_root)
_build_note_keeper_channel_scoped(out_root)
_build_researcher_empty(out_root)
print(f"Wrote QwenPaw fixtures under {out_root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION="${1:-}"
EXTRA_INSTRUCTIONS="${2:-}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [extra_instructions]"
echo "Example: $0 0.2.0"
echo "Example: $0 0.2.0 \"Focus on analytics improvements\""
exit 1
fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format X.Y.Z (e.g., 0.2.0)"
exit 1
fi
TAG="v$VERSION"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Error: Tag $TAG already exists"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo "Error: You have uncommitted changes. Please commit or stash them first."
exit 1
fi
# Generate changelog
CHANGELOG_FILE=$(mktemp)
trap 'rm -f "$CHANGELOG_FILE"' EXIT
"$SCRIPT_DIR/changelog.sh" "$VERSION" "-" "$EXTRA_INSTRUCTIONS" > "$CHANGELOG_FILE"
echo ""
echo "=========================================="
echo "PROPOSED CHANGELOG FOR $TAG"
echo "=========================================="
cat "$CHANGELOG_FILE"
echo ""
echo "=========================================="
echo ""
read -p "Accept this changelog and create release $TAG? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Release cancelled."
exit 0
fi
echo "Creating tag $TAG..."
git tag -a "$TAG" -m "Release $VERSION
$(cat $CHANGELOG_FILE)"
echo "Pushing tag to origin..."
git push origin "$TAG"
echo ""
echo "Release $TAG created and pushed successfully!"
echo "GitHub Actions will build and publish the release."
echo ""
echo "GitHub release URL: https://github.com/kenn-io/agentsview/releases/tag/$TAG"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -u
usage() {
echo "usage: retry.sh <max-attempts> <base-delay-seconds> <command> [args...]" >&2
exit 2
}
if [ "$#" -lt 3 ] || ! [[ "$1" =~ ^[1-9][0-9]*$ ]] || \
! [[ "$2" =~ ^[0-9]+$ ]]; then
usage
fi
max_attempts="$1"
base_delay="$2"
shift 2
attempt=1
while true; do
"$@"
status=$?
if [ "$status" -eq 0 ]; then
exit 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
exit "$status"
fi
delay=$((base_delay * attempt))
printf 'command failed with status %s; retrying in %s seconds\n' \
"$status" "$delay" >&2
sleep "$delay"
attempt=$((attempt + 1))
done
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
export ATTEMPT_FILE="$TMP_DIR/attempts"
export ARGUMENT_FILE="$TMP_DIR/arguments"
export SLEEP_FILE="$TMP_DIR/sleeps"
cat > "$TMP_DIR/eventually_succeeds" <<'EOF'
#!/bin/bash
attempt=0
if [ -f "$ATTEMPT_FILE" ]; then
attempt="$(cat "$ATTEMPT_FILE")"
fi
attempt=$((attempt + 1))
printf '%s\n' "$attempt" > "$ATTEMPT_FILE"
printf '%s\n' "$#" >> "$ARGUMENT_FILE"
printf '<%s>\n' "$@" >> "$ARGUMENT_FILE"
[ "$attempt" -ge 3 ]
EOF
cat > "$TMP_DIR/always_fails" <<'EOF'
#!/bin/bash
attempt=0
if [ -f "$ATTEMPT_FILE" ]; then
attempt="$(cat "$ATTEMPT_FILE")"
fi
printf '%s\n' "$((attempt + 1))" > "$ATTEMPT_FILE"
exit 17
EOF
cat > "$TMP_DIR/sleep" <<'EOF'
#!/bin/bash
printf '%s\n' "$@" >> "$SLEEP_FILE"
EOF
cat > "$TMP_DIR/invalid_command" <<'EOF'
#!/bin/bash
touch "$INVALID_COMMAND_FILE"
EOF
chmod +x "$TMP_DIR/eventually_succeeds" "$TMP_DIR/always_fails" \
"$TMP_DIR/invalid_command" "$TMP_DIR/sleep"
export INVALID_COMMAND_FILE="$TMP_DIR/invalid-command-ran"
EXPECTED_USAGE="usage: retry.sh <max-attempts> <base-delay-seconds> <command> [args...]"
assert_invalid() {
local description="$1"
shift
local output status
rm -f "$INVALID_COMMAND_FILE"
set +e
output="$(bash "$SCRIPT_DIR/retry.sh" "$@" 2>&1)"
status=$?
set -e
if [ "$status" -ne 2 ]; then
printf 'FAIL: %s returned %s instead of 2\n' "$description" "$status" >&2
return 1
fi
if [ "$output" != "$EXPECTED_USAGE" ]; then
printf 'FAIL: %s emitted unexpected error: %s\n' "$description" "$output" >&2
return 1
fi
if [ -e "$INVALID_COMMAND_FILE" ]; then
printf 'FAIL: %s ran the wrapped command\n' "$description" >&2
return 1
fi
}
assert_invalid "nonnumeric max attempts" nope 0 "$TMP_DIR/invalid_command"
assert_invalid "zero max attempts" 0 0 "$TMP_DIR/invalid_command"
assert_invalid "negative max attempts" -1 0 "$TMP_DIR/invalid_command"
assert_invalid "nonnumeric base delay" 3 nope "$TMP_DIR/invalid_command"
assert_invalid "negative base delay" 3 -1 "$TMP_DIR/invalid_command"
assert_invalid "missing command" 3 0
PATH="$TMP_DIR:$PATH" bash "$SCRIPT_DIR/retry.sh" \
3 10 "$TMP_DIR/eventually_succeeds" "argument with spaces"
[ "$(cat "$ATTEMPT_FILE")" = "3" ]
[ "$(cat "$ARGUMENT_FILE")" = $'1\n<argument with spaces>\n1\n<argument with spaces>\n1\n<argument with spaces>' ]
[ "$(cat "$SLEEP_FILE")" = $'10\n20' ]
rm -f "$ATTEMPT_FILE" "$SLEEP_FILE"
set +e
PATH="$TMP_DIR:$PATH" bash "$SCRIPT_DIR/retry.sh" \
3 0 "$TMP_DIR/always_fails"
status=$?
set -e
[ "$status" -eq 17 ]
[ "$(cat "$ATTEMPT_FILE")" = "3" ]
echo "retry tests passed"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Regenerate generated docs assets, verify the docs build, and deploy to Vercel.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
dry_run=false
usage() {
cat <<EOF
Usage: $(basename "$0") [--dry-run]
Regenerate and push docs-generated-assets, hydrate docs assets, build and
check the docs, then deploy the current committed workspace to production
Vercel.
Run this after docs source changes have already been committed.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
dry_run=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'unknown option: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'ERROR: %s is required for docs deployment\n' "$1" >&2
exit 1
fi
}
run() {
printf '+'
printf ' %q' "$@"
printf '\n'
if [[ "$dry_run" == false ]]; then
"$@"
fi
}
require_clean_tracked_tree() {
local status
status="$(git status --porcelain --untracked-files=all)"
if [[ -n "$status" ]]; then
printf 'ERROR: uncommitted, non-ignored changes are present. Commit or stash docs source changes before deploying.\n' >&2
printf '%s\n' "$status" >&2
exit 1
fi
}
require_cmd bash
require_cmd git
require_cmd make
require_cmd vercel
cd "$repo_root"
if [[ "$dry_run" == false ]]; then
require_clean_tracked_tree
fi
run make docs-install
run rm -rf docs/assets/generated
run bash docs/screenshots/update-generated-assets-branch.sh --push
run rm -rf docs/assets/static docs/assets/generated
run bash docs/assets/hydrate-assets.sh
run make docs-build
run make docs-check
run make docs-deploy