diff --git a/.specify/feature.json b/.specify/feature.json index 35781f75..1ce695e1 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/014-a2a-protocol-upgrade" + "feature_directory": "specs/015-chat-message-reliability" } diff --git a/CLAUDE.md b/CLAUDE.md index 383525b8..9d490a4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,4 +27,9 @@ string. `en.json` per slice is the source, `bun run i18n:sync` generates `ru`, templates use the injected `$t`, and copy decided in script travels as a key. Never hand-write `ru.json` as the first step. `admin/` stays English-only. +**Client state (`admin` + `app`):** read `docs/state.md` before adding a store, +a fetch or a live feed. An entity lives once in its Pinia store: fetches upsert, +pushes patch, components render by id, `useAsyncData` is for loading state only, +optimistic changes go through the store's `patch()` with a rollback. + Project overview: `README.md`. diff --git a/admin/package.json b/admin/package.json index e1bc9b7a..d116a387 100644 --- a/admin/package.json +++ b/admin/package.json @@ -12,7 +12,7 @@ "pretypecheck": "openapi-ts", "typecheck": "nuxt typecheck", "lint": "echo 'admin lint: not configured (see eslint.config.mjs TODO)'", - "test": "echo 'admin test: no tests yet'" + "test": "bun test slices" }, "dependencies": { "@hey-api/client-axios": "^0.6", diff --git a/admin/slices/agent/agent/components/agent/chat/Tab.vue b/admin/slices/agent/agent/components/agent/chat/Tab.vue index f6621498..ba6dc70e 100644 --- a/admin/slices/agent/agent/components/agent/chat/Tab.vue +++ b/admin/slices/agent/agent/components/agent/chat/Tab.vue @@ -45,12 +45,9 @@ const restartUnderway = computed( // reads as "Agent reconnecting…" for 30s on a freshly opened page, because // bridle only sees its own WS. const agentStatusStore = useAgentStatusStore(); -const liveAgent = computed(() => agentStatusStore.agents[props.agent.id]); -// The SSE record is fresher than the row this page fetched — the drift sweep -// flips 'running' → 'unreachable' between refetches. -const displayStatus = computed( - () => liveAgent.value?.status ?? props.agent.status, -); +// `props.agent` is the store record, which the status stream writes into — +// the drift sweep's 'running' → 'unreachable' arrives here by itself. +const displayStatus = computed(() => props.agent.status); const bridleAgentState = computed(() => { if (restartUnderway.value) return 'restarting'; @@ -81,7 +78,7 @@ const hubUnreachable = computed( const offlineHint = computed(() => hubUnreachable.value ? { - reason: liveAgent.value?.statusReason ?? props.agent.statusReason, + reason: props.agent.statusReason, envHref: `/agents/${props.agent.id}?tab=env`, settingsHref: '/settings/bridle', } diff --git a/admin/slices/agent/agent/components/agent/edit/Provider.vue b/admin/slices/agent/agent/components/agent/edit/Provider.vue index c950b328..49ebbfc1 100644 --- a/admin/slices/agent/agent/components/agent/edit/Provider.vue +++ b/admin/slices/agent/agent/components/agent/edit/Provider.vue @@ -33,8 +33,13 @@ const apiUrl = (typeof process !== 'undefined' ? process.env.API_URL : undefined) ?? 'http://localhost:3333'; +// The agent request is here for `pending` / `refresh`; the page renders the +// store's record (docs/state.md), which `fetchById` and every mutation below +// upsert into. +const agent = computed(() => agentStore.byId(props.id)); + const [ - { data: agent, pending: pendingAgent, refresh: refreshAgent }, + { pending: pendingAgent, refresh: refreshAgent }, { data: templates, pending: pendingTemplates }, { pending: pendingLlms }, { data: knowledges, pending: pendingKnowledges }, @@ -180,10 +185,11 @@ async function onPromote() { promoting.value = true; promoteError.value = null; try { - const updated = agent.value.isAdmin - ? await agentStore.demoteAdmin(agent.value.id) - : await agentStore.promoteAdmin(agent.value.id); - agent.value = { ...updated, status: 'deploying' }; + if (agent.value.isAdmin) await agentStore.demoteAdmin(agent.value.id); + else await agentStore.promoteAdmin(agent.value.id); + // Both redeploy the agent; show it until the refetch confirms. No + // rollback — the change itself has already succeeded. + agentStore.patch(props.id, { status: 'deploying' }); await refreshAgent(); } catch (err) { promoteError.value = (err as Error).message || 'Promote failed'; @@ -357,7 +363,7 @@ async function onRemove() { :api-url="apiUrl" :is-public="agent.isPublic" :allowed-origins="agent.allowedOrigins" - @saved="(updated) => (agent = updated)" + @saved="(updated) => agentStore.upsert(updated)" /> @@ -556,8 +562,10 @@ async function onRemove() { +
Agent not found. diff --git a/admin/slices/agent/agent/components/agent/overview/RuntimeCard.vue b/admin/slices/agent/agent/components/agent/overview/RuntimeCard.vue index 54353da6..c44d0e43 100644 --- a/admin/slices/agent/agent/components/agent/overview/RuntimeCard.vue +++ b/admin/slices/agent/agent/components/agent/overview/RuntimeCard.vue @@ -10,15 +10,10 @@ const agentStatusStore = useAgentStatusStore(); const podStatus = computed(() => agentStatusStore.statuses[props.agent.id] ?? null); const podLabel = computed(() => podPhaseLabel(podStatus.value)); -// The SSE record is fresher than the fetched row (the drift sweep writes -// 'unreachable' between refetches) — prefer it when present. -const liveAgent = computed(() => agentStatusStore.agents[props.agent.id]); -const displayStatus = computed( - () => (liveAgent.value?.status as IAgentData['status']) || props.agent.status, -); -const statusReason = computed( - () => liveAgent.value?.statusReason ?? props.agent.statusReason, -); +// `props.agent` is the store record — fetches and status-stream frames both +// land in it, so this card, the header and the rail row read one value. +const displayStatus = computed(() => props.agent.status); +const statusReason = computed(() => props.agent.statusReason); // Explicit `=== false`: undefined just means the stream hasn't reported yet. const runtimeOffline = computed( () => diff --git a/admin/slices/agent/agent/components/agent/status/Indicator.vue b/admin/slices/agent/agent/components/agent/status/Indicator.vue index ae865d1f..9da92802 100644 --- a/admin/slices/agent/agent/components/agent/status/Indicator.vue +++ b/admin/slices/agent/agent/components/agent/status/Indicator.vue @@ -22,7 +22,10 @@ const pulseClass = computed(() => : '', ); -const totalAgents = computed(() => Object.keys(store.agents).length); +// Agents live in the agent store (docs/state.md); the stream's snapshot loads +// that collection, so the count is still "what the stream knows about". +const agentStore = useAgentStore(); +const totalAgents = computed(() => agentStore.agents.length); const runningCount = computed( () => Object.values(store.statuses).filter( diff --git a/admin/slices/agent/agent/components/agent/workspace/Main.vue b/admin/slices/agent/agent/components/agent/workspace/Main.vue index 6f7ffb3a..345e17b7 100644 --- a/admin/slices/agent/agent/components/agent/workspace/Main.vue +++ b/admin/slices/agent/agent/components/agent/workspace/Main.vue @@ -17,7 +17,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '#theme/components/ui/dropdown-menu'; -import type { IAgentData } from '#agent/domain'; import { agentInitials } from '#agent/composables/useAgentRailEntries'; import { useAgentSectionCounts } from '#agent/composables/useAgentSectionCounts'; import { useAgentTab } from '#agent/composables/useAgentTab'; @@ -38,11 +37,16 @@ const apiUrl = // until the data arrives. Without lazy, top-level awaits in diff --git a/admin/slices/agent/agent/components/agent/workspace/RailItem.vue b/admin/slices/agent/agent/components/agent/workspace/RailItem.vue index e54cdde7..88bd495a 100644 --- a/admin/slices/agent/agent/components/agent/workspace/RailItem.vue +++ b/admin/slices/agent/agent/components/agent/workspace/RailItem.vue @@ -60,6 +60,20 @@ defineProps<{ entry: IRailEntry }>(); {{ formatDate(entry.createdAt) }} + + + + {{ entry.statusReason }} + diff --git a/admin/slices/agent/agent/composables/useAgentLifecycle.ts b/admin/slices/agent/agent/composables/useAgentLifecycle.ts index b03006a1..076ab410 100644 --- a/admin/slices/agent/agent/composables/useAgentLifecycle.ts +++ b/admin/slices/agent/agent/composables/useAgentLifecycle.ts @@ -55,7 +55,7 @@ export function useAgentLifecycle( // DOWN and come back (or the reconciled status confirms running/failed). const agentWentDown = ref(false); watch( - () => bridleStore.isAgentConnected, + () => bridleStore.isAgentConnectedFor(agentId), (up) => { if (!up && (restarting.value || agentStore.isRestartInFlight(agentId))) { agentWentDown.value = true; @@ -79,17 +79,15 @@ export function useAgentLifecycle( // status='deploying') still shows the overlay. agentStore.markRestartInFlight(agentId); // Restarting a dead pod skips the "goes down" phase — it's already down. - agentWentDown.value = !bridleStore.isAgentConnected; - // Optimistic — flip to "deploying" right away so the badge reacts before - // the API call resolves (cancel + submit takes a few seconds). - const previousStatus = agent.value.status; - agent.value = { ...agent.value, status: 'deploying' }; + agentWentDown.value = !bridleStore.isAgentConnectedFor(agentId); + // The optimistic "deploying" flip (and its rollback on error) happens + // inside `agentStore.restart`, on the one store record `agent` is read + // from — so the rail row flips together with this header. try { await agentStore.restart(agentId); agentStore.clearPendingRestart(agentId); await refresh(); } catch (err) { - if (agent.value) agent.value = { ...agent.value, status: previousStatus }; agentStore.clearRestartInFlight(agentId); restartError.value = (err as Error).message || 'Restart failed'; } finally { @@ -111,13 +109,8 @@ export function useAgentLifecycle( if (!agent.value || toggling.value) return; toggling.value = true; toggleError.value = null; - const previousStatus = agent.value.status; + // Read before the call: the store's optimistic flip changes `canStop`. const stopping = canStop.value; - // Optimistic flip so the badge reacts before the API resolves. - agent.value = { - ...agent.value, - status: stopping ? 'stopped' : 'deploying', - }; try { if (stopping) { await agentStore.stop(agentId); @@ -129,7 +122,6 @@ export function useAgentLifecycle( } await refresh(); } catch (err) { - if (agent.value) agent.value = { ...agent.value, status: previousStatus }; if (!stopping) agentStore.clearRestartInFlight(agentId); toggleError.value = (err as Error).message || (stopping ? 'Stop failed' : 'Start failed'); @@ -149,7 +141,7 @@ export function useAgentLifecycle( // 'running' while the pod is still being recreated (or is gone) — without // polling nothing reactive ever changes, the overlay computed freezes and // the flag's TTL never gets re-evaluated, pinning "restarting" forever. - // Each refresh replaces the agent ref, which re-runs the computeds (fresh + // Each refresh replaces the store record, which re-runs the computeds (fresh // Date.now() → TTL honored) and gives the server a chance to reconcile. let statusTimer: ReturnType | null = null; // While a lifecycle mutation (restart/stop/start) is awaiting its HTTP @@ -228,7 +220,9 @@ export function useAgentLifecycle( // Strongest "agent is up" signal: chat WS is connected AND the runtime is // registered with the hub. This bypasses DB/pod entirely — if the agent // is actually talking to us, nothing else matters. - const chatLive = bridleStore.isConnected && bridleStore.isAgentConnected; + const chatLive = + bridleStore.isConnectedFor(agentId) && + bridleStore.isAgentConnectedFor(agentId); if (chatLive) return null; @@ -270,8 +264,8 @@ export function useAgentLifecycle( [ agent.value?.status, podStatus.value?.ready, - bridleStore.isConnected, - bridleStore.isAgentConnected, + bridleStore.isConnectedFor(agentId), + bridleStore.isAgentConnectedFor(agentId), ] as const, ([status, ready, chatConnected, agentConnected]) => { const chatLive = chatConnected && agentConnected; diff --git a/admin/slices/agent/agent/composables/useAgentRailEntries.ts b/admin/slices/agent/agent/composables/useAgentRailEntries.ts index abf0eb36..995a98fc 100644 --- a/admin/slices/agent/agent/composables/useAgentRailEntries.ts +++ b/admin/slices/agent/agent/composables/useAgentRailEntries.ts @@ -68,34 +68,16 @@ export function agentInitials(name: string, id: string): string { } /** - * Live pod state wins over the DB row where we have it — the same precedence - * `rancher/Provider.vue` uses. The DB row is reconciled asynchronously, so - * right after a stop/restart it can lag the pod by seconds; the rail is the - * one place where that lag is visible across every agent at once. + * The rail's view model: the agent store's records, filtered by the search + * term. The Ranch admin agent (Rancher) is pinned first — it is the agent an + * operator reaches for most — and the rest keep the list's own order. * - * No pod at all means either "never deployed" or "stopped" — both are states - * the DB row describes correctly, so we defer to it rather than inventing one. - */ -function reconcileStatus( - agent: IAgentData, - pod: { phase: string; ready: boolean } | undefined, -): AgentStatusTypes { - // 'unreachable' is precisely "pod healthy, runtime absent" — a Running+Ready - // pod is part of the diagnosis, not evidence against it. Letting the pod - // override to green here would re-create the incident this status exposes. - if (agent.status === 'unreachable') return 'unreachable'; - if (!pod) return agent.status; - if (pod.phase === 'Running') return pod.ready ? 'running' : 'deploying'; - if (pod.phase === 'Pending') return 'pending'; - if (pod.phase === 'Failed') return 'failed'; - return agent.status; -} - -/** - * The rail's view model: the agent list, reconciled against the live status - * stream, filtered by the search term. The Ranch admin agent (Rancher) is - * pinned first — it is the agent an operator reaches for most — and the rest - * keep the list's own order. + * A row shows the record's own `status` / `statusReason` — exactly what the + * open agent's header shows (docs/state.md). It used to derive a status of its + * own from the pod phase, which is a second opinion the header never shared: + * two derivations of one fact is how a row and a header end up disagreeing. + * The server reconciles pod state into the row and the status stream delivers + * it here. * * Deliberately carries no action handlers — a rail entry identifies an agent * and nothing more (FR-002). Restart/stop/delete live in the settings panel. @@ -105,8 +87,6 @@ export function useAgentRailEntries( activeId: Ref, search: Ref, ) { - const agentStatusStore = useAgentStatusStore(); - return computed(() => { const term = search.value.trim().toLowerCase(); return (agents.value ?? []) @@ -114,26 +94,16 @@ export function useAgentRailEntries( // Stable sort: admin agents float to the top, everything else keeps // its relative order. .sort((a, b) => Number(b.isAdmin) - Number(a.isAdmin)) - .map((a) => { - const live = agentStatusStore.agents[a.id]; - // The SSE record is fresher than the fetched list (the sweep writes - // 'unreachable' between refetches) — prefer it when present. - const dbStatus = (live?.status as AgentStatusTypes) || a.status; - const status = reconcileStatus( - { ...a, status: dbStatus }, - agentStatusStore.statuses[a.id], - ); - return { - id: a.id, - name: a.name, - initials: agentInitials(a.name, a.id), - status, - statusReason: live?.statusReason ?? a.statusReason, - tone: TONE[status], - createdAt: a.createdAt, - isAdmin: a.isAdmin, - isActive: a.id === activeId.value, - }; - }); + .map((a) => ({ + id: a.id, + name: a.name, + initials: agentInitials(a.name, a.id), + status: a.status, + statusReason: a.statusReason, + tone: TONE[a.status], + createdAt: a.createdAt, + isAdmin: a.isAdmin, + isActive: a.id === activeId.value, + })); }); } diff --git a/admin/slices/agent/agent/data/agent.mapper.ts b/admin/slices/agent/agent/data/agent.mapper.ts index ac281402..dd0a0cc1 100644 --- a/admin/slices/agent/agent/data/agent.mapper.ts +++ b/admin/slices/agent/agent/data/agent.mapper.ts @@ -20,6 +20,9 @@ const KNOWN_STATUSES = new Set([ 'running', 'failed', 'stopped', + // Was missing: a fetched 'unreachable' row decoded as 'pending', which the + // screens papered over by preferring a second, stream-fed copy of the agent. + 'unreachable', ]); const KNOWN_LAUNCH_CONTEXTS = new Set([ diff --git a/admin/slices/agent/agent/data/agentStatus.mapper.ts b/admin/slices/agent/agent/data/agentStatus.mapper.ts index be7c3f14..8e36010f 100644 --- a/admin/slices/agent/agent/data/agentStatus.mapper.ts +++ b/admin/slices/agent/agent/data/agentStatus.mapper.ts @@ -5,6 +5,7 @@ import type { IAgentRecord, IAgentStatus, } from '../domain/agentStatus.types'; +import { AgentMapper } from './agent.mapper'; const EVENT_TYPES = new Set([ 'added', @@ -18,9 +19,20 @@ const EVENT_TYPES = new Set([ * so the store's reducer can't crash on a malformed payload. */ export class AgentStatusMapper { + // One decoder for an agent row, whichever transport carried it — REST and + // the stream must not drift into two shapes of the same entity. + private agentMapper = new AgentMapper(); + toStreamMessage(raw: unknown): AgentStatusStreamMessage | null { if (!raw || typeof raw !== 'object') return null; - const o = raw as Record; + let o = raw as Record; + // The API's response interceptor wraps each SSE emission too, so a frame + // arrives as `{ data: { type, payload } }`. Reading `type` off the wrapper + // dropped every frame silently — the stream looked connected and fed + // nothing. Accept both shapes so a bare frame keeps working. + if (o.type === undefined && o.data && typeof o.data === 'object') { + o = o.data as Record; + } if (o.type === 'snapshot') { const items = Array.isArray(o.payload) ? o.payload : []; @@ -56,7 +68,7 @@ export class AgentStatusMapper { private toStatus(raw: unknown): IAgentStatus | null { if (!raw || typeof raw !== 'object') return null; const o = raw as Record; - const agent = this.toAgent(o.agent); + const agent: IAgentRecord | null = this.agentMapper.toEntity(o.agent); if (!agent) return null; return { agent, @@ -65,28 +77,6 @@ export class AgentStatusMapper { }; } - private toAgent(raw: unknown): IAgentRecord | null { - if (!raw || typeof raw !== 'object') return null; - const o = raw as Record; - if (typeof o.id !== 'string') return null; - return { - id: o.id, - name: typeof o.name === 'string' ? o.name : '', - status: typeof o.status === 'string' ? o.status : '', - statusReason: typeof o.statusReason === 'string' ? o.statusReason : null, - lastDeployStartedAt: - typeof o.lastDeployStartedAt === 'string' - ? o.lastDeployStartedAt - : null, - launchContext: - o.launchContext === 'initial' || o.launchContext === 'restart' - ? o.launchContext - : null, - lastPullAt: typeof o.lastPullAt === 'string' ? o.lastPullAt : null, - lastSyncAt: typeof o.lastSyncAt === 'string' ? o.lastSyncAt : null, - }; - } - // Pod fields come from our own API; validate presence and trust the shape. private toPod(raw: unknown): IAgentPodStatus | null { if (!raw || typeof raw !== 'object') return null; diff --git a/admin/slices/agent/agent/domain/agentStatus.types.ts b/admin/slices/agent/agent/domain/agentStatus.types.ts index 01d88bcd..77c7eccc 100644 --- a/admin/slices/agent/agent/domain/agentStatus.types.ts +++ b/admin/slices/agent/agent/domain/agentStatus.types.ts @@ -2,6 +2,8 @@ // the EventSource transport and parses raw frames into these; the store applies // them to reactive state. +import type { IAgentData } from './agent.types'; + export type ConnectionStateTypes = | 'idle' | 'connecting' @@ -21,20 +23,11 @@ export interface IAgentPodStatus { observedAt: string; } -export interface IAgentRecord { - id: string; - name: string; - status: string; - statusReason: string | null; - // Deploy/pull markers ride the stream because the API sends the full agent - // row in every frame; without them the header's "restarted N ago" (and the - // Files-tab copy banner) would freeze on the one-shot fetched row when a - // restart is triggered from anywhere else (CLEAN-59). - lastDeployStartedAt: string | null; - launchContext: 'initial' | 'restart' | null; - lastPullAt: string | null; - lastSyncAt: string | null; -} +// The API sends the full agent row (`AgentDto`) in every frame — the same +// shape GET /agents returns. It is decoded as the same `IAgentData` so the +// store can write it straight into the one agent record (docs/state.md); a +// thinner projection here is what used to make the stream a second copy. +export type IAgentRecord = IAgentData; export interface IAgentStatus { agent: IAgentRecord; diff --git a/admin/slices/agent/agent/pages/agents/index.vue b/admin/slices/agent/agent/pages/agents/index.vue index 55e82f37..c9d485fd 100644 --- a/admin/slices/agent/agent/pages/agents/index.vue +++ b/admin/slices/agent/agent/pages/agents/index.vue @@ -8,12 +8,15 @@ // agent should leave the agents area, not bounce through the resolver. const agentStore = useAgentStore(); -const { data: agents, pending } = await useAsyncData('admin-agents', () => +// The request is awaited for its loading state; the list itself is read from +// the store like everywhere else (docs/state.md). +const { pending } = await useAsyncData('admin-agents', () => agentStore.fetchAll(), ); +const { agents } = storeToRefs(agentStore); const landing = computed(() => { - const list = agents.value ?? []; + const list = agents.value; if (!list.length) return null; // The Ranch admin agent is the one an operator almost always wants: it is // the agent that can act on the rest of the install. @@ -30,7 +33,7 @@ watchEffect(() => {