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 apps/agent-forge/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ test('Ctrl+K opens the command palette; ? lists the shortcuts', async ({ page })
});

// One <main>, 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();
Expand Down
15 changes: 12 additions & 3 deletions apps/agent-forge/server/__tests__/observeProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand All @@ -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);
});
});
7 changes: 4 additions & 3 deletions apps/agent-forge/server/observeProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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$/ },
];

/**
Expand Down
22 changes: 22 additions & 0 deletions apps/agent-forge/src/observe/__tests__/observeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions apps/agent-forge/src/observe/observeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
import { STUDIO_TOKEN_HEADER, loadStudioToken } from '../runtime/studioToken';
import type {
ObserveAgentRow,
NewHubEntry,
ObserveHealth,
ObserveHubEntry,
ObserveHubScope,
ObserveInsights,
ObservePromptHistory,
ObservePromptLabelMove,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ObserveHubEntry[]> {
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<ObserveScoreRow[]> {
const body = await this.request<{ scores: ObserveScoreRow[] }>('/api/v1/scores', { query: { ...filter } });
Expand Down
28 changes: 28 additions & 0 deletions apps/agent-forge/src/observe/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 10 additions & 1 deletion apps/agent-forge/src/observe/useObserve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -172,3 +173,11 @@ export function usePrompts(refreshKey?: unknown): ObserveQuery<ObservePromptRow[
export function usePromptVersions(name: string, refreshKey?: unknown): ObserveQuery<ObservePromptHistory | undefined> {
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<ObserveHubEntry[]> {
return useObserveQuery(
() => (filter === undefined ? Promise.resolve([]) : observeClient.listHubEntries(filter)),
[JSON.stringify(filter), refreshKey]
);
}
135 changes: 135 additions & 0 deletions apps/agent-forge/src/screens/hub/HubEntry.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="hub-value">
{parsed.json ? <JsonView value={parsed.parsed} /> : <pre className="hub-value-text">{parsed.text}</pre>}
</div>
);
}

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 (
<form
className="hub-editor"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<label className="hub-editor-label" htmlFor={`hub-edit-${entry.entry_id}`}>
Value (republished as {writer}, scope fleet)
</label>
<textarea
id={`hub-edit-${entry.entry_id}`}
className="hub-editor-text"
value={text}
rows={Math.min(16, Math.max(4, text.split('\n').length + 1))}
aria-invalid={invalid || undefined}
aria-describedby={`hub-edit-${entry.entry_id}-note`}
onChange={(e) => {
setText(e.target.value);
setInvalid(false);
}}
/>
<p className="hub-note" id={`hub-edit-${entry.entry_id}-note`}>
{invalid && <span className="hub-invalid" role="alert">This entry holds JSON; the text does not parse. </span>}
{ttlNote}
</p>
<div className="hub-editor-actions">
<button type="submit" className="btn" disabled={saving}>
Save
</button>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
Cancel
</button>
</div>
</form>
);
}

/** The selected entry's value and, for a fleet entry with a known writer, the Edit affordance. */
export function HubEntryDetail({ entry, writer, onSaved }: { entry: ObserveHubEntry; writer: string; onSaved: () => void }) {
const [editing, setEditing] = useState(false);
const editable = entry.scope === 'fleet' && entry.agent_id !== null;
return (
<section className="hub-entry" aria-label={`Entry ${entry.key}`}>
<div className="hub-entry-head">
<h3 className="hub-section-title">
<code>{entry.key}</code>
</h3>
{editable && !editing && (
<button type="button" className="btn" onClick={() => setEditing(true)}>
Edit
</button>
)}
</div>
{editing && editable ? (
<Editor entry={{ ...entry, agent_id: entry.agent_id! }} writer={writer} onClose={() => setEditing(false)} onSaved={onSaved} />
) : (
<ValueView value={entry.value} />
)}
</section>
);
}
Loading
Loading