feat(admin-web): add agent runtime console

This commit is contained in:
Saravana Achu Mac 2026-04-04 01:45:45 -07:00
parent 6f3a563edf
commit 71ef2ac6f6
7 changed files with 533 additions and 4 deletions

View File

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetCurrentUser = vi.fn();
vi.mock('@/lib/auth-server', () => ({
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
getCurrentUserFromRequest: (...args: unknown[]) => mockGetCurrentUser(...args),
}));
const mockLogError = vi.fn();
vi.mock('@/lib/logger', () => ({
logError: (...args: unknown[]) => mockLogError(...args),
}));
import { GET, POST } from '@/app/api/agent-runtime/route';
async function callGet(qs = '') {
const { NextRequest } = await import('next/server');
return GET(
new NextRequest(
new Request(`http://localhost:3001/api/agent-runtime${qs}`, {
headers: { Authorization: 'Bearer test', 'x-product-id': 'lysnrai' },
})
)
);
}
async function callPost(payload: unknown) {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/agent-runtime', {
method: 'POST',
headers: { Authorization: 'Bearer test', 'x-product-id': 'lysnrai' },
body: JSON.stringify(payload),
})
)
);
}
describe('agent runtime proxy route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 401 for unauthenticated callers', async () => {
mockGetCurrentUser.mockResolvedValue(null);
const res = await callGet();
expect(res.status).toBe(401);
});
it('proxies GET session requests to platform-service', async () => {
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
json: async () => ({ sessions: [{ sessionId: 'sess_1' }], count: 1 }),
});
vi.stubGlobal('fetch', fetchMock);
const res = await callGet('?section=sessions&userId=user_1');
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4003/api/agent-runtime/sessions?userId=user_1',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'x-user-id': 'usr_admin',
'x-product-id': 'lysnrai',
}),
})
);
});
it('proxies POST dispatch validation requests to platform-service', async () => {
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
json: async () => ({ valid: true }),
});
vi.stubGlobal('fetch', fetchMock);
const res = await callPost({
dispatchId: 'dispatch_1',
targetProductId: 'lysnrai',
targetExecutor: 'generic-agent',
userId: 'user_1',
title: 'Test',
intent: 'Validate payload',
artifactRefs: [],
memoryRefs: [],
dispatchContext: {
originSurface: 'web',
originProductId: 'lysnrai',
dispatchMode: 'interactive',
initiatedAt: '2026-04-04T08:00:00.000Z',
},
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4003/api/agent-runtime/dispatch/validate',
expect.objectContaining({
method: 'POST',
})
);
});
});

View File

@ -0,0 +1,332 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { BadgeCheck, PlayCircle, RefreshCw, Send, TimerReset } from 'lucide-react';
import { createProxyFetch } from '@/lib/proxy-fetch';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
type AgentSession = {
sessionId: string;
productId: string;
userId: string;
status: string;
startedAt: string;
updatedAt: string;
resumable: boolean;
dispatchContext?: {
originSurface: string;
dispatchMode: string;
} | null;
};
type AgentRun = {
runId: string;
sessionId: string;
productId: string;
status: string;
startedAt: string;
completedAt?: string | null;
correlationId?: string | null;
};
const apiFetch = createProxyFetch('/api/agent-runtime');
function formatDate(iso: string | null | undefined) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export default function AgentRuntimePage() {
const [sessions, setSessions] = useState<AgentSession[]>([]);
const [runs, setRuns] = useState<AgentRun[]>([]);
const [loading, setLoading] = useState(true);
const [userId, setUserId] = useState('');
const [limit, setLimit] = useState('20');
const [dispatchPayload, setDispatchPayload] = useState(
JSON.stringify(
{
dispatchId: 'dispatch_example',
targetProductId: 'lysnrai',
targetExecutor: 'generic-agent',
userId: 'user_1',
title: 'Summarize the latest transcript',
intent: 'Prepare a note and memory candidate.',
artifactRefs: ['art_transcript_1'],
memoryRefs: [],
dispatchContext: {
originSurface: 'web',
originProductId: 'lysnrai',
dispatchMode: 'interactive',
initiatedAt: '2026-04-04T08:00:00.000Z',
},
},
null,
2
)
);
const [dispatchResult, setDispatchResult] = useState<string>('');
const loadData = useCallback(async () => {
setLoading(true);
const sessionParams = new URLSearchParams({ section: 'sessions' });
if (userId.trim()) sessionParams.set('userId', userId.trim());
const runParams = new URLSearchParams({ section: 'runs', limit });
const [sessionData, runData] = await Promise.all([
apiFetch(`?${sessionParams.toString()}`),
apiFetch(`?${runParams.toString()}`),
]);
setSessions(Array.isArray(sessionData?.sessions) ? sessionData.sessions : []);
setRuns(Array.isArray(runData?.runs) ? runData.runs : []);
setLoading(false);
}, [limit, userId]);
useEffect(() => {
void loadData();
}, [loadData]);
async function validateDispatch() {
try {
const parsed = JSON.parse(dispatchPayload);
const result = await apiFetch('', {
method: 'POST',
body: JSON.stringify(parsed),
});
setDispatchResult(JSON.stringify(result, null, 2));
} catch (error) {
setDispatchResult(
JSON.stringify(
{
error: error instanceof Error ? error.message : 'Invalid JSON payload',
},
null,
2
)
);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Runtime</h1>
<p className="text-sm text-muted-foreground">
Inspect shared runtime projections and validate dispatch payloads
</p>
</div>
<Button variant="outline" onClick={() => void loadData()}>
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Sessions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{sessions.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Runs</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{runs.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Runs</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{runs.filter(run => run.status === 'running').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Resumable Sessions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{sessions.filter(session => session.resumable).length}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Input
placeholder="Filter sessions by user ID"
value={userId}
onChange={event => setUserId(event.target.value)}
/>
<Input
placeholder="Run limit"
value={limit}
onChange={event => setLimit(event.target.value)}
/>
</div>
<Tabs defaultValue="sessions" className="space-y-4">
<TabsList>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="runs">Runs</TabsTrigger>
<TabsTrigger value="dispatch">Dispatch Validation</TabsTrigger>
</TabsList>
<TabsContent value="sessions">
<Card>
<CardContent className="space-y-3 p-4">
{loading ? (
<div className="text-sm text-muted-foreground">Loading sessions...</div>
) : sessions.length === 0 ? (
<div className="text-sm text-muted-foreground">No runtime sessions found.</div>
) : (
sessions.map(session => (
<div key={session.sessionId} className="rounded-lg border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<div className="font-medium">{session.sessionId}</div>
<Badge variant="outline">{session.status}</Badge>
<Badge variant="outline">{session.productId}</Badge>
{session.resumable && (
<Badge className="bg-emerald-50 text-emerald-700 border-0">resumable</Badge>
)}
</div>
<div className="grid gap-2 text-sm md:grid-cols-4">
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
User
</div>
<div className="font-mono text-xs">{session.userId}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Started
</div>
<div>{formatDate(session.startedAt)}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Updated
</div>
<div>{formatDate(session.updatedAt)}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Dispatch
</div>
<div className="text-xs">
{session.dispatchContext
? `${session.dispatchContext.originSurface} · ${session.dispatchContext.dispatchMode}`
: '—'}
</div>
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runs">
<Card>
<CardContent className="space-y-3 p-4">
{loading ? (
<div className="text-sm text-muted-foreground">Loading runs...</div>
) : runs.length === 0 ? (
<div className="text-sm text-muted-foreground">No runtime runs found.</div>
) : (
runs.map(run => (
<div key={run.runId} className="rounded-lg border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<div className="font-medium">{run.runId}</div>
<Badge variant="outline">{run.status}</Badge>
<Badge variant="outline">{run.productId}</Badge>
</div>
<div className="grid gap-2 text-sm md:grid-cols-4">
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Session
</div>
<div className="font-mono text-xs">{run.sessionId}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Started
</div>
<div>{formatDate(run.startedAt)}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Completed
</div>
<div>{formatDate(run.completedAt)}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Correlation
</div>
<div className="font-mono text-xs">{run.correlationId || '—'}</div>
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="dispatch">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Send className="h-4 w-4" />
Dispatch Validation
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={dispatchPayload}
onChange={event => setDispatchPayload(event.target.value)}
className="min-h-[240px] font-mono text-xs"
/>
<Button onClick={() => void validateDispatch()}>
<BadgeCheck className="mr-2 h-4 w-4" />
Validate Payload
</Button>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<TimerReset className="h-4 w-4" />
Validation Result
</div>
<pre className="overflow-x-auto text-xs">
{dispatchResult || 'Run validation to inspect the result.'}
</pre>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUserFromRequest } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
function buildHeaders(req: NextRequest, callerId: string): Record<string, string> {
return {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': callerId,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
}
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUserFromRequest(req);
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const url = new URL(req.url);
const section = url.searchParams.get('section') ?? 'sessions';
const qs = new URLSearchParams(url.searchParams);
qs.delete('section');
const suffix = qs.toString() ? `?${qs.toString()}` : '';
const targetPath =
section === 'runs'
? `/api/agent-runtime/runs${suffix}`
: `/api/agent-runtime/sessions${suffix}`;
const res = await fetch(`${PLATFORM_URL}${targetPath}`, {
method: 'GET',
headers: buildHeaders(req, caller.id),
});
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Agent runtime proxy GET error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function POST(req: NextRequest) {
try {
const caller = await getCurrentUserFromRequest(req);
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const res = await fetch(`${PLATFORM_URL}/api/agent-runtime/dispatch/validate`, {
method: 'POST',
headers: buildHeaders(req, caller.id),
body: await req.text(),
});
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Agent runtime proxy POST error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}

View File

@ -54,6 +54,7 @@ import {
ShieldBan,
Wrench,
MonitorSmartphone,
Bot,
Globe,
Download,
} from 'lucide-react';
@ -94,6 +95,7 @@ const navItems = [
{ href: '/marketplace', label: 'Marketplace', icon: Store },
{ href: '/delivery', label: 'Delivery Log', icon: Mail },
{ href: '/jobs', label: 'Scheduled Jobs', icon: Timer },
{ href: '/agent-runtime', label: 'Agent Runtime', icon: Bot },
{ href: '/event-subscriptions', label: 'Event Subs', icon: Radio },
{ href: '/ip-rules', label: 'IP Rules', icon: ShieldBan },
{ href: '/maintenance', label: 'Maintenance', icon: Wrench },

View File

@ -205,6 +205,12 @@ These should be resolved before claiming the ecosystem docs are fully implementa
- platform-service now persists timeline items in `timeline_items`
- `POST /api/timeline/ingest` accepts canonical ecosystem events and stores unified timeline items
- `GET /api/timeline` returns the unified timeline stream with non-admin JWT callers scoped to their own data
- [x] add first hosted internal timeline UI in `admin-web`
Commits:
- `6f3a563`
Status note:
- admin-web now exposes `/timeline` over the shared platform timeline API
- the first hosted internal timeline UI supports user, artifact, correlation, and event-name filtering
---
@ -226,6 +232,12 @@ These should be resolved before claiming the ecosystem docs are fully implementa
- `GET /api/agent-runtime/sessions` projects platform sessions into the shared `AgentSession` shape
- `GET /api/agent-runtime/runs` projects platform runs into the shared `AgentRun` shape
- `POST /api/agent-runtime/dispatch/validate` validates `AgentDispatchRequest` payloads against the canonical schema
- [x] add first hosted internal runtime console in `admin-web`
Commits:
- `COMMIT_PENDING`
Status note:
- admin-web now exposes `/agent-runtime` over the shared platform runtime API
- the first hosted internal runtime UI supports projected session review, projected run review, and dispatch payload validation
- [ ] wire first product implementations to emit the shared runtime objects directly from Cowork and FlowMonk
Status note:
- FlowMonk runtime-emitter implementation work was started, but this clone cannot currently verify it because the repo depends on a local npm registry at `http://localhost:3300` and backend dependencies are not installed

View File

@ -1,7 +1,7 @@
# Phase 4 Execution Plan
> **Flow:** Shared personal timeline over the first three ecosystem flows
> **Status:** Service baseline implemented
> **Status:** Service and first hosted UI implemented
> **Owner:** `learning_ai_common_plat`
> **Purpose:** Turn the personal timeline PRD into a reusable contract and baseline aggregator so the first three ecosystem flows can be rendered as one coherent activity stream.
@ -16,7 +16,7 @@ Phase 4 defines and implements:
3. a pure aggregator that converts canonical ecosystem events into timeline items
4. the first hosted platform-service API for timeline ingest and timeline query
This phase still does not add a hosted UI. It now establishes both the shared data contract and the first persisted service surface.
This phase now establishes the shared data contract, the first persisted service surface, and the first hosted internal timeline UI.
---
@ -29,6 +29,9 @@ This phase still does not add a hosted UI. It now establishes both the shared da
- `services/platform-service/src/modules/timeline/repository.ts`
- `services/platform-service/src/modules/timeline/routes.ts`
- `services/platform-service/src/modules/timeline/routes.test.ts`
- `dashboards/admin-web/src/app/api/timeline/route.ts`
- `dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx`
- `dashboards/admin-web/src/__tests__/timeline.test.ts`
---
@ -69,6 +72,8 @@ Observed baseline:
- platform-service now exposes `POST /api/timeline/ingest` for canonical ecosystem-event ingestion
- platform-service now exposes `GET /api/timeline` for unified timeline query
- non-admin JWT callers are scoped to their own timeline while admin and API-key callers can query broader product timelines
- admin-web now exposes the first hosted internal timeline UI at `/timeline`
- the hosted UI supports filtering by user, correlation, artifact, event name, and page size
---
@ -79,7 +84,7 @@ Observed baseline:
- [x] implement pure timeline aggregation over canonical events
- [x] verify that Phase 1 to Phase 3 events render in one unified stream
- [x] add first hosted timeline ingest/query API in platform-service
- [ ] choose first hosted timeline UI
- [x] choose first hosted timeline UI
---
@ -87,3 +92,4 @@ Observed baseline:
- `3f2482b` timeline contract and aggregator baseline
- `e377351` platform-service timeline ingest + query baseline
- `6f3a563` admin-web hosted timeline dashboard

View File

@ -1,7 +1,7 @@
# Phase 5 Execution Plan
> **Flow:** Shared agent runtime contract baseline
> **Status:** Baseline implemented, platform integration in progress
> **Status:** Baseline implemented, platform integration and first hosted UI in progress
> **Owner:** `learning_ai_common_plat`
> **Purpose:** Turn the runtime contract draft into concrete schemas for sessions, tasks, todos, runs, approvals, dispatch, and action logs.
@ -16,6 +16,7 @@ Phase 5 defines and implements:
3. approval checkpoint contract
4. action-log contract
5. the first product-facing runtime projection and dispatch-validation API
6. the first hosted internal runtime console over that API
This phase establishes the contract layer needed before cross-product runtime adoption in Cowork, FlowMonk, and future agent surfaces.
@ -28,6 +29,9 @@ This phase establishes the contract layer needed before cross-product runtime ad
- `packages/events/src/index.ts`
- `services/platform-service/src/modules/agent-runtime/routes.ts`
- `services/platform-service/src/modules/agent-runtime/routes.test.ts`
- `dashboards/admin-web/src/app/api/agent-runtime/route.ts`
- `dashboards/admin-web/src/app/(dashboard)/agent-runtime/page.tsx`
- `dashboards/admin-web/src/__tests__/agent-runtime.test.ts`
---
@ -49,6 +53,8 @@ This phase establishes the contract layer needed before cross-product runtime ad
- `cd learning_ai_common_plat/packages/events && pnpm exec vitest run src/agent-runtime.test.ts`
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec vitest run src/modules/agent-runtime/routes.test.ts src/server.test.ts`
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec tsc --noEmit`
- `cd learning_ai_common_plat/dashboards/admin-web && npm run test -- src/__tests__/agent-runtime.test.ts`
- `cd learning_ai_common_plat/dashboards/admin-web && npm run typecheck`
Observed baseline:
@ -58,6 +64,8 @@ Observed baseline:
- platform-service now exposes `GET /api/agent-runtime/sessions` as a shared runtime-session projection
- platform-service now exposes `GET /api/agent-runtime/runs` as a shared runtime-run projection
- platform-service now exposes `POST /api/agent-runtime/dispatch/validate` against the canonical dispatch schema
- admin-web now exposes the first hosted internal runtime console at `/agent-runtime`
- the hosted UI supports projected session review, projected run review, and dispatch payload validation
---
@ -77,6 +85,7 @@ Observed baseline:
- `3f2482b` runtime contract schema baseline
- `fe36296` platform-service runtime projection + dispatch validation
- `COMMIT_PENDING` admin-web hosted runtime console
## 7. Remaining Gaps