feat(FN-3548): add agent action routes and approval pause/resume lifecycle
Merges the FN-3548 approval pause/resume system: agents now stall at workflow gates pending approval, with a full lifecycle spanning gate context lookup, action-gate pause/retry, executor and heartbeat pause callbacks, and dedicated approval decision routes. Also lands FN-3744 agent action routes an Fusion-Task-Id: FN-3548
This commit is contained in:
@@ -111,8 +111,8 @@
|
||||
}
|
||||
|
||||
.project-stat--error {
|
||||
border-color: rgba(248, 81, 73, 0.3);
|
||||
background: rgba(248, 81, 73, 0.08);
|
||||
border-color: color-mix(in srgb, var(--color-error) 30%, var(--border));
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.project-stat--error .project-stat__value {
|
||||
@@ -180,8 +180,8 @@
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors.active {
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
border-color: rgba(248, 81, 73, 0.3);
|
||||
background: color-mix(in srgb, var(--color-error) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-error) 30%, var(--border));
|
||||
}
|
||||
|
||||
.project-filter-count {
|
||||
@@ -206,7 +206,7 @@
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors .project-filter-count {
|
||||
background: rgba(248, 81, 73, 0.2);
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
|
||||
153
packages/dashboard/src/__tests__/routes-approval.test.ts
Normal file
153
packages/dashboard/src/__tests__/routes-approval.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { get, request } from "../test-request.js";
|
||||
|
||||
const state = {
|
||||
requests: new Map<string, any>(),
|
||||
audits: new Map<string, any[]>(),
|
||||
task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" },
|
||||
agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" },
|
||||
};
|
||||
|
||||
class MockApprovalRequestStore {
|
||||
constructor(_: unknown) {}
|
||||
list(input: any = {}) {
|
||||
let rows = [...state.requests.values()];
|
||||
if (input.status) rows = rows.filter((r) => r.status === input.status);
|
||||
if (input.requesterActorId) rows = rows.filter((r) => r.requester.actorId === input.requesterActorId);
|
||||
if (input.taskId) rows = rows.filter((r) => r.taskId === input.taskId);
|
||||
return rows;
|
||||
}
|
||||
get(id: string) {
|
||||
return state.requests.get(id) ?? null;
|
||||
}
|
||||
decide(id: string, status: "approved" | "denied") {
|
||||
const req = state.requests.get(id);
|
||||
if (!req) throw new Error("Approval request not found");
|
||||
if (req.status !== "pending") throw new Error(`Invalid approval request transition: ${req.status} -> ${status}`);
|
||||
req.status = status;
|
||||
state.audits.set(id, [...(state.audits.get(id) ?? []), { event: status }]);
|
||||
return req;
|
||||
}
|
||||
getAuditHistory(id: string) {
|
||||
return state.audits.get(id) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const updateAgent = vi.fn(async (_id: string, updates: any) => ({ ...state.agent, ...updates }));
|
||||
|
||||
class MockAgentStore {
|
||||
constructor(_: unknown) {}
|
||||
async init() {}
|
||||
async getAgent(id: string) {
|
||||
return id === state.agent.id ? state.agent : null;
|
||||
}
|
||||
async updateAgentState(id: string, nextState: string) {
|
||||
if (id === state.agent.id) state.agent = { ...state.agent, state: nextState };
|
||||
}
|
||||
async updateAgent(id: string, updates: any) {
|
||||
if (id === state.agent.id) state.agent = { ...state.agent, ...updates };
|
||||
return updateAgent(id, updates);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
ApprovalRequestStore: MockApprovalRequestStore,
|
||||
AgentStore: MockAgentStore,
|
||||
}));
|
||||
|
||||
describe("approval routes", async () => {
|
||||
const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js");
|
||||
|
||||
function createApp() {
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerApprovalRoutes({
|
||||
router,
|
||||
runtimeLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() } as any,
|
||||
getProjectContext: async () => ({
|
||||
store: {
|
||||
getDatabase: () => ({}),
|
||||
getFusionDir: () => "/tmp/fusion",
|
||||
getTask: async () => state.task,
|
||||
pauseTask: async (_id: string, paused: boolean) => {
|
||||
state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined };
|
||||
},
|
||||
},
|
||||
engine: undefined,
|
||||
projectId: "p1",
|
||||
}),
|
||||
rethrowAsApiError: (e: unknown) => {
|
||||
throw e;
|
||||
},
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use("/api", router);
|
||||
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||
const status = err?.statusCode ?? 500;
|
||||
res.status(status).json({ error: err?.message ?? String(err) });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateAgent.mockClear();
|
||||
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
|
||||
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
|
||||
state.requests = new Map([
|
||||
["apr-1", { id: "apr-1", status: "pending", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
|
||||
["apr-2", { id: "apr-2", status: "denied", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
|
||||
]);
|
||||
state.audits = new Map([["apr-1", [{ event: "created" }]]]);
|
||||
});
|
||||
|
||||
it("lists and filters requests", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approval-requests?status=pending");
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any[]).map((r) => r.id)).toEqual(["apr-1"]);
|
||||
});
|
||||
|
||||
it("returns 404 for missing request", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approval-requests/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns audit history", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approval-requests/apr-1/audit");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([{ event: "created" }]);
|
||||
});
|
||||
|
||||
it("approves and unpauses task/agent", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-1/approve", JSON.stringify({}));
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).status).toBe("approved");
|
||||
expect(state.task.paused).toBe(false);
|
||||
expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined });
|
||||
});
|
||||
|
||||
it("denies and unpauses task/agent", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).status).toBe("denied");
|
||||
expect(state.task.paused).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when task already unpaused", async () => {
|
||||
state.task.paused = false;
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 409 for invalid transition", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-2/approve", JSON.stringify({}));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,7 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide
|
||||
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
|
||||
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
|
||||
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
|
||||
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
|
||||
import { runGitCommand } from "./routes/resolve-diff-base.js";
|
||||
|
||||
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500;
|
||||
@@ -982,6 +983,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
registerFilesTerminalWorkspaceRoutes(routeContext);
|
||||
registerAgentsProjectsNodesRoutes(routeContext);
|
||||
registerPluginsAutomationRoutes(routeContext);
|
||||
registerApprovalRoutes(routeContext);
|
||||
|
||||
// HeartbeatMonitor for triggering agent execution runs
|
||||
const heartbeatMonitor = options?.heartbeatMonitor;
|
||||
|
||||
157
packages/dashboard/src/routes/register-approval-routes.ts
Normal file
157
packages/dashboard/src/routes/register-approval-routes.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
|
||||
actorId: "user",
|
||||
actorType: "user",
|
||||
actorName: "User",
|
||||
};
|
||||
|
||||
function parseOptionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string") throw badRequest(`${field} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalInt(value: unknown, field: string): number | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const n = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(n) || n < 0) throw badRequest(`${field} must be a non-negative integer`);
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): ApprovalRequestStatus | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (value === "pending" || value === "approved" || value === "denied" || value === "completed") return value;
|
||||
throw badRequest("status must be one of: pending, approved, denied, completed");
|
||||
}
|
||||
|
||||
async function resumeAfterDecision(params: {
|
||||
scopedStore: import("@fusion/core").TaskStore;
|
||||
request: import("@fusion/core").ApprovalRequest;
|
||||
runtimeLogger: ApiRoutesContext["runtimeLogger"];
|
||||
}): Promise<void> {
|
||||
const { scopedStore, request, runtimeLogger } = params;
|
||||
|
||||
try {
|
||||
if (request.taskId) {
|
||||
const task = await scopedStore.getTask(request.taskId);
|
||||
if (task?.paused && task.pausedByAgentId === request.requester.actorId) {
|
||||
await scopedStore.pauseTask(request.taskId, false, undefined);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
runtimeLogger.warn("Failed to unpause task after approval decision", {
|
||||
requestId: request.id,
|
||||
taskId: request.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.getAgent(request.requester.actorId);
|
||||
if (agent?.state === "paused" && agent.pauseReason === "awaiting-approval") {
|
||||
await agentStore.updateAgentState(agent.id, "idle");
|
||||
await agentStore.updateAgent(agent.id, { pauseReason: undefined });
|
||||
}
|
||||
} catch (error) {
|
||||
runtimeLogger.warn("Failed to unpause agent after approval decision", {
|
||||
requestId: request.id,
|
||||
agentId: request.requester.actorId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx;
|
||||
|
||||
router.get("/approval-requests", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requests = approvalStore.list({
|
||||
status: parseStatus(req.query.status),
|
||||
requesterActorId: parseOptionalString(req.query.requesterActorId, "requesterActorId"),
|
||||
taskId: parseOptionalString(req.query.taskId, "taskId"),
|
||||
runId: parseOptionalString(req.query.runId, "runId"),
|
||||
limit: parseOptionalInt(req.query.limit, "limit"),
|
||||
offset: parseOptionalInt(req.query.offset, "offset"),
|
||||
});
|
||||
res.json(requests);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/approval-requests/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requestId = String(req.params.id);
|
||||
const request = approvalStore.get(requestId);
|
||||
if (!request) throw notFound("Approval request not found");
|
||||
res.json(request);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/approval-requests/:id/audit", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requestId = String(req.params.id);
|
||||
const request = approvalStore.get(requestId);
|
||||
if (!request) throw notFound("Approval request not found");
|
||||
res.json(approvalStore.getAuditHistory(requestId));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
const decideHandler = (status: "approved" | "denied") => async (req: import("express").Request, res: import("express").Response) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as { actor?: ApprovalRequestActorSnapshot; note?: string };
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requestId = String(req.params.id);
|
||||
const existing = approvalStore.get(requestId);
|
||||
if (!existing) throw notFound("Approval request not found");
|
||||
|
||||
const actor = body.actor ?? DEFAULT_ACTOR;
|
||||
if (!actor || typeof actor.actorId !== "string" || typeof actor.actorType !== "string" || typeof actor.actorName !== "string") {
|
||||
throw badRequest("actor must include actorId, actorType, and actorName");
|
||||
}
|
||||
if (body.note !== undefined && typeof body.note !== "string") {
|
||||
throw badRequest("note must be a string");
|
||||
}
|
||||
|
||||
let updated;
|
||||
try {
|
||||
updated = approvalStore.decide(requestId, status, { actor, note: body.note });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Invalid approval request transition")) {
|
||||
throw conflict(message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
|
||||
res.json(updated);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/approval-requests/:id/approve", decideHandler("approved"));
|
||||
router.post("/approval-requests/:id/deny", decideHandler("denied"));
|
||||
}
|
||||
Reference in New Issue
Block a user