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:
rob
2026-01-29 17:18:37 -04:00
co-authored by Claude Opus 4.5
parent 071ade0ffb
commit 0ee21f27f7
13 changed files with 1459 additions and 106 deletions
+310 -11
View File
@@ -15,9 +15,139 @@ from PySide6.QtGui import QFont
from ...collection import (
Collection, list_collections, get_collection, COLLECTIONS_DIR,
classify_tool_reference, resolve_tool_references
classify_tool_reference, resolve_tool_references, gather_local_unpublished_deps
)
from ...tool import list_tools
from ...config import load_config
class CollectionStatusSyncWorker(QThread):
"""Background worker to sync collection statuses from registry."""
collection_updated = Signal(str) # collection name
finished = Signal()
def __init__(self, pending_collections: list):
"""
Args:
pending_collections: List of (collection_name, pending_tools) tuples
"""
super().__init__()
self.pending_collections = pending_collections
def run(self):
from ...registry_client import get_client, RegistryError
try:
client = get_client()
# Validate token first
is_valid, _ = client.validate_token()
if not is_valid:
return
for coll_name, pending_tools in self.pending_collections:
try:
self._sync_collection(client, coll_name, pending_tools)
except Exception:
pass # Skip collections that fail to sync
except Exception:
pass # Silently fail - this is background sync
finally:
self.finished.emit()
def _sync_collection(self, client, coll_name: str, pending_tools: list):
"""Sync a single collection's pending tool statuses."""
from ...registry_client import RegistryError
coll = get_collection(coll_name)
if not coll or not coll.pending_approval:
return
all_approved = True
has_rejected = False
still_pending = []
for tool_name in pending_tools:
try:
status_info = client.get_my_tool_status(tool_name)
status = status_info.get("status", "pending")
if status == "approved":
pass # Good
elif status == "rejected":
has_rejected = True
all_approved = False
else:
# Still pending or changes_requested
still_pending.append(tool_name)
all_approved = False
except RegistryError:
# Tool not found or other error - treat as still pending
still_pending.append(tool_name)
all_approved = False
# Update collection state based on results
changed = False
if all_approved:
# All tools approved - now actually publish the collection to registry!
try:
user_slug = coll.maintainer
if not user_slug:
me = client.get_me()
user_slug = me.get("slug", "")
# Build registry refs
registry_refs = []
transformed_pinned = {}
for tool_ref in coll.tools:
if "/" in tool_ref:
registry_refs.append(tool_ref)
if tool_ref in coll.pinned:
transformed_pinned[tool_ref] = coll.pinned[tool_ref]
else:
full_ref = f"{user_slug}/{tool_ref}"
registry_refs.append(full_ref)
if tool_ref in coll.pinned:
transformed_pinned[full_ref] = coll.pinned[tool_ref]
# Publish collection
payload = {
"name": coll.name,
"display_name": coll.display_name,
"description": coll.description,
"maintainer": user_slug,
"tools": registry_refs,
"pinned": transformed_pinned,
"tags": coll.tags,
}
client.publish_collection(payload)
# Update local state
coll.published = True
coll.registry_name = coll.name
coll.pending_approval = False
coll.pending_tools = []
coll.maintainer = user_slug
changed = True
except Exception:
# Failed to publish - just clear pending state
coll.pending_approval = False
coll.pending_tools = []
changed = True
elif has_rejected:
# Some tools rejected - clear pending state so user can retry
coll.pending_approval = False
coll.pending_tools = []
changed = True
elif still_pending != pending_tools:
# Some tools approved, update the list
coll.pending_tools = still_pending
changed = True
if changed:
coll.save()
self.collection_updated.emit(coll_name)
class CollectionCreateDialog(QDialog):
@@ -201,6 +331,7 @@ class PublishAnalysisWorker(QThread):
"error": None,
"user_slug": None,
"resolution": None,
"dep_result": None, # Transitive dependency check result
}
try:
@@ -233,6 +364,13 @@ class PublishAnalysisWorker(QThread):
)
result["resolution"] = resolution
# Gather transitive dependencies from local tools in the collection
local_tool_names = [ref for ref in self.collection.tools if '/' not in ref]
if local_tool_names:
dep_result = gather_local_unpublished_deps(local_tool_names, client, user_slug)
result["dep_result"] = dep_result
result["success"] = True
self.finished.emit(result)
@@ -350,6 +488,12 @@ class CollectionsPage(QWidget):
self._install_worker = None
self._pending_analysis = None # Stores analysis result for publish flow
# Status sync
self._sync_worker = None
self._poll_timer = None
self._has_pending_collections = False
self._syncing = False
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
@@ -474,9 +618,16 @@ class CollectionsPage(QWidget):
def refresh(self):
"""Refresh both local and registry collections."""
self._has_pending_collections = False
self._load_local_collections()
self._load_registry_collections()
# Start background sync for pending collections
self._start_background_sync()
# Manage polling timer based on pending state
self._manage_poll_timer()
def _load_local_collections(self):
"""Load local collections into the list."""
self.collection_list.clear()
@@ -490,6 +641,7 @@ class CollectionsPage(QWidget):
# Show status indicator
if coll.pending_approval:
item.setText(f"{coll.display_name} (pending)")
self._has_pending_collections = True
elif coll.published:
item.setText(f"{coll.display_name} (published)")
@@ -537,6 +689,109 @@ class CollectionsPage(QWidget):
except Exception as e:
self.registry_list.addItem(f"Error: {str(e)}")
def _start_background_sync(self):
"""Start background sync for collections with pending tool approvals."""
if self._syncing:
return
config = load_config()
if not config.registry.token:
return # No auth, can't check status
# Gather pending collections
pending_collections = []
for name in list_collections():
coll = get_collection(name)
if coll and coll.pending_approval and coll.pending_tools:
pending_collections.append((name, list(coll.pending_tools)))
if not pending_collections:
return
# Stop any existing sync
if self._sync_worker and self._sync_worker.isRunning():
self._sync_worker.wait(1000)
# Start new sync
self._sync_worker = CollectionStatusSyncWorker(pending_collections)
self._sync_worker.collection_updated.connect(self._on_collection_status_updated)
self._sync_worker.finished.connect(self._on_sync_finished)
self._syncing = True
self._sync_worker.start()
def _on_sync_finished(self):
"""Handle background sync completion."""
self._syncing = False
def _manage_poll_timer(self):
"""Start or stop the polling timer based on pending collections."""
config = load_config()
should_poll = self._has_pending_collections and config.registry.token
if should_poll:
if not self._poll_timer:
# Create timer that polls every 30 seconds
self._poll_timer = QTimer(self)
self._poll_timer.timeout.connect(self._poll_status)
if not self._poll_timer.isActive():
self._poll_timer.start(30000) # 30 seconds
else:
# No pending collections, stop polling
if self._poll_timer and self._poll_timer.isActive():
self._poll_timer.stop()
def _poll_status(self):
"""Timer callback to poll status for pending collections."""
if self._syncing:
return # Already syncing
# Check if we still have pending collections
pending_collections = []
for name in list_collections():
coll = get_collection(name)
if coll and coll.pending_approval and coll.pending_tools:
pending_collections.append((name, list(coll.pending_tools)))
if pending_collections:
# Stop existing sync if any
if self._sync_worker and self._sync_worker.isRunning():
self._sync_worker.wait(1000)
# Start new sync
self._sync_worker = CollectionStatusSyncWorker(pending_collections)
self._sync_worker.collection_updated.connect(self._on_collection_status_updated)
self._sync_worker.finished.connect(self._on_sync_finished)
self._syncing = True
self._sync_worker.start()
else:
# No more pending collections, stop timer
if self._poll_timer and self._poll_timer.isActive():
self._poll_timer.stop()
self._has_pending_collections = False
def _on_collection_status_updated(self, coll_name: str):
"""Handle background sync updating a collection's status."""
# Refresh the display
self._load_local_collections()
# Re-select the current item if it was the updated one
current = self.collection_list.currentItem()
if current and current.data(Qt.UserRole) == coll_name:
coll = get_collection(coll_name)
if coll:
self._show_collection_details(coll)
# Show status message
coll = get_collection(coll_name)
if coll:
if coll.pending_approval:
self.main_window.show_status(f"Collection '{coll_name}' still has pending tools")
elif coll.published:
self.main_window.show_status(f"Collection '{coll_name}' published to registry!")
self._load_registry_collections() # Refresh registry tab when auto-published
else:
self.main_window.show_status(f"Collection '{coll_name}' status updated (some tools may have been rejected)")
def _on_selection_changed(self, current, previous):
"""Handle collection selection change."""
if not current:
@@ -784,32 +1039,75 @@ class CollectionsPage(QWidget):
)
return
# Handle unpublished local tools
# Gather all unpublished tools (collection tools + transitive deps)
dep_result = result.get("dep_result")
# Start with tools not in registry at all
collection_tools_unpub = list(resolution.local_unpublished)
# Also include tools that exist but aren't approved (rejected, pending, changes_requested)
for name, status, has_approved in resolution.local_published:
if status != "approved" and name not in collection_tools_unpub:
collection_tools_unpub.append(name)
dependency_tools_unpub = []
if dep_result and dep_result.unpublished:
for dep in dep_result.unpublished:
if dep not in collection_tools_unpub:
dependency_tools_unpub.append(dep)
all_unpublished = collection_tools_unpub + dependency_tools_unpub
# Handle unpublished local tools (both collection tools and their deps)
publish_list = []
skip_list = []
if resolution.local_unpublished:
tools_list = "\n".join(f" - {t}" for t in resolution.local_unpublished)
if all_unpublished:
# Build detailed message
message = "Some tools are not published yet.\n\n"
if collection_tools_unpub:
message += "Collection tools:\n"
message += "\n".join(f" - {t}" for t in collection_tools_unpub)
message += "\n\n"
if dependency_tools_unpub:
message += "Dependencies:\n"
message += "\n".join(f" - {t}" for t in dependency_tools_unpub)
message += "\n\n"
if dep_result and dep_result.cycles:
message += "Warning: Circular dependencies detected:\n"
for cycle in dep_result.cycles:
message += f" {' -> '.join(cycle)}\n"
message += "(Cyclic deps excluded from auto-publish)\n\n"
if dep_result and dep_result.skipped:
message += f"Note: Could not check {len(dep_result.skipped)} dep(s) due to errors.\n\n"
message += "Choose how to proceed:"
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Warning)
dialog.setWindowTitle("Unpublished Tools")
dialog.setText(
"Some local tools are not published yet.\n\n"
f"{tools_list}\n\n"
"Choose how to proceed:"
)
dialog.setText(message)
btn_publish = dialog.addButton("Publish Tools First", QMessageBox.AcceptRole)
btn_skip = dialog.addButton("Skip Unpublished", QMessageBox.DestructiveRole)
btn_cancel = dialog.addButton("Cancel", QMessageBox.RejectRole)
dialog.setDefaultButton(btn_publish)
# Ensure buttons are wide enough for their text
for btn in [btn_publish, btn_skip, btn_cancel]:
btn.setMinimumWidth(btn.fontMetrics().horizontalAdvance(btn.text()) + 30)
dialog.exec()
clicked = dialog.clickedButton()
if clicked == btn_publish:
publish_list = resolution.local_unpublished
# Use topological order if available
if dep_result and dep_result.publish_order:
# Filter to only unpublished tools
publish_list = [t for t in dep_result.publish_order if t in all_unpublished]
else:
publish_list = all_unpublished
elif clicked == btn_skip:
skip_list = resolution.local_unpublished
skip_list = all_unpublished
else:
return
@@ -854,6 +1152,7 @@ class CollectionsPage(QWidget):
if success:
QMessageBox.information(self, "Published", message)
self._load_local_collections()
self._load_registry_collections() # Also refresh registry tab
else:
QMessageBox.warning(self, "Publish Failed", message)
self.btn_publish.setEnabled(True)
+2 -2
View File
@@ -11,7 +11,7 @@ from PySide6.QtGui import QColor
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
from ...tool import list_tools, load_tool
from ...tool import list_tools, load_tool, get_all_categories
class SearchWorker(QThread):
@@ -152,7 +152,7 @@ class RegistryPage(QWidget):
cat_label = QLabel("Category:")
filters_layout.addWidget(cat_label)
self.category_combo = QComboBox()
self.category_combo.addItems(["All", "Text", "Developer", "Data", "Other"])
self.category_combo.addItems(["All"] + get_all_categories())
self.category_combo.setMinimumWidth(100)
self.category_combo.setToolTip("Filter by tool category")
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
+4 -4
View File
@@ -13,7 +13,7 @@ from PySide6.QtCore import Qt
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES,
load_tool, save_tool, validate_tool_name, get_all_categories,
ensure_settings
)
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
@@ -92,7 +92,7 @@ class ToolBuilderPage(QWidget):
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
self.category_combo.setToolTip("Category for organizing tools (select or type custom)")
for cat in DEFAULT_CATEGORIES:
for cat in get_all_categories():
self.category_combo.addItem(cat)
info_layout.addRow("Category:", self.category_combo)
@@ -1070,8 +1070,8 @@ class ToolBuilderPage(QWidget):
return
# Validate name
error = validate_tool_name(name)
if error:
is_valid, error = validate_tool_name(name)
if not is_valid:
QMessageBox.warning(self, "Validation", error)
return
+75 -34
View File
@@ -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}")