diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index 4903c69d..63bd46d0 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -113,7 +113,7 @@ test('Ctrl+K opens the command palette; ? lists the shortcuts', async ({ page }) }); // One
, one h1, the banner and the primary nav on every screen, not just Agents. -for (const route of ['#/traces', '#/traces?status=error', '#/traces/e2e-trace?obs=e2e-obs', '#/threads', '#/threads/s1', '#/threads?status=active', '#/monitoring', '#/evals/scores', '#/prompts', '#/prompts/mira@2?diff=1,2', '#/settings', '#/playground']) { +for (const route of ['#/traces', '#/traces?status=error', '#/traces/e2e-trace?obs=e2e-obs', '#/threads', '#/threads/s1', '#/threads?status=active', '#/monitoring', '#/evals/scores', '#/prompts', '#/prompts/mira@2?diff=1,2', '#/hub', '#/hub/docs?scope=fleet', '#/settings', '#/playground']) { test(`${route} keeps the landmarks and exactly one main`, async ({ page }) => { await page.goto(`/?token=e2e-studio-token${route}`); await expect(page.locator('.screen-main')).toBeVisible(); diff --git a/apps/agent-forge/server/__tests__/observeProxy.test.ts b/apps/agent-forge/server/__tests__/observeProxy.test.ts index 421bb152..273011a4 100644 --- a/apps/agent-forge/server/__tests__/observeProxy.test.ts +++ b/apps/agent-forge/server/__tests__/observeProxy.test.ts @@ -3,7 +3,7 @@ * panels' GETs same-origin, because the daemon itself serves no CORS * headers. A dead daemon answers 502 with the `lousho observe start` hint. * Writes go through only when OBSERVE_WRITE_ALLOWLIST names the method and - * path (feedback scores, a prompt label move); every other non-GET stays 405. + * path (feedback scores, a prompt label move, a hub publish); every other non-GET stays 405. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; @@ -101,6 +101,13 @@ describe('observe proxy', () => { expect((await request(app).delete('/observe/api/v1/scores')).status).toBe(405); }); + it('forwards POST /api/v1/hub (a Context Hub fleet entry edit) with its JSON body', async () => { + const entry = { agent_id: 'a1', namespace: 'docs', key: 'k', value: { a: 1 }, scope: 'fleet' }; + const res = await request(app).post('/observe/api/v1/hub').send(entry); + expect(res.status).toBe(201); + expect(res.body).toEqual({ echoed: '/api/v1/hub', method: 'POST', contentType: 'application/json', body: entry }); + }); + it('forwards an allowlisted PUT (prompt label move) with its JSON body and content-type', async () => { const res = await request(app).put('/observe/api/v1/prompts/mira/labels/prod').send({ version: 2 }); expect(res.status).toBe(200); @@ -116,6 +123,8 @@ describe('observe proxy', () => { ['put', '/observe/api/v1/prompts/mira/labels/prod/extra'], ['delete', '/observe/api/v1/prompts/mira/labels/prod'], ['post', '/observe/api/v1/prompts/mira/labels/prod'], + ['post', '/observe/api/v1/hub/x'], + ['put', '/observe/api/v1/hub'], ['post', '/observe/v1/traces'], ] as const)('keeps %s %s at 405', async (method, url) => { const res = await request(app)[method](url).send({ version: 1 }); @@ -141,8 +150,8 @@ describe('observe proxy', () => { expect(res.status).toBe(413); }); - it('exports one write allowlist of method + path pairs: feedback scores and the label move', () => { - expect(OBSERVE_WRITE_ALLOWLIST.map((entry) => entry.method)).toEqual(['POST', 'PUT']); + it('exports one write allowlist of method + path pairs: feedback scores, the label move and hub publishes', () => { + expect(OBSERVE_WRITE_ALLOWLIST.map((entry) => entry.method)).toEqual(['POST', 'PUT', 'POST']); expect(OBSERVE_WRITE_ALLOWLIST.every((entry) => entry.path instanceof RegExp)).toBe(true); }); }); diff --git a/apps/agent-forge/server/observeProxy.ts b/apps/agent-forge/server/observeProxy.ts index 10420eef..d6da6a11 100644 --- a/apps/agent-forge/server/observeProxy.ts +++ b/apps/agent-forge/server/observeProxy.ts @@ -10,9 +10,9 @@ * * GET is forwarded for every path. Writes are forwarded only when * {@link OBSERVE_WRITE_ALLOWLIST} names the method and path - a short list of - * Studio actions (human feedback scores, moving a prompt label) - with the - * JSON body app.ts's `express.json` already parsed, re-serialised and capped - * at 1 MiB. Every other non-GET - the ingest endpoint above all - answers 405 + * Studio actions (human feedback scores, moving a prompt label, editing a + * Context Hub fleet entry) - with the JSON body app.ts's `express.json` + * already parsed, re-serialised and capped at 1 MiB. Every other non-GET - the ingest endpoint above all - answers 405 * and stays reporter-only. `/observe` sits in app.ts's API_PREFIXES, so it * carries the same per-launch token requirement as the rest of the studio API. */ @@ -33,6 +33,7 @@ export interface ObserveWrite { export const OBSERVE_WRITE_ALLOWLIST: readonly ObserveWrite[] = [ { method: 'POST', path: /^\/api\/v1\/scores$/ }, { method: 'PUT', path: /^\/api\/v1\/prompts\/[^/]+\/labels\/[^/]+$/ }, + { method: 'POST', path: /^\/api\/v1\/hub$/ }, ]; /** diff --git a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts index 34b0bd6d..213aed66 100644 --- a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts +++ b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts @@ -171,4 +171,26 @@ describe('ObserveClient', () => { const missing = clientWith((async () => jsonResponse(404, { error: "No prompt 'mira@9'." })) as typeof fetch); await expect(missing.movePromptLabel('mira', 'prod', 9)).rejects.toMatchObject({ status: 404, message: "No prompt 'mira@9'." }); }); + it('listHubEntries sends agent_id plus the optional namespace/scope/limit and returns the entries', async () => { + const entry = { entry_id: 'e1', namespace: 'docs', agent_id: 'a1', key: 'k', value: '1', scope: 'fleet', created_at: 1, ttl: null, expires_at: null }; + const fetchFn = vi.fn(async () => jsonResponse(200, { entries: [entry] })); + const client = clientWith(fetchFn as typeof fetch); + expect(await client.listHubEntries({ agent_id: 'a1', namespace: 'docs', scope: 'fleet', limit: 1000 })).toEqual([entry]); + expect(fetchFn).toHaveBeenCalledWith('/observe/api/v1/hub?agent_id=a1&namespace=docs&scope=fleet&limit=1000', expect.anything()); + await client.listHubEntries({ agent_id: 'a1' }); + expect(fetchFn).toHaveBeenLastCalledWith('/observe/api/v1/hub?agent_id=a1', expect.anything()); + }); + + it('publishHubEntry POSTs the entry as JSON to /api/v1/hub', async () => { + const fetchFn = vi.fn(async (_url: unknown, init?: RequestInit) => { + void init; + return jsonResponse(200, { entry: undefined, created: false }); + }); + const body = { agent_id: 'a1', namespace: 'docs', key: 'k', value: { a: 1 }, scope: 'fleet' as const, ttl: 60_000 }; + expect(await clientWith(fetchFn as typeof fetch).publishHubEntry(body)).toMatchObject({ created: false }); + const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/observe/api/v1/hub'); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual(body); + }); }); diff --git a/apps/agent-forge/src/observe/observeClient.ts b/apps/agent-forge/src/observe/observeClient.ts index 238f5e9d..faaa880c 100644 --- a/apps/agent-forge/src/observe/observeClient.ts +++ b/apps/agent-forge/src/observe/observeClient.ts @@ -12,7 +12,10 @@ import { STUDIO_TOKEN_HEADER, loadStudioToken } from '../runtime/studioToken'; import type { ObserveAgentRow, + NewHubEntry, ObserveHealth, + ObserveHubEntry, + ObserveHubScope, ObserveInsights, ObservePromptHistory, ObservePromptLabelMove, @@ -127,6 +130,14 @@ export interface NewScore { comment?: string; } +/** `GET /api/v1/hub` params. `agent_id` is required: the daemon enforces scope against it (no `scope` = every fleet entry plus that agent's private ones; max `limit` 1000). */ +export interface HubEntryFilter { + agent_id: string; + namespace?: string; + scope?: ObserveHubScope; + limit?: number; +} + export interface InsightsFilter { days?: number; since?: number; @@ -274,6 +285,17 @@ export class ObserveClient { }); } + /** `GET /api/v1/hub` - live (unexpired) Context Hub entries visible to `agent_id`, newest first. */ + async listHubEntries(filter: HubEntryFilter): Promise { + const body = await this.request<{ entries: ObserveHubEntry[] }>('/api/v1/hub', { query: { ...filter } }); + return body.entries; + } + + /** `POST /api/v1/hub` - upsert one entry (per namespace, agent_id, key); republishing resets `created_at`, so the TTL restarts. */ + publishHubEntry(entry: NewHubEntry): Promise<{ entry: ObserveHubEntry | undefined; created: boolean }> { + return this.request('/api/v1/hub', { method: 'POST', body: entry }); + } + /** `GET /api/v1/scores` - eval/judge/human score rows. */ async listScores(filter: ScoreListFilter = {}): Promise { const body = await this.request<{ scores: ObserveScoreRow[] }>('/api/v1/scores', { query: { ...filter } }); diff --git a/apps/agent-forge/src/observe/types.ts b/apps/agent-forge/src/observe/types.ts index f9e6b29a..01c5856f 100644 --- a/apps/agent-forge/src/observe/types.ts +++ b/apps/agent-forge/src/observe/types.ts @@ -224,3 +224,31 @@ export interface ObservePromptLabelMove { version: number; previous_version: number | null; } + +/** A Context Hub entry's visibility: `private` to its writer, or `fleet`-wide. */ +export type ObserveHubScope = 'private' | 'fleet'; + +/** One row of `GET /api/v1/hub`. `value` is the stored text: JSON-encoded unless it was published as a string. `ttl` is a lifetime in ms from `created_at` (the last publish). */ +export interface ObserveHubEntry { + entry_id: string; + namespace: string; + /** The writer. */ + agent_id: string | null; + key: string; + value: string | null; + scope: ObserveHubScope; + created_at: number; + ttl: number | null; + expires_at: number | null; +} + +/** `POST /api/v1/hub` body: one entry per (namespace, agent_id, key), upserted; republishing restarts the TTL clock. */ +export interface NewHubEntry { + agent_id: string; + namespace: string; + key: string; + /** Any JSON value; a string is stored verbatim. */ + value: unknown; + scope?: ObserveHubScope; + ttl?: number; +} diff --git a/apps/agent-forge/src/observe/useObserve.ts b/apps/agent-forge/src/observe/useObserve.ts index 3eed3fd2..4248a14a 100644 --- a/apps/agent-forge/src/observe/useObserve.ts +++ b/apps/agent-forge/src/observe/useObserve.ts @@ -6,10 +6,11 @@ * duplicating effect logic. */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { observeClient, ObserveDaemonDownError, type InsightsFilter, type ScoreListFilter, type SessionListFilter, type TraceListFilter } from './observeClient'; +import { observeClient, ObserveDaemonDownError, type HubEntryFilter, type InsightsFilter, type ScoreListFilter, type SessionListFilter, type TraceListFilter } from './observeClient'; import type { ObserveAgentRow, ObserveHealth, + ObserveHubEntry, ObserveInsights, ObservePromptHistory, ObservePromptRow, @@ -172,3 +173,11 @@ export function usePrompts(refreshKey?: unknown): ObserveQuery { return useObserveQuery(() => observeClient.promptVersions(name), [name, refreshKey]); } + +/** `GET /api/v1/hub`; `filter` undefined (no viewer agent yet) skips the request and resolves to `[]`. */ +export function useHubEntries(filter: HubEntryFilter | undefined, refreshKey?: unknown): ObserveQuery { + return useObserveQuery( + () => (filter === undefined ? Promise.resolve([]) : observeClient.listHubEntries(filter)), + [JSON.stringify(filter), refreshKey] + ); +} diff --git a/apps/agent-forge/src/screens/hub/HubEntry.tsx b/apps/agent-forge/src/screens/hub/HubEntry.tsx new file mode 100644 index 00000000..136f9832 --- /dev/null +++ b/apps/agent-forge/src/screens/hub/HubEntry.tsx @@ -0,0 +1,135 @@ +/** + * One selected Context Hub entry: its full value (JSON through JsonView, any + * other string preformatted) and, for a fleet entry, the "Edit" inline editor. + * + * Saving republishes the entry as its writer (`POST /api/v1/hub` with the + * entry's own `agent_id`, scope `fleet` and original `ttl`) through the studio + * proxy's write allowlist. The daemon resets `created_at` on every publish, so + * an edit restarts the TTL clock - the editor says so next to the TTL. + */ +import { useState } from 'react'; +import { observeClient } from '../../observe/observeClient'; +import type { ObserveHubEntry } from '../../observe/types'; +import { JsonView } from '../../ui/JsonView'; +import { useToast } from '../../ui/Toast'; +import { editableText, fmtSpan, parseEditedValue, parseValue } from './hubModel'; + +function ValueView({ value }: { value: string | null }) { + const parsed = parseValue(value); + return ( +
+ {parsed.json ? :
{parsed.text}
} +
+ ); +} + +function Editor({ + entry, + writer, + onClose, + onSaved, +}: { + entry: ObserveHubEntry & { agent_id: string }; + writer: string; + onClose: () => void; + onSaved: () => void; +}) { + const toast = useToast(); + const wasJson = parseValue(entry.value).json; + const [text, setText] = useState(() => editableText(entry.value)); + const [invalid, setInvalid] = useState(false); + const [saving, setSaving] = useState(false); + + const save = async () => { + const parsed = parseEditedValue(text, wasJson); + if (!parsed.ok) { + setInvalid(true); + return; + } + setSaving(true); + try { + await observeClient.publishHubEntry({ + agent_id: entry.agent_id, + namespace: entry.namespace, + key: entry.key, + value: parsed.value, + scope: 'fleet', + ...(entry.ttl === null ? {} : { ttl: entry.ttl }), + }); + toast.show({ tone: 'success', message: `Saved ${entry.key}.` }); + onSaved(); + onClose(); + } catch (error) { + toast.show({ tone: 'error', message: `Could not save ${entry.key}: ${(error as Error).message}` }); + setSaving(false); + } + }; + + const ttlNote = + entry.ttl === null + ? 'No TTL: the entry does not expire.' + : `TTL ${fmtSpan(entry.ttl)}. Saving restarts the TTL clock: the entry expires ${fmtSpan(entry.ttl)} after the save.`; + + return ( +
{ + e.preventDefault(); + void save(); + }} + > + +