@leaves615/dsh-llm-ctl 0.1.0
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/LICENSE +21 -0
- package/README.md +174 -0
- package/cordis.patch.yml +7 -0
- package/lib/client-plugin.d.ts +82 -0
- package/lib/client-plugin.js +685 -0
- package/lib/client.js +1712 -0
- package/lib/concurrency.d.ts +28 -0
- package/lib/concurrency.js +36 -0
- package/lib/config.d.ts +203 -0
- package/lib/config.js +67 -0
- package/lib/controller.d.ts +42 -0
- package/lib/controller.js +51 -0
- package/lib/delay.d.ts +68 -0
- package/lib/delay.js +134 -0
- package/lib/discover-ui.d.ts +94 -0
- package/lib/discover-ui.js +91 -0
- package/lib/discover.d.ts +79 -0
- package/lib/discover.js +141 -0
- package/lib/events.d.ts +45 -0
- package/lib/events.js +37 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +378 -0
- package/lib/menu-filter.d.ts +134 -0
- package/lib/menu-filter.js +428 -0
- package/lib/menu-visibility.d.ts +26 -0
- package/lib/menu-visibility.js +77 -0
- package/lib/queue-dock.d.ts +85 -0
- package/lib/queue-dock.js +291 -0
- package/lib/queue.d.ts +128 -0
- package/lib/queue.js +313 -0
- package/lib/reactive.d.ts +57 -0
- package/lib/reactive.js +75 -0
- package/lib/reasoning-efforts.d.ts +120 -0
- package/lib/reasoning-efforts.js +143 -0
- package/lib/routes.d.ts +126 -0
- package/lib/routes.js +267 -0
- package/lib/settings-ui.d.ts +183 -0
- package/lib/settings-ui.js +367 -0
- package/lib/visibility-settings.d.ts +193 -0
- package/lib/visibility-settings.js +225 -0
- package/lib/visibility.d.ts +152 -0
- package/lib/visibility.js +235 -0
- package/package.json +92 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React views for the `conversation.composer.dock` queue seat.
|
|
3
|
+
*
|
|
4
|
+
* The dock is the official list slot below the composer card
|
|
5
|
+
* (`dsh-client-ui-conversation`, kind `list`, scope `session`, no owner
|
|
6
|
+
* props). Seats render as siblings inside one `display: contents` slot
|
|
7
|
+
* anchor: the official stats pills (`dsh-client-ui-chat` `StatsPills`,
|
|
8
|
+
* order 0, `.bOPqQW_root` — a centered 13px tertiary pill row) come first,
|
|
9
|
+
* our queue pills go last (order 1000). We cannot nest inside the stats
|
|
10
|
+
* seat's own root (it is owned by another plugin's component), so this panel
|
|
11
|
+
* mirrors its pill-row language so the two stacked rows read as one dock.
|
|
12
|
+
*
|
|
13
|
+
* Each pill is a compact trigger (glyph + count) that opens an anchored
|
|
14
|
+
* dialog with the full queue or cooling detail — the same interaction the
|
|
15
|
+
* official stats pills use. The seat returns null while the control plane is
|
|
16
|
+
* idle so the dock collapses to nothing.
|
|
17
|
+
*
|
|
18
|
+
* Pure view over a small state object plus callbacks: shaping and the trigger
|
|
19
|
+
* markup are testable without a browser. Hover/expanded styles live in the
|
|
20
|
+
* client half (`client-plugin.ts` `ensureDockStyles`) because inline styles
|
|
21
|
+
* cannot express `:hover`.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-llm-ctl/queue-dock
|
|
24
|
+
*/
|
|
25
|
+
import React from 'react';
|
|
26
|
+
import { createPortal } from 'react-dom';
|
|
27
|
+
/**
|
|
28
|
+
* Format milliseconds as a compact human delay.
|
|
29
|
+
*
|
|
30
|
+
* @param ms - Non-negative duration in milliseconds.
|
|
31
|
+
* @returns Compact label such as `850ms`, `12.0s`, or `2m05s`.
|
|
32
|
+
*/
|
|
33
|
+
export function formatMs(ms) {
|
|
34
|
+
if (ms < 1000)
|
|
35
|
+
return Math.max(0, Math.round(ms)) + 'ms';
|
|
36
|
+
const seconds = ms / 1000;
|
|
37
|
+
if (seconds < 60)
|
|
38
|
+
return (seconds < 10 ? seconds.toFixed(1) : String(Math.round(seconds))) + 's';
|
|
39
|
+
return Math.floor(seconds / 60) + 'm' + Math.round(seconds % 60) + 's';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Shape the dock view, or undefined when the control plane is idle.
|
|
43
|
+
*
|
|
44
|
+
* @param queue - Live lanes and waiters from the state poll.
|
|
45
|
+
* @returns The content to render, or undefined when there is nothing to show.
|
|
46
|
+
*/
|
|
47
|
+
export function buildQueueDockView(queue) {
|
|
48
|
+
const cooling = queue.lanes.filter((lane) => lane.cooldownRemainingMs > 0);
|
|
49
|
+
if (queue.waiters.length === 0 && cooling.length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
return { waiters: [...queue.waiters], cooling };
|
|
52
|
+
}
|
|
53
|
+
/** Cancel-button class; hover is styled by the injected dock stylesheet. */
|
|
54
|
+
export const DOCK_CANCEL_CLASS = 'dsh-llm-ctl-dock-cancel';
|
|
55
|
+
/** Pill-trigger class; hover/expanded styling comes from the dock stylesheet. */
|
|
56
|
+
export const DOCK_PILL_CLASS = 'dsh-llm-ctl-dock-pill';
|
|
57
|
+
/**
|
|
58
|
+
* Pill row: mirrors the stats seat's `.bOPqQW_root` (centered 13px tertiary
|
|
59
|
+
* row, same content width and side clearance). Class hashes are
|
|
60
|
+
* version-specific, so the values are repeated here instead of referencing
|
|
61
|
+
* the stats classes.
|
|
62
|
+
*/
|
|
63
|
+
const panelStyle = {
|
|
64
|
+
display: 'flex',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
flexWrap: 'wrap',
|
|
68
|
+
gap: '4px 12px',
|
|
69
|
+
boxSizing: 'border-box',
|
|
70
|
+
width: '100%',
|
|
71
|
+
maxWidth: 'var(--dsh-chat-content-width)',
|
|
72
|
+
margin: '0 auto',
|
|
73
|
+
padding: '4px calc(var(--dsh-composer-side-clearance, 16px) + 16px) 0',
|
|
74
|
+
fontSize: 'var(--dsh-content-font-size-secondary, 13px)',
|
|
75
|
+
lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
|
|
76
|
+
color: 'var(--dsw-alias-label-tertiary, #93a1c0)',
|
|
77
|
+
};
|
|
78
|
+
const anchorStyle = { display: 'inline-flex', minWidth: 0 };
|
|
79
|
+
/** Same inline-flex rhythm as `.bOPqQW_pill`; hover comes from CSS. */
|
|
80
|
+
const triggerStyle = {
|
|
81
|
+
boxSizing: 'border-box',
|
|
82
|
+
display: 'inline-flex',
|
|
83
|
+
alignItems: 'center',
|
|
84
|
+
gap: 6,
|
|
85
|
+
maxWidth: '100%',
|
|
86
|
+
padding: '1px 8px',
|
|
87
|
+
border: 'none',
|
|
88
|
+
borderRadius: 24,
|
|
89
|
+
background: 'transparent',
|
|
90
|
+
color: 'inherit',
|
|
91
|
+
font: 'inherit',
|
|
92
|
+
fontVariantNumeric: 'tabular-nums',
|
|
93
|
+
lineHeight: 'inherit',
|
|
94
|
+
whiteSpace: 'nowrap',
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
};
|
|
97
|
+
const labelStyle = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' };
|
|
98
|
+
const glyphStyle = { flex: 'none', width: 14, height: 14 };
|
|
99
|
+
const dialogPanelStyle = {
|
|
100
|
+
position: 'fixed',
|
|
101
|
+
zIndex: 1100,
|
|
102
|
+
boxSizing: 'border-box',
|
|
103
|
+
transform: 'translateX(-50%)',
|
|
104
|
+
width: 'max-content',
|
|
105
|
+
minWidth: 'min(300px, 100vw - 24px)',
|
|
106
|
+
maxWidth: 'min(440px, 100vw - 24px)',
|
|
107
|
+
background: 'var(--dsw-specific-menu)',
|
|
108
|
+
boxShadow: 'var(--dsw-elevation-prominent)',
|
|
109
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
110
|
+
border: 0,
|
|
111
|
+
borderRadius: 12,
|
|
112
|
+
padding: 16,
|
|
113
|
+
fontSize: 12,
|
|
114
|
+
lineHeight: '18px',
|
|
115
|
+
};
|
|
116
|
+
const dialogTitleStyle = {
|
|
117
|
+
display: 'flex',
|
|
118
|
+
alignItems: 'center',
|
|
119
|
+
gap: 6,
|
|
120
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
121
|
+
marginBottom: 8,
|
|
122
|
+
fontWeight: 500,
|
|
123
|
+
};
|
|
124
|
+
const dialogRuleStyle = { borderTop: '.5px solid var(--dsw-alias-border-l2)', marginBottom: 10 };
|
|
125
|
+
const listStyle = {
|
|
126
|
+
listStyle: 'none',
|
|
127
|
+
margin: 0,
|
|
128
|
+
padding: 0,
|
|
129
|
+
display: 'flex',
|
|
130
|
+
flexDirection: 'column',
|
|
131
|
+
gap: 6,
|
|
132
|
+
maxHeight: 240,
|
|
133
|
+
overflowY: 'auto',
|
|
134
|
+
};
|
|
135
|
+
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 };
|
|
136
|
+
const rowBadgeStyle = { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', fontVariantNumeric: 'tabular-nums' };
|
|
137
|
+
const rowProviderStyle = {
|
|
138
|
+
flex: 1,
|
|
139
|
+
minWidth: 0,
|
|
140
|
+
overflow: 'hidden',
|
|
141
|
+
textOverflow: 'ellipsis',
|
|
142
|
+
whiteSpace: 'nowrap',
|
|
143
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
144
|
+
};
|
|
145
|
+
const rowMetaStyle = { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', fontVariantNumeric: 'tabular-nums' };
|
|
146
|
+
const cancelButtonStyle = {
|
|
147
|
+
flex: 'none',
|
|
148
|
+
border: 'none',
|
|
149
|
+
background: 'transparent',
|
|
150
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
151
|
+
borderRadius: 6,
|
|
152
|
+
padding: '2px 8px',
|
|
153
|
+
font: 'inherit',
|
|
154
|
+
cursor: 'pointer',
|
|
155
|
+
};
|
|
156
|
+
/** Clock glyph for the queue pill (stroke-style, matches the stats pills). */
|
|
157
|
+
function QueueGlyph() {
|
|
158
|
+
return React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, style: glyphStyle }, React.createElement('circle', { cx: 8, cy: 8, r: 6, stroke: 'currentColor', strokeWidth: 1.25 }), React.createElement('path', {
|
|
159
|
+
d: 'M8 4.6V8.4L10.6 10',
|
|
160
|
+
stroke: 'currentColor',
|
|
161
|
+
strokeWidth: 1.25,
|
|
162
|
+
strokeLinecap: 'round',
|
|
163
|
+
strokeLinejoin: 'round',
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
/** Snowflake glyph for the cooling pill. */
|
|
167
|
+
function CoolingGlyph() {
|
|
168
|
+
return React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, style: glyphStyle }, React.createElement('path', {
|
|
169
|
+
d: 'M8 1.5V14.5M2.4 4.75L13.6 11.25M13.6 4.75L2.4 11.25',
|
|
170
|
+
stroke: 'currentColor',
|
|
171
|
+
strokeWidth: 1.25,
|
|
172
|
+
strokeLinecap: 'round',
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* One compact trigger that opens its dialog above itself.
|
|
177
|
+
*
|
|
178
|
+
* Mirrors the official stats-pill interaction (anchored panel, outside
|
|
179
|
+
* pointer and Escape dismiss) with local state, so the browser half needs no
|
|
180
|
+
* UI-primitives types.
|
|
181
|
+
*
|
|
182
|
+
* @param props - Glyph, pill label, dialog title, and dialog body.
|
|
183
|
+
* @returns The trigger plus, while open, its portaled dialog.
|
|
184
|
+
*/
|
|
185
|
+
function DockPill(props) {
|
|
186
|
+
const { icon, label, title, children } = props;
|
|
187
|
+
const [open, setOpen] = React.useState(false);
|
|
188
|
+
const [pos, setPos] = React.useState(undefined);
|
|
189
|
+
const rootRef = React.useRef(null);
|
|
190
|
+
const panelRef = React.useRef(null);
|
|
191
|
+
React.useEffect(() => {
|
|
192
|
+
if (!open) {
|
|
193
|
+
setPos(undefined);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const anchor = rootRef.current;
|
|
197
|
+
if (anchor === null)
|
|
198
|
+
return;
|
|
199
|
+
const rect = anchor.getBoundingClientRect();
|
|
200
|
+
setPos({ left: rect.left + rect.width / 2, bottom: window.innerHeight - rect.top + 8 });
|
|
201
|
+
}, [open]);
|
|
202
|
+
React.useEffect(() => {
|
|
203
|
+
if (!open)
|
|
204
|
+
return;
|
|
205
|
+
const onPointerDown = (event) => {
|
|
206
|
+
const target = event.target;
|
|
207
|
+
if (target !== null) {
|
|
208
|
+
if (rootRef.current !== null && rootRef.current.contains(target))
|
|
209
|
+
return;
|
|
210
|
+
if (panelRef.current !== null && panelRef.current.contains(target))
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
setOpen(false);
|
|
214
|
+
};
|
|
215
|
+
const onKeyDown = (event) => {
|
|
216
|
+
if (event.key === 'Escape')
|
|
217
|
+
setOpen(false);
|
|
218
|
+
};
|
|
219
|
+
document.addEventListener('mousedown', onPointerDown, true);
|
|
220
|
+
document.addEventListener('keydown', onKeyDown);
|
|
221
|
+
return () => {
|
|
222
|
+
document.removeEventListener('mousedown', onPointerDown, true);
|
|
223
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
224
|
+
};
|
|
225
|
+
}, [open]);
|
|
226
|
+
const trigger = React.createElement('span', { ref: rootRef, style: anchorStyle }, React.createElement('button', {
|
|
227
|
+
type: 'button',
|
|
228
|
+
className: DOCK_PILL_CLASS,
|
|
229
|
+
style: triggerStyle,
|
|
230
|
+
'aria-haspopup': 'dialog',
|
|
231
|
+
'aria-expanded': open,
|
|
232
|
+
'aria-label': label,
|
|
233
|
+
title: label,
|
|
234
|
+
onClick: () => setOpen(!open),
|
|
235
|
+
}, icon, React.createElement('span', { style: labelStyle }, label)));
|
|
236
|
+
const dialog = open
|
|
237
|
+
? createPortal(React.createElement('div', {
|
|
238
|
+
ref: panelRef,
|
|
239
|
+
className: 'dsh-llm-ctl-queue-dialog',
|
|
240
|
+
role: 'dialog',
|
|
241
|
+
'aria-label': title,
|
|
242
|
+
style: pos === undefined ? { ...dialogPanelStyle, visibility: 'hidden' } : { ...dialogPanelStyle, ...pos },
|
|
243
|
+
}, React.createElement('div', { style: dialogTitleStyle }, icon, title), React.createElement('div', { style: dialogRuleStyle, 'aria-hidden': true }), children), document.body)
|
|
244
|
+
: null;
|
|
245
|
+
return React.createElement(React.Fragment, null, trigger, dialog);
|
|
246
|
+
}
|
|
247
|
+
/** Queue pill: count plus the front waiter's ETA, details on click. */
|
|
248
|
+
function QueuePill(props) {
|
|
249
|
+
const { waiters, onCancel } = props;
|
|
250
|
+
const first = waiters[0];
|
|
251
|
+
const label = first === undefined ? '排队 ' + waiters.length : '排队 ' + waiters.length + ' · ~' + formatMs(first.etaMs);
|
|
252
|
+
return React.createElement(DockPill, { icon: QueueGlyph(), label, title: '队列状况' }, React.createElement(QueueDetails, { waiters, onCancel }));
|
|
253
|
+
}
|
|
254
|
+
/** Dialog body: one row per waiter, each with its own cancel button. */
|
|
255
|
+
export function QueueDetails(props) {
|
|
256
|
+
const { waiters, onCancel } = props;
|
|
257
|
+
return React.createElement('ul', { style: listStyle }, waiters.map((waiter) => React.createElement('li', { key: waiter.queueId, style: rowStyle }, React.createElement('span', { style: rowBadgeStyle }, '#' + waiter.position), React.createElement('span', { style: rowProviderStyle }, waiter.provider), React.createElement('span', { style: rowMetaStyle }, (waiter.origin === 'background' ? '后台' : '对话') + ' · ' + formatMs(waiter.etaMs)), React.createElement('button', {
|
|
258
|
+
type: 'button',
|
|
259
|
+
className: DOCK_CANCEL_CLASS,
|
|
260
|
+
style: cancelButtonStyle,
|
|
261
|
+
onClick: () => onCancel(waiter.queueId),
|
|
262
|
+
}, '取消'))));
|
|
263
|
+
}
|
|
264
|
+
/** Cooling pill: lane count plus the longest remaining cooldown. */
|
|
265
|
+
function CoolingPill(props) {
|
|
266
|
+
const { lanes } = props;
|
|
267
|
+
const longest = lanes.reduce((max, lane) => Math.max(max, lane.cooldownRemainingMs), 0);
|
|
268
|
+
return React.createElement(DockPill, { icon: CoolingGlyph(), label: '冷却 ' + lanes.length + ' · 最长 ' + formatMs(longest), title: '冷却中的 provider' }, React.createElement(CoolingDetails, { lanes }));
|
|
269
|
+
}
|
|
270
|
+
/** Dialog body: one row per cooling lane. */
|
|
271
|
+
export function CoolingDetails(props) {
|
|
272
|
+
const { lanes } = props;
|
|
273
|
+
return React.createElement('ul', { style: listStyle }, lanes.map((lane) => React.createElement('li', { key: lane.provider, style: rowStyle }, React.createElement('span', { style: rowProviderStyle }, lane.provider), React.createElement('span', { style: rowMetaStyle }, formatMs(lane.cooldownRemainingMs)))));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Queue/cooldown pills for the composer dock.
|
|
277
|
+
*
|
|
278
|
+
* @param props - Shaped dock view plus the cancel callback.
|
|
279
|
+
* @returns The rendered pill row.
|
|
280
|
+
*/
|
|
281
|
+
export function QueueDockPanel(props) {
|
|
282
|
+
const { view, onCancel } = props;
|
|
283
|
+
const children = [];
|
|
284
|
+
if (view.waiters.length > 0) {
|
|
285
|
+
children.push(React.createElement(QueuePill, { key: 'queue', waiters: view.waiters, onCancel }));
|
|
286
|
+
}
|
|
287
|
+
if (view.cooling.length > 0) {
|
|
288
|
+
children.push(React.createElement(CoolingPill, { key: 'cooling', lanes: view.cooling }));
|
|
289
|
+
}
|
|
290
|
+
return React.createElement('div', { style: panelStyle, role: 'status', 'aria-live': 'polite' }, children);
|
|
291
|
+
}
|
package/lib/queue.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider admission queue: concurrency cap, arrival-order FIFO, and the
|
|
3
|
+
* provider cooldown clock that follows a rate-limit failure.
|
|
4
|
+
*
|
|
5
|
+
* The gate is deliberately transport-free: it never touches a stream, only
|
|
6
|
+
* decides when a request may be dispatched. Clock and timers are injectable so
|
|
7
|
+
* the whole scheduler is testable without real time.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-llm-ctl/queue
|
|
10
|
+
*/
|
|
11
|
+
import type { CtlEvent, CtlEventKind, Origin } from './events.ts';
|
|
12
|
+
/** Why an admission request did not get a slot. */
|
|
13
|
+
export type AcquireFailureCode = 'QUEUE_FULL' | 'QUEUE_TIMEOUT' | 'ABORTED';
|
|
14
|
+
/** Successful admission: the caller must invoke `release` exactly once. */
|
|
15
|
+
export interface AcquireGranted {
|
|
16
|
+
ok: true;
|
|
17
|
+
queueId: string;
|
|
18
|
+
provider: string;
|
|
19
|
+
origin: Origin;
|
|
20
|
+
waitMs: number;
|
|
21
|
+
release: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** Refused admission. */
|
|
24
|
+
export interface AcquireRefused {
|
|
25
|
+
ok: false;
|
|
26
|
+
code: AcquireFailureCode;
|
|
27
|
+
provider: string;
|
|
28
|
+
origin: Origin;
|
|
29
|
+
waitMs: number;
|
|
30
|
+
reason: string;
|
|
31
|
+
queueId?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Result of one admission attempt. */
|
|
34
|
+
export type AcquireOutcome = AcquireGranted | AcquireRefused;
|
|
35
|
+
/** Minimal clock and timer seam, so tests can drive time deterministically. */
|
|
36
|
+
export interface Scheduler {
|
|
37
|
+
now(): number;
|
|
38
|
+
setTimer(callback: () => void, delayMs: number): unknown;
|
|
39
|
+
clearTimer(handle: unknown): void;
|
|
40
|
+
}
|
|
41
|
+
/** Production scheduler backed by the global timers. */
|
|
42
|
+
export declare const realScheduler: Scheduler;
|
|
43
|
+
/** One queued request as reported to the UI. */
|
|
44
|
+
export interface QueueWaiterView {
|
|
45
|
+
queueId: string;
|
|
46
|
+
provider: string;
|
|
47
|
+
origin: Origin;
|
|
48
|
+
position: number;
|
|
49
|
+
waitedMs: number;
|
|
50
|
+
etaMs: number;
|
|
51
|
+
}
|
|
52
|
+
/** Current control-plane state. */
|
|
53
|
+
export interface GateSnapshot {
|
|
54
|
+
at: number;
|
|
55
|
+
lanes: Array<{
|
|
56
|
+
provider: string;
|
|
57
|
+
active: number;
|
|
58
|
+
/** Effective cap; `0` means unlimited. */
|
|
59
|
+
concurrency: number;
|
|
60
|
+
queued: number;
|
|
61
|
+
cooldownRemainingMs: number;
|
|
62
|
+
averageDurationMs: number;
|
|
63
|
+
}>;
|
|
64
|
+
waiters: QueueWaiterView[];
|
|
65
|
+
}
|
|
66
|
+
/** Construction options for {@link ProviderGate}. */
|
|
67
|
+
export interface GateOptions {
|
|
68
|
+
scheduler?: Scheduler;
|
|
69
|
+
concurrencyFor: (provider: string) => number;
|
|
70
|
+
maxQueueDepth: number;
|
|
71
|
+
maxWaitMs: number;
|
|
72
|
+
/** Emit one observable fact. */
|
|
73
|
+
onEvent?: (kind: CtlEventKind, detail: Omit<CtlEvent, 'seq' | 'at' | 'kind'>) => void;
|
|
74
|
+
idFactory?: () => string;
|
|
75
|
+
}
|
|
76
|
+
/** Admission gate over all provider routes. */
|
|
77
|
+
export declare class ProviderGate {
|
|
78
|
+
private readonly scheduler;
|
|
79
|
+
private readonly concurrencyFor;
|
|
80
|
+
private maxQueueDepth;
|
|
81
|
+
private maxWaitMs;
|
|
82
|
+
private readonly onEvent;
|
|
83
|
+
private readonly idFactory;
|
|
84
|
+
private readonly lanes;
|
|
85
|
+
private disposed;
|
|
86
|
+
private counter;
|
|
87
|
+
constructor(options: GateOptions);
|
|
88
|
+
private lane;
|
|
89
|
+
private emit;
|
|
90
|
+
private grant;
|
|
91
|
+
/** Refuse immediately when the provider cannot serve inside the budget. */
|
|
92
|
+
private preflightRefusal;
|
|
93
|
+
/** Request admission; resolves once a slot is granted or refused. */
|
|
94
|
+
acquire(provider: string, options: {
|
|
95
|
+
origin: Origin;
|
|
96
|
+
signal?: AbortSignal | undefined;
|
|
97
|
+
}): Promise<AcquireOutcome>;
|
|
98
|
+
/**
|
|
99
|
+
* Replace the global wait budget and depth cap at runtime.
|
|
100
|
+
*
|
|
101
|
+
* Only admissions started after the call observe the new values: waiters
|
|
102
|
+
* already queued keep the deadline timer armed at acquire time.
|
|
103
|
+
*
|
|
104
|
+
* @param limits - partial limits; each defined field replaces the current one.
|
|
105
|
+
*/
|
|
106
|
+
updateLimits(limits: {
|
|
107
|
+
maxWaitMs?: number;
|
|
108
|
+
maxQueueDepth?: number;
|
|
109
|
+
}): void;
|
|
110
|
+
/** Return a slot granted by {@link acquire}. */
|
|
111
|
+
release(provider: string, durationMs?: number): void;
|
|
112
|
+
/**
|
|
113
|
+
* Push the provider's cooldown to at least `now + delayMs`.
|
|
114
|
+
*
|
|
115
|
+
* A cooldown longer than the wait budget fails every current waiter at once:
|
|
116
|
+
* none of them could be served before its own deadline, so waiting would only
|
|
117
|
+
* spend the user's time to produce the same failure.
|
|
118
|
+
*/
|
|
119
|
+
registerCooldown(provider: string, delayMs: number, reason: string, source?: string): void;
|
|
120
|
+
/** Cancel one still-queued request. */
|
|
121
|
+
cancel(queueId: string): boolean;
|
|
122
|
+
/** Current lanes and waiters, ordered by provider then arrival. */
|
|
123
|
+
snapshot(): GateSnapshot;
|
|
124
|
+
/** Fail every waiter and stop all timers. */
|
|
125
|
+
dispose(): void;
|
|
126
|
+
private armCooldown;
|
|
127
|
+
private pump;
|
|
128
|
+
}
|