diff --git a/.changeset/fn-8198-todo-api.md b/.changeset/fn-8198-todo-api.md new file mode 100644 index 0000000000..e1a6db2b7a --- /dev/null +++ b/.changeset/fn-8198-todo-api.md @@ -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". diff --git a/docs/todo-view.md b/docs/todo-view.md index cc95ca3879..c76f378d1b 100644 --- a/docs/todo-view.md +++ b/docs/todo-view.md @@ -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: diff --git a/packages/core/src/__tests__/postgres/todo-store.pg.test.ts b/packages/core/src/__tests__/postgres/todo-store.pg.test.ts index 4164faa039..0abbfd3174 100644 --- a/packages/core/src/__tests__/postgres/todo-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/todo-store.pg.test.ts @@ -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/); }); diff --git a/packages/core/src/async-todo-store.ts b/packages/core/src/async-todo-store.ts index e9e44a5fda..b856382319 100644 --- a/packages/core/src/async-todo-store.ts +++ b/packages/core/src/async-todo-store.ts @@ -391,6 +391,23 @@ export class AsyncTodoStore extends EventEmitter { 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 { + return getTodoList(this.layer.db, id); + } + + async getItem(id: string): Promise { + return getTodoItem(this.layer.db, id); + } + + async listItems(listId: string): Promise { + return listTodoItems(this.layer.db, listId); + } + async createList(projectId: string, input: TodoListCreateInput): Promise { const now = new Date().toISOString(); const list = await createTodoList(this.layer.db, { diff --git a/packages/dashboard/src/__tests__/todo-routes.test.ts b/packages/dashboard/src/__tests__/todo-routes.test.ts index 19de492bae..02c2a7073e 100644 --- a/packages/dashboard/src/__tests__/todo-routes.test.ts +++ b/packages/dashboard/src/__tests__/todo-routes.test.ts @@ -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; - let mockStore: { getTodoStore: ReturnType; getRootDir: ReturnType }; + let mockStore: { + getTodoStore: ReturnType; + getRootDir: ReturnType; + createTask: ReturnType; + }; 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; + 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" }); diff --git a/packages/dashboard/src/todo-routes.ts b/packages/dashboard/src/todo-routes.ts index afcaa3e0d1..53509f9d03 100644 --- a/packages/dashboard/src/todo-routes.ts +++ b/packages/dashboard/src/todo-routes.ts @@ -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(); @@ -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;