diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md index 70347480e..3584c2f64 100644 --- a/docs/MCP_SERVER.md +++ b/docs/MCP_SERVER.md @@ -61,7 +61,7 @@ Use these fields when creating personal tasks: - `p_success`: probability from `0` to `1`; the MCP layer computes expected value as `value * p_success`. - `cash_cost`: dollars required to execute; defaults to `0`. - `executor_type`: `Self` for normal user work, even if AI assists; `AI Agent` only for autonomous assistant work. -- `depends_on`: task IDs that must be `VERIFIED` before this task appears in queues. +- `blockerTaskRefs`: task IDs or exact task keys that must be `VERIFIED` before this task appears in queues. `depends_on` remains a legacy alias. - `available_at`: earliest ISO time the task should appear in active queues. - `due_at`: due date or expiry date. - `deadline_policy`: `NONE`, `SOFT`, `EXPIRES`, or `REQUIRED`. @@ -74,7 +74,7 @@ Deadline policy rules: - `EXPIRES`: opportunity disappears after `due_at`, such as a grant or application. - `REQUIRED`: must-do obligation, such as taxes, legal filings, medicine refills, or safety/health maintenance. -Do not use difficulty or urgency words as substitutes for estimates. If something is mandatory, encode the avoided downside in `value`, put the real due date in `due_at`, and use `deadline_policy: "REQUIRED"`. If something unlocks other work, use `depends_on`. +Do not use difficulty or urgency words as substitutes for estimates. If something is mandatory, encode the avoided downside in `value`, put the real due date in `due_at`, and use `deadline_policy: "REQUIRED"`. If something unlocks other work, add its task ID or exact task key to each dependent task's `blockerTaskRefs`. Recommended OAuth scope for a personal life-planning AI: @@ -99,6 +99,165 @@ Do not request `tasks:admin` for personal planning. Public manual search and pub Task-listing tools take `visibility: "all" | "public" | "private"` — signed-in callers default to `all` (public plus their own private work). On the tools that previously exposed `scope`/`taskScope`, `"accessible"` survives as a deprecated alias for `"all"`; `listTasks` never had the alias and takes only `visibility`. +## Task References, Pagination, And Bundles + +Call `listTasks` or `searchTasks` with `paginated: true`. + +Example `listTasks` request: + +```json +{ + "parentTaskId": "", + "visibility": "all", + "limit": 50, + "paginated": true +} +``` + +`searchTasks` uses the same pagination fields plus its required `query`. + +Paginated response from either tool: + +```json +{ + "tasks": [], + "nextCursor": "" +} +``` + +When `nextCursor` is not `null`, repeat the same tool call with that exact value as `cursor`. Keep `paginated: true`, the query, and every filter unchanged. Continue until `nextCursor` is `null` before saying that a task list or search is complete. Paginated inventory uses immutable task-ID order so priority or relevance changes between calls cannot move tasks across the cursor; use queue-ranking tools when order matters. A cursor belongs only to the call that produced it. Calls with neither `paginated: true` nor `cursor` retain the legacy one-page array response for existing clients. + +Pagination is intentionally bounded. If a call returns `RESULT_WINDOW_EXCEEDED`, narrow its query-level filters instead of assuming the returned window is complete. + +A persisted task reference is one of these: + +- The task ID returned by a create, apply, search, or list operation. +- The exact stable `taskKey`. + +Titles are display text, not references. Do not search for a task merely to recover the ID of something the preceding write already returned. + +`createTask` returns the persisted identifier as both `id` and `taskId`, and repeats it in `writeReceipt.taskId`. Save it or the stable task key immediately. + +For `createTask`, use `parentTaskId` or `parentTaskKey` for the parent. Use `blockerTaskRefs` and `blockedTaskRefs` for graph edges; each reference may be a persisted ID or exact task key. The legacy `depends_on`, `blockerTaskIds`, and `blockedTaskIds` fields remain accepted, but new agents should use the `*Refs` names because they describe the actual contract. + +For one shared/public multi-task proposal, use `proposeTaskBundle` and give every candidate: + +- A short, unique `ref` for links within that request. +- A stable `taskKey` for idempotency and later calls. + +Use a candidate's `ref` in another candidate's `parentTaskRef`, `blockerRefs`, or `dependencies[].taskRef`. An `id` candidate field remains a legacy alias for `ref`. Candidate refs, legacy ids, and task keys must not collide. + +```json +{ + "candidates": [ + { + "ref": "implement-contract", + "taskKey": "optimitron:dev:mcp-task-reference-contract", + "title": "Make MCP task references consistent", + "description": "Accept exact task IDs or task keys and return an explicit reference map.", + "parentTaskRef": "optimitron:dev", + "estimatedEffortHours": 4, + "executorType": "AI Agent", + "acceptanceCriteria": [ + "Standalone and bundle dependency calls resolve exact task keys", + "Every created draft is mapped to its persisted task ID" + ] + }, + { + "ref": "document-contract", + "taskKey": "optimitron:dev:mcp-task-reference-docs", + "title": "Document the MCP task reference contract", + "description": "Give agents one copyable task-authoring workflow.", + "parentTaskRef": "implement-contract", + "blockerRefs": ["implement-contract"], + "dependencies": [ + { + "taskRef": "implement-contract", + "assumptions": ["Documentation must describe the shipped contract"] + } + ], + "estimatedEffortHours": 1, + "executorType": "AI Agent", + "acceptanceCriteria": [ + "The MCP instructions and guide contain the same reference rules" + ] + } + ] +} +``` + +The response includes review decisions plus a `referenceMap` object whose keys are the accepted bundle refs, legacy ids, task keys, and persisted IDs and whose values are persisted task IDs. Save that map. `createdDrafts`, `existingDrafts`, and `changedDrafts` report the write outcome. Bundle refs end with that request; later calls use the mapped task ID or task key. + +```json +{ + "createdDrafts": [ + { + "proposalRef": "implement-contract", + "taskId": "", + "title": "Make MCP task references consistent" + }, + { + "proposalRef": "document-contract", + "taskId": "", + "title": "Document the MCP task reference contract" + } + ], + "existingDrafts": [], + "changedDrafts": [], + "referenceMap": { + "implement-contract": "", + "optimitron:dev:mcp-task-reference-contract": "", + "document-contract": "", + "optimitron:dev:mcp-task-reference-docs": "" + }, + "review": { + "decisions": [ + { + "proposalRef": "implement-contract", + "title": "Make MCP task references consistent", + "promotable": true, + "evaluation": { + "qualityScore": 1, + "rationale": [] + }, + "issues": [] + }, + { + "proposalRef": "document-contract", + "title": "Document the MCP task reference contract", + "promotable": true, + "evaluation": { + "qualityScore": 1, + "rationale": [] + }, + "issues": [] + } + ], + "promotableCount": 2, + "summary": "" + }, + "message": "" +} +``` + +`proposeTaskBundle` creates reviewed `DRAFT` tasks. Inspect every decision, then call `promoteTask` with the accepted tasks' returned IDs or exact task keys. Being the task owner does not skip this promotion boundary. + +The caller's OAuth grant must include the selected personal or organization target. Accepted task rows, parents, blocker edges, communication endpoints, impact estimates, and source provenance commit in one transaction; a failed attachment does not leave a partial bundle. + +Private source-derived work uses a different, atomic protocol: call `reviewPrivateTaskBundle`, inspect its normalized actions and errors, then pass the unchanged bundle and returned `reviewHash` to `applyPrivateTaskBundle`. Its candidate `ref`, `parentRef`, and `dependencyRefs` are local to that reviewed bundle. Successful apply returns persisted IDs and creates private `ACTIVE` tasks; do not send private work through the public draft/promotion path. + +### Finishing A Task + +There is no generic `COMPLETED` task status. Pick the operation from the work's actual ownership and review boundary: + +| Work | Operation | Result | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Private, uncompensated `Self` task created by and owned by the caller | `completeTask` with factual evidence | Records the evidence and moves the task to `VERIFIED`. | +| One contributor finishing a claim on open work | `completeTaskClaim` | Marks that claim `COMPLETED` for review; it does not by itself verify the task. | +| Delegated, shared, paid, public, organization, or agent work | `startTaskExecution`, `submitTaskArtifact`, then `submitTaskForVerification` | Preserves the formal artifact and verification trail; an authorized human accepts or rejects it. | + +Do not call `updateTask(status="VERIFIED")`; the server rejects that shortcut. If `completeTask` says the task is ineligible, read the returned reason instead of submitting an unrelated claim. Correct a genuinely misclassified private one-person task to `OPEN_SINGLE` only when its real workflow permits that change. + Example private task (use the `personalRoot.id` returned by `getMe`): ```json diff --git a/packages/web/scripts/mcp-personal-task-engine-smoke.ts b/packages/web/scripts/mcp-personal-task-engine-smoke.ts index 10e85757c..9fe3db12d 100644 --- a/packages/web/scripts/mcp-personal-task-engine-smoke.ts +++ b/packages/web/scripts/mcp-personal-task-engine-smoke.ts @@ -29,10 +29,12 @@ type QueueRow = { type CreatedTask = { id: string; + taskKey: string; title: string; }; const createdTasks: CreatedTask[] = []; +let personalRootId = ""; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); @@ -99,9 +101,14 @@ async function createTask(input: { p_success: number; executor_type: "Self" | "AI Agent"; }) { - const task = await callTool("createTask", { - title: `${RUN_ID}: ${input.title}`, + assert(personalRootId, "getMe returned no personal planning root"); + const title = `${RUN_ID}: ${input.title}`; + const taskKey = `${RUN_ID}:${input.key}`; + const response = await callTool<{ id?: string; taskId?: string; title?: string }>("createTask", { + title, description: `Temporary personal task engine smoke test row ${input.key}.`, + parentTaskId: personalRootId, + taskKey, hours: input.hours, value: input.value, p_success: input.p_success, @@ -109,8 +116,12 @@ async function createTask(input: { executor_type: input.executor_type, ev_math: "Smoke-test deterministic EV inputs.", category: "OTHER", + acceptanceCriteria: ["The smoke test completes and cleans up this task"], + impactStatement: "Verifies the live personal task queue and dependency contract.", }); - assert(task.id, `createTask returned no id for ${input.title}`); + const taskId = response.taskId ?? response.id; + assert(taskId, `createTask returned no task ID for ${input.title}`); + const task = { id: taskId, taskKey, title: response.title ?? title }; createdTasks.push(task); return task; } @@ -126,13 +137,29 @@ async function getAIQueue() { } async function markDone(task: CreatedTask) { - await callTool("updateTask", { + await callTool("completeTask", { taskId: task.id, - status: "VERIFIED", completionEvidence: "Completed by MCP personal task engine smoke test.", }); } +async function searchPublicSmokeTasks() { + const tasks: unknown[] = []; + let cursor: string | null = null; + do { + const page = await callTool<{ nextCursor: string | null; tasks: unknown[] }>("searchTasks", { + ...(cursor ? { cursor } : {}), + limit: 5, + paginated: true, + query: RUN_ID, + visibility: "public", + }); + tasks.push(...page.tasks); + cursor = page.nextCursor; + } while (cursor); + return tasks; +} + async function cleanup() { for (const task of [...createdTasks].reverse()) { try { @@ -146,7 +173,9 @@ async function cleanup() { async function main() { console.log(`MCP personal task engine smoke test against ${BASE}`); - const publicBefore = await callTool("listTasks", { limit: 500 }); + const me = await callTool<{ personalRoot: { id: string } | null }>("getMe"); + personalRootId = me.personalRoot?.id ?? ""; + assert(personalRootId, "getMe returned no personalRoot; grant tasks:personal"); try { const A = await createTask({ key: "A", title: "Build product demo", hours: 6, value: 50000, p_success: 0.9, executor_type: "Self" }); @@ -158,13 +187,18 @@ async function main() { const G = await createTask({ key: "G", title: "Write Grant Application Beta", hours: 6, value: 50000, p_success: 0.2, executor_type: "Self" }); const H = await createTask({ key: "H", title: "Set up donation page", hours: 2, value: 10000, p_success: 0.8, executor_type: "Self" }); const I = await createTask({ key: "I", title: "Organize files and folders", hours: 1, value: 100, p_success: 1, executor_type: "Self" }); - const J = await createTask({ key: "J", title: "Research potential funders list", hours: 3, value: 20000, p_success: 0.7, executor_type: "AI Agent" }); - - await callTool("updateTask", { taskId: C.id, depends_on: [A.id] }); - await callTool("updateTask", { taskId: D.id, depends_on: [A.id] }); - await callTool("updateTask", { taskId: E.id, depends_on: [A.id] }); - await callTool("updateTask", { taskId: F.id, depends_on: [A.id, B.id, C.id] }); - await callTool("updateTask", { taskId: G.id, depends_on: [A.id, B.id, D.id] }); + await createTask({ key: "J", title: "Research potential funders list", hours: 3, value: 20000, p_success: 0.7, executor_type: "AI Agent" }); + const publicSmokeTasks = await searchPublicSmokeTasks(); + assert( + publicSmokeTasks.length === 0, + `Personal smoke tasks leaked into public search: ${JSON.stringify(publicSmokeTasks)}`, + ); + + await callTool("updateTask", { taskId: C.id, blockerTaskRefs: [A.taskKey] }); + await callTool("updateTask", { taskId: D.id, blockerTaskRefs: [A.taskKey] }); + await callTool("updateTask", { taskId: E.id, blockerTaskRefs: [A.taskKey] }); + await callTool("updateTask", { taskId: F.id, blockerTaskRefs: [A.taskKey, B.taskKey, C.taskKey] }); + await callTool("updateTask", { taskId: G.id, blockerTaskRefs: [A.taskKey, B.taskKey, D.taskKey] }); assertQueue(await getMyQueue(), [ "Create credibility page on website", @@ -231,14 +265,10 @@ async function main() { await markDone(task); } assertQueue(await getMyQueue(), [], {}); - await markDone(J); } finally { await cleanup(); } - const publicAfter = await callTool("listTasks", { limit: 500 }); - assert(publicAfter.length === publicBefore.length, "Public task count changed after personal smoke test cleanup"); - console.log("MCP personal task engine smoke test passed."); } diff --git a/packages/web/src/lib/__tests__/mcp-server.test.ts b/packages/web/src/lib/__tests__/mcp-server.test.ts index 586cd7a66..97afe4032 100644 --- a/packages/web/src/lib/__tests__/mcp-server.test.ts +++ b/packages/web/src/lib/__tests__/mcp-server.test.ts @@ -49,6 +49,7 @@ const mocks = vi.hoisted(() => ({ taskFindMany: vi.fn(), taskEdgeCreateMany: vi.fn(), taskEdgeCreate: vi.fn(), + taskEdgeFindFirst: vi.fn(), taskEdgeFindMany: vi.fn(), taskEdgeUpdateMany: vi.fn(), createDirectTaskImpact: vi.fn(), @@ -379,6 +380,7 @@ vi.mock("../prisma", () => ({ taskEdge: { create: mocks.taskEdgeCreate, createMany: mocks.taskEdgeCreateMany, + findFirst: mocks.taskEdgeFindFirst, findMany: mocks.taskEdgeFindMany, updateMany: mocks.taskEdgeUpdateMany, }, @@ -473,6 +475,8 @@ import { getToolDefinitions, } from "../mcp-server"; +let transactionClient: Record; + interface ToolText { text: string; } @@ -504,6 +508,19 @@ function parseToolBody(result: unknown): Record { return JSON.parse(content![0]!.text) as Record; } +async function callTaskPage( + client: Client, + name: "listTasks" | "searchTasks", + args: Record, +) { + return parseToolBody( + await client.callTool({ arguments: args, name }), + ) as unknown as { + nextCursor: string | null; + tasks: Array<{ id: string }>; + }; +} + function makeCreatedTask(overrides: Record = {}) { return { id: "task-1", @@ -555,6 +572,50 @@ function makeCreateTaskArguments(overrides: Record = {}) { }; } +function makeProposalCandidate(overrides: Record = {}) { + return { + acceptanceCriteria: ["The requested work is independently verifiable."], + description: + "Complete a concrete, bounded task and record independently verifiable evidence.", + estimatedEffortHours: 1, + executorType: "AI Agent", + impact: { + expectedEconomicValueUsdBase: 1_000, + successProbabilityBase: 0.8, + }, + parentTaskRef: "$target-root", + title: "Complete bounded work", + ...overrides, + }; +} + +function makeDependencyTask(id: string, taskKey: string) { + return { + createdByUserId: "user-1", + id, + isPublic: false, + ownerOrganizationId: null, + taskKey, + }; +} + +function makeExistingProposalTask(overrides: Record = {}) { + return { + assigneeOrganizationId: null, + assigneePersonId: "person-1", + createdByUserId: "user-1", + id: "existing-upstream", + isPublic: false, + ownerOrganizationId: null, + roleTitle: null, + sourceArtifacts: [], + status: TaskStatus.DRAFT, + taskKey: "existing:upstream", + title: "Existing upstream task", + ...overrides, + }; +} + function makeOptimizeEarthRoot() { return { id: "optimize-earth", @@ -663,58 +724,67 @@ beforeEach(() => { delete process.env.GITHUB_TOKEN; delete process.env.GITHUB_REPO_ALLOWLIST; delete process.env.GITHUB_DEFAULT_REPO; + transactionClient = { + task: { + create: mocks.taskCreate, + findMany: mocks.taskFindMany, + update: mocks.taskUpdate, + updateMany: mocks.taskUpdateMany, + }, + taskEdge: { + create: mocks.taskEdgeCreate, + createMany: mocks.taskEdgeCreateMany, + findFirst: mocks.taskEdgeFindFirst, + findMany: mocks.taskEdgeFindMany, + updateMany: mocks.taskEdgeUpdateMany, + }, + taskApplication: { + create: mocks.taskApplicationCreate, + findFirst: mocks.taskApplicationFindFirst, + update: mocks.taskApplicationUpdate, + }, + taskApplicationEvent: { + create: mocks.taskApplicationEventCreate, + }, + user: { + findUniqueOrThrow: mocks.userFindUniqueOrThrow, + }, + globalVariable: { + findFirst: mocks.globalVariableFindFirst, + upsert: mocks.globalVariableUpsert, + }, + variableCategory: { + findFirst: mocks.variableCategoryFindFirst, + }, + unit: { + findFirst: mocks.unitFindFirst, + }, + nOf1Variable: { + upsert: mocks.nOf1VariableUpsert, + }, + measurement: { + upsert: mocks.measurementUpsert, + }, + trackingReminder: { + upsert: mocks.trackingReminderUpsert, + findFirst: mocks.trackingReminderFindFirst, + update: mocks.trackingReminderUpdate, + }, + trackingReminderNotification: { + findFirst: mocks.trackingReminderNotificationFindFirst, + create: mocks.trackingReminderNotificationCreate, + update: mocks.trackingReminderNotificationUpdate, + }, + sourceArtifact: { + upsert: mocks.sourceArtifactUpsert, + }, + taskSourceArtifact: { + upsert: mocks.taskSourceArtifactUpsert, + }, + }; mocks.transaction.mockImplementation( async (callback: (tx: unknown) => Promise) => - callback({ - task: { - create: mocks.taskCreate, - findMany: mocks.taskFindMany, - update: mocks.taskUpdate, - updateMany: mocks.taskUpdateMany, - }, - taskEdge: { - createMany: mocks.taskEdgeCreateMany, - findMany: mocks.taskEdgeFindMany, - updateMany: mocks.taskEdgeUpdateMany, - }, - taskApplication: { - create: mocks.taskApplicationCreate, - findFirst: mocks.taskApplicationFindFirst, - update: mocks.taskApplicationUpdate, - }, - taskApplicationEvent: { - create: mocks.taskApplicationEventCreate, - }, - user: { - findUniqueOrThrow: mocks.userFindUniqueOrThrow, - }, - globalVariable: { - findFirst: mocks.globalVariableFindFirst, - upsert: mocks.globalVariableUpsert, - }, - variableCategory: { - findFirst: mocks.variableCategoryFindFirst, - }, - unit: { - findFirst: mocks.unitFindFirst, - }, - nOf1Variable: { - upsert: mocks.nOf1VariableUpsert, - }, - measurement: { - upsert: mocks.measurementUpsert, - }, - trackingReminder: { - upsert: mocks.trackingReminderUpsert, - findFirst: mocks.trackingReminderFindFirst, - update: mocks.trackingReminderUpdate, - }, - trackingReminderNotification: { - findFirst: mocks.trackingReminderNotificationFindFirst, - create: mocks.trackingReminderNotificationCreate, - update: mocks.trackingReminderNotificationUpdate, - }, - }), + callback(transactionClient), ); mocks.listTasks.mockResolvedValue([]); mocks.searchTasks.mockResolvedValue([]); @@ -894,6 +964,7 @@ beforeEach(() => { ); mocks.taskEdgeCreateMany.mockResolvedValue({ count: 0 }); mocks.taskEdgeUpdateMany.mockResolvedValue({ count: 0 }); + mocks.taskEdgeFindFirst.mockResolvedValue(null); mocks.taskEdgeFindMany.mockResolvedValue([]); mocks.userFindUnique.mockResolvedValue(makeMatchingUser()); mocks.userFindMany.mockResolvedValue([]); @@ -2141,7 +2212,6 @@ describe("MCP server tool dispatch", () => { expect(mocks.listTasks).toHaveBeenCalledWith( expect.objectContaining({ assigneePersonId: "person-1", - limit: 5, status: TaskStatus.ACTIVE, userId: "user-1", visibility: "accessible", @@ -2176,7 +2246,6 @@ describe("MCP server tool dispatch", () => { expect(result.isError).toBeFalsy(); expect(mocks.listTasks).toHaveBeenCalledWith( expect.objectContaining({ - limit: 5, parentTaskId: "parent-1", status: TaskStatus.ACTIVE, // Authenticated callers default to visibility "all" (accessible @@ -2199,6 +2268,187 @@ describe("MCP server tool dispatch", () => { }); }); + it("pages listTasks and searchTasks without duplicates or gaps", async () => { + const client = await setup("user-1", ALL_SCOPES); + const tools = await client.listTools(); + const verifyPages = async ( + name: "listTasks" | "searchTasks", + args: Record, + ids: string[], + ) => { + const tool = tools.tools.find((entry) => entry.name === name); + expect(tool?.inputSchema.properties).toMatchObject({ + cursor: expect.objectContaining({ type: "string" }), + paginated: expect.objectContaining({ type: "boolean" }), + }); + if (name === "listTasks") { + expect(tool?.inputSchema.properties).toMatchObject({ + limit: expect.objectContaining({ + maximum: 50, + minimum: 1, + type: "integer", + }), + }); + } + const legacy = parseToolBody( + await client.callTool({ + arguments: { ...args, limit: 2 }, + name, + }), + ) as unknown as Array<{ id: string }>; + expect(legacy.map((task) => task.id)).toEqual(ids.slice(0, 2)); + const first = await callTaskPage(client, name, { + ...args, + limit: 2, + paginated: true, + }); + const second = await callTaskPage(client, name, { + ...args, + cursor: first.nextCursor, + limit: 2, + paginated: true, + }); + expect(first.nextCursor).toEqual(expect.any(String)); + expect(second.nextCursor).toBeNull(); + expect( + [...first.tasks, ...second.tasks].map((task) => task.id), + ).toEqual(ids); + }; + + mocks.listTasks.mockResolvedValue( + ["task-a", "task-b", "task-c"].map((id) => makeCreatedTask({ id })), + ); + await verifyPages("listTasks", { status: "ACTIVE" }, [ + "task-a", + "task-b", + "task-c", + ]); + + mocks.searchTasks.mockResolvedValue( + ["match-a", "match-b", "match-c"].map((id) => ({ id })), + ); + await verifyPages("searchTasks", { query: "match" }, [ + "match-a", + "match-b", + "match-c", + ]); + }); + + it("keeps paginated inventory stable when task rankings change", async () => { + const client = await setup("user-1", ALL_SCOPES); + mocks.listTasks + .mockResolvedValueOnce( + ["task-a", "task-b", "task-c"].map((id) => makeCreatedTask({ id })), + ) + .mockResolvedValueOnce( + ["task-c", "task-a", "task-b"].map((id) => makeCreatedTask({ id })), + ); + + const first = await callTaskPage(client, "listTasks", { + limit: 1, + paginated: true, + }); + const second = await callTaskPage(client, "listTasks", { + cursor: first.nextCursor, + limit: 2, + paginated: true, + }); + + expect(first.tasks.map((task) => task.id)).toEqual(["task-a"]); + expect(second.tasks.map((task) => task.id)).toEqual(["task-b", "task-c"]); + expect(second.nextCursor).toBeNull(); + }); + + it("clamps invalid list page limits to at least one task", async () => { + mocks.listTasks.mockResolvedValue( + ["task-a", "task-b"].map((id) => makeCreatedTask({ id })), + ); + const client = await setup("user-1", ALL_SCOPES); + + const page = await callTaskPage(client, "listTasks", { + limit: -1, + paginated: true, + }); + + expect(page.tasks.map((task) => task.id)).toEqual(["task-a"]); + expect(page.nextCursor).toEqual(expect.any(String)); + }); + + it("rejects a pagination cursor reused with changed filters", async () => { + mocks.listTasks.mockResolvedValue( + ["task-a", "task-b"].map((id) => makeCreatedTask({ id })), + ); + const client = await setup("user-1", ALL_SCOPES); + const first = await callTaskPage(client, "listTasks", { + limit: 1, + paginated: true, + status: "ACTIVE", + }); + + const result = await client.callTool({ + name: "listTasks", + arguments: { + cursor: first.nextCursor, + limit: 1, + paginated: true, + status: "DRAFT", + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result)).toMatchObject({ + errorCode: "INVALID_ARGUMENT", + message: "cursor does not match this task query.", + }); + }); + + it("rejects list pagination beyond its bounded authorized window", async () => { + mocks.listTasks.mockResolvedValue( + Array.from({ length: 5_001 }, (_, index) => + makeCreatedTask({ id: `task-${index}` }), + ), + ); + const client = await setup("user-1", ALL_SCOPES); + + const result = await client.callTool({ + name: "listTasks", + arguments: { limit: 50, paginated: true }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result)).toMatchObject({ + errorCode: "RESULT_WINDOW_EXCEEDED", + }); + expect(mocks.listTasks).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5_001 }), + ); + }); + + it("applies extended list filters before rejecting a large authorized result set", async () => { + mocks.listTasks.mockResolvedValue( + Array.from({ length: 5_000 }, (_, index) => + makeCreatedTask({ + executionMode: + index === 4_999 + ? TaskExecutionMode.AGENT_ONLY + : TaskExecutionMode.HUMAN_ONLY, + id: `task-${index}`, + }), + ), + ); + + const client = await setup("user-1", ALL_SCOPES); + const result = await client.callTool({ + name: "listTasks", + arguments: { executionMode: "AGENT_ONLY", limit: 5 }, + }); + + expect(result.isError).toBeFalsy(); + expect(parseToolBody(result)).toEqual([ + expect.objectContaining({ id: "task-4999" }), + ]); + }); + it("routes natural development-parent searches through the authenticated task boundary", async () => { mocks.searchTasks.mockResolvedValue([ { @@ -2231,7 +2481,6 @@ describe("MCP server tool dispatch", () => { "find Optimize Optimitron parent", expect.objectContaining({ clientAccessBoundary: expect.any(Object), - limit: 20, status: "ACTIVE", userId: "user-1", visibility: "accessible", @@ -3728,9 +3977,14 @@ describe("MCP server tool dispatch", () => { }); expect(result.isError).toBeFalsy(); - expect(parseToolBody(result).createdDrafts).toEqual([ - expect.objectContaining({ taskId: "draft-mercury" }), - ]); + const body = parseToolBody(result) as { + createdDrafts: Array<{ proposalRef: string; taskId: string }>; + referenceMap: Record; + }; + const generatedRef = body.createdDrafts[0]?.proposalRef; + expect(generatedRef).toEqual(expect.any(String)); + expect(generatedRef).not.toBe("Open Mercury account for EOS"); + expect(body.referenceMap[generatedRef!]).toBe("draft-mercury"); expect(mocks.taskCreate).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -3751,7 +4005,7 @@ describe("MCP server tool dispatch", () => { }), }), ); - expect(mocks.createDirectTaskImpact).toHaveBeenCalledWith( + expect(mocks.createDirectTaskImpactInTransaction).toHaveBeenCalledWith( expect.objectContaining({ frame: expect.objectContaining({ expectedEconomicValueUsdBase: 5_000, @@ -3760,7 +4014,15 @@ describe("MCP server tool dispatch", () => { }), expect.objectContaining({ userId: "user-1" }), { publish: false }, - expect.anything(), + transactionClient, + ); + expect(mocks.createDirectTaskImpact).not.toHaveBeenCalled(); + expect(mocks.upsertPrimaryTaskCommunicationEndpoint).toHaveBeenCalledWith( + transactionClient, + "draft-mercury", + { + url: null, + }, ); expect(mocks.taskUpdate).toHaveBeenCalledWith({ where: { id: "draft-mercury" }, @@ -3769,76 +4031,506 @@ describe("MCP server tool dispatch", () => { expect(mocks.sourceArtifactUpsert).toHaveBeenCalledTimes(1); }); - it("defaults an admin's personal proposal to the admin's Person record", async () => { + it("persists same-bundle parent and dependency refs and returns every persisted alias", async () => { mocks.taskFindFirst.mockImplementation( (args: { where?: { taskKey?: string } }) => args.where?.taskKey ? null : makeOptimizeEarthRoot(), ); + mocks.taskFindMany.mockResolvedValue([]); mocks.taskCreate .mockResolvedValueOnce({ - id: "personal-tasks", + id: "personal-project", taskKey: "planner:person:person-1", }) .mockResolvedValueOnce({ - id: "draft-task", - title: "Review the personal plan", - }); - mocks.taskFindMany.mockResolvedValue([]); + id: "persisted-upstream", + title: "Upstream work", + }) + .mockResolvedValueOnce({ id: "persisted-child", title: "Child work" }); - const client = await setup("admin-1", ALL_SCOPES, { isAdmin: true }); + const client = await setup("user-1", ALL_SCOPES); const result = await client.callTool({ name: "proposeTaskBundle", arguments: { candidates: [ - { + makeProposalCandidate({ description: - "Review the proposed personal execution plan and record any corrections.", - estimatedEffortHours: 0.5, - impact: { - expectedEconomicValueUsdBase: 1_000, - successProbabilityBase: 0.8, - }, - parentTaskRef: "$target-root", - title: "Review the personal plan", - }, + "Define the bounded parent project and its independently verifiable completion evidence.", + id: "legacy-upstream-id", + ref: "upstream-ref", + taskKey: "bundle:upstream", + title: "Upstream work", + }), + makeProposalCandidate({ + blockerRefs: ["bundle:upstream", "upstream-ref"], + description: + "Complete the child work beneath its same-bundle parent after the prerequisite evidence exists.", + id: "legacy-child-id", + parentTaskRef: "legacy-upstream-id", + ref: "child-ref", + taskKey: "bundle:child", + title: "Child work", + }), ], }, }); expect(result.isError).toBeFalsy(); - expect(mocks.taskCreate).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - data: expect.objectContaining({ - assigneePersonId: "person-1", - taskKey: "planner:person:person-1", - }), - }), - ); - expect(mocks.taskCreate).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - data: expect.objectContaining({ assigneePersonId: "person-1" }), + expect(parseToolBody(result)).toMatchObject({ + createdDrafts: [ + { proposalRef: "upstream-ref", taskId: "persisted-upstream" }, + { proposalRef: "child-ref", taskId: "persisted-child" }, + ], + referenceMap: { + "bundle:child": "persisted-child", + "bundle:upstream": "persisted-upstream", + "legacy-child-id": "persisted-child", + "legacy-upstream-id": "persisted-upstream", + "child-ref": "persisted-child", + "upstream-ref": "persisted-upstream", + }, + }); + expect(mocks.taskUpdate).toHaveBeenCalledWith({ + where: { id: "persisted-child" }, + data: { parentTaskId: "persisted-upstream" }, + }); + expect(mocks.taskEdgeCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: "persisted-upstream", + toTaskId: "persisted-child", }), - ); + }); + expect(mocks.taskEdgeCreate).toHaveBeenCalledTimes(1); }); - it("rejects an occupied planning branch key unless it is private and rooted", async () => { - mocks.taskFindFirst.mockImplementation( - (args: { where?: { taskKey?: string } }) => - args.where?.taskKey - ? makePlanningBranch({ isPublic: true }) - : makeOptimizeEarthRoot(), - ); + it.each([ + { + candidate: { assigneeOrganizationId: "org-a" }, + label: "personal scope for an organization target", + options: { organizationIds: null }, + scopes: [McpScope.TASKS_PERSONAL], + }, + { + candidate: {}, + label: "organization scope for a personal target", + options: { organizationIds: ["org-a"] }, + scopes: [McpScope.TASKS_ORGANIZATION], + }, + { + candidate: { assigneeOrganizationId: "org-b" }, + label: "organization scope outside its allowlist", + options: { organizationIds: ["org-a"] }, + scopes: [McpScope.TASKS_ORGANIZATION], + }, + ])( + "rejects $label before writing", + async ({ candidate, options, scopes }) => { + mocks.isTaskWithinClientAccessBoundary.mockImplementation( + ( + task: { isPublic: boolean; ownerOrganizationId?: string | null }, + boundary: { + allowPersonalPrivate: boolean; + organizationIds: readonly string[] | null; + }, + ) => + task.isPublic || + (task.ownerOrganizationId + ? boundary.organizationIds === null || + boundary.organizationIds.includes(task.ownerOrganizationId) + : boundary.allowPersonalPrivate), + ); + mocks.canManageOrganization.mockResolvedValue(true); + const client = await setup("user-1", scopes, options); - const client = await setup("user-1", ALL_SCOPES); - const result = await client.callTool({ - name: "proposeTaskBundle", - arguments: { - candidates: [ - { - description: - "This draft must not attach below an invalid branch.", + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [makeProposalCandidate(candidate)], + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain( + "OAuth grant does not allow private tasks for this target", + ); + expect(mocks.taskCreate).not.toHaveBeenCalled(); + }, + ); + + it("stores organization proposals inside the OAuth organization boundary", async () => { + mocks.isTaskWithinClientAccessBoundary.mockImplementation( + ( + task: { isPublic: boolean; ownerOrganizationId?: string | null }, + boundary: { + allowPersonalPrivate: boolean; + organizationIds: readonly string[] | null; + }, + ) => + task.isPublic || + (task.ownerOrganizationId + ? boundary.organizationIds === null || + boundary.organizationIds.includes(task.ownerOrganizationId) + : boundary.allowPersonalPrivate), + ); + mocks.getTaskClientAccessWhere.mockReturnValue({ + clientBoundarySentinel: true, + }); + mocks.canManageOrganization.mockResolvedValue(true); + mocks.organizationFindFirst.mockResolvedValue({ + id: "org-a", + name: "Organization A", + }); + mocks.taskFindFirst.mockImplementation( + (args: { where?: { id?: string; taskKey?: string } }) => { + if (args.where?.id === "optimize-earth") { + return makeOptimizeEarthRoot(); + } + if (args.where?.taskKey === "planner:organization:org-a") { + return makePlanningBranch({ + assigneeOrganizationId: "org-a", + assigneePersonId: null, + id: "org-project", + ownerOrganizationId: "org-a", + taskKey: "planner:organization:org-a", + }); + } + return null; + }, + ); + mocks.taskFindMany.mockResolvedValue([]); + mocks.taskCreate.mockResolvedValue({ + id: "org-draft", + title: "Complete bounded work", + }); + const client = await setup("user-1", [McpScope.TASKS_ORGANIZATION], { + organizationIds: ["org-a"], + }); + + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + makeProposalCandidate({ assigneeOrganizationId: "org-a" }), + ], + }, + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.taskFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([{ clientBoundarySentinel: true }]), + }), + }), + ); + expect(mocks.taskCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + assigneeOrganizationId: "org-a", + ownerOrganizationId: "org-a", + }), + }), + ); + }); + + it("keeps proposal attachments inside the draft transaction", async () => { + mocks.taskFindMany.mockResolvedValue([]); + mocks.taskCreate.mockResolvedValue({ + id: "draft-with-failed-impact", + title: "Complete bounded work", + }); + mocks.createDirectTaskImpactInTransaction.mockRejectedValue( + new Error("forced impact failure"), + ); + const client = await setup("user-1", ALL_SCOPES); + + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { candidates: [makeProposalCandidate()] }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain("forced impact failure"); + expect(mocks.createDirectTaskImpactInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: "user-1" }), + { publish: false }, + transactionClient, + ); + expect(mocks.upsertPrimaryTaskCommunicationEndpoint).toHaveBeenCalledWith( + transactionClient, + "draft-with-failed-impact", + expect.anything(), + ); + expect(mocks.createDirectTaskImpact).not.toHaveBeenCalled(); + expect(mocks.sourceArtifactUpsert).not.toHaveBeenCalled(); + }); + + it("resolves a reused taskKey candidate's local ref for a new child's parent and blocker", async () => { + mocks.taskFindFirst.mockImplementation( + (args: { where?: { taskKey?: string } }) => + args.where?.taskKey ? makePlanningBranch() : makeOptimizeEarthRoot(), + ); + mocks.taskFindMany.mockResolvedValue([makeExistingProposalTask()]); + mocks.taskCreate.mockResolvedValue({ + id: "persisted-child", + title: "New child task", + }); + + const client = await setup("user-1", ALL_SCOPES); + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + makeProposalCandidate({ + parentTaskRef: "$target-root", + ref: "local-upstream", + taskKey: "existing:upstream", + title: "Existing upstream task", + }), + makeProposalCandidate({ + blockerRefs: ["local-upstream"], + description: + "Create a new child beneath the reused task after that prerequisite is complete.", + parentTaskRef: "local-upstream", + ref: "local-child", + taskKey: "bundle:new-child", + title: "New child task", + }), + ], + }, + }); + + expect(result.isError).toBeFalsy(); + expect(parseToolBody(result)).toMatchObject({ + createdDrafts: [ + { proposalRef: "local-child", taskId: "persisted-child" }, + ], + existingDrafts: [ + { proposalRef: "local-upstream", taskId: "existing-upstream" }, + ], + referenceMap: { "local-upstream": "existing-upstream" }, + }); + expect(mocks.taskUpdate).toHaveBeenCalledWith({ + data: { parentTaskId: "existing-upstream" }, + where: { id: "persisted-child" }, + }); + expect(mocks.taskEdgeCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + fromTaskId: "existing-upstream", + toTaskId: "persisted-child", + }), + }); + }); + + it("rejects proposal aliases that collide across ref, id, and taskKey", async () => { + const client = await setup("user-1", ALL_SCOPES); + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + makeProposalCandidate({ + id: "first-id", + ref: "shared-alias", + taskKey: "bundle:first", + title: "First candidate", + }), + makeProposalCandidate({ + description: + "Complete a different bounded task whose task key must not impersonate another candidate alias.", + id: "second-id", + ref: "second-ref", + taskKey: "shared-alias", + title: "Second candidate", + }), + ], + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain("shared-alias"); + expect(mocks.taskCreate).not.toHaveBeenCalled(); + expect(mocks.taskUpdate).not.toHaveBeenCalled(); + expect(mocks.taskEdgeCreate).not.toHaveBeenCalled(); + }); + + it.each(["existing-upstream", "existing:upstream"])( + "rejects candidate alias %s when an unrelated persisted task owns it", + async (persistedAlias) => { + mocks.taskFindFirst.mockImplementation( + (args: { where?: { taskKey?: string } }) => + args.where?.taskKey + ? makePlanningBranch() + : makeOptimizeEarthRoot(), + ); + mocks.taskFindMany.mockResolvedValue([makeExistingProposalTask()]); + const client = await setup("user-1", ALL_SCOPES); + + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + makeProposalCandidate({ + ref: persistedAlias, + taskKey: "bundle:unrelated", + }), + ], + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain(persistedAlias); + expect(mocks.taskCreate).not.toHaveBeenCalled(); + }, + ); + + it("rejects a ref that is one persisted task's ID and another task's taskKey", async () => { + mocks.taskFindFirst.mockImplementation( + (args: { where?: { taskKey?: string } }) => + args.where?.taskKey ? makePlanningBranch() : makeOptimizeEarthRoot(), + ); + mocks.taskFindMany.mockResolvedValue([ + makeExistingProposalTask({ id: "ambiguous-ref", taskKey: "task:a" }), + makeExistingProposalTask({ id: "task-b", taskKey: "ambiguous-ref" }), + ]); + const client = await setup("user-1", ALL_SCOPES); + + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + makeProposalCandidate({ + parentTaskRef: "ambiguous-ref", + ref: "new-child", + taskKey: "bundle:new-child", + }), + ], + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain("ambiguous-ref"); + expect(mocks.taskCreate).not.toHaveBeenCalled(); + }); + + it("does not persist a task whose same-bundle parent and blocker are non-promotable", async () => { + mocks.taskFindFirst.mockImplementation( + (args: { where?: { taskKey?: string } }) => + args.where?.taskKey ? null : makeOptimizeEarthRoot(), + ); + mocks.taskFindMany.mockResolvedValue([]); + mocks.taskCreate.mockResolvedValueOnce({ + id: "personal-project", + taskKey: "planner:person:person-1", + }); + + const client = await setup("user-1", ALL_SCOPES); + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + makeProposalCandidate({ + description: "Too short.", + ref: "rejected-upstream", + taskKey: "bundle:rejected-upstream", + title: "Rejected upstream task", + }), + makeProposalCandidate({ + blockerRefs: ["rejected-upstream"], + description: + "This valid child must not persist without its rejected same-bundle parent and blocker.", + parentTaskRef: "rejected-upstream", + ref: "dependent-child", + taskKey: "bundle:dependent-child", + title: "Dependent child", + }), + ], + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain("rejected-upstream"); + const draftCreates = mocks.taskCreate.mock.calls.filter( + ([args]) => + (args as { data?: { status?: string } }).data?.status === + TaskStatus.DRAFT, + ); + expect(draftCreates).toHaveLength(0); + expect(mocks.taskUpdate).not.toHaveBeenCalled(); + expect(mocks.taskEdgeCreate).not.toHaveBeenCalled(); + }); + + it("defaults an admin's personal proposal to the admin's Person record", async () => { + mocks.taskFindFirst.mockImplementation( + (args: { where?: { taskKey?: string } }) => + args.where?.taskKey ? null : makeOptimizeEarthRoot(), + ); + mocks.taskCreate + .mockResolvedValueOnce({ + id: "personal-tasks", + taskKey: "planner:person:person-1", + }) + .mockResolvedValueOnce({ + id: "draft-task", + title: "Review the personal plan", + }); + mocks.taskFindMany.mockResolvedValue([]); + + const client = await setup("admin-1", ALL_SCOPES, { isAdmin: true }); + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + { + description: + "Review the proposed personal execution plan and record any corrections.", + estimatedEffortHours: 0.5, + impact: { + expectedEconomicValueUsdBase: 1_000, + successProbabilityBase: 0.8, + }, + parentTaskRef: "$target-root", + title: "Review the personal plan", + }, + ], + }, + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.taskCreate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + data: expect.objectContaining({ + assigneePersonId: "person-1", + taskKey: "planner:person:person-1", + }), + }), + ); + expect(mocks.taskCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: expect.objectContaining({ assigneePersonId: "person-1" }), + }), + ); + }); + + it("rejects an occupied planning branch key unless it is private and rooted", async () => { + mocks.taskFindFirst.mockImplementation( + (args: { where?: { taskKey?: string } }) => + args.where?.taskKey + ? makePlanningBranch({ isPublic: true }) + : makeOptimizeEarthRoot(), + ); + + const client = await setup("user-1", ALL_SCOPES); + const result = await client.callTool({ + name: "proposeTaskBundle", + arguments: { + candidates: [ + { + description: + "This draft must not attach below an invalid branch.", estimatedEffortHours: 1, parentTaskRef: "$target-root", title: "Private planning task", @@ -4606,6 +5298,10 @@ describe("MCP server tool dispatch", () => { }, }); const body = parseToolBody(result); + expect(body).toMatchObject({ + id: "created-task", + taskId: "created-task", + }); expect(body.missingFields).toEqual( expect.arrayContaining([ "cash_cost", @@ -4734,6 +5430,7 @@ describe("MCP server tool dispatch", () => { expect(result.isError).toBeFalsy(); expect(parseToolBody(result)).toMatchObject({ + id: "existing-task", idempotentReplay: true, taskId: "existing-task", writeReceipt: { @@ -4784,6 +5481,7 @@ describe("MCP server tool dispatch", () => { expect(result.isError).toBeFalsy(); expect(parseToolBody(result)).toMatchObject({ + id: "winning-task", idempotentReplay: true, taskId: "winning-task", writeReceipt: { @@ -5285,6 +5983,69 @@ describe("MCP server tool dispatch", () => { ); }); + it("createTask resolves dependency refs by persisted ID or exact taskKey", async () => { + const dependencyTasks = [ + makeDependencyTask("blocker-by-id", "dependency:blocker-id"), + makeDependencyTask("blocked-by-key-id", "dependency:blocked-key"), + ]; + mocks.taskFindMany.mockResolvedValue(dependencyTasks); + mocks.taskCreate.mockResolvedValue( + makeCreatedTask({ id: "created-with-dependencies" }), + ); + mocks.getTaskDetailData.mockResolvedValue({ + task: makeCreatedTask({ id: "created-with-dependencies" }), + }); + mocks.computeTaskPriority.mockReturnValue(makePriority()); + + const client = await setup("user-1", ALL_SCOPES); + const result = await client.callTool({ + name: "createTask", + arguments: makeCreateTaskArguments({ + blockedTaskRefs: ["dependency:blocked-key"], + blockerTaskRefs: ["blocker-by-id"], + }), + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.taskEdgeCreateMany).toHaveBeenCalledWith({ + data: [ + { + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: "blocker-by-id", + toTaskId: "created-with-dependencies", + }, + ], + skipDuplicates: true, + }); + expect(mocks.taskEdgeCreateMany).toHaveBeenCalledWith({ + data: [ + { + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: "created-with-dependencies", + toTaskId: "blocked-by-key-id", + }, + ], + skipDuplicates: true, + }); + }); + + it("createTask does not resolve dependency refs from task titles", async () => { + mocks.taskFindMany.mockResolvedValue([]); + const client = await setup("user-1", ALL_SCOPES); + + const result = await client.callTool({ + name: "createTask", + arguments: makeCreateTaskArguments({ + blockerTaskRefs: ["A matching task title"], + }), + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain("not found"); + expect(mocks.taskCreate).not.toHaveBeenCalled(); + expect(mocks.taskEdgeCreateMany).not.toHaveBeenCalled(); + }); + it("does not let admin status reveal a private dependency task", async () => { mocks.taskFindMany.mockResolvedValue([]); @@ -5310,9 +6071,7 @@ describe("MCP server tool dispatch", () => { }); expect(result.isError).toBe(true); - expect(parseToolBody(result).message).toContain( - "not found or are inaccessible", - ); + expect(parseToolBody(result).message).toContain("not found"); expect(mocks.taskEdgeCreateMany).not.toHaveBeenCalled(); }); @@ -6103,7 +6862,7 @@ describe("MCP server tool dispatch", () => { expect(mocks.taskUpdate).not.toHaveBeenCalled(); }); - it("updateTask preserves the type of a retained soft-deleted dependency", async () => { + it("updateTask resolves blockerTaskRefs by exact taskKey and preserves a retained edge type", async () => { mocks.getTaskDetailData .mockResolvedValueOnce({ task: makeCreatedTask({ @@ -6125,7 +6884,7 @@ describe("MCP server tool dispatch", () => { }), }); mocks.taskFindMany.mockResolvedValue([ - { id: "new-blocker", isPublic: false, createdByUserId: "user-1" }, + makeDependencyTask("new-blocker", "dependency:new-blocker"), ]); mocks.taskEdgeFindMany.mockResolvedValueOnce([]).mockResolvedValueOnce([ { @@ -6143,7 +6902,10 @@ describe("MCP server tool dispatch", () => { const client = await setup("user-1", ALL_SCOPES); await client.callTool({ name: "updateTask", - arguments: { taskId: "task-1", depends_on: ["new-blocker"] }, + arguments: { + taskId: "task-1", + blockerTaskRefs: ["dependency:new-blocker"], + }, }); expect(mocks.taskEdgeUpdateMany).toHaveBeenCalledWith( @@ -6207,6 +6969,11 @@ describe("MCP server tool dispatch", () => { { id: "blocked-task", isPublic: false, createdByUserId: "user-1" }, { id: "blocker-task", isPublic: false, createdByUserId: "user-1" }, ]); + mocks.taskEdgeFindFirst.mockResolvedValue({ + deletedAt: new Date("2026-07-01T00:00:00.000Z"), + id: "existing-edge", + }); + mocks.taskEdgeUpdateMany.mockResolvedValue({ count: 1 }); const client = await setup("user-1", ALL_SCOPES, { isAdmin: true }); const result = await client.callTool({ @@ -6253,10 +7020,10 @@ describe("MCP server tool dispatch", () => { data: [ expect.objectContaining({ fromTaskId: "blocker-task", - toTaskId: "blocked-task", + notes: "Raises grant odds", probabilityDeltaBase: 0.35, timeDeltaDaysBase: 14, - notes: "Raises grant odds", + toTaskId: "blocked-task", }), ], skipDuplicates: true, @@ -6264,6 +7031,156 @@ describe("MCP server tool dispatch", () => { ); }); + it("advertises canonical-or-legacy references for both dependency sides", async () => { + const client = await setup("user-1", ALL_SCOPES, { isAdmin: true }); + + const tools = await client.listTools(); + const addDependency = tools.tools.find( + (tool) => tool.name === "addDependency", + ); + + expect(addDependency?.inputSchema).toMatchObject({ + allOf: [ + { + anyOf: [ + { required: ["blockedTaskRef"] }, + { required: ["blockedTaskId"] }, + ], + }, + { + anyOf: [ + { required: ["blockerTaskRef"] }, + { required: ["blockerTaskId"] }, + ], + }, + ], + }); + }); + + it("addDependency resolves persisted IDs and exact taskKeys and reports a new edge", async () => { + mocks.taskFindMany.mockResolvedValue([ + makeDependencyTask("blocked-task-id", "task:blocked"), + makeDependencyTask("blocker-task-id", "task:blocker"), + ]); + mocks.taskEdgeFindFirst.mockResolvedValue(null); + mocks.taskEdgeCreateMany.mockResolvedValue({ count: 1 }); + + const client = await setup("user-1", ALL_SCOPES, { isAdmin: true }); + const result = await client.callTool({ + name: "addDependency", + arguments: { + blockedTaskRef: "task:blocked", + blockerTaskRef: "blocker-task-id", + }, + }); + + expect(result.isError).toBeFalsy(); + expect(parseToolBody(result)).toMatchObject({ + blockedTaskId: "blocked-task-id", + blockerTaskId: "blocker-task-id", + created: true, + outcome: "created", + }); + expect(mocks.taskEdgeCreateMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: "blocker-task-id", + toTaskId: "blocked-task-id", + }), + ], + skipDuplicates: true, + }); + expect(mocks.taskEdgeUpdateMany).toHaveBeenCalledWith({ + data: { deletedAt: null }, + where: { + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: "blocker-task-id", + toTaskId: "blocked-task-id", + }, + }); + }); + + it("addDependency rejects conflicting canonical and legacy refs before lookup", async () => { + const client = await setup("user-1", ALL_SCOPES, { isAdmin: true }); + + const result = await client.callTool({ + name: "addDependency", + arguments: { + blockedTaskRef: "task:blocked", + blockerTaskId: "legacy-blocker-id", + blockerTaskRef: "task:blocker", + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toContain( + "blockerTaskRef or legacy blockerTaskId", + ); + expect(mocks.taskFindMany).not.toHaveBeenCalled(); + expect(mocks.taskEdgeCreateMany).not.toHaveBeenCalled(); + }); + + it("addDependency does not resolve refs from task titles", async () => { + mocks.taskFindMany.mockResolvedValue([]); + const client = await setup("user-1", ALL_SCOPES, { isAdmin: true }); + + const result = await client.callTool({ + name: "addDependency", + arguments: { + blockedTaskRef: "A blocked task title", + blockerTaskRef: "A blocker task title", + }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).message).toBe("Task not found"); + expect(mocks.taskEdgeFindFirst).not.toHaveBeenCalled(); + expect(mocks.taskEdgeUpdateMany).not.toHaveBeenCalled(); + expect(mocks.taskEdgeCreateMany).not.toHaveBeenCalled(); + }); + + it.each([ + { + deletedAt: null, + expectedOutcome: "updated", + label: "updates an active edge", + }, + { + deletedAt: new Date("2026-07-02T00:00:00.000Z"), + expectedOutcome: "reactivated", + label: "reactivates a soft-deleted edge", + }, + ])("addDependency $label", async ({ deletedAt, expectedOutcome }) => { + mocks.taskFindMany.mockResolvedValue([ + makeDependencyTask("blocked-task", "task:blocked"), + makeDependencyTask("blocker-task", "task:blocker"), + ]); + mocks.taskEdgeFindFirst.mockResolvedValue({ + deletedAt, + id: "existing-edge", + }); + mocks.taskEdgeUpdateMany.mockResolvedValue({ count: 1 }); + + const client = await setup("user-1", ALL_SCOPES, { isAdmin: true }); + const result = await client.callTool({ + name: "addDependency", + arguments: { + blockedTaskRef: "task:blocked", + blockerTaskRef: "task:blocker", + notes: "Refresh the dependency evidence.", + }, + }); + + expect(result.isError).toBeFalsy(); + expect(parseToolBody(result)).toMatchObject({ + blockedTaskId: "blocked-task", + blockerTaskId: "blocker-task", + created: false, + outcome: expectedOutcome, + }); + }); + it("addDependency rejects an edge that closes a dependency cycle", async () => { mocks.taskFindMany.mockResolvedValue([ { id: "task-a", isPublic: false, createdByUserId: "user-1" }, diff --git a/packages/web/src/lib/mcp-instructions.ts b/packages/web/src/lib/mcp-instructions.ts index c19866da9..87af80d88 100644 --- a/packages/web/src/lib/mcp-instructions.ts +++ b/packages/web/src/lib/mcp-instructions.ts @@ -11,7 +11,13 @@ START HERE (in order): 2. getQueueAudit — data-health check of the personal queue; repair high-severity issues before trusting rankings. 3. getNextAction or getMyQueue — the user's own ranked next actions. getAIQueue — tasks marked for autonomous agent execution. getNextTask — best public task for an anonymous agent. -CREATING WORK: always searchTasks first (visibility defaults to 'all' — public plus your private work — when signed in; pass 'public' or 'private' to narrow). createTask requires an explicit parentTaskId or exact parentTaskKey — choose the closest existing objective; never attach directly to Optimize Earth. Estimate value/p_success/hours honestly; a calibrated guess beats omission. Use proposeTaskBundle for multi-task drafts (it runs duplicate review). +DISCOVERY: call listTasks or searchTasks with paginated=true to receive {tasks, nextCursor}. Repeat the same call with cursor=nextCursor until nextCursor is null before claiming an inventory is complete. Copy cursors verbatim and never reuse one with different filters. Paginated inventory uses immutable task-ID order, not priority or relevance order. Calls with neither paginated=true nor cursor retain the legacy one-page array response. + +TASK REFERENCES: a persisted task reference is either its returned task ID or its exact taskKey, never its title. Use bundle-local ref values only inside that same bundle request; use a stable taskKey or returned task ID in later calls. Save returned task IDs and referenceMap entries instead of searching for records you just created. + +CREATING WORK: always searchTasks first (visibility defaults to 'all' — public plus your private work — when signed in; pass 'public' or 'private' to narrow). createTask requires an explicit parentTaskId or exact parentTaskKey — choose the closest existing objective; never attach directly to Optimize Earth. Use blockerTaskRefs/blockedTaskRefs for dependencies; each entry may be a task ID or exact taskKey. Estimate value/p_success/hours honestly; a calibrated guess beats omission. + +BUNDLES: use proposeTaskBundle for shared/public multi-task proposals. Give every candidate a short unique ref for same-request parent/blocker links and a stable taskKey for later calls. Inspect review decisions and referenceMap, then explicitly call promoteTask with returned task IDs or taskKeys; ownership never bypasses review. For private source-derived work, call reviewPrivateTaskBundle, inspect the complete normalized plan, then apply the unchanged bundle and reviewHash with applyPrivateTaskBundle. Do not mix the public draft/promotion workflow with the private review/apply workflow. OPTIMITRON DEVELOPMENT: for an improvement to Optimitron itself, searchTasks with query "optimitron:dev" and visibility "all". Confirm the exact taskKey "optimitron:dev", search again for duplicate work, then call createTask with parentTaskKey='optimitron:dev'. If the development root is not accessible, stop instead of attaching the task somewhere else. @@ -19,7 +25,7 @@ COMPLETING WORK: for a private uncompensated Self task you own, call completeTas COORDINATING: postTaskComment for threaded discussion (markdown, math, mermaid); use claimTask to reserve open work before starting; updateTask to fix estimates, parents, or dependencies as scope changes. -CLIENT RECOVERY: if the MCP host says a tool "has not been loaded yet," retry the exact same call once. That message comes from the host's lazy tool catalog, not Optimitron's argument validation; do not rewrite correct parameters in response. +CLIENT RECOVERY: if the MCP host says a tool "has not been loaded yet," retry the exact same call once. That message comes from the host's lazy tool catalog, not Optimitron's argument validation; do not rewrite correct parameters in response. Authentication, authorization, validation, and expired-token failures are not lazy-load failures; report or repair those instead of retrying blindly. FORMS: applications, surveys, RFPs, intake forms, and questionnaires use one private task owned by the person or organization answering. These tools cover reusable text and narrative answers; handle signatures, file uploads, and one-time typed controls separately. Read the exact form, then call findReviewedAnswers for each question; include a stable knowledgeKey when the same fact or narrative may be worded differently elsewhere. Call prepareFormResponses with approved revision IDs where available. It reuses only an exact knowledge key or exact normalized prompt, and creates atomic verification tasks for unresolved answers. Context tags improve search but do not split one stable answer into duplicates. Drafts are not approved answers. For each unresolved task, create or use its answer document, run startTaskExecution → submitTaskArtifact → submitTaskForVerification, and wait for acceptance through verifyTaskExecution. Once every response pins an accepted immutable revision, run startTaskExecution for the form task and pass that attempt ID to proposeFormSubmission. It blocks placeholders and returns one exact pending payload. Never submit, publish, spend, or send from that proposal until the human approves the ExternalActionRequest. After execution, record the receipt with recordExternalActionResult. Do not invent facts, silently rewrite an approved answer, or duplicate an authorized current answer. diff --git a/packages/web/src/lib/mcp-server.ts b/packages/web/src/lib/mcp-server.ts index c2039ad04..b58e38ce8 100644 --- a/packages/web/src/lib/mcp-server.ts +++ b/packages/web/src/lib/mcp-server.ts @@ -926,6 +926,105 @@ function dedupeStrings(values: Array) { ); } +const TASK_PAGE_CURSOR_VERSION = 2; + +function getTaskPageSignature( + tool: "listTasks" | "searchTasks", + filters: Record, +) { + return createHash("sha256") + .update(JSON.stringify({ filters, tool })) + .digest("base64url") + .slice(0, 24); +} + +function encodeTaskPageCursor(input: { + afterTaskId: string; + signature: string; + tool: "listTasks" | "searchTasks"; +}) { + return Buffer.from( + JSON.stringify({ + afterTaskId: input.afterTaskId, + signature: input.signature, + tool: input.tool, + version: TASK_PAGE_CURSOR_VERSION, + }), + ).toString("base64url"); +} + +function paginateAuthorizedTasks(input: { + cursor: unknown; + limit: number; + signature: string; + tasks: T[]; + tool: "listTasks" | "searchTasks"; +}) { + // Ranked task order can change between calls. Paginated inventory instead + // uses the immutable persisted task ID so a score change cannot move an + // unseen task across the cursor or return an already-seen task again. + const orderedTasks = [...input.tasks].sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ); + let startIndex = 0; + if (input.cursor != null && input.cursor !== "") { + if (typeof input.cursor !== "string") { + throw new Error("cursor must be a string."); + } + try { + const parsed = JSON.parse( + Buffer.from(input.cursor, "base64url").toString("utf8"), + ) as Record; + if ( + parsed.version !== TASK_PAGE_CURSOR_VERSION || + parsed.tool !== input.tool || + parsed.signature !== input.signature || + typeof parsed.afterTaskId !== "string" + ) { + throw new Error("cursor does not match this task query."); + } + const nextIndex = orderedTasks.findIndex( + (task) => task.id > (parsed.afterTaskId as string), + ); + startIndex = nextIndex < 0 ? orderedTasks.length : nextIndex; + } catch (error) { + throw new Error( + error instanceof Error && error.message.startsWith("cursor ") + ? error.message + : "cursor is invalid.", + ); + } + } + + const page = orderedTasks.slice(startIndex, startIndex + input.limit); + const hasMore = startIndex + page.length < orderedTasks.length; + return { + nextCursor: + hasMore && page.length > 0 + ? encodeTaskPageCursor({ + afterTaskId: page[page.length - 1]!.id, + signature: input.signature, + tool: input.tool, + }) + : null, + tasks: page, + }; +} + +function resolveExactTaskReferences< + T extends { id: string; taskKey?: string | null }, +>(refs: string[], tasks: T[]) { + const taskByRef = new Map(); + for (const ref of refs) { + const matches = tasks.filter( + (task) => task.id === ref || task.taskKey === ref, + ); + if (matches.length !== 1) return null; + taskByRef.set(ref, matches[0]!); + } + return taskByRef; +} + function asStringArray(value: unknown) { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") @@ -1532,6 +1631,7 @@ function buildStoredProposalContext(input: { parentTaskRef: (input.candidate.parentTaskRef as string) ?? null, proposalRef: input.decision?.proposalRef ?? + (input.candidate.ref as string) ?? (input.candidate.id as string) ?? (input.candidate.taskKey as string) ?? null, @@ -1620,11 +1720,9 @@ function matchCandidateToDecision( decision: { proposalRef: string; title: string }, ) { return ( + (candidate.ref as string) === decision.proposalRef || (candidate.id as string) === decision.proposalRef || - (candidate.taskKey as string) === decision.proposalRef || - (((candidate.id as string) ?? "").length === 0 && - ((candidate.taskKey as string) ?? "").length === 0 && - (candidate.title as string) === decision.title) + (candidate.taskKey as string) === decision.proposalRef ); } @@ -1695,7 +1793,7 @@ function taskProposalCandidateFromRecord(task: { async function attachProposalImpactEstimate(input: { actor: { isAdmin: boolean; userId: string }; - prisma: Awaited>; + prisma: Prisma.TransactionClient; taskId: string; estimatedEffortHours: number | null; impact: Record | null; @@ -1718,7 +1816,7 @@ async function attachProposalImpactEstimate(input: { (input.estimatedEffortHours == null || expectedValuePerHourUsd == null ? null : expectedValuePerHourUsd * input.estimatedEffortHours); - return taskImpact.createDirectTaskImpact( + return taskImpact.createDirectTaskImpactInTransaction( { assumptions: asStringArray(impact.assumptions), calculationVersion: "mcp-proposal-v2", @@ -1866,6 +1964,7 @@ function normalizeProposalCandidate( isPublic: candidate.isPublic === true, source: normalizedSource, sourceUrls, + ref: optionalString(candidate.ref), taskKey: generatedTaskKey, }; } @@ -1882,7 +1981,7 @@ function getPlannerSourceArtifactKey(source: Record | null) { } async function attachProposalSourceArtifact(input: { - prisma: Awaited>; + prisma: Prisma.TransactionClient; source: Record | null; taskId: string; }) { @@ -2012,6 +2111,7 @@ function createTaskReplayResult( requestId: string, ) { return { + id: task.id, idempotentReplay: true, isPublic: task.isPublic, status: task.status, @@ -5489,7 +5589,7 @@ const TASK_TOOL_DEFINITIONS = [ description: "List tasks with optional filters. Returns up to 50 tasks sorted by accountability score. " + "Signed-in callers see public tasks plus their own private work by default (visibility 'all'); " + - "pass visibility 'public' or 'private' to narrow.", + "pass visibility 'public' or 'private' to narrow. Returns the legacy task array unless paginated=true or cursor is supplied. Paginated inventory uses immutable task-ID order, not priority order.", inputSchema: { type: "object" as const, properties: { @@ -5575,8 +5675,20 @@ const TASK_TOOL_DEFINITIONS = [ type: "string", description: "Filter by parent task ID (get subtasks)", }, + cursor: { + type: "string", + description: + "Opaque cursor returned as nextCursor by the preceding page. Copy it verbatim and keep all filters unchanged.", + }, + paginated: { + type: "boolean", + description: + "Return {tasks,nextCursor}. Omit for the legacy task-array response.", + }, limit: { - type: "number", + type: "integer", + minimum: 1, + maximum: 50, description: "Max results (default 20, max 50)", }, }, @@ -6462,6 +6574,12 @@ const TASK_TOOL_DEFINITIONS = [ "Alias for blockerTaskIds: existing task IDs that must be VERIFIED before this task appears in active queues. Use only for real prerequisites, not generic importance.", minItems: 0, }, + blockerTaskRefs: { + type: "array", + items: { type: "string" }, + description: "Prerequisite task IDs or exact taskKeys.", + minItems: 0, + }, blockerTaskIds: { type: "array", items: { type: "string" }, @@ -6470,6 +6588,12 @@ const TASK_TOOL_DEFINITIONS = [ "Use searchTasks first to discover valid blocker task IDs.", minItems: 0, }, + blockedTaskRefs: { + type: "array", + items: { type: "string" }, + description: "Blocked task IDs or exact taskKeys.", + minItems: 0, + }, blockedTaskIds: { type: "array", items: { type: "string" }, @@ -6890,6 +7014,11 @@ const TASK_TOOL_DEFINITIONS = [ description: "Action and acceptance criteria", }, taskKey: { type: "string", description: "Stable key for dedup" }, + ref: { + type: "string", + description: + "Canonical same-request alias used by parentTaskRef, blockerRefs, proposalRef, and referenceMap.", + }, id: { type: "string", description: "Draft reference ID" }, assigneePersonId: { type: "string" }, assigneeOrganizationId: { type: "string" }, @@ -7126,6 +7255,11 @@ const TASK_TOOL_DEFINITIONS = [ description: "Replace blocker dependencies with this exact list of task IDs. Blockers must be completed/VERIFIED before this task appears in active queues.", }, + blockerTaskRefs: { + type: "array", + items: { type: "string" }, + description: "Replace blockers using task IDs or exact taskKeys.", + }, blockerTaskIds: { type: "array", items: { type: "string" }, @@ -7597,8 +7731,32 @@ const TASK_TOOL_DEFINITIONS = [ "Add a dependency between tasks. The blocked task cannot proceed until the blocker is done. Optional edge metadata can estimate how much the blocker raises downstream success probability or accelerates downstream value.", inputSchema: { type: "object" as const, + allOf: [ + { + anyOf: [ + { required: ["blockedTaskRef"] }, + { required: ["blockedTaskId"] }, + ], + }, + { + anyOf: [ + { required: ["blockerTaskRef"] }, + { required: ["blockerTaskId"] }, + ], + }, + ], properties: { + blockedTaskRef: { + type: "string", + description: + "Canonical task ID or exact taskKey for the task that is blocked.", + }, blockedTaskId: { type: "string", description: "Task that is blocked" }, + blockerTaskRef: { + type: "string", + description: + "Canonical task ID or exact taskKey for the task that must complete first.", + }, blockerTaskId: { type: "string", description: "Task that must complete first", @@ -7640,7 +7798,6 @@ const TASK_TOOL_DEFINITIONS = [ description: "Optional note describing the dependency", }, }, - required: ["blockedTaskId", "blockerTaskId"], }, }, { @@ -7975,11 +8132,21 @@ const TASK_TOOL_DEFINITIONS = [ description: "Search your accessible tasks by title, description, task key, assignee, or organization. " + "Use this before createTask/updateTask to find parents, duplicates, blockerTaskIds, or blockedTaskIds. " + - "For Optimitron code or documentation work, query the stable key 'optimitron:dev', select that exact result, and call createTask with parentTaskKey='optimitron:dev'.", + "For Optimitron code or documentation work, query the stable key 'optimitron:dev', select that exact result, and call createTask with parentTaskKey='optimitron:dev'. Returns the legacy result array unless paginated=true or cursor is supplied. Paginated inventory uses immutable task-ID order, not relevance order.", inputSchema: { type: "object" as const, properties: { query: { type: "string", description: "Search query text." }, + cursor: { + type: "string", + description: + "Opaque cursor returned as nextCursor by the preceding page. Copy it verbatim and keep query and filters unchanged.", + }, + paginated: { + type: "boolean", + description: + "Return {tasks,nextCursor}. Omit for the legacy result-array response.", + }, limit: { type: "number", description: "Max results to return (default 20, max 100)", @@ -9389,7 +9556,7 @@ export function createMcpServer( typeof a.category === "string" && a.category in TaskCategory ? TaskCategory[a.category as keyof typeof TaskCategory] : null; - const limit = Math.min(Number(a.limit) || 20, 50); + const limit = parseQueueLimit(a.limit, 20, 50); let assigneePersonId = (a.assigneePersonId as string) ?? null; const assigneeOrganizationId = (a.assigneeOrganizationId as string) ?? null; @@ -9466,6 +9633,9 @@ export function createMcpServer( error instanceof Error ? error.message : "Invalid filter.", ); } + const wantsPagination = + a.paginated === true || + (typeof a.cursor === "string" && a.cursor.length > 0); const needsExtendedFiltering = Object.values(extendedFilters).some( (value) => Array.isArray(value) ? value.length > 0 : value != null, @@ -9474,6 +9644,8 @@ export function createMcpServer( typeof a.parentTaskId === "string" && a.parentTaskId ? a.parentTaskId : null; + const needsCompleteAuthorizedWindow = + wantsPagination || needsExtendedFiltering; const list = await tasks.listTasks({ status, category, @@ -9482,13 +9654,27 @@ export function createMcpServer( assigneePersonId, assigneeOrganizationId, parentTaskId: parentTaskIdFilter, - limit: needsExtendedFiltering ? 5000 : limit, + // Pagination and post-query filters need a stable authorized + // window. Fetch one sentinel row past the advertised maximum so + // oversized queries fail instead of doing unbounded work or + // silently omitting matches. + limit: needsCompleteAuthorizedWindow ? 5001 : limit, personId: visibility === "public" ? null : viewerPersonId, userId: visibility === "public" ? null : userId, visibility, }); // parentTaskId is filtered in the Prisma query above; no in-memory pass. - let filtered = Array.isArray(list) ? list : []; + const authorizedWindow = Array.isArray(list) ? list : []; + if ( + needsCompleteAuthorizedWindow && + authorizedWindow.length > 5000 + ) { + return err( + "This task listing exceeds the 5000-task result window. Narrow the query-level filters before using pagination or extended filters.", + { code: "RESULT_WINDOW_EXCEEDED" }, + ); + } + let filtered = authorizedWindow; filtered = filtered.filter((task: Record) => { if ( extendedFilters.ownerOrganizationId && @@ -9530,7 +9716,43 @@ export function createMcpServer( } return true; }); - return ok(filtered.slice(0, limit).map(summarizeTask)); + if (!wantsPagination) { + return ok(filtered.slice(0, limit).map(summarizeTask)); + } + try { + const page = paginateAuthorizedTasks({ + cursor: a.cursor, + limit, + signature: getTaskPageSignature("listTasks", { + applicationPolicy: extendedFilters.applicationPolicy ?? null, + assignedToMe: a.assignedToMe === true, + assigneeOrganizationId, + assigneePersonId, + category, + compensationKind: extendedFilters.compensationKind ?? null, + engagementKind: extendedFilters.engagementKind ?? null, + executionMode: extendedFilters.executionMode ?? null, + ownerOrganizationId: + extendedFilters.ownerOrganizationId ?? null, + parentTaskId: parentTaskIdFilter, + remotePolicy: extendedFilters.remotePolicy ?? null, + requiredTags: extendedFilters.requiredTags ?? [], + status, + visibility, + }), + tasks: filtered as Array, + tool: "listTasks", + }); + return ok({ + nextCursor: page.nextCursor, + tasks: page.tasks.map(summarizeTask), + }); + } catch (error) { + return err( + error instanceof Error ? error.message : "Invalid cursor.", + { code: "INVALID_ARGUMENT" }, + ); + } } // ── listPeople ──────────────────────────────────────── @@ -9899,17 +10121,48 @@ export function createMcpServer( const status = a.status ? (a.status as "DRAFT" | "ACTIVE" | "VERIFIED" | "STALE") : undefined; + const wantsPagination = + a.paginated === true || + (typeof a.cursor === "string" && a.cursor.length > 0); + // Search ranks a bounded candidate set in memory. Use one fixed + // authorized window for every page; paginateAuthorizedTasks then + // traverses it by immutable task ID rather than mutable rank. const results = await tasks.searchTasks(query, { clientAccessBoundary: scope === "public" ? undefined : taskClientBoundary, - limit, + limit: wantsPagination ? 500 : limit, userId: scope === "public" ? null : userId, status, visibility: scope, }); + if (!wantsPagination) return ok(results.slice(0, limit)); + if (results.length >= 500) { + return err( + "This search matched the server's 500-candidate ranking window. Narrow the query or filters before paginating so no matches are silently omitted.", + { code: "RESULT_WINDOW_EXCEEDED" }, + ); + } - return ok(results); + try { + const page = paginateAuthorizedTasks({ + cursor: a.cursor, + limit, + signature: getTaskPageSignature("searchTasks", { + query, + scope, + status: status ?? null, + }), + tasks: results, + tool: "searchTasks", + }); + return ok(page); + } catch (error) { + return err( + error instanceof Error ? error.message : "Invalid cursor.", + { code: "INVALID_ARGUMENT" }, + ); + } } // ── getTask ──────────────────────────────────────────── @@ -10798,7 +11051,10 @@ export function createMcpServer( const prisma = await getPrisma(); const economics = resolveTaskEconomics(a); - const blockerTaskIds = dedupeStrings([ + const blockerTaskRefs = dedupeStrings([ + ...(Array.isArray(a.blockerTaskRefs) + ? (a.blockerTaskRefs as string[]) + : []), ...(Array.isArray(a.blockerTaskIds) ? (a.blockerTaskIds as string[]) : []), @@ -10806,15 +11062,20 @@ export function createMcpServer( ? (a.depends_on as string[]) : []), ]); - const blockedTaskIds = dedupeStrings( - Array.isArray(a.blockedTaskIds) + const blockedTaskRefs = dedupeStrings([ + ...(Array.isArray(a.blockedTaskRefs) + ? (a.blockedTaskRefs as string[]) + : []), + ...(Array.isArray(a.blockedTaskIds) ? (a.blockedTaskIds as string[]) - : [], - ); - const dependencyTaskIds = dedupeStrings([ - ...blockerTaskIds, - ...blockedTaskIds, + : []), + ]); + const dependencyTaskRefs = dedupeStrings([ + ...blockerTaskRefs, + ...blockedTaskRefs, ]); + let blockerTaskIds: string[] = []; + let blockedTaskIds: string[] = []; // Accept either an explicit array, or a markdown 'Acceptance // criteria' section in the description. Missing both = error. @@ -10974,13 +11235,18 @@ export function createMcpServer( } } - if (dependencyTaskIds.length > 0) { + if (dependencyTaskRefs.length > 0) { const sessionPersonId = await loadSessionPersonId(userId); const dependencyTasks = await prisma.task.findMany({ where: { deletedAt: null, - id: { in: dependencyTaskIds }, AND: [ + { + OR: [ + { id: { in: dependencyTaskRefs } }, + { taskKey: { in: dependencyTaskRefs } }, + ], + }, getTaskAccessWhere({ action: "READ", personId: sessionPersonId, @@ -10993,20 +11259,24 @@ export function createMcpServer( createdByUserId: true, id: true, isPublic: true, + taskKey: true, }, }); - const foundDependencyIds = new Set( - dependencyTasks.map((task) => task.id), + const dependencyTaskByRef = resolveExactTaskReferences( + dependencyTaskRefs, + dependencyTasks, ); - const missingDependencyIds = dependencyTaskIds.filter( - (id) => !foundDependencyIds.has(id), - ); - if (missingDependencyIds.length > 0) { + if (!dependencyTaskByRef) { return err( - "One or more dependency tasks were not found or are inaccessible.", + "One or more dependency task references were not found, were inaccessible, or were ambiguous.", ); } - + blockerTaskIds = dedupeStrings( + blockerTaskRefs.map((ref) => dependencyTaskByRef.get(ref)?.id), + ); + blockedTaskIds = dedupeStrings( + blockedTaskRefs.map((ref) => dependencyTaskByRef.get(ref)?.id), + ); const forbiddenBlockedTaskIds: string[] = []; for (const task of dependencyTasks) { if (!blockedTaskIds.includes(task.id)) continue; @@ -11347,6 +11617,7 @@ export function createMcpServer( }; return ok({ ...baseResult, + id: task.id, idempotentReplay: false, isPublic, missingFields, @@ -11355,6 +11626,7 @@ export function createMcpServer( ? "Task created with full metadata." : `Task created. Consider an updateTask call to fill in: ${missingFields.join(", ")}.`, supersededTaskIds, + taskId: task.id, visibility: formatTaskVisibility(isPublic), writeReceipt: { idempotencyKey: taskKey, @@ -12236,9 +12508,39 @@ export function createMcpServer( >(); const { canManageOrganization } = await import("./organization.server"); + const candidateAliasOwner = new Map(); + const normalizedCandidates = rawCandidates.map( + (rawCandidate, candidateIndex) => { + const candidate = normalizeProposalCandidate(rawCandidate); + return optionalString(candidate.ref) || + optionalString(candidate.id) || + optionalString(candidate.taskKey) + ? candidate + : { ...candidate, ref: `candidate-${candidateIndex + 1}` }; + }, + ); + for (const [ + candidateIndex, + candidate, + ] of normalizedCandidates.entries()) { + const candidateAliases = dedupeStrings([ + optionalString(candidate.ref), + optionalString(candidate.id), + optionalString(candidate.taskKey), + ]); + for (const alias of candidateAliases) { + const ownerIndex = candidateAliasOwner.get(alias); + if (ownerIndex != null && ownerIndex !== candidateIndex) { + return err( + `Candidate reference alias ${JSON.stringify(alias)} is used by more than one candidate. ref, id, and taskKey aliases must be unique within a bundle.`, + { code: "INVALID_ARGUMENT" }, + ); + } + candidateAliasOwner.set(alias, candidateIndex); + } + } - for (const rawCandidate of rawCandidates) { - const candidate = normalizeProposalCandidate(rawCandidate); + for (const candidate of normalizedCandidates) { if ( typeof candidate.taskKey === "string" && /^planner:(?:person|organization):/.test(candidate.taskKey) @@ -12286,12 +12588,26 @@ export function createMcpServer( typeof candidate.assigneePersonId === "string" ? candidate.assigneePersonId : null; + const candidateIsPublic = isAdmin && candidate.isPublic === true; if (!isAdmin && candidate.isPublic) { return err( "Non-admin task proposals must remain private drafts.", ); } + if ( + !isTaskWithinClientAccessBoundary( + { + isPublic: candidateIsPublic, + ownerOrganizationId: assigneeOrganizationId, + }, + taskClientBoundary, + ) + ) { + return err( + "The OAuth grant does not allow private tasks for this target.", + ); + } if (assigneeOrganizationId) { if ( !isAdmin && @@ -12339,7 +12655,7 @@ export function createMcpServer( ...candidate, assigneeOrganizationId, assigneePersonId, - isPublic: isAdmin && candidate.isPublic === true, + isPublic: candidateIsPublic, parentTaskRef: requestedParentTaskRef === "$target-root" ? branch.id @@ -12358,32 +12674,37 @@ export function createMcpServer( ); const existingTasks = await prisma.task.findMany({ where: { - deletedAt: null, - ...(!isAdmin - ? { - OR: [ - { isPublic: true }, - { createdByUserId: userId }, - ...(sessionPersonId - ? [{ assigneePersonId: sessionPersonId }] - : []), - ...(proposalOrganizationIds.length > 0 - ? [ - { - assigneeOrganizationId: { - in: proposalOrganizationIds, - }, - }, - { - ownerOrganizationId: { - in: proposalOrganizationIds, - }, - }, - ] - : []), - ], - } - : {}), + AND: [ + { deletedAt: null }, + getTaskClientAccessWhere(taskClientBoundary), + ...(!isAdmin + ? [ + { + OR: [ + { isPublic: true }, + { createdByUserId: userId }, + ...(sessionPersonId + ? [{ assigneePersonId: sessionPersonId }] + : []), + ...(proposalOrganizationIds.length > 0 + ? [ + { + assigneeOrganizationId: { + in: proposalOrganizationIds, + }, + }, + { + ownerOrganizationId: { + in: proposalOrganizationIds, + }, + }, + ] + : []), + ], + }, + ] + : []), + ], }, select: { assigneeOrganizationId: true, @@ -12414,7 +12735,11 @@ export function createMcpServer( : `person:${String(candidate.assigneePersonId ?? userId)}`; const candidateByRef = new Map>(); for (const candidate of candidates) { - for (const ref of [candidate.id, candidate.taskKey]) { + for (const ref of [ + candidate.ref, + candidate.id, + candidate.taskKey, + ]) { if (typeof ref === "string" && ref) { candidateByRef.set(ref, candidate); } @@ -12425,19 +12750,68 @@ export function createMcpServer( (typeof existingTasks)[number] >(); for (const task of existingTasks) { - existingTaskByRef.set(task.id, task); - if (task.taskKey) existingTaskByRef.set(task.taskKey, task); + for (const ref of dedupeStrings([task.id, task.taskKey])) { + const existing = existingTaskByRef.get(ref); + if (existing && existing.id !== task.id) { + return err( + `Persisted task reference ${JSON.stringify(ref)} is ambiguous between task IDs and taskKeys.`, + { code: "AMBIGUOUS_TASK_REFERENCE" }, + ); + } + existingTaskByRef.set(ref, task); + } + } + const existingTaskByKey = new Map( + existingTasks.flatMap((task) => + task.taskKey ? [[task.taskKey, task] as const] : [], + ), + ); + const reusedAliasToTaskId = new Map(); + for (const candidate of candidates) { + const reusedTask = optionalString(candidate.taskKey) + ? existingTaskByKey.get(candidate.taskKey as string) + : null; + for (const alias of dedupeStrings([ + optionalString(candidate.ref), + optionalString(candidate.id), + optionalString(candidate.taskKey), + ])) { + const persistedTask = existingTaskByRef.get(alias); + const branchTaskId = Array.from(planningBranches.values()).find( + (branch) => alias === branch.id || alias === branch.taskKey, + )?.id; + if ( + (persistedTask && persistedTask.id !== reusedTask?.id) || + (branchTaskId && branchTaskId !== reusedTask?.id) + ) { + return err( + `Candidate alias ${JSON.stringify(alias)} is ambiguous with a persisted task reference.`, + { code: "AMBIGUOUS_TASK_REFERENCE" }, + ); + } + if (reusedTask) reusedAliasToTaskId.set(alias, reusedTask.id); + } } for (const candidate of candidates) { const targetKey = targetKeyForCandidate(candidate); const branch = planningBranches.get(targetKey)!; const parentRef = String(candidate.parentTaskRef ?? ""); - const parentCandidate = candidateByRef.get(parentRef); - const parentTask = existingTaskByRef.get(parentRef); + const reusedParentTaskId = reusedAliasToTaskId.get(parentRef); + const parentCandidate = reusedParentTaskId + ? undefined + : candidateByRef.get(parentRef); + const parentTask = existingTaskByRef.get( + reusedParentTaskId ?? parentRef, + ); const isBranch = parentRef === branch.id || parentRef === branch.taskKey; - if (parentCandidate === candidate) { + if ( + parentCandidate === candidate || + (reusedParentTaskId != null && + existingTaskByKey.get(candidate.taskKey as string)?.id === + reusedParentTaskId) + ) { return err("A proposed task cannot be its own parent."); } if (!isBranch && !parentCandidate && !parentTask) { @@ -12470,8 +12844,13 @@ export function createMcpServer( const targetKey = targetKeyForCandidate(candidate); const branch = planningBranches.get(targetKey)!; const parentRef = String(candidate.parentTaskRef ?? ""); - const parentCandidate = candidateByRef.get(parentRef); - const parentTask = existingTaskByRef.get(parentRef); + const reusedParentTaskId = reusedAliasToTaskId.get(parentRef); + const parentCandidate = reusedParentTaskId + ? undefined + : candidateByRef.get(parentRef); + const parentTask = existingTaskByRef.get( + reusedParentTaskId ?? parentRef, + ); const isBranch = parentRef === branch.id || parentRef === branch.taskKey; const isSameTargetCandidate = @@ -12494,8 +12873,14 @@ export function createMcpServer( } for (const blockerRef of candidate.blockerRefs as string[]) { - if (candidateByRef.has(blockerRef)) continue; - const dependencyTask = existingTaskByRef.get(blockerRef); + const reusedBlockerTaskId = + reusedAliasToTaskId.get(blockerRef); + if (!reusedBlockerTaskId && candidateByRef.has(blockerRef)) { + continue; + } + const dependencyTask = existingTaskByRef.get( + reusedBlockerTaskId ?? blockerRef, + ); if ( !dependencyTask || !canUsePrivateDependency(dependencyTask) @@ -12507,11 +12892,6 @@ export function createMcpServer( } } } - const existingTaskByKey = new Map( - existingTasks.flatMap((task) => - task.taskKey ? [[task.taskKey, task] as const] : [], - ), - ); const existingDrafts: Array<{ proposalRef: string; status: TaskStatus; @@ -12551,14 +12931,20 @@ export function createMcpServer( changedDrafts.push({ newSourceHash, previousSourceHash: linkedArtifact?.contentHash ?? null, - proposalRef: taskKey, + proposalRef: + optionalString(candidate.ref) ?? + optionalString(candidate.id) ?? + taskKey, status: existingTask.status, taskId: existingTask.id, title: existingTask.title, }); } else { existingDrafts.push({ - proposalRef: taskKey, + proposalRef: + optionalString(candidate.ref) ?? + optionalString(candidate.id) ?? + taskKey, status: existingTask.status, taskId: existingTask.id, title: existingTask.title, @@ -12569,12 +12955,29 @@ export function createMcpServer( const taskKey = candidate.taskKey as string | null; return !taskKey || !existingTaskByKey.has(taskKey); }); + const referenceMap: Record = {}; + for (const candidate of candidates) { + const taskKey = optionalString(candidate.taskKey); + const existingTask = taskKey + ? existingTaskByKey.get(taskKey) + : null; + if (!existingTask) continue; + for (const ref of dedupeStrings([ + optionalString(candidate.ref), + optionalString(candidate.id), + taskKey, + existingTask.id, + ])) { + referenceMap[ref] = existingTask.id; + } + } if (newCandidates.length === 0) { return ok({ changedDrafts, createdDrafts: [], existingDrafts, + referenceMap, message: changedDrafts.length > 0 ? `${changedDrafts.length} existing draft sources changed and require review; no duplicate drafts were created.` @@ -12595,15 +12998,24 @@ export function createMcpServer( title: c.title as string, description: (c.description as string) ?? null, taskKey: (c.taskKey as string) ?? null, - id: (c.id as string) ?? null, + id: + (c.ref as string) ?? + (c.id as string) ?? + (c.taskKey as string) ?? + null, assigneePersonId: (c.assigneePersonId as string) ?? null, assigneeOrganizationId: (c.assigneeOrganizationId as string) ?? null, roleTitle: (c.roleTitle as string) ?? null, contactUrl: (c.contactUrl as string) ?? null, sourceUrls: (c.sourceUrls as string[]) ?? [], - blockerRefs: (c.blockerRefs as string[]) ?? [], - parentTaskRef: (c.parentTaskRef as string) ?? null, + blockerRefs: ((c.blockerRefs as string[]) ?? []).map( + (ref) => reusedAliasToTaskId.get(ref) ?? ref, + ), + parentTaskRef: + reusedAliasToTaskId.get(c.parentTaskRef as string) ?? + (c.parentTaskRef as string) ?? + null, estimatedEffortHours: (c.estimatedEffortHours as number) ?? null, isPublic: (c.isPublic as boolean) ?? false, @@ -12621,11 +13033,46 @@ export function createMcpServer( })), }); + const decisionByCandidate = new Map< + Record, + (typeof review.decisions)[number] + >(); + for (const decision of review.decisions) { + const candidate = newCandidates.find((item) => + matchCandidateToDecision(item, decision), + ); + if (candidate) decisionByCandidate.set(candidate, decision); + } + for (const [candidate, decision] of decisionByCandidate) { + if (!decision.promotable) continue; + const rejectedReference = dedupeStrings([ + optionalString(candidate.parentTaskRef), + ...asStringArray(candidate.blockerRefs), + ]).find((ref) => { + const referencedCandidate = candidateByRef.get(ref); + return ( + referencedCandidate != null && + newCandidates.includes(referencedCandidate) && + decisionByCandidate.get(referencedCandidate)?.promotable === + false + ); + }); + if (rejectedReference) { + return err( + `Candidate ${JSON.stringify(decision.proposalRef)} references same-bundle parent or blocker ${JSON.stringify(rejectedReference)}, which did not pass review. No bundle drafts were persisted.`, + { code: "BUNDLE_REFERENCE_CLOSURE_VIOLATION" }, + ); + } + } + const existingRefToTaskId = new Map(); for (const task of existingTasks) { existingRefToTaskId.set(task.id, task.id); if (task.taskKey) existingRefToTaskId.set(task.taskKey, task.id); } + for (const [alias, taskId] of reusedAliasToTaskId) { + existingRefToTaskId.set(alias, taskId); + } for (const branch of planningBranches.values()) { existingRefToTaskId.set(branch.id, branch.id); if (branch.taskKey) { @@ -12633,171 +13080,207 @@ export function createMcpServer( } } - const created: Array<{ - taskId: string; - title: string; - proposalRef: string; - }> = []; - const createdRefToTaskId = new Map(); - const createdDecisionByTaskId = new Map< - string, - { - candidate: Record; - decision: (typeof review.decisions)[number]; - } - >(); - - for (const decision of review.decisions) { - if (!decision.promotable) continue; - const candidate = newCandidates.find((c) => - matchCandidateToDecision(c, decision), - ); - if (!candidate) continue; - - const task = await prisma.task.create({ - data: { - title: candidate.title as string, - description: (candidate.description as string) ?? "", - createdByUserId: userId, - taskKey: (candidate.taskKey as string) ?? null, - category: inferProposalCategory(candidate), - assigneePersonId: - (candidate.assigneePersonId as string) ?? null, - assigneeOrganizationId: - (candidate.assigneeOrganizationId as string) ?? null, - roleTitle: (candidate.roleTitle as string) ?? null, - estimatedEffortHours: - (candidate.estimatedEffortHours as number) ?? null, - isPublic: candidate.isPublic === true, - impactStatement: (candidate.description as string) ?? null, - contextJson: { - ...buildStoredProposalContext({ candidate, decision }), - acceptanceCriteria: - (candidate.acceptanceCriteria as string[]) ?? [], - best_route: (candidate.bestRoute as string) ?? null, - executor_type: normalizeExecutorType( - candidate.executorType, - ), - sourceProvenance: asObject(candidate.source), - }, - deadlinePolicy: normalizeDeadlinePolicy( - candidate.deadlinePolicy, - ), - dueAt: parseTaskDate(candidate.dueAt), - status: TaskStatus.DRAFT, - } as any, - }); - await endpoints.upsertPrimaryTaskCommunicationEndpoint( - prisma, - task.id, - { - url: (candidate.contactUrl as string) ?? null, - }, - ); - await attachProposalImpactEstimate({ - actor: { isAdmin, userId }, - estimatedEffortHours: - (candidate.estimatedEffortHours as number) ?? null, - impact: (candidate.impact as Record) ?? null, - prisma, - sourceUrls: asStringArray(candidate.sourceUrls), - taskId: task.id, - }); - await attachProposalSourceArtifact({ - prisma, - source: asObject(candidate.source), - taskId: task.id, - }); + const created = await prisma.$transaction( + async (tx) => { + const createdDrafts: Array<{ + taskId: string; + title: string; + proposalRef: string; + }> = []; + const createdRefToTaskId = new Map(); + const createdDecisionByTaskId = new Map< + string, + { + candidate: Record; + decision: (typeof review.decisions)[number]; + } + >(); - created.push({ - taskId: task.id, - title: task.title, - proposalRef: decision.proposalRef, - }); - createdRefToTaskId.set(decision.proposalRef, task.id); - if (candidate.taskKey) - createdRefToTaskId.set(candidate.taskKey as string, task.id); - if (candidate.id) - createdRefToTaskId.set(candidate.id as string, task.id); - createdDecisionByTaskId.set(task.id, { candidate, decision }); - } + for (const decision of review.decisions) { + if (!decision.promotable) continue; + const candidate = newCandidates.find((c) => + matchCandidateToDecision(c, decision), + ); + if (!candidate) continue; - for (const [ - taskId, - { candidate }, - ] of createdDecisionByTaskId.entries()) { - const parentTaskRef = (candidate.parentTaskRef as string) ?? null; - if (parentTaskRef) { - const parentTaskId = - createdRefToTaskId.get(parentTaskRef) ?? - existingRefToTaskId.get(parentTaskRef) ?? - null; - if (parentTaskId) { - await prisma.task.update({ - where: { id: taskId }, - data: { parentTaskId }, + const task = await tx.task.create({ + data: { + title: candidate.title as string, + description: (candidate.description as string) ?? "", + createdByUserId: userId, + taskKey: (candidate.taskKey as string) ?? null, + category: inferProposalCategory(candidate), + assigneePersonId: + (candidate.assigneePersonId as string) ?? null, + assigneeOrganizationId: + (candidate.assigneeOrganizationId as string) ?? null, + ownerOrganizationId: + (candidate.assigneeOrganizationId as string) ?? null, + roleTitle: (candidate.roleTitle as string) ?? null, + estimatedEffortHours: + (candidate.estimatedEffortHours as number) ?? null, + isPublic: candidate.isPublic === true, + impactStatement: + (candidate.description as string) ?? null, + contextJson: { + ...buildStoredProposalContext({ candidate, decision }), + acceptanceCriteria: + (candidate.acceptanceCriteria as string[]) ?? [], + best_route: (candidate.bestRoute as string) ?? null, + executor_type: normalizeExecutorType( + candidate.executorType, + ), + sourceProvenance: asObject(candidate.source), + }, + deadlinePolicy: normalizeDeadlinePolicy( + candidate.deadlinePolicy, + ), + dueAt: parseTaskDate(candidate.dueAt), + status: TaskStatus.DRAFT, + } as any, }); + createdDrafts.push({ + taskId: task.id, + title: task.title, + proposalRef: decision.proposalRef, + }); + createdRefToTaskId.set(decision.proposalRef, task.id); + referenceMap[decision.proposalRef] = task.id; + referenceMap[task.id] = task.id; + if (candidate.ref) { + createdRefToTaskId.set(candidate.ref as string, task.id); + referenceMap[candidate.ref as string] = task.id; + } + if (candidate.taskKey) + createdRefToTaskId.set( + candidate.taskKey as string, + task.id, + ); + if (candidate.taskKey) + referenceMap[candidate.taskKey as string] = task.id; + if (candidate.id) + createdRefToTaskId.set(candidate.id as string, task.id); + if (candidate.id) + referenceMap[candidate.id as string] = task.id; + createdDecisionByTaskId.set(task.id, { candidate, decision }); } - } - for (const blockerRef of ( - (candidate.blockerRefs as string[]) ?? [] - ).filter(Boolean)) { - const blockerTaskId = - createdRefToTaskId.get(blockerRef) ?? - existingRefToTaskId.get(blockerRef) ?? - null; - if (!blockerTaskId) continue; - - const dependency = ( - (candidate.dependencies as Array>) ?? - [] - ).find((item) => item.taskRef === blockerRef); - const probabilityDeltaBase = parseFiniteNumber( - dependency?.probabilityDeltaBase, - ); - const timeDeltaDaysBase = parseFiniteNumber( - dependency?.timeDeltaDaysBase, - ); - if ( - probabilityDeltaBase != null && - (probabilityDeltaBase < 0 || probabilityDeltaBase > 1) - ) { - throw new Error( - `Dependency ${blockerRef} probabilityDeltaBase must be between 0 and 1.`, - ); - } - if (timeDeltaDaysBase != null && timeDeltaDaysBase < 0) { - throw new Error( - `Dependency ${blockerRef} timeDeltaDaysBase must be non-negative.`, + for (const [ + taskId, + { candidate }, + ] of createdDecisionByTaskId.entries()) { + const parentTaskRef = + (candidate.parentTaskRef as string) ?? null; + if (parentTaskRef) { + const parentTaskId = + createdRefToTaskId.get(parentTaskRef) ?? + existingRefToTaskId.get(parentTaskRef) ?? + null; + if (!parentTaskId) { + throw new Error( + `Accepted task references unavailable parent ${JSON.stringify(parentTaskRef)}.`, + ); + } + await tx.task.update({ + where: { id: taskId }, + data: { parentTaskId }, + }); + } + + const wiredBlockerTaskIds = new Set(); + for (const blockerRef of ( + (candidate.blockerRefs as string[]) ?? [] + ).filter(Boolean)) { + const blockerTaskId = + createdRefToTaskId.get(blockerRef) ?? + existingRefToTaskId.get(blockerRef) ?? + null; + if (!blockerTaskId) { + throw new Error( + `Accepted task references unavailable blocker ${JSON.stringify(blockerRef)}.`, + ); + } + if (wiredBlockerTaskIds.has(blockerTaskId)) continue; + wiredBlockerTaskIds.add(blockerTaskId); + + const dependency = ( + (candidate.dependencies as Array< + Record + >) ?? [] + ).find((item) => item.taskRef === blockerRef); + const probabilityDeltaBase = parseFiniteNumber( + dependency?.probabilityDeltaBase, + ); + const timeDeltaDaysBase = parseFiniteNumber( + dependency?.timeDeltaDaysBase, + ); + if ( + probabilityDeltaBase != null && + (probabilityDeltaBase < 0 || probabilityDeltaBase > 1) + ) { + throw new Error( + `Dependency ${blockerRef} probabilityDeltaBase must be between 0 and 1.`, + ); + } + if (timeDeltaDaysBase != null && timeDeltaDaysBase < 0) { + throw new Error( + `Dependency ${blockerRef} timeDeltaDaysBase must be non-negative.`, + ); + } + + await tx.taskEdge.create({ + data: { + assumptionsJson: + asStringArray(dependency?.assumptions).length > 0 + ? toInputJsonValue({ + assumptions: asStringArray( + dependency?.assumptions, + ), + }) + : undefined, + calculationVersion: + (dependency?.calculationVersion as string) ?? null, + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: blockerTaskId, + probabilityDeltaBase, + timeDeltaDaysBase, + toTaskId: taskId, + }, + }); + } + + await endpoints.upsertPrimaryTaskCommunicationEndpoint( + tx, + taskId, + { url: (candidate.contactUrl as string) ?? null }, ); + await attachProposalImpactEstimate({ + actor: { isAdmin, userId }, + estimatedEffortHours: + (candidate.estimatedEffortHours as number) ?? null, + impact: + (candidate.impact as Record) ?? null, + prisma: tx, + sourceUrls: asStringArray(candidate.sourceUrls), + taskId, + }); + await attachProposalSourceArtifact({ + prisma: tx, + source: asObject(candidate.source), + taskId, + }); } - - await prisma.taskEdge.create({ - data: { - assumptionsJson: - asStringArray(dependency?.assumptions).length > 0 - ? toInputJsonValue({ - assumptions: asStringArray(dependency?.assumptions), - }) - : undefined, - calculationVersion: - (dependency?.calculationVersion as string) ?? null, - edgeType: TaskEdgeType.BLOCKS, - fromTaskId: blockerTaskId, - probabilityDeltaBase, - timeDeltaDaysBase, - toTaskId: taskId, - }, - }); - } - } + return createdDrafts; + }, + { maxWait: 10_000, timeout: 60_000 }, + ); return ok({ review, changedDrafts, createdDrafts: created, existingDrafts, + referenceMap, message: `${review.promotableCount} of ${review.decisions.length} new candidates passed review. ${created.length} drafts created; ${existingDrafts.length} source-identical tasks reused; ${changedDrafts.length} changed sources require review.`, }); } @@ -13189,9 +13672,14 @@ export function createMcpServer( updates.parentTaskId = parentTaskId; } const dependencyPatchProvided = - Array.isArray(a.depends_on) || Array.isArray(a.blockerTaskIds); - const blockerTaskIds = dependencyPatchProvided + Array.isArray(a.depends_on) || + Array.isArray(a.blockerTaskRefs) || + Array.isArray(a.blockerTaskIds); + const blockerTaskRefs = dependencyPatchProvided ? dedupeStrings([ + ...(Array.isArray(a.blockerTaskRefs) + ? (a.blockerTaskRefs as string[]) + : []), ...(Array.isArray(a.blockerTaskIds) ? (a.blockerTaskIds as string[]) : []), @@ -13200,13 +13688,19 @@ export function createMcpServer( : []), ]) : []; - if (dependencyPatchProvided && blockerTaskIds.length > 0) { + let blockerTaskIds: string[] = []; + if (dependencyPatchProvided && blockerTaskRefs.length > 0) { const sessionPersonId = await loadSessionPersonId(userId); const dependencyTasks = await prisma.task.findMany({ where: { deletedAt: null, - id: { in: blockerTaskIds }, AND: [ + { + OR: [ + { id: { in: blockerTaskRefs } }, + { taskKey: { in: blockerTaskRefs } }, + ], + }, getTaskAccessWhere({ action: "READ", personId: sessionPersonId, @@ -13215,19 +13709,25 @@ export function createMcpServer( getTaskScopeWhere(scopes, organizationIds), ], }, - select: { createdByUserId: true, id: true, isPublic: true }, + select: { + createdByUserId: true, + id: true, + isPublic: true, + taskKey: true, + }, }); - const foundDependencyIds = new Set( - dependencyTasks.map((task) => task.id), - ); - const missingDependencyIds = blockerTaskIds.filter( - (id) => !foundDependencyIds.has(id), + const dependencyTaskByRef = resolveExactTaskReferences( + blockerTaskRefs, + dependencyTasks, ); - if (missingDependencyIds.length > 0) { + if (!dependencyTaskByRef) { return err( - "One or more dependency tasks were not found or are inaccessible.", + "One or more dependency task references were not found, were inaccessible, or were ambiguous.", ); } + blockerTaskIds = dedupeStrings( + blockerTaskRefs.map((ref) => dependencyTaskByRef.get(ref)?.id), + ); } if (dependencyPatchProvided) { const dependencyEdges = await loadReachableDependencyEdges( @@ -14170,14 +14670,48 @@ export function createMcpServer( ); const prisma = await getPrisma(); const { TaskEdgeType } = await import("@optimitron/db"); - const blockedTaskId = a.blockedTaskId as string; - const blockerTaskId = a.blockerTaskId as string; + const canonicalBlockedTaskRef = optionalString(a.blockedTaskRef); + const legacyBlockedTaskRef = optionalString(a.blockedTaskId); + const canonicalBlockerTaskRef = optionalString(a.blockerTaskRef); + const legacyBlockerTaskRef = optionalString(a.blockerTaskId); + if ( + canonicalBlockedTaskRef && + legacyBlockedTaskRef && + canonicalBlockedTaskRef !== legacyBlockedTaskRef + ) { + return err( + "Provide blockedTaskRef or legacy blockedTaskId, not both with different values.", + ); + } + if ( + canonicalBlockerTaskRef && + legacyBlockerTaskRef && + canonicalBlockerTaskRef !== legacyBlockerTaskRef + ) { + return err( + "Provide blockerTaskRef or legacy blockerTaskId, not both with different values.", + ); + } + const blockedTaskRef = + canonicalBlockedTaskRef ?? legacyBlockedTaskRef; + const blockerTaskRef = + canonicalBlockerTaskRef ?? legacyBlockerTaskRef; + if (!blockedTaskRef || !blockerTaskRef) { + return err("blockedTaskRef and blockerTaskRef are required.", { + code: "INVALID_ARGUMENT", + }); + } const sessionPersonId = await loadSessionPersonId(userId); const dependencyTasks = await prisma.task.findMany({ where: { deletedAt: null, - id: { in: [blockedTaskId, blockerTaskId] }, AND: [ + { + OR: [ + { id: { in: [blockedTaskRef, blockerTaskRef] } }, + { taskKey: { in: [blockedTaskRef, blockerTaskRef] } }, + ], + }, getTaskAccessWhere({ action: "READ", personId: sessionPersonId, @@ -14191,15 +14725,18 @@ export function createMcpServer( id: true, isPublic: true, ownerOrganizationId: true, + taskKey: true, }, }); - const blockedTask = dependencyTasks.find( - (task) => task.id === blockedTaskId, - ); - const blockerTask = dependencyTasks.find( - (task) => task.id === blockerTaskId, + const dependencyTaskByRef = resolveExactTaskReferences( + [blockedTaskRef, blockerTaskRef], + dependencyTasks, ); + const blockedTask = dependencyTaskByRef?.get(blockedTaskRef); + const blockerTask = dependencyTaskByRef?.get(blockerTaskRef); if (!blockedTask || !blockerTask) return err("Task not found"); + const blockedTaskId = blockedTask.id; + const blockerTaskId = blockerTask.id; if (blockedTaskId === blockerTaskId) { return err("A task cannot depend on itself."); } @@ -14269,10 +14806,18 @@ export function createMcpServer( if (cycle) { return err(`Dependency update rejected: ${cycle.message}`); } + const existingEdge = await prisma.taskEdge.findFirst({ + where: { + edgeType: TaskEdgeType.BLOCKS, + fromTaskId: blockerTaskId, + toTaskId: blockedTaskId, + }, + select: { deletedAt: true }, + }); await prisma.taskEdge.updateMany({ where: { - fromTaskId: a.blockerTaskId as string, - toTaskId: a.blockedTaskId as string, + fromTaskId: blockerTaskId, + toTaskId: blockedTaskId, edgeType: TaskEdgeType.BLOCKS, }, data: { deletedAt: null, ...edgeMetadata }, @@ -14280,8 +14825,8 @@ export function createMcpServer( await prisma.taskEdge.createMany({ data: [ { - fromTaskId: a.blockerTaskId as string, - toTaskId: a.blockedTaskId as string, + fromTaskId: blockerTaskId, + toTaskId: blockedTaskId, edgeType: TaskEdgeType.BLOCKS, ...edgeMetadata, }, @@ -14291,7 +14836,13 @@ export function createMcpServer( return ok({ blockedTaskId, blockerTaskId, - created: true, + created: existingEdge == null, + outcome: + existingEdge == null + ? "created" + : existingEdge.deletedAt + ? "reactivated" + : "updated", ...edgeMetadata, }); }