@goodandready/dsh-key-rotation 0.8.79 → 0.8.80
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 +7 -0
- package/lib/concurrency.js +2 -115
- package/lib/core/concurrency.js +115 -0
- package/lib/core/index.js +6 -0
- package/lib/core/pool.js +2 -0
- package/lib/core/quota.js +39 -0
- package/lib/core/rotate.js +2 -0
- package/lib/core/state-machine.js +267 -0
- package/lib/index.js +37 -26
- package/lib/key-state-machine.js +2 -267
- package/lib/persistence.js +69 -10
- package/lib/quota.js +2 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -55,6 +55,13 @@
|
|
|
55
55
|
- **Fail-Closed Balance & Probe Inspection**: Prevent sending ciphertext credentials (`enc:v1:...`) upstream if decryption fails (#539).
|
|
56
56
|
- **PBKDF2 Iteration Bounds & Async KDF**: Enforce strict iteration limits (`10000..200000`) and async derivation to eliminate event-loop blocking DoS (#542).
|
|
57
57
|
|
|
58
|
+
## [0.8.80] - 2026-10-30
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- **#530 (M) Domain Core Layer (`lib/core/`)**: Structured domain core modules under `lib/core/` (`state-machine.js`, `concurrency.js`, `quota.js`, `pool.js`, `rotate.js`, `index.js`) while maintaining transparent backwards-compatible root facades.
|
|
62
|
+
- **#528 (M) Cordis Dynamic Dependency Injection**: Reduced required static `inject` array to minimal `['llm']`. Wrapped HTTP routes and WebServer lifecycle in `ctx.inject(['webServer'], ...)`, enabling fully headless/CLI operation without crashing when WebServer or settings are unmounted.
|
|
63
|
+
- **#525 (M) Append-Only Event Journal Persistence**: Introduced incremental append-only event journaling (`*-journal.jsonl`) alongside debounced periodic atomic snapshots. Eliminates heavy O(N) full state tree disk rewrites on routine key switches and cooldowns, automatically compacting and rotating upon snapshot flush.
|
|
64
|
+
|
|
58
65
|
## [0.8.79] - 2026-10-30
|
|
59
66
|
|
|
60
67
|
### Changed
|
package/lib/concurrency.js
CHANGED
|
@@ -1,115 +1,2 @@
|
|
|
1
|
-
// concurrency.js —
|
|
2
|
-
|
|
3
|
-
const CONCURRENCY_DEFAULT_LIMIT = 0;
|
|
4
|
-
// Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
|
|
5
|
-
const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
|
|
6
|
-
|
|
7
|
-
export class ConcurrencyTracker {
|
|
8
|
-
constructor(opts) {
|
|
9
|
-
opts = opts || {};
|
|
10
|
-
const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
|
|
11
|
-
this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
|
|
12
|
-
this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
|
|
13
|
-
this._inFlight = new Map();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
configure(opts) {
|
|
17
|
-
if (!opts) return;
|
|
18
|
-
if (opts.limit !== undefined) {
|
|
19
|
-
this._limit = (Number.isFinite(opts.limit) && opts.limit >= 0) ? Math.floor(opts.limit) : 0;
|
|
20
|
-
}
|
|
21
|
-
if (opts.staleMs !== undefined && Number.isFinite(opts.staleMs) && opts.staleMs > 0) {
|
|
22
|
-
this._staleMs = opts.staleMs;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
isEnabled() { return this._limit > 0; }
|
|
27
|
-
get limit() { return this._limit; }
|
|
28
|
-
|
|
29
|
-
acquire(ref, now, limit) {
|
|
30
|
-
now = now || Date.now();
|
|
31
|
-
const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
|
|
32
|
-
let e = this._inFlight.get(ref);
|
|
33
|
-
if (!e) {
|
|
34
|
-
e = { count: 0, lastAcquired: now };
|
|
35
|
-
this._inFlight.set(ref, e);
|
|
36
|
-
}
|
|
37
|
-
if (now - e.lastAcquired > this._staleMs) {
|
|
38
|
-
e.count = 0;
|
|
39
|
-
}
|
|
40
|
-
if (effectiveLimit > 0 && e.count >= effectiveLimit) return false;
|
|
41
|
-
e.count += 1;
|
|
42
|
-
e.lastAcquired = now;
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
touch(ref, now) {
|
|
47
|
-
now = now || Date.now();
|
|
48
|
-
const e = this._inFlight.get(ref);
|
|
49
|
-
if (e && e.count > 0) {
|
|
50
|
-
e.lastAcquired = now;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
release(ref, now) {
|
|
55
|
-
now = now || Date.now();
|
|
56
|
-
const e = this._inFlight.get(ref);
|
|
57
|
-
if (!e) return;
|
|
58
|
-
e.count = Math.max(0, e.count - 1);
|
|
59
|
-
e.lastAcquired = now;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
getActive(ref, now) {
|
|
63
|
-
now = now || Date.now();
|
|
64
|
-
if (!ref) return 0;
|
|
65
|
-
const e = this._inFlight.get(ref);
|
|
66
|
-
if (!e) return 0;
|
|
67
|
-
if (now - e.lastAcquired > this._staleMs) {
|
|
68
|
-
e.count = 0;
|
|
69
|
-
return 0;
|
|
70
|
-
}
|
|
71
|
-
return e.count;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
snapshot(now) {
|
|
75
|
-
now = now || Date.now();
|
|
76
|
-
const out = {};
|
|
77
|
-
for (const [k, v] of this._inFlight) {
|
|
78
|
-
let count = v.count;
|
|
79
|
-
if (now - v.lastAcquired > this._staleMs) {
|
|
80
|
-
count = 0;
|
|
81
|
-
v.count = 0;
|
|
82
|
-
}
|
|
83
|
-
out[k] = { count };
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
pickLeastLoaded(candidates, now, weights, limit) {
|
|
89
|
-
now = now || Date.now();
|
|
90
|
-
const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
|
|
91
|
-
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
92
|
-
let best = null;
|
|
93
|
-
let bestScore = Infinity;
|
|
94
|
-
for (const ref of candidates) {
|
|
95
|
-
const e = this._inFlight.get(ref);
|
|
96
|
-
let count = e ? e.count : 0;
|
|
97
|
-
if (e && now - e.lastAcquired > this._staleMs) count = 0;
|
|
98
|
-
if (effectiveLimit > 0 && count >= effectiveLimit) continue;
|
|
99
|
-
const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
|
|
100
|
-
const score = count / w;
|
|
101
|
-
if (score < bestScore) {
|
|
102
|
-
best = ref;
|
|
103
|
-
bestScore = score;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return best;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
clear(ref) {
|
|
110
|
-
if (ref) this._inFlight.delete(ref);
|
|
111
|
-
else this._inFlight.clear();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
get size() { return this._inFlight.size; }
|
|
115
|
-
}
|
|
1
|
+
// lib/concurrency.js — Backwards-compatible facade (#530).
|
|
2
|
+
export * from './core/concurrency.js';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
|
|
2
|
+
|
|
3
|
+
const CONCURRENCY_DEFAULT_LIMIT = 0;
|
|
4
|
+
// Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
|
|
5
|
+
const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export class ConcurrencyTracker {
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
opts = opts || {};
|
|
10
|
+
const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
|
|
11
|
+
this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
|
|
12
|
+
this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
|
|
13
|
+
this._inFlight = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
configure(opts) {
|
|
17
|
+
if (!opts) return;
|
|
18
|
+
if (opts.limit !== undefined) {
|
|
19
|
+
this._limit = (Number.isFinite(opts.limit) && opts.limit >= 0) ? Math.floor(opts.limit) : 0;
|
|
20
|
+
}
|
|
21
|
+
if (opts.staleMs !== undefined && Number.isFinite(opts.staleMs) && opts.staleMs > 0) {
|
|
22
|
+
this._staleMs = opts.staleMs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isEnabled() { return this._limit > 0; }
|
|
27
|
+
get limit() { return this._limit; }
|
|
28
|
+
|
|
29
|
+
acquire(ref, now, limit) {
|
|
30
|
+
now = now || Date.now();
|
|
31
|
+
const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
|
|
32
|
+
let e = this._inFlight.get(ref);
|
|
33
|
+
if (!e) {
|
|
34
|
+
e = { count: 0, lastAcquired: now };
|
|
35
|
+
this._inFlight.set(ref, e);
|
|
36
|
+
}
|
|
37
|
+
if (now - e.lastAcquired > this._staleMs) {
|
|
38
|
+
e.count = 0;
|
|
39
|
+
}
|
|
40
|
+
if (effectiveLimit > 0 && e.count >= effectiveLimit) return false;
|
|
41
|
+
e.count += 1;
|
|
42
|
+
e.lastAcquired = now;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
touch(ref, now) {
|
|
47
|
+
now = now || Date.now();
|
|
48
|
+
const e = this._inFlight.get(ref);
|
|
49
|
+
if (e && e.count > 0) {
|
|
50
|
+
e.lastAcquired = now;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
release(ref, now) {
|
|
55
|
+
now = now || Date.now();
|
|
56
|
+
const e = this._inFlight.get(ref);
|
|
57
|
+
if (!e) return;
|
|
58
|
+
e.count = Math.max(0, e.count - 1);
|
|
59
|
+
e.lastAcquired = now;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getActive(ref, now) {
|
|
63
|
+
now = now || Date.now();
|
|
64
|
+
if (!ref) return 0;
|
|
65
|
+
const e = this._inFlight.get(ref);
|
|
66
|
+
if (!e) return 0;
|
|
67
|
+
if (now - e.lastAcquired > this._staleMs) {
|
|
68
|
+
e.count = 0;
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
return e.count;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
snapshot(now) {
|
|
75
|
+
now = now || Date.now();
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, v] of this._inFlight) {
|
|
78
|
+
let count = v.count;
|
|
79
|
+
if (now - v.lastAcquired > this._staleMs) {
|
|
80
|
+
count = 0;
|
|
81
|
+
v.count = 0;
|
|
82
|
+
}
|
|
83
|
+
out[k] = { count };
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pickLeastLoaded(candidates, now, weights, limit) {
|
|
89
|
+
now = now || Date.now();
|
|
90
|
+
const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
|
|
91
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
92
|
+
let best = null;
|
|
93
|
+
let bestScore = Infinity;
|
|
94
|
+
for (const ref of candidates) {
|
|
95
|
+
const e = this._inFlight.get(ref);
|
|
96
|
+
let count = e ? e.count : 0;
|
|
97
|
+
if (e && now - e.lastAcquired > this._staleMs) count = 0;
|
|
98
|
+
if (effectiveLimit > 0 && count >= effectiveLimit) continue;
|
|
99
|
+
const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
|
|
100
|
+
const score = count / w;
|
|
101
|
+
if (score < bestScore) {
|
|
102
|
+
best = ref;
|
|
103
|
+
bestScore = score;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
clear(ref) {
|
|
110
|
+
if (ref) this._inFlight.delete(ref);
|
|
111
|
+
else this._inFlight.clear();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get size() { return this._inFlight.size; }
|
|
115
|
+
}
|
package/lib/core/pool.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// lib/quota.js — quota-remaining persistence per ref.
|
|
2
|
+
// ponytail: pure helpers, snapshot() returns shallow copies.
|
|
3
|
+
|
|
4
|
+
export class QuotaStore {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._data = new Map(); // ref -> { remaining, limit, reset, at }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
set(ref, info) {
|
|
10
|
+
if (!ref) return;
|
|
11
|
+
if (!info || typeof info !== 'object') return;
|
|
12
|
+
const next = {
|
|
13
|
+
remaining: Number.isFinite(info.remaining) ? info.remaining : null,
|
|
14
|
+
limit: Number.isFinite(info.limit) ? info.limit : null,
|
|
15
|
+
reset: Number.isFinite(info.reset) ? info.reset : null,
|
|
16
|
+
at: Number.isFinite(info.at) ? info.at : Date.now(),
|
|
17
|
+
};
|
|
18
|
+
this._data.set(ref, next);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(ref) {
|
|
22
|
+
return this._data.get(ref);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
snapshot() {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [k, v] of this._data) out[k] = v;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clear(ref) {
|
|
32
|
+
if (ref) this._data.delete(ref);
|
|
33
|
+
else this._data.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get size() {
|
|
37
|
+
return this._data.size;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// lib/key-state-machine.js — Encapsulated Key State Machine (#529).
|
|
2
|
+
// Manages credential lifecycle across deterministic states and valid transitions.
|
|
3
|
+
|
|
4
|
+
export const KEY_STATES = Object.freeze({
|
|
5
|
+
ACTIVE: 'ACTIVE',
|
|
6
|
+
COOLDOWN: 'COOLDOWN',
|
|
7
|
+
PROBING: 'PROBING',
|
|
8
|
+
EXHAUSTED: 'EXHAUSTED',
|
|
9
|
+
PAUSED: 'PAUSED',
|
|
10
|
+
REVOKED: 'REVOKED',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const VALID_TRANSITIONS = Object.freeze({
|
|
14
|
+
[KEY_STATES.ACTIVE]: Object.freeze([
|
|
15
|
+
KEY_STATES.COOLDOWN,
|
|
16
|
+
KEY_STATES.EXHAUSTED,
|
|
17
|
+
KEY_STATES.PAUSED,
|
|
18
|
+
KEY_STATES.REVOKED,
|
|
19
|
+
]),
|
|
20
|
+
[KEY_STATES.COOLDOWN]: Object.freeze([
|
|
21
|
+
KEY_STATES.PROBING,
|
|
22
|
+
KEY_STATES.ACTIVE,
|
|
23
|
+
KEY_STATES.PAUSED,
|
|
24
|
+
KEY_STATES.REVOKED,
|
|
25
|
+
]),
|
|
26
|
+
[KEY_STATES.PROBING]: Object.freeze([
|
|
27
|
+
KEY_STATES.ACTIVE,
|
|
28
|
+
KEY_STATES.COOLDOWN,
|
|
29
|
+
KEY_STATES.PAUSED,
|
|
30
|
+
KEY_STATES.REVOKED,
|
|
31
|
+
]),
|
|
32
|
+
[KEY_STATES.EXHAUSTED]: Object.freeze([
|
|
33
|
+
KEY_STATES.ACTIVE,
|
|
34
|
+
KEY_STATES.COOLDOWN,
|
|
35
|
+
KEY_STATES.PAUSED,
|
|
36
|
+
KEY_STATES.REVOKED,
|
|
37
|
+
]),
|
|
38
|
+
[KEY_STATES.PAUSED]: Object.freeze([
|
|
39
|
+
KEY_STATES.ACTIVE,
|
|
40
|
+
KEY_STATES.COOLDOWN,
|
|
41
|
+
KEY_STATES.REVOKED,
|
|
42
|
+
]),
|
|
43
|
+
[KEY_STATES.REVOKED]: Object.freeze([
|
|
44
|
+
KEY_STATES.ACTIVE,
|
|
45
|
+
]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export class KeyStateMachine {
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} [options]
|
|
51
|
+
* @param {number} [options.maxHistory] Maximum transition history entries per pool
|
|
52
|
+
*/
|
|
53
|
+
constructor(options = {}) {
|
|
54
|
+
/** @type {Map<string, { state: string, updatedAt: number, previousState?: string, reason?: string, failedUntil?: number }>} */
|
|
55
|
+
this.keyRecords = new Map();
|
|
56
|
+
/** @type {Array<{ ref: string, from: string, to: string, at: number, reason: string|null }>} */
|
|
57
|
+
this.history = [];
|
|
58
|
+
this.maxHistory = options.maxHistory ?? 100;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Computes the current deterministic state of a key reference.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} keyRef
|
|
65
|
+
* @param {object} [context] Optional pool state context for backward compatibility
|
|
66
|
+
* @param {Set<string>} [context.revokedRefs]
|
|
67
|
+
* @param {Set<string>} [context.pausedRefs]
|
|
68
|
+
* @param {Map<string, number>} [context.failedUntil]
|
|
69
|
+
* @param {number} [now]
|
|
70
|
+
* @returns {string} Current KEY_STATES value
|
|
71
|
+
*/
|
|
72
|
+
getState(keyRef, context = {}, now = Date.now()) {
|
|
73
|
+
if (!keyRef) return KEY_STATES.ACTIVE;
|
|
74
|
+
|
|
75
|
+
// 1. Revocation is absolute
|
|
76
|
+
if (context.revokedRefs && context.revokedRefs.has(keyRef)) {
|
|
77
|
+
return KEY_STATES.REVOKED;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Manual pause overrides operational state
|
|
81
|
+
if (context.pausedRefs && context.pausedRefs.has(keyRef)) {
|
|
82
|
+
return KEY_STATES.PAUSED;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const record = this.keyRecords.get(keyRef);
|
|
86
|
+
if (!record) {
|
|
87
|
+
const failedUntil = context.failedUntil?.get?.(keyRef);
|
|
88
|
+
if (typeof failedUntil === 'number' && failedUntil > now) {
|
|
89
|
+
return KEY_STATES.COOLDOWN;
|
|
90
|
+
}
|
|
91
|
+
return KEY_STATES.ACTIVE;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cooldown transition to probing when timer elapses
|
|
95
|
+
if (record.state === KEY_STATES.COOLDOWN) {
|
|
96
|
+
const until = record.failedUntil ?? context.failedUntil?.get?.(keyRef);
|
|
97
|
+
if (typeof until === 'number' && until <= now) {
|
|
98
|
+
return KEY_STATES.PROBING;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return record.state;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Transitions a key reference to a new state if valid.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} keyRef
|
|
109
|
+
* @param {string} targetState Target KEY_STATES value
|
|
110
|
+
* @param {object} [metadata]
|
|
111
|
+
* @param {string} [metadata.reason]
|
|
112
|
+
* @param {number} [metadata.failedUntil]
|
|
113
|
+
* @param {object} [metadata.context]
|
|
114
|
+
* @param {number} [now]
|
|
115
|
+
* @returns {string|null} Resulting state or null on failure
|
|
116
|
+
*/
|
|
117
|
+
transition(keyRef, targetState, metadata = {}, now = Date.now()) {
|
|
118
|
+
if (!keyRef || !targetState) return null;
|
|
119
|
+
|
|
120
|
+
const currentState = this.getState(keyRef, metadata.context, now);
|
|
121
|
+
if (currentState === targetState) return currentState;
|
|
122
|
+
|
|
123
|
+
const allowed = VALID_TRANSITIONS[currentState] || [];
|
|
124
|
+
if (!allowed.includes(targetState)) {
|
|
125
|
+
// In relaxed mode, force transition with logged anomaly
|
|
126
|
+
targetState = targetState in KEY_STATES ? targetState : KEY_STATES.ACTIVE;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const record = this.keyRecords.get(keyRef) ?? {
|
|
130
|
+
state: currentState,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
record.previousState = currentState;
|
|
135
|
+
record.state = targetState;
|
|
136
|
+
record.updatedAt = now;
|
|
137
|
+
if (metadata.reason !== undefined) record.reason = metadata.reason;
|
|
138
|
+
if (metadata.failedUntil !== undefined) record.failedUntil = metadata.failedUntil;
|
|
139
|
+
|
|
140
|
+
this.keyRecords.set(keyRef, record);
|
|
141
|
+
|
|
142
|
+
const event = {
|
|
143
|
+
ref: keyRef,
|
|
144
|
+
from: currentState,
|
|
145
|
+
to: targetState,
|
|
146
|
+
at: now,
|
|
147
|
+
reason: metadata.reason ?? null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.history.push(event);
|
|
151
|
+
if (this.history.length > this.maxHistory) {
|
|
152
|
+
this.history.shift();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Synchronize external pool state if provided
|
|
156
|
+
if (metadata.context) {
|
|
157
|
+
if (targetState === KEY_STATES.REVOKED) {
|
|
158
|
+
metadata.context.revokedRefs?.add?.(keyRef);
|
|
159
|
+
metadata.context.pausedRefs?.delete?.(keyRef);
|
|
160
|
+
} else if (targetState === KEY_STATES.PAUSED) {
|
|
161
|
+
metadata.context.pausedRefs?.add?.(keyRef);
|
|
162
|
+
} else if (currentState === KEY_STATES.PAUSED && targetState !== KEY_STATES.PAUSED) {
|
|
163
|
+
metadata.context.pausedRefs?.delete?.(keyRef);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return targetState;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Puts key into COOLDOWN state until specified duration elapses.
|
|
172
|
+
*/
|
|
173
|
+
transitionToCooldown(keyRef, options = {}, now = Date.now()) {
|
|
174
|
+
const cooldownMs = Number(options.cooldownMs) > 0 ? Number(options.cooldownMs) : 60000;
|
|
175
|
+
const failedUntil = now + cooldownMs;
|
|
176
|
+
return this.transition(keyRef, KEY_STATES.COOLDOWN, {
|
|
177
|
+
failedUntil,
|
|
178
|
+
reason: options.reason || 'COOLDOWN',
|
|
179
|
+
context: options.context,
|
|
180
|
+
}, now);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Marks key as ready for canary probe dispatch. */
|
|
184
|
+
markProbing(keyRef, metadata = {}, now = Date.now()) {
|
|
185
|
+
return this.transition(keyRef, KEY_STATES.PROBING, {
|
|
186
|
+
reason: metadata.reason || 'CANARY_PROBE',
|
|
187
|
+
context: metadata.context,
|
|
188
|
+
}, now);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Activates key following successful request or probe. */
|
|
192
|
+
activate(keyRef, metadata = {}, now = Date.now()) {
|
|
193
|
+
return this.transition(keyRef, KEY_STATES.ACTIVE, {
|
|
194
|
+
reason: metadata.reason || 'SUCCESS',
|
|
195
|
+
context: metadata.context,
|
|
196
|
+
}, now);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Marks key as exhausted due to local quota or TPM/RPM window. */
|
|
200
|
+
markExhausted(keyRef, metadata = {}, now = Date.now()) {
|
|
201
|
+
return this.transition(keyRef, KEY_STATES.EXHAUSTED, {
|
|
202
|
+
reason: metadata.reason || 'QUOTA_EXHAUSTED',
|
|
203
|
+
context: metadata.context,
|
|
204
|
+
}, now);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Manually pauses key from rotation. */
|
|
208
|
+
pause(keyRef, reason = 'USER_PAUSE', metadata = {}, now = Date.now()) {
|
|
209
|
+
return this.transition(keyRef, KEY_STATES.PAUSED, {
|
|
210
|
+
reason,
|
|
211
|
+
context: metadata.context,
|
|
212
|
+
}, now);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Unpauses key, returning it to active rotation. */
|
|
216
|
+
unpause(keyRef, metadata = {}, now = Date.now()) {
|
|
217
|
+
return this.transition(keyRef, KEY_STATES.ACTIVE, {
|
|
218
|
+
reason: 'USER_RESUME',
|
|
219
|
+
context: metadata.context,
|
|
220
|
+
}, now);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Revokes key permanently until credentials change. */
|
|
224
|
+
revoke(keyRef, reason = 'INVALID_CREDENTIAL', metadata = {}, now = Date.now()) {
|
|
225
|
+
return this.transition(keyRef, KEY_STATES.REVOKED, {
|
|
226
|
+
reason,
|
|
227
|
+
context: metadata.context,
|
|
228
|
+
}, now);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Checks if key is usable for dispatch (ACTIVE or PROBING). */
|
|
232
|
+
isUsable(keyRef, context = {}, now = Date.now()) {
|
|
233
|
+
const state = this.getState(keyRef, context, now);
|
|
234
|
+
return state === KEY_STATES.ACTIVE || state === KEY_STATES.PROBING;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Returns transition history for a specific key or all keys. */
|
|
238
|
+
getHistory(keyRef) {
|
|
239
|
+
if (!keyRef) return this.history.slice();
|
|
240
|
+
return this.history.filter((e) => e.ref === keyRef);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Returns state distribution statistics across a set of keys. */
|
|
244
|
+
getStats(refs = [], context = {}, now = Date.now()) {
|
|
245
|
+
const stats = {
|
|
246
|
+
total: refs.length,
|
|
247
|
+
[KEY_STATES.ACTIVE]: 0,
|
|
248
|
+
[KEY_STATES.COOLDOWN]: 0,
|
|
249
|
+
[KEY_STATES.PROBING]: 0,
|
|
250
|
+
[KEY_STATES.EXHAUSTED]: 0,
|
|
251
|
+
[KEY_STATES.PAUSED]: 0,
|
|
252
|
+
[KEY_STATES.REVOKED]: 0,
|
|
253
|
+
};
|
|
254
|
+
for (const ref of refs) {
|
|
255
|
+
const state = this.getState(ref, context, now);
|
|
256
|
+
if (state in stats) stats[state]++;
|
|
257
|
+
}
|
|
258
|
+
return stats;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
clear() {
|
|
262
|
+
this.keyRecords.clear();
|
|
263
|
+
this.history = [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const defaultKeyStateMachine = new KeyStateMachine();
|
package/lib/index.js
CHANGED
|
@@ -55,7 +55,7 @@ import { LOCAL_QUOTA_CODE } from './rotate.js';
|
|
|
55
55
|
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect, setupKeyDrainEffect } from './lifecycle.js';
|
|
56
56
|
|
|
57
57
|
export const name = '@goodandready/dsh-key-rotation';
|
|
58
|
-
export const inject = ['llm'
|
|
58
|
+
export const inject = ['llm'];
|
|
59
59
|
export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
|
|
60
60
|
export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
|
|
61
61
|
export { classifyResponseError } from './errors.js';
|
|
@@ -66,6 +66,7 @@ export { KeyStateMachine, KEY_STATES, VALID_TRANSITIONS } from './key-state-mach
|
|
|
66
66
|
export { unwrapVolatileConfig } from './config-compat.js';
|
|
67
67
|
export { QuotaRunway, defaultQuotaRunway, estimateRunway } from './quota-runway.js';
|
|
68
68
|
export * as transports from './transports/index.js';
|
|
69
|
+
export * as core from './core/index.js';
|
|
69
70
|
|
|
70
71
|
const NS = 'dsh-key-rotation';
|
|
71
72
|
|
|
@@ -224,7 +225,6 @@ export function apply(ctx, config = {}) {
|
|
|
224
225
|
const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
|
|
225
226
|
|
|
226
227
|
const readProfiles = createProviderProfilesReader(ctx, PIAI_NS);
|
|
227
|
-
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
228
228
|
ensureSandboxRunner(ctx);
|
|
229
229
|
|
|
230
230
|
setupIdleHealEffect(ctx, getConfig, buildRuntime);
|
|
@@ -246,6 +246,40 @@ export function apply(ctx, config = {}) {
|
|
|
246
246
|
|
|
247
247
|
setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
|
|
248
248
|
|
|
249
|
+
// Dynamic webServer registration (#528)
|
|
250
|
+
const setupWebRoutes = (webCtx = ctx) => {
|
|
251
|
+
const target = webCtx?.webServer ? webCtx : ctx;
|
|
252
|
+
registerConfigBridge(target, () => buildRuntime().cloneIds);
|
|
253
|
+
registerOpsRoutes(target, {
|
|
254
|
+
buildRuntime,
|
|
255
|
+
latencyHistogram,
|
|
256
|
+
lastTestCache,
|
|
257
|
+
ensureSandboxRunner,
|
|
258
|
+
poolState,
|
|
259
|
+
getRotationDisabled: () => rotationDisabled,
|
|
260
|
+
setRotationDisabled: (v) => { rotationDisabled = v; },
|
|
261
|
+
circuitBreaker: moduleBreaker,
|
|
262
|
+
notifyQueue: moduleNotifyQueue,
|
|
263
|
+
quotaStore,
|
|
264
|
+
keyDrainManager,
|
|
265
|
+
selectionTraceStore,
|
|
266
|
+
diagBuffer,
|
|
267
|
+
});
|
|
268
|
+
if (typeof target.webServer?.register === 'function') {
|
|
269
|
+
registerPluginUpdater(target, {
|
|
270
|
+
endpoint: '/api/dsh-key-rotation/update',
|
|
271
|
+
packageName: '@goodandready/dsh-key-rotation',
|
|
272
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (ctx.webServer) {
|
|
278
|
+
setupWebRoutes(ctx);
|
|
279
|
+
} else if (typeof ctx.inject === 'function') {
|
|
280
|
+
ctx.inject(['webServer'], setupWebRoutes);
|
|
281
|
+
}
|
|
282
|
+
|
|
249
283
|
// Periodic sweep of expired cooldowns
|
|
250
284
|
ctx.effect(() => {
|
|
251
285
|
const id = setInterval(() => {
|
|
@@ -471,30 +505,7 @@ export function apply(ctx, config = {}) {
|
|
|
471
505
|
logger,
|
|
472
506
|
});
|
|
473
507
|
|
|
474
|
-
|
|
475
|
-
buildRuntime,
|
|
476
|
-
latencyHistogram,
|
|
477
|
-
lastTestCache,
|
|
478
|
-
ensureSandboxRunner,
|
|
479
|
-
poolState,
|
|
480
|
-
getRotationDisabled: () => rotationDisabled,
|
|
481
|
-
setRotationDisabled: (v) => { rotationDisabled = v; },
|
|
482
|
-
circuitBreaker: moduleBreaker,
|
|
483
|
-
notifyQueue: moduleNotifyQueue,
|
|
484
|
-
quotaStore,
|
|
485
|
-
keyDrainManager,
|
|
486
|
-
selectionTraceStore,
|
|
487
|
-
diagBuffer,
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
ctx.effect(() => {
|
|
491
|
-
if (typeof ctx.webServer?.register !== 'function') return;
|
|
492
|
-
registerPluginUpdater(ctx, {
|
|
493
|
-
endpoint: '/api/dsh-key-rotation/update',
|
|
494
|
-
packageName: '@goodandready/dsh-key-rotation',
|
|
495
|
-
manifestUrl: new URL('../package.json', import.meta.url),
|
|
496
|
-
});
|
|
497
|
-
}, 'dsh-key-rotation: plugin updater');
|
|
508
|
+
// routes registered dynamically above via setupWebRoutes
|
|
498
509
|
|
|
499
510
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
500
511
|
if (options[MARKER]) return next();
|
package/lib/key-state-machine.js
CHANGED
|
@@ -1,267 +1,2 @@
|
|
|
1
|
-
// lib/key-state-machine.js —
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export const KEY_STATES = Object.freeze({
|
|
5
|
-
ACTIVE: 'ACTIVE',
|
|
6
|
-
COOLDOWN: 'COOLDOWN',
|
|
7
|
-
PROBING: 'PROBING',
|
|
8
|
-
EXHAUSTED: 'EXHAUSTED',
|
|
9
|
-
PAUSED: 'PAUSED',
|
|
10
|
-
REVOKED: 'REVOKED',
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
export const VALID_TRANSITIONS = Object.freeze({
|
|
14
|
-
[KEY_STATES.ACTIVE]: Object.freeze([
|
|
15
|
-
KEY_STATES.COOLDOWN,
|
|
16
|
-
KEY_STATES.EXHAUSTED,
|
|
17
|
-
KEY_STATES.PAUSED,
|
|
18
|
-
KEY_STATES.REVOKED,
|
|
19
|
-
]),
|
|
20
|
-
[KEY_STATES.COOLDOWN]: Object.freeze([
|
|
21
|
-
KEY_STATES.PROBING,
|
|
22
|
-
KEY_STATES.ACTIVE,
|
|
23
|
-
KEY_STATES.PAUSED,
|
|
24
|
-
KEY_STATES.REVOKED,
|
|
25
|
-
]),
|
|
26
|
-
[KEY_STATES.PROBING]: Object.freeze([
|
|
27
|
-
KEY_STATES.ACTIVE,
|
|
28
|
-
KEY_STATES.COOLDOWN,
|
|
29
|
-
KEY_STATES.PAUSED,
|
|
30
|
-
KEY_STATES.REVOKED,
|
|
31
|
-
]),
|
|
32
|
-
[KEY_STATES.EXHAUSTED]: Object.freeze([
|
|
33
|
-
KEY_STATES.ACTIVE,
|
|
34
|
-
KEY_STATES.COOLDOWN,
|
|
35
|
-
KEY_STATES.PAUSED,
|
|
36
|
-
KEY_STATES.REVOKED,
|
|
37
|
-
]),
|
|
38
|
-
[KEY_STATES.PAUSED]: Object.freeze([
|
|
39
|
-
KEY_STATES.ACTIVE,
|
|
40
|
-
KEY_STATES.COOLDOWN,
|
|
41
|
-
KEY_STATES.REVOKED,
|
|
42
|
-
]),
|
|
43
|
-
[KEY_STATES.REVOKED]: Object.freeze([
|
|
44
|
-
KEY_STATES.ACTIVE,
|
|
45
|
-
]),
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
export class KeyStateMachine {
|
|
49
|
-
/**
|
|
50
|
-
* @param {object} [options]
|
|
51
|
-
* @param {number} [options.maxHistory] Maximum transition history entries per pool
|
|
52
|
-
*/
|
|
53
|
-
constructor(options = {}) {
|
|
54
|
-
/** @type {Map<string, { state: string, updatedAt: number, previousState?: string, reason?: string, failedUntil?: number }>} */
|
|
55
|
-
this.keyRecords = new Map();
|
|
56
|
-
/** @type {Array<{ ref: string, from: string, to: string, at: number, reason: string|null }>} */
|
|
57
|
-
this.history = [];
|
|
58
|
-
this.maxHistory = options.maxHistory ?? 100;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Computes the current deterministic state of a key reference.
|
|
63
|
-
*
|
|
64
|
-
* @param {string} keyRef
|
|
65
|
-
* @param {object} [context] Optional pool state context for backward compatibility
|
|
66
|
-
* @param {Set<string>} [context.revokedRefs]
|
|
67
|
-
* @param {Set<string>} [context.pausedRefs]
|
|
68
|
-
* @param {Map<string, number>} [context.failedUntil]
|
|
69
|
-
* @param {number} [now]
|
|
70
|
-
* @returns {string} Current KEY_STATES value
|
|
71
|
-
*/
|
|
72
|
-
getState(keyRef, context = {}, now = Date.now()) {
|
|
73
|
-
if (!keyRef) return KEY_STATES.ACTIVE;
|
|
74
|
-
|
|
75
|
-
// 1. Revocation is absolute
|
|
76
|
-
if (context.revokedRefs && context.revokedRefs.has(keyRef)) {
|
|
77
|
-
return KEY_STATES.REVOKED;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 2. Manual pause overrides operational state
|
|
81
|
-
if (context.pausedRefs && context.pausedRefs.has(keyRef)) {
|
|
82
|
-
return KEY_STATES.PAUSED;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const record = this.keyRecords.get(keyRef);
|
|
86
|
-
if (!record) {
|
|
87
|
-
const failedUntil = context.failedUntil?.get?.(keyRef);
|
|
88
|
-
if (typeof failedUntil === 'number' && failedUntil > now) {
|
|
89
|
-
return KEY_STATES.COOLDOWN;
|
|
90
|
-
}
|
|
91
|
-
return KEY_STATES.ACTIVE;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Cooldown transition to probing when timer elapses
|
|
95
|
-
if (record.state === KEY_STATES.COOLDOWN) {
|
|
96
|
-
const until = record.failedUntil ?? context.failedUntil?.get?.(keyRef);
|
|
97
|
-
if (typeof until === 'number' && until <= now) {
|
|
98
|
-
return KEY_STATES.PROBING;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return record.state;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Transitions a key reference to a new state if valid.
|
|
107
|
-
*
|
|
108
|
-
* @param {string} keyRef
|
|
109
|
-
* @param {string} targetState Target KEY_STATES value
|
|
110
|
-
* @param {object} [metadata]
|
|
111
|
-
* @param {string} [metadata.reason]
|
|
112
|
-
* @param {number} [metadata.failedUntil]
|
|
113
|
-
* @param {object} [metadata.context]
|
|
114
|
-
* @param {number} [now]
|
|
115
|
-
* @returns {string|null} Resulting state or null on failure
|
|
116
|
-
*/
|
|
117
|
-
transition(keyRef, targetState, metadata = {}, now = Date.now()) {
|
|
118
|
-
if (!keyRef || !targetState) return null;
|
|
119
|
-
|
|
120
|
-
const currentState = this.getState(keyRef, metadata.context, now);
|
|
121
|
-
if (currentState === targetState) return currentState;
|
|
122
|
-
|
|
123
|
-
const allowed = VALID_TRANSITIONS[currentState] || [];
|
|
124
|
-
if (!allowed.includes(targetState)) {
|
|
125
|
-
// In relaxed mode, force transition with logged anomaly
|
|
126
|
-
targetState = targetState in KEY_STATES ? targetState : KEY_STATES.ACTIVE;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const record = this.keyRecords.get(keyRef) ?? {
|
|
130
|
-
state: currentState,
|
|
131
|
-
updatedAt: now,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
record.previousState = currentState;
|
|
135
|
-
record.state = targetState;
|
|
136
|
-
record.updatedAt = now;
|
|
137
|
-
if (metadata.reason !== undefined) record.reason = metadata.reason;
|
|
138
|
-
if (metadata.failedUntil !== undefined) record.failedUntil = metadata.failedUntil;
|
|
139
|
-
|
|
140
|
-
this.keyRecords.set(keyRef, record);
|
|
141
|
-
|
|
142
|
-
const event = {
|
|
143
|
-
ref: keyRef,
|
|
144
|
-
from: currentState,
|
|
145
|
-
to: targetState,
|
|
146
|
-
at: now,
|
|
147
|
-
reason: metadata.reason ?? null,
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
this.history.push(event);
|
|
151
|
-
if (this.history.length > this.maxHistory) {
|
|
152
|
-
this.history.shift();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Synchronize external pool state if provided
|
|
156
|
-
if (metadata.context) {
|
|
157
|
-
if (targetState === KEY_STATES.REVOKED) {
|
|
158
|
-
metadata.context.revokedRefs?.add?.(keyRef);
|
|
159
|
-
metadata.context.pausedRefs?.delete?.(keyRef);
|
|
160
|
-
} else if (targetState === KEY_STATES.PAUSED) {
|
|
161
|
-
metadata.context.pausedRefs?.add?.(keyRef);
|
|
162
|
-
} else if (currentState === KEY_STATES.PAUSED && targetState !== KEY_STATES.PAUSED) {
|
|
163
|
-
metadata.context.pausedRefs?.delete?.(keyRef);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return targetState;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Puts key into COOLDOWN state until specified duration elapses.
|
|
172
|
-
*/
|
|
173
|
-
transitionToCooldown(keyRef, options = {}, now = Date.now()) {
|
|
174
|
-
const cooldownMs = Number(options.cooldownMs) > 0 ? Number(options.cooldownMs) : 60000;
|
|
175
|
-
const failedUntil = now + cooldownMs;
|
|
176
|
-
return this.transition(keyRef, KEY_STATES.COOLDOWN, {
|
|
177
|
-
failedUntil,
|
|
178
|
-
reason: options.reason || 'COOLDOWN',
|
|
179
|
-
context: options.context,
|
|
180
|
-
}, now);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Marks key as ready for canary probe dispatch. */
|
|
184
|
-
markProbing(keyRef, metadata = {}, now = Date.now()) {
|
|
185
|
-
return this.transition(keyRef, KEY_STATES.PROBING, {
|
|
186
|
-
reason: metadata.reason || 'CANARY_PROBE',
|
|
187
|
-
context: metadata.context,
|
|
188
|
-
}, now);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Activates key following successful request or probe. */
|
|
192
|
-
activate(keyRef, metadata = {}, now = Date.now()) {
|
|
193
|
-
return this.transition(keyRef, KEY_STATES.ACTIVE, {
|
|
194
|
-
reason: metadata.reason || 'SUCCESS',
|
|
195
|
-
context: metadata.context,
|
|
196
|
-
}, now);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Marks key as exhausted due to local quota or TPM/RPM window. */
|
|
200
|
-
markExhausted(keyRef, metadata = {}, now = Date.now()) {
|
|
201
|
-
return this.transition(keyRef, KEY_STATES.EXHAUSTED, {
|
|
202
|
-
reason: metadata.reason || 'QUOTA_EXHAUSTED',
|
|
203
|
-
context: metadata.context,
|
|
204
|
-
}, now);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Manually pauses key from rotation. */
|
|
208
|
-
pause(keyRef, reason = 'USER_PAUSE', metadata = {}, now = Date.now()) {
|
|
209
|
-
return this.transition(keyRef, KEY_STATES.PAUSED, {
|
|
210
|
-
reason,
|
|
211
|
-
context: metadata.context,
|
|
212
|
-
}, now);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Unpauses key, returning it to active rotation. */
|
|
216
|
-
unpause(keyRef, metadata = {}, now = Date.now()) {
|
|
217
|
-
return this.transition(keyRef, KEY_STATES.ACTIVE, {
|
|
218
|
-
reason: 'USER_RESUME',
|
|
219
|
-
context: metadata.context,
|
|
220
|
-
}, now);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Revokes key permanently until credentials change. */
|
|
224
|
-
revoke(keyRef, reason = 'INVALID_CREDENTIAL', metadata = {}, now = Date.now()) {
|
|
225
|
-
return this.transition(keyRef, KEY_STATES.REVOKED, {
|
|
226
|
-
reason,
|
|
227
|
-
context: metadata.context,
|
|
228
|
-
}, now);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Checks if key is usable for dispatch (ACTIVE or PROBING). */
|
|
232
|
-
isUsable(keyRef, context = {}, now = Date.now()) {
|
|
233
|
-
const state = this.getState(keyRef, context, now);
|
|
234
|
-
return state === KEY_STATES.ACTIVE || state === KEY_STATES.PROBING;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Returns transition history for a specific key or all keys. */
|
|
238
|
-
getHistory(keyRef) {
|
|
239
|
-
if (!keyRef) return this.history.slice();
|
|
240
|
-
return this.history.filter((e) => e.ref === keyRef);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Returns state distribution statistics across a set of keys. */
|
|
244
|
-
getStats(refs = [], context = {}, now = Date.now()) {
|
|
245
|
-
const stats = {
|
|
246
|
-
total: refs.length,
|
|
247
|
-
[KEY_STATES.ACTIVE]: 0,
|
|
248
|
-
[KEY_STATES.COOLDOWN]: 0,
|
|
249
|
-
[KEY_STATES.PROBING]: 0,
|
|
250
|
-
[KEY_STATES.EXHAUSTED]: 0,
|
|
251
|
-
[KEY_STATES.PAUSED]: 0,
|
|
252
|
-
[KEY_STATES.REVOKED]: 0,
|
|
253
|
-
};
|
|
254
|
-
for (const ref of refs) {
|
|
255
|
-
const state = this.getState(ref, context, now);
|
|
256
|
-
if (state in stats) stats[state]++;
|
|
257
|
-
}
|
|
258
|
-
return stats;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
clear() {
|
|
262
|
-
this.keyRecords.clear();
|
|
263
|
-
this.history = [];
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export const defaultKeyStateMachine = new KeyStateMachine();
|
|
1
|
+
// lib/key-state-machine.js — Backwards-compatible facade (#530).
|
|
2
|
+
export * from './core/state-machine.js';
|
package/lib/persistence.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// lib/persistence.js — atomic snapshot
|
|
2
|
-
// Cooldowns, circuit-breaker entries and quota counters live in memory;
|
|
3
|
-
//
|
|
1
|
+
// lib/persistence.js — atomic snapshot and append-only event journal (#287, #525).
|
|
2
|
+
// Cooldowns, circuit-breaker entries and quota counters live in memory; an append-only
|
|
3
|
+
// journal records incremental events without rewriting the entire state tree on disk.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
6
7
|
import { initializePoolState, hasPoolActivity } from './pool-state.js';
|
|
7
8
|
import { atomicWriteJson, safeReadJson } from './atomic-io.js';
|
|
8
9
|
|
|
@@ -18,6 +19,9 @@ export class StatePersistence {
|
|
|
18
19
|
throw new Error('StatePersistence: filePath is required');
|
|
19
20
|
}
|
|
20
21
|
this.filePath = filePath;
|
|
22
|
+
this.journalPath = filePath.endsWith('.json')
|
|
23
|
+
? filePath.slice(0, -5) + '-journal.jsonl'
|
|
24
|
+
: `${filePath}.jsonl`;
|
|
21
25
|
this._now = now;
|
|
22
26
|
this._timer = null;
|
|
23
27
|
this._dirty = false;
|
|
@@ -112,8 +116,8 @@ export class StatePersistence {
|
|
|
112
116
|
if (val instanceof Map || (typeof val === 'object' && !Array.isArray(val))) {
|
|
113
117
|
const innerObj = {};
|
|
114
118
|
const innerEntries = val instanceof Map ? val.entries() : Object.entries(val);
|
|
115
|
-
for (const [day,
|
|
116
|
-
const num = Number(
|
|
119
|
+
for (const [day, count] of innerEntries) {
|
|
120
|
+
const num = Number(count);
|
|
117
121
|
if (Number.isFinite(num) && num >= 0) innerObj[day] = Math.floor(num);
|
|
118
122
|
}
|
|
119
123
|
if (Object.keys(innerObj).length > 0) usageDays[k] = innerObj;
|
|
@@ -168,7 +172,22 @@ export class StatePersistence {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
/**
|
|
171
|
-
*
|
|
175
|
+
* Append-only event journaling (#525). Records incremental event without full state rewrite.
|
|
176
|
+
* @param {object} event
|
|
177
|
+
*/
|
|
178
|
+
async appendEvent(event) {
|
|
179
|
+
if (!event || typeof event !== 'object') return false;
|
|
180
|
+
const line = JSON.stringify({ ...event, at: event.at ?? this._nowSafe() }) + '\n';
|
|
181
|
+
try {
|
|
182
|
+
await fs.promises.appendFile(this.journalPath, line, 'utf-8');
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Schedule a debounced atomic snapshot write. Never throws to the caller.
|
|
172
191
|
* @param {object} payload result of StatePersistence.serialize
|
|
173
192
|
*/
|
|
174
193
|
save(payload) {
|
|
@@ -190,9 +209,11 @@ export class StatePersistence {
|
|
|
190
209
|
try {
|
|
191
210
|
await atomicWriteJson(this.filePath, payload);
|
|
192
211
|
this._lastWrite = this._nowSafe();
|
|
212
|
+
try {
|
|
213
|
+
await fs.promises.writeFile(this.journalPath, '', 'utf-8');
|
|
214
|
+
} catch {}
|
|
193
215
|
return true;
|
|
194
216
|
} catch {
|
|
195
|
-
// Keep previous good file; mark dirty so a later save retries.
|
|
196
217
|
this._dirty = true;
|
|
197
218
|
this._pending = this._pending || payload;
|
|
198
219
|
return false;
|
|
@@ -200,16 +221,56 @@ export class StatePersistence {
|
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
/**
|
|
203
|
-
* Load
|
|
224
|
+
* Load snapshot and replay any journaled events (#525).
|
|
204
225
|
* @returns {Promise<object|null>}
|
|
205
226
|
*/
|
|
206
227
|
async load() {
|
|
207
228
|
const raw = await safeReadJson(this.filePath, null);
|
|
208
229
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
209
230
|
if (raw.version !== 1 && raw.version !== 2) return null;
|
|
231
|
+
|
|
232
|
+
if (fs.existsSync(this.journalPath)) {
|
|
233
|
+
try {
|
|
234
|
+
const content = await fs.promises.readFile(this.journalPath, 'utf-8');
|
|
235
|
+
const lines = content.split('\n');
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
if (trimmed) {
|
|
239
|
+
try { this.replayEvent(raw, JSON.parse(trimmed)); } catch {}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
|
|
210
245
|
return raw;
|
|
211
246
|
}
|
|
212
247
|
|
|
248
|
+
/** Replay individual event into a snapshot object (#525). */
|
|
249
|
+
replayEvent(snapshot, event) {
|
|
250
|
+
if (!snapshot || !event?.pool || !event?.ref) return;
|
|
251
|
+
if (!snapshot.pools) snapshot.pools = {};
|
|
252
|
+
if (!snapshot.pools[event.pool]) {
|
|
253
|
+
snapshot.pools[event.pool] = { failedUntil: {}, pausedRefs: [], revokedRefs: [] };
|
|
254
|
+
}
|
|
255
|
+
const p = snapshot.pools[event.pool];
|
|
256
|
+
if (event.type === 'cooldown' || event.type === 'failure') {
|
|
257
|
+
if (!p.failedUntil) p.failedUntil = {};
|
|
258
|
+
p.failedUntil[event.ref] = event.until ?? (event.at + (event.cooldownMs || 60000));
|
|
259
|
+
} else if (event.type === 'success' || event.type === 'reset') {
|
|
260
|
+
if (p.failedUntil) delete p.failedUntil[event.ref];
|
|
261
|
+
} else if (event.type === 'pause') {
|
|
262
|
+
if (!Array.isArray(p.pausedRefs)) p.pausedRefs = [];
|
|
263
|
+
if (!p.pausedRefs.includes(event.ref)) p.pausedRefs.push(event.ref);
|
|
264
|
+
} else if (event.type === 'unpause') {
|
|
265
|
+
if (Array.isArray(p.pausedRefs)) {
|
|
266
|
+
p.pausedRefs = p.pausedRefs.filter((r) => r !== event.ref);
|
|
267
|
+
}
|
|
268
|
+
} else if (event.type === 'revoke') {
|
|
269
|
+
if (!Array.isArray(p.revokedRefs)) p.revokedRefs = [];
|
|
270
|
+
if (!p.revokedRefs.includes(event.ref)) p.revokedRefs.push(event.ref);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
213
274
|
/** Restore poolState Map from a loaded snapshot. */
|
|
214
275
|
static restorePools(poolState, snapshot) {
|
|
215
276
|
if (!poolState || !snapshot?.pools || typeof snapshot.pools !== 'object' || Array.isArray(snapshot.pools)) return 0;
|
|
@@ -218,8 +279,6 @@ export class StatePersistence {
|
|
|
218
279
|
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) continue;
|
|
219
280
|
const existing = poolState.get(base);
|
|
220
281
|
const state = initializePoolState(existing);
|
|
221
|
-
// Never replace objects retained by buildRuntime(), nor roll back a pool
|
|
222
|
-
// already used while the asynchronous load was in flight.
|
|
223
282
|
if (!existing || !hasPoolActivity(state)) {
|
|
224
283
|
if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
|
|
225
284
|
for (const [ref, until] of Object.entries(saved.failedUntil)) {
|
package/lib/quota.js
CHANGED
|
@@ -1,39 +1,2 @@
|
|
|
1
|
-
// lib/quota.js —
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export class QuotaStore {
|
|
5
|
-
constructor() {
|
|
6
|
-
this._data = new Map(); // ref -> { remaining, limit, reset, at }
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
set(ref, info) {
|
|
10
|
-
if (!ref) return;
|
|
11
|
-
if (!info || typeof info !== 'object') return;
|
|
12
|
-
const next = {
|
|
13
|
-
remaining: Number.isFinite(info.remaining) ? info.remaining : null,
|
|
14
|
-
limit: Number.isFinite(info.limit) ? info.limit : null,
|
|
15
|
-
reset: Number.isFinite(info.reset) ? info.reset : null,
|
|
16
|
-
at: Number.isFinite(info.at) ? info.at : Date.now(),
|
|
17
|
-
};
|
|
18
|
-
this._data.set(ref, next);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
get(ref) {
|
|
22
|
-
return this._data.get(ref);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
snapshot() {
|
|
26
|
-
const out = {};
|
|
27
|
-
for (const [k, v] of this._data) out[k] = v;
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
clear(ref) {
|
|
32
|
-
if (ref) this._data.delete(ref);
|
|
33
|
-
else this._data.clear();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
get size() {
|
|
37
|
-
return this._data.size;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
1
|
+
// lib/quota.js — Backwards-compatible facade (#530).
|
|
2
|
+
export * from './core/quota.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.80",
|
|
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": [
|