@sparkleideas/claims 3.5.2-patch.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +261 -0
- package/package.json +84 -0
- package/src/api/cli-commands.ts +1459 -0
- package/src/api/cli-types.ts +154 -0
- package/src/api/index.ts +24 -0
- package/src/api/mcp-tools.ts +1977 -0
- package/src/application/claim-service.ts +753 -0
- package/src/application/index.ts +46 -0
- package/src/application/load-balancer.ts +840 -0
- package/src/application/work-stealing-service.ts +807 -0
- package/src/domain/events.ts +779 -0
- package/src/domain/index.ts +214 -0
- package/src/domain/repositories.ts +239 -0
- package/src/domain/rules.ts +526 -0
- package/src/domain/types.ts +826 -0
- package/src/index.ts +79 -0
- package/src/infrastructure/claim-repository.ts +358 -0
- package/src/infrastructure/event-store.ts +297 -0
- package/src/infrastructure/index.ts +21 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim Service - Application Layer
|
|
3
|
+
*
|
|
4
|
+
* Implements IClaimService interface for managing issue claims.
|
|
5
|
+
* Supports both human and agent claimants with handoff capabilities.
|
|
6
|
+
*
|
|
7
|
+
* Key Features:
|
|
8
|
+
* - Issue claiming and releasing
|
|
9
|
+
* - Human-to-agent and agent-to-agent handoffs
|
|
10
|
+
* - Status tracking and updates
|
|
11
|
+
* - Auto-management (expiration, auto-assignment)
|
|
12
|
+
* - Full event sourcing (ADR-007)
|
|
13
|
+
*
|
|
14
|
+
* @module v3/claims/application/claim-service
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import {
|
|
19
|
+
ClaimId,
|
|
20
|
+
IssueId,
|
|
21
|
+
Claimant,
|
|
22
|
+
ClaimStatus,
|
|
23
|
+
Issue,
|
|
24
|
+
IssueClaim,
|
|
25
|
+
IssueWithClaim,
|
|
26
|
+
IssueFilters,
|
|
27
|
+
ClaimResult,
|
|
28
|
+
Duration,
|
|
29
|
+
HandoffRecord,
|
|
30
|
+
ClaimOperationError,
|
|
31
|
+
durationToMs,
|
|
32
|
+
} from '../domain/types.js';
|
|
33
|
+
import {
|
|
34
|
+
IClaimRepository,
|
|
35
|
+
IIssueRepository,
|
|
36
|
+
IClaimantRepository,
|
|
37
|
+
IClaimEventStore,
|
|
38
|
+
} from '../domain/repositories.js';
|
|
39
|
+
import {
|
|
40
|
+
createClaimCreatedEvent,
|
|
41
|
+
createClaimReleasedEvent,
|
|
42
|
+
createClaimExpiredEvent,
|
|
43
|
+
createClaimStatusChangedEvent,
|
|
44
|
+
createClaimNoteAddedEvent,
|
|
45
|
+
createHandoffRequestedEvent,
|
|
46
|
+
createHandoffAcceptedEvent,
|
|
47
|
+
createHandoffRejectedEvent,
|
|
48
|
+
createReviewRequestedEvent,
|
|
49
|
+
} from '../domain/events.js';
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Service Interface
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* IClaimService interface - main contract for claim operations
|
|
57
|
+
*/
|
|
58
|
+
export interface IClaimService {
|
|
59
|
+
// ==========================================================================
|
|
60
|
+
// Claiming
|
|
61
|
+
// ==========================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Claim an issue for a claimant
|
|
65
|
+
*/
|
|
66
|
+
claim(issueId: string, claimant: Claimant): Promise<ClaimResult>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Release a claim on an issue
|
|
70
|
+
*/
|
|
71
|
+
release(issueId: string, claimant: Claimant): Promise<void>;
|
|
72
|
+
|
|
73
|
+
// ==========================================================================
|
|
74
|
+
// Handoffs (human<->agent and agent<->agent)
|
|
75
|
+
// ==========================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Request a handoff from one claimant to another
|
|
79
|
+
*/
|
|
80
|
+
requestHandoff(issueId: string, from: Claimant, to: Claimant, reason: string): Promise<void>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Accept a pending handoff
|
|
84
|
+
*/
|
|
85
|
+
acceptHandoff(issueId: string, claimant: Claimant): Promise<void>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reject a pending handoff
|
|
89
|
+
*/
|
|
90
|
+
rejectHandoff(issueId: string, claimant: Claimant, reason: string): Promise<void>;
|
|
91
|
+
|
|
92
|
+
// ==========================================================================
|
|
93
|
+
// Status
|
|
94
|
+
// ==========================================================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Update the status of a claim
|
|
98
|
+
*/
|
|
99
|
+
updateStatus(issueId: string, status: ClaimStatus, note?: string): Promise<void>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Request review for a claimed issue
|
|
103
|
+
*/
|
|
104
|
+
requestReview(issueId: string, reviewers: Claimant[]): Promise<void>;
|
|
105
|
+
|
|
106
|
+
// ==========================================================================
|
|
107
|
+
// Queries
|
|
108
|
+
// ==========================================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get all issues claimed by a specific claimant
|
|
112
|
+
*/
|
|
113
|
+
getClaimedBy(claimant: Claimant): Promise<IssueClaim[]>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get available (unclaimed) issues matching filters
|
|
117
|
+
*/
|
|
118
|
+
getAvailableIssues(filters?: IssueFilters): Promise<Issue[]>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the current status of an issue including claim info
|
|
122
|
+
*/
|
|
123
|
+
getIssueStatus(issueId: string): Promise<IssueWithClaim>;
|
|
124
|
+
|
|
125
|
+
// ==========================================================================
|
|
126
|
+
// Auto-management
|
|
127
|
+
// ==========================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Expire stale claims that haven't had activity
|
|
131
|
+
*/
|
|
132
|
+
expireStale(maxAge: Duration): Promise<IssueClaim[]>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Auto-assign an issue to the best available claimant
|
|
136
|
+
*/
|
|
137
|
+
autoAssign(issue: Issue): Promise<Claimant | null>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// =============================================================================
|
|
141
|
+
// Service Implementation
|
|
142
|
+
// =============================================================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Claim Service implementation with event sourcing
|
|
146
|
+
*/
|
|
147
|
+
export class ClaimService implements IClaimService {
|
|
148
|
+
constructor(
|
|
149
|
+
private readonly claimRepository: IClaimRepository,
|
|
150
|
+
private readonly issueRepository: IIssueRepository,
|
|
151
|
+
private readonly claimantRepository: IClaimantRepository,
|
|
152
|
+
private readonly eventStore: IClaimEventStore
|
|
153
|
+
) {}
|
|
154
|
+
|
|
155
|
+
// ==========================================================================
|
|
156
|
+
// Claiming
|
|
157
|
+
// ==========================================================================
|
|
158
|
+
|
|
159
|
+
async claim(issueId: string, claimant: Claimant): Promise<ClaimResult> {
|
|
160
|
+
// Validate issue exists
|
|
161
|
+
const issue = await this.issueRepository.findById(issueId);
|
|
162
|
+
if (!issue) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
error: {
|
|
166
|
+
code: 'ISSUE_NOT_FOUND',
|
|
167
|
+
message: `Issue ${issueId} not found`,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check if already claimed
|
|
173
|
+
const existingClaim = await this.claimRepository.findByIssueId(issueId);
|
|
174
|
+
if (existingClaim && existingClaim.status === 'active') {
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
error: {
|
|
178
|
+
code: 'ALREADY_CLAIMED',
|
|
179
|
+
message: `Issue ${issueId} is already claimed by ${existingClaim.claimant.name}`,
|
|
180
|
+
details: { currentClaimant: existingClaim.claimant },
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check claimant's current workload
|
|
186
|
+
const currentClaimCount = await this.claimRepository.countByClaimant(claimant.id);
|
|
187
|
+
const maxClaims = claimant.maxConcurrentClaims ?? 5;
|
|
188
|
+
if (currentClaimCount >= maxClaims) {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: {
|
|
192
|
+
code: 'MAX_CLAIMS_EXCEEDED',
|
|
193
|
+
message: `Claimant ${claimant.name} has reached maximum concurrent claims (${maxClaims})`,
|
|
194
|
+
details: { currentClaims: currentClaimCount, maxClaims },
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Validate capabilities match (if required)
|
|
200
|
+
if (issue.requiredCapabilities && issue.requiredCapabilities.length > 0) {
|
|
201
|
+
const claimantCapabilities = claimant.capabilities ?? [];
|
|
202
|
+
const missingCapabilities = issue.requiredCapabilities.filter(
|
|
203
|
+
(cap) => !claimantCapabilities.includes(cap)
|
|
204
|
+
);
|
|
205
|
+
if (missingCapabilities.length > 0) {
|
|
206
|
+
return {
|
|
207
|
+
success: false,
|
|
208
|
+
error: {
|
|
209
|
+
code: 'CAPABILITY_MISMATCH',
|
|
210
|
+
message: `Claimant lacks required capabilities: ${missingCapabilities.join(', ')}`,
|
|
211
|
+
details: { missingCapabilities, requiredCapabilities: issue.requiredCapabilities },
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create the claim
|
|
218
|
+
const now = new Date();
|
|
219
|
+
const claimId = `claim-${randomUUID()}` as ClaimId;
|
|
220
|
+
const claim: IssueClaim = {
|
|
221
|
+
id: claimId,
|
|
222
|
+
issueId,
|
|
223
|
+
claimant,
|
|
224
|
+
status: 'active',
|
|
225
|
+
claimedAt: now,
|
|
226
|
+
lastActivityAt: now,
|
|
227
|
+
notes: [],
|
|
228
|
+
handoffChain: [],
|
|
229
|
+
reviewers: [],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Save claim
|
|
233
|
+
await this.claimRepository.save(claim);
|
|
234
|
+
|
|
235
|
+
// Emit event
|
|
236
|
+
const event = createClaimCreatedEvent(claimId, issueId, claimant);
|
|
237
|
+
await this.eventStore.append(event);
|
|
238
|
+
|
|
239
|
+
return { success: true, claim };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async release(issueId: string, claimant: Claimant): Promise<void> {
|
|
243
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
244
|
+
|
|
245
|
+
// Validate claim exists
|
|
246
|
+
if (!claim) {
|
|
247
|
+
throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Validate claimant owns the claim
|
|
251
|
+
if (claim.claimant.id !== claimant.id) {
|
|
252
|
+
throw new ClaimOperationError(
|
|
253
|
+
'UNAUTHORIZED',
|
|
254
|
+
`Claimant ${claimant.name} does not own the claim on issue ${issueId}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check for pending handoffs
|
|
259
|
+
const pendingHandoff = claim.handoffChain?.find((h) => h.status === 'pending');
|
|
260
|
+
if (pendingHandoff) {
|
|
261
|
+
throw new ClaimOperationError(
|
|
262
|
+
'HANDOFF_PENDING',
|
|
263
|
+
`Cannot release claim with pending handoff to ${pendingHandoff.to.name}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Update claim status
|
|
268
|
+
const previousStatus = claim.status;
|
|
269
|
+
claim.status = 'released';
|
|
270
|
+
claim.lastActivityAt = new Date();
|
|
271
|
+
|
|
272
|
+
await this.claimRepository.save(claim);
|
|
273
|
+
|
|
274
|
+
// Emit events
|
|
275
|
+
const releaseEvent = createClaimReleasedEvent(claim.id, issueId, claimant);
|
|
276
|
+
await this.eventStore.append(releaseEvent);
|
|
277
|
+
|
|
278
|
+
if (previousStatus !== 'released') {
|
|
279
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
280
|
+
claim.id,
|
|
281
|
+
issueId,
|
|
282
|
+
previousStatus,
|
|
283
|
+
'released'
|
|
284
|
+
);
|
|
285
|
+
await this.eventStore.append(statusEvent);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ==========================================================================
|
|
290
|
+
// Handoffs
|
|
291
|
+
// ==========================================================================
|
|
292
|
+
|
|
293
|
+
async requestHandoff(issueId: string, from: Claimant, to: Claimant, reason: string): Promise<void> {
|
|
294
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
295
|
+
|
|
296
|
+
// Validate claim exists
|
|
297
|
+
if (!claim) {
|
|
298
|
+
throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Validate 'from' claimant owns the claim
|
|
302
|
+
if (claim.claimant.id !== from.id) {
|
|
303
|
+
throw new ClaimOperationError(
|
|
304
|
+
'UNAUTHORIZED',
|
|
305
|
+
`Claimant ${from.name} does not own the claim on issue ${issueId}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check for existing pending handoffs
|
|
310
|
+
const existingPending = claim.handoffChain?.find((h) => h.status === 'pending');
|
|
311
|
+
if (existingPending) {
|
|
312
|
+
throw new ClaimOperationError(
|
|
313
|
+
'HANDOFF_PENDING',
|
|
314
|
+
`A handoff to ${existingPending.to.name} is already pending`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Validate 'to' claimant exists
|
|
319
|
+
const toClaimant = await this.claimantRepository.findById(to.id);
|
|
320
|
+
if (!toClaimant) {
|
|
321
|
+
throw new ClaimOperationError('CLAIMANT_NOT_FOUND', `Target claimant ${to.name} not found`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Create handoff record
|
|
325
|
+
const handoffId = `handoff-${randomUUID()}`;
|
|
326
|
+
const handoffRecord: HandoffRecord = {
|
|
327
|
+
id: handoffId,
|
|
328
|
+
from,
|
|
329
|
+
to,
|
|
330
|
+
reason,
|
|
331
|
+
status: 'pending',
|
|
332
|
+
requestedAt: new Date(),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Update claim
|
|
336
|
+
claim.handoffChain = claim.handoffChain ?? [];
|
|
337
|
+
claim.handoffChain.push(handoffRecord);
|
|
338
|
+
claim.status = 'pending_handoff';
|
|
339
|
+
claim.lastActivityAt = new Date();
|
|
340
|
+
|
|
341
|
+
await this.claimRepository.save(claim);
|
|
342
|
+
|
|
343
|
+
// Emit events
|
|
344
|
+
const handoffEvent = createHandoffRequestedEvent(claim.id, issueId, handoffId, from, to, reason);
|
|
345
|
+
await this.eventStore.append(handoffEvent);
|
|
346
|
+
|
|
347
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
348
|
+
claim.id,
|
|
349
|
+
issueId,
|
|
350
|
+
'active',
|
|
351
|
+
'pending_handoff'
|
|
352
|
+
);
|
|
353
|
+
await this.eventStore.append(statusEvent);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async acceptHandoff(issueId: string, claimant: Claimant): Promise<void> {
|
|
357
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
358
|
+
|
|
359
|
+
// Validate claim exists
|
|
360
|
+
if (!claim) {
|
|
361
|
+
throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Find pending handoff for this claimant
|
|
365
|
+
const pendingHandoff = claim.handoffChain?.find(
|
|
366
|
+
(h) => h.status === 'pending' && h.to.id === claimant.id
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
if (!pendingHandoff) {
|
|
370
|
+
throw new ClaimOperationError(
|
|
371
|
+
'HANDOFF_NOT_FOUND',
|
|
372
|
+
`No pending handoff found for claimant ${claimant.name}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check claimant's workload
|
|
377
|
+
const currentClaimCount = await this.claimRepository.countByClaimant(claimant.id);
|
|
378
|
+
const maxClaims = claimant.maxConcurrentClaims ?? 5;
|
|
379
|
+
if (currentClaimCount >= maxClaims) {
|
|
380
|
+
throw new ClaimOperationError(
|
|
381
|
+
'MAX_CLAIMS_EXCEEDED',
|
|
382
|
+
`Cannot accept handoff: claimant ${claimant.name} at max capacity`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Update handoff record
|
|
387
|
+
pendingHandoff.status = 'accepted';
|
|
388
|
+
pendingHandoff.resolvedAt = new Date();
|
|
389
|
+
|
|
390
|
+
// Transfer claim to new owner
|
|
391
|
+
const previousClaimant = claim.claimant;
|
|
392
|
+
claim.claimant = claimant;
|
|
393
|
+
claim.status = 'active';
|
|
394
|
+
claim.lastActivityAt = new Date();
|
|
395
|
+
|
|
396
|
+
await this.claimRepository.save(claim);
|
|
397
|
+
|
|
398
|
+
// Emit events
|
|
399
|
+
const acceptEvent = createHandoffAcceptedEvent(
|
|
400
|
+
claim.id,
|
|
401
|
+
issueId,
|
|
402
|
+
pendingHandoff.id,
|
|
403
|
+
previousClaimant,
|
|
404
|
+
claimant
|
|
405
|
+
);
|
|
406
|
+
await this.eventStore.append(acceptEvent);
|
|
407
|
+
|
|
408
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
409
|
+
claim.id,
|
|
410
|
+
issueId,
|
|
411
|
+
'pending_handoff',
|
|
412
|
+
'active'
|
|
413
|
+
);
|
|
414
|
+
await this.eventStore.append(statusEvent);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async rejectHandoff(issueId: string, claimant: Claimant, reason: string): Promise<void> {
|
|
418
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
419
|
+
|
|
420
|
+
// Validate claim exists
|
|
421
|
+
if (!claim) {
|
|
422
|
+
throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Find pending handoff for this claimant
|
|
426
|
+
const pendingHandoff = claim.handoffChain?.find(
|
|
427
|
+
(h) => h.status === 'pending' && h.to.id === claimant.id
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (!pendingHandoff) {
|
|
431
|
+
throw new ClaimOperationError(
|
|
432
|
+
'HANDOFF_NOT_FOUND',
|
|
433
|
+
`No pending handoff found for claimant ${claimant.name}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Update handoff record
|
|
438
|
+
pendingHandoff.status = 'rejected';
|
|
439
|
+
pendingHandoff.resolvedAt = new Date();
|
|
440
|
+
pendingHandoff.rejectionReason = reason;
|
|
441
|
+
|
|
442
|
+
// Revert claim status to active
|
|
443
|
+
claim.status = 'active';
|
|
444
|
+
claim.lastActivityAt = new Date();
|
|
445
|
+
|
|
446
|
+
await this.claimRepository.save(claim);
|
|
447
|
+
|
|
448
|
+
// Emit events
|
|
449
|
+
const rejectEvent = createHandoffRejectedEvent(
|
|
450
|
+
claim.id,
|
|
451
|
+
issueId,
|
|
452
|
+
pendingHandoff.id,
|
|
453
|
+
pendingHandoff.from,
|
|
454
|
+
claimant,
|
|
455
|
+
reason
|
|
456
|
+
);
|
|
457
|
+
await this.eventStore.append(rejectEvent);
|
|
458
|
+
|
|
459
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
460
|
+
claim.id,
|
|
461
|
+
issueId,
|
|
462
|
+
'pending_handoff',
|
|
463
|
+
'active'
|
|
464
|
+
);
|
|
465
|
+
await this.eventStore.append(statusEvent);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ==========================================================================
|
|
469
|
+
// Status
|
|
470
|
+
// ==========================================================================
|
|
471
|
+
|
|
472
|
+
async updateStatus(issueId: string, status: ClaimStatus, note?: string): Promise<void> {
|
|
473
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
474
|
+
|
|
475
|
+
// Validate claim exists
|
|
476
|
+
if (!claim) {
|
|
477
|
+
throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Validate status transition
|
|
481
|
+
const validTransitions = this.getValidStatusTransitions(claim.status);
|
|
482
|
+
if (!validTransitions.includes(status)) {
|
|
483
|
+
throw new ClaimOperationError(
|
|
484
|
+
'INVALID_STATUS_TRANSITION',
|
|
485
|
+
`Cannot transition from ${claim.status} to ${status}`,
|
|
486
|
+
{ currentStatus: claim.status, requestedStatus: status, validTransitions }
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const previousStatus = claim.status;
|
|
491
|
+
claim.status = status;
|
|
492
|
+
claim.lastActivityAt = new Date();
|
|
493
|
+
|
|
494
|
+
// Add note if provided
|
|
495
|
+
if (note) {
|
|
496
|
+
claim.notes = claim.notes ?? [];
|
|
497
|
+
claim.notes.push(`[${new Date().toISOString()}] Status changed to ${status}: ${note}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await this.claimRepository.save(claim);
|
|
501
|
+
|
|
502
|
+
// Emit event
|
|
503
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
504
|
+
claim.id,
|
|
505
|
+
issueId,
|
|
506
|
+
previousStatus,
|
|
507
|
+
status,
|
|
508
|
+
note
|
|
509
|
+
);
|
|
510
|
+
await this.eventStore.append(statusEvent);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async requestReview(issueId: string, reviewers: Claimant[]): Promise<void> {
|
|
514
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
515
|
+
|
|
516
|
+
// Validate claim exists
|
|
517
|
+
if (!claim) {
|
|
518
|
+
throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Validate at least one reviewer
|
|
522
|
+
if (!reviewers || reviewers.length === 0) {
|
|
523
|
+
throw new ClaimOperationError('VALIDATION_ERROR', 'At least one reviewer is required');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Validate reviewers exist
|
|
527
|
+
for (const reviewer of reviewers) {
|
|
528
|
+
const exists = await this.claimantRepository.exists(reviewer.id);
|
|
529
|
+
if (!exists) {
|
|
530
|
+
throw new ClaimOperationError(
|
|
531
|
+
'CLAIMANT_NOT_FOUND',
|
|
532
|
+
`Reviewer ${reviewer.name} not found`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Update claim
|
|
538
|
+
const previousStatus = claim.status;
|
|
539
|
+
claim.reviewers = reviewers;
|
|
540
|
+
claim.status = 'in_review';
|
|
541
|
+
claim.lastActivityAt = new Date();
|
|
542
|
+
|
|
543
|
+
await this.claimRepository.save(claim);
|
|
544
|
+
|
|
545
|
+
// Emit events
|
|
546
|
+
const reviewEvent = createReviewRequestedEvent(
|
|
547
|
+
claim.id,
|
|
548
|
+
issueId,
|
|
549
|
+
reviewers,
|
|
550
|
+
claim.claimant
|
|
551
|
+
);
|
|
552
|
+
await this.eventStore.append(reviewEvent);
|
|
553
|
+
|
|
554
|
+
if (previousStatus !== 'in_review') {
|
|
555
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
556
|
+
claim.id,
|
|
557
|
+
issueId,
|
|
558
|
+
previousStatus,
|
|
559
|
+
'in_review'
|
|
560
|
+
);
|
|
561
|
+
await this.eventStore.append(statusEvent);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ==========================================================================
|
|
566
|
+
// Queries
|
|
567
|
+
// ==========================================================================
|
|
568
|
+
|
|
569
|
+
async getClaimedBy(claimant: Claimant): Promise<IssueClaim[]> {
|
|
570
|
+
return this.claimRepository.findByClaimant(claimant);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async getAvailableIssues(filters?: IssueFilters): Promise<Issue[]> {
|
|
574
|
+
return this.issueRepository.findAvailable(filters);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async getIssueStatus(issueId: string): Promise<IssueWithClaim> {
|
|
578
|
+
const issue = await this.issueRepository.findById(issueId);
|
|
579
|
+
if (!issue) {
|
|
580
|
+
throw new ClaimOperationError('ISSUE_NOT_FOUND', `Issue ${issueId} not found`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const claim = await this.claimRepository.findByIssueId(issueId);
|
|
584
|
+
const pendingHandoffs = claim?.handoffChain?.filter((h) => h.status === 'pending') ?? [];
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
issue,
|
|
588
|
+
claim,
|
|
589
|
+
pendingHandoffs,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ==========================================================================
|
|
594
|
+
// Auto-management
|
|
595
|
+
// ==========================================================================
|
|
596
|
+
|
|
597
|
+
async expireStale(maxAge: Duration): Promise<IssueClaim[]> {
|
|
598
|
+
const maxAgeMs = durationToMs(maxAge);
|
|
599
|
+
const staleSince = new Date(Date.now() - maxAgeMs);
|
|
600
|
+
|
|
601
|
+
const staleClaims = await this.claimRepository.findStaleClaims(staleSince);
|
|
602
|
+
const expiredClaims: IssueClaim[] = [];
|
|
603
|
+
|
|
604
|
+
for (const claim of staleClaims) {
|
|
605
|
+
// Only expire active claims
|
|
606
|
+
if (claim.status !== 'active') {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const previousStatus = claim.status;
|
|
611
|
+
claim.status = 'expired';
|
|
612
|
+
|
|
613
|
+
await this.claimRepository.save(claim);
|
|
614
|
+
|
|
615
|
+
// Emit events
|
|
616
|
+
const expireEvent = createClaimExpiredEvent(
|
|
617
|
+
claim.id,
|
|
618
|
+
claim.issueId,
|
|
619
|
+
claim.claimant,
|
|
620
|
+
claim.lastActivityAt.getTime()
|
|
621
|
+
);
|
|
622
|
+
await this.eventStore.append(expireEvent);
|
|
623
|
+
|
|
624
|
+
const statusEvent = createClaimStatusChangedEvent(
|
|
625
|
+
claim.id,
|
|
626
|
+
claim.issueId,
|
|
627
|
+
previousStatus,
|
|
628
|
+
'expired'
|
|
629
|
+
);
|
|
630
|
+
await this.eventStore.append(statusEvent);
|
|
631
|
+
|
|
632
|
+
expiredClaims.push(claim);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return expiredClaims;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async autoAssign(issue: Issue): Promise<Claimant | null> {
|
|
639
|
+
// Get available claimants
|
|
640
|
+
const availableClaimants = await this.claimantRepository.findAvailable();
|
|
641
|
+
|
|
642
|
+
if (availableClaimants.length === 0) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Score claimants based on capability match and workload
|
|
647
|
+
const scoredClaimants = availableClaimants.map((claimant) => {
|
|
648
|
+
let score = 0;
|
|
649
|
+
|
|
650
|
+
// Capability matching
|
|
651
|
+
if (issue.requiredCapabilities && issue.requiredCapabilities.length > 0) {
|
|
652
|
+
const claimantCapabilities = claimant.capabilities ?? [];
|
|
653
|
+
const matchedCapabilities = issue.requiredCapabilities.filter((cap) =>
|
|
654
|
+
claimantCapabilities.includes(cap)
|
|
655
|
+
);
|
|
656
|
+
score += matchedCapabilities.length * 10;
|
|
657
|
+
|
|
658
|
+
// Bonus for having all required capabilities
|
|
659
|
+
if (matchedCapabilities.length === issue.requiredCapabilities.length) {
|
|
660
|
+
score += 20;
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
// No specific capabilities required, all claimants are equal
|
|
664
|
+
score += 10;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Specialization matching (labels to specializations)
|
|
668
|
+
if (claimant.specializations && issue.labels) {
|
|
669
|
+
const matchedSpecializations = issue.labels.filter(
|
|
670
|
+
(label) => claimant.specializations?.includes(label)
|
|
671
|
+
);
|
|
672
|
+
score += matchedSpecializations.length * 5;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Lower workload is better
|
|
676
|
+
const workload = claimant.currentWorkload ?? 0;
|
|
677
|
+
const maxClaims = claimant.maxConcurrentClaims ?? 5;
|
|
678
|
+
const utilizationPenalty = (workload / maxClaims) * 15;
|
|
679
|
+
score -= utilizationPenalty;
|
|
680
|
+
|
|
681
|
+
// Prefer agents for agent-suitable tasks
|
|
682
|
+
if (claimant.type === 'agent' && issue.complexity !== 'epic') {
|
|
683
|
+
score += 3;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return { claimant, score };
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Sort by score (descending)
|
|
690
|
+
scoredClaimants.sort((a, b) => b.score - a.score);
|
|
691
|
+
|
|
692
|
+
// Return the best match
|
|
693
|
+
const bestMatch = scoredClaimants[0];
|
|
694
|
+
|
|
695
|
+
// Only return if the claimant has required capabilities
|
|
696
|
+
if (issue.requiredCapabilities && issue.requiredCapabilities.length > 0) {
|
|
697
|
+
const claimantCapabilities = bestMatch.claimant.capabilities ?? [];
|
|
698
|
+
const hasAllRequired = issue.requiredCapabilities.every((cap) =>
|
|
699
|
+
claimantCapabilities.includes(cap)
|
|
700
|
+
);
|
|
701
|
+
if (!hasAllRequired) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return bestMatch.claimant;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ==========================================================================
|
|
710
|
+
// Private Helpers
|
|
711
|
+
// ==========================================================================
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Get valid status transitions from a given status
|
|
715
|
+
*/
|
|
716
|
+
private getValidStatusTransitions(currentStatus: ClaimStatus): ClaimStatus[] {
|
|
717
|
+
const transitions: Record<ClaimStatus, ClaimStatus[]> = {
|
|
718
|
+
active: ['pending_handoff', 'in_review', 'completed', 'released', 'paused', 'blocked', 'stealable'],
|
|
719
|
+
pending_handoff: ['active', 'released'],
|
|
720
|
+
in_review: ['active', 'completed', 'released'],
|
|
721
|
+
completed: [], // Terminal state
|
|
722
|
+
released: [], // Terminal state
|
|
723
|
+
expired: [], // Terminal state
|
|
724
|
+
paused: ['active', 'blocked', 'stealable', 'completed'],
|
|
725
|
+
blocked: ['active', 'paused', 'stealable', 'completed'],
|
|
726
|
+
stealable: ['active', 'completed'],
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
return transitions[currentStatus] ?? [];
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ==========================================================================
|
|
733
|
+
// Lifecycle
|
|
734
|
+
// ==========================================================================
|
|
735
|
+
|
|
736
|
+
async initialize(): Promise<void> {
|
|
737
|
+
await Promise.all([
|
|
738
|
+
this.claimRepository.initialize(),
|
|
739
|
+
this.issueRepository.initialize(),
|
|
740
|
+
this.claimantRepository.initialize(),
|
|
741
|
+
this.eventStore.initialize(),
|
|
742
|
+
]);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async shutdown(): Promise<void> {
|
|
746
|
+
await Promise.all([
|
|
747
|
+
this.claimRepository.shutdown(),
|
|
748
|
+
this.issueRepository.shutdown(),
|
|
749
|
+
this.claimantRepository.shutdown(),
|
|
750
|
+
this.eventStore.shutdown(),
|
|
751
|
+
]);
|
|
752
|
+
}
|
|
753
|
+
}
|