Add admin publish-as-official, hash-based status sync, and GUI fixes
Registry: - Add admin owner override for publishing tools as "official" - Add POST /api/v1/tools/status-by-hash batch endpoint for status lookup scoped to publisher_id (works for tools published under any owner) GUI: - Add "Publish as" dropdown in publish dialog for admin users - Add "installed" tool state (teal with arrow indicator) for registry-installed tools - Fix tool editing for official/* qualified tool names (_get_qualified_name helper) - Fix cancel navigation returning to wrong page (Welcome instead of Tools) - Fix collections tab not refreshing after publish - Refactor StatusSyncWorker to use batch hash lookup (1 request instead of N) with chunking (100 max) and hash collision handling CLI: - Switch registry status sync to hash-based lookup - Add collection dependency checking and unpublished dep detection Publish dialog cleanup: - Move yaml import to module level, remove duplicate _bump_patch_version - Fix owner combo using currentText() for reliable selection Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -16,14 +16,14 @@ from PySide6.QtGui import QFont, QColor, QBrush, QShortcut, QKeySequence
|
||||
|
||||
from ...tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
||||
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES,
|
||||
list_tools, load_tool, delete_tool, get_all_categories,
|
||||
get_tools_dir
|
||||
)
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class StatusSyncWorker(QThread):
|
||||
"""Background worker to sync tool statuses from registry."""
|
||||
"""Background worker to sync tool statuses from registry using hash-based batch lookup."""
|
||||
finished = Signal()
|
||||
tool_updated = Signal(str) # Emits tool name when status changes
|
||||
|
||||
@@ -32,8 +32,9 @@ class StatusSyncWorker(QThread):
|
||||
self.tool_names = tool_names
|
||||
|
||||
def run(self):
|
||||
"""Sync status for all tools with registry_hash."""
|
||||
"""Sync status for all tools with registry_hash using batch hash lookup."""
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
@@ -43,43 +44,59 @@ class StatusSyncWorker(QThread):
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
# Collect all tools that have a registry_hash
|
||||
# Multiple tools can share the same hash (copies/forks)
|
||||
tools_dir = get_tools_dir()
|
||||
tool_hashes = defaultdict(list) # hash -> [(tool_name, config_path), ...]
|
||||
for tool_name in self.tool_names:
|
||||
config_path = tools_dir / tool_name / "config.yaml"
|
||||
if not config_path.exists():
|
||||
continue
|
||||
try:
|
||||
self._sync_tool(client, tool_name)
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
h = config_data.get("registry_hash")
|
||||
if h:
|
||||
tool_hashes[h].append((tool_name, config_path))
|
||||
except Exception:
|
||||
pass # Skip tools that fail to sync
|
||||
continue
|
||||
|
||||
if not tool_hashes:
|
||||
return
|
||||
|
||||
# Batch requests in chunks of 100 (server limit)
|
||||
all_hashes = list(tool_hashes.keys())
|
||||
results = {}
|
||||
for i in range(0, len(all_hashes), 100):
|
||||
chunk = all_hashes[i:i + 100]
|
||||
results.update(client.get_tool_status_by_hashes(chunk))
|
||||
|
||||
# Update local configs from results
|
||||
for h, tool_entries in tool_hashes.items():
|
||||
if h in results:
|
||||
for tool_name, config_path in tool_entries:
|
||||
try:
|
||||
self._update_local_config(tool_name, config_path, results[h])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass # Silently fail - this is background sync
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
def _sync_tool(self, client, tool_name: str):
|
||||
"""Sync a single tool's status."""
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
def _update_local_config(self, tool_name: str, config_path, status_data: dict):
|
||||
"""Update a tool's local config with registry status."""
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
if not config_data.get("registry_hash"):
|
||||
return # Not published
|
||||
|
||||
# Get status from registry
|
||||
status_data = client.get_my_tool_status(tool_name)
|
||||
new_status = status_data.get("status", "pending")
|
||||
new_hash = status_data.get("config_hash")
|
||||
new_feedback = status_data.get("feedback")
|
||||
|
||||
old_status = config_data.get("registry_status", "pending")
|
||||
old_hash = config_data.get("registry_hash")
|
||||
old_feedback = config_data.get("registry_feedback")
|
||||
|
||||
changed = False
|
||||
if old_status != new_status:
|
||||
config_data["registry_status"] = new_status
|
||||
changed = True
|
||||
if new_hash and old_hash != new_hash:
|
||||
config_data["registry_hash"] = new_hash
|
||||
changed = True
|
||||
if new_feedback != old_feedback:
|
||||
if new_feedback:
|
||||
config_data["registry_feedback"] = new_feedback
|
||||
@@ -102,6 +119,7 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
||||
- "pending" - submitted but awaiting moderation
|
||||
- "changes_requested" - admin requested changes before approval
|
||||
- "rejected" - rejected by admin
|
||||
- "installed" - installed from registry (has hash but no explicit status)
|
||||
- "local" - no registry_hash (never published)
|
||||
"""
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
@@ -111,20 +129,27 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text())
|
||||
registry_hash = config.get("registry_hash")
|
||||
registry_status = config.get("registry_status", "pending")
|
||||
|
||||
if not registry_hash:
|
||||
return ("local", None)
|
||||
|
||||
# Return the moderation status directly - no local hash comparison
|
||||
# Only use explicit registry_status - don't default to "pending"
|
||||
registry_status = config.get("registry_status")
|
||||
|
||||
if not registry_status:
|
||||
# Has registry_hash but no status = installed from registry
|
||||
return ("installed", registry_hash)
|
||||
|
||||
if registry_status == "approved":
|
||||
return ("published", registry_hash)
|
||||
elif registry_status == "changes_requested":
|
||||
return ("changes_requested", registry_hash)
|
||||
elif registry_status == "rejected":
|
||||
return ("rejected", registry_hash)
|
||||
else:
|
||||
elif registry_status == "pending":
|
||||
return ("pending", registry_hash)
|
||||
else:
|
||||
return ("installed", registry_hash)
|
||||
except Exception:
|
||||
return ("local", None)
|
||||
|
||||
@@ -285,11 +310,8 @@ class ToolsPage(QWidget):
|
||||
category = tool.category if tool.category else "Other"
|
||||
tools_by_category[category].append((name, tool))
|
||||
|
||||
# Build tree with categories
|
||||
all_categories = list(DEFAULT_CATEGORIES)
|
||||
for cat in tools_by_category:
|
||||
if cat not in all_categories:
|
||||
all_categories.append(cat)
|
||||
# Build tree with categories (get_all_categories returns defaults + custom)
|
||||
all_categories = get_all_categories()
|
||||
|
||||
for category in all_categories:
|
||||
if category in tools_by_category and tools_by_category[category]:
|
||||
@@ -327,6 +349,10 @@ class ToolsPage(QWidget):
|
||||
display_name = f"{name} ✗"
|
||||
tooltip = "Rejected by moderator"
|
||||
color = QColor(220, 38, 38) # Red
|
||||
elif state == "installed":
|
||||
display_name = f"{name} ↓"
|
||||
tooltip = "Installed from registry"
|
||||
color = QColor(56, 178, 172) # Teal
|
||||
else:
|
||||
display_name = name
|
||||
tooltip = "Local tool - not published"
|
||||
@@ -475,6 +501,13 @@ class ToolsPage(QWidget):
|
||||
self._show_tool_info(tool)
|
||||
self._update_buttons()
|
||||
|
||||
def _get_qualified_name(self) -> Optional[str]:
|
||||
"""Get the qualified name (e.g., 'official/summarize') of the selected tool."""
|
||||
items = self.tool_tree.selectedItems()
|
||||
if items:
|
||||
return items[0].data(0, Qt.UserRole)
|
||||
return self._current_tool.name if self._current_tool else None
|
||||
|
||||
def _on_double_click(self, item, column):
|
||||
"""Handle double-click on tool."""
|
||||
tool_name = item.data(0, Qt.UserRole)
|
||||
@@ -509,6 +542,12 @@ class ToolsPage(QWidget):
|
||||
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||
"✓ Published to registry - approved</p>"
|
||||
)
|
||||
elif state == "installed":
|
||||
lines.append(
|
||||
"<p style='background: #e2e8f0; color: #4a5568; padding: 6px 10px; "
|
||||
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||
"↓ Installed from registry</p>"
|
||||
)
|
||||
elif state == "pending":
|
||||
lines.append(
|
||||
"<p style='background: #fef3c7; color: #92400e; padding: 6px 10px; "
|
||||
@@ -622,7 +661,7 @@ class ToolsPage(QWidget):
|
||||
def _edit_tool(self):
|
||||
"""Edit the selected tool."""
|
||||
if self._current_tool:
|
||||
self.main_window.open_tool_builder(self._current_tool.name)
|
||||
self.main_window.open_tool_builder(self._get_qualified_name())
|
||||
|
||||
def _configure_tool(self):
|
||||
"""Open settings dialog for the selected tool."""
|
||||
@@ -639,19 +678,21 @@ class ToolsPage(QWidget):
|
||||
return
|
||||
|
||||
from ..dialogs.settings_dialog import SettingsDialog
|
||||
dialog = SettingsDialog(self, self._current_tool.name)
|
||||
qualified_name = self._get_qualified_name()
|
||||
dialog = SettingsDialog(self, qualified_name)
|
||||
if dialog.exec():
|
||||
self.main_window.show_status(f"Settings saved for '{self._current_tool.name}'")
|
||||
self.main_window.show_status(f"Settings saved for '{qualified_name}'")
|
||||
|
||||
def _delete_tool(self):
|
||||
"""Delete the selected tool."""
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
qualified_name = self._get_qualified_name()
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Tool",
|
||||
f"Are you sure you want to delete '{self._current_tool.name}'?\n\n"
|
||||
f"Are you sure you want to delete '{qualified_name}'?\n\n"
|
||||
"This will remove the tool configuration and wrapper script.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
@@ -659,8 +700,8 @@ class ToolsPage(QWidget):
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
try:
|
||||
delete_tool(self._current_tool.name)
|
||||
self.main_window.show_status(f"Deleted tool '{self._current_tool.name}'")
|
||||
delete_tool(qualified_name)
|
||||
self.main_window.show_status(f"Deleted tool '{qualified_name}'")
|
||||
self.refresh()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to delete tool:\n{e}")
|
||||
|
||||
Reference in New Issue
Block a user