diff --git a/frontend-v2/src/features/settings/data/skill-deployments.test.tsx b/frontend-v2/src/features/settings/data/skill-deployments.test.tsx new file mode 100644 index 000000000..b43223149 --- /dev/null +++ b/frontend-v2/src/features/settings/data/skill-deployments.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, expect, it } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { http } from '@/lib/api-client' +import { fakeBackend } from '@/test/backend' +import type { SpacePlugin } from '@/api/generated.schemas' +import { marketplaceEntry, marketplaceSelection } from '../test-utils' +import { useSkillDeployments } from './skill-deployments' + +const original = http.defaults.adapter +afterEach(() => { + http.defaults.adapter = original +}) + +const wrap = ({ children }: { children: ReactNode }) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return {children} +} + +const skillSelection = (): SpacePlugin => + marketplaceSelection(marketplaceEntry('skills', { kind: 'skill' })) + +it('aggregates project receipts into a single delivery state', async () => { + const selection = skillSelection() + fakeBackend((config) => + (config.url ?? '').includes('/plugins/results/') + ? { + status: 200, + data: { + items: [ + { projectId: 'a', name: 'A', state: 'installed', installedVersion: '1.0.0', failure: '' }, + { projectId: 'b', name: 'B', state: 'applying', installedVersion: '', failure: '' }, + ], + }, + } + : { status: 404 }, + ) + const { result } = renderHook( + () => useSkillDeployments('tenant', 'space', [selection]), + { wrapper: wrap }, + ) + await waitFor(() => { + const summary = result.current.summaryOf(selection) + expect(summary?.aggregate).toBe('applying') + expect(summary?.states).toHaveLength(2) + }) +}) + +it('marks a failed receipt and keeps the reason', async () => { + const selection = skillSelection() + fakeBackend((config) => + (config.url ?? '').includes('/plugins/results/') + ? { + status: 200, + data: { + items: [ + { + projectId: 'a', + name: 'A', + state: 'failed', + installedVersion: '', + failure: '技能包下载失败。', + }, + ], + }, + } + : { status: 404 }, + ) + const { result } = renderHook( + () => useSkillDeployments('tenant', 'space', [selection]), + { wrapper: wrap }, + ) + await waitFor(() => { + expect(result.current.summaryOf(selection)?.aggregate).toBe('failed') + expect(result.current.summaryOf(selection)?.failure).toBe('技能包下载失败。') + }) +}) + +it('reports none when a selected Skill has no receipts', async () => { + const selection = skillSelection() + fakeBackend((config) => + (config.url ?? '').includes('/plugins/results/') + ? { status: 200, data: { items: [] } } + : { status: 404 }, + ) + const { result } = renderHook( + () => useSkillDeployments('tenant', 'space', [selection]), + { wrapper: wrap }, + ) + await waitFor(() => { + expect(result.current.summaryOf(selection)?.aggregate).toBe('none') + }) +}) diff --git a/frontend-v2/src/features/settings/data/skill-deployments.ts b/frontend-v2/src/features/settings/data/skill-deployments.ts new file mode 100644 index 000000000..8e934f47d --- /dev/null +++ b/frontend-v2/src/features/settings/data/skill-deployments.ts @@ -0,0 +1,68 @@ +import { useQuery } from '@tanstack/react-query' +import type { SpacePlugin } from '@/api/generated.schemas' +import { getSkillProgress, type SkillProgress } from '../market-api/generated' +import { pluginKey } from './marketplace' + +export type DeploymentSummary = { + states: SkillProgress[] + aggregate: 'installed' | 'applying' | 'failed' | 'waiting' | 'removed' | 'none' + failure?: string +} + +const aggregate = (items: SkillProgress[]): DeploymentSummary['aggregate'] => { + if (items.length === 0) return 'none' + if (items.some((item) => item.state === 'failed')) return 'failed' + if (items.every((item) => item.state === 'installed')) return 'installed' + if (items.every((item) => item.state === 'removed')) return 'removed' + if (items.some((item) => item.state === 'applying')) return 'applying' + return 'waiting' +} + +/** + * Reads each selected Skill's per-project delivery receipts. A selection alone does not prove the + * package reached any project, so the Skills page and the workflow Agent picker can show what has + * actually been delivered, what is still in flight, and what failed. + */ +export function useSkillDeployments( + tenant: string, + space: string, + selections: readonly SpacePlugin[], +) { + const selected = selections.filter( + (selection) => selection.kind === 'skill' && selection.desiredState === 'installed', + ) + const query = useQuery({ + queryKey: ['skill-deployments', tenant, space, selected.map(pluginKey).join('|')], + enabled: tenant !== '' && space !== '' && selected.length > 0, + refetchInterval: 3000, + queryFn: async () => { + const rows = await Promise.all( + selected.map(async (selection) => { + const { items } = await getSkillProgress( + tenant, + space, + selection.sourceNamespace, + selection.identifier, + ) + return [pluginKey(selection), items] as const + }), + ) + return new Map(rows) + }, + }) + const byKey = query.data ?? new Map() + return { + byKey, + /** The aggregate delivery state for one selection; `undefined` while the receipt is unknown. */ + summaryOf: (selection: SpacePlugin): DeploymentSummary | undefined => { + const items = byKey.get(pluginKey(selection)) + if (items === undefined) return undefined + const failed = items.find((item) => item.state === 'failed') + return { + states: items, + aggregate: aggregate(items), + failure: failed?.failure, + } + }, + } +} diff --git a/frontend-v2/src/features/settings/skills/page.test.tsx b/frontend-v2/src/features/settings/skills/page.test.tsx index f5a7840d9..ab2d647d4 100644 --- a/frontend-v2/src/features/settings/skills/page.test.tsx +++ b/frontend-v2/src/features/settings/skills/page.test.tsx @@ -120,7 +120,9 @@ it('installs and removes the source plugin from the skill row and keeps the docu const row = await screen.findByRole('article', { name: 'writing-guide' }) expect(within(row).getByText('来源插件未安装')).toBeInTheDocument() await user.click(within(row).getByRole('button', { name: '安装技能' })) - await waitFor(() => expect(within(row).getByText('可用')).toBeInTheDocument()) + await waitFor(() => + expect(within(row).getByText('等待下发')).toBeInTheDocument(), + ) expect( backend.state.selections.find( (selection) => selection.identifier === 'writing-kit', @@ -146,8 +148,8 @@ it('installs and removes the source plugin from the skill row and keeps the docu ).toHaveAttribute('href', '/settings/plugins?plugin=demo%2Fwriting-kit') }) -it('treats a selected market skill as available without a project node receipt', async () => { - installMarketplaceBackend({ +it('treats a selected market skill as waiting for delivery without a project node receipt', async () => { + const backend = installMarketplaceBackend({ catalog: [writing], selections: [ marketplaceSelection(writing, { @@ -158,7 +160,14 @@ it('treats a selected market skill as available without a project node receipt', }) renderSettingsPage() const row = await screen.findByRole('article', { name: 'writing-guide' }) - expect(within(row).getByText('可用')).toBeInTheDocument() + await waitFor(() => { + expect(backend.seen).toContain( + 'GET /api/v1/tenants/00000000-0000-4000-8000-0000000000aa/spaces/00000000-0000-4000-8000-0000000000bb/plugins/results/demo/writing-kit', + ) + }) + await waitFor(() => + expect(within(row).getByText('等待下发')).toBeInTheDocument(), + ) expect(within(row).queryByText('等待节点')).not.toBeInTheDocument() expect(within(row).getByRole('button', { name: '移除技能' })).toBeVisible() }) @@ -171,9 +180,120 @@ it('shows a member the market state without market write controls', async () => }) renderSettingsPage() const row = await screen.findByRole('article', { name: 'writing-guide' }) - expect(within(row).getByText('可用')).toBeInTheDocument() + await waitFor(() => + expect(within(row).getByText('等待下发')).toBeInTheDocument(), + ) expect( within(row).queryByRole('button', { name: '移除技能' }), ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: '新建技能' })).toBeVisible() }) + +it('shows per-project delivery results on the skill row and detail panel', async () => { + installMarketplaceBackend({ + catalog: [writing], + selections: [marketplaceSelection(writing)], + progress: [ + { + namespace: 'demo', + identifier: 'writing-kit', + results: [ + { + projectId: 'project-a', + name: '项目 A', + state: 'installed', + installedVersion: '1.0.0', + failure: '', + }, + { + projectId: 'project-b', + name: '项目 B', + state: 'applying', + installedVersion: '', + failure: '', + }, + ], + }, + ], + }) + const user = userEvent.setup() + renderSettingsPage() + const row = await screen.findByRole('article', { name: 'writing-guide' }) + await waitFor(() => + expect(within(row).getByText('下发中')).toBeInTheDocument(), + ) + await user.click( + within(row).getByRole('button', { name: '查看技能 writing-guide' }), + ) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('项目下发状态')).toBeInTheDocument() + expect(within(dialog).getByText('项目 A')).toBeInTheDocument() + expect(within(dialog).getByText('项目 B')).toBeInTheDocument() + const blockOf = (name: string) => + within(dialog).getByText(name, { exact: false }).closest('div') + expect(blockOf('项目 A')).toHaveTextContent(/已下发/) + expect(blockOf('项目 B')).toHaveTextContent(/下发中/) +}) + +it('marks a failed delivery and shows the failure reason', async () => { + installMarketplaceBackend({ + catalog: [writing], + selections: [marketplaceSelection(writing)], + progress: [ + { + namespace: 'demo', + identifier: 'writing-kit', + results: [ + { + projectId: 'project-a', + name: '项目 A', + state: 'failed', + installedVersion: '', + failure: '技能包下载失败或完整性校验未通过,请重新应用。', + }, + ], + }, + ], + }) + const user = userEvent.setup() + renderSettingsPage() + const row = await screen.findByRole('article', { name: 'writing-guide' }) + await waitFor(() => + expect(within(row).getByText('安装失败')).toBeInTheDocument(), + ) + await user.click( + within(row).getByRole('button', { name: '查看技能 writing-guide' }), + ) + const dialog = await screen.findByRole('dialog') + await waitFor(() => + expect( + within(dialog).getAllByText(/技能包下载失败或完整性校验未通过/).length, + ).toBeGreaterThan(0), + ) +}) + +it('prompts a reinstall for a legacy selection without a frozen package', async () => { + installMarketplaceBackend({ + catalog: [writing], + selections: [ + marketplaceSelection(writing, { + installError: '旧版本没有保留安装包信息,请重新安装。', + }), + ], + }) + const user = userEvent.setup() + renderSettingsPage() + const row = await screen.findByRole('article', { name: 'writing-guide' }) + await waitFor(() => + expect(within(row).getByText('安装失败')).toBeInTheDocument(), + ) + await user.click( + within(row).getByRole('button', { name: '查看技能 writing-guide' }), + ) + const dialog = await screen.findByRole('dialog') + await waitFor(() => + expect( + within(dialog).getByText(/旧版本没有保留安装包信息,请重新安装/), + ).toBeInTheDocument(), + ) +}) diff --git a/frontend-v2/src/features/settings/skills/page.tsx b/frontend-v2/src/features/settings/skills/page.tsx index 1f071442b..e8cbb2fcc 100644 --- a/frontend-v2/src/features/settings/skills/page.tsx +++ b/frontend-v2/src/features/settings/skills/page.tsx @@ -29,6 +29,8 @@ import { DetailPanel } from '../components/detail-panel' import { ConfirmDialog } from '../components/confirm-dialog' import { MarkdownPreview } from '../components/markdown-preview' import type { Skill as ServerSkill, SkillInput } from '@/lib/skills-api/generated' +import { useSkillDeployments } from '../data/skill-deployments' +import type { SkillProgress } from '../market-api/generated' import { ImportSkillDialog } from './import-skill-dialog' import { SkillForm } from './skill-form' import { useUserSkills } from './use-skills' @@ -47,18 +49,45 @@ type SkillRow = { content: string source: SkillSource availability: 'available' | 'unavailable' + /** Per-project delivery outcome for a selected market Skill; `undefined` before receipts load. */ + deployment?: SkillRowDeployment + /** Per-project receipts, shown in the detail panel; only for market Skills with a selection. */ + states?: SkillProgress[] updatedAt?: string local?: ServerSkill entry?: PluginCatalogEntry selection?: SpacePlugin } +/** Aggregates one selection's project receipts into a single visible state. */ +type SkillRowDeployment = + | { state: 'installed' } + | { state: 'applying' } + | { state: 'failed'; failure: string } + | { state: 'waiting' } + | { state: 'removed' } + type Removing = | { kind: 'local'; skill: SkillRow } | { kind: 'plugin'; skill: SkillRow } +const deploymentLabel: Record = { + installed: '已下发', + applying: '下发中', + failed: '下发失败', + waiting: '等待下发', + removed: '已移除', +} + function skillStatus(skill: SkillRow, detail = false) { - if (skill.availability === 'available') return '可用' + if (skill.deployment?.state === 'failed') { + return detail ? `安装失败:${skill.deployment.failure}` : '安装失败' + } + if (skill.availability === 'available') { + return skill.deployment === undefined + ? '可用' + : deploymentLabel[skill.deployment.state] + } return detail ? '不可用,来源插件未安装' : '来源插件未安装' } @@ -80,6 +109,7 @@ function localSkillRows(skills: ServerSkill[]): SkillRow[] { function pluginSkillRows( catalog: PluginCatalogEntry[], selection: (entry: PluginCatalogEntry) => SpacePlugin | undefined, + deployed: (entry: PluginCatalogEntry) => SkillRow['deployment'], ): SkillRow[] { return catalog .filter((entry) => entry.kind === 'skill') @@ -93,6 +123,7 @@ function pluginSkillRows( content: entry.readme ?? '', source: { kind: 'plugin', pluginId: pluginKey(entry) }, availability: chosen?.desiredState === 'installed' ? 'available' : 'unavailable', + deployment: chosen?.desiredState === 'installed' ? deployed(entry) : undefined, entry, selection: chosen, } @@ -116,6 +147,11 @@ const updatedLabel = (value?: string) => { export default function SkillsPage() { const local = useUserSkills() const market = usePluginMarketplace() + const deployments = useSkillDeployments( + market.tenantId, + market.spaceId, + market.installations, + ) const [search, setSearch] = useState('') const [source, setSource] = useState('all') const [status, setStatus] = useState('all') @@ -123,10 +159,33 @@ export default function SkillsPage() { const [panel, setPanel] = useState(null) const [removing, setRemoving] = useState() const [importing, setImporting] = useState(false) + const deploymentOf = (entry: PluginCatalogEntry): SkillRowDeployment | undefined => { + const selection = market.selection(entry) + if (!selection) return undefined + if (selection.installError) { + return { state: 'failed', failure: selection.installError } + } + const summary = deployments.summaryOf(selection) + if (!summary) return undefined + if (summary.aggregate === 'failed') return { state: 'failed', failure: summary.failure ?? '未知原因' } + if (summary.aggregate === 'applying') return { state: 'applying' } + if (summary.aggregate === 'waiting') return { state: 'waiting' } + if (summary.aggregate === 'removed') return { state: 'removed' } + if (summary.aggregate === 'installed') return { state: 'installed' } + return { state: 'waiting' } + } + const statesOf = (entry: PluginCatalogEntry): SkillProgress[] | undefined => { + const selection = market.selection(entry) + return selection ? deployments.byKey.get(pluginKey(selection)) : undefined + } const skills: SkillRow[] = [ ...localSkillRows(local.skills), - ...pluginSkillRows(market.catalog, market.selection), - ] + ...pluginSkillRows(market.catalog, market.selection, deploymentOf), + ].map((skill) => + skill.entry + ? { ...skill, states: statesOf(skill.entry) } + : skill, + ) const selected = panel && 'id' in panel ? skills.find((skill) => skill.id === panel.id) @@ -138,7 +197,13 @@ export default function SkillsPage() { .toLowerCase() .includes(search.trim().toLowerCase()) && (source === 'all' || skill.source.kind === source) && - (status === 'all' || skill.availability === status), + (status === 'all' || + (status === 'available' + ? skill.availability === 'available' && + skill.deployment?.state !== 'failed' + : status === 'failed' + ? skill.deployment?.state === 'failed' + : skill.availability === 'unavailable')), ) .sort( (a, b) => @@ -299,7 +364,8 @@ export default function SkillsPage() { onChange={setStatus} options={[ ['all', '全部状态'], - ['available', '可用'], + ['available', '可用 / 已下发'], + ['failed', '安装失败'], ['unavailable', '不可用'], ]} /> @@ -353,7 +419,11 @@ export default function SkillsPage() {
{skillStatus(skill)} @@ -492,6 +562,9 @@ export default function SkillsPage() { )} + {selected.entry && selected.selection && ( + + )} {selected.availability === 'unavailable' && (

说明仍可预览。安装来源插件后,此技能会恢复可用。 @@ -544,3 +617,41 @@ export default function SkillsPage() { ) } + +const projectStateLabel: Record = { + waiting: '等待下发', + applying: '下发中', + installed: '已下发', + removed: '已移除', + failed: '下发失败', +} + +/** Shows each bound project's delivery receipt for a selected market Skill. */ +function SkillDeploymentStates({ states }: { states?: SkillProgress[] }) { + if (!states || states.length === 0) { + return ( +

+ 当前空间还没有关联项目;运行工作流前请先添加项目并等待技能下发。 +

+ ) + } + return ( +
+

项目下发状态

+
+ {states.map((item) => ( +
+ {item.name} + · {projectStateLabel[item.state] ?? item.state} + {item.installedVersion && · v{item.installedVersion}} + {item.failure && ( +

+ {item.failure} +

+ )} +
+ ))} +
+
+ ) +} diff --git a/frontend-v2/src/features/settings/test-utils.tsx b/frontend-v2/src/features/settings/test-utils.tsx index ebcb1c531..55f6511df 100644 --- a/frontend-v2/src/features/settings/test-utils.tsx +++ b/frontend-v2/src/features/settings/test-utils.tsx @@ -11,6 +11,7 @@ import type { import type { MarketSource, MarketSourceInput, + SkillProgress, } from './market-api/generated' import type { Skill, SkillInput } from '@/lib/skills-api/generated' import { demoUser, fakeBackend } from '@/test/backend' @@ -86,18 +87,21 @@ export function installMarketplaceBackend({ selections = [], marketSources = [], skills = [], + progress = [], }: { role?: 'owner' | 'member' catalog?: PluginCatalogEntry[] selections?: SpacePlugin[] marketSources?: MarketSource[] skills?: Skill[] + progress?: { namespace: string; identifier: string; results: SkillProgress[] }[] } = {}) { const state = { catalog: [...catalog], selections: [...selections], marketSources: [...marketSources], skills: [...skills], + progress: [...progress], } const seen: string[] = [] const bodies: unknown[] = [] @@ -256,6 +260,24 @@ export function installMarketplaceBackend({ } const base = `/api/v1/tenants/${MARKET_TENANT}/spaces/${MARKET_SPACE}/plugins` const sourceBase = `${base}/sources` + const resultsBase = `${base}/results` + if (url.startsWith(resultsBase + '/') && method === 'GET') { + const [namespace, identifier] = url + .slice(resultsBase.length + 1) + .split('/') + .map(decodeURIComponent) + return { + status: 200, + data: { + items: state.progress + .filter( + (item) => + item.namespace === namespace && item.identifier === identifier, + ) + .flatMap((item) => item.results), + }, + } + } if (url === sourceBase && method === 'GET') return { status: 200, data: { items: state.marketSources } } if (url === sourceBase && method === 'POST') { diff --git a/frontend-v2/src/features/workflows/editor/workflow-editor.tsx b/frontend-v2/src/features/workflows/editor/workflow-editor.tsx index 0487530be..811c0b227 100644 --- a/frontend-v2/src/features/workflows/editor/workflow-editor.tsx +++ b/frontend-v2/src/features/workflows/editor/workflow-editor.tsx @@ -86,6 +86,7 @@ import { import type { EditorWorkflowVariable } from "./workflow-variable-display"; import { usePlatform } from "@/lib/workflows/host/platform"; import { useContractsClient } from "@/lib/workflows/data/contracts-client"; +import type { CatalogSkill } from "@/lib/workflows/data/plugin-catalog"; import { useAgents } from "@/lib/workflows/data/catalog-hooks"; import { availableSkills, useSkills } from "@/lib/workflows/data/catalog-hooks"; import { useWorkflowAgentModels } from "@/lib/workflows/data/catalog-hooks"; @@ -338,6 +339,22 @@ function unsupportedNodesMessage( }); } +/** Renders a Skill's delivery state as a one-line hint for the Agent picker. */ +function deploymentHint(deployment: { state: string; failure?: string }): string { + switch (deployment.state) { + case "installed": + return "已下发"; + case "applying": + return "下发中"; + case "failed": + return deployment.failure ? `下发失败:${deployment.failure}` : "下发失败"; + case "removed": + return "已移除"; + default: + return "等待下发"; + } +} + /** Provides one React Flow store to the canvas and its sibling inspector. */ export function WorkflowEditor(props: WorkflowEditorProps = {}) { return ( @@ -376,10 +393,14 @@ function WorkflowEditorContent({ value: agent.name, label: agent.name, })); - const skills = availableSkills(skillsQuery.data ?? []).map((skill) => ({ - value: skill.name, - label: skill.name, - })); + const skills = availableSkills(skillsQuery.data ?? []).map((skill) => { + const deployment = (skill as CatalogSkill).deployment + return { + value: skill.name, + label: skill.name, + ...(deployment ? { deployment: deploymentHint(deployment) } : {}), + } + }); const mcps = workflowMcpChoices(pluginsQuery.data ?? []); const agentModels = agentModelsCatalog.agentModels; const defaultExecutor = agentModels[0]; diff --git a/frontend-v2/src/features/workflows/editor/workflow-inspector.tsx b/frontend-v2/src/features/workflows/editor/workflow-inspector.tsx index 9b2223cc7..2df54a3d2 100644 --- a/frontend-v2/src/features/workflows/editor/workflow-inspector.tsx +++ b/frontend-v2/src/features/workflows/editor/workflow-inspector.tsx @@ -58,6 +58,7 @@ import { } from "./workflow-node-details"; import { WorkflowVariableDisplay } from "./workflow-variable-display"; import { WorkflowVariableSelectGroups } from "./workflow-variable-list"; +import { cn } from "@/lib/utils"; import { WorkflowPromptEditor } from "./workflow-prompt-editor"; import { WorkflowStructuredOutputDialog, @@ -871,7 +872,23 @@ function AgentConfigurationFields({ value={`${skill.label} ${skill.value}`} onSelect={() => addSkill(skill.value)} > - {skill.label} + + + {skill.label} + + {skill.deployment && ( + + {skill.deployment} + + )} + ))} diff --git a/frontend-v2/src/lib/workflows/data/catalog-hooks.ts b/frontend-v2/src/lib/workflows/data/catalog-hooks.ts index f99a332cf..1a2819adc 100644 --- a/frontend-v2/src/lib/workflows/data/catalog-hooks.ts +++ b/frontend-v2/src/lib/workflows/data/catalog-hooks.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import type { Skill } from "../contracts"; +import type { CatalogSkill } from "./plugin-catalog"; import type { WorkflowAgentModel } from "../mock"; import { useRoleCatalog } from "@/lib/settings-demo/role-hooks"; import { useContractsClient } from "./contracts-client"; @@ -34,7 +35,7 @@ export function useAgents() { } /** Pickers only surface skills whose on-disk package is still usable. */ -export function availableSkills(skills: readonly Skill[]): Skill[] { +export function availableSkills(skills: readonly T[]): T[] { return skills.filter((skill) => skill.availability === "available"); } @@ -43,7 +44,7 @@ export function useSkills() { const client = useContractsClient(); return useQuery({ queryKey: [...catalogKeys.skills, client.tenantId], - queryFn: () => client.skill.list({}).then((response) => response.skills), + queryFn: () => client.skill.list({}).then((response) => response.skills as CatalogSkill[]), }); } diff --git a/frontend-v2/src/lib/workflows/data/contracts-client.ts b/frontend-v2/src/lib/workflows/data/contracts-client.ts index 75fc424d4..d98be4889 100644 --- a/frontend-v2/src/lib/workflows/data/contracts-client.ts +++ b/frontend-v2/src/lib/workflows/data/contracts-client.ts @@ -183,8 +183,7 @@ export function createContractsClient(workflow: WorkflowContracts, tenantId: str availability: "available" as const, })), ...(await workflowPluginCatalog(tenantId)).skills], }), - }, - plugin: { + }, plugin: { listInstalled: async (_request: Record) => ({ plugins: (await workflowPluginCatalog(tenantId)).plugins, }), diff --git a/frontend-v2/src/lib/workflows/data/plugin-catalog.test.ts b/frontend-v2/src/lib/workflows/data/plugin-catalog.test.ts index a77d865ab..c8a26ced4 100644 --- a/frontend-v2/src/lib/workflows/data/plugin-catalog.test.ts +++ b/frontend-v2/src/lib/workflows/data/plugin-catalog.test.ts @@ -9,17 +9,40 @@ afterEach(() => { http.defaults.adapter = original }) it('offers a selected Skill before a project confirms it and keeps imported package identities', async () => { fakeBackend((config) => config.url === '/api/v1/tenants/t/spaces' ? { status: 200, data: { items: [{ id: 'space' }] } } - : { status: 200, data: { items: [ - { sourceNamespace: 'official', identifier: 'skills', kind: 'skill', desiredState: 'installed', observedState: 'installed' }, - { sourceNamespace: 'custom', identifier: 'pending', kind: 'skill', desiredState: 'installed', observedState: 'pending' }, - { sourceNamespace: 'official', identifier: 'removed', kind: 'mcp', desiredState: 'removed', observedState: 'installed' }, - { sourceNamespace: 'official', identifier: 'node-missing', kind: 'mcp', desiredState: 'installed', observedState: 'pending' }, - ] } }) + : (config.url ?? '').includes('/plugins/results/') + ? { status: 200, data: { items: [] } } + : { status: 200, data: { items: [ + { sourceNamespace: 'official', identifier: 'skills', kind: 'skill', desiredState: 'installed', observedState: 'installed' }, + { sourceNamespace: 'custom', identifier: 'pending', kind: 'skill', desiredState: 'installed', observedState: 'pending' }, + { sourceNamespace: 'official', identifier: 'removed', kind: 'mcp', desiredState: 'removed', observedState: 'installed' }, + { sourceNamespace: 'official', identifier: 'node-missing', kind: 'mcp', desiredState: 'installed', observedState: 'pending' }, + ] } }) const result = await workflowPluginCatalog('t') expect(result.skills.map((skill) => skill.name)).toEqual(['official/skills', 'custom/pending']) + expect(result.skills.map((skill) => skill.deployment)).toEqual([ + { state: 'waiting' }, + { state: 'waiting' }, + ]) expect(result.plugins).toEqual([]) }) +it('annotates a selected Skill with its project delivery failure', async () => { + fakeBackend((config) => config.url === '/api/v1/tenants/t/spaces' + ? { status: 200, data: { items: [{ id: 'space' }] } } + : (config.url ?? '').includes('/plugins/results/') + ? { status: 200, data: { items: [ + { projectId: 'project', name: '项目', state: 'failed', installedVersion: '', failure: '技能包下载失败。' }, + ] } } + : { status: 200, data: { items: [ + { sourceNamespace: 'official', identifier: 'skills', kind: 'skill', desiredState: 'installed', observedState: 'installed' }, + ] } }) + const result = await workflowPluginCatalog('t') + expect(result.skills[0].deployment).toEqual({ + state: 'failed', + failure: '技能包下载失败。', + }) +}) + it('does not substitute demo plugins on a catalog failure', async () => { fakeBackend(() => ({ status: 503 })) await expect(workflowPluginCatalog('t')).rejects.toThrow() diff --git a/frontend-v2/src/lib/workflows/data/plugin-catalog.ts b/frontend-v2/src/lib/workflows/data/plugin-catalog.ts index ac69f3d2a..a16d39f9d 100644 --- a/frontend-v2/src/lib/workflows/data/plugin-catalog.ts +++ b/frontend-v2/src/lib/workflows/data/plugin-catalog.ts @@ -3,6 +3,18 @@ import { getApiV1TenantsTidSpacesSpaceIdPlugins, } from '@/api/spaces/spaces' import type { InstalledPlugin, Skill } from '../contracts' +import { getSkillProgress } from '@/features/settings/market-api/generated' + +/** Aggregates one selected Skill's project receipts into the workflow picker's visible state. */ +export type SkillDeployment = + | { state: 'installed' } + | { state: 'applying' } + | { state: 'failed'; failure: string } + | { state: 'waiting' } + | { state: 'removed' } + +/** A Skill the workflow Agent picker offers, annotated with its delivery state. */ +export type CatalogSkill = Skill & { deployment?: SkillDeployment } /** Definitions are tenant-wide; execution rechecks the chosen project's space and receipt. */ export async function workflowPluginCatalog(tenantId: string) { @@ -12,14 +24,21 @@ export async function workflowPluginCatalog(tenantId: string) { const selected = lists.flatMap((list) => list.items).filter((item) => item.desiredState === 'installed') const unique = new Map(selected.map((item) => [`${item.sourceNamespace}/${item.identifier}`, item])) - const skills: Skill[] = [] + const skills: CatalogSkill[] = [] const plugins: InstalledPlugin[] = [] for (const [id, item] of unique) { // A Skill selection is the install, so the picker offers it without a project receipt; an MCP // server only exists once its Node reports it installed. if (item.kind === 'skill') { - skills.push({ id, namespace: item.sourceNamespace, name: id, - description: item.identifier, source: { kind: 'plugin', pluginId: id }, availability: 'available' }) + skills.push({ + id, + namespace: item.sourceNamespace, + name: id, + description: item.identifier, + source: { kind: 'plugin', pluginId: id }, + availability: 'available', + deployment: await skillDeployment(tenantId, item.spaceId, item.sourceNamespace, item.identifier), + }) } if (item.kind === 'mcp' && item.observedState === 'installed') { plugins.push({ id, namespace: item.sourceNamespace, name: item.identifier, @@ -31,3 +50,23 @@ export async function workflowPluginCatalog(tenantId: string) { } return { skills, plugins } } + +async function skillDeployment( + tenant: string, + space: string, + namespace: string, + identifier: string, +): Promise { + try { + const { items } = await getSkillProgress(tenant, space, namespace, identifier) + if (items.length === 0) return { state: 'waiting' } + if (items.some((item) => item.state === 'failed')) + return { state: 'failed', failure: items.find((item) => item.state === 'failed')?.failure ?? '' } + if (items.every((item) => item.state === 'installed')) return { state: 'installed' } + if (items.every((item) => item.state === 'removed')) return { state: 'removed' } + if (items.some((item) => item.state === 'applying')) return { state: 'applying' } + return { state: 'waiting' } + } catch { + return undefined + } +} diff --git a/frontend-v2/src/lib/workflows/mock/capabilities.ts b/frontend-v2/src/lib/workflows/mock/capabilities.ts index 7d2bde1d7..e30a84c2d 100644 --- a/frontend-v2/src/lib/workflows/mock/capabilities.ts +++ b/frontend-v2/src/lib/workflows/mock/capabilities.ts @@ -4,6 +4,8 @@ import type { WorkflowAgentConfig, WorkflowNodeKind } from "./node-data"; export interface WorkflowChoice { value: string; label: string; + /** Delivery hint shown beside a Skill candidate; kept optional so mock catalogs stay plain. */ + deployment?: string; } /** Availability is descriptive: authors can save bindings before configuring the plugin. */ diff --git a/v2-server/internal/store/plugins.go b/v2-server/internal/store/plugins.go index 8f275e29f..7d999297a 100644 --- a/v2-server/internal/store/plugins.go +++ b/v2-server/internal/store/plugins.go @@ -619,8 +619,8 @@ func uniqueWorkflowPluginName(ctx context.Context, tx pgx.Tx, tenant, base strin } // RemoveSpacePlugin records an owner's removal and settles the simplified terminal state. A Skill -// selection is deleted instead of settled, because the selection record is the only state the -// Skills page and the workflow Agent picker read. +// selection that no project ever received is deleted; one a project Node already holds is kept as a +// removed selection so the reconciler can tell the Node to remove the package (see DeleteSettledSkillRemovals). func (s *Store) RemoveSpacePlugin(ctx context.Context, tenant, space, caller, path, key string, in PluginRemoveInput) (SpacePlugin, error) { var out SpacePlugin e := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { @@ -653,22 +653,36 @@ func (s *Store) RemoveSpacePlugin(ctx context.Context, tenant, space, caller, pa return ErrPluginRuntimeManaged } if old.Kind == "skill" { - // A Skill selection is the whole install, so removal deletes the row: the Skills page and - // the workflow Agent picker drop it immediately instead of waiting for project Nodes to - // confirm a package removal. Package copies a Node already received stay local; v2 does - // not chase them. - if _, e := tx.Exec(ctx, `DELETE FROM v2_space_agents WHERE id = $1`, old.ID); e != nil { - return fmt.Errorf("remove skill selection: %w", e) + // A Skill selection is the whole install, but a project Node that already received the + // package must be told to remove it: keep the row as a removed selection so SkillJobs + // can raise a removal job and drop the market-skills.json link. When no project ever + // confirmed delivery there is nothing to notify, so the row is deleted immediately and + // the Skills page and workflow Agent picker drop it as they did before. + var outstanding bool + if e := tx.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM v2_skill_results r + WHERE r.selection_id = $1 + AND (r.installed_version IS NOT NULL OR r.state IN ('applying','failed')) + )`, old.ID).Scan(&outstanding); e != nil { + return fmt.Errorf("check skill delivery: %w", e) + } + if !outstanding { + if _, e := tx.Exec(ctx, `DELETE FROM v2_space_agents WHERE id = $1`, old.ID); e != nil { + return fmt.Errorf("remove skill selection: %w", e) + } + out = old + out.DesiredState = "removed" + out.ObservedState = "removed" + out.ObservedVersion = nil + out.InstallError = nil + out.RequestedByUserID = &caller + out.Version++ + out.UpdatedAt = time.Now() + return recordPluginMutation(ctx, tx, tenant, caller, "DELETE", path, key, pluginRemoveHash(in), out) } - out = old - out.DesiredState = "removed" - out.ObservedState = "removed" - out.ObservedVersion = nil - out.InstallError = nil - out.RequestedByUserID = &caller - out.Version++ - out.UpdatedAt = time.Now() - return recordPluginMutation(ctx, tx, tenant, caller, "DELETE", path, key, pluginRemoveHash(in), out) + // A project Node still holds the package: fall through to the generic removal state and + // let the skill reconciler remove it, then the settled row is cleaned up. } if _, e := tx.Exec(ctx, ` WITH removal_state AS ( diff --git a/v2-server/internal/store/plugins_test.go b/v2-server/internal/store/plugins_test.go index a08ad0895..de672468c 100644 --- a/v2-server/internal/store/plugins_test.go +++ b/v2-server/internal/store/plugins_test.go @@ -34,7 +34,7 @@ func TestEmptyPluginCatalogKeepsThePreviousSnapshot(t *testing.T) { } } -func TestRemoveSpacePluginDeletesTheSkillSelection(t *testing.T) { +func TestRemoveSpacePluginDeletesAnUndeliveredSkillSelection(t *testing.T) { f := newSquadFixture(t) owner := f.login(t, "owner", "owner") spaces, e := f.store.TenantSpaces(f.ctx, f.tenant, owner) @@ -62,21 +62,14 @@ func TestRemoveSpacePluginDeletesTheSkillSelection(t *testing.T) { installed.ObservedVersion == nil || *installed.ObservedVersion != "1.0.0" { t.Fatalf("installed = %+v", installed) } - if _, e := f.db.Exec(f.ctx, `INSERT INTO v2_projects(id,tenant_id,data,plugin_space_id) VALUES('pending-project',$1,'{}',$2)`, f.tenant, space); e != nil { - t.Fatal(e) - } - if _, e := f.db.Exec(f.ctx, `INSERT INTO v2_skill_results(selection_id,project_id,revision,state,installed_version) - VALUES($1,'pending-project',$2,'applying','1.0.0')`, installed.ID, installed.Version); e != nil { - t.Fatal(e) - } - + // No project ever received the package: removal deletes the selection row immediately and no + // removal job is raised. removed, e := f.store.RemoveSpacePlugin(f.ctx, f.tenant, space, owner, "plugins", "remove-pending", store.PluginRemoveInput{ Identifier: "official/pending.skill", Version: installed.Version, }) if e != nil { t.Fatal(e) } - // An unsettled project receipt must not keep the removal pending: the row is gone. if removed.DesiredState != "removed" || removed.ObservedState != "removed" || removed.ObservedVersion != nil { t.Fatalf("removed = %+v", removed) } @@ -87,9 +80,110 @@ func TestRemoveSpacePluginDeletesTheSkillSelection(t *testing.T) { if _, found := findSpacePlugin(list, installed.ID); found { t.Fatalf("removed selection is still listed: %+v", list) } - var results int - if e := f.db.QueryRow(f.ctx, `SELECT count(*) FROM v2_skill_results WHERE selection_id=$1`, installed.ID).Scan(&results); e != nil || results != 0 { - t.Fatalf("project results after removal = %d %v", results, e) + if jobs, e := f.store.SkillJobs(f.ctx); e != nil || len(jobs) != 0 { + t.Fatalf("undelivered removal raised a job: %+v %v", jobs, e) + } +} + +func TestRemoveSpacePluginNotifiesDeliveredProjectsAndReinstallWorks(t *testing.T) { + f := newSquadFixture(t) + owner := f.login(t, "owner", "owner") + spaces, e := f.store.TenantSpaces(f.ctx, f.tenant, owner) + if e != nil || len(spaces) != 1 { + t.Fatalf("spaces = %v %v", spaces, e) + } + space := spaces[0].ID + addProject := func(id string) { + t.Helper() + if _, e := f.db.Exec(f.ctx, `INSERT INTO v2_projects(id,tenant_id,data,plugin_space_id) VALUES($1,$2,'{}',$3)`, id, f.tenant, space); e != nil { + t.Fatal(e) + } + } + addProject("a") + entry := pluginmarket.Entry{ + Namespace: "official", Identifier: "notify.skill", Title: "Notify Skill", + Kind: "skill", Version: "1.0.0", URL: "https://example.org/notify.skill", + SHA256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + MarketplaceVisible: true, SourceURL: "https://github.com/ora-space/marketplace", + } + if e := f.store.ReplacePluginCatalog(f.ctx, []pluginmarket.Entry{entry}, time.Now()); e != nil { + t.Fatal(e) + } + installed, e := f.store.InstallSpacePlugin(f.ctx, f.tenant, space, owner, "plugins", "install-notify", store.PluginInstallInput{ + Identifier: "official/notify.skill", + }) + if e != nil { + t.Fatal(e) + } + // The project confirms delivery. + jobs, e := f.store.SkillJobs(f.ctx) + if e != nil || len(jobs) != 1 || jobs[0].Project != "a" { + t.Fatal(jobs, e) + } + if ok, e := f.store.StartSkillJob(f.ctx, jobs[0]); !ok || e != nil { + t.Fatal(e) + } + if e := f.store.FinishSkillJob(f.ctx, jobs[0], store.SkillJobSucceeded, ""); e != nil { + t.Fatal(e) + } + // Removal keeps the selection and raises a removal job for the delivered project. + removed, e := f.store.RemoveSpacePlugin(f.ctx, f.tenant, space, owner, "plugins", "remove-notify", store.PluginRemoveInput{ + Identifier: "official/notify.skill", Version: installed.Version, + }) + if e != nil { + t.Fatal(e) + } + if removed.DesiredState != "removed" || removed.ObservedState != "removing" || removed.ObservedVersion == nil { + t.Fatalf("removed = %+v", removed) + } + if list, e := f.store.SpacePlugins(f.ctx, f.tenant, space, owner); e != nil { + t.Fatal(e) + } else if _, found := findSpacePlugin(list, installed.ID); !found { + t.Fatalf("removal dropped the selection before the project confirmed: %+v", list) + } + jobs, e = f.store.SkillJobs(f.ctx) + if e != nil || len(jobs) != 1 || jobs[0].Project != "a" || jobs[0].Desired != "removed" { + t.Fatalf("removal jobs = %+v %v", jobs, e) + } + if ok, e := f.store.StartSkillJob(f.ctx, jobs[0]); !ok || e != nil { + t.Fatal(e) + } + if e := f.store.FinishSkillJob(f.ctx, jobs[0], store.SkillJobSucceeded, ""); e != nil { + t.Fatal(e) + } + // Once every project confirmed the removal, the settled selection row is cleaned up and no + // further job is raised. + if e := f.store.DeleteSettledSkillRemovals(f.ctx); e != nil { + t.Fatal(e) + } + if list, e := f.store.SpacePlugins(f.ctx, f.tenant, space, owner); e != nil { + t.Fatal(e) + } else if _, found := findSpacePlugin(list, installed.ID); found { + t.Fatalf("settled removal left a selection: %+v", list) + } + if jobs, e := f.store.SkillJobs(f.ctx); e != nil || len(jobs) != 0 { + t.Fatalf("settled removal raised a job: %+v %v", jobs, e) + } + // Reinstalling on the same project re-creates the selection and delivers again. + reinstalled, e := f.store.InstallSpacePlugin(f.ctx, f.tenant, space, owner, "plugins", "reinstall-notify", store.PluginInstallInput{ + Identifier: "official/notify.skill", + }) + if e != nil { + t.Fatal(e) + } + if reinstalled.DesiredState != "installed" || reinstalled.ObservedState != "installed" { + t.Fatalf("reinstalled = %+v", reinstalled) + } + if jobs, e := f.store.SkillJobs(f.ctx); e != nil || len(jobs) != 1 || jobs[0].Project != "a" { + t.Fatalf("reinstall jobs = %+v %v", jobs, e) + } else if ok, e := f.store.StartSkillJob(f.ctx, jobs[0]); !ok || e != nil { + t.Fatalf("start reinstall job %+v ok=%v err=%v", jobs[0], ok, e) + } else if e := f.store.FinishSkillJob(f.ctx, jobs[0], store.SkillJobSucceeded, ""); e != nil { + t.Fatal(e) + } + result, e := f.store.SkillProgress(f.ctx, f.tenant, space, owner, "official", "notify.skill") + if e != nil || len(result) != 1 || result[0].State != "installed" { + t.Fatalf("reinstall progress = %+v %v", result, e) } } diff --git a/v2-server/internal/store/schema.sql b/v2-server/internal/store/schema.sql index ea46efe88..3cebd5e6a 100644 --- a/v2-server/internal/store/schema.sql +++ b/v2-server/internal/store/schema.sql @@ -578,9 +578,21 @@ ALTER TABLE v2_skill_results ADD COLUMN IF NOT EXISTS retry_count integer NOT NU ALTER TABLE v2_skill_results ADD COLUMN IF NOT EXISTS retry_at timestamptz; -- A Skill selection is the Space selection itself, not a Node-confirmed install: settle rows an older -- version left waiting for a project Node, and drop the selections whose removal never finished. +-- Rows without a frozen plan have no package to deliver: they must not read as installed, so they +-- stay failed and ask for a reinstall instead. +UPDATE v2_space_agents a SET observed_state='failed', observed_version=NULL, + install_error='旧版本没有保留安装包信息,请重新安装。' +WHERE kind='skill' AND desired_state='installed' + AND NOT EXISTS(SELECT 1 FROM v2_skill_plans p WHERE p.selection_id=a.id); UPDATE v2_space_agents SET observed_state='installed', observed_version=desired_version, install_error=NULL WHERE kind='skill' AND desired_state='installed' AND (observed_state<>'installed' OR install_error IS NOT NULL); -DELETE FROM v2_space_agents WHERE kind='skill' AND desired_state='removed'; +-- Settled removal rows are dropped once no project still needs to remove the package. +DELETE FROM v2_space_agents a +WHERE kind='skill' AND desired_state='removed' + AND NOT EXISTS( + SELECT 1 FROM v2_skill_results r + WHERE r.selection_id=a.id AND (r.installed_version IS NOT NULL OR r.state IN ('applying','failed')) + ); UPDATE v2_space_agents SET observed_state='pending',observed_version=NULL WHERE kind NOT IN ('agent','skill','workflow','hook') AND desired_state='installed' AND observed_state='installed'; diff --git a/v2-server/internal/store/skill_plans.go b/v2-server/internal/store/skill_plans.go index bd4af4a78..92aae74ac 100644 --- a/v2-server/internal/store/skill_plans.go +++ b/v2-server/internal/store/skill_plans.go @@ -150,6 +150,19 @@ func (s *Store) FinishSkillJob(ctx context.Context, j SkillJob, outcome SkillJob } } +// DeleteSettledSkillRemovals drops a Skill selection whose removal every bound project confirmed. +// The row exists only to carry the removal job (see RemoveSpacePlugin); once no project still +// holds the package, the Skills page and workflow Agent picker must see it gone again. +func (s *Store) DeleteSettledSkillRemovals(ctx context.Context) error { + _, e := s.pool.Exec(ctx, `DELETE FROM v2_space_agents a + WHERE a.kind='skill' AND a.desired_state='removed' + AND NOT EXISTS ( + SELECT 1 FROM v2_skill_results r + WHERE r.selection_id=a.id AND (r.installed_version IS NOT NULL OR r.state IN ('applying','failed')) + )`) + return e +} + // RefreshSkillStates re-evaluates Node-confirmed plugin selections against all bound projects, // including ones created while the service was offline. Skills are left out: a Skill selection is // usable from its record, so a project that never confirms must not move it back to pending. diff --git a/v2-server/internal/store/skill_plans_test.go b/v2-server/internal/store/skill_plans_test.go index c6587c51c..6f1c4cfa9 100644 --- a/v2-server/internal/store/skill_plans_test.go +++ b/v2-server/internal/store/skill_plans_test.go @@ -97,6 +97,30 @@ func TestSkillDeliveryCoversEveryBoundProjectWithoutGatingTheSelection(t *testin }); e != nil { t.Fatal(e) } + // The selection stays as a removed row while every delivered project must confirm the removal, + // so the reconciler can raise removal jobs against it. + if jobs, e := f.store.SkillJobs(f.ctx); e != nil || len(jobs) != 3 { + t.Fatalf("removal jobs = %+v %v", jobs, e) + } + // Once every project confirmed the removal, the settled selection is dropped. + removalJobs, e := f.store.SkillJobs(f.ctx) + if e != nil { + t.Fatal(e) + } + for _, j := range removalJobs { + if j.Desired != "removed" { + t.Fatalf("removal job desired = %q", j.Desired) + } + if ok, e := f.store.StartSkillJob(f.ctx, j); !ok || e != nil { + t.Fatal(ok, e) + } + if e := f.store.FinishSkillJob(f.ctx, j, store.SkillJobSucceeded, ""); e != nil { + t.Fatal(e) + } + } + if e := f.store.DeleteSettledSkillRemovals(f.ctx); e != nil { + t.Fatal(e) + } if list, e := f.store.SpacePlugins(f.ctx, f.tenant, space, owner); e != nil { t.Fatal(e) } else if _, found := findSpacePlugin(list, selection.ID); found { diff --git a/v2-server/internal/store/workflow_skills.go b/v2-server/internal/store/workflow_skills.go index a9f220d5f..66905ac30 100644 --- a/v2-server/internal/store/workflow_skills.go +++ b/v2-server/internal/store/workflow_skills.go @@ -8,6 +8,17 @@ import ( "github.com/wanglongan587/cloud/v2-server/internal/execution" ) +// projectName returns the display name of a tenant project, falling back to its id when the +// row has no Name yet (projects are created from repository URLs). +func (s *Store) projectName(ctx context.Context, tenant, id string) (string, error) { + var name string + err := s.pool.QueryRow(ctx, `SELECT coalesce(data->>'Name', id) FROM v2_projects WHERE id=$1 AND tenant_id=$2::uuid`, id, tenant).Scan(&name) + if err != nil { + return "", err + } + return name, nil +} + // ResolveWorkflowSkills resolves names against the run owner's catalog and plugin IDs // against the target project's space. A selection never grants access to another space. func (s *Store) ResolveWorkflowSkills(ctx context.Context, scope execution.Scope, project string, names []string) (*execution.Skills, error) { @@ -18,6 +29,10 @@ func (s *Store) ResolveWorkflowSkills(ctx context.Context, scope execution.Scope if _, err := s.GetProject(ctx, scope, project); err != nil { return nil, fmt.Errorf("could not load the project for selected Skills") } + projectName, err := s.projectName(ctx, scope.TenantID, project) + if err != nil { + return nil, fmt.Errorf("could not load the project for selected Skills") + } documents, err := s.ListSkills(ctx, scope.TenantID, scope.UserID) if err != nil { return nil, fmt.Errorf("could not load your imported Skills") @@ -46,7 +61,7 @@ func (s *Store) ResolveWorkflowSkills(ctx context.Context, scope execution.Scope AND r.revision=a.version AND r.state='installed' AND r.installed_version=a.desired_version`, project, scope.TenantID, namespace, identifier).Scan(&version) if err != nil { - return nil, fmt.Errorf("Skill plugin %s is not installed in this project; install it and wait for project confirmation", name) + return nil, fmt.Errorf("技能 %s 还没下发到项目「%s」:请先在技能页确认该技能已下发成功(或等下发中完成)后再重新运行工作流", name, projectName) } result.Plugins = append(result.Plugins, execution.SkillPlugin{ID: name, Version: version}) } else { diff --git a/v2-server/market_skills.go b/v2-server/market_skills.go index 7c5df7f25..b91ca279a 100644 --- a/v2-server/market_skills.go +++ b/v2-server/market_skills.go @@ -61,9 +61,11 @@ func reconcileSkills(ctx context.Context, db *store.Store, driver *agentruntime. } } } - return db.RefreshSkillStates(ctx) + if err := db.RefreshSkillStates(ctx); err != nil { + return err + } + return db.DeleteSettledSkillRemovals(ctx) } - func skillJobOutcome(e error) store.SkillJobOutcome { switch { case e == nil: