agentxchain 2.107.0 → 2.109.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.
@@ -39,7 +39,7 @@ import {
39
39
  import { getMaxConcurrentTurns } from './normalized-config.js';
40
40
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
41
41
  import { runHooks } from './hook-runner.js';
42
- import { emitNotifications } from './notification-runner.js';
42
+ import { emitNotifications, clearSlaReminders } from './notification-runner.js';
43
43
  import { emitRunEvent } from './run-events.js';
44
44
  import { writeSessionCheckpoint } from './session-checkpoint.js';
45
45
  import { recordRunHistory } from './run-history.js';
@@ -54,6 +54,7 @@ import {
54
54
  replayVerificationMachineEvidence,
55
55
  summarizeVerificationReplay,
56
56
  } from './verification-replay.js';
57
+ import { executeGateActions } from './gate-actions.js';
57
58
 
58
59
  // ── Constants ────────────────────────────────────────────────────────────────
59
60
 
@@ -1273,6 +1274,67 @@ function canApprovePendingGate(state) {
1273
1274
  return state?.status === 'paused' || state?.status === 'blocked';
1274
1275
  }
1275
1276
 
1277
+ function blockRunForGateActionFailure(root, state, gateFailure, config) {
1278
+ const gateType = gateFailure.gate_type === 'run_completion' ? 'run_completion' : 'phase_transition';
1279
+ const recoveryAction = gateType === 'run_completion'
1280
+ ? 'agentxchain approve-completion'
1281
+ : 'agentxchain approve-transition';
1282
+ const actionLabel = gateFailure.action_label || gateFailure.command || gateFailure.gate_id || 'gate action';
1283
+ const blockedAt = gateFailure.timestamp || new Date().toISOString();
1284
+ const blockedState = {
1285
+ ...state,
1286
+ status: 'blocked',
1287
+ blocked_on: `gate_action:${gateFailure.gate_id || 'unknown'}`,
1288
+ blocked_reason: {
1289
+ category: 'gate_action_failed',
1290
+ blocked_at: blockedAt,
1291
+ turn_id: gateFailure.requested_by_turn || null,
1292
+ detail: `Gate action failed for "${gateFailure.gate_id || 'unknown'}": ${actionLabel}`,
1293
+ recovery: {
1294
+ typed_reason: 'gate_action_failed',
1295
+ owner: 'human',
1296
+ recovery_action: recoveryAction,
1297
+ turn_retained: false,
1298
+ detail: `${gateFailure.gate_id || 'unknown'} action ${gateFailure.action_index || '?'} (${actionLabel})`,
1299
+ },
1300
+ gate_action: {
1301
+ attempt_id: gateFailure.attempt_id || null,
1302
+ gate_id: gateFailure.gate_id || null,
1303
+ gate_type: gateType,
1304
+ action_index: gateFailure.action_index || null,
1305
+ action_label: gateFailure.action_label || null,
1306
+ command: gateFailure.command || null,
1307
+ exit_code: gateFailure.exit_code ?? null,
1308
+ stderr_tail: gateFailure.stderr_tail || null,
1309
+ },
1310
+ },
1311
+ };
1312
+
1313
+ writeState(root, blockedState);
1314
+ emitBlockedNotification(root, config, blockedState, {
1315
+ category: 'gate_action_failed',
1316
+ blockedOn: blockedState.blocked_on,
1317
+ recovery: blockedState.blocked_reason.recovery,
1318
+ });
1319
+ emitRunEvent(root, 'run_blocked', {
1320
+ run_id: blockedState.run_id,
1321
+ phase: blockedState.phase,
1322
+ status: blockedState.status,
1323
+ turn: gateFailure.requested_by_turn
1324
+ ? { turn_id: gateFailure.requested_by_turn, role_id: null }
1325
+ : undefined,
1326
+ payload: {
1327
+ category: 'gate_action_failed',
1328
+ gate_id: gateFailure.gate_id || null,
1329
+ gate_type: gateType,
1330
+ action_index: gateFailure.action_index || null,
1331
+ exit_code: gateFailure.exit_code ?? null,
1332
+ },
1333
+ });
1334
+
1335
+ return blockedState;
1336
+ }
1337
+
1276
1338
  function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
1277
1339
  const isTamper = errorCode?.includes('_tamper');
1278
1340
  const pendingPhaseTransition = state?.pending_phase_transition;
@@ -2652,11 +2714,32 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2652
2714
  },
2653
2715
  });
2654
2716
 
2717
+ // DEC-RUN-LOOP-CONFLICT-002: Persist turn_conflicted as a durable run event
2718
+ emitRunEvent(root, 'turn_conflicted', {
2719
+ run_id: state.run_id,
2720
+ phase: state.phase,
2721
+ status: updatedState.status,
2722
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
2723
+ payload: {
2724
+ error_code: 'conflict',
2725
+ detection_count: detectionCount,
2726
+ conflicting_files: conflict.conflicting_files,
2727
+ accepted_since_turn_ids: conflict.accepted_since.map(entry => entry.turn_id),
2728
+ overlap_ratio: conflict.overlap_ratio,
2729
+ },
2730
+ });
2731
+
2655
2732
  writeState(root, updatedState);
2656
2733
 
2657
2734
  // DEC-RHTR-SPEC: Record conflict_loop blocked outcome in cross-run history (non-fatal)
2658
2735
  if (updatedState.status === 'blocked') {
2659
2736
  recordRunHistory(root, updatedState, config, 'blocked');
2737
+ // DEC-CONFLICT-NOTIFY-001: Emit run_blocked notification for conflict-loop exhaustion
2738
+ emitBlockedNotification(root, config, updatedState, {
2739
+ category: 'conflict_loop',
2740
+ blockedOn: updatedState.blocked_on,
2741
+ recovery: updatedState.blocked_reason?.recovery || null,
2742
+ }, currentTurn);
2660
2743
  }
2661
2744
 
2662
2745
  return {
@@ -3799,9 +3882,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3799
3882
  *
3800
3883
  * @param {string} root - project root directory
3801
3884
  * @param {object} [config] - normalized config (optional; required for hook support)
3885
+ * @param {object} [opts] - optional execution controls
3802
3886
  * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
3803
3887
  */
3804
- export function approvePhaseTransition(root, config) {
3888
+ export function approvePhaseTransition(root, config, opts = {}) {
3805
3889
  const state = readState(root);
3806
3890
  if (!state) {
3807
3891
  return { ok: false, error: 'No governed state.json found' };
@@ -3815,6 +3899,23 @@ export function approvePhaseTransition(root, config) {
3815
3899
 
3816
3900
  const transition = state.pending_phase_transition;
3817
3901
 
3902
+ if (opts.dryRun) {
3903
+ const gateActions = executeGateActions(root, config, {
3904
+ gateId: transition.gate,
3905
+ gateType: 'phase_transition',
3906
+ phase: state.phase,
3907
+ requestedByTurn: transition.requested_by_turn || null,
3908
+ triggerCommand: 'approve-transition',
3909
+ }, { dryRun: true });
3910
+ return {
3911
+ ok: true,
3912
+ dry_run: true,
3913
+ state: attachLegacyCurrentTurnAlias(state),
3914
+ transition,
3915
+ gate_actions: gateActions.actions,
3916
+ };
3917
+ }
3918
+
3818
3919
  // ── before_gate hooks ──────────────────────────────────────────────
3819
3920
  const hooksConfig = config?.hooks || {};
3820
3921
  if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
@@ -3856,6 +3957,32 @@ export function approvePhaseTransition(root, config) {
3856
3957
  }
3857
3958
  }
3858
3959
 
3960
+ const gateActions = executeGateActions(root, config, {
3961
+ gateId: transition.gate,
3962
+ gateType: 'phase_transition',
3963
+ phase: state.phase,
3964
+ requestedByTurn: transition.requested_by_turn || null,
3965
+ triggerCommand: 'approve-transition',
3966
+ });
3967
+
3968
+ if (!gateActions.ok) {
3969
+ for (const entry of gateActions.actions || []) {
3970
+ appendJsonl(root, LEDGER_PATH, entry);
3971
+ }
3972
+ const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
3973
+ return {
3974
+ ok: false,
3975
+ error: gateActions.error,
3976
+ error_code: 'gate_action_failed',
3977
+ state: blockedState,
3978
+ gateActionRun: gateActions,
3979
+ };
3980
+ }
3981
+
3982
+ for (const entry of gateActions.actions || []) {
3983
+ appendJsonl(root, LEDGER_PATH, entry);
3984
+ }
3985
+
3859
3986
  const updatedState = {
3860
3987
  ...state,
3861
3988
  phase: transition.to,
@@ -3872,6 +3999,7 @@ export function approvePhaseTransition(root, config) {
3872
3999
  };
3873
4000
 
3874
4001
  writeState(root, updatedState);
4002
+ clearSlaReminders(root, 'pending_phase_transition');
3875
4003
  emitRunEvent(root, 'gate_approved', {
3876
4004
  run_id: updatedState.run_id,
3877
4005
  phase: updatedState.phase,
@@ -3897,6 +4025,7 @@ export function approvePhaseTransition(root, config) {
3897
4025
  ok: true,
3898
4026
  state: attachLegacyCurrentTurnAlias(updatedState),
3899
4027
  transition,
4028
+ gateActionRun: gateActions,
3900
4029
  };
3901
4030
  }
3902
4031
 
@@ -3912,9 +4041,10 @@ export function approvePhaseTransition(root, config) {
3912
4041
  *
3913
4042
  * @param {string} root - project root directory
3914
4043
  * @param {object} [config] - normalized config (optional; required for hook support)
4044
+ * @param {object} [opts] - optional execution controls
3915
4045
  * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
3916
4046
  */
3917
- export function approveRunCompletion(root, config) {
4047
+ export function approveRunCompletion(root, config, opts = {}) {
3918
4048
  const state = readState(root);
3919
4049
  if (!state) {
3920
4050
  return { ok: false, error: 'No governed state.json found' };
@@ -3928,6 +4058,23 @@ export function approveRunCompletion(root, config) {
3928
4058
 
3929
4059
  const completion = state.pending_run_completion;
3930
4060
 
4061
+ if (opts.dryRun) {
4062
+ const gateActions = executeGateActions(root, config, {
4063
+ gateId: completion.gate,
4064
+ gateType: 'run_completion',
4065
+ phase: state.phase,
4066
+ requestedByTurn: completion.requested_by_turn || null,
4067
+ triggerCommand: 'approve-completion',
4068
+ }, { dryRun: true });
4069
+ return {
4070
+ ok: true,
4071
+ dry_run: true,
4072
+ state: attachLegacyCurrentTurnAlias(state),
4073
+ completion,
4074
+ gate_actions: gateActions.actions,
4075
+ };
4076
+ }
4077
+
3931
4078
  // ── before_gate hooks ──────────────────────────────────────────────
3932
4079
  const hooksConfig = config?.hooks || {};
3933
4080
  if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
@@ -3969,6 +4116,32 @@ export function approveRunCompletion(root, config) {
3969
4116
  }
3970
4117
  }
3971
4118
 
4119
+ const gateActions = executeGateActions(root, config, {
4120
+ gateId: completion.gate,
4121
+ gateType: 'run_completion',
4122
+ phase: state.phase,
4123
+ requestedByTurn: completion.requested_by_turn || null,
4124
+ triggerCommand: 'approve-completion',
4125
+ });
4126
+
4127
+ if (!gateActions.ok) {
4128
+ for (const entry of gateActions.actions || []) {
4129
+ appendJsonl(root, LEDGER_PATH, entry);
4130
+ }
4131
+ const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
4132
+ return {
4133
+ ok: false,
4134
+ error: gateActions.error,
4135
+ error_code: 'gate_action_failed',
4136
+ state: blockedState,
4137
+ gateActionRun: gateActions,
4138
+ };
4139
+ }
4140
+
4141
+ for (const entry of gateActions.actions || []) {
4142
+ appendJsonl(root, LEDGER_PATH, entry);
4143
+ }
4144
+
3972
4145
  const updatedState = {
3973
4146
  ...state,
3974
4147
  status: 'completed',
@@ -3984,6 +4157,7 @@ export function approveRunCompletion(root, config) {
3984
4157
  };
3985
4158
 
3986
4159
  writeState(root, updatedState);
4160
+ clearSlaReminders(root, 'pending_run_completion');
3987
4161
 
3988
4162
  emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
3989
4163
  completed_at: updatedState.completed_at,
@@ -4014,6 +4188,7 @@ export function approveRunCompletion(root, config) {
4014
4188
  ok: true,
4015
4189
  state: attachLegacyCurrentTurnAlias(updatedState),
4016
4190
  completion,
4191
+ gateActionRun: gateActions,
4017
4192
  };
4018
4193
  }
4019
4194
 
@@ -28,6 +28,7 @@ import {
28
28
  isWorkflowKitPhaseTemplateId,
29
29
  VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS,
30
30
  } from './workflow-kit-phase-templates.js';
31
+ import { validateGateActionsConfig } from './gate-actions.js';
31
32
 
32
33
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
33
34
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
@@ -501,7 +502,8 @@ export function validateV4Config(data, projectRoot) {
501
502
 
502
503
  // Gates (optional but validated if present)
503
504
  if (data.gates) {
504
- if (data.routing) {
505
+ validateGateActionsConfig(data.gates, errors);
506
+ if (data.gates && typeof data.gates === 'object' && !Array.isArray(data.gates) && data.routing) {
505
507
  for (const [, route] of Object.entries(data.routing)) {
506
508
  if (route.exit_gate && !data.gates[route.exit_gate]) {
507
509
  errors.push(`Routing references unknown gate: "${route.exit_gate}"`);
@@ -520,6 +522,9 @@ export function validateV4Config(data, projectRoot) {
520
522
  if (data.notifications) {
521
523
  const notificationValidation = validateNotificationsConfig(data.notifications);
522
524
  errors.push(...notificationValidation.errors);
525
+ if (Array.isArray(notificationValidation.warnings)) {
526
+ warnings.push(...notificationValidation.warnings);
527
+ }
523
528
  }
524
529
 
525
530
  // Schedules (optional but validated if present)
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
@@ -13,6 +13,7 @@ export const VALID_NOTIFICATION_EVENTS = [
13
13
  'phase_transition_pending',
14
14
  'run_completion_pending',
15
15
  'run_completed',
16
+ 'approval_sla_reminder',
16
17
  ];
17
18
 
18
19
  const NOTIFICATION_NAME_RE = /^[a-z0-9_-]+$/;
@@ -166,10 +167,10 @@ export function validateNotificationsConfig(notifications) {
166
167
 
167
168
  if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications)) {
168
169
  errors.push('notifications must be an object');
169
- return { ok: false, errors };
170
+ return { ok: false, errors, warnings: [] };
170
171
  }
171
172
 
172
- const allowedKeys = new Set(['webhooks']);
173
+ const allowedKeys = new Set(['webhooks', 'approval_sla']);
173
174
  for (const key of Object.keys(notifications)) {
174
175
  if (!allowedKeys.has(key)) {
175
176
  errors.push(`notifications contains unknown field "${key}"`);
@@ -177,12 +178,12 @@ export function validateNotificationsConfig(notifications) {
177
178
  }
178
179
 
179
180
  if (!('webhooks' in notifications)) {
180
- return { ok: errors.length === 0, errors };
181
+ return { ok: errors.length === 0, errors, warnings: [] };
181
182
  }
182
183
 
183
184
  if (!Array.isArray(notifications.webhooks)) {
184
185
  errors.push('notifications.webhooks must be an array');
185
- return { ok: false, errors };
186
+ return { ok: false, errors, warnings: [] };
186
187
  }
187
188
 
188
189
  if (notifications.webhooks.length > MAX_NOTIFICATION_WEBHOOKS) {
@@ -261,7 +262,51 @@ export function validateNotificationsConfig(notifications) {
261
262
  }
262
263
  });
263
264
 
264
- return { ok: errors.length === 0, errors };
265
+ // Validate approval_sla if present
266
+ const warnings = [];
267
+ if (notifications.approval_sla !== undefined) {
268
+ const sla = notifications.approval_sla;
269
+ if (!sla || typeof sla !== 'object' || Array.isArray(sla)) {
270
+ errors.push('notifications.approval_sla must be an object');
271
+ } else {
272
+ const slaAllowed = new Set(['enabled', 'reminder_after_seconds']);
273
+ for (const key of Object.keys(sla)) {
274
+ if (!slaAllowed.has(key)) {
275
+ errors.push(`notifications.approval_sla contains unknown field "${key}"`);
276
+ }
277
+ }
278
+ if ('enabled' in sla && typeof sla.enabled !== 'boolean') {
279
+ errors.push('notifications.approval_sla.enabled must be a boolean');
280
+ }
281
+ if (!Array.isArray(sla.reminder_after_seconds) || sla.reminder_after_seconds.length === 0) {
282
+ errors.push('notifications.approval_sla.reminder_after_seconds must be a non-empty array of positive integers');
283
+ } else {
284
+ if (sla.reminder_after_seconds.length > 10) {
285
+ errors.push('notifications.approval_sla.reminder_after_seconds: maximum 10 thresholds');
286
+ }
287
+ let prev = 0;
288
+ for (let i = 0; i < sla.reminder_after_seconds.length; i++) {
289
+ const v = sla.reminder_after_seconds[i];
290
+ if (!Number.isInteger(v) || v <= 0) {
291
+ errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: must be a positive integer`);
292
+ } else if (v < 300) {
293
+ errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: minimum value is 300 (5 minutes)`);
294
+ } else if (v <= prev) {
295
+ errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: values must be strictly ascending`);
296
+ }
297
+ prev = v;
298
+ }
299
+ }
300
+ // Warn if no webhook subscribes to approval_sla_reminder
301
+ const hasSubscriber = Array.isArray(notifications.webhooks) &&
302
+ notifications.webhooks.some(w => Array.isArray(w.events) && w.events.includes('approval_sla_reminder'));
303
+ if (!hasSubscriber) {
304
+ warnings.push('notifications.approval_sla is configured but no webhook subscribes to "approval_sla_reminder"');
305
+ }
306
+ }
307
+ }
308
+
309
+ return { ok: errors.length === 0, errors, warnings };
265
310
  }
266
311
 
267
312
  export function emitNotifications(root, config, state, eventType, payload = {}, turn = null) {
@@ -328,3 +373,114 @@ export function emitNotifications(root, config, state, eventType, payload = {},
328
373
 
329
374
  return { ok: true, event_id: eventId, results };
330
375
  }
376
+
377
+ const SLA_REMINDERS_PATH = '.agentxchain/sla-reminders.json';
378
+
379
+ function readSlaReminders(root) {
380
+ try {
381
+ const filePath = join(root, SLA_REMINDERS_PATH);
382
+ if (!existsSync(filePath)) return [];
383
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
384
+ return Array.isArray(data) ? data : [];
385
+ } catch { return []; }
386
+ }
387
+
388
+ function writeSlaReminders(root, sent) {
389
+ const filePath = join(root, SLA_REMINDERS_PATH);
390
+ mkdirSync(dirname(filePath), { recursive: true });
391
+ writeFileSync(filePath, JSON.stringify(sent, null, 2) + '\n');
392
+ }
393
+
394
+ /**
395
+ * Evaluate and emit approval SLA reminders for pending approvals.
396
+ * Uses `.agentxchain/sla-reminders.json` for dedup tracking (not governed state).
397
+ * Returns { reminders_sent: string[], notifications_emitted: number }.
398
+ */
399
+ export function evaluateApprovalSlaReminders(root, config, state) {
400
+ const result = { reminders_sent: [], notifications_emitted: 0 };
401
+
402
+ const sla = config?.notifications?.approval_sla;
403
+ if (!sla || sla.enabled === false) return result;
404
+ if (!Array.isArray(sla.reminder_after_seconds) || sla.reminder_after_seconds.length === 0) return result;
405
+
406
+ const webhooks = config?.notifications?.webhooks;
407
+ if (!Array.isArray(webhooks) || !webhooks.some(w => Array.isArray(w.events) && w.events.includes('approval_sla_reminder'))) {
408
+ return result;
409
+ }
410
+
411
+ if (!state) return result;
412
+
413
+ const sent = readSlaReminders(root);
414
+ const now = Date.now();
415
+ const pendingApprovals = [];
416
+
417
+ if (state.pending_phase_transition && state.pending_phase_transition.requested_at) {
418
+ pendingApprovals.push({
419
+ approval_type: 'pending_phase_transition',
420
+ requested_at: state.pending_phase_transition.requested_at,
421
+ from_phase: state.pending_phase_transition.from || null,
422
+ to_phase: state.pending_phase_transition.to || null,
423
+ gate: state.pending_phase_transition.gate || null,
424
+ });
425
+ }
426
+
427
+ if (state.pending_run_completion && state.pending_run_completion.requested_at) {
428
+ pendingApprovals.push({
429
+ approval_type: 'pending_run_completion',
430
+ requested_at: state.pending_run_completion.requested_at,
431
+ from_phase: null,
432
+ to_phase: null,
433
+ gate: state.pending_run_completion.gate || null,
434
+ });
435
+ }
436
+
437
+ let changed = false;
438
+ for (const approval of pendingApprovals) {
439
+ const requestedMs = new Date(approval.requested_at).getTime();
440
+ if (isNaN(requestedMs)) continue;
441
+ const elapsedSeconds = Math.floor((now - requestedMs) / 1000);
442
+
443
+ for (let i = 0; i < sla.reminder_after_seconds.length; i++) {
444
+ const threshold = sla.reminder_after_seconds[i];
445
+ const reminderKey = `${approval.approval_type}:${threshold}`;
446
+
447
+ if (elapsedSeconds >= threshold && !sent.includes(reminderKey)) {
448
+ const payload = {
449
+ approval_type: approval.approval_type,
450
+ requested_at: approval.requested_at,
451
+ elapsed_seconds: elapsedSeconds,
452
+ threshold_seconds: threshold,
453
+ reminder_index: i + 1,
454
+ total_thresholds: sla.reminder_after_seconds.length,
455
+ };
456
+ if (approval.from_phase) payload.from_phase = approval.from_phase;
457
+ if (approval.to_phase) payload.to_phase = approval.to_phase;
458
+ if (approval.gate) payload.gate = approval.gate;
459
+
460
+ emitNotifications(root, config, state, 'approval_sla_reminder', payload);
461
+ sent.push(reminderKey);
462
+ result.reminders_sent.push(reminderKey);
463
+ result.notifications_emitted++;
464
+ changed = true;
465
+ }
466
+ }
467
+ }
468
+
469
+ if (changed) {
470
+ writeSlaReminders(root, sent);
471
+ }
472
+
473
+ return result;
474
+ }
475
+
476
+ /**
477
+ * Clear SLA reminder tracking for a resolved approval type.
478
+ * Called from approve-transition / approve-completion.
479
+ */
480
+ export function clearSlaReminders(root, approvalType) {
481
+ const sent = readSlaReminders(root);
482
+ const filtered = sent.filter(k => !k.startsWith(`${approvalType}:`));
483
+ if (filtered.length !== sent.length) {
484
+ writeSlaReminders(root, filtered);
485
+ }
486
+ }