@goodandready/dsh-key-rotation 0.7.26 → 0.7.28

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/lib/canary.js CHANGED
@@ -1,56 +1,56 @@
1
- // canary.js — canary probing before releasing a key from cooldown (issue #196).
2
-
3
- export const CANARY_PROBE_TIMEOUT_MS = 5000;
4
- export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
5
-
6
- export class CanaryProber {
7
- constructor(opts) {
8
- opts = opts || {};
9
- if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
10
- this._runner = opts.sandboxRunner;
11
- this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
12
- this._results = new Map();
13
- this._inProgress = new Set();
14
- }
15
-
16
- get intervalMs() { return this._intervalMs; }
17
-
18
- async probe(ref, key) {
19
- if (!ref || this._inProgress.has(ref)) return null;
20
- this._inProgress.add(ref);
21
- try {
22
- const result = await this._runner.probeModels(ref, key);
23
- this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
24
- return result;
25
- } catch (e) {
26
- return { ok: false, code: 'error', at: Date.now() };
27
- } finally {
28
- this._inProgress.delete(ref);
29
- }
30
- }
31
-
32
- lastResult(ref) {
33
- return this._results.get(ref) || null;
34
- }
35
-
36
- isHealthy(ref) {
37
- const r = this._results.get(ref);
38
- return Boolean(r && r.ok);
39
- }
40
-
41
- clear(ref) {
42
- if (ref) {
43
- this._results.delete(ref);
44
- this._inProgress.delete(ref);
45
- } else {
46
- this._results.clear();
47
- this._inProgress.clear();
48
- }
49
- }
50
-
51
- snapshot() {
52
- const out = {};
53
- for (const [k, v] of this._results) out[k] = v;
54
- return out;
55
- }
56
- }
1
+ // canary.js — canary probing before releasing a key from cooldown (issue #196).
2
+
3
+ export const CANARY_PROBE_TIMEOUT_MS = 5000;
4
+ export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
5
+
6
+ export class CanaryProber {
7
+ constructor(opts) {
8
+ opts = opts || {};
9
+ if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
10
+ this._runner = opts.sandboxRunner;
11
+ this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
12
+ this._results = new Map();
13
+ this._inProgress = new Set();
14
+ }
15
+
16
+ get intervalMs() { return this._intervalMs; }
17
+
18
+ async probe(ref, key) {
19
+ if (!ref || this._inProgress.has(ref)) return null;
20
+ this._inProgress.add(ref);
21
+ try {
22
+ const result = await this._runner.probeModels(ref, key);
23
+ this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
24
+ return result;
25
+ } catch (e) {
26
+ return { ok: false, code: 'error', at: Date.now() };
27
+ } finally {
28
+ this._inProgress.delete(ref);
29
+ }
30
+ }
31
+
32
+ lastResult(ref) {
33
+ return this._results.get(ref) || null;
34
+ }
35
+
36
+ isHealthy(ref) {
37
+ const r = this._results.get(ref);
38
+ return Boolean(r && r.ok);
39
+ }
40
+
41
+ clear(ref) {
42
+ if (ref) {
43
+ this._results.delete(ref);
44
+ this._inProgress.delete(ref);
45
+ } else {
46
+ this._results.clear();
47
+ this._inProgress.clear();
48
+ }
49
+ }
50
+
51
+ snapshot() {
52
+ const out = {};
53
+ for (const [k, v] of this._results) out[k] = v;
54
+ return out;
55
+ }
56
+ }
package/lib/cascade.js CHANGED
@@ -1,39 +1,39 @@
1
- // cascade.js — cross-provider failover cascade (issue #194).
2
- // ponytail: minimal — pick fallback provider from a RegionMap-like config.
3
-
4
- export const CASCADE_MAX_DEPTH = 1;
5
-
6
- export function pickCascadeFallback(provider, cfg, pools) {
7
- const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
8
- for (const entry of list) {
9
- const fb = typeof entry === 'string' ? { provider: entry } : entry;
10
- if (!fb || !fb.provider || fb.provider === provider) continue;
11
- const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
12
- if (!pool) continue;
13
- const now = Date.now();
14
- let healthy = 0;
15
- for (const ref of pool.refs) {
16
- const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
17
- if (failedUntil > now) continue;
18
- const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
19
- if (exp !== undefined && now >= exp) continue;
20
- healthy += 1;
21
- }
22
- if (healthy === 0) continue;
23
- return { provider: fb.provider, pool, model: fb.model || null };
24
- }
25
- return null;
26
- }
27
-
28
- export function hasHealthyKey(pool, now) {
29
- now = now || Date.now();
30
- if (!pool || !Array.isArray(pool.refs)) return false;
31
- for (const ref of pool.refs) {
32
- const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
33
- if (failedUntil > now) continue;
34
- const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
35
- if (exp !== undefined && now >= exp) continue;
36
- return true;
37
- }
38
- return false;
39
- }
1
+ // cascade.js — cross-provider failover cascade (issue #194).
2
+ // ponytail: minimal — pick fallback provider from a RegionMap-like config.
3
+
4
+ export const CASCADE_MAX_DEPTH = 1;
5
+
6
+ export function pickCascadeFallback(provider, cfg, pools) {
7
+ const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
8
+ for (const entry of list) {
9
+ const fb = typeof entry === 'string' ? { provider: entry } : entry;
10
+ if (!fb || !fb.provider || fb.provider === provider) continue;
11
+ const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
12
+ if (!pool) continue;
13
+ const now = Date.now();
14
+ let healthy = 0;
15
+ for (const ref of pool.refs) {
16
+ const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
17
+ if (failedUntil > now) continue;
18
+ const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
19
+ if (exp !== undefined && now >= exp) continue;
20
+ healthy += 1;
21
+ }
22
+ if (healthy === 0) continue;
23
+ return { provider: fb.provider, pool, model: fb.model || null };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function hasHealthyKey(pool, now) {
29
+ now = now || Date.now();
30
+ if (!pool || !Array.isArray(pool.refs)) return false;
31
+ for (const ref of pool.refs) {
32
+ const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
33
+ if (failedUntil > now) continue;
34
+ const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
35
+ if (exp !== undefined && now >= exp) continue;
36
+ return true;
37
+ }
38
+ return false;
39
+ }
@@ -1,21 +1,21 @@
1
- export function formatAgo(t, at) {
2
- if (!at) return '';
3
- const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
4
- if (sec < 60) return t('justNow');
5
- if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
6
- return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
7
- }
8
-
9
- export function nextKeyRef(providerId, existingKeys, allRefs) {
10
- const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
11
- const base = fromExisting
12
- ? fromExisting.replace(/_\d+$/, '')
13
- : String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
14
- const taken = new Set(allRefs);
15
- if (!taken.has(base)) return base;
16
- for (let n = 2; n < 1000; n++) {
17
- const candidate = base + '_' + n;
18
- if (!taken.has(candidate)) return candidate;
19
- }
20
- return base + '_' + Date.now();
21
- }
1
+ export function formatAgo(t, at) {
2
+ if (!at) return '';
3
+ const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
4
+ if (sec < 60) return t('justNow');
5
+ if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
6
+ return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
7
+ }
8
+
9
+ export function nextKeyRef(providerId, existingKeys, allRefs) {
10
+ const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
11
+ const base = fromExisting
12
+ ? fromExisting.replace(/_\d+$/, '')
13
+ : String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
14
+ const taken = new Set(allRefs);
15
+ if (!taken.has(base)) return base;
16
+ for (let n = 2; n < 1000; n++) {
17
+ const candidate = base + '_' + n;
18
+ if (!taken.has(candidate)) return candidate;
19
+ }
20
+ return base + '_' + Date.now();
21
+ }
package/lib/client.js CHANGED
@@ -63,6 +63,10 @@ window.__ModuleLoader__.load({
63
63
  keySaved: 'saved',
64
64
  keyFromEnv: 'from the environment, read-only here',
65
65
  keyWriteFailed: 'could not store the key: {msg}',
66
+ notSecretShape: 'saved, but the value does not look like an API key - check for a typo',
67
+ rpmTitle: 'requests/min: {u} used, {r} remaining',
68
+ budgetLabel: 'budget:',
69
+ exportCsv: 'Export CSV',
66
70
  keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
67
71
  brokenKey: 'broken (3× AUTH)',
68
72
  keyExpired: 'expired',
@@ -119,6 +123,10 @@ window.__ModuleLoader__.load({
119
123
  keySaved: 'сохранён',
120
124
  keyFromEnv: 'задан в окружении, отсюда не меняется',
121
125
  keyWriteFailed: 'не удалось сохранить ключ: {msg}',
126
+ notSecretShape: 'сохранено, но значение не похоже на API-ключ — проверьте опечатки',
127
+ rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
128
+ budgetLabel: 'бюджет:',
129
+ exportCsv: 'CSV',
122
130
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
123
131
  brokenKey: 'сломан (3× AUTH)',
124
132
  keyExpired: 'истёк',
@@ -292,7 +300,8 @@ window.__ModuleLoader__.load({
292
300
  }); };
293
301
  const doTest = (ref) => {
294
302
  setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
295
- fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
303
+ // #212: real API probe (models is free on most providers), not just presence
304
+ fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
296
305
  .then((r) => r.json())
297
306
  .then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
298
307
  .catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
@@ -390,6 +399,11 @@ window.__ModuleLoader__.load({
390
399
  .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
391
400
  .then(({ ok, data }) => {
392
401
  if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
402
+ // #200 leak-detector hint: stored value does not match any known
403
+ // API-key shape - probably a placeholder or a typo.
404
+ if (data?.looksLikeSecret === false) {
405
+ setSecretError(t('notSecretShape'));
406
+ }
393
407
  setSecretDraft((cur) => {
394
408
  const next = { ...cur };
395
409
  delete next[rowKey];
@@ -510,7 +524,8 @@ window.__ModuleLoader__.load({
510
524
  if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
511
525
  if (hit.expiresAt && !hit.expired) {
512
526
  const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
513
- if (days <= 7) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
527
+ const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
528
+ if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
514
529
  }
515
530
  if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
516
531
  if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
@@ -586,12 +601,19 @@ window.__ModuleLoader__.load({
586
601
  }
587
602
  }
588
603
  if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
604
+ // #210: RPM capacity indicator (only when rpmLimit is active)
605
+ if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
606
+ title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
607
+ style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
608
+ '⏱' + info.rpm.remaining));
589
609
  if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
590
610
  const tr = testResult[key];
591
611
  if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
592
- title: tr.message || (tr.ok ? t('testOk') : t('testFail')),
612
+ title: (tr.message || (tr.ok ? t('testOk') : t('testFail')))
613
+ + (tr.ok && tr.modelsCount ? ' · ' + tr.modelsCount + ' models' : '')
614
+ + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
593
615
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
594
- tr.ok ? '✓' : '✕'));
616
+ tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
595
617
  meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
596
618
  meta.push(h('span', { key: 'a', className: 'krot-acts' },
597
619
  btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
@@ -616,6 +638,28 @@ window.__ModuleLoader__.load({
616
638
  const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
617
639
  ? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
618
640
  : null;
641
+ // #208: budget line (warn color at >=80%, red at 100%)
642
+ const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
643
+ ? (() => {
644
+ const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
645
+ const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
646
+ const worst = Math.max(dayRatio, weekRatio);
647
+ const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-label-tertiary)';
648
+ const parts = [];
649
+ if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
650
+ if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
651
+ if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· paused');
652
+ return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
653
+ })()
654
+ : null;
655
+ // #209: CSV export for this provider's usage (last 7 days)
656
+ const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
657
+ onClick: () => {
658
+ const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
659
+ const a = document.createElement('a');
660
+ a.href = url; a.download = 'usage-' + entry.provider + '.csv';
661
+ document.body.appendChild(a); a.click(); a.remove();
662
+ } }, t('exportCsv'));
619
663
 
620
664
  return h('div', { key: pIndex, className: 'krot-prov' },
621
665
  h('div', { className: 'krot-prov-head' },
@@ -653,11 +697,13 @@ window.__ModuleLoader__.load({
653
697
  h('div', { className: 'krot-foot' },
654
698
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
655
699
  switchesLine,
700
+ budgetLine,
656
701
  exhaustionWarning,
657
702
  (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),
658
703
  (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),
659
704
  btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
660
705
  btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
706
+ exportCsv,
661
707
  ),
662
708
  );
663
709
  });
@@ -803,10 +849,54 @@ window.__ModuleLoader__.load({
803
849
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
804
850
  }
805
851
 
806
- function apply(ctx) {
852
+ // #201 header chip: one dot + counts for all pools. Green = all healthy,
853
+ // amber = some keys cooling, red = a pool fully exhausted. Click opens the
854
+ // same summary the floating dashboard shows.
855
+ function KeyRotationHeaderChip() {
856
+ const [snap, setSnap] = React.useState(null);
857
+ React.useEffect(() => {
858
+ let alive = true;
859
+ const load = () => {
860
+ fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
861
+ .then((r) => (r.ok ? r.json() : null))
862
+ .then((d) => { if (alive) setSnap(d); })
863
+ .catch(() => {});
864
+ };
865
+ load();
866
+ const id = setInterval(load, 4000);
867
+ return () => { alive = false; clearInterval(id); };
868
+ }, []);
869
+ const pools = Object.values((snap && snap.pools) || {});
870
+ const total = pools.reduce((a, p) => a + (p.total || 0), 0);
871
+ const healthy = pools.reduce((a, p) => a + (p.healthy || 0), 0);
872
+ const anyExhausted = pools.some((p) => p.exhausted);
873
+ const color = !pools.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? '#e5484d' : healthy < total ? '#f5a623' : '#30a46c';
874
+ const label = pools.length ? `${healthy}/${total}` : 'rot';
875
+ return h('span', {
876
+ className: 'krot-chip',
877
+ title: 'Key Rotation',
878
+ style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', cursor: 'default' },
879
+ },
880
+ h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none' } }),
881
+ label);
882
+ }
883
+
884
+ function apply(ctx) {
807
885
  ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-key-rotation: dictionaries');
808
886
  // Dashboard widget: lives on every page, polls /health (#152).
809
887
  ctx.effect(() => mountDashboard(), 'dsh-key-rotation: dashboard widget');
888
+ // Header chip (#201): status dot in the session header utilities slot,
889
+ // same slot dsh-gitea / dsh-subscriptions use for their header widgets.
890
+ ctx.effect(() => {
891
+ if (!ctx.slots) return;
892
+ try {
893
+ ctx.slots.inject('conversation.session.header.utilities', () =>
894
+ ctx.slots.register(
895
+ { name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6 },
896
+ KeyRotationHeaderChip,
897
+ ));
898
+ } catch { /* slot not available in this build */ }
899
+ }, 'dsh-key-rotation: header chip');
810
900
  function useLocale() {
811
901
  return useActiveLocale(ctx);
812
902
  }
@@ -1,72 +1,72 @@
1
- // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
2
-
3
- export const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
5
-
6
- export class ConcurrencyTracker {
7
- constructor(opts) {
8
- opts = opts || {};
9
- const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
10
- this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
11
- this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
12
- this._inFlight = new Map();
13
- }
14
-
15
- isEnabled() { return this._limit > 0; }
16
- get limit() { return this._limit; }
17
-
18
- acquire(ref, now) {
19
- now = now || Date.now();
20
- if (!this.isEnabled()) return true;
21
- let e = this._inFlight.get(ref);
22
- if (!e) {
23
- e = { count: 0, lastAcquired: now };
24
- this._inFlight.set(ref, e);
25
- }
26
- if (now - e.lastAcquired > this._staleMs) {
27
- e.count = 0;
28
- }
29
- if (e.count >= this._limit) return false;
30
- e.count += 1;
31
- e.lastAcquired = now;
32
- return true;
33
- }
34
-
35
- release(ref, now) {
36
- now = now || Date.now();
37
- const e = this._inFlight.get(ref);
38
- if (!e) return;
39
- e.count = Math.max(0, e.count - 1);
40
- e.lastAcquired = now;
41
- }
42
-
43
- snapshot() {
44
- const out = {};
45
- for (const [k, v] of this._inFlight) out[k] = { count: v.count };
46
- return out;
47
- }
48
-
49
- pickLeastLoaded(candidates, now) {
50
- now = now || Date.now();
51
- if (!Array.isArray(candidates) || candidates.length === 0) return null;
52
- let best = null;
53
- let bestCount = Infinity;
54
- for (const ref of candidates) {
55
- const e = this._inFlight.get(ref);
56
- const count = e ? e.count : 0;
57
- if (this.isEnabled() && count >= this._limit) continue;
58
- if (count < bestCount) {
59
- best = ref;
60
- bestCount = count;
61
- }
62
- }
63
- return best;
64
- }
65
-
66
- clear(ref) {
67
- if (ref) this._inFlight.delete(ref);
68
- else this._inFlight.clear();
69
- }
70
-
71
- get size() { return this._inFlight.size; }
72
- }
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
2
+
3
+ export const CONCURRENCY_DEFAULT_LIMIT = 0;
4
+ export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
5
+
6
+ export class ConcurrencyTracker {
7
+ constructor(opts) {
8
+ opts = opts || {};
9
+ const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
10
+ this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
11
+ this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
12
+ this._inFlight = new Map();
13
+ }
14
+
15
+ isEnabled() { return this._limit > 0; }
16
+ get limit() { return this._limit; }
17
+
18
+ acquire(ref, now) {
19
+ now = now || Date.now();
20
+ if (!this.isEnabled()) return true;
21
+ let e = this._inFlight.get(ref);
22
+ if (!e) {
23
+ e = { count: 0, lastAcquired: now };
24
+ this._inFlight.set(ref, e);
25
+ }
26
+ if (now - e.lastAcquired > this._staleMs) {
27
+ e.count = 0;
28
+ }
29
+ if (e.count >= this._limit) return false;
30
+ e.count += 1;
31
+ e.lastAcquired = now;
32
+ return true;
33
+ }
34
+
35
+ release(ref, now) {
36
+ now = now || Date.now();
37
+ const e = this._inFlight.get(ref);
38
+ if (!e) return;
39
+ e.count = Math.max(0, e.count - 1);
40
+ e.lastAcquired = now;
41
+ }
42
+
43
+ snapshot() {
44
+ const out = {};
45
+ for (const [k, v] of this._inFlight) out[k] = { count: v.count };
46
+ return out;
47
+ }
48
+
49
+ pickLeastLoaded(candidates, now) {
50
+ now = now || Date.now();
51
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
52
+ let best = null;
53
+ let bestCount = Infinity;
54
+ for (const ref of candidates) {
55
+ const e = this._inFlight.get(ref);
56
+ const count = e ? e.count : 0;
57
+ if (this.isEnabled() && count >= this._limit) continue;
58
+ if (count < bestCount) {
59
+ best = ref;
60
+ bestCount = count;
61
+ }
62
+ }
63
+ return best;
64
+ }
65
+
66
+ clear(ref) {
67
+ if (ref) this._inFlight.delete(ref);
68
+ else this._inFlight.clear();
69
+ }
70
+
71
+ get size() { return this._inFlight.size; }
72
+ }
package/lib/heal.js CHANGED
@@ -1,35 +1,35 @@
1
- // heal.js — self-healing idle cooldowns.
2
- // ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
3
-
4
- // Returns array of { ref, poolBase } entries that were healed in this tick.
5
- // Mutates `pools` (removes from failedUntil, pushes heal event into events).
6
- // `now` parameter is injectable for tests.
7
- export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
8
- if (!Array.isArray(pools) || pools.length === 0) return [];
9
- if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
10
- const healed = [];
11
- for (const pool of pools) {
12
- if (!pool || !pool.state || !pool.base) continue;
13
- const fu = pool.state.failedUntil;
14
- const lu = pool.state.lastUsed;
15
- if (!fu || fu.size === 0) continue;
16
- const expiredRefs = [];
17
- for (const [ref, until] of fu.entries()) {
18
- if (!Number.isFinite(until)) continue;
19
- if (until > now) continue; // cooldown still active
20
- const last = lu ? lu.get(ref) : undefined;
21
- if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
- if (now - last < idleMs) continue; // used recently → don't heal
23
- expiredRefs.push(ref);
24
- }
25
- for (const ref of expiredRefs) {
26
- fu.delete(ref);
27
- if (Array.isArray(pool.state.events)) {
28
- pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
29
- if (pool.state.events.length > 50) pool.state.events.shift();
30
- }
31
- healed.push({ ref, poolBase: pool.base });
32
- }
33
- }
34
- return healed;
35
- }
1
+ // heal.js — self-healing idle cooldowns.
2
+ // ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
3
+
4
+ // Returns array of { ref, poolBase } entries that were healed in this tick.
5
+ // Mutates `pools` (removes from failedUntil, pushes heal event into events).
6
+ // `now` parameter is injectable for tests.
7
+ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
8
+ if (!Array.isArray(pools) || pools.length === 0) return [];
9
+ if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
10
+ const healed = [];
11
+ for (const pool of pools) {
12
+ if (!pool || !pool.state || !pool.base) continue;
13
+ const fu = pool.state.failedUntil;
14
+ const lu = pool.state.lastUsed;
15
+ if (!fu || fu.size === 0) continue;
16
+ const expiredRefs = [];
17
+ for (const [ref, until] of fu.entries()) {
18
+ if (!Number.isFinite(until)) continue;
19
+ if (until > now) continue; // cooldown still active
20
+ const last = lu ? lu.get(ref) : undefined;
21
+ if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
+ if (now - last < idleMs) continue; // used recently → don't heal
23
+ expiredRefs.push(ref);
24
+ }
25
+ for (const ref of expiredRefs) {
26
+ fu.delete(ref);
27
+ if (Array.isArray(pool.state.events)) {
28
+ pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
29
+ if (pool.state.events.length > 50) pool.state.events.shift();
30
+ }
31
+ healed.push({ ref, poolBase: pool.base });
32
+ }
33
+ }
34
+ return healed;
35
+ }