@goodandready/dsh-key-rotation 0.8.5 → 0.8.6

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.
@@ -90,4 +90,22 @@ export class CircuitBreaker {
90
90
  if (provider) this._st.delete(provider);
91
91
  else this._st.clear();
92
92
  }
93
- }
93
+
94
+ /** Restore entries from a persistence snapshot (#287). */
95
+ restore(snapshot) {
96
+ if (!snapshot || typeof snapshot !== 'object') return 0;
97
+ let n = 0;
98
+ for (const [provider, e] of Object.entries(snapshot)) {
99
+ if (!e || typeof e !== 'object') continue;
100
+ const state = e.state === BREAKER_OPEN || e.state === BREAKER_HALF_OPEN ? e.state : BREAKER_CLOSED;
101
+ this._st.set(provider, {
102
+ state,
103
+ fails: Number.isFinite(e.fails) ? e.fails : 0,
104
+ openedAt: Number.isFinite(e.openedAt) ? e.openedAt : 0,
105
+ probes: 0,
106
+ });
107
+ n += 1;
108
+ }
109
+ return n;
110
+ }
111
+ }
package/lib/client.js CHANGED
@@ -116,6 +116,31 @@ window.__ModuleLoader__.load({
116
116
  undoProvider: 'Provider removed',
117
117
  undoKey: 'Key removed',
118
118
  undo: 'Undo',
119
+ // #289 a11y / #290 states / #291 bulk / #292 locale
120
+ emptyTitle: 'No pools yet',
121
+ emptyDesc: 'Add a provider and at least one API key name to start rotating.',
122
+ errorTitle: 'Could not load settings',
123
+ retry: 'Retry',
124
+ unavailableTitle: 'Settings bridge unavailable',
125
+ unavailableDesc: 'The host did not expose the dsh-key-rotation settings namespace. Reopen Settings or check the plugin is enabled.',
126
+ modalClose: 'Close dialog',
127
+ dialogLabel: 'Confirmation',
128
+ loadChartAria: 'Key traffic distribution',
129
+ bulkCooldownPlaceholder: 'Bulk cooldown (ms)',
130
+ bulkApply: 'Apply to selected',
131
+ bulkRemove: 'Remove selected',
132
+ confirmBulkRemoveTitle: 'Remove {n} provider(s)?',
133
+ confirmBulkRemoveDesc: 'Selected providers and all their keys will be removed from rotation. You can undo once immediately after.',
134
+ selectProvider: 'Select provider {p}',
135
+ activeBadge: 'active',
136
+ healthScoreTitle: 'health score',
137
+ totalRequestsTitle: 'total requests',
138
+ exportProvider: 'Export this provider',
139
+ recentFailures: 'Recent failures ({n})',
140
+ noActivePools: t('noActivePools'),
141
+ headerPoolsTitle: 'Key rotation pools',
142
+ pausedBudget: 'paused',
143
+ sortUsage: 'Sort by usage',
119
144
  };
120
145
 
121
146
  // Коды, на которых имеет смысл переключать ключ. Список из хоста
@@ -409,6 +434,12 @@ window.__ModuleLoader__.load({
409
434
  getScopeSnapshot
410
435
  );
411
436
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
437
+ // #290: surface settingsScope availability as card state
438
+ React.useEffect(() => {
439
+ if (!settingsScope) {
440
+ setState((s) => (s.status === 'unavailable' ? s : { ...s, status: 'unavailable' }));
441
+ }
442
+ }, [settingsScope]);
412
443
  const [draft, setDraft] = React.useState(null);
413
444
  // ── all hooks live ABOVE any early return (React error 310 otherwise) ──
414
445
  const [search, setSearch] = React.useState('');
@@ -419,6 +450,46 @@ window.__ModuleLoader__.load({
419
450
  const [bulkCooldown, setBulkCooldown] = React.useState('');
420
451
  const [undo, setUndo] = React.useState(null);
421
452
  const undoTimer = React.useRef(null);
453
+ // #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
454
+ const modalReturnFocusRef = React.useRef(null);
455
+ const modalCardRef = React.useRef(null);
456
+ React.useEffect(() => {
457
+ if (!confirmModal) return undefined;
458
+ modalReturnFocusRef.current = typeof document !== 'undefined' ? document.activeElement : null;
459
+ const onKey = (e) => {
460
+ if (e.key === 'Escape') {
461
+ e.stopPropagation();
462
+ setConfirmModal(null);
463
+ return;
464
+ }
465
+ if (e.key !== 'Tab' || !modalCardRef.current) return;
466
+ const focusables = modalCardRef.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
467
+ if (!focusables.length) return;
468
+ const first = focusables[0];
469
+ const last = focusables[focusables.length - 1];
470
+ if (e.shiftKey && document.activeElement === first) {
471
+ e.preventDefault();
472
+ last.focus();
473
+ } else if (!e.shiftKey && document.activeElement === last) {
474
+ e.preventDefault();
475
+ first.focus();
476
+ }
477
+ };
478
+ document.addEventListener('keydown', onKey, true);
479
+ const id = requestAnimationFrame(() => {
480
+ if (!modalCardRef.current) return;
481
+ const cancelBtn = modalCardRef.current.querySelector('[data-krot-modal-cancel]');
482
+ (cancelBtn || modalCardRef.current.querySelector('button'))?.focus();
483
+ });
484
+ return () => {
485
+ document.removeEventListener('keydown', onKey, true);
486
+ cancelAnimationFrame(id);
487
+ const prev = modalReturnFocusRef.current;
488
+ if (prev && typeof prev.focus === 'function') {
489
+ try { prev.focus(); } catch (_) {}
490
+ }
491
+ };
492
+ }, [confirmModal]);
422
493
  const [testing, setTesting] = React.useState('');
423
494
  const [testResult, setTestResult] = React.useState({});
424
495
  const [testAllProvider, setTestAllProvider] = React.useState('');
@@ -586,8 +657,31 @@ window.__ModuleLoader__.load({
586
657
  })
587
658
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
588
659
  };
589
- if (state.status === 'loading' || !val) {
590
- return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, t('loading'));
660
+ // #290: explicit card states — loading / error / unavailable / empty / ready
661
+ if (state.status === 'loading' || (!val && state.status !== 'error' && state.status !== 'unavailable')) {
662
+ return React.createElement('div', { className: 'krot-state-block', role: 'status', 'aria-live': 'polite', style: { padding: '18px 4px', display: 'flex', flexDirection: 'column', gap: '8px' } },
663
+ React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, margin: 0 } }, t('loading')),
664
+ React.createElement('div', {
665
+ className: 'krot-skeleton',
666
+ 'aria-hidden': 'true',
667
+ style: {
668
+ height: 10, borderRadius: 999, background: 'linear-gradient(90deg, var(--dsw-alias-bg-layer-2), var(--dsw-alias-bg-layer-3), var(--dsw-alias-bg-layer-2))',
669
+ backgroundSize: '200% 100%', animation: 'krot-shimmer 1.2s ease-in-out infinite', maxWidth: 280,
670
+ },
671
+ })
672
+ );
673
+ }
674
+ if (state.status === 'error') {
675
+ return React.createElement('div', { className: 'krot-state-block', role: 'alert', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-start' } },
676
+ React.createElement('p', { className: 'krot-err', style: { margin: 0 } }, t('errorTitle') + (state.error ? (': ' + state.error) : '')),
677
+ React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
678
+ );
679
+ }
680
+ if (state.status === 'unavailable' || !settingsScope) {
681
+ return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
682
+ React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
683
+ React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
684
+ );
591
685
  }
592
686
 
593
687
  const providers = state.providers;
@@ -914,7 +1008,7 @@ window.__ModuleLoader__.load({
914
1008
  const parts = [];
915
1009
  if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
916
1010
  if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
917
- if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· paused');
1011
+ if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· ' + t('pausedBudget'));
918
1012
  return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
919
1013
  })()
920
1014
  : null;
@@ -956,6 +1050,8 @@ window.__ModuleLoader__.load({
956
1050
  return h('div', { className: 'krot-load-chart' },
957
1051
  h('div', { className: 'krot-load-header' },
958
1052
  h('span', { style: { fontWeight: 600 } }, '📊 ' + t('loadDistribution')),
1053
+ h('span', { className: 'krot-sr-only', style: { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 } },
1054
+ t('loadChartAria') + ': ' + (total > 0 ? keyUsages.map((x) => t('keyLabel').replace('{n}', String(x.index + 1)) + ' ' + x.usage).join(', ') : t('noTrafficYet'))),
959
1055
  h('span', null, total > 0 ? (total + ' ' + t('statRequests')) : t('noTrafficYet'))
960
1056
  ),
961
1057
  h('div', { className: 'krot-load-bar' },
@@ -988,13 +1084,18 @@ window.__ModuleLoader__.load({
988
1084
 
989
1085
  return h('div', { key: pIndex, className: 'krot-prov' },
990
1086
  h('div', { className: 'krot-prov-head' },
991
- h('input', { type: 'checkbox', checked: selected.has(entry.provider), onChange: (e) => { const ns = new Set(selected); if (e.target.checked) ns.add(entry.provider); else ns.delete(entry.provider); setSelected(ns); } }),
1087
+ h('input', {
1088
+ type: 'checkbox',
1089
+ 'aria-label': t('selectProvider').replace('{p}', entry.provider),
1090
+ checked: selected.has(entry.provider),
1091
+ onChange: (e) => { const ns = new Set(selected); if (e.target.checked) ns.add(entry.provider); else ns.delete(entry.provider); setSelected(ns); },
1092
+ }),
992
1093
  btn(t('exportOne'), () => {
993
1094
  const data = JSON.stringify([entry], null, 2);
994
1095
  const blob = new Blob([data], { type: 'application/json' });
995
1096
  const url = URL.createObjectURL(blob);
996
1097
  const a = document.createElement('a'); a.href = url; a.download = entry.provider + '.json'; a.click(); URL.revokeObjectURL(url);
997
- }, { title: 'Export this provider' }),
1098
+ }, { title: t('exportProvider') }),
998
1099
  btn('⇅', () => {
999
1100
  const ps = status[entry.provider];
1000
1101
  if (!ps || !Array.isArray(ps.keys)) return;
@@ -1006,16 +1107,16 @@ window.__ModuleLoader__.load({
1006
1107
  next[pIndex] = { ...next[pIndex], keys: sorted };
1007
1108
  return { ...cur, providers: next };
1008
1109
  });
1009
- }, { title: 'Sort by usage' }),
1110
+ }, { title: t('sortUsage') }),
1010
1111
  h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
1011
1112
  (() => {
1012
1113
  const ps = status[entry.provider];
1013
1114
  const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
1014
1115
  if (score === null) return null;
1015
1116
  const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-error-primary)';
1016
- return h('span', { className: 'krot-tail', title: 'health score', style: { flex: 'none', color, fontWeight: 700 } }, String(score));
1117
+ return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
1017
1118
  })(),
1018
- (() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: 'total requests', style: { flex: 'none' } }, String(tot)) : null; })(),
1119
+ (() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: t('totalRequestsTitle'), style: { flex: 'none' } }, String(tot)) : null; })(),
1019
1120
  btn('✕', () => setConfirmModal({
1020
1121
  title: t('confirmRemoveProvTitle').replace('{p}', entry.provider),
1021
1122
  desc: t('confirmRemoveProvDesc'),
@@ -1036,7 +1137,7 @@ window.__ModuleLoader__.load({
1036
1137
  (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
1037
1138
  // #224: 7-day switches per day (client-side, from the same events)
1038
1139
  (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-state-info-primary, var(--dsw-alias-state-warning-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
1039
- (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, 'Recent failures ('+providerStatus.events.length+')'), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i, style: ev.type === 'probe' ? { opacity: .5 } : null }, new Date(ev.at).toLocaleTimeString() + ' ' + (ev.type === 'probe' ? '[probe] ' : '') + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
1140
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, t('recentFailures').replace('{n}', String(providerStatus.events.length))), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i, style: ev.type === 'probe' ? { opacity: .5 } : null }, new Date(ev.at).toLocaleTimeString() + ' ' + (ev.type === 'probe' ? '[probe] ' : '') + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
1040
1141
  btn(t('resetCooldown'), () => setConfirmModal({
1041
1142
  title: t('confirmResetTitle').replace('{p}', entry.provider),
1042
1143
  desc: t('confirmResetDesc'),
@@ -1051,7 +1152,11 @@ window.__ModuleLoader__.load({
1051
1152
  });
1052
1153
 
1053
1154
  const noProviders = providers.length === 0
1054
- ? h('p', { className: 'krot-err' }, t('noProviders'))
1155
+ ? h('div', { className: 'krot-empty', role: 'status', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '16px 14px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)' } },
1156
+ h('p', { style: { margin: 0, fontWeight: 600, fontSize: 14, color: 'var(--dsw-alias-label-primary)' } }, t('emptyTitle')),
1157
+ h('p', { className: 'krot-hint', style: { margin: 0 } }, t('emptyDesc')),
1158
+ h('p', { className: 'krot-hint', style: { margin: 0 } }, t('noProviders')),
1159
+ )
1055
1160
  : null;
1056
1161
 
1057
1162
  const allConfiguredProviders = Array.isArray(val?.providers) ? val.providers : [];
@@ -1102,19 +1207,36 @@ window.__ModuleLoader__.load({
1102
1207
  h('div', { className: 'krot-foot', style: { marginBottom: '8px' } },
1103
1208
  h('input', {
1104
1209
  className: 'krot-in',
1105
- placeholder: 'Bulk cooldown ms',
1210
+ placeholder: t('bulkCooldownPlaceholder'),
1106
1211
  value: bulkCooldown,
1107
1212
  onChange: (e) => setBulkCooldown(e.target.value),
1108
1213
  style: { maxWidth: '160px' },
1109
1214
  }),
1110
- btn('Apply to selected', () => {
1215
+ btn(t('bulkApply'), () => {
1111
1216
  const v = Number(bulkCooldown); if (!v) return;
1112
1217
  setField((cur) => {
1113
1218
  const next = [...(cur.providers ?? [])];
1114
1219
  for (let i = 0; i < next.length; i++) if (selected.has(next[i].provider)) next[i] = { ...next[i], cooldownMs: v };
1115
1220
  return { ...cur, providers: next };
1116
1221
  });
1117
- }, { disabled: selected.size === 0 || !bulkCooldown })
1222
+ }, { disabled: selected.size === 0 || !bulkCooldown }),
1223
+ btn(t('bulkRemove'), () => {
1224
+ const ids = [...selected];
1225
+ if (!ids.length) return;
1226
+ setConfirmModal({
1227
+ title: t('confirmBulkRemoveTitle').replace('{n}', String(ids.length)),
1228
+ desc: t('confirmBulkRemoveDesc'),
1229
+ actionLabel: t('bulkRemove'),
1230
+ danger: true,
1231
+ onConfirm: () => {
1232
+ setField((cur) => {
1233
+ const next = (cur.providers ?? []).filter((p) => !ids.has(p.provider));
1234
+ return { ...cur, providers: next };
1235
+ });
1236
+ setSelected(new Set());
1237
+ },
1238
+ });
1239
+ }, { disabled: selected.size === 0, className: 'krot-btn-danger' })
1118
1240
  ),
1119
1241
  h('div', { className: 'krot-keys' }, providerRows),
1120
1242
  h('div', { className: 'krot-foot', style: { marginTop: '10px' } },
@@ -1126,7 +1248,7 @@ window.__ModuleLoader__.load({
1126
1248
  const failoverSection = h('div', { className: 'krot-section-card' },
1127
1249
  h('div', { className: 'krot-section-title' },
1128
1250
  h('span', null, '⚡ ' + t('codesTitle')),
1129
- h('span', { className: 'krot-badge krot-badge-warn' }, selectedCodes.size + ' active')
1251
+ h('span', { className: 'krot-badge krot-badge-warn' }, selectedCodes.size + ' ' + t('activeBadge'))
1130
1252
  ),
1131
1253
  h('div', { className: 'krot-section-desc' }, t('sectionFailoverDesc')),
1132
1254
  h('div', { className: 'krot-codes' }, codeList.map((code) => h('label', { key: code, className: 'krot-code' },
@@ -1211,12 +1333,23 @@ window.__ModuleLoader__.load({
1211
1333
  ) : null,
1212
1334
  ].filter(Boolean);
1213
1335
 
1214
- const confirmModalEl = confirmModal ? h('div', { className: 'krot-modal-backdrop', onClick: () => setConfirmModal(null) },
1215
- h('div', { className: 'krot-modal-card', onClick: (e) => e.stopPropagation() },
1216
- h('div', { className: 'krot-modal-title' }, confirmModal.title),
1217
- h('div', { className: 'krot-modal-desc' }, confirmModal.desc),
1336
+ const confirmModalEl = confirmModal ? h('div', {
1337
+ className: 'krot-modal-backdrop',
1338
+ onClick: () => setConfirmModal(null),
1339
+ },
1340
+ h('div', {
1341
+ className: 'krot-modal-card',
1342
+ ref: modalCardRef,
1343
+ role: 'dialog',
1344
+ 'aria-modal': 'true',
1345
+ 'aria-labelledby': 'krot-modal-title',
1346
+ 'aria-describedby': 'krot-modal-desc',
1347
+ onClick: (e) => e.stopPropagation(),
1348
+ },
1349
+ h('div', { className: 'krot-modal-title', id: 'krot-modal-title' }, confirmModal.title),
1350
+ h('div', { className: 'krot-modal-desc', id: 'krot-modal-desc' }, confirmModal.desc),
1218
1351
  h('div', { className: 'krot-modal-actions' },
1219
- btn(t('cancel'), () => setConfirmModal(null)),
1352
+ btn(t('cancel'), () => setConfirmModal(null), { 'data-krot-modal-cancel': '1' }),
1220
1353
  btn(confirmModal.actionLabel || t('confirm'), () => {
1221
1354
  const fn = confirmModal.onConfirm;
1222
1355
  setConfirmModal(null);
@@ -1301,7 +1434,7 @@ window.__ModuleLoader__.load({
1301
1434
  h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
1302
1435
  ),
1303
1436
  open ? h('div', { className: 'krot-popover' },
1304
- h('div', { className: 'krot-pop-title' }, 'Key rotation pools'),
1437
+ h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
1305
1438
  !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'No active pools') : null,
1306
1439
  poolEntries.map(([name, p]) => {
1307
1440
  const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)');
package/lib/index.js CHANGED
@@ -49,6 +49,8 @@ import { BoundedMap } from './bounded-map.js';
49
49
  import { classifyFailure } from './error-taxonomy.js';
50
50
  import { safeParseJson } from './atomic-io.js';
51
51
  import { registerOpsRoutes } from './routes-ops.js';
52
+ import { StatePersistence, resolveStatePath } from './persistence.js';
53
+ import path from 'node:path';
52
54
 
53
55
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
54
56
  const PIAI_NS = 'llm-pi-ai';
@@ -113,6 +115,9 @@ let moduleBreaker = null;
113
115
  let moduleNotifyQueue = null;
114
116
  // Global config accessor safe against early initialization
115
117
  let getConfig = () => null;
118
+ function verboseLoggingOn() {
119
+ try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
120
+ }
116
121
  let getRuntime = () => null;
117
122
  let sandboxRunner = null;
118
123
  const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
@@ -204,6 +209,9 @@ export const Config = Schema.object({
204
209
  circuitBreakerThreshold: Schema.number().default(5),
205
210
  circuitBreakerOpenMs: Schema.number().default(30000),
206
211
  circuitBreakerHalfOpenProbes: Schema.number().default(1),
212
+ // #287 persistence across restarts
213
+ persistenceEnabled: Schema.boolean().default(true),
214
+ persistencePath: Schema.string().default(''),
207
215
  switchNotifyThrottleMs: Schema.number().default(60000),
208
216
  warnBelowHealthy: Schema.number().default(0),
209
217
  latencySloMs: Schema.number().default(0),
@@ -278,6 +286,77 @@ export function apply(ctx, config = {}) {
278
286
  // ── key-pool state, persisted across config reloads ──
279
287
  // base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
280
288
  const poolState = new Map();
289
+
290
+ // #287: persist cooldowns / circuit / quota across DSH reloads.
291
+ // Path resolution: explicit config -> host data dir -> DSH_HOME -> cwd.
292
+ // Disabled (with a single warn) when no writable directory can be found.
293
+ let statePersistence = null;
294
+ try {
295
+ const cfg0 = getConfig() ?? config ?? {};
296
+ // Cordis ctx properties require inject; bare getters throw.
297
+ // Use only host env/cwd so apply() never trips the injector.
298
+ const hostDirs = [
299
+ process.env.DSH_HOME,
300
+ process.cwd(),
301
+ ].filter((d) => typeof d === 'string' && d.length > 0);
302
+ const resolvedPath = resolveStatePath({
303
+ configuredPath: cfg0.persistencePath,
304
+ dataDir: hostDirs[0],
305
+ });
306
+ if (cfg0.persistenceEnabled !== false && resolvedPath) {
307
+ statePersistence = new StatePersistence({ filePath: resolvedPath });
308
+ statePersistence.load().then((snap) => {
309
+ if (!snap) return;
310
+ try {
311
+ StatePersistence.restorePools(poolState, snap);
312
+ if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
313
+ if (verboseLoggingOn()) {
314
+ console.warn(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
315
+ }
316
+ } catch (e) {
317
+ console.warn('[dsh-key-rotation] persistence restore failed', e?.message ?? e);
318
+ }
319
+ }).catch(() => {});
320
+ } else if (cfg0.persistenceEnabled !== false && !resolvedPath) {
321
+ console.warn('[dsh-key-rotation] persistence disabled: no data directory');
322
+ }
323
+ } catch (e) {
324
+ console.warn('[dsh-key-rotation] persistence init failed', e?.message ?? e);
325
+ }
326
+
327
+ function persistenceSnapshot() {
328
+ if (!statePersistence) return null;
329
+ return StatePersistence.serialize({
330
+ poolState,
331
+ circuitSnapshot: moduleBreaker ? moduleBreaker.snapshot() : {},
332
+ quotaSnapshot: {},
333
+ });
334
+ }
335
+
336
+ function schedulePersist() {
337
+ if (!statePersistence) return;
338
+ const snap = persistenceSnapshot();
339
+ if (snap) statePersistence.save(snap);
340
+ }
341
+
342
+ ctx.effect(() => {
343
+ const timer = setInterval(() => { try { schedulePersist(); } catch (_) {} }, 15000);
344
+ if (typeof timer.unref === 'function') timer.unref();
345
+ return () => {
346
+ clearInterval(timer);
347
+ try {
348
+ if (statePersistence) {
349
+ const snap = persistenceSnapshot();
350
+ if (snap) {
351
+ statePersistence.save(snap);
352
+ // best-effort flush; dispose clears the debounce timer
353
+ void statePersistence.flush();
354
+ }
355
+ statePersistence.dispose();
356
+ }
357
+ } catch (_) {}
358
+ };
359
+ }, 'dsh-key-rotation: state persistence');
281
360
  // Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
282
361
  ctx.effect(() => {
283
362
  const id = setInterval(() => {
@@ -786,6 +865,7 @@ export function apply(ctx, config = {}) {
786
865
  if (ref) {
787
866
  const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft, true);
788
867
  pushEvent(pool, ref, code || 'UNKNOWN', backoff);
868
+ schedulePersist();
789
869
  pool.state.switches = (pool.state.switches ?? 0) + 1;
790
870
  pool.state.lastReason = code || 'UNKNOWN';
791
871
  pool.state.lastSwitchAt = Date.now();
@@ -0,0 +1,146 @@
1
+ // lib/persistence.js — atomic snapshot of rotation state across restarts (#287).
2
+ // Cooldowns, circuit-breaker entries and quota counters live in memory; a DSH
3
+ // reload would otherwise forget cooldowns and stampede recovered keys.
4
+
5
+ import path from 'node:path';
6
+ import { atomicWriteJson, safeReadJson } from './atomic-io.js';
7
+
8
+ const DEFAULT_FILE = 'dsh-key-rotation-state.json';
9
+ const SAVE_DEBOUNCE_MS = 400;
10
+
11
+ /**
12
+ * @param {{ filePath: string, now?: () => number }} opts
13
+ */
14
+ export class StatePersistence {
15
+ constructor({ filePath, now = Date.now } = {}) {
16
+ if (!filePath || typeof filePath !== 'string') {
17
+ throw new Error('StatePersistence: filePath is required');
18
+ }
19
+ this.filePath = filePath;
20
+ this._now = now;
21
+ this._timer = null;
22
+ this._dirty = false;
23
+ this._lastWrite = 0;
24
+ }
25
+
26
+ /** Build a plain snapshot from live maps/objects. */
27
+ static serialize({ poolState, circuitSnapshot, quotaSnapshot }) {
28
+ const pools = {};
29
+ if (poolState && typeof poolState.forEach === 'function') {
30
+ poolState.forEach((st, base) => {
31
+ const failedUntil = {};
32
+ if (st && st.failedUntil && typeof st.failedUntil.forEach === 'function') {
33
+ st.failedUntil.forEach((until, ref) => {
34
+ if (Number.isFinite(until)) failedUntil[ref] = until;
35
+ });
36
+ }
37
+ pools[base] = {
38
+ failedUntil,
39
+ pointer: Number.isFinite(st?.pointer) ? st.pointer : 0,
40
+ lastUsed: typeof st?.lastUsed === 'string' ? st.lastUsed : null,
41
+ };
42
+ });
43
+ }
44
+ return {
45
+ version: 1,
46
+ savedAt: Date.now(),
47
+ pools,
48
+ circuit: circuitSnapshot && typeof circuitSnapshot === 'object' ? circuitSnapshot : {},
49
+ quota: quotaSnapshot && typeof quotaSnapshot === 'object' ? quotaSnapshot : {},
50
+ };
51
+ }
52
+
53
+ _nowSafe() {
54
+ const n = Number(this._now?.() ?? Date.now());
55
+ return Number.isFinite(n) ? n : Date.now();
56
+ }
57
+
58
+ /**
59
+ * Schedule a debounced atomic write. Never throws to the caller.
60
+ * @param {object} payload result of StatePersistence.serialize
61
+ */
62
+ save(payload) {
63
+ this._pending = payload;
64
+ this._dirty = true;
65
+ if (this._timer) return;
66
+ this._timer = setTimeout(() => {
67
+ this._timer = null;
68
+ void this.flush();
69
+ }, SAVE_DEBOUNCE_MS);
70
+ if (typeof this._timer.unref === 'function') this._timer.unref();
71
+ }
72
+
73
+ async flush() {
74
+ if (!this._dirty || !this._pending) return false;
75
+ const payload = this._pending;
76
+ this._dirty = false;
77
+ this._pending = null;
78
+ try {
79
+ await atomicWriteJson(this.filePath, payload);
80
+ this._lastWrite = this._nowSafe();
81
+ return true;
82
+ } catch {
83
+ // Keep previous good file; mark dirty so a later save retries.
84
+ this._dirty = true;
85
+ this._pending = this._pending || payload;
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Load a snapshot. Corrupt/missing file → null (never wipes memory).
92
+ * @returns {Promise<object|null>}
93
+ */
94
+ async load() {
95
+ const raw = await safeReadJson(this.filePath, null);
96
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
97
+ if (raw.version !== 1) return null;
98
+ return raw;
99
+ }
100
+
101
+ /** Restore poolState Map from a loaded snapshot. */
102
+ static restorePools(poolState, snapshot) {
103
+ if (!poolState || !snapshot || !snapshot.pools || typeof snapshot.pools !== 'object') return 0;
104
+ let n = 0;
105
+ for (const [base, st] of Object.entries(snapshot.pools)) {
106
+ if (!st || typeof st !== 'object') continue;
107
+ const failedUntil = new Map();
108
+ if (st.failedUntil && typeof st.failedUntil === 'object') {
109
+ for (const [ref, until] of Object.entries(st.failedUntil)) {
110
+ if (Number.isFinite(until)) failedUntil.set(ref, until);
111
+ }
112
+ }
113
+ poolState.set(base, {
114
+ failedUntil,
115
+ pointer: Number.isFinite(st.pointer) ? st.pointer : 0,
116
+ lastUsed: typeof st.lastUsed === 'string' ? st.lastUsed : undefined,
117
+ });
118
+ n += 1;
119
+ }
120
+ return n;
121
+ }
122
+
123
+ dispose() {
124
+ if (this._timer) {
125
+ clearTimeout(this._timer);
126
+ this._timer = null;
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Resolve the on-disk state path. Prefer an explicit config path; otherwise
133
+ * place the file under `dataDir` when the host provides one. Returns null when
134
+ * persistence cannot be enabled safely (no writable data directory).
135
+ */
136
+ export function resolveStatePath({ dataDir, configuredPath } = {}) {
137
+ if (configuredPath && typeof configuredPath === 'string' && configuredPath.trim()) {
138
+ return configuredPath.trim();
139
+ }
140
+ if (dataDir && typeof dataDir === 'string' && dataDir.trim()) {
141
+ return path.join(dataDir.trim(), DEFAULT_FILE);
142
+ }
143
+ return null;
144
+ }
145
+
146
+ export default StatePersistence;
package/lib/routes-ops.js CHANGED
@@ -22,6 +22,7 @@ import { usageRows, usageCsv } from './usage-report.js';
22
22
  import { findSecrets, looksLikeApiSecret } from './keycheck.js';
23
23
  import { nextQuotaReset } from './quota-window.js';
24
24
  import { classifyFailure } from './error-taxonomy.js';
25
+ import { sanitizeSnapshot } from './sanitize-snapshot.js';
25
26
 
26
27
  const STATUS_PATH = '/dsh-key-rotation/status';
27
28
  const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
@@ -166,7 +167,7 @@ export function registerOpsRoutes(ctx, deps) {
166
167
  providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
167
168
  }
168
169
  }
169
- json(res, 200, {
170
+ json(res, 200, sanitizeSnapshot({
170
171
  providers,
171
172
  // #266/#263 operational extras (additive)
172
173
  meta: {
@@ -175,7 +176,7 @@ export function registerOpsRoutes(ctx, deps) {
175
176
  breakerEnabled: runtime.circuitBreakerEnabled !== false,
176
177
  at: now,
177
178
  },
178
- });
179
+ }, now));
179
180
  },
180
181
  }), 'dsh-key-rotation: status route');
181
182
 
@@ -0,0 +1,83 @@
1
+ // lib/sanitize-snapshot.js — clamp/repair runtime snapshots before they leave the host (#288).
2
+ // Guards the settings card and ops APIs against NaN, negative remaining times,
3
+ // and inconsistent counters after clock jumps or empty-pool edge cases.
4
+
5
+ function toFiniteNumber(value, fallback = 0) {
6
+ const n = Number(value);
7
+ return Number.isFinite(n) ? n : fallback;
8
+ }
9
+
10
+ function clampNonNegative(value) {
11
+ const n = toFiniteNumber(value, 0);
12
+ return n < 0 ? 0 : n;
13
+ }
14
+
15
+ function clampRemaining(value, nowMs) {
16
+ const n = toFiniteNumber(value, 0);
17
+ if (n <= 0) return 0;
18
+ // If the absolute timestamp is in the past relative to now, remaining is 0.
19
+ if (n > 1e12 && n <= nowMs) return 0;
20
+ // Cooldown remaining should not exceed ~7 days.
21
+ if (n < 1e12 && n > 7 * 86400000) return 7 * 86400000;
22
+ return n;
23
+ }
24
+
25
+ /**
26
+ * Sanitize a per-key status entry.
27
+ * @param {object} key
28
+ * @param {number} now
29
+ */
30
+ export function sanitizeKeyStatus(key, now = Date.now()) {
31
+ if (!key || typeof key !== 'object') return null;
32
+ return {
33
+ ...key,
34
+ present: Boolean(key.present),
35
+ active: Boolean(key.active),
36
+ cooldownMsLeft: clampRemaining(key.cooldownMsLeft, now),
37
+ usage: clampNonNegative(key.usage),
38
+ failures: clampNonNegative(key.failures),
39
+ weight: clampNonNegative(key.weight || 1) || 1,
40
+ rpm: key.rpm && typeof key.rpm === 'object'
41
+ ? {
42
+ ...key.rpm,
43
+ used: clampNonNegative(key.rpm.used),
44
+ remaining: clampNonNegative(key.rpm.remaining),
45
+ limit: clampNonNegative(key.rpm.limit),
46
+ }
47
+ : key.rpm,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Sanitize a full status snapshot ({ providers: [...], ... }).
53
+ * Never mutates the input.
54
+ */
55
+ export function sanitizeSnapshot(snapshot, now = Date.now()) {
56
+ if (!snapshot || typeof snapshot !== 'object') return { providers: [] };
57
+ const providers = Array.isArray(snapshot.providers) ? snapshot.providers : [];
58
+ return {
59
+ ...snapshot,
60
+ providers: providers.map((p) => {
61
+ if (!p || typeof p !== 'object') return p;
62
+ const keys = Array.isArray(p.keys) ? p.keys : [];
63
+ return {
64
+ ...p,
65
+ switches: clampNonNegative(p.switches),
66
+ totalUsage: p.totalUsage == null ? p.totalUsage : clampNonNegative(p.totalUsage),
67
+ healthScore: p.healthScore == null
68
+ ? p.healthScore
69
+ : Math.max(0, Math.min(100, toFiniteNumber(p.healthScore, 0))),
70
+ lastSwitchAt: p.lastSwitchAt == null ? null : (Number.isFinite(Number(p.lastSwitchAt)) ? Number(p.lastSwitchAt) : null),
71
+ lastExhaustionAt: p.lastExhaustionAt == null ? null : (Number.isFinite(Number(p.lastExhaustionAt)) ? Number(p.lastExhaustionAt) : null),
72
+ todayCost: p.todayCost == null ? p.todayCost : clampNonNegative(p.todayCost),
73
+ weeklyCost: p.weeklyCost == null ? p.weeklyCost : clampNonNegative(p.weeklyCost),
74
+ budgetDaily: p.budgetDaily == null ? p.budgetDaily : clampNonNegative(p.budgetDaily),
75
+ budgetWeekly: p.budgetWeekly == null ? p.budgetWeekly : clampNonNegative(p.budgetWeekly),
76
+ p95: p.p95 == null ? null : clampNonNegative(p.p95),
77
+ keys: keys.map((k) => sanitizeKeyStatus(k, now)).filter(Boolean),
78
+ };
79
+ }),
80
+ };
81
+ }
82
+
83
+ export default sanitizeSnapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [