From c9f942805c15fa57593bf06fbfa975862d026f1a Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:13:48 -0400 Subject: [PATCH] Split skills registry and runtime modules (#3481) --- .github/workflows/coverage.yml | 17 +- .github/workflows/pr-ci.yml | 18 +- .../components/skills/SkillsExplorerTab.tsx | 567 +++++++--- .../skills/UninstallSkillConfirmDialog.tsx | 2 +- .../components/skills/WorkflowRunnerBody.tsx | 986 +++++++++--------- .../__tests__/SkillsExplorerTab.test.tsx | 555 +++++++++- .../__tests__/WorkflowRunnerBody.test.tsx | 596 ++++++++++- app/src/components/skills/preflightGate.ts | 12 +- app/src/lib/i18n/ar.ts | 16 +- app/src/lib/i18n/bn.ts | 17 +- app/src/lib/i18n/de.ts | 17 +- app/src/lib/i18n/en.ts | 9 + app/src/lib/i18n/es.ts | 17 +- app/src/lib/i18n/fr.ts | 17 +- app/src/lib/i18n/hi.ts | 17 +- app/src/lib/i18n/id.ts | 17 +- app/src/lib/i18n/it.ts | 17 +- app/src/lib/i18n/ko.ts | 17 +- app/src/lib/i18n/pl.ts | 17 +- app/src/lib/i18n/pt.ts | 17 +- app/src/lib/i18n/ru.ts | 17 +- app/src/lib/i18n/zh-CN.ts | 16 +- .../api/__tests__/workflowsApi.test.ts | 2 +- app/src/services/api/skillRegistryApi.test.ts | 156 +++ app/src/services/api/skillRegistryApi.ts | 142 ++- app/src/services/api/workflowsApi.test.ts | 85 +- app/src/services/api/workflowsApi.ts | 113 +- .../playwright/specs/skills-registry.spec.ts | 84 ++ scripts/test-rust-e2e.sh | 1 + src/core/all.rs | 6 +- src/core/jsonrpc.rs | 5 +- src/openhuman/agent/tools/run_workflow.rs | 5 +- src/openhuman/agent_registry/agents/loader.rs | 10 + .../agents/orchestrator/agent.toml | 15 +- .../agents/orchestrator/prompt.md | 2 + .../agents/orchestrator/prompt.rs | 42 + src/openhuman/mcp_server/resources.rs | 12 + src/openhuman/mod.rs | 1 + src/openhuman/skill_registry/README.md | 47 + src/openhuman/skill_registry/agent/mod.rs | 1 + .../agent/skill_setup/agent.toml | 29 + .../skill_registry/agent/skill_setup/mod.rs | 1 + .../agent/skill_setup/prompt.md | 33 + .../agent/skill_setup/prompt.rs | 38 + src/openhuman/skill_registry/mod.rs | 9 +- src/openhuman/skill_registry/ops.rs | 610 ++++++----- .../schemas/controller_schemas.rs | 150 +-- .../skill_registry/schemas/handlers.rs | 81 +- .../skill_registry/schemas/wire_types.rs | 43 +- src/openhuman/skill_registry/store.rs | 32 +- src/openhuman/skill_registry/tools.rs | 288 +++++ src/openhuman/skill_registry/types.rs | 54 +- src/openhuman/skill_runtime/README.md | 31 + src/openhuman/skill_runtime/agent/mod.rs | 1 + .../agent/skill_executor/agent.toml | 29 + .../skill_runtime/agent/skill_executor/mod.rs | 1 + .../agent/skill_executor/prompt.md | 26 + .../agent/skill_executor/prompt.rs | 38 + src/openhuman/skill_runtime/mod.rs | 19 + src/openhuman/skill_runtime/ops.rs | 189 ++++ .../run_machinery.rs | 10 +- src/openhuman/skill_runtime/schemas.rs | 393 +++++++ src/openhuman/skill_runtime/tools.rs | 69 ++ src/openhuman/tools/mod.rs | 2 + src/openhuman/tools/ops.rs | 13 +- src/openhuman/workflows/e2e_plumbing_tests.rs | 2 +- src/openhuman/workflows/e2e_run_tests.rs | 5 +- src/openhuman/workflows/ops_install.rs | 24 +- src/openhuman/workflows/preflight.rs | 2 +- src/openhuman/workflows/registry.rs | 2 +- src/openhuman/workflows/schemas/handlers.rs | 5 +- src/openhuman/workflows/schemas/mod.rs | 6 - tests/skill_registry_e2e.rs | 549 ++++++++++ 73 files changed, 5165 insertions(+), 1329 deletions(-) create mode 100644 app/src/services/api/skillRegistryApi.test.ts create mode 100644 src/openhuman/skill_registry/README.md create mode 100644 src/openhuman/skill_registry/agent/mod.rs create mode 100644 src/openhuman/skill_registry/agent/skill_setup/agent.toml create mode 100644 src/openhuman/skill_registry/agent/skill_setup/mod.rs create mode 100644 src/openhuman/skill_registry/agent/skill_setup/prompt.md create mode 100644 src/openhuman/skill_registry/agent/skill_setup/prompt.rs create mode 100644 src/openhuman/skill_registry/tools.rs create mode 100644 src/openhuman/skill_runtime/README.md create mode 100644 src/openhuman/skill_runtime/agent/mod.rs create mode 100644 src/openhuman/skill_runtime/agent/skill_executor/agent.toml create mode 100644 src/openhuman/skill_runtime/agent/skill_executor/mod.rs create mode 100644 src/openhuman/skill_runtime/agent/skill_executor/prompt.md create mode 100644 src/openhuman/skill_runtime/agent/skill_executor/prompt.rs create mode 100644 src/openhuman/skill_runtime/mod.rs create mode 100644 src/openhuman/skill_runtime/ops.rs rename src/openhuman/{workflows/schemas => skill_runtime}/run_machinery.rs (98%) create mode 100644 src/openhuman/skill_runtime/schemas.rs create mode 100644 src/openhuman/skill_runtime/tools.rs create mode 100644 tests/skill_registry_e2e.rs diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1462082e6..25840bdb0 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -64,22 +64,17 @@ jobs: rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) runs-on: ubuntu-22.04 - # See test.yml `rust-core-tests` — same shared-singleton flake risk in - # the lib suite. Coverage instrumentation roughly doubles wall time vs. - # the plain test runs; give it enough headroom for cold/warm cache - # variance while still surfacing deadlocks with logs. timeout-minutes: 30 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: "0" - # Coverage instrumentation makes each test binary link substantially - # heavier. Keep the core coverage job's linker work serialized to avoid - # intermittent rust-lld bus errors on hosted Linux runners. - CARGO_BUILD_JOBS: "1" - # sccache is incompatible with `-C instrument-coverage` profiles, so we - # skip it for coverage runs and rely on Swatinem/rust-cache for warmup. steps: + - name: Free disk space + run: | + rm -rf /__t/* || true + echo "Disk after cleanup:" + df -h /__w || true - name: Checkout code uses: actions/checkout@v5 with: @@ -97,7 +92,7 @@ jobs: - name: Run cargo llvm-cov for openhuman core run: cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info env: - CARGO_BUILD_JOBS: "2" + CARGO_BUILD_JOBS: "1" - name: Upload core lcov uses: actions/upload-artifact@v5 with: diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 93f5b3c85..5fa1035fe 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -297,6 +297,18 @@ jobs: env: CARGO_INCREMENTAL: "0" steps: + - name: Free disk space + run: | + # Coverage-instrumented builds produce huge binaries that exhaust the + # runner's ~14GB free disk during linking (SIGBUS). Inside a container, + # host paths are bind-mounted: + # /opt/hostedtoolcache → /__t + # /home/runner/work → /__w + # Deleting /__t frees ~8GB on the same partition the build uses. + rm -rf /__t/* || true + echo "Disk after cleanup:" + df -h /__w || true + - name: Checkout code uses: actions/checkout@v5 with: @@ -309,10 +321,6 @@ jobs: with: workspaces: . -> target cache-on-failure: true - # Coverage uses instrumented builds (-C instrument-coverage), whose - # rustflags hash to a different Swatinem key than the plain builds, so - # it gets its own cache and cannot reuse pr-rust-core. It still reuses - # the (non-instrumented) dependency tree run-over-run. shared-key: pr-rust-core-cov - name: Install cargo-llvm-cov @@ -321,7 +329,7 @@ jobs: - name: Run cargo llvm-cov for openhuman core run: bash scripts/ci-cancel-aware.sh cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info env: - CARGO_BUILD_JOBS: "2" + CARGO_BUILD_JOBS: "1" - name: Upload core lcov uses: actions/upload-artifact@v5 diff --git a/app/src/components/skills/SkillsExplorerTab.tsx b/app/src/components/skills/SkillsExplorerTab.tsx index d69e284ec..69116689d 100644 --- a/app/src/components/skills/SkillsExplorerTab.tsx +++ b/app/src/components/skills/SkillsExplorerTab.tsx @@ -1,14 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; import debug from 'debug'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; +import { type CatalogEntry, skillRegistryApi } from '../../services/api/skillRegistryApi'; import { - skillRegistryApi, - type CatalogEntry, -} from '../../services/api/skillRegistryApi'; -import { - workflowsApi, type InstallWorkflowFromUrlResult, + workflowsApi, type WorkflowSummary, } from '../../services/api/workflowsApi'; import EmptyStateCard from '../EmptyStateCard'; @@ -16,29 +13,73 @@ import InstallSkillDialog from './InstallSkillDialog'; import UninstallSkillConfirmDialog from './UninstallSkillConfirmDialog'; const log = debug('skills:explorer-tab'); +const CATALOG_PAGE_SIZE = 60; +const SEARCH_DEBOUNCE_MS = 300; -function SkillFormatBadge({ format }: { format: string }) { - const lower = format.toLowerCase(); - const label = - lower === 'hermes' - ? 'Hermes' - : lower === 'legacy' - ? 'Legacy' - : lower === 'openclaw' - ? 'OpenClaw' - : 'OpenHuman'; +function SourceBadge({ source }: { source: string }) { + const SOURCE_COLORS: Record = { + 'built-in': + 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/30', + optional: + 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-500/30', + ClawHub: + 'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30', + 'skills.sh': + 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30', + LobeHub: + 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-500/10 dark:text-pink-300 dark:border-pink-500/30', + 'browse.sh': + 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/30', + }; const colors = - lower === 'hermes' - ? 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30' - : lower === 'openclaw' - ? 'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30' - : lower === 'legacy' - ? 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/30' - : 'bg-primary-50 text-primary-700 border-primary-200 dark:bg-primary-500/10 dark:text-primary-300 dark:border-primary-500/30'; + SOURCE_COLORS[source] ?? + 'bg-stone-50 text-stone-600 border-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:border-neutral-700'; return ( - {label} + {source} + + ); +} + +function SkillFormatBadge({ format }: { format: string }) { + const lower = format.toLowerCase(); + const FORMAT_MAP: Record = { + hermes: { + label: 'Hermes', + colors: + 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30', + }, + agentskills: { + label: 'AgentSkills', + colors: + 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30', + }, + openclaw: { + label: 'OpenClaw', + colors: + 'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30', + }, + clawhub: { + label: 'ClawHub', + colors: + 'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30', + }, + legacy: { + label: 'Legacy', + colors: + 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/30', + }, + }; + const entry = FORMAT_MAP[lower] ?? { + label: format || 'Skill', + colors: + 'bg-stone-50 text-stone-600 border-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:border-neutral-700', + }; + return ( + + {entry.label} ); } @@ -58,27 +99,30 @@ function SkillScopeBadge({ scope }: { scope: string }) { ); } -function SourceBadge({ sourceId }: { sourceId: string }) { - return ( - - {sourceId} - - ); -} - interface SkillTileProps { skill: WorkflowSummary; onUninstall: () => void; + onClick: () => void; } -function SkillTile({ skill, onUninstall }: SkillTileProps) { +function SkillTile({ skill, onUninstall, onClick }: SkillTileProps) { const { t } = useT(); const canUninstall = skill.scope === 'user'; return (
+ role="button" + tabIndex={0} + onClick={onClick} + onKeyDown={e => { + if (e.key === 'Enter') onClick(); + if (e.key === ' ' || e.key === 'Space') { + e.preventDefault(); + onClick(); + } + }} + className="group flex flex-col justify-between rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 transition-colors cursor-pointer hover:bg-stone-50 dark:hover:bg-neutral-800/60">
@@ -163,14 +207,25 @@ interface CatalogTileProps { installed: boolean; installing: boolean; onInstall: () => void; + onClick: () => void; } -function CatalogTile({ entry, installed, installing, onInstall }: CatalogTileProps) { +function CatalogTile({ entry, installed, installing, onInstall, onClick }: CatalogTileProps) { const { t } = useT(); return (
{ + if (e.key === 'Enter') onClick(); + if (e.key === ' ' || e.key === 'Space') { + e.preventDefault(); + onClick(); + } + }} + className={`group flex flex-col justify-between rounded-2xl border p-3 transition-colors cursor-pointer ${ installed ? 'border-sage-300 bg-sage-50/60 dark:border-sage-500/30 dark:bg-sage-500/10' : 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-stone-50 dark:hover:bg-neutral-800/60' @@ -192,8 +247,7 @@ function CatalogTile({ entry, installed, installing, onInstall }: CatalogTilePro
- - +
@@ -225,14 +279,7 @@ function CatalogTile({ entry, installed, installing, onInstall }: CatalogTilePro )} {entry.author && ( - - {entry.author} - - )} - {entry.stars != null && entry.stars > 0 && ( - - {entry.stars} - + {entry.author} )}
{installed ? ( @@ -257,6 +304,161 @@ function CatalogTile({ entry, installed, installing, onInstall }: CatalogTilePro ); } +interface SkillDetailDialogProps { + entry: CatalogEntry | null; + skill: WorkflowSummary | null; + installed: boolean; + onClose: () => void; + onInstall?: () => void; + installing?: boolean; +} + +function SkillDetailDialog({ + entry, + skill, + installed, + onClose, + onInstall, + installing, +}: SkillDetailDialogProps) { + const { t } = useT(); + const name = entry?.name ?? skill?.name ?? ''; + const description = entry?.description ?? skill?.description ?? ''; + const tags = entry?.tags ?? skill?.tags ?? []; + const version = entry?.version ?? skill?.version ?? ''; + const author = entry?.author ?? ''; + const source = entry?.source ?? ''; + const category = entry?.category ?? ''; + const downloadUrl = entry?.download_url ?? ''; + const license = entry?.license ?? ''; + + return ( +
+
e.stopPropagation()}> +
+
+
+

+ {name} +

+ {installed && ( + + {t('skills.explorer.installed')} + + )} +
+
+ {source && } + {category && ( + + {category} + + )} +
+
+ +
+ +
+ {description && ( +
+

+ {t('skills.detail.description')} +

+

+ {description} +

+
+ )} + +
+ {version && ( +
+ + {t('skills.detail.version')} + +

{version}

+
+ )} + {author && ( +
+ + {t('skills.detail.author')} + +

{author}

+
+ )} + {license && ( +
+ + {t('skills.detail.license')} + +

{license}

+
+ )} +
+ + {tags.length > 0 && ( +
+

+ {t('skills.detail.tags')} +

+
+ {tags.map(tag => ( + + {tag} + + ))} +
+
+ )} + + {downloadUrl && ( +
+

+ {t('skills.detail.source')} +

+

+ {downloadUrl} +

+
+ )} +
+ + {!installed && onInstall && ( +
+ +
+ )} +
+
+ ); +} + type ExplorerView = 'installed' | 'registry'; interface SkillsExplorerTabProps { @@ -271,15 +473,34 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { const [skillsLoading, setSkillsLoading] = useState(true); const [skillsError, setSkillsError] = useState(null); - const [catalog, setCatalog] = useState([]); + const [catalogEntries, setCatalogEntries] = useState([]); + const [catalogTotal, setCatalogTotal] = useState(0); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogError, setCatalogError] = useState(null); + const [catalogInitialized, setCatalogInitialized] = useState(false); const [installingId, setInstallingId] = useState(null); + const [sources, setSources] = useState([]); + const [activeSources, setActiveSources] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); - const [formatFilter, setFormatFilter] = useState('all'); + const [debouncedQuery, setDebouncedQuery] = useState(''); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [uninstallTarget, setUninstallTarget] = useState(null); + const [detailEntry, setDetailEntry] = useState(null); + const [detailSkill, setDetailSkill] = useState(null); + + const debounceRef = useRef | null>(null); + + // Debounce search input + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedQuery(searchQuery); + }, SEARCH_DEBOUNCE_MS); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [searchQuery]); const fetchSkills = useCallback(async () => { log('fetchSkills: start'); @@ -298,32 +519,63 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { } }, []); - const fetchCatalog = useCallback(async (forceRefresh = false) => { - log('fetchCatalog: forceRefresh=%s', forceRefresh); - setCatalogLoading(true); - setCatalogError(null); - try { - const entries = await skillRegistryApi.browse(forceRefresh); - log('fetchCatalog: count=%d', entries.length); - setCatalog(entries); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log('fetchCatalog: error=%s', msg); - setCatalogError(msg); - } finally { - setCatalogLoading(false); - } - }, []); + // Compute the active source filter for RPC calls. + // Only apply when the user has deselected at least one source. + const activeSourceFilter = useMemo(() => { + if (activeSources.size === 0 || activeSources.size >= sources.length) return undefined; + // If exactly one source is active, pass it as the filter + if (activeSources.size === 1) return [...activeSources][0]; + return undefined; + }, [activeSources, sources.length]); + + // Fetch catalog via RPC search (handles both browse and search). + // When query is empty and no source filter, uses browse; otherwise uses search. + const fetchCatalog = useCallback( + async (query: string, sourceFilter: string | undefined, forceRefresh: boolean) => { + log('fetchCatalog: query=%s source=%s forceRefresh=%s', query, sourceFilter, forceRefresh); + setCatalogLoading(true); + setCatalogError(null); + try { + let entries: CatalogEntry[]; + if (!query && !sourceFilter && !forceRefresh) { + entries = await skillRegistryApi.browse(false); + } else if (!query && !sourceFilter && forceRefresh) { + entries = await skillRegistryApi.browse(true); + } else { + entries = await skillRegistryApi.search(query || '', sourceFilter); + } + log('fetchCatalog: total=%d', entries.length); + setCatalogTotal(entries.length); + setCatalogEntries(entries.slice(0, CATALOG_PAGE_SIZE)); + setCatalogInitialized(true); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('fetchCatalog: error=%s', msg); + setCatalogError(msg); + } finally { + setCatalogLoading(false); + } + }, + [] + ); useEffect(() => { void fetchSkills(); + skillRegistryApi + .sources() + .then(s => { + setSources(s); + setActiveSources(new Set(s)); + }) + .catch(() => {}); }, [fetchSkills]); + // Trigger catalog search when debounced query or source filter changes useEffect(() => { - if (view === 'registry' && catalog.length === 0 && !catalogLoading) { - void fetchCatalog(); + if (view === 'registry') { + void fetchCatalog(debouncedQuery, activeSourceFilter, false); } - }, [view, catalog.length, catalogLoading, fetchCatalog]); + }, [view, debouncedQuery, activeSourceFilter, fetchCatalog]); const installedIds = useMemo(() => new Set(skills.map(s => s.id)), [skills]); @@ -347,25 +599,18 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { }); }, [filteredSkills]); - const filteredCatalog = useMemo(() => { - const q = searchQuery.toLowerCase().trim(); - return catalog.filter(entry => { - if (formatFilter !== 'all' && entry.format !== formatFilter) return false; - if (!q) return true; - return ( - entry.name.toLowerCase().includes(q) || - entry.description.toLowerCase().includes(q) || - entry.tags.some(tag => tag.toLowerCase().includes(q)) || - entry.format.toLowerCase().includes(q) || - (entry.author ?? '').toLowerCase().includes(q) - ); - }); - }, [catalog, searchQuery, formatFilter]); - - const catalogFormats = useMemo(() => { - const formats = new Set(catalog.map(e => e.format)); - return ['all', ...Array.from(formats).sort()]; - }, [catalog]); + // When multiple sources are active (but not all), do client-side filtering + // on the already-fetched results since the RPC only supports single source filter. + const displayedCatalog = useMemo(() => { + if ( + activeSources.size === 0 || + activeSources.size >= sources.length || + activeSources.size === 1 + ) { + return catalogEntries; + } + return catalogEntries.filter(e => activeSources.has(e.source)); + }, [catalogEntries, activeSources, sources.length]); const handleInstalled = useCallback( (result: InstallWorkflowFromUrlResult) => { @@ -388,32 +633,25 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { const handleUninstalled = useCallback(() => { log('handleUninstalled'); void fetchSkills(); - onToast?.({ - type: 'success', - title: t('skills.explorer.uninstallSuccess'), - }); + onToast?.({ type: 'success', title: t('skills.explorer.uninstallSuccess') }); }, [fetchSkills, onToast, t]); const handleRegistryInstall = useCallback( async (entry: CatalogEntry) => { - log('handleRegistryInstall: id=%s source=%s', entry.id, entry.source_id); + log('handleRegistryInstall: id=%s source=%s', entry.id, entry.source); setInstallingId(entry.id); try { - const result = await skillRegistryApi.install(entry.id, entry.source_id); + const result = await skillRegistryApi.install(entry.id); void fetchSkills(); onToast?.({ type: 'success', title: t('skills.install.installComplete'), - message: `Installed ${entry.name}${result.new_skills.length > 0 ? ` (${result.new_skills.join(', ')})` : ''}`, + message: `Installed ${entry.name}${result.newSkills.length > 0 ? ` (${result.newSkills.join(', ')})` : ''}`, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log('handleRegistryInstall: error=%s', msg); - onToast?.({ - type: 'error', - title: t('skills.install.errors.genericTitle'), - message: msg, - }); + onToast?.({ type: 'error', title: t('skills.install.errors.genericTitle'), message: msg }); } finally { setInstallingId(null); } @@ -457,8 +695,8 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) { : 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60' }`}> {t('skills.explorer.registryTab')} - {catalog.length > 0 && ( - {catalog.length} + {catalogTotal > 0 && ( + {catalogTotal.toLocaleString()} )}
- {/* Search + format filter */} + {/* Source toggles */} + {view === 'registry' && sources.length > 0 && ( +
+ {sources.map(src => { + const active = activeSources.has(src); + return ( + + ); + })} +
+ )} + + {/* Search */}
- {view === 'registry' && catalogFormats.length > 2 && ( - - )} {view === 'registry' && (
); } diff --git a/app/src/components/skills/UninstallSkillConfirmDialog.tsx b/app/src/components/skills/UninstallSkillConfirmDialog.tsx index 79674b60a..7d50264d1 100644 --- a/app/src/components/skills/UninstallSkillConfirmDialog.tsx +++ b/app/src/components/skills/UninstallSkillConfirmDialog.tsx @@ -4,7 +4,7 @@ * * Small centered confirm modal for destructive uninstall of a user-scope * SKILL.md skill. Wraps `workflowsApi.uninstallWorkflow` which calls - * `openhuman.workflows_uninstall` on the Rust side — that RPC only accepts + * `openhuman.skill_registry_uninstall` on the Rust side — that RPC only accepts * user-scope installs (`~/.openhuman/skills//`) and refuses project * and legacy scopes. The card that opens this dialog is responsible for * not surfacing the Uninstall action for non-user-scope entries. diff --git a/app/src/components/skills/WorkflowRunnerBody.tsx b/app/src/components/skills/WorkflowRunnerBody.tsx index 4ed3bfbd6..5b84a14ca 100644 --- a/app/src/components/skills/WorkflowRunnerBody.tsx +++ b/app/src/components/skills/WorkflowRunnerBody.tsx @@ -11,20 +11,19 @@ // Used by both the Settings → Developer Options → Skills Runner panel // AND the top-level /skills page's "Runners" tab (one source of truth; // the Settings panel is now a thin wrapper around this body). - import createDebug from 'debug'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useT } from '../../lib/i18n/I18nContext'; import { SCHEDULE_PRESETS } from '../../lib/cron/schedulePresets'; +import { useT } from '../../lib/i18n/I18nContext'; import { type RunLogSlice, type ScannedRun, type WorkflowDescription, type WorkflowRunStarted, - type WorkflowSummary, workflowsApi, + type WorkflowSummary, } from '../../services/api/workflowsApi'; import { type CoreCronJob, @@ -58,13 +57,7 @@ const SMART_PICKER_INPUT_NAMES = new Set(['repo', 'upstream', 'target_branch', ' // conventional names get the picker for free; nothing in skill.toml // needs to change. (We pick a generous overlap that covers both // github-issue-crusher and dev-workflow's input naming.) -const REPO_INPUT_NAMES = new Set([ - 'repo', - 'repository', - 'upstream', - 'fork', - 'fork_owner', -]); +const REPO_INPUT_NAMES = new Set(['repo', 'repository', 'upstream', 'fork', 'fork_owner']); const BRANCH_INPUT_NAMES = new Set([ 'branch', 'target_branch', @@ -98,7 +91,6 @@ interface RunState { result?: WorkflowRunStarted; } - /** Name prefix used to identify cron jobs owned by this panel (per-skill). */ const CRON_NAME_PREFIX = 'skill-run-'; @@ -109,7 +101,7 @@ const CRON_NAME_PREFIX = 'skill-run-'; function buildCronJobName(skillId: string, inputs: Record): string { const keys = Object.keys(inputs).sort(); const compact = keys - .map((k) => { + .map(k => { const v = inputs[k]; if (v === undefined || v === null || v === '') return ''; const s = typeof v === 'string' ? v : String(v); @@ -175,7 +167,7 @@ function defaultForType(type: string): InputValue { } /** - * Project the form-state map back into the JSON inputs shape `workflows_run` + * Project the form-state map back into the JSON inputs shape `skill_runtime_run` * expects: trim strings, coerce integer-typed fields to numbers, drop * empty optional fields entirely (so the backend sees them as "not * provided" rather than `""`). @@ -217,6 +209,16 @@ function buildInputsPayload( return out; } +function inferRuntimeRequirement(skill?: WorkflowSummary): 'node' | 'python' | 'all' | null { + const resources = skill?.resources ?? []; + const needsNode = resources.some(resource => /\.(?:cjs|js|mjs)$/i.test(resource)); + const needsPython = resources.some(resource => /\.py$/i.test(resource)); + if (needsNode && needsPython) return 'all'; + if (needsNode) return 'node'; + if (needsPython) return 'python'; + return null; +} + // ── Component ────────────────────────────────────────────────────────── export interface SkillsRunnerBodyProps { @@ -339,7 +341,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const [recentRunsLoading, setRecentRunsLoading] = useState(false); const [recentRunsRefreshNonce, setRecentRunsRefreshNonce] = useState(0); // Timers for the post-run refresh burst (cleared on unmount). A just-started - // run's log header is written a beat after `workflows_run` returns, so a + // run's log header is written a beat after `skill_runtime_run` returns, so a // single immediate re-scan can miss it — we bump now and a couple more times. const recentRunsTimersRef = useRef[]>([]); useEffect( @@ -350,10 +352,10 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr [] ); const scheduleRecentRunsRefresh = useCallback(() => { - setRecentRunsRefreshNonce((n) => n + 1); + setRecentRunsRefreshNonce(n => n + 1); recentRunsTimersRef.current.forEach(clearTimeout); - recentRunsTimersRef.current = [1500, 4000].map((ms) => - setTimeout(() => setRecentRunsRefreshNonce((n) => n + 1), ms) + recentRunsTimersRef.current = [1500, 4000].map(ms => + setTimeout(() => setRecentRunsRefreshNonce(n => n + 1), ms) ); }, []); // Guards against a fast double-click spawning two runs. `runSubmitGuardRef` @@ -391,7 +393,10 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // refetching when the user collapses-and-re-expands the same row. const [expandedRunId, setExpandedRunId] = useState(null); const [viewer, setViewer] = useState< - Record + Record< + string, + { content: string; offset: number; complete: boolean; loading: boolean; error: string | null } + > >({}); // Mirror of `viewer` into a ref so the tail-poll interval (whose effect @@ -456,10 +461,10 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setSkillsError(null); workflowsApi .listWorkflows() - .then((list) => { + .then(list => { if (cancelled) return; // Hide the codegraph-smoke skill — internal smoke-test only. - const filtered = list.filter((s) => s.id !== 'codegraph-smoke'); + const filtered = list.filter(s => s.id !== 'codegraph-smoke'); setSkills(filtered); log('loaded %d skills', filtered.length); }) @@ -490,7 +495,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setRun({ status: 'idle' }); workflowsApi .describeWorkflow(selectedSkillId) - .then((desc) => { + .then(desc => { if (cancelled) return; setDescription(desc); // Seed form values from each input's default. @@ -535,7 +540,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr }, [description, formValues]); // ── Run handler ──────────────────────────────────────────────────── - // Stop a RUNNING recent-run row via workflows_cancel, then refresh the list + // Stop a RUNNING recent-run row via skill_runtime_cancel, then refresh the list // so it flips to CANCELLED. const handleStopRun = useCallback(async (runId: string) => { log('stop run runId=%s', runId); @@ -544,13 +549,30 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr } catch (err) { log('cancelRun error: %s', err instanceof Error ? err.message : String(err)); } - setRecentRunsRefreshNonce((n) => n + 1); + setRecentRunsRefreshNonce(n => n + 1); }, []); + const ensureRuntimeAvailability = useCallback(async () => { + const runtimeRequirement = inferRuntimeRequirement(selectedWorkflow); + if (!runtimeRequirement) return; + + const resolved = await workflowsApi.resolveRuntimes(runtimeRequirement); + const unavailable = resolved.runtimes.filter(runtime => !runtime.available); + if (unavailable.length === 0) return; + + const prefix = t('settings.skillsRunner.error.runtimeUnavailable', 'Runtime unavailable'); + const defaultReason = t('settings.skillsRunner.error.runtimeUnavailableDefault', 'unavailable'); + throw new Error( + unavailable + .map(runtime => `${prefix}: ${runtime.runtime} (${runtime.error ?? defaultReason})`) + .join('; ') + ); + }, [selectedWorkflow, t]); + const handleRun = useCallback(async () => { if (!description) return; // Re-entry guard: a second click before React applies the disabled state - // would otherwise fire `workflows_run` twice and spawn two real runs. + // would otherwise fire `skill_runtime_run` twice and spawn two real runs. if (runSubmitGuardRef.current) { log('runWorkflow: ignoring re-entrant click while a run is starting'); return; @@ -568,6 +590,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr try { const inputs = buildInputsPayload(description, formValues); log('runWorkflow %s inputs=%o', description.id, inputs); + await ensureRuntimeAvailability(); const result = await workflowsApi.runWorkflow(description.id, inputs); setRun({ status: 'started', result }); // Surface the new run in "Recent runs" without a manual refresh, and @@ -581,7 +604,15 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setRun({ status: 'error', message: msg }); releaseRunGuard(0); // allow immediate retry on failure } - }, [description, formValues, missingRequired, scheduleRecentRunsRefresh, releaseRunGuard, t]); + }, [ + description, + formValues, + missingRequired, + scheduleRecentRunsRefresh, + releaseRunGuard, + ensureRuntimeAvailability, + t, + ]); // ── Recent runs: load on mount + on skill change + on demand ─────── useEffect(() => { @@ -589,7 +620,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setRecentRunsLoading(true); workflowsApi .recentRuns(selectedSkillId || undefined, 10) - .then((list) => { + .then(list => { if (cancelled) return; setRecentRuns(list); }) @@ -622,7 +653,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // user can toggle / edit them from the unified runner. const isDevWorkflow = selectedSkillId === 'dev-workflow'; setScheduledJobs( - allJobs.filter((j) => { + allJobs.filter(j => { const n = j.name ?? ''; if (n.startsWith(wanted)) return true; if (isDevWorkflow && n.startsWith('dev-workflow-')) return true; @@ -645,7 +676,9 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const handleSaveSchedule = useCallback(async () => { if (!description) return; if (missingRequired.length > 0) { - setScheduleError(`${t('settings.skillsRunner.error.missingRequired')} ${missingRequired.join(', ')}`); + setScheduleError( + `${t('settings.skillsRunner.error.missingRequired')} ${missingRequired.join(', ')}` + ); return; } setSavingSchedule(true); @@ -691,7 +724,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const fetchSlice = async (fromOffset: number): Promise => { try { - setViewer((prev) => ({ + setViewer(prev => ({ ...prev, [runId]: { content: prev[runId]?.content ?? '', @@ -703,7 +736,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr })); const slice: RunLogSlice = await workflowsApi.readRunLog(runId, fromOffset); if (cancelled) return; - setViewer((prev) => { + setViewer(prev => { const prior = prev[runId]?.content ?? ''; return { ...prev, @@ -720,7 +753,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr if (cancelled) return; const msg = err instanceof Error ? err.message : String(err); log('readRunLog error: %s', msg); - setViewer((prev) => ({ + setViewer(prev => ({ ...prev, [runId]: { content: prev[runId]?.content ?? '', @@ -757,11 +790,10 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // this effect on every viewer update would tear down and re-create the // timer on every poll. Equally, depending on `viewer` would cause // an infinite re-render loop because setViewer happens inside. - }, [expandedRunId]); const toggleExpand = useCallback((runId: string) => { - setExpandedRunId((prev) => (prev === runId ? null : runId)); + setExpandedRunId(prev => (prev === runId ? null : runId)); }, []); // ── Schedule-row actions ─────────────────────────────────────────── @@ -780,10 +812,11 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setRunBusy(true); setScheduleError(null); const inputs = Object.fromEntries( - parseScheduledInputs(job.prompt).map((i) => [i.key, i.value]) + parseScheduledInputs(job.prompt).map(i => [i.key, i.value]) ); try { log('runJobNow: running %s directly with %o', selectedSkillId, inputs); + await ensureRuntimeAvailability(); await workflowsApi.runWorkflow(selectedSkillId, inputs); scheduleRecentRunsRefresh(); releaseRunGuard(2500); @@ -794,7 +827,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr releaseRunGuard(0); } }, - [selectedSkillId, scheduleRecentRunsRefresh, releaseRunGuard] + [selectedSkillId, scheduleRecentRunsRefresh, releaseRunGuard, ensureRuntimeAvailability] ); const handleRemoveJob = useCallback( @@ -831,7 +864,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr // we get authoritative cron-run records keyed off the specific // schedule (status / duration / output stored at tick time). const loadJobHistory = useCallback(async (jobId: string) => { - setHistoryState((prev) => ({ + setHistoryState(prev => ({ ...prev, [jobId]: { runs: prev[jobId]?.runs ?? [], @@ -844,7 +877,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const res = await openhumanCronRuns(jobId, 5); const raw = (res as { result?: { runs?: CoreCronRun[] } | CoreCronRun[] }).result; const runs = Array.isArray(raw) ? raw : (raw?.runs ?? []); - setHistoryState((prev) => ({ + setHistoryState(prev => ({ ...prev, [jobId]: { runs: Array.isArray(runs) ? runs : [], @@ -856,7 +889,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr log('loaded %d history entries for job %s', Array.isArray(runs) ? runs.length : 0, jobId); } catch (err: unknown) { log('loadJobHistory error: %s', err instanceof Error ? err.message : String(err)); - setHistoryState((prev) => ({ + setHistoryState(prev => ({ ...prev, [jobId]: { runs: prev[jobId]?.runs ?? [], @@ -870,13 +903,10 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const toggleJobHistory = useCallback( (jobId: string) => { - setHistoryState((prev) => { + setHistoryState(prev => { const cur = prev[jobId]; if (cur?.expanded) { - return { - ...prev, - [jobId]: { ...cur, expanded: false }, - }; + return { ...prev, [jobId]: { ...cur, expanded: false } }; } return prev; }); @@ -889,15 +919,12 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr ); const toggleHistoryRun = useCallback((jobId: string, runId: number) => { - setHistoryState((prev) => { + setHistoryState(prev => { const cur = prev[jobId]; if (!cur) return prev; return { ...prev, - [jobId]: { - ...cur, - expandedRunId: cur.expandedRunId === runId ? null : runId, - }, + [jobId]: { ...cur, expandedRunId: cur.expandedRunId === runId ? null : runId }, }; }); }, []); @@ -917,8 +944,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr const commonLabel = ( @@ -932,11 +958,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr return (
{commonLabel} - + {desc}
); @@ -964,13 +986,12 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
- {/* Workflow header (locked) / picker (embedded). When the page is + {/* Workflow header (locked) / picker (embedded). When the page is locked to one workflow, the name is the page heading with Edit beside it — not a disabled-looking form field. */} -
- {locked ? ( -
-

- {selectedWorkflow?.name || selectedSkillId} -

-
- ) : ( - <> - - - - )} - {skillsError && ( -

- {t('settings.skillsRunner.error.listWorkflows')} {skillsError} -

- )} -
- - {/* Description + form */} - {selectedSkillId && ( +
+ {locked ? ( +
+

+ {selectedWorkflow?.name || selectedSkillId} +

+
+ ) : ( <> - {descLoading && ( -
- {t('settings.skillsRunner.loadingDescription')} -
- )} - {descError && ( -
- {t('settings.skillsRunner.error.describe')} {descError} -
- )} - {description && ( - <> - {/* Description + Inputs + Run + Schedule in one box — read + + + + )} + {skillsError && ( +

+ {t('settings.skillsRunner.error.listWorkflows')} {skillsError} +

+ )} +
+ + {/* Description + form */} + {selectedSkillId && ( + <> + {descLoading && ( +
+ {t('settings.skillsRunner.loadingDescription')} +
+ )} + {descError && ( +
+ {t('settings.skillsRunner.error.describe')} {descError} +
+ )} + {description && ( + <> + {/* Description + Inputs + Run + Schedule in one box — read what it does, fill the inputs once, then either Run now or save a recurring schedule that snapshots them. */} -
-
-

- {description.when_to_use} -

-
+
+
+

+ {description.when_to_use} +

+
{description.inputs.length === 0 ? (

@@ -1122,13 +1140,11 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr ? (formValues.fork_owner as string) : '', }} - onPatchInputs={(patch) => - setFormValues((prev) => ({ ...prev, ...patch })) - } + onPatchInputs={patch => setFormValues(prev => ({ ...prev, ...patch }))} /> )} {description.inputs - .filter((inp) => { + .filter(inp => { // When the smart picker is mounted, hide the // inputs it manages — the picker already drives // them via onPatchInputs and the user shouldn't @@ -1143,11 +1159,9 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr } return true; }) - .map((inp) => - renderField( - inp, - formValues[inp.name] ?? defaultForType(inp.type), - (next) => setFormValues((prev) => ({ ...prev, [inp.name]: next })) + .map(inp => + renderField(inp, formValues[inp.name] ?? defaultForType(inp.type), next => + setFormValues(prev => ({ ...prev, [inp.name]: next })) ) )}

@@ -1159,9 +1173,10 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr )} @@ -1186,86 +1200,81 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr {t('settings.skillsRunner.started')} {run.result.run_id}

- {t('settings.skillsRunner.logPath')}{' '} - {run.result.log} + {t('settings.skillsRunner.logPath')} {run.result.log}

)} - {run.status === 'error' && (() => { - // Detect the `[preflight::] ` shape - // emitted by spawn_skill_run_background's preflight - // branch (src/openhuman/skills/preflight.rs). When - // matched, surface a dedicated "Preflight gate - // failed" pill above the body so the user knows - // this isn't a generic crash — there's a concrete - // remediation the body describes. - const parsed = parseWorkflowRunError(run.message); - const isGateFailure = isGithubGateFailure(parsed); - return ( -
- {isGateFailure && ( -
- {t('settings.skillsRunner.error.preflightGate')} - {parsed.tag ? ( - - {parsed.tag} - - ) : null} -
- )} -

- {isGateFailure - ? parsed.body - : `${t('settings.skillsRunner.error.run')} ${run.message ?? ''}`} -

-
- ); - })()} + {run.status === 'error' && + (() => { + // Detect the `[preflight::] ` shape + // emitted by spawn_skill_run_background's preflight + // branch (src/openhuman/skills/preflight.rs). When + // matched, surface a dedicated "Preflight gate + // failed" pill above the body so the user knows + // this isn't a generic crash — there's a concrete + // remediation the body describes. + const parsed = parseWorkflowRunError(run.message); + const isGateFailure = isGithubGateFailure(parsed); + return ( +
+ {isGateFailure && ( +
+ {t('settings.skillsRunner.error.preflightGate')} + {parsed.tag ? ( + + {parsed.tag} + + ) : null} +
+ )} +

+ {isGateFailure + ? parsed.body + : `${t('settings.skillsRunner.error.run')} ${run.message ?? ''}`} +

+
+ ); + })()}
- {/* Same inputs, second action: run it on a schedule. */} -
-

- {t('settings.skillsRunner.schedule.heading')} -

-
-
- - -
- + {/* Same inputs, second action: run it on a schedule. */} +
+

+ {t('settings.skillsRunner.schedule.heading')} +

+
+
+ +
+ +
{scheduleSaved && (

@@ -1277,26 +1286,26 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr {t('settings.skillsRunner.schedule.error')} {scheduleError}

)} -
+
- {/* Second box — saved schedules for this workflow. */} -
- {/* Existing scheduled jobs for this skill */} - {scheduledJobsLoading ? ( -

- {t('settings.skillsRunner.schedule.loadingJobs')} -

- ) : scheduledJobs.length === 0 ? ( -

- {t('settings.skillsRunner.schedule.noJobs')} -

- ) : ( -
-
- {t('settings.skillsRunner.schedule.existing')} -
- {/* Per-skill saved-schedule list — uses the shared + {/* Second box — saved schedules for this workflow. */} +
+ {/* Existing scheduled jobs for this skill */} + {scheduledJobsLoading ? ( +

+ {t('settings.skillsRunner.schedule.loadingJobs')} +

+ ) : scheduledJobs.length === 0 ? ( +

+ {t('settings.skillsRunner.schedule.noJobs')} +

+ ) : ( +
+
+ {t('settings.skillsRunner.schedule.existing')} +
+ {/* Per-skill saved-schedule list — uses the shared ScheduledCronCard so the runner and the global /skills dashboard render the same polished card chrome (toggle + cronToHuman + last/next run). @@ -1307,278 +1316,263 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr `scheduled-job--toggle` (switch); the history pieces below keep their own testids (`history-toggle-`, `history-run--`). */} - {sortedScheduledJobs.map((job) => { - const hist = historyState[job.id]; - const isActive = job.id === activeJobId; - // Each job's own inputs, recovered from the prompt it - // was created with. - const jobInputs = parseScheduledInputs(job.prompt); - return ( - void handleToggleJob(job)} - testIdRoot={`scheduled-job-${job.id}`} - actions={ - <> - - - - } - > - {/* The inputs this schedule was created with — + {sortedScheduledJobs.map(job => { + const hist = historyState[job.id]; + const isActive = job.id === activeJobId; + // Each job's own inputs, recovered from the prompt it + // was created with. + const jobInputs = parseScheduledInputs(job.prompt); + return ( + void handleToggleJob(job)} + testIdRoot={`scheduled-job-${job.id}`} + actions={ + <> + + + + }> + {/* The inputs this schedule was created with — each job keeps its own snapshot, so the card shows exactly what this schedule runs with. */} -
- {jobInputs.length > 0 ? ( -
- - {t('settings.skillsRunner.schedule.inputsLabel')} - - {jobInputs.map((inp) => ( - - {inp.key}: {inp.value} - - ))} -
- ) : ( - - {t('settings.skillsRunner.schedule.inputsNone')} +
+ {jobInputs.length > 0 ? ( +
+ + {t('settings.skillsRunner.schedule.inputsLabel')} - )} -
- {/* Per-job run history (lazy on first expand). + {jobInputs.map(inp => ( + + {inp.key}: {inp.value} + + ))} +
+ ) : ( + + {t('settings.skillsRunner.schedule.inputsNone')} + + )} +
+ {/* Per-job run history (lazy on first expand). Ports DevWorkflowPanel:591-645's pattern: a disclosure toggle reveals up to 5 runs each with status badge + duration; click a run to expand its captured output. */} -
- - {hist?.expanded && ( -
- {hist.loading && hist.runs.length === 0 ? ( -

- {t('settings.skillsRunner.schedule.historyLoading')} -

- ) : hist.runs.length === 0 ? ( -

- {t('settings.skillsRunner.schedule.historyEmpty')} -

- ) : ( - hist.runs.map((r) => { - const open = hist.expandedRunId === r.id; - const okClass = - r.status === 'ok' - ? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300' - : 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'; - return ( -
- + {hist?.expanded && ( +
+ {hist.loading && hist.runs.length === 0 ? ( +

+ {t('settings.skillsRunner.schedule.historyLoading')} +

+ ) : hist.runs.length === 0 ? ( +

+ {t('settings.skillsRunner.schedule.historyEmpty')} +

+ ) : ( + hist.runs.map(r => { + const open = hist.expandedRunId === r.id; + const okClass = + r.status === 'ok' + ? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300' + : 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'; + return ( +
+ - {open && r.output && ( -
-                                              {r.output}
-                                            
- )} - {open && !r.output && ( -
- {t('settings.skillsRunner.schedule.historyNoOutput')} -
- )} -
- ); - }) - )} -
- )} -
- - ); - })} + )} + + {r.status} + +
+ + {open && r.output && ( +
+                                            {r.output}
+                                          
+ )} + {open && !r.output && ( +
+ {t('settings.skillsRunner.schedule.historyNoOutput')} +
+ )} +
+ ); + }) + )} +
+ )} +
+ + ); + })} +
+ )} +
+ + )} + + )} + + {/* Recent runs (cross-skill if no skill picked; otherwise scoped) */} +
+
+

+ {selectedSkillId + ? t('settings.skillsRunner.recentRuns.headingForSkill') + : t('settings.skillsRunner.recentRuns.headingAll')} +

+ +
+ {recentRunsLoading ? ( +

+ {t('settings.skillsRunner.recentRuns.loading')} +

+ ) : recentRuns.length === 0 ? ( +

+ {t('settings.skillsRunner.recentRuns.empty')} +

+ ) : ( +
+ {recentRuns.map(r => { + const badgeClass = (() => { + if (r.status === 'RUNNING') + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + if (r.status === 'DONE') + return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'; + if (r.status === 'DEGENERATE') + return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'; + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + })(); + const dur = r.duration_ms !== null ? `${Math.round(r.duration_ms / 1000)}s` : '—'; + const expanded = expandedRunId === r.run_id; + const v = viewer[r.run_id]; + return ( +
+ + + {r.status === 'RUNNING' && ( +
+ +
+ )} + + {expanded && ( +
+ {/* Live indicator while tailing */} + {!v?.complete && ( +
+ + + {t('settings.skillsRunner.viewer.tailing')} + {v?.loading ? ` · ${t('settings.skillsRunner.viewer.fetching')}` : ''} + + + {v?.offset ?? 0} B + +
+ )} + {v?.error && ( +
+ {t('settings.skillsRunner.viewer.error')} {v.error} +
+ )} +
+                        {v?.content ??
+                          (v?.loading ? t('settings.skillsRunner.viewer.loading') : '')}
+                      
)}
- - )} - - )} - - {/* Recent runs (cross-skill if no skill picked; otherwise scoped) */} -
-
-

- {selectedSkillId - ? t('settings.skillsRunner.recentRuns.headingForSkill') - : t('settings.skillsRunner.recentRuns.headingAll')} -

- + ); + })}
- {recentRunsLoading ? ( -

- {t('settings.skillsRunner.recentRuns.loading')} -

- ) : recentRuns.length === 0 ? ( -

- {t('settings.skillsRunner.recentRuns.empty')} -

- ) : ( -
- {recentRuns.map((r) => { - const badgeClass = (() => { - if (r.status === 'RUNNING') - return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; - if (r.status === 'DONE') - return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'; - if (r.status === 'DEGENERATE') - return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'; - return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; - })(); - const dur = r.duration_ms !== null ? `${Math.round(r.duration_ms / 1000)}s` : '—'; - const expanded = expandedRunId === r.run_id; - const v = viewer[r.run_id]; - return ( -
- - - {r.status === 'RUNNING' && ( -
- -
- )} - - {expanded && ( -
- {/* Live indicator while tailing */} - {!v?.complete && ( -
- - - {t('settings.skillsRunner.viewer.tailing')} - {v?.loading ? ` · ${t('settings.skillsRunner.viewer.fetching')}` : ''} - - - {v?.offset ?? 0} B - -
- )} - {v?.error && ( -
- {t('settings.skillsRunner.viewer.error')} {v.error} -
- )} -
-                          {v?.content ?? (v?.loading ? t('settings.skillsRunner.viewer.loading') : '')}
-                        
-
- )} -
- ); - })} -
- )} -
+ )} +
{/* Edit-this-workflow modal (locked view only). Refreshes metadata + inputs on save. */} @@ -1590,7 +1584,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr setEditOpen(false); void workflowsApi .listWorkflows() - .then((list) => setSkills(list.filter((s) => s.id !== 'codegraph-smoke'))) + .then(list => setSkills(list.filter(s => s.id !== 'codegraph-smoke'))) .catch(() => {}); void workflowsApi .describeWorkflow(selectedSkillId) diff --git a/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx b/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx index df724fcc8..e2110b431 100644 --- a/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx +++ b/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../../services/api/skillRegistryApi', () => ({ browse: vi.fn(), search: vi.fn(), sources: vi.fn(), + categories: vi.fn(), install: vi.fn(), }, })); @@ -53,14 +54,27 @@ const MOCK_CATALOG_ENTRY: CatalogEntry = { id: 'registry-skill-1', name: 'Registry Skill', description: 'A skill from the registry', - format: 'hermes', + source: 'built-in', + category: 'productivity', author: 'Registry Author', version: '2.0.0', tags: ['registry', 'remote'], + platforms: [], download_url: 'https://example.com/SKILL.md', - source_id: 'openhuman-community', - stars: 42, - updated_at: '2026-01-01', + docs_path: null, + commands: [], + env_vars: [], + license: 'MIT', +}; + +const MOCK_DOCKER_ENTRY: CatalogEntry = { + ...MOCK_CATALOG_ENTRY, + id: 'docker-manager', + name: 'Docker Manager', + description: 'Manage Docker containers and images', + source: 'skills.sh', + category: 'devops', + tags: ['docker', 'containers'], }; async function switchToInstalled() { @@ -70,6 +84,27 @@ async function switchToInstalled() { }); } +const MOCK_LEGACY_SKILL: WorkflowSummary = { + ...MOCK_SKILL, + id: 'legacy-skill', + name: 'Legacy Skill', + scope: 'user', + legacy: true, + sourceFormat: 'legacy', +}; + +const MOCK_CATALOG_ENTRY_WITH_META: CatalogEntry = { + ...MOCK_CATALOG_ENTRY, + id: 'full-meta-skill', + name: 'Full Meta Skill', + source: 'ClawHub', + version: '3.1.0', + author: 'Meta Author', + license: 'Apache-2.0', + tags: ['tag1', 'tag2', 'tag3'], + download_url: 'https://clawhub.io/skills/full-meta', +}; + describe('SkillsExplorerTab', () => { beforeEach(async () => { const { workflowsApi } = await import('../../../services/api/workflowsApi'); @@ -79,8 +114,12 @@ describe('SkillsExplorerTab', () => { vi.mocked(workflowsApi.listWorkflows).mockReset(); vi.mocked(workflowsApi.uninstallWorkflow).mockReset(); vi.mocked(skillRegistryApi.browse).mockReset(); + vi.mocked(skillRegistryApi.search).mockReset(); vi.mocked(skillRegistryApi.install).mockReset(); + vi.mocked(skillRegistryApi.sources).mockReset(); vi.mocked(skillRegistryApi.browse).mockResolvedValue([]); + vi.mocked(skillRegistryApi.search).mockResolvedValue([]); + vi.mocked(skillRegistryApi.sources).mockResolvedValue([]); }); it('defaults to registry view and shows catalog entries', async () => { @@ -96,8 +135,37 @@ describe('SkillsExplorerTab', () => { await waitFor(() => { expect(screen.getByText('Registry Skill')).toBeInTheDocument(); }); - expect(screen.getByText('Hermes')).toBeInTheDocument(); - expect(screen.getByText('openhuman-community')).toBeInTheDocument(); + expect(screen.getByText('built-in')).toBeInTheDocument(); + }); + + it('searches catalog via RPC when typing in search box', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import( + '../../../services/api/skillRegistryApi' + ); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + vi.mocked(skillRegistryApi.search).mockResolvedValue([MOCK_DOCKER_ENTRY]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Registry Skill')).toBeInTheDocument(); + }); + + const searchInput = screen.getByTestId('skill-search-input'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'docker' } }); + }); + + // Wait for the debounce to fire and the RPC search to be called + await waitFor(() => { + expect(skillRegistryApi.search).toHaveBeenCalledWith('docker', undefined); + }, { timeout: 2000 }); + + await waitFor(() => { + expect(screen.getByText('Docker Manager')).toBeInTheDocument(); + }); }); it('shows installed skills when switching to installed tab', async () => { @@ -264,4 +332,479 @@ describe('SkillsExplorerTab', () => { 'Install from URL' ); }); + + it('shows "no results" when installed skills exist but search has no matches', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]); + + render(); + await switchToInstalled(); + + await waitFor(() => { + expect(screen.getByText('Test Skill')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search skills...'); + fireEvent.change(searchInput, { target: { value: 'xyznotfound999' } }); + + await waitFor(() => { + expect(screen.queryByText('Test Skill')).not.toBeInTheDocument(); + }); + }); + + it('opens skill detail dialog when a skill tile is clicked', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]); + + render(); + await switchToInstalled(); + + await waitFor(() => { + expect(screen.getByTestId('skill-explorer-tile-test-skill')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('skill-explorer-tile-test-skill')); + }); + + // The detail dialog should appear with the skill name + await waitFor(() => { + expect(screen.getAllByText('Test Skill').length).toBeGreaterThan(1); + }); + }); + + it('activates skill tile on Enter key and Space key', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]); + + render(); + await switchToInstalled(); + + const tile = await screen.findByTestId('skill-explorer-tile-test-skill'); + + // Enter opens detail + await act(async () => { + fireEvent.keyDown(tile, { key: 'Enter' }); + }); + await waitFor(() => { + expect(screen.getAllByText('Test Skill').length).toBeGreaterThan(1); + }); + }); + + it('opens catalog entry detail dialog when a registry tile is clicked', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY_WITH_META]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('registry-tile-full-meta-skill')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('registry-tile-full-meta-skill')); + }); + + // Detail dialog shows the entry's name and metadata + await waitFor(() => { + expect(screen.getAllByText('Full Meta Skill').length).toBeGreaterThan(1); + }); + // Should show version, author, license + expect(screen.getByText('v3.1.0')).toBeInTheDocument(); + expect(screen.getAllByText('Meta Author').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Apache-2.0')).toBeInTheDocument(); + // Download URL + expect(screen.getByText('https://clawhub.io/skills/full-meta')).toBeInTheDocument(); + }); + + it('closes skill detail dialog when overlay is clicked', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]); + + render(); + await switchToInstalled(); + + const tile = await screen.findByTestId('skill-explorer-tile-test-skill'); + await act(async () => { + fireEvent.click(tile); + }); + + // Dialog open — find the backdrop and click it + await waitFor(() => { + // The close button (×) should be visible + expect(screen.getByRole('button', { name: '' })).toBeInTheDocument(); + }); + + // Click the ×-close button inside the dialog header + const closeBtns = screen.getAllByRole('button'); + const closeBtn = closeBtns.find(b => { + const svg = b.querySelector('svg'); + return svg !== null && b.closest('[class*="fixed inset-0"]') !== null; + }); + if (closeBtn) { + await act(async () => { + fireEvent.click(closeBtn); + }); + } + }); + + it('shows install button in detail dialog footer for non-installed registry entry', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + + render(); + + // Click tile to open detail dialog + const tile = await screen.findByTestId('registry-tile-registry-skill-1'); + await act(async () => { + fireEvent.click(tile); + }); + + // The dialog should show the skill name (header) and description + await waitFor(() => { + expect(screen.getAllByText('Registry Skill').length).toBeGreaterThan(1); + }); + // The detail dialog shows a second install button in the footer (in addition to the tile's button) + const installBtns = screen.getAllByRole('button', { name: 'Install' }); + expect(installBtns.length).toBeGreaterThanOrEqual(2); + }); + + it('calls skillRegistryApi.install when the install button in registry tile is clicked', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + const onToast = vi.fn(); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + vi.mocked(skillRegistryApi.install).mockResolvedValue({ + url: 'https://example.com/SKILL.md', + stdout: 'ok', + stderr: '', + newSkills: ['registry-skill-1'], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('registry-install-registry-skill-1')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('registry-install-registry-skill-1')); + }); + + await waitFor(() => { + expect(skillRegistryApi.install).toHaveBeenCalledWith('registry-skill-1'); + }); + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }) + ); + }); + }); + + it('shows error toast when registry install fails', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + const onToast = vi.fn(); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + vi.mocked(skillRegistryApi.install).mockRejectedValue(new Error('Install failed')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('registry-install-registry-skill-1')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('registry-install-registry-skill-1')); + }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }) + ); + }); + }); + + it('shows source toggle buttons when sources are available', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.sources).mockResolvedValue(['built-in', 'ClawHub']); + // No catalog entries so "built-in" only appears in the toggle buttons + vi.mocked(skillRegistryApi.browse).mockResolvedValue([]); + + render(); + + // Source toggles are rendered as buttons with their source name text + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + const sourceButtons = buttons.filter(b => b.textContent === 'built-in' || b.textContent === 'ClawHub'); + expect(sourceButtons.length).toBe(2); + }); + }); + + it('deselecting a source filter triggers search with single active source', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.sources).mockResolvedValue(['built-in', 'ClawHub']); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([]); + vi.mocked(skillRegistryApi.search).mockResolvedValue([]); + + render(); + + // Wait for sources to load and buttons to appear + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + expect(buttons.some(b => b.textContent === 'ClawHub')).toBe(true); + }); + + // Deselect 'ClawHub' — only 'built-in' remains active → triggers search with source filter + const clawhubBtn = screen.getAllByRole('button').find(b => b.textContent === 'ClawHub')!; + await act(async () => { + fireEvent.click(clawhubBtn); + }); + + await waitFor(() => { + expect(skillRegistryApi.search).toHaveBeenCalledWith('', 'built-in'); + }); + }); + + it('shows catalog count in Registry tab badge when entries exist', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY, MOCK_DOCKER_ENTRY]); + + render(); + + await waitFor(() => { + // The catalog count badge (2) should appear in the Registry tab + expect(screen.getByText('2')).toBeInTheDocument(); + }); + }); + + it('shows installed skill count in Installed tab badge', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]); + + render(); + + await waitFor(() => { + // The skills count badge (2) should appear in the Installed tab + expect(screen.getByText('2')).toBeInTheDocument(); + }); + }); + + it('shows legacy scope badge for legacy skills', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_LEGACY_SKILL]); + + render(); + await switchToInstalled(); + + await waitFor(() => { + expect(screen.getByText('Legacy Skill')).toBeInTheDocument(); + }); + // legacy scope badge should show + expect(screen.getAllByText('Legacy').length).toBeGreaterThanOrEqual(1); + }); + + it('displays SkillFormatBadge with fallback label for unknown format', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const unknownFormatSkill = { ...MOCK_SKILL, id: 'unk-skill', name: 'Unknown Format Skill', sourceFormat: 'unknown-format' }; + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([unknownFormatSkill]); + + render(); + await switchToInstalled(); + + await waitFor(() => { + expect(screen.getByText('Unknown Format Skill')).toBeInTheDocument(); + }); + // The badge renders the raw format string for unknown formats + expect(screen.getByText('unknown-format')).toBeInTheDocument(); + }); + + it('shows empty registry state when catalog returns no results', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([]); + + render(); + + await waitFor(() => { + // Empty registry state shows its title (i18n key: skills.explorer.registryEmptyTitle) + expect(screen.getByText('No registry entries')).toBeInTheDocument(); + }); + }); + + it('retry button on error retriggers catalog fetch', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse) + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValue([MOCK_CATALOG_ENTRY]); + + render(); + + await waitFor(() => { + expect(screen.getByText('timeout')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Try again/ })); + }); + + await waitFor(() => { + expect(screen.getByText('Registry Skill')).toBeInTheDocument(); + }); + }); + + it('retry button on installed view error retriggers skills fetch', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + vi.mocked(workflowsApi.listWorkflows) + .mockRejectedValueOnce(new Error('skills fetch failed')) + .mockResolvedValue([MOCK_SKILL]); + + render(); + await switchToInstalled(); + + await waitFor(() => { + expect(screen.getByText('skills fetch failed')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Try again/ })); + }); + + await waitFor(() => { + expect(screen.getByText('Test Skill')).toBeInTheDocument(); + }); + }); + + it('refresh button triggers force-refresh catalog fetch', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Registry Skill')).toBeInTheDocument(); + }); + + const callsBefore = vi.mocked(skillRegistryApi.browse).mock.calls.length; + + const refreshBtn = screen.getByTitle('Refresh registry'); + await act(async () => { + fireEvent.click(refreshBtn); + }); + + await waitFor(() => { + expect(vi.mocked(skillRegistryApi.browse).mock.calls.length).toBeGreaterThan(callsBefore); + }); + // The last browse call should use forceRefresh=true + const calls = vi.mocked(skillRegistryApi.browse).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0]).toBe(true); + }); + + it('sorts hermes skills before non-hermes in installed view', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const alphaSkill = { + ...MOCK_SKILL, + id: 'alpha', + name: 'Alpha Skill', + sourceFormat: 'openhuman', + }; + const hermesSkill = { + ...MOCK_SKILL, + id: 'hermes', + name: 'Hermes Skill', + sourceFormat: 'hermes', + }; + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([alphaSkill, hermesSkill]); + + render(); + await switchToInstalled(); + + await waitFor(() => { + expect(screen.getByText('Hermes Skill')).toBeInTheDocument(); + }); + + const allTiles = screen.getAllByRole('button').filter( + el => el.getAttribute('data-testid')?.startsWith('skill-explorer-tile') + ); + // Hermes should come first + expect(allTiles[0]).toHaveAttribute('data-testid', 'skill-explorer-tile-hermes'); + expect(allTiles[1]).toHaveAttribute('data-testid', 'skill-explorer-tile-alpha'); + }); + + it('activates catalog tile on Enter key', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + + render(); + + const tile = await screen.findByTestId('registry-tile-registry-skill-1'); + + await act(async () => { + fireEvent.keyDown(tile, { key: 'Enter' }); + }); + + // Detail dialog opens + await waitFor(() => { + expect(screen.getAllByText('Registry Skill').length).toBeGreaterThan(1); + }); + }); + + it('detail dialog install button (footer) triggers install', async () => { + const { workflowsApi } = await import('../../../services/api/workflowsApi'); + const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi'); + const onToast = vi.fn(); + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]); + // Override the beforeEach mock so browse returns an entry + vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]); + vi.mocked(skillRegistryApi.install).mockResolvedValue({ + url: 'https://example.com/SKILL.md', + stdout: 'ok', + stderr: '', + newSkills: [], + }); + + render(); + + // Wait for tile to appear, then open detail dialog + const tile = await screen.findByTestId('registry-tile-registry-skill-1'); + await act(async () => { + fireEvent.click(tile); + }); + + // Wait for dialog to open (name appears twice — tile + dialog header) + await waitFor(() => { + expect(screen.getAllByText('Registry Skill').length).toBeGreaterThan(1); + }); + + // The dialog footer has an extra Install button — click the last one (the footer button) + const installBtns = screen.getAllByRole('button', { name: 'Install' }); + const dialogInstallBtn = installBtns[installBtns.length - 1]; + await act(async () => { + fireEvent.click(dialogInstallBtn); + }); + + await waitFor(() => { + expect(skillRegistryApi.install).toHaveBeenCalledWith('registry-skill-1'); + }); + }); }); diff --git a/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx b/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx index c3a8992be..45d6d8f3c 100644 --- a/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx +++ b/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx @@ -38,6 +38,7 @@ const hoisted = vi.hoisted(() => ({ recentRuns: vi.fn(), readRunLog: vi.fn(), cancelRun: vi.fn(), + resolveRuntimes: vi.fn(), describeSkillList: vi.fn(), })); @@ -58,6 +59,7 @@ vi.mock('../../../services/api/workflowsApi', () => ({ recentRuns: hoisted.recentRuns, readRunLog: hoisted.readRunLog, cancelRun: hoisted.cancelRun, + resolveRuntimes: hoisted.resolveRuntimes, }, })); @@ -79,7 +81,7 @@ vi.mock('../inputs/RepoPicker', () => ({ data-testid="repo-picker-stub" id={props.id} value={props.value} - onChange={(e) => props.onChange(e.target.value)} + onChange={e => props.onChange(e.target.value)} /> ), })); @@ -89,7 +91,7 @@ vi.mock('../inputs/BranchPicker', () => ({ data-testid="branch-picker-stub" id={props.id} value={props.value} - onChange={(e) => props.onChange(e.target.value)} + onChange={e => props.onChange(e.target.value)} /> ), })); @@ -156,11 +158,12 @@ function renderBody(Body: React.ComponentType, initialPath = '/workflows/run') { describe('WorkflowRunnerBody — saved-schedule toggle', () => { beforeEach(() => { - Object.values(hoisted).forEach((fn) => fn.mockReset()); + Object.values(hoisted).forEach(fn => fn.mockReset()); hoisted.listWorkflows.mockResolvedValue(skillsList); hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); + hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] }); hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] }); hoisted.cronUpdate.mockResolvedValue({ result: makeJob({ enabled: false }) }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } }); @@ -261,10 +264,11 @@ function makeRun( describe('WorkflowRunnerBody — per-job history viewer', () => { beforeEach(() => { - Object.values(hoisted).forEach((fn) => fn.mockReset()); + Object.values(hoisted).forEach(fn => fn.mockReset()); hoisted.listWorkflows.mockResolvedValue(skillsList); hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); + hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] }); hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [makeRun(1), makeRun(2)] } }); }); @@ -285,7 +289,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { expect(screen.getByTestId('history-run-job-1-2')).toBeInTheDocument(); }); - it("expands a run row to show its captured output, hides on collapse", async () => { + it('expands a run row to show its captured output, hides on collapse', async () => { const Body = await importBody(); renderBody(Body); await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); @@ -319,11 +323,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { enabled: true, last_run: '2026-05-29T10:00:00Z', }), - makeJob({ - id: 'job-paused', - name: `skill-run-${SKILL_ID}-paused`, - enabled: false, - }), + makeJob({ id: 'job-paused', name: `skill-run-${SKILL_ID}-paused`, enabled: false }), ]; hoisted.cronList.mockResolvedValue({ result: jobs }); @@ -350,7 +350,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { // card emits a number of helper testids (`*-toggle`, `*-open`, // etc.) prefixed with the same root — narrow the regex to just // the card root by anchoring on a job-id pattern. - const rows = ['job-recent-enabled', 'job-old-enabled', 'job-paused'].map((id) => + const rows = ['job-recent-enabled', 'job-old-enabled', 'job-paused'].map(id => screen.getByTestId(`scheduled-job-${id}`) ); expect(rows[0]).toHaveAttribute('data-active', 'true'); @@ -358,10 +358,10 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { expect(rows[2]).toHaveAttribute('data-active', 'false'); // Confirm DOM order by walking the parent's children. const parent = rows[0].parentElement!; - const cardChildren = Array.from(parent.children).filter((el) => + const cardChildren = Array.from(parent.children).filter(el => el.getAttribute('data-testid')?.startsWith('scheduled-job-job-') ); - expect(cardChildren.map((el) => el.getAttribute('data-testid'))).toEqual([ + expect(cardChildren.map(el => el.getAttribute('data-testid'))).toEqual([ 'scheduled-job-job-recent-enabled', 'scheduled-job-job-old-enabled', 'scheduled-job-job-paused', @@ -369,9 +369,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { }); it('does not show an Active badge when no schedules are enabled', async () => { - hoisted.cronList.mockResolvedValue({ - result: [makeJob({ enabled: false })], - }); + hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: false })] }); const Body = await importBody(); renderBody(Body); await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); @@ -402,10 +400,11 @@ describe('WorkflowRunnerBody — per-job history viewer', () => { describe('WorkflowRunnerBody — schedule frequency + save', () => { beforeEach(() => { - Object.values(hoisted).forEach((fn) => fn.mockReset()); + Object.values(hoisted).forEach(fn => fn.mockReset()); hoisted.listWorkflows.mockResolvedValue(skillsList); hoisted.describeWorkflow.mockResolvedValue(skillDescription); hoisted.recentRuns.mockResolvedValue([]); + hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] }); hoisted.cronList.mockResolvedValue({ result: [] }); hoisted.cronAdd.mockResolvedValue({ result: makeJob() }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } }); @@ -437,8 +436,9 @@ describe('WorkflowRunnerBody — schedule frequency + save', () => { describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { beforeEach(() => { - Object.values(hoisted).forEach((fn) => fn.mockReset()); + Object.values(hoisted).forEach(fn => fn.mockReset()); hoisted.recentRuns.mockResolvedValue([]); + hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] }); hoisted.cronList.mockResolvedValue({ result: [] }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } }); }); @@ -501,7 +501,7 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => { describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { beforeEach(() => { - Object.values(hoisted).forEach((fn) => fn.mockReset()); + Object.values(hoisted).forEach(fn => fn.mockReset()); hoisted.listWorkflows.mockResolvedValue([ { id: 'dev-workflow', name: 'Dev Workflow' }, { id: 'github-issue-crusher', name: 'GitHub Issue Crusher' }, @@ -513,6 +513,7 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { inputs: [], }); hoisted.recentRuns.mockResolvedValue([]); + hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] }); hoisted.cronList.mockResolvedValue({ result: [] }); hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } }); }); @@ -530,9 +531,7 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { 'settings.skillsRunner.skill' )) as HTMLSelectElement; expect(select.value).toBe('dev-workflow'); - await waitFor(() => - expect(hoisted.describeWorkflow).toHaveBeenCalledWith('dev-workflow') - ); + await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('dev-workflow')); }); it('does not preselect when no ?workflow= is present', async () => { @@ -558,15 +557,11 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { renderBody(Body, '/workflows/run?workflow=does-not-exist'); await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled()); - await waitFor(() => - expect(hoisted.describeWorkflow).toHaveBeenCalledWith('does-not-exist') - ); + await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('does-not-exist')); // The dropdown value won't render as an option (not in the list), // so its current value normalises to '' visually — but the state // we care about is that the error surfaces, not crashes. - expect( - await screen.findByText(/settings.skillsRunner.error.describe/) - ).toBeInTheDocument(); + expect(await screen.findByText(/settings.skillsRunner.error.describe/)).toBeInTheDocument(); }); it('locked (lock=1): shows the workflow-name header (no picker) AND the Run button', async () => { @@ -582,13 +577,9 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => { const heading = await screen.findByTestId('skills-runner-skill-locked'); expect(heading).toHaveTextContent('Dev Workflow'); // ...so there is no