task-graph.ts 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. /** Complete dependency validation for current Team task snapshots. */
  2. import type { TeamTaskId, TeamTaskSnapshot } from './types.ts'
  3. /** Task dependency relation rejected by the shared graph validator. */
  4. export type TeamTaskGraphViolation = 'missing' | 'duplicate' | 'cycle'
  5. /** Package-private task dependency failure retained for command error mapping. */
  6. export class TeamTaskGraphError extends Error {
  7. /**
  8. * @param message - concrete invalid dependency relation.
  9. * @param violation - stable relation category used by Team commands.
  10. */
  11. constructor(message: string, readonly violation: TeamTaskGraphViolation) {
  12. super(message)
  13. this.name = 'TeamTaskGraphError'
  14. }
  15. }
  16. /**
  17. * Validate the complete active task graph after replacing one candidate snapshot.
  18. * @param current - current task snapshots before the candidate event.
  19. * @param candidate - new or next-revision task snapshot.
  20. * @throws {TeamTaskGraphError} when an active dependency is missing, duplicated, self-referential, or cyclic.
  21. */
  22. export function assertTaskGraphCandidate(
  23. current: readonly TeamTaskSnapshot[],
  24. candidate: TeamTaskSnapshot,
  25. ): void {
  26. const tasks = new Map(current.map(task => [task.id, task]))
  27. tasks.set(candidate.id, candidate)
  28. for (const task of tasks.values()) {
  29. if (task.status === 'deleted') continue
  30. const seen = new Set<TeamTaskId>()
  31. for (const blockerId of task.blockedBy) {
  32. if (blockerId === task.id) {
  33. throw new TeamTaskGraphError(`team task "${task.id}" cannot block itself`, 'cycle')
  34. }
  35. if (seen.has(blockerId)) {
  36. throw new TeamTaskGraphError(`team task "${task.id}" repeats blocker "${blockerId}"`, 'duplicate')
  37. }
  38. const blocker = tasks.get(blockerId)
  39. if (blocker === undefined || blocker.status === 'deleted') {
  40. throw new TeamTaskGraphError(
  41. `blocker task "${blockerId}" for "${task.id}" is missing or deleted`,
  42. 'missing',
  43. )
  44. }
  45. seen.add(blockerId)
  46. }
  47. }
  48. const visiting = new Set<TeamTaskId>()
  49. const visited = new Set<TeamTaskId>()
  50. const visit = (id: TeamTaskId): void => {
  51. if (visiting.has(id)) {
  52. throw new TeamTaskGraphError(`task dependency cycle includes "${id}"`, 'cycle')
  53. }
  54. if (visited.has(id)) return
  55. const task = tasks.get(id)
  56. if (task === undefined || task.status === 'deleted') return
  57. visiting.add(id)
  58. for (const blockerId of task.blockedBy) visit(blockerId)
  59. visiting.delete(id)
  60. visited.add(id)
  61. }
  62. for (const task of tasks.values()) visit(task.id)
  63. }