返回源码地图

packages/experimental/agent-team/src/task-board.ts

main snapshot · da00f7f5358f · 正文引用章节 13;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** Shared Team task DAG commands and runtime-enriched views. */
2
3import type { Agent } from '@deepseek-ai/dsh-agent'
4import type { TeamMembership } from './roster.ts'
5import { TeamError } from './error.ts'
6import type { TeamJournal } from './journal.ts'
7import type { TeamState } from './projection.ts'
8import { resolveActiveMember } from './roster.ts'
9import { assertTaskGraphCandidate, TeamTaskGraphError } from './task-graph.ts'
10import type { TeamTaskGraphViolation } from './task-graph.ts'
11import { TeamId, TeamTaskId } from './types.ts'
12import type {
13 CreateTeamTaskRequest,
14 TeamTaskSnapshot,
15 TeamTaskView,
16 UpdateTeamTaskRequest,
17} from './types.ts'
18import { projectTaskView, taskReady } from './task-view.ts'
19import { requiredText, writeScope } from './validation.ts'
20
21const 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}
26
27/** Owns Team task limits, authorization, transitions, and derived views. */
28export 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 ) {}
37
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 } = membership
46 return this.journal.transact(root.id, async () => {
47 const state = this.journal.state(root)
48 const active = state.tasks.filter(task => task.status !== 'deleted').length
49 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 }
70
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 } = membership
79 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 }
84
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 } = membership
92 const state = this.journal.state(root)
93 return state.tasks
94 .filter(task => task.status !== 'deleted')
95 .map(task => projectTaskView(state, task))
96 }
97
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.root
111 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.id
124 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: TeamTaskSnapshot
128 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 break
138 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 break
143 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 === undefined
152 ? {}
153 : { description: requiredText(request.description, 'description', 16_384) },
154 ...request.writeScopes === undefined ? {} : { writeScopes: this.writeScopes(request.writeScopes) },
155 }
156 break
157 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 break
162 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 break
167 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 break
172 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 break
183 }
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 break
188 }
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 break
198 }
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 }
212
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 result
232 }
233
234 /** Normalize and de-duplicate task write scopes. */
235 private writeScopes(values: readonly string[]): string[] {
236 return [...new Set(values.map(writeScope))]
237 }
238
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 error
246 throw new TeamError(error.message, TASK_GRAPH_ERROR_CODES[error.violation], { cause: error })
247 }
248 }
249
250 /** Remove an optional owner field under exactOptionalPropertyTypes. */
251 private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot {
252 const { ownerId: _ownerId, ...without } = task
253 return without
254 }
255}