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:
7
.changeset/fn-8198-todo-api.md
Normal file
7
.changeset/fn-8198-todo-api.md
Normal 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".
|
||||
@@ -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:
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user