@innofeight/global-workflow 0.0.1 → 0.0.2

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,673 @@
1
+ const SCHEMA = 'rn-launcher/aet-v2-event/v1'
2
+ const METRIC = 'AET-v2'
3
+
4
+ const EVENT_TYPES = new Set([
5
+ 'AET_V2_LEDGER_DECLARED',
6
+ 'AET_V2_SEGMENT_STARTED',
7
+ 'AET_V2_SEGMENT_ENDED',
8
+ 'AET_V2_ESTIMATE_REVISED',
9
+ 'AET_V2_NON_AET_RECORDED',
10
+ 'AET_V2_MILESTONE_RECORDED',
11
+ 'AET_V2_CORRECTION_RECORDED'
12
+ ])
13
+
14
+ const NON_AET_CLOCKS = new Set([
15
+ 'human-attention',
16
+ 'human-qa',
17
+ 'human-unavailable',
18
+ 'human-wait',
19
+ 'external-system-wait'
20
+ ])
21
+
22
+ const YIELD_CLOCKS = new Set(['human-wait', 'external-system-wait'])
23
+ const EXCLUSIVE_COVERAGE_CLOCKS = YIELD_CLOCKS
24
+
25
+ const COMMON_KEYS = [
26
+ 'eventId',
27
+ 'eventType',
28
+ 'metric',
29
+ 'recordedAt',
30
+ 'schema',
31
+ 'source'
32
+ ]
33
+
34
+ const EVENT_KEYS = {
35
+ AET_V2_CORRECTION_RECORDED: ['reason', 'replacement', 'targetEventId'],
36
+ AET_V2_ESTIMATE_REVISED: [
37
+ 'approvalSource',
38
+ 'reason',
39
+ 'revisedTotalEstimateMinutes',
40
+ 'scopeDeltaMinutes',
41
+ 'taskId',
42
+ 'timestamp'
43
+ ],
44
+ AET_V2_LEDGER_DECLARED: [
45
+ 'calendarStartedAt',
46
+ 'originalEstimateMinutes',
47
+ 'taskId',
48
+ 'taskTitle',
49
+ 'workClass'
50
+ ],
51
+ AET_V2_MILESTONE_RECORDED: ['evidence', 'milestone', 'taskId', 'timestamp'],
52
+ AET_V2_NON_AET_RECORDED: [
53
+ 'clock',
54
+ 'endedAt',
55
+ 'evidence',
56
+ 'startedAt',
57
+ 'taskId'
58
+ ],
59
+ AET_V2_SEGMENT_ENDED: [
60
+ 'endReason',
61
+ 'executionId',
62
+ 'startEventId',
63
+ 'taskId',
64
+ 'timestamp'
65
+ ],
66
+ AET_V2_SEGMENT_STARTED: [
67
+ 'category',
68
+ 'executionId',
69
+ 'reason',
70
+ 'taskId',
71
+ 'timestamp'
72
+ ]
73
+ }
74
+
75
+ function fail(code, detail) {
76
+ throw new Error(`${code}: ${detail}`)
77
+ }
78
+
79
+ function record(value, label) {
80
+ if (!value || typeof value !== 'object' || Array.isArray(value))
81
+ fail('aet-v2.invalid-record', `${label} must be an object`)
82
+ return value
83
+ }
84
+
85
+ function text(value, label) {
86
+ if (typeof value !== 'string' || !value.trim())
87
+ fail('aet-v2.invalid-field', `${label} must be a non-empty string`)
88
+ return value
89
+ }
90
+
91
+ function instant(value, label) {
92
+ const source = text(value, label)
93
+ const milliseconds = Date.parse(source)
94
+ if (
95
+ !Number.isFinite(milliseconds) ||
96
+ new Date(milliseconds).toISOString() !== source
97
+ )
98
+ fail('aet-v2.invalid-timestamp', `${label} must be canonical UTC ISO-8601`)
99
+ return milliseconds
100
+ }
101
+
102
+ function positiveNumber(value, label) {
103
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
104
+ fail('aet-v2.invalid-field', `${label} must be a positive number`)
105
+ return value
106
+ }
107
+
108
+ function common(event) {
109
+ record(event, 'event')
110
+ if (event.schema !== SCHEMA)
111
+ fail('aet-v2.unsupported-schema', String(event.schema ?? 'missing'))
112
+ if (event.metric !== METRIC)
113
+ fail('aet-v2.metric-regime-mismatch', String(event.metric ?? 'missing'))
114
+ text(event.eventId, 'eventId')
115
+ if (!EVENT_TYPES.has(event.eventType))
116
+ fail('aet-v2.unsupported-event', String(event.eventType ?? 'missing'))
117
+ const allowed = new Set([...COMMON_KEYS, ...EVENT_KEYS[event.eventType]])
118
+ const unknown = Object.keys(event).find((key) => !allowed.has(key))
119
+ if (unknown) fail('aet-v2.unknown-field', `${event.eventType}.${unknown}`)
120
+ instant(event.recordedAt, 'recordedAt')
121
+ text(event.source, 'source')
122
+ }
123
+
124
+ export function validateAetV2Event(event) {
125
+ common(event)
126
+ switch (event.eventType) {
127
+ case 'AET_V2_LEDGER_DECLARED':
128
+ text(event.taskId, 'taskId')
129
+ text(event.taskTitle, 'taskTitle')
130
+ text(event.workClass, 'workClass')
131
+ positiveNumber(event.originalEstimateMinutes, 'originalEstimateMinutes')
132
+ instant(event.calendarStartedAt, 'calendarStartedAt')
133
+ break
134
+ case 'AET_V2_SEGMENT_STARTED':
135
+ text(event.taskId, 'taskId')
136
+ text(event.executionId, 'executionId')
137
+ instant(event.timestamp, 'timestamp')
138
+ text(event.category, 'category')
139
+ text(event.reason, 'reason')
140
+ break
141
+ case 'AET_V2_SEGMENT_ENDED':
142
+ text(event.taskId, 'taskId')
143
+ text(event.executionId, 'executionId')
144
+ instant(event.timestamp, 'timestamp')
145
+ text(event.startEventId, 'startEventId')
146
+ text(event.endReason, 'endReason')
147
+ break
148
+ case 'AET_V2_ESTIMATE_REVISED':
149
+ text(event.taskId, 'taskId')
150
+ instant(event.timestamp, 'timestamp')
151
+ positiveNumber(
152
+ event.revisedTotalEstimateMinutes,
153
+ 'revisedTotalEstimateMinutes'
154
+ )
155
+ if (
156
+ typeof event.scopeDeltaMinutes !== 'number' ||
157
+ !Number.isFinite(event.scopeDeltaMinutes)
158
+ )
159
+ fail(
160
+ 'aet-v2.invalid-field',
161
+ 'scopeDeltaMinutes must be a finite number'
162
+ )
163
+ text(event.reason, 'reason')
164
+ text(event.approvalSource, 'approvalSource')
165
+ break
166
+ case 'AET_V2_NON_AET_RECORDED':
167
+ text(event.taskId, 'taskId')
168
+ if (!NON_AET_CLOCKS.has(event.clock))
169
+ fail('aet-v2.invalid-clock', String(event.clock ?? 'missing'))
170
+ instant(event.startedAt, 'startedAt')
171
+ instant(event.endedAt, 'endedAt')
172
+ if (Date.parse(event.endedAt) <= Date.parse(event.startedAt))
173
+ fail('aet-v2.invalid-interval', event.eventId)
174
+ text(event.evidence, 'evidence')
175
+ break
176
+ case 'AET_V2_MILESTONE_RECORDED':
177
+ text(event.taskId, 'taskId')
178
+ if (!['first-pr-ready', 'merged', 'complete'].includes(event.milestone))
179
+ fail('aet-v2.invalid-milestone', String(event.milestone ?? 'missing'))
180
+ instant(event.timestamp, 'timestamp')
181
+ text(event.evidence, 'evidence')
182
+ break
183
+ case 'AET_V2_CORRECTION_RECORDED': {
184
+ text(event.targetEventId, 'targetEventId')
185
+ text(event.reason, 'reason')
186
+ const replacement = record(event.replacement, 'replacement')
187
+ common(replacement)
188
+ if (replacement.eventId !== event.targetEventId)
189
+ fail(
190
+ 'aet-v2.invalid-correction',
191
+ 'replacement must retain target eventId'
192
+ )
193
+ if (replacement.eventType === 'AET_V2_CORRECTION_RECORDED')
194
+ fail(
195
+ 'aet-v2.invalid-correction',
196
+ 'correction events cannot replace corrections'
197
+ )
198
+ validateAetV2Event(replacement)
199
+ break
200
+ }
201
+ }
202
+ return event
203
+ }
204
+
205
+ export function parseAetV2JsonLines(contents) {
206
+ if (typeof contents !== 'string')
207
+ fail('aet-v2.invalid-ledger', 'ledger must be text')
208
+ const events = contents
209
+ .split(/\r?\n/u)
210
+ .map((line) => line.trim())
211
+ .filter(Boolean)
212
+ .map((line, index) => {
213
+ try {
214
+ return JSON.parse(line)
215
+ } catch {
216
+ fail('aet-v2.invalid-json', `line ${index + 1}`)
217
+ }
218
+ })
219
+ if (!events.length) fail('aet-v2.empty-ledger', 'no events')
220
+ return events
221
+ }
222
+
223
+ function correctedEvents(events) {
224
+ const semantic = new Map()
225
+ const sourceEventIds = new Set()
226
+ const order = []
227
+ const corrected = new Set()
228
+ const correctionTaskIds = []
229
+ for (const event of events) {
230
+ validateAetV2Event(event)
231
+ if (sourceEventIds.has(event.eventId))
232
+ fail('aet-v2.duplicate-event-id', event.eventId)
233
+ sourceEventIds.add(event.eventId)
234
+ if (event.eventType === 'AET_V2_CORRECTION_RECORDED') {
235
+ if (!semantic.has(event.targetEventId))
236
+ fail('aet-v2.correction-target-missing', event.targetEventId)
237
+ const target = semantic.get(event.targetEventId)
238
+ if (target.eventType === 'AET_V2_LEDGER_DECLARED')
239
+ fail('aet-v2.immutable-original-estimate', event.targetEventId)
240
+ if (event.replacement.eventType !== target.eventType)
241
+ fail(
242
+ 'aet-v2.correction-type-mismatch',
243
+ `${event.targetEventId} must remain ${target.eventType}`
244
+ )
245
+ if (event.replacement.taskId !== target.taskId)
246
+ fail(
247
+ 'aet-v2.correction-task-mismatch',
248
+ `${event.targetEventId} must remain on ${target.taskId}`
249
+ )
250
+ if (corrected.has(event.targetEventId))
251
+ fail('aet-v2.correction-duplicate', event.targetEventId)
252
+ semantic.set(event.targetEventId, event.replacement)
253
+ corrected.add(event.targetEventId)
254
+ correctionTaskIds.push(event.replacement.taskId)
255
+ continue
256
+ }
257
+ semantic.set(event.eventId, event)
258
+ order.push(event.eventId)
259
+ }
260
+ return {
261
+ events: order.map((id) => semantic.get(id)),
262
+ correctionTaskIds,
263
+ corrections: corrected.size
264
+ }
265
+ }
266
+
267
+ function overlaps(left, right) {
268
+ return left.start < right.end && right.start < left.end
269
+ }
270
+
271
+ export function analyzeAetV2Ledger(input, options = {}) {
272
+ const sourceEvents = Array.isArray(input) ? input : parseAetV2JsonLines(input)
273
+ const corrected = correctedEvents(sourceEvents)
274
+ const declarations = new Map()
275
+ const starts = new Map()
276
+ const ends = new Map()
277
+ const nonAet = []
278
+ const milestones = []
279
+ const estimateRevisions = []
280
+
281
+ for (const event of corrected.events) {
282
+ if (event.eventType === 'AET_V2_LEDGER_DECLARED') {
283
+ if (declarations.has(event.taskId))
284
+ fail('aet-v2.duplicate-declaration', event.taskId)
285
+ declarations.set(event.taskId, event)
286
+ } else if (event.eventType === 'AET_V2_SEGMENT_STARTED') {
287
+ starts.set(event.eventId, event)
288
+ } else if (event.eventType === 'AET_V2_SEGMENT_ENDED') {
289
+ if (ends.has(event.startEventId))
290
+ fail('aet-v2.duplicate-end', event.startEventId)
291
+ ends.set(event.startEventId, event)
292
+ } else if (event.eventType === 'AET_V2_ESTIMATE_REVISED') {
293
+ estimateRevisions.push(event)
294
+ } else if (event.eventType === 'AET_V2_NON_AET_RECORDED') nonAet.push(event)
295
+ else if (event.eventType === 'AET_V2_MILESTONE_RECORDED')
296
+ milestones.push(event)
297
+ }
298
+
299
+ if (!declarations.size)
300
+ fail('aet-v2.missing-declaration', 'no task ledger declaration')
301
+ const intervals = []
302
+ for (const [startId, end] of ends) {
303
+ const start = starts.get(startId)
304
+ if (!start) fail('aet-v2.missing-start', startId)
305
+ if (start.taskId !== end.taskId || start.executionId !== end.executionId)
306
+ fail('aet-v2.segment-identity-mismatch', startId)
307
+ const startMs = instant(start.timestamp, 'timestamp')
308
+ const endMs = instant(end.timestamp, 'timestamp')
309
+ if (endMs <= startMs) fail('aet-v2.invalid-interval', startId)
310
+ intervals.push({
311
+ category: start.category,
312
+ end: endMs,
313
+ executionId: start.executionId,
314
+ start: startMs,
315
+ startEventId: startId,
316
+ taskId: start.taskId
317
+ })
318
+ }
319
+ const openStarts = [...starts.entries()].filter(
320
+ ([startId]) => !ends.has(startId)
321
+ )
322
+ if (!options.allowIncomplete && openStarts.length)
323
+ fail('aet-v2.missing-end', openStarts[0][0])
324
+ if (options.allowIncomplete && openStarts.length > 1)
325
+ fail(
326
+ 'aet-v2.contradictory-open-segments',
327
+ openStarts.map(([startId]) => startId).join(',')
328
+ )
329
+
330
+ intervals.sort(
331
+ (left, right) => left.start - right.start || left.end - right.end
332
+ )
333
+ for (let index = 1; index < intervals.length; index += 1)
334
+ if (overlaps(intervals[index - 1], intervals[index]))
335
+ fail(
336
+ 'aet-v2.overlapping-agent-segments',
337
+ `${intervals[index - 1].startEventId} overlaps ${intervals[index].startEventId}`
338
+ )
339
+
340
+ for (const wait of nonAet) {
341
+ if (!EXCLUSIVE_COVERAGE_CLOCKS.has(wait.clock)) continue
342
+ const waitInterval = {
343
+ start: Date.parse(wait.startedAt),
344
+ end: Date.parse(wait.endedAt)
345
+ }
346
+ const conflict = intervals.find(
347
+ (interval) =>
348
+ interval.taskId === wait.taskId && overlaps(interval, waitInterval)
349
+ )
350
+ if (conflict)
351
+ fail(
352
+ 'aet-v2.yield-overlaps-agent-segment',
353
+ `${wait.eventId} overlaps ${conflict.startEventId}`
354
+ )
355
+ }
356
+
357
+ const exclusiveCoverage = nonAet
358
+ .filter((event) => EXCLUSIVE_COVERAGE_CLOCKS.has(event.clock))
359
+ .map((event) => ({
360
+ end: Date.parse(event.endedAt),
361
+ eventId: event.eventId,
362
+ start: Date.parse(event.startedAt),
363
+ taskId: event.taskId
364
+ }))
365
+ .sort((left, right) => left.start - right.start || left.end - right.end)
366
+ for (const taskId of new Set(exclusiveCoverage.map((item) => item.taskId))) {
367
+ const taskCoverage = exclusiveCoverage.filter(
368
+ (item) => item.taskId === taskId
369
+ )
370
+ for (let index = 1; index < taskCoverage.length; index += 1) {
371
+ const previous = taskCoverage[index - 1]
372
+ const current = taskCoverage[index]
373
+ if (overlaps(previous, current))
374
+ fail(
375
+ 'aet-v2.overlapping-exclusive-coverage',
376
+ `${previous.eventId} overlaps ${current.eventId}`
377
+ )
378
+ }
379
+ }
380
+
381
+ for (const taskId of new Set(nonAet.map((item) => item.taskId))) {
382
+ const taskClocks = nonAet.filter((item) => item.taskId === taskId)
383
+ for (const clock of new Set(taskClocks.map((item) => item.clock))) {
384
+ const clockIntervals = taskClocks
385
+ .filter((item) => item.clock === clock)
386
+ .map((item) => ({
387
+ end: Date.parse(item.endedAt),
388
+ eventId: item.eventId,
389
+ start: Date.parse(item.startedAt)
390
+ }))
391
+ .sort((left, right) => left.start - right.start || left.end - right.end)
392
+ for (let index = 1; index < clockIntervals.length; index += 1)
393
+ if (overlaps(clockIntervals[index - 1], clockIntervals[index]))
394
+ fail(
395
+ 'aet-v2.overlapping-clock-intervals',
396
+ `${clockIntervals[index - 1].eventId} overlaps ${clockIntervals[index].eventId}`
397
+ )
398
+ }
399
+ }
400
+
401
+ const tasks = {}
402
+ for (const [taskId, declaration] of declarations) {
403
+ const taskIntervals = intervals.filter(
404
+ (interval) => interval.taskId === taskId
405
+ )
406
+ const categoryMilliseconds = {}
407
+ for (const interval of taskIntervals)
408
+ categoryMilliseconds[interval.category] =
409
+ (categoryMilliseconds[interval.category] ?? 0) +
410
+ interval.end -
411
+ interval.start
412
+ const aetMilliseconds = taskIntervals.reduce(
413
+ (total, interval) => total + interval.end - interval.start,
414
+ 0
415
+ )
416
+ const clockMilliseconds = {}
417
+ for (const item of nonAet.filter((event) => event.taskId === taskId))
418
+ clockMilliseconds[item.clock] =
419
+ (clockMilliseconds[item.clock] ?? 0) +
420
+ Date.parse(item.endedAt) -
421
+ Date.parse(item.startedAt)
422
+ const taskMilestones = milestones.filter((event) => event.taskId === taskId)
423
+ const taskEstimateRevisions = estimateRevisions
424
+ .filter((event) => event.taskId === taskId)
425
+ .sort(
426
+ (left, right) =>
427
+ Date.parse(left.timestamp) - Date.parse(right.timestamp) ||
428
+ left.eventId.localeCompare(right.eventId)
429
+ )
430
+ const latestEvidenceAt = Math.max(
431
+ Date.parse(declaration.calendarStartedAt),
432
+ ...taskIntervals.map((interval) => interval.end),
433
+ ...nonAet
434
+ .filter((event) => event.taskId === taskId)
435
+ .map((event) => Date.parse(event.endedAt)),
436
+ ...taskMilestones.map((event) => Date.parse(event.timestamp)),
437
+ ...taskEstimateRevisions.map((event) => Date.parse(event.timestamp))
438
+ )
439
+ const milestonesNamed = (name) =>
440
+ taskMilestones
441
+ .filter((event) => event.milestone === name)
442
+ .sort(
443
+ (left, right) =>
444
+ Date.parse(left.timestamp) - Date.parse(right.timestamp) ||
445
+ left.eventId.localeCompare(right.eventId)
446
+ )
447
+ const merges = milestonesNamed('merged')
448
+ const completions = milestonesNamed('complete')
449
+ const firstPrReadies = milestonesNamed('first-pr-ready')
450
+ const merged = merges.at(-1)
451
+ const complete = completions.at(-1)
452
+ if (options.postMerge && (!merged || !complete))
453
+ fail(
454
+ 'aet-v2.post-merge-incomplete',
455
+ `${taskId} requires merged and complete milestones`
456
+ )
457
+ if (complete && !merged)
458
+ fail('aet-v2.invalid-milestone-order', `${taskId} complete without merge`)
459
+ if (
460
+ complete &&
461
+ merged &&
462
+ Date.parse(complete.timestamp) < Date.parse(merged.timestamp)
463
+ )
464
+ fail(
465
+ 'aet-v2.invalid-milestone-order',
466
+ `${taskId} completion precedes latest merge`
467
+ )
468
+ const firstPrReady = firstPrReadies[0]
469
+ if (
470
+ firstPrReadies.some(
471
+ (event) =>
472
+ merged &&
473
+ Date.parse(event.timestamp) > Date.parse(merges[0].timestamp)
474
+ )
475
+ )
476
+ fail(
477
+ 'aet-v2.invalid-milestone-order',
478
+ `${taskId} first PR-ready follows merge`
479
+ )
480
+ const latestEstimateRevision = taskEstimateRevisions.at(-1)
481
+ let approvedEstimateMinutes = declaration.originalEstimateMinutes
482
+ for (const revision of taskEstimateRevisions) {
483
+ if (
484
+ Math.abs(
485
+ approvedEstimateMinutes +
486
+ revision.scopeDeltaMinutes -
487
+ revision.revisedTotalEstimateMinutes
488
+ ) > Number.EPSILON
489
+ )
490
+ fail(
491
+ 'aet-v2.inconsistent-estimate-revision',
492
+ `${revision.eventId} delta does not reconcile`
493
+ )
494
+ approvedEstimateMinutes = revision.revisedTotalEstimateMinutes
495
+ }
496
+ const latestApprovedEstimateMinutes =
497
+ latestEstimateRevision?.revisedTotalEstimateMinutes ??
498
+ declaration.originalEstimateMinutes
499
+ const calendarStartedAt = Date.parse(declaration.calendarStartedAt)
500
+ for (const [startId, start] of openStarts) {
501
+ if (start.taskId !== taskId) continue
502
+ const startMs = instant(start.timestamp, 'timestamp')
503
+ if (startMs < calendarStartedAt || complete)
504
+ fail('aet-v2.invalid-open-segment', startId)
505
+ const conflict = taskIntervals.find((interval) =>
506
+ overlaps(interval, { start: startMs, end: latestEvidenceAt + 1 })
507
+ )
508
+ if (conflict)
509
+ fail(
510
+ 'aet-v2.overlapping-agent-segments',
511
+ `${startId} overlaps ${conflict.startEventId}`
512
+ )
513
+ }
514
+ const taskExclusiveCoverage = exclusiveCoverage.filter(
515
+ (interval) => interval.taskId === taskId
516
+ )
517
+ const reportingBoundary = options.postMerge
518
+ ? Date.parse(complete.timestamp)
519
+ : complete
520
+ ? Date.parse(complete.timestamp)
521
+ : latestEvidenceAt
522
+ const assertInDomain = (start, end, eventId) => {
523
+ if (start < calendarStartedAt || end > reportingBoundary)
524
+ fail(
525
+ 'aet-v2.evidence-outside-lifecycle',
526
+ `${eventId} outside ${declaration.calendarStartedAt}..${new Date(reportingBoundary).toISOString()}`
527
+ )
528
+ }
529
+ for (const interval of taskIntervals)
530
+ assertInDomain(interval.start, interval.end, interval.startEventId)
531
+ for (const event of nonAet.filter((item) => item.taskId === taskId))
532
+ assertInDomain(
533
+ Date.parse(event.startedAt),
534
+ Date.parse(event.endedAt),
535
+ event.eventId
536
+ )
537
+ for (const event of taskMilestones)
538
+ assertInDomain(
539
+ Date.parse(event.timestamp),
540
+ Date.parse(event.timestamp),
541
+ event.eventId
542
+ )
543
+ for (const event of taskEstimateRevisions)
544
+ assertInDomain(
545
+ Date.parse(event.timestamp),
546
+ Date.parse(event.timestamp),
547
+ event.eventId
548
+ )
549
+ const coverage = [
550
+ ...taskIntervals.map((interval) => ({
551
+ end: interval.end,
552
+ eventId: interval.startEventId,
553
+ start: interval.start
554
+ })),
555
+ ...taskExclusiveCoverage
556
+ ].sort((left, right) => left.start - right.start || left.end - right.end)
557
+ if (!options.allowIncomplete && !coverage.length)
558
+ fail('aet-v2.uncovered-timeline', `${taskId} has no capacity coverage`)
559
+ let coveredThrough = calendarStartedAt
560
+ for (const interval of coverage) {
561
+ if (
562
+ interval.end <= calendarStartedAt ||
563
+ interval.start >= reportingBoundary
564
+ )
565
+ continue
566
+ const clippedStart = Math.max(interval.start, calendarStartedAt)
567
+ const clippedEnd = Math.min(interval.end, reportingBoundary)
568
+ if (!options.allowIncomplete && clippedStart > coveredThrough)
569
+ fail(
570
+ 'aet-v2.uncovered-timeline',
571
+ `${taskId} gap ${new Date(coveredThrough).toISOString()}..${new Date(clippedStart).toISOString()}`
572
+ )
573
+ coveredThrough = Math.max(coveredThrough, clippedEnd)
574
+ }
575
+ if (!options.allowIncomplete && coveredThrough < reportingBoundary)
576
+ fail(
577
+ 'aet-v2.uncovered-timeline',
578
+ `${taskId} gap ${new Date(coveredThrough).toISOString()}..${new Date(reportingBoundary).toISOString()}`
579
+ )
580
+ tasks[taskId] = {
581
+ aetMilliseconds: options.allowIncomplete ? null : aetMilliseconds,
582
+ measuredLowerBoundAetMilliseconds: options.allowIncomplete
583
+ ? aetMilliseconds
584
+ : null,
585
+ calendarCycleMilliseconds:
586
+ reportingBoundary - Date.parse(declaration.calendarStartedAt),
587
+ categoryMilliseconds,
588
+ clockMilliseconds,
589
+ completedAt: complete?.timestamp ?? null,
590
+ corrections: corrected.correctionTaskIds.filter(
591
+ (correctionTaskId) => correctionTaskId === taskId
592
+ ).length,
593
+ estimateHistory: [
594
+ {
595
+ estimateMinutes: declaration.originalEstimateMinutes,
596
+ kind: 'original',
597
+ recordedAt: declaration.recordedAt
598
+ },
599
+ ...taskEstimateRevisions.map((event) => ({
600
+ approvalSource: event.approvalSource,
601
+ estimateMinutes: event.revisedTotalEstimateMinutes,
602
+ kind: 'revision',
603
+ reason: event.reason,
604
+ recordedAt: event.timestamp,
605
+ scopeDeltaMinutes: event.scopeDeltaMinutes
606
+ }))
607
+ ],
608
+ estimateRatio: options.allowIncomplete
609
+ ? null
610
+ : aetMilliseconds / (declaration.originalEstimateMinutes * 60_000),
611
+ estimateVarianceMilliseconds: options.allowIncomplete
612
+ ? null
613
+ : aetMilliseconds - declaration.originalEstimateMinutes * 60_000,
614
+ firstPrReadyAt: firstPrReady?.timestamp ?? null,
615
+ latestApprovedEstimateMinutes,
616
+ latestApprovedEstimateRatio: options.allowIncomplete
617
+ ? null
618
+ : aetMilliseconds / (latestApprovedEstimateMinutes * 60_000),
619
+ latestApprovedEstimateVarianceMilliseconds: options.allowIncomplete
620
+ ? null
621
+ : aetMilliseconds - latestApprovedEstimateMinutes * 60_000,
622
+ lifecycleFinal: Boolean(complete),
623
+ lifecycleStatus: complete
624
+ ? 'final-complete'
625
+ : merged
626
+ ? 'intermediate-post-merge'
627
+ : 'active-intermediate',
628
+ measurementComplete: !options.allowIncomplete,
629
+ mergedAt: merged?.timestamp ?? null,
630
+ metric: METRIC,
631
+ originalEstimateRatio: options.allowIncomplete
632
+ ? null
633
+ : aetMilliseconds / (declaration.originalEstimateMinutes * 60_000),
634
+ originalEstimateMinutes: declaration.originalEstimateMinutes,
635
+ originalEstimateVarianceMilliseconds: options.allowIncomplete
636
+ ? null
637
+ : aetMilliseconds - declaration.originalEstimateMinutes * 60_000,
638
+ remediationCycles: taskIntervals.filter(
639
+ (interval) => interval.category === 'remediation'
640
+ ).length,
641
+ reportingBoundaryAt: new Date(reportingBoundary).toISOString(),
642
+ taskTitle: declaration.taskTitle,
643
+ workClass: declaration.workClass
644
+ }
645
+ }
646
+
647
+ for (const interval of intervals)
648
+ if (!declarations.has(interval.taskId))
649
+ fail('aet-v2.missing-declaration', interval.taskId)
650
+ for (const event of [...nonAet, ...milestones, ...estimateRevisions])
651
+ if (!declarations.has(event.taskId))
652
+ fail('aet-v2.missing-declaration', event.taskId)
653
+
654
+ return {
655
+ corrections: corrected.corrections,
656
+ measurementComplete: !options.allowIncomplete,
657
+ metric: METRIC,
658
+ schema: 'rn-launcher/aet-v2-report/v1',
659
+ tasks
660
+ }
661
+ }
662
+
663
+ export function stableAetV2Json(value) {
664
+ if (Array.isArray(value)) return `[${value.map(stableAetV2Json).join(',')}]`
665
+ if (value && typeof value === 'object')
666
+ return `{${Object.keys(value)
667
+ .sort()
668
+ .map((key) => `${JSON.stringify(key)}:${stableAetV2Json(value[key])}`)
669
+ .join(',')}}`
670
+ return JSON.stringify(value)
671
+ }
672
+
673
+ export const aetV2Contract = Object.freeze({ metric: METRIC, schema: SCHEMA })