swarm-ui: read the agent's grant once for the internet and permissions controls, and save one permission change at a time
This commit is contained in:
parent
5e23734667
commit
9afe21819f
3 changed files with 73 additions and 36 deletions
|
|
@ -17,7 +17,11 @@ import type { AgentActions } from "../roster/AgentActions.js";
|
|||
import { AgentInference } from "./AgentInference.js";
|
||||
import { AgentInstructions } from "./AgentInstructions.js";
|
||||
import { AgentLimits } from "./AgentLimits.js";
|
||||
import { AgentInternet, AgentPermissions } from "./AgentPermissions.js";
|
||||
import {
|
||||
AgentInternet,
|
||||
AgentPermissions,
|
||||
useGrant,
|
||||
} from "./AgentPermissions.js";
|
||||
import { AllowedDomains } from "./AllowedDomains.js";
|
||||
import { AgentStateRow } from "./AgentStateRow.js";
|
||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||
|
|
@ -184,9 +188,21 @@ function SettingsRows({ agent }: { agent: string }) {
|
|||
<dd>
|
||||
<AgentLimits key={agent} agent={agent} />
|
||||
</dd>
|
||||
<GrantRows key={agent} agent={agent} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The internet and permissions rows read and write the one stored grant, so
|
||||
// they share one read of it: a save in one is seen by the other. Keyed by
|
||||
// agent, so switching agents remounts it instead of showing the last grant.
|
||||
function GrantRows({ agent }: { agent: string }) {
|
||||
const g = useGrant(agent);
|
||||
return (
|
||||
<>
|
||||
<dt>internet</dt>
|
||||
<dd>
|
||||
<AgentInternet key={agent} agent={agent} />
|
||||
{g.grant === null ? g.loading : <AgentInternet agent={agent} g={g} />}
|
||||
</dd>
|
||||
<dt>allowed domains</dt>
|
||||
<dd>
|
||||
|
|
@ -194,7 +210,14 @@ function SettingsRows({ agent }: { agent: string }) {
|
|||
</dd>
|
||||
<dt>permissions</dt>
|
||||
<dd>
|
||||
<AgentPermissions key={agent} agent={agent} />
|
||||
{g.grant === null ? (
|
||||
<span class="ui-agent-limits-muted">—</span>
|
||||
) : (
|
||||
<>
|
||||
{g.readFailure}
|
||||
<AgentPermissions agent={agent} g={g} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,38 +60,48 @@ function permissionsUrl(agent: string): string {
|
|||
return `/api/agents/${encodeURIComponent(agent)}/permissions`;
|
||||
}
|
||||
|
||||
// The stored grant of one agent, and what changes it.
|
||||
function useGrant(agent: string) {
|
||||
// The stored grant of one agent, and what changes it. One instance serves
|
||||
// every control of the grant, so a save from one is seen by the others.
|
||||
export function useGrant(agent: string) {
|
||||
const [grant, setGrant] = useState<Grant | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Bumped after a save or a clear, so the grant is fetched again.
|
||||
// Bumped by a retry, so the grant is fetched again.
|
||||
const [reload, setReload] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The refusal of the last save, kept apart from a failed read.
|
||||
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// Reads the grant; false when the read failed.
|
||||
async function load(isCancelled: () => boolean): Promise<boolean> {
|
||||
try {
|
||||
const r = await fetch(permissionsUrl(agent));
|
||||
if (!r.ok) {
|
||||
if (!cancelled) setError(await readApiError(r));
|
||||
return;
|
||||
if (!isCancelled()) setError(await readApiError(r));
|
||||
return false;
|
||||
}
|
||||
const body = (await r.json()) as Grant;
|
||||
if (cancelled) return;
|
||||
if (isCancelled()) return false;
|
||||
setGrant(body);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled) setError({ detail: String(e) });
|
||||
});
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
if (!isCancelled()) setError({ detail: String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void load(() => cancelled);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent, reload]);
|
||||
|
||||
// Sends one change and reads the grant again; true when the route took it.
|
||||
// Sends one change and reads the grant again before it lets go of `busy`,
|
||||
// so the next change builds on what was stored; true when the route took it.
|
||||
async function send(init: RequestInit): Promise<boolean> {
|
||||
if (busy) return false;
|
||||
setBusy(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
|
|
@ -100,7 +110,7 @@ function useGrant(agent: string) {
|
|||
setSaveError(await readApiError(r));
|
||||
return false;
|
||||
}
|
||||
setReload((n) => n + 1);
|
||||
await load(() => false);
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
setSaveError({ detail: String(e) });
|
||||
|
|
@ -129,14 +139,21 @@ function useGrant(agent: string) {
|
|||
return { grant, busy, saveError, readFailure, loading, send };
|
||||
}
|
||||
|
||||
export function AgentPermissions({ agent }: { agent: string }) {
|
||||
const { grant, busy, saveError, readFailure, loading, send } =
|
||||
useGrant(agent);
|
||||
export type GrantState = ReturnType<typeof useGrant>;
|
||||
|
||||
export function AgentPermissions({
|
||||
agent,
|
||||
g,
|
||||
}: {
|
||||
agent: string;
|
||||
g: GrantState;
|
||||
}) {
|
||||
const { grant, busy, saveError, loading, send } = g;
|
||||
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
|
||||
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
|
||||
null,
|
||||
);
|
||||
// The boxes as ticked; they follow the stored grant once it is read again.
|
||||
// The boxes as ticked while a change is being saved; null shows the stored grant.
|
||||
const [selected, setSelected] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -157,14 +174,9 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// The stored grant changing (a save went through) resets the boxes to it.
|
||||
const stored = grant?.permissions;
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
}, [stored]);
|
||||
|
||||
if (grant === null) return loading;
|
||||
|
||||
const stored = grant.permissions;
|
||||
const ticked =
|
||||
selected ??
|
||||
(catalogue
|
||||
|
|
@ -172,12 +184,12 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
|||
: []);
|
||||
|
||||
async function toggle(value: string) {
|
||||
if (!catalogue) return;
|
||||
if (!catalogue || busy) return;
|
||||
const next = togglePermission(ticked, value);
|
||||
setSelected(next);
|
||||
// The default set sends no list, so the agent follows the default.
|
||||
const list = requestPermissions(next, catalogue.default);
|
||||
const ok = await send(
|
||||
await send(
|
||||
list === undefined
|
||||
? { method: "DELETE" }
|
||||
: {
|
||||
|
|
@ -186,13 +198,12 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
|||
body: JSON.stringify({ permissions: list }),
|
||||
},
|
||||
);
|
||||
// A refused change puts the boxes back as stored.
|
||||
if (!ok) setSelected(null);
|
||||
// The grant was read again by now, or the change refused: show what is stored.
|
||||
setSelected(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="ui-agent-limits">
|
||||
{readFailure}
|
||||
<BadgePopover
|
||||
label="permissions"
|
||||
value={grantSummary(grant.permissions)}
|
||||
|
|
@ -212,6 +223,7 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
|||
options={catalogue.all}
|
||||
filter={{ values: ticked, negate: false }}
|
||||
onToggle={(v) => void toggle(v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -236,15 +248,13 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function AgentInternet({ agent }: { agent: string }) {
|
||||
const { grant, busy, saveError, readFailure, loading, send } =
|
||||
useGrant(agent);
|
||||
export function AgentInternet({ agent, g }: { agent: string; g: GrantState }) {
|
||||
const { grant, busy, saveError, loading, send } = g;
|
||||
if (grant === null) return loading;
|
||||
|
||||
const mode = internetMode(grant.unrestricted_internet, grant.internet_log);
|
||||
return (
|
||||
<div class="ui-agent-limits">
|
||||
{readFailure}
|
||||
<BadgePopover
|
||||
label="internet"
|
||||
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ export function MultiselectFilterOptions({
|
|||
options,
|
||||
filter,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}: {
|
||||
options: string[];
|
||||
filter: MultiselectFilterState;
|
||||
onToggle: (value: string) => void;
|
||||
/** Greys the boxes out, e.g. while a change is being saved. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div class="ui-multiselect-filter-options">
|
||||
|
|
@ -41,6 +44,7 @@ export function MultiselectFilterOptions({
|
|||
<label key={v} class="ui-multiselect-filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={filter.values.includes(v)}
|
||||
onChange={() => onToggle(v)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue