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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user