Skip to content

Commit 37f4c67

Browse files
patel-lyzrclaude
andcommitted
feat: SRS connectivity status across the full guardrail chain
Policies page now shows a live three-row status pill (on load + 30s poll + click-to-recheck): AgentOS — the health fetch itself (SPA → agentos-server) AgentOS → SRS — raw /health reachability + an x-api-key'd /v1/rai/policies probe (the exact proxy path the Policies UI uses) CAS → SRS — relayed from the CAS's new GET /srs/health: the link the gateway PII redaction + SrsPolicyDecider depend on All probes carry 5s timeouts so a wedged SRS can never hang the UI (the failure mode behind the earlier policies-list outage). - computeragent-server: new GET /srs/health (probes SRS from the CAS pod) - agentos-server: GET /v1/srs/health aggregates own probe + CAS relay - spa: SrsStatus pill + SrsHealth/SrsProbe types Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c372cb4 commit 37f4c67

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

agentos/src/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,23 @@ export interface PIIDetectionConfig {
205205
custom_pii?: CustomPII[];
206206
}
207207

208+
/** One SRS connectivity probe (reachability + authenticated API call). */
209+
export interface SrsProbe {
210+
configured: boolean;
211+
reachable: boolean;
212+
authenticated: boolean;
213+
latencyMs: number | null;
214+
error?: string;
215+
}
216+
217+
/** GET /srs/health — the full guardrail chain, measured server-side:
218+
* `agentos` = agentos-server → SRS; `cas` = agentos → CAS + CAS → SRS.
219+
* The response arriving at all proves the SPA → agentos hop. */
220+
export interface SrsHealth {
221+
agentos: SrsProbe;
222+
cas: { reachable: boolean; srs: SrsProbe | null; error?: string };
223+
}
224+
208225
export interface PolicyDoc {
209226
_id: string;
210227
name: string;
@@ -503,6 +520,9 @@ export const api = {
503520
deleteSchedule: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/schedules/${encodeURIComponent(id)}`),
504521
runScheduleNow: (id: string) => postJSON<{ ok: boolean }>(`/schedules/${encodeURIComponent(id)}/run-now`, {}),
505522
// Policies — SRS-proxied. Server injects x-api-key.
523+
// SRS connectivity, measured server-side: raw /health reachability + an
524+
// authenticated /v1/rai/policies probe (the exact path this UI uses).
525+
srsHealth: () => getJSON<SrsHealth>("/srs/health"),
506526
policies: () => getJSON<{ policies: PolicyDoc[] }>("/policies").then((d) => d.policies),
507527
policy: (id: string) => getJSON<PolicyDoc>(`/policies/${encodeURIComponent(id)}`),
508528
createPolicy: (body: Partial<PolicyDoc>) => postJSON<PolicyDoc>("/policies", body),

agentos/src/components/PoliciesPage.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction } from "../api.ts";
2+
import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction, type SrsHealth } from "../api.ts";
33
import { useAuth } from "../context/AuthContext.tsx";
44

55
/**
@@ -49,6 +49,7 @@ export function PoliciesPage() {
4949
<div>
5050
<div className="text-base font-semibold">Policies</div>
5151
<div className="text-[11px] text-gray-500">SRS-managed · Cedar + OPA</div>
52+
<SrsStatus />
5253
</div>
5354
{canWrite && (
5455
<button
@@ -645,6 +646,66 @@ function RegoPoliciesModal({
645646
);
646647
}
647648

649+
/**
650+
* SRS connectivity pill. Polls `/srs/health` on mount, on every refresh, and
651+
* every 30s — both signals are measured from agentos-server (the browser has
652+
* no route to SRS): raw reachability + the authenticated proxy path the
653+
* Policies UI itself uses. Click to re-check immediately.
654+
*/
655+
function SrsStatus() {
656+
const [health, setHealth] = useState<SrsHealth | null>(null);
657+
const [agentosUp, setAgentosUp] = useState<boolean | null>(null); // the fetch itself = SPA → agentos
658+
const [checking, setChecking] = useState(false);
659+
660+
const check = () => {
661+
setChecking(true);
662+
api.srsHealth()
663+
.then((h) => { setHealth(h); setAgentosUp(true); })
664+
.catch(() => { setHealth(null); setAgentosUp(false); })
665+
.finally(() => setChecking(false));
666+
};
667+
useEffect(() => {
668+
check();
669+
const t = setInterval(check, 30_000);
670+
return () => clearInterval(t);
671+
}, []);
672+
673+
const probeState = (p: { configured: boolean; reachable: boolean; authenticated: boolean; latencyMs: number | null; error?: string } | null | undefined, fallback: string) => {
674+
if (!p) return { dot: "bg-gray-500", text: fallback };
675+
if (!p.configured) return { dot: "bg-gray-500", text: "not configured" };
676+
if (p.authenticated) return { dot: "bg-emerald-500", text: `connected · ${p.latencyMs}ms` };
677+
if (p.reachable) return { dot: "bg-amber-500", text: "reachable, auth failing" };
678+
return { dot: "bg-red-500", text: "unreachable" };
679+
};
680+
681+
const rows: Array<{ label: string; dot: string; text: string; title?: string }> = [
682+
agentosUp === false
683+
? { label: "AgentOS", dot: "bg-red-500", text: "unreachable" }
684+
: { label: "AgentOS", dot: agentosUp ? "bg-emerald-500" : "bg-gray-500", text: agentosUp ? "connected" : "checking…" },
685+
{ label: "AgentOS → SRS", ...probeState(health?.agentos, "checking…"), title: health?.agentos?.error },
686+
health && !health.cas.reachable
687+
? { label: "CAS → SRS", dot: "bg-red-500", text: "CAS unreachable", title: health.cas.error }
688+
: { label: "CAS → SRS", ...probeState(health?.cas.srs, health ? "no /srs/health (old CAS?)" : "checking…"), title: health?.cas.error ?? health?.cas.srs?.error },
689+
];
690+
691+
return (
692+
<button
693+
type="button"
694+
onClick={check}
695+
title="Click to re-check connectivity"
696+
className={`mt-1.5 block text-left space-y-0.5 ${checking ? "opacity-60" : ""}`}
697+
>
698+
{rows.map((r) => (
699+
<span key={r.label} title={r.title} className="flex items-center gap-1.5 text-[10px] text-gray-500 hover:text-gray-300">
700+
<span className={`h-1.5 w-1.5 rounded-full ${r.dot}`} />
701+
<span className="w-24 text-left">{r.label}</span>
702+
<span>{r.text}</span>
703+
</span>
704+
))}
705+
</button>
706+
);
707+
}
708+
648709
/**
649710
* Click-to-copy policy id. Shown wherever a policy surfaces so users can paste
650711
* the id straight into the SDK (`ComputerAgent(policy_id="…")`). `short` shows

examples/computeragent-server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,43 @@ export class ComputerAgentServer {
942942
}),
943943
);
944944

945+
// SRS connectivity as seen from THIS server — the exact link the gateway's
946+
// PII redaction and the SrsPolicyDecider depend on. `reachable` = raw
947+
// GET /health; `authenticated` = an x-api-key'd /v1/rai/policies probe.
948+
// Surfaced in the AgentOS UI's SRS status pill (proxied via agentos-server).
949+
this.app.get("/srs/health", async (c) => {
950+
const srsBase = (process.env.SRS_BASE_URL ?? "").replace(/\/+$/, "");
951+
const srsKey = process.env.SRS_API_KEY ?? "";
952+
if (!srsBase) {
953+
return c.json({ configured: false, reachable: false, authenticated: false, latencyMs: null });
954+
}
955+
const t0 = Date.now();
956+
let reachable = false;
957+
let authenticated = false;
958+
let error: string | null = null;
959+
try {
960+
const h = await fetch(`${srsBase}/health`, { signal: AbortSignal.timeout(5000) });
961+
reachable = h.ok;
962+
if (reachable) {
963+
const a = await fetch(`${srsBase}/v1/rai/policies`, {
964+
headers: { "x-api-key": srsKey },
965+
signal: AbortSignal.timeout(5000),
966+
});
967+
authenticated = a.ok;
968+
if (!a.ok) error = `auth check: SRS ${a.status}`;
969+
}
970+
} catch (err) {
971+
error = (err as Error).message;
972+
}
973+
return c.json({
974+
configured: true,
975+
reachable,
976+
authenticated,
977+
latencyMs: Date.now() - t0,
978+
...(error ? { error } : {}),
979+
});
980+
});
981+
945982
this.app.post("/run", async (c) => {
946983
const max = this.opts.maxConcurrentRuns ?? 4;
947984
if (this.runs.size >= max) {

packages/agentos-server/src/routes/policies.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,67 @@ async function srs(
8181
}
8282
}
8383

84+
// ── SRS connectivity health ────────────────────────────────────────────────
85+
// The full guardrail chain, measured server-side (the browser has no route to
86+
// SRS or the CAS):
87+
// agentos — THIS server → SRS: raw GET /health reachability + an x-api-key'd
88+
// /v1/rai/policies probe (the exact proxy path the Policies UI uses).
89+
// cas — THIS server → CAS (is the harness up?) and CAS → SRS (the link
90+
// the gateway PII redaction + SrsPolicyDecider depend on), relayed
91+
// from the CAS's own /srs/health.
92+
// The SPA polls this to render the SRS status pill. Responding at all proves
93+
// the SPA → agentos hop.
94+
interface SrsProbe {
95+
configured: boolean;
96+
reachable: boolean;
97+
authenticated: boolean;
98+
latencyMs: number | null;
99+
error?: string;
100+
}
101+
102+
async function probeSrs(): Promise<SrsProbe> {
103+
if (!SRS_BASE) return { configured: false, reachable: false, authenticated: false, latencyMs: null };
104+
const t0 = Date.now();
105+
let reachable = false;
106+
let authenticated = false;
107+
let error: string | null = null;
108+
try {
109+
const h = await fetch(`${SRS_BASE}/health`, { signal: AbortSignal.timeout(5000) });
110+
reachable = h.ok;
111+
if (reachable) {
112+
const a = await fetch(`${SRS_BASE}/v1/rai/policies`, {
113+
headers: { "x-api-key": SRS_KEY },
114+
signal: AbortSignal.timeout(5000),
115+
});
116+
authenticated = a.ok;
117+
if (!a.ok) error = `auth check: SRS ${a.status}`;
118+
}
119+
} catch (err) {
120+
error = (err as Error).message;
121+
}
122+
return { configured: true, reachable, authenticated, latencyMs: Date.now() - t0, ...(error ? { error } : {}) };
123+
}
124+
125+
async function probeCasSrs(): Promise<{ reachable: boolean; srs: SrsProbe | null; error?: string }> {
126+
const { caBase } = await import("../upstream.js");
127+
const { caAuthHeader } = await import("../auth.js");
128+
try {
129+
const r = await fetch(`${caBase()}/srs/health`, {
130+
headers: caAuthHeader(),
131+
signal: AbortSignal.timeout(5000),
132+
});
133+
if (!r.ok) return { reachable: true, srs: null, error: `CAS /srs/health → ${r.status}` };
134+
return { reachable: true, srs: (await r.json()) as SrsProbe };
135+
} catch (err) {
136+
return { reachable: false, srs: null, error: (err as Error).message };
137+
}
138+
}
139+
140+
policiesRouter.get("/srs/health", authorize("policies:read"), async (_req, res) => {
141+
const [agentos, cas] = await Promise.all([probeSrs(), probeCasSrs()]);
142+
res.json({ agentos, cas });
143+
});
144+
84145
// ── RAI policies → SRS /v1/rai/policies ───────────────────────────────────
85146
policiesRouter.get("/policies", authorize("policies:read"), (_req, res) =>
86147
srs(res, "GET", "/v1/rai/policies", { fallback: { policies: [] } }),

0 commit comments

Comments
 (0)