Files
fusion/plugins/fusion-plugin-even-realities-glasses/src/agent-actions.ts
Fusion fe5148cc19 feat(FN-3744): add agent action routes, tools, and roadmap plugin extractio
Merges agent lifecycle tools (create_agent, delete_agent) with full API routes, web fetch tool implementation and tests, and full roadmap UI extraction into a bundled plugin — including schema hook, chat scroll fixes, and autostash orchestrator cleanup. Also adds mesh auth snapshot replication acros

Fusion-Task-Id: FN-3744
2026-05-08 19:59:43 -07:00

154 lines
5.6 KiB
TypeScript

import type { PluginContext } from "@fusion/plugin-sdk";
import { taskToCard, type GlassesCard } from "./cards.js";
import { GlassesInputError } from "./quick-capture.js";
type TaskRecord = NonNullable<Awaited<ReturnType<PluginContext["taskStore"]["getTask"]>>>;
type AgentActionInput = {
taskId: unknown;
};
type AgentActionDeps = {
taskStore: PluginContext["taskStore"];
pluginId: string;
cardOptions?: unknown;
};
type AgentActionResult = {
task: TaskRecord;
card: GlassesCard;
};
const START_WORK_BLOCKED_STATUSES = new Set(["planning", "needs-replan", "awaiting-approval", "awaiting-user-review"]);
const RETRYABLE_FAILURE_STATUSES = new Set(["failed", "stuck-killed"]);
const RETRYABLE_TRIAGE_STATUSES = new Set(["failed", "planning", "needs-replan"]);
function normalizeTaskId(value: unknown): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new GlassesInputError(400, "taskId is required");
}
return value.trim();
}
async function getTaskOrThrow(taskStore: PluginContext["taskStore"], taskId: string): Promise<TaskRecord> {
const task = await taskStore.getTask(taskId);
if (!task) {
throw new GlassesInputError(404, "task not found");
}
return task as TaskRecord;
}
function conflict(verb: string, task: { column: unknown; status?: unknown }): never {
throw new GlassesInputError(409, `${verb} not allowed in column=${String(task.column)} status=${String(task.status ?? null)}`);
}
async function toResult(taskStore: PluginContext["taskStore"], taskId: string): Promise<AgentActionResult> {
const task = await getTaskOrThrow(taskStore, taskId);
return { task, card: taskToCard(task as never) };
}
export async function startWork(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
const taskId = normalizeTaskId(input.taskId);
const task = await getTaskOrThrow(deps.taskStore, taskId);
if ((task.column !== "triage" && task.column !== "todo") || START_WORK_BLOCKED_STATUSES.has(String(task.status))) {
conflict("start-work", task);
}
// Intentional v1 limitation: plugin cannot import engine allocator, so moveTask runs without allocateWorktree.
await deps.taskStore.moveTask(taskId, "in-progress");
return toResult(deps.taskStore, taskId);
}
export async function requestReview(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
const taskId = normalizeTaskId(input.taskId);
const task = await getTaskOrThrow(deps.taskStore, taskId);
if (task.column !== "in-progress") {
conflict("request-review", task);
}
await deps.taskStore.moveTask(taskId, "in-review");
return toResult(deps.taskStore, taskId);
}
export async function approvePlan(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
const taskId = normalizeTaskId(input.taskId);
const task = await getTaskOrThrow(deps.taskStore, taskId);
if (task.column !== "triage" || task.status !== "awaiting-approval") {
conflict("approve-plan", task);
}
await deps.taskStore.moveTask(taskId, "todo");
await deps.taskStore.updateTask(taskId, { status: undefined });
return toResult(deps.taskStore, taskId);
}
export async function acceptReview(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
const taskId = normalizeTaskId(input.taskId);
const task = await getTaskOrThrow(deps.taskStore, taskId);
if (task.column !== "in-review") {
conflict("accept-review", task);
}
await deps.taskStore.updateTask(taskId, { status: null, assigneeUserId: null });
return toResult(deps.taskStore, taskId);
}
export async function returnToAgent(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
const taskId = normalizeTaskId(input.taskId);
const task = await getTaskOrThrow(deps.taskStore, taskId);
if (task.column !== "in-review") {
conflict("return-to-agent", task);
}
await deps.taskStore.updateTask(taskId, {
assigneeUserId: null,
status: null,
assignedAgentId: null,
});
await deps.taskStore.moveTask(taskId, "todo");
return toResult(deps.taskStore, taskId);
}
export async function retryTask(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
const taskId = normalizeTaskId(input.taskId);
const task = await getTaskOrThrow(deps.taskStore, taskId);
if (task.column === "in-review" && RETRYABLE_FAILURE_STATUSES.has(String(task.status))) {
await deps.taskStore.updateTask(taskId, { status: null, error: null, stuckKillCount: 0, mergeRetries: 0 });
return toResult(deps.taskStore, taskId);
}
if (
task.column === "triage" &&
(RETRYABLE_TRIAGE_STATUSES.has(String(task.status)) || (typeof task.stuckKillCount === "number" && task.stuckKillCount > 0))
) {
// Intentional v1 limitation: does not delete on-disk PROMPT.md or run dashboard step-reset/branch-inspection logic.
await deps.taskStore.updateTask(taskId, {
status: "needs-replan",
error: null,
worktree: null,
branch: null,
baseBranch: null,
baseCommitSha: null,
stuckKillCount: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
return toResult(deps.taskStore, taskId);
}
if (RETRYABLE_FAILURE_STATUSES.has(String(task.status))) {
// Intentional v1 limitation: omits dashboard retry step-reset/branch-inspection behavior.
await deps.taskStore.updateTask(taskId, {
status: null,
error: null,
worktree: null,
branch: null,
baseBranch: null,
baseCommitSha: null,
stuckKillCount: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
await deps.taskStore.moveTask(taskId, "todo");
return toResult(deps.taskStore, taskId);
}
conflict("retry", task);
}