diff --git a/packages/api/src/domains/plugin/PluginRegistry.ts b/packages/api/src/domains/plugin/PluginRegistry.ts index 79fdeda92b..3fe073baf3 100644 --- a/packages/api/src/domains/plugin/PluginRegistry.ts +++ b/packages/api/src/domains/plugin/PluginRegistry.ts @@ -142,13 +142,18 @@ export class PluginRegistry { (c) => c.pluginId === manifest.id && c.type === r.type && normalizeCapId(c.id) === resourceCapId(manifest.id, r), ); - return { + const base: PluginResourceStatus = { type: r.type, path: r.path, name: r.name, enabled: capEntry?.enabled ?? false, ...(capEntry?.agentProvider?.state ? { agentProviderState: capEntry.agentProvider.state } : {}), }; + if (r.type !== 'agentProvider') return base; + return { + ...base, + ...buildAgentProviderResourceProjection(manifest.id, r, capEntry?.agentProvider), + }; }); return { @@ -170,6 +175,70 @@ export class PluginRegistry { } } +type AgentProviderCapDescriptor = NonNullable; +type AgentProviderResource = NonNullable; + +/** Project host-owned routeable + binding state from the persisted capability row. */ +function projectHostOwnedAgentProviderFields( + ap: AgentProviderCapDescriptor | undefined, +): Partial { + if (!ap) return {}; + const out: Partial = {}; + if (typeof ap.routeable === 'boolean') out.agentProviderRouteable = ap.routeable; + if (typeof ap.routeableApproved === 'boolean') out.agentProviderRouteableApproved = ap.routeableApproved; + if (ap.routeableBinding) { + const b = ap.routeableBinding; + out.agentProviderBinding = { + catId: b.catId, + ...(b.profileId ? { profileId: b.profileId } : {}), + ...(b.mentionPatterns ? { mentionPatterns: [...b.mentionPatterns] } : {}), + }; + } + if (ap.descriptorHash) out.agentProviderDescriptorHash = ap.descriptorHash; + if (ap.health?.passed === false && ap.health.failureReason) { + out.agentProviderHealthFailureReason = ap.health.failureReason; + } + // F241 Phase C (PR #42 round-1 review @codex P2): surface persisted sync + // failure separately from health probe failure. Sync failures fire AFTER + // approval + health both pass, during the Step 6 AgentRegistry projection + // (post-approval sync hook). Without this, the Hub renders + // `approved=true / healthy / routeable=false` with no inline explanation. + if (ap.lastSyncError) { + out.agentProviderLastSyncError = { + message: ap.lastSyncError.message, + occurredAt: ap.lastSyncError.occurredAt, + }; + } + return out; +} + +/** Project manifest-declared identity claims (PR #39) — Hub UI uses these as form defaults. */ +function projectAgentProviderClaims(c: AgentProviderResource | undefined): Partial { + if (!c) return {}; + const claims = { + ...(c.providerId ? { providerId: c.providerId } : {}), + ...(c.displayName ? { displayName: c.displayName } : {}), + ...(c.mentionPatterns ? { mentionPatterns: [...c.mentionPatterns] } : {}), + }; + return Object.keys(claims).length > 0 ? { agentProviderClaims: claims } : {}; +} + +/** + * F241 Phase C — Compose the agentProvider extension fields for `PluginResourceStatus`. + * Pure: never touches `base`, just returns the additional fields to merge. + */ +function buildAgentProviderResourceProjection( + pluginId: string, + resource: PluginManifest['resources'][number], + ap: AgentProviderCapDescriptor | undefined, +): Partial { + return { + capId: resourceCapId(pluginId, resource), + ...projectHostOwnedAgentProviderFields(ap), + ...projectAgentProviderClaims(resource.agentProvider), + }; +} + export function resourceCapId(pluginId: string, resource: { type: string; path?: string; name?: string }): string { if (resource.type === 'skill' && resource.path) { return resourcePathBasename(resource.path); diff --git a/packages/api/test/plugin-registry-agent-provider-info.test.js b/packages/api/test/plugin-registry-agent-provider-info.test.js new file mode 100644 index 0000000000..c5194d7b26 --- /dev/null +++ b/packages/api/test/plugin-registry-agent-provider-info.test.js @@ -0,0 +1,226 @@ +/** + * F241 Phase C — `PluginRegistry.getPluginInfo` agentProvider projection tests. + * + * The Hub UI for owner approval renders entirely off the `PluginResourceStatus` + * fields that `getPluginInfo` projects out of the persisted capabilities row + * and the manifest declaration. These tests lock down that projection: + * - capId is populated for agentProvider resources + * - host-owned routeable / approval / binding state mirrors capabilities + * - manifest-declared claims (PR #39 providerId/displayName/mentionPatterns) + * are surfaced so the form can prefill defaults + * - failureReason surfaces only when health.passed === false + * - non-agentProvider resources are unaffected + */ + +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const { PluginRegistry } = await import('../dist/domains/plugin/PluginRegistry.js'); + +function makeManifest({ withClaims = true } = {}) { + return { + id: 'clowder-code', + name: 'Clowder Code', + version: '0.1.0', + builtin: false, + config: [], + resources: [ + { + type: 'agentProvider', + name: 'clowder-code', + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + sessionPolicy: 'stateless', + outputProfile: 'clowder-code-turn-result-v1', + ...(withClaims + ? { + providerId: 'clowder-code', + displayName: 'Clowder Code', + mentionPatterns: ['@clowder', '@clowder-code'], + } + : {}), + }, + }, + ], + }; +} + +function makeCapabilities({ routeable, approved, binding, descriptorHash, failureReason, lastSyncError } = {}) { + return { + version: 1, + capabilities: [ + { + id: 'plugin:clowder-code:clowder-code', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'clowder-code', + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + sessionPolicy: 'stateless', + outputProfile: 'clowder-code-turn-result-v1', + state: routeable ? 'healthy' : 'transportReady', + routeable: !!routeable, + routeableApproved: !!approved, + descriptorHash: descriptorHash ?? 'hash-X', + ...(binding ? { routeableBinding: binding } : {}), + ...(failureReason + ? { + health: { + passed: false, + checkedAt: 1000, + ttlMs: 60_000, + descriptorHash: descriptorHash ?? 'hash-X', + failureReason, + }, + } + : {}), + ...(lastSyncError ? { lastSyncError } : {}), + }, + }, + ], + }; +} + +function makeRegistry() { + return new PluginRegistry(mkdtempSync(join(os.tmpdir(), 'plugin-info-test-'))); +} + +describe('PluginRegistry.getPluginInfo — F241 agentProvider projection', () => { + it('populates capId for the Hub UI POST URL', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo(makeManifest(), makeCapabilities({ routeable: false, approved: false }), {}); + const r = info.resources[0]; + assert.equal(r.capId, 'plugin:clowder-code:clowder-code'); + }); + + it('surfaces routeable + approval flags from capabilities', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ + routeable: true, + approved: true, + binding: { catId: 'clowder-cat', mentionPatterns: ['@clowder'] }, + }), + {}, + ); + const r = info.resources[0]; + assert.equal(r.agentProviderRouteable, true); + assert.equal(r.agentProviderRouteableApproved, true); + assert.equal(r.agentProviderState, 'healthy'); + assert.deepEqual(r.agentProviderBinding, { catId: 'clowder-cat', mentionPatterns: ['@clowder'] }); + }); + + it('surfaces manifest identity claims (PR #39) for form prefill', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest({ withClaims: true }), + makeCapabilities({ routeable: false, approved: false }), + {}, + ); + assert.deepEqual(info.resources[0].agentProviderClaims, { + providerId: 'clowder-code', + displayName: 'Clowder Code', + mentionPatterns: ['@clowder', '@clowder-code'], + }); + }); + + it('omits agentProviderClaims when manifest declares none', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest({ withClaims: false }), + makeCapabilities({ routeable: false, approved: false }), + {}, + ); + assert.equal(info.resources[0].agentProviderClaims, undefined); + }); + + it('surfaces descriptorHash so the operator can see when re-approval is pending', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: false, approved: false, descriptorHash: 'hash-Y' }), + {}, + ); + assert.equal(info.resources[0].agentProviderDescriptorHash, 'hash-Y'); + }); + + it('surfaces health.failureReason when probe failed', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: false, approved: false, failureReason: 'cli-probe-cli-not-found:clowder-code' }), + {}, + ); + assert.equal(info.resources[0].agentProviderHealthFailureReason, 'cli-probe-cli-not-found:clowder-code'); + }); + + it('omits failureReason when health passed (no operator-visible noise on the happy path)', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: true, approved: true, binding: { catId: 'clowder-cat' } }), + {}, + ); + assert.equal(info.resources[0].agentProviderHealthFailureReason, undefined); + }); + + // PR #42 round-1 review @codex P2: persisted post-approval sync failures + // must surface to the Hub so operators can diagnose a row that is + // approved + healthy but stuck non-routeable. + it('surfaces lastSyncError (message + occurredAt) so post-approval sync failure is diagnosable', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ + routeable: false, + approved: true, + binding: { catId: 'clowder-cat' }, + lastSyncError: { message: 'agent registry sync failed: ENOENT', occurredAt: 1_700_000_000_999 }, + }), + {}, + ); + assert.deepEqual(info.resources[0].agentProviderLastSyncError, { + message: 'agent registry sync failed: ENOENT', + occurredAt: 1_700_000_000_999, + }); + }); + + it('omits lastSyncError on the happy path (no UI noise after sync succeeds)', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: true, approved: true, binding: { catId: 'clowder-cat' } }), + {}, + ); + assert.equal(info.resources[0].agentProviderLastSyncError, undefined); + }); + + it('does NOT add F241 fields to non-agentProvider resources', () => { + const reg = makeRegistry(); + const manifest = { + id: 'gh', + name: 'GitHub', + version: '1.0.0', + builtin: false, + config: [], + resources: [{ type: 'schedule', name: 'cicd-check', factoryId: 'github.cicd-check' }], + }; + const info = reg.getPluginInfo(manifest, { version: 1, capabilities: [] }, {}); + const r = info.resources[0]; + assert.equal(r.capId, undefined); + assert.equal(r.agentProviderRouteable, undefined); + assert.equal(r.agentProviderBinding, undefined); + assert.equal(r.agentProviderClaims, undefined); + }); +}); diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 8aba6c852b..f7ab46229a 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -112,6 +112,57 @@ export interface PluginResourceStatus { enabled: boolean; agentProviderState?: AgentProviderLifecycleState; error?: string; + /** + * F241 Phase C — Operator-visible state for `agentProvider` resources. + * + * Surfaces the host-owned routeable view + manifest-declared claims to the + * Hub UI so an operator can render the approve-routeable form, see whether + * a binding is already live, and prefill the form from claim defaults. + * All fields are optional and only populated for `type === 'agentProvider'`. + */ + + /** Canonical capability id (e.g. `plugin:clowder-code:clowder-code`) — required for approve-routeable POST URL. */ + capId?: string; + + /** Effective truth — "you can `@` this cat now". Always false until the routeable gate fully passes. */ + agentProviderRouteable?: boolean; + + /** Operator intent — flips true on explicit approve, resets to false on descriptor delta. */ + agentProviderRouteableApproved?: boolean; + + /** Live binding the operator set at approve-time. Undefined when not yet approved. */ + agentProviderBinding?: { + catId: string; + profileId?: string; + mentionPatterns?: string[]; + }; + + /** Manifest-declared identity claims (PR #39). Hub UI uses these as form defaults. */ + agentProviderClaims?: { + providerId?: string; + displayName?: string; + mentionPatterns?: string[]; + }; + + /** Stable hash bound to the descriptor; surfaced so the operator can see when re-approval is needed after a manifest delta. */ + agentProviderDescriptorHash?: string; + + /** Operator-visible probe failure (e.g. `cli-probe-cli-not-found:foo`, `cli-probe-timeout:10000ms`). */ + agentProviderHealthFailureReason?: string; + + /** + * Operator-visible AgentRegistry sync failure surfaced from the persisted + * `lastSyncError`. Distinct from `agentProviderHealthFailureReason` — + * sync failures fire AFTER approval / health both pass, during the Step 6 + * AgentRegistry projection (post-approval sync hook in + * `agent-provider-approval-service.ts`). Without this field a sync failure + * leaves the row as `approved=true / healthy / routeable=false` with no + * UI explanation of WHY it isn't routeable. (PR #42 round-1 review @codex.) + */ + agentProviderLastSyncError?: { + message: string; + occurredAt: number; + }; } /** Full plugin info returned by API (manifest + derived state) */ diff --git a/packages/web/src/components/settings/AgentProviderApprovalSection.tsx b/packages/web/src/components/settings/AgentProviderApprovalSection.tsx new file mode 100644 index 0000000000..8f917cdc8c --- /dev/null +++ b/packages/web/src/components/settings/AgentProviderApprovalSection.tsx @@ -0,0 +1,374 @@ +'use client'; + +/** + * F241 Phase C — Hub UI for owner approval. + * + * Renders an approval section for each `agentProvider` resource of a plugin: + * - Shows the current lifecycle state (transportReady / healthy) + routeable + * bool + any `health.failureReason` so operators can see why a probe failed. + * - For non-routeable rows: renders the approve form (catId input + + * mentionPatterns chip input) prefilled from manifest claims (PR #39). + * - For already-routeable rows: shows the live binding (catId + patterns) + * and a "重新绑定" / "停用路由" pair so the operator can re-approve under + * a new binding or take the cat offline without disabling the whole plugin. + * + * POSTs `/api/plugins/:id/capabilities/:capId/approve-routeable` with + * { catId, mentionPatterns? }. Failure body includes structured + * `reason` + `details` from the approval service so the form can surface + * admission collisions / health-probe failures inline. + */ + +import type { PluginInfo, PluginResourceStatus } from '@cat-cafe/shared'; +import { useMemo, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; + +interface Props { + plugin: PluginInfo; + onUpdated: () => void; +} + +type ApprovalRowState = { + catId: string; + mentionPatternsText: string; + busy: boolean; + result: { type: 'success' | 'error'; msg: string } | null; + /** Tracks whether the operator is editing an already-bound row (re-bind flow). */ + rebinding: boolean; +}; + +/** Comma- or whitespace-separated `@name` list → trimmed string[] (drop empties). */ +function parseMentionPatterns(text: string): string[] { + return text + .split(/[,\s]+/u) + .map((p) => p.trim()) + .filter((p) => p.length > 0); +} + +function joinMentionPatterns(values: string[] | undefined): string { + return (values ?? []).join(', '); +} + +function defaultCatIdFromResource(resource: PluginResourceStatus, pluginId: string): string { + // Prefer manifest providerId claim → resource name → plugin id (last-resort). + return resource.agentProviderClaims?.providerId ?? resource.name ?? pluginId; +} + +type ApproveResponse = + | { ok: true; capability?: unknown } + | { ok: false; reason?: string; details?: string; conflictingIdentity?: string }; + +/** Build operator-visible error message from a structured approval failure. */ +function approvalErrorMessage(data: Extract): string { + const head = data.reason ?? '未知错误'; + const tail = data.details ? `: ${data.details}` : ''; + const conflict = data.conflictingIdentity ? ` (冲突身份: ${data.conflictingIdentity})` : ''; + return `${head}${tail}${conflict}`; +} + +/** POST approve-routeable + normalize the network/JSON/structured-failure error paths. */ +async function postApproveRouteable( + pluginId: string, + capId: string, + catId: string, + mentionPatterns: string[], +): Promise<{ ok: true } | { ok: false; msg: string }> { + try { + const res = await apiFetch(`/api/plugins/${pluginId}/capabilities/${encodeURIComponent(capId)}/approve-routeable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + catId, + ...(mentionPatterns.length > 0 ? { mentionPatterns } : {}), + }), + }); + const data = (await res.json().catch(() => ({}))) as ApproveResponse; + if (!res.ok || ('ok' in data && data.ok === false)) { + const msg = 'ok' in data && data.ok === false ? approvalErrorMessage(data) : `HTTP ${res.status}`; + return { ok: false, msg }; + } + return { ok: true }; + } catch (err) { + return { ok: false, msg: err instanceof Error ? err.message : '网络错误' }; + } +} + +function StateChip({ resource }: { resource: PluginResourceStatus }) { + const state = resource.agentProviderState; + const routeable = resource.agentProviderRouteable; + if (state === 'healthy' && routeable) { + return ( + + ✓ routeable · healthy + + ); + } + if (state === 'transportReady') { + return ( + + 待审批 · transportReady + + ); + } + return ( + + {state ?? '未激活'} + + ); +} + +export function AgentProviderApprovalSection({ plugin, onUpdated }: Props) { + const agentProviderRows = useMemo( + () => plugin.resources.filter((r) => r.type === 'agentProvider'), + [plugin.resources], + ); + + const initialRowState = (resource: PluginResourceStatus): ApprovalRowState => ({ + catId: resource.agentProviderBinding?.catId ?? defaultCatIdFromResource(resource, plugin.id), + mentionPatternsText: joinMentionPatterns( + resource.agentProviderBinding?.mentionPatterns ?? resource.agentProviderClaims?.mentionPatterns, + ), + busy: false, + result: null, + rebinding: false, + }); + + // Key by capId so re-renders after onUpdated() naturally re-mount the form + // state from the freshest binding rather than holding stale operator input. + const [rowState, setRowState] = useState>(() => { + const acc: Record = {}; + for (const r of agentProviderRows) { + if (r.capId) acc[r.capId] = initialRowState(r); + } + return acc; + }); + + if (agentProviderRows.length === 0) return null; + + const getRow = (resource: PluginResourceStatus): ApprovalRowState => { + if (!resource.capId) return initialRowState(resource); + return rowState[resource.capId] ?? initialRowState(resource); + }; + + const updateRow = (capId: string, patch: Partial) => { + setRowState((prev) => ({ + ...prev, + [capId]: { ...(prev[capId] ?? ({} as ApprovalRowState)), ...patch }, + })); + }; + + const handleApprove = async (resource: PluginResourceStatus) => { + const capId = resource.capId; + if (!capId) return; + const row = getRow(resource); + const catId = row.catId.trim(); + if (!catId) { + updateRow(capId, { result: { type: 'error', msg: '请填写 catId 绑定' } }); + return; + } + updateRow(capId, { busy: true, result: null }); + const mentionPatterns = parseMentionPatterns(row.mentionPatternsText); + const result = await postApproveRouteable(plugin.id, capId, catId, mentionPatterns); + if (result.ok) { + updateRow(capId, { busy: false, rebinding: false, result: { type: 'success', msg: '审批已生效' } }); + onUpdated(); + } else { + updateRow(capId, { busy: false, result: { type: 'error', msg: result.msg } }); + } + }; + + return ( +
+
外部 Agent Provider 路由审批 (F241)
+ {agentProviderRows.map((resource) => ( + resource.capId && updateRow(resource.capId, patch)} + onApprove={() => void handleApprove(resource)} + /> + ))} +
+ ); +} + +/** + * Single approval row — extracted to keep the parent under the Biome cognitive- + * complexity budget. Owns no state; the parent threads `row` + `onPatch` so + * `onUpdated()` re-renders cleanly when the binding refreshes. + */ +interface ApprovalRowProps { + resource: PluginResourceStatus; + row: ApprovalRowState; + pluginId: string; + onPatch: (patch: Partial) => void; + onApprove: () => void; +} + +function ApprovalRow({ resource, row, pluginId, onPatch, onApprove }: ApprovalRowProps) { + const liveBinding = resource.agentProviderBinding; + const isRouteable = resource.agentProviderRouteable === true; + const showForm = !isRouteable || row.rebinding; + const claims = resource.agentProviderClaims; + return ( +
+
+ + {claims?.displayName ?? resource.name ?? 'agentProvider'} + + + {resource.agentProviderHealthFailureReason && ( + + 探针失败: {resource.agentProviderHealthFailureReason} + + )} + {/* F241 PR #42 round-1 review @codex P2: surface persisted sync failure + separately from health probe failure so operators see WHY a row is + `approved=true / healthy / routeable=false` after a Step 6 sync hook + throws. Distinct chip + label keeps the two failure sources + operator-distinguishable; the title attribute shows the occurredAt + timestamp on hover so operators can correlate with logs. */} + {resource.agentProviderLastSyncError && ( + + 同步失败: {resource.agentProviderLastSyncError.message} + + )} +
+ + {isRouteable && liveBinding && ( + onPatch({ rebinding: true, result: null })} + /> + )} + + {showForm && ( + + )} + + {row.result && ( +
+ {row.result.msg} +
+ )} + + {resource.agentProviderDescriptorHash && ( +
+ descriptorHash: {resource.agentProviderDescriptorHash.slice(0, 12)}… +
+ )} +
+ ); +} + +function BindingSummary({ + binding, + rebinding, + onRebind, +}: { + binding: NonNullable; + rebinding: boolean; + onRebind: () => void; +}) { + return ( +
+
+ 当前绑定 · @{binding.catId} +
+ {binding.mentionPatterns && binding.mentionPatterns.length > 0 && ( +
mention: {binding.mentionPatterns.join(', ')}
+ )} + {!rebinding && ( + + )} +
+ ); +} + +interface ApprovalFormProps { + resource: PluginResourceStatus; + row: ApprovalRowState; + pluginId: string; + claims: PluginResourceStatus['agentProviderClaims']; + onPatch: (patch: Partial) => void; + onApprove: () => void; +} + +function ApprovalForm({ resource, row, pluginId, claims, onPatch, onApprove }: ApprovalFormProps) { + return ( +
+ + +
+ {row.rebinding && ( + + )} + +
+
+ ); +} diff --git a/packages/web/src/components/settings/PluginConfigPanel.tsx b/packages/web/src/components/settings/PluginConfigPanel.tsx index bc6385167f..07b59f0019 100644 --- a/packages/web/src/components/settings/PluginConfigPanel.tsx +++ b/packages/web/src/components/settings/PluginConfigPanel.tsx @@ -4,6 +4,7 @@ import type { PluginInfo } from '@cat-cafe/shared'; import { useState } from 'react'; import { apiFetch } from '@/utils/api-client'; import { ExternalLinkIcon, StepBadge } from '../HubConfigIcons'; +import { AgentProviderApprovalSection } from './AgentProviderApprovalSection'; import { ConfigFieldRenderer } from './primitives/ConfigFieldRenderer'; function isSafeUrl(url: string): boolean { @@ -195,6 +196,11 @@ export function PluginConfigPanel({ plugin, onUpdated }: Props) { )} + {/* F241 Phase C — Hub UI for owner approval of agentProvider routeable rows. */} + {plugin.resources.some((r) => r.type === 'agentProvider') && ( + + )} + {result && (