Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/014-a2a-protocol-upgrade"
"feature_directory": "specs/015-chat-message-reliability"
}
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 4 additions & 7 deletions admin/slices/agent/agent/components/agent/chat/Tab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
}
Expand Down
22 changes: 15 additions & 7 deletions admin/slices/agent/agent/components/agent/edit/Provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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)"
/>
</section>

Expand Down Expand Up @@ -556,8 +562,10 @@ async function onRemove() {
</AlertDialogRoot>
</template>

<!-- `!removing`: a delete drops the store record before the navigation
away lands — that gap is not a "not found". -->
<div
v-else
v-else-if="!removing"
class="rounded-md border border-dashed p-10 text-center text-sm text-muted-foreground"
>
Agent not found.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 17 additions & 29 deletions admin/slices/agent/agent/components/agent/workspace/Main.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,11 +37,16 @@ const apiUrl =
// until the data arrives. Without lazy, top-level awaits in <script setup>
// block the Vue Router transition until every promise resolves — the user
// perceives this as a multi-second delay before the page opens.
const { data: agent, pending, refresh } = useAsyncData(
//
// The request is here for `pending` and `refresh` only. What renders is the
// store's record (docs/state.md) — the same object the rail row shows, so the
// two cannot disagree; `fetchById` upserts into it.
const { pending, refresh } = useAsyncData(
`admin-agent-${props.id}`,
() => agentStore.fetchById(props.id),
{ lazy: true },
);
const agent = computed(() => agentStore.byId(props.id));

const {
isRestarting,
Expand Down Expand Up @@ -74,20 +78,11 @@ const initials = computed(() =>
agent.value ? agentInitials(agent.value.name, agent.value.id) : '?',
);

// The SSE record is fresher than the fetched row — the drift sweep flips
// 'running' → 'unreachable' between refetches, and the header badge is the
// first place an operator looks.
// Fetches and status-stream frames both land in the one store record, so
// there is no "live vs fetched" to pick between any more.
const agentStatusStore = useAgentStatusStore();
const liveAgent = computed(() => agentStatusStore.agents[props.id]);
const displayStatus = computed(
() =>
(liveAgent.value?.status as IAgentData['status']) ??
agent.value?.status ??
'pending',
);
const statusReason = computed(
() => liveAgent.value?.statusReason ?? agent.value?.statusReason ?? null,
);
const displayStatus = computed(() => agent.value?.status ?? 'pending');
const statusReason = computed(() => agent.value?.statusReason ?? null);
// `=== false` on purpose: undefined means the stream hasn't reported yet.
const runtimeOffline = computed(
() =>
Expand All @@ -102,21 +97,14 @@ const lifecycleError = computed(() => restartError.value || toggleError.value);
// deploy ran", stays 'restart' forever after the first restart) and the
// deploying phase lasts seconds, so a snapshot look always lands on
// status=running. The moment of the last deploy is the missing piece.
// Live-first like displayStatus: the SSE frame carries the full agent row,
// so a restart triggered anywhere (Files-tab banner, rancher, another tab)
// updates the hint without a page reload — the fetched row alone goes stale.
// A restart triggered anywhere (Files-tab banner, rancher, another tab)
// reaches the store record through the status stream, so the hint updates
// without a page reload.
const lastDeployStartedAt = computed(
() =>
liveAgent.value?.lastDeployStartedAt ??
agent.value?.lastDeployStartedAt ??
null,
);
const launchContext = computed(
() => liveAgent.value?.launchContext ?? agent.value?.launchContext ?? null,
);
const lastPullAt = computed(
() => liveAgent.value?.lastPullAt ?? agent.value?.lastPullAt ?? null,
() => agent.value?.lastDeployStartedAt ?? null,
);
const launchContext = computed(() => agent.value?.launchContext ?? null);
const lastPullAt = computed(() => agent.value?.lastPullAt ?? null);
const { locale } = useI18n();
const deployAgo = useTimeAgoIntl(
() => new Date(lastDeployStartedAt.value ?? Date.now()),
Expand Down Expand Up @@ -323,7 +311,7 @@ async function onRemove() {
:toggling="toggling"
@restart="restart"
@toggle-running="toggleRunning"
@agent-updated="(updated) => (agent = updated)"
@agent-updated="(updated) => agentStore.upsert(updated)"
/>
</div>

Expand Down
15 changes: 12 additions & 3 deletions admin/slices/agent/agent/components/agent/workspace/Provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ const route = useRoute();

// One list request for the whole workspace. It lives here — above the keyed
// `Main` — so switching agents does not refetch it or flash the rail.
const { data: agents, pending, refresh: refreshAgents } = useAsyncData(
//
// The request gives `pending` and `refresh`; the rail renders the store's
// collection (docs/state.md). Rendering the array this request returned is
// what froze a row on "Deploying" while the open agent — a separately fetched
// copy — had already moved on to "Failed".
const { pending, refresh: refreshAgents } = useAsyncData(
'admin-agents',
() => agentStore.fetchAll(),
{ lazy: true },
);
const { agents } = storeToRefs(agentStore);

// Cluster headroom, rendered in the rail's footer next to the create action.
// Store actions refetch on their own; the interval catches pods actually
Expand Down Expand Up @@ -104,9 +110,12 @@ onUnmounted(() => {
* rail falls through to the resolver, which renders the empty state.
*/
async function onDeleted() {
await refreshAgents();
const next = (agents.value ?? []).find((a) => a.id !== props.id);
// `remove` already dropped the record from the store, so the next agent is
// known right now — navigate first (the pane has nothing left to render),
// then let the refetch confirm the list.
const next = agents.value.find((a) => a.id !== props.id);
await router.replace(next ? `/agents/${next.id}` : '/agents');
void refreshAgents();
}
</script>

Expand Down
14 changes: 14 additions & 0 deletions admin/slices/agent/agent/components/agent/workspace/RailItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ defineProps<{ entry: IRailEntry }>();
{{ formatDate(entry.createdAt) }}
</span>
</span>

<!-- Why it is not running, where the operator scans for trouble — the
same condition and text as the Overview card, so the row never says
less than the open agent does. -->
<span
v-if="
(entry.status === 'failed' || entry.status === 'unreachable') &&
entry.statusReason
"
class="mt-0.5 block truncate text-xs text-muted-foreground"
:title="entry.statusReason"
>
{{ entry.statusReason }}
</span>
</span>
</button>
</template>
30 changes: 12 additions & 18 deletions admin/slices/agent/agent/composables/useAgentLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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');
Expand All @@ -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<typeof setInterval> | null = null;
// While a lifecycle mutation (restart/stop/start) is awaiting its HTTP
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading