feat(FN-3956): execute provisioning on approval decisions

The merge completes Step 1 of agent provisioning approval followthrough, executing provisioning actions when approval decisions are made, with supporting documentation in AGENTS.md and docs/agents.md. Test coverage spans provisioning policy, approval routes, gating classifications, extension integra

Fusion-Task-Id: FN-3956
This commit is contained in:
Fusion
2026-05-10 18:35:13 -07:00
committed by gsxdsm
parent 5b32c45393
commit 7d67dc3597
11 changed files with 392 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Wire dashboard approval decisions for `agent_provisioning` requests to execute deferred agent create/delete actions.
Add focused test coverage for provisioning decision routing, policy/gating contracts, and approval request category round-trips.

View File

@@ -311,10 +311,14 @@ Six tools enable inter-agent coordination — discovering agents, provisioning/d
Create a non-ephemeral agent that reports to the caller (or, for CEO-level callers, any `reportsTo` target).
Provisioning can be policy-gated (`projectSettings.agentProvisioning`). Tool responses include `details.outcome` of `created`, `pending_approval`, or `denied`. Pending requests are resolved via dashboard/API approval decision route (`POST /api/approvals/:id/decision`), which executes deferred creation on approve.
### `agent_delete` Tool
Delete a non-ephemeral direct report. If the target holds a task checkout lease, deletion is blocked unless `force: true`. Assigned tasks can be reassigned via `reassign_to` or released/unassigned.
Provisioning policy also applies to deletes (`details.outcome`: `deleted`, `pending_approval`, or `denied`). Approval decisions emit provisioning audit events (`agent:create:approved|denied`, `agent:delete:approved|denied`) tied to the original run/task metadata.
### `list_agents` Tool
List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment.

View File

@@ -102,6 +102,14 @@ Approval pause/resume lifecycle (FN-3548):
- Dashboard mailbox entry points (Header/Mobile nav) display pending-approval indicators so waiting approvals are visible before opening Mailbox.
- Agents list/board cards and Agent Detail summary display per-agent `pendingApprovalCount` badges to show which agents are blocked by waiting approvals.
Agent provisioning approvals (`agent_provisioning` category):
- `fn_agent_create` / `fn_agent_delete` can return `pending_approval` under `projectSettings.agentProvisioning` policy (`approvalMode`, trusted roles/IDs, `alwaysApproveDelete`).
- Approval request is persisted with provisioning context (`tool` + `params`) and visible in mailbox/API approval queues.
- Dashboard/API decision route `POST /api/approvals/:id/decision` executes deferred provisioning on `approve` via engine dispatcher (`executeApprovedAgentProvisioning`) and never executes on `deny`.
- Decision handling emits run-audit mutations: `agent:create:approved`, `agent:create:denied`, `agent:delete:approved`, `agent:delete:denied` using original request task/run/requester linkage.
- Malformed provisioning context or failed execution returns 500 from the decision route (no silent approval).
Default and legacy fallback behavior:
- New **non-ephemeral/permanent** agents persist a normalized `permissionPolicy` using preset `unrestricted` when not explicitly provided.

View File

@@ -188,6 +188,32 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
expect(persisted?.description).toBe("Ship the packed CLI contract");
});
it("runs provisioning tools through the built extension", async () => {
const createTool = api.tools.get("fn_agent_create")!;
const created = await createTool.execute(
"create-agent-1",
{ name: "built-ext-agent", role: "executor" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(created.details.outcome).toBe("created");
expect(created.details.agentId).toMatch(/^agent-/);
const deleteTool = api.tools.get("fn_agent_delete")!;
const deleted = await deleteTool.execute(
"delete-agent-1",
{ id: created.details.agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(deleted.details.outcome).toBe("deleted");
expect(deleted.details.agentId).toBe(created.details.agentId);
});
it("delegates to real non-ephemeral agents and rejects runtime workers", async () => {
const agent = await seedAgent(tmpDir, { name: "release-agent" });
const runtimeWorker = await seedAgent(tmpDir, { name: "runtime-worker", ephemeral: true });

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { extractAgentProvisioningRequest, resolveAgentProvisioningPolicy } from "../agent-provisioning-policy.js";
describe("resolveAgentProvisioningPolicy", () => {
it("denies missing caller", () => {
const decision = resolveAgentProvisioningPolicy({ tool: "fn_agent_create", caller: undefined, settings: undefined });
expect(decision.decision).toBe("deny");
expect(decision.matchedRule).toBe("missing-caller");
});
it("allows privileged caller", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "a1", role: "executor", isPrivileged: true },
settings: { agentProvisioning: { approvalMode: "always", alwaysApproveDelete: true } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("privileged-caller");
});
it("allows trusted agent id in trusted-only mode", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "trusted-id" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedAgentIds: ["trusted-id"] } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("trusted-agent-id");
});
it("matches trusted role case-insensitively", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "a1", role: "CEO" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedRoles: ["ceo"] } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("trusted-role");
});
it("requires approval for untrusted caller in trusted-only mode", () => {
const decision = resolveAgentProvisioningPolicy({ tool: "fn_agent_create", caller: { id: "a1" }, settings: undefined });
expect(decision.decision).toBe("require-approval");
expect(decision.matchedRule).toBe("approval-mode-trusted-only");
expect(decision.effectiveMode).toBe("trusted-only");
});
it("requires approval in always mode", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "a1" },
settings: { agentProvisioning: { approvalMode: "always" } },
});
expect(decision.decision).toBe("require-approval");
expect(decision.matchedRule).toBe("approval-mode-always");
});
it("alwaysApproveDelete forces approval by default", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "trusted", role: "ceo" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedAgentIds: ["trusted"] } },
});
expect(decision.decision).toBe("require-approval");
expect(decision.matchedRule).toBe("delete-always-approve");
});
it("allows trusted delete when alwaysApproveDelete is false", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "trusted" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedAgentIds: ["trusted"], alwaysApproveDelete: false } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("trusted-agent-id");
});
it("never mode short-circuits delete approval", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "a1" },
settings: { agentProvisioning: { approvalMode: "never", alwaysApproveDelete: true } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("approval-mode-never");
});
});
describe("extractAgentProvisioningRequest", () => {
it("extracts tool and params from provisioning request", () => {
const request: any = {
id: "apr-1",
targetAction: {
category: "agent_provisioning",
context: { tool: "fn_agent_create", params: { name: "helper" } },
},
};
expect(extractAgentProvisioningRequest(request)).toEqual({ tool: "fn_agent_create", params: { name: "helper" } });
});
it("throws for malformed context", () => {
const request: any = { id: "apr-1", targetAction: { category: "agent_provisioning", context: {} } };
expect(() => extractAgentProvisioningRequest(request)).toThrow("invalid provisioning tool");
});
});

View File

@@ -126,6 +126,23 @@ describe("ApprovalRequestStore", () => {
expect(fetched?.runId).toBe("run-abc");
});
it("round-trips agent_provisioning category unchanged", () => {
const created = store.create({
requester: REQUESTER,
targetAction: {
category: "agent_provisioning",
action: "create",
summary: "Create helper",
resourceType: "agent",
resourceId: "",
},
});
const fetched = store.get(created.id);
expect(fetched?.targetAction.category).toBe("agent_provisioning");
expect(store.list({ status: "pending" }).some((row) => row.id === created.id && row.targetAction.category === "agent_provisioning")).toBe(true);
});
it("normalizes legacy category aliases on create/read", () => {
const created = store.create({
requester: REQUESTER,

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PROJECT_SETTINGS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
import { AGENT_PROVISIONING_APPROVAL_MODES } from "../types.js";
describe("agentProvisioning settings schema contract", () => {
it("includes agentProvisioning key with object default", () => {
expect(PROJECT_SETTINGS_KEYS).toContain("agentProvisioning");
expect(DEFAULT_PROJECT_SETTINGS.agentProvisioning).toEqual({});
});
it("exposes valid approval mode vocabulary", () => {
expect(AGENT_PROVISIONING_APPROVAL_MODES).toEqual(["always", "trusted-only", "never"]);
});
it("supports omitted block defaults", () => {
const settings = DEFAULT_PROJECT_SETTINGS.agentProvisioning ?? {};
expect(settings).toEqual({});
});
});

View File

@@ -7,6 +7,8 @@ const state = {
audits: new Map<string, any[]>(),
task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" },
agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" },
runAuditEvents: [] as any[],
provisionedAgents: new Set<string>(),
};
class MockApprovalRequestStore {
@@ -59,11 +61,31 @@ class MockAgentStore {
}
}
const executeApprovedAgentProvisioning = vi.fn(async (request: any) => {
const tool = request?.targetAction?.context?.tool;
if (!tool) throw new Error("Malformed agent provisioning request: missing tool");
if (tool === "fn_agent_create") {
const id = String(request?.targetAction?.context?.params?.name ?? "created-agent");
state.provisionedAgents.add(id);
return { id };
}
if (tool === "fn_agent_delete") {
const id = String(request?.targetAction?.resourceId ?? "");
state.provisionedAgents.delete(id);
return { deletedId: id };
}
throw new Error(`Unsupported provisioning tool: ${tool}`);
});
vi.mock("@fusion/core", () => ({
ApprovalRequestStore: MockApprovalRequestStore,
AgentStore: MockAgentStore,
}));
vi.mock("@fusion/engine", () => ({
executeApprovedAgentProvisioning,
}));
describe("approval routes", async () => {
const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js");
@@ -81,6 +103,10 @@ describe("approval routes", async () => {
pauseTask: async (_id: string, paused: boolean) => {
state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined };
},
recordRunAuditEvent: (event: any) => {
state.runAuditEvents.push(event);
return event;
},
},
engine: undefined,
projectId: "p1",
@@ -101,6 +127,9 @@ describe("approval routes", async () => {
beforeEach(() => {
updateAgent.mockClear();
const now = new Date().toISOString();
executeApprovedAgentProvisioning.mockClear();
state.runAuditEvents = [];
state.provisionedAgents = new Set(["target-1"]);
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
state.requests = new Map([
@@ -124,6 +153,60 @@ describe("approval routes", async () => {
updatedAt: now,
requestedAt: now,
}],
["apr-3", {
id: "apr-3",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "agent_provisioning",
summary: "Create provisioned agent",
action: "create",
resourceType: "agent",
resourceId: "",
context: { tool: "fn_agent_create", params: { name: "created-agent", role: "executor" } },
},
taskId: "FN-1",
runId: "run-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
["apr-4", {
id: "apr-4",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "agent_provisioning",
summary: "Delete provisioned agent",
action: "delete",
resourceType: "agent",
resourceId: "target-1",
context: { tool: "fn_agent_delete", params: { agent_id: "target-1" } },
},
taskId: "FN-1",
runId: "run-2",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
["apr-5", {
id: "apr-5",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "agent_provisioning",
summary: "Malformed",
action: "create",
resourceType: "agent",
resourceId: "",
context: {},
},
taskId: "FN-1",
runId: "run-3",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
]);
state.audits = new Map([
["apr-1", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]],
@@ -135,9 +218,9 @@ describe("approval routes", async () => {
const app = createApp();
const res = await get(app, "/api/approvals?status=pending");
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
expect(res.body.pendingCount).toBe(1);
expect(res.body.requests).toHaveLength(1);
expect(res.body.total).toBe(4);
expect(res.body.pendingCount).toBe(4);
expect(res.body.requests).toHaveLength(4);
expect(res.body.requests[0]).toMatchObject({
id: "apr-1",
actionCategory: "command_execution",
@@ -191,8 +274,65 @@ describe("approval routes", async () => {
expect(res.body.status).toBe("denied");
});
it("returns 409 for invalid transition", async () => {
it("approves provisioning create and records audit", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-3/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(executeApprovedAgentProvisioning).toHaveBeenCalledTimes(1);
expect(state.provisionedAgents.has("created-agent")).toBe(true);
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:create:approved", runId: "run-1" });
expect(state.task.paused).toBe(false);
});
it("denies provisioning create without execution and records denied audit", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-3/decision",
JSON.stringify({ decision: "deny" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(executeApprovedAgentProvisioning).not.toHaveBeenCalled();
expect(state.provisionedAgents.has("created-agent")).toBe(false);
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:create:denied", runId: "run-1" });
});
it("approves provisioning delete and records audit", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-4/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(state.provisionedAgents.has("target-1")).toBe(false);
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:delete:approved", runId: "run-2" });
});
it("returns 500 for malformed provisioning request context", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-5/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Malformed agent provisioning request");
});
it("returns 409 for invalid transition", async () => { const app = createApp();
const res = await request(
app,
"POST",

View File

@@ -1,4 +1,5 @@
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
import { AgentStore, ApprovalRequestStore, type ApprovalRequest, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
import { executeApprovedAgentProvisioning } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
import { emitApprovalSseEvent } from "../sse.js";
@@ -97,6 +98,34 @@ function toDetailDto(
};
}
function emitProvisioningDecisionAudit(params: {
scopedStore: import("@fusion/core").TaskStore;
request: ApprovalRequest;
decision: "approved" | "denied";
}): void {
const { scopedStore, request, decision } = params;
if (request.targetAction.category !== "agent_provisioning") return;
const action = request.targetAction.action === "delete" ? "delete" : "create";
const mutationType = `agent:${action}:${decision}` as const;
const event: Parameters<typeof scopedStore.recordRunAuditEvent>[0] = {
agentId: request.requester.actorId,
domain: "database",
mutationType,
target: request.targetAction.resourceId || request.requester.actorId,
metadata: {
approvalRequestId: request.id,
action,
resourceId: request.targetAction.resourceId,
requesterAgentId: request.requester.actorId,
},
runId: request.id,
};
if (request.taskId) event.taskId = request.taskId;
if (request.runId) event.runId = request.runId;
scopedStore.recordRunAuditEvent(event);
}
async function resumeAfterDecision(params: {
scopedStore: import("@fusion/core").TaskStore;
request: import("@fusion/core").ApprovalRequest;
@@ -210,6 +239,17 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
throw error;
}
if (updated.targetAction.category === "agent_provisioning") {
if (body.decision === "approve") {
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
await executeApprovedAgentProvisioning(updated, { agentStore });
emitProvisioningDecisionAudit({ scopedStore, request: updated, decision: "approved" });
} else {
emitProvisioningDecisionAudit({ scopedStore, request: updated, decision: "denied" });
}
}
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
const history = approvalStore.getAuditHistory(requestId);
const detail = toDetailDto(updated, history);

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS,
PERMANENT_AGENT_TASK_MUTATION_TOOLS,
TASK_AGENT_MUTATION_TOOLS,
} from "../gating-classifications.js";
describe("gating classifications provisioning split", () => {
it("keeps provisioning tools out of action-gate set", () => {
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_agent_create")).toBe(false);
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_agent_delete")).toBe(false);
});
it("retains provisioning tools in permanent/task mutation sets", () => {
expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_agent_create")).toBe(true);
expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_agent_delete")).toBe(true);
expect(TASK_AGENT_MUTATION_TOOLS.has("fn_agent_create")).toBe(true);
expect(TASK_AGENT_MUTATION_TOOLS.has("fn_agent_delete")).toBe(true);
});
});

View File

@@ -11,6 +11,7 @@ export {
taskDocumentReadParams,
taskDocumentWriteParams,
taskLogParams,
executeApprovedAgentProvisioning,
} from "./agent-tools.js";
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";