Merge branch 'simp/r3-29-C' into simp/integration3
This commit is contained in:
@@ -158,9 +158,8 @@ class PhotonSidecarStartupError(RuntimeError):
|
||||
class PhotonSidecarError(RuntimeError):
|
||||
"""Structured failure returned by the supervised Photon sidecar."""
|
||||
|
||||
def __init__(
|
||||
self, *, path: str, status_code: int, error: str,
|
||||
error_class: str = "sidecar_error", retryable: bool = False) -> None:
|
||||
def __init__(self, *, path: str, status_code: int, error: str, error_class: str = "sidecar_error",
|
||||
retryable: bool = False) -> None:
|
||||
self.path = path
|
||||
self.status_code = status_code
|
||||
self.error = error
|
||||
@@ -171,9 +170,8 @@ class PhotonSidecarError(RuntimeError):
|
||||
f"({error_class}, retryable={retryable}): {error}")
|
||||
|
||||
|
||||
def _sidecar_error_from_response(
|
||||
path: str, status_code: int, text: str, data: Optional[Dict[str, Any]] = None,
|
||||
) -> PhotonSidecarError:
|
||||
def _sidecar_error_from_response(path: str, status_code: int, text: str,
|
||||
data: Optional[Dict[str, Any]] = None) -> PhotonSidecarError:
|
||||
if data is None:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
@@ -199,13 +197,6 @@ def sidecar_deps_installed() -> bool:
|
||||
return (_sidecar_dir() / "node_modules" / "spectrum-ts").exists()
|
||||
|
||||
|
||||
def _coerce(cast: Callable[[Any], Any], value: Any, default: Any) -> Any:
|
||||
try:
|
||||
return cast(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _is_timeout_error(exc: BaseException) -> bool:
|
||||
"""True when *exc* indicates the request timed out (call hung)."""
|
||||
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
|
||||
@@ -259,8 +250,7 @@ def _reinstall_sidecar_deps() -> None:
|
||||
def _run(verb: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run( # noqa: S603
|
||||
[npm, verb], cwd=str(_sidecar_dir()), capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", check=False, timeout=_NPM_REINSTALL_TIMEOUT, creationflags=windows_hide_flags(),
|
||||
)
|
||||
errors="replace", check=False, timeout=_NPM_REINSTALL_TIMEOUT, creationflags=windows_hide_flags())
|
||||
try:
|
||||
result = _run("ci")
|
||||
if result.returncode != 0:
|
||||
@@ -270,7 +260,8 @@ def _reinstall_sidecar_deps() -> None:
|
||||
logger.error("[photon] sidecar dependency reinstall timed out after %ss", _NPM_REINSTALL_TIMEOUT)
|
||||
return
|
||||
if result.returncode != 0:
|
||||
logger.error("[photon] sidecar dependency reinstall failed: %s", (result.stderr or result.stdout or "").strip())
|
||||
logger.error("[photon] sidecar dependency reinstall failed: %s",
|
||||
(result.stderr or result.stdout or "").strip())
|
||||
else:
|
||||
logger.info("[photon] sidecar dependencies reinstalled from lockfile")
|
||||
|
||||
@@ -437,23 +428,25 @@ def _normalize_group_content(content: Dict[str, Any]) -> _Normalized:
|
||||
return text, mtype, media_urls, media_types
|
||||
|
||||
|
||||
_CONTENT_NORMALIZERS: Dict[Any, Callable[[Dict[str, Any]], _Normalized]] = {
|
||||
"text": lambda c: (c.get("text") or "", MessageType.TEXT, [], []),
|
||||
"attachment": _normalize_binary_payload, "voice": _normalize_binary_payload,
|
||||
"richlink": lambda c: (_format_richlink_content(c), MessageType.TEXT, [], []),
|
||||
"group": _normalize_group_content,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_content(content: Dict[str, Any]) -> _Normalized:
|
||||
"""Turn a sidecar ``content`` payload into (text, type, media_urls, media_types)."""
|
||||
ctype = content.get("type")
|
||||
if ctype == "text":
|
||||
return content.get("text") or "", MessageType.TEXT, [], []
|
||||
if ctype in {"attachment", "voice"}:
|
||||
return _normalize_binary_payload(content)
|
||||
if ctype == "richlink":
|
||||
return _format_richlink_content(content), MessageType.TEXT, [], []
|
||||
if ctype == "group":
|
||||
return _normalize_group_content(content)
|
||||
return f"[Photon content type not handled: {ctype}]", MessageType.TEXT, [], []
|
||||
normalize = _CONTENT_NORMALIZERS.get(ctype) if isinstance(ctype, str) else None
|
||||
if normalize is None:
|
||||
return f"[Photon content type not handled: {ctype}]", MessageType.TEXT, [], []
|
||||
return normalize(content)
|
||||
|
||||
|
||||
def _attachment_body(
|
||||
space_id: str, safe_path: str, *, kind: str, name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None, caption: Optional[str] = None) -> Dict[str, Any]:
|
||||
def _attachment_body(space_id: str, safe_path: str, *, kind: str, name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None, caption: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""``/send-attachment`` body; spectrum-ts infers name/mimeType from the extension,
|
||||
so optional keys are only sent when Hermes supplied them."""
|
||||
body: Dict[str, Any] = {"spaceId": space_id, "path": safe_path, "kind": kind}
|
||||
@@ -503,8 +496,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
self._project_secret: str = (
|
||||
_get_scoped_secret("PHOTON_PROJECT_SECRET") or extra.get("project_secret") or stored_sec or "")
|
||||
self._sidecar_port = _coerce_port(
|
||||
extra.get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_PORT,
|
||||
)
|
||||
extra.get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_PORT)
|
||||
self._sidecar_bind = _DEFAULT_SIDECAR_BIND
|
||||
self._sidecar_token = _get_scoped_secret("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16)
|
||||
autostart = str(_get_scoped_secret("PHOTON_SIDECAR_AUTOSTART", "true")).lower()
|
||||
@@ -517,24 +509,24 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
value = extra.get(key)
|
||||
if value is None:
|
||||
value = _get_scoped_secret(env)
|
||||
return _coerce(cast, value, default)
|
||||
try:
|
||||
return cast(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
self._probe_interval = _setting("probe_interval_seconds", "PHOTON_PROBE_INTERVAL_SECONDS", 600.0, float)
|
||||
self._probe_timeout = _setting("probe_timeout_seconds", "PHOTON_PROBE_TIMEOUT_SECONDS", 10.0, float)
|
||||
self._probe_max_failures = _setting("probe_max_failures", "PHOTON_PROBE_MAX_FAILURES", 3, int)
|
||||
self._probe_enabled = self._probe_interval > 0
|
||||
self.supports_code_blocks = _markdown_enabled() # markdown on => fences pass through
|
||||
self._sidecar_proc: Optional[subprocess.Popen] = None
|
||||
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
|
||||
self._inbound_task: Optional[asyncio.Task] = None
|
||||
self._sidecar_health_task: Optional[asyncio.Task] = None
|
||||
self._inbound_running = False
|
||||
self._http_client: Optional["httpx.AsyncClient"] = None
|
||||
self._sidecar_health_interval = 15.0
|
||||
self._respawn_lock: Optional[asyncio.Lock] = None
|
||||
self._sidecar_supervisor_task = self._inbound_task = self._sidecar_health_task = None
|
||||
self._watchdog_task: Optional[asyncio.Task] = None
|
||||
self._watchdog_running = False
|
||||
self._inbound_running = self._watchdog_running = False
|
||||
self._sidecar_health_interval = 15.0
|
||||
self._probe_failures = 0
|
||||
self._last_upstream_activity = 0.0 # monotonic; watchdog skips probe if traffic proved liveness
|
||||
self._respawn_lock: Optional[asyncio.Lock] = None
|
||||
self._seen_messages: Dict[str, float] = {} # at-least-once stream dedup
|
||||
self._sent_message_ids: Dict[str, float] = {} # only reactions targeting OUR sends are routed
|
||||
self._last_inbound_by_chat: Dict[str, str] = {} # default target for the react action
|
||||
@@ -547,7 +539,8 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
_require_mention = _get_scoped_secret("PHOTON_REQUIRE_MENTION")
|
||||
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra["mention_patterns"] if "mention_patterns" in extra else _get_scoped_secret("PHOTON_MENTION_PATTERNS"))
|
||||
extra["mention_patterns"] if "mention_patterns" in extra
|
||||
else _get_scoped_secret("PHOTON_MENTION_PATTERNS"))
|
||||
|
||||
# -- Group-mention gating (parity with BlueBubbles) ----------------------------
|
||||
|
||||
@@ -588,14 +581,12 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
if not self._project_id or not self._project_secret:
|
||||
self._set_fatal_error(
|
||||
"MISSING_CREDENTIALS",
|
||||
"PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. "
|
||||
"Run: hermes photon setup",
|
||||
"PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. Run: hermes photon setup",
|
||||
retryable=False)
|
||||
return False
|
||||
client = httpx.AsyncClient(timeout=30.0, trust_env=False)
|
||||
self._http_client = client
|
||||
# The sidecar holds the gRPC stream for BOTH directions, so it is required now.
|
||||
if self._autostart_sidecar:
|
||||
if self._autostart_sidecar: # the sidecar holds the gRPC stream for BOTH directions: required now
|
||||
try:
|
||||
await self._start_sidecar()
|
||||
except Exception as e:
|
||||
@@ -628,8 +619,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._inbound_running = False
|
||||
# Stop the watchdog first so it can't respawn while we tear the sidecar down.
|
||||
await self._stop_watchdog()
|
||||
await self._stop_watchdog() # first, so it can't respawn while we tear the sidecar down
|
||||
task, self._sidecar_health_task = self._sidecar_health_task, None
|
||||
await _cancel_task(task)
|
||||
task, self._inbound_task = self._inbound_task, None
|
||||
@@ -712,7 +702,8 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
# Loud line for a suspected zombie stream before the sidecar's degraded->exit-75 fires.
|
||||
staleness = stream.get("staleness")
|
||||
if isinstance(staleness, dict) and staleness.get("zombieSuspected") is True:
|
||||
logger.warning("[photon] sidecar reports suspected zombie stream (silentForMs=%s, lastProbeOutcome=%s)",
|
||||
logger.warning("[photon] sidecar reports suspected zombie stream"
|
||||
" (silentForMs=%s, lastProbeOutcome=%s)",
|
||||
staleness.get("silentForMs"), staleness.get("lastProbeOutcome"))
|
||||
if stream.get("ok") is not False:
|
||||
continue
|
||||
@@ -725,8 +716,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
break
|
||||
|
||||
async def _on_inbound_line(self, line: str) -> None:
|
||||
# Any inbound line proves the upstream gRPC channel is live.
|
||||
self._note_upstream_activity()
|
||||
self._note_upstream_activity() # any inbound line proves the upstream gRPC channel is live
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
@@ -785,8 +775,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
user_id=sender_id, user_name=sender_id or None)
|
||||
return MessageEvent(text=text, message_type=mtype, source=source, message_id=message_id,
|
||||
raw_message=event, timestamp=timestamp, **kwargs)
|
||||
if ctype in {"read", "read_receipt"}:
|
||||
# Presence signal, not a user turn (sidecar only forwards receipts for our sends).
|
||||
if ctype in {"read", "read_receipt"}: # presence signal, not a user turn (receipts for our sends)
|
||||
logger.debug("[photon] outbound message read: %s", content.get("targetMessageId") or "unknown")
|
||||
return
|
||||
if ctype == "reaction":
|
||||
@@ -957,8 +946,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
"PHOTON_SIDECAR_PORT": str(self._sidecar_port), "PHOTON_SIDECAR_BIND": self._sidecar_bind,
|
||||
"PHOTON_SIDECAR_TOKEN": self._sidecar_token,
|
||||
# Exit on stdin EOF so ANY gateway death (incl. SIGKILL) can't orphan it on the port.
|
||||
"PHOTON_SIDECAR_WATCH_STDIN": "1",
|
||||
})
|
||||
"PHOTON_SIDECAR_WATCH_STDIN": "1"})
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags # hide child console on Windows
|
||||
await self._apply_spectrum_patch(windows_hide_flags())
|
||||
try:
|
||||
@@ -973,8 +961,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
code="SIDECAR_NODE_MISSING", retryable=False) from exc
|
||||
loop = asyncio.get_event_loop()
|
||||
self._sidecar_supervisor_task = loop.create_task(self._supervise_sidecar(self._sidecar_proc))
|
||||
# Wait for /healthz — up to 15s on cold start.
|
||||
deadline = time.time() + 15.0
|
||||
deadline = time.time() + 15.0 # wait for /healthz — up to 15s on cold start
|
||||
last_err: Optional[Exception] = None
|
||||
async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client:
|
||||
while time.time() < deadline:
|
||||
@@ -984,8 +971,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
f"Photon sidecar exited with code {self._sidecar_proc.returncode} before becoming ready")
|
||||
try:
|
||||
resp = await client.post(self._sidecar_url("/healthz"), headers=self._sidecar_headers())
|
||||
if resp.status_code == 200:
|
||||
# Let out-of-process senders (cron, `hermes send`) reach this sidecar.
|
||||
if resp.status_code == 200: # let out-of-process senders (cron) reach this sidecar
|
||||
_write_runtime_record(self._sidecar_port, self._sidecar_token, self._sidecar_proc.pid)
|
||||
return
|
||||
except httpx.RequestError as e:
|
||||
@@ -1021,8 +1007,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
_delete_runtime_record() # never leave a record behind on disconnect
|
||||
return
|
||||
try:
|
||||
# Closing our stdin end is itself a shutdown signal (sidecar watches EOF).
|
||||
if proc.stdin is not None:
|
||||
if proc.stdin is not None: # closing our stdin end is itself a shutdown signal (EOF watch)
|
||||
with contextlib.suppress(Exception):
|
||||
proc.stdin.close()
|
||||
if self._http_client is not None: # polite shutdown first
|
||||
@@ -1104,8 +1089,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
logger.exception("[photon] failed to respawn sidecar; watchdog will retry")
|
||||
return
|
||||
# Fresh stream: give it a full interval before probing again.
|
||||
self._note_upstream_activity()
|
||||
self._note_upstream_activity() # fresh stream: give it a full interval before probing again
|
||||
logger.info("[photon] presence watchdog: sidecar respawned, gRPC stream renewed")
|
||||
|
||||
async def _presence_watchdog(self) -> None:
|
||||
@@ -1142,16 +1126,12 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
|
||||
# -- Outbound ------------------------------------------------------------------
|
||||
|
||||
async def send(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
return await self._sidecar_send(chat_id, self.format_message(content))
|
||||
|
||||
async def send_clarify(
|
||||
self, chat_id: str, question: str, choices: Optional[list], clarify_id: str,
|
||||
session_key: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
async def send_clarify(self, chat_id: str, question: str, choices: Optional[list], clarify_id: str,
|
||||
session_key: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
"""Multiple-choice renders as a native poll; the vote comes back as a `poll_option`
|
||||
event that _dispatch_inbound turns into plain text, so the clarify is flipped into
|
||||
text-capture mode like the base fallback."""
|
||||
@@ -1170,9 +1150,8 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
# -- Outbound media (parity with BlueBubbles): URL-based helpers cache to a local path
|
||||
# first; file-based ones pass the path straight to /send-attachment.
|
||||
|
||||
async def send_image(
|
||||
self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
async def send_image(self, chat_id: str, image_url: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
||||
try:
|
||||
from gateway.platforms.base import cache_image_from_url
|
||||
local_path = await cache_image_from_url(image_url)
|
||||
@@ -1180,38 +1159,28 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
return await self._sidecar_send_attachment(chat_id, local_path, caption=caption)
|
||||
|
||||
async def send_image_file(
|
||||
self, chat_id: str, image_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
|
||||
) -> SendResult:
|
||||
async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs) -> SendResult:
|
||||
return await self._sidecar_send_attachment(chat_id, image_path, caption=caption)
|
||||
|
||||
async def send_voice(
|
||||
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
|
||||
) -> SendResult:
|
||||
async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs) -> SendResult:
|
||||
return await self._sidecar_send_attachment(chat_id, audio_path, caption=caption, kind="voice")
|
||||
|
||||
async def send_video(
|
||||
self, chat_id: str, video_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
|
||||
) -> SendResult:
|
||||
async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs) -> SendResult:
|
||||
return await self._sidecar_send_attachment(chat_id, video_path, caption=caption)
|
||||
|
||||
async def send_document(
|
||||
self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
||||
async def send_document(self, chat_id: str, file_path: str, caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None, reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
||||
return await self._sidecar_send_attachment(chat_id, file_path, name=file_name, caption=caption)
|
||||
|
||||
# send_animation: base falls back to send_image (iMessage renders GIFs inline as images).
|
||||
|
||||
async def send_poll(
|
||||
self, chat_id: str, title: str, options: list[str], metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a native iMessage poll (thin wrapper over ``_sidecar_send_poll``)."""
|
||||
return await self._sidecar_send_poll(chat_id, title, list(options or []))
|
||||
|
||||
async def _sidecar_try(self, path: str, body: Dict[str, Any], what: str) -> bool:
|
||||
"""Soft-failing sidecar call: True on success, False (debug-logged) on any error."""
|
||||
try:
|
||||
@@ -1330,8 +1299,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
return {"name": chat_id, "type": "dm", "id": chat_id}
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
# Markdown passes through verbatim (sidecar markdown() builder); stripping is the
|
||||
# PHOTON_MARKDOWN=false kill-switch.
|
||||
# Markdown passes through verbatim (sidecar markdown() builder); PHOTON_MARKDOWN=false strips.
|
||||
return content if _markdown_enabled() else strip_markdown(content)
|
||||
|
||||
@staticmethod
|
||||
@@ -1353,9 +1321,8 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
return (isinstance(raw, dict) and raw.get("retryable") is False
|
||||
and raw.get("error_class") in ("auth_or_config", "target_not_allowed"))
|
||||
|
||||
async def _send_with_retry(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Any = None,
|
||||
max_retries: int = 1, base_delay: float = 2.0) -> SendResult:
|
||||
async def _send_with_retry(self, chat_id: str, content: str, reply_to: Optional[str] = None,
|
||||
metadata: Any = None, max_retries: int = 1, base_delay: float = 2.0) -> SendResult:
|
||||
"""Retry sends without the generic Markdown banner (replies are markdown or
|
||||
already-stripped plain text, so it never applies)."""
|
||||
text = self.format_message(content)
|
||||
@@ -1413,15 +1380,11 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
self._record_sent_message(data.get("messageId"))
|
||||
return SendResult(success=True, message_id=data.get("messageId"))
|
||||
|
||||
async def _sidecar_send_richlink(self, space_id: str, url: str) -> SendResult:
|
||||
return await self._post_send("/send-richlink", {"spaceId": space_id, "url": url})
|
||||
|
||||
async def _sidecar_send(
|
||||
self, space_id: str, text: str, *, richlink: bool = True, markdown: bool = True,
|
||||
) -> SendResult:
|
||||
async def _sidecar_send(self, space_id: str, text: str, *, richlink: bool = True,
|
||||
markdown: bool = True) -> SendResult:
|
||||
rich_url = _richlink_candidate(text) if richlink else None
|
||||
if rich_url:
|
||||
rich_result = await self._sidecar_send_richlink(space_id, rich_url)
|
||||
rich_result = await self._post_send("/send-richlink", {"spaceId": space_id, "url": rich_url})
|
||||
if rich_result.success:
|
||||
return rich_result
|
||||
logger.warning("[photon] rich-link send failed, falling back to plain text: %s", rich_result.error)
|
||||
@@ -1430,8 +1393,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
logger.warning("[photon] truncating outbound from %d to %d chars", len(text), self.MAX_MESSAGE_LENGTH)
|
||||
text = text[: self.MAX_MESSAGE_LENGTH]
|
||||
body: Dict[str, Any] = {"spaceId": space_id, "text": text}
|
||||
# Omit the key when disabled so a pre-`format` sidecar keeps accepting the body.
|
||||
if markdown and _markdown_enabled():
|
||||
if markdown and _markdown_enabled(): # key omitted when disabled: pre-`format` sidecars still accept
|
||||
body["format"] = "markdown"
|
||||
return await self._post_send("/send", body, structured=True)
|
||||
|
||||
@@ -1445,13 +1407,12 @@ class PhotonAdapter(BasePlatformAdapter):
|
||||
body = {"spaceId": space_id, "title": title.strip()[: self.MAX_MESSAGE_LENGTH], "options": opts}
|
||||
return await self._post_send("/send-poll", body)
|
||||
|
||||
async def _sidecar_send_attachment(
|
||||
self, space_id: str, path: str, *, name: Optional[str] = None, mime_type: Optional[str] = None,
|
||||
caption: Optional[str] = None, kind: str = "attachment") -> SendResult:
|
||||
async def _sidecar_send_attachment(self, space_id: str, path: str, *, name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None, caption: Optional[str] = None,
|
||||
kind: str = "attachment") -> SendResult:
|
||||
"""POST a local file to ``/send-attachment``. ``kind="voice"`` sends audio as a voice
|
||||
note (downgrades to a plain audio attachment where unsupported)."""
|
||||
# Defense-in-depth: send_*_file / cron callers may pass arbitrary strings.
|
||||
safe_path = self.validate_media_delivery_path(str(path))
|
||||
safe_path = self.validate_media_delivery_path(str(path)) # send_*_file / cron may pass arbitrary strings
|
||||
if not safe_path:
|
||||
return SendResult(success=False, error=f"unsafe or missing attachment path: {path}")
|
||||
body = _attachment_body(
|
||||
@@ -1493,8 +1454,8 @@ _AUDIO_EXT_BY_MIME = {
|
||||
"audio/x-caf": ".caf", "audio/mp4": ".m4a", "audio/aac": ".m4a"}
|
||||
|
||||
|
||||
def _cache_inbound_attachment(
|
||||
content: Dict[str, Any], name: str, mime: str, *, force_audio: bool = False) -> Optional[str]:
|
||||
def _cache_inbound_attachment(content: Dict[str, Any], name: str, mime: str, *,
|
||||
force_audio: bool = False) -> Optional[str]:
|
||||
"""Decode base64-inlined ``content["data"]`` into the shared media cache by MIME; None
|
||||
when there are no bytes (over the inline cap) or caching fails → marker."""
|
||||
if not content.get("data"):
|
||||
@@ -1590,8 +1551,7 @@ async def _standalone_send(
|
||||
return resp, None
|
||||
data = resp.json() or {}
|
||||
return resp, (data if data.get("ok") else None)
|
||||
# 1. Text body first (if any), so it leads the conversation.
|
||||
if message:
|
||||
if message: # 1. text body first, so it leads the conversation
|
||||
rich_url = _richlink_candidate(message)
|
||||
if rich_url:
|
||||
_resp, data = await _post("/send-richlink", {"spaceId": chat_id, "url": rich_url})
|
||||
|
||||
@@ -531,8 +531,7 @@ def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def create_project(
|
||||
token: str, *, name: str = DEFAULT_PROJECT_NAME, location: str = "United States",
|
||||
) -> Dict[str, Any]:
|
||||
token: str, *, name: str = DEFAULT_PROJECT_NAME, location: str = "United States") -> Dict[str, Any]:
|
||||
"""POST ``/api/projects`` and return the project (Spectrum is always provisioned;
|
||||
the request carries no ``spectrum`` flag)."""
|
||||
body: Dict[str, Any] = {"name": name, "location": location, "template": False, "observability": False}
|
||||
@@ -587,8 +586,7 @@ def find_user_by_phone(project_id: str, project_secret: str, phone_number: str)
|
||||
|
||||
def create_user(
|
||||
project_id: str, project_secret: str, *, phone_number: str, first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None, email: Optional[str] = None, send_invite: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
last_name: Optional[str] = None, email: Optional[str] = None, send_invite: bool = False) -> Dict[str, Any]:
|
||||
"""POST Spectrum Cloud ``/projects/{id}/users/`` and return the user."""
|
||||
_require_httpx(" user creation")
|
||||
if not E164_RE.match(phone_number):
|
||||
@@ -737,6 +735,5 @@ def print_credential_summary(emit: Any = print) -> None:
|
||||
" project id : " + (sid if sid else "✗ missing"),
|
||||
" project secret : " + ("✓ stored" if sec else "✗ missing"),
|
||||
" my number : " + (phone if phone else "✗ missing (run `hermes photon setup --phone ...`)"),
|
||||
" assigned number : " + (assigned if assigned else "✗ missing (run `hermes photon setup`)"),
|
||||
]
|
||||
" assigned number : " + (assigned if assigned else "✗ missing (run `hermes photon setup`)")]
|
||||
emit("\n".join(rows))
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"""``hermes photon ...`` CLI subcommands (registered via ``ctx.register_cli_command()``):
|
||||
|
||||
setup full first-time setup (device login + project + user + sidecar)
|
||||
status show login + project + sidecar dep state
|
||||
install-sidecar npm install inside plugins/platforms/photon/sidecar/
|
||||
telemetry show or toggle Spectrum SDK telemetry (on/off)
|
||||
|
||||
Device login is the first step of ``setup`` (no standalone ``login`` verb); inbound is
|
||||
the spectrum-ts gRPC stream, so there are no webhook subcommands.
|
||||
setup (device login + project + user + sidecar), status, install-sidecar (npm install in
|
||||
the sidecar dir), telemetry [on|off]. Device login is the first step of ``setup`` (no
|
||||
standalone ``login`` verb); inbound is the gRPC stream, so there are no webhook subcommands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,9 +21,6 @@ from .sidecar_paths import _NPM_ERROR_LOG_MAX_CHARS, _npm_error_log, _sidecar_di
|
||||
import contextlib
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring
|
||||
|
||||
def register_cli(parser: argparse.ArgumentParser) -> None:
|
||||
"""Wire up `hermes photon ...` subcommands."""
|
||||
subs = parser.add_subparsers(dest="photon_command", required=False)
|
||||
@@ -51,9 +43,6 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
|
||||
parser.set_defaults(func=dispatch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "photon_command", None)
|
||||
if sub is None:
|
||||
@@ -65,20 +54,16 @@ def dispatch(args: argparse.Namespace) -> int:
|
||||
return handler(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
# -- Subcommand handlers -------------------------------------------------------------
|
||||
|
||||
def _run_device_login(args: argparse.Namespace) -> int:
|
||||
"""Run the RFC 8628 device-code login flow and persist the token (first step of ``setup``)."""
|
||||
def _print_code(code):
|
||||
target = code.verification_uri_complete or code.verification_uri
|
||||
print()
|
||||
print("┌─ Photon device login ────────────────────────────────────────")
|
||||
print(f"│ Open this URL: {target}")
|
||||
print(f"│ Enter the code: {code.user_code}")
|
||||
print("│ (waiting for approval — Ctrl-C to cancel)")
|
||||
print("└──────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
print("\n┌─ Photon device login ────────────────────────────────────────\n"
|
||||
f"│ Open this URL: {code.verification_uri_complete or code.verification_uri}\n"
|
||||
f"│ Enter the code: {code.user_code}\n"
|
||||
"│ (waiting for approval — Ctrl-C to cancel)\n"
|
||||
"└──────────────────────────────────────────────────────────────\n")
|
||||
try:
|
||||
photon_auth.login_device_flow(open_browser=not args.no_browser, on_user_code=_print_code)
|
||||
except Exception as e:
|
||||
@@ -93,8 +78,8 @@ def _setup_token(args: argparse.Namespace) -> Optional[str]:
|
||||
"""[1/5] Reuse a valid dashboard token or run device login; None on failure."""
|
||||
token = photon_auth.load_photon_token()
|
||||
if token:
|
||||
# The dashboard token has a short TTL (~3-4 days); a stale one makes every
|
||||
# management call 401, so validate upfront and fall back to a fresh login.
|
||||
# The dashboard token has a short TTL (~3-4 days); a stale one makes every management
|
||||
# call 401, so validate upfront and fall back to a fresh login.
|
||||
print("[1/5] Checking existing Photon token...")
|
||||
if photon_auth.check_photon_token_valid(token):
|
||||
print(" ✓ token is valid")
|
||||
@@ -141,34 +126,29 @@ def _setup_project(token: str, name: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _setup_credentials(token: str, dashboard_id: str, name: str) -> Optional[str]:
|
||||
"""[3/5] Provision Spectrum credentials (runtime -> .env, ids -> auth.json); the
|
||||
dashboard id *is* the Spectrum id. A valid existing secret is reused: regenerating
|
||||
breaks a running sidecar's sends until restart. Returns the secret or None."""
|
||||
"""[3/5] Provision Spectrum credentials (runtime -> .env, ids -> auth.json); the dashboard
|
||||
id *is* the Spectrum id. A valid existing secret is reused: regenerating breaks a running
|
||||
sidecar's sends until restart. Returns the secret or None."""
|
||||
try:
|
||||
print("[3/5] Provisioning Spectrum credentials...")
|
||||
existing_id, existing_secret = photon_auth.load_project_credentials()
|
||||
secret: str = ""
|
||||
reused = False
|
||||
if existing_id and existing_secret:
|
||||
try:
|
||||
with contextlib.suppress(Exception): # failure => fall through to regeneration
|
||||
photon_auth.list_users(existing_id, existing_secret) # lightweight validation
|
||||
secret = existing_secret
|
||||
reused = True
|
||||
except Exception:
|
||||
secret = "" # fall through to regeneration
|
||||
reused = bool(secret)
|
||||
if not secret:
|
||||
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id=dashboard_id, project_secret=secret, dashboard_project_id=dashboard_id, name=name,
|
||||
)
|
||||
spectrum_project_id=dashboard_id, project_secret=secret, dashboard_project_id=dashboard_id, name=name)
|
||||
if reused:
|
||||
print(f" ✓ Spectrum ready (project id {dashboard_id}) — existing credentials valid")
|
||||
else:
|
||||
print(f" ✓ Spectrum ready (project id {dashboard_id}) — new secret saved")
|
||||
print(
|
||||
" ⚠ Project secret was regenerated. If the gateway is running, "
|
||||
"restart it so the sidecar picks up the new secret:\n"
|
||||
" hermes gateway restart")
|
||||
print(" ⚠ Project secret was regenerated. If the gateway is running, "
|
||||
"restart it so the sidecar picks up the new secret:\n"
|
||||
" hermes gateway restart")
|
||||
except Exception as e:
|
||||
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
@@ -187,14 +167,12 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
if not secret:
|
||||
return 1
|
||||
# 4. Register the operator's phone number as a Spectrum user (idempotent).
|
||||
phone = args.phone or _prompt(
|
||||
color("[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ", Colors.CYAN))
|
||||
phone = args.phone or _prompt(color("[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ", Colors.CYAN))
|
||||
agent_number = registered_phone = registered_user_id = None
|
||||
if not phone:
|
||||
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
||||
else:
|
||||
# Name/email are optional and never prompted for (--first-name / --email).
|
||||
try:
|
||||
try: # name/email are optional and never prompted for (--first-name / --email)
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
dashboard_id, secret, phone_number=phone, first_name=args.first_name,
|
||||
last_name=args.last_name, email=args.email)
|
||||
@@ -207,11 +185,10 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
||||
registered_phone = phone
|
||||
registered_user_id = user.get("id")
|
||||
# The number to text the agent is the user's assigned line ("TEXTS ON");
|
||||
# shared-number plans have no dedicated /lines entry.
|
||||
# The number to text the agent is the user's assigned line ("TEXTS ON"); shared-number
|
||||
# plans have no dedicated /lines entry.
|
||||
agent_number = photon_auth.user_assigned_line(user)
|
||||
# Otherwise the gateway denies the operator's own inbound ("Unauthorized user")
|
||||
# and has no default space for cron delivery.
|
||||
# Otherwise the gateway denies the operator's own inbound and has no cron home space.
|
||||
_autoconfigure_access(phone)
|
||||
# 5. Surface the agent's iMessage number.
|
||||
if not agent_number:
|
||||
@@ -233,8 +210,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number=registered_phone, assigned_phone_number=agent_number,
|
||||
user_id=str(registered_user_id) if registered_user_id else None, dashboard_project_id=dashboard_id,
|
||||
)
|
||||
user_id=str(registered_user_id) if registered_user_id else None, dashboard_project_id=dashboard_id)
|
||||
except Exception as e:
|
||||
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
|
||||
# 6. Sidecar deps (spectrum-ts).
|
||||
@@ -252,22 +228,19 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
print(" ✓ photon platform enabled in config.yaml")
|
||||
except Exception as e:
|
||||
print(f" (could not enable Photon in config: {e})", file=sys.stderr)
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start")
|
||||
print("\n✓ Photon setup complete.\n Start the gateway: hermes gateway start")
|
||||
return 0
|
||||
|
||||
|
||||
def _autoconfigure_access(phone: str) -> None:
|
||||
"""Set PHOTON_ALLOWED_USERS and PHOTON_HOME_CHANNEL to the operator's number,
|
||||
each only when unset so a hand-tuned value is never clobbered on re-run."""
|
||||
"""Set PHOTON_ALLOWED_USERS and PHOTON_HOME_CHANNEL to the operator's number, each only
|
||||
when unset so a hand-tuned value is never clobbered on re-run."""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except ImportError:
|
||||
return
|
||||
for key, label in (
|
||||
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
||||
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel")):
|
||||
for key, label in (("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
||||
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel")):
|
||||
try:
|
||||
if get_env_value(key):
|
||||
print(f" {key} already set — leaving it as-is.")
|
||||
@@ -279,31 +252,24 @@ def _autoconfigure_access(phone: str) -> None:
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
_refresh_status_numbers()
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
if not (phone and assigned):
|
||||
spectrum_id, project_secret = photon_auth.load_project_credentials()
|
||||
if spectrum_id and project_secret:
|
||||
try:
|
||||
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
|
||||
# auth.print_credential_summary's emit callback is the only sink that sees
|
||||
# credential-derived strings (keeps cli.py taint-free for CodeQL).
|
||||
photon_auth.print_credential_summary(print)
|
||||
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
||||
sidecar_installed = sidecar_deps_installed()
|
||||
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
||||
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
|
||||
print(f" sidecar deps : {'✓ installed' if sidecar_deps_installed() else '✗ run `hermes photon install-sidecar`'}")
|
||||
print(f" telemetry : {'on' if _telemetry_enabled() else 'off'} (`hermes photon telemetry on|off`)")
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_status_numbers() -> None:
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
if phone and assigned:
|
||||
return
|
||||
spectrum_id, project_secret = photon_auth.load_project_credentials()
|
||||
if not spectrum_id or not project_secret:
|
||||
return
|
||||
try:
|
||||
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
|
||||
|
||||
|
||||
def _telemetry_enabled() -> bool:
|
||||
"""PHOTON_TELEMETRY from env / ~/.hermes/.env; truthy set mirrors the sidecar's."""
|
||||
try:
|
||||
@@ -336,15 +302,14 @@ def _install_sidecar() -> int:
|
||||
if not shutil.which(npm):
|
||||
print("npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) and re-run.", file=sys.stderr)
|
||||
return 1
|
||||
# spectrum-ts is pinned exactly (the SDK ships breaking majors); upgrades are
|
||||
# deliberate — never `@latest` (see README "Upgrading spectrum-ts"). `npm ci`
|
||||
# installs the lockfile verbatim; `npm install` is the fallback for a
|
||||
# missing/drifted lockfile.
|
||||
# spectrum-ts is pinned exactly (the SDK ships breaking majors); upgrades are deliberate —
|
||||
# never `@latest` (see README "Upgrading spectrum-ts"). `npm ci` installs the lockfile
|
||||
# verbatim; `npm install` is the fallback for a missing/drifted lockfile.
|
||||
print(f" $ cd {_sidecar_dir()} && {npm} ci")
|
||||
|
||||
def _run(verb: str) -> subprocess.CompletedProcess:
|
||||
# stdout streams to the terminal; stderr is captured so the failure reason
|
||||
# can be persisted for check_requirements() to surface later.
|
||||
# stdout streams to the terminal; stderr is captured so the failure reason can be
|
||||
# persisted for check_requirements() to surface later.
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, verb], cwd=str(_sidecar_dir()), check=False, stderr=subprocess.PIPE, text=True)
|
||||
if proc.stderr:
|
||||
@@ -356,8 +321,7 @@ def _install_sidecar() -> int:
|
||||
proc = _run("install")
|
||||
if proc.returncode != 0:
|
||||
print("npm install failed", file=sys.stderr)
|
||||
# Bounded to what check_requirements() will ever surface.
|
||||
error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS]
|
||||
error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS] # bounded to what check_requirements surfaces
|
||||
if error:
|
||||
with contextlib.suppress(OSError):
|
||||
_npm_error_log().write_text(error, encoding="utf-8")
|
||||
@@ -368,33 +332,23 @@ def _install_sidecar() -> int:
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"setup": _cmd_setup,
|
||||
"status": _cmd_status,
|
||||
"install-sidecar": lambda _args: _install_sidecar(),
|
||||
"setup": _cmd_setup, "status": _cmd_status, "install-sidecar": lambda _args: _install_sidecar(),
|
||||
"telemetry": _cmd_telemetry}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-setup entry point
|
||||
|
||||
def gateway_setup() -> None:
|
||||
"""Run Photon first-time setup from the unified `hermes gateway setup` wizard
|
||||
(same flow as ``hermes photon setup``; phone is prompted when stdin is a TTY)."""
|
||||
"""Run Photon first-time setup from the unified `hermes gateway setup` wizard (same flow
|
||||
as ``hermes photon setup``; phone is prompted when stdin is a TTY)."""
|
||||
_cmd_setup(argparse.Namespace(
|
||||
photon_command="setup", project_name=None, phone=None, first_name=None, last_name=None,
|
||||
email=None, no_browser=False, skip_sidecar_install=False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small interactive helpers
|
||||
|
||||
def _prompt(prompt: str, *, secret: bool = False) -> str:
|
||||
if not sys.stdin.isatty():
|
||||
return ""
|
||||
try:
|
||||
if secret:
|
||||
return getpass.getpass(prompt).strip()
|
||||
return input(prompt).strip()
|
||||
return (getpass.getpass(prompt) if secret else input(prompt)).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
"""Resolve where the Photon sidecar runs from and where its Node deps live.
|
||||
|
||||
Hosted images keep the plugin tree read-only (EROFS), so, mirroring
|
||||
``resolve_whatsapp_bridge_dir``: (1) ``PHOTON_SIDECAR_DIR`` override as-is; (2) writable
|
||||
source dir → run in place; (3) read-only with baked, current ``node_modules`` → in place;
|
||||
(4) read-only and deps missing/stale → mirror the source files to
|
||||
``$HERMES_HOME/photon/sidecar``. The mirror is refreshed by content compare; ``node_modules``
|
||||
is left alone so the lockfile-vs-install-marker check triggers ``npm ci`` inside the mirror.
|
||||
Resolution never happens at import time (it probes/copies on disk).
|
||||
"""
|
||||
Resolve where the Photon sidecar runs from and where its Node deps live.
|
||||
|
||||
The sidecar source ships inside the installed plugin tree. Hosted/managed
|
||||
images keep that tree read-only (``/opt/hermes``, EROFS on every install /
|
||||
self-heal path), so resolution mirrors ``resolve_whatsapp_bridge_dir``:
|
||||
|
||||
1. ``PHOTON_SIDECAR_DIR`` env override — used as-is.
|
||||
2. Source dir writable → run in place (dev installs).
|
||||
3. Source dir read-only but ``node_modules`` is baked and current → run in
|
||||
place (the Dockerfile bakes deps with ``npm ci`` at build time).
|
||||
4. Source dir read-only and deps missing/stale → mirror the sidecar source
|
||||
files to ``$HERMES_HOME/photon/sidecar`` (durable, writable) and return it.
|
||||
|
||||
The mirror is refreshed on every resolve by content compare (not mtime);
|
||||
``node_modules`` is left alone so the adapter's lockfile-vs-install-marker
|
||||
staleness check triggers the ``npm ci`` self-heal inside the mirror.
|
||||
|
||||
Import-light on purpose: both ``adapter.py`` and ``cli.py`` use it, and
|
||||
resolution never happens at import time (it probes/copies on disk).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import filecmp
|
||||
@@ -32,30 +20,27 @@ from typing import Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
||||
|
||||
# Files that define the sidecar; node_modules is deliberately absent (baked
|
||||
# on managed images or installed by npm in the mirror).
|
||||
# Files that define the sidecar; node_modules is deliberately absent (baked on managed
|
||||
# images or installed by npm in the mirror).
|
||||
_MIRROR_FILES = ("index.mjs", "package.json", "package-lock.json", "patch-spectrum-mixed-attachments.mjs")
|
||||
|
||||
# Tests monkeypatch these module globals directly; the accessors honor a
|
||||
# non-None value and only resolve/derive when unset.
|
||||
# Tests monkeypatch these module globals directly; the accessors honor a non-None value.
|
||||
_SIDECAR_DIR: Optional[Path] = None
|
||||
# Written by `hermes photon install-sidecar` on npm failure so
|
||||
# check_requirements() can surface the root cause later; cleared on success.
|
||||
# Written by `hermes photon install-sidecar` on npm failure so check_requirements() can
|
||||
# surface the root cause later; cleared on success.
|
||||
_NPM_ERROR_LOG: Optional[Path] = None
|
||||
_NPM_ERROR_LOG_MAX_CHARS = 300
|
||||
|
||||
|
||||
def dir_writable(path: Path) -> bool:
|
||||
"""True when we can create files in ``path`` (probe, not stat — stat lies on
|
||||
root-squash / read-only bind mounts)."""
|
||||
"""True when we can create files in ``path`` (probe, not stat — stat lies on root-squash
|
||||
/ read-only bind mounts)."""
|
||||
probe = path / ".hermes-write-probe"
|
||||
try:
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_dir_writable = dir_writable
|
||||
@@ -63,8 +48,8 @@ _dir_writable = dir_writable
|
||||
|
||||
def _lock_newer_than_install(sidecar_dir: Path) -> bool:
|
||||
"""True when the committed lockfile postdates npm's install marker
|
||||
(``node_modules/.package-lock.json``) — the same signal ``npm ci`` uses.
|
||||
False on any stat failure so an odd filesystem never blocks start."""
|
||||
(``node_modules/.package-lock.json``) — the same signal ``npm ci`` uses. False on any
|
||||
stat failure so an odd filesystem never blocks start."""
|
||||
lockfile = sidecar_dir / "package-lock.json"
|
||||
marker = sidecar_dir / "node_modules" / ".package-lock.json"
|
||||
try:
|
||||
@@ -81,8 +66,7 @@ def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path:
|
||||
return Path(override)
|
||||
if _dir_writable(source):
|
||||
return source
|
||||
# Read-only tree with baked, current deps: run in place (the sidecar never
|
||||
# writes inside its own directory).
|
||||
# Read-only tree with baked, current deps: run in place (the sidecar never writes there).
|
||||
if (source / "node_modules").exists() and not _lock_newer_than_install(source):
|
||||
return source
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -90,11 +74,8 @@ def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path:
|
||||
try:
|
||||
mirror.mkdir(parents=True, exist_ok=True)
|
||||
for name in _MIRROR_FILES:
|
||||
src = source / name
|
||||
if not src.exists():
|
||||
continue
|
||||
dst = mirror / name
|
||||
if not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False):
|
||||
src, dst = source / name, mirror / name
|
||||
if src.exists() and (not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False)):
|
||||
shutil.copy2(str(src), str(dst))
|
||||
return mirror
|
||||
except OSError as exc:
|
||||
@@ -102,8 +83,7 @@ def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path:
|
||||
"[photon] install tree is read-only and mirroring the sidecar "
|
||||
"to %s failed (%s) — falling back to the read-only source dir; "
|
||||
"dependency installs will not be possible",
|
||||
mirror,
|
||||
exc)
|
||||
mirror, exc)
|
||||
return source
|
||||
|
||||
|
||||
@@ -117,6 +97,4 @@ def _sidecar_dir() -> Path:
|
||||
|
||||
def _npm_error_log() -> Path:
|
||||
"""Path of the persisted npm-failure log (derived from the sidecar dir)."""
|
||||
if _NPM_ERROR_LOG is not None:
|
||||
return _NPM_ERROR_LOG
|
||||
return _sidecar_dir() / ".photon-npm-error.log"
|
||||
return _NPM_ERROR_LOG if _NPM_ERROR_LOG is not None else _sidecar_dir() / ".photon-npm-error.log"
|
||||
|
||||
Reference in New Issue
Block a user