diff --git a/README.md b/README.md index f32481f..080f95b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ The Console talks only to the NeKiro Gateway. It supports trusted Agent publication, public Agent share URLs, Catalog discovery, exact Release installation, managed JSON/SSE invocation, and Workspace-scoped Ledger reads. +The authenticated Console presents those capabilities as one guided journey: + +```text +Agents -> Publish -> Install -> Invoke -> Trace +``` + +Successful steps carry only the exact server-returned Agent, Release, +Installation, and correlation identifiers into the next screen. The browser +does not persist those handoffs or turn failed reads into local success. + ## Configuration Copy `.env.example` to `.env.local` and provide every value explicitly: diff --git a/e2e/console.spec.ts b/e2e/console.spec.ts index fc238a9..7f070d6 100644 --- a/e2e/console.spec.ts +++ b/e2e/console.spec.ts @@ -128,7 +128,7 @@ test('production Console completes trusted publication, invocation, trace, and i await page.goto('/'); await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); - await page.getByRole('button', {name: 'Installations', exact: true}).click(); + await page.getByRole('button', {name: 'Install', exact: true}).click(); const publicPanel = page.locator('section').filter({hasText: 'Public Share'}); await publicPanel.getByLabel('Public Agent URL', {exact: true}).fill(shareB.publicUrl); const pastedPublicRequestPromise = page.waitForRequest((request) => request.method() === 'GET' && request.url().endsWith(`/v4/public/agents/${shareB.publicAgentId}`)); @@ -145,12 +145,12 @@ test('production Console completes trusted publication, invocation, trace, and i await publicPanel.getByRole('button', {name: 'Install exact Release', exact: true}).click(); const pastedInstallResponse = await pastedInstallResponsePromise; expect(pastedInstallResponse.status()).toBe(201); - const pastedInstallation = await pastedInstallResponse.json() as {installedReleaseId: string; agentId: string; acceptedPermissions: string[]}; + const pastedInstallation = await pastedInstallResponse.json() as {installationId: string; installedReleaseId: string; agentId: string; acceptedPermissions: string[]}; expect(pastedInstallation).toMatchObject({installedReleaseId: releaseB.releaseId, agentId: runtimeB.id, acceptedPermissions: ['text.read']}); await expect(publicPanel.getByText(`Installed exact Release ${releaseB.releaseId}.`, {exact: true})).toBeVisible(); expect(publicResolutionRequests).toEqual([`${apiBaseURL}/v4/public/agents/${shareA.publicAgentId}`, `${apiBaseURL}/v4/public/agents/${shareB.publicAgentId}`]); - await page.getByRole('button', {name: 'Installations', exact: true}).click(); + await page.getByRole('button', {name: 'Install', exact: true}).click(); await selectOptionContaining(page.getByLabel('Published Agent', {exact: true}), runtimeA.id); await page.getByLabel('Trusted Release ID', {exact: true}).fill('release-does-not-exist'); const preflightResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/releases/release-does-not-exist') && response.request().method() === 'GET'); @@ -166,13 +166,13 @@ test('production Console completes trusted publication, invocation, trace, and i await expect(page.getByText(/HTTP 404/)).toBeVisible(); await expect(page.getByText(new RegExp('traceId: ' + escapeRegExp(preflightError.traceId)))).toBeVisible(); - await page.getByRole('button', {name: 'Invocations', exact: true}).click(); + await page.locator(`[data-installation-id="${pastedInstallation.installationId}"]`).getByRole('button', {name: 'Invoke', exact: true}).click(); const installationSelect = page.getByLabel('Installed Agent', {exact: true}); - await selectOptionContaining(installationSelect, runtimeB.id); - await page.getByLabel('Capability', {exact: true}).fill(runtimeB.capability); + await expect(installationSelect).toHaveValue(pastedInstallation.installationId); + await page.getByLabel('Capability', {exact: true}).selectOption(runtimeB.capability); await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'nested', value: {message: 'browser-json'}})); const jsonResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":false')); - await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + await page.getByRole('button', {name: 'Invoke Agent', exact: true}).click(); const jsonResponse = await jsonResponsePromise; const jsonResponseBody = await jsonResponse.text(); if (jsonResponse.status() !== 200) await logInvocationTraceDiagnostic(page, jsonResponseBody); @@ -185,23 +185,8 @@ test('production Console completes trusted publication, invocation, trace, and i expect(result.rootTaskId).toBeTruthy(); expect(result.traceId).toBeTruthy(); - await page.getByRole('button', {name: 'Invocations', exact: true}).click(); - await selectOptionContaining(installationSelect, runtimeB.id); - await page.getByLabel('Capability', {exact: true}).fill(runtimeB.capability); - await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'stream-success', value: 'browser-sse'})); - await page.getByLabel('Stream result over SSE', {exact: true}).check(); - const sseResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":true')); - await page.getByRole('button', {name: 'Invoke', exact: true}).click(); - const sseResponse = await sseResponsePromise; - expect(sseResponse.status()).toBe(200); - assertResultStream(await sseResponse.text()); - await expect(page.getByText('#0 accepted', {exact: true})).toBeVisible(); - await expect(page.getByText(/completed/, {exact: true}).last()).toBeVisible(); - - await page.getByRole('button', {name: 'Ledger', exact: true}).click(); - await page.getByLabel('Trace ID', {exact: true}).fill(result.traceId); const traceResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/traces/' + result.traceId) && response.request().method() === 'GET'); - await page.getByRole('button', {name: 'Read', exact: true}).last().click(); + await page.getByRole('button', {name: 'Open correlated trace', exact: true}).click(); const traceResponse = await traceResponsePromise; expect(traceResponse.status()).toBe(200); const tracePayload = await traceResponse.json() as { @@ -217,6 +202,7 @@ test('production Console completes trusted publication, invocation, trace, and i expect(childInvocation?.rootTaskId).toBe(rootInvocation?.rootTaskId); expect(childInvocation?.traceId).toBe(rootInvocation?.traceId); await expect(page.getByText(new RegExp(`${escapeRegExp(result.traceId)}`)).last()).toBeVisible(); + await expect(page.locator('[data-journey-step="ledger"]')).toHaveAttribute('data-complete', 'true'); const ledgerText = await page.locator('main').innerText(); expect(ledgerText).toContain(runtimeA.id); expect(ledgerText).toContain(runtimeB.id); @@ -226,6 +212,19 @@ test('production Console completes trusted publication, invocation, trace, and i expect(ledgerText).toContain(releaseA.cardDigest); expect(ledgerText).toContain(releaseB.cardDigest); + await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + await selectOptionContaining(installationSelect, runtimeB.id); + await page.getByLabel('Capability', {exact: true}).selectOption(runtimeB.capability); + await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'stream-success', value: 'browser-sse'})); + await page.getByLabel('Stream result over SSE', {exact: true}).check(); + const sseResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":true')); + await page.getByRole('button', {name: 'Invoke Agent', exact: true}).click(); + const sseResponse = await sseResponsePromise; + expect(sseResponse.status()).toBe(200); + assertResultStream(await sseResponse.text()); + await expect(page.getByText('#0 accepted', {exact: true})).toBeVisible(); + await expect(page.getByText(/completed/, {exact: true}).last()).toBeVisible(); + const gatewayOrigin = new URL(apiBaseURL).origin; expect(apiRequests.length).toBeGreaterThan(0); expect(apiRequests.every((url) => new URL(url).origin === gatewayOrigin)).toBe(true); @@ -255,7 +254,7 @@ async function createWorkspace(page: Page): Promise { } async function registerCard(page: Page, fixture: AgentFixture): Promise { - await page.getByRole('button', {name: 'Registry', exact: true}).click(); + await page.getByRole('button', {name: 'Agents', exact: true}).click(); await page.getByRole('button', {name: 'Register Agent Card', exact: true}).click(); await page.getByLabel('Agent ID', {exact: true}).fill(fixture.id); await page.getByLabel('Name', {exact: true}).fill(fixture.name); @@ -277,11 +276,16 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise candidate.url().includes(`/v3/agents/${fixture.id}/versions/1.0.0/publish`) && candidate.request().method() === 'POST'); + await page.getByRole('button', {name: 'Publish to Catalog', exact: true}).click(); + expect((await publishResponsePromise).status()).toBe(200); + await page.getByRole('button', {name: 'Continue to Publish', exact: true}).click(); + await expect(page.getByRole('heading', {name: 'Trusted Publication', exact: true})).toBeVisible(); return {publicAgentId: body.publicAgentId as string, publicUrl: body.publicUrl as string}; } async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTracker: BrowserLeakTracker): Promise { - await page.getByRole('button', {name: 'Trusted Publication', exact: true}).click(); + await page.getByRole('button', {name: 'Publish', exact: true}).click(); await page.getByRole('button', {name: new RegExp(escapeRegExp(fixture.id))}).first().click(); await page.getByLabel('Agent endpoint', {exact: true}).fill(fixture.endpoint); await page.getByRole('button', {name: 'Create Binding', exact: true}).click(); @@ -322,11 +326,14 @@ async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTrac if (!/^[A-Za-z0-9._:-]+$/.test(releaseId) || !/^[0-9a-f]{64}$/.test(cardDigest)) { throw new Error('Console did not render immutable Release provenance'); } + await releaseSection.getByRole('button', {name: 'Continue to Install', exact: true}).click(); + await expect(page.getByLabel('Published Agent', {exact: true})).toHaveValue(`${fixture.id}@1.0.0`); + await expect(page.getByLabel('Trusted Release ID', {exact: true})).toHaveValue(releaseId); return {releaseId, cardDigest}; } async function installRelease(page: Page, fixture: AgentFixture, releaseId: string): Promise { - await page.getByRole('button', {name: 'Installations', exact: true}).click(); + await page.getByRole('button', {name: 'Install', exact: true}).click(); const agentSelect = page.getByLabel('Published Agent', {exact: true}); await selectOptionContaining(agentSelect, fixture.id); await page.getByLabel('Trusted Release ID', {exact: true}).fill(releaseId); diff --git a/src/App.tsx b/src/App.tsx index 8746b8b..304f0d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,20 +3,21 @@ import {AnimatePresence, motion} from 'motion/react'; import {CheckCircle2, Cpu, HelpCircle, ShieldAlert, X} from 'lucide-react'; import {mapCatalogEntry, NekiroApiClient, NekiroApiError, toPlatformErrorView, validateTrustedInstallation, type AgentCardV02, type AgentRelease} from './api/nekiro'; -import {agentKey, isCurrentRequest, matchesPublishedRelease, nextRequestGeneration} from './consolePolicy'; +import {agentKey, isCurrentRequest, isTrustedEnabledInstallation, matchesPublishedRelease, nextRequestGeneration} from './consolePolicy'; import Header from './components/Header'; import InstallationsTab from './components/InstallationsTab'; import InvocationsTab from './components/InvocationsTab'; +import JourneyBar from './components/JourneyBar'; import LedgerTab from './components/LedgerTab'; import RegistryTab from './components/RegistryTab'; import Sidebar from './components/Sidebar'; import TrustedPublicationTab from './components/TrustedPublicationTab'; import {requireConsoleConfiguration} from './consoleConfig'; -import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from './types'; +import type {Agent, AgentIntent, ConsoleTab, Installation, InstallationStatus, InstallIntent, InvocationIntent, LedgerIntent, PlatformErrorView, Workspace} from './types'; export default function App() { requireConsoleConfiguration(import.meta.env); - const [activeTab, setActiveTab] = useState<'registry' | 'trusted' | 'installations' | 'invocations' | 'ledger'>('registry'); + const [activeTab, setActiveTab] = useState('registry'); const [searchQuery, setSearchQuery] = useState(''); const [agents, setAgents] = useState([]); const [providerAgents, setProviderAgents] = useState([]); @@ -36,12 +37,28 @@ export default function App() { const [installationError, setInstallationError] = useState(null); const [showSettings, setShowSettings] = useState(false); const [showSupport, setShowSupport] = useState(false); + const [trustedSelection, setTrustedSelection] = useState(); + const [installSelection, setInstallSelection] = useState(); + const [invocationSelection, setInvocationSelection] = useState(); + const [ledgerSelection, setLedgerSelection] = useState(); + const [traceComplete, setTraceComplete] = useState(false); + const intentSequence = useRef(0); const catalogRequestGeneration = useRef(0); const providerCatalogRequestGeneration = useRef(0); const workspaceRequestGeneration = useRef(0); const installationRequestGeneration = useRef(0); const defaultWorkspaceInitialized = useRef(false); + const navigate = (tab: ConsoleTab) => { + setActiveTab(tab); + setSearchQuery(''); + }; + + const nextIntentSequence = () => { + intentSequence.current += 1; + return intentSequence.current; + }; + const providerClient = useMemo( () => new NekiroApiClient({ baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, @@ -100,6 +117,9 @@ export default function App() { workspaceRequestGeneration.current = generation; installationRequestGeneration.current = nextRequestGeneration(installationRequestGeneration.current); setInstallations([]); + setInvocationSelection(undefined); + setLedgerSelection(undefined); + setTraceComplete(false); setInstallationLoading(false); setWorkspaceLoading(true); setWorkspaceError(null); @@ -108,11 +128,13 @@ export default function App() { if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return null; setWorkspace(value); setWorkspaceDraft(value.workspaceId); + setTraceComplete(false); return value; } catch (error) { if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return null; setWorkspace(null); setInstallations([]); + setTraceComplete(false); setWorkspaceError(toPlatformErrorView(error, 'Unable to load Workspace.')); return null; } finally { @@ -163,6 +185,9 @@ export default function App() { workspaceRequestGeneration.current = generation; installationRequestGeneration.current = nextRequestGeneration(installationRequestGeneration.current); setInstallations([]); + setInvocationSelection(undefined); + setLedgerSelection(undefined); + setTraceComplete(false); setInstallationLoading(false); setWorkspaceLoading(true); setWorkspaceError(null); @@ -171,6 +196,7 @@ export default function App() { if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return; setWorkspace(value); setWorkspaceDraft(value.workspaceId); + setTraceComplete(false); await loadInstallations(value.workspaceId); } catch (error) { if (isCurrentRequest(generation, workspaceRequestGeneration.current)) { @@ -227,6 +253,7 @@ export default function App() { await loadInstallations(operationWorkspaceId); } } + return installation; }; const handleUpdateInstallation = async (installation: Installation, status: Exclude) => { @@ -295,7 +322,7 @@ export default function App() { }; return ( -
+
@@ -305,10 +332,7 @@ export default function App() { { - setActiveTab(tab); - setSearchQuery(''); - }} + setActiveTab={navigate} onOpenSettings={() => setShowSettings(true)} onOpenSupport={() => setShowSupport(true)} /> @@ -329,6 +353,15 @@ export default function App() { />
+ {activeTab === 'registry' && ( @@ -343,6 +376,10 @@ export default function App() { defaultOwnerId={import.meta.env.VITE_NEKIRO_PROVIDER_ID ?? ''} defaultOwnerName={import.meta.env.VITE_NEKIRO_PROVIDER_NAME ?? ''} searchQuery={searchQuery} + onContinueToTrusted={(agent) => { + setTrustedSelection({agentKey: agentKey(agent), sequence: nextIntentSequence()}); + navigate('trusted'); + }} /> )} @@ -355,7 +392,15 @@ export default function App() { agents={providerAgents} draftAgents={draftAgents} providerCatalogError={providerCatalogError} + initialSelection={trustedSelection} onRefresh={() => void loadProviderAgents(searchQuery)} + onContinueToInstall={(agent, release) => { + setInstallSelection({agentKey: agentKey(agent), releaseId: release.releaseId, sequence: nextIntentSequence()}); + navigate('installations'); + }} + onReleaseStateChange={(release) => { + if (release.state !== 'published' && installSelection?.releaseId === release.releaseId) setInstallSelection(undefined); + }} /> )} @@ -370,24 +415,41 @@ export default function App() { error={installationError} searchQuery={searchQuery} client={ownerClient} + initialSelection={installSelection} onInstallAgent={handleInstallAgent} onUpdateInstallation={handleUpdateInstallation} onUninstall={handleUninstall} onRefresh={() => void loadInstallations()} onPublicInstalled={() => loadInstallations()} + onContinueToInvoke={(installation) => { + setInvocationSelection({installationId: installation.installationId, sequence: nextIntentSequence()}); + navigate('invocations'); + }} /> )} {activeTab === 'invocations' && ( - + { + setTraceComplete(false); + setLedgerSelection({kind: 'trace', id: traceId, sequence: nextIntentSequence()}); + navigate('ledger'); + }} + /> )} {activeTab === 'ledger' && ( -
+
{ + if (activeWorkspaceRef.current?.workspaceId === readWorkspaceId) setTraceComplete(true); + }} />
)}
@@ -405,9 +467,9 @@ export default function App() { )} {showSupport && ( - } onClose={() => setShowSupport(false)}> + } onClose={() => setShowSupport(false)}>
-

Live surfaces: Registry, Workspace, Installations, Invocation Dispatch, and metadata-only Ledger through public Gateway routes.

+

Follow Agents → Publish → Install → Invoke → Trace. Successful steps offer a direct continue action with exact server-returned identifiers.

Runtime reads are Owner-authorized and Workspace-scoped. The Console never stores Agent secrets or fabricates Ledger events.

@@ -425,7 +487,7 @@ function Overlay({title, icon, children, onClose}: {title: string; icon: React.R {icon}

{title}

-
diff --git a/src/api/nekiro.ts b/src/api/nekiro.ts index c7d1a18..9e924ae 100644 --- a/src/api/nekiro.ts +++ b/src/api/nekiro.ts @@ -1517,6 +1517,7 @@ export function mapCatalogEntry(entry: CatalogEntry): Agent { status: entry.publicationStatus, schema: JSON.stringify(entry.card, null, 2), permissions: entry.card.permissions, + skills: entry.card.skills, registeredAt: entry.registeredAt, publishedAt: entry.publishedAt, publicAgentId: entry.publicAgentId, diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..7ee8a16 --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,31 @@ +import {useState} from 'react'; +import {Check, Copy} from 'lucide-react'; + +export default function CopyButton({value, label = 'Copy'}: {value: string; label?: string}) { + const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); + + const copy = async () => { + setStatus('idle'); + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); + await navigator.clipboard.writeText(value); + setStatus('copied'); + } catch { + setStatus('failed'); + } + }; + + const accessibleLabel = status === 'copied' ? `${label}: copied` : status === 'failed' ? `${label}: copy failed` : label; + return ( + + ); +} diff --git a/src/components/InstallationsTab.tsx b/src/components/InstallationsTab.tsx index 7d038af..8f648e3 100644 --- a/src/components/InstallationsTab.tsx +++ b/src/components/InstallationsTab.tsx @@ -1,9 +1,9 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; -import {AlertTriangle, Database, Loader2, RefreshCw, ShieldCheck, Trash2} from 'lucide-react'; +import {AlertTriangle, Database, Loader2, PlayCircle, RefreshCw, ShieldCheck, Trash2} from 'lucide-react'; import {NekiroApiError, toPlatformErrorView, type AgentRelease, type NekiroApiClient} from '../api/nekiro'; -import {agentKey, isCurrentRequest, matchesPublishedRelease, nextRequestGeneration} from '../consolePolicy'; -import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from '../types'; +import {agentKey, isCurrentRequest, isTrustedEnabledInstallation, matchesPublishedRelease, nextRequestGeneration} from '../consolePolicy'; +import type {Agent, Installation, InstallationStatus, InstallIntent, PlatformErrorView, Workspace} from '../types'; import PublicAgentInstallPanel from './PublicAgentInstallPanel'; interface InstallationsTabProps { @@ -14,11 +14,13 @@ interface InstallationsTabProps { error: PlatformErrorView | null; searchQuery: string; client: NekiroApiClient; - onInstallAgent: (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => Promise; + initialSelection?: InstallIntent; + onInstallAgent: (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => Promise; onUpdateInstallation: (installation: Installation, status: Exclude) => Promise; onUninstall: (installation: Installation) => Promise; onRefresh: () => void; onPublicInstalled?: () => Promise; + onContinueToInvoke?: (installation: Installation) => void; } export default function InstallationsTab({ @@ -29,11 +31,13 @@ export default function InstallationsTab({ error, searchQuery, client, + initialSelection, onInstallAgent, onUpdateInstallation, onUninstall, onRefresh, onPublicInstalled = async () => {}, + onContinueToInvoke, }: InstallationsTabProps) { const publishedAgents = useMemo(() => agents.filter((agent) => agent.status === 'published'), [agents]); const [selectedAgentKey, setSelectedAgentKey] = useState(''); @@ -46,7 +50,9 @@ export default function InstallationsTab({ const [localError, setLocalError] = useState(null); const [confirmUninstallId, setConfirmUninstallId] = useState(null); const [busyLifecycle, setBusyLifecycle] = useState(false); + const [recentInstallation, setRecentInstallation] = useState(null); const preflightGeneration = useRef(0); + const appliedSelectionSequence = useRef(null); const invalidatePreflight = () => { preflightGeneration.current = nextRequestGeneration(preflightGeneration.current); @@ -89,8 +95,27 @@ export default function InstallationsTab({ setReleaseId(''); setPreflightRelease(null); setLocalError(null); + setRecentInstallation(null); }; + useEffect(() => { + if (!initialSelection || appliedSelectionSequence.current === initialSelection.sequence) return; + const agent = publishedAgents.find((item) => agentKey(item) === initialSelection.agentKey); + if (!agent) return; + appliedSelectionSequence.current = initialSelection.sequence; + invalidatePreflight(); + setSelectedAgentKey(initialSelection.agentKey); + setVersionConstraint(agent.version); + setAcceptedPermissions([]); + setReleaseId(initialSelection.releaseId); + setLocalError(null); + setRecentInstallation(null); + }, [initialSelection, publishedAgents]); + + useEffect(() => { + setRecentInstallation(null); + }, [workspace?.workspaceId]); + const handlePreflight = async () => { if (!selectedAgent) return; const generation = nextRequestGeneration(preflightGeneration.current); @@ -121,7 +146,8 @@ export default function InstallationsTab({ setSubmitting(true); setLocalError(null); try { - await onInstallAgent(selectedAgent, preflightRelease, acceptedPermissions); + const installation = await onInstallAgent(selectedAgent, preflightRelease, acceptedPermissions); + setRecentInstallation(installation); } catch (installError) { setLocalError(toPlatformErrorView(installError, 'Unable to install Agent.')); } finally { @@ -159,6 +185,13 @@ export default function InstallationsTab({ + {recentInstallation && isTrustedEnabledInstallation(recentInstallation) && onContinueToInvoke && ( +
+
Agent installed. The exact Release is enabled in {recentInstallation.workspaceId}.
+ +
+ )} +
@@ -181,7 +214,7 @@ export default function InstallationsTab({
-
{ invalidatePreflight(); setReleaseId(event.target.value); setLocalError(null); }} disabled={!workspace || preflightLoading || busyLifecycle} placeholder="release-id" className="flex-1 bg-brand-lowest border border-brand-outline-variant rounded px-3 py-2 text-brand-on-surface outline-none disabled:opacity-50" />
+
{ invalidatePreflight(); setReleaseId(event.target.value); setLocalError(null); setRecentInstallation(null); }} disabled={!workspace || preflightLoading || busyLifecycle} placeholder="release-id" className="flex-1 bg-brand-lowest border border-brand-outline-variant rounded px-3 py-2 text-brand-on-surface outline-none disabled:opacity-50" />
{preflightRelease &&
Published Release preflight passed
} @@ -223,7 +256,7 @@ export default function InstallationsTab({ {filteredInstallations.length === 0 ? (
No Installation facts returned for this Workspace.
) : filteredInstallations.map((installation) => ( -
+
{installation.agentId}
@@ -243,6 +276,7 @@ export default function InstallationsTab({ : installation.acceptedPermissions.map((permission) => {permission})}
+ {isTrustedEnabledInstallation(installation) && onContinueToInvoke && } {installation.status === 'enabled' && } {installation.status === 'disabled' && } {installation.status === 'disabled' && confirmUninstallId !== installation.installationId && } @@ -258,6 +292,7 @@ export default function InstallationsTab({ ); async function runInstallationAction(installation: Installation, status: Exclude) { + setRecentInstallation(null); setBusyLifecycle(true); try { await onUpdateInstallation(installation, status); @@ -267,6 +302,7 @@ export default function InstallationsTab({ } async function runUninstall(installation: Installation) { + setRecentInstallation(null); setBusyLifecycle(true); try { if (await onUninstall(installation)) setConfirmUninstallId(null); diff --git a/src/components/InvocationsTab.tsx b/src/components/InvocationsTab.tsx index e15fd17..64504ce 100644 --- a/src/components/InvocationsTab.tsx +++ b/src/components/InvocationsTab.tsx @@ -1,38 +1,52 @@ import {useEffect, useMemo, useRef, useState} from 'react'; -import {Activity, CheckCircle2, LoaderCircle, Play, Radio, ShieldAlert} from 'lucide-react'; +import {Activity, CheckCircle2, GitBranch, LoaderCircle, Play, Radio, RotateCcw, ShieldAlert} from 'lucide-react'; -import {NekiroApiClient, toPlatformErrorView, type InvocationResultStreamEventV2} from '../api/nekiro'; -import {isCurrentRequest, isTrustedEnabledInstallation, nextRequestGeneration} from '../consolePolicy'; -import type {Installation, PlatformErrorView, Workspace} from '../types'; +import {toPlatformErrorView, type AgentCardV02, type InvocationResultStreamEventV2, type InvocationResultV1, type NekiroApiClient} from '../api/nekiro'; +import {compatibleSkills, inputTemplateFromSchema, invocationCorrelation, isCurrentRequest, isTrustedEnabledInstallation, nextRequestGeneration} from '../consolePolicy'; +import type {Installation, InvocationIntent, PlatformErrorView, Workspace} from '../types'; interface InvocationsTabProps { workspace: Workspace | null; installations: Installation[]; client: NekiroApiClient; + initialSelection?: InvocationIntent; + onInspect: (invocationId: string, traceId: string) => void; } -export default function InvocationsTab({workspace, installations, client}: InvocationsTabProps) { +export default function InvocationsTab({workspace, installations, client, initialSelection, onInspect}: InvocationsTabProps) { const enabled = useMemo(() => installations.filter(isTrustedEnabledInstallation), [installations]); const [installationId, setInstallationId] = useState(''); + const [card, setCard] = useState(null); + const [cardLoading, setCardLoading] = useState(false); + const [cardError, setCardError] = useState(null); const [capability, setCapability] = useState(''); - const [input, setInput] = useState('{\n "message": "hello"\n}'); + const [input, setInput] = useState('{}'); const [stream, setStream] = useState(false); const [events, setEvents] = useState([]); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const requestGeneration = useRef(0); + const cardRequestGeneration = useRef(0); + const appliedSelectionSequence = useRef(null); + const selectedInstallation = enabled.find((item) => item.installationId === installationId); + const skills = useMemo(() => selectedInstallation ? compatibleSkills(card ? {skills: card.skills} : undefined, selectedInstallation) : [], [card, selectedInstallation]); + const selectedSkill = skills.find((skill) => skill.id === capability); useEffect(() => { requestGeneration.current = nextRequestGeneration(requestGeneration.current); + cardRequestGeneration.current = nextRequestGeneration(cardRequestGeneration.current); setLoading(false); setResult(null); setEvents([]); setError(null); - if (installationId && !enabled.some((item) => item.installationId === installationId)) { - setInstallationId(''); - setCapability(''); - } + setCard(null); + setCardLoading(false); + setCardError(null); + setInstallationId(''); + setCapability(''); + setInput('{}'); + setStream(false); }, [workspace?.workspaceId]); useEffect(() => { @@ -43,20 +57,76 @@ export default function InvocationsTab({workspace, installations, client}: Invoc setEvents([]); setError(null); setInstallationId(''); - setCapability(''); } }, [enabled, installationId]); + useEffect(() => { + if (!initialSelection || appliedSelectionSequence.current === initialSelection.sequence || !enabled.some((item) => item.installationId === initialSelection.installationId)) return; + appliedSelectionSequence.current = initialSelection.sequence; + setInstallationId(initialSelection.installationId); + }, [enabled, initialSelection]); + + useEffect(() => { + const generation = nextRequestGeneration(cardRequestGeneration.current); + cardRequestGeneration.current = generation; + setCard(null); + setCardError(null); + setCapability(''); + setInput('{}'); + setStream(false); + if (!selectedInstallation) { + setCardLoading(false); + return; + } + setCardLoading(true); + void client.getAgentVersion(selectedInstallation.agentId, selectedInstallation.installedVersion).then((entry) => { + if (!isCurrentRequest(generation, cardRequestGeneration.current)) return; + setCard(entry.card); + }).catch((value) => { + if (!isCurrentRequest(generation, cardRequestGeneration.current)) return; + setCardError(toPlatformErrorView(value, 'Unable to read the installed Agent Card.')); + }).finally(() => { + if (isCurrentRequest(generation, cardRequestGeneration.current)) setCardLoading(false); + }); + }, [client, selectedInstallation?.agentId, selectedInstallation?.installedVersion]); + + useEffect(() => { + if (skills.length === 0) { + setCapability(''); + return; + } + if (skills.some((skill) => skill.id === capability)) return; + selectCapability(skills[0].id); + }, [skills]); + + const selectInstallation = (value: string) => { + requestGeneration.current = nextRequestGeneration(requestGeneration.current); + setLoading(false); + setResult(null); + setEvents([]); + setError(null); + setInstallationId(value); + }; + + const selectCapability = (value: string) => { + setCapability(value); + const skill = skills.find((candidate) => candidate.id === value); + if (skill) setInput(JSON.stringify(inputTemplateFromSchema(skill.inputSchema), null, 2)); + }; + const run = async () => { if (!workspace) { setError({status: 0, code: 'CONFIGURATION_ERROR', message: 'Select the active Workspace first.'}); return; } - const installation = enabled.find((item) => item.installationId === installationId); - if (!installation) { + if (!selectedInstallation) { setError({status: 0, code: 'INSTALLATION_DISABLED', message: 'Select an enabled trusted Installation before invoking.'}); return; } + if (!selectedSkill) { + setError({status: 0, code: 'CAPABILITY_NOT_ALLOWED', message: 'Select a capability allowed by this Installation.'}); + return; + } let parsed: unknown; try { parsed = JSON.parse(input); @@ -77,11 +147,11 @@ export default function InvocationsTab({workspace, installations, client}: Invoc setEvents([]); try { if (stream) { - await client.invokeStream(workspaceId, {agentId: installation.agentId, capability, input: parsed as Record}, (event) => { + await client.invokeStream(workspaceId, {agentId: selectedInstallation.agentId, capability: selectedSkill.id, input: parsed as Record}, (event) => { if (isCurrentRequest(generation, requestGeneration.current)) setEvents((current) => [...current, event]); }); } else { - const value = await client.invoke(workspaceId, {agentId: installation.agentId, capability, input: parsed as Record, stream: false}); + const value = await client.invoke(workspaceId, {agentId: selectedInstallation.agentId, capability: selectedSkill.id, input: parsed as Record, stream: false}); if (isCurrentRequest(generation, requestGeneration.current)) setResult(value); } } catch (value) { @@ -94,65 +164,85 @@ export default function InvocationsTab({workspace, installations, client}: Invoc return (
-
Invocations / Owner
-

Invoke an installed Agent

-

Requests use Gateway v4. JSON and SSE responses are validated for correlation and terminal semantics before display.

+
Invoke
+

Try an installed Agent

+

Choose an enabled Installation, use a declared capability, then open the correlated trace directly from the result.

{ setInstallationId(value); setCapability(''); }} + setInstallationId={selectInstallation} + skills={skills} capability={capability} - setCapability={setCapability} + setCapability={selectCapability} + selectedSkill={selectedSkill} input={input} setInput={setInput} stream={stream} setStream={setStream} + streamingSupported={card?.limits.streaming === true} + cardLoading={cardLoading} + cardError={cardError} loading={loading} onSubmit={() => void run()} /> - +
); } -function DispatchForm({workspace, enabled, installationId, setInstallationId, capability, setCapability, input, setInput, stream, setStream, loading, onSubmit}: { +function DispatchForm({workspace, enabled, installationId, setInstallationId, skills, capability, setCapability, selectedSkill, input, setInput, stream, setStream, streamingSupported, cardLoading, cardError, loading, onSubmit}: { workspace: Workspace | null; enabled: Installation[]; installationId: string; setInstallationId: (value: string) => void; + skills: AgentCardV02['skills']; capability: string; setCapability: (value: string) => void; + selectedSkill?: AgentCardV02['skills'][number]; input: string; setInput: (value: string) => void; stream: boolean; setStream: (value: boolean) => void; + streamingSupported: boolean; + cardLoading: boolean; + cardError: PlatformErrorView | null; loading: boolean; onSubmit: () => void; }) { return (
-
Dispatch request
+
Build request
- setCapability(event.target.value)} disabled={loading} placeholder="Enter declared capability" className="w-full rounded-lg border border-brand-outline-variant bg-brand-lowest px-3 py-2 text-sm text-brand-on-surface outline-none disabled:opacity-40" /> - -