@goodandready/dsh-key-rotation 0.8.57 → 0.8.59

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.59 - 2026-10-14
4
+
5
+ ### Refactored & Cleaned
6
+ - **Legacy Settings Registration Removed (#482)**:
7
+ - Deleted dead legacy `settings.register` branch in `lib/index.js`, fixing static configuration liveness to use standard `createConfigReader`.
8
+ - **Dead Subscriptions & Settings Fallbacks Removed (#482)**:
9
+ - Removed dead `ctx.on('config')` subscription in `lib/config-compat.js`.
10
+ - Removed unreachable legacy `settings.get` fallback in `createProviderProfilesReader` in favor of standard `settings.describe`.
11
+ - **Test Harness Synchronization (#482)**:
12
+ - Updated test fixtures for modern descriptor caching and multi-listener lifecycle events.
13
+
14
+ ## 0.8.58 - 2026-10-13
15
+
16
+ ### Fixed
17
+ - **Early-Warning Notification Dispatch (#477)**:
18
+ - Connected `notifyEarlyWarning` during key penalization in `lib/rotate.js` when healthy pool capacity drops to or below `earlyWarningKeyThreshold`.
19
+ - Added export `notifyEarlyWarning` in `lib/index.js` and wired dependency through `createRotate`.
20
+ - **Proactive Health Probing Logging (#478)**:
21
+ - Replaced empty catch block in `lib/lifecycle.js` `setupProactiveHealthProbeEffect` with structured debug logging (`logger?.debug`), eliminating silent swallowed exceptions.
22
+ - **Key Drain Lifecycle Teardown (#479)**:
23
+ - Registered `setupKeyDrainEffect` in `lib/lifecycle.js` to ensure module-level `drainingKeys` map is wiped cleanly on plugin teardown/dispose via `clearAllDraining()`.
24
+ - **Quota Reset Window Schema Validation (#480)**:
25
+ - Connected `QUOTA_WINDOW_TYPES` into `SettingsSchema` via `Schema.union(QUOTA_WINDOW_TYPES).default('midnight_utc')`.
26
+ - Connected `isBlockedUntilReset` in `lib/ops-status.js` to accurately expose calendar-based reset lock status.
27
+
3
28
  ## 0.8.57 - 2026-10-12
4
29
 
5
30
  ### Added
@@ -10,11 +10,9 @@ export function createConfigReader(config, ctx) {
10
10
  if (typeof ctx.effect === 'function') {
11
11
  ctx.effect(() => ctx.on('settings/document-updated', markDirty));
12
12
  ctx.effect(() => ctx.on('loader/volatile-update', markDirty));
13
- ctx.effect(() => ctx.on('config', markDirty));
14
13
  } else {
15
14
  ctx.on('settings/document-updated', markDirty);
16
15
  ctx.on('loader/volatile-update', markDirty);
17
- ctx.on('config', markDirty);
18
16
  }
19
17
  }
20
18
 
@@ -68,23 +66,13 @@ export function createProviderProfilesReader(ctx, namespace) {
68
66
  return held;
69
67
  }
70
68
  const settings = ctx?.get?.('settings');
71
- if (!settings) return null;
72
- if (typeof settings.get === 'function') {
73
- try {
74
- held = settings.get(namespace)?.providers ?? null;
75
- dirty = false;
76
- } catch (_) {
77
- held = null;
78
- dirty = false;
79
- }
80
- } else {
81
- try {
82
- held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
83
- dirty = false;
84
- } catch (_) {
85
- held = null;
86
- dirty = false;
87
- }
69
+ if (!settings || typeof settings.describe !== 'function') return null;
70
+ try {
71
+ held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
72
+ dirty = false;
73
+ } catch (_) {
74
+ held = null;
75
+ dirty = false;
88
76
  }
89
77
  return held;
90
78
  };
package/lib/index.js CHANGED
@@ -31,7 +31,8 @@ import { NotifyQueue } from './notify-queue.js';
31
31
  import { classifyFailure } from './error-taxonomy.js';
32
32
  import { registerOpsRoutes } from './routes-ops.js';
33
33
  import { registerPluginUpdater } from './plugin-updater.js';
34
- import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
34
+ import { notifySwitch, notifyExhaustion, notifyEarlyWarning, pushEvent } from './notify-events.js';
35
+ import { QUOTA_WINDOW_TYPES } from './quota-window.js';
35
36
  import { getLogger } from './logger.js';
36
37
  import { createSandboxService } from './sandbox-service.js';
37
38
  import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
@@ -44,12 +45,12 @@ import {
44
45
  formatModelQuotaExhaustion,
45
46
  } from './model-quota.js';
46
47
  import { LOCAL_QUOTA_CODE } from './rotate.js';
47
- import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect } from './lifecycle.js';
48
+ import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect, setupKeyDrainEffect } from './lifecycle.js';
48
49
 
49
50
  export const name = '@goodandready/dsh-key-rotation';
50
51
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
51
52
  export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
52
- export { notifySwitch, notifyExhaustion };
53
+ export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
53
54
 
54
55
  const NS = 'dsh-key-rotation';
55
56
 
@@ -88,7 +89,7 @@ const SettingsSchema = Schema.object({
88
89
  model: Schema.string(),
89
90
  })).default([]),
90
91
  quotaResetWindow: Schema.object({
91
- type: Schema.string().default('midnight_utc'),
92
+ type: Schema.union(QUOTA_WINDOW_TYPES).default('midnight_utc'),
92
93
  hour: Schema.number().default(0),
93
94
  }),
94
95
  rateLimitThreshold: Schema.number().default(0.1),
@@ -177,7 +178,7 @@ function registerConfigBridge(ctx, getCloneIds) {
177
178
  export function apply(ctx, config = {}) {
178
179
  const logger = getLogger(ctx);
179
180
  const readEntryConfig = createConfigReader(config, ctx);
180
- let getConfig = readEntryConfig;
181
+ const getConfig = readEntryConfig;
181
182
  const verboseLoggingOn = () => {
182
183
  try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
183
184
  };
@@ -206,6 +207,7 @@ export function apply(ctx, config = {}) {
206
207
 
207
208
  setupIdleHealEffect(ctx, getConfig, buildRuntime);
208
209
  setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
210
+ setupKeyDrainEffect(ctx);
209
211
 
210
212
  const poolState = new Map();
211
213
  const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
@@ -421,6 +423,7 @@ export function apply(ctx, config = {}) {
421
423
  pushEvent,
422
424
  notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
423
425
  notifyExhaustion: (runtime, pool, options) => notifyExhaustion(runtime, pool, options, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
426
+ notifyEarlyWarning: (runtime, pool, info) => notifyEarlyWarning(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
424
427
  recordLatency,
425
428
  latencyHistogram,
426
429
  concurrencyTracker,
@@ -498,15 +501,4 @@ export function apply(ctx, config = {}) {
498
501
  return next();
499
502
  }), 'dsh-key-rotation: agent/request-error');
500
503
 
501
- ctx.inject(['settings'], (sctx) => {
502
- const settingsSvc = sctx.get('settings');
503
- // Modern SettingsForms already owns this Loader entry's volatile Config.
504
- // Legacy SettingsProvider instead needs a plain namespace registration.
505
- if (typeof settingsSvc?.register !== 'function') return;
506
- const scope = settingsSvc.register(NS, SettingsSchema, { base: readEntryConfig() });
507
- getConfig = () => scope.get() ?? readEntryConfig();
508
- if (typeof sctx?.effect === 'function') {
509
- sctx.effect(() => () => { getConfig = readEntryConfig; });
510
- }
511
- });
512
504
  }
package/lib/lifecycle.js CHANGED
@@ -4,6 +4,7 @@ import { decryptSecret } from './crypto-storage.js';
4
4
  import { StatePersistence, resolveStatePath } from './persistence.js';
5
5
  import path from 'node:path';
6
6
  import { envValue } from './pool.js';
7
+ import { clearAllDraining } from './key-drain.js';
7
8
 
8
9
  export function setupIdleHealEffect(ctx, getConfig, buildRuntime, timers = globalThis) {
9
10
  return ctx.effect(() => {
@@ -209,7 +210,9 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
209
210
  }
210
211
  logger?.warn?.(`[dsh-key-rotation] proactive-probe: restored key ${ref} in pool ${pool.base}`);
211
212
  }
212
- } catch (_) {}
213
+ } catch (err) {
214
+ logger?.debug?.(`[dsh-key-rotation] proactive-probe failed for ${ref}: ${err?.message || err}`);
215
+ }
213
216
  }
214
217
  }
215
218
  } catch (e) {
@@ -244,3 +247,11 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
244
247
  };
245
248
  }, 'dsh-key-rotation: proactive health probing');
246
249
  }
250
+
251
+ export function setupKeyDrainEffect(ctx) {
252
+ return ctx.effect(() => {
253
+ return () => {
254
+ clearAllDraining();
255
+ };
256
+ }, 'dsh-key-rotation: clear draining keys on teardown');
257
+ }
package/lib/ops-status.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  isLoopbackAddress,
16
16
  } from './pool.js';
17
17
  import { bucketInfo, tpmInfo } from './bucket.js';
18
+ import { isBlockedUntilReset } from './quota-window.js';
18
19
  import { sanitizeSnapshot } from './sanitize-snapshot.js';
19
20
  import {
20
21
  STATUS_PATH,
@@ -92,6 +93,7 @@ ctx.effect(() => ctx.webServer.register({
92
93
  /* describe is optional — the card falls back to editable */
93
94
  }
94
95
  const until = pool.state.failedUntil?.get(ref);
96
+ const quotaWin = pool.state.quotaWindows?.get(ref);
95
97
  const modelQuota = getModelQuotaStatus(pool, ref, now);
96
98
  const activeModelPools = runtime?.index ? modelPoolsForRef(runtime.index, ref) : [];
97
99
  keys.push({
@@ -102,6 +104,7 @@ ctx.effect(() => ctx.webServer.register({
102
104
  writable,
103
105
  active: pool.state.lastUsed === ref,
104
106
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
107
+ blockedUntilReset: isBlockedUntilReset(until, quotaWin, now),
105
108
  // #210: RPM capacity snapshot (null when rpmLimit is off)
106
109
  rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
107
110
  // TPM capacity snapshot (null when tpmLimit is off)
package/lib/rotate.js CHANGED
@@ -19,6 +19,7 @@ import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
19
19
  import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
20
20
  import { createSelectionTrace } from './selection-trace.js';
21
21
  import { isKeyDraining } from './key-drain.js';
22
+ import { notifyEarlyWarning as defaultNotifyEarlyWarning } from './notify-events.js';
22
23
  import { nextQuotaReset, poolResetAt } from './quota-window.js';
23
24
  import { tpmRecord } from './bucket.js';
24
25
  import {
@@ -64,6 +65,7 @@ export function createRotate(deps) {
64
65
  pushEvent,
65
66
  notifySwitch,
66
67
  notifyExhaustion,
68
+ notifyEarlyWarning,
67
69
  recordLatency,
68
70
  latencyHistogram,
69
71
  concurrencyTracker,
@@ -77,6 +79,7 @@ export function createRotate(deps) {
77
79
  now = nowMono,
78
80
  } = deps;
79
81
 
82
+ const effectiveNotifyEarlyWarning = notifyEarlyWarning ?? defaultNotifyEarlyWarning;
80
83
  const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
81
84
 
82
85
  function rotate(options, pool) {
@@ -204,6 +207,24 @@ export function createRotate(deps) {
204
207
  if (typeof schedulePersist === 'function') {
205
208
  try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
206
209
  }
210
+ if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
211
+ const total = pool.refs?.length || 1;
212
+ let available = 0;
213
+ const at = now();
214
+ for (const r of (pool.refs || [])) {
215
+ if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
216
+ available++;
217
+ }
218
+ }
219
+ const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
220
+ if (total > 0 && (available / total) <= threshold) {
221
+ effectiveNotifyEarlyWarning(runtime0, pool, {
222
+ provider: options.provider,
223
+ availableCount: available,
224
+ totalCount: total,
225
+ });
226
+ }
227
+ }
207
228
  if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
208
229
  if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
209
230
  const _cStr = String(errCode ?? '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.57",
3
+ "version": "0.8.59",
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": [