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
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:
@@ -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()
|
||||
@@ -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)
|
||||
Executable
+121
@@ -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"
|
||||
Executable
+90
@@ -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
|
||||
Executable
+91
@@ -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
@@ -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
@@ -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 ]
|
||||
Executable
+256
@@ -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 ]
|
||||
@@ -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
|
||||
}
|
||||
Executable
+27
@@ -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
|
||||
@@ -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...)
|
||||
}
|
||||
Executable
+100
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Executable
+213
@@ -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
|
||||
Executable
+75
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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())
|
||||
Executable
+68
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Executable
+82
@@ -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
|
||||
Reference in New Issue
Block a user