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:
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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" }) },
|
||||
];
|
||||
Reference in New Issue
Block a user