Allow local agents to run project review checks

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-26 23:16:54 -07:00
parent 26858f813f
commit 0c0ba88fdb
2 changed files with 141 additions and 55 deletions

View File

@@ -47,6 +47,7 @@ import type { ProjectsConversationOpener } from "@/features/projects/lib/project
import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks";
import type { ProjectPullRequest, Repository } from "@/features/projects/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { listManagedAgents } from "@/shared/api/tauri";
import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents";
import { sendChannelMessage } from "@/shared/api/tauriMessages";
import { getProjectRepoFileContent } from "@/shared/api/projectGit";
@@ -81,7 +82,7 @@ type ReviewCheckRun = {
type ReviewCheckRuns = Record<string, ReviewCheckRun>;
type RelayAssignedAgent = {
type ReviewCheckAgent = {
pubkey: string;
name: string;
isManaged: boolean;
@@ -146,47 +147,42 @@ function normalizeStoredRuns(value: Record<string, unknown>): ReviewCheckRuns {
return runs;
}
/**
* A strict relay-backed candidate list. Unlike the general Projects assistant
* picker, this does not add machine-local agents that the active identity
* cannot also resolve through the relay directory.
*/
function useRelayAssignedAgents() {
/** Local managed agents can run on this dev build; relay agents must pass the
* active identity's community-level respond-to policy. */
function useReviewCheckAgents() {
const identityQuery = useIdentityQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const channelsQuery = useChannelsQuery();
const candidates = React.useMemo(() => {
const managedAgents = managedAgentsQuery.data ?? [];
const managedByPubkey = new Map(
(managedAgentsQuery.data ?? []).map((agent) => [
normalizePubkey(agent.pubkey),
agent,
]),
managedAgents.map((agent) => [normalizePubkey(agent.pubkey), agent]),
);
const relayAgents = relayAgentsQuery.data ?? [];
const sharedChannelIds = getSharedChannelIds(channelsQuery.data);
const allowedPubkeys = getMentionableAgentPubkeys({
currentPubkey: identityQuery.data?.pubkey,
eligibilityScope: { type: "community" },
managedAgentPubkeys: [],
managedAgentPubkeys: managedByPubkey.keys(),
relayAgents,
sharedChannelIds,
});
const seen = new Set<string>();
const assigned: RelayAssignedAgent[] = [];
const assigned: ReviewCheckAgent[] = managedAgents.map((agent) => ({
pubkey: normalizePubkey(agent.pubkey),
name: agent.name,
isManaged: true,
isActive: isManagedAgentActive(agent),
}));
for (const relayAgent of relayAgents) {
const pubkey = normalizePubkey(relayAgent.pubkey);
if (seen.has(pubkey) || !allowedPubkeys.has(pubkey)) continue;
seen.add(pubkey);
const managedAgent = managedByPubkey.get(pubkey);
if (managedByPubkey.has(pubkey) || !allowedPubkeys.has(pubkey)) continue;
assigned.push({
pubkey,
name: relayAgent.name,
isManaged: Boolean(managedAgent),
isActive: managedAgent
? isManagedAgentActive(managedAgent)
: relayAgent.status !== "offline",
isManaged: false,
isActive: relayAgent.status !== "offline",
});
}
return assigned.sort((left, right) => {
@@ -208,7 +204,7 @@ function useRelayAssignedAgents() {
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading ||
channelsQuery.isLoading,
isError: relayAgentsQuery.isError,
isError: managedAgentsQuery.isError || relayAgentsQuery.isError,
};
}
@@ -363,10 +359,10 @@ function AgentPicker({
onSelect,
selected,
}: {
candidates: RelayAssignedAgent[];
candidates: ReviewCheckAgent[];
disabled: boolean;
onSelect: (pubkey: string) => void;
selected: RelayAssignedAgent | null;
selected: ReviewCheckAgent | null;
}) {
return (
<DropdownMenu>
@@ -402,7 +398,7 @@ function AgentPicker({
<span className="shrink-0 text-xs text-muted-foreground">
{candidate.isManaged
? candidate.isActive
? "On this Mac"
? "Running here"
: "Can start here"
: candidate.isActive
? "Online on relay"
@@ -428,7 +424,7 @@ export function ProjectReviewChecks({
const channelsQuery = useChannelsQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
const relayAssignedAgents = useRelayAssignedAgents();
const reviewCheckAgents = useReviewCheckAgents();
const checkDefinitions = useProjectReviewCheckDefinitions(project);
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
@@ -516,7 +512,7 @@ export function ProjectReviewChecks({
);
const runCheck = React.useCallback(
async (check: ProjectReviewCheckDefinition, agent: RelayAssignedAgent) => {
async (check: ProjectReviewCheckDefinition, agent: ReviewCheckAgent) => {
const expectedStorageKey = storageKey;
if (!expectedStorageKey || !relayScope || !signerScope) return;
if (!agent.isManaged && !agent.isActive) {
@@ -527,35 +523,60 @@ export function ProjectReviewChecks({
}
setPendingCheckIds((current) => new Set(current).add(check.id));
try {
if (agent.isManaged && !agent.isActive) {
await startAgentMutation.mutateAsync({
pubkey: agent.pubkey,
expectedRelayUrl: relayScope,
expectedSignerPubkey: signerScope,
});
if (agent.isManaged) {
const managedAgent = (await listManagedAgents()).find(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(agent.pubkey),
);
if (!managedAgent) {
throw new Error(
`${agent.name} is no longer managed by this dev build.`,
);
}
if (!isManagedAgentActive(managedAgent)) {
await startAgentMutation.mutateAsync({
pubkey: agent.pubkey,
expectedRelayUrl: relayScope,
expectedSignerPubkey: signerScope,
});
}
}
const channel = await openDmMutation.mutateAsync({
pubkeys: [agent.pubkey],
expectedRelayUrl: relayScope,
expectedSignerPubkey: signerScope,
});
// DMs represent every participant as a regular member, so authorization
// comes from the selected agent's relay-signed assignment elsewhere in
// the community rather than treating this transport channel as proof.
const revalidated = await revalidateRelayAgents(
[agent.pubkey],
undefined,
);
if (
!revalidated.some(
if (agent.isManaged) {
const isStillManaged = (await listManagedAgents()).some(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(agent.pubkey),
)
) {
throw new Error(
`${agent.name} is no longer authorized for this identity on the relay.`,
);
if (!isStillManaged) {
throw new Error(
`${agent.name} is no longer managed by this dev build.`,
);
}
} else {
// DMs represent every participant as a regular member, so remote
// authorization comes from the relay directory instead of treating
// this transport channel as proof.
const revalidated = await revalidateRelayAgents(
[agent.pubkey],
undefined,
);
if (
!revalidated.some(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(agent.pubkey),
)
) {
throw new Error(
`${agent.name} is no longer authorized for this identity on the relay.`,
);
}
}
const prompt = buildProjectReviewCheckPrompt({
check,
@@ -644,8 +665,9 @@ export function ProjectReviewChecks({
return (
<div className="-mx-6" data-testid="project-review-checks">
<div className="border-b border-border/55 px-6 pb-3 text-xs text-muted-foreground">
Agents are limited to identities the relay directory authorizes for your
signed-in identity. Definitions come from the target branch
Choose a local managed agent on this dev build or a relay agent
authorized for your signed-in identity. Definitions come from the target
branch
{checkDefinitions.source === "project"
? ` (${PROJECT_REVIEW_CHECKS_CONFIG_PATH})`
: checkDefinitions.source === "starter"
@@ -660,7 +682,7 @@ export function ProjectReviewChecks({
status: "idle" as const,
};
const selectedAgent =
relayAssignedAgents.candidates.find(
reviewCheckAgents.candidates.find(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(run.agentPubkey ?? ""),
@@ -699,7 +721,7 @@ export function ProjectReviewChecks({
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<AgentPicker
candidates={relayAssignedAgents.candidates}
candidates={reviewCheckAgents.candidates}
disabled={isPending || run.status === "running"}
onSelect={(pubkey) => assignAgent(check.id, pubkey)}
selected={selectedAgent}
@@ -780,12 +802,12 @@ export function ProjectReviewChecks({
: `Could not load ${PROJECT_REVIEW_CHECKS_CONFIG_PATH}.`}
</div>
) : null}
{!relayAssignedAgents.isLoading &&
relayAssignedAgents.candidates.length === 0 ? (
{!reviewCheckAgents.isLoading &&
reviewCheckAgents.candidates.length === 0 ? (
<div className="border-t border-border/55 px-6 py-3 text-xs text-muted-foreground">
{relayAssignedAgents.isError
? "The relay agent directory could not be loaded."
: "No agents are currently assigned to this identity by the relay."}
{reviewCheckAgents.isError
? "Local managed agents or the relay agent directory could not be loaded."
: "No local managed or authorized relay agents are available."}
</div>
) : null}
</div>

View File

@@ -9,6 +9,7 @@ const REVIEWER_AGENT_PUBKEY = "a".repeat(64);
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const RELAY_REVIEW_AGENT_PUBKEY =
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea";
const LOCAL_REVIEW_AGENT_PUBKEY = "c".repeat(64);
async function expectSinglePrimaryTextColumn(row: Locator) {
const primary = row.locator('[data-projects-text-priority="primary"]');
@@ -173,6 +174,69 @@ test("review checks dispatch to an agent authorized for the relay identity", asy
.toBe(true);
});
test("review checks dispatch to a running local managed agent", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page, {
managedAgents: [
{
pubkey: LOCAL_REVIEW_AGENT_PUBKEY,
name: "Roof",
status: "running",
// Deliberately exclude the signed-in identity from relay authorization.
// A local managed agent must still be available to this dev build.
respondTo: "allowlist",
respondToAllowlist: [],
},
],
});
await openBuzzProject(page);
await page.getByRole("tab", { name: "Review" }).click();
const aliceRow = pullRequestRowByAuthor(page, "alice").first();
await expect(aliceRow).toBeVisible({ timeout: 10_000 });
await aliceRow.getByRole("button", { name: /^#/ }).click();
await page.getByRole("button", { name: "Checks", exact: true }).click();
const checks = page.getByTestId("project-review-checks");
const interfaceCheck = checks.locator("article").first();
await interfaceCheck.getByRole("button", { name: "Assign an agent" }).click();
await page
.getByRole("menuitemradio", { name: /Roof.*Running here/i })
.click();
const revalidationCountBefore = await page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "revalidate_relay_agents",
).length ?? 0,
);
await interfaceCheck
.getByRole("button", { name: "Run", exact: true })
.click();
await expect(interfaceCheck).toContainText("Running");
await expect
.poll(() =>
page.evaluate((pubkey) => {
const entry = window.__BUZZ_E2E_COMMAND_PAYLOADS__?.findLast(
(candidate) => candidate.command === "send_channel_message",
);
return (
entry?.payload as { mentionPubkeys?: string[] } | undefined
)?.mentionPubkeys?.includes(pubkey);
}, LOCAL_REVIEW_AGENT_PUBKEY),
)
.toBe(true);
const commands = await page.evaluate(
() => window.__BUZZ_E2E_COMMANDS__ ?? [],
);
expect(
commands.filter((command) => command === "revalidate_relay_agents"),
).toHaveLength(revalidationCountBefore);
expect(commands).not.toContain("start_managed_agent");
});
test("same-second request changes supersedes approval", async ({ page }) => {
await enableProjectsFeature(page);
await page.addInitScript(() => {