Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/server/src/core/lua-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 53 additions & 7 deletions apps/server/src/handlers/task/task.list.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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;
})
);

Expand Down
31 changes: 19 additions & 12 deletions apps/server/src/schemas/task.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ export const taskClaimOutput = z.object({
export type TaskClaimInput = z.infer<typeof taskClaimInput>;
export type TaskClaimOutput = z.infer<typeof taskClaimOutput>;

// 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(),
Expand All @@ -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({
Expand All @@ -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(),
Expand All @@ -151,15 +167,6 @@ export const taskListOutput = z.object({
export type TaskListInput = z.infer<typeof taskListInput>;
export type TaskListOutput = z.infer<typeof taskListOutput>;

// 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),
Expand Down
97 changes: 97 additions & 0 deletions apps/server/tests/contract/task.update.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down