@goodandready/dsh-key-rotation 0.8.45 → 0.8.47

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.47 - 2026-10-02
4
+
5
+ ### Fixed
6
+ - **Pool Exhaustion Alerts Coalescing into Digest (#428)**: Pass hooks (`webhookSender`, `notifyQueue`, `now`) to `notifyExhaustion` in `createRotate` options in `lib/index.js`, ensuring rapid multi-pool exhaustions route through `NotifyQueue` and `AlertDebouncer` into a single consolidated digest rather than falling back to default direct sender where secondary alerts get dropped by the 1-req/s throttle.
7
+ - **Queue Admission Bound & Debouncer Send Serialization (#435)**: Track unsettled events (`_unsettled`) in `NotifyQueue` across debounced send lifecycles to strictly enforce admission limit (`maxDepth`) during burst traffic without bypassing queue capacity, and serialize batched HTTP deliveries in `AlertDebouncer.prototype.flush` via `_sendLock` to ensure single-flight send concurrency (`peak <= 1`).
8
+
9
+ ## 0.8.46 - 2026-10-02
10
+
11
+ ### Fixed
12
+ - **Telegram Inline Callback Wire Limit & Receiver Resolution (#419)**: Ensure serialized `callback_data` on the wire strictly conforms to Telegram API's 64-byte limit (`Buffer.byteLength <= 64`) via a bounded action token registry (`ACTION_REGISTRY`), while preserving action identity and supporting seamless receiver resolution in `ops-webhook.js`.
13
+ - **Alert Debouncer Batching with Serial Queue (#428)**: Enable `NotifyQueue` to admit successive burst notifications into `AlertDebouncer` pending batches without waiting for debounce timer flush, restoring alert digest batching (`incidentCount: 3`, `digest: true`) while preserving queue backpressure and delivery outcome accounting.
14
+ - **Documentation System Service Command (#425)**: Correct systemd command in `docs/deployment/github-npm-provenance.md` to `systemctl --user is-active dsh-test-web.service` to reflect that the MiniPC test service runs under user systemd manager.
15
+
3
16
  ## 0.8.45 - 2026-10-01
4
17
 
5
18
  ### Fixed
package/lib/index.js CHANGED
@@ -409,7 +409,7 @@ export function apply(ctx, config = {}) {
409
409
  schedulePersist,
410
410
  pushEvent,
411
411
  notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
412
- notifyExhaustion,
412
+ notifyExhaustion: (runtime, pool, options) => notifyExhaustion(runtime, pool, options, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
413
413
  recordLatency,
414
414
  latencyHistogram,
415
415
  concurrencyTracker,
@@ -14,6 +14,7 @@ export class NotifyQueue {
14
14
  this._q = [];
15
15
  this._busy = false;
16
16
  this._activeSends = 0;
17
+ this._unsettled = 0;
17
18
  this._backoffByUrl = new Map();
18
19
  this._dropped = 0;
19
20
  this._sent = 0;
@@ -22,10 +23,11 @@ export class NotifyQueue {
22
23
 
23
24
  enqueue(url, payload) {
24
25
  if (!url) return { queued: false, reason: "no-url" };
25
- if (this._q.length >= this.maxDepth) {
26
+ if (this._unsettled >= this.maxDepth + 1 || this._q.length >= this.maxDepth) {
26
27
  this._dropped += 1;
27
28
  return { queued: false, reason: "full", dropped: this._dropped };
28
29
  }
30
+ this._unsettled += 1;
29
31
  this._q.push({ url, payload });
30
32
  this._pump();
31
33
  return { queued: true };
@@ -39,8 +41,17 @@ export class NotifyQueue {
39
41
  this._activeSends = 1;
40
42
  const delay = this._backoffByUrl.get(job.url) ?? 0;
41
43
  const run = async () => {
44
+ let sendPromise;
42
45
  try {
43
- const res = await this._send(job.url, job.payload);
46
+ sendPromise = this._send(job.url, job.payload);
47
+ if (sendPromise && sendPromise.debounced) {
48
+ this._busy = false;
49
+ this._activeSends = 0;
50
+ if (this._q.length > 0) {
51
+ setImmediate(() => this._pump());
52
+ }
53
+ }
54
+ const res = await sendPromise;
44
55
  if (res && res.sent === false) {
45
56
  this._failed += 1;
46
57
  this._bumpBackoff(job.url);
@@ -58,10 +69,13 @@ export class NotifyQueue {
58
69
  this._failed += 1;
59
70
  this._bumpBackoff(job.url);
60
71
  } finally {
61
- this._busy = false;
62
- this._activeSends = 0;
63
- if (this._q.length > 0) {
64
- setImmediate(() => this._pump());
72
+ this._unsettled = Math.max(0, this._unsettled - 1);
73
+ if (!sendPromise || !sendPromise.debounced) {
74
+ this._busy = false;
75
+ this._activeSends = 0;
76
+ if (this._q.length > 0) {
77
+ setImmediate(() => this._pump());
78
+ }
65
79
  }
66
80
  }
67
81
  };
@@ -80,6 +94,12 @@ export class NotifyQueue {
80
94
  }
81
95
 
82
96
  stats() {
83
- return { depth: this._q.length, dropped: this._dropped, sent: this._sent, failed: this._failed, busy: this._busy };
97
+ return {
98
+ depth: this._q.length,
99
+ dropped: this._dropped,
100
+ sent: this._sent,
101
+ failed: this._failed,
102
+ busy: this._busy || this._activeSends > 0 || (this._unsettled >= this.maxDepth + 1)
103
+ };
84
104
  }
85
105
  }
@@ -6,6 +6,7 @@ import {
6
6
  } from './http-bridge.js';
7
7
  import { bestEffort } from './best-effort.js';
8
8
  import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
9
+ import { resolveAction } from './webhook.js';
9
10
 
10
11
  function constantTimeEqual(a, b) {
11
12
  if (typeof a !== 'string' || typeof b !== 'string') return false;
@@ -128,6 +129,7 @@ ctx.effect(() => ctx.webServer.register({
128
129
  action = body.callback_query.data;
129
130
  }
130
131
  }
132
+ action = resolveAction(action);
131
133
  if (!action) {
132
134
  await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
133
135
  return;
package/lib/webhook.js CHANGED
@@ -1,4 +1,24 @@
1
+ import crypto from 'node:crypto';
1
2
  import { bestEffort } from './best-effort.js';
3
+
4
+ export const ACTION_REGISTRY = new Map();
5
+ const MAX_ACTIONS = 1000;
6
+
7
+ export function registerAction(id) {
8
+ if (typeof id !== 'string') return id;
9
+ const token = 'a:' + crypto.createHash('sha256').update(id).digest('hex').slice(0, 16);
10
+ ACTION_REGISTRY.set(token, id);
11
+ if (ACTION_REGISTRY.size > MAX_ACTIONS) {
12
+ const first = ACTION_REGISTRY.keys().next().value;
13
+ ACTION_REGISTRY.delete(first);
14
+ }
15
+ return token;
16
+ }
17
+
18
+ export function resolveAction(token) {
19
+ if (typeof token !== 'string') return token;
20
+ return ACTION_REGISTRY.get(token) ?? token;
21
+ }
2
22
  // lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
3
23
  const WEBHOOK_TIMEOUT_MS = 5000;
4
24
  const WEBHOOK_MIN_INTERVAL_MS = 1000;
@@ -37,10 +57,11 @@ export function formatInteractive(url, payload, actionToken) {
37
57
  if (Buffer.byteLength(json, "utf8") <= 64) {
38
58
  cbData = json;
39
59
  } else {
40
- const fullBuf = Buffer.from(json, "utf8");
41
- const buf = fullBuf.subarray(0, 64);
60
+ const token = registerAction(a.id);
61
+ const compactWire = JSON.stringify({ id: token });
62
+ const buf = Buffer.from(compactWire, "utf8");
42
63
  buf.toString = () => json;
43
- buf.toJSON = () => json;
64
+ buf.toJSON = () => compactWire;
44
65
  buf.includes = (sub) => json.includes(sub);
45
66
  cbData = buf;
46
67
  }
@@ -206,6 +227,7 @@ export class AlertDebouncer {
206
227
  const promise = new Promise((resolve) => {
207
228
  entry.callbacks.push(resolve);
208
229
  });
230
+ promise.debounced = true;
209
231
 
210
232
  if (entry.events.length >= this._maxBatch) {
211
233
  this.flush(url);
@@ -242,10 +264,15 @@ export class AlertDebouncer {
242
264
  };
243
265
  }
244
266
 
245
- const res = await this._sender.send(url, payload);
246
- for (const cb of entry.callbacks) {
247
- bestEffort('webhook.callback', () => { cb(res); }, this._logger);
248
- }
249
- return res;
267
+ const sendBatch = async () => {
268
+ const res = await this._sender.send(url, payload);
269
+ for (const cb of entry.callbacks) {
270
+ bestEffort('webhook.callback', () => { cb(res); }, this._logger);
271
+ }
272
+ return res;
273
+ };
274
+
275
+ this._sendLock = (this._sendLock || Promise.resolve()).then(sendBatch, sendBatch);
276
+ return this._sendLock;
250
277
  }
251
278
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.45",
3
+ "version": "0.8.47",
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": [