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
+242 -29
View File
@@ -8,7 +8,9 @@ from PySide6.QtWidgets import (
)
from PySide6.QtCore import QThread, Signal
from ...tool import Tool
import yaml
from ...tool import Tool, get_all_categories
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
@@ -35,7 +37,7 @@ def bump_version(version: str, bump_type: str) -> str:
class VersionFetchWorker(QThread):
"""Background worker to fetch registry version info."""
finished = Signal(dict) # Dict with 'my_version' and 'original_versions'
finished = Signal(dict) # Dict with 'my_version', 'original_versions', and 'user_role'
error = Signal(str)
def __init__(self, name: str, original_owner: Optional[str] = None):
@@ -54,10 +56,20 @@ class VersionFetchWorker(QThread):
"my_status": None,
"original_versions": [],
"original_owner": self.original_owner,
"user_role": "user",
"user_slug": "",
}
# Get my published version of this tool
# Get my published version of this tool and user info
if config.registry.token:
try:
# Get user info including role
me = client.get_me()
result["user_role"] = me.get("role", "user")
result["user_slug"] = me.get("slug", "")
except RegistryError:
pass
try:
status = client.get_my_tool_status(self.name)
result["my_version"] = status.get("version")
@@ -84,11 +96,12 @@ class PublishWorker(QThread):
success = Signal(dict)
error = Signal(str)
def __init__(self, config_yaml: str, readme: str = "", defaults: str = ""):
def __init__(self, config_yaml: str, readme: str = "", defaults: str = "", owner: str = ""):
super().__init__()
self.config_yaml = config_yaml
self.readme = readme
self.defaults = defaults
self.owner = owner
def run(self):
try:
@@ -96,7 +109,7 @@ class PublishWorker(QThread):
client = RegistryClient()
client.token = config.registry.token
result = client.publish_tool(self.config_yaml, self.readme, self.defaults)
result = client.publish_tool(self.config_yaml, self.readme, self.defaults, owner=self.owner)
self.success.emit(result)
except Exception as e:
self.error.emit(str(e))
@@ -108,7 +121,7 @@ class PublishDialog(QDialog):
def __init__(self, parent, tool: Tool):
super().__init__(parent)
self.setWindowTitle("Publish Tool")
self.setMinimumSize(500, 450)
self.setMinimumSize(500, 580)
self._tool = tool
self._worker = None
self._version_worker = None
@@ -117,6 +130,8 @@ class PublishDialog(QDialog):
self._original_versions: List[str] = []
self._local_version = self._get_local_version()
self._original_owner = self._get_original_owner()
self._user_role: str = "user"
self._user_slug: str = ""
self._setup_ui()
self._fetch_registry_versions()
@@ -124,7 +139,6 @@ class PublishDialog(QDialog):
"""Get the version from local tool config."""
if hasattr(self._tool, 'path') and self._tool.path:
try:
import yaml
config = yaml.safe_load(self._tool.path.read_text())
return config.get("version", "1.0.0")
except Exception:
@@ -135,7 +149,6 @@ class PublishDialog(QDialog):
"""Get the original owner if this tool was installed from registry."""
if hasattr(self._tool, 'path') and self._tool.path:
try:
import yaml
config = yaml.safe_load(self._tool.path.read_text())
# Check if it has forked_from or was installed from registry
if config.get("forked_from"):
@@ -158,7 +171,10 @@ class PublishDialog(QDialog):
self._my_registry_version = result.get("my_version")
self._my_registry_status = result.get("my_status")
self._original_versions = result.get("original_versions", [])
self._user_role = result.get("user_role", "user")
self._user_slug = result.get("user_slug", "")
self._update_version_info()
self._update_admin_fields()
def _on_versions_error(self, error: str):
"""Handle version fetch error (non-critical)."""
@@ -202,6 +218,18 @@ class PublishDialog(QDialog):
"""Show or hide version bump buttons."""
self.bump_widget.setVisible(show)
def _update_admin_fields(self):
"""Show/hide admin-only fields based on user role."""
is_admin = self._user_role == "admin"
self.owner_widget.setVisible(is_admin)
self.owner_label.setVisible(is_admin)
if is_admin and self.owner_combo.count() == 0:
# Only populate once to avoid resetting user's selection
self.owner_combo.addItem(self._user_slug)
self.owner_combo.addItem("official")
self.owner_combo.setCurrentIndex(0)
def _bump_patch(self):
"""Bump patch version."""
if self._my_registry_version:
@@ -263,6 +291,7 @@ class PublishDialog(QDialog):
self.version_input.setText(self._local_version)
self.version_input.setPlaceholderText("1.0.0")
self.version_input.setMaximumWidth(100)
self.version_input.setMinimumHeight(28)
version_row.addWidget(self.version_input)
# Bump buttons (hidden until we know registry version)
@@ -273,21 +302,21 @@ class PublishDialog(QDialog):
btn_patch = QPushButton("+Patch")
btn_patch.setObjectName("secondary")
btn_patch.setFixedWidth(60)
btn_patch.setFixedWidth(75)
btn_patch.setToolTip("Bump patch version (bug fixes)")
btn_patch.clicked.connect(self._bump_patch)
bump_layout.addWidget(btn_patch)
btn_minor = QPushButton("+Minor")
btn_minor.setObjectName("secondary")
btn_minor.setFixedWidth(60)
btn_minor.setFixedWidth(75)
btn_minor.setToolTip("Bump minor version (new features)")
btn_minor.clicked.connect(self._bump_minor)
bump_layout.addWidget(btn_minor)
btn_major = QPushButton("+Major")
btn_major.setObjectName("secondary")
btn_major.setFixedWidth(60)
btn_major.setFixedWidth(75)
btn_major.setToolTip("Bump major version (breaking changes)")
btn_major.clicked.connect(self._bump_major)
bump_layout.addWidget(btn_major)
@@ -298,14 +327,34 @@ class PublishDialog(QDialog):
version_row.addStretch()
form.addRow("Version:", version_row)
# Category
# Owner (admin only) - hidden by default
self.owner_widget = QWidget()
owner_layout = QHBoxLayout(self.owner_widget)
owner_layout.setContentsMargins(0, 0, 0, 0)
self.owner_combo = QComboBox()
self.owner_combo.setMinimumWidth(150)
self.owner_combo.setMinimumHeight(28)
# Make it editable but read-only to fix hover styling issues
self.owner_combo.setEditable(True)
self.owner_combo.lineEdit().setReadOnly(True)
owner_layout.addWidget(self.owner_combo)
owner_hint = QLabel("(admin only)")
owner_hint.setStyleSheet("color: #805ad5; font-size: 11px;")
owner_layout.addWidget(owner_hint)
owner_layout.addStretch()
self.owner_widget.setVisible(False) # Hidden until we know user is admin
self.owner_label = QLabel("Publish as:")
self.owner_label.setVisible(False)
form.addRow(self.owner_label, self.owner_widget)
# Category - populated from defaults + local tools
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
categories = [
"Text Processing", "Code", "Data", "System",
"Writing", "Analysis", "Productivity", "Other"
]
self.category_combo.addItems(categories)
self.category_combo.setMinimumHeight(28)
self.category_combo.addItems(get_all_categories())
if self._tool.category:
idx = self.category_combo.findText(self._tool.category)
if idx >= 0:
@@ -314,9 +363,18 @@ class PublishDialog(QDialog):
self.category_combo.setCurrentText(self._tool.category)
form.addRow("Category:", self.category_combo)
# Tags
# Tags - load from config.yaml if available
self.tags_input = QLineEdit()
self.tags_input.setMinimumHeight(28)
self.tags_input.setPlaceholderText("ai, text, productivity (comma separated)")
if self._tool.path:
try:
config_data = yaml.safe_load(self._tool.path.read_text()) or {}
existing_tags = config_data.get("tags", [])
if existing_tags:
self.tags_input.setText(", ".join(existing_tags))
except Exception:
pass # No existing tags or error reading
form.addRow("Tags:", self.tags_input)
layout.addLayout(form)
@@ -328,7 +386,8 @@ class PublishDialog(QDialog):
self.desc_input = QTextEdit()
self.desc_input.setPlaceholderText("Describe what your tool does...")
self.desc_input.setPlainText(self._tool.description or "")
self.desc_input.setMaximumHeight(100)
self.desc_input.setMinimumHeight(80)
self.desc_input.setMaximumHeight(120)
layout.addWidget(self.desc_input)
# Status
@@ -336,12 +395,18 @@ class PublishDialog(QDialog):
self.status_label.setStyleSheet("color: #718096;")
layout.addWidget(self.status_label)
# Progress
# Progress - use fixed height container so layout doesn't shift
self.progress = QProgressBar()
self.progress.setRange(0, 0)
self.progress.hide()
self.progress.setFixedHeight(20)
self.progress.setVisible(False)
layout.addWidget(self.progress)
# Spacer that matches progress bar height when hidden
self.progress_spacer = QWidget()
self.progress_spacer.setFixedHeight(20)
layout.addWidget(self.progress_spacer)
layout.addStretch()
# Buttons
@@ -359,10 +424,81 @@ class PublishDialog(QDialog):
layout.addLayout(buttons)
def _check_unpublished_dependencies(self):
"""Check for unpublished dependencies."""
from ...collection import gather_local_unpublished_deps, DepCheckResult
try:
config = load_config()
client = RegistryClient()
client.token = config.registry.token
if not client.token:
# Can't check without auth, skip the check
return DepCheckResult([], [], [], [])
# Get user slug for proper path resolution
try:
me = client.get_me()
my_owner = me.get("slug", "")
except Exception:
my_owner = None
return gather_local_unpublished_deps([self._tool.name], client, my_owner)
except Exception:
return DepCheckResult([], [], [], [])
def _publish_with_dependencies(self, dep_result):
"""Publish dependencies first, then the main tool."""
from ...cli.collections_commands import _publish_single_tool
config = load_config()
client = RegistryClient()
client.token = config.registry.token
# Get tools to publish in order (only unpublished deps, exclude main tool)
tools_to_publish = [
t for t in dep_result.publish_order
if t in dep_result.unpublished and t != self._tool.name
]
if not tools_to_publish:
# No deps to publish, proceed with main publish
self._do_publish()
return
# Publish dependencies first
self.btn_publish.setEnabled(False)
self.btn_cancel.setEnabled(False)
self.progress.setVisible(True)
self.progress_spacer.setVisible(False)
failed = []
for tool_name in tools_to_publish:
self.status_label.setText(f"Publishing dependency: {tool_name}...")
result = _publish_single_tool(tool_name, client)
if not result.get("success"):
failed.append((tool_name, result.get("error", "Unknown error")))
if failed:
self.progress.setVisible(False)
self.progress_spacer.setVisible(True)
self.btn_publish.setEnabled(True)
self.btn_cancel.setEnabled(True)
errors = "\n".join(f" - {name}: {err}" for name, err in failed)
QMessageBox.warning(
self, "Dependency Publish Failed",
f"Failed to publish some dependencies:\n\n{errors}\n\n"
"Fix these issues and try again."
)
return
# Now publish the main tool
self.status_label.setText("Publishing main tool...")
self._do_publish()
def _publish(self):
"""Publish the tool."""
import yaml
version = self.version_input.text().strip()
if not version:
QMessageBox.warning(self, "Validation", "Version is required")
@@ -373,6 +509,75 @@ class PublishDialog(QDialog):
QMessageBox.warning(self, "Validation", "Description is required")
return
# Check for unpublished dependencies
dep_result = self._check_unpublished_dependencies()
# Filter out the tool itself - we're about to publish it
unpublished_deps = [d for d in dep_result.unpublished if d != self._tool.name]
if unpublished_deps:
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Warning)
dialog.setWindowTitle("Unpublished Dependencies")
message = "This tool depends on unpublished tools:\n\n"
message += "\n".join(f" - {t}" for t in unpublished_deps)
message += "\n\n"
if 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.skipped:
message += f"Note: Could not check {len(dep_result.skipped)} dep(s).\n\n"
message += "Choose how to proceed:"
dialog.setText(message)
btn_publish = dialog.addButton("Publish Dependencies First", QMessageBox.AcceptRole)
btn_anyway = dialog.addButton("Publish Anyway", QMessageBox.DestructiveRole)
btn_cancel = dialog.addButton("Cancel", QMessageBox.RejectRole)
for btn in [btn_publish, btn_anyway, btn_cancel]:
btn.setMinimumWidth(btn.fontMetrics().horizontalAdvance(btn.text()) + 30)
dialog.exec()
clicked = dialog.clickedButton()
if clicked == btn_cancel:
return
elif clicked == btn_publish:
self._publish_with_dependencies(dep_result)
return
# btn_anyway falls through to normal publish
self._do_publish()
def _do_publish(self):
"""Actually perform the publish operation."""
version = self.version_input.text().strip()
description = self.desc_input.toPlainText().strip()
# Check if tool was previously rejected - if so, bump version
try:
config = load_config()
client = RegistryClient()
client.token = config.registry.token
if client.token:
status_info = client.get_my_tool_status(self._tool.name)
current_status = status_info.get("status", "")
if current_status == "rejected":
new_version = bump_version(version, "patch")
self.version_input.setText(new_version)
version = new_version
self.status_label.setText(f"Version bumped to {new_version} (previous was rejected)")
except RegistryError as e:
if e.code != "TOOL_NOT_FOUND":
pass # Other errors - continue
except Exception:
pass # Continue with publish
category = self.category_combo.currentText()
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
@@ -415,22 +620,29 @@ class PublishDialog(QDialog):
self.btn_publish.setEnabled(False)
self.btn_cancel.setEnabled(False)
self.progress.show()
self.progress.setVisible(True)
self.progress_spacer.setVisible(False)
self.status_label.setText("Publishing...")
# Store the config we're publishing so we can save it on success
self._published_config = tool_config
self._worker = PublishWorker(config_yaml, readme, defaults)
# Get owner if admin selected a different one
owner = ""
if self._user_role == "admin" and self.owner_widget.isVisible():
selected_text = self.owner_combo.currentText().strip()
if selected_text == "official":
owner = "official"
self._worker = PublishWorker(config_yaml, readme, defaults, owner=owner)
self._worker.success.connect(self._on_success)
self._worker.error.connect(self._on_error)
self._worker.start()
def _on_success(self, result: dict):
"""Handle publish success."""
import yaml
self.progress.hide()
self.progress.setVisible(False)
self.progress_spacer.setVisible(True)
self.status_label.setText("Published successfully!")
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
@@ -463,7 +675,8 @@ class PublishDialog(QDialog):
def _on_error(self, error: str):
"""Handle publish error."""
self.progress.hide()
self.progress.setVisible(False)
self.progress_spacer.setVisible(True)
self.btn_publish.setEnabled(True)
self.btn_cancel.setEnabled(True)
self.status_label.setText(f"Error: {error}")