diff --git a/src/hermes_workflows/plugin_install.py b/src/hermes_workflows/plugin_install.py new file mode 100644 index 0000000..154944a --- /dev/null +++ b/src/hermes_workflows/plugin_install.py @@ -0,0 +1,851 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import shutil +import stat +import uuid +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, Union + +from .package_resources import installed_package_version + + +PLUGIN_NAME = "hermes-workflows-approvals" +PACKAGE_NAME = "hermes-workflows" +OWNER_ID = PACKAGE_NAME +PACKAGE_VERSION = installed_package_version() +RECEIPT_NAME = ".hermes-workflows-owner.json" +RECEIPT_SCHEMA_VERSION = 1 +RESCAN_ENDPOINT = "/api/dashboard/plugins/rescan" +PAYLOAD_FILES = ( + "__init__.py", + "dashboard/dist/index.js", + "dashboard/dist/style.css", + "dashboard/manifest.json", + "dashboard/plugin_api.py", + "plugin.yaml", +) +_RECEIPT_FIELDS = frozenset( + { + "schema_version", + "owner_id", + "package_name", + "package_version", + "plugin_name", + "plugin_version", + "ownership_key", + "files", + } +) +_FILE_FIELDS = frozenset({"path", "sha256", "size_bytes"}) +_IDENTIFIER = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,127}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_VERSION_LINE = re.compile(r'^version:\s*["\']?([^"\'\s#]+)["\']?\s*(?:#.*)?$') +_NAME_LINE = re.compile(r"^name:\s*([a-z0-9][a-z0-9_.-]*)\s*(?:#.*)?$") +PathLike = Union[str, Path] + + +class PluginInstallError(RuntimeError): + pass + + +class PayloadValidationError(PluginInstallError): + pass + + +class OwnershipError(PluginInstallError): + pass + + +class UserFileConflictError(OwnershipError): + pass + + +class RollbackUnavailableError(PluginInstallError): + pass + + +@dataclass(frozen=True) +class PayloadDescriptor: + package_version: str + plugin_version: str + files: Tuple[str, ...] + ownership_key: str + dashboard_manifest: Mapping[str, Any] + + +@dataclass(frozen=True) +class PluginDiscovery: + plugin_name: str + plugin_version: str + package_version: str + plugin_path: str + manifest_path: str + api_path: str + entry_path: str + css_path: str + api_route: str + asset_routes: Tuple[str, ...] + ownership_key: str + + def to_dict(self) -> Dict[str, object]: + return { + "plugin_name": self.plugin_name, + "plugin_version": self.plugin_version, + "package_version": self.package_version, + "plugin_path": self.plugin_path, + "manifest_path": self.manifest_path, + "api_path": self.api_path, + "entry_path": self.entry_path, + "css_path": self.css_path, + "api_route": self.api_route, + "asset_routes": list(self.asset_routes), + "ownership_key": self.ownership_key, + } + + +@dataclass(frozen=True) +class PluginLifecycleReport: + action: str + profile_home: str + plugin_path: str + package_version: str + plugin_version: str + ownership_key: str + enabled: bool + previous_version: Optional[str] + rollback_available: bool + restart_required: bool + rescan_supported: bool + rescan_endpoint: str + reload_note: str + files: Tuple[str, ...] + + def to_dict(self) -> Dict[str, object]: + return { + "action": self.action, + "profile_home": self.profile_home, + "plugin_path": self.plugin_path, + "package_version": self.package_version, + "plugin_version": self.plugin_version, + "ownership_key": self.ownership_key, + "enabled": self.enabled, + "previous_version": self.previous_version, + "rollback_available": self.rollback_available, + "restart_required": self.restart_required, + "rescan_supported": self.rescan_supported, + "rescan_endpoint": self.rescan_endpoint, + "reload_note": self.reload_note, + "files": list(self.files), + } + + +def canonical_payload_root() -> Path: + return Path(__file__).resolve().parent / "plugin_payload" / PLUGIN_NAME + + +def _validate_relative_path(value: object, *, error_type: type[PluginInstallError]) -> str: + if not isinstance(value, str) or not value: + raise error_type("owned file path must be a nonempty relative POSIX path") + if value.startswith("/") or "\\" in value or "\x00" in value: + raise error_type("owned file path must be a normalized relative POSIX path") + parts = value.split("/") + if any(part in {"", ".", ".."} for part in parts) or str(PurePosixPath(value)) != value: + raise error_type("owned file path must be a normalized relative POSIX path") + return value + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def _canonical_json(value: Mapping[str, Any]) -> str: + return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")) + + +def _ownership_key(value: Mapping[str, Any]) -> str: + unsigned = {key: item for key, item in value.items() if key != "ownership_key"} + return hashlib.sha256(_canonical_json(unsigned).encode("utf-8")).hexdigest() + + +def _regular_file(path: Path, *, error_type: type[PluginInstallError], label: str) -> None: + if path.is_symlink() or not path.is_file(): + raise error_type(f"{label} must be a regular non-symlink file") + + +def _tree_files(root: Path, *, error_type: type[PluginInstallError]) -> Tuple[str, ...]: + if root.is_symlink() or not root.is_dir(): + raise error_type("plugin root must be a regular directory, not a symlink") + discovered = [] + for directory, directory_names, file_names in os.walk(root, followlinks=False): + directory_path = Path(directory) + for name in directory_names: + child = directory_path / name + if child.is_symlink(): + raise error_type(f"plugin directory contains symlink: {child.relative_to(root).as_posix()}") + for name in file_names: + child = directory_path / name + if child.is_symlink() or not child.is_file(): + raise error_type(f"plugin payload contains non-regular file: {child.relative_to(root).as_posix()}") + discovered.append(child.relative_to(root).as_posix()) + return tuple(sorted(discovered)) + + +def _plugin_yaml_identity(path: Path) -> Tuple[str, str]: + _regular_file(path, error_type=PayloadValidationError, label="plugin.yaml") + name = None + version = None + for line in path.read_text(encoding="utf-8").splitlines(): + name_match = _NAME_LINE.fullmatch(line) + if name_match: + if name is not None: + raise PayloadValidationError("plugin.yaml must declare name exactly once") + name = name_match.group(1) + version_match = _VERSION_LINE.fullmatch(line) + if version_match: + if version is not None: + raise PayloadValidationError("plugin.yaml must declare version exactly once") + version = version_match.group(1) + if name != PLUGIN_NAME: + raise PayloadValidationError(f"plugin.yaml name must equal {PLUGIN_NAME}") + if not version: + raise PayloadValidationError("plugin.yaml must declare a nonblank version") + return name, version + + +def _dashboard_manifest(path: Path) -> Mapping[str, Any]: + _regular_file(path, error_type=PayloadValidationError, label="dashboard manifest") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PayloadValidationError("dashboard manifest must be valid UTF-8 JSON") from exc + if not isinstance(value, dict): + raise PayloadValidationError("dashboard manifest must be a JSON object") + expected = { + "name": PLUGIN_NAME, + "api": "plugin_api.py", + "entry": "dist/index.js", + "css": "dist/style.css", + } + for key, expected_value in expected.items(): + if value.get(key) != expected_value: + raise PayloadValidationError(f"dashboard manifest {key} must equal {expected_value}") + if not isinstance(value.get("version"), str) or not value["version"].strip(): + raise PayloadValidationError("dashboard manifest must declare a nonblank version") + return value + + +def inspect_payload( + payload_root: Optional[PathLike] = None, + *, + expected_package_version: Optional[str] = None, +) -> PayloadDescriptor: + root = canonical_payload_root() if payload_root is None else Path(payload_root) + if not root.is_absolute(): + root = root.resolve() + actual_files = _tree_files(root, error_type=PayloadValidationError) + if actual_files != PAYLOAD_FILES: + missing = sorted(set(PAYLOAD_FILES) - set(actual_files)) + unexpected = sorted(set(actual_files) - set(PAYLOAD_FILES)) + raise PayloadValidationError(f"payload file set mismatch; missing={missing}, unexpected={unexpected}") + + _, plugin_version = _plugin_yaml_identity(root / "plugin.yaml") + dashboard_manifest = _dashboard_manifest(root / "dashboard" / "manifest.json") + package_version = PACKAGE_VERSION if expected_package_version is None else expected_package_version + if not isinstance(package_version, str) or not package_version.strip(): + raise PayloadValidationError("expected package version must be nonblank") + if plugin_version != package_version or dashboard_manifest["version"] != package_version: + raise PayloadValidationError("package, plugin, and dashboard manifest versions must match") + + files = [] + for relative in PAYLOAD_FILES: + path = root / relative + _regular_file(path, error_type=PayloadValidationError, label=relative) + files.append({"path": relative, "sha256": _sha256_file(path), "size_bytes": path.stat().st_size}) + unsigned = { + "schema_version": RECEIPT_SCHEMA_VERSION, + "owner_id": OWNER_ID, + "package_name": PACKAGE_NAME, + "package_version": package_version, + "plugin_name": PLUGIN_NAME, + "plugin_version": plugin_version, + "files": files, + } + return PayloadDescriptor( + package_version=package_version, + plugin_version=plugin_version, + files=PAYLOAD_FILES, + ownership_key=_ownership_key(unsigned), + dashboard_manifest=dashboard_manifest, + ) + + +def _receipt_for_payload(root: Path, descriptor: PayloadDescriptor) -> Dict[str, Any]: + files = [ + { + "path": relative, + "sha256": _sha256_file(root / relative), + "size_bytes": (root / relative).stat().st_size, + } + for relative in descriptor.files + ] + receipt: Dict[str, Any] = { + "schema_version": RECEIPT_SCHEMA_VERSION, + "owner_id": OWNER_ID, + "package_name": PACKAGE_NAME, + "package_version": descriptor.package_version, + "plugin_name": PLUGIN_NAME, + "plugin_version": descriptor.plugin_version, + "files": files, + } + receipt["ownership_key"] = _ownership_key(receipt) + return receipt + + +def _load_receipt(root: Path) -> Mapping[str, Any]: + receipt_path = root / RECEIPT_NAME + if not receipt_path.exists(): + raise UserFileConflictError(f"refusing to manage user-owned plugin directory without {RECEIPT_NAME}: {root}") + _regular_file(receipt_path, error_type=OwnershipError, label="ownership receipt") + try: + value = json.loads(receipt_path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise OwnershipError("ownership receipt must be valid UTF-8 JSON") from exc + if not isinstance(value, dict) or set(value) != _RECEIPT_FIELDS: + raise OwnershipError("ownership receipt fields do not match schema version 1") + if value.get("schema_version") != RECEIPT_SCHEMA_VERSION: + raise OwnershipError("ownership receipt schema_version must equal 1") + for key, expected in (("owner_id", OWNER_ID), ("package_name", PACKAGE_NAME), ("plugin_name", PLUGIN_NAME)): + if value.get(key) != expected: + raise OwnershipError(f"ownership receipt {key} must equal {expected}") + package_version = value.get("package_version") + plugin_version = value.get("plugin_version") + if not isinstance(package_version, str) or not package_version or plugin_version != package_version: + raise OwnershipError("ownership receipt package and plugin versions must be equal and nonblank") + key = value.get("ownership_key") + if not isinstance(key, str) or _SHA256.fullmatch(key) is None: + raise OwnershipError("ownership receipt key must be a lowercase SHA-256") + if not hmac.compare_digest(key, _ownership_key(value)): + raise OwnershipError("ownership receipt key does not match its contents") + return value + + +def _validate_owned_tree(root: Path) -> Mapping[str, Any]: + if root.is_symlink() or not root.is_dir(): + raise UserFileConflictError(f"refusing to manage non-directory or symlink plugin path: {root}") + receipt = _load_receipt(root) + actual_files = _tree_files(root, error_type=OwnershipError) + expected_actual = tuple(sorted(PAYLOAD_FILES + (RECEIPT_NAME,))) + if actual_files != expected_actual: + extras = sorted(set(actual_files) - set(expected_actual)) + missing = sorted(set(expected_actual) - set(actual_files)) + if extras: + raise UserFileConflictError(f"refusing to overwrite or delete user-owned plugin files: {extras}") + raise OwnershipError(f"managed plugin is missing owned files: {missing}") + + raw_files = receipt.get("files") + if not isinstance(raw_files, list) or len(raw_files) != len(PAYLOAD_FILES): + raise OwnershipError("ownership receipt files must list the complete payload") + paths = [] + for item in raw_files: + if not isinstance(item, dict) or set(item) != _FILE_FIELDS: + raise OwnershipError("ownership receipt file fields do not match schema version 1") + relative = _validate_relative_path(item.get("path"), error_type=OwnershipError) + paths.append(relative) + sha256 = item.get("sha256") + size_bytes = item.get("size_bytes") + if not isinstance(sha256, str) or _SHA256.fullmatch(sha256) is None: + raise OwnershipError("ownership receipt file hash must be a lowercase SHA-256") + if type(size_bytes) is not int or size_bytes < 0: + raise OwnershipError("ownership receipt file size must be a nonnegative integer") + path = root / relative + _regular_file(path, error_type=OwnershipError, label=relative) + if path.stat().st_size != size_bytes or not hmac.compare_digest(_sha256_file(path), sha256): + raise OwnershipError(f"owned plugin file no longer matches receipt: {relative}") + if tuple(paths) != PAYLOAD_FILES: + raise OwnershipError("ownership receipt paths must equal the canonical sorted payload paths") + return receipt + + +def _validated_profile_home(profile_home: PathLike) -> Path: + raw_home = os.fspath(profile_home) + if not isinstance(raw_home, str) or not raw_home or "\x00" in raw_home: + raise UserFileConflictError("profile home must be a nonempty filesystem path") + expanded_home = os.path.expanduser(raw_home) + if raw_home.startswith("~") and expanded_home == raw_home: + raise UserFileConflictError("profile home user expansion could not be resolved") + if not os.path.isabs(expanded_home): + raise UserFileConflictError("profile home must be absolute after user expansion") + + component_text = os.path.splitdrive(expanded_home)[1] + if os.altsep: + component_text = component_text.replace(os.altsep, os.sep) + if any(component in {".", ".."} for component in component_text.split(os.sep)): + raise UserFileConflictError("profile home traversal components are not allowed") + + home = Path(expanded_home) + current = Path(home.anchor) + candidates = [current] + for component in home.parts[1:]: + current = current / component + candidates.append(current) + + for candidate in candidates: + try: + mode = candidate.lstat().st_mode + except FileNotFoundError: + break + if stat.S_ISLNK(mode): + raise UserFileConflictError(f"profile home path contains symlink component: {candidate}") + if not stat.S_ISDIR(mode): + raise UserFileConflictError(f"profile home path component must be a directory: {candidate}") + + return home.resolve() + + +def _validated_config_path(home: Path) -> Path: + config = home / "config.yaml" + if config.is_symlink() or (config.exists() and not config.is_file()): + raise UserFileConflictError("profile config.yaml must be a regular non-symlink file") + return config + + +def _profile_paths(profile_home: PathLike) -> Tuple[Path, Path, Path, Path]: + home = _validated_profile_home(profile_home) + home.mkdir(parents=True, exist_ok=True) + _validated_config_path(home) + plugins = home / "plugins" + if plugins.is_symlink() or (plugins.exists() and not plugins.is_dir()): + raise UserFileConflictError("profile plugin root must be a directory, not a file or symlink") + plugins.mkdir(exist_ok=True) + destination = plugins / PLUGIN_NAME + rollback = plugins / f".{PLUGIN_NAME}.rollback" + for managed in (destination, rollback): + if managed.is_symlink() or (managed.exists() and not managed.is_dir()): + raise UserFileConflictError(f"managed plugin path must be a directory, not a file or symlink: {managed}") + return home, plugins, destination, rollback + + +def _write_exclusive(path: Path, data: bytes, *, mode: int) -> None: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + except Exception: + try: + path.unlink() + except FileNotFoundError: + pass + raise + + +def _copy_payload_to_stage(payload_root: Path, stage: Path, descriptor: PayloadDescriptor) -> None: + stage.mkdir(mode=0o700) + try: + for relative in descriptor.files: + source = payload_root / relative + destination = stage / relative + destination.parent.mkdir(parents=True, exist_ok=True) + _write_exclusive(destination, source.read_bytes(), mode=0o644) + staged_descriptor = inspect_payload(stage, expected_package_version=descriptor.package_version) + if not hmac.compare_digest(staged_descriptor.ownership_key, descriptor.ownership_key): + raise PayloadValidationError("payload changed while it was being staged") + receipt = _receipt_for_payload(stage, staged_descriptor) + _write_exclusive((stage / RECEIPT_NAME), (_canonical_json(receipt) + "\n").encode("utf-8"), mode=0o600) + _validate_owned_tree(stage) + except Exception: + if stage.exists(): + shutil.rmtree(stage) + raise + + +def _remove_verified_tree(path: Path) -> None: + _validate_owned_tree(path) + shutil.rmtree(path) + + +def _recover_interrupted(plugins: Path, destination: Path, rollback: Path) -> None: + for stale in sorted(plugins.glob(f".{PLUGIN_NAME}.stage-*")): + _remove_verified_tree(stale) + + interrupted_swaps = sorted(plugins.glob(f".{PLUGIN_NAME}.swap-*")) + if len(interrupted_swaps) > 1: + raise OwnershipError("multiple interrupted rollback trees require operator review") + if interrupted_swaps: + swap = interrupted_swaps[0] + _validate_owned_tree(swap) + if destination.exists() and rollback.exists(): + raise OwnershipError("interrupted rollback conflicts with current and retained plugin trees") + if destination.exists(): + _validate_owned_tree(destination) + os.replace(destination, rollback) + os.replace(swap, destination) + + interrupted_current = sorted(plugins.glob(f".{PLUGIN_NAME}.remove-current-*")) + interrupted_rollback = sorted(plugins.glob(f".{PLUGIN_NAME}.remove-rollback-*")) + if len(interrupted_current) > 1 or len(interrupted_rollback) > 1: + raise OwnershipError("multiple interrupted uninstall trees require operator review") + if interrupted_current: + _validate_owned_tree(interrupted_current[0]) + if destination.exists(): + raise OwnershipError("interrupted uninstall conflicts with an installed plugin tree") + os.replace(interrupted_current[0], destination) + if interrupted_rollback: + _validate_owned_tree(interrupted_rollback[0]) + if rollback.exists(): + raise OwnershipError("interrupted uninstall conflicts with an existing rollback tree") + os.replace(interrupted_rollback[0], rollback) + if not destination.exists() and rollback.exists(): + _validate_owned_tree(rollback) + os.replace(rollback, destination) + + +def _list_section(lines: Sequence[str], start: int, end: int, key: str) -> Tuple[Optional[Tuple[int, int]], list[str]]: + header = re.compile(rf"^ {re.escape(key)}:\s*(?:#.*)?$") + unsupported = re.compile(rf"^ {re.escape(key)}:\s*\S") + found = None + values: list[str] = [] + for index in range(start + 1, end): + line = lines[index].rstrip("\n") + if unsupported.match(line) and not header.fullmatch(line): + raise UserFileConflictError(f"config plugins.{key} must use a block list") + if not header.fullmatch(line): + continue + if found is not None: + raise UserFileConflictError(f"config contains duplicate plugins.{key} sections") + section_end = index + 1 + while section_end < end: + candidate = lines[section_end].rstrip("\n") + if candidate.strip() and not candidate.lstrip().startswith("#") and len(candidate) - len(candidate.lstrip(" ")) <= 2: + break + section_end += 1 + for item_index in range(index + 1, section_end): + candidate = lines[item_index].rstrip("\n") + if not candidate.strip() or candidate.lstrip().startswith("#"): + continue + match = re.fullmatch(r"\s{4}-\s+([a-z0-9][a-z0-9_.-]{0,127})\s*(?:#.*)?", candidate) + if match is None: + raise UserFileConflictError(f"config plugins.{key} contains an unsupported list item") + values.append(match.group(1)) + found = (index, section_end) + return found, values + + +def _unique(values: Sequence[str]) -> list[str]: + seen = set() + result = [] + for value in values: + if value not in seen: + seen.add(value) + result.append(value) + return result + + +def _update_enablement_text(text: str, *, enabled: bool) -> str: + if "\t" in text: + raise UserFileConflictError("config.yaml tabs are unsupported for safe plugin enablement editing") + lines = text.splitlines(keepends=True) + inline_plugins = [ + line + for line in lines + if line.startswith("plugins:") and re.fullmatch(r"plugins:\s*(?:#.*)?\n?", line) is None + ] + if inline_plugins: + raise UserFileConflictError("config plugins must use a block mapping") + starts = [index for index, line in enumerate(lines) if re.fullmatch(r"plugins:\s*(?:#.*)?\n?", line)] + if len(starts) > 1: + raise UserFileConflictError("config contains duplicate top-level plugins sections") + if not starts: + if not enabled: + return text + prefix = text + if prefix and not prefix.endswith("\n"): + prefix += "\n" + if prefix and not prefix.endswith("\n\n"): + prefix += "\n" + return prefix + f"plugins:\n enabled:\n - {PLUGIN_NAME}\n" + + start = starts[0] + end = len(lines) + for index in range(start + 1, len(lines)): + candidate = lines[index] + if candidate.strip() and not candidate.lstrip().startswith("#") and not candidate.startswith(" "): + end = index + break + enabled_range, enabled_values = _list_section(lines, start, end, "enabled") + disabled_range, disabled_values = _list_section(lines, start, end, "disabled") + ranges = [item for item in (enabled_range, disabled_range) if item is not None] + covered = set() + for first, last in ranges: + covered.update(range(first, last)) + remaining = [lines[index] for index in range(start + 1, end) if index not in covered] + enabled_values = [value for value in _unique(enabled_values) if value != PLUGIN_NAME] + disabled_values = [value for value in _unique(disabled_values) if value != PLUGIN_NAME] + if enabled: + enabled_values.append(PLUGIN_NAME) + + block = [lines[start] if lines[start].endswith("\n") else lines[start] + "\n"] + if enabled_values: + block.append(" enabled:\n") + block.extend(f" - {value}\n" for value in enabled_values) + if disabled_values: + block.append(" disabled:\n") + block.extend(f" - {value}\n" for value in disabled_values) + block.extend(remaining) + meaningful_body = [line for line in block[1:] if line.strip() and not line.lstrip().startswith("#")] + if not meaningful_body: + block = [] + return "".join(lines[:start] + block + lines[end:]) + + +def _config_update(home: Path, *, enabled: bool) -> Tuple[Path, bytes]: + config = _validated_config_path(home) + text = config.read_text(encoding="utf-8") if config.exists() else "" + updated = _update_enablement_text(text, enabled=enabled).encode("utf-8") + temporary = home / f".config.yaml.hermes-workflows-{uuid.uuid4().hex}.tmp" + _write_exclusive(temporary, updated, mode=0o600) + return temporary, updated + + +def _lifecycle_report( + *, + action: str, + home: Path, + destination: Path, + receipt: Mapping[str, Any], + enabled: bool, + previous_version: Optional[str], + rollback_available: bool, +) -> PluginLifecycleReport: + return PluginLifecycleReport( + action=action, + profile_home=str(home), + plugin_path=str(destination), + package_version=str(receipt["package_version"]), + plugin_version=str(receipt["plugin_version"]), + ownership_key=str(receipt["ownership_key"]), + enabled=enabled, + previous_version=previous_version, + rollback_available=rollback_available, + restart_required=True, + rescan_supported=True, + rescan_endpoint=RESCAN_ENDPOINT, + reload_note="Rescan refreshes the manifest and static assets; restart the dashboard process to mount plugin_api.py.", + files=tuple(item["path"] for item in receipt["files"]), + ) + + +def _install_or_upgrade( + profile_home: PathLike, + *, + payload_root: Optional[PathLike], + expected_package_version: Optional[str], + require_existing: bool, +) -> PluginLifecycleReport: + validated_home = _validated_profile_home(profile_home) + source = canonical_payload_root() if payload_root is None else Path(payload_root).resolve() + descriptor = inspect_payload(source, expected_package_version=expected_package_version) + home, plugins, destination, rollback = _profile_paths(validated_home) + _recover_interrupted(plugins, destination, rollback) + if require_existing and not destination.exists(): + raise UserFileConflictError("upgrade requires an existing owned plugin installation") + + previous_receipt = _validate_owned_tree(destination) if destination.exists() else None + if rollback.exists(): + _validate_owned_tree(rollback) + config_temporary, _ = _config_update(home, enabled=True) + stage = plugins / f".{PLUGIN_NAME}.stage-{uuid.uuid4().hex}" + moved_previous = False + try: + _copy_payload_to_stage(source, stage, descriptor) + if destination.exists(): + if rollback.exists(): + _remove_verified_tree(rollback) + os.replace(destination, rollback) + moved_previous = True + os.replace(stage, destination) + try: + os.replace(config_temporary, home / "config.yaml") + except Exception: + _remove_verified_tree(destination) + if moved_previous and rollback.exists(): + os.replace(rollback, destination) + raise + except Exception as exc: + if not destination.exists() and moved_previous and rollback.exists(): + os.replace(rollback, destination) + if stage.exists(): + _remove_verified_tree(stage) + if config_temporary.exists(): + config_temporary.unlink() + if isinstance(exc, PluginInstallError): + raise + raise PluginInstallError(str(exc)) from exc + + receipt = _validate_owned_tree(destination) + action = "upgrade" if previous_receipt is not None else "install" + return _lifecycle_report( + action=action, + home=home, + destination=destination, + receipt=receipt, + enabled=True, + previous_version=str(previous_receipt["plugin_version"]) if previous_receipt else None, + rollback_available=rollback.exists(), + ) + + +def install_plugin( + profile_home: PathLike, + *, + payload_root: Optional[PathLike] = None, + expected_package_version: Optional[str] = None, +) -> PluginLifecycleReport: + return _install_or_upgrade( + profile_home, + payload_root=payload_root, + expected_package_version=expected_package_version, + require_existing=False, + ) + + +def upgrade_plugin( + profile_home: PathLike, + *, + payload_root: Optional[PathLike] = None, + expected_package_version: Optional[str] = None, +) -> PluginLifecycleReport: + return _install_or_upgrade( + profile_home, + payload_root=payload_root, + expected_package_version=expected_package_version, + require_existing=True, + ) + + +def rollback_plugin(profile_home: PathLike) -> PluginLifecycleReport: + home, plugins, destination, rollback = _profile_paths(profile_home) + _recover_interrupted(plugins, destination, rollback) + if not destination.exists() or not rollback.exists(): + raise RollbackUnavailableError("one owned rollback is required") + current = _validate_owned_tree(destination) + previous = _validate_owned_tree(rollback) + config_temporary, _ = _config_update(home, enabled=True) + swap = plugins / f".{PLUGIN_NAME}.swap-{uuid.uuid4().hex}" + swapped = False + try: + os.replace(destination, swap) + os.replace(rollback, destination) + os.replace(swap, rollback) + swapped = True + os.replace(config_temporary, home / "config.yaml") + except Exception as exc: + if swapped: + os.replace(destination, swap) + os.replace(rollback, destination) + os.replace(swap, rollback) + elif swap.exists(): + if destination.exists() and not rollback.exists(): + os.replace(destination, rollback) + if not destination.exists(): + os.replace(swap, destination) + if config_temporary.exists(): + config_temporary.unlink() + if isinstance(exc, PluginInstallError): + raise + raise PluginInstallError(str(exc)) from exc + + installed = _validate_owned_tree(destination) + _validate_owned_tree(rollback) + return _lifecycle_report( + action="rollback", + home=home, + destination=destination, + receipt=installed, + enabled=True, + previous_version=str(current["plugin_version"]), + rollback_available=True, + ) + + +def uninstall_plugin(profile_home: PathLike) -> PluginLifecycleReport: + home, plugins, destination, rollback = _profile_paths(profile_home) + _recover_interrupted(plugins, destination, rollback) + current = _validate_owned_tree(destination) if destination.exists() else None + retained = _validate_owned_tree(rollback) if rollback.exists() else None + if current is None and retained is None: + raise UserFileConflictError("uninstall requires an existing owned plugin installation") + config_temporary, _ = _config_update(home, enabled=False) + removed = [] + try: + for kind, source in (("current", destination), ("rollback", rollback)): + if not source.exists(): + continue + temporary = plugins / f".{PLUGIN_NAME}.remove-{kind}-{uuid.uuid4().hex}" + os.replace(source, temporary) + removed.append((source, temporary)) + os.replace(config_temporary, home / "config.yaml") + except Exception as exc: + for source, temporary in reversed(removed): + if temporary.exists() and not source.exists(): + os.replace(temporary, source) + if config_temporary.exists(): + config_temporary.unlink() + if isinstance(exc, PluginInstallError): + raise + raise PluginInstallError(str(exc)) from exc + for _, temporary in removed: + _remove_verified_tree(temporary) + + receipt = current if current is not None else retained + assert receipt is not None + return _lifecycle_report( + action="uninstall", + home=home, + destination=destination, + receipt=receipt, + enabled=False, + previous_version=str(receipt["plugin_version"]), + rollback_available=False, + ) + + +def discover_installed_plugin(profile_home: PathLike) -> PluginDiscovery: + home = _validated_profile_home(profile_home) + destination = home / "plugins" / PLUGIN_NAME + receipt = _validate_owned_tree(destination) + manifest = _dashboard_manifest(destination / "dashboard" / "manifest.json") + dashboard = destination / "dashboard" + entry = str(manifest["entry"]) + css = str(manifest["css"]) + api = str(manifest["api"]) + for relative in (entry, css, api): + _validate_relative_path(relative, error_type=OwnershipError) + _regular_file(dashboard / relative, error_type=OwnershipError, label=relative) + return PluginDiscovery( + plugin_name=PLUGIN_NAME, + plugin_version=str(receipt["plugin_version"]), + package_version=str(receipt["package_version"]), + plugin_path=str(destination), + manifest_path=str(dashboard / "manifest.json"), + api_path=str(dashboard / api), + entry_path=str(dashboard / entry), + css_path=str(dashboard / css), + api_route=f"/api/plugins/{PLUGIN_NAME}", + asset_routes=(f"/dashboard-plugins/{PLUGIN_NAME}/{entry}", f"/dashboard-plugins/{PLUGIN_NAME}/{css}"), + ownership_key=str(receipt["ownership_key"]), + ) diff --git a/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/__init__.py b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/__init__.py new file mode 100644 index 0000000..6b8f9eb --- /dev/null +++ b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/__init__.py @@ -0,0 +1,5 @@ +"""Directory-plugin shim for Hermes Agent development checkouts.""" + +from hermes_workflows.hermes_plugin_approvals import register + +__all__ = ["register"] diff --git a/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/dist/index.js b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/dist/index.js new file mode 100644 index 0000000..a44ad1d --- /dev/null +++ b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/dist/index.js @@ -0,0 +1,1556 @@ +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK || !window.__HERMES_PLUGINS__) { + console.warn("Hermes Workflows dashboard plugin: Hermes plugin SDK not found"); + return; + } + + const React = SDK.React; + const hooks = SDK.hooks || React; + const components = SDK.components || {}; + const Card = components.Card || "section"; + const CardHeader = components.CardHeader || "div"; + const CardTitle = components.CardTitle || "h2"; + const CardContent = components.CardContent || "div"; + const Badge = components.Badge || "span"; + const Button = components.Button || "button"; + const Input = components.Input || "input"; + const API = "/api/plugins/hermes-workflows-approvals"; + const artifactRenderers = {}; + + function registerArtifactRenderer(name, renderer) { + if (typeof name === "string" && name.trim() && typeof renderer === "function") artifactRenderers[name.trim()] = renderer; + } + + window.__HERMES_WORKFLOWS_ARTIFACT_RENDERERS__ = window.__HERMES_WORKFLOWS_ARTIFACT_RENDERERS__ || {}; + window.__HERMES_WORKFLOWS_ARTIFACT_RENDERERS__.register = registerArtifactRenderer; + window.__HERMES_WORKFLOWS_ARTIFACT_RENDERERS__.renderers = artifactRenderers; + + function e(type, props) { + const children = Array.prototype.slice.call(arguments, 2); + return React.createElement.apply(React, [type, props].concat(children)); + } + + function statusClass(status) { + if (["completed", "approve", "decision_recorded"].includes(status)) return "hwf-ok"; + if (["waiting", "running", "pending"].includes(status)) return "hwf-warn"; + if (["failed", "reject", "cancelled", "invalid_decision"].includes(status)) return "hwf-bad"; + return "hwf-muted"; + } + + function runtimeStateLabel(runtime) { + runtime = runtime || {}; + if (runtime.label) return runtime.label; + const primary = runtime.primary || "unknown"; + const command = runtime.command || {}; + const worker = runtime.worker || {}; + const workerId = worker.worker_id || command.claimed_by; + if (primary === "waiting_on_human") return "Waiting on Skylar"; + if (primary === "queued") return "Queued — no worker has claimed this yet"; + if (primary === "running" && workerId) return "Running — claimed by " + workerId; + if (primary === "running") return "Running"; + if (primary === "stuck") return "Stuck — " + String(runtime.reason || command.last_error || "unknown reason").replace(/_/g, " "); + return String(primary).replace(/_/g, " ").replace(/^./, function (ch) { return ch.toUpperCase(); }); + } + + function runtimeStateClass(runtime) { + const primary = runtime && runtime.primary; + if (["completed"].includes(primary)) return "hwf-ok"; + if (["failed", "cancelled", "stuck"].includes(primary)) return "hwf-bad"; + if (["queued", "running", "waiting", "waiting_on_human"].includes(primary)) return "hwf-warn"; + return "hwf-muted"; + } + + function RuntimeStatePill(props) { + const runtime = props.runtime; + if (!runtime) return null; + return e(Pill, { label: runtimeStateLabel(runtime), className: runtimeStateClass(runtime) }); + } + + function RuntimeFacts(props) { + const runtime = props.runtime; + if (!runtime) return null; + const command = runtime.command || {}; + const worker = runtime.worker || {}; + const source = runtime.source || {}; + const env = worker.environment || {}; + return e("div", { className: "hwf-runtime-facts" }, + e("div", { className: "hwf-section-title" }, "Runtime facts"), + e("div", { className: "hwf-meta" }, + e(RuntimeStatePill, { runtime: runtime }), + source.alias && e(Pill, { label: "Source: " + source.alias }), + command.status && e(Pill, { label: "command: " + command.status }), + command.type && e(Pill, { label: command.type }), + worker.worker_id && e(Pill, { label: "worker: " + worker.worker_id }), + env.workspace_relation && e(Pill, { label: "workspace: " + env.workspace_relation.replace(/_/g, " ") })), + runtime.next_action && e("p", { className: "hwf-muted" }, runtime.next_action)); + } + + function riskClass(level) { + if (level === "high") return "hwf-risk-high"; + if (level === "medium") return "hwf-risk-medium"; + return "hwf-risk-low"; + } + + function pretty(value) { + if (value === null || value === undefined || value === "") return "—"; + if (typeof value === "string") return value; + try { return JSON.stringify(value, null, 2); } catch (_err) { return String(value); } + } + + function qs(params) { + const search = new URLSearchParams(); + Object.keys(params || {}).forEach(function (key) { + if (params[key] !== undefined && params[key] !== null && params[key] !== "") search.set(key, params[key]); + }); + const text = search.toString(); + return text ? "?" + text : ""; + } + + function useJSON(path, refreshKey) { + const useState = hooks.useState; + const useEffect = hooks.useEffect; + const state = useState({ loading: true, error: null, data: null }); + const value = state[0]; + const setValue = state[1]; + useEffect(function () { + let cancelled = false; + setValue({ loading: true, error: null, data: null }); + SDK.fetchJSON(path) + .then(function (data) { if (!cancelled) setValue({ loading: false, error: null, data: data }); }) + .catch(function (err) { if (!cancelled) setValue({ loading: false, error: err.message || String(err), data: null }); }); + return function () { cancelled = true; }; + }, [path, refreshKey]); + return value; + } + + function StatCard(props) { + return e(Card, { className: "hwf-stat" }, + e(CardContent, { className: "hwf-stat-content" }, + e("div", { className: "hwf-stat-label" }, props.label), + e("div", { className: "hwf-stat-value" }, String(props.value || 0)), + props.help && e("div", { className: "hwf-stat-help" }, props.help))); + } + + function Pill(props) { + return e(Badge, { className: "hwf-pill " + (props.className || "") }, props.children || props.label); + } + + function ArtifactRenderSummary(props) { + const render = props.render || {}; + if (!render.kind) return null; + return e("div", { className: "hwf-artifact-render" }, + e(Pill, { label: "artifact: " + render.kind }), + e(Pill, { label: "render: " + render.render }), + render.warning && e("span", { className: "hwf-muted" }, render.warning)); + } + + function ArtifactInlinePreview(props) { + const render = props.render || {}; + const value = artifactInlineValue(props.value); + if (render.render === "python-source") { + return e(WorkflowSourcePreview, { render: render, value: value }); + } + if (isGeneratedPythonWorkflowArtifact(value)) { + return e(GeneratedPythonWorkflowPreview, { value: value }); + } + if (render.render === "inline-markdown") { + const markdown = value && typeof value === "object" ? (value.markdown || value.content) : value; + if (typeof markdown === "string" && markdown.trim()) return e(MarkdownArtifactPreview, { markdown: markdown }); + } + if (render.render === "inline-html") { + const html = value && typeof value === "object" ? (value.html || value.content) : value; + if (typeof html === "string" && html.trim()) return e(HtmlArtifactPreview, { html: html }); + } + if (render.render === "inline-diff") { + const diff = value && typeof value === "object" ? (value.diff || value.patch || value.content) : value; + if (typeof diff === "string" && diff.trim()) return e(DiffPreview, { diff: diff }); + } + if (render.render === "inline-text" && typeof value === "string") { + return e("pre", { className: "hwf-text-preview" }, value); + } + if (render.render === "media-reference") return e(MediaArtifactPreview, { render: render }); + if (render.render === "file-reference") return e(FileReferencePreview, { render: render }); + if (render.render === "external-link" || render.render === "external-reference") return e(ExternalReferencePreview, { render: render }); + if (render.render === "custom-render") return e(CustomArtifactPreview, { render: render, value: value }); + return null; + } + + function ArtifactReviewRawDump(props) { + const render = props.render || {}; + const value = props.value; + if (render.render === "inline-markdown") { + return e("details", { className: "hwf-raw-json" }, + e("summary", null, "Raw JSON"), + e("pre", null, pretty(value))); + } + return e("details", { className: "hwf-raw-json" }, + e("summary", null, "Raw JSON"), + e("pre", null, pretty(value))); + } + + function HumanResponsePreview(props) { + const output = props.output || props.decision; + if (output === undefined || output === null) return null; + const hasFeedback = output && typeof output === "object" && output.feedback; + return e("div", { className: "hwf-response-preview" }, + e("div", { className: "hwf-section-title" }, "Recorded response"), + output && typeof output === "object" && output.action && e(Pill, { label: "action: " + output.action }), + hasFeedback && e("blockquote", null, output.feedback), + !hasFeedback && e("pre", null, pretty(output))); + } + + function MarkdownArtifactPreview(props) { + const lines = String(props.markdown || "").split(/\r?\n/); + const nodes = []; + let inCode = false; + let code = []; + function flushCode(i) { + if (code.length) nodes.push(e("pre", { key: "code-" + i, className: "hwf-markdown-code" }, code.join("\n"))); + code = []; + } + lines.forEach(function (line, i) { + if (line.trim().startsWith("```")) { + if (inCode) flushCode(i); + inCode = !inCode; + return; + } + if (inCode) { code.push(line); return; } + if (/^###\s+/.test(line)) nodes.push(e("h4", { key: i }, line.replace(/^###\s+/, ""))); + else if (/^##\s+/.test(line)) nodes.push(e("h3", { key: i }, line.replace(/^##\s+/, ""))); + else if (/^#\s+/.test(line)) nodes.push(e("h2", { key: i }, line.replace(/^#\s+/, ""))); + else if (/^[-*]\s+/.test(line)) nodes.push(e("div", { key: i, className: "hwf-markdown-list-item" }, "• ", line.replace(/^[-*]\s+/, ""))); + else if (line.trim()) nodes.push(e("p", { key: i }, line)); + else nodes.push(e("br", { key: i })); + }); + flushCode("end"); + return e("div", { className: "hwf-markdown-preview" }, nodes); + } + + function HtmlArtifactPreview(props) { + return e("div", { className: "hwf-html-preview" }, + e("div", { className: "hwf-muted" }, "Sandboxed HTML preview"), + e("iframe", { sandbox: "", srcDoc: String(props.html || ""), title: "HTML artifact preview" })); + } + + function DiffPreview(props) { + return e("pre", { className: "hwf-diff-preview" }, + String(props.diff || "").split(/\r?\n/).map(function (line, i) { + let cls = "hwf-diff-line"; + if (line.startsWith("@@")) cls += " hwf-diff-hunk"; + else if (line.startsWith("+") && !line.startsWith("+++")) cls += " hwf-diff-added"; + else if (line.startsWith("-") && !line.startsWith("---")) cls += " hwf-diff-removed"; + return e("code", { key: i, className: cls }, line + "\n"); + })); + } + + function referenceHref(render) { + return render && render.reference && typeof render.reference.href === "string" ? render.reference.href : ""; + } + + function isSafeHttpUrl(href) { + try { const parsed = new URL(href); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch (_err) { return false; } + } + + function MediaArtifactPreview(props) { + const render = props.render || {}; + const href = referenceHref(render); + if (!isSafeHttpUrl(href)) return e(FileReferencePreview, { render: render }); + const kind = render.kind; + if (kind === "image") return e("div", { className: "hwf-media-preview" }, e("img", { className: "hwf-media-image", src: href, alt: "Artifact image preview" })); + if (kind === "audio") return e("div", { className: "hwf-media-preview" }, e("audio", { className: "hwf-media-audio", controls: true, src: href })); + if (kind === "video") return e("div", { className: "hwf-media-preview" }, e("video", { className: "hwf-media-video", controls: true, src: href })); + return e(ExternalReferencePreview, { render: render }); + } + + function FileReferencePreview(props) { + const render = props.render || {}; + const href = referenceHref(render); + return e("div", { className: "hwf-file-reference" }, + e("strong", null, "File artifact"), + render.media_type && e(Pill, { label: render.media_type }), + href && e("code", null, href), + e("div", { className: "hwf-muted" }, "Local/private files are not served by the dashboard.")); + } + + function ExternalReferencePreview(props) { + const render = props.render || {}; + const href = referenceHref(render); + if (!isSafeHttpUrl(href)) return null; + return e("div", { className: "hwf-external-reference" }, e("a", { href: href, target: "_blank", rel: "noreferrer" }, href)); + } + + function CustomArtifactPreview(props) { + const render = props.render || {}; + const ref = render.reference || {}; + const rendererId = ref.renderer; + const custom = rendererId && artifactRenderers[rendererId]; + if (custom) { + try { return custom({ render: render, value: props.value, React: React, e: e }); } + catch (err) { return e(CustomArtifactFallback, { render: render, value: props.value, error: err.message || String(err) }); } + } + return e(CustomArtifactFallback, { render: render, value: props.value }); + } + + function CustomArtifactFallback(props) { + const render = props.render || {}; + const ref = render.reference || {}; + return e("div", { className: "hwf-custom-render-fallback" }, + e("strong", null, "Custom artifact renderer"), + ref.renderer && e(Pill, { label: ref.renderer }), + props.error && e("div", { className: "hwf-bad" }, props.error), + e("pre", null, pretty(props.value))); + } + + function artifactInlineValue(value) { + if (value && typeof value === "object" && value.__hermes_type__ === "Artifact" && Object.prototype.hasOwnProperty.call(value, "value")) { + return value.value; + } + return value; + } + + function isGeneratedPythonWorkflowArtifact(value) { + return Boolean( + value && + typeof value === "object" && + value.kind === "generated_workflow.approval.v1" && + typeof value.source === "string" && + value.source.trim() + ); + } + + function GeneratedPythonWorkflowPreview(props) { + const value = props.value || {}; + return e("div", { className: "hwf-generated-source" }, + e("div", { className: "hwf-section-title" }, "Generated Python workflow"), + e("div", { className: "hwf-meta" }, + value.symbol && e(Pill, { label: "symbol: " + value.symbol }), + value.source_sha256 && e(Pill, { label: "sha256: " + shortId(value.source_sha256) })), + e("pre", { className: "hwf-code-block hwf-generated-code-block" }, + e(PythonCode, { className: "language-python", code: value.source }))); + } + + function shortId(value) { + const text = String(value || ""); + if (text.length <= 28) return text || "—"; + return text.slice(0, 12) + "…" + text.slice(-10); + } + + function sourceStepId(value) { + return value && (value.step_id || value.source_step_id || value.key || (value.source && value.source.key)); + } + + function StepIdBadge(props) { + const stepId = sourceStepId(props.value); + if (!stepId) return null; + return e("div", { className: "hwf-step-id-callout" }, + e("span", null, "Step"), + e("code", { title: String(stepId) }, String(stepId))); + } + + function primitiveValue(value) { + return value === null || value === undefined || ["string", "number", "boolean"].includes(typeof value); + } + + function ArtifactSummary(props) { + const artifact = props.artifact || {}; + const preview = artifact.preview; + const rows = []; + const noisy = { approval_queue: true, entity_proposals: true, archive_candidates: true, zero_side_effect_ledger: true, ledger: true, diagnostics: true, metadata: true }; + if (artifact.artifact_render && artifact.artifact_render.render === "python-source") noisy.source = true; + if (preview && typeof preview === "object" && !Array.isArray(preview)) { + ["workflow_name", "symbol", "source_sha256", "source_hash_verified", "source_account", "account", "from", "sender", "subject", "snippet", "body_preview", "draft_to", "to", "draft_subject", "gmail_draft_id", "send_requires_approval", "path", "uri", "href", "url"].forEach(function (key) { + if (preview[key] !== undefined && preview[key] !== null && rows.length < 10) rows.push([key, preview[key]]); + }); + Object.keys(preview).forEach(function (key) { + if (rows.length >= 10 || noisy[key] || rows.some(function (row) { return row[0] === key; })) return; + if (primitiveValue(preview[key])) rows.push([key, preview[key]]); + }); + if (!rows.length) { + Object.keys(preview).slice(0, 6).forEach(function (key) { + const value = preview[key]; + rows.push([key, Array.isArray(value) ? value.length + " items" : value && typeof value === "object" ? "object" : value]); + }); + } + } else { + rows.push(["value", preview === undefined ? "—" : preview]); + } + return e("div", { className: "hwf-artifact-summary" }, + e("dl", null, rows.map(function (row) { + return e("div", { key: row[0], className: "hwf-summary-row" }, e("dt", null, row[0].replace(/_/g, " ")), e("dd", null, pretty(row[1]))); + })), + preview && typeof preview === "object" && preview.approval_queue && e("p", { className: "hwf-muted" }, "approval_queue hidden in summary; open Raw JSON for debug.")); + } + + function ArtifactCard(props) { + const artifact = props.artifact; + const raw = artifact.preview !== undefined ? artifact.preview : artifact; + return e(Card, { key: artifact.id, className: "hwf-artifact-card" }, + e(CardHeader, null, + e("div", { className: "hwf-artifact-title" }, + e(CardTitle, null, artifact.title || artifact.kind || "Artifact"), + e("div", { className: "hwf-meta" }, + e(Pill, { label: "Workflow → Run → Step → Artifact" }), + e(Pill, { label: artifact.kind || "artifact" }), + artifact.workflow_id && e("code", { className: "hwf-run-id", title: artifact.workflow_id }, shortId(artifact.workflow_id)), + artifact.source && artifact.source.key && e(Pill, { label: "step: " + artifact.source.key })))), + e(CardContent, null, + e(StepIdBadge, { value: artifact }), + e(ArtifactRenderSummary, { render: artifact.artifact_render }), + e(ArtifactInlinePreview, { render: artifact.artifact_render, value: raw }), + e(ArtifactSummary, { artifact: artifact }), + e("details", { className: "hwf-raw-json" }, + e("summary", null, "Raw JSON"), + e("pre", null, pretty(raw))))); + } + + function Tabs(props) { + return e("div", { className: "hwf-tabs", role: "tablist" }, props.tabs.map(function (tab) { + return e("button", { + key: tab, + type: "button", + className: "hwf-tab " + (props.active === tab ? "is-active" : ""), + onClick: function () { props.setActive(tab); } + }, tab); + })); + } + + function ApprovalActions(props) { + const approval = props.approval; + const workflowId = approval && approval.workflow_id || props.workflowId; + const db = props.db; + const onDecided = props.onDecided; + const useState = hooks.useState; + const state = useState({ busy: false, error: null, done: null, note: "", reason: "" }); + const ui = state[0]; + const setUi = state[1]; + if (!approval || approval.decision || !approval.allowed || approval.allowed.length === 0) { + return e("span", { className: "hwf-muted" }, approval && approval.decision ? "decided" : "no actions"); + } + function submit(action) { + setUi(Object.assign({}, ui, { busy: true, error: null, done: null })); + SDK.fetchJSON(API + "/approvals/decision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + db: db, + workflow_id: workflowId, + key: approval.key, + action: action, + note: ui.note || undefined, + reason: action === "reject" ? (ui.reason || "Rejected from dashboard") : undefined, + resume: true + }) + }).then(function (data) { + setUi(Object.assign({}, ui, { busy: false, error: null, done: data.receipt && data.receipt.status || "recorded" })); + if (onDecided) onDecided(); + }).catch(function (err) { + setUi(Object.assign({}, ui, { busy: false, error: err.message || String(err), done: null })); + }); + } + return e("div", { className: "hwf-approval-actions" }, + e(Input, { + value: ui.note, + placeholder: "Optional note", + onInput: function (event) { setUi(Object.assign({}, ui, { note: event.target.value })); } + }), + approval.allowed.includes("approve") && e(Button, { disabled: ui.busy, onClick: function () { submit("approve"); } }, "Approve"), + approval.allowed.includes("reject") && e(Button, { disabled: ui.busy, variant: "outline", onClick: function () { submit("reject"); } }, "Reject"), + ui.done && e("span", { className: "hwf-ok" }, ui.done), + ui.error && e("span", { className: "hwf-bad" }, ui.error)); + } + + function ApprovalCard(props) { + const approval = props.approval; + const risk = approval.risk || { level: "low" }; + return e(Card, { className: "hwf-approval-card" }, + e(CardHeader, null, + e("div", null, + e(CardTitle, null, approval.headline || approval.prompt || approval.key), + e("div", { className: "hwf-meta" }, + e(Pill, { label: approval.status || "waiting", className: statusClass(approval.status) }), + e(RuntimeStatePill, { runtime: approval.runtime_state }), + e(Pill, { label: "risk: " + (risk.level || "low"), className: riskClass(risk.level) }), + approval.workflow_name && e(Pill, { label: approval.workflow_name }))), + e("div", { className: "hwf-row-actions" }, + e(Button, { variant: "outline", onClick: function () { props.onView(approval); } }, "View approval"))), + e(CardContent, null, + e(StepIdBadge, { value: approval }), + e("p", { className: "hwf-consequence" }, approval.consequence || "Records decision and creates inspectable workflow continuation"), + e("div", { className: "hwf-two-col" }, + e("div", null, + e("div", { className: "hwf-section-title" }, "What you are approving"), + e("p", null, approval.prompt || approval.key), + e("p", { className: "hwf-muted" }, "Workflow: " + (approval.workflow_id || "—"))), + e("div", null, + e("div", { className: "hwf-section-title" }, "Artifact preview"), + e(ArtifactRenderSummary, { render: approval.artifact_render }), + e(ArtifactInlinePreview, { render: approval.artifact_render, value: approval.artifact_preview || approval.artifact }), + e(ArtifactReviewRawDump, { render: approval.artifact_render, value: approval.artifact_preview || approval.artifact }))), + e(HumanResponsePreview, { output: approval.output, decision: approval.decision }), + e(ApprovalActions, { db: props.db, approval: approval, onDecided: props.onRefresh }))); + } + + function HumanInputActions(props) { + const step = props.step; + const useState = hooks.useState; + const state = useState({ busy: false, error: null, done: null, payloadText: "{}", feedback: "", selectedOptionId: "", editedOutput: "", editLoaded: false, formValues: {} }); + const ui = state[0]; + const setUi = state[1]; + if (!step || step.status !== "waiting") return e("span", { className: "hwf-muted" }, step && step.status === "completed" ? "answered" : "no actions"); + function isReviewDecisionStep(step) { + const surface = step && step.input_surface || {}; + return surface.kind === "review_decision"; + } + function normalizeAction(action) { + if (action && typeof action === "object") return action; + const value = String(action || ""); + return { value: value, label: formatActionLabel(value) }; + } + function formatActionLabel(action) { + const label = action && typeof action === "object" ? action.label : null; + if (label) return label; + const value = action && typeof action === "object" ? action.value : String(action || ""); + return value.replace(/_/g, " ").replace(/^./, function (ch) { return ch.toUpperCase(); }); + } + function schemaFields(surface) { + const schema = surface && surface.schema || step.request_schema || {}; + return Array.isArray(schema.fields) ? schema.fields.filter(function (field) { return field && field.name; }) : []; + } + function fieldTypeLabel(field) { + if (!field) return "value"; + if (field.kind === "choice") return "choice: " + (field.options || []).join(" / "); + if (field.kind === "list") { + const itemKind = field.items && field.items.kind ? field.items.kind : "value"; + return "list of " + itemKind; + } + if (field.kind === "boolean") return "true / false"; + return field.kind || "value"; + } + function structuredValue(name) { + return Object.prototype.hasOwnProperty.call(ui.formValues || {}, name) ? ui.formValues[name] : ""; + } + function setStructuredValue(name, value) { + const formValues = Object.assign({}, ui.formValues || {}); + formValues[name] = value; + setUi(Object.assign({}, ui, { formValues: formValues, error: null })); + } + function parseListValue(value, field) { + const text = String(value || "").trim(); + if (!text) return []; + let rawItems; + if (text[0] === "[") { + const parsed = JSON.parse(text); + if (!Array.isArray(parsed)) throw new Error(field.name + " must be a JSON array or newline-separated list"); + rawItems = parsed; + } else { + rawItems = text.split(/\n|,/).map(function (item) { return item.trim(); }).filter(Boolean); + } + const itemKind = field.items && field.items.kind || "text"; + return rawItems.map(function (item, index) { + if (itemKind === "number") { + const number = Number(item); + if (!Number.isFinite(number)) throw new Error(field.name + " item " + (index + 1) + " must be a number"); + return number; + } + if (itemKind === "boolean") { + if (item === true || item === false) return item; + const lowered = String(item).toLowerCase(); + if (lowered === "true") return true; + if (lowered === "false") return false; + throw new Error(field.name + " item " + (index + 1) + " must be true or false"); + } + return String(item); + }); + } + function valueForField(field) { + const value = structuredValue(field.name); + if (field.kind === "boolean") return Boolean(value); + if (field.kind === "number") { + if (String(value).trim() === "") return undefined; + const number = Number(value); + if (!Number.isFinite(number)) throw new Error(field.name + " must be a number"); + return number; + } + if (field.kind === "list") return parseListValue(value, field); + if (field.kind === "choice") { + if (String(value).trim() === "") return undefined; + const options = field.options || []; + const matched = options.find(function (option) { return String(option) === String(value); }); + return matched === undefined ? value : matched; + } + if (field.kind === "object") { + const text = String(value || "").trim(); + if (!text) return undefined; + try { return JSON.parse(text); } catch (_err) { throw new Error(field.name + " must be valid JSON"); } + } + return value; + } + function buildStructuredPayload(surface) { + const fields = schemaFields(surface); + const payload = {}; + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index]; + const hasValue = Object.prototype.hasOwnProperty.call(ui.formValues || {}, field.name); + const raw = structuredValue(field.name); + const empty = field.kind === "boolean" ? (!field.required && !hasValue) : String(raw || "").trim() === ""; + if (field.required && empty) throw new Error(field.name + " is required"); + if (!field.required && empty) continue; + const value = valueForField(field); + if (field.required && field.kind === "list" && Array.isArray(value) && value.length === 0) throw new Error(field.name + " needs at least one item"); + if (field.kind === "choice" && field.options && field.options.length && !field.options.includes(value)) throw new Error(field.name + " must be one of: " + field.options.join(", ")); + payload[field.name] = value; + } + return payload; + } + function submitPayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { setUi(Object.assign({}, ui, { error: "Payload must be a JSON object" })); return; } + const requestId = "dashboard:" + ((globalThis.crypto && globalThis.crypto.randomUUID && globalThis.crypto.randomUUID()) || (Date.now() + "-" + Math.random().toString(16).slice(2))); + setUi(Object.assign({}, ui, { busy: true, error: null, done: null })); + SDK.fetchJSON(API + "/review-requests/response", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ db: props.db, workflow_id: step.workflow_id, key: step.key, payload: payload, resume: true, idempotency_key: requestId }) + }).then(function (data) { + setUi(Object.assign({}, ui, { busy: false, error: null, done: data.receipt && data.receipt.status || "recorded" })); + if (props.onResponded) props.onResponded(); + }).catch(function (err) { + setUi(Object.assign({}, ui, { busy: false, error: err.message || String(err), done: null })); + }); + } + function submit() { + let payload; + try { payload = JSON.parse(ui.payloadText || "{}"); } catch (err) { setUi(Object.assign({}, ui, { error: "Invalid JSON payload" })); return; } + submitPayload(payload); + } + function submitStructuredForm(surface) { + try { submitPayload(buildStructuredPayload(surface)); } catch (err) { setUi(Object.assign({}, ui, { error: err.message || String(err) })); } + } + function selectionPayload(surface, option) { + if (!option) return null; + if (surface.submit === "value" || !surface.field) { + const value = Object.prototype.hasOwnProperty.call(option, "value") ? option.value : option.id; + if (value && typeof value === "object" && !Array.isArray(value)) return value; + return { value: value }; + } + const payload = {}; + payload[surface.field] = Object.prototype.hasOwnProperty.call(option, "value") ? option.value : option.id; + return payload; + } + function submitSelection(surface) { + const options = surface.options || []; + const option = options.find(function (item) { return String(item.id) === String(ui.selectedOptionId); }); + if (!option) { setUi(Object.assign({}, ui, { error: "Choose an option first" })); return; } + submitPayload(selectionPayload(surface, option)); + } + function chooseSelection(option) { + setUi(Object.assign({}, ui, { selectedOptionId: String(option.id), error: null })); + } + function editableOutputSeed() { + const artifact = step.artifact || {}; + if (typeof artifact.markdown === "string") return artifact.markdown; + if (typeof artifact.text === "string") return artifact.text; + if (typeof artifact.value === "string") return artifact.value; + if (typeof step.prompt === "string") return step.prompt; + return ""; + } + function loadEditableOutput() { + setUi(Object.assign({}, ui, { editedOutput: editableOutputSeed(), editLoaded: true, error: null })); + } + function submitReviewDecision(action, actions) { + const feedbackText = String(ui.feedback || "").trim(); + const editedOutputText = String(ui.editedOutput || "").trim(); + let selectedAction = action; + if ((feedbackText || editedOutputText) && String(action || "").toLowerCase().replace(/-/g, "_") === "approve") { + const feedbackAction = (actions || []).find(function (item) { return item.requires_feedback && item.value !== "approve"; }); + if (feedbackAction) selectedAction = feedbackAction.value; + } + if (String(selectedAction || "").toLowerCase().replace(/-/g, "_") === "request_changes" && !feedbackText && !editedOutputText) { + setUi(Object.assign({}, ui, { error: "request_changes requires nonblank feedback or valid edited_output" })); + return; + } + const payload = { action: selectedAction, feedback: feedbackText || undefined }; + const editable = (step.input_surface || {}).editable_output || {}; + const editField = editable.field || "edited_output"; + if (editedOutputText) payload[editField] = editedOutputText; + submitPayload(payload); + } + if (isReviewDecisionStep(step)) { + const surface = step.input_surface || {}; + const actions = (surface.actions && surface.actions.length ? surface.actions : [{ value: "approve", label: "Approve" }, { value: "request_changes", label: "Request changes", requires_feedback: true }]).map(normalizeAction); + const feedback = surface.feedback || {}; + const editable = surface.editable_output; + return e("div", { className: "hwf-approval-actions hwf-review-actions" }, + e(Input, { value: ui.feedback, placeholder: feedback.placeholder || "What should change?", onInput: function (event) { setUi(Object.assign({}, ui, { feedback: event.target.value })); } }), + editable && e("div", { className: "hwf-edit-output-box" }, + e("div", { className: "hwf-review-action-row" }, + e(Button, { variant: "outline", disabled: ui.busy, onClick: loadEditableOutput }, ui.editLoaded ? "Reset editable output" : "Edit output / branch retry"), + e("span", { className: "hwf-muted" }, "Optional. If filled, the workflow receives this as ", e("code", null, editable.field || "edited_output"), ".")), + ui.editLoaded && e("textarea", { + className: "hwf-edit-output-textarea", + value: ui.editedOutput, + rows: 12, + placeholder: editable.placeholder || "Paste or edit the output to branch from this version.", + spellCheck: true, + onInput: function (event) { setUi(Object.assign({}, ui, { editedOutput: event.target.value })); } + })), + e("div", { className: "hwf-review-action-row" }, + actions.map(function (action) { + return e(Button, { + key: action.value, + disabled: ui.busy, + variant: action.value === "approve" ? undefined : "outline", + onClick: function () { submitReviewDecision(action.value, actions); } + }, formatActionLabel(action)); + })), + ui.done && e("span", { className: "hwf-ok" }, ui.done), + ui.error && e("span", { className: "hwf-bad" }, ui.error)); + } + const surface = step.input_surface || {}; + if (surface.kind === "document_upload") { + return e("div", { className: "hwf-approval-actions" }, + e("p", { className: "hwf-muted" }, "Upload support is not wired yet. Attach the document through a workflow extension and submit the returned artifact reference."), + ui.error && e("span", { className: "hwf-bad" }, ui.error)); + } + if (surface.kind === "selection") { + const options = surface.options || []; + return e("div", { className: "hwf-approval-actions hwf-selection-actions" }, + e("div", { className: "hwf-section-title" }, "Choose one"), + e("div", { className: "hwf-selection-options" }, options.map(function (option) { + const selected = String(ui.selectedOptionId) === String(option.id); + return e("label", { key: option.id, className: "hwf-selection-option " + (selected ? "is-selected" : ""), onClick: function () { chooseSelection(option); } }, + e("input", { + type: "radio", + name: "selection-" + step.key, + value: option.id, + checked: selected, + onInput: function () { chooseSelection(option); } + }), + e("span", { className: "hwf-selection-option-body" }, + e("strong", null, option.label || option.id), + option.details && e("span", { className: "hwf-muted" }, option.details))); + })), + e(Button, { disabled: ui.busy || !ui.selectedOptionId, onClick: function () { submitSelection(surface); } }, "Submit selection"), + ui.done && e("span", { className: "hwf-ok" }, ui.done), + ui.error && e("span", { className: "hwf-bad" }, ui.error)); + } + if (surface.kind === "structured_form") { + const fields = schemaFields(surface); + return e("div", { className: "hwf-approval-actions hwf-structured-form" }, + e("div", { className: "hwf-section-title" }, "Structured response"), + step.request_schema && e("p", { className: "hwf-muted" }, "Expected response: " + (step.request_schema.name || step.schema) + " — fill the fields below."), + fields.map(function (field) { + const value = structuredValue(field.name); + const help = field.help || field.description; + const defaultText = Object.prototype.hasOwnProperty.call(field, "default") && field.default !== null && field.default !== undefined ? "Default: " + pretty(field.default) : null; + const common = { value: value, placeholder: field.kind === "list" ? "One item per line" : (field.kind === "object" ? "JSON object" : defaultText || ""), onInput: function (event) { setStructuredValue(field.name, event.target.type === "checkbox" ? event.target.checked : event.target.value); } }; + return e("label", { key: field.name, className: "hwf-structured-field" }, + e("span", { className: "hwf-structured-field-label" }, + e("strong", null, field.name), + e("span", { className: "hwf-structured-field-type" }, field.type || fieldTypeLabel(field)), + e("span", { className: field.required ? "hwf-required" : "hwf-muted" }, field.required ? "Required" : "Optional")), + help && e("span", { className: "hwf-muted" }, help), + defaultText && e("span", { className: "hwf-muted" }, defaultText), + field.kind === "choice" ? e("select", Object.assign({}, common), + e("option", { value: "" }, "Choose…"), + (field.options || []).map(function (option) { return e("option", { key: option, value: option }, option); })) : + field.kind === "boolean" ? e("input", { type: "checkbox", checked: Boolean(value), onInput: common.onInput }) : + field.kind === "list" || field.kind === "object" ? e("textarea", Object.assign({ rows: field.kind === "list" ? 4 : 6 }, common)) : + e("input", Object.assign({ type: field.kind === "number" ? "number" : "text" }, common))); + }), + e(Button, { disabled: ui.busy || fields.length === 0, onClick: function () { submitStructuredForm(surface); } }, "Submit structured input"), + e("details", { className: "hwf-raw-json" }, + e("summary", null, "Raw JSON fallback"), + e("textarea", { value: ui.payloadText, rows: 6, placeholder: "Advanced: JSON object matching the requested schema", onInput: function (event) { setUi(Object.assign({}, ui, { payloadText: event.target.value, error: null })); } }), + e("div", { className: "hwf-review-action-row" }, + e(Button, { disabled: ui.busy, variant: "outline", onClick: submit }, "Submit raw JSON"), + e("span", { className: "hwf-muted" }, "For complex schemas or manual corrections.")), + e("pre", null, pretty(surface.schema || step.request_schema || {}))), + ui.done && e("span", { className: "hwf-ok" }, ui.done), + ui.error && e("span", { className: "hwf-bad" }, ui.error)); + } + return e("div", { className: "hwf-approval-actions" }, + step.schema && e("p", { className: "hwf-muted" }, "Expected response: " + (step.request_schema && step.request_schema.name || step.schema)), + e(Input, { value: ui.payloadText, placeholder: surface.kind === "textarea" ? "Enter feedback" : "Enter structured response", onInput: function (event) { setUi(Object.assign({}, ui, { payloadText: event.target.value })); } }), + e(Button, { disabled: ui.busy, onClick: submit }, "Submit input"), + ui.done && e("span", { className: "hwf-ok" }, ui.done), + ui.error && e("span", { className: "hwf-bad" }, ui.error)); + } + + function HumanInputCard(props) { + const step = props.step; + const risk = step.risk || { level: "low" }; + return e(Card, { className: "hwf-approval-card" }, + e(CardHeader, null, + e("div", null, + e(CardTitle, null, step.headline || step.prompt || step.key), + e("div", { className: "hwf-meta" }, + e(Pill, { label: step.status || "waiting", className: statusClass(step.status) }), + e(RuntimeStatePill, { runtime: step.runtime_state }), + e(Pill, { label: "Human input" }), + e(Pill, { label: "risk: " + (risk.level || "low"), className: riskClass(risk.level) }), + step.workflow_name && e(Pill, { label: step.workflow_name }), + step.schema && e(Pill, { label: "Schema: " + step.schema })))), + e(CardContent, null, + e(StepIdBadge, { value: step }), + e("p", { className: "hwf-consequence" }, step.consequence || "Records typed human input with provenance and creates inspectable continuation."), + e("div", { className: "hwf-two-col" }, + e("div", null, + e("div", { className: "hwf-section-title" }, "What this request needs"), + e("p", null, step.prompt || step.key), + e("p", { className: "hwf-muted" }, "Workflow: " + (step.workflow_id || "—"))), + e("div", null, + e("div", { className: "hwf-section-title" }, "Artifact preview"), + e(ArtifactRenderSummary, { render: step.artifact_render }), + e(ArtifactInlinePreview, { render: step.artifact_render, value: step.artifact_preview || step.artifact }), + e(ArtifactReviewRawDump, { render: step.artifact_render, value: step.artifact_preview || step.artifact }))), + e(HumanResponsePreview, { output: step.output }), + e(HumanInputActions, { db: props.db, step: step, onResponded: props.onRefresh }))); + } + + function ApprovalDetail(props) { + const refreshState = hooks.useState(0); + const refreshKey = refreshState[0]; + const setRefreshKey = refreshState[1]; + const dialogRef = hooks.useRef(null); + const detail = useJSON(API + "/approvals/detail" + qs({ db: props.db, workflow_id: props.approval.workflow_id, key: props.approval.key }), refreshKey + ":" + props.outerRefresh); + + function closeDialog() { + const node = dialogRef.current; + if (node && node.open && typeof node.close === "function") { + try { node.returnValue = "closed"; node.close("closed"); } catch (_err) { /* noop */ } + } + props.onClose(); + } + + hooks.useEffect(function () { + const node = dialogRef.current; + if (!node) return; + if (typeof node.showModal === "function" && !node.open) { + try { node.showModal(); } catch (_err) { node.setAttribute("open", ""); } + } else if (!node.open) { + node.setAttribute("open", ""); + } + return function () { + if (node.open && typeof node.close === "function") { + try { node.close("component-unmount"); } catch (_err) { /* noop */ } + } + }; + }, []); + + function frame(content) { + return e("dialog", { + ref: dialogRef, + className: "hwf-approval-dialog", + onCancel: function (event) { event.preventDefault(); closeDialog(); }, + onClick: function (event) { if (event.target === event.currentTarget) closeDialog(); } + }, content); + } + + if (detail.loading) return frame(e("aside", { className: "hwf-approval-detail", role: "dialog", "aria-modal": "true" }, + e("div", { className: "hwf-approval-detail-body" }, "Loading approval…"))); + if (detail.error) return frame(e("aside", { className: "hwf-approval-detail hwf-bad", role: "dialog", "aria-modal": "true" }, + e("div", { className: "hwf-approval-detail-body" }, detail.error))); + + const data = detail.data || {}; + const approval = data.approval_card || props.approval; + const what = data.what_you_are_approving || {}; + const risk = data.risk || approval.risk || {}; + const header = e("div", { className: "hwf-detail-header" }, + e("div", null, + e("p", { className: "hwf-eyebrow" }, "Single approval review"), + e("h2", null, approval.headline || what.prompt || approval.key)), + e(Button, { className: "hwf-close-button", variant: "outline", onClick: closeDialog }, "Close")); + const body = e("div", { className: "hwf-approval-detail-body" }, + e(StepIdBadge, { value: approval }), + e("div", { className: "hwf-approval-hero" }, + e("div", null, + e("div", { className: "hwf-section-title" }, "What you are approving"), + e("p", null, what.prompt || approval.prompt || approval.key), + e(ArtifactRenderSummary, { render: what.artifact_render || approval.artifact_render }), + e(ArtifactInlinePreview, { render: what.artifact_render || approval.artifact_render, value: what.artifact || approval.artifact_preview }), + e(ArtifactReviewRawDump, { render: what.artifact_render || approval.artifact_render, value: what.artifact || approval.artifact_preview })), + e("div", null, + e("div", { className: "hwf-section-title" }, "Consequence"), + e("p", { className: "hwf-consequence" }, data.consequence || approval.consequence), + e("div", { className: "hwf-section-title" }, "Risk / blast radius"), + e(Pill, { label: (risk.level || "low") + " risk", className: riskClass(risk.level) }), + e("p", { className: "hwf-muted" }, risk.reason || "Records human provenance and creates inspectable continuation state."), + e("div", { className: "hwf-section-title" }, "Decision semantics"), + e("p", null, (data.decision_semantics && data.decision_semantics.label) || "Record and continue"), + e("p", { className: "hwf-muted" }, data.decision_semantics && data.decision_semantics.description))), + e(ApprovalActions, { db: props.db, approval: approval, onDecided: function () { setRefreshKey(refreshKey + 1); if (props.onRefresh) props.onRefresh(); } }), + e("div", { className: "hwf-section-title" }, "Approval timeline"), + e("ol", { className: "hwf-timeline" }, (data.timeline || []).map(function (event) { + return e("li", { key: event.seq + ":" + event.type }, + e("span", null, "#" + event.seq + " " + event.type), + e("code", null, event.key || "")); + }))); + return frame(e("aside", { className: "hwf-approval-detail", role: "dialog", "aria-modal": "true", onClick: function (event) { event.stopPropagation(); } }, header, body)); + } + + function PythonCode(props) { + const code = props.code || ""; + const parts = []; + const pattern = /("""[\s\S]*?"""|'''[\s\S]*?'''|#[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|@[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*|\b\d+(?:\.\d+)?\b|\b(?:async|await|def|class|return|if|elif|else|for|while|try|except|finally|with|from|import|as|raise|yield|True|False|None|and|or|not|in|is)\b)/g; + let last = 0; + let match; + while ((match = pattern.exec(code))) { + if (match.index > last) parts.push(code.slice(last, match.index)); + const token = match[0]; + let className = "hwf-code-keyword"; + if (token.startsWith("#")) className = "hwf-code-comment"; + else if (token.startsWith("\"") || token.startsWith("'")) className = "hwf-code-string"; + else if (token.startsWith("@")) className = "hwf-code-decorator"; + else if (/^\d/.test(token)) className = "hwf-code-number"; + parts.push(e("span", { key: parts.length, className: className }, token)); + last = match.index + token.length; + } + if (last < code.length) parts.push(code.slice(last)); + return e("code", { className: props.className || "language-python" }, parts); + } + + function WorkflowSourcePreview(props) { + const value = props.value && typeof props.value === "object" ? props.value : {}; + const render = props.render || {}; + const source = value.source || ""; + const symbol = value.symbol || render.symbol || "workflow"; + const sourceHash = value.source_sha256 || render.source_hash || "—"; + const provenance = value.provenance; + return e("details", { className: "hwf-workflow-source-preview", open: true }, + e("summary", null, "Open generated Workflow source"), + e("div", { className: "hwf-meta" }, + e(Pill, { label: "Python" }), + e(Pill, { label: "symbol: " + symbol }), + e(Pill, { label: "Source hash" }), + e("code", { className: "hwf-run-id", title: sourceHash }, shortId(sourceHash)), + value.source_hash_verified === false && e(Pill, { label: "hash mismatch", className: "hwf-bad" })), + e("pre", { className: "hwf-code-block" }, e(PythonCode, { className: value.highlight_class || render.highlight_class || "language-python", code: source })), + e("details", { className: "hwf-source-provenance" }, + e("summary", null, "Provenance"), + e("pre", null, provenance ? pretty(provenance) : "No runner provenance recorded for this generated Workflow."))); + } + + function WorkflowSourceModal(props) { + const dialogRef = hooks.useRef(null); + const source = useJSON(API + "/definitions/" + encodeURIComponent(props.definition.id) + "/source" + qs({ db: props.db }), props.definition.id + ":source"); + function closeDialog() { + const node = dialogRef.current; + if (node && node.open && typeof node.close === "function") { + try { node.returnValue = "closed"; node.close("closed"); } catch (_err) { /* noop */ } + } + props.onClose(); + } + hooks.useEffect(function () { + const node = dialogRef.current; + if (!node) return; + if (typeof node.showModal === "function" && !node.open) { + try { node.showModal(); } catch (_err) { node.setAttribute("open", ""); } + } else if (!node.open) { + node.setAttribute("open", ""); + } + return function () { + if (node.open && typeof node.close === "function") { + try { node.close("component-unmount"); } catch (_err) { /* noop */ } + } + }; + }, []); + const data = source.data || {}; + const location = data.location || {}; + return e("dialog", { + ref: dialogRef, + className: "hwf-approval-dialog", + onCancel: function (event) { event.preventDefault(); closeDialog(); }, + onClick: function (event) { if (event.target === event.currentTarget) closeDialog(); } + }, e("aside", { className: "hwf-approval-detail", role: "dialog", "aria-modal": "true", onClick: function (event) { event.stopPropagation(); } }, + e("div", { className: "hwf-detail-header" }, + e("div", null, + e("p", { className: "hwf-eyebrow" }, "Workflow source"), + e("h2", null, props.definition.name || props.definition.id), + e("p", { className: "hwf-muted" }, location.file ? location.file + ":" + location.line_start + "-" + location.line_end : props.definition.workflow_ref)), + e(Button, { className: "hwf-close-button", variant: "outline", onClick: closeDialog }, "Close")), + e("div", { className: "hwf-approval-detail-body" }, + source.loading && e("p", null, "Loading workflow source…"), + source.error && e("p", { className: "hwf-bad" }, source.error), + data.code && e("pre", { className: "hwf-code-block" }, e(PythonCode, { className: data.highlight_class || "language-python", code: data.code }))))); + } + + function DefinitionCard(props) { + const definition = props.definition; + const useState = hooks.useState; + const sourceState = useState(false); + const showSource = sourceState[0]; + const setShowSource = sourceState[1]; + const inputState = useState(pretty(definition.input_defaults || {})); + const inputText = inputState[0]; + const setInputText = inputState[1]; + const runState = useState({ busy: false, error: null, result: null }); + const runUi = runState[0]; + const setRunUi = runState[1]; + function runWorkflow() { + let input; + try { input = JSON.parse(inputText || "{}"); } + catch (err) { setRunUi({ busy: false, error: "Invalid JSON input: " + err.message, result: null }); return; } + setRunUi({ busy: true, error: null, result: null }); + SDK.fetchJSON(API + "/runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ db: props.db, definition_id: definition.id, input: input }) + }).then(function (data) { + setRunUi({ busy: false, error: null, result: data.run }); + if (props.onRefresh) props.onRefresh(); + }).catch(function (err) { + setRunUi({ busy: false, error: err.message || String(err), result: null }); + }); + } + const runs = definition.runs || { total: 0, by_status: {} }; + const canRun = definition.runnable !== false; + return e(Card, { className: "hwf-definition-card" }, + e(CardHeader, null, + e("div", null, + e(CardTitle, null, definition.name || definition.id), + e("p", { className: "hwf-muted" }, definition.description || definition.workflow_ref)), + e("div", { className: "hwf-row-actions" }, + e(Button, { variant: "outline", onClick: function () { setShowSource(true); } }, "View code"), + canRun + ? e(Button, { disabled: runUi.busy, onClick: runWorkflow }, definition.run_button_label || "Run workflow") + : e(Pill, { label: "history only" }))), + e(CardContent, null, + e("div", { className: "hwf-meta" }, + e(Pill, { label: "runs: " + runs.total }), + Object.keys(runs.by_status || {}).map(function (status) { return e(Pill, { key: status, label: status + ": " + runs.by_status[status], className: statusClass(status) }); }), + (definition.tags || []).map(function (tag) { return e(Pill, { key: tag, label: tag }); })), + canRun ? e("div", { className: "hwf-run-box" }, + e("div", { className: "hwf-section-title" }, "Run workflow"), + e("textarea", { + value: inputText, + rows: 7, + onInput: function (event) { setInputText(event.target.value); }, + spellCheck: false + }), + e("p", { className: "hwf-muted" }, "Schema-driven inputs when available. This starts the configured workflow ref only.")) + : e("div", { className: "hwf-run-box hwf-history-only" }, + e("div", { className: "hwf-section-title" }, "History only"), + e("p", { className: "hwf-muted" }, "This workflow was inferred from run history. Add it to workflow_catalog before browser launches are allowed.")), + runUi.error && e("p", { className: "hwf-bad" }, runUi.error), + runUi.result && e("div", { className: "hwf-run-result" }, + e("strong", null, "Started: "), e("code", null, runUi.result.workflow_id), " ", e(Pill, { label: runUi.result.status, className: statusClass(runUi.result.status) })), + e("details", null, + e("summary", null, "Input schema"), + e("pre", null, pretty(definition.input_schema))), + e("details", null, + e("summary", null, "Run history"), + definition.latest_run ? e("p", null, "Latest: ", e("code", null, definition.latest_run.workflow_id), " ", e(Pill, { label: definition.latest_run.status, className: statusClass(definition.latest_run.status) })) : e("p", { className: "hwf-muted" }, "No runs yet.")), + showSource && e(WorkflowSourceModal, { db: props.db, definition: definition, onClose: function () { setShowSource(false); } }))); + } + + function RunApprovalSummary(props) { + const approval = props.approval || {}; + return e("div", { className: "hwf-run-approval" }, + e("div", null, + e("strong", null, approval.prompt || approval.key || "Approval needed"), + e("p", { className: "hwf-muted" }, "Workflow → Run → Step → Approval")), + e("div", { className: "hwf-meta" }, + e(Pill, { label: approval.status || "waiting", className: statusClass(approval.status || "waiting") }), + approval.key && e(Pill, { label: "key: " + approval.key }))); + } + + function dagNodeLabel(node) { + const value = String(node.label || node.id || "node"); + return value.replace(/^step:/, "").replace(/:0$/, "").replace(/^workflow:/, "workflow:"); + } + + function dagNodeSubLabel(node) { + if (node.kind === "step" && node.completion_mode === "approval") return "approval step"; + if (node.kind === "step" && node.completion_mode === "worker") return "worker step"; + if (node.kind === "step") return "step"; + if (node.kind === "child_workflow") return "subworkflow"; + if (node.kind === "gather") return "fan-in"; + if (node.id === "workflow:start") return "start"; + if (node.id === "workflow:completed") return "completed"; + return node.kind || "event"; + } + + function truncateDagLabel(value, max) { + const text = String(value || ""); + return text.length > max ? text.slice(0, max - 1) + "…" : text; + } + + function childInlineDagNodeId(parentNodeId, childNodeId) { + return String(parentNodeId) + "::child::" + String(childNodeId); + } + + function terminalChildDagNodeIds(childDag) { + const childNodes = childDag && Array.isArray(childDag.nodes) ? childDag.nodes : []; + const childEdges = childDag && Array.isArray(childDag.edges) ? childDag.edges : []; + const outgoing = {}; + childEdges.forEach(function (edge) { outgoing[edge.from] = true; }); + return childNodes.filter(function (node) { return !outgoing[node.id]; }).map(function (node) { return node.id; }); + } + + function startChildDagNodeIds(childDag) { + const childNodes = childDag && Array.isArray(childDag.nodes) ? childDag.nodes : []; + const childEdges = childDag && Array.isArray(childDag.edges) ? childDag.edges : []; + const incoming = {}; + childEdges.forEach(function (edge) { incoming[edge.to] = true; }); + return childNodes.filter(function (node) { return !incoming[node.id]; }).map(function (node) { return node.id; }); + } + + function expandInlineChildWorkflows(nodes, edges, expandedChildWorkflowIds) { + const byId = {}; + nodes.forEach(function (node) { byId[node.id] = node; }); + const visibleNodes = []; + const visibleEdges = []; + nodes.forEach(function (node) { + visibleNodes.push(node); + const childWorkflowId = node.kind === "child_workflow" && node.child_workflow_id; + const childDag = childWorkflowId && expandedChildWorkflowIds[childWorkflowId] && node.child_dag; + if (!childDag || !Array.isArray(childDag.nodes)) return; + childDag.nodes.forEach(function (childNode) { + visibleNodes.push(Object.assign({}, childNode, { + id: childInlineDagNodeId(node.id, childNode.id), + label: childNode.label || childNode.id, + inline_child_workflow_id: childWorkflowId, + inline_child_parent_node_id: node.id, + kind: childNode.kind || "step", + status: childNode.status || "recorded" + })); + }); + startChildDagNodeIds(childDag).forEach(function (startId) { + visibleEdges.push({ from: node.id, to: childInlineDagNodeId(node.id, startId) }); + }); + (childDag.edges || []).forEach(function (edge) { + visibleEdges.push({ + from: childInlineDagNodeId(node.id, edge.from), + to: childInlineDagNodeId(node.id, edge.to) + }); + }); + }); + edges.forEach(function (edge) { + const fromNode = byId[edge.from]; + const toNode = byId[edge.to]; + if (!fromNode || !toNode) return; + const childWorkflowId = fromNode.kind === "child_workflow" && fromNode.child_workflow_id; + const childDag = childWorkflowId && expandedChildWorkflowIds[childWorkflowId] && fromNode.child_dag; + if (childDag && Array.isArray(childDag.nodes)) { + terminalChildDagNodeIds(childDag).forEach(function (terminalId) { + visibleEdges.push({ from: childInlineDagNodeId(edge.from, terminalId), to: edge.to }); + }); + } else { + visibleEdges.push(edge); + } + }); + return { nodes: visibleNodes, edges: visibleEdges }; + } + + function layoutDagNodes(nodes, edges) { + const nodeWidth = 184; + const nodeHeight = 68; + const columnGap = 110; + const rowGap = 48; + const marginX = 52; + const marginY = 42; + const byId = {}; + nodes.forEach(function (node) { byId[node.id] = node; }); + const incoming = {}; + const outgoing = {}; + nodes.forEach(function (node) { incoming[node.id] = []; outgoing[node.id] = []; }); + edges.forEach(function (edge) { + if (!byId[edge.from] || !byId[edge.to]) return; + incoming[edge.to].push(edge.from); + outgoing[edge.from].push(edge.to); + }); + const depth = {}; + nodes.forEach(function (node) { depth[node.id] = 0; }); + for (let pass = 0; pass < nodes.length; pass += 1) { + edges.forEach(function (edge) { + if (!byId[edge.from] || !byId[edge.to]) return; + depth[edge.to] = Math.max(depth[edge.to] || 0, (depth[edge.from] || 0) + 1); + }); + } + const columns = {}; + nodes.forEach(function (node) { + const column = depth[node.id] || 0; + if (!columns[column]) columns[column] = []; + columns[column].push(node.id); + }); + Object.keys(columns).forEach(function (columnKey) { + columns[columnKey].sort(function (a, b) { + const aIncoming = incoming[a] || []; + const bIncoming = incoming[b] || []; + const aHint = aIncoming.length ? Math.min.apply(null, aIncoming.map(function (id) { return nodes.findIndex(function (node) { return node.id === id; }); })) : nodes.findIndex(function (node) { return node.id === a; }); + const bHint = bIncoming.length ? Math.min.apply(null, bIncoming.map(function (id) { return nodes.findIndex(function (node) { return node.id === id; }); })) : nodes.findIndex(function (node) { return node.id === b; }); + return aHint - bHint; + }); + }); + const maxRows = Math.max(1, Object.keys(columns).reduce(function (value, columnKey) { return Math.max(value, columns[columnKey].length); }, 1)); + const totalColumnHeight = maxRows * nodeHeight + Math.max(0, maxRows - 1) * rowGap; + const positions = {}; + Object.keys(columns).forEach(function (columnKey) { + const column = Number(columnKey); + const ids = columns[columnKey]; + const columnHeight = ids.length * nodeHeight + Math.max(0, ids.length - 1) * rowGap; + const yOffset = (totalColumnHeight - columnHeight) / 2; + ids.forEach(function (id, row) { + positions[id] = { + x: marginX + column * (nodeWidth + columnGap), + y: marginY + yOffset + row * (nodeHeight + rowGap) + }; + }); + }); + const maxDepth = nodes.reduce(function (value, node) { return Math.max(value, depth[node.id] || 0); }, 0); + return { + nodeWidth: nodeWidth, + nodeHeight: nodeHeight, + marginX: marginX, + marginY: marginY, + positions: positions, + incoming: incoming, + outgoing: outgoing, + graphWidth: Math.max(520, marginX * 2 + (maxDepth + 1) * nodeWidth + maxDepth * columnGap), + graphHeight: Math.max(220, marginY * 2 + totalColumnHeight) + }; + } + + function RunDag(props) { + const selectedState = hooks.useState(null); + const selectedDagNodeId = selectedState[0]; + const setSelectedDagNodeId = selectedState[1]; + const expandedChildWorkflowIdsState = hooks.useState({}); + const expandedChildWorkflowIds = expandedChildWorkflowIdsState[0]; + const setExpandedChildWorkflowIds = expandedChildWorkflowIdsState[1]; + const dag = useJSON(API + "/runs/" + encodeURIComponent(props.workflowId) + "/dag" + qs({ db: props.db }), props.workflowId + ":dag:" + props.refreshKey); + if (dag.loading) return e("p", { className: "hwf-muted" }, "Loading Run DAG…"); + if (dag.error) return e("p", { className: "hwf-bad" }, dag.error); + const data = dag.data || {}; + const baseNodes = data.nodes || []; + const baseEdges = data.edges || []; + const expandedGraph = expandInlineChildWorkflows(baseNodes, baseEdges, expandedChildWorkflowIds); + const nodes = expandedGraph.nodes; + const edges = expandedGraph.edges; + const selected = nodes.find(function (node) { return node.id === selectedDagNodeId; }) || baseNodes[0] || nodes[0] || null; + const layout = layoutDagNodes(nodes, edges); + const nodeWidth = layout.nodeWidth; + const nodeHeight = layout.nodeHeight; + const graphWidth = layout.graphWidth; + const graphHeight = layout.graphHeight; + const positions = layout.positions; + const incomingByTarget = layout.incoming; + const outgoingBySource = layout.outgoing; + const markerId = "hwf-dag-arrow-" + String(props.workflowId || "run").replace(/[^A-Za-z0-9_-]/g, "-"); + const selectedChildWorkflowId = selected && selected.kind === "child_workflow" && selected.child_workflow_id; + const selectedChildExpanded = selectedChildWorkflowId && !!expandedChildWorkflowIds[selectedChildWorkflowId]; + function toggleSelectedChildWorkflow() { + if (!selectedChildWorkflowId) return; + setExpandedChildWorkflowIds(Object.assign({}, expandedChildWorkflowIds, { [selectedChildWorkflowId]: !selectedChildExpanded })); + } + function selectNode(node) { + setSelectedDagNodeId(node.id); + if (node.kind === "child_workflow" && node.child_workflow_id) { + setExpandedChildWorkflowIds(Object.assign({}, expandedChildWorkflowIds, { [node.child_workflow_id]: !expandedChildWorkflowIds[node.child_workflow_id] })); + } + } + function isConnected(edge) { return selected && (edge.from === selected.id || edge.to === selected.id); } + return e("div", { className: "hwf-dag" }, + nodes.length ? e("div", { className: "hwf-dag-graph", role: "group", "aria-label": "Workflow run DAG graph" }, + e("svg", { className: "hwf-dag-svg hwf-dag-edge-svg", viewBox: "0 0 " + graphWidth + " " + graphHeight, role: "img", "aria-label": "Run-derived workflow DAG", style: { width: graphWidth + "px", height: graphHeight + "px" } }, + e("defs", null, + e("linearGradient", { id: markerId + "-node", x1: "0", y1: "0", x2: "1", y2: "1" }, + e("stop", { offset: "0%", stopColor: "rgba(113, 112, 255, 0.24)" }), + e("stop", { offset: "100%", stopColor: "rgba(255, 255, 255, 0.035)" })), + e("marker", { id: markerId, markerWidth: "12", markerHeight: "12", refX: "9", refY: "6", orient: "auto", markerUnits: "strokeWidth" }, + e("path", { d: "M1,1 L10,6 L1,11 L4,6 z" }))), + edges.map(function (edge, index) { + const from = positions[edge.from]; + const to = positions[edge.to]; + if (!from || !to) return null; + const startX = from.x + nodeWidth; + const startY = from.y + nodeHeight / 2; + const endX = to.x; + const endY = to.y + nodeHeight / 2; + const bend = Math.max(46, Math.min(120, (endX - startX) * 0.48)); + return e("path", { + key: edge.from + "->" + edge.to + ":" + index, + className: "hwf-dag-edge-line " + (isConnected(edge) ? "hwf-dag-edge-active" : ""), + d: "M " + startX + " " + startY + " C " + (startX + bend) + " " + startY + " " + (endX - bend) + " " + endY + " " + endX + " " + endY, + markerEnd: "url(#" + markerId + ")" + }); + }), + nodes.map(function (node) { + const pos = positions[node.id] || { x: layout.marginX, y: layout.marginY }; + const nodeSelected = selected && selected.id === node.id; + const label = truncateDagLabel(dagNodeLabel(node), 22); + const subLabel = dagNodeSubLabel(node); + const status = node.status || "recorded"; + const reviewText = node.review_action ? String(node.review_action).replace(/_/g, " ") : ""; + const artifactText = reviewText || (node.artifact_count ? node.artifact_count + " artifact" + (node.artifact_count === 1 ? "" : "s") : ""); + return e("g", { + key: node.id, + className: "hwf-dag-svg-node " + (nodeSelected ? "hwf-dag-node-selected" : "") + " hwf-dag-node-kind-" + (node.kind || "event") + (node.inline_child_workflow_id ? " hwf-dag-node-child-inline" : ""), + transform: "translate(" + pos.x + " " + pos.y + ")", + onClick: function () { selectNode(node); }, + onKeyDown: function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectNode(node); } }, + tabIndex: 0, + role: "button", + title: node.id, + "data-dag-node-id": node.id + }, + e("rect", { className: "hwf-dag-node-shadow", x: 0, y: 0, width: nodeWidth, height: nodeHeight, rx: 15 }), + e("rect", { className: "hwf-dag-node-bg", x: 0, y: 0, width: nodeWidth, height: nodeHeight, rx: 15, fill: "url(#" + markerId + "-node)" }), + e("text", { className: "hwf-dag-kind", x: 16, y: 21 }, subLabel), + e("text", { className: "hwf-dag-label", x: 16, y: 43 }, label), + artifactText && e("text", { className: "hwf-dag-artifacts", x: 16, y: 59 }, artifactText), + e("rect", { className: "hwf-dag-status-bg", x: nodeWidth - 74, y: 12, width: 58, height: 18, rx: 9 }), + e("text", { className: "hwf-dag-status-text", x: nodeWidth - 45, y: 25, textAnchor: "middle" }, truncateDagLabel(status, 9))); + }))) : e("p", { className: "hwf-muted" }, "No DAG nodes recorded for this run yet."), + selected && e("div", { className: "hwf-dag-inspector" }, + e("div", { className: "hwf-section-title" }, "Selected piece"), + e("div", { className: "hwf-meta" }, + e(Pill, { label: selected.kind || "node" }), + e(Pill, { label: selected.status || "recorded", className: statusClass(selected.status) }), + e("code", { className: "hwf-run-id", title: selected.id }, shortId(selected.id))), + e("p", { className: "hwf-muted" }, + (incomingByTarget[selected.id] || []).length ? "After: " + incomingByTarget[selected.id].map(shortId).join(", ") + ". " : "No incoming edges. ", + (outgoingBySource[selected.id] || []).length ? "Next: " + outgoingBySource[selected.id].map(shortId).join(", ") + "." : "No outgoing edges."), + selected.review_action && e("p", { className: "hwf-muted" }, "Review result: ", String(selected.review_action).replace(/_/g, " "), selected.review_feedback ? " — " + selected.review_feedback : ""), + selectedChildWorkflowId && e("div", { className: "hwf-child-workflow-summary" }, + e("div", { className: "hwf-meta" }, + e(Pill, { label: "child: " + shortId(selectedChildWorkflowId) }), + selected.child_status && e(Pill, { label: selected.child_status, className: statusClass(selected.child_status) }), + selected.child_node_count !== undefined && e(Pill, { label: "nodes: " + selected.child_node_count })), + e(Button, { variant: "outline", onClick: toggleSelectedChildWorkflow }, selectedChildExpanded ? "Collapse inline DAG" : "Expand inline DAG")), + e("div", { className: "hwf-section-title" }, "Artifacts from this step"), + selected.artifacts && selected.artifacts.length ? selected.artifacts.map(function (artifact) { + return e(ArtifactCard, { key: artifact.id, artifact: artifact }); + }) : e("p", { className: "hwf-muted" }, "No artifacts captured for this piece yet."))); + } + + function RunRow(props) { + const run = props.run; + return e(Card, { className: "hwf-run-row" }, + e(CardContent, null, + e("div", { className: "hwf-run-grid" }, + e("div", { className: "hwf-run-main" }, + e("strong", { title: run.workflow_name || "workflow" }, run.workflow_name || "workflow"), + e("p", { className: "hwf-muted", title: run.workflow_ref || "—" }, run.workflow_ref || "—")), + e("code", { className: "hwf-run-id", title: run.workflow_id }, shortId(run.workflow_id)), + e("div", { className: "hwf-run-signals" }, + e(Pill, { label: run.status, className: statusClass(run.status) }), + e(RuntimeStatePill, { runtime: run.runtime_state }), + e("span", { className: "hwf-muted hwf-waiting-on", title: run.waiting_on || "not waiting" }, run.waiting_on || "not waiting")), + e("div", { className: "hwf-run-tail" }, + e(Button, { variant: "outline", onClick: function () { props.onInspect(run); } }, "Inspect run"))))); + } + + function RunsPanel(props) { + const useEffect = hooks.useEffect; + const selectedState = hooks.useState(null); + const selected = selectedState[0]; + const setSelected = selectedState[1]; + const cancelState = hooks.useState({ busy: false, error: null, done: null }); + const cancelUi = cancelState[0]; + const setCancelUi = cancelState[1]; + useEffect(function () { + if (props.inspectRun) setSelected(props.inspectRun); + }, [props.inspectRun && props.inspectRun.workflow_id]); + const statusPath = selected ? API + "/runs/" + encodeURIComponent(selected.workflow_id) + qs({ db: props.db }) : API + "/runs" + qs({ db: props.db, limit: 1 }); + const status = useJSON(statusPath, props.refreshKey + ":" + (selected && selected.workflow_id || "none")); + const runStatus = status.data && status.data.run; + const runArtifacts = status.data && Array.isArray(status.data.artifacts) ? status.data.artifacts : []; + const runs = Array.isArray(props.runs) ? props.runs : []; + function cancelSelectedRun() { + if (!selected || !selected.workflow_id || !runStatus) return; + if (["completed", "failed", "cancelled"].indexOf(String(runStatus.status)) >= 0) return; + const reason = globalThis.prompt ? globalThis.prompt("Cancel this workflow run? Reason:", "Cancelled from dashboard") : "Cancelled from dashboard"; + if (!reason) return; + setCancelUi({ busy: true, error: null, done: null }); + SDK.fetchJSON(API + "/runs/" + encodeURIComponent(selected.workflow_id) + "/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ db: props.db, reason: reason }) + }).then(function (data) { + setCancelUi({ busy: false, error: null, done: data.result && data.result.status || "cancelled" }); + if (props.onRefresh) props.onRefresh(); + }).catch(function (err) { + setCancelUi({ busy: false, error: err.message || String(err), done: null }); + }); + } + const canCancel = runStatus && ["completed", "failed", "cancelled"].indexOf(String(runStatus.status)) < 0; + const children = [ + e("div", { className: "hwf-panel-header" }, e("h2", null, "Runs"), e("p", { className: "hwf-muted" }, "Workflow → Run → Step → Artifact/Approval. Inspect a run for outputs and decisions.")), + selected && e(Card, { className: "hwf-inspector" }, + e(CardHeader, null, + e("div", null, + e(CardTitle, null, "Run status"), + e("p", { className: "hwf-muted" }, selected.workflow_name || selected.workflow_ref || selected.workflow_id)), + e("div", { className: "hwf-review-action-row" }, + canCancel && e(Button, { variant: "outline", disabled: cancelUi.busy, onClick: cancelSelectedRun }, cancelUi.busy ? "Cancelling…" : "Cancel run"), + e(Button, { variant: "outline", onClick: function () { setSelected(null); } }, "Close"))), + e(CardContent, null, + status && status.loading && e("p", null, "Loading status…"), + status && status.error && e("p", { className: "hwf-bad" }, status.error), + cancelUi.error && e("p", { className: "hwf-bad" }, cancelUi.error), + cancelUi.done && e("p", { className: "hwf-ok" }, "Run status: " + cancelUi.done), + status && status.data && !runStatus && e("p", { className: "hwf-muted" }, "Loading run status…"), + runStatus && e("div", null, + e("div", { className: "hwf-meta" }, + e(Pill, { label: runStatus.status, className: statusClass(runStatus.status) }), + e(RuntimeStatePill, { runtime: runStatus.runtime_state }), + e(Pill, { label: "events: " + runStatus.event_count }), + e(Pill, { label: "artifacts: " + runArtifacts.length }), + e("code", { className: "hwf-run-id", title: runStatus.workflow_id }, shortId(runStatus.workflow_id))), + e(RuntimeFacts, { runtime: runStatus.runtime_state }), + e("div", { className: "hwf-section-title" }, "Run DAG"), + e(RunDag, { db: props.db, workflowId: selected.workflow_id, refreshKey: props.refreshKey }), + e("div", { className: "hwf-section-title" }, "Human input requests in this run"), + (runStatus.operator_steps || []).length ? (runStatus.operator_steps || []).map(function (step) { + return e(RunApprovalSummary, { key: step.key, approval: { prompt: step.prompt || step.label, key: step.key, status: step.status } }); + }) : e("p", { className: "hwf-muted" }, "No human input requests recorded for this run."), + e("div", { className: "hwf-section-title" }, "Approval policy gates in this run"), + (runStatus.approvals || []).length ? (runStatus.approvals || []).map(function (approval) { +return e(RunApprovalSummary, { key: approval.key, approval: approval }); + }) : e("p", { className: "hwf-muted" }, "No approval gates recorded for this run."), + e("div", { className: "hwf-section-title" }, "Artifacts / outputs in this run"), + runArtifacts.length ? runArtifacts.map(function (artifact) { return e(ArtifactCard, { key: artifact.id, artifact: artifact }); }) : e("p", { className: "hwf-muted" }, "No artifacts captured yet."), + e("details", { className: "hwf-raw-json" }, + e("summary", null, "Recent events"), + e("pre", null, pretty(runStatus.recent_events || [])))))), + props.loading && e(Card, null, e(CardContent, { className: "hwf-empty" }, "Loading runs…")), + props.error && e(Card, null, e(CardContent, { className: "hwf-empty hwf-bad" }, props.error)), + !props.loading && !props.error && !runs.length && e(Card, null, + e(CardContent, { className: "hwf-empty" }, + e("strong", null, "No runs found for the active source."), + e("p", { className: "hwf-muted" }, "Source: ", props.db || "not configured", ". If a workflow just ran, hit Refresh; if this stays empty, the dashboard is looking at the wrong state source."))) + ].filter(Boolean).concat(runs.map(function (run) { + return e(RunRow, { key: run.workflow_id, run: run, onInspect: setSelected }); + })); + return React.createElement.apply(React, ["div", { className: "hwf-panel" }].concat(children)); + } + + function OverviewPanel(props) { + const counts = props.counts || {}; + const approvals = props.approvals || []; + const reviewRequests = props.reviewRequests || []; + return e("div", { className: "hwf-panel" }, + e("div", { className: "hwf-stats" }, + e(StatCard, { label: "Runnable workflows", value: props.definitions.length, help: "Catalog" }), + e(StatCard, { label: "Runs", value: props.runs.length, help: "Recent history" }), + e(StatCard, { label: "Waiting", value: counts.waiting || 0, help: "Blocked runs" }), + e(StatCard, { label: "Needs review", value: reviewRequests.length, help: "Human input and approval requests" }), + e(StatCard, { label: "Approvals", value: approvals.length, help: "Approve/reject review requests" }), + e(StatCard, { label: "Artifacts", value: props.artifacts.length, help: "Outputs" })), + e("div", { className: "hwf-two-col" }, + e("div", null, + e("div", { className: "hwf-panel-header" }, e("h2", null, "Needs review")), + reviewRequests.length ? reviewRequests.slice(0, 3).map(function (request) { return request.request_type === "approval_policy" || request.allowed ? e(ApprovalCard, { key: "approval:" + request.workflow_id + request.key, db: props.db, approval: request, onView: props.onViewApproval, onRefresh: props.onRefresh }) : e(HumanInputCard, { key: "input:" + request.workflow_id + request.key, db: props.db, step: request, onRefresh: props.onRefresh }); }) : e(Card, null, e(CardContent, { className: "hwf-empty" }, "No active review requests."))), + e("div", null, + e("div", { className: "hwf-panel-header" }, e("h2", null, "Recent runs")), + props.runs.slice(0, 5).map(function (run) { return e(RunRow, { key: run.workflow_id, run: run, onInspect: props.onInspectRun }); })))); + } + + function ArtifactsPanel(props) { + const grouped = {}; + (props.artifacts || []).forEach(function (artifact) { + const id = artifact.workflow_id || "unknown-run"; + if (!grouped[id]) grouped[id] = []; + grouped[id].push(artifact); + }); + const runIds = Object.keys(grouped); + return e("div", { className: "hwf-panel" }, + e("div", { className: "hwf-panel-header" }, + e("h2", null, "Artifacts"), + e("p", { className: "hwf-muted" }, "Workflow → Run → Step → Artifact. Top-level queue for active approvals; artifacts live under their run.")), + runIds.length ? runIds.map(function (workflowId) { + const run = (props.runs || []).find(function (item) { return item.workflow_id === workflowId; }) || {}; + return e("details", { key: workflowId, className: "hwf-run-artifacts" }, + e("summary", { className: "hwf-run-artifacts-summary" }, + e("div", null, + e(CardTitle, null, run.workflow_name || run.workflow_ref || "Run artifacts"), + e("div", { className: "hwf-meta" }, + e("code", { className: "hwf-run-id", title: workflowId }, shortId(workflowId)), + run.status && e(Pill, { label: run.status, className: statusClass(run.status) }), + e(Pill, { label: grouped[workflowId].length + " artifact" + (grouped[workflowId].length === 1 ? "" : "s") }))), + e("span", { className: "hwf-collapse-hint" }, "Expand")), + e("div", { className: "hwf-run-artifacts-body" }, grouped[workflowId].map(function (artifact) { return e(ArtifactCard, { key: artifact.id, artifact: artifact }); }))); + }) : e(Card, null, e(CardContent, { className: "hwf-empty" }, "No artifacts yet. Inspect a run after it emits outputs."))); + } + + function WorkflowsPage() { + const useState = hooks.useState; + const tabState = useState("Overview"); + const activeTab = tabState[0]; + const setActiveTab = tabState[1]; + const approvalState = useState(null); + const selectedApproval = approvalState[0]; + const setSelectedApproval = approvalState[1]; + const inspectedRunState = useState(null); + const inspectedRun = inspectedRunState[0]; + const setInspectedRun = inspectedRunState[1]; + const refreshState = useState(0); + const refreshKey = refreshState[0]; + const setRefreshKey = refreshState[1]; + const dbs = useJSON(API + "/dbs", refreshKey); + const activeSource = dbs.data && dbs.data.active_source; + const activeDb = activeSource && activeSource.name || ""; + const activeSourceLabel = activeSource ? activeSource.name + (activeSource.exists ? "" : " (missing)") : "Not configured"; + const overview = useJSON(activeDb ? API + "/overview" + qs({ db: activeDb, recent_events: 10, command_limit: 10 }) : API + "/overview", refreshKey + ":" + activeDb); + const definitionsData = useJSON(activeDb ? API + "/definitions" + qs({ db: activeDb }) : API + "/definitions", refreshKey + ":defs:" + activeDb); + const runsData = useJSON(activeDb ? API + "/runs" + qs({ db: activeDb, limit: 100 }) : API + "/runs", refreshKey + ":runs:" + activeDb); + const approvalsData = useJSON(activeDb ? API + "/approvals" + qs({ db: activeDb, status: "waiting" }) : API + "/approvals", refreshKey + ":approvals:" + activeDb); + const reviewRequestsData = useJSON(activeDb ? API + "/review-requests" + qs({ db: activeDb, status: "waiting" }) : API + "/review-requests", refreshKey + ":review-requests:" + activeDb); + + function refresh() { setRefreshKey(refreshKey + 1); } + function inspectRun(run) { + setInspectedRun(run); + setActiveTab("Runs"); + } + if (dbs.loading) return e("div", { className: "hwf-page" }, "Loading workflow DBs…"); + if (dbs.error) return e("div", { className: "hwf-page hwf-bad" }, dbs.error); + const overviewData = overview.data || {}; + const definitions = definitionsData.data && definitionsData.data.definitions || overviewData.definitions || []; + const runs = runsData.data && runsData.data.runs || overviewData.workflows || []; + const approvals = approvalsData.data && approvalsData.data.approvals || overviewData.active_approvals || []; + const reviewRequests = reviewRequestsData.data && reviewRequestsData.data.review_requests || overviewData.active_review_requests || []; + const artifacts = overviewData.artifacts || []; + const counts = overviewData.counts_by_status || (runsData.data && runsData.data.counts && runsData.data.counts.by_status) || {}; + const hasConsoleData = Boolean(overview.data || definitionsData.data || runsData.data || approvalsData.data || reviewRequestsData.data); + const initialConsoleLoading = (overview.loading || definitionsData.loading || runsData.loading || approvalsData.loading || reviewRequestsData.loading) && !hasConsoleData; + const refreshingConsole = (overview.loading || definitionsData.loading || runsData.loading || approvalsData.loading || reviewRequestsData.loading) && hasConsoleData; + + return e("div", { className: "hwf-page hwf-shell" }, + e("div", { className: "hwf-header" }, + e("div", null, + e("p", { className: "hwf-eyebrow" }, "Workflow console"), + e("h1", null, "Hermes Workflows"), + e("p", { className: "hwf-muted" }, "Run workflows, track status/history, review artifacts, and respond to human input or approval requests with provenance.")), + e("div", { className: "hwf-controls" }, + e("div", { className: "hwf-active-source", title: "Workflow state source" }, + e("span", { className: "hwf-active-source-label" }, "Source"), + e("strong", null, activeSourceLabel)), + e(Button, { onClick: refresh }, "Refresh"))), + e(Tabs, { tabs: ["Overview", "Workflows", "Runs", "Review Queue", "Artifacts"], active: activeTab, setActive: setActiveTab }), + e("div", { className: "hwf-runtime-note" }, + e("strong", null, "Runtime: "), + "workflow code runs in the local WorkflowEngine process for the active workflow state source. Human input requests record typed outputs; approval gates are approve/reject review requests."), + reviewRequests.length > 0 && e("div", { className: "hwf-attention", role: "alert" }, + e("strong", null, reviewRequests.length + " review request" + (reviewRequests.length === 1 ? "" : "s") + " waiting. "), + "Open Review Queue or a card below to answer the waiting request."), + initialConsoleLoading && e("p", { className: "hwf-muted" }, "Loading workflow data…"), + refreshingConsole && e("p", { className: "hwf-muted hwf-refreshing" }, "Refreshing workflow console…"), + (overview.error || definitionsData.error || runsData.error || approvalsData.error || reviewRequestsData.error) && e("p", { className: "hwf-bad" }, overview.error || definitionsData.error || runsData.error || approvalsData.error || reviewRequestsData.error), + activeTab === "Overview" && e(OverviewPanel, { db: activeDb, definitions: definitions, runs: runs, reviewRequests: reviewRequests, approvals: approvals, artifacts: artifacts, counts: counts, onViewApproval: setSelectedApproval, onInspectRun: inspectRun, onRefresh: refresh }), + activeTab === "Workflows" && e("div", { className: "hwf-panel" }, + e("div", { className: "hwf-panel-header" }, e("h2", null, "Workflows you can run"), e("p", { className: "hwf-muted" }, "See workflows I can run, then run one with JSON inputs.")), + definitions.length ? definitions.map(function (definition) { return e(DefinitionCard, { key: definition.id, db: activeDb, definition: definition, onRefresh: refresh }); }) : e(Card, null, e(CardContent, { className: "hwf-empty" }, "No runnable workflows configured yet."))), + activeTab === "Runs" && e(RunsPanel, { db: activeDb, refreshKey: refreshKey, runs: runs, loading: runsData.loading, error: runsData.error, inspectRun: inspectedRun, onRefresh: refresh }), + activeTab === "Review Queue" && e("div", { className: "hwf-panel" }, + e("div", { className: "hwf-panel-header" }, e("h2", null, "Review Queue"), e("p", { className: "hwf-muted" }, "One queue for human work: answer typed input requests or approve/reject policy gates.")), + reviewRequests.length ? reviewRequests.map(function (request) { return request.request_type === "approval_policy" || request.allowed ? e(ApprovalCard, { key: "approval:" + request.workflow_id + request.key, db: activeDb, approval: request, onView: setSelectedApproval, onRefresh: refresh }) : e(HumanInputCard, { key: "input:" + request.workflow_id + request.key, db: activeDb, step: request, onRefresh: refresh }); }) : e(Card, null, e(CardContent, { className: "hwf-empty" }, "No active review requests."))), + activeTab === "Artifacts" && e(ArtifactsPanel, { artifacts: artifacts, runs: runs }), + selectedApproval && e(ApprovalDetail, { db: activeDb, approval: selectedApproval, outerRefresh: refreshKey, onClose: function () { setSelectedApproval(null); }, onRefresh: refresh })); + } + + window.__HERMES_PLUGINS__.register("hermes-workflows-approvals", WorkflowsPage); +})(); diff --git a/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/dist/style.css b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/dist/style.css new file mode 100644 index 0000000..06424c2 --- /dev/null +++ b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/dist/style.css @@ -0,0 +1,1010 @@ +.hwf-page { + display: grid; + gap: 1rem; +} + +.hwf-shell { + --hwf-surface: color-mix(in srgb, var(--color-card), black 8%); + --hwf-border: var(--color-border); + --hwf-code-keyword: color-mix(in srgb, var(--color-foreground), var(--color-primary) 26%); + --hwf-code-string: color-mix(in srgb, var(--color-foreground), #8fd6a3 34%); + --hwf-code-comment: color-mix(in srgb, var(--color-muted-foreground), var(--color-foreground) 14%); +} + +.hwf-header, +.hwf-controls, +.hwf-artifact-render, +.hwf-meta, +.hwf-stats, +.hwf-approval-actions, +.hwf-row-actions, +.hwf-detail-header, +.hwf-panel-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.hwf-header, +.hwf-detail-header, +.hwf-panel-header { + justify-content: space-between; +} + +.hwf-header h1, +.hwf-approval-detail h2, +.hwf-panel h2 { + margin: 0; + letter-spacing: -0.035em; +} + +.hwf-header h1 { + font-size: clamp(2rem, 4vw, 3rem); +} + +.hwf-eyebrow, +.hwf-stat-label { + color: var(--color-muted-foreground); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; +} + +.hwf-muted { + color: var(--color-muted-foreground); +} + +.hwf-active-source { + display: inline-flex; + align-items: center; + gap: 0.45rem; + border: 1px solid var(--hwf-border); + border-radius: 999px; + padding: 0.45rem 0.75rem; + background: var(--hwf-surface); + white-space: nowrap; +} + +.hwf-active-source-label { + color: var(--color-muted-foreground); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 800; +} + +.hwf-runtime-note, +.hwf-attention { + border: 1px solid var(--hwf-border); + border-radius: var(--radius-lg, 0.75rem); + padding: 0.75rem 1rem; + background: var(--hwf-surface); +} + +.hwf-attention { + border-color: color-mix(in srgb, #f7c948, var(--hwf-border) 45%); + background: color-mix(in srgb, #f7c948, transparent 88%); +} + +.hwf-ok { + color: #35d49a; +} + +.hwf-warn { + color: #f7c948; +} + +.hwf-bad { + color: #ff6b6b; +} + +.hwf-risk-low { + color: #35d49a; +} + +.hwf-risk-medium { + color: #f7c948; +} + +.hwf-risk-high { + color: #ff6b6b; +} + +.hwf-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.35rem; + border: 1px solid var(--hwf-border); + border-radius: 999px; + background: var(--hwf-surface); + width: fit-content; +} + +.hwf-tab { + border: 0; + border-radius: 999px; + padding: 0.55rem 0.9rem; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + font-weight: 650; +} + +.hwf-tab.is-active { + background: var(--color-primary, #6d7cff); + color: var(--color-primary-foreground, white); +} + +.hwf-panel { + display: grid; + gap: 1rem; +} + +.hwf-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); +} + +.hwf-stat-content { + padding: 1rem; +} + +.hwf-stat-value { + margin-top: 0.35rem; + font-size: 2.2rem; + line-height: 1; + font-weight: 800; + letter-spacing: -0.04em; +} + +.hwf-stat-help { + margin-top: 0.35rem; + color: var(--color-muted-foreground); + font-size: 0.85rem; +} + +.hwf-two-col, +.hwf-approval-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; +} + +.hwf-definition-card, +.hwf-approval-card, +.hwf-run-row, +.hwf-artifact-card, +.hwf-inspector { + overflow: hidden; +} + +.hwf-pill { + border: 1px solid var(--hwf-border); + border-radius: 999px; + padding: 0.18rem 0.5rem; + background: color-mix(in srgb, var(--color-card), white 4%); + font-size: 0.78rem; +} + +.hwf-section-title { + margin-top: 1rem; + margin-bottom: 0.5rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +.hwf-consequence { + border-left: 3px solid var(--color-primary, #6d7cff); + padding-left: 0.75rem; + font-weight: 650; +} + +.hwf-run-box { + display: grid; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.hwf-run-box textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + background: color-mix(in srgb, var(--color-card), black 14%); + color: inherit; + padding: 0.75rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.hwf-run-result { + margin-top: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.hwf-run-grid { + display: grid; + grid-template-columns: minmax(12rem, 1.1fr) minmax(10rem, 0.7fr) minmax(0, 1.4fr) max-content; + gap: 0.75rem; + align-items: center; +} + +.hwf-run-main, +.hwf-artifact-title, +.hwf-run-signals, +.hwf-run-tail, +.hwf-summary-row, +.hwf-run-id, +.hwf-waiting-on { + min-width: 0; +} + +.hwf-run-main strong, +.hwf-run-main p, +.hwf-artifact-title h2, +.hwf-artifact-title p, +.hwf-run-id, +.hwf-waiting-on { + overflow: hidden; + text-overflow: ellipsis; +} + +.hwf-run-main strong, +.hwf-run-main p, +.hwf-run-id, +.hwf-waiting-on { + display: block; + white-space: nowrap; +} + +.hwf-run-id { + display: block; + max-width: 100%; + line-height: 1.25; +} + +.hwf-run-signals { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.hwf-run-signals .hwf-pill { + flex: 0 0 auto; +} + +.hwf-waiting-on { + flex: 1 1 auto; +} + +.hwf-run-tail { + display: flex; + flex-wrap: nowrap; + justify-content: flex-end; + align-items: center; + gap: 0.5rem; +} + +.hwf-run-artifacts { + border: 1px solid var(--hwf-border); + border-radius: var(--radius-lg, 0.75rem); + background: var(--hwf-surface); + overflow: hidden; +} + +.hwf-run-artifacts-summary { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem; + cursor: pointer; + list-style: none; +} + +.hwf-run-artifacts-summary::-webkit-details-marker { + display: none; +} + +.hwf-run-artifacts-summary::before { + content: "▸"; + flex: 0 0 auto; + color: var(--color-muted-foreground); +} + +.hwf-run-artifacts[open] .hwf-run-artifacts-summary::before { + content: "▾"; +} + +.hwf-run-artifacts[open] .hwf-collapse-hint { + color: transparent; + position: relative; +} + +.hwf-run-artifacts[open] .hwf-collapse-hint::after { + content: "Collapse"; + color: var(--color-muted-foreground); + position: absolute; + inset: 0; +} + +.hwf-run-artifacts-body { + border-top: 1px solid var(--hwf-border); + padding: 1rem; +} + +.hwf-collapse-hint { + flex: 0 0 auto; + color: var(--color-muted-foreground); + font-size: 0.8rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.hwf-artifact-card { + margin-top: 0.75rem; +} + +.hwf-artifact-summary dl { + display: grid; + gap: 0.4rem; + margin: 0.75rem 0; +} + +.hwf-summary-row { + display: grid; + grid-template-columns: minmax(8rem, 0.28fr) minmax(0, 1fr); + gap: 0.75rem; + padding: 0.45rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--hwf-border), transparent 35%); +} + +.hwf-summary-row dt { + color: var(--color-muted-foreground); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.72rem; +} + +.hwf-summary-row dd { + margin: 0; + overflow-wrap: anywhere; +} + +.hwf-run-approval { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + padding: 0.75rem; + margin-bottom: 0.5rem; + background: var(--hwf-surface); +} + +.hwf-raw-json summary { + cursor: pointer; + color: var(--color-muted-foreground); +} + +.hwf-approval-actions { + margin-top: 0.9rem; +} + +.hwf-review-actions { + display: grid; + gap: 0.75rem; +} + +.hwf-review-actions input { + width: min(100%, 42rem); +} + +.hwf-review-action-row { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} + +.hwf-review-action-row button { + letter-spacing: normal; + text-transform: none; +} + +.hwf-approval-actions input { + min-width: 14rem; +} + +.hwf-edit-output-box { + width: 100%; + display: grid; + gap: 0.55rem; +} + +.hwf-edit-output-textarea { + width: 100%; + min-height: 13rem; + resize: vertical; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 14px; + background: rgba(2, 6, 23, 0.62); + color: #e5edf7; + padding: 0.75rem 0.85rem; + font: 0.88rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.hwf-step-id-callout { + display: inline-flex; + align-items: center; + gap: 0.55rem; + width: fit-content; + margin: 0 0 0.85rem; + padding: 0.42rem 0.62rem; + border: 1px solid color-mix(in srgb, var(--color-primary), var(--hwf-border) 45%); + border-radius: var(--radius-md, 0.5rem); + background: color-mix(in srgb, var(--color-primary), transparent 88%); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary), transparent 82%); +} + +.hwf-step-id-callout span { + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-muted-foreground); +} + +.hwf-step-id-callout code { + font-size: 0.92rem; + font-weight: 900; + color: var(--color-foreground); +} + +.hwf-selection-actions { + display: grid; + gap: 0.75rem; +} + +.hwf-selection-options { + display: grid; + gap: 0.6rem; +} + +.hwf-selection-option { + display: grid; + grid-template-columns: 1.25rem minmax(0, 1fr); + gap: 0.75rem; + align-items: flex-start; + padding: 0.75rem; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + background: var(--hwf-surface); + cursor: pointer; +} + +.hwf-selection-option input[type="radio"] { + min-width: 0; + width: 1rem; + inline-size: 1rem; + margin-top: 0.15rem; + accent-color: var(--color-primary); + cursor: pointer; +} + +.hwf-selection-option.is-selected { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary), transparent 90%); +} + +.hwf-selection-option-body { + display: grid; + gap: 0.25rem; +} + +.hwf-structured-form { + display: grid; + gap: 0.75rem; + max-width: 56rem; +} + +.hwf-structured-field { + display: grid; + gap: 0.35rem; + padding: 0.75rem; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + background: var(--hwf-surface); +} + +.hwf-structured-field-label { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.hwf-structured-field-type, +.hwf-required { + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-muted-foreground); +} + +.hwf-required { + color: var(--color-primary); +} + +.hwf-structured-field input, +.hwf-structured-field select, +.hwf-structured-field textarea, +.hwf-raw-json textarea { + width: 100%; + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + background: rgba(2, 6, 23, 0.62); + color: #e5edf7; + padding: 0.62rem 0.72rem; +} + +.hwf-structured-field input[type="checkbox"] { + width: auto; + justify-self: start; +} + +.hwf-approval-card pre, +.hwf-approval-detail pre, +.hwf-artifact-card pre, +.hwf-inspector pre, +.hwf-definition-card pre { + max-height: 18rem; + overflow: auto; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + background: color-mix(in srgb, var(--color-card), black 14%); + padding: 0.75rem; + white-space: pre-wrap; + font-size: 0.78rem; +} + +.hwf-markdown-preview, +.hwf-text-preview, +.hwf-html-preview, +.hwf-media-preview, +.hwf-file-reference, +.hwf-external-reference, +.hwf-custom-render-fallback, +.hwf-diff-preview { + margin: 0.75rem 0; + line-height: 1.45; + background: color-mix(in srgb, var(--hwf-surface), var(--color-primary) 4%); + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + padding: 0.75rem; +} + +.hwf-markdown-preview { + max-height: 30rem; + overflow: auto; +} + +.hwf-markdown-preview h2, +.hwf-markdown-preview h3, +.hwf-markdown-preview h4 { + margin: 1.15rem 0 0.45rem; + line-height: 1.18; + font-weight: 850; + letter-spacing: -0.035em; + color: var(--color-foreground); +} + +.hwf-markdown-preview h2:first-child, +.hwf-markdown-preview h3:first-child, +.hwf-markdown-preview h4:first-child { + margin-top: 0; +} + +.hwf-markdown-preview h2 { + font-size: 1.55em; + padding-bottom: 0.35rem; + border-bottom: 1px solid color-mix(in srgb, var(--hwf-border), var(--color-foreground) 18%); +} + +.hwf-markdown-preview h3 { + font-size: 1.34em; + padding-bottom: 0.2rem; + border-bottom: 1px solid color-mix(in srgb, var(--hwf-border), transparent 28%); +} + +.hwf-markdown-preview h4 { + font-size: 1.15em; +} + +.hwf-markdown-preview p { + margin: 0.35rem 0; +} + +.hwf-markdown-list-item { + margin: 0.2rem 0 0.2rem 0.5rem; +} + +.hwf-markdown-code { + white-space: pre-wrap; + overflow: auto; +} + +.hwf-html-preview iframe { + width: 100%; + min-height: 16rem; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-sm, 0.375rem); + background: white; +} + +.hwf-media-image, +.hwf-media-video { + max-width: 100%; + max-height: 30rem; + border-radius: var(--radius-sm, 0.375rem); +} + +.hwf-media-audio { + width: 100%; +} + +.hwf-file-reference, +.hwf-external-reference, +.hwf-custom-render-fallback { + display: grid; + gap: 0.45rem; +} + +.hwf-file-reference code { + overflow-wrap: anywhere; +} + +.hwf-diff-preview { + white-space: pre-wrap; + overflow: auto; +} + +.hwf-diff-line { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.hwf-diff-added { + color: var(--hwf-ok); +} + +.hwf-diff-removed { + color: var(--hwf-bad); +} + +.hwf-diff-hunk { + color: var(--hwf-warn); + font-weight: 700; +} + +.hwf-code-block { + max-height: 70vh; + overflow: auto; + white-space: pre; +} + +.hwf-generated-source { + margin: 0.75rem 0; +} + +.hwf-generated-code-block { + max-height: 28rem; +} + +.hwf-code-block code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.hwf-workflow-source-preview { + margin: 0.75rem 0; + border: 1px solid var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + background: color-mix(in srgb, var(--hwf-surface), var(--color-primary) 3%); + padding: 0.75rem; +} + +.hwf-workflow-source-preview summary, +.hwf-source-provenance summary { + cursor: pointer; + font-weight: 800; +} + +.hwf-workflow-source-preview .hwf-code-block { + margin-top: 0.75rem; +} + +.hwf-source-provenance { + margin-top: 0.75rem; +} + +.hwf-code-keyword { + color: var(--hwf-code-keyword); + font-weight: 600; +} + +.hwf-code-decorator, +.hwf-code-number { + color: var(--hwf-code-keyword); +} + +.hwf-code-string { + color: var(--hwf-code-string); +} + +.hwf-code-comment { + color: var(--hwf-code-comment); + font-style: italic; +} + +.hwf-dag { + display: grid; + gap: 1rem; +} + +.hwf-dag-graph { + position: relative; + overflow: auto; + min-height: 15rem; + padding: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1rem; + background: + radial-gradient(circle at 18% 24%, rgba(113, 112, 255, 0.14), transparent 28rem), + radial-gradient(circle at 72% 68%, rgba(16, 185, 129, 0.08), transparent 24rem), + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 1.5rem 4rem rgba(0, 0, 0, 0.22); +} + +.hwf-dag-svg, +.hwf-dag-edge-svg { + display: block; + min-width: 100%; + overflow: visible; +} + +.hwf-dag-edge-svg marker path { + fill: #9da3ff; +} + +.hwf-dag-edge-line { + fill: none; + stroke: rgba(157, 163, 255, 0.62); + stroke-width: 2.4; + stroke-linecap: round; + opacity: 0.92; + filter: drop-shadow(0 0 8px rgba(113, 112, 255, 0.32)); +} + +.hwf-dag-edge-active { + stroke: #f7f8ff; + stroke-width: 3.2; + opacity: 1; + filter: drop-shadow(0 0 10px rgba(157, 163, 255, 0.75)); +} + +.hwf-dag-layer { + position: relative; + z-index: 1; +} + +.hwf-dag-svg-node { + cursor: pointer; + outline: none; +} + +.hwf-dag-svg-node:focus-visible .hwf-dag-node-bg, +.hwf-dag-svg-node:hover .hwf-dag-node-bg, +.hwf-dag-node-selected .hwf-dag-node-bg { + stroke: #aab1ff; + stroke-width: 1.6; + filter: drop-shadow(0 0 18px rgba(113, 112, 255, 0.38)); +} + +.hwf-dag-node-shadow { + fill: rgba(0, 0, 0, 0.28); + transform: translate(0, 5px); + filter: blur(8px); +} + +.hwf-dag-node-bg, +.hwf-dag-node { + stroke: rgba(255, 255, 255, 0.105); + stroke-width: 1; + fill: rgba(17, 24, 39, 0.94); +} + +.hwf-dag-node-kind-gather .hwf-dag-node-bg { + stroke: rgba(16, 185, 129, 0.42); +} + +.hwf-dag-node-kind-child_workflow .hwf-dag-node-bg { + stroke: rgba(96, 165, 250, 0.58); + stroke-dasharray: 5 3; +} + +.hwf-dag-node-child-inline .hwf-dag-node-bg { + fill: rgba(15, 23, 42, 0.96); + stroke: rgba(96, 165, 250, 0.34); +} + +.hwf-dag-node-child-inline .hwf-dag-kind { + fill: #93c5fd; +} + +.hwf-dag-node-child-inline .hwf-dag-label { + font-size: 0.84rem; +} + +.hwf-dag-node-child-inline .hwf-dag-node-shadow { + opacity: 0.65; +} + +.hwf-dag-node-kind-approval .hwf-dag-node-bg { + stroke: rgba(245, 158, 11, 0.52); +} + +.hwf-dag-kind { + fill: #8a8f98; + color: #8a8f98; + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; +} + +.hwf-dag-label { + fill: #f7f8f8; + font-size: 0.92rem; + font-weight: 620; + letter-spacing: -0.02em; +} + +.hwf-dag-artifacts { + fill: #8a8f98; + font-size: 0.68rem; + font-weight: 500; +} + +.hwf-dag-status-bg { + fill: rgba(16, 185, 129, 0.13); + stroke: rgba(16, 185, 129, 0.28); +} + +.hwf-dag-status-text { + fill: #b7f7d4; + font-size: 0.57rem; + font-weight: 700; + text-transform: uppercase; +} + +.hwf-dag-inspector { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1rem; + padding: 1rem; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.02)); +} + +.hwf-child-workflow-summary { + display: grid; + gap: 0.6rem; + padding: 0.85rem; + border: 1px dashed rgba(96, 165, 250, 0.38); + border-radius: 0.85rem; + background: rgba(96, 165, 250, 0.08); +} + +.hwf-child-dag-expanded { + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; + padding: 0.85rem; + border: 1px solid rgba(96, 165, 250, 0.22); + border-radius: 0.85rem; + background: rgba(15, 23, 42, 0.52); +} + +.hwf-approval-dialog { + width: min(72rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + margin: auto; + padding: 0; + border: 0; + background: transparent; + color: inherit; + overflow: visible; +} + +.hwf-approval-dialog::backdrop { + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(6px); +} + +.hwf-approval-detail { + width: 100%; + max-height: calc(100vh - 2rem); + overflow: hidden; + border: 1px solid var(--hwf-border); + border-radius: 1rem; + background: var(--color-background); + box-shadow: 0 2rem 5rem rgba(0, 0, 0, 0.72); + display: flex; + flex-direction: column; +} + +.hwf-detail-header { + flex: 0 0 auto; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--hwf-border); + background: color-mix(in srgb, var(--color-background), black 8%); +} + +.hwf-approval-detail-body { + min-height: 0; + overflow: auto; + padding: 1.25rem; + display: grid; + align-content: start; + gap: 1rem; +} + +.hwf-close-button { + flex: 0 0 auto; + min-width: auto; + padding: 0.5rem 0.8rem; + line-height: 1; +} + +.hwf-timeline { + display: grid; + gap: 0.5rem; + margin: 0; + padding-left: 1.2rem; +} + +.hwf-timeline li { + display: flex; + justify-content: space-between; + gap: 0.75rem; + border-bottom: 1px solid var(--hwf-border); + padding-bottom: 0.45rem; +} + +.hwf-empty { + padding: 2rem; + color: var(--color-muted-foreground); + text-align: center; +} + +@media (max-width: 920px) { + .hwf-two-col, + .hwf-approval-hero, + .hwf-run-grid { + grid-template-columns: 1fr; + } + + .hwf-tabs { + border-radius: var(--radius-lg, 0.75rem); + width: auto; + } +} diff --git a/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/manifest.json b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/manifest.json new file mode 100644 index 0000000..58fe533 --- /dev/null +++ b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "hermes-workflows-approvals", + "label": "Workflows", + "description": "Workflow observability, review queue, human input, and approval controls for hermes-workflows SQLite DBs.", + "icon": "Workflow", + "version": "0.0.1rc1", + "tab": { "path": "/workflows", "position": "after:kanban" }, + "slots": [], + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "plugin_api.py" +} diff --git a/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/plugin_api.py b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/plugin_api.py new file mode 100644 index 0000000..b567cb7 --- /dev/null +++ b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/dashboard/plugin_api.py @@ -0,0 +1,2361 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import re +import sqlite3 +import sys +import time +import uuid +from pathlib import Path +from typing import Any + +from hermes_workflows import ApprovalDecisionInput, WorkflowEngine +from hermes_workflows.approvals import strip_client_controlled_provenance, validate_revision_response +from hermes_workflows.artifacts import artifact_descriptor, workflow_source_preview +from hermes_workflows.hermes_plugin_approvals import ( + _configured_dbs, + _next_step_for_receipt, + _redact, + _receipt_to_payload, + _revision_schema_for_response, + _source_for_normalized_revision_replay, + approval_view_to_dict, +) +from hermes_workflows.revision_validation import RevisionActionValidationError +from hermes_workflows.workflow_loading import load_workflow_ref + +try: # FastAPI is provided by Hermes Agent's dashboard process. + import fastapi as _fastapi +except Exception: # pragma: no cover - keeps direct unit imports dependency-light. + _fastapi = None + + +class _FallbackHTTPException(Exception): + def __init__(self, status_code: int, detail: Any): + super().__init__(str(detail)) + self.status_code = status_code + self.detail = detail + + +class _FallbackAPIRouter: # minimal decorator shim for tests without FastAPI. + def get(self, *_args: Any, **_kwargs: Any): + return lambda fn: fn + + def post(self, *_args: Any, **_kwargs: Any): + return lambda fn: fn + + +HTTPException = _fastapi.HTTPException if _fastapi is not None else _FallbackHTTPException +APIRouter = _fastapi.APIRouter if _fastapi is not None else _FallbackAPIRouter + +router = APIRouter() + + +def _workflow_project_root_for_db(db_path: str | Path) -> Path | None: + path = Path(db_path).expanduser().resolve() + if path.parent.name == ".hermes": + return path.parent.parent + return None + + +def _ensure_workflow_project_on_path(db_path: str | Path) -> Path | None: + """Make project-local workflow modules importable for trusted resume. + + Workflow projects often keep state in /.hermes/workflows.sqlite + while workflow modules live under . The dashboard process may be + launched from Hermes or the runtime repo, so raw engine resume can otherwise + see the DB row but fail to import the stored workflow_ref. + """ + + project_root = _workflow_project_root_for_db(db_path) + if project_root is None or not project_root.exists(): + return None + root = str(project_root) + if root not in sys.path: + sys.path.insert(0, root) + return project_root + + +def _int(value: Any, *, default: int, minimum: int = 1, maximum: int = 100) -> int: + try: + parsed = int(value) + except Exception: + parsed = default + return max(minimum, min(maximum, parsed)) + + +def _slug(value: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower() + return cleaned or "workflow" + + +def _load_workflow(ref: str) -> Any: + return load_workflow_ref(ref) + + +def _strip_internal_fields(value: Any) -> Any: + """Remove local-only implementation details before returning browser JSON.""" + if isinstance(value, dict): + return {key: _strip_internal_fields(item) for key, item in value.items() if key != "db_path"} + if isinstance(value, list): + return [_strip_internal_fields(item) for item in value] + return value + + +def _looks_like_local_path(value: str) -> bool: + cleaned = value.strip() + return cleaned.startswith(("/", "./", "../", "~", "file://")) or re.match(r"^[A-Za-z]:[\\/]", cleaned) is not None + + +def _path_environment_relation(value: Any) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + worker_path = Path(value).expanduser().resolve() + dashboard_path = Path.cwd().resolve() + except Exception: + return "unknown" + return "same_as_dashboard" if worker_path == dashboard_path else "different_from_dashboard" + + +def _dashboard_runtime_label(state: dict[str, Any]) -> str: + primary = str(state.get("primary") or "unknown") + command = state.get("command") if isinstance(state.get("command"), dict) else {} + worker = state.get("worker") if isinstance(state.get("worker"), dict) else {} + worker_id = worker.get("worker_id") or command.get("claimed_by") + if primary == "waiting_on_human": + return "Waiting on Skylar" + if primary == "queued": + return "Queued — no worker has claimed this yet" + if primary == "running" and worker_id: + return f"Running — claimed by {worker_id}" + if primary == "running": + return "Running" + if primary == "stuck": + reason = str(state.get("reason") or command.get("last_error") or "unknown reason").replace("_", " ") + return f"Stuck — {reason}" + return str(state.get("label") or primary.replace("_", " ").capitalize()) + + +def _dashboard_worker_metadata(metadata: Any) -> dict[str, Any]: + if not isinstance(metadata, dict): + return {} + safe: dict[str, Any] = {} + if metadata.get("source_db_name") is not None: + safe["source_db_name"] = str(metadata.get("source_db_name")) + if metadata.get("allowed_workflow_refs_count") is not None: + try: + safe["allowed_workflow_refs_count"] = int(metadata.get("allowed_workflow_refs_count")) + except (TypeError, ValueError): + pass + package_fingerprint = metadata.get("package_fingerprint") + if isinstance(package_fingerprint, dict): + safe_package: dict[str, Any] = {} + for key in ("hermes_workflows", "python"): + if package_fingerprint.get(key) is not None: + safe_package[key] = str(package_fingerprint.get(key)) + if safe_package: + safe["package_fingerprint"] = safe_package + active_command = metadata.get("active_command") + if isinstance(active_command, dict): + safe_active: dict[str, Any] = {} + for key in ("command_id", "command_type", "command_key", "workflow_id"): + if active_command.get(key) is not None: + safe_active[key] = active_command.get(key) + if safe_active: + safe["active_command"] = safe_active + return safe + + +def _dashboard_runtime_state(runtime_state: Any, *, db_alias: str | None) -> dict[str, Any] | None: + """Sanitize runtime_state for browser packets. + + The engine projection is intentionally detailed enough for local CLI/debugging. + The dashboard should show the configured source alias and useful lease/worker + facts without exposing raw SQLite paths, worker cwd, or Python executable paths. + """ + + if not isinstance(runtime_state, dict): + return None + state = _strip_internal_fields(_redact(runtime_state)) + if not isinstance(state, dict): + return None + state = dict(state) + if db_alias: + state["source"] = {"alias": db_alias} + command = state.get("command") + if isinstance(command, dict): + safe_command = dict(command) + safe_command.pop("claimed_by_instance_id", None) + state["command"] = safe_command + worker = state.get("worker") + if isinstance(worker, dict): + safe_worker = dict(worker) + environment = safe_worker.get("environment") if isinstance(safe_worker.get("environment"), dict) else {} + safe_worker["environment"] = { + "hostname": environment.get("hostname"), + "pid": environment.get("pid"), + "platform": environment.get("platform"), + "python_version": environment.get("python_version"), + "python_executable": Path(str(environment.get("python_executable") or "")).name or None, + "hermes_version": environment.get("hermes_version"), + "agent_runner_enabled": bool(environment.get("agent_runner_enabled")), + "workspace_relation": _path_environment_relation(environment.get("cwd")), + } + safe_worker["metadata"] = _dashboard_worker_metadata(safe_worker.get("metadata")) + state["worker"] = safe_worker + state["label"] = _dashboard_runtime_label(state) + return state + + +def _active_worker_warning(engine: WorkflowEngine) -> dict[str, Any] | None: + now = int(time.time()) + try: + with engine._connect() as con: + rows = con.execute( + """ + SELECT worker_id, worker_instance_id, status, heartbeat_expires_at, cwd + FROM workflow_workers + WHERE status != 'stopped' AND heartbeat_expires_at > ? + ORDER BY last_heartbeat_at DESC + """, + (now,), + ).fetchall() + except sqlite3.OperationalError as exc: + if "no such table" in str(exc): + return None + raise + if len(rows) <= 1: + return None + cwd_counts: dict[str, int] = {} + for row in rows: + cwd = str(row["cwd"] or "") + if cwd: + cwd_counts[cwd] = cwd_counts.get(cwd, 0) + 1 + same_workspace_count = max(cwd_counts.values(), default=0) + message = "Multiple active workflow workers are heartbeating for this state source. Leases should fence duplicate claims, but verify the worker deployment." + if same_workspace_count > 1: + message = "Multiple active workflow workers are heartbeating for this state source and workspace. Leases should fence duplicate claims, but verify the worker deployment." + return { + "level": "warning", + "code": "multiple_active_workers", + "message": message, + "active_worker_count": len(rows), + "worker_ids": sorted({str(row["worker_id"]) for row in rows}), + } + + +def _redact_artifact_local_refs(value: Any) -> Any: + """Return artifact values unchanged for local/operator dashboard review.""" + + return value + + +def _operator_approval_artifact(value: Any) -> Any: + """Shape approval artifacts around the one decision being requested. + + Older workflow dry-run approvals sometimes persist the full workflow packet, + including an internal `approval_queue` for possible future actions. Rendering + that field inside an approval card makes it look like the current approval is + a bundled send/archive/writeback decision. Normalize that legacy packet into + the same single-review artifact future workflow runs emit. + """ + + if ( + isinstance(value, dict) + and "approval_queue" in value + and isinstance(value.get("summary"), dict) + and isinstance(value.get("items"), list) + ): + summary = dict(value.get("summary") or {}) + return { + "kind": "email_ops_dry_run_review", + "mode": value.get("mode", "dry_run"), + "review_scope": "classification_review_only", + "decision_requested": "Approve whether this dry-run classification packet is useful enough to continue; this does not send, archive, schedule, or write entities.", + "summary": summary, + "items": value.get("items", []), + "entity_proposals": value.get("entity_proposals", []), + "side_effect_ledger": value.get("side_effect_ledger", {}), + "deferred_action_counts": { + "drafts_requiring_send_review": summary.get("draft_artifacts", len(value.get("draft_artifacts", []))), + "followups_requiring_separate_approval": len(value.get("follow_up_recommendations", [])), + "archive_candidates_requiring_policy_or_approval": len(value.get("archive_candidates", [])), + "entity_proposals_requiring_separate_writeback_review": summary.get("entity_proposals", len(value.get("entity_proposals", []))), + }, + "notes": value.get("notes", []), + } + if isinstance(value, dict) and value.get("kind") in {"email_draft_send_approval", "entity_extraction_approval"}: + return {key: item for key, item in value.items() if key != "atomic"} + return value + + +def _workflow_source_preview(value: Any) -> dict[str, Any] | None: + preview = workflow_source_preview(value) + return dict(preview) if preview is not None else None + + +def _source_step_id_from_key(key: Any) -> str | None: + text = str(key or "").strip() + if not text: + return None + return _agent_request_step_id(_approval_step_id(text) or text) + + +def _workflow_source_artifact( + value: Any, + *, + artifact_id: str, + workflow_id: str, + title: str, + source: dict[str, Any], + metadata: Any = None, +) -> dict[str, Any] | None: + preview = _workflow_source_preview(value) + if preview is None: + return None + artifact: dict[str, Any] = { + "id": artifact_id, + "workflow_id": workflow_id, + "kind": "workflow_source", + "title": title, + "source": source, + "source_step_id": _source_step_id_from_key(source.get("key")), + "preview": _redact_artifact_local_refs(preview), + "artifact_render": _artifact_descriptor(value), + } + if metadata is not None: + artifact["metadata"] = metadata + return artifact + + +def _artifact_descriptor(artifact: Any) -> dict[str, Any]: + """Return the shared framework artifact render descriptor.""" + + return dict(artifact_descriptor(artifact)) + + +def _runtime_semantics() -> dict[str, Any]: + return { + "execution_environment": "Workflow code is imported and executed in the Python process that owns the WorkflowEngine for the configured workflow state source. The dashboard API route runs that engine locally for trusted sources; review responses and approval decisions return observable workflow status and command history.", + "state_source": "The dashboard uses the configured workflow DB alias as its state source. Raw SQLite paths are intentionally hidden from browser responses; the review UI shows the active source instead of making users choose debug databases.", + "agent_requests": "Worker-capable steps are queued, claimed, executed, and completed with step output/provenance. agent(...) calls run through the engine's configured agent_runner when present; runner requests and live responses are persisted as step metadata for replay.", + "review_responses": "Human input requests are completed by trusted review surfaces setting typed step output with provenance.", + "approval_decisions": "Approval gates are approve/reject review requests for risky transitions, not a separate place operators need to hunt for work.", + "artifacts": "Human input, approval, and run artifacts are persisted in workflow history and returned as review previews plus artifact_render descriptors. The dashboard does not host local media files.", + } + + +def _status_packet( + engine: WorkflowEngine, + workflow_id: str, + *, + recent_events: int, + commands: str = "recent", + command_limit: int, + command_payload_chars: int, + db_alias: str | None = None, +) -> dict[str, Any]: + packet = _redact( + engine.workflow_status( + workflow_id, + recent_events=recent_events, + command_history=commands, + command_limit=command_limit, + command_payload_chars=command_payload_chars, + ) + ) + packet = _redact_artifact_local_refs(_strip_internal_fields(packet)) + runtime_state = _dashboard_runtime_state(packet.get("runtime_state"), db_alias=db_alias) + if runtime_state is not None: + packet["runtime_state"] = runtime_state + packet["recent_events"] = packet.get("events", []) + packet["run_id"] = packet.get("workflow_id") + return packet + + + +def _workflow_count_for_dashboard_db(path: str) -> int: + db_path = Path(path).expanduser() + if not db_path.exists(): + return 0 + try: + return len(WorkflowEngine(str(db_path), read_only=True).list_workflows()) + except Exception: + return 0 + + +def _active_dashboard_db(configured: dict[str, str]) -> tuple[str, str] | None: + if not configured: + return None + if len(configured) == 1: + return next(iter(configured.items())) + + existing = [(alias, path) for alias, path in configured.items() if Path(path).expanduser().exists()] + populated = [(alias, path) for alias, path in existing if _workflow_count_for_dashboard_db(path) > 0] + if len(populated) == 1: + return populated[0] + if "default" in configured: + return "default", configured["default"] + if len(existing) == 1: + return existing[0] + return None + + +def _resolve_dashboard_db(db: Any = None) -> tuple[str, str]: + """Resolve dashboard requests through configured aliases only. + + The dashboard runs inside the Hermes process, so accepting explicit paths + over HTTP would turn a UI route into arbitrary local SQLite read/write. + Operators can add aliases in plugin config instead. + """ + configured = _configured_dbs() + raw = str(db or "").strip() + if not raw: + active = _active_dashboard_db(configured) + if active: + return active + raise HTTPException(status_code=400, detail="Select a configured DB alias.") + if raw not in configured: + raise HTTPException(status_code=400, detail="Dashboard API only accepts configured DB aliases.") + return raw, configured[raw] + + +def _append_catalog_entries(entries: list[dict[str, Any]], configured: Any) -> None: + if isinstance(configured, str): + try: + configured = json.loads(configured) + except Exception: + return + if isinstance(configured, list): + entries.extend(item for item in configured if isinstance(item, dict)) + elif isinstance(configured, dict): + raw_items = configured.get("workflows", configured) + if isinstance(raw_items, str): + try: + raw_items = json.loads(raw_items) + except Exception: + return + if isinstance(raw_items, list): + entries.extend(item for item in raw_items if isinstance(item, dict)) + elif isinstance(raw_items, dict): + for key, value in raw_items.items(): + if isinstance(value, dict): + entries.append({"id": str(key), **value}) + + +def _raw_catalog_entries() -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + env_catalog = os.getenv("HERMES_WORKFLOWS_CATALOG") + if env_catalog: + _append_catalog_entries(entries, env_catalog) + try: + from hermes_cli.config import cfg_get, load_config # type: ignore + + config = load_config() + configured = cfg_get(config, "plugins", "entries", "hermes-workflows-approvals", "workflow_catalog", default=[]) + _append_catalog_entries(entries, configured) + except Exception: + pass + return entries + + +def _workflow_catalog() -> list[dict[str, Any]]: + seen: set[str] = set() + catalog: list[dict[str, Any]] = [] + for entry in _raw_catalog_entries(): + ref = str(entry.get("workflow_ref") or entry.get("ref") or "").strip() + if not ref: + continue + name = str(entry.get("name") or ref.rsplit(":", 1)[-1].replace("_", " ")).strip() + definition_id = str(entry.get("id") or _slug(name or ref)).strip() + if definition_id in seen: + continue + seen.add(definition_id) + input_schema = entry.get("input_schema") or entry.get("schema") or {"type": "object", "properties": {}} + defaults = entry.get("input_defaults") or entry.get("defaults") or {} + tags = entry.get("tags") or [] + if not isinstance(tags, list): + tags = [str(tags)] + catalog.append( + { + "id": definition_id, + "name": name, + "description": str(entry.get("description") or ""), + "workflow_ref": ref, + "input_schema": input_schema, + "input_defaults": defaults, + "tags": tags, + "runnable": True, + "run_button_label": str(entry.get("run_button_label") or "Run workflow"), + } + ) + return catalog + + +def _definition_by_id(definition_id: str, engine: WorkflowEngine | None = None) -> dict[str, Any]: + catalog = _catalog_for_engine(engine) if engine is not None else _workflow_catalog() + for definition in catalog: + if definition["id"] == definition_id: + return definition + raise HTTPException(status_code=404, detail=f"Unknown workflow definition: {definition_id}") + + +def _catalog_for_engine(engine: WorkflowEngine | None) -> list[dict[str, Any]]: + """Return configured runnable workflows plus inferred historical refs. + + Configured catalog entries carry richer schema/description. Inferred entries + keep the UX useful on a live DB that already has runs but has not yet had a + formal catalog configured. + """ + catalog = list(_workflow_catalog()) + if engine is None: + return catalog + seen_refs = {str(item.get("workflow_ref")) for item in catalog} + seen_ids = {str(item.get("id")) for item in catalog} + for run in _all_runs(engine, limit=500): + ref = str(run.get("workflow_ref") or "").strip() + if not ref or ref in seen_refs: + continue + base_id = _slug(ref.rsplit(":", 1)[-1]) + definition_id = base_id + suffix = 2 + while definition_id in seen_ids: + definition_id = f"{base_id}-{suffix}" + suffix += 1 + seen_refs.add(ref) + seen_ids.add(definition_id) + catalog.append( + { + "id": definition_id, + "name": ref.rsplit(":", 1)[-1].replace("_", " ").title(), + "description": "Inferred from existing run history. Add workflow_catalog config to make this runnable.", + "workflow_ref": ref, + "input_schema": {"type": "object", "properties": {}}, + "input_defaults": {}, + "tags": ["inferred"], + "runnable": False, + "run_button_label": "View history", + } + ) + return catalog + + +def _all_runs(engine: WorkflowEngine, *, status: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + return engine.list_workflows(status=status)[: _int(limit, default=100, maximum=500)] + + +def _runs_for_ref(engine: WorkflowEngine, workflow_ref: str, *, status: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + return [row for row in _all_runs(engine, status=status, limit=limit) if row.get("workflow_ref") == workflow_ref] + + +def _run_counts(runs: list[dict[str, Any]]) -> dict[str, Any]: + by_status: dict[str, int] = {} + for run in runs: + status = str(run.get("status") or "unknown") + by_status[status] = by_status.get(status, 0) + 1 + return {"total": len(runs), "by_status": by_status} + + +def _runtime_state_for_run(engine: WorkflowEngine, workflow_id: Any, *, db_alias: str, cache: dict[str, dict[str, Any] | None]) -> dict[str, Any] | None: + run_id = str(workflow_id or "") + if not run_id: + return None + if run_id not in cache: + try: + status = engine.workflow_status(run_id, recent_events=1, command_history="recent", command_limit=5, command_payload_chars=500) + cache[run_id] = _dashboard_runtime_state(status.get("runtime_state"), db_alias=db_alias) + except Exception: + cache[run_id] = None + return cache[run_id] + + +def _runs_with_runtime_state(engine: WorkflowEngine, rows: list[dict[str, Any]], *, db_alias: str) -> list[dict[str, Any]]: + cache: dict[str, dict[str, Any] | None] = {} + enriched: list[dict[str, Any]] = [] + for row in rows: + item = dict(_strip_internal_fields(row)) + runtime_state = _runtime_state_for_run(engine, item.get("workflow_id"), db_alias=db_alias, cache=cache) + if runtime_state is not None: + item["runtime_state"] = runtime_state + enriched.append(item) + return enriched + + +def _include_review_card_for_status(card: dict[str, Any], requested_status: str | None) -> bool: + if requested_status is None: + return True + card_status = str(card.get("status") or "") + return card_status == requested_status + + +def _review_cards(engine: WorkflowEngine, *, db_alias: str, status: str | None, limit: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + runtime_cache: dict[str, dict[str, Any] | None] = {} + approvals: list[dict[str, Any]] = [] + source_status = "waiting" if status == "waiting" else None + for approval in engine.list_approvals(status=source_status): + payload = approval_view_to_dict(approval) + runtime_state = _runtime_state_for_run(engine, payload.get("workflow_id"), db_alias=db_alias, cache=runtime_cache) + card = _approval_card(payload, db_alias=db_alias, runtime_state=runtime_state) + if _include_review_card_for_status(card, status): + approvals.append(card) + if len(approvals) >= limit: + break + human_inputs: list[dict[str, Any]] = [] + remaining = max(0, limit - len(approvals)) + if remaining: + for step in engine.list_operator_steps(status=source_status): + payload = _strip_internal_fields(step) + runtime_state = _runtime_state_for_run(engine, payload.get("workflow_id"), db_alias=db_alias, cache=runtime_cache) + card = _operator_step_card(payload, db_alias=db_alias, runtime_state=runtime_state) + if _include_review_card_for_status(card, status): + human_inputs.append(card) + if len(human_inputs) >= remaining: + break + return approvals + human_inputs, approvals, human_inputs + + +def _definition_payload(definition: dict[str, Any], engine: WorkflowEngine) -> dict[str, Any]: + runs = _runs_for_ref(engine, str(definition["workflow_ref"]), limit=500) + latest = runs[0] if runs else None + return {**definition, "runs": _run_counts(runs), "latest_run": latest} + + +def _relative_source_path(path: str | None) -> str | None: + if not path: + return None + source_path = Path(path).expanduser().resolve() + for root in (Path.cwd().resolve(),): + try: + return str(source_path.relative_to(root)) + except ValueError: + continue + return source_path.name + + +def _workflow_source_payload(definition: dict[str, Any]) -> dict[str, Any]: + workflow_ref = str(definition["workflow_ref"]) + workflow = _load_workflow(workflow_ref) + try: + lines, line_start = inspect.getsourcelines(workflow) + except (OSError, TypeError) as exc: + raise HTTPException(status_code=404, detail=f"Workflow source is not inspectable: {workflow_ref}") from exc + if ":" in workflow_ref: + module_name, attr = workflow_ref.rsplit(":", 1) + else: + module_name = getattr(workflow, "__module__", workflow_ref) + attr = getattr(workflow, "__name__", None) + source_file = inspect.getsourcefile(workflow) or inspect.getfile(workflow) + code = "".join(lines) + return { + "definition": definition, + "workflow_ref": workflow_ref, + "language": "python", + "highlight_class": "language-python", + "code": code, + "location": { + "module": module_name, + "attribute": attr, + "file": _relative_source_path(source_file), + "line_start": line_start, + "line_end": line_start + len(lines) - 1, + }, + "runtime_semantics": _runtime_semantics(), + } + + +def _approval_step_id(key: str) -> str | None: + if not key: + return None + return key.split(":", 1)[1] if key.startswith("approval:") else key + + +def _agent_request_step_id(key: str) -> str | None: + if not key: + return None + # AgentRequested event keys use an outbox prefix (`agent:agent:`), + # while StepRequested and agent.completed signal payloads already carry the + # canonical step key (`agent:`). Strip only the outbox wrapper; stripping + # every `agent:` prefix splits one logical agent step into request/completion + # twins in the DAG. + return key.split(":", 1)[1] if key.startswith("agent:agent:") else key + + +def _signal_step_id(payload: dict[str, Any]) -> str | None: + signal_type = str(payload.get("signal_type") or "") + key = str(payload.get("key") or "") + if signal_type == "approval.decision": + return _approval_step_id(key) + if signal_type == "operator.response": + return _approval_step_id(key) + if signal_type == "agent.completed": + return _agent_request_step_id(key) + return None + + +def _dag_node_id_for_event(event: dict[str, Any]) -> str | None: + event_type = str(event.get("type") or "") + payload = event.get("payload") or {} + key = str(payload.get("key") or event.get("key") or "") + if event_type == "WorkflowStarted": + return "workflow:start" + if event_type in {"StepRequested", "StepCompleted", "StepFailed"}: + return key or None + if event_type == "GatherWaiting": + return key or None + if event_type in {"ChildWorkflowRequested", "ChildWorkflowCompleted", "ChildWorkflowFailed"}: + return key or None + if event_type == "ChildWorkflowGatherWaiting": + return key or None + if event_type == "ApprovalRequested": + return _approval_step_id(key) + if event_type == "AgentRequested": + return _agent_request_step_id(key) + if event_type == "WaitRequested": + return None + if event_type == "SignalReceived": + return _signal_step_id(payload) + if event_type == "WorkflowCompleted": + return "workflow:completed" + if event_type == "WorkflowFailed": + return "workflow:failed" + if event_type == "WorkflowCancelled": + return "workflow:cancelled" + return None + + +def _dag_node_kind(event_type: str) -> str: + if event_type.startswith("Step"): + return "step" + if event_type == "ApprovalRequested": + return "step" + if event_type == "AgentRequested": + return "step" + if event_type in {"SignalReceived", "WaitRequested"}: + return "step" + if event_type in {"GatherWaiting", "ChildWorkflowGatherWaiting"}: + return "gather" + if event_type.startswith("ChildWorkflow"): + return "child_workflow" + return "workflow" + + +def _dag_node_status(event_type: str, existing: str | None = None) -> str: + if event_type == "StepRequested": + return existing or "requested" + if event_type == "StepCompleted": + return "completed" + if event_type == "StepFailed": + return "failed" + if event_type == "ApprovalRequested": + return "waiting" + if event_type == "AgentRequested": + return existing or "waiting" + if event_type == "WaitRequested": + return existing or "waiting" + if event_type == "SignalReceived": + return "completed" + if event_type in {"GatherWaiting", "ChildWorkflowGatherWaiting"}: + return existing or "waiting" + if event_type == "ChildWorkflowRequested": + return existing or "requested" + if event_type == "ChildWorkflowCompleted": + return "completed" + if event_type == "ChildWorkflowFailed": + return "failed" + if event_type == "WorkflowCompleted": + return "completed" + if event_type == "WorkflowFailed": + return "failed" + if event_type == "WorkflowCancelled": + return "cancelled" + if event_type == "WorkflowStarted": + return "started" + return existing or "recorded" + + +def _dag_completion_mode(event_type: str, payload: dict[str, Any]) -> str | None: + if event_type.startswith("Step") and payload.get("completion_mode"): + return str(payload.get("completion_mode")) + if event_type == "ApprovalRequested": + kind = str(payload.get("kind") or "") + return "operator" if kind in {"human_input.request.v1", "operator.request.v1"} else "approval" + if event_type == "AgentRequested": + return "agent" + if event_type == "SignalReceived": + signal_type = str(payload.get("signal_type") or "") + if signal_type == "approval.decision": + return "approval" + if signal_type == "operator.response": + return "operator" + if signal_type == "agent.completed": + return "agent" + if event_type.startswith("Step"): + return "agent" + return None + + +def _dag_node_label(event_type: str, payload: dict[str, Any], event: dict[str, Any]) -> str: + public_label = _public_dag_label(payload) + if public_label: + return public_label + if event_type == "ApprovalRequested": + return str(payload.get("prompt") or payload.get("key") or event.get("key") or "Operator step") + if event_type == "AgentRequested": + return str(payload.get("key") or event.get("key") or "Agent step") + if event_type.startswith("ChildWorkflow"): + return str(payload.get("symbol") or payload.get("workflow_name") or payload.get("child_key") or event.get("key") or "Child workflow") + if event_type == "SignalReceived": + return str(payload.get("key") or event.get("key") or "Step output") + return str(payload.get("step_name") or event.get("key") or event_type) + + +def _public_dag_label(payload: dict[str, Any]) -> str | None: + for field in ("public_label", "public_name"): + value = payload.get(field) + if value: + return str(value) + args = payload.get("args") + if isinstance(args, list) and args and isinstance(args[0], dict): + for field in ("public_label", "public_name"): + value = args[0].get(field) + if value: + return str(value) + request = payload.get("request") + if isinstance(request, dict): + for field in ("public_label", "public_name"): + value = request.get(field) + if value: + return str(value) + artifact = payload.get("artifact") + if isinstance(artifact, dict): + for field in ("public_label", "public_name"): + value = artifact.get(field) + if value: + return str(value) + return None + + +def _artifact_node_id(artifact: dict[str, Any]) -> str | None: + source = artifact.get("source") or {} + key = source.get("key") + if not key: + if source.get("event") == "WorkflowCompleted": + return "workflow:completed" + return None + if artifact.get("kind") == "approval_artifact": + return _approval_step_id(str(key)) + return str(key) + + +def _payload_contains_value(container: Any, value: Any) -> bool: + """Return true when a request payload concretely carries a prior value.""" + + if container == value: + return True + if isinstance(container, str) and isinstance(value, dict): + haystack = container.strip() + required_strings = [ + str(value[key]).strip() + for key in ("title", "heading", "text", "task", "summary") + if isinstance(value.get(key), str) and len(str(value.get(key)).strip()) >= 4 + ] + if required_strings: + return all(item in haystack for item in required_strings) + meaningful_strings = [ + str(item).strip() + for key, item in value.items() + if key not in {"kind", "schema", "schema_descriptor", "timeout", "key"} + and isinstance(item, str) + and len(item.strip()) >= 4 + ] + return bool(meaningful_strings) and all(item in haystack for item in meaningful_strings) + if isinstance(container, str) and isinstance(value, (list, tuple)): + meaningful_items = [item for item in value if item not in (None, "", [], {})] + return bool(meaningful_items) and all(_payload_contains_value(container, item) for item in meaningful_items) + if isinstance(value, str): + needle = value.strip() + if isinstance(container, str): + haystack = container.strip() + return haystack == needle if len(needle) < 4 else needle in haystack + if isinstance(container, dict): + return any(_payload_contains_value(item, value) for item in container.values()) + if isinstance(container, (list, tuple)): + return any(_payload_contains_value(item, value) for item in container) + return False + if isinstance(value, dict): + meaningful_values = [ + item + for key, item in value.items() + if key not in {"kind", "schema", "schema_descriptor", "timeout", "key"} and item not in (None, "", [], {}) + ] + return bool(meaningful_values) and all(_payload_contains_value(container, item) for item in meaningful_values) + if isinstance(value, (list, tuple)): + meaningful_items = [item for item in value if item not in (None, "", [], {})] + return bool(meaningful_items) and all(_payload_contains_value(container, item) for item in meaningful_items) + if isinstance(container, dict): + return any(_payload_contains_value(item, value) for item in container.values()) + if isinstance(container, (list, tuple)): + return any(_payload_contains_value(item, value) for item in container) + return False + + +def _append_unique_match_value(values: list[Any], value: Any) -> None: + if value in (None, "", [], {}): + return + if value not in values: + values.append(value) + + +def _dag_match_values_from_payload(payload: dict[str, Any]) -> list[Any]: + values: list[Any] = [] + + def add_artifact(artifact: Any) -> None: + if not isinstance(artifact, dict): + return + added_content = False + value = artifact.get("value") + if value not in (None, "", [], {}): + _append_unique_match_value(values, value) + added_content = True + markdown = artifact.get("markdown") + if isinstance(markdown, str): + _append_unique_match_value(values, markdown.strip()) + body = "\n".join(line for line in markdown.splitlines() if not line.startswith("#")).strip() + _append_unique_match_value(values, body) + added_content = True + options = artifact.get("options") + if isinstance(options, list): + for option in options: + if isinstance(option, dict) and option.get("value") not in (None, "", [], {}): + _append_unique_match_value(values, option.get("value")) + added_content = True + if not added_content: + _append_unique_match_value(values, artifact.get("title")) + + if "output" in payload: + output = payload.get("output") + if isinstance(output, dict) and "action" in output: + _append_unique_match_value(values, output.get("feedback")) + else: + _append_unique_match_value(values, output) + raw_request = payload.get("request") + request = raw_request if isinstance(raw_request, dict) else {} + add_artifact(request.get("artifact")) + add_artifact(payload.get("artifact")) + _append_unique_match_value(values, request.get("input") if isinstance(request, dict) else None) + args = payload.get("args") if isinstance(payload.get("args"), list) else [] + for arg in args: + if isinstance(arg, dict): + _append_unique_match_value(values, arg.get("input")) + _append_unique_match_value(values, arg.get("title")) + _append_unique_match_value(values, arg.get("text")) + _append_unique_match_value(values, arg) + return values + + +def _dag_request_dependency_values(payload: dict[str, Any]) -> list[Any]: + """Extract the concrete inputs that should drive DAG lineage. + + Step payloads often include large background context such as research notes. + If we match against the whole request, that background dominates scoring and + hides the real foreground lineage: selected angle -> outline -> sections. + """ + + values: list[Any] = [] + + def add(value: Any) -> None: + _append_unique_match_value(values, value) + + def add_from_input(value: Any) -> None: + if value in (None, "", [], {}): + return + if isinstance(value, dict): + if isinstance(value.get("feedback"), str) and value.get("feedback", "").strip(): + add(value.get("feedback")) + return + # Preserve all explicit foreground data dependencies, in priority + # order. Composite content-generation requests can genuinely consume + # both planning context (`outline`) and a final artifact (`draft`); the + # graph should show both edges rather than letting the earlier outline + # match hide the approved draft lineage. + explicit_added = False + for key in ("previous", "draft", "visual_aids", "section", "sections", "outline"): + if value.get(key) not in (None, "", [], {}): + add(value.get(key)) + explicit_added = True + if not explicit_added and value.get("angle") not in (None, "", [], {}): + add(value.get("angle")) + explicit_added = True + if explicit_added: + return + non_background = { + key: item + for key, item in value.items() + if key not in {"research", "topic", "prompt", "instructions"} and item not in (None, "", [], {}) + } + if non_background: + add(non_background) + return + add(value) + + raw_request = payload.get("request") + request = raw_request if isinstance(raw_request, dict) else {} + if "input" in request: + add_from_input(request.get("input")) + artifact = request.get("artifact") if isinstance(request.get("artifact"), dict) else None + if isinstance(artifact, dict): + markdown = artifact.get("markdown") + if isinstance(markdown, str): + add(markdown.strip()) + add("\n".join(line for line in markdown.splitlines() if not line.startswith("#")).strip()) + else: + add(artifact.get("value") or artifact.get("title")) + + raw_args = payload.get("args") + args = raw_args if isinstance(raw_args, list) else [] + for arg in args: + if isinstance(arg, dict) and "input" in arg: + add_from_input(arg.get("input")) + elif isinstance(arg, dict): + add_from_input(arg) + else: + add_from_input(arg) + return values + + +def _review_output_from_payload(payload: dict[str, Any]) -> dict[str, str] | None: + output = payload.get("output") + if not isinstance(output, dict): + nested = payload.get("payload") + output = nested if isinstance(nested, dict) else None + if not isinstance(output, dict): + return None + action = output.get("action") + if not action: + return None + result = {"review_action": str(action)} + feedback = output.get("feedback") + if isinstance(feedback, str) and feedback.strip(): + result["review_feedback"] = feedback.strip() + return result + + +def _child_workflow_nodes_from_events(events: list[dict[str, Any]], child_runs: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + summaries: dict[str, dict[str, Any]] = {} + for event in events: + if event.get("type") != "ChildWorkflowRequested": + continue + payload = event.get("payload") or {} + node_id = str(payload.get("key") or event.get("key") or "") + child_workflow_id = payload.get("child_workflow_id") + if not node_id or not child_workflow_id: + continue + child_id = str(child_workflow_id) + child_dag = child_runs.get(child_id) + summary: dict[str, Any] = { + "child_workflow_id": child_id, + "child_key": payload.get("child_key"), + "group": payload.get("group"), + "workflow_name": payload.get("workflow_name") or payload.get("symbol"), + "symbol": payload.get("symbol"), + "source_sha256": payload.get("source_sha256"), + "collapsible": True, + "expanded_by_default": False, + } + if child_dag: + child_run = child_dag.get("run") or {} + summary.update( + { + "child_status": child_run.get("status"), + "child_waiting_on": child_run.get("waiting_on"), + "child_node_count": len(child_dag.get("nodes") or []), + "child_edge_count": len(child_dag.get("edges") or []), + "child_dag": child_dag, + } + ) + summaries[node_id] = summary + return summaries + + +def _child_run_dags( + engine: WorkflowEngine, + status: dict[str, Any], + *, + recent_events: int, + command_limit: int = 50, + command_payload_chars: int = 2000, +) -> dict[str, dict[str, Any]]: + child_ids: list[str] = [] + seen: set[str] = set() + for event in status.get("events") or status.get("recent_events") or []: + if event.get("type") != "ChildWorkflowRequested": + continue + payload = event.get("payload") or {} + child_workflow_id = payload.get("child_workflow_id") + if child_workflow_id and str(child_workflow_id) not in seen: + child_ids.append(str(child_workflow_id)) + seen.add(str(child_workflow_id)) + + child_runs: dict[str, dict[str, Any]] = {} + for child_id in child_ids: + try: + child_status = _status_packet( + engine, + child_id, + recent_events=recent_events, + commands="all", + command_limit=command_limit, + command_payload_chars=command_payload_chars, + ) + except Exception: + continue + child_artifacts = _artifacts_from_status(child_status) + child_runs[child_id] = _run_dag_payload(child_status, child_artifacts) + return child_runs + + +def _run_dag_payload(status: dict[str, Any], artifacts: list[dict[str, Any]], child_runs: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]: + nodes: dict[str, dict[str, Any]] = {} + edges: list[dict[str, Any]] = [] + edge_keys: set[tuple[str, str]] = set() + frontier: set[str] = set() + pending_requests: list[str] = [] + pending_parents: dict[str, set[str]] = {} + completed_pending_frontier: set[str] = set() + gather_children: set[str] = set() + gather_pending_by_node: dict[str, set[str]] = {} + completed_event_nodes: set[str] = set() + completed_outputs: dict[str, Any] = {} + match_values_by_node: dict[str, list[Any]] = {} + events = status.get("events") or status.get("recent_events") or [] + child_node_summaries = _child_workflow_nodes_from_events(events, child_runs or {}) + + def add_edge(source: str, target: str) -> None: + if not source or not target or source == target: + return + key = (source, target) + if key not in edge_keys: + edge_keys.add(key) + edges.append({"from": source, "to": target}) + + def add_node(event: dict[str, Any]) -> str | None: + node_id = _dag_node_id_for_event(event) + if not node_id: + return None + event_type = str(event.get("type") or "") + payload = event.get("payload") or {} + node = nodes.get(node_id) + if node is None: + node = { + "id": node_id, + "kind": _dag_node_kind(event_type), + "label": _dag_node_label(event_type, payload, event), + "status": _dag_node_status(event_type), + "first_seq": event.get("seq"), + "last_seq": event.get("seq"), + "event_types": [event_type], + "artifacts": [], + "artifact_count": 0, + } + completion_mode = _dag_completion_mode(event_type, payload) + if completion_mode: + node["completion_mode"] = completion_mode + review_output = _review_output_from_payload(payload) + if review_output: + node.update(review_output) + if node.get("kind") == "child_workflow" and node_id in child_node_summaries: + node.update(child_node_summaries[node_id]) + nodes[node_id] = node + else: + node["status"] = _dag_node_status(event_type, str(node.get("status") or "")) + node["last_seq"] = event.get("seq") + if event_type not in node["event_types"]: + node["event_types"].append(event_type) + completion_mode = _dag_completion_mode(event_type, payload) + if completion_mode: + node["completion_mode"] = completion_mode + review_output = _review_output_from_payload(payload) + if review_output: + node.update(review_output) + if payload.get("step_name") or event_type in {"ApprovalRequested", "AgentRequested"}: + node["label"] = _dag_node_label(event_type, payload, event) + if node.get("kind") == "child_workflow" and node_id in child_node_summaries: + node.update(child_node_summaries[node_id]) + for value in _dag_match_values_from_payload(payload): + _append_unique_match_value(match_values_by_node.setdefault(node_id, []), value) + return node_id + + def flush_sequential_until(node_id: str | None = None) -> None: + nonlocal completed_pending_frontier, frontier + if not pending_requests: + return + keep: list[str] = [] + for request_id in pending_requests: + if node_id is not None and request_id != node_id: + keep.append(request_id) + continue + parents = pending_parents.pop(request_id, None) or frontier + for parent_id in parents: + add_edge(parent_id, request_id) + completed_pending_frontier.add(request_id) + if node_id is not None: + keep.extend(item for item in pending_requests if item != request_id and item not in keep) + break + pending_requests[:] = keep + if not pending_requests and completed_pending_frontier: + frontier = set(completed_pending_frontier) + completed_pending_frontier = set() + + def parents_for_request(request_payload: dict[str, Any], request_id: str | None = None) -> set[str]: + parents = set(frontier or {"workflow:start"}) + + dependency_values = _dag_request_dependency_values(request_payload) + + def scored_matching_parent_ids(candidates: set[str], needle: Any | None = None) -> list[tuple[int, str]]: + candidate_ids = set(candidates) + if request_id is not None: + candidate_ids.discard(request_id) + container = request_payload if needle is None else needle + scored: list[tuple[int, str]] = [] + for parent_id in candidate_ids: + best_score = 0 + for value in match_values_by_node.get(parent_id, []): + matched = _payload_contains_value(container, value) or (needle is not None and _payload_contains_value(value, container)) + if matched: + try: + score = len(json.dumps(value, sort_keys=True, default=str)) + except Exception: + score = len(str(value)) + if container == value: + score += 1_000_000 + parent_node = nodes.get(parent_id) or {} + if parent_node.get("completion_mode") in {"operator", "approval"}: + if parent_node.get("review_action") == "approve": + score += 2_000_000 + elif parent_node.get("review_action") == "request_changes": + score += 500_000 + else: + score += 250_000 + best_score = max(best_score, score) + if best_score: + scored.append((best_score, parent_id)) + return sorted(scored, reverse=True) + + def best_matching_parent_ids( + candidates: set[str], + *, + include_same_stage_fan_in: bool = True, + needle: Any | None = None, + ) -> set[str]: + scored = scored_matching_parent_ids(candidates, needle=needle) + if not scored: + return set() + top_score, top_parent = scored[0] + top_node = nodes.get(top_parent, {}) + top_kind = top_node.get("kind") + top_label = top_node.get("label") + if include_same_stage_fan_in and top_kind == "step" and top_label: + same_stage = { + parent_id + for score, parent_id in scored + if score > 0 + and nodes.get(parent_id, {}).get("kind") == top_kind + and nodes.get(parent_id, {}).get("label") == top_label + } + if same_stage: + return same_stage + return {parent_id for score, parent_id in scored if score == top_score} + + def review_feedback_parent_ids() -> set[str]: + feedback_text = "" + raw_args = request_payload.get("args") if isinstance(request_payload.get("args"), list) else [] + raw_request_value = request_payload.get("request") + raw_request = raw_request_value if isinstance(raw_request_value, dict) else {} + raw_input = raw_request.get("input") + candidates = [raw_input] + candidates.extend(arg.get("input") for arg in raw_args if isinstance(arg, dict)) + for candidate in candidates: + if isinstance(candidate, dict) and isinstance(candidate.get("feedback"), str): + feedback_text = candidate["feedback"].strip() + break + if not feedback_text: + return set() + review_nodes = { + node_id + for node_id, node in nodes.items() + if node.get("completion_mode") in {"operator", "approval"} + and ("SignalReceived" in node.get("event_types", []) or "StepCompleted" in node.get("event_types", [])) + } + matches = { + node_id + for node_id in review_nodes + for value in match_values_by_node.get(node_id, []) + if isinstance(value, str) and value.strip() == feedback_text + } + return matches + + def dependency_parent_ids(candidates: set[str], *, include_same_stage_fan_in: bool = True) -> set[str]: + matches: set[str] = set() + for value in dependency_values: + matches.update(best_matching_parent_ids(candidates, include_same_stage_fan_in=include_same_stage_fan_in, needle=value)) + return matches + + def has_composite_foreground_input() -> bool: + raw_request_value = request_payload.get("request") + raw_request = raw_request_value if isinstance(raw_request_value, dict) else {} + candidates: list[Any] = [raw_request.get("input")] + raw_args_value = request_payload.get("args") + raw_args = raw_args_value if isinstance(raw_args_value, list) else [] + candidates.extend(arg.get("input") for arg in raw_args if isinstance(arg, dict) and "input" in arg) + foreground_keys = {"previous", "draft", "visual_aids", "section", "sections", "outline"} + for candidate in candidates: + if not isinstance(candidate, dict): + continue + present = {key for key in foreground_keys if candidate.get(key) not in (None, "", [], {})} + if len(present) > 1: + return True + return False + + feedback_matches = review_feedback_parent_ids() + if len(feedback_matches) == 1: + return feedback_matches + + if any(nodes.get(parent_id, {}).get("kind") == "gather" for parent_id in parents): + return parents + + is_review_request = str(request_payload.get("completion_mode") or "") in {"operator", "approval"} + raw_request_for_kind = request_payload.get("request") + if isinstance(raw_request_for_kind, dict) and str(raw_request_for_kind.get("kind") or "").startswith(("operator.", "human_input.")): + is_review_request = True + matching_parents = dependency_parent_ids(parents, include_same_stage_fan_in=not is_review_request) + if not matching_parents and len(parents) > 1: + matching_parents = best_matching_parent_ids(parents, include_same_stage_fan_in=not is_review_request) + if matching_parents: + if not is_review_request and has_composite_foreground_input(): + supplemental_matches = dependency_parent_ids(set(match_values_by_node), include_same_stage_fan_in=False) + if supplemental_matches: + return matching_parents | supplemental_matches + return matching_parents + + # If the local frontier cannot explain a request, fall back to explicit + # foreground inputs across the run. This prevents large background + # context like research notes from stealing edges, while still letting + # completed approval gates feed the steps they unlocked. + foreground_matches = dependency_parent_ids(set(match_values_by_node)) + if foreground_matches: + return foreground_matches + + # Prefer data lineage over the current control frontier. This keeps + # final/output steps attached to the draft they consume, not to an old + # approval or selection node that merely happened to be the latest + # frontier after retries and fan-ins. + non_review_nodes = { + node_id + for node_id, node in nodes.items() + if node.get("completion_mode") not in {"operator", "approval"} + } + data_matches = dependency_parent_ids(non_review_nodes) + if not data_matches: + data_matches = best_matching_parent_ids(non_review_nodes) + if data_matches: + return data_matches + + review_nodes = { + node_id + for node_id, node in nodes.items() + if node.get("completion_mode") in {"operator", "approval"} + and ("SignalReceived" in node.get("event_types", []) or "StepCompleted" in node.get("event_types", [])) + } + review_matches = dependency_parent_ids(review_nodes) + if not review_matches: + review_matches = best_matching_parent_ids(review_nodes) + if review_matches: + return review_matches + + return parents + + for event in events: + event_type = str(event.get("type") or "") + payload = event.get("payload") or {} + node_id = add_node(event) + if not node_id: + if event_type == "ParallelWaiting": + flush_sequential_until() + continue + + if event_type == "WorkflowStarted": + frontier = {node_id} + elif event_type in {"StepRequested", "ChildWorkflowRequested"}: + if node_id not in pending_requests and node_id not in gather_children: + pending_requests.append(node_id) + pending_parents[node_id] = parents_for_request(payload, node_id) + elif event_type in {"GatherWaiting", "ChildWorkflowGatherWaiting"}: + pending = [str(item) for item in payload.get("pending") or []] + gather_pending_by_node[node_id] = set(pending) + parents = frontier or {"workflow:start"} + for child_id in pending: + gather_children.add(child_id) + for parent_id in parents: + add_edge(parent_id, child_id) + add_edge(child_id, node_id) + pending_requests[:] = [item for item in pending_requests if item not in set(pending)] + for child_id in pending: + pending_parents.pop(child_id, None) + frontier = {node_id} + elif event_type in {"StepCompleted", "StepFailed", "ChildWorkflowCompleted", "ChildWorkflowFailed"}: + if event_type in {"StepCompleted", "ChildWorkflowCompleted"}: + completed_event_nodes.add(node_id) + if event_type in {"StepCompleted", "ChildWorkflowCompleted"} and "output" in payload: + completed_outputs[node_id] = payload.get("output") + if node_id not in gather_children: + flush_sequential_until(node_id) + elif event_type in {"ApprovalRequested", "AgentRequested"}: + if node_id in pending_requests: + if any(payload.get(field) not in (None, "", [], {}) for field in ("artifact", "request", "args")): + refined_parents = parents_for_request(payload, node_id) + if refined_parents: + pending_parents[node_id] = refined_parents + continue + flush_sequential_until() + if node_id not in frontier or "StepRequested" not in nodes[node_id].get("event_types", []): + for parent_id in frontier: + add_edge(parent_id, node_id) + frontier = {node_id} + elif event_type == "WaitRequested": + continue + elif event_type == "SignalReceived": + flush_sequential_until() + if node_id not in frontier or "StepRequested" not in nodes[node_id].get("event_types", []): + for parent_id in frontier: + add_edge(parent_id, node_id) + frontier = {node_id} + elif event_type in {"WorkflowCompleted", "WorkflowFailed", "WorkflowCancelled"}: + flush_sequential_until() + for parent_id in frontier: + add_edge(parent_id, node_id) + frontier = {node_id} + + flush_sequential_until() + for artifact in artifacts: + node_id = _artifact_node_id(artifact) + if node_id and node_id in nodes: + nodes[node_id]["artifacts"].append(artifact) + outgoing_targets = {edge["from"] for edge in edges} + for node in nodes.values(): + node["artifact_count"] = len(node["artifacts"]) + if node.get("kind") == "gather" and node.get("status") == "waiting": + pending = gather_pending_by_node.get(str(node.get("id")), set()) + if node.get("id") in outgoing_targets or (pending and pending.issubset(completed_event_nodes)): + node["status"] = "completed" + return { + "workflow_id": status.get("workflow_id"), + "run": status, + "layout": "run-derived-topology", + "nodes": list(nodes.values()), + "edges": edges, + "artifact_count": len(artifacts), + "artifacts": artifacts, + "runtime_semantics": _runtime_semantics(), + } + + +def _risk_for_approval(approval: dict[str, Any]) -> dict[str, str]: + artifact = approval.get("artifact") + text = json.dumps({"artifact": artifact}, default=str).lower() + if any(word in text for word in ("payment", "purchase", "delete", "publish", "send_email", "external_send", "credential")): + return {"level": "high", "reason": "The approval appears to authorize an external, destructive, financial, or credential-affecting action."} + if any(word in text for word in ("email", "calendar", "schedule", "deploy", "post", "message")): + return {"level": "medium", "reason": "The approval may affect people, publishing, scheduling, or deployment state."} + return {"level": "low", "reason": "Approval records human provenance and creates observable continuation state; no obvious external/destructive keyword was detected."} + + +def _review_request_schema_descriptor(schema_id: str) -> dict[str, Any]: + normalized = schema_id or "json" + if ":" in normalized: + module, name = normalized.rsplit(":", 1) + else: + module, name = "", normalized + if name == "ReviewDecision": + kind = "review_decision" + elif normalized in {"json", "dict", "builtins:dict"}: + kind = "json_object" + elif normalized in {"str", "builtins:str"}: + kind = "text" + else: + kind = "structured_object" + descriptor = {"id": normalized, "name": name or normalized, "kind": kind} + if module: + descriptor["module"] = module + return descriptor + + +def _review_input_surface(schema: str | dict[str, Any]) -> dict[str, Any]: + descriptor = schema if isinstance(schema, dict) else _review_request_schema_descriptor(schema) + fields = descriptor.get("fields") if isinstance(descriptor.get("fields"), list) else [] + action_field = next((field for field in fields if isinstance(field, dict) and field.get("name") == "action" and field.get("kind") == "choice"), None) + feedback_field = next( + ( + field + for field in fields + if isinstance(field, dict) + and field.get("name") in {"feedback", "comment", "comments", "reason", "note", "notes"} + and field.get("kind") in {"text", "object"} + ), + None, + ) + edited_output_field = next( + ( + field + for field in fields + if isinstance(field, dict) + and field.get("name") in {"edited_output", "edited_markdown", "edited_text", "replacement"} + and field.get("kind") in {"text", "object"} + ), + None, + ) + action_options = (action_field.get("options") or []) if isinstance(action_field, dict) else [] + if action_field: + surface: dict[str, Any] = { + "kind": "review_decision", + "actions": [_review_action_descriptor(option, has_feedback=feedback_field is not None) for option in action_options], + } + if feedback_field is not None: + surface["feedback"] = {"kind": "text", "optional": True, "placeholder": "What should change?"} + if edited_output_field is not None: + surface["editable_output"] = { + "kind": "textarea", + "field": edited_output_field.get("name") or "edited_output", + "optional": True, + "placeholder": "Paste or edit the output to branch the next retry from this version.", + } + return surface + if descriptor["kind"] == "review_decision": + return { + "kind": "review_decision", + "actions": [_review_action_descriptor(action, has_feedback=True) for action in ["approve", "request_changes"]], + "feedback": {"kind": "text", "optional": True}, + } + if descriptor["kind"] == "text": + return {"kind": "textarea", "placeholder": "Enter feedback"} + if descriptor["kind"] == "structured_object": + return {"kind": "structured_form", "schema": descriptor} + return {"kind": "json_object", "schema": descriptor} + + +def _selection_option_label(option: dict[str, Any]) -> str: + label = option.get("label") + if isinstance(label, str) and label.strip(): + return label.strip() + value = option.get("value") + if isinstance(value, dict): + title = value.get("title") or value.get("name") or value.get("label") + if isinstance(title, str) and title.strip(): + return title.strip() + return str(option.get("id") or "Option") + + +def _selection_option_details(option: dict[str, Any]) -> str | None: + details = option.get("details") or option.get("description") or option.get("summary") + if isinstance(details, str) and details.strip(): + return details.strip() + value = option.get("value") + if isinstance(value, dict): + summary = value.get("summary") or value.get("description") or value.get("details") + if isinstance(summary, str) and summary.strip(): + return summary.strip() + if isinstance(value, str) and value.strip() and value.strip() != _selection_option_label(option): + return value.strip() + return None + + +def _selection_options_from_artifact(artifact: Any) -> list[dict[str, Any]]: + if not isinstance(artifact, dict): + return [] + raw_options_value = artifact.get("options") + raw_options = raw_options_value if isinstance(raw_options_value, list) else [] + if not raw_options: + return [] + options: list[dict[str, Any]] = [] + for index, raw_option in enumerate(raw_options): + if isinstance(raw_option, dict): + item = dict(raw_option) + else: + item = {"value": raw_option} + item.setdefault("id", str(index)) + item["id"] = str(item.get("id")) + item["label"] = _selection_option_label(item) + details = _selection_option_details(item) + if details: + item["details"] = details + options.append(item) + return options + + +def _selection_field_from_descriptor(descriptor: dict[str, Any]) -> str | None: + fields = descriptor.get("fields") if isinstance(descriptor.get("fields"), list) else [] + for field in fields: + if isinstance(field, dict) and field.get("kind") == "choice" and field.get("name") != "action": + name = field.get("name") + return str(name) if name else None + return None + + +def _selection_input_surface(descriptor: dict[str, Any], artifact: Any, fallback: dict[str, Any]) -> dict[str, Any]: + options = _selection_options_from_artifact(artifact) + if not options: + return fallback + field = _selection_field_from_descriptor(descriptor) + submit = "choice_field" if field else "value" + surface: dict[str, Any] = { + "kind": "selection", + "options": options, + "submit": submit, + } + if field: + surface["field"] = field + return surface + + +def _review_action_descriptor(action: Any, *, has_feedback: bool = False) -> dict[str, Any]: + value = str(action) + label = value.replace("_", " ").strip().capitalize() or value + item: dict[str, Any] = {"value": value, "label": label} + if has_feedback and value not in {"approve", "accept", "ship", "proceed", "continue", "yes"}: + item["requires_feedback"] = True + return item + + +def _is_positive_review_action(value: Any) -> bool: + normalized = str(value or "").strip().lower().replace("-", "_") + return normalized in {"approve", "approved", "accept", "accepted", "yes", "ship", "pass", "passed", "proceed", "continue"} + + +def _feedback_action_for_surface(surface: dict[str, Any]) -> str | None: + if surface.get("kind") != "review_decision": + return None + for action in surface.get("actions") or []: + if isinstance(action, dict): + value = str(action.get("value") or "").strip() + if action.get("requires_feedback") and value: + return value + for action in surface.get("actions") or []: + value = str(action.get("value") if isinstance(action, dict) else action).strip() + if value.lower().replace("-", "_") in {"request_changes", "revise", "edit", "rerun", "reject"}: + return value + return None + + +def _normalize_review_payload_for_dashboard_request(engine: WorkflowEngine, workflow_id: str, key: str, payload: dict[str, Any]) -> dict[str, Any]: + # Review-decision cards expose feedback/edit boxes. If the browser sends + # approve+feedback or approve+edited_output, never silently approve and discard + # the user's correction. + feedback = payload.get("feedback") + edited_output = next( + (payload.get(key) for key in ("edited_output", "edited_markdown", "edited_text", "replacement") if isinstance(payload.get(key), str) and payload.get(key, "").strip()), + None, + ) + has_revision_input = (isinstance(feedback, str) and feedback.strip()) or isinstance(edited_output, str) + if not has_revision_input or not _is_positive_review_action(payload.get("action")): + return payload + matching_step = next( + (step for step in engine.list_operator_steps(status="waiting") if step.get("workflow_id") == workflow_id and step.get("key") == key), + None, + ) + if not matching_step: + return payload + card = _operator_step_card(_strip_internal_fields(matching_step), db_alias="") + surface = card.get("input_surface") or {} + feedback_action = _feedback_action_for_surface(surface if isinstance(surface, dict) else {}) + if not feedback_action: + return payload + normalized = dict(payload) + normalized["action"] = feedback_action + return normalized + + +def _risk_for_operator_step(step: dict[str, Any]) -> dict[str, str]: + raw_request = step.get("request") + request: dict[str, Any] = raw_request if isinstance(raw_request, dict) else {} + artifact = step.get("artifact") if step.get("artifact") is not None else request.get("artifact") + text = json.dumps({"artifact": artifact, "request": step.get("request")}, default=str).lower() + if any(word in text for word in ("payment", "purchase", "delete", "publish", "send_email", "external_send", "credential")): + return {"level": "high", "reason": "This human input request may authorize an external, destructive, financial, or credential-affecting action."} + if any(word in text for word in ("email", "calendar", "schedule", "deploy", "post", "message")): + return {"level": "medium", "reason": "This human input request may affect people, publishing, scheduling, or deployment state."} + return {"level": "low", "reason": "This human input request records input/provenance for the trusted local workflow."} + + +def _runtime_state_applies_to_card(runtime_state: dict[str, Any] | None, key: str, status: str | None) -> bool: + if runtime_state is None: + return False + if status not in {"completed", "approve", "reject"}: + return True + current_wait = runtime_state.get("current_wait") if isinstance(runtime_state, dict) else None + return isinstance(current_wait, dict) and str(current_wait.get("key") or "") == str(key or "") + + +def _operator_step_card(step: dict[str, Any], *, db_alias: str, runtime_state: dict[str, Any] | None = None) -> dict[str, Any]: + raw_request = step.get("request") + request: dict[str, Any] = raw_request if isinstance(raw_request, dict) else {} + artifact = _operator_approval_artifact(step.get("artifact") if step.get("artifact") is not None else request.get("artifact")) + redacted_artifact = _redact_artifact_local_refs(artifact) + prompt = step.get("prompt") or step.get("label") or step.get("key") or "Operator input needed" + schema_id = str(step.get("schema") or request.get("schema") or "json") + raw_descriptor = step.get("schema_descriptor") if isinstance(step.get("schema_descriptor"), dict) else request.get("schema_descriptor") + descriptor = raw_descriptor if isinstance(raw_descriptor, dict) else _review_request_schema_descriptor(schema_id) + step_id = _source_step_id_from_key(step.get("key")) + input_surface = _selection_input_surface(descriptor, redacted_artifact, _review_input_surface(descriptor)) + card = { + "db_alias": db_alias, + "workflow_id": step.get("workflow_id"), + "workflow_name": step.get("workflow_name"), + "workflow_ref": step.get("workflow_ref"), + "key": step.get("key"), + "step_id": step_id, + "source_step_id": step_id, + "status": step.get("status"), + "kind": "human_input", + "request_type": "human_input", + "headline": prompt, + "prompt": prompt, + "schema": schema_id, + "request_schema": descriptor, + "input_surface": input_surface, + "artifact_preview": redacted_artifact, + "artifact_render": _artifact_descriptor(artifact), + "output": step.get("output"), + "source": step.get("source"), + "waiting_on": step.get("waiting_on"), + "requested_seq": step.get("requested_seq"), + "risk": _risk_for_operator_step(step), + "consequence": "Records typed human input with provenance, then the workflow worker or trusted runtime can continue.", + } + if _runtime_state_applies_to_card(runtime_state, str(card.get("key") or ""), card.get("status")): + card["runtime_state"] = runtime_state + return card + + +def _approval_card(approval: dict[str, Any], *, db_alias: str, runtime_state: dict[str, Any] | None = None) -> dict[str, Any]: + prompt = approval.get("prompt") or approval.get("key") or "Approval needed" + artifact = _operator_approval_artifact(approval.get("artifact")) + step_id = _source_step_id_from_key(approval.get("key")) + card = { + "db_alias": db_alias, + "workflow_id": approval.get("workflow_id"), + "workflow_name": approval.get("workflow_name"), + "workflow_ref": approval.get("workflow_ref"), + "key": approval.get("key"), + "step_id": step_id, + "source_step_id": step_id, + "status": approval.get("status"), + "kind": "approval_policy", + "request_type": "approval_policy", + "headline": prompt, + "prompt": prompt, + "allowed": approval.get("allowed") or ["approve", "reject"], + "request_schema": { + "id": "hermes_workflows.approvals:ApprovalDecision", + "name": "ApprovalDecision", + "kind": "approval_decision", + }, + "input_surface": { + "kind": "approval_decision", + "actions": list(approval.get("allowed") or ["approve", "reject"]), + "feedback": {"kind": "text", "optional": True}, + }, + "artifact_preview": _redact_artifact_local_refs(artifact), + "artifact_render": _artifact_descriptor(artifact), + "decision": approval.get("decision"), + "source": approval.get("source"), + "diagnostics": approval.get("diagnostics") or [], + "waiting_on": approval.get("waiting_on"), + "requested_seq": approval.get("requested_seq"), + "risk": _risk_for_approval(approval), + "consequence": "Records approve/reject with human provenance and creates an inspectable workflow continuation.", + "detail_url": f"/approvals/detail?db={db_alias}&workflow_id={approval.get('workflow_id')}&key={approval.get('key')}", + } + if _runtime_state_applies_to_card(runtime_state, str(card.get("key") or ""), card.get("status")): + card["runtime_state"] = runtime_state + return card + + +def _artifacts_from_status(status: dict[str, Any]) -> list[dict[str, Any]]: + workflow_id = str(status.get("workflow_id") or "") + artifacts: list[dict[str, Any]] = [] + for approval in status.get("approvals") or []: + artifact = _operator_approval_artifact(approval.get("artifact")) + if artifact is not None: + artifacts.append( + { + "id": f"{workflow_id}:approval:{approval.get('key')}", + "workflow_id": workflow_id, + "kind": "approval_artifact", + "title": approval.get("prompt") or approval.get("key") or "Approval artifact", + "source": {"event": "ApprovalRequested", "key": approval.get("key"), "seq": approval.get("requested_seq")}, + "source_step_id": _source_step_id_from_key(approval.get("key")), + "preview": _redact_artifact_local_refs(artifact), + "artifact_render": _artifact_descriptor(artifact), + } + ) + for operator_step in status.get("operator_steps") or []: + if operator_step.get("kind") != "operator": + continue + request = operator_step.get("request") if isinstance(operator_step.get("request"), dict) else {} + artifact = _operator_approval_artifact(operator_step.get("artifact") if operator_step.get("artifact") is not None else request.get("artifact")) + if artifact is not None: + artifacts.append( + { + "id": f"{workflow_id}:operator:{operator_step.get('key')}", + "workflow_id": workflow_id, + "kind": "operator_step_artifact", + "title": operator_step.get("prompt") or operator_step.get("label") or operator_step.get("key") or "Operator step artifact", + "source": {"event": "StepRequested", "key": operator_step.get("key"), "seq": operator_step.get("requested_seq")}, + "source_step_id": _source_step_id_from_key(operator_step.get("key")), + "preview": _redact_artifact_local_refs(artifact), + "artifact_render": _artifact_descriptor(artifact), + } + ) + result = status.get("result") + if result is not None: + artifacts.append( + { + "id": f"{workflow_id}:result", + "workflow_id": workflow_id, + "kind": "run_result", + "title": "Run result", + "source": {"event": "WorkflowCompleted"}, + "source_step_id": "workflow:completed", + "preview": _redact_artifact_local_refs(result), + "artifact_render": _artifact_descriptor(result), + } + ) + for event in status.get("events") or status.get("recent_events") or []: + if event.get("type") == "ChildWorkflowRequested": + payload = event.get("payload") or {} + workflow_artifact = _workflow_source_artifact( + payload.get("workflow"), + artifact_id=f"{workflow_id}:child-workflow-source:{event.get('key')}", + workflow_id=workflow_id, + title=f"Generated child workflow source: {payload.get('symbol') or event.get('key')}", + source={"event": "ChildWorkflowRequested", "key": event.get("key"), "seq": event.get("seq")}, + ) + if workflow_artifact is not None: + artifacts.append(workflow_artifact) + continue + if event.get("type") != "StepCompleted": + continue + payload = event.get("payload") or {} + if "output" in payload: + workflow_artifact = _workflow_source_artifact( + payload.get("output"), + artifact_id=f"{workflow_id}:workflow-source:{event.get('key')}", + workflow_id=workflow_id, + title=f"Generated workflow source: {event.get('key')}", + source={"event": "StepCompleted", "key": event.get("key"), "seq": event.get("seq")}, + metadata=payload.get("metadata"), + ) + if workflow_artifact is not None: + artifacts.append(workflow_artifact) + continue + artifacts.append( + { + "id": f"{workflow_id}:step:{event.get('key')}", + "workflow_id": workflow_id, + "kind": "step_output", + "title": f"Step output: {event.get('key')}", + "source": {"event": "StepCompleted", "key": event.get("key"), "seq": event.get("seq")}, + "source_step_id": _source_step_id_from_key(event.get("key")), + "preview": _redact_artifact_local_refs(payload.get("output")), + "artifact_render": _artifact_descriptor(payload.get("output")), + "metadata": payload.get("metadata"), + } + ) + return artifacts + + +def _input_from_body(body: dict[str, Any]) -> Any: + if "input" in body: + return body["input"] + if "inputs" in body: + return body["inputs"] + if "input_json" in body: + raw = body["input_json"] + if isinstance(raw, str): + return json.loads(raw) + return raw + return {} + + +def _new_workflow_id(definition_id: str) -> str: + return f"wf_{_slug(definition_id).replace('-', '_')}_{int(time.time())}_{uuid.uuid4().hex[:8]}" + + +@router.get("/dbs") +async def list_dbs() -> dict[str, Any]: + configured = _configured_dbs() + dbs = [] + for name, path in sorted(configured.items()): + dbs.append({"name": name, "exists": Path(path).expanduser().exists()}) + active_source = None + try: + active_alias, active_path = _resolve_dashboard_db(None) + active_source = {"name": active_alias, "exists": Path(active_path).expanduser().exists()} + except HTTPException: + active_source = None + return {"count": len(dbs), "dbs": dbs, "active_source": active_source, "runtime_semantics": _runtime_semantics()} + + +@router.get("/definitions") +async def workflow_definitions(db: str | None = None) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + definitions = [_definition_payload(definition, engine) for definition in _catalog_for_engine(engine)] + return {"db_alias": db_alias, "count": len(definitions), "definitions": definitions, "runtime_semantics": _runtime_semantics()} + + +@router.get("/definitions/{definition_id}/runs") +async def definition_runs( + definition_id: str, + db: str | None = None, + status: str | None = None, + limit: int = 50, +) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + definition = _definition_by_id(definition_id, engine) + runs = _runs_for_ref(engine, str(definition["workflow_ref"]), status=status, limit=_int(limit, default=50, maximum=500)) + return {"db_alias": db_alias, "definition": definition, "count": len(runs), "runs": runs, "runtime_semantics": _runtime_semantics()} + + +@router.get("/definitions/{definition_id}/source") +async def workflow_definition_source(definition_id: str, db: str | None = None) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + definition = _definition_by_id(definition_id, engine) + _ensure_workflow_project_on_path(db_path) + return {"db_alias": db_alias, **_workflow_source_payload(definition)} + + +@router.post("/runs") +async def run_workflow(body: dict[str, Any]) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(body.get("db")) + read_engine = WorkflowEngine(db_path, read_only=True) + definition = _definition_by_id(str(body.get("definition_id") or ""), read_engine) + if "workflow_id" in body: + raise HTTPException(status_code=400, detail="workflow_id is server-generated for dashboard launches") + if not definition.get("runnable"): + raise HTTPException(status_code=403, detail="Only workflows explicitly configured in workflow_catalog are browser-runnable") + workflow_id = _new_workflow_id(str(definition["id"])) + workflow_ref = str(definition["workflow_ref"]) + inputs = _input_from_body(body) + def execute_run() -> tuple[Any, dict[str, Any]]: + _ensure_workflow_project_on_path(db_path) + workflow = _load_workflow(workflow_ref) + result = WorkflowEngine(db_path).run_until_idle(workflow, inputs, workflow_id=workflow_id, workflow_ref=workflow_ref) + status = _status_packet( + WorkflowEngine(db_path, read_only=True), + workflow_id, + recent_events=20, + commands="recent", + command_limit=20, + command_payload_chars=2000, + db_alias=db_alias, + ) + return result, status + + try: + result, status = await asyncio.to_thread(execute_run) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"workflow run failed: {type(exc).__name__}: {exc}") from exc + return { + "success": True, + "db_alias": db_alias, + "definition": definition, + "result": {"workflow_id": result.workflow_id, "status": result.status, "waiting_on": result.waiting_on, "error": result.error}, + "run": status, + "artifacts": _artifacts_from_status(status), + "runtime_semantics": _runtime_semantics(), + } + + +@router.get("/runs") +async def runs( + db: str | None = None, + status: str | None = None, + limit: int = 100, +) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + rows = _runs_with_runtime_state(engine, _all_runs(engine, status=status, limit=_int(limit, default=100, maximum=500)), db_alias=db_alias) + return {"db_alias": db_alias, "count": len(rows), "runs": rows, "counts": _run_counts(rows), "runtime_semantics": _runtime_semantics(), "worker_warning": _active_worker_warning(engine)} + + +@router.post("/runs/{workflow_id}/cancel") +async def cancel_run(workflow_id: str, body: dict[str, Any]) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(body.get("db")) + reason = str(body.get("reason") or "Cancelled from Hermes dashboard").strip() or "Cancelled from Hermes dashboard" + superseded_by = str(body.get("superseded_by") or "").strip() or None + + def cancel_and_read() -> tuple[Any, dict[str, Any]]: + engine = WorkflowEngine(db_path) + result = engine.cancel_workflow( + workflow_id, + reason=reason, + superseded_by=superseded_by, + source={"channel": "hermes-dashboard", "message_id": f"dashboard:{uuid.uuid4()}"}, + ) + status = _status_packet( + WorkflowEngine(db_path, read_only=True), + workflow_id, + recent_events=20, + commands="recent", + command_limit=20, + command_payload_chars=2000, + db_alias=db_alias, + ) + return result, status + + try: + result, status = await asyncio.to_thread(cancel_and_read) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"workflow cancel failed: {type(exc).__name__}: {exc}") from exc + return { + "success": True, + "db_alias": db_alias, + "result": {"workflow_id": result.workflow_id, "status": result.status, "waiting_on": result.waiting_on, "error": result.error}, + "run": status, + "runtime_semantics": _runtime_semantics(), + } + + +@router.get("/runs/{workflow_id}") +async def run_status( + workflow_id: str, + db: str | None = None, + recent_events: int = 20, + commands: str = "recent", + command_limit: int = 20, + command_payload_chars: int = 1000, +) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + packet = _status_packet( + engine, + workflow_id, + recent_events=_int(recent_events, default=20, maximum=200), + commands=commands, + command_limit=_int(command_limit, default=20, maximum=200), + command_payload_chars=_int(command_payload_chars, default=1000, maximum=20000), + db_alias=db_alias, + ) + artifacts = _artifacts_from_status(packet) + return {"db_alias": db_alias, "run": packet, "artifacts": artifacts, "runtime_semantics": _runtime_semantics(), "worker_warning": _active_worker_warning(engine)} + + +@router.get("/runs/{workflow_id}/artifacts") +async def run_artifacts(workflow_id: str, db: str | None = None, recent_events: int = 100) -> dict[str, Any]: + status = await run_status(workflow_id, db=db, recent_events=recent_events, commands="all", command_limit=100, command_payload_chars=5000) + artifacts = status["artifacts"] + return {"db_alias": status["db_alias"], "workflow_id": workflow_id, "count": len(artifacts), "artifacts": artifacts, "runtime_semantics": _runtime_semantics()} + + +@router.get("/runs/{workflow_id}/dag") +async def run_dag(workflow_id: str, db: str | None = None, recent_events: int = 200) -> dict[str, Any]: + status = await run_status( + workflow_id, + db=db, + recent_events=recent_events, + commands="all", + command_limit=200, + command_payload_chars=5000, + ) + _db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + child_runs = _child_run_dags( + engine, + status["run"], + recent_events=_int(recent_events, default=200, maximum=200), + command_limit=50, + command_payload_chars=2000, + ) + return {"db_alias": status["db_alias"], **_run_dag_payload(status["run"], status["artifacts"], child_runs=child_runs)} + + +@router.get("/operator-steps") +async def active_operator_steps(db: str | None = None, status: str | None = "waiting", limit: int = 100) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + _review_requests, _approvals, operator_steps = _review_cards(engine, db_alias=db_alias, status=status, limit=_int(limit, default=100, maximum=500)) + return {"db_alias": db_alias, "count": len(operator_steps), "operator_steps": operator_steps, "runtime_semantics": _runtime_semantics(), "worker_warning": _active_worker_warning(engine)} + + +@router.get("/review-requests") +async def active_review_requests(db: str | None = None, status: str | None = "waiting", limit: int = 100) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + max_items = _int(limit, default=100, maximum=500) + review_requests, _approvals, _human_inputs = _review_cards(engine, db_alias=db_alias, status=status, limit=max_items) + return {"db_alias": db_alias, "count": len(review_requests), "review_requests": review_requests, "runtime_semantics": _runtime_semantics(), "worker_warning": _active_worker_warning(engine)} + + +@router.get("/approvals") +async def active_approvals(db: str | None = None, status: str | None = "waiting", limit: int = 100) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + _review_requests, approvals, _human_inputs = _review_cards(engine, db_alias=db_alias, status=status, limit=_int(limit, default=100, maximum=500)) + return {"db_alias": db_alias, "count": len(approvals), "approvals": approvals, "runtime_semantics": _runtime_semantics(), "worker_warning": _active_worker_warning(engine)} + + +@router.get("/approvals/detail") +async def approval_detail(db: str | None = None, workflow_id: str = "", key: str = "") -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + if not workflow_id or not key: + raise HTTPException(status_code=400, detail="workflow_id and key are required") + engine = WorkflowEngine(db_path, read_only=True) + approval = _redact_artifact_local_refs(_strip_internal_fields(approval_view_to_dict(engine.get_approval(workflow_id, key)))) + approval["artifact"] = _operator_approval_artifact(approval.get("artifact")) + status = _status_packet(engine, workflow_id, recent_events=100, commands="recent", command_limit=20, command_payload_chars=5000, db_alias=db_alias) + timeline = [event for event in engine.events(workflow_id) if event.get("seq", 0) <= (approval.get("requested_seq") or 10**9)] + timeline = _redact_artifact_local_refs(_redact(timeline)) + card = _approval_card(approval, db_alias=db_alias, runtime_state=status.get("runtime_state")) + return { + "db_alias": db_alias, + "workflow": status, + "approval": approval, + "approval_card": card, + "what_you_are_approving": { + "action": approval.get("key"), + "step_id": card.get("step_id"), + "source_step_id": card.get("source_step_id"), + "prompt": approval.get("prompt"), + "allowed_decisions": approval.get("allowed") or ["approve", "reject"], + "artifact": _redact_artifact_local_refs(approval.get("artifact")), + "artifact_render": _artifact_descriptor(approval.get("artifact")), + }, + "risk": card["risk"], + "consequence": card["consequence"], + "decision_semantics": { + "resume": True, + "label": "Record and continue", + "description": "The dashboard records approve/reject with server-derived human provenance and creates an inspectable continuation; trusted runners consume the queued workflow work.", + }, + "timeline": timeline, + "artifacts": _artifacts_from_status(status), + "runtime_semantics": _runtime_semantics(), + } + + +@router.get("/overview") +async def overview( + db: str | None = None, + status: str | None = None, + limit: int = 50, + recent_events: int = 10, + commands: str = "recent", + command_limit: int = 10, + command_payload_chars: int = 1000, +) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + workflow_rows = engine.list_workflows(status=status)[: _int(limit, default=50, maximum=200)] + workflows = [ + _status_packet( + engine, + row["workflow_id"], + recent_events=_int(recent_events, default=10, maximum=100), + commands=commands, + command_limit=_int(command_limit, default=10, maximum=100), + command_payload_chars=_int(command_payload_chars, default=1000, maximum=10000), + db_alias=db_alias, + ) + for row in workflow_rows + ] + counts_by_status: dict[str, int] = {} + artifacts: list[dict[str, Any]] = [] + for item in workflows: + counts_by_status[str(item.get("status") or "unknown")] = counts_by_status.get(str(item.get("status") or "unknown"), 0) + 1 + artifacts.extend(_artifacts_from_status(item)) + definitions = [_definition_payload(definition, engine) for definition in _catalog_for_engine(engine)] + review_requests, approvals, operator_steps = _review_cards(engine, db_alias=db_alias, status="waiting", limit=50) + return { + "db_alias": db_alias, + "workflow_count": len(workflows), + "counts_by_status": counts_by_status, + "workflows": workflows, + "definitions_count": len(definitions), + "definitions": definitions, + "active_review_request_count": len(review_requests), + "active_review_requests": review_requests, + "active_operator_step_count": len(operator_steps), + "active_operator_steps": operator_steps, + "active_approval_count": len(approvals), + "active_approvals": approvals, + "artifact_count": len(artifacts), + "artifacts": artifacts[:50], + "runtime_semantics": _runtime_semantics(), + "worker_warning": _active_worker_warning(engine), + } + + +@router.get("/workflows/{workflow_id}") +async def workflow_status( + workflow_id: str, + db: str | None = None, + recent_events: int = 20, + commands: str = "recent", + command_limit: int = 20, + command_payload_chars: int = 1000, +) -> dict[str, Any]: + db_alias, db_path = _resolve_dashboard_db(db) + engine = WorkflowEngine(db_path, read_only=True) + return _status_packet( + engine, + workflow_id, + recent_events=_int(recent_events, default=20, maximum=200), + commands=commands, + command_limit=_int(command_limit, default=20, maximum=200), + command_payload_chars=_int(command_payload_chars, default=1000, maximum=20000), + db_alias=db_alias, + ) + + +@router.post("/operator-steps/response") +async def respond_operator_step(body: dict[str, Any]) -> dict[str, Any]: + return await respond_review_request(body) + + +@router.post("/review-requests/response") +async def respond_review_request(body: dict[str, Any]) -> dict[str, Any]: + # Typed Review Queue/operator responses are input values, not approval + # decisions. The browser still cannot supply trusted provenance, but local + # dashboard responses should not require operators to configure or invent an + # approver identity just to answer select(...)/ask(...) gates. Stamp the + # response with server-generated dashboard event provenance and keep the + # typed payload free of spoofable by/source fields. + db_alias, db_path = _resolve_dashboard_db(body.get("db")) + workflow_id = str(body.get("workflow_id") or "").strip() + key = str(body.get("key") or "").strip() + if not workflow_id or not key: + raise HTTPException(status_code=400, detail="workflow_id and key are required") + raw_payload = body.get("payload") if isinstance(body.get("payload"), dict) else {} + if not raw_payload: + raise HTTPException(status_code=400, detail="review response payload is required") + payload = strip_client_controlled_provenance(raw_payload) + raw_idempotency_key = str(body.get("idempotency_key") or body.get("event_id") or "").strip() + message_id = raw_idempotency_key or f"dashboard:{uuid.uuid4()}" + if not message_id.startswith("dashboard:"): + message_id = f"dashboard:{message_id}" + + def record_and_resume() -> tuple[Any, dict[str, Any]]: + _ensure_workflow_project_on_path(db_path) + engine = WorkflowEngine(db_path) + normalized_payload = _normalize_review_payload_for_dashboard_request(engine, workflow_id, key, payload) + effective_idempotency_key = message_id + source = { + "channel": "local-dashboard", + "message_id": message_id, + } + if _revision_schema_for_response(engine, workflow_id, key) is not None: + normalized_payload, validated = validate_revision_response(normalized_payload) + effective_idempotency_key = f"revision:{workflow_id}:{key}:{validated.idempotency_key}" + source = _source_for_normalized_revision_replay( + engine, + workflow_id, + key, + effective_idempotency_key, + source, + ) + receipt = engine.submit_operator_response( + workflow_id=workflow_id, + key=key, + payload=normalized_payload, + source=source, + idempotency_key=effective_idempotency_key, + resume=True, + ) + post_resume = _status_packet( + WorkflowEngine(db_path, read_only=True), + workflow_id, + recent_events=20, + commands="recent", + command_limit=20, + command_payload_chars=2000, + db_alias=db_alias, + ) + return receipt, post_resume + + try: + receipt, post_resume = await asyncio.to_thread(record_and_resume) + except RevisionActionValidationError as exc: + raise HTTPException(status_code=400, detail=exc.to_dict()) from exc + except Exception as exc: + raise HTTPException(status_code=400, detail=f"review response/resume failed: {type(exc).__name__}: {exc}") from exc + receipt_payload = _receipt_to_payload(receipt, resume_requested=True) + receipt_payload.pop("by", None) + return { + "success": True, + "db_alias": db_alias, + "receipt": receipt_payload, + "post_resume": post_resume, + "next_step": _next_step_for_receipt(receipt_payload), + } + + +@router.post("/approvals/decision") +async def decide_approval(body: dict[str, Any]) -> dict[str, Any]: + # Dashboard approval buttons are local operator actions, not an identity or + # permission system. Ignore browser-supplied actor/provenance fields and + # stamp only dashboard event provenance for audit/replay. + db_alias, db_path = _resolve_dashboard_db(body.get("db")) + action = str(body.get("action") or "approve").strip().lower() + if action not in {"approve", "reject"}: + raise HTTPException(status_code=400, detail="action must be approve or reject") + workflow_id = str(body.get("workflow_id") or "").strip() + key = str(body.get("key") or "").strip() + if not workflow_id or not key: + raise HTTPException(status_code=400, detail="workflow_id and key are required") + + message_id = f"dashboard:{uuid.uuid4()}" + decision = ApprovalDecisionInput( + workflow_id=workflow_id, + key=key, + action=action, + source={ + "channel": "local-dashboard", + "message_id": message_id, + }, + note=body.get("note"), + reason=body.get("reason"), + idempotency_key=message_id, + ) + def record_and_resume() -> tuple[Any, dict[str, Any]]: + _ensure_workflow_project_on_path(db_path) + receipt = WorkflowEngine(db_path).submit_approval_decision(decision, resume=True) + post_resume = _status_packet( + WorkflowEngine(db_path, read_only=True), + workflow_id, + recent_events=20, + commands="recent", + command_limit=20, + command_payload_chars=2000, + db_alias=db_alias, + ) + return receipt, post_resume + + try: + receipt, post_resume = await asyncio.to_thread(record_and_resume) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"approval decision/resume failed: {type(exc).__name__}: {exc}") from exc + receipt_payload = _receipt_to_payload(receipt, resume_requested=True) + receipt_payload.pop("by", None) + return { + "success": True, + "db_alias": db_alias, + "receipt": receipt_payload, + "post_resume": post_resume, + "next_step": _next_step_for_receipt(receipt_payload), + } diff --git a/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/plugin.yaml b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/plugin.yaml new file mode 100644 index 0000000..32b9a60 --- /dev/null +++ b/src/hermes_workflows/plugin_payload/hermes-workflows-approvals/plugin.yaml @@ -0,0 +1,14 @@ +name: hermes-workflows-approvals +version: "0.0.1rc1" +description: Hermes Agent adapter for hermes-workflows review queue, human input, and approval gates +kind: standalone +provides_tools: + - workflow_review_requests_list + - workflow_review_respond + - workflow_approval_decide +provides_hooks: + - pre_gateway_dispatch +provides_skills: + - hermes-workflows + - hermes-workflows-running + - hermes-workflows-creating diff --git a/tests/fixtures/plugin_manifest_v1.json b/tests/fixtures/plugin_manifest_v1.json new file mode 100644 index 0000000..386e948 --- /dev/null +++ b/tests/fixtures/plugin_manifest_v1.json @@ -0,0 +1,21 @@ +{ + "owned_paths": [ + "__init__.py", + "dashboard/dist/index.js", + "dashboard/dist/style.css", + "dashboard/manifest.json", + "dashboard/plugin_api.py", + "plugin.yaml" + ], + "receipt_fields": [ + "files", + "owner_id", + "ownership_key", + "package_name", + "package_version", + "plugin_name", + "plugin_version", + "schema_version" + ], + "schema_version": 1 +} diff --git a/tests/probes/fresh_profile_plugin.py b/tests/probes/fresh_profile_plugin.py new file mode 100644 index 0000000..003df7c --- /dev/null +++ b/tests/probes/fresh_profile_plugin.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +from hermes_workflows import plugin_install + + +def _scratch_base(): + if sys.platform != "darwin": + return None + + base = Path("/private/tmp") + try: + resolved = base.resolve(strict=True) + except OSError as exc: + raise RuntimeError("macOS probe scratch base /private/tmp is unavailable") from exc + if resolved != base or base.is_symlink() or not base.is_dir(): + raise RuntimeError("macOS probe scratch base /private/tmp must be a real non-symlink directory") + if not os.access(base, os.W_OK | os.X_OK): + raise RuntimeError("macOS probe scratch base /private/tmp must be writable and searchable") + return str(base) + + +def _old_payload(root: Path) -> Path: + source = plugin_install.canonical_payload_root() + destination = root / "old-payload" + shutil.copytree(source, destination) + plugin_yaml = destination / "plugin.yaml" + plugin_yaml.write_text( + plugin_yaml.read_text(encoding="utf-8").replace(plugin_install.PACKAGE_VERSION, "0.0.1rc0"), + encoding="utf-8", + ) + manifest_path = destination / "dashboard" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = "0.0.1rc0" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + style = destination / "dashboard" / "dist" / "style.css" + style.write_text(style.read_text(encoding="utf-8") + "\n/* probe-old */\n", encoding="utf-8") + return destination + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="hermes-workflows-plugin-probe-", dir=_scratch_base()) as raw: + root = Path(raw) + profile = root / "profile" + old_payload = _old_payload(root) + plugin_install.install_plugin(profile, payload_root=old_payload, expected_package_version="0.0.1rc0") + upgrade = plugin_install.upgrade_plugin(profile) + discovered = plugin_install.discover_installed_plugin(profile) + rollback = plugin_install.rollback_plugin(profile) + uninstall = plugin_install.uninstall_plugin(profile) + result = { + "scratch_root": str(root), + "temporary_profile": True, + "live_profile_mutated": False, + "wheel_payload_verified": False, + "deferred_wheel_gate": "INT-PKG-META and INT-PKG-ASSETS", + "discovered": discovered.to_dict(), + "upgrade": upgrade.to_dict(), + "rollback": rollback.to_dict(), + "uninstall": uninstall.to_dict(), + "profile_removed": not (profile / "plugins" / plugin_install.PLUGIN_NAME).exists(), + } + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_plugin_install.py b/tests/test_plugin_install.py new file mode 100644 index 0000000..6cbe9bc --- /dev/null +++ b/tests/test_plugin_install.py @@ -0,0 +1,735 @@ +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from hermes_workflows import plugin_install + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PAYLOAD_ROOT = REPO_ROOT / "src" / "hermes_workflows" / "plugin_payload" / plugin_install.PLUGIN_NAME +FIXTURE = REPO_ROOT / "tests" / "fixtures" / "plugin_manifest_v1.json" + + +def _versioned_payload(tmp_path: Path, version: str, marker: str) -> Path: + destination = tmp_path / ("payload-" + version.replace(".", "-")) + shutil.copytree(PAYLOAD_ROOT, destination) + plugin_yaml = destination / "plugin.yaml" + plugin_yaml.write_text( + plugin_yaml.read_text(encoding="utf-8").replace(plugin_install.PACKAGE_VERSION, version), + encoding="utf-8", + ) + manifest_path = destination / "dashboard" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = version + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + style_path = destination / "dashboard" / "dist" / "style.css" + style_path.write_text(style_path.read_text(encoding="utf-8") + f"\n/* {marker} */\n", encoding="utf-8") + return destination + + +def _installed(profile: Path) -> Path: + return profile / "plugins" / plugin_install.PLUGIN_NAME + + +def _tree_snapshot(root: Path): + snapshot = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + if path.is_symlink(): + snapshot[relative] = ("symlink", os.readlink(path)) + elif path.is_dir(): + snapshot[relative] = ("directory", None) + else: + snapshot[relative] = ("file", path.read_bytes()) + return snapshot + + +def _lexical_tree_snapshot(root: Path): + snapshot = {} + + def visit(path: Path, relative: str) -> None: + try: + mode = path.lstat().st_mode + except FileNotFoundError: + snapshot[relative] = ("missing", None) + return + if stat.S_ISLNK(mode): + snapshot[relative] = ("symlink", os.readlink(path)) + return + if stat.S_ISDIR(mode): + names = tuple(sorted(entry.name for entry in os.scandir(path))) + snapshot[relative] = ("directory", names) + for name in names: + child_relative = name if relative == "." else f"{relative}/{name}" + visit(path / name, child_relative) + return + if stat.S_ISREG(mode): + snapshot[relative] = ("file", path.read_bytes()) + return + snapshot[relative] = ("other", mode) + + visit(root, ".") + return snapshot + + +def _prepare_profile_for_action(tmp_path: Path, profile: Path, action: str) -> None: + profile.mkdir(parents=True) + (profile / "user-file.txt").write_bytes(b"must remain byte-for-byte unchanged\n") + if action == "rollback": + old = _versioned_payload(tmp_path, "0.0.1rc0", "ancestor-symlink-rollback") + plugin_install.install_plugin(profile, payload_root=old, expected_package_version="0.0.1rc0") + plugin_install.upgrade_plugin(profile) + elif action != "install": + plugin_install.install_plugin(profile) + + +def _invoke_action(action: str, profile: plugin_install.PathLike) -> None: + if action == "install": + plugin_install.install_plugin(profile) + elif action == "upgrade": + plugin_install.upgrade_plugin(profile) + elif action == "rollback": + plugin_install.rollback_plugin(profile) + elif action == "uninstall": + plugin_install.uninstall_plugin(profile) + else: + plugin_install.discover_installed_plugin(profile) + + +RELATIVE_PROFILE_CASES = ( + pytest.param("profile", "profile", id="string-bare"), + pytest.param(Path("profile"), "profile", id="path-bare"), + pytest.param("profiles/nested-profile", "profiles/nested-profile", id="string-nested"), + pytest.param(Path("profiles") / "nested-profile", "profiles/nested-profile", id="path-nested"), + pytest.param("./profile", "profile", id="string-dot"), + pytest.param(Path(".") / "profile", "profile", id="path-dot"), + pytest.param("nested/../profile", "profile", id="string-parent"), + pytest.param(Path("nested") / ".." / "profile", "profile", id="path-parent"), +) + + +def test_dangling_config_symlink_is_refused_without_any_profile_mutation(tmp_path: Path): + profile = tmp_path / "profile" + profile.mkdir() + config = profile / "config.yaml" + missing_target = tmp_path / "missing-config-target" + config.symlink_to(missing_target) + before = _tree_snapshot(profile) + + with pytest.raises(plugin_install.UserFileConflictError, match="config.yaml.*non-symlink"): + plugin_install.install_plugin(profile) + + assert config.is_symlink() + assert os.readlink(config) == str(missing_target) + assert not missing_target.exists() + assert _tree_snapshot(profile) == before + + +def test_dangling_plugin_root_symlink_is_refused_without_any_profile_mutation(tmp_path: Path): + profile = tmp_path / "profile" + profile.mkdir() + config = profile / "config.yaml" + config.write_bytes(b"model:\n default: user/model\n") + plugins = profile / "plugins" + missing_target = tmp_path / "missing-plugin-root" + plugins.symlink_to(missing_target, target_is_directory=True) + before = _tree_snapshot(profile) + + with pytest.raises(plugin_install.UserFileConflictError, match="plugin root.*symlink"): + plugin_install.install_plugin(profile) + + assert plugins.is_symlink() + assert os.readlink(plugins) == str(missing_target) + assert not missing_target.exists() + assert _tree_snapshot(profile) == before + + +@pytest.mark.parametrize( + "managed_name", + [plugin_install.PLUGIN_NAME, f".{plugin_install.PLUGIN_NAME}.rollback"], +) +def test_dangling_managed_plugin_symlink_is_refused_without_any_profile_mutation( + tmp_path: Path, + managed_name: str, +): + profile = tmp_path / "profile" + plugins = profile / "plugins" + plugins.mkdir(parents=True) + config = profile / "config.yaml" + config.write_bytes(b"model:\n default: user/model\n") + managed_path = plugins / managed_name + missing_target = tmp_path / f"missing-{managed_name}" + managed_path.symlink_to(missing_target, target_is_directory=True) + before = _tree_snapshot(profile) + + with pytest.raises(plugin_install.UserFileConflictError, match="plugin.*symlink"): + plugin_install.install_plugin(profile) + + assert managed_path.is_symlink() + assert os.readlink(managed_path) == str(missing_target) + assert not missing_target.exists() + assert _tree_snapshot(profile) == before + + +@pytest.mark.parametrize("action", ["install", "upgrade", "rollback", "uninstall", "discovery"]) +def test_symlinked_profile_root_is_rejected_without_touching_target(tmp_path: Path, action: str): + target = tmp_path / "profile-target" + target.mkdir() + (target / "user-file.txt").write_bytes(b"must remain byte-for-byte unchanged\n") + + if action == "rollback": + old = _versioned_payload(tmp_path, "0.0.1rc0", "symlink-root-rollback") + plugin_install.install_plugin(target, payload_root=old, expected_package_version="0.0.1rc0") + plugin_install.upgrade_plugin(target) + elif action != "install": + plugin_install.install_plugin(target) + + profile = tmp_path / "supplied-profile" + profile.symlink_to(target, target_is_directory=True) + before = _tree_snapshot(target) + + with pytest.raises(plugin_install.UserFileConflictError, match="profile home.*symlink"): + if action == "install": + plugin_install.install_plugin(profile) + elif action == "upgrade": + plugin_install.upgrade_plugin(profile) + elif action == "rollback": + plugin_install.rollback_plugin(profile) + elif action == "uninstall": + plugin_install.uninstall_plugin(profile) + else: + plugin_install.discover_installed_plugin(profile) + + assert _tree_snapshot(target) == before + + +@pytest.mark.parametrize("action", ["install", "upgrade", "rollback", "uninstall", "discovery"]) +@pytest.mark.parametrize("ancestor_shape", ["immediate", "multi-depth"]) +def test_symlinked_profile_ancestor_is_refused_before_target_mutation( + tmp_path: Path, + action: str, + ancestor_shape: str, +): + outside = tmp_path / "outside" + suffix = ("profile",) if ancestor_shape == "immediate" else ("nested", "profile") + target_profile = outside.joinpath(*suffix) + _prepare_profile_for_action(tmp_path, target_profile, action) + + if ancestor_shape == "immediate": + supplied_ancestor = tmp_path / "supplied-ancestor" + else: + supplied_ancestor = tmp_path / "supplied-root" / "level-one" / "linked-ancestor" + supplied_ancestor.parent.mkdir(parents=True) + supplied_ancestor.symlink_to(outside, target_is_directory=True) + supplied_profile = supplied_ancestor.joinpath(*suffix) + lexical_before = _lexical_tree_snapshot(tmp_path) + target_before = _lexical_tree_snapshot(outside) + supplied_before = _lexical_tree_snapshot(supplied_ancestor) + + with pytest.raises(plugin_install.UserFileConflictError, match="profile home.*symlink"): + _invoke_action(action, supplied_profile) + + assert _lexical_tree_snapshot(tmp_path) == lexical_before + assert _lexical_tree_snapshot(outside) == target_before + assert _lexical_tree_snapshot(supplied_ancestor) == supplied_before + assert supplied_ancestor.is_symlink() + assert os.readlink(supplied_ancestor) == str(outside) + + +def test_dangling_profile_ancestor_is_refused_without_creating_its_target(tmp_path: Path): + missing_target = tmp_path / "missing-ancestor-target" + supplied_ancestor = tmp_path / "supplied-ancestor" + supplied_ancestor.symlink_to(missing_target, target_is_directory=True) + supplied_profile = supplied_ancestor / "nested" / "profile" + lexical_before = _lexical_tree_snapshot(tmp_path) + supplied_before = _lexical_tree_snapshot(supplied_ancestor) + missing_before = _lexical_tree_snapshot(missing_target) + + with pytest.raises(plugin_install.UserFileConflictError, match="profile home.*symlink"): + plugin_install.install_plugin(supplied_profile) + + assert _lexical_tree_snapshot(tmp_path) == lexical_before + assert _lexical_tree_snapshot(supplied_ancestor) == supplied_before + assert _lexical_tree_snapshot(missing_target) == missing_before + assert _lexical_tree_snapshot(supplied_profile) == {".": ("missing", None)} + assert os.readlink(supplied_ancestor) == str(missing_target) + + +def test_profile_home_parent_traversal_is_refused_without_normalizing_components(tmp_path: Path): + anchor = tmp_path / "anchor" + anchor.mkdir() + supplied_profile = Path(f"{anchor}{os.sep}..{os.sep}profile") + before = _lexical_tree_snapshot(tmp_path) + + with pytest.raises(plugin_install.UserFileConflictError, match="traversal"): + plugin_install.install_plugin(supplied_profile) + + assert _lexical_tree_snapshot(tmp_path) == before + assert not (tmp_path / "profile").exists() + + +def test_profile_home_dot_traversal_is_refused_without_normalizing_components(tmp_path: Path): + anchor = tmp_path / "anchor" + anchor.mkdir() + supplied_profile = f"{anchor}{os.sep}.{os.sep}profile" + before = _lexical_tree_snapshot(tmp_path) + + with pytest.raises(plugin_install.UserFileConflictError, match="traversal"): + plugin_install.install_plugin(supplied_profile) + + assert _lexical_tree_snapshot(tmp_path) == before + assert not (anchor / "profile").exists() + + +def test_real_profile_ancestors_allow_existing_and_missing_leaf_profiles(tmp_path: Path): + real_parent = tmp_path / "real-parent" + existing = real_parent / "existing-profile" + existing.mkdir(parents=True) + (existing / "user-file.txt").write_bytes(b"preserve me\n") + missing_leaf = real_parent / "missing-profile" + + existing_report = plugin_install.install_plugin(existing) + missing_report = plugin_install.install_plugin(missing_leaf) + + assert existing_report.profile_home == str(existing.resolve()) + assert missing_report.profile_home == str(missing_leaf.resolve()) + assert (existing / "user-file.txt").read_bytes() == b"preserve me\n" + assert plugin_install.discover_installed_plugin(existing).plugin_name == plugin_install.PLUGIN_NAME + assert plugin_install.discover_installed_plugin(missing_leaf).plugin_name == plugin_install.PLUGIN_NAME + + +def test_profile_home_expands_tilde_to_an_absolute_profile(tmp_path: Path, monkeypatch): + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + tilde_report = plugin_install.install_plugin("~/tilde-profile") + + assert tilde_report.profile_home == str((fake_home / "tilde-profile").resolve()) + + +def test_relative_missing_leaf_is_refused_before_real_cwd_mutation(tmp_path: Path, monkeypatch): + work = tmp_path / "work" + work.mkdir() + monkeypatch.chdir(work) + before = _lexical_tree_snapshot(work) + + with pytest.raises(plugin_install.UserFileConflictError, match="absolute"): + plugin_install.install_plugin("profiles/missing-profile") + + assert _lexical_tree_snapshot(work) == before + assert _lexical_tree_snapshot(work / "profiles") == {".": ("missing", None)} + + +def test_relative_logical_cwd_symlink_alias_is_refused_before_physical_mutation(tmp_path: Path, monkeypatch): + physical = tmp_path / "physical-work" + physical.mkdir() + (physical / "user-file.txt").write_bytes(b"preserve physical cwd bytes\n") + logical = tmp_path / "logical-work" + logical.symlink_to(physical, target_is_directory=True) + monkeypatch.chdir(logical) + monkeypatch.setenv("PWD", str(logical)) + lexical_before = _lexical_tree_snapshot(tmp_path) + physical_before = _lexical_tree_snapshot(physical) + + with pytest.raises(plugin_install.UserFileConflictError, match="absolute"): + plugin_install.install_plugin("profiles/missing-profile") + + assert _lexical_tree_snapshot(tmp_path) == lexical_before + assert _lexical_tree_snapshot(physical) == physical_before + assert logical.is_symlink() + assert os.readlink(logical) == str(physical) + + +@pytest.mark.parametrize("action", ["install", "upgrade", "rollback", "uninstall", "discovery"]) +@pytest.mark.parametrize(("supplied_profile", "target_suffix"), RELATIVE_PROFILE_CASES) +def test_relative_profile_home_is_refused_for_every_action_without_mutation( + tmp_path: Path, + monkeypatch, + action: str, + supplied_profile, + target_suffix: str, +): + work = tmp_path / "work" + target_profile = work / target_suffix + _prepare_profile_for_action(tmp_path, target_profile, action) + (work / "nested").mkdir(exist_ok=True) + monkeypatch.chdir(work) + before = _lexical_tree_snapshot(work) + + with pytest.raises(plugin_install.UserFileConflictError, match="absolute"): + _invoke_action(action, supplied_profile) + + assert _lexical_tree_snapshot(work) == before + + +@pytest.mark.parametrize("pwd_value", [None, "/poisoned/logical/cwd"]) +def test_relative_profile_refusal_does_not_consult_pwd_or_cwd(tmp_path: Path, monkeypatch, pwd_value): + work = tmp_path / "work" + work.mkdir() + monkeypatch.chdir(work) + if pwd_value is None: + monkeypatch.delenv("PWD", raising=False) + else: + monkeypatch.setenv("PWD", pwd_value) + + def fail_getcwd(): + raise AssertionError("relative profile refusal must not consult cwd") + + monkeypatch.setattr(plugin_install.os, "getcwd", fail_getcwd) + before = _lexical_tree_snapshot(work) + + with pytest.raises(plugin_install.UserFileConflictError, match="absolute"): + plugin_install.install_plugin("missing-profile") + + assert _lexical_tree_snapshot(work) == before + + +@pytest.mark.parametrize("action", ["install", "upgrade"]) +def test_relative_install_and_upgrade_refuse_before_payload_discovery(monkeypatch, action: str): + def fail_payload_discovery(*args, **kwargs): + raise AssertionError("relative profile refusal must precede payload discovery") + + monkeypatch.setattr(plugin_install, "inspect_payload", fail_payload_discovery) + + with pytest.raises(plugin_install.UserFileConflictError, match="absolute"): + _invoke_action(action, "relative-profile") + + +@pytest.mark.parametrize("invalid_home", ["", "profile\x00home"]) +def test_empty_and_nul_profile_homes_are_refused_without_mutation(tmp_path: Path, invalid_home: str): + before = _lexical_tree_snapshot(tmp_path) + + with pytest.raises(plugin_install.UserFileConflictError, match="nonempty"): + plugin_install.install_plugin(invalid_home) + + assert _lexical_tree_snapshot(tmp_path) == before + + +def test_unresolved_tilde_profile_home_is_refused_without_mutation(tmp_path: Path, monkeypatch): + supplied_profile = "~missing-hermes-workflows-user/profile" + monkeypatch.setattr(plugin_install.os.path, "expanduser", lambda value: value) + before = _lexical_tree_snapshot(tmp_path) + + with pytest.raises(plugin_install.UserFileConflictError, match="expansion.*resolved"): + plugin_install.install_plugin(supplied_profile) + + assert _lexical_tree_snapshot(tmp_path) == before + + +def test_canonical_payload_has_one_version_and_all_dashboard_surfaces(): + payload = plugin_install.inspect_payload(PAYLOAD_ROOT) + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + + assert payload.package_version == plugin_install.PACKAGE_VERSION + assert payload.plugin_version == plugin_install.PACKAGE_VERSION + assert list(payload.files) == fixture["owned_paths"] + assert payload.dashboard_manifest["name"] == plugin_install.PLUGIN_NAME + assert payload.dashboard_manifest["api"] == "plugin_api.py" + assert payload.dashboard_manifest["entry"] == "dist/index.js" + assert payload.dashboard_manifest["css"] == "dist/style.css" + assert "__HERMES_PLUGINS__.register" in (PAYLOAD_ROOT / "dashboard" / "dist" / "index.js").read_text(encoding="utf-8") + assert (PAYLOAD_ROOT / "dashboard" / "plugin_api.py").read_text(encoding="utf-8").find("router = APIRouter()") >= 0 + + +def test_install_is_profile_scoped_atomic_enabled_and_reports_reload_contract(tmp_path: Path): + profile = tmp_path / "fresh-profile" + + report = plugin_install.install_plugin(profile) + discovery = plugin_install.discover_installed_plugin(profile) + receipt = json.loads((_installed(profile) / plugin_install.RECEIPT_NAME).read_text(encoding="utf-8")) + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + + assert report.action == "install" + assert report.enabled is True + assert report.restart_required is True + assert report.rescan_supported is True + assert report.rescan_endpoint == "/api/dashboard/plugins/rescan" + assert discovery.plugin_name == plugin_install.PLUGIN_NAME + assert discovery.api_route == "/api/plugins/hermes-workflows-approvals" + assert discovery.asset_routes == ( + "/dashboard-plugins/hermes-workflows-approvals/dist/index.js", + "/dashboard-plugins/hermes-workflows-approvals/dist/style.css", + ) + assert set(receipt) == set(fixture["receipt_fields"]) + assert [item["path"] for item in receipt["files"]] == fixture["owned_paths"] + config = (profile / "config.yaml").read_text(encoding="utf-8") + assert "plugins:" in config + assert "enabled:" in config + assert f"- {plugin_install.PLUGIN_NAME}" in config + assert not list((profile / "plugins").glob(f".{plugin_install.PLUGIN_NAME}.stage-*")) + + +def test_enablement_preserves_other_config_and_removes_explicit_disable(tmp_path: Path): + profile = tmp_path / "profile" + profile.mkdir() + (profile / "config.yaml").write_text( + "model:\n default: test/model\nplugins:\n enabled:\n - other-plugin\n disabled:\n - hermes-workflows-approvals\n - noisy-plugin\n", + encoding="utf-8", + ) + + plugin_install.install_plugin(profile) + + config = (profile / "config.yaml").read_text(encoding="utf-8") + assert "default: test/model" in config + assert "- other-plugin" in config + assert "- noisy-plugin" in config + assert config.count("- hermes-workflows-approvals") == 1 + + +def test_unsupported_inline_plugin_config_is_refused_without_mutation(tmp_path: Path): + profile = tmp_path / "profile" + profile.mkdir() + config_path = profile / "config.yaml" + original = "plugins: {enabled: [other-plugin]}\n" + config_path.write_text(original, encoding="utf-8") + + with pytest.raises(plugin_install.UserFileConflictError, match="block mapping"): + plugin_install.install_plugin(profile) + + assert config_path.read_text(encoding="utf-8") == original + assert not _installed(profile).exists() + + +def test_upgrade_retains_one_owned_rollback_and_rollback_swaps_versions(tmp_path: Path): + profile = tmp_path / "profile" + old = _versioned_payload(tmp_path, "0.0.1rc0", "old-payload") + plugin_install.install_plugin(profile, payload_root=old, expected_package_version="0.0.1rc0") + + upgraded = plugin_install.upgrade_plugin(profile) + assert upgraded.previous_version == "0.0.1rc0" + assert upgraded.plugin_version == plugin_install.PACKAGE_VERSION + assert upgraded.rollback_available is True + assert "old-payload" not in (_installed(profile) / "dashboard" / "dist" / "style.css").read_text(encoding="utf-8") + + rolled_back = plugin_install.rollback_plugin(profile) + assert rolled_back.action == "rollback" + assert rolled_back.plugin_version == "0.0.1rc0" + assert rolled_back.previous_version == plugin_install.PACKAGE_VERSION + assert "old-payload" in (_installed(profile) / "dashboard" / "dist" / "style.css").read_text(encoding="utf-8") + + +def test_interrupted_upgrade_restores_previous_install_and_cleans_stage(tmp_path: Path, monkeypatch): + profile = tmp_path / "profile" + old = _versioned_payload(tmp_path, "0.0.1rc0", "survives-interruption") + plugin_install.install_plugin(profile, payload_root=old, expected_package_version="0.0.1rc0") + real_replace = plugin_install.os.replace + + def fail_stage_promotion(source, destination): + source_path = Path(source) + destination_path = Path(destination) + if source_path.name.startswith(f".{plugin_install.PLUGIN_NAME}.stage-") and destination_path.name == plugin_install.PLUGIN_NAME: + raise OSError("simulated interruption") + return real_replace(source, destination) + + monkeypatch.setattr(plugin_install.os, "replace", fail_stage_promotion) + with pytest.raises(plugin_install.PluginInstallError, match="simulated interruption"): + plugin_install.upgrade_plugin(profile) + + current = plugin_install.discover_installed_plugin(profile) + assert current.plugin_version == "0.0.1rc0" + assert "survives-interruption" in (_installed(profile) / "dashboard" / "dist" / "style.css").read_text(encoding="utf-8") + assert not list((profile / "plugins").glob(f".{plugin_install.PLUGIN_NAME}.stage-*")) + + +def test_recovery_removes_only_receipted_stale_stage(tmp_path: Path): + profile = tmp_path / "profile" + plugin_install.install_plugin(profile) + stage = profile / "plugins" / f".{plugin_install.PLUGIN_NAME}.stage-stale" + shutil.copytree(_installed(profile), stage) + + report = plugin_install.upgrade_plugin(profile) + + assert report.plugin_version == plugin_install.PACKAGE_VERSION + assert not stage.exists() + + +def test_recovery_restores_receipted_tree_from_interrupted_uninstall(tmp_path: Path): + profile = tmp_path / "profile" + plugin_install.install_plugin(profile) + destination = _installed(profile) + interrupted = profile / "plugins" / f".{plugin_install.PLUGIN_NAME}.remove-current-interrupted" + os.replace(destination, interrupted) + + report = plugin_install.upgrade_plugin(profile) + + assert report.previous_version == plugin_install.PACKAGE_VERSION + assert destination.exists() + assert not interrupted.exists() + + +def test_recovery_completes_interrupted_rollback_swap_safely(tmp_path: Path): + profile = tmp_path / "profile" + old = _versioned_payload(tmp_path, "0.0.1rc0", "rollback-after-crash") + plugin_install.install_plugin(profile, payload_root=old, expected_package_version="0.0.1rc0") + plugin_install.upgrade_plugin(profile) + destination = _installed(profile) + interrupted = profile / "plugins" / f".{plugin_install.PLUGIN_NAME}.swap-interrupted" + os.replace(destination, interrupted) + + report = plugin_install.rollback_plugin(profile) + + assert report.plugin_version == "0.0.1rc0" + assert destination.exists() + assert not interrupted.exists() + + +def test_corrupt_or_stale_receipt_blocks_upgrade_and_uninstall_without_touching_files(tmp_path: Path): + profile = tmp_path / "profile" + plugin_install.install_plugin(profile) + destination = _installed(profile) + manifest_before = (destination / "dashboard" / "manifest.json").read_bytes() + (destination / plugin_install.RECEIPT_NAME).write_text("{not-json", encoding="utf-8") + + with pytest.raises(plugin_install.OwnershipError): + plugin_install.upgrade_plugin(profile) + with pytest.raises(plugin_install.OwnershipError): + plugin_install.uninstall_plugin(profile) + + assert destination.exists() + assert (destination / "dashboard" / "manifest.json").read_bytes() == manifest_before + + +def test_modified_owned_file_is_treated_as_stale_and_never_replaced(tmp_path: Path): + profile = tmp_path / "profile" + plugin_install.install_plugin(profile) + style = _installed(profile) / "dashboard" / "dist" / "style.css" + style.write_text(style.read_text(encoding="utf-8") + "\n/* user edit */\n", encoding="utf-8") + + with pytest.raises(plugin_install.OwnershipError, match="no longer matches"): + plugin_install.upgrade_plugin(profile) + + assert style.read_text(encoding="utf-8").endswith("/* user edit */\n") + + +def test_user_owned_destination_and_added_file_are_never_overwritten_or_deleted(tmp_path: Path): + profile = tmp_path / "profile" + destination = _installed(profile) + destination.mkdir(parents=True) + note = destination / "user-note.txt" + note.write_text("mine", encoding="utf-8") + + with pytest.raises(plugin_install.UserFileConflictError): + plugin_install.install_plugin(profile) + assert note.read_text(encoding="utf-8") == "mine" + + shutil.rmtree(destination) + plugin_install.install_plugin(profile) + note = _installed(profile) / "user-note.txt" + note.write_text("mine", encoding="utf-8") + with pytest.raises(plugin_install.UserFileConflictError): + plugin_install.uninstall_plugin(profile) + assert note.read_text(encoding="utf-8") == "mine" + + +def test_uninstall_removes_only_verified_owned_install_and_enablement(tmp_path: Path): + profile = tmp_path / "profile" + plugin_install.install_plugin(profile) + + report = plugin_install.uninstall_plugin(profile) + + assert report.action == "uninstall" + assert report.enabled is False + assert not _installed(profile).exists() + assert not (profile / "plugins" / f".{plugin_install.PLUGIN_NAME}.rollback").exists() + assert plugin_install.PLUGIN_NAME not in (profile / "config.yaml").read_text(encoding="utf-8") + + +def test_payload_and_receipt_traversal_or_symlink_escape_are_refused(tmp_path: Path): + hostile = tmp_path / "hostile-payload" + shutil.copytree(PAYLOAD_ROOT, hostile) + style = hostile / "dashboard" / "dist" / "style.css" + style.unlink() + outside = tmp_path / "outside.css" + outside.write_text("outside", encoding="utf-8") + style.symlink_to(outside) + + with pytest.raises(plugin_install.PayloadValidationError): + plugin_install.install_plugin(tmp_path / "profile-a", payload_root=hostile) + + profile = tmp_path / "profile-b" + plugin_install.install_plugin(profile) + receipt_path = _installed(profile) / plugin_install.RECEIPT_NAME + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["files"][0]["path"] = "../escape" + receipt_path.write_text(json.dumps(receipt), encoding="utf-8") + with pytest.raises(plugin_install.OwnershipError): + plugin_install.uninstall_plugin(profile) + assert _installed(profile).exists() + + +def test_payload_version_mismatch_is_rejected_before_profile_mutation(tmp_path: Path): + hostile = _versioned_payload(tmp_path, "9.9.9", "mismatch") + manifest_path = hostile / "dashboard" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = "8.8.8" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + profile = tmp_path / "profile" + + with pytest.raises(plugin_install.PayloadValidationError, match="version"): + plugin_install.install_plugin(profile, payload_root=hostile, expected_package_version="9.9.9") + assert not profile.exists() + + +def test_fresh_profile_probe_exercises_discovery_upgrade_rollback_and_uninstall(): + completed = subprocess.run( + [sys.executable, "tests/probes/fresh_profile_plugin.py"], + cwd=REPO_ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, "PYTHONPATH": str(REPO_ROOT / "src")}, + ) + receipt = json.loads(completed.stdout) + + assert receipt["temporary_profile"] is True + assert receipt["discovered"]["plugin_name"] == plugin_install.PLUGIN_NAME + assert receipt["upgrade"]["plugin_version"] == plugin_install.PACKAGE_VERSION + assert receipt["rollback"]["plugin_version"] == "0.0.1rc0" + assert receipt["uninstall"]["action"] == "uninstall" + assert receipt["live_profile_mutated"] is False + assert receipt["wheel_payload_verified"] is False + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS temp roots can contain symlink ancestors") +def test_fresh_profile_probe_ignores_symlink_rooted_inherited_tmpdir(tmp_path: Path): + inherited_target = tmp_path / "inherited-target" + inherited_target.mkdir() + inherited_tmpdir = tmp_path / "inherited-tmpdir" + inherited_tmpdir.symlink_to(inherited_target, target_is_directory=True) + + completed = subprocess.run( + [sys.executable, "tests/probes/fresh_profile_plugin.py"], + cwd=REPO_ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={ + **os.environ, + "PYTHONPATH": str(REPO_ROOT / "src"), + "TMPDIR": str(inherited_tmpdir), + }, + ) + receipt = json.loads(completed.stdout) + + scratch_root = Path(receipt["scratch_root"]) + assert scratch_root.parent == Path("/private/tmp") + assert not scratch_root.exists() + assert receipt["temporary_profile"] is True + assert receipt["discovered"]["plugin_name"] == plugin_install.PLUGIN_NAME + assert receipt["upgrade"]["plugin_version"] == plugin_install.PACKAGE_VERSION + assert receipt["rollback"]["plugin_version"] == "0.0.1rc0" + assert receipt["uninstall"]["action"] == "uninstall" + assert receipt["profile_removed"] is True + assert receipt["live_profile_mutated"] is False + assert receipt["wheel_payload_verified"] is False