diff --git a/apps/server/src/core/lua-scripts.ts b/apps/server/src/core/lua-scripts.ts index f7df8c1..e01501d 100644 --- a/apps/server/src/core/lua-scripts.ts +++ b/apps/server/src/core/lua-scripts.ts @@ -634,9 +634,19 @@ local updates = cjson.decode(updates_json) local current_status = redis.call('hget', task_key, 'status') local current_priority = tonumber(redis.call('hget', task_key, 'priority')) --- CRITICAL: Prevent regression of completed tasks (but allow retry of failed tasks) +-- Check if this is a project task (has projectId or type='project' in metadata) +local is_project_task = false +local metadata_str = redis.call('hget', task_key, 'metadata') +if metadata_str then + local metadata = cjson.decode(metadata_str) + if metadata.projectId or metadata.type == 'project' then + is_project_task = true + end +end + +-- CRITICAL: Prevent regression of completed tasks (but allow retry of failed tasks and project tasks) if current_status == 'completed' and updates.status then - if updates.status ~= 'completed' then + if updates.status ~= 'completed' and not is_project_task then return {0, 'Cannot change status of completed task to ' .. updates.status} end end diff --git a/apps/server/src/handlers/task/task.list.handler.ts b/apps/server/src/handlers/task/task.list.handler.ts index 5a007a2..2a384e1 100644 --- a/apps/server/src/handlers/task/task.list.handler.ts +++ b/apps/server/src/handlers/task/task.list.handler.ts @@ -81,36 +81,82 @@ export class TaskListHandler { const attachmentsIndexKey = `cb:task:${task.id}:attachments`; let attachmentCount = 0; let attachmentKeys: string[] = []; + let resultAttachment: any = null; + let resultAttachmentData: any = null; try { // Get attachment count from Redis attachmentCount = await ctx.redis.pub.zcard(attachmentsIndexKey); - // Also get attachment keys for discovery + // Also get attachment keys for discovery and fetch result attachment if (attachmentCount > 0 && ctx.prisma) { const attachments = await ctx.prisma.taskAttachment.findMany({ where: { taskId: task.id }, - select: { key: true }, + select: { + key: true, + type: true, + value: true, + content: true, + createdAt: true + }, take: 20 // Limit keys to prevent response bloat }); + attachmentKeys = attachments.map(a => a.key); + + // Find and include the "result" attachment content if it exists + const resultAtt = attachments.find(a => a.key === 'result'); + if (resultAtt) { + // Store the raw attachment data for later + resultAttachmentData = resultAtt; + resultAttachment = { + type: resultAtt.type, + value: resultAtt.value || undefined, + content: resultAtt.content || undefined, + }; + + // Add timestamp only if requested + if (input.includeTimestamps) { + resultAttachment.createdAt = resultAtt.createdAt.toISOString(); + } + } } } catch (error) { // Silently handle errors - attachment info is non-critical for listing attachmentCount = 0; attachmentKeys = []; + resultAttachment = null; } - return { - ...task, + // Build task data without including Date objects + const taskData: any = { + id: task.id, + text: task.text, + status: task.status, + priority: task.priority, + assignedTo: task.assignedTo, metadata: task.metadata as Record | null, result: task.result as unknown, - createdAt: task.createdAt.toISOString(), - updatedAt: task.updatedAt.toISOString(), - completedAt: task.completedAt ? task.completedAt.toISOString() : null, + error: task.error, attachmentCount, attachmentKeys, // Include keys for discovery }; + + // Only include timestamps if requested + if (input.includeTimestamps) { + taskData.createdAt = task.createdAt.toISOString(); + taskData.updatedAt = task.updatedAt.toISOString(); + taskData.completedAt = task.completedAt ? task.completedAt.toISOString() : null; + } + + // Add result attachment (with or without timestamp based on above) + if (resultAttachment) { + taskData.resultAttachment = resultAttachment; + } else { + taskData.resultAttachment = null; + } + + return taskData; }) ); diff --git a/apps/server/src/schemas/task.schema.ts b/apps/server/src/schemas/task.schema.ts index 5a85163..6a02d1b 100644 --- a/apps/server/src/schemas/task.schema.ts +++ b/apps/server/src/schemas/task.schema.ts @@ -117,6 +117,15 @@ export const taskClaimOutput = z.object({ export type TaskClaimInput = z.infer; export type TaskClaimOutput = z.infer; +// Attachment type enum (moved before task.list to fix ordering) +export const AttachmentType = z.enum([ + "json", + "markdown", + "text", + "url", + "binary", +]); + // task.list - NEW for listing/filtering tasks export const taskListInput = z.object({ status: TaskStatus.optional(), @@ -126,6 +135,7 @@ export const taskListInput = z.object({ offset: z.number().int().min(0).default(0), orderBy: z.enum(["createdAt", "updatedAt", "priority", "status", "assignedTo"]).default("createdAt"), order: z.enum(["asc", "desc"]).default("desc"), + includeTimestamps: z.boolean().optional().default(false), }); export const taskListOutput = z.object({ @@ -138,11 +148,17 @@ export const taskListOutput = z.object({ metadata: z.record(z.string(), z.unknown()).nullable(), result: z.unknown().nullable(), error: z.string().nullable(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().nullable().optional(), attachmentCount: z.number().int().min(0).default(0), attachmentKeys: z.array(z.string()).optional(), + resultAttachment: z.object({ + type: AttachmentType, + value: z.any().optional(), + content: z.string().optional(), + createdAt: z.string().datetime().optional() + }).nullable().optional(), })), totalCount: z.number(), hasMore: z.boolean(), @@ -151,15 +167,6 @@ export const taskListOutput = z.object({ export type TaskListInput = z.infer; export type TaskListOutput = z.infer; -// Attachment type enum -export const AttachmentType = z.enum([ - "json", - "markdown", - "text", - "url", - "binary", -]); - // task.create_attachment export const taskCreateAttachmentInput = z.object({ taskId: z.string().min(1), diff --git a/apps/server/tests/contract/task.update.contract.test.ts b/apps/server/tests/contract/task.update.contract.test.ts index 5fa1634..9a16960 100644 --- a/apps/server/tests/contract/task.update.contract.test.ts +++ b/apps/server/tests/contract/task.update.contract.test.ts @@ -186,6 +186,103 @@ describe("Contract Validation: task.update", () => { await expect(registry.executeHandler("task.update", input)).rejects.toThrow(); }); + it("should allow completed project tasks to transition back to pending", async () => { + // Create a project task + const projectTaskResult = await registry.executeHandler("task.create", { + text: "Project task for transition test", + priority: 80, + metadata: { + projectId: "proj-test-123", + type: "project" + } + }); + const projectTaskId = projectTaskResult.id; + + // Complete the project task + await registry.executeHandler("task.update", { + id: projectTaskId, + updates: { status: "completed" as const } + }); + + // Verify it's completed + const taskKey = `cb:task:${projectTaskId}`; + let storedTask = await redis.stream.hgetall(taskKey); + expect(storedTask.status).toBe("completed"); + + // Now transition back to pending (should succeed for project task) + const result = await registry.executeHandler("task.update", { + id: projectTaskId, + updates: { status: "pending" as const } + }); + + expect(result.status).toBe("pending"); + + // Verify in Redis + storedTask = await redis.stream.hgetall(taskKey); + expect(storedTask.status).toBe("pending"); + }); + + it("should allow completed project tasks with only projectId to transition", async () => { + // Create a task with just projectId (not type: 'project') + const projectTaskResult = await registry.executeHandler("task.create", { + text: "Subtask with projectId", + priority: 70, + metadata: { + projectId: "proj-another-123", + type: "subtask" + } + }); + const projectTaskId = projectTaskResult.id; + + // Complete the task + await registry.executeHandler("task.update", { + id: projectTaskId, + updates: { status: "completed" as const } + }); + + // Transition back to in_progress (should succeed due to projectId) + const result = await registry.executeHandler("task.update", { + id: projectTaskId, + updates: { status: "in_progress" as const } + }); + + expect(result.status).toBe("in_progress"); + }); + + it("should block completed non-project tasks from transitioning back", async () => { + // Create a regular task (no projectId or type='project') + const regularTaskResult = await registry.executeHandler("task.create", { + text: "Regular task - no transitions allowed", + priority: 60, + metadata: { + category: "regular" + } + }); + const regularTaskId = regularTaskResult.id; + + // Complete the regular task + await registry.executeHandler("task.update", { + id: regularTaskId, + updates: { status: "completed" as const } + }); + + // Try to transition back to pending (should fail for regular task) + await expect( + registry.executeHandler("task.update", { + id: regularTaskId, + updates: { status: "pending" as const } + }) + ).rejects.toThrow("Cannot change status of completed task to pending"); + + // Try to transition to in_progress (should also fail) + await expect( + registry.executeHandler("task.update", { + id: regularTaskId, + updates: { status: "in_progress" as const } + }) + ).rejects.toThrow("Cannot change status of completed task to in_progress"); + }); + it("should publish update event to Redis stream", async () => { const input = { id: testTaskId,