diff --git a/__tests__/get-claude-context-filter.test.ts b/__tests__/get-claude-context-filter.test.ts index 38386dc..089f379 100644 --- a/__tests__/get-claude-context-filter.test.ts +++ b/__tests__/get-claude-context-filter.test.ts @@ -4,12 +4,12 @@ const { mockProductFindFirst, mockSprintFindFirst, mockStoryFindFirst, - mockTodoFindMany, + mockIdeaFindMany, } = vi.hoisted(() => ({ mockProductFindFirst: vi.fn(), mockSprintFindFirst: vi.fn(), mockStoryFindFirst: vi.fn(), - mockTodoFindMany: vi.fn(), + mockIdeaFindMany: vi.fn(), })) vi.mock('../src/auth.js', () => ({ @@ -21,7 +21,7 @@ vi.mock('../src/prisma.js', () => ({ product: { findFirst: mockProductFindFirst }, sprint: { findFirst: mockSprintFindFirst }, story: { findFirst: mockStoryFindFirst }, - todo: { findMany: mockTodoFindMany }, + idea: { findMany: mockIdeaFindMany }, }, })) @@ -55,7 +55,7 @@ beforeEach(() => { }) mockSprintFindFirst.mockResolvedValue({ id: 'sprint-1', sprint_goal: 'Goal', status: 'ACTIVE' }) mockStoryFindFirst.mockResolvedValue(null) - mockTodoFindMany.mockResolvedValue([]) + mockIdeaFindMany.mockResolvedValue([]) }) describe('get_claude_context safety-net filter', () => { diff --git a/__tests__/tasks-status-update.test.ts b/__tests__/tasks-status-update.test.ts index 363a945..21a836e 100644 --- a/__tests__/tasks-status-update.test.ts +++ b/__tests__/tasks-status-update.test.ts @@ -8,6 +8,24 @@ vi.mock('../src/prisma.js', () => ({ }, story: { findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -15,14 +33,47 @@ vi.mock('../src/prisma.js', () => ({ })) import { prisma } from '../src/prisma.js' -import { updateTaskStatusWithStoryPromotion } from '../src/lib/tasks-status-update.js' +import { + propagateStatusUpwards, + updateTaskStatusWithStoryPromotion, +} from '../src/lib/tasks-status-update.js' -const mockPrisma = prisma as unknown as { +type MockedPrisma = { task: { update: ReturnType; findMany: ReturnType } - story: { findUniqueOrThrow: ReturnType; update: ReturnType } + story: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType + update: ReturnType + } $transaction: ReturnType } +const mockPrisma = prisma as unknown as MockedPrisma + +const TASK_BASE = { + id: 'task-1', + title: 'Task', + story_id: 'story-1', + implementation_plan: null, +} + beforeEach(() => { vi.clearAllMocks() mockPrisma.$transaction.mockImplementation( @@ -30,107 +81,181 @@ beforeEach(() => { ) }) -const TASK_BASE = { - id: 'task-1', - title: 'Task', - story_id: 'story-1', - implementation_plan: null, -} - -describe('updateTaskStatusWithStoryPromotion', () => { - it('promotes story to DONE when last sibling task transitions to DONE', async () => { +describe('propagateStatusUpwards — story-niveau', () => { + it('zet story op DONE wanneer alle siblings DONE zijn', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + + const result = await propagateStatusUpwards('task-1', 'DONE') + + expect(result.storyChanged).toBe(true) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + + it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'FAILED' }, + { status: 'DONE' }, + { status: 'TO_DO' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }]) + + const result = await propagateStatusUpwards('task-1', 'FAILED') + + expect(result.storyChanged).toBe(true) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'FAILED' }, + }) + }) +}) + +describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => { + it('overschrijft een handmatig BLOCKED PBI niet', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' }) + + const result = await propagateStatusUpwards('task-1', 'DONE') + + expect(result.pbiChanged).toBe(false) + expect(mockPrisma.pbi.update).not.toHaveBeenCalled() + }) +}) + +describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => { + it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'FAILED' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation( + async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'FAILED' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }, + ) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi + .fn() + .mockResolvedValue([{ status: 'FAILED' }]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) + + const result = await propagateStatusUpwards('task-1', 'FAILED') + + expect(result.sprintChanged).toBe(true) + expect(result.sprintRunChanged).toBe(true) + expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'run-1' }, + data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }), + }), + ) + expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + sprint_run_id: 'run-1', + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + id: { not: 'job-1' }, + }), + data: expect.objectContaining({ status: 'CANCELLED' }), + }), + ) + }) +}) + +describe('updateTaskStatusWithStoryPromotion (BC-wrapper)', () => { + it('mapt storyChanged + DONE-newStatus naar storyStatusChange="promoted"', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') expect(result.storyStatusChange).toBe('promoted') expect(result.storyId).toBe('story-1') - expect(mockPrisma.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - data: { status: 'DONE' }, - }) }) - it('does not promote when story is already DONE (idempotent)', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) - - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') - - expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() - }) - - it('does not promote when not all siblings are DONE', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') - - expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() - }) - - it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { + it('mapt storyChanged + non-DONE naar storyStatusChange="demoted"', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') expect(result.storyStatusChange).toBe('demoted') - expect(mockPrisma.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - data: { status: 'IN_SPRINT' }, - }) }) - it('does not demote when story is not DONE', async () => { + it('null wanneer story niet verandert', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'TO_DO' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation( + async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }, + ) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi + .fn() + .mockResolvedValue([{ status: 'READY' }]) const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() - }) - - it('updates the task regardless of story-status change', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - - expect(mockPrisma.task.update).toHaveBeenCalledWith({ - where: { id: 'task-1' }, - data: { status: 'IN_PROGRESS' }, - select: expect.any(Object), - }) - }) - - it('uses the provided transaction client when passed', async () => { - const tx = { - task: { update: vi.fn(), findMany: vi.fn() }, - story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, - } - tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) - - expect(result.storyStatusChange).toBe('promoted') - expect(mockPrisma.$transaction).not.toHaveBeenCalled() - expect(tx.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - data: { status: 'DONE' }, - }) }) }) diff --git a/__tests__/verify/classify.test.ts b/__tests__/verify/classify.test.ts index 690aa04..1658e36 100644 --- a/__tests__/verify/classify.test.ts +++ b/__tests__/verify/classify.test.ts @@ -124,3 +124,42 @@ describe('classifyDiffAgainstPlan — DIVERGENT (scope creep)', () => { expect(r.reasoning).toMatch(/extra/i) }) }) + +// Helper voor pure-delete diffs: +++ /dev/null betekent dat het bestand +// volledig verwijderd is. Pad zit alleen nog in de "--- a/" regel. +function makeDeleteDiff(files: string[], linesPerFile = 5): string { + return files + .map( + (f) => + `diff --git a/${f} b/${f}\ndeleted file mode 100644\n--- a/${f}\n+++ /dev/null\n` + + Array.from({ length: linesPerFile }, (_, i) => `-removed line ${i}`).join('\n'), + ) + .join('\n') +} + +describe('classifyDiffAgainstPlan — delete-only commits', () => { + it('herkent delete-only diff (geen +++ b/, wel --- a/) als ALIGNED bij matchend plan', () => { + const plan = 'Verwijder `src/old-helper.ts` — niet meer gebruikt.' + const diff = makeDeleteDiff(['src/old-helper.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('retourneert PARTIAL wanneer plan meer paden noemt dan zijn verwijderd', () => { + const plan = 'Verwijder `src/a.ts` en `src/b.ts`.' + const diff = makeDeleteDiff(['src/a.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('PARTIAL') + }) + + it('retourneert ALIGNED voor delete-only diff zonder plan-baseline', () => { + const diff = makeDeleteDiff(['src/old.ts']) + const r = classifyDiffAgainstPlan({ diff, plan: null }) + expect(r.result).toBe('ALIGNED') + }) + + it('retourneert nog steeds EMPTY voor echt lege diff', () => { + const r = classifyDiffAgainstPlan({ diff: '', plan: 'Verwijder `src/x.ts`.' }) + expect(r.result).toBe('EMPTY') + }) +}) diff --git a/package-lock.json b/package-lock.json index 54d0e01..dd27830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me-mcp", - "version": "0.5.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.5.0", + "version": "0.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b286071..c6c4aa3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,11 +18,13 @@ enum StoryStatus { OPEN IN_SPRINT DONE + FAILED } enum PbiStatus { READY BLOCKED + FAILED DONE } @@ -54,6 +56,7 @@ enum TaskStatus { IN_PROGRESS REVIEW DONE + FAILED } enum LogType { @@ -70,6 +73,21 @@ enum TestStatus { enum SprintStatus { ACTIVE COMPLETED + FAILED +} + +enum SprintRunStatus { + QUEUED + RUNNING + PAUSED + DONE + FAILED + CANCELLED +} + +enum PrStrategy { + SPRINT + STORY } enum IdeaStatus { @@ -105,33 +123,33 @@ enum UserQuestionStatus { } model User { - id String @id @default(cuid()) - username String @unique - email String? @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - must_reset_password Boolean @default(false) - avatar_data Bytes? - active_product_id String? - active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) - idea_code_counter Int @default(0) - min_quota_pct Int @default(20) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - ideas Idea[] - product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") - login_pairings LoginPairing[] - asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") - answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") - claude_jobs ClaudeJob[] - claude_workers ClaudeWorker[] + id String @id @default(cuid()) + username String @unique + email String? @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + must_reset_password Boolean @default(false) + avatar_data Bytes? + active_product_id String? + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + idea_code_counter Int @default(0) + min_quota_pct Int @default(20) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + ideas Idea[] + product_members ProductMember[] + assigned_stories Story[] @relation("StoryAssignee") + login_pairings LoginPairing[] + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") + claude_jobs ClaudeJob[] + claude_workers ClaudeWorker[] + started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") @@index([active_product_id]) @@map("users") @@ -172,6 +190,7 @@ model Product { repo_url String? definition_of_done String auto_pr Boolean @default(false) + pr_strategy PrStrategy @default(SPRINT) archived Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -179,7 +198,6 @@ model Product { sprints Sprint[] stories Story[] tasks Task[] - todos Todo[] members ProductMember[] active_for_users User[] @relation("UserActiveProduct") claude_questions ClaudeQuestion[] @@ -275,11 +293,36 @@ model Sprint { completed_at DateTime? stories Story[] tasks Task[] + sprint_runs SprintRun[] @@index([product_id, status]) @@map("sprints") } +model SprintRun { + id String @id @default(cuid()) + sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) + sprint_id String + started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) + started_by_id String + status SprintRunStatus @default(QUEUED) + pr_strategy PrStrategy + branch String? + pr_url String? + started_at DateTime? + finished_at DateTime? + failure_reason String? + failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) + failed_task_id String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + jobs ClaudeJob[] + + @@index([sprint_id, status]) + @@index([started_by_id, status]) + @@map("sprint_runs") +} + model Task { id String @id @default(cuid()) story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) @@ -306,6 +349,7 @@ model Task { updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] + sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@ -324,6 +368,8 @@ model ClaudeJob { task_id String? idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) idea_id String? + sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) + sprint_run_id String? kind ClaudeJobKind @default(TASK_IMPLEMENTATION) status ClaudeJobStatus @default(QUEUED) claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) @@ -350,31 +396,32 @@ model ClaudeJob { @@index([user_id, status]) @@index([task_id, status]) @@index([idea_id, status]) + @@index([sprint_run_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) @@map("claude_jobs") } model ModelPrice { - id String @id @default(cuid()) - model_id String @unique - input_price_per_1m Decimal @db.Decimal(12, 6) - output_price_per_1m Decimal @db.Decimal(12, 6) - cache_read_price_per_1m Decimal @db.Decimal(12, 6) - cache_write_price_per_1m Decimal @db.Decimal(12, 6) - currency String @default("USD") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + model_id String @unique + input_price_per_1m Decimal @db.Decimal(12, 6) + output_price_per_1m Decimal @db.Decimal(12, 6) + cache_read_price_per_1m Decimal @db.Decimal(12, 6) + cache_write_price_per_1m Decimal @db.Decimal(12, 6) + currency String @default("USD") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@map("model_prices") } model ClaudeWorker { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) - token_id String + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) + token_id String product_id String? started_at DateTime @default(now()) last_seen_at DateTime @default(now()) @@ -399,24 +446,6 @@ model ProductMember { @@map("product_members") } -model Todo { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - title String - description String? @db.VarChar(2000) - done Boolean @default(false) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@index([user_id, done, archived]) - @@index([user_id, product_id]) - @@map("todos") -} - model Idea { id String @id @default(cuid()) user User @relation(fields: [user_id], references: [id], onDelete: Cascade) @@ -453,8 +482,8 @@ model IdeaProduct { product_id String created_at DateTime @default(now()) - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) @@unique([idea_id, product_id]) @@index([product_id]) @@ -484,7 +513,7 @@ model UserQuestion { created_at DateTime @default(now()) updated_at DateTime @updatedAt - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) @@index([idea_id, status]) @@index([user_id]) diff --git a/src/index.ts b/src/index.ts index a6d6c72..d05900c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ import { registerUpdateTaskPlanTool } from './tools/update-task-plan.js' import { registerLogImplementationTool } from './tools/log-implementation.js' import { registerLogTestResultTool } from './tools/log-test-result.js' import { registerLogCommitTool } from './tools/log-commit.js' -import { registerCreateTodoTool } from './tools/create-todo.js' import { registerCreatePbiTool } from './tools/create-pbi.js' import { registerCreateStoryTool } from './tools/create-story.js' import { registerCreateTaskTool } from './tools/create-task.js' @@ -71,7 +70,6 @@ async function main() { registerLogImplementationTool(server) registerLogTestResultTool(server) registerLogCommitTool(server) - registerCreateTodoTool(server) registerCreatePbiTool(server) registerCreateStoryTool(server) registerCreateTaskTool(server) diff --git a/src/lib/tasks-status-update.ts b/src/lib/tasks-status-update.ts index 2f14f9d..3549f3d 100644 --- a/src/lib/tasks-status-update.ts +++ b/src/lib/tasks-status-update.ts @@ -1,9 +1,11 @@ -import type { Prisma, TaskStatus } from '@prisma/client' +// **HOUD SYNC** met Scrum4Me/lib/tasks-status-update.ts. +// Beide repos delen dezelfde DB; deze helper moet bit-voor-bit gelijke +// statusovergangen produceren als de Scrum4Me-versie. Bij wijziging hier +// ook in de Scrum4Me-repo updaten en omgekeerd. +import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client' import { prisma } from '../prisma.js' -export type StoryStatusChange = 'promoted' | 'demoted' | null - -export interface UpdateTaskStatusResult { +export interface PropagationResult { task: { id: string title: string @@ -11,21 +13,33 @@ export interface UpdateTaskStatusResult { story_id: string implementation_plan: string | null } - storyStatusChange: StoryStatusChange storyId: string + storyChanged: boolean + pbiChanged: boolean + sprintChanged: boolean + sprintRunChanged: boolean } -// Update task.status atomically and auto-promote/demote the parent story: -// - All sibling tasks DONE → story.status = DONE -// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT -// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog", -// which is a sprint-management action, not a status side-effect. -export async function updateTaskStatusWithStoryPromotion( +// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten +// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie. +// +// Regels: +// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE, +// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN +// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY +// (BLOCKED is handmatig en wordt niet overschreven door deze helper) +// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED, +// ELSE ALL PBIs van die stories DONE → COMPLETED, +// ELSE ACTIVE +// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk + +// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders +// blijft SprintRun ongewijzigd. +export async function propagateStatusUpwards( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, -): Promise { - const run = async (tx: Prisma.TransactionClient): Promise => { +): Promise { + const run = async (tx: Prisma.TransactionClient): Promise => { const task = await tx.task.update({ where: { id: taskId }, data: { status: newStatus }, @@ -38,35 +52,199 @@ export async function updateTaskStatusWithStoryPromotion( }, }) + // Story herevalueren const siblings = await tx.task.findMany({ where: { story_id: task.story_id }, select: { status: true }, }) - const allDone = siblings.every((s) => s.status === 'DONE') + const anyTaskFailed = siblings.some((s) => s.status === 'FAILED') + const allTasksDone = + siblings.length > 0 && siblings.every((s) => s.status === 'DONE') const story = await tx.story.findUniqueOrThrow({ where: { id: task.story_id }, - select: { status: true }, + select: { id: true, status: true, pbi_id: true, sprint_id: true }, }) - let storyStatusChange: StoryStatusChange = null - if (newStatus === 'DONE' && allDone && story.status !== 'DONE') { + const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN' + let nextStoryStatus: StoryStatus + if (anyTaskFailed) nextStoryStatus = 'FAILED' + else if (allTasksDone) nextStoryStatus = 'DONE' + else nextStoryStatus = defaultActive + + let storyChanged = false + if (nextStoryStatus !== story.status) { await tx.story.update({ - where: { id: task.story_id }, - data: { status: 'DONE' }, + where: { id: story.id }, + data: { status: nextStoryStatus }, }) - storyStatusChange = 'promoted' - } else if (newStatus !== 'DONE' && story.status === 'DONE') { - await tx.story.update({ - where: { id: task.story_id }, - data: { status: 'IN_SPRINT' }, - }) - storyStatusChange = 'demoted' + storyChanged = true } - return { task, storyStatusChange, storyId: task.story_id } + // PBI herevalueren — BLOCKED met rust laten + const pbi = await tx.pbi.findUniqueOrThrow({ + where: { id: story.pbi_id }, + select: { id: true, status: true }, + }) + + let pbiChanged = false + if (pbi.status !== 'BLOCKED') { + const pbiStories = await tx.story.findMany({ + where: { pbi_id: pbi.id }, + select: { status: true }, + }) + const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED') + const allStoriesDone = + pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE') + + let nextPbiStatus: PbiStatus + if (anyStoryFailed) nextPbiStatus = 'FAILED' + else if (allStoriesDone) nextPbiStatus = 'DONE' + else nextPbiStatus = 'READY' + + if (nextPbiStatus !== pbi.status) { + await tx.pbi.update({ + where: { id: pbi.id }, + data: { status: nextPbiStatus }, + }) + pbiChanged = true + } + } + + // Sprint herevalueren — alleen als deze story aan een sprint hangt + let sprintChanged = false + let nextSprintStatus: SprintStatus | null = null + if (story.sprint_id) { + const sprint = await tx.sprint.findUniqueOrThrow({ + where: { id: story.sprint_id }, + select: { id: true, status: true }, + }) + + const sprintPbiRows = await tx.story.findMany({ + where: { sprint_id: sprint.id }, + select: { pbi_id: true }, + distinct: ['pbi_id'], + }) + const sprintPbis = await tx.pbi.findMany({ + where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } }, + select: { status: true }, + }) + const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED') + const allPbisDone = + sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE') + + let nextStatus: SprintStatus + if (anyPbiFailed) nextStatus = 'FAILED' + else if (allPbisDone) nextStatus = 'COMPLETED' + else nextStatus = 'ACTIVE' + + if (nextStatus !== sprint.status) { + await tx.sprint.update({ + where: { id: sprint.id }, + data: { + status: nextStatus, + ...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}), + }, + }) + sprintChanged = true + nextSprintStatus = nextStatus + } + } + + // SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task + let sprintRunChanged = false + if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') { + const job = await tx.claudeJob.findFirst({ + where: { task_id: taskId, sprint_run_id: { not: null } }, + orderBy: { created_at: 'desc' }, + select: { id: true, sprint_run_id: true }, + }) + + if (job?.sprint_run_id) { + const sprintRun = await tx.sprintRun.findUnique({ + where: { id: job.sprint_run_id }, + select: { id: true, status: true }, + }) + if ( + sprintRun && + (sprintRun.status === 'QUEUED' || + sprintRun.status === 'RUNNING' || + sprintRun.status === 'PAUSED') + ) { + if (nextSprintStatus === 'FAILED') { + await tx.sprintRun.update({ + where: { id: sprintRun.id }, + data: { + status: 'FAILED', + finished_at: new Date(), + failed_task_id: taskId, + }, + }) + await tx.claudeJob.updateMany({ + where: { + sprint_run_id: sprintRun.id, + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + id: { not: job.id }, + }, + data: { + status: 'CANCELLED', + finished_at: new Date(), + error: `Cancelled: task ${taskId} failed in same sprint run`, + }, + }) + sprintRunChanged = true + } else { + // COMPLETED + await tx.sprintRun.update({ + where: { id: sprintRun.id }, + data: { status: 'DONE', finished_at: new Date() }, + }) + sprintRunChanged = true + } + } + } + } + + return { + task, + storyId: task.story_id, + storyChanged, + pbiChanged, + sprintChanged, + sprintRunChanged, + } } if (client) return run(client) return prisma.$transaction(run) } + +// ─── Backwards-compat wrapper ──────────────────────────────────────────────── +// Bestaande tools (update-task-status, log-implementation, etc.) verwachten +// de oude { task, storyStatusChange, storyId } shape. We mappen storyChanged +// op promoted/demoted via een eenvoudige heuristiek op nieuwe TaskStatus. + +export type StoryStatusChange = 'promoted' | 'demoted' | null + +export interface UpdateTaskStatusResult { + task: PropagationResult['task'] + storyStatusChange: StoryStatusChange + storyId: string +} + +export async function updateTaskStatusWithStoryPromotion( + taskId: string, + newStatus: TaskStatus, + client?: Prisma.TransactionClient, +): Promise { + const result = await propagateStatusUpwards(taskId, newStatus, client) + let storyStatusChange: StoryStatusChange = null + if (result.storyChanged) { + storyStatusChange = newStatus === 'DONE' ? 'promoted' : 'demoted' + } + return { + task: result.task, + storyStatusChange, + storyId: result.storyId, + } +} diff --git a/src/status.ts b/src/status.ts index 74e2e52..b256252 100644 --- a/src/status.ts +++ b/src/status.ts @@ -5,6 +5,7 @@ const TASK_DB_TO_API = { IN_PROGRESS: 'in_progress', REVIEW: 'review', DONE: 'done', + FAILED: 'failed', } as const satisfies Record const TASK_API_TO_DB: Record = { @@ -12,18 +13,21 @@ const TASK_API_TO_DB: Record = { in_progress: 'IN_PROGRESS', review: 'REVIEW', done: 'DONE', + failed: 'FAILED', } const STORY_DB_TO_API = { OPEN: 'open', IN_SPRINT: 'in_sprint', DONE: 'done', + FAILED: 'failed', } as const satisfies Record const STORY_API_TO_DB: Record = { open: 'OPEN', in_sprint: 'IN_SPRINT', done: 'DONE', + failed: 'FAILED', } export type TaskStatusApi = (typeof TASK_DB_TO_API)[TaskStatus] diff --git a/src/tools/create-todo.ts b/src/tools/create-todo.ts deleted file mode 100644 index 94eedfe..0000000 --- a/src/tools/create-todo.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from 'zod' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { prisma } from '../prisma.js' -import { requireWriteAccess } from '../auth.js' -import { userCanAccessProduct } from '../access.js' -import { toolError, toolJson, withToolErrors } from '../errors.js' - -const inputSchema = z.object({ - title: z.string().min(1), - description: z.string().max(2000).optional(), - product_id: z.string().min(1).optional(), -}) - -export function registerCreateTodoTool(server: McpServer) { - server.registerTool( - 'create_todo', - { - title: 'Create todo', - description: - 'Add a todo for the authenticated user, optionally scoped to a product. ' + - 'Forbidden for demo accounts.', - inputSchema, - }, - async ({ title, description, product_id }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - if (product_id && !(await userCanAccessProduct(product_id, auth.userId))) { - return toolError(`Product ${product_id} not found or not accessible`) - } - const todo = await prisma.todo.create({ - data: { - user_id: auth.userId, - product_id: product_id ?? null, - title, - description: description ?? null, - }, - select: { id: true, title: true, description: true, created_at: true }, - }) - return toolJson(todo) - }), - ) -} diff --git a/src/tools/get-claude-context.ts b/src/tools/get-claude-context.ts index 355f939..fb450e7 100644 --- a/src/tools/get-claude-context.ts +++ b/src/tools/get-claude-context.ts @@ -99,19 +99,21 @@ export function registerGetClaudeContextTool(server: McpServer) { } } - const openTodos = await prisma.todo.findMany({ + const openIdeas = await prisma.idea.findMany({ where: { user_id: auth.userId, - done: false, archived: false, + status: { not: 'PLANNED' }, OR: [{ product_id: product_id }, { product_id: null }], }, orderBy: { created_at: 'asc' }, take: 50, select: { id: true, + code: true, title: true, description: true, + status: true, created_at: true, }, }) @@ -120,7 +122,7 @@ export function registerGetClaudeContextTool(server: McpServer) { product, active_sprint: activeSprint, next_story: nextStory, - open_todos: openTodos, + open_ideas: openIdeas, }) }), ) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 5a25579..41eb9cd 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -15,6 +15,7 @@ import { resolveRepoRoot } from './wait-for-job.js' import { pushBranchForJob } from '../git/push.js' import { createPullRequest } from '../git/pr.js' import { cancelPbiOnFailure } from '../cancel/pbi-cascade.js' +import { propagateStatusUpwards } from '../lib/tasks-status-update.js' const inputSchema = z.object({ job_id: z.string().min(1), @@ -420,6 +421,25 @@ export function registerUpdateJobStatusTool(server: McpServer) { }, }) + // PBI-46 sprint-flow: propageer Task → Story → PBI → Sprint → SprintRun + // bij elke task-statusovergang (DONE of FAILED). De helper handelt ook + // sibling-cancel binnen dezelfde SprintRun af bij FAILED. + // Idea-jobs hebben geen task_id en worden hier overgeslagen. + if ( + (actualStatus === 'done' || actualStatus === 'failed') && + job.kind === 'TASK_IMPLEMENTATION' && + job.task_id + ) { + try { + await propagateStatusUpwards(job.task_id, actualStatus === 'done' ? 'DONE' : 'FAILED') + } catch (err) { + console.warn( + `[update_job_status] propagateStatusUpwards error for task ${job.task_id}:`, + err, + ) + } + } + // M12: bij failed voor IDEA_*-jobs: zet idea.status op // GRILL_FAILED / PLAN_FAILED + log JOB_EVENT. Bij done laten we de // idea-status met rust — die wordt door update_idea_*_md gezet. diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 389d4ef..2cb6621 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -247,29 +247,47 @@ export async function tryClaimJob( tokenId: string, productId?: string, ): Promise { - // Atomic claim in a single transaction — also captures plan_snapshot from task + // Atomic claim in a single transaction — also captures plan_snapshot from task. + // + // Sprint-flow filter (PBI-46): + // Idea-jobs (task_id IS NULL) blijven onafhankelijk claimable. + // Task-jobs zijn alleen claimable wanneer ze aan een actieve SprintRun + // hangen (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id + // en jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen. + // Bij eerste claim van een nog QUEUED SprintRun → status RUNNING. const rows = await prisma.$transaction(async (tx) => { - // SELECT FOR UPDATE OF claude_jobs SKIP LOCKED — LEFT JOIN tasks zodat - // idea-jobs (task_id IS NULL, M12) ook gevonden worden. plan_snapshot - // blijft dan NULL/'' voor idea-jobs — niet nodig (geen verify-flow). const found = productId - ? await tx.$queryRaw>` - SELECT cj.id, t.implementation_plan + ? await tx.$queryRaw< + Array<{ id: string; implementation_plan: string | null; sprint_run_id: string | null }> + >` + SELECT cj.id, t.implementation_plan, cj.sprint_run_id FROM claude_jobs cj LEFT JOIN tasks t ON t.id = cj.task_id + LEFT JOIN sprint_runs sr ON sr.id = cj.sprint_run_id WHERE cj.user_id = ${userId} AND cj.product_id = ${productId} AND cj.status = 'QUEUED' + AND ( + cj.task_id IS NULL + OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) + ) ORDER BY cj.created_at ASC LIMIT 1 FOR UPDATE OF cj SKIP LOCKED ` - : await tx.$queryRaw>` - SELECT cj.id, t.implementation_plan + : await tx.$queryRaw< + Array<{ id: string; implementation_plan: string | null; sprint_run_id: string | null }> + >` + SELECT cj.id, t.implementation_plan, cj.sprint_run_id FROM claude_jobs cj LEFT JOIN tasks t ON t.id = cj.task_id + LEFT JOIN sprint_runs sr ON sr.id = cj.sprint_run_id WHERE cj.user_id = ${userId} AND cj.status = 'QUEUED' + AND ( + cj.task_id IS NULL + OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) + ) ORDER BY cj.created_at ASC LIMIT 1 FOR UPDATE OF cj SKIP LOCKED @@ -279,6 +297,7 @@ export async function tryClaimJob( const jobId = found[0].id const snapshot = found[0].implementation_plan ?? '' + const sprintRunId = found[0].sprint_run_id await tx.$executeRaw` UPDATE claude_jobs SET status = 'CLAIMED', @@ -287,6 +306,19 @@ export async function tryClaimJob( plan_snapshot = ${snapshot} WHERE id = ${jobId} ` + + // SprintRun QUEUED → RUNNING bij eerste claim, in dezelfde tx zodat + // concurrent claims dezelfde overgang niet dubbel doen (UPDATE skipt + // rows die al RUNNING zijn). + if (sprintRunId) { + await tx.$executeRaw` + UPDATE sprint_runs + SET status = 'RUNNING', + started_at = COALESCE(started_at, NOW()), + updated_at = NOW() + WHERE id = ${sprintRunId} AND status = 'QUEUED' + ` + } return [{ id: jobId }] }) diff --git a/src/verify/classify.ts b/src/verify/classify.ts index e713232..3fe99f5 100644 --- a/src/verify/classify.ts +++ b/src/verify/classify.ts @@ -5,12 +5,16 @@ export interface ClassifyResult { reasoning: string } -// Extract changed file paths from a unified diff ("+++ b/" lines). +// Extract changed file paths from a unified diff. Reads both "+++ b/" +// (created/modified files) and "--- a/" (deleted/modified files), so +// pure-delete commits (where +++ is /dev/null) are still recognised. function extractDiffPaths(diff: string): string[] { const paths = new Set() for (const line of diff.split('\n')) { - const m = line.match(/^\+\+\+ b\/(.+)$/) - if (m && m[1].trim() !== '/dev/null') paths.add(m[1].trim()) + const plus = line.match(/^\+\+\+ b\/(.+)$/) + if (plus && plus[1].trim() !== '/dev/null') paths.add(plus[1].trim()) + const minus = line.match(/^--- a\/(.+)$/) + if (minus && minus[1].trim() !== '/dev/null') paths.add(minus[1].trim()) } return [...paths] } diff --git a/vendor/scrum4me b/vendor/scrum4me index 555ed8f..77617e8 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 555ed8fe89f0a3c9e52098fa0590ab8ba16e357a +Subproject commit 77617e89ac830bc4a86fa7d41f16a5122a1d9689