@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.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * @sparkleideas/claims - Business Rules (ADR-016)
3
+ * Domain rules for claiming, stealing eligibility, and load balancing
4
+ *
5
+ * Pure functions that encode the business logic for the claiming system
6
+ */
7
+
8
+ import type {
9
+ IssueClaim,
10
+ Claimant,
11
+ ClaimStatus,
12
+ IssuePriority,
13
+ WorkStealingConfig,
14
+ IssueClaimWithStealing,
15
+ StealableInfo,
16
+ StealableReason,
17
+ ExtendedClaimStatus,
18
+ ExtendedIssueClaim,
19
+ AgentLoadInfo,
20
+ LoadBalancingConfig,
21
+ StealReason,
22
+ DEFAULT_LOAD_BALANCING_CONFIG,
23
+ } from './types.js';
24
+
25
+ // =============================================================================
26
+ // Result Types
27
+ // =============================================================================
28
+
29
+ export interface RuleResult<T = boolean> {
30
+ success: boolean;
31
+ data?: T;
32
+ error?: {
33
+ code: string;
34
+ message: string;
35
+ details?: Record<string, unknown>;
36
+ };
37
+ }
38
+
39
+ export function ruleSuccess<T>(data: T): RuleResult<T> {
40
+ return { success: true, data };
41
+ }
42
+
43
+ export function ruleFailure(code: string, message: string, details?: Record<string, unknown>): RuleResult<never> {
44
+ return { success: false, error: { code, message, details } };
45
+ }
46
+
47
+ // =============================================================================
48
+ // Claim Eligibility Rules
49
+ // =============================================================================
50
+
51
+ /**
52
+ * Check if a claimant can claim a new issue
53
+ */
54
+ export function canClaimIssue(
55
+ claimant: Claimant,
56
+ existingClaims: readonly IssueClaim[],
57
+ ): RuleResult<boolean> {
58
+ // Check capacity
59
+ const maxClaims = claimant.maxConcurrentClaims ?? 5;
60
+ const activeClaims = existingClaims.filter(
61
+ (c) => c.claimant.id === claimant.id && isActiveClaim(c.status),
62
+ ).length;
63
+
64
+ if (activeClaims >= maxClaims) {
65
+ return ruleFailure(
66
+ 'CLAIMANT_AT_CAPACITY',
67
+ `Claimant has reached maximum concurrent claims (${maxClaims})`,
68
+ { currentClaims: activeClaims, maxClaims },
69
+ );
70
+ }
71
+
72
+ // Check workload if available
73
+ const workload = claimant.currentWorkload ?? 0;
74
+ if (workload >= 100) {
75
+ return ruleFailure(
76
+ 'CLAIMANT_AT_CAPACITY',
77
+ 'Claimant is at 100% capacity',
78
+ { workload },
79
+ );
80
+ }
81
+
82
+ return ruleSuccess(true);
83
+ }
84
+
85
+ /**
86
+ * Check if an issue is already claimed
87
+ */
88
+ export function isIssueClaimed(
89
+ issueId: string,
90
+ claims: readonly IssueClaim[],
91
+ ): IssueClaim | null {
92
+ return claims.find(
93
+ (c) => c.issueId === issueId && isActiveClaim(c.status),
94
+ ) ?? null;
95
+ }
96
+
97
+ /**
98
+ * Determine if a claim status is considered "active"
99
+ */
100
+ export function isActiveClaim(status: ClaimStatus | ExtendedClaimStatus): boolean {
101
+ return [
102
+ 'active',
103
+ 'paused',
104
+ 'blocked',
105
+ 'pending_handoff',
106
+ 'handoff-pending',
107
+ 'in_review',
108
+ 'review-requested',
109
+ ].includes(status);
110
+ }
111
+
112
+ /**
113
+ * Get valid status transitions for original ClaimStatus
114
+ */
115
+ export function getOriginalStatusTransitions(currentStatus: ClaimStatus): readonly ClaimStatus[] {
116
+ const transitions: Record<ClaimStatus, readonly ClaimStatus[]> = {
117
+ 'active': ['pending_handoff', 'in_review', 'completed', 'released', 'paused', 'blocked', 'stealable'],
118
+ 'pending_handoff': ['active', 'completed'],
119
+ 'in_review': ['active', 'completed'],
120
+ 'completed': [],
121
+ 'released': [],
122
+ 'expired': [],
123
+ 'paused': ['active', 'blocked', 'stealable', 'completed'],
124
+ 'blocked': ['active', 'paused', 'stealable', 'completed'],
125
+ 'stealable': ['active', 'completed'],
126
+ };
127
+ return transitions[currentStatus] ?? [];
128
+ }
129
+
130
+ /**
131
+ * Get valid status transitions for ExtendedClaimStatus (ADR-016)
132
+ */
133
+ export function getExtendedStatusTransitions(currentStatus: ExtendedClaimStatus): readonly ExtendedClaimStatus[] {
134
+ const transitions: Record<ExtendedClaimStatus, readonly ExtendedClaimStatus[]> = {
135
+ 'active': ['paused', 'blocked', 'handoff-pending', 'review-requested', 'stealable', 'completed'],
136
+ 'paused': ['active', 'blocked', 'handoff-pending', 'stealable', 'completed'],
137
+ 'blocked': ['active', 'paused', 'stealable', 'completed'],
138
+ 'handoff-pending': ['active', 'completed'],
139
+ 'review-requested': ['active', 'completed', 'blocked'],
140
+ 'stealable': ['active', 'completed'],
141
+ 'completed': [],
142
+ };
143
+ return transitions[currentStatus];
144
+ }
145
+
146
+ /**
147
+ * Check if a status transition is valid
148
+ */
149
+ export function canTransitionStatus(
150
+ currentStatus: ClaimStatus | ExtendedClaimStatus,
151
+ newStatus: ClaimStatus | ExtendedClaimStatus,
152
+ ): RuleResult<boolean> {
153
+ if (currentStatus === newStatus) {
154
+ return ruleSuccess(true); // No-op is always valid
155
+ }
156
+
157
+ // Try original transitions first
158
+ const originalTransitions = getOriginalStatusTransitions(currentStatus as ClaimStatus);
159
+ if (originalTransitions.includes(newStatus as ClaimStatus)) {
160
+ return ruleSuccess(true);
161
+ }
162
+
163
+ // Try extended transitions
164
+ const extendedTransitions = getExtendedStatusTransitions(currentStatus as ExtendedClaimStatus);
165
+ if (extendedTransitions.includes(newStatus as ExtendedClaimStatus)) {
166
+ return ruleSuccess(true);
167
+ }
168
+
169
+ return ruleFailure(
170
+ 'INVALID_STATUS_TRANSITION',
171
+ `Cannot transition from '${currentStatus}' to '${newStatus}'`,
172
+ { currentStatus, newStatus },
173
+ );
174
+ }
175
+
176
+ // =============================================================================
177
+ // Work Stealing Rules
178
+ // =============================================================================
179
+
180
+ /**
181
+ * Check if a claim is eligible to be marked as stealable
182
+ */
183
+ export function canMarkAsStealable(
184
+ claim: IssueClaimWithStealing,
185
+ config: WorkStealingConfig,
186
+ now: Date = new Date(),
187
+ ): RuleResult<StealableReason | null> {
188
+ // Already stealable or terminal status
189
+ if (claim.status === 'stealable' || claim.status === 'completed' || claim.status === 'released') {
190
+ return ruleSuccess(null);
191
+ }
192
+
193
+ // Check for stale (no activity)
194
+ const lastActivity = claim.lastActivityAt.getTime();
195
+ const staleThreshold = config.staleThresholdMinutes * 60 * 1000;
196
+ if (now.getTime() - lastActivity >= staleThreshold) {
197
+ return ruleSuccess('stale');
198
+ }
199
+
200
+ // Check for blocked too long
201
+ if (claim.blockedAt && claim.blockedReason) {
202
+ const blockedThreshold = config.blockedThresholdMinutes * 60 * 1000;
203
+ if (now.getTime() - claim.blockedAt.getTime() >= blockedThreshold) {
204
+ return ruleSuccess('blocked');
205
+ }
206
+ }
207
+
208
+ return ruleSuccess(null);
209
+ }
210
+
211
+ /**
212
+ * Check if a claim can be stolen by a specific agent
213
+ */
214
+ export function canStealClaim(
215
+ claim: IssueClaimWithStealing,
216
+ challenger: Claimant,
217
+ config: WorkStealingConfig,
218
+ now: Date = new Date(),
219
+ ): RuleResult<boolean> {
220
+ // Check if claim is stealable
221
+ if (claim.status !== 'stealable' && !claim.stealInfo) {
222
+ return ruleFailure('NOT_STEALABLE', `Claim status is '${claim.status}', not stealable`);
223
+ }
224
+
225
+ // Check grace period
226
+ if (claim.stealableAt && now < claim.stealableAt) {
227
+ return ruleFailure(
228
+ 'IN_GRACE_PERIOD',
229
+ `Grace period has not ended. Ends at ${claim.stealableAt.toISOString()}`,
230
+ { stealableAt: claim.stealableAt.getTime() },
231
+ );
232
+ }
233
+
234
+ // Check progress protection
235
+ if (claim.progress >= config.minProgressToProtect) {
236
+ return ruleFailure(
237
+ 'PROTECTED_BY_PROGRESS',
238
+ `Claim is protected due to high progress (${claim.progress}%)`,
239
+ { progress: claim.progress, threshold: config.minProgressToProtect },
240
+ );
241
+ }
242
+
243
+ // Check cross-type stealing rules if applicable
244
+ if (config.allowCrossTypeSteal && claim.stealInfo?.allowedStealerTypes) {
245
+ const challengerType = (challenger as any).agentType;
246
+ if (challengerType && !claim.stealInfo.allowedStealerTypes.includes(challengerType)) {
247
+ return ruleFailure(
248
+ 'CROSS_TYPE_NOT_ALLOWED',
249
+ `Agent type '${challengerType}' cannot steal from this claim`,
250
+ );
251
+ }
252
+ }
253
+
254
+ // Cannot steal own claim
255
+ if (claim.claimant.id === challenger.id) {
256
+ return ruleFailure('UNAUTHORIZED', 'Cannot steal your own claim');
257
+ }
258
+
259
+ // Check if there's a pending contest
260
+ if (claim.contestInfo && !claim.contestInfo.resolution) {
261
+ return ruleFailure('CONTEST_PENDING', 'A steal contest is already in progress');
262
+ }
263
+
264
+ return ruleSuccess(true);
265
+ }
266
+
267
+ /**
268
+ * Determine if a contest is required for stealing
269
+ */
270
+ export function requiresStealContest(
271
+ claim: IssueClaimWithStealing,
272
+ config: WorkStealingConfig,
273
+ ): boolean {
274
+ // No contest for timeout/stale claims
275
+ if (claim.stealInfo?.reason === 'stale' || claim.stealInfo?.reason === 'timeout') {
276
+ return false;
277
+ }
278
+
279
+ // No contest for manual releases
280
+ if (claim.stealInfo?.reason === 'manual') {
281
+ return false;
282
+ }
283
+
284
+ // Contest required based on progress
285
+ return claim.progress > 0;
286
+ }
287
+
288
+ // =============================================================================
289
+ // Handoff Rules
290
+ // =============================================================================
291
+
292
+ /**
293
+ * Check if a handoff can be initiated
294
+ */
295
+ export function canInitiateHandoff(
296
+ claim: IssueClaim,
297
+ targetClaimant: Claimant,
298
+ currentClaimant: Claimant,
299
+ ): RuleResult<boolean> {
300
+ // Cannot handoff completed claims
301
+ if (claim.status === 'completed' || claim.status === 'released') {
302
+ return ruleFailure('INVALID_STATUS', 'Cannot hand off a completed or released claim');
303
+ }
304
+
305
+ // Cannot handoff if already pending
306
+ if (claim.status === 'pending_handoff') {
307
+ return ruleFailure('HANDOFF_PENDING', 'A handoff is already pending for this claim');
308
+ }
309
+
310
+ // Must be the current claimant
311
+ if (claim.claimant.id !== currentClaimant.id) {
312
+ return ruleFailure('UNAUTHORIZED', 'Only the current claimant can initiate a handoff');
313
+ }
314
+
315
+ // Cannot handoff to self
316
+ if (claim.claimant.id === targetClaimant.id) {
317
+ return ruleFailure('VALIDATION_ERROR', 'Cannot hand off to yourself');
318
+ }
319
+
320
+ // Check target capacity
321
+ const targetMaxClaims = targetClaimant.maxConcurrentClaims ?? 5;
322
+ const targetWorkload = targetClaimant.currentWorkload ?? 0;
323
+ if (targetWorkload >= 100) {
324
+ return ruleFailure(
325
+ 'TARGET_AT_CAPACITY',
326
+ 'Target claimant is at full capacity',
327
+ );
328
+ }
329
+
330
+ return ruleSuccess(true);
331
+ }
332
+
333
+ /**
334
+ * Check if a handoff can be accepted
335
+ */
336
+ export function canAcceptHandoff(
337
+ claim: IssueClaim,
338
+ acceptingClaimant: Claimant,
339
+ ): RuleResult<boolean> {
340
+ if (claim.status !== 'pending_handoff') {
341
+ return ruleFailure(
342
+ 'INVALID_STATUS',
343
+ 'Claim is not in pending handoff status',
344
+ );
345
+ }
346
+
347
+ // Find the pending handoff record
348
+ const pendingHandoff = claim.handoffChain?.find(h => h.status === 'pending');
349
+ if (!pendingHandoff) {
350
+ return ruleFailure('HANDOFF_NOT_FOUND', 'No pending handoff found');
351
+ }
352
+
353
+ if (pendingHandoff.to.id !== acceptingClaimant.id) {
354
+ return ruleFailure(
355
+ 'UNAUTHORIZED',
356
+ 'Only the target claimant can accept this handoff',
357
+ );
358
+ }
359
+
360
+ return ruleSuccess(true);
361
+ }
362
+
363
+ /**
364
+ * Check if a handoff can be rejected
365
+ */
366
+ export function canRejectHandoff(
367
+ claim: IssueClaim,
368
+ rejectingClaimant: Claimant,
369
+ ): RuleResult<boolean> {
370
+ if (claim.status !== 'pending_handoff') {
371
+ return ruleFailure(
372
+ 'INVALID_STATUS',
373
+ 'Claim is not in pending handoff status',
374
+ );
375
+ }
376
+
377
+ const pendingHandoff = claim.handoffChain?.find(h => h.status === 'pending');
378
+ if (!pendingHandoff) {
379
+ return ruleFailure('HANDOFF_NOT_FOUND', 'No pending handoff found');
380
+ }
381
+
382
+ // Either the target or the initiator can reject
383
+ const canReject =
384
+ pendingHandoff.to.id === rejectingClaimant.id ||
385
+ pendingHandoff.from.id === rejectingClaimant.id;
386
+
387
+ if (!canReject) {
388
+ return ruleFailure(
389
+ 'UNAUTHORIZED',
390
+ 'Only the target or initiating claimant can reject this handoff',
391
+ );
392
+ }
393
+
394
+ return ruleSuccess(true);
395
+ }
396
+
397
+ // =============================================================================
398
+ // Load Balancing Rules
399
+ // =============================================================================
400
+
401
+ /**
402
+ * Determine if an agent is overloaded
403
+ */
404
+ export function isAgentOverloaded(
405
+ load: number,
406
+ threshold: number = 90,
407
+ ): boolean {
408
+ return load >= threshold;
409
+ }
410
+
411
+ /**
412
+ * Determine if an agent is underloaded
413
+ */
414
+ export function isAgentUnderloaded(
415
+ load: number,
416
+ threshold: number = 30,
417
+ ): boolean {
418
+ return load <= threshold;
419
+ }
420
+
421
+ /**
422
+ * Check if rebalancing is needed for a set of agents
423
+ */
424
+ export function needsRebalancing(
425
+ agentLoads: readonly { load: number }[],
426
+ config: { overloadThreshold: number; underloadThreshold: number; rebalanceThreshold: number },
427
+ ): boolean {
428
+ if (agentLoads.length < 2) {
429
+ return false;
430
+ }
431
+
432
+ // Check for overloaded agents
433
+ const hasOverloaded = agentLoads.some((a) => isAgentOverloaded(a.load, config.overloadThreshold));
434
+ const hasUnderloaded = agentLoads.some((a) => isAgentUnderloaded(a.load, config.underloadThreshold));
435
+
436
+ if (hasOverloaded && hasUnderloaded) {
437
+ return true;
438
+ }
439
+
440
+ // Check for large load differential
441
+ const loads = agentLoads.map((a) => a.load);
442
+ const maxLoad = Math.max(...loads);
443
+ const minLoad = Math.min(...loads);
444
+ const loadDifferential = maxLoad - minLoad;
445
+
446
+ return loadDifferential >= config.rebalanceThreshold;
447
+ }
448
+
449
+ /**
450
+ * Check if a claim can be moved during rebalancing
451
+ */
452
+ export function canMoveClaim(claim: IssueClaimWithStealing): boolean {
453
+ // Cannot move completed claims
454
+ if (claim.status === 'completed' || claim.status === 'released') {
455
+ return false;
456
+ }
457
+
458
+ // Cannot move claims with pending handoffs
459
+ if (claim.status === 'pending_handoff') {
460
+ return false;
461
+ }
462
+
463
+ // Cannot move high-progress claims (>75%)
464
+ if (claim.progress > 75) {
465
+ return false;
466
+ }
467
+
468
+ // Cannot move claims with active reviews
469
+ if (claim.status === 'in_review') {
470
+ return false;
471
+ }
472
+
473
+ // Cannot move contested claims
474
+ if (claim.contestInfo && !claim.contestInfo.resolution) {
475
+ return false;
476
+ }
477
+
478
+ return true;
479
+ }
480
+
481
+ // =============================================================================
482
+ // Validation Rules
483
+ // =============================================================================
484
+
485
+ /**
486
+ * Validate claim priority
487
+ */
488
+ export function isValidPriority(priority: string): priority is IssuePriority {
489
+ return ['critical', 'high', 'medium', 'low'].includes(priority);
490
+ }
491
+
492
+ /**
493
+ * Validate claim status
494
+ */
495
+ export function isValidStatus(status: string): status is ClaimStatus {
496
+ return [
497
+ 'active',
498
+ 'pending_handoff',
499
+ 'in_review',
500
+ 'completed',
501
+ 'released',
502
+ 'expired',
503
+ ].includes(status);
504
+ }
505
+
506
+ /**
507
+ * Validate extended claim status (ADR-016)
508
+ */
509
+ export function isValidExtendedStatus(status: string): status is ExtendedClaimStatus {
510
+ return [
511
+ 'active',
512
+ 'paused',
513
+ 'handoff-pending',
514
+ 'review-requested',
515
+ 'blocked',
516
+ 'stealable',
517
+ 'completed',
518
+ ].includes(status);
519
+ }
520
+
521
+ /**
522
+ * Validate repository format (owner/repo)
523
+ */
524
+ export function isValidRepository(repository: string): boolean {
525
+ return /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(repository);
526
+ }