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
9 changes: 5 additions & 4 deletions docs/testing/automated_test_catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ flowchart TD
- Do not hand-edit suite inventory entries in this file. Update the generator or the repository tree, then regenerate.

## Repo-wide summary
- Total automated test files: **509**
- Backend and repo Vitest files: **475**
- Total automated test files: **510**
- Backend and repo Vitest files: **476**
- Frontend Vitest files: **9**
- Playwright spec files: **25**

Expand All @@ -85,7 +85,7 @@ flowchart TD
| Playwright E2E tests | 22 |
| Playwright Inspector E2E tests | 3 |
| Tests Performance | 1 |
| Tests Scripts | 1 |
| Tests Scripts | 2 |

## Primary validation commands
- `npm test`
Expand Down Expand Up @@ -729,7 +729,8 @@ flowchart TD
**Runner:** `vitest`
**Command:** `npx vitest run tests/scripts`
**Requirements:** Basic `.env` if required by the module under test.
**Files (1):**
**Files (2):**
- `tests/scripts/bundles_scaffold.test.ts`
- `tests/scripts/launchd_cli_sync_tooling.test.ts`

### Python unit tests
Expand Down
2 changes: 2 additions & 0 deletions inspector/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const ConversationDetailPage = lazy(() => import("@/pages/conversation_detail"))
const TurnsPage = lazy(() => import("@/pages/turns"));
const TurnDetailPage = lazy(() => import("@/pages/turn_detail"));
const InterpretationsPage = lazy(() => import("@/pages/interpretations"));
const BundlesPage = lazy(() => import("@/pages/bundles"));
const AgentsPage = lazy(() => import("@/pages/agents"));
const AgentDetailPage = lazy(() => import("@/pages/agent_detail"));
const AgentGrantsPage = lazy(() => import("@/pages/agent_grants"));
Expand Down Expand Up @@ -116,6 +117,7 @@ export default function App() {
<Route path="/timeline" element={<TimelinePage />} />
<Route path="/timeline/:id" element={<TimelineEventDetailPage />} />
<Route path="/interpretations" element={<InterpretationsPage />} />
<Route path="/bundles" element={<BundlesPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/agents/grants" element={<AgentGrantsPage />} />
<Route path="/agents/grants/:id" element={<AgentGrantDetailPage />} />
Expand Down
10 changes: 10 additions & 0 deletions inspector/src/api/endpoints/bundles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { get, type FetchOptions } from "../client";
import type { BundleInfoResponse, BundleListResponse } from "@/types/api";

export function listBundles(fetch?: FetchOptions) {
return get<BundleListResponse>("/bundles", undefined, fetch);
}

export function getBundle(name: string, fetch?: FetchOptions) {
return get<BundleInfoResponse>(`/bundles/${encodeURIComponent(name)}`, undefined, fetch);
}
2 changes: 2 additions & 0 deletions inspector/src/components/layout/sidebar_nav_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
KeyRound,
Settings,
Layers,
Package,
BookOpen,
PenLine,
Palette,
Expand Down Expand Up @@ -57,6 +58,7 @@ export const SIDEBAR_MORE_NAV_ITEMS: SidebarNavItem[] = [
{ to: "/turns", label: "Turns", icon: Repeat },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
{ to: "/schemas", label: "Schemas", icon: Database },
{ to: "/bundles", label: "Bundles", icon: Package },
{ to: "/interpretations", label: "Interpretations", icon: Cpu },
{ to: "/subscriptions", label: "Subscriptions", icon: Bell },
{ to: "/peers", label: "Peers", icon: RefreshCw },
Expand Down
20 changes: 20 additions & 0 deletions inspector/src/hooks/use_bundles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { isApiUrlConfigured } from "@/api/client";
import { getBundle, listBundles } from "@/api/endpoints/bundles";

export function useBundles() {
return useQuery({
queryKey: ["bundles"],
queryFn: ({ signal }) => listBundles({ signal }),
placeholderData: keepPreviousData,
enabled: isApiUrlConfigured(),
});
}

export function useBundle(name: string | undefined) {
return useQuery({
queryKey: ["bundles", name],
queryFn: ({ signal }) => getBundle(name as string, { signal }),
enabled: isApiUrlConfigured() && Boolean(name),
});
}
232 changes: 232 additions & 0 deletions inspector/src/pages/bundles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Bundles directory (Bundles m4 surfacing).
*
* Read-only view of the bundle registry served by `GET /bundles`. Each row
* shows a bundle's name, type, version, enable/always-active state, the count
* of entity types it provides, and the use cases it serves. Clicking a row
* opens a detail dialog backed by `GET /bundles/:name` showing the full
* manifest.
*
* Enable/disable controls are intentionally absent: those mutations need the
* AAuth admin gate deferred from m3 and are not exposed over HTTP yet.
*
* Plan ent_089da2ecebc3bd804d63dcf2 (Bundles Strategy).
*/

import { useMemo, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { PageShell } from "@/components/layout/page_shell";
import { DataTableSkeleton, QueryErrorAlert } from "@/components/shared/query_status";
import { DataTable } from "@/components/ui/data-table";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showBackgroundQueryRefresh, showInitialQuerySkeleton } from "@/lib/query_loading";
import { QueryRefreshIndicator } from "@/components/shared/query_refresh_indicator";
import { useBundle, useBundles } from "@/hooks/use_bundles";
import type { BundleListEntry } from "@/types/api";

function StatusBadge({ bundle }: { bundle: Pick<BundleListEntry, "enabled" | "always_active"> }) {
if (bundle.always_active) {
return <Badge variant="default">Always active</Badge>;
}
return bundle.enabled ? (
<Badge variant="secondary">Enabled</Badge>
) : (
<Badge variant="outline">Disabled</Badge>
);
}

function UseCasesCell({ useCases }: { useCases: string[] | undefined }) {
const list = useCases ?? [];
if (list.length === 0) {
return <span className="text-xs text-muted-foreground">(none)</span>;
}
const shown = list.slice(0, 4);
const rest = list.length - shown.length;
return (
<div className="flex flex-wrap gap-1">
{shown.map((u) => (
<Badge key={u} variant="outline" className="text-xs font-normal">
{u}
</Badge>
))}
{rest > 0 ? <span className="text-xs text-muted-foreground">+{rest} more</span> : null}
</div>
);
}

function BundleDetailDialog({ name, onClose }: { name: string | null; onClose: () => void }) {
const q = useBundle(name ?? undefined);
const info = q.data;
return (
<Dialog open={Boolean(name)} onOpenChange={(open) => (!open ? onClose() : undefined)}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono">{name}</DialogTitle>
<DialogDescription>{info?.manifest.description ?? "Bundle manifest"}</DialogDescription>
</DialogHeader>
{q.isLoading ? (
<p className="text-sm text-muted-foreground">Loading manifest…</p>
) : q.error ? (
<QueryErrorAlert title="Could not load bundle">{q.error.message}</QueryErrorAlert>
) : info ? (
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Type</dt>
<dd>
<Badge variant="secondary">{info.manifest.bundle_type}</Badge>
</dd>
<dt className="text-muted-foreground">Version</dt>
<dd className="font-mono">{info.manifest.version}</dd>
<dt className="text-muted-foreground">State</dt>
<dd>
<StatusBadge bundle={{ enabled: info.enabled, always_active: info.always_active }} />
</dd>
{info.manifest.category ? (
<>
<dt className="text-muted-foreground">Category</dt>
<dd>{info.manifest.category}</dd>
</>
) : null}
<dt className="text-muted-foreground">Compatible modes</dt>
<dd>{info.manifest.compatible_modes.join(", ") || "(all)"}</dd>
<dt className="text-muted-foreground">Requires bundles</dt>
<dd>{info.manifest.requires_bundles.join(", ") || "(none)"}</dd>
<dt className="text-muted-foreground">Provides entity types</dt>
<dd className="font-mono text-xs">
{info.manifest.provides_entity_types.join(", ") || "(none)"}
</dd>
<dt className="text-muted-foreground">References shared schemas</dt>
<dd className="font-mono text-xs">
{info.manifest.references_shared_schemas.join(", ") || "(none)"}
</dd>
<dt className="text-muted-foreground">Extends schemas</dt>
<dd className="font-mono text-xs">
{info.manifest.extends_schemas.join(", ") || "(none)"}
</dd>
<dt className="text-muted-foreground">Provides skills</dt>
<dd className="font-mono text-xs">
{info.manifest.provides_skills.length > 0
? info.manifest.provides_skills.map((s) => s.name).join(", ")
: "(none)"}
</dd>
<dt className="text-muted-foreground">Serves use cases</dt>
<dd>
<UseCasesCell useCases={info.manifest.serves_use_cases} />
</dd>
</dl>
) : null}
</DialogContent>
</Dialog>
);
}

export default function BundlesPage() {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const bundlesQ = useBundles();

const filtered = useMemo(() => {
const items = bundlesQ.data?.bundles ?? [];
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((b) => {
const tokens = [b.name, b.bundle_type, b.version, ...(b.serves_use_cases ?? [])].filter(
Boolean
) as string[];
return tokens.some((v) => v.toLowerCase().includes(q));
});
}, [bundlesQ.data, query]);

const columns: ColumnDef<BundleListEntry, unknown>[] = [
{
header: "Bundle",
accessorKey: "name",
cell: ({ row }) => (
<button
type="button"
onClick={() => setSelected(row.original.name)}
className="font-medium text-foreground hover:underline"
>
{row.original.name}
</button>
),
},
{
header: "Type",
accessorKey: "bundle_type",
cell: ({ getValue }) => {
const v = getValue() as string | undefined;
return v ? <Badge variant="secondary">{v}</Badge> : <span>—</span>;
},
},
{
header: "Version",
accessorKey: "version",
cell: ({ getValue }) => (
<span className="font-mono text-xs">{(getValue() as string) ?? "—"}</span>
),
},
{
header: "State",
id: "state",
cell: ({ row }) => <StatusBadge bundle={row.original} />,
},
{
header: () => (
<span className="block leading-tight">
Entity types
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
Provided
</span>
</span>
),
id: "entity_types",
cell: ({ row }) => String(row.original.provides_entity_types_count ?? 0),
},
{
header: "Serves use cases",
id: "serves_use_cases",
cell: ({ row }) => <UseCasesCell useCases={row.original.serves_use_cases} />,
},
];

return (
<PageShell
title="Bundles"
description="The deliverable units Neotoma ships: schemas, record-type docs, and skills. Read-only directory of the installed bundle registry. Enable/disable controls are not yet exposed (they need the AAuth admin gate)."
actions={showBackgroundQueryRefresh(bundlesQ) ? <QueryRefreshIndicator /> : undefined}
>
<div className="flex flex-wrap items-end gap-3">
<Input
placeholder="Search by name, type, use case…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-[320px]"
/>
{bundlesQ.data && (
<p className="text-sm text-muted-foreground">
{bundlesQ.data.bundles.length} bundle
{bundlesQ.data.bundles.length === 1 ? "" : "s"}
</p>
)}
</div>

{showInitialQuerySkeleton(bundlesQ) ? (
<DataTableSkeleton rows={6} cols={6} />
) : bundlesQ.error ? (
<QueryErrorAlert title="Could not load bundles">{bundlesQ.error.message}</QueryErrorAlert>
) : (
<DataTable columns={columns} data={filtered} />
)}

<BundleDetailDialog name={selected} onClose={() => setSelected(null)} />
</PageShell>
);
}
49 changes: 49 additions & 0 deletions inspector/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,3 +822,52 @@ export interface ConversationTurnIndex {
conversation_entity_id: string;
turns: ConversationTurnIndexTurn[];
}

// Bundles m4 surfacing — mirrors `InstalledBundleView` (src/services/bundles/loader.ts),
// served read-only by `GET /bundles`. Plan ent_089da2ecebc3bd804d63dcf2.
export interface BundleListEntry {
name: string;
enabled: boolean;
always_active: boolean;
/** Present when the bundle is in the discovered registry. */
bundle_type?: "schema" | "skill";
version?: string;
provides_entity_types_count?: number;
serves_use_cases?: string[];
}

export interface BundleListResponse {
bundles: BundleListEntry[];
}

// A bundle's full skill spec, as declared in its manifest.
export interface BundleSkillSpec {
name: string;
requires_entity_types?: string[];
depth?: string;
}

// Full parsed manifest, mirrors `BundleManifest` (src/services/bundles/types.ts).
export interface BundleManifest {
name: string;
version: string;
description: string;
bundle_type: "schema" | "skill";
requires_bundles: string[];
provides_entity_types: string[];
references_shared_schemas: string[];
extends_schemas: string[];
provides_skills: BundleSkillSpec[];
compatible_modes: string[];
category?: string;
serves_use_cases: string[];
}

// Mirrors `BundleInfo` (src/services/bundles/activation.ts), served by
// `GET /bundles/:name`.
export interface BundleInfoResponse {
name: string;
enabled: boolean;
always_active: boolean;
manifest: BundleManifest;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"build": "npm run build:server && npm run build:inspector",
"build:server": "tsc && node scripts/copy_pdf_worker_wrapper.js && node scripts/copy_bundle_assets.js && npm run build:scripts && npm run build:bundled-docs",
"bundles:check": "tsx scripts/bundles_check.ts",
"bundles:scaffold": "tsx scripts/bundles_scaffold.ts",
"build:bundled-docs": "tsx scripts/build_bundled_docs.ts",
"build:scripts": "tsc -p tsconfig.scripts.json",
"build:inspector": "node scripts/build_inspector.js",
Expand Down
Loading
Loading