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
This commit is contained in:
Fusion
2026-05-08 19:54:41 -07:00
committed by gsxdsm
parent d060558189
commit fe5148cc19
6 changed files with 648 additions and 92 deletions

View File

@@ -4,10 +4,9 @@
## Scope (v1)
- Read board/task status through Fusion dashboard HTTP APIs (`/api/tasks*`)
- Quick capture text into new tasks
- Polling-based task transition notifications
- Agent actions: start work (`in-progress`) and request review (`in-review`)
- Selected task workflow actions from glasses
Out of scope in v1: missions, roadmaps, search, multi-project routing, cloud/remote deployment orchestration.
@@ -50,28 +49,78 @@ curl -X POST http://localhost:4040/api/plugins/fusion-plugin-even-realities-glas
-d '{"text":"hey fusion, file a bug about the merge gate"}'
```
Response:
## Agent actions
All action routes require `Authorization: Bearer <apiKey>` and `enableAgentActions=true`.
### Endpoints
| Method | Path |
| --- | --- |
| POST | `/actions/start-work` |
| POST | `/actions/request-review` |
| POST | `/actions/approve-plan` |
| POST | `/actions/accept-review` |
| POST | `/actions/return-to-agent` |
| POST | `/actions/retry` |
Request body:
```json
{ "taskId": "FN-123" }
```
Success response:
```json
{
"task": {
"id": "FN-1234",
"description": "file a bug about the merge gate\nfile a bug about the merge gate",
"column": "triage"
},
"card": {
"id": "task-FN-1234",
"kind": "task",
"title": "FN-1234: file a bug about the merge gate",
"bodyLines": [
"file a bug about the merge gate\nfile a bug about the merge gate",
"Column: triage"
],
"accentColor": "yellow"
}
"task": { "id": "FN-123" },
"card": { "kind": "task", "taskId": "FN-123" }
}
```
Error envelope:
```json
{ "error": "message" }
```
Status mapping:
- `401`: missing/wrong API key
- `403`: `enableAgentActions` disabled
- `400`: invalid input (for example empty `taskId`)
- `404`: task not found
- `409`: action not allowed for current column/status
- `500`: unexpected internal error
Preconditions and mutations:
| Action | Allowed preconditions | Mutation |
| --- | --- | --- |
| `start-work` | `column ∈ {triage, todo}` and `status` not in `{planning, needs-replan, awaiting-approval, awaiting-user-review}` | `moveTask(id, "in-progress")` |
| `request-review` | `column === "in-progress"` | `moveTask(id, "in-review")` |
| `approve-plan` | `column === "triage"` and `status === "awaiting-approval"` | `moveTask(id, "todo")` then `updateTask(id, { status: undefined })` |
| `accept-review` | `column === "in-review"` | `updateTask(id, { status: null, assigneeUserId: null })` |
| `return-to-agent` | `column === "in-review"` | `updateTask(id, { assigneeUserId: null, status: null, assignedAgentId: null })` then `moveTask(id, "todo")` |
| `retry` (in-review branch) | `column === "in-review"` and `status ∈ {failed, stuck-killed}` | `updateTask(id, { status: null, error: null, stuckKillCount: 0, mergeRetries: 0 })` |
| `retry` (triage/planning branch) | `column === "triage"` and (`status ∈ {failed, planning, needs-replan}` or `(stuckKillCount ?? 0) > 0`) | `updateTask(id, { status: "needs-replan", error: null, worktree: null, branch: null, baseBranch: null, baseCommitSha: null, stuckKillCount: 0, recoveryRetryCount: null, nextRecoveryAt: null })` |
| `retry` (general failed branch) | `status ∈ {failed, stuck-killed}` and not covered by branches above | `updateTask(id, { status: null, error: null, worktree: null, branch: null, baseBranch: null, baseCommitSha: null, stuckKillCount: 0, recoveryRetryCount: null, nextRecoveryAt: null })` then `moveTask(id, "todo")` |
Example:
```bash
curl -X POST http://localhost:4040/api/plugins/fusion-plugin-even-realities-glasses/actions/start-work \
-H "Authorization: Bearer <apiKey>" \
-H "Content-Type: application/json" \
-d '{"taskId":"FN-123"}'
```
### Known limitations (v1)
- `startWork` does not allocate a worktree directly. Tasks can enter `in-progress` with `worktree: null`; executor `createWorktree` flow allocates on first dispatch.
- `approvePlan` performs move-then-clear; re-fetched responses may present `task.status == null`.
- `retry` triage/planning branch does not delete on-disk `PROMPT.md` and does not run dashboard retry step-reset / branch-inspection logic.
## Notifications
Notifications are produced by polling `taskStore.listTasks({ includeArchived: false })` on `pollingIntervalSeconds` and diffing against persisted snapshot rows in `even_realities_seen_tasks`.

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import { agentActionRoutes } from "../routes/agent-action-routes.js";
type FakeTask = {
id: string;
column: string;
status?: string | null;
description: string;
title?: string;
updatedAt: string;
assigneeUserId?: string | null;
assignedAgentId?: string | null;
stuckKillCount?: number | null;
};
type Ctx = {
pluginId: string;
settings: { apiKey: string; enableAgentActions: boolean };
taskStore: {
getTask: ReturnType<typeof vi.fn>;
moveTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
};
logger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; info: ReturnType<typeof vi.fn> };
moveTask: ReturnType<typeof vi.fn>;
updateTask: ReturnType<typeof vi.fn>;
};
function makeTask(overrides: Partial<FakeTask> = {}): FakeTask {
return {
id: "FN-1",
column: "todo",
status: null,
description: "task",
title: "task",
updatedAt: "2026-01-01T00:00:00.000Z",
assigneeUserId: "u1",
assignedAgentId: "a1",
stuckKillCount: 0,
...overrides,
};
}
function getRoute(path: string) {
const route = agentActionRoutes.find((entry) => entry.method === "POST" && entry.path === path);
if (!route) throw new Error(`missing route ${path}`);
return route;
}
function createCtx(task: FakeTask, overrides: Partial<Ctx> = {}): Ctx {
const state = { ...task };
const getTask = vi.fn(async (id: string) => (id === state.id ? { ...state } : null));
const moveTask = vi.fn(async (id: string, column: string) => {
if (id !== state.id) throw new Error("boom");
state.column = column;
});
const updateTask = vi.fn(async (id: string, updates: Record<string, unknown>) => {
if (id !== state.id) throw new Error("boom");
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) delete (state as Record<string, unknown>)[key];
else (state as Record<string, unknown>)[key] = value;
}
});
const base: Ctx = {
pluginId: "fusion-plugin-even-realities-glasses",
settings: { apiKey: "secret", enableAgentActions: true },
taskStore: { getTask, moveTask, updateTask },
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
moveTask,
updateTask,
};
return { ...base, ...overrides };
}
const CASES = [
{ verb: "start-work", path: "/actions/start-work", happyTask: makeTask({ column: "todo" }), badTask: makeTask({ column: "triage", status: "planning" }) },
{ verb: "request-review", path: "/actions/request-review", happyTask: makeTask({ column: "in-progress" }), badTask: makeTask({ column: "todo" }) },
{ verb: "approve-plan", path: "/actions/approve-plan", happyTask: makeTask({ column: "triage", status: "awaiting-approval" }), badTask: makeTask({ column: "triage", status: "planning" }) },
{ verb: "accept-review", path: "/actions/accept-review", happyTask: makeTask({ column: "in-review", status: "awaiting-user-review" }), badTask: makeTask({ column: "todo" }) },
{ verb: "return-to-agent", path: "/actions/return-to-agent", happyTask: makeTask({ column: "in-review", status: "failed" }), badTask: makeTask({ column: "todo" }) },
{ verb: "retry", path: "/actions/retry", happyTask: makeTask({ column: "todo", status: "failed" }), badTask: makeTask({ column: "in-progress", status: null }) },
] as const;
describe("agentActionRoutes", () => {
it.each(CASES)("returns 401 for missing/wrong key ($verb)", async ({ path, happyTask }) => {
const route = getRoute(path);
const ctx = createCtx(happyTask);
const missing = await route.handler({ body: { taskId: "FN-1" }, headers: {} } as never, ctx as never);
const wrong = await route.handler({ body: { taskId: "FN-1" }, headers: { authorization: "Bearer bad" } } as never, ctx as never);
expect(missing).toMatchObject({ status: 401 });
expect(wrong).toMatchObject({ status: 401 });
});
it.each(CASES)("returns 403 when disabled ($verb)", async ({ path, happyTask }) => {
const route = getRoute(path);
const ctx = createCtx(happyTask, { settings: { apiKey: "secret", enableAgentActions: false } });
const res = await route.handler({ body: { taskId: "FN-1" }, headers: { authorization: "Bearer secret" } } as never, ctx as never);
expect(res).toMatchObject({ status: 403, body: { error: "agent actions are disabled" } });
expect(ctx.moveTask).not.toHaveBeenCalled();
expect(ctx.updateTask).not.toHaveBeenCalled();
});
it.each(CASES)("returns 400 for invalid taskId ($verb)", async ({ path, happyTask }) => {
const route = getRoute(path);
const ctx = createCtx(happyTask);
const res = await route.handler({ body: { taskId: " " }, headers: { authorization: "Bearer secret" } } as never, ctx as never);
expect(res).toMatchObject({ status: 400, body: { error: "taskId is required" } });
});
it.each(CASES)("returns 404 for unknown task ($verb)", async ({ path, happyTask }) => {
const route = getRoute(path);
const ctx = createCtx(happyTask);
const res = await route.handler({ body: { taskId: "FN-999" }, headers: { authorization: "Bearer secret" } } as never, ctx as never);
expect(res).toMatchObject({ status: 404, body: { error: "task not found" } });
});
it.each(CASES)("returns 409 for precondition mismatch ($verb)", async ({ path, badTask }) => {
const route = getRoute(path);
const ctx = createCtx(badTask);
const res = await route.handler({ body: { taskId: "FN-1" }, headers: { authorization: "Bearer secret" } } as never, ctx as never);
expect(res).toMatchObject({ status: 409 });
expect(ctx.moveTask).not.toHaveBeenCalled();
});
it.each(CASES)("returns 200 with task + card on success ($verb)", async ({ path, happyTask }) => {
const route = getRoute(path);
const ctx = createCtx(happyTask);
const res = await route.handler({ body: { taskId: "FN-1" }, headers: { authorization: "Bearer secret" } } as never, ctx as never);
expect(res).toMatchObject({ status: 200, body: { task: { id: "FN-1" }, card: { kind: "task" } } });
});
it.each(CASES)("returns 500 for unexpected store failure ($verb)", async ({ path, happyTask, verb }) => {
const route = getRoute(path);
const ctx = createCtx(happyTask, {
taskStore: {
getTask: vi.fn(async () => ({ ...happyTask })),
moveTask: vi.fn(async () => {
throw new Error("db down");
}),
updateTask: vi.fn(async () => {
throw new Error("db down");
}),
},
} as Partial<Ctx>);
const res = await route.handler({ body: { taskId: "FN-1" }, headers: { authorization: "Bearer secret" } } as never, ctx as never);
expect(res).toMatchObject({ status: 500, body: { error: `${verb} failed` } });
expect(ctx.logger.error).toHaveBeenCalled();
});
});

View File

@@ -1,28 +1,219 @@
import { describe, expect, it, vi } from "vitest";
import { requestReview, startWork } from "../agent-actions.js";
import {
acceptReview,
approvePlan,
requestReview,
retryTask,
returnToAgent,
startWork,
} from "../agent-actions.js";
import { GlassesInputError } from "../quick-capture.js";
describe("agent actions", () => {
it("moves task to in-progress when enabled", async () => {
const moveTask = vi.fn(async () => ({ id: "FN-1", title: "Task", description: "", column: "in-progress" }));
const card = await startWork("FN-1", {
apiClient: { moveTask } as never,
enableAgentActions: true,
logger: console,
});
expect(moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
expect(card?.id).toBe("task-FN-1");
type FakeTask = {
id: string;
column: string;
status?: string | null;
description: string;
title?: string;
updatedAt: string;
assigneeUserId?: string | null;
assignedAgentId?: string | null;
stuckKillCount?: number | null;
};
function makeTask(overrides: Partial<FakeTask> = {}): FakeTask {
return {
id: "FN-1",
column: "todo",
status: null,
description: "task",
title: "task",
updatedAt: "2026-01-01T00:00:00.000Z",
assigneeUserId: "u1",
assignedAgentId: "agent-1",
stuckKillCount: 0,
...overrides,
};
}
function createDeps(task: FakeTask) {
const state = { ...task };
const getTask = vi.fn(async (id: string) => (id === state.id ? { ...state } : null));
const moveTask = vi.fn(async (id: string, column: string) => {
if (id !== state.id) throw new Error("missing task");
state.column = column;
});
const updateTask = vi.fn(async (id: string, updates: Record<string, unknown>) => {
if (id !== state.id) throw new Error("missing task");
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
delete (state as Record<string, unknown>)[key];
} else {
(state as Record<string, unknown>)[key] = value;
}
}
});
return {
taskStore: { getTask, moveTask, updateTask },
pluginId: "fusion-plugin-even-realities-glasses",
state,
getTask,
moveTask,
updateTask,
};
}
async function expectInputError(promise: Promise<unknown>, status: number) {
await expect(promise).rejects.toBeInstanceOf(GlassesInputError);
await expect(promise).rejects.toMatchObject({ status });
}
describe("startWork", () => {
it("moves allowed tasks to in-progress and returns task card", async () => {
const deps = createDeps(makeTask({ column: "todo", status: null }));
const result = await startWork({ taskId: "FN-1" }, deps as never);
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
expect(result.card.kind).toBe("task");
expect(result.task.column).toBe("in-progress");
});
it("skips when disabled", async () => {
const moveTask = vi.fn();
const warn = vi.fn();
const card = await requestReview("FN-1", {
apiClient: { moveTask } as never,
enableAgentActions: false,
logger: { warn },
});
expect(card).toBeUndefined();
expect(moveTask).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
it("returns 409 for disallowed status/column with no mutation", async () => {
const deps = createDeps(makeTask({ column: "triage", status: "planning" }));
await expectInputError(startWork({ taskId: "FN-1" }, deps as never), 409);
expect(deps.moveTask).not.toHaveBeenCalled();
expect(deps.updateTask).not.toHaveBeenCalled();
});
it.each([undefined, "", " "])('returns 400 for invalid taskId: %p', async (taskId) => {
const deps = createDeps(makeTask());
await expectInputError(startWork({ taskId }, deps as never), 400);
});
it("returns 404 for unknown task", async () => {
const deps = createDeps(makeTask());
await expectInputError(startWork({ taskId: "FN-999" }, deps as never), 404);
});
});
describe("requestReview", () => {
it("moves in-progress task to in-review", async () => {
const deps = createDeps(makeTask({ column: "in-progress" }));
const result = await requestReview({ taskId: "FN-1" }, deps as never);
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "in-review");
expect(result.task.column).toBe("in-review");
});
it("returns 409 for wrong column", async () => {
const deps = createDeps(makeTask({ column: "todo" }));
await expectInputError(requestReview({ taskId: "FN-1" }, deps as never), 409);
expect(deps.moveTask).not.toHaveBeenCalled();
});
});
describe("approvePlan", () => {
it("moves then clears status in order", async () => {
const deps = createDeps(makeTask({ column: "triage", status: "awaiting-approval" }));
const result = await approvePlan({ taskId: "FN-1" }, deps as never);
expect(deps.moveTask).toHaveBeenCalledTimes(1);
expect(deps.updateTask).toHaveBeenCalledTimes(1);
expect(deps.moveTask.mock.invocationCallOrder[0]).toBeLessThan(deps.updateTask.mock.invocationCallOrder[0]);
expect(result.task.column).toBe("todo");
expect(result.task.status == null).toBe(true);
});
it("returns 409 for wrong status", async () => {
const deps = createDeps(makeTask({ column: "triage", status: "planning" }));
await expectInputError(approvePlan({ taskId: "FN-1" }, deps as never), 409);
expect(deps.moveTask).not.toHaveBeenCalled();
expect(deps.updateTask).not.toHaveBeenCalled();
});
});
describe("acceptReview", () => {
it("clears status and assignee on in-review task", async () => {
const deps = createDeps(makeTask({ column: "in-review", status: "awaiting-user-review" }));
const result = await acceptReview({ taskId: "FN-1" }, deps as never);
expect(deps.updateTask).toHaveBeenCalledTimes(1);
expect(deps.moveTask).not.toHaveBeenCalled();
expect(result.task.status == null).toBe(true);
expect(result.task.assigneeUserId == null).toBe(true);
});
it("returns 409 for wrong column", async () => {
const deps = createDeps(makeTask({ column: "todo" }));
await expectInputError(acceptReview({ taskId: "FN-1" }, deps as never), 409);
expect(deps.updateTask).not.toHaveBeenCalled();
});
});
describe("returnToAgent", () => {
it("clears assignment fields then moves to todo", async () => {
const deps = createDeps(makeTask({ column: "in-review", status: "failed" }));
const result = await returnToAgent({ taskId: "FN-1" }, deps as never);
expect(deps.updateTask).toHaveBeenCalledTimes(1);
expect(deps.moveTask).toHaveBeenCalledTimes(1);
expect(deps.updateTask.mock.invocationCallOrder[0]).toBeLessThan(deps.moveTask.mock.invocationCallOrder[0]);
expect(result.task.column).toBe("todo");
expect(result.task.assigneeUserId == null).toBe(true);
expect(result.task.status == null).toBe(true);
expect(result.task.assignedAgentId == null).toBe(true);
});
it("returns 409 for wrong column", async () => {
const deps = createDeps(makeTask({ column: "todo" }));
await expectInputError(returnToAgent({ taskId: "FN-1" }, deps as never), 409);
expect(deps.updateTask).not.toHaveBeenCalled();
expect(deps.moveTask).not.toHaveBeenCalled();
});
});
describe("retryTask", () => {
it.each([
{
name: "in-review failed branch",
task: makeTask({ column: "in-review", status: "failed" }),
expectMove: false,
expectedColumn: "in-review",
expectedStatus: null,
},
{
name: "triage planning branch",
task: makeTask({ column: "triage", status: "planning", stuckKillCount: 0 }),
expectMove: false,
expectedColumn: "triage",
expectedStatus: "needs-replan",
},
{
name: "triage stuck-killed-count branch",
task: makeTask({ column: "triage", status: null, stuckKillCount: 1 }),
expectMove: false,
expectedColumn: "triage",
expectedStatus: "needs-replan",
},
{
name: "general failed branch",
task: makeTask({ column: "todo", status: "stuck-killed" }),
expectMove: true,
expectedColumn: "todo",
expectedStatus: null,
},
])("applies $name", async ({ task, expectMove, expectedColumn, expectedStatus }) => {
const deps = createDeps(task);
const result = await retryTask({ taskId: "FN-1" }, deps as never);
expect(deps.updateTask).toHaveBeenCalledTimes(1);
if (expectMove) {
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "todo");
} else {
expect(deps.moveTask).not.toHaveBeenCalled();
}
expect(result.task.column).toBe(expectedColumn);
expect(result.task.status ?? null).toBe(expectedStatus);
});
it("returns 409 for healthy task", async () => {
const deps = createDeps(makeTask({ column: "in-progress", status: null }));
await expectInputError(retryTask({ taskId: "FN-1" }, deps as never), 409);
expect(deps.updateTask).not.toHaveBeenCalled();
expect(deps.moveTask).not.toHaveBeenCalled();
});
});

View File

@@ -1,26 +1,153 @@
import type { PluginContext } from "@fusion/plugin-sdk";
import { taskToCard, type GlassesCard } from "./cards.js";
import type { FusionApiClient } from "./fusion-api-client.js";
import { GlassesInputError } from "./quick-capture.js";
export async function startWork(
taskId: string,
deps: { apiClient: FusionApiClient; enableAgentActions: boolean; logger: Pick<Console, "warn"> },
): Promise<GlassesCard | undefined> {
if (!deps.enableAgentActions) {
deps.logger.warn("Agent actions are disabled; skipping start-work action");
return undefined;
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");
}
const task = await deps.apiClient.moveTask(taskId, "in-progress");
return taskToCard(task as never);
return value.trim();
}
export async function requestReview(
taskId: string,
deps: { apiClient: FusionApiClient; enableAgentActions: boolean; logger: Pick<Console, "warn"> },
): Promise<GlassesCard | undefined> {
if (!deps.enableAgentActions) {
deps.logger.warn("Agent actions are disabled; skipping request-review action");
return undefined;
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");
}
const task = await deps.apiClient.moveTask(taskId, "in-review");
return taskToCard(task as never);
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);
}

View File

@@ -1,17 +1,11 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import { requestReview, startWork } from "./agent-actions.js";
import { FusionApiClient } from "./fusion-api-client.js";
import { createNotifier } from "./notifier.js";
import { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
import { createNotificationRoutes } from "./routes/notification-routes.js";
import {
agentActionsEnabled,
getFusionBaseUrl,
getFusionToken,
getNotifyColumns,
settingsSchema,
} from "./settings.js";
import { agentActionRoutes } from "./routes/agent-action-routes.js";
import { getFusionBaseUrl, getFusionToken, getNotifyColumns, settingsSchema } from "./settings.js";
import { StubGlassesTransport } from "./transport.js";
export type PluginDb = {
@@ -61,30 +55,6 @@ const coreRoutes: PluginRouteDefinition[] = [
};
},
},
{
method: "POST",
path: "/actions/start-work",
handler: async (req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
const taskId = typeof (req as { body?: { taskId?: unknown } }).body?.taskId === "string" ? (req as { body?: { taskId?: string } }).body?.taskId : undefined;
if (!taskId) return { status: 400, body: { error: "taskId is required" } };
const card = await startWork(taskId, { apiClient: instance.client, enableAgentActions: agentActionsEnabled(ctx.settings), logger: ctx.logger });
return { status: 200, body: { ok: true, card: card ?? null } };
},
},
{
method: "POST",
path: "/actions/request-review",
handler: async (req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
const taskId = typeof (req as { body?: { taskId?: unknown } }).body?.taskId === "string" ? (req as { body?: { taskId?: string } }).body?.taskId : undefined;
if (!taskId) return { status: 400, body: { error: "taskId is required" } };
const card = await requestReview(taskId, { apiClient: instance.client, enableAgentActions: agentActionsEnabled(ctx.settings), logger: ctx.logger });
return { status: 200, body: { ok: true, card: card ?? null } };
},
},
{
method: "POST",
path: "/reconnect",
@@ -111,7 +81,7 @@ const plugin: FusionPlugin = definePlugin({
settingsSchema,
},
state: "installed",
routes: [...coreRoutes, ...quickCaptureRoutes, ...notificationRoutes],
routes: [...coreRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes],
hooks: {
onSchemaInit: (db) => {
(db as PluginDb).exec(`

View File

@@ -0,0 +1,67 @@
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import {
acceptReview,
approvePlan,
requestReview,
retryTask,
returnToAgent,
startWork,
} from "../agent-actions.js";
import { GlassesInputError } from "../quick-capture.js";
import { agentActionsEnabled } from "../settings.js";
import { requireApiKey } from "./quick-capture-routes.js";
type ActionOrchestrator = typeof startWork;
type HandlerOptions = {
verb: string;
};
function makeAgentActionHandler(orchestrator: ActionOrchestrator, options: HandlerOptions) {
return async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const auth = requireApiKey(ctx, req as { headers?: Record<string, string | string[] | undefined> });
if (!auth.ok) return auth.response;
if (!agentActionsEnabled(ctx.settings)) {
ctx.logger?.warn?.(`agent action rejected (${options.verb}): enableAgentActions is false`);
return { status: 403, body: { error: "agent actions are disabled" } };
}
const body = (req as { body?: { taskId?: unknown } }).body ?? {};
try {
const result = await orchestrator(
{ taskId: body.taskId },
{ taskStore: ctx.taskStore, pluginId: ctx.pluginId },
);
return { status: 200, body: result };
} catch (error) {
if (error instanceof GlassesInputError) {
return { status: error.status, body: { error: error.message } };
}
ctx.logger?.error?.(`${options.verb} failed`, error);
return { status: 500, body: { error: `${options.verb} failed` } };
}
};
}
export const agentActionRoutes: PluginRouteDefinition[] = [
{ method: "POST", path: "/actions/start-work", handler: makeAgentActionHandler(startWork, { verb: "start-work" }) },
{
method: "POST",
path: "/actions/request-review",
handler: makeAgentActionHandler(requestReview, { verb: "request-review" }),
},
{ method: "POST", path: "/actions/approve-plan", handler: makeAgentActionHandler(approvePlan, { verb: "approve-plan" }) },
{
method: "POST",
path: "/actions/accept-review",
handler: makeAgentActionHandler(acceptReview, { verb: "accept-review" }),
},
{
method: "POST",
path: "/actions/return-to-agent",
handler: makeAgentActionHandler(returnToAgent, { verb: "return-to-agent" }),
},
{ method: "POST", path: "/actions/retry", handler: makeAgentActionHandler(retryTask, { verb: "retry" }) },
];