The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog: - search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with whether each is already installed in the default profile. Read-only. - install: opens the same connection operation manage_connections opens, with rows of kind plugin / skill. Nothing installs until the user approves a row. An approved row installs into `default` (or the profile the Advanced modal named) through dashboard_install_plugin / the hub's headless install, so the catalog pin, kill list, security scan and live activation (#119644) are the host's. The row settles with the live MCP tool names and the plugin's skill. The model sends catalog ids and an action only; every other key is refused before anything runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the result is the `hermes plugins install` / `hermes skills install` pointer. - contract: plugin/skill targets follow the MCP transitions. - run.apply_answer / reissue route a card answer to the module that owns the operation. - tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never deferred behind tool_search. - docs: tools reference, toolsets reference, plugin catalog page. Linear NS-964.
66 lines
2.6 KiB
Python
66 lines
2.6 KiB
Python
"""Connection contract: the states a target moves through, who may cause each move, and the
|
|
reasons an operation settles. ``operation.py`` enforces it; the desktop reads a generated copy.
|
|
|
|
A `str` enum so payloads serialise to the bare value and the TypeScript side sees literal unions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Dict, Optional, Tuple
|
|
|
|
|
|
class TargetState(str, Enum):
|
|
pending = "pending"
|
|
initiated = "initiated"
|
|
connected = "connected"
|
|
skipped = "skipped"
|
|
failed = "failed"
|
|
expired = "expired"
|
|
# Stamped by settle() on every unresolved target; never a transition target.
|
|
not_connected = "not_connected"
|
|
|
|
|
|
class Actor(str, Enum):
|
|
user = "user"
|
|
backend_watcher = "backend_watcher"
|
|
clock = "clock"
|
|
|
|
|
|
class SettleReason(str, Enum):
|
|
all_resolved = "all_resolved"
|
|
continue_ = "continue"
|
|
deadline = "deadline"
|
|
interrupt = "interrupt"
|
|
|
|
|
|
KINDS: Tuple[str, ...] = ("connector", "mcp", "plugin", "skill")
|
|
|
|
RESOLVED_STATES = frozenset({TargetState.connected, TargetState.skipped})
|
|
|
|
_S, _A = TargetState, Actor
|
|
|
|
# (kind, from) -> {to: the only actor allowed to cause it}. Every `connected` is witnessed by the
|
|
# backend: the gateway's account list for a managed target, the install / enable / OAuth worker for
|
|
# an MCP one. The card can only skip a target, or ask for a failed one to be run again.
|
|
TRANSITIONS: Dict[Tuple[str, TargetState], Dict[TargetState, Actor]] = {
|
|
("connector", _S.pending): {_S.initiated: _A.backend_watcher, _S.failed: _A.backend_watcher, _S.skipped: _A.user},
|
|
("connector", _S.initiated): {
|
|
_S.connected: _A.backend_watcher, _S.failed: _A.backend_watcher, _S.expired: _A.clock, _S.skipped: _A.user,
|
|
},
|
|
("connector", _S.failed): {_S.initiated: _A.user, _S.skipped: _A.user},
|
|
("connector", _S.expired): {_S.initiated: _A.user, _S.skipped: _A.user},
|
|
("mcp", _S.pending): {_S.initiated: _A.backend_watcher, _S.failed: _A.backend_watcher, _S.skipped: _A.user},
|
|
("mcp", _S.initiated): {_S.connected: _A.backend_watcher, _S.failed: _A.backend_watcher, _S.skipped: _A.user},
|
|
("mcp", _S.failed): {_S.initiated: _A.user, _S.skipped: _A.user},
|
|
}
|
|
# ``manage_catalog`` rows install like an MCP entry: approve starts the host's install, the install
|
|
# worker witnesses ``connected``, Try again re-runs a failed row.
|
|
for _kind in ("plugin", "skill"):
|
|
for (_k, _from), _edges in list(TRANSITIONS.items()):
|
|
if _k == "mcp":
|
|
TRANSITIONS[(_kind, _from)] = dict(_edges)
|
|
|
|
|
|
def allowed(kind: str, current: TargetState, to: TargetState) -> Optional[Actor]:
|
|
return TRANSITIONS.get((kind, current), {}).get(to)
|