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
77 changes: 69 additions & 8 deletions admin/slices/agent/toolCatalog/components/toolCatalog/Sheet.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Search, X } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Loader2, Search, X } from 'lucide-vue-next'
import {
Sheet,
SheetContent,
Expand Down Expand Up @@ -41,11 +41,24 @@ const catalog = computed(() => store.byAgent(props.agentId))
const agent = computed(() => agentStore.byId(props.agentId))
const ui = computed(() => store.uiOf(props.agentId))

const { pending, error, refresh } = useAsyncData(
`admin-tool-catalog-${props.agentId}`,
() => store.fetchByAgent(props.agentId),
{ immediate: false, server: false },
)
// Loading state only; the record itself is read from the store (docs/state.md).
// Plain refs rather than useAsyncData: this sheet re-reads on every open and
// polls while the agent boots, and a keyed cache in between is exactly the
// kind of second copy that showed a restarted agent its old list.
const pending = ref(false)
const error = ref<unknown>(null)

async function refresh(): Promise<void> {
pending.value = true
try {
await store.fetchByAgent(props.agentId)
error.value = null
} catch (e) {
error.value = e
} finally {
pending.value = false
}
}

// Load on first open, and again on every open so a tool that landed since is
// there; the cached record renders meanwhile, so nothing flickers.
Expand All @@ -68,6 +81,46 @@ watch(
},
)

// A freshly started pod lists its tools a few seconds after it is up. While
// the sheet is open and the API says the listing is still pending, poll —
// bounded, so a pod that never lists (or an operator who walks away) does not
// keep the tab busy forever.
const POLL_MS = 4000
const POLL_LIMIT = 30
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollCount = 0

function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = null
pollCount = 0
}

function schedulePoll() {
if (pollTimer || !props.open) return
if (catalog.value?.listingState !== 'pending') return
if (pollCount >= POLL_LIMIT) return
pollTimer = setTimeout(async () => {
pollTimer = null
pollCount += 1
if (!props.open) return
await refresh()
schedulePoll()
}, POLL_MS)
}

watch(
() => [props.open, catalog.value?.listingState] as const,
([isOpen, state]) => {
if (!isOpen) return stopPolling()
if (state === 'pending') schedulePoll()
else stopPolling()
},
{ immediate: true },
)

onBeforeUnmount(stopPolling)

const query = computed({
get: () => ui.value.query,
set: (value: string) => store.setQuery(props.agentId, value),
Expand Down Expand Up @@ -192,11 +245,19 @@ const searchRef = ref<InstanceType<typeof Input> | null>(null)
</ScrollArea>

<p
v-if="catalog?.podStartedAt === null"
v-if="catalog?.listingState === 'none'"
class="border-t px-4 py-2 text-[11px] text-muted-foreground"
>
The agent is not running. Tools are listed as they will be once it starts.
</p>
<p
v-else-if="catalog?.listingState === 'pending'"
class="flex items-center gap-1.5 border-t px-4 py-2 text-[11px] text-muted-foreground"
role="status"
>
<Loader2 class="size-3 animate-spin" />
The agent has started and is loading its tools; this list updates on its own.
</p>
</SheetContent>
</Sheet>
</template>
4 changes: 4 additions & 0 deletions admin/slices/agent/toolCatalog/data/toolCatalog.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export class ToolCatalogMapper {
agentId: str(dto.agentId),
podStartedAt: dto.podStartedAt ?? null,
listedAt: dto.listedAt ?? null,
listingState:
dto.listingState === 'fresh' || dto.listingState === 'pending'
? dto.listingState
: 'none',
groups: Array.isArray(dto.groups)
? dto.groups.map((g) => this.toGroup(g))
: [],
Expand Down
7 changes: 7 additions & 0 deletions admin/slices/agent/toolCatalog/domain/toolCatalog.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ export interface IAgentToolGroup {
tools: IAgentToolEntry[];
}

/**
* none — no pod runs; pending — the running pod has not listed its tools yet;
* fresh — the per-tool `inPod` flags come from this pod.
*/
export type ToolListingStates = 'none' | 'pending' | 'fresh';

export interface IAgentToolCatalog {
agentId: string;
podStartedAt: string | null;
listedAt: string | null;
listingState: ToolListingStates;
groups: IAgentToolGroup[];
}

Expand Down
1 change: 1 addition & 0 deletions admin/slices/agent/toolCatalog/utils/filterCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const catalog: IAgentToolCatalog = {
agentId: 'a',
podStartedAt: null,
listedAt: null,
listingState: 'none',
groups: [
{
key: 'agents',
Expand Down
8 changes: 7 additions & 1 deletion admin/slices/setup/api/data/repositories/api/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3549,14 +3549,20 @@ export const AgentToolCatalogDtoSchema = {
description: "When the pod last called tools/list; null if it never did.",
example: "2026-09-22T10:33:05.000Z",
},
listingState: {
type: "string",
enum: ["none", "pending", "fresh"],
description:
"none — no pod runs; pending — the running pod has not listed its tools yet, so no inPod flag is set; fresh — the snapshot is from this pod.",
},
groups: {
type: "array",
items: {
$ref: "#/components/schemas/AgentToolGroupDto",
},
},
},
required: ["agentId", "podStartedAt", "listedAt", "groups"],
required: ["agentId", "podStartedAt", "listedAt", "listingState", "groups"],
} as const;

export const SecretEntryDtoSchema = {
Expand Down
4 changes: 4 additions & 0 deletions admin/slices/setup/api/data/repositories/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,10 @@ export type AgentToolCatalogDto = {
* When the pod last called tools/list; null if it never did.
*/
listedAt: string | null;
/**
* none — no pod runs; pending — the running pod has not listed its tools yet, so no inPod flag is set; fresh — the snapshot is from this pod.
*/
listingState: "none" | "pending" | "fresh";
groups: Array<AgentToolGroupDto>;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { NotFoundException } from '@nestjs/common';
import { ToolCatalogService, agentPrincipal } from './toolCatalog.service';
import {
ToolCatalogService,
agentPrincipal,
listingStateOf,
} from './toolCatalog.service';
import type { IListedTool } from '#/mcp/services/tool-catalog.service';
import { UserRoleTypes } from '#/user/user/domain';

Expand Down Expand Up @@ -130,27 +134,51 @@ describe('ToolCatalogService.forAgent', () => {
});
});

it('inPod: null without a pod, false for a pod that never listed, per name otherwise', async () => {
it('inPod: null without a pod or a listing from this pod, per name once the pod listed', async () => {
const tools = [listed('old', 'agents'), listed('new', 'agents')];

const noPod = await harness({ tools, pod: false }).service.forAgent('agent-1');
expect(noPod.podStartedAt).toBeNull();
expect(noPod.listingState).toBe('none');
expect(noPod.groups[0].tools.map((t) => t.inPod)).toEqual([null, null]);
expect(noPod.groups[0].afterRestart).toBe(false);

// Never listed: we cannot say what the pod lacks, so no badge.
const neverListed = await harness({ tools, snapshot: null }).service.forAgent('agent-1');
expect(neverListed.listedAt).toBeNull();
expect(neverListed.groups[0].tools.map((t) => t.inPod)).toEqual([false, false]);
expect(neverListed.groups[0].afterRestart).toBe(true);
expect(neverListed.listingState).toBe('pending');
expect(neverListed.groups[0].tools.map((t) => t.inPod)).toEqual([null, null]);
expect(neverListed.groups[0].afterRestart).toBe(false);

// The previous pod's snapshot, right after a restart: the new pod has not
// listed yet, and its predecessor's list must not read as this pod's.
const stale = await harness({
tools,
snapshot: { toolNames: ['old'], listedAt: new Date('2026-09-22T09:00:00.000Z') },
}).service.forAgent('agent-1');
expect(stale.listingState).toBe('pending');
expect(stale.groups[0].tools.map((t) => t.inPod)).toEqual([null, null]);
expect(stale.groups[0].afterRestart).toBe(false);

// Listed by this pod (a few seconds after it started): precise flags.
const partial = await harness({
tools,
snapshot: { toolNames: ['old'], listedAt: new Date(POD_START) },
snapshot: { toolNames: ['old'], listedAt: new Date('2026-09-22T10:00:05.000Z') },
}).service.forAgent('agent-1');
expect(partial.listingState).toBe('fresh');
const byName = Object.fromEntries(partial.groups[0].tools.map((t) => [t.name, t.inPod]));
expect(byName).toEqual({ old: true, new: false });
expect(partial.groups[0].afterRestart).toBe(true);
expect(partial.listedAt).toBe(POD_START);
expect(partial.listedAt).toBe('2026-09-22T10:00:05.000Z');
});

it('listingStateOf tolerates clock skew between kubelet and the API', () => {
expect(listingStateOf(null, new Date())).toBe('none');
expect(listingStateOf(POD_START, null)).toBe('pending');
// Listed 3 s "before" the pod started: two clocks, one pod — still fresh.
expect(listingStateOf(POD_START, new Date('2026-09-22T09:59:57.000Z'))).toBe('fresh');
// Listed a minute before: a previous pod.
expect(listingStateOf(POD_START, new Date('2026-09-22T09:59:00.000Z'))).toBe('pending');
});

it('appends external servers as opaque groups without url or auth, with drift', async () => {
Expand Down
38 changes: 32 additions & 6 deletions api/src/slices/agent/toolCatalog/domain/toolCatalog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
IAgentToolEntry,
IAgentToolGroup,
IToolListingGateway,
ToolListingStates,
} from './toolCatalog.types';

/**
Expand Down Expand Up @@ -76,14 +77,20 @@ export class ToolCatalogService {

const pod = pods.find((p) => p.agentId === agentId) ?? null;
const podStartedAt = pod?.startedAt ?? null;
const known = snapshot ? new Set(snapshot.toolNames) : null;

// A snapshot only speaks for the pod that made it. Right after a restart
// the new pod is up before it has listed its tools, and the previous
// pod's snapshot would read as "this pod lacks everything" — a badge that
// told the person to restart the agent they had just restarted. So a
// snapshot older than the pod counts as "not listed yet": no badge,
// and the sheet says the agent is still loading its tools.
const listingState = listingStateOf(podStartedAt, snapshot?.listedAt ?? null);
const known =
listingState === 'fresh' && snapshot ? new Set(snapshot.toolNames) : null;

const inPodOf = (name: string): boolean | null => {
// No pod: nothing to restart, so no marker (data-model.md §3).
if (!podStartedAt) return null;
// A pod that never listed predates this feature — it needs a restart
// to receive anything new, so every tool reads as absent.
if (!known) return false;
// No pod, or no listing from this pod yet: nothing to claim.
if (!known) return null;
return known.has(name);
};

Expand Down Expand Up @@ -140,7 +147,26 @@ export class ToolCatalogService {
agentId,
podStartedAt,
listedAt: snapshot ? snapshot.listedAt.toISOString() : null,
listingState,
groups,
};
}
}

/**
* Container start and the first tools/list are seconds apart in the same
* direction, but the two clocks (kubelet vs. this API) need not agree to the
* millisecond; a small slack keeps a legitimate listing from reading as
* older than its pod.
*/
const CLOCK_SLACK_MS = 10_000;

export function listingStateOf(
podStartedAt: string | null,
listedAt: Date | null,
): ToolListingStates {
if (!podStartedAt) return 'none';
if (!listedAt) return 'pending';
const start = new Date(podStartedAt).getTime();
return listedAt.getTime() + CLOCK_SLACK_MS >= start ? 'fresh' : 'pending';
}
8 changes: 8 additions & 0 deletions api/src/slices/agent/toolCatalog/domain/toolCatalog.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,20 @@ export interface IAgentToolGroup {
tools: IAgentToolEntry[];
}

/**
* none — no pod runs; pending — the running pod has not listed its tools yet
* (or only an older pod did); fresh — the snapshot comes from this pod, so
* per-tool `inPod` flags are meaningful.
*/
export type ToolListingStates = 'none' | 'pending' | 'fresh';

export interface IAgentToolCatalog {
agentId: string;
/** ISO; null when no pod runs. */
podStartedAt: string | null;
/** ISO of the last tools/list the pod made; null if it never did. */
listedAt: string | null;
listingState: ToolListingStates;
groups: IAgentToolGroup[];
}

Expand Down
7 changes: 7 additions & 0 deletions api/src/slices/agent/toolCatalog/dto/agentToolCatalog.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ export class AgentToolCatalogDto {
})
listedAt: string | null;

@ApiProperty({
enum: ['none', 'pending', 'fresh'],
description:
'none — no pod runs; pending — the running pod has not listed its tools yet, so no inPod flag is set; fresh — the snapshot is from this pod.',
})
listingState: 'none' | 'pending' | 'fresh';

@ApiProperty({ type: [AgentToolGroupDto] })
groups: AgentToolGroupDto[];
}
Loading
Loading