@triagepilot/application 1.1.0

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,878 @@
1
+ import { activeApprovedReviewers, selectReplacement, } from "@triagepilot/core";
2
+ const NO_REPLACEMENT_POLICY_SUMMARY = "No replacement is available for an absent required reviewer.";
3
+ const reviewerMutationIntentIdBrand = Symbol("ReviewerMutationIntentId");
4
+ export class ReviewerReplacementContractError extends Error {
5
+ }
6
+ export function parseReviewerMutationIntentId(value) {
7
+ if (typeof value !== "string" || value.trim().length === 0) {
8
+ throw new ReviewerReplacementContractError("Reviewer mutation intent ID must not be empty");
9
+ }
10
+ return value;
11
+ }
12
+ export function assertReviewerReplacementRecoveryRecord(value) {
13
+ if (!isRecord(value)
14
+ || !isNonEmptyString(value.id)
15
+ || !isNonEmptyString(value.workspaceId)
16
+ || !isProviderKind(value.provider)
17
+ || !isNonEmptyString(value.providerConnectionId)
18
+ || !isNonEmptyString(value.absenceId)
19
+ || !isPositiveInteger(value.absenceRevision)
20
+ || !isNonEmptyString(value.decisionId)
21
+ || !isNonEmptyString(value.unavailableActorId)
22
+ || !isReviewerReplacementState(value.state))
23
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery record is malformed");
24
+ const record = value;
25
+ assertReviewerReplacementProvenance(value);
26
+ if ((record.outcome === "permanent_failure"
27
+ && (record.state !== "permanent_failure" || !isNonEmptyString(record.lastError)))
28
+ || (record.state === "permanent_failure" && !isNonEmptyString(record.lastError))
29
+ || (record.state === "finalizer_pending"
30
+ && (finalizerFor(record.decisionId, record.outcome) === null
31
+ || record.lastError !== null))
32
+ || (record.state === "completed"
33
+ && (record.outcome === "permanent_failure" || record.lastError !== null)))
34
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery state is malformed");
35
+ }
36
+ export function assertReviewerReplacementProvenance(value) {
37
+ if (!isRecord(value)) {
38
+ throw new ReviewerReplacementContractError("Reviewer replacement provenance is malformed");
39
+ }
40
+ const outcome = value.outcome;
41
+ const replacementActorId = value.replacementActorId;
42
+ const mutationIntentId = value.mutationIntentId;
43
+ if (outcome === "replaced") {
44
+ if (!isNonEmptyString(replacementActorId) || !isNonEmptyString(mutationIntentId)) {
45
+ throw new ReviewerReplacementContractError("Replaced reviewer outcome requires durable mutation provenance");
46
+ }
47
+ return;
48
+ }
49
+ if (outcome === "simulated_replacement") {
50
+ if (!isNonEmptyString(replacementActorId) || mutationIntentId !== null) {
51
+ throw new ReviewerReplacementContractError("Simulated reviewer replacement requires an actor and null mutation provenance");
52
+ }
53
+ return;
54
+ }
55
+ if (!isReviewerReplacementNonMutationOutcome(outcome)) {
56
+ throw new ReviewerReplacementContractError("Reviewer replacement outcome is malformed");
57
+ }
58
+ if (replacementActorId !== null) {
59
+ throw new ReviewerReplacementContractError("Non-mutating reviewer outcome requires a null replacement actor");
60
+ }
61
+ if (mutationIntentId !== null)
62
+ parseReviewerMutationIntentId(mutationIntentId);
63
+ }
64
+ export function assertReviewerReplacementFinalizerRecord(value) {
65
+ if (!isRecord(value)
66
+ || !isNonEmptyString(value.id)
67
+ || !isNonEmptyString(value.workspaceId)
68
+ || !isProviderKind(value.provider)
69
+ || !isNonEmptyString(value.providerConnectionId)
70
+ || !isNonEmptyString(value.absenceId)
71
+ || !isPositiveInteger(value.absenceRevision)
72
+ || !isNonEmptyString(value.decisionId)
73
+ || !isNonEmptyString(value.unavailableActorId)
74
+ || value.state !== "finalizer_pending") {
75
+ throw new ReviewerReplacementContractError("Pending reviewer replacement finalizer is malformed");
76
+ }
77
+ if (value.outcome !== "replaced"
78
+ && value.outcome !== "skipped_policy_satisfied"
79
+ && value.outcome !== "no_replacement_available") {
80
+ throw new ReviewerReplacementContractError("Pending reviewer replacement has no mapped finalizer");
81
+ }
82
+ assertReviewerReplacementProvenance(value);
83
+ }
84
+ export function assertPersistReviewerReplacementInput(value) {
85
+ if (!isRecord(value)) {
86
+ throw new ReviewerReplacementContractError("Reviewer replacement persistence is malformed");
87
+ }
88
+ const record = value;
89
+ assertReviewerReplacementProvenance(value);
90
+ if (!isProviderKind(record.provider)
91
+ || !isNonEmptyString(record.providerConnectionId)
92
+ || !isNonEmptyString(record.absenceId)
93
+ || !isPositiveInteger(record.absenceRevision)
94
+ || !isNonEmptyString(record.decisionId)
95
+ || !isNonEmptyString(record.expectedHeadRevision)
96
+ || !isNonEmptyString(record.unavailableActorId)
97
+ || !isNonEmptyString(record.reason)
98
+ || !isReviewerReplacementState(record.state)
99
+ || (record.lastError !== null && typeof record.lastError !== "string")
100
+ || !isFiniteDate(record.startedAt)
101
+ || !isFiniteDate(record.completedAt)
102
+ || record.completedAt < record.startedAt) {
103
+ throw new ReviewerReplacementContractError("Reviewer replacement persistence is malformed");
104
+ }
105
+ if ((record.state === "completed" && record.lastError !== null)
106
+ || (record.state === "permanent_failure" && !isNonEmptyString(record.lastError))) {
107
+ throw new ReviewerReplacementContractError("Reviewer replacement persistence state is malformed");
108
+ }
109
+ const expectedState = value.outcome === "permanent_failure"
110
+ ? "permanent_failure"
111
+ : finalizerFor(record.decisionId, value.outcome) === null
112
+ ? "completed"
113
+ : "finalizer_pending";
114
+ if (record.state !== expectedState) {
115
+ throw new ReviewerReplacementContractError("Reviewer replacement persistence state does not match its outcome");
116
+ }
117
+ const replacesCohort = value.outcome === "replaced" || value.outcome === "simulated_replacement";
118
+ if (record.replaceCohort !== replacesCohort || !isRecord(record.event)) {
119
+ throw new ReviewerReplacementContractError("Reviewer replacement persistence is malformed");
120
+ }
121
+ const event = record.event;
122
+ if (event.schemaVersion !== 1
123
+ || event.eventType !== "reviewer_replacement"
124
+ || !isNonEmptyString(event.eventId)
125
+ || !isNonEmptyString(event.occurredAt)
126
+ || !isNonEmptyString(event.workspaceId)
127
+ || event.provider !== record.provider
128
+ || event.providerConnectionId !== record.providerConnectionId
129
+ || event.absenceId !== record.absenceId
130
+ || event.absenceRevision !== record.absenceRevision
131
+ || event.decisionId !== record.decisionId
132
+ || !isNonEmptyString(event.repositoryId)
133
+ || !isNonEmptyString(event.changeRequestId)
134
+ || event.unavailableActor !== record.unavailableActorId
135
+ || event.outcome !== value.outcome
136
+ || event.replacementActor !== value.replacementActorId
137
+ || new Date(event.occurredAt).getTime() !== record.completedAt.getTime()) {
138
+ throw new ReviewerReplacementContractError("Reviewer replacement event does not match persistence provenance");
139
+ }
140
+ }
141
+ export function assertReviewerReplacementFinalizerRecovery(value) {
142
+ if (!isRecord(value)
143
+ || value.kind !== "reviewer_replacement_finalizer"
144
+ || !isNonEmptyString(value.lastError)
145
+ || typeof value.retryable !== "boolean"
146
+ || !isRecord(value.job)
147
+ || value.job.kind !== "activate_reviewer_absence"
148
+ || !isNonEmptyString(value.job.workspaceId)
149
+ || !isNonEmptyString(value.job.providerConnectionId)
150
+ || !isNonEmptyString(value.job.absenceId)
151
+ || !isPositiveInteger(value.job.absenceRevision)
152
+ || !isProviderKind(value.provider)
153
+ || !isNonEmptyString(value.unavailableActorId)) {
154
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery is malformed");
155
+ }
156
+ const record = value;
157
+ if (value.providerEffectsApplied === true) {
158
+ if (!isNonEmptyString(value.mutationIntentId)) {
159
+ throw new ReviewerReplacementContractError("Provider-effect recovery requires durable mutation provenance");
160
+ }
161
+ if (value.outcome !== "replaced" && value.outcome !== "permanent_failure") {
162
+ throw new ReviewerReplacementContractError("Provider-effect recovery has an invalid reviewer outcome");
163
+ }
164
+ }
165
+ else if (value.providerEffectsApplied !== false
166
+ || (value.outcome !== "skipped_policy_satisfied" && value.outcome !== "no_replacement_available")) {
167
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery effects are malformed");
168
+ }
169
+ assertReviewerReplacementProvenance(value);
170
+ if (value.outcome === "permanent_failure") {
171
+ if (record.finalizer !== null || record.phase !== "persist_replacement") {
172
+ throw new ReviewerReplacementContractError("Permanent provider failure recovery is malformed");
173
+ }
174
+ }
175
+ else {
176
+ if (!isRecord(record.finalizer)) {
177
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery finalizer is malformed");
178
+ }
179
+ const expectedAction = value.outcome === "no_replacement_available"
180
+ ? "fail_policy"
181
+ : "reevaluate_policy";
182
+ if (record.finalizer.action !== expectedAction
183
+ || !isNonEmptyString(record.finalizer.decisionId)
184
+ || (record.finalizer.summary !== null && typeof record.finalizer.summary !== "string")) {
185
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery finalizer is malformed");
186
+ }
187
+ }
188
+ if (record.phase === "persist_replacement") {
189
+ if (record.replacementId !== null || record.persistence === null) {
190
+ throw new ReviewerReplacementContractError("Reviewer replacement persistence recovery is malformed");
191
+ }
192
+ }
193
+ else if (record.phase === "run_finalizer" || record.phase === "complete_replacement") {
194
+ if (!isNonEmptyString(record.replacementId) || record.persistence !== null) {
195
+ throw new ReviewerReplacementContractError("Reviewer replacement finalizer recovery is malformed");
196
+ }
197
+ }
198
+ else {
199
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery phase is malformed");
200
+ }
201
+ if (record.persistence !== null) {
202
+ assertPersistReviewerReplacementInput(record.persistence);
203
+ if (record.persistence.outcome !== value.outcome
204
+ || record.persistence.provider !== record.provider
205
+ || record.persistence.unavailableActorId !== record.unavailableActorId
206
+ || record.persistence.replacementActorId !== value.replacementActorId
207
+ || record.persistence.mutationIntentId !== value.mutationIntentId) {
208
+ throw new ReviewerReplacementContractError("Reviewer replacement recovery does not match persistence provenance");
209
+ }
210
+ }
211
+ }
212
+ export async function activateReviewerAbsence(job, ports) {
213
+ const startedAt = ports.clock.now();
214
+ const results = [];
215
+ const pending = await ports.availability.listPendingFinalizers({
216
+ absenceId: job.absenceId,
217
+ absenceRevision: job.absenceRevision,
218
+ });
219
+ for (const record of pending) {
220
+ assertReviewerReplacementFinalizerRecord(record);
221
+ const replay = await replayPendingFinalizer(job, record, ports);
222
+ if (replay.recovery !== null) {
223
+ return { status: "finalizer_pending", results, recovery: replay.recovery };
224
+ }
225
+ results.push({
226
+ decisionId: record.decisionId,
227
+ outcome: record.outcome,
228
+ replacementActor: record.replacementActorId ?? null,
229
+ mutationIntentId: record.mutationIntentId,
230
+ finalized: true,
231
+ });
232
+ }
233
+ const unfinalizedIntents = await ports.availability.listUnfinalizedMutationIntents({
234
+ absenceId: job.absenceId,
235
+ absenceRevision: job.absenceRevision,
236
+ });
237
+ const activation = await ports.availability.loadActivation(job.absenceId, job.absenceRevision);
238
+ if (activation === null) {
239
+ return await auditUnfinalizedIntents(job, unfinalizedIntents, startedAt, results, ports, "stale_activation");
240
+ }
241
+ if (activation.providerConnectionId !== job.providerConnectionId
242
+ || activation.absenceId !== job.absenceId
243
+ || activation.revision !== job.absenceRevision)
244
+ return await auditUnfinalizedIntents(job, unfinalizedIntents, startedAt, results, ports, "stale_activation");
245
+ if (activation.startAt > startedAt || activation.endAt <= startedAt) {
246
+ return await auditUnfinalizedIntents(job, unfinalizedIntents, startedAt, results, ports, "inactive_activation");
247
+ }
248
+ const processedDecisions = new Set();
249
+ for (const candidate of activation.candidates) {
250
+ const processed = await processCandidate(job, activation, candidate, startedAt, ports);
251
+ if ("recovery" in processed) {
252
+ return { status: "finalizer_pending", results, recovery: processed.recovery };
253
+ }
254
+ if (processed.result === null) {
255
+ return { status: "skipped", reason: "final_revalidation", results };
256
+ }
257
+ processedDecisions.add(candidate.decisionId);
258
+ results.push(processed.result);
259
+ }
260
+ const remainingIntents = unfinalizedIntents.filter((intent) => !processedDecisions.has(intent.decisionId));
261
+ if (remainingIntents.length > 0) {
262
+ return await auditUnfinalizedIntents(job, remainingIntents, startedAt, results, ports, "stale_activation");
263
+ }
264
+ return { status: "completed", results };
265
+ }
266
+ async function auditUnfinalizedIntents(job, intents, startedAt, results, ports, emptyReason) {
267
+ if (intents.length === 0)
268
+ return { status: "skipped", reason: emptyReason, results };
269
+ for (const intent of intents) {
270
+ const completedAt = ports.clock.now();
271
+ const reason = "Durable reviewer mutation intent could not resume after activation scope changed.";
272
+ const persistence = mutationIntentRecoveryPersistence(job, intent, reason, startedAt, completedAt);
273
+ try {
274
+ const persisted = await ports.availability.persistMutationIntentRecovery(persistence);
275
+ if (persisted.replacement === null)
276
+ throw new Error("Mutation intent recovery audit was not persisted");
277
+ }
278
+ catch (error) {
279
+ const plan = permanentFailurePlan(reason, intent.id);
280
+ return {
281
+ status: "finalizer_pending",
282
+ results,
283
+ recovery: recovery(job, "persist_replacement", null, null, true, persistence, errorMessage(error), plan, true),
284
+ };
285
+ }
286
+ results.push({
287
+ decisionId: intent.decisionId,
288
+ outcome: "permanent_failure",
289
+ replacementActor: null,
290
+ mutationIntentId: intent.id,
291
+ finalized: true,
292
+ });
293
+ }
294
+ return { status: "completed", results };
295
+ }
296
+ async function processCandidate(job, activation, candidate, startedAt, ports) {
297
+ const read = await readCandidatePlan(job, activation, candidate, startedAt, ports);
298
+ const applied = await applyCandidatePlan(job, activation, candidate, startedAt, read, ports);
299
+ return await finalizeCandidatePlan(job, activation, candidate, startedAt, applied.plan, applied.providerEffectsApplied, ports);
300
+ }
301
+ async function readCandidatePlan(job, activation, candidate, startedAt, ports) {
302
+ const mutationIntent = candidate.mode === "enforce"
303
+ ? await ports.availability.loadMutationIntent(mutationIntentKey(job, candidate))
304
+ : null;
305
+ if (mutationIntent !== null) {
306
+ const mismatch = mutationIntentMismatch(job, activation, candidate, mutationIntent);
307
+ if (mismatch !== null) {
308
+ return {
309
+ plan: permanentFailurePlan(mismatch, mutationIntent.id),
310
+ selection: null,
311
+ mutationIntent,
312
+ revalidateProvider: false,
313
+ };
314
+ }
315
+ }
316
+ if (candidate.policyCheckState === "failure") {
317
+ return {
318
+ plan: withMutationIntent(terminalPlan("permanent_failure", "Human-review policy is already in a terminal failure state."), mutationIntent),
319
+ selection: null,
320
+ mutationIntent,
321
+ revalidateProvider: false,
322
+ };
323
+ }
324
+ if (candidate.policyCheckState === "success") {
325
+ return {
326
+ plan: withMutationIntent({
327
+ ...terminalPlan("skipped_policy_satisfied", "Required human approval count is already satisfied."),
328
+ finalizer: finalizerFor(candidate.decisionId, "skipped_policy_satisfied"),
329
+ }, mutationIntent),
330
+ selection: null,
331
+ mutationIntent,
332
+ revalidateProvider: false,
333
+ };
334
+ }
335
+ const inspected = await inspectProviderState(job, candidate, ports);
336
+ if (inspected.state === null) {
337
+ return {
338
+ plan: permanentFailurePlan(inspected.permanentError, mutationIntent?.id ?? null),
339
+ selection: null,
340
+ mutationIntent,
341
+ revalidateProvider: false,
342
+ };
343
+ }
344
+ const initialTerminal = terminalProviderPlan(activation, candidate, inspected.state, candidate.mode === "enforce");
345
+ if (initialTerminal !== null) {
346
+ return {
347
+ plan: mutationIntent === null ? initialTerminal : { ...initialTerminal, mutationIntentId: mutationIntent.id },
348
+ selection: null,
349
+ mutationIntent,
350
+ revalidateProvider: true,
351
+ };
352
+ }
353
+ const planned = await planFromProviderState(job, activation, candidate, inspected.state, startedAt, null, mutationIntent, ports);
354
+ return { ...planned, mutationIntent, revalidateProvider: true };
355
+ }
356
+ async function applyCandidatePlan(job, activation, candidate, startedAt, read, ports) {
357
+ if (!read.revalidateProvider) {
358
+ return { plan: read.plan, providerEffectsApplied: false };
359
+ }
360
+ let intent = read.mutationIntent;
361
+ if (candidate.mode === "enforce"
362
+ && read.plan.providerIntent === "prepare"
363
+ && read.plan.replacementActor !== null) {
364
+ const prepared = await prepareValidatedMutationIntent(job, activation, candidate, read.plan.replacementActor, ports);
365
+ if (prepared.failure !== null)
366
+ return { plan: prepared.failure, providerEffectsApplied: false };
367
+ intent = prepared.intent;
368
+ }
369
+ let inspected = await inspectProviderState(job, candidate, ports);
370
+ if (inspected.state === null) {
371
+ return {
372
+ plan: permanentFailurePlan(inspected.permanentError, intent?.id ?? null),
373
+ providerEffectsApplied: false,
374
+ };
375
+ }
376
+ let planned = await planFromProviderState(job, activation, candidate, inspected.state, startedAt, read.selection, intent, ports);
377
+ if (candidate.mode !== "enforce"
378
+ || planned.plan.providerIntent === "none"
379
+ || planned.plan.replacementActor === null)
380
+ return { plan: planned.plan, providerEffectsApplied: false };
381
+ if (planned.plan.providerIntent === "prepare") {
382
+ const prepared = await prepareValidatedMutationIntent(job, activation, candidate, planned.plan.replacementActor, ports);
383
+ if (prepared.failure !== null)
384
+ return { plan: prepared.failure, providerEffectsApplied: false };
385
+ intent = prepared.intent;
386
+ inspected = await inspectProviderState(job, candidate, ports);
387
+ if (inspected.state === null) {
388
+ return { plan: permanentFailurePlan(inspected.permanentError, intent.id), providerEffectsApplied: false };
389
+ }
390
+ planned = await planFromProviderState(job, activation, candidate, inspected.state, startedAt, read.selection, intent, ports);
391
+ if (planned.plan.providerIntent === "none") {
392
+ return { plan: planned.plan, providerEffectsApplied: false };
393
+ }
394
+ }
395
+ if (intent === null)
396
+ throw new Error("Durable reviewer mutation intent is required before provider mutation");
397
+ const ineligibleIntentPlan = await durableIntentIneligibilityPlan(job, candidate, inspected.state, intent, startedAt, ports);
398
+ if (ineligibleIntentPlan !== null) {
399
+ return { plan: ineligibleIntentPlan, providerEffectsApplied: false };
400
+ }
401
+ const intentPlan = durableIntentPlan(activation, candidate, intent);
402
+ try {
403
+ await ports.provider.reconcileReviewRequest({
404
+ ...target(job, candidate),
405
+ unavailableActor: activation.externalActorId,
406
+ replacementActor: intent.replacementActorId,
407
+ });
408
+ }
409
+ catch (error) {
410
+ const classified = ports.provider.classifyError(error);
411
+ if (classified.kind === "obsolete_claim")
412
+ throw error;
413
+ if (classified.kind === "retryable")
414
+ throw error;
415
+ return { plan: permanentFailurePlan(classified.message, intent.id), providerEffectsApplied: true };
416
+ }
417
+ return { plan: intentPlan, providerEffectsApplied: true };
418
+ }
419
+ async function finalizeCandidatePlan(job, activation, candidate, startedAt, plan, providerEffectsApplied, ports) {
420
+ const completedAt = ports.clock.now();
421
+ const persistence = persistenceInput(job, activation, candidate, plan, startedAt, completedAt);
422
+ let persisted;
423
+ try {
424
+ persisted = await ports.availability.persistReplacement(persistence);
425
+ }
426
+ catch (error) {
427
+ if (!providerEffectsApplied)
428
+ throw error;
429
+ return {
430
+ recovery: recovery(job, "persist_replacement", plan.finalizer, null, true, persistence, errorMessage(error), plan),
431
+ };
432
+ }
433
+ if (!persisted.activationCurrent || persisted.replacement === null) {
434
+ if (providerEffectsApplied) {
435
+ return {
436
+ recovery: recovery(job, "persist_replacement", plan.finalizer, null, true, persistence, "Final replacement persistence rejected stale state.", plan),
437
+ };
438
+ }
439
+ return { result: null };
440
+ }
441
+ if (plan.finalizer === null) {
442
+ return {
443
+ result: result(candidate, plan, true),
444
+ };
445
+ }
446
+ try {
447
+ await runFinalizer(job, plan.finalizer, ports);
448
+ }
449
+ catch (error) {
450
+ const classified = ports.finalizers.classifyError(error);
451
+ return {
452
+ recovery: recovery(job, "run_finalizer", plan.finalizer, persisted.replacement.id, providerEffectsApplied, persistence, classified.message, plan, classified.kind === "retryable"),
453
+ };
454
+ }
455
+ try {
456
+ const completed = await ports.availability.updateReplacementState({
457
+ replacementId: persisted.replacement.id,
458
+ expectedState: "finalizer_pending",
459
+ state: "completed",
460
+ lastError: null,
461
+ });
462
+ if (completed === null) {
463
+ return {
464
+ recovery: recovery(job, "complete_replacement", plan.finalizer, persisted.replacement.id, providerEffectsApplied, persistence, "Finalizer completion state was not persisted.", plan),
465
+ };
466
+ }
467
+ }
468
+ catch (error) {
469
+ return {
470
+ recovery: recovery(job, "complete_replacement", plan.finalizer, persisted.replacement.id, providerEffectsApplied, persistence, errorMessage(error), plan),
471
+ };
472
+ }
473
+ return { result: result(candidate, plan, true) };
474
+ }
475
+ async function replayPendingFinalizer(job, record, ports) {
476
+ const finalizer = finalizerFor(record.decisionId, record.outcome);
477
+ if (finalizer === null)
478
+ throw new Error("Pending reviewer replacement has no mapped finalizer");
479
+ try {
480
+ await runFinalizer(job, finalizer, ports);
481
+ }
482
+ catch (error) {
483
+ const classified = ports.finalizers.classifyError(error);
484
+ return {
485
+ recovery: recovery(job, "run_finalizer", finalizer, record.id, record.outcome === "replaced", null, classified.message, record, classified.kind === "retryable"),
486
+ };
487
+ }
488
+ try {
489
+ const completed = await ports.availability.updateReplacementState({
490
+ replacementId: record.id,
491
+ expectedState: "finalizer_pending",
492
+ state: "completed",
493
+ lastError: null,
494
+ });
495
+ if (completed === null) {
496
+ return {
497
+ recovery: recovery(job, "complete_replacement", finalizer, record.id, record.outcome === "replaced", null, "Finalizer completion state was not persisted.", record),
498
+ };
499
+ }
500
+ }
501
+ catch (error) {
502
+ return {
503
+ recovery: recovery(job, "complete_replacement", finalizer, record.id, record.outcome === "replaced", null, errorMessage(error), record),
504
+ };
505
+ }
506
+ return { recovery: null };
507
+ }
508
+ async function loadSelectionContext(job, candidate, at, ports) {
509
+ const [absences, load] = await Promise.all([
510
+ ports.availability.findActive({
511
+ workspaceId: job.workspaceId,
512
+ providerConnectionId: job.providerConnectionId,
513
+ actors: candidate.originalEligibleActors,
514
+ at,
515
+ }),
516
+ ports.reviewerLoad({
517
+ workspaceId: job.workspaceId,
518
+ actors: candidate.originalEligibleActors,
519
+ }),
520
+ ]);
521
+ return { absences, load };
522
+ }
523
+ async function inspectProviderState(job, candidate, ports) {
524
+ try {
525
+ return {
526
+ state: await ports.provider.inspectChangeRequest(target(job, candidate)),
527
+ permanentError: null,
528
+ };
529
+ }
530
+ catch (error) {
531
+ const classified = ports.provider.classifyError(error);
532
+ if (classified.kind === "retryable")
533
+ throw error;
534
+ return { state: null, permanentError: classified.message };
535
+ }
536
+ }
537
+ function mutationIntentKey(job, candidate) {
538
+ return {
539
+ workspaceId: job.workspaceId,
540
+ providerConnectionId: job.providerConnectionId,
541
+ absenceId: job.absenceId,
542
+ absenceRevision: job.absenceRevision,
543
+ decisionId: candidate.decisionId,
544
+ };
545
+ }
546
+ function mutationIntentInput(job, activation, candidate, replacementActorId) {
547
+ return {
548
+ ...mutationIntentKey(job, candidate),
549
+ provider: candidate.provider,
550
+ repositoryId: candidate.repository.externalId,
551
+ changeRequestId: candidate.changeRequestId,
552
+ expectedHeadRevision: candidate.routedHeadRevision,
553
+ unavailableActorId: activation.externalActorId,
554
+ replacementActorId,
555
+ };
556
+ }
557
+ function mutationIntentMismatch(job, activation, candidate, intent) {
558
+ const expected = mutationIntentInput(job, activation, candidate, intent.replacementActorId);
559
+ const sourceMatches = intent.id.trim().length > 0
560
+ && intent.workspaceId === expected.workspaceId
561
+ && intent.provider === expected.provider
562
+ && intent.providerConnectionId === expected.providerConnectionId
563
+ && intent.absenceId === expected.absenceId
564
+ && intent.absenceRevision === expected.absenceRevision
565
+ && intent.decisionId === expected.decisionId
566
+ && intent.repositoryId === expected.repositoryId
567
+ && intent.changeRequestId === expected.changeRequestId
568
+ && intent.expectedHeadRevision === expected.expectedHeadRevision
569
+ && intent.unavailableActorId === expected.unavailableActorId;
570
+ const actorIsValid = candidate.originalEligibleActors.includes(intent.replacementActorId)
571
+ && !candidate.selectedActors.includes(intent.replacementActorId)
572
+ && intent.replacementActorId !== activation.externalActorId;
573
+ return sourceMatches && actorIsValid
574
+ ? null
575
+ : "Durable reviewer mutation intent does not match the immutable activation source.";
576
+ }
577
+ async function prepareValidatedMutationIntent(job, activation, candidate, replacementActorId, ports) {
578
+ const intent = await ports.availability.prepareMutationIntent(mutationIntentInput(job, activation, candidate, replacementActorId));
579
+ const mismatch = mutationIntentMismatch(job, activation, candidate, intent);
580
+ return mismatch === null
581
+ ? { intent, failure: null }
582
+ : { intent, failure: permanentFailurePlan(mismatch, intent.id) };
583
+ }
584
+ async function durableIntentIneligibilityPlan(job, candidate, current, intent, at, ports) {
585
+ if (intent.replacementActorId === current.authorActor) {
586
+ return permanentFailurePlan(`Durable replacement actor ${intent.replacementActorId} is the current change-request author.`, intent.id);
587
+ }
588
+ const absences = await ports.availability.findActive({
589
+ workspaceId: job.workspaceId,
590
+ providerConnectionId: job.providerConnectionId,
591
+ actors: [intent.replacementActorId],
592
+ at,
593
+ });
594
+ if (absences.some((absence) => absence.externalActorId === intent.replacementActorId)) {
595
+ return permanentFailurePlan(`Durable replacement actor ${intent.replacementActorId} is currently unavailable.`, intent.id);
596
+ }
597
+ const currentHeadApprovals = activeApprovedReviewers(current.reviews.filter((review) => review.commitId === current.currentHeadRevision));
598
+ if (currentHeadApprovals.includes(intent.replacementActorId)) {
599
+ return permanentFailurePlan(`Durable replacement actor ${intent.replacementActorId} already approved the current head.`, intent.id);
600
+ }
601
+ return null;
602
+ }
603
+ async function planFromProviderState(job, activation, candidate, current, at, existingSelection, mutationIntent, ports) {
604
+ const terminal = terminalProviderPlan(activation, candidate, current, candidate.mode === "enforce");
605
+ if (terminal !== null) {
606
+ return {
607
+ plan: mutationIntent === null ? terminal : { ...terminal, mutationIntentId: mutationIntent.id },
608
+ selection: existingSelection,
609
+ };
610
+ }
611
+ if (mutationIntent !== null) {
612
+ return { plan: durableIntentPlan(activation, candidate, mutationIntent), selection: existingSelection };
613
+ }
614
+ const selection = existingSelection ?? await loadSelectionContext(job, candidate, at, ports);
615
+ return {
616
+ plan: replacementPlan(activation, candidate, current, selection, at),
617
+ selection,
618
+ };
619
+ }
620
+ function terminalProviderPlan(activation, candidate, current, mapPolicyFinalizer) {
621
+ if (current.state !== "open") {
622
+ return terminalPlan("skipped_closed", "Change request is no longer open.");
623
+ }
624
+ if (current.currentHeadRevision !== candidate.routedHeadRevision) {
625
+ return terminalPlan("skipped_changed_head", "Change request head no longer matches the routed head.");
626
+ }
627
+ const approvedActors = activeApprovedReviewers(current.reviews);
628
+ if (approvedActors.length >= candidate.requestedReviewerCount) {
629
+ return {
630
+ ...terminalPlan("skipped_policy_satisfied", "Required human approval count is already satisfied."),
631
+ finalizer: mapPolicyFinalizer ? finalizerFor(candidate.decisionId, "skipped_policy_satisfied") : null,
632
+ };
633
+ }
634
+ if (approvedActors.includes(activation.externalActorId)) {
635
+ return terminalPlan("skipped_approved", "Unavailable actor has already approved the change request.");
636
+ }
637
+ return null;
638
+ }
639
+ function replacementPlan(activation, candidate, current, selection, at) {
640
+ const approvedActors = activeApprovedReviewers(current.reviews);
641
+ const selected = selectReplacement({
642
+ author: current.authorActor,
643
+ unavailableActor: activation.externalActorId,
644
+ activeCohort: candidate.selectedActors,
645
+ approvedActors,
646
+ originalEligibleActors: candidate.originalEligibleActors,
647
+ originalPreferredActors: candidate.originalPreferredActors,
648
+ absences: selection.absences,
649
+ load: selection.load,
650
+ selectionKey: `${candidate.repository.externalId}:${candidate.changeRequestId}`,
651
+ now: at,
652
+ });
653
+ if (selected.replacementActor === null) {
654
+ return {
655
+ outcome: "no_replacement_available",
656
+ replacementActor: null,
657
+ reason: "No available actor remains in the original ownership-eligible pool.",
658
+ replaceCohort: false,
659
+ finalizer: candidate.mode === "enforce"
660
+ ? finalizerFor(candidate.decisionId, "no_replacement_available")
661
+ : null,
662
+ providerIntent: "none",
663
+ mutationIntentId: null,
664
+ };
665
+ }
666
+ const outcome = candidate.mode === "enforce" ? "replaced" : "simulated_replacement";
667
+ return {
668
+ outcome,
669
+ replacementActor: selected.replacementActor,
670
+ reason: candidate.mode === "enforce"
671
+ ? `Replaced unavailable actor ${activation.externalActorId} with ${selected.replacementActor}.`
672
+ : `Would replace unavailable actor ${activation.externalActorId} with ${selected.replacementActor}.`,
673
+ replaceCohort: true,
674
+ finalizer: finalizerFor(candidate.decisionId, outcome),
675
+ providerIntent: candidate.mode === "enforce" ? "prepare" : "none",
676
+ mutationIntentId: null,
677
+ };
678
+ }
679
+ function durableIntentPlan(activation, candidate, intent) {
680
+ return {
681
+ outcome: "replaced",
682
+ replacementActor: intent.replacementActorId,
683
+ reason: `Replaced unavailable actor ${activation.externalActorId} with ${intent.replacementActorId}.`,
684
+ replaceCohort: true,
685
+ finalizer: finalizerFor(candidate.decisionId, "replaced"),
686
+ providerIntent: "apply",
687
+ mutationIntentId: intent.id,
688
+ };
689
+ }
690
+ function terminalPlan(outcome, reason) {
691
+ return {
692
+ outcome,
693
+ replacementActor: null,
694
+ reason,
695
+ replaceCohort: false,
696
+ finalizer: null,
697
+ providerIntent: "none",
698
+ mutationIntentId: null,
699
+ };
700
+ }
701
+ function withMutationIntent(plan, intent) {
702
+ return intent === null ? plan : { ...plan, mutationIntentId: intent.id };
703
+ }
704
+ function permanentFailurePlan(reason, mutationIntentId = null) {
705
+ return { ...terminalPlan("permanent_failure", reason), mutationIntentId };
706
+ }
707
+ function finalizerFor(decisionId, outcome) {
708
+ if (outcome === "replaced" || outcome === "skipped_policy_satisfied") {
709
+ return { action: "reevaluate_policy", decisionId, summary: null };
710
+ }
711
+ if (outcome === "no_replacement_available") {
712
+ return { action: "fail_policy", decisionId, summary: NO_REPLACEMENT_POLICY_SUMMARY };
713
+ }
714
+ return null;
715
+ }
716
+ function persistenceInput(job, activation, candidate, plan, startedAt, completedAt) {
717
+ const state = plan.outcome === "permanent_failure"
718
+ ? "permanent_failure"
719
+ : plan.finalizer === null ? "completed" : "finalizer_pending";
720
+ const value = {
721
+ provider: candidate.provider,
722
+ providerConnectionId: candidate.providerConnectionId,
723
+ absenceId: activation.absenceId,
724
+ absenceRevision: activation.revision,
725
+ decisionId: candidate.decisionId,
726
+ expectedHeadRevision: candidate.routedHeadRevision,
727
+ unavailableActorId: activation.externalActorId,
728
+ replacementActorId: plan.replacementActor,
729
+ mutationIntentId: plan.mutationIntentId,
730
+ outcome: plan.outcome,
731
+ reason: plan.reason,
732
+ state,
733
+ lastError: plan.outcome === "permanent_failure" ? plan.reason : null,
734
+ startedAt,
735
+ completedAt,
736
+ replaceCohort: plan.replaceCohort,
737
+ event: {
738
+ schemaVersion: 1,
739
+ eventType: "reviewer_replacement",
740
+ eventId: `reviewer-replacement:${activation.absenceId}:revision:${activation.revision}:decision:${candidate.decisionId}:v1`,
741
+ occurredAt: completedAt.toISOString(),
742
+ workspaceId: job.workspaceId,
743
+ provider: candidate.provider,
744
+ providerConnectionId: candidate.providerConnectionId,
745
+ absenceId: activation.absenceId,
746
+ absenceRevision: activation.revision,
747
+ decisionId: candidate.decisionId,
748
+ repositoryId: candidate.repository.externalId,
749
+ changeRequestId: candidate.changeRequestId,
750
+ unavailableActor: activation.externalActorId,
751
+ replacementActor: plan.replacementActor,
752
+ outcome: plan.outcome,
753
+ },
754
+ };
755
+ assertPersistReviewerReplacementInput(value);
756
+ return value;
757
+ }
758
+ function mutationIntentRecoveryPersistence(job, intent, reason, startedAt, completedAt) {
759
+ const value = {
760
+ provider: intent.provider,
761
+ providerConnectionId: intent.providerConnectionId,
762
+ absenceId: intent.absenceId,
763
+ absenceRevision: intent.absenceRevision,
764
+ decisionId: intent.decisionId,
765
+ expectedHeadRevision: intent.expectedHeadRevision,
766
+ unavailableActorId: intent.unavailableActorId,
767
+ replacementActorId: null,
768
+ mutationIntentId: intent.id,
769
+ outcome: "permanent_failure",
770
+ reason,
771
+ state: "permanent_failure",
772
+ lastError: reason,
773
+ startedAt,
774
+ completedAt,
775
+ replaceCohort: false,
776
+ event: {
777
+ schemaVersion: 1,
778
+ eventType: "reviewer_replacement",
779
+ eventId: `reviewer-replacement:${intent.absenceId}:revision:${intent.absenceRevision}:decision:${intent.decisionId}:v1`,
780
+ occurredAt: completedAt.toISOString(),
781
+ workspaceId: job.workspaceId,
782
+ provider: intent.provider,
783
+ providerConnectionId: intent.providerConnectionId,
784
+ absenceId: intent.absenceId,
785
+ absenceRevision: intent.absenceRevision,
786
+ decisionId: intent.decisionId,
787
+ repositoryId: intent.repositoryId,
788
+ changeRequestId: intent.changeRequestId,
789
+ unavailableActor: intent.unavailableActorId,
790
+ replacementActor: null,
791
+ outcome: "permanent_failure",
792
+ },
793
+ };
794
+ assertPersistReviewerReplacementInput(value);
795
+ return value;
796
+ }
797
+ function target(job, candidate) {
798
+ return {
799
+ workspaceId: job.workspaceId,
800
+ providerConnectionId: job.providerConnectionId,
801
+ repository: candidate.repository,
802
+ changeRequestId: candidate.changeRequestId,
803
+ changeRequestNumber: candidate.changeRequestNumber,
804
+ };
805
+ }
806
+ async function runFinalizer(job, finalizer, ports) {
807
+ await ports.finalizers.run({
808
+ workspaceId: job.workspaceId,
809
+ providerConnectionId: job.providerConnectionId,
810
+ decisionId: finalizer.decisionId,
811
+ action: finalizer.action,
812
+ summary: finalizer.summary,
813
+ });
814
+ }
815
+ function result(candidate, plan, finalized) {
816
+ return {
817
+ decisionId: candidate.decisionId,
818
+ outcome: plan.outcome,
819
+ replacementActor: plan.replacementActor,
820
+ mutationIntentId: plan.mutationIntentId,
821
+ finalized,
822
+ };
823
+ }
824
+ function recovery(job, phase, finalizer, replacementId, providerEffectsApplied, persistence, lastError, source, retryable = true) {
825
+ const replacementActorId = "replacementActor" in source
826
+ ? source.replacementActor
827
+ : source.replacementActorId;
828
+ const provider = persistence?.provider ?? ("provider" in source ? source.provider : undefined);
829
+ const unavailableActorId = persistence?.unavailableActorId
830
+ ?? ("unavailableActorId" in source ? source.unavailableActorId : undefined);
831
+ const value = {
832
+ kind: "reviewer_replacement_finalizer",
833
+ phase,
834
+ job,
835
+ provider,
836
+ unavailableActorId,
837
+ finalizer,
838
+ replacementId,
839
+ outcome: source.outcome,
840
+ replacementActorId,
841
+ mutationIntentId: source.mutationIntentId,
842
+ providerEffectsApplied,
843
+ persistence: phase === "persist_replacement" ? persistence : null,
844
+ lastError,
845
+ retryable,
846
+ };
847
+ assertReviewerReplacementFinalizerRecovery(value);
848
+ return value;
849
+ }
850
+ function errorMessage(error) {
851
+ return error instanceof Error ? error.message : String(error);
852
+ }
853
+ function isRecord(value) {
854
+ return typeof value === "object" && value !== null;
855
+ }
856
+ function isNonEmptyString(value) {
857
+ return typeof value === "string" && value.trim().length > 0;
858
+ }
859
+ function isPositiveInteger(value) {
860
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
861
+ }
862
+ function isFiniteDate(value) {
863
+ return value instanceof Date && Number.isFinite(value.getTime());
864
+ }
865
+ function isProviderKind(value) {
866
+ return value === "github" || value === "gitlab" || value === "bitbucket";
867
+ }
868
+ function isReviewerReplacementState(value) {
869
+ return value === "finalizer_pending" || value === "completed" || value === "permanent_failure";
870
+ }
871
+ function isReviewerReplacementNonMutationOutcome(value) {
872
+ return value === "no_replacement_available"
873
+ || value === "skipped_approved"
874
+ || value === "skipped_closed"
875
+ || value === "skipped_changed_head"
876
+ || value === "skipped_policy_satisfied"
877
+ || value === "permanent_failure";
878
+ }