feat(FN-4702): complete Step 2 — worktrunk approval routes
Fusion-Task-Id: FN-4702 Fusion-Task-Lineage: 378b46bc-2e71-43bf-9ff7-85e7aed8bf85
This commit is contained in:
committed by
gsxdsm
parent
ac264bf64b
commit
d733b01925
@@ -82,8 +82,11 @@ vi.mock("@fusion/core", () => ({
|
||||
AgentStore: MockAgentStore,
|
||||
}));
|
||||
|
||||
const executeApprovedWorktrunkInstall = vi.fn(async () => ({ binaryPath: "~/.fusion/bin/worktrunk", source: "installed-release" }));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
executeApprovedAgentProvisioning,
|
||||
executeApprovedWorktrunkInstall,
|
||||
}));
|
||||
|
||||
describe("approval routes", async () => {
|
||||
@@ -128,6 +131,7 @@ describe("approval routes", async () => {
|
||||
updateAgent.mockClear();
|
||||
const now = new Date().toISOString();
|
||||
executeApprovedAgentProvisioning.mockClear();
|
||||
executeApprovedWorktrunkInstall.mockClear();
|
||||
state.runAuditEvents = [];
|
||||
state.provisionedAgents = new Set(["target-1"]);
|
||||
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
|
||||
@@ -207,10 +211,47 @@ describe("approval routes", async () => {
|
||||
updatedAt: now,
|
||||
requestedAt: now,
|
||||
}],
|
||||
["apr-6", {
|
||||
id: "apr-6",
|
||||
status: "pending",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
|
||||
targetAction: {
|
||||
category: "network_api",
|
||||
summary: "Install worktrunk",
|
||||
action: "worktrunk_install",
|
||||
resourceType: "binary",
|
||||
resourceId: "~/.fusion/bin/worktrunk",
|
||||
},
|
||||
taskId: "FN-1",
|
||||
runId: "run-4",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
requestedAt: now,
|
||||
}],
|
||||
]);
|
||||
state.audits = new Map([
|
||||
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 }]],
|
||||
["apr-2", [{ id: "evt-denied", eventType: "denied", actor: { actorId: "dashboard", actorType: "user", actorName: "User" }, createdAt: now }]],
|
||||
["apr-6", [{ id: "evt-created-6", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -347,6 +388,32 @@ describe("approval routes", async () => {
|
||||
expect(res.body.error).toContain("Malformed agent provisioning request");
|
||||
});
|
||||
|
||||
it("invokes worktrunk installer on approve for worktrunk_install approvals", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/approvals/apr-6/decision",
|
||||
JSON.stringify({ decision: "approve" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(executeApprovedWorktrunkInstall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not invoke worktrunk installer on deny for worktrunk_install approvals", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/approvals/apr-6/decision",
|
||||
JSON.stringify({ decision: "deny" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(executeApprovedWorktrunkInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 for invalid transition", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(
|
||||
|
||||
104
packages/dashboard/src/__tests__/routes-worktrunk.test.ts
Normal file
104
packages/dashboard/src/__tests__/routes-worktrunk.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { get, request } from "../test-request.js";
|
||||
|
||||
const state = {
|
||||
installed: false,
|
||||
pendingApprovalId: "apr-worktrunk",
|
||||
requests: new Map<string, any>(),
|
||||
};
|
||||
|
||||
const requestWorktrunkInstallApproval = vi.fn(async () => ({ approvalRequestId: state.pendingApprovalId, status: "pending" as const }));
|
||||
const resolveWorktrunkBinary = vi.fn(async () => {
|
||||
if (!state.installed) throw new Error("missing");
|
||||
return { binaryPath: "~/.fusion/bin/worktrunk", source: "cached" as const };
|
||||
});
|
||||
const probeWorktrunk = vi.fn(async () => ({ ok: true, version: "0.4.2" }));
|
||||
|
||||
class MockApprovalRequestStore {
|
||||
constructor(_: unknown) {}
|
||||
findLatestByDedupeKey() {
|
||||
return state.requests.get(state.pendingApprovalId) ?? null;
|
||||
}
|
||||
get(id: string) {
|
||||
return state.requests.get(id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/worktrunk",
|
||||
WORKTRUNK_PINNED_RELEASE: { version: "0.4.2", assets: { unknown: { url: "u", sha256: "s" } } },
|
||||
requestWorktrunkInstallApproval,
|
||||
resolveWorktrunkBinary,
|
||||
probeWorktrunk,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async (orig) => {
|
||||
const actual = await orig<any>();
|
||||
return { ...actual, ApprovalRequestStore: MockApprovalRequestStore };
|
||||
});
|
||||
|
||||
describe("worktrunk routes", async () => {
|
||||
const { registerWorktrunkRoutes } = await import("../routes/register-worktrunk-routes.js");
|
||||
|
||||
function createApp() {
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerWorktrunkRoutes({
|
||||
router,
|
||||
getProjectContext: async () => ({
|
||||
store: {
|
||||
getDatabase: () => ({}),
|
||||
getSettings: async () => ({ worktrunk: { enabled: true, onFailure: "fail" } }),
|
||||
},
|
||||
projectId: "p1",
|
||||
engine: undefined,
|
||||
}),
|
||||
rethrowAsApiError: (e: unknown) => {
|
||||
throw e;
|
||||
},
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use("/api", router);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.installed = false;
|
||||
state.requests = new Map();
|
||||
requestWorktrunkInstallApproval.mockClear();
|
||||
});
|
||||
|
||||
it("returns installed from status when binary exists", async () => {
|
||||
state.installed = true;
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/worktrunk/status");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("installed");
|
||||
});
|
||||
|
||||
it("returns pending-approval from status when pending request exists", async () => {
|
||||
state.requests.set("apr-worktrunk", { id: "apr-worktrunk", status: "pending" });
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/worktrunk/status");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("pending-approval");
|
||||
});
|
||||
|
||||
it("creates install request when missing", async () => {
|
||||
state.requests.set("apr-worktrunk", {
|
||||
id: "apr-worktrunk",
|
||||
status: "pending",
|
||||
requester: { actorId: "user", actorType: "user", actorName: "User" },
|
||||
targetAction: { category: "network_api", summary: "Install", action: "worktrunk_install", resourceType: "binary", resourceId: "x" },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
requestedAt: new Date().toISOString(),
|
||||
});
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/worktrunk/install-request", JSON.stringify({}), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("pending-approval");
|
||||
expect(requestWorktrunkInstallApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,7 @@ 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 { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
|
||||
import { runGitCommand } from "./routes/resolve-diff-base.js";
|
||||
|
||||
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500;
|
||||
@@ -1037,6 +1038,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
registerAgentsProjectsNodesRoutes(routeContext);
|
||||
registerPluginsAutomationRoutes(routeContext);
|
||||
registerApprovalRoutes(routeContext);
|
||||
registerWorktrunkRoutes(routeContext);
|
||||
|
||||
// HeartbeatMonitor for triggering agent execution runs
|
||||
const heartbeatMonitor = options?.heartbeatMonitor;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ApprovalRequestActorSnapshot,
|
||||
type ApprovalRequestStatus,
|
||||
} from "@fusion/core";
|
||||
import { executeApprovedAgentProvisioning } from "@fusion/engine";
|
||||
import { executeApprovedAgentProvisioning, executeApprovedWorktrunkInstall } from "@fusion/engine";
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
import { emitApprovalSseEvent } from "../sse.js";
|
||||
@@ -300,6 +300,36 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (updated.targetAction.category === "network_api" && updated.targetAction.action === "worktrunk_install") {
|
||||
if (body.decision === "approve") {
|
||||
try {
|
||||
const settings = await scopedStore.getSettings();
|
||||
await executeApprovedWorktrunkInstall({
|
||||
approvalStore,
|
||||
settings: settings.worktrunk ?? {},
|
||||
request: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
runtimeLogger.warn("Worktrunk install approval execution failed", {
|
||||
requestId: updated.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
scopedStore.recordRunAuditEvent({
|
||||
domain: "filesystem",
|
||||
mutationType: "binary:install-failed",
|
||||
target: updated.targetAction.resourceId,
|
||||
agentId: updated.requester.actorId,
|
||||
runId: updated.runId ?? updated.id,
|
||||
...(updated.taskId ? { taskId: updated.taskId } : {}),
|
||||
metadata: {
|
||||
approvalRequestId: updated.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated.targetAction.category === "sandbox_provisioning") {
|
||||
if (body.decision === "approve") {
|
||||
if (sandboxProvisioningExecutor) {
|
||||
|
||||
113
packages/dashboard/src/routes/register-worktrunk-routes.ts
Normal file
113
packages/dashboard/src/routes/register-worktrunk-routes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ApprovalRequestStore, type ApprovalRequestActorSnapshot } from "@fusion/core";
|
||||
import {
|
||||
WORKTRUNK_INSTALL_PATH,
|
||||
WORKTRUNK_PINNED_RELEASE,
|
||||
probeWorktrunk,
|
||||
requestWorktrunkInstallApproval,
|
||||
resolveWorktrunkBinary,
|
||||
} from "@fusion/engine";
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import { emitApprovalSseEvent } from "../sse.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
|
||||
actorId: "user",
|
||||
actorType: "user",
|
||||
actorName: "User",
|
||||
};
|
||||
|
||||
export function registerWorktrunkRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
|
||||
router.get("/worktrunk/status", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const worktrunkSettings = settings.worktrunk ?? {};
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorktrunkBinary({ settings: worktrunkSettings });
|
||||
const probe = await probeWorktrunk(resolved.binaryPath);
|
||||
res.json({
|
||||
status: "installed",
|
||||
version: probe.version ?? WORKTRUNK_PINNED_RELEASE.version,
|
||||
installPath: resolved.binaryPath,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// continue to pending/missing lookup
|
||||
}
|
||||
|
||||
const pending = approvalStore.findLatestByDedupeKey({
|
||||
requesterActorId: DEFAULT_ACTOR.actorId,
|
||||
dedupeKey: `worktrunk_install:${WORKTRUNK_PINNED_RELEASE.version}`,
|
||||
});
|
||||
|
||||
if (pending?.status === "pending") {
|
||||
res.json({
|
||||
status: "pending-approval",
|
||||
pendingApprovalId: pending.id,
|
||||
installPath: WORKTRUNK_INSTALL_PATH,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending?.status === "denied") {
|
||||
res.json({ status: "denied", error: "Install approval was denied", installPath: WORKTRUNK_INSTALL_PATH });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ status: "missing", installPath: WORKTRUNK_INSTALL_PATH });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/worktrunk/install-request", async (req, res) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as { actor?: ApprovalRequestActorSnapshot };
|
||||
if (body.actor && (!body.actor.actorId || !body.actor.actorType || !body.actor.actorName)) {
|
||||
throw badRequest("actor must include actorId, actorType, and actorName");
|
||||
}
|
||||
const actor = body.actor ?? DEFAULT_ACTOR;
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const worktrunkSettings = settings.worktrunk ?? {};
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorktrunkBinary({ settings: worktrunkSettings });
|
||||
res.json({ status: "installed", installPath: resolved.binaryPath, version: WORKTRUNK_PINNED_RELEASE.version });
|
||||
return;
|
||||
} catch {
|
||||
// proceed with approval request
|
||||
}
|
||||
|
||||
const request = await requestWorktrunkInstallApproval({
|
||||
approvalStore,
|
||||
actor,
|
||||
projectId,
|
||||
});
|
||||
const detail = approvalStore.get(request.approvalRequestId);
|
||||
if (detail) {
|
||||
emitApprovalSseEvent("approval:requested", {
|
||||
id: detail.id,
|
||||
status: detail.status,
|
||||
actionCategory: detail.targetAction.category,
|
||||
actionSummary: detail.targetAction.summary,
|
||||
agentId: detail.requester.actorId,
|
||||
taskId: detail.taskId,
|
||||
createdAt: detail.createdAt,
|
||||
updatedAt: detail.updatedAt,
|
||||
decidedAt: detail.decidedAt,
|
||||
}, projectId);
|
||||
}
|
||||
res.json({ status: "pending-approval", approvalRequestId: request.approvalRequestId });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user