502 lines
24 KiB
Python
502 lines
24 KiB
Python
"""Cron job argument normalization, validation and result shaping (re-exported by
|
|
tools/cronjob_tools.py)."""
|
|
|
|
import contextlib
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
from cron.jobs import effective_job_state
|
|
|
|
import hermes_time
|
|
|
|
# Logger parity with the origin module.
|
|
logger = logging.getLogger("tools.cronjob_tools")
|
|
|
|
# A one-shot firing within this many minutes is still part of the conversation that created it:
|
|
# with platforms.slack.extra.reply_in_thread at its default true, the whole exchange under a
|
|
# top-level message lives in the thread keyed on that message's own id.
|
|
_THREAD_HORIZON_MINUTES = 60
|
|
|
|
|
|
def _first_fire_within_thread_horizon(
|
|
schedule: Union[str, Dict[str, Any], None],
|
|
) -> bool:
|
|
"""True when the job's first fire is close enough that the creating conversation is still
|
|
alive when it happens. Only near one-shots qualify; recurring jobs and one-shots beyond the
|
|
horizon outlive the conversation, which is what the synthetic-drop rule protects."""
|
|
if not schedule:
|
|
return False
|
|
parsed: Optional[Dict[str, Any]]
|
|
if isinstance(schedule, dict):
|
|
parsed = schedule
|
|
else:
|
|
parsed = None
|
|
with contextlib.suppress(Exception):
|
|
from cron.jobs import parse_schedule
|
|
|
|
parsed = parse_schedule(schedule)
|
|
if not isinstance(parsed, dict) or parsed.get("kind") != "once":
|
|
return False
|
|
run_at = parsed.get("run_at")
|
|
if not run_at:
|
|
return False
|
|
try:
|
|
fire_at = datetime.fromisoformat(str(run_at).replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return False
|
|
now = hermes_time.now()
|
|
if fire_at.tzinfo is None:
|
|
fire_at = fire_at.replace(tzinfo=now.tzinfo)
|
|
# Bounded interval: an already-expired run_at gives a negative delta that would
|
|
# otherwise sail through a bare upper bound — a conversation that is already over
|
|
# must fail closed to the channel-level drop, while a fire at this instant still
|
|
# happens inside the live conversation and keeps the thread.
|
|
delta = fire_at - now
|
|
return timedelta(0) <= delta <= timedelta(minutes=_THREAD_HORIZON_MINUTES)
|
|
|
|
|
|
def _origin_from_env(
|
|
schedule: Union[str, Dict[str, Any], None] = None,
|
|
) -> Optional[Dict[str, str]]:
|
|
from gateway.session_context import async_delivery_supported, get_session_env
|
|
origin_platform = get_session_env("HERMES_SESSION_PLATFORM")
|
|
origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
|
|
if not (origin_platform and origin_chat_id):
|
|
return None
|
|
# A non-push surface (api_server: request/response, ``send()`` is a stub) cannot receive a
|
|
# fire-time report, so an origin stamp would make ``deliver=origin`` fail silently on every
|
|
# fire (#69304). No origin => the home-channel fallback + creation-time notice apply.
|
|
if not async_delivery_supported():
|
|
return None
|
|
thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None
|
|
# Slack stamps every TOP-LEVEL message's own id as the session thread (a per-message
|
|
# KEY, not a location); persisting it would pin all future deliveries inside an
|
|
# ephemeral thread, so thread == creating message id is synthetic and dropped — unless
|
|
# the job's first fire is within the conversation's remaining lifetime: under the
|
|
# default reply_in_thread the exchange under a top-level message lives in exactly that
|
|
# thread, so a near one-shot must deliver back into it.
|
|
if thread_id and origin_platform == "slack":
|
|
message_id = get_session_env("HERMES_SESSION_MESSAGE_ID") or None
|
|
if message_id and str(thread_id) == str(message_id):
|
|
if _first_fire_within_thread_horizon(schedule):
|
|
logger.debug(
|
|
"Cron origin: keeping synthetic Slack thread_id=%s — first fire is "
|
|
"within the conversation horizon",
|
|
thread_id,
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Cron origin: dropping synthetic per-message Slack "
|
|
"thread_id=%s (== creation message id)",
|
|
thread_id,
|
|
)
|
|
thread_id = None
|
|
if thread_id:
|
|
logger.debug(
|
|
"Cron origin captured thread_id=%s for %s:%s",
|
|
thread_id, origin_platform, origin_chat_id)
|
|
return {
|
|
"platform": origin_platform, "chat_id": origin_chat_id,
|
|
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None, "thread_id": thread_id,
|
|
# Lets a delivery mirror resolve the participant's session in per-user-isolated groups.
|
|
"user_id": get_session_env("HERMES_SESSION_USER_ID") or None,
|
|
# Workspace/server scope (Slack team, Discord guild...): Slack session keys embed it,
|
|
# so a continuable cron seed built without it would never resolve a scoped reply.
|
|
"scope_id": get_session_env("HERMES_SESSION_SCOPE_ID") or None,
|
|
}
|
|
|
|
|
|
def _local_delivery_notice(job: Dict[str, Any], user_deliver: Optional[str]) -> Optional[str]:
|
|
"""Notice when a created job won't deliver anywhere: CLI/TUI sessions have no capturable
|
|
origin, so deliver='origin' (or omitted) saves output but never delivers it. None when the
|
|
user explicitly asked for ``local`` or the job resolves to a real target.
|
|
|
|
TUI/CLI sessions cannot be captured as a cron ``origin`` (no ``HERMES_SESSION_PLATFORM``/``CHAT_ID`` is
|
|
set for them), so a ``deliver="origin"`` request — or an omitted ``deliver`` that defaults to
|
|
origin-or-local — produces a job that runs and saves output to ``last_output`` but is never delivered
|
|
back into the session. This is by design (there is no live-delivery channel for local sessions), but
|
|
silently dropping the user's "tell me when it runs" intent is the trap reported in 51568. Surface it at
|
|
create time so the agent can relay it instead of promising a delivery that never happens. See #51568.
|
|
"""
|
|
if (user_deliver or "").strip().lower() == "local":
|
|
return None
|
|
try:
|
|
from cron.scheduler import _resolve_delivery_targets
|
|
targets = _resolve_delivery_targets(job)
|
|
if targets:
|
|
# _origin_from_env() dropped a non-push origin (api_server) and the job rerouted to a
|
|
# home channel: tell the creating client where the report goes (#69304).
|
|
from gateway.session_context import async_delivery_supported, get_session_env
|
|
fallback = [t for t in targets if t.get("_resolved_from") == "origin_fallback"]
|
|
if fallback and get_session_env("HERMES_SESSION_PLATFORM") and not async_delivery_supported():
|
|
return ("Note: this stateless HTTP API session cannot receive cron delivery, so this "
|
|
f"job will report to the home channel {fallback[0]['platform']}:"
|
|
f"{fallback[0]['chat_id']} instead of back here.")
|
|
return None
|
|
except Exception: # resolution unavailable — fall back to the origin signal
|
|
if job.get("origin"):
|
|
return None
|
|
return (
|
|
"This is a local-only cron job: its output is saved (view it with "
|
|
"cronjob(action='list')) but will NOT be delivered back into this "
|
|
"session — CLI/TUI and stateless HTTP API sessions have no live-delivery channel. To be "
|
|
"notified when it runs, recreate or update the job with deliver set to "
|
|
"a gateway-connected platform, e.g. deliver='telegram' or deliver='all'.")
|
|
|
|
|
|
def _mode_guidance_notes(job: Dict[str, Any], user_deliver: Optional[str]) -> List[str]:
|
|
"""Mode guidance echoed once in the create/update response (not in the schema, which is
|
|
paid for on every API call)."""
|
|
notes: List[str] = []
|
|
if job.get("monitor_script") or job.get("monitor_url"):
|
|
notes.append(
|
|
"Monitor mode: the source runs first each tick and its output is "
|
|
"hashed as exact bytes — unchanged output suppresses the agent run "
|
|
"(silent no_change tick), changed output injects a MONITOR CHANGE "
|
|
"DETECTED diff into the prompt. The first tick always runs as "
|
|
"baseline. The source must emit STABLE output (no timestamps, no "
|
|
"random ordering) or every tick will look changed.")
|
|
if job.get("no_agent"):
|
|
notes.append(
|
|
"no_agent mode: stdout is delivered verbatim; EMPTY stdout sends "
|
|
"nothing at all (watchdog pattern — script should stay quiet when "
|
|
"there is nothing to report). Non-zero exit or timeout sends an "
|
|
"error alert. prompt/skills are ignored.")
|
|
_deliver = (user_deliver or "").strip().lower()
|
|
if _deliver:
|
|
if "all" in _deliver.split(","):
|
|
notes.append(
|
|
"deliver='all' resolves at fire time and never includes "
|
|
"bot-chat targets — channels connected later are picked up "
|
|
"automatically.")
|
|
if _deliver.startswith("bot-chat:"):
|
|
notes.append("Targeting another profile's Bot Chat costs that bot an agent turn per run.")
|
|
# platform:chat_id with no thread segment loses topic targeting.
|
|
for target in _deliver.split(","):
|
|
parts = target.strip().split(":")
|
|
if (
|
|
len(parts) == 2
|
|
and parts[0] not in ("bot-chat", "sms")
|
|
and parts[1]
|
|
and not parts[1].startswith("#")):
|
|
notes.append(
|
|
f"deliver target '{target.strip()}' has no :thread_id "
|
|
"segment — on thread/topic platforms the delivery lands in "
|
|
"the main chat, not a topic.")
|
|
break
|
|
return notes
|
|
|
|
|
|
def _split_monitor_arg(
|
|
monitor: Optional[str],
|
|
monitor_script: Optional[str],
|
|
monitor_url: Optional[str]) -> tuple:
|
|
"""Resolve the model-facing ``monitor`` field into the stored ``(monitor_script,
|
|
monitor_url)`` pair. http(s):// is a URL, anything else a script path (a legal script path
|
|
never starts with a URL scheme). None = unchanged, '' = clear; setting one source clears
|
|
the other so switching transports never trips mutual exclusion; an explicit ``monitor``
|
|
wins over the legacy alias fields."""
|
|
if monitor is None:
|
|
return monitor_script, monitor_url
|
|
value = monitor.strip()
|
|
if not value:
|
|
return "", ""
|
|
if value.lower().startswith(("http://", "https://")):
|
|
return "", value
|
|
return value, ""
|
|
|
|
|
|
def _repeat_display(job: Dict[str, Any]) -> str:
|
|
rep = job.get("repeat") or {}
|
|
times, completed = rep.get("times"), rep.get("completed", 0)
|
|
if times is None:
|
|
return "forever"
|
|
if times == 1:
|
|
return "once" if completed == 0 else "1/1"
|
|
return f"{completed}/{times}" if completed else f"{times} times"
|
|
|
|
|
|
def _clean_str_list(items: Any) -> List[str]:
|
|
"""Stripped, non-empty ``str(item)`` values from a str-or-iterable (order kept)."""
|
|
if items is None:
|
|
return []
|
|
if isinstance(items, str):
|
|
items = [items]
|
|
return [s for s in (str(i).strip() for i in items) if s]
|
|
|
|
|
|
def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
|
|
if skills is None:
|
|
skills = [skill] if skill else []
|
|
elif isinstance(skills, str):
|
|
skills = [skills]
|
|
# `item or ""`: a None entry must drop out, not stringify to "None".
|
|
return list(dict.fromkeys(_clean_str_list(item or "" for item in skills)))
|
|
|
|
|
|
def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
if strip_trailing_slash:
|
|
text = text.rstrip("/")
|
|
return text or None
|
|
|
|
|
|
def _normalize_deliver_param(value: Any) -> Optional[str]:
|
|
"""Canonical string form of ``deliver``; None for None/empty. MCP clients may pass a list
|
|
(``["telegram"]``) which the scheduler's ``str(deliver).split(",")`` would mangle."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (list, tuple)):
|
|
return ",".join(_clean_str_list(value)) or None
|
|
return str(value).strip() or None
|
|
|
|
|
|
def _validate_bot_chat_deliver(deliver: Optional[str]) -> Optional[str]:
|
|
"""Validate ``bot-chat[:<profile>]`` deliver elements at create time: Bot Chat delivery is
|
|
machine-local, so the profile must exist where the scheduler fires (Desktop rosters may
|
|
show same-named profiles from other machines). Returns an error string or None."""
|
|
if not deliver:
|
|
return None
|
|
try:
|
|
from cron.scheduler_delivery import parse_bot_chat_deliver_token
|
|
from hermes_cli.profiles import normalize_profile_name, profile_exists
|
|
except Exception:
|
|
return None # best-effort; resolution re-checks at fire time
|
|
for part in str(deliver).split(","):
|
|
profile_arg = parse_bot_chat_deliver_token(part.strip())
|
|
if not profile_arg:
|
|
continue # not a bot-chat token, or bare token (own profile)
|
|
try:
|
|
canon = normalize_profile_name(profile_arg)
|
|
except Exception:
|
|
return f"invalid bot-chat profile name '{profile_arg}'"
|
|
if not profile_exists(canon):
|
|
return (
|
|
f"bot-chat delivery profile '{profile_arg}' not found on this "
|
|
"gateway's machine. Bot Chat delivery is machine-local — use a "
|
|
"profile that exists here (hermes profile list), or omit the "
|
|
"name (deliver='bot-chat') for the job's own profile.")
|
|
return None
|
|
|
|
|
|
def _resolve_cron_context_deliver(deliver: Optional[str]) -> Optional[str]:
|
|
"""Resolve ``origin`` to a concrete target for creates made FROM a cron run (the creating
|
|
session is ephemeral, so by fire time there is no origin). Non-cron sessions: unchanged.
|
|
Cron sessions: ``origin`` (or omitted) becomes the creating run's ``platform:chat_id[:thread]``
|
|
from HERMES_CRON_AUTO_DELIVER_*, or ``local`` when it has no concrete target; other
|
|
elements pass through. Otherwise the scheduler would guess a home channel."""
|
|
from gateway.session_context import get_session_env
|
|
from utils import is_truthy_value
|
|
if not is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")):
|
|
return deliver
|
|
|
|
def _creator_target() -> str:
|
|
platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip()
|
|
chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
|
|
if not platform or not chat_id:
|
|
return "local"
|
|
thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip()
|
|
return f"{platform}:{chat_id}:{thread_id}" if thread_id else f"{platform}:{chat_id}"
|
|
|
|
if deliver is None:
|
|
return _creator_target()
|
|
resolved = [_creator_target() if p.lower() == "origin" else p for p in _clean_str_list(str(deliver).split(","))]
|
|
# Order-preserving de-dup: 'origin,local' with a local creator -> 'local'.
|
|
return ",".join(dict.fromkeys(resolved)) or None
|
|
|
|
|
|
def _validate_cron_base_url(
|
|
provider: Optional[Any], base_url: Optional[Any]) -> Optional[str]:
|
|
"""Reject pairing a named provider's stored credential with an off-host base_url (a
|
|
prompt-injected job could exfil the key). Allowed: no override; bare 'custom' (pure BYOK,
|
|
key derived from the base_url); an override whose host matches the named provider's own
|
|
endpoint. Everything else fails closed. Returns an error string if blocked, else None."""
|
|
bu = _normalize_optional_job_value(base_url, strip_trailing_slash=True)
|
|
if not bu:
|
|
return None
|
|
prov = _normalize_optional_job_value(provider)
|
|
if not prov: # no provider inherits the default provider's stored key — same primitive
|
|
return (
|
|
"base_url override requires an explicit provider. Set provider to a "
|
|
"configured custom provider to use a custom endpoint.")
|
|
try:
|
|
from hermes_cli.runtime_provider import (
|
|
has_named_custom_provider,
|
|
resolve_requested_provider,
|
|
_get_named_custom_provider)
|
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
|
from utils import base_url_host_matches, base_url_hostname
|
|
except Exception:
|
|
return f"Unable to validate base_url override for provider {prov!r}; refused."
|
|
|
|
if prov.lower() == "custom": # pure BYOK: key keyed by THIS base_url, never a stored secret
|
|
return None
|
|
if has_named_custom_provider(prov):
|
|
# A NAMED custom provider's STORED key is still sent to an override base_url.
|
|
try:
|
|
cp = _get_named_custom_provider(prov)
|
|
except Exception:
|
|
cp = None
|
|
cfg_host = base_url_hostname((cp or {}).get("base_url", "")) if cp else ""
|
|
if cfg_host and base_url_host_matches(bu, cfg_host):
|
|
return None
|
|
return (
|
|
f"base_url {bu!r} is not allowed for provider {prov!r}. A named "
|
|
f"custom provider's stored credential may only be sent to its own "
|
|
f"configured endpoint ({cfg_host or 'unknown'}).")
|
|
try:
|
|
resolved = resolve_requested_provider(prov)
|
|
except Exception:
|
|
resolved = prov
|
|
pconfig = PROVIDER_REGISTRY.get(resolved) if isinstance(resolved, str) else None
|
|
known_host = base_url_hostname(getattr(pconfig, "inference_base_url", "") if pconfig else "")
|
|
if known_host and base_url_host_matches(bu, known_host):
|
|
return None
|
|
# Fail closed: named providers with stored credentials AND unknown names we cannot host-match.
|
|
return (
|
|
f"base_url {bu!r} is not allowed for provider {prov!r}. A named "
|
|
f"provider's stored credential may only be sent to its own endpoint; "
|
|
f'use a configured custom provider (provider="custom") for a custom base_url.')
|
|
|
|
|
|
def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
|
|
"""Scripts must be relative paths within HERMES_HOME/scripts/ (absolute / ~ / drive-letter
|
|
rejected — prompt-injection guard). Error string if blocked, else None; empty = clear."""
|
|
if not script or not script.strip():
|
|
return None
|
|
|
|
from hermes_constants import get_hermes_home
|
|
raw = script.strip()
|
|
scripts_dir = get_hermes_home() / "scripts"
|
|
if raw.startswith(("/", "~")) or (len(raw) >= 2 and raw[1] == ":"):
|
|
return (
|
|
f"Script path must be relative to {scripts_dir}/. "
|
|
f"Got absolute or home-relative path: {raw!r}. "
|
|
f"Place scripts in {scripts_dir}/ and use just the filename.")
|
|
|
|
from tools.path_security import validate_within_dir
|
|
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
resolved_script = scripts_dir / raw
|
|
if validate_within_dir(resolved_script, scripts_dir):
|
|
return f"Script path escapes the scripts directory via traversal: {raw!r}"
|
|
if not resolved_script.is_file():
|
|
return (
|
|
f"Script file not found: {resolved_script}. "
|
|
f"Create it in {scripts_dir}/ first.")
|
|
return None
|
|
|
|
|
|
def _apply_continuity(
|
|
context_from: Optional[Union[str, List[str]]],
|
|
continuity: bool) -> Optional[List[str]]:
|
|
"""continuity=True ensures "self" is in context_from; False removes it; others untouched."""
|
|
refs = _clean_str_list(context_from)
|
|
has_self = any(r.lower() == "self" for r in refs)
|
|
if continuity and not has_self:
|
|
refs.append("self")
|
|
elif not continuity and has_self:
|
|
refs = [r for r in refs if r.lower() != "self"]
|
|
return refs or None
|
|
|
|
|
|
def _validate_context_from_refs(refs: List[Any]) -> Optional[str]:
|
|
"""Error string if any non-"self" ref names a missing job ("self" resolves to the job's
|
|
own id at run time, so it can't be checked — the job doesn't exist yet at create)."""
|
|
from cron.jobs import get_job as _get_job
|
|
for ref_id in refs:
|
|
if isinstance(ref_id, str) and ref_id.strip().lower() == "self":
|
|
continue
|
|
if not _get_job(ref_id):
|
|
return (
|
|
f"context_from job '{ref_id}' not found. "
|
|
"Use cronjob(action='list') to see available jobs.")
|
|
return None
|
|
|
|
|
|
# Optional fields echoed by _format_job only when truthy (order = JSON key order).
|
|
_FORMAT_JOB_OPTIONAL_KEYS = (
|
|
"script", "reasoning_effort", "monitor_script", "monitor_url",
|
|
"monitor_state", "no_agent", "enabled_toolsets", "workdir")
|
|
|
|
|
|
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
prompt = str(job.get("prompt") or "")
|
|
skills = _canonical_skills(job.get("skill"), job.get("skills"))
|
|
job_id = str(job.get("id") or "unknown")
|
|
name = str(job.get("name") or prompt[:50] or (skills[0] if skills else "") or job_id or "cron job")
|
|
result = {
|
|
"job_id": job_id,
|
|
"name": name,
|
|
"skill": skills[0] if skills else None,
|
|
"skills": skills,
|
|
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
|
|
"model": job.get("model"),
|
|
"provider": job.get("provider"),
|
|
# Locked to its own model; unpinned jobs follow cron.model, then the main agent model.
|
|
"pinned": bool(str(job.get("model") or "").strip()),
|
|
"base_url": job.get("base_url"),
|
|
"schedule": job.get("schedule_display") or "?",
|
|
"repeat": _repeat_display(job),
|
|
"deliver": job.get("deliver", "local"),
|
|
"next_run_at": job.get("next_run_at"),
|
|
"last_run_at": job.get("last_run_at"),
|
|
"last_status": job.get("last_status"),
|
|
"last_delivery_error": job.get("last_delivery_error"),
|
|
"last_delivery_unverified": job.get("last_delivery_unverified"),
|
|
"last_fire_error": job.get("last_fire_error"),
|
|
"last_error": redact_sensitive_text(
|
|
job["last_error"], force=True, redact_url_credentials=True,
|
|
) if job.get("last_error") else job.get("last_error"),
|
|
"enabled": job.get("enabled", True),
|
|
# Derive from enabled so half-paused records never render as paused.
|
|
"state": effective_job_state(job),
|
|
"paused_at": job.get("paused_at"),
|
|
"paused_reason": job.get("paused_reason"),
|
|
}
|
|
for key in _FORMAT_JOB_OPTIONAL_KEYS:
|
|
if job.get(key):
|
|
result[key] = True if key == "no_agent" else job[key]
|
|
stored_refs = job.get("context_from") or []
|
|
if isinstance(stored_refs, str):
|
|
stored_refs = [stored_refs]
|
|
is_self = lambda r: str(r).strip().lower() == "self" or r == job.get("id") # noqa: E731
|
|
if any(is_self(r) for r in stored_refs):
|
|
result["continuity"] = True
|
|
external_refs = [r for r in stored_refs if not is_self(r)]
|
|
if external_refs:
|
|
result["context_from"] = external_refs
|
|
if isinstance(job.get("attach_to_session"), bool):
|
|
result["attach_to_session"] = job["attach_to_session"]
|
|
return result
|
|
|
|
|
|
def _gateway_liveness_notice(plural: bool = False) -> dict:
|
|
"""``gateway_running``/``warning`` payload via the shared CLI helper so CLI and tool agree
|
|
on "scheduler active". False -> warning (no gateway process), None -> probe failed.
|
|
|
|
Thin adapter over the shared CLI helper ``hermes_cli.cron._builtin_gateway_liveness`` (#87033) so the
|
|
CLI and this tool can never disagree about what "scheduler active" means. ``plural`` rewords the warning
|
|
for multi-job results (the ``list`` action).
|
|
"""
|
|
try:
|
|
from hermes_cli.cron import _builtin_gateway_liveness
|
|
_gw = _builtin_gateway_liveness()
|
|
except Exception:
|
|
return {"gateway_running": None}
|
|
if _gw is False:
|
|
subject = "these jobs are saved" if plural else "this job is saved"
|
|
return {
|
|
"gateway_running": False,
|
|
"warning": (
|
|
f"The Hermes gateway is not running — {subject} "
|
|
"but will NOT fire until the gateway is started "
|
|
"(hermes gateway install / hermes gateway start). "
|
|
"Tell the user the task is scheduled but not active yet."),
|
|
}
|
|
return {"gateway_running": None if _gw is None else True}
|