FN-8198: add Todo item task creation API

Add API endpoints that turn Todo lists and items into executable board tasks.

- Expose Todo list and item read endpoints with async store parity.
- Create tasks from Todo items with validation, project scoping, provenance, and workflow-default placement.
- Document the scripting flow and cover dashboard and PostgreSQL behavior.

Files changed:
 .changeset/fn-8198-todo-api.md                     |   7 +
 docs/todo-view.md                                  |  18 ++-
 .../src/__tests__/postgres/todo-store.pg.test.ts   |  13 ++
 packages/core/src/async-todo-store.ts              |  17 +++
 .../dashboard/src/__tests__/todo-routes.test.ts    | 157 ++++++++++++++++++++-
 packages/dashboard/src/todo-routes.ts              | 128 ++++++++++++++++-
 6 files changed, 335 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8198

Fusion-Task-Lineage: f8d5032b-4844-43c9-97d1-3cc7d13d0ce1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 01:23:32 -07:00
parent a51cba0d69
commit 7b540959c5
6 changed files with 335 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Todo API read + create-task endpoints so scripts can turn a todo into a running task.
category: feature
dev: New `/api/todos/:id`, `/api/todos/:id/items`, `/api/todos/items/:id`, and `POST /api/todos/items/:id/create-task` routes in todo-routes.ts; AsyncTodoStore gains getList/getItem/listItems; create-task validates title/priority/workflowId/assignedAgentId, honors body projectId scoping, and delegates to TaskStore.createTask with source.sourceType="api".

View File

@@ -11,7 +11,7 @@ Todo View lets you:
- Create multiple todo lists per project
- Add, edit, complete, delete, and reorder todo items
- Start Planning Mode from any todo item (💡)
- Create a triage task from a todo item
- Create a task from a todo item using the project-default workflow
- Create and immediately assign a task to an agent from a todo item
The feature is implemented in `TodoView.tsx` with data/state orchestration in `useTodoLists.ts` and backend routes in `packages/dashboard/src/todo-routes.ts`.
@@ -87,8 +87,8 @@ See also: [Task Management → Todo item → Plan Mode](./task-management.md#3-t
Each item also has task actions:
- **Create task** (`+`): creates a new task in `triage` with todo text as description
- **Assign to agent** (bot icon): loads agents, then creates a new task in `triage` with `assignedAgentId`
- **Create task** (`+`): creates a new task with todo text as description; the project-default workflow selects its intake column
- **Assign to agent** (bot icon): loads agents, then creates a new task with `assignedAgentId` using that workflow's intake column
Both actions use dashboard task creation APIs and preserve project scoping when a project is selected.
@@ -100,16 +100,28 @@ Base prefix: `/api/todos`
- `GET /api/todos` — list lists with embedded items
- `POST /api/todos` — create list (`{ title }`)
- `GET /api/todos/:id` — get one list with its ordered items
- `PATCH /api/todos/:id` — update list title (`{ title }`)
- `DELETE /api/todos/:id` — delete list
### Items
- `POST /api/todos/:id/items` — create item in list (`{ text }`)
- `GET /api/todos/:id/items` — list ordered items in one list
- `GET /api/todos/items/:id` — get one item
- `POST /api/todos/items/:id/create-task` — create a board task from an item
- `PATCH /api/todos/items/:id` — update item (`{ text?; completed? }`)
- `DELETE /api/todos/items/:id` — delete item
- `POST /api/todos/:id/items/reorder` — reorder full list (`{ itemIds: string[] }`)
### Scripting a todo into execution
A script can create a list (`POST /api/todos`), add an item (`POST /api/todos/:id/items`), then create executable board work with `POST /api/todos/items/:id/create-task`.
The create-task request accepts optional `{ title?, priority?, workflowId?, assignedAgentId?, projectId? }`. `title` is trimmed and must be 1–200 characters when supplied; otherwise the task title is `item.text.slice(0, 200)`. `priority` must be `low`, `normal`, `high`, or `urgent`. Blank `workflowId` and `assignedAgentId` values are omitted, while non-blank values are trimmed. Invalid title or priority values return HTTP 400 without creating a task.
The created task has `source.sourceType: "api"` and `sourceMetadata.todoItemId` / `sourceMetadata.todoListId` provenance. It does not force `triage`: the selected or project-default workflow resolves the intake column.
### Project scoping
`projectId` may be provided:

View File

@@ -73,6 +73,19 @@ pgTest("TodoStore (PostgreSQL backend mode)", () => {
expect(await t.getListsWithItems("P-TODO")).toHaveLength(0);
});
it("exposes sync TodoStore read-method parity", async () => {
const t = todo();
const list = await t.createList("P-TODO-READS", { title: "Read parity" });
const first = await t.createItem(list.id, { text: "first" });
const second = await t.createItem(list.id, { text: "second" });
expect(await t.getList(list.id)).toMatchObject({ id: list.id, title: "Read parity" });
expect(await t.getItem(first.id)).toMatchObject({ id: first.id, text: "first" });
expect((await t.listItems(list.id)).map((item) => item.id)).toEqual([first.id, second.id]);
expect(await t.getList("TDL-MISSING")).toBeUndefined();
expect(await t.getItem("TDI-MISSING")).toBeUndefined();
});
it("createItem rejects a missing list with a clear error (parity with sync store)", async () => {
await expect(todo().createItem("TDL-DOES-NOT-EXIST", { text: "x" })).rejects.toThrow(/not found/);
});

View File

@@ -391,6 +391,23 @@ export class AsyncTodoStore extends EventEmitter<TodoStoreEvents> {
return getTodoListsWithItems(this.layer.db, projectId);
}
/*
FNXC:TodoStore 2026-07-16-00:48:
The /api/todos single-resource GET and create-task routes need the same
read-side methods for PostgreSQL as the sync TodoStore exposes.
*/
async getList(id: string): Promise<TodoList | undefined> {
return getTodoList(this.layer.db, id);
}
async getItem(id: string): Promise<TodoItem | undefined> {
return getTodoItem(this.layer.db, id);
}
async listItems(listId: string): Promise<TodoItem[]> {
return listTodoItems(this.layer.db, listId);
}
async createList(projectId: string, input: TodoListCreateInput): Promise<TodoList> {
const now = new Date().toISOString();
const list = await createTodoList(this.layer.db, {

View File

@@ -71,6 +71,12 @@ function createMockTodoStore() {
}))
),
getList: vi.fn((id: string) => lists.get(id)),
getItem: vi.fn((id: string) => items.get(id)),
listItems: vi.fn((listId: string) => listItemsForList(listId)),
createItem: vi.fn((listId: string, input: { text: string }) => {
if (!lists.has(listId)) {
throw new Error(`Todo list ${listId} not found`);
@@ -140,13 +146,18 @@ function createMockTodoStore() {
describe("Todo Routes", () => {
let app: express.Express;
let mockTodoStore: ReturnType<typeof createMockTodoStore>;
let mockStore: { getTodoStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
let mockStore: {
getTodoStore: ReturnType<typeof vi.fn>;
getRootDir: ReturnType<typeof vi.fn>;
createTask: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockTodoStore = createMockTodoStore();
mockStore = {
getTodoStore: vi.fn(() => mockTodoStore),
getRootDir: vi.fn(() => "/test/root"),
createTask: vi.fn(async (input) => ({ id: "FN-TODO-TASK", ...input })),
};
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
@@ -249,6 +260,45 @@ describe("Todo Routes", () => {
});
describe("item endpoints", () => {
it("GET /:id returns a list with its ordered items and 404s when missing", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const first = mockTodoStore.createItem(list.id, { text: "First" });
const second = mockTodoStore.createItem(list.id, { text: "Second" });
const response = await performGet(app, `/api/todos/${list.id}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: list.id, title: "Inbox" });
expect(response.body.items.map((item: TodoItem) => item.id)).toEqual([first.id, second.id]);
const missing = await performGet(app, "/api/todos/TDL-MISSING");
expect(missing.status).toBe(404);
});
it("GET /:id/items returns ordered items and 404s for a missing list", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const first = mockTodoStore.createItem(list.id, { text: "First" });
const second = mockTodoStore.createItem(list.id, { text: "Second" });
const response = await performGet(app, `/api/todos/${list.id}/items`);
expect(response.status).toBe(200);
expect(response.body.map((item: TodoItem) => item.id)).toEqual([first.id, second.id]);
const missing = await performGet(app, "/api/todos/TDL-MISSING/items");
expect(missing.status).toBe(404);
});
it("GET /items/:id returns an item and 404s when missing", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Read me" });
const response = await performGet(app, `/api/todos/items/${item.id}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: item.id, text: "Read me" });
const missing = await performGet(app, "/api/todos/items/TDI-MISSING");
expect(missing.status).toBe(404);
});
it("POST /:id/items creates item with valid text", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const response = await performRequest(
@@ -262,6 +312,94 @@ describe("Todo Routes", () => {
expect(response.body.text).toBe("Buy milk");
});
it("POST /items/:id/create-task creates a task with todo provenance and no forced column", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Ship Todo API" });
const response = await performRequest(app, "POST", `/api/todos/items/${item.id}/create-task`);
expect(response.status).toBe(201);
expect(response.body.id).toBe("FN-TODO-TASK");
expect(mockStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: item.text.slice(0, 200),
description: item.text,
source: {
sourceType: "api",
sourceMetadata: { todoItemId: item.id, todoListId: item.listId },
},
}));
expect(mockStore.createTask.mock.calls[0]?.[0]).not.toHaveProperty("column");
});
it("POST /items/:id/create-task honors trimmed optional task fields", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Original title" });
const response = await performRequest(
app,
"POST",
`/api/todos/items/${item.id}/create-task`,
JSON.stringify({
title: " Custom title ",
priority: "high",
workflowId: " workflow-custom ",
assignedAgentId: " agent-1 ",
}),
{ "Content-Type": "application/json" },
);
expect(response.status).toBe(201);
expect(mockStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Custom title",
priority: "high",
workflowId: "workflow-custom",
assignedAgentId: "agent-1",
}));
});
it("POST /items/:id/create-task omits blank workflow and agent IDs", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Task" });
const response = await performRequest(
app,
"POST",
`/api/todos/items/${item.id}/create-task`,
JSON.stringify({ workflowId: " ", assignedAgentId: "\t" }),
{ "Content-Type": "application/json" },
);
expect(response.status).toBe(201);
const input = mockStore.createTask.mock.calls[0]?.[0] as Record<string, unknown>;
expect(input).not.toHaveProperty("workflowId");
expect(input).not.toHaveProperty("assignedAgentId");
});
it.each([
[{ title: " " }, "blank"],
[{ title: "A".repeat(201) }, "200 characters"],
[{ priority: "critical" }, "priority"],
])("POST /items/:id/create-task rejects invalid input %#", async (body, message) => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Task" });
const response = await performRequest(
app,
"POST",
`/api/todos/items/${item.id}/create-task`,
JSON.stringify(body),
{ "Content-Type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain(message);
expect(mockStore.createTask).not.toHaveBeenCalled();
});
it("POST /items/:id/create-task returns 404 for a missing item", async () => {
const response = await performRequest(app, "POST", "/api/todos/items/TDI-MISSING/create-task");
expect(response.status).toBe(404);
expect(mockStore.createTask).not.toHaveBeenCalled();
});
it.each([
[{}, "text is required"],
[{ text: "" }, "text is required"],
@@ -403,6 +541,23 @@ describe("Todo Routes", () => {
expect(mockTodoStore.createList).toHaveBeenCalledWith("project-body", { title: "List" });
});
it("uses a body projectId to resolve the scoped store for create-task", async () => {
const list = mockTodoStore.createList("project-body", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Scoped task" });
const response = await performRequest(
app,
"POST",
`/api/todos/items/${item.id}/create-task`,
JSON.stringify({ projectId: "project-task-scope" }),
{ "Content-Type": "application/json" },
);
expect(response.status).toBe(201);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-task-scope");
expect(mockStore.createTask).toHaveBeenCalledTimes(1);
});
it("passes projectId from body to item update route via scoped resolver", async () => {
const list = mockTodoStore.createList("project-body", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Initial" });

View File

@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore } from "@fusion/core";
import { TaskStore, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import {
ApiError,
badRequest,
@@ -57,6 +57,37 @@ function validateStringArray(arr: unknown, fieldName: string): string[] {
return arr;
}
function validateOptionalTaskTitle(title: unknown): string | undefined {
if (title === undefined) return undefined;
if (typeof title !== "string") {
throw badRequest("title must be a string");
}
const trimmed = title.trim();
if (!trimmed) {
throw badRequest("title must not be blank");
}
if (trimmed.length > 200) {
throw badRequest("title must not exceed 200 characters");
}
return trimmed;
}
function validateOptionalTrimmedString(value: unknown, field: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") {
throw badRequest(`${field} must be a string`);
}
return value.trim() || undefined;
}
function validateOptionalPriority(priority: unknown): TaskPriority | undefined {
if (priority === undefined) return undefined;
if (priority === "low" || priority === "normal" || priority === "high" || priority === "urgent") {
return priority;
}
throw badRequest("priority must be one of low, normal, high, urgent");
}
export function createTodoRouter(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
@@ -115,6 +146,36 @@ export function createTodoRouter(store: TaskStore, options?: ServerOptions): Rou
}
});
// FNXC:TodoApiRoutes 2026-07-16-00:52: `/:id` is single-segment while item routes are two or more segments, so these GET routes cannot shadow each other.
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const todoStore = getScopedStore().getTodoStore();
const list = await todoStore.getList(id);
if (!list) {
throw notFound(`Todo list ${id} not found`);
}
const items = await todoStore.listItems(id);
res.json({ ...list, items });
} catch (error) {
rethrowAsApiError(error, "Failed to get todo list");
}
});
router.get("/:id/items", async (req, res) => {
try {
const { id } = req.params;
const todoStore = getScopedStore().getTodoStore();
const list = await todoStore.getList(id);
if (!list) {
throw notFound(`Todo list ${id} not found`);
}
res.json(await todoStore.listItems(id));
} catch (error) {
rethrowAsApiError(error, "Failed to list todo items");
}
});
router.patch("/:id", async (req, res) => {
try {
const { id } = req.params;
@@ -161,6 +222,71 @@ export function createTodoRouter(store: TaskStore, options?: ServerOptions): Rou
}
});
router.get("/items/:id", async (req, res) => {
try {
const { id } = req.params;
const todoStore = getScopedStore().getTodoStore();
const item = await todoStore.getItem(id);
if (!item) {
throw notFound(`Todo item ${id} not found`);
}
res.json(item);
} catch (error) {
rethrowAsApiError(error, "Failed to get todo item");
}
});
router.post("/items/:id/create-task", async (req, res) => {
try {
const { id } = req.params;
const body = (req.body ?? {}) as {
title?: unknown;
priority?: unknown;
workflowId?: unknown;
assignedAgentId?: unknown;
};
const title = validateOptionalTaskTitle(body.title);
const priority = validateOptionalPriority(body.priority);
const workflowId = validateOptionalTrimmedString(body.workflowId, "workflowId");
const assignedAgentId = validateOptionalTrimmedString(body.assignedAgentId, "assignedAgentId");
const scopedStore = getScopedStore();
const todoStore = scopedStore.getTodoStore();
const item = await todoStore.getItem(id);
if (!item) {
throw notFound(`Todo item ${id} not found`);
}
/*
FNXC:TodoTaskCreation 2026-07-16-00:54:
Scripts must be able to create a todo then start it as Fusion board work.
Preserve todo provenance through API source metadata so the task retains its
todo item/list linkage without a schema migration.
FNXC:TodoTaskCreation 2026-07-16-00:54:
Do not set `column`: the project-default workflow's intake trait chooses
the landing column, preserving FN-7591/FN-7611 custom-workflow behavior.
*/
const input: TaskCreateInput = {
title: title ?? item.text.slice(0, 200),
description: item.text,
...(priority ? { priority } : {}),
...(workflowId ? { workflowId } : {}),
...(assignedAgentId ? { assignedAgentId } : {}),
source: {
sourceType: "api",
sourceMetadata: {
todoItemId: item.id,
todoListId: item.listId,
},
},
};
const task = await scopedStore.createTask(input);
res.status(201).json(task);
} catch (error) {
rethrowAsApiError(error, "Failed to create task from todo item");
}
});
router.patch("/items/:id", async (req, res) => {
try {
const { id } = req.params;