1
/** Shared Team task DAG commands and runtime-enriched views. */3
import type { Agent } from '@deepseek-ai/dsh-agent'4
import type { TeamMembership } from './roster.ts'5
import { TeamError } from './error.ts'6
import type { TeamJournal } from './journal.ts'7
import type { TeamState } from './projection.ts'8
import { resolveActiveMember } from './roster.ts'9
import { assertTaskGraphCandidate, TeamTaskGraphError } from './task-graph.ts'10
import type { TeamTaskGraphViolation } from './task-graph.ts'11
import { TeamId, TeamTaskId } from './types.ts'12
import type {13
CreateTeamTaskRequest,14
TeamTaskSnapshot,15
TeamTaskView,16
UpdateTeamTaskRequest,17
} from './types.ts'18
import { projectTaskView, taskReady } from './task-view.ts'19
import { requiredText, writeScope } from './validation.ts'21
const TASK_GRAPH_ERROR_CODES: Record<TeamTaskGraphViolation, string> = {22
missing: 'TEAM_TASK_NOT_FOUND',23
duplicate: 'TEAM_INVALID_ARGUMENT',24
cycle: 'TEAM_TASK_DEPENDENCY_CYCLE',25
}27
/** Owns Team task limits, authorization, transitions, and derived views. */28
export class TeamTaskBoard {29
/**30
* @param journal - authoritative Lead-log transaction owner.31
* @param maxTasks - maximum non-deleted tasks retained by one Team.32
*/33
constructor(34
private readonly journal: TeamJournal,35
private readonly maxTasks: number,36
) {}38
/**39
* Create one unowned pending task in the Team Lead log.40
* @param membership - exact caller membership resolved by the Team roster.41
* @param request - task text, blockers, and advisory write scopes.42
* @returns the revision-one task view.43
*/44
async create(membership: TeamMembership, request: CreateTeamTaskRequest): Promise<TeamTaskView> {45
const { root } = membership46
return this.journal.transact(root.id, async () => {47
const state = this.journal.state(root)48
const active = state.tasks.filter(task => task.status !== 'deleted').length49
if (active >= this.maxTasks) {50
throw new TeamError(`Team task limit ${this.maxTasks} reached`, 'TEAM_TASK_LIMIT')51
}52
const id = TeamTaskId(`task-${state.nextTaskNumber}`)53
if (state.tasks.some(task => task.id === id)) {54
throw new TeamError('Team task id space exhausted', 'TEAM_TASK_LIMIT')55
}56
const task: TeamTaskSnapshot = {57
id,58
revision: 1,59
subject: requiredText(request.subject, 'subject', 200),60
description: requiredText(request.description, 'description', 16_384),61
status: 'pending',62
blockedBy: this.dependencies(request.blockedBy ?? [], state),63
writeScopes: this.writeScopes(request.writeScopes ?? []),64
}65
this.assertTaskGraph(state, task)66
await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task })67
return projectTaskView(state, task)68
})69
}71
/**72
* Return one task, including a deleted tombstone.73
* @param membership - exact caller membership resolved by the Team roster.74
* @param id - Team-local task identity.75
* @returns the latest task value and derived readiness diagnostics.76
*/77
get(membership: TeamMembership, id: TeamTaskId): TeamTaskView {78
const { root } = membership79
const state = this.journal.state(root)80
const task = state.tasks.find(candidate => candidate.id === id)81
if (task === undefined) throw new TeamError(`team task "${id}" not found`, 'TEAM_TASK_NOT_FOUND')82
return projectTaskView(state, task)83
}85
/**86
* List current non-deleted tasks in numeric creation order.87
* @param membership - exact caller membership resolved by the Team roster.88
* @returns detached current task views.89
*/90
list(membership: TeamMembership): TeamTaskView[] {91
const { root } = membership92
const state = this.journal.state(root)93
return state.tasks94
.filter(task => task.status !== 'deleted')95
.map(task => projectTaskView(state, task))96
}98
/**99
* Compare-and-set one authorized task transition.100
* @param caller - exact live Team member authorizing the mutation.101
* @param membership - caller role and exact live Lead.102
* @param request - task identity, expected revision, action, and action fields.103
* @returns the committed next task revision.104
*/105
async update(106
caller: Agent,107
membership: TeamMembership,108
request: UpdateTeamTaskRequest,109
): Promise<TeamTaskView> {110
const root = membership.root111
return this.journal.transact(root.id, async () => {112
const state = this.journal.state(root)113
const current = state.tasks.find(task => task.id === request.taskId)114
if (current === undefined) throw new TeamError(`team task "${request.taskId}" not found`, 'TEAM_TASK_NOT_FOUND')115
if (current.revision !== request.expectedRevision) {116
throw new TeamError(117
`stale team task "${current.id}" revision ${request.expectedRevision}; current revision is ${current.revision}`,118
'TEAM_TASK_STALE_REVISION',119
)120
}121
if (current.status === 'deleted') throw new TeamError(`team task "${current.id}" is deleted`, 'TEAM_TASK_DELETED')122
const lead = membership.role === 'lead'123
const owner = current.ownerId === caller.id124
const authorizeOwner = (): void => {125
if (!lead && !owner) throw new TeamError('task mutation requires its owner or Team Lead', 'TEAM_TASK_UNAUTHORIZED')126
}127
let next: TeamTaskSnapshot128
switch (request.action) {129
case 'claim':130
if (current.ownerId !== undefined && current.ownerId !== caller.id) {131
throw new TeamError(`team task "${current.id}" is owned by another member`, 'TEAM_TASK_ALREADY_CLAIMED')132
}133
if (current.status !== 'pending' || !taskReady(state, current)) {134
throw new TeamError(`team task "${current.id}" is not ready to claim`, 'TEAM_TASK_BLOCKED')135
}136
next = { ...current, status: 'in_progress', ownerId: caller.id }137
break138
case 'release':139
authorizeOwner()140
if (current.status !== 'in_progress') throw new TeamError('only an in-progress task can be released', 'TEAM_TASK_INVALID_TRANSITION')141
next = this.withoutOwner({ ...current, status: 'pending' })142
break143
case 'edit':144
authorizeOwner()145
if (request.subject === undefined && request.description === undefined && request.writeScopes === undefined) {146
throw new TeamError('task edit requires subject, description, or write_scopes', 'TEAM_INVALID_ARGUMENT')147
}148
next = {149
...current,150
...request.subject === undefined ? {} : { subject: requiredText(request.subject, 'subject', 200) },151
...request.description === undefined152
? {}153
: { description: requiredText(request.description, 'description', 16_384) },154
...request.writeScopes === undefined ? {} : { writeScopes: this.writeScopes(request.writeScopes) },155
}156
break157
case 'set_dependencies':158
authorizeOwner()159
if (request.blockedBy === undefined) throw new TeamError('set_dependencies requires blocked_by', 'TEAM_INVALID_ARGUMENT')160
next = { ...current, blockedBy: this.dependencies(request.blockedBy, state, current.id) }161
break162
case 'complete':163
authorizeOwner()164
if (current.status !== 'in_progress') throw new TeamError('only an in-progress task can complete', 'TEAM_TASK_INVALID_TRANSITION')165
next = { ...current, status: 'completed' }166
break167
case 'reopen':168
authorizeOwner()169
if (current.status !== 'completed') throw new TeamError('only a completed task can reopen', 'TEAM_TASK_INVALID_TRANSITION')170
next = this.withoutOwner({ ...current, status: 'pending' })171
break172
case 'reassign': {173
if (!lead) throw new TeamError('only the Team Lead can reassign tasks', 'TEAM_LEAD_REQUIRED')174
if (current.status !== 'pending' && current.status !== 'in_progress') {175
throw new TeamError(176
'only a pending or in-progress task can be reassigned',177
'TEAM_TASK_INVALID_TRANSITION',178
)179
}180
if (request.owner === undefined || request.owner.trim().length === 0) {181
next = this.withoutOwner({ ...current, status: 'pending' })182
break183
}184
if (!taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED')185
const assignee = resolveActiveMember(root, state, request.owner)186
next = { ...current, status: 'in_progress', ownerId: assignee.id }187
break188
}189
case 'delete': {190
authorizeOwner()191
const dependent = state.tasks.find(task =>192
task.status !== 'deleted' && task.id !== current.id && task.blockedBy.includes(current.id))193
if (dependent !== undefined) {194
throw new TeamError(`team task "${current.id}" still blocks "${dependent.id}"`, 'TEAM_TASK_HAS_DEPENDENTS')195
}196
next = { ...current, status: 'deleted' }197
break198
}199
/* v8 ignore next 2 -- TeamTaskAction is closed and every member is handled above. */200
default:201
throw new TeamError(`unsupported task action ${String(request.action)}`, 'TEAM_INVALID_ARGUMENT')202
}203
const task: TeamTaskSnapshot = {204
...next,205
revision: current.revision + 1,206
}207
this.assertTaskGraph(state, task)208
await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task })209
return projectTaskView(state, task)210
})211
}213
/** Validate and de-duplicate dependency ids against the current task graph. */214
private dependencies(215
values: readonly TeamTaskId[],216
state: TeamState,217
self?: TeamTaskId,218
): TeamTaskId[] {219
const seen = new Set<TeamTaskId>()220
const result: TeamTaskId[] = []221
for (const id of values) {222
if (id === self) throw new TeamError('a team task cannot block itself', 'TEAM_TASK_DEPENDENCY_CYCLE')223
if (seen.has(id)) throw new TeamError(`duplicate blocker "${id}"`, 'TEAM_INVALID_ARGUMENT')224
const task = state.tasks.find(candidate => candidate.id === id)225
if (task === undefined || task.status === 'deleted') {226
throw new TeamError(`blocker task "${id}" not found`, 'TEAM_TASK_NOT_FOUND')227
}228
seen.add(id)229
result.push(id)230
}231
return result232
}234
/** Normalize and de-duplicate task write scopes. */235
private writeScopes(values: readonly string[]): string[] {236
return [...new Set(values.map(writeScope))]237
}239
/** Map shared task-graph validation onto stable command error codes. */240
private assertTaskGraph(state: TeamState, candidate: TeamTaskSnapshot): void {241
try {242
assertTaskGraphCandidate(state.tasks, candidate)243
} catch (error: unknown) {244
/* v8 ignore next -- the shared validator is the only statement in the try and throws this exact error. */245
if (!(error instanceof TeamTaskGraphError)) throw error246
throw new TeamError(error.message, TASK_GRAPH_ERROR_CODES[error.violation], { cause: error })247
}248
}250
/** Remove an optional owner field under exactOptionalPropertyTypes. */251
private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot {252
const { ownerId: _ownerId, ...without } = task253
return without254
}255
}