PBI-46: Sprint-niveau jobflow met cascade-FAIL (F1/F2/F4 Scrum4Me) (#136)
* ST-1243: F1 schema + propagateStatusUpwards-helper voor sprint-flow Schema-uitbreidingen voor de sprint-niveau jobflow (PBI-46): - TaskStatus, StoryStatus, PbiStatus, SprintStatus krijgen FAILED - Nieuwe enums: SprintRunStatus, PrStrategy - Nieuw SprintRun-model dat per-task ClaudeJobs groepeert - ClaudeJob.sprint_run_id koppeling + index - Product.pr_strategy (default SPRINT) - Bijhorende Prisma-migratie propagateStatusUpwards vervangt updateTaskStatusWithStoryPromotion en herevalueert de keten Task → Story → PBI → Sprint → SprintRun bij elke task-statuswijziging. Bij FAILED cancelt het sibling-jobs in dezelfde SprintRun. PBI-status BLOCKED blijft handmatig en wordt niet overschreven. Status-mappers + theme krijgen failed-token + label-uitbreidingen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1244: F2 sprint-runs actions + deprecate per-task enqueues actions/sprint-runs.ts (nieuw): - startSprintRunAction met pre-flight (impl_plan / open ClaudeQuestion / PBI BLOCKED|FAILED) - Maakt SprintRun + ClaudeJobs in PBI→Story→Task volgorde - resumeSprintAction zet FAILED tasks/stories/PBIs terug en start nieuwe SprintRun - cancelSprintRunAction breekt lopende SprintRun af zonder cascade actions/claude-jobs.ts: - enqueueClaudeJobAction, enqueueAllTodoJobsAction, previewEnqueueAllAction, enqueueClaudeJobsBatchAction nu deprecation-stubs (UI-cleanup volgt in F4) - cancelClaudeJobAction blijft beschikbaar voor losse jobs Tests bijgewerkt: 11 nieuwe sprint-runs tests, claude-jobs(-batch) tests herzien naar deprecation-asserties. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1246: F4 UI Start/Resume/Cancel sprint + pr_strategy dropdown - components/sprint/sprint-run-controls.tsx: knoppen Start Sprint (sprintStatus=ACTIVE), Hervat sprint (sprintStatus=FAILED) en Annuleer sprint-run (lopende run). Pre-flight blocker-modal toont blockers met directe links naar de relevante pagina's. - components/products/pr-strategy-select.tsx: dropdown SPRINT|STORY in product-settings, met optimistic update + sonner-toast op fail. - actions/products.ts: updatePrStrategyAction (eigenaar-only, demo-block). - Sprint-page: query op actieve SprintRun + tonen van controls-balk. Live cascade-visualisatie (T-634) staat als follow-up genoteerd — huidige sprint-board statusbadges volstaan voor MVP. De Solo-board "Voer uit"-knoppen zijn niet expliciet verwijderd; ze tonen nu de deprecation-error van de gestubde actions tot de Solo-flow opnieuw ontworpen wordt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab8c3dca3f
commit
77617e89ac
25 changed files with 1798 additions and 1014 deletions
|
|
@ -1,13 +1,20 @@
|
|||
// Bidirectionele case-mappers voor de REST API-boundary.
|
||||
// DB houdt UPPER_SNAKE; API exposeert lowercase.
|
||||
|
||||
import type { TaskStatus, StoryStatus, PbiStatus } from '@prisma/client'
|
||||
import type {
|
||||
TaskStatus,
|
||||
StoryStatus,
|
||||
PbiStatus,
|
||||
SprintStatus,
|
||||
SprintRunStatus,
|
||||
} from '@prisma/client'
|
||||
|
||||
const TASK_DB_TO_API = {
|
||||
TO_DO: 'todo',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
REVIEW: 'review',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
} as const satisfies Record<TaskStatus, string>
|
||||
|
||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||
|
|
@ -15,35 +22,72 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
|||
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<StoryStatus, string>
|
||||
|
||||
const STORY_API_TO_DB: Record<string, StoryStatus> = {
|
||||
open: 'OPEN',
|
||||
in_sprint: 'IN_SPRINT',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
}
|
||||
|
||||
const PBI_DB_TO_API = {
|
||||
READY: 'ready',
|
||||
BLOCKED: 'blocked',
|
||||
FAILED: 'failed',
|
||||
DONE: 'done',
|
||||
} as const satisfies Record<PbiStatus, string>
|
||||
|
||||
const PBI_API_TO_DB: Record<string, PbiStatus> = {
|
||||
ready: 'READY',
|
||||
blocked: 'BLOCKED',
|
||||
failed: 'FAILED',
|
||||
done: 'DONE',
|
||||
}
|
||||
|
||||
const SPRINT_DB_TO_API = {
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
} as const satisfies Record<SprintStatus, string>
|
||||
|
||||
const SPRINT_API_TO_DB: Record<string, SprintStatus> = {
|
||||
active: 'ACTIVE',
|
||||
completed: 'COMPLETED',
|
||||
failed: 'FAILED',
|
||||
}
|
||||
|
||||
const SPRINT_RUN_DB_TO_API = {
|
||||
QUEUED: 'queued',
|
||||
RUNNING: 'running',
|
||||
PAUSED: 'paused',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const satisfies Record<SprintRunStatus, string>
|
||||
|
||||
const SPRINT_RUN_API_TO_DB: Record<string, SprintRunStatus> = {
|
||||
queued: 'QUEUED',
|
||||
running: 'RUNNING',
|
||||
paused: 'PAUSED',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
cancelled: 'CANCELLED',
|
||||
}
|
||||
|
||||
export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus]
|
||||
export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus]
|
||||
export type PbiStatusApi = typeof PBI_DB_TO_API[PbiStatus]
|
||||
export type SprintStatusApi = typeof SPRINT_DB_TO_API[SprintStatus]
|
||||
export type SprintRunStatusApi = typeof SPRINT_RUN_DB_TO_API[SprintRunStatus]
|
||||
|
||||
export function taskStatusToApi(s: TaskStatus): TaskStatusApi {
|
||||
return TASK_DB_TO_API[s]
|
||||
|
|
@ -69,6 +113,24 @@ export function pbiStatusFromApi(s: string): PbiStatus | null {
|
|||
return PBI_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export function sprintStatusToApi(s: SprintStatus): SprintStatusApi {
|
||||
return SPRINT_DB_TO_API[s]
|
||||
}
|
||||
|
||||
export function sprintStatusFromApi(s: string): SprintStatus | null {
|
||||
return SPRINT_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export function sprintRunStatusToApi(s: SprintRunStatus): SprintRunStatusApi {
|
||||
return SPRINT_RUN_DB_TO_API[s]
|
||||
}
|
||||
|
||||
export function sprintRunStatusFromApi(s: string): SprintRunStatus | null {
|
||||
return SPRINT_RUN_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API)
|
||||
export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API)
|
||||
export const PBI_STATUS_API_VALUES = Object.values(PBI_DB_TO_API)
|
||||
export const SPRINT_STATUS_API_VALUES = Object.values(SPRINT_DB_TO_API)
|
||||
export const SPRINT_RUN_STATUS_API_VALUES = Object.values(SPRINT_RUN_DB_TO_API)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import type { Prisma, TaskStatus } from '@prisma/client'
|
||||
import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type StoryStatusChange = 'promoted' | 'demoted' | null
|
||||
|
||||
export interface UpdateTaskStatusResult {
|
||||
export interface PropagationResult {
|
||||
task: {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -11,21 +9,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<UpdateTaskStatusResult> {
|
||||
const run = async (tx: Prisma.TransactionClient): Promise<UpdateTaskStatusResult> => {
|
||||
): Promise<PropagationResult> {
|
||||
const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => {
|
||||
const task = await tx.task.update({
|
||||
where: { id: taskId },
|
||||
data: { status: newStatus },
|
||||
|
|
@ -38,33 +48,167 @@ 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue