feat(hermes): brand chat and prove image rollout

This commit is contained in:
jenkins 2026-08-23 17:13:05 -03:00
parent ddc46aa845
commit b87f33530b
11 changed files with 813 additions and 9 deletions

View File

@ -92,6 +92,15 @@ replace_exact(
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>',
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes Chat</span>',
)
replace_between_exact(
index,
' <div class="empty-logo"><svg',
' <h2 data-i18n="empty_title">',
' <div class="empty-logo">\n'
' <img class="hermes-agent-portrait" '
'src="static/hermes-agent-192.png" alt="Hermes Agent">\n'
' </div>\n',
)
service_worker = ROOT / "static/sw.js"
replace_exact(

View File

@ -9,6 +9,8 @@
--gold: #9a661f;
--focus-ring: rgba(24, 127, 139, 0.38);
--focus-glow: rgba(24, 127, 139, 0.12);
--hermes-violet: #7065a8;
--hermes-grid: rgba(24, 127, 139, 0.035);
}
:root.dark {
@ -43,6 +45,9 @@
--main-bg: rgba(7, 10, 18, 0.72);
--focus-ring: rgba(72, 207, 204, 0.38);
--focus-glow: rgba(72, 207, 204, 0.12);
--hermes-violet: #9d8ee0;
--hermes-violet-muted: rgba(157, 142, 224, 0.12);
--hermes-grid: rgba(72, 207, 204, 0.025);
--error: #f08b79;
--success: #65c9a6;
--warning: #f0b66b;
@ -51,7 +56,8 @@
:root.dark body {
background:
radial-gradient(circle at 78% 8%, rgba(72, 164, 205, 0.07), transparent 31rem),
radial-gradient(circle at 78% 8%, rgba(72, 164, 205, 0.085), transparent 31rem),
radial-gradient(circle at 28% 100%, rgba(157, 142, 224, 0.045), transparent 34rem),
linear-gradient(145deg, #070a12, #080d17 55%, #071017);
}
@ -65,6 +71,36 @@
background-color: rgba(13, 20, 32, 0.94);
}
/* A quiet workstation canvas: enough technical texture to feel intentional,
but low-contrast enough to remain behind the work. */
:root.dark .main-view,
:root.dark .messages {
background-color: transparent;
}
:root.dark .messages {
background-image:
linear-gradient(var(--hermes-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--hermes-grid) 1px, transparent 1px),
radial-gradient(circle at 76% 22%, rgba(76, 164, 205, 0.055), transparent 30rem),
radial-gradient(circle at 22% 78%, var(--hermes-violet-muted), transparent 35rem);
background-size: 32px 32px, 32px 32px, auto, auto;
}
:root.dark .rail {
background-image: linear-gradient(180deg, rgba(72, 207, 204, 0.035), transparent 38%, rgba(157, 142, 224, 0.03));
}
:root.dark .sidebar {
box-shadow: inset -1px 0 rgba(72, 207, 204, 0.035);
}
:root.dark .session-item.active {
border-color: rgba(72, 207, 204, 0.17);
background: linear-gradient(90deg, rgba(72, 207, 204, 0.11), rgba(157, 142, 224, 0.045));
box-shadow: inset 2px 0 rgba(72, 207, 204, 0.52);
}
.app-titlebar-icon img {
display: block;
width: 22px;
@ -78,9 +114,87 @@
letter-spacing: 0.025em;
}
/* The established Hermes-agent character is the new-chat persona. */
.empty-logo {
isolation: isolate;
min-width: 126px;
min-height: 126px;
margin-bottom: 14px;
}
.empty-logo::before {
width: 224px;
height: 224px;
background:
radial-gradient(circle, rgba(72, 207, 204, 0.15), rgba(76, 164, 205, 0.07) 42%, rgba(112, 101, 168, 0.035) 58%, transparent 72%);
}
.empty-logo::after {
content: "";
position: absolute;
z-index: 1;
inset: -7px;
border: 1px solid rgba(24, 127, 139, 0.2);
border-radius: 29px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.12), transparent 38%, rgba(112, 101, 168, 0.08));
box-shadow: 0 16px 42px rgba(18, 55, 65, 0.12);
pointer-events: none;
}
.empty-logo .hermes-agent-portrait {
position: relative;
z-index: 2;
display: block;
width: 112px;
height: 112px;
object-fit: cover;
border: 1px solid rgba(24, 127, 139, 0.28);
border-radius: 22px;
box-shadow: 0 10px 30px rgba(18, 55, 65, 0.18);
}
:root.dark .empty-logo::after {
border-color: rgba(72, 207, 204, 0.28);
background: linear-gradient(145deg, rgba(72, 207, 204, 0.1), transparent 40%, rgba(157, 142, 224, 0.09));
box-shadow: 0 0 0 1px rgba(72, 207, 204, 0.035), 0 18px 48px rgba(0, 0, 0, 0.36), 0 0 34px rgba(76, 164, 205, 0.07);
}
:root.dark .empty-logo .hermes-agent-portrait {
border-color: rgba(72, 207, 204, 0.32);
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.4);
}
:root.dark .empty-state h2 {
color: var(--strong);
letter-spacing: -0.015em;
text-shadow: 0 0 24px rgba(72, 207, 204, 0.08);
}
:root.dark .empty-state p {
color: #9cafba;
}
:root.dark .suggestion {
border-color: rgba(174, 218, 224, 0.15);
background: linear-gradient(105deg, rgba(17, 27, 41, 0.88), rgba(13, 20, 32, 0.74));
box-shadow: inset 2px 0 rgba(72, 207, 204, 0.12), 0 8px 22px rgba(0, 0, 0, 0.08);
}
:root.dark .suggestion:hover,
:root.dark .suggestion:focus-visible {
color: var(--strong);
border-color: rgba(72, 207, 204, 0.4);
background: linear-gradient(105deg, rgba(72, 207, 204, 0.11), rgba(157, 142, 224, 0.065));
}
:root.dark .suggestion:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
:root.dark .composer-box:focus-within {
border-color: rgba(72, 207, 204, 0.66);
box-shadow: 0 0 0 2px var(--focus-glow), 0 10px 34px rgba(0, 0, 0, 0.2);
box-shadow: 0 0 0 2px var(--focus-glow), 0 10px 34px rgba(0, 0, 0, 0.28), 0 0 28px rgba(157, 142, 224, 0.04);
}
/* Keep the conversation instrument inside the same cyan/blue/gold family. */
@ -99,7 +213,31 @@
}
.app-titlebar-icon img,
:root.dark .composer-box:focus-within {
:root.dark .composer-box:focus-within,
.suggestion {
transition: none !important;
}
.suggestion:hover,
.suggestion:focus-visible {
transform: none !important;
}
}
@media (max-width: 640px) {
.empty-logo {
min-width: 104px;
min-height: 104px;
}
.empty-logo::before {
width: 176px;
height: 176px;
}
.empty-logo .hermes-agent-portrait {
width: 92px;
height: 92px;
border-radius: 19px;
}
}

View File

@ -376,10 +376,18 @@ data:
private voice services, passing a full commit already contained by `main`.
Jenkins builds the newest main containing that commit, publishes a final
immutable release tag only after its evidence passes, and Flux applies the
resulting digest. Candidate tags and failed builds never deploy. Follow the
returned queue item with `jenkins_build_evidence.py`, then verify the Flux
revision, pod image digest, rollout health, and public SSO redirect before
reporting the release complete.
resulting digest. Candidate tags and failed builds never deploy. Run the
safe `follow_command` returned by the trigger and do not report completion
until it says `converged`: that proves the exact reviewed source is the
selected immutable tag/digest, Flux is Ready, every consumer has the digest
in desired state, and all corresponding pods are Ready on that digest. If
main advanced before Jenkins checked it out, the status command reports
`different_revision_selected`; independently prove the requested commit is
an ancestor of that exact source, then follow the exact selected revision.
Use `jenkins_build_evidence.py` for a terminal failure cause when its
bounded controller evidence path is available. Candidate-only, queued, or
merged code is not a completed release. Finally verify the public SSO
redirect and the changed behavior before reporting the release complete.
The terminal PATH contains the pinned operator tools. Start cluster work
with `kubectl config current-context`, read-only status/events/logs, and the

View File

@ -143,6 +143,7 @@ configMapGenerator:
- install_agent_tools.sh=scripts/install_agent_tools.sh
- jenkins_build_evidence.py=scripts/jenkins_build_evidence.py
- jenkins_image_build_trigger.py=scripts/jenkins_image_build_trigger.py
- hermes_image_release_status.py=scripts/hermes_image_release_status.py
- kanban_status_recovery.py=scripts/kanban_status_recovery.py
- kanban_supervisor.py=scripts/kanban_supervisor.py
- supervisor_policy.py=scripts/supervisor_policy.py

View File

@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Report whether a validated Hermes image has converged through Flux."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
NAMESPACE = "hermes"
FLUX_NAMESPACE = "flux-system"
AUTOMATION = "hermes"
FLUX_KUSTOMIZATION = "hermes"
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
RELEASE_TAG_PATTERN = re.compile(
r"^git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)-release$"
)
COMPONENTS = {
"agent": {
"policy": "hermes-agent-release",
"repository": "registry.bstein.dev/bstein/hermes-agent",
"workloads": (("deployment", "hermes-agent", "hermes-agent"),),
},
"webui": {
"policy": "hermes-webui-release",
"repository": "registry.bstein.dev/bstein/hermes-webui",
"workloads": (
("deployment", "hermes", "hermes"),
("statefulset", "hermes-chat-tenant", "hermes-chat-tenant"),
),
},
"stt": {
"policy": "hermes-stt-release",
"repository": "registry.bstein.dev/bstein/hermes-jetson-stt",
"workloads": (("deployment", "hermes-stt", "hermes-stt"),),
},
"tts": {
"policy": "hermes-tts-release",
"repository": "registry.bstein.dev/bstein/hermes-jetson-tts",
"workloads": (("deployment", "hermes-tts", "hermes-tts"),),
},
}
@dataclass(frozen=True)
class WorkloadStatus:
"""Desired and observed state for one image-consuming workload."""
kind: str
name: str
desired_replicas: int
ready_replicas: int
desired_image_matches: bool
ready_pods: int
matching_pods: int
converged: bool
def _kubectl_json(*command: str) -> dict:
"""Read one Kubernetes object as JSON without shell or credential output."""
result = subprocess.run(
["kubectl", *command, "-o", "json"],
check=True,
capture_output=True,
text=True,
timeout=30,
)
value = json.loads(result.stdout)
if not isinstance(value, dict):
raise ValueError("kubectl returned a non-object JSON value")
return value
def _condition_ready(resource: dict) -> bool:
"""Return true only for an explicit current Ready condition."""
generation = resource.get("metadata", {}).get("generation")
for condition in resource.get("status", {}).get("conditions", []):
if condition.get("type") != "Ready" or condition.get("status") != "True":
continue
observed = condition.get("observedGeneration")
return generation is None or observed is None or int(observed) >= int(generation)
return False
def _release_ref(policy: dict) -> tuple[str | None, str | None, str | None, int | None]:
"""Extract the immutable release tag, digest, source revision, and build."""
ref = policy.get("status", {}).get("latestRef", {})
tag = ref.get("tag")
digest = ref.get("digest")
match = RELEASE_TAG_PATTERN.fullmatch(tag or "")
if not match or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest or ""):
return tag, digest, None, None
return tag, digest, match.group("revision"), int(match.group("build"))
def _desired_images(workload: dict) -> list[str]:
"""Return pod-template container and init-container image references."""
spec = workload.get("spec", {}).get("template", {}).get("spec", {})
containers = list(spec.get("initContainers", [])) + list(spec.get("containers", []))
return [item.get("image", "") for item in containers]
def _rollout_ready(kind: str, workload: dict) -> tuple[int, int, bool]:
"""Evaluate generation and replica convergence for a workload controller."""
metadata = workload.get("metadata", {})
spec = workload.get("spec", {})
status = workload.get("status", {})
desired = int(spec.get("replicas", 1))
ready = int(status.get("readyReplicas", 0))
generation_ready = int(status.get("observedGeneration", 0)) >= int(
metadata.get("generation", 1)
)
if kind == "deployment":
controller_ready = (
int(status.get("updatedReplicas", 0)) >= desired
and int(status.get("availableReplicas", 0)) >= desired
)
else:
controller_ready = (
int(status.get("currentReplicas", 0)) >= desired
and int(status.get("updatedReplicas", 0)) >= desired
and status.get("currentRevision") == status.get("updateRevision")
)
return desired, ready, generation_ready and controller_ready and ready >= desired
def _pod_image_status(label: str, repository: str, digest: str) -> tuple[int, int]:
"""Count ready pods whose relevant running containers use the exact digest."""
pod_list = _kubectl_json(
"-n", NAMESPACE, "get", "pods", "-l", f"app={label}"
)
matching = 0
ready = 0
for pod in pod_list.get("items", []):
statuses = list(pod.get("status", {}).get("initContainerStatuses", []))
statuses.extend(pod.get("status", {}).get("containerStatuses", []))
relevant = [item for item in statuses if item.get("image", "").startswith(repository)]
if not relevant:
continue
if all((item.get("imageID") or "").endswith(f"@{digest}") for item in relevant):
matching += 1
conditions = pod.get("status", {}).get("conditions", [])
if any(
item.get("type") == "Ready" and item.get("status") == "True"
for item in conditions
):
ready += 1
return matching, ready
def _workload_status(
kind: str, name: str, label: str, repository: str, digest: str
) -> WorkloadStatus:
"""Read desired image, controller rollout, and running-pod digest evidence."""
workload = _kubectl_json("-n", NAMESPACE, "get", kind, name)
desired, ready_replicas, controller_ready = _rollout_ready(kind, workload)
relevant_images = [
image for image in _desired_images(workload) if image.startswith(repository)
]
desired_matches = bool(relevant_images) and all(
image.endswith(f"@{digest}") for image in relevant_images
)
matching_pods, ready_pods = _pod_image_status(label, repository, digest)
converged = (
desired_matches
and controller_ready
and matching_pods >= desired
and ready_pods >= desired
)
return WorkloadStatus(
kind=kind,
name=name,
desired_replicas=desired,
ready_replicas=ready_replicas,
desired_image_matches=desired_matches,
ready_pods=ready_pods,
matching_pods=matching_pods,
converged=converged,
)
def inspect_release(component: str, revision: str) -> dict:
"""Return safe end-to-end release state for one exact reviewed revision."""
if component not in COMPONENTS:
raise ValueError("component must be agent, webui, stt, or tts")
if not REVISION_PATTERN.fullmatch(revision):
raise ValueError("revision must be a lowercase full 40-character commit")
config = COMPONENTS[component]
policy = _kubectl_json(
"-n", NAMESPACE, "get", "imagepolicy", config["policy"]
)
tag, digest, selected_revision, build = _release_ref(policy)
result = {
"component": component,
"requested_revision": revision,
"selected_revision": selected_revision,
"release_tag": tag,
"digest": digest,
"build": build,
"stage": "image_policy_pending",
"converged": False,
"workloads": [],
}
if selected_revision != revision or digest is None:
if selected_revision is not None:
result["stage"] = "different_revision_selected"
return result
automation = _kubectl_json(
"-n", NAMESPACE, "get", "imageupdateautomation", AUTOMATION
)
flux = _kubectl_json(
"-n", FLUX_NAMESPACE, "get", "kustomization", FLUX_KUSTOMIZATION
)
result["image_policy_ready"] = _condition_ready(policy)
result["image_automation_ready"] = _condition_ready(automation)
result["flux_ready"] = _condition_ready(flux)
result["flux_revision"] = flux.get("status", {}).get("lastAppliedRevision")
workloads = [
_workload_status(kind, name, label, config["repository"], digest)
for kind, name, label in config["workloads"]
]
result["workloads"] = [asdict(item) for item in workloads]
if not all((result["image_policy_ready"], result["image_automation_ready"])):
result["stage"] = "image_automation_pending"
elif not result["flux_ready"] or not all(item.desired_image_matches for item in workloads):
result["stage"] = "flux_apply_pending"
elif not all(item.converged for item in workloads):
result["stage"] = "rollout_pending"
else:
result["stage"] = "converged"
result["converged"] = True
return result
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse one bounded release-follow request."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--component", required=True, choices=sorted(COMPONENTS))
parser.add_argument("--revision", required=True)
parser.add_argument("--wait", action="store_true")
parser.add_argument("--timeout", type=int, default=1800)
parser.add_argument("--poll", type=int, default=15)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
"""Print safe JSON immediately or wait for exact live convergence."""
args = parse_args(argv)
if args.timeout < 1 or args.poll < 1:
print("timeout and poll must be positive", file=sys.stderr)
return 2
deadline = time.monotonic() + args.timeout
try:
while True:
status = inspect_release(args.component, args.revision)
if status["converged"]:
print(json.dumps(status, indent=2, sort_keys=True))
return 0
if not args.wait or time.monotonic() >= deadline:
status["timed_out"] = bool(args.wait)
print(json.dumps(status, indent=2, sort_keys=True))
return 2 if args.wait else 1
time.sleep(args.poll)
except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
print(f"Hermes release status failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -112,6 +112,10 @@ def trigger_build(
queue_path = parsed_queue.path
return {
"component": component,
"follow_command": (
"/opt/coordinator/hermes_image_release_status.py "
f"--component {component} --revision {revision} --wait"
),
"job": job["job"],
"queue_path": queue_path,
"source_revision": revision,

View File

@ -8,5 +8,7 @@ Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
The fixture stays intentionally narrow, but tests execute the shipped
`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic.
The fixture stays intentionally narrow, but tests execute the shipped patchers
against it; large inline artwork bodies are reduced while their exact unique
boundary tags are retained so fail-closed replacements are exercised without
copying generated SVG payloads into the fixture.

View File

@ -30,6 +30,11 @@
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
</div>
<div class="empty-state" id="emptyState">
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus"><defs><linearGradient id="hermes-mark"><stop class="hm-g0"/><stop class="hm-g1"/></linearGradient></defs><path fill="url(#hermes-mark)" d="M32 4v56"/></svg></div>
<h2 data-i18n="empty_title">What can I help with?</h2>
</div>
<div class="messages-inner" id="msgInner"></div>
<div class="composer-box" id="composerBox">
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>

View File

@ -17,6 +17,7 @@
"ci/scripts/hermes_image_release.py",
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",
@ -107,6 +108,7 @@
"ci/scripts/hermes_image_release.py",
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",
@ -341,6 +343,7 @@
"ci/scripts/hermes_image_release.py",
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",

View File

@ -0,0 +1,331 @@
"""Contracts for Hermes' read-only image release follow-through helper."""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
STATUS = ROOT / "services/hermes/scripts/hermes_image_release_status.py"
TRIGGER = ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py"
def _load(path: Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
def _condition(ready: bool = True, generation: int = 3) -> list[dict]:
return [
{
"type": "Ready",
"status": "True" if ready else "False",
"observedGeneration": generation,
}
]
def _policy(module, revision: str, digest: str, *, ready: bool = True) -> dict:
return {
"metadata": {"generation": 3},
"status": {
"conditions": _condition(ready),
"latestRef": {
"tag": f"git-{revision}-build-17-release",
"digest": digest,
},
},
}
def _controller(kind: str, repository: str, digest: str, *, ready: bool = True) -> dict:
status = {
"observedGeneration": 4,
"readyReplicas": 1 if ready else 0,
"updatedReplicas": 1,
"availableReplicas": 1,
"currentReplicas": 1,
"currentRevision": "revision-2",
"updateRevision": "revision-2",
}
if kind == "statefulset" and not ready:
status["currentRevision"] = "revision-1"
return {
"metadata": {"generation": 4},
"spec": {
"replicas": 1,
"template": {
"spec": {
"initContainers": [{"image": "busybox:1"}],
"containers": [{"image": f"{repository}@{digest}"}],
}
},
},
"status": status,
}
def _pods(repository: str, digest: str, *, ready: bool = True) -> dict:
return {
"items": [
{
"status": {
"conditions": [
{"type": "Ready", "status": "True" if ready else "False"}
],
"containerStatuses": [
{
"image": f"{repository}@{digest}",
"imageID": f"{repository}@{digest}",
}
],
}
}
]
}
def _healthy_objects(module, component: str, revision: str, digest: str) -> dict:
config = module.COMPONENTS[component]
objects = {
("hermes", "imagepolicy", config["policy"]): _policy(
module, revision, digest
),
("hermes", "imageupdateautomation", "hermes"): {
"metadata": {"generation": 3},
"status": {"conditions": _condition()},
},
("flux-system", "kustomization", "hermes"): {
"metadata": {"generation": 3},
"status": {
"conditions": _condition(),
"lastAppliedRevision": "main@sha1:" + "f" * 40,
},
},
}
for kind, name, label in config["workloads"]:
objects[("hermes", kind, name)] = _controller(
kind, config["repository"], digest
)
objects[("hermes", "pods", f"app={label}")] = _pods(
config["repository"], digest
)
return objects
def _install_fake(module, monkeypatch: pytest.MonkeyPatch, objects: dict) -> None:
def fake(*command):
namespace = command[1]
kind = command[3]
if kind == "pods":
key = (namespace, kind, command[5])
else:
key = (namespace, kind, command[4])
return objects[key]
monkeypatch.setattr(module, "_kubectl_json", fake)
@pytest.mark.parametrize("component", ["agent", "webui", "stt", "tts"])
def test_exact_release_convergence_covers_every_component(
monkeypatch: pytest.MonkeyPatch, component: str
) -> None:
"""Every lane binds policy source/digest to Ready desired and running state."""
module = _load(STATUS, f"release_status_{component}")
revision = "a" * 40
digest = "sha256:" + "b" * 64
_install_fake(module, monkeypatch, _healthy_objects(module, component, revision, digest))
result = module.inspect_release(component, revision)
assert result["converged"] is True
assert result["stage"] == "converged"
assert result["selected_revision"] == revision
assert result["digest"] == digest
assert result["build"] == 17
assert all(item["converged"] for item in result["workloads"])
def test_pending_and_different_policy_never_claim_deployment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No or different immutable source remains visibly incomplete."""
module = _load(STATUS, "release_status_policy_pending")
revision = "a" * 40
digest = "sha256:" + "b" * 64
config = module.COMPONENTS["tts"]
objects = _healthy_objects(module, "tts", revision, digest)
policy_key = ("hermes", "imagepolicy", config["policy"])
objects[policy_key]["status"]["latestRef"] = {}
_install_fake(module, monkeypatch, objects)
assert module.inspect_release("tts", revision)["stage"] == "image_policy_pending"
objects[policy_key] = _policy(module, "c" * 40, digest)
result = module.inspect_release("tts", revision)
assert result["stage"] == "different_revision_selected"
assert result["selected_revision"] == "c" * 40
assert result["converged"] is False
@pytest.mark.parametrize(
("mutation", "expected"),
[
("automation", "image_automation_pending"),
("flux", "flux_apply_pending"),
("desired", "flux_apply_pending"),
("pod", "rollout_pending"),
],
)
def test_followthrough_reports_the_first_incomplete_delivery_stage(
monkeypatch: pytest.MonkeyPatch, mutation: str, expected: str
) -> None:
"""The helper separates promotion, GitOps apply, and live rollout gaps."""
module = _load(STATUS, f"release_status_stage_{mutation}")
revision = "d" * 40
digest = "sha256:" + "e" * 64
objects = _healthy_objects(module, "stt", revision, digest)
if mutation == "automation":
objects[("hermes", "imageupdateautomation", "hermes")]["status"][
"conditions"
] = _condition(False)
elif mutation == "flux":
objects[("flux-system", "kustomization", "hermes")]["status"][
"conditions"
] = _condition(False)
elif mutation == "desired":
objects[("hermes", "deployment", "hermes-stt")]["spec"]["template"][
"spec"
]["containers"][0]["image"] = "registry.bstein.dev/bstein/hermes-jetson-stt@sha256:" + "1" * 64
else:
objects[("hermes", "pods", "app=hermes-stt")] = _pods(
module.COMPONENTS["stt"]["repository"], digest, ready=False
)
_install_fake(module, monkeypatch, objects)
result = module.inspect_release("stt", revision)
assert result["stage"] == expected
assert result["converged"] is False
def test_helpers_fail_closed_on_stale_generation_and_malformed_references() -> None:
"""Malformed release evidence and stale Ready conditions are not accepted."""
module = _load(STATUS, "release_status_helpers")
assert module._condition_ready(
{"metadata": {"generation": 4}, "status": {"conditions": _condition(True, 3)}}
) is False
assert module._condition_ready({}) is False
assert module._release_ref({"status": {"latestRef": {"tag": "latest"}}}) == (
"latest",
None,
None,
None,
)
assert module._rollout_ready("deployment", _controller("deployment", "r", "d"))[2]
stale = _controller("deployment", "r", "d")
stale["status"]["observedGeneration"] = 1
assert module._rollout_ready("deployment", stale)[2] is False
with pytest.raises(ValueError):
module.inspect_release("invalid", "a" * 40)
with pytest.raises(ValueError):
module.inspect_release("agent", "main")
def test_kubectl_reader_is_bounded_and_requires_an_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Status uses argument arrays, a timeout, and object-shaped JSON only."""
module = _load(STATUS, "release_status_kubectl")
seen = {}
def run(command, **kwargs):
seen["command"] = command
seen["kwargs"] = kwargs
return subprocess.CompletedProcess(command, 0, '{"ok": true}', "")
monkeypatch.setattr(module.subprocess, "run", run)
assert module._kubectl_json("get", "pod", "demo") == {"ok": True}
assert seen["command"] == ["kubectl", "get", "pod", "demo", "-o", "json"]
assert seen["kwargs"]["timeout"] == 30
monkeypatch.setattr(
module.subprocess,
"run",
lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "[]", ""),
)
with pytest.raises(ValueError, match="non-object"):
module._kubectl_json("get", "pods")
def test_cli_waits_for_convergence_and_returns_safe_json(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Wait mode polls bounded status and terminates only at convergence."""
module = _load(STATUS, "release_status_main")
revision = "7" * 40
states = [
{"stage": "image_policy_pending", "converged": False},
{"stage": "converged", "converged": True, "digest": "sha256:" + "8" * 64},
]
monkeypatch.setattr(module, "inspect_release", lambda *_args: states.pop(0))
monkeypatch.setattr(module.time, "sleep", lambda _seconds: None)
assert module.main(
["--component", "agent", "--revision", revision, "--wait", "--poll", "1"]
) == 0
assert json.loads(capsys.readouterr().out)["converged"] is True
assert module.main(
["--component", "agent", "--revision", revision, "--timeout", "0"]
) == 2
assert "must be positive" in capsys.readouterr().err
def test_trigger_returns_a_token_free_exact_follow_command(tmp_path: Path) -> None:
"""A successful trigger tells Hermes how to prove the approved release landed."""
module = _load(TRIGGER, "release_status_trigger")
revision = "9" * 40
token = tmp_path / "token"
token.write_text("never-print-me\n", encoding="utf-8")
class Response:
status = 201
headers = {"Location": "/queue/item/91/"}
def __enter__(self):
return self
def __exit__(self, *_args):
return None
result = module.trigger_build(
revision,
component="webui",
token_file=token,
opener=lambda *_args, **_kwargs: Response(),
)
assert result["follow_command"] == (
"/opt/coordinator/hermes_image_release_status.py --component webui "
f"--revision {revision} --wait"
)
assert "never-print-me" not in json.dumps(result)
def test_runtime_bundle_and_guidance_require_live_convergence() -> None:
"""The helper ships to Worker and merged code alone is explicitly insufficient."""
kustomization = (ROOT / "services/hermes/kustomization.yaml").read_text()
guidance = (ROOT / "services/hermes/agent-configmap.yaml").read_text()
assert "hermes_image_release_status.py=scripts/hermes_image_release_status.py" in kustomization
assert "safe `follow_command`" in guidance
assert "Merged code is not a completed release" in guidance.replace(
"merged code", "Merged code"
)

View File

@ -142,6 +142,11 @@ def test_production_patchers_apply_title_icons_theme_and_cache_contract(
assert '<meta name="theme-color" content="#0D1420"' in index
assert 'id="appTitlebarTitle">Hermes Chat</span>' in index
assert '<img src="static/hermes-agent-192.png" alt="">' in index
assert (
'<img class="hermes-agent-portrait" '
'src="static/hermes-agent-192.png" alt="Hermes Agent">'
) in index
assert 'aria-label="Hermes caduceus"' not in index
assert "favicon.svg" not in index
assert "favicon-32.png" not in index
@ -291,12 +296,35 @@ def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
assert "--accent: #48cfcc" in dark
assert "--voice-accent: 72, 207, 204" in css
assert "--voice-accent-secondary: 76, 164, 205" in css
assert "--hermes-violet: #9d8ee0" in dark
assert "--hermes-grid: rgba(72, 207, 204, 0.025)" in dark
assert ".empty-logo .hermes-agent-portrait" in css
assert "width: 112px" in css
assert ":root.dark .messages" in css
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
assert ":root.dark .session-item.active" in css
assert ":root.dark .suggestion:focus-visible" in css
assert "@media (prefers-reduced-motion: reduce)" in css
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
assert "transition: none !important" in reduced
assert "transform: none !important" in reduced
assert "animation:" not in css
def test_empty_state_uses_canonical_character_without_inline_staff(
tmp_path: Path,
) -> None:
"""The new-chat identity reuses the established full character artwork."""
index = (_patched_fixture(tmp_path) / "static/index.html").read_text(
encoding="utf-8"
)
empty_state = index.split('<div class="empty-state" id="emptyState">', 1)[1]
empty_state = empty_state.split('<div class="messages-inner"', 1)[0]
assert empty_state.count('class="hermes-agent-portrait"') == 1
assert empty_state.count('src="static/hermes-agent-192.png"') == 1
assert "<svg" not in empty_state.split('<h2 data-i18n="empty_title">', 1)[0]
def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None:
"""The immutable image, not a runtime coordinator path, owns PWA assets."""
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")