@cassius0924/dsh-usage-dashboard 0.8.1 → 0.8.3
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/README.md +55 -3
- package/lib/client.js +849 -277
- package/lib/client.js.map +4 -4
- package/lib/index.js +506 -61
- package/lib/index.js.map +4 -4
- package/lib/types/breaker-gate.d.ts +94 -0
- package/lib/types/breaker-settings.d.ts +28 -0
- package/lib/types/breaker.d.ts +49 -0
- package/lib/types/client/api.d.ts +8 -1
- package/lib/types/client/breaker-pill.d.ts +21 -0
- package/lib/types/client/breaker-state.d.ts +8 -0
- package/lib/types/client/breaker.d.ts +60 -0
- package/lib/types/client/charts.d.ts +8 -0
- package/lib/types/client/i18n.d.ts +21 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/client/top-up.d.ts +13 -0
- package/lib/types/context.d.ts +36 -0
- package/lib/types/contract.d.ts +65 -0
- package/lib/types/index.d.ts +19 -16
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -2,61 +2,18 @@
|
|
|
2
2
|
var USAGE_WINDOW_DAYS = [7, 30, 90, 365];
|
|
3
3
|
var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
4
|
var isValidSessionId = (id) => SESSION_ID_PATTERN.test(id);
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
(err) => {
|
|
18
|
-
state.inflight = void 0;
|
|
19
|
-
throw err;
|
|
20
|
-
}
|
|
21
|
-
);
|
|
22
|
-
state.inflight = task;
|
|
23
|
-
return task;
|
|
24
|
-
};
|
|
25
|
-
return {
|
|
26
|
-
get: () => {
|
|
27
|
-
const hit = state.entry;
|
|
28
|
-
if (hit !== void 0 && hit.value.ok && Date.now() - hit.setAt < ttlMs) {
|
|
29
|
-
return Promise.resolve(hit.value);
|
|
30
|
-
}
|
|
31
|
-
return run();
|
|
32
|
-
},
|
|
33
|
-
refresh: () => run()
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// src/trust-fence.ts
|
|
38
|
-
function isLoopbackHostname(hostname) {
|
|
39
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
40
|
-
}
|
|
41
|
-
function isTrustedApiRequest(req) {
|
|
42
|
-
const host = req.headers.host;
|
|
43
|
-
if (host === void 0) return false;
|
|
44
|
-
let hostUrl;
|
|
45
|
-
try {
|
|
46
|
-
hostUrl = new URL(`http://${host}`);
|
|
47
|
-
} catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
51
|
-
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
52
|
-
const origin = req.headers.origin;
|
|
53
|
-
if (origin === void 0) return true;
|
|
54
|
-
try {
|
|
55
|
-
return new URL(origin).host === hostUrl.host;
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
5
|
+
var BREAKER_DEFAULT_SETTINGS = { enabled: true, sessions: [] };
|
|
6
|
+
var BREAKER_API = {
|
|
7
|
+
state: "/api/dsh-usage-dashboard/breaker",
|
|
8
|
+
release: "/api/dsh-usage-dashboard/breaker/release"
|
|
9
|
+
};
|
|
10
|
+
var isBreakerSettingsRequest = (value) => {
|
|
11
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
12
|
+
const candidate = value;
|
|
13
|
+
if (typeof candidate.enabled !== "boolean") return false;
|
|
14
|
+
if (candidate.sessionId === void 0) return true;
|
|
15
|
+
return typeof candidate.sessionId === "string" && isValidSessionId(candidate.sessionId);
|
|
16
|
+
};
|
|
60
17
|
|
|
61
18
|
// src/pricing.ts
|
|
62
19
|
var PEAK_PRICING_FROM_MS = Date.UTC(2026, 7, 16, 16, 0, 0);
|
|
@@ -152,6 +109,338 @@ function pricingInfo(nowMs) {
|
|
|
152
109
|
};
|
|
153
110
|
}
|
|
154
111
|
|
|
112
|
+
// src/breaker.ts
|
|
113
|
+
var BEIJING_OFFSET_MS2 = 8 * 36e5;
|
|
114
|
+
var HOUR_MS = 36e5;
|
|
115
|
+
var DAY_MS = 864e5;
|
|
116
|
+
var BREAKER_PEAK_WINDOWS = ["09:00\u201312:00", "14:00\u201318:00"];
|
|
117
|
+
function nextResumeAt(nowMs) {
|
|
118
|
+
if (!isPeak(nowMs)) return nowMs;
|
|
119
|
+
const beijingMs = nowMs + BEIJING_OFFSET_MS2;
|
|
120
|
+
const dayStartMs = Math.floor(beijingMs / DAY_MS) * DAY_MS;
|
|
121
|
+
return dayStartMs + 18 * HOUR_MS - BEIJING_OFFSET_MS2;
|
|
122
|
+
}
|
|
123
|
+
function isUserTurn(messages) {
|
|
124
|
+
return messages.some((message) => message.source?.kind === "user");
|
|
125
|
+
}
|
|
126
|
+
function normalizeBreakerSettings(settings) {
|
|
127
|
+
const sessions = [...new Set(settings.sessions.filter(isValidSessionId))].sort();
|
|
128
|
+
return { enabled: settings.enabled, sessions };
|
|
129
|
+
}
|
|
130
|
+
function breakerProject(settings, nowMs, holding) {
|
|
131
|
+
const peak = isPeak(nowMs);
|
|
132
|
+
const normalized = normalizeBreakerSettings(settings);
|
|
133
|
+
const parkedSessions = [...new Set(holding.filter(isValidSessionId))].sort();
|
|
134
|
+
return {
|
|
135
|
+
enabled: normalized.enabled,
|
|
136
|
+
sessions: normalized.sessions,
|
|
137
|
+
holding: parkedSessions,
|
|
138
|
+
now: new Date(nowMs).toISOString(),
|
|
139
|
+
peak,
|
|
140
|
+
resumeAt: peak ? new Date(nextResumeAt(nowMs)).toISOString() : null,
|
|
141
|
+
peakWindows: [...BREAKER_PEAK_WINDOWS]
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/breaker-gate.ts
|
|
146
|
+
var MAX_BREAKER_TIMER_DELAY_MS = 2147483647;
|
|
147
|
+
var systemBreakerTimers = {
|
|
148
|
+
schedule: (callback, delayMs) => {
|
|
149
|
+
const handle = setTimeout(callback, delayMs);
|
|
150
|
+
handle.unref?.();
|
|
151
|
+
return handle;
|
|
152
|
+
},
|
|
153
|
+
cancel: (handle) => {
|
|
154
|
+
clearTimeout(handle);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
var clampBreakerTimerDelay = (delayMs) => {
|
|
158
|
+
if (!Number.isFinite(delayMs)) return 0;
|
|
159
|
+
return Math.max(0, Math.min(Math.trunc(delayMs), MAX_BREAKER_TIMER_DELAY_MS));
|
|
160
|
+
};
|
|
161
|
+
var MAX_TRACKED_TURNS = 512;
|
|
162
|
+
var BreakerGate = class {
|
|
163
|
+
#settings;
|
|
164
|
+
#sessions;
|
|
165
|
+
#now;
|
|
166
|
+
#timers;
|
|
167
|
+
#held = /* @__PURE__ */ new Set();
|
|
168
|
+
#turns = /* @__PURE__ */ new Map();
|
|
169
|
+
#timer;
|
|
170
|
+
#disposed = false;
|
|
171
|
+
constructor(options = {}) {
|
|
172
|
+
this.#settings = normalizeBreakerSettings(options.initialSettings ?? { ...BREAKER_DEFAULT_SETTINGS });
|
|
173
|
+
this.#sessions = new Set(this.#settings.sessions);
|
|
174
|
+
this.#now = options.now ?? Date.now;
|
|
175
|
+
this.#timers = options.timers ?? systemBreakerTimers;
|
|
176
|
+
}
|
|
177
|
+
get settings() {
|
|
178
|
+
return normalizeBreakerSettings(this.#settings);
|
|
179
|
+
}
|
|
180
|
+
/** Steps parked right now, across every session. */
|
|
181
|
+
get held() {
|
|
182
|
+
return this.#held.size;
|
|
183
|
+
}
|
|
184
|
+
/** Session ids with at least one parked step, sorted for the wire projection. */
|
|
185
|
+
get heldSessions() {
|
|
186
|
+
return [...new Set([...this.#held].map((record) => record.sessionId))].sort();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Park one proposed step until it may proceed. Resolves immediately when the
|
|
190
|
+
* step is not subject to the breaker (session not opted in, off-peak,
|
|
191
|
+
* machine-started turn, turn already released by hand) or when the turn is
|
|
192
|
+
* aborted/disposed.
|
|
193
|
+
*
|
|
194
|
+
* The listener wrapping this must call `next()` after it resolves — this
|
|
195
|
+
* method never throws and never returns a decision of its own.
|
|
196
|
+
*
|
|
197
|
+
* `sessionId` is the agent id from the payload; an agent without one cannot
|
|
198
|
+
* be matched against the opt-in list, so its steps pass.
|
|
199
|
+
*/
|
|
200
|
+
async hold(sessionId, turn, messages, signal) {
|
|
201
|
+
if (this.#disposed || signal.aborted || sessionId === void 0 || sessionId === "") return;
|
|
202
|
+
const key = `${sessionId}:${turn}`;
|
|
203
|
+
let state = this.#turns.get(key);
|
|
204
|
+
const user = isUserTurn(messages) || state?.user === true;
|
|
205
|
+
if (state === void 0) {
|
|
206
|
+
state = { user, released: false };
|
|
207
|
+
this.rememberTurn(key, state);
|
|
208
|
+
} else {
|
|
209
|
+
state.user = user;
|
|
210
|
+
}
|
|
211
|
+
if (state.released || !this.#settings.enabled || !this.#sessions.has(sessionId) || !isPeak(this.#now()) || !state.user) return;
|
|
212
|
+
await new Promise((resolve) => {
|
|
213
|
+
const onAbort = () => {
|
|
214
|
+
this.releaseRecord(record, false);
|
|
215
|
+
};
|
|
216
|
+
const record = {
|
|
217
|
+
sessionId,
|
|
218
|
+
turn,
|
|
219
|
+
resolve,
|
|
220
|
+
detach: () => {
|
|
221
|
+
signal.removeEventListener("abort", onAbort);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
signal.addEventListener("abort", onAbort);
|
|
225
|
+
this.#held.add(record);
|
|
226
|
+
this.armTimer();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Let parked steps continue now. `sessionId` narrows it to one session; no
|
|
231
|
+
* argument releases every parked step (the UI's "continue all" button). The
|
|
232
|
+
* released turns are remembered, because re-parking their next step would
|
|
233
|
+
* contradict the user's instruction.
|
|
234
|
+
* @returns how many parked steps were released.
|
|
235
|
+
*/
|
|
236
|
+
release(sessionId) {
|
|
237
|
+
const released = [...this.#held].filter((record) => sessionId === void 0 || record.sessionId === sessionId);
|
|
238
|
+
for (const record of released) this.releaseRecord(record, true);
|
|
239
|
+
return released.length;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Apply new settings. Turning off the master switch — or a session's own
|
|
243
|
+
* switch — releases the affected parked steps immediately and without
|
|
244
|
+
* remembering a bypass: that is the user's decision, not a request for one
|
|
245
|
+
* particular turn to run.
|
|
246
|
+
*/
|
|
247
|
+
update(settings) {
|
|
248
|
+
this.#settings = normalizeBreakerSettings(settings);
|
|
249
|
+
this.#sessions = new Set(this.#settings.sessions);
|
|
250
|
+
if (this.#disposed) return;
|
|
251
|
+
const nowMs = this.#now();
|
|
252
|
+
for (const record of [...this.#held]) {
|
|
253
|
+
if (!this.#settings.enabled || !this.#sessions.has(record.sessionId) || !isPeak(nowMs)) {
|
|
254
|
+
this.releaseRecord(record, false);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (this.#held.size > 0) this.armTimer();
|
|
258
|
+
else this.clearTimer();
|
|
259
|
+
}
|
|
260
|
+
/** Release everything and stop the timer; called when the plugin unloads. */
|
|
261
|
+
dispose() {
|
|
262
|
+
this.#disposed = true;
|
|
263
|
+
this.clearTimer();
|
|
264
|
+
for (const record of [...this.#held]) this.releaseRecord(record, false);
|
|
265
|
+
this.#turns.clear();
|
|
266
|
+
}
|
|
267
|
+
/** (Re)arm the single settle timer for the current peak window. */
|
|
268
|
+
armTimer() {
|
|
269
|
+
this.clearTimer();
|
|
270
|
+
if (this.#disposed || this.#held.size === 0) return;
|
|
271
|
+
const nowMs = this.#now();
|
|
272
|
+
const delayMs = nextResumeAt(nowMs) - nowMs;
|
|
273
|
+
if (!(delayMs > 0)) return;
|
|
274
|
+
const timer = { handle: void 0 };
|
|
275
|
+
timer.handle = this.#timers.schedule(() => {
|
|
276
|
+
if (this.#timer === timer) this.#timer = void 0;
|
|
277
|
+
this.onTimer();
|
|
278
|
+
}, clampBreakerTimerDelay(delayMs));
|
|
279
|
+
this.#timer = timer;
|
|
280
|
+
}
|
|
281
|
+
onTimer() {
|
|
282
|
+
if (this.#disposed || this.#held.size === 0) return;
|
|
283
|
+
if (isPeak(this.#now())) {
|
|
284
|
+
this.armTimer();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const record of [...this.#held]) this.releaseRecord(record, false);
|
|
288
|
+
}
|
|
289
|
+
clearTimer() {
|
|
290
|
+
const timer = this.#timer;
|
|
291
|
+
if (timer === void 0) return;
|
|
292
|
+
this.#timer = void 0;
|
|
293
|
+
this.#timers.cancel(timer.handle);
|
|
294
|
+
}
|
|
295
|
+
releaseRecord(record, remember) {
|
|
296
|
+
if (!this.#held.delete(record)) return;
|
|
297
|
+
record.detach();
|
|
298
|
+
if (remember) this.markReleased(record.sessionId, record.turn);
|
|
299
|
+
record.resolve();
|
|
300
|
+
if (this.#held.size === 0) this.clearTimer();
|
|
301
|
+
}
|
|
302
|
+
markReleased(sessionId, turn) {
|
|
303
|
+
const key = `${sessionId}:${turn}`;
|
|
304
|
+
const state = this.#turns.get(key);
|
|
305
|
+
if (state !== void 0) {
|
|
306
|
+
state.released = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.rememberTurn(key, { user: false, released: true });
|
|
310
|
+
}
|
|
311
|
+
rememberTurn(key, state) {
|
|
312
|
+
if (this.#turns.size >= MAX_TRACKED_TURNS) {
|
|
313
|
+
const oldest = this.#turns.keys().next();
|
|
314
|
+
if (!oldest.done) this.#turns.delete(oldest.value);
|
|
315
|
+
}
|
|
316
|
+
this.#turns.set(key, state);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// src/breaker-settings.ts
|
|
321
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
322
|
+
import { homedir } from "node:os";
|
|
323
|
+
import { dirname, join } from "node:path";
|
|
324
|
+
var PLUGIN_LABEL = "dsh-usage-dashboard";
|
|
325
|
+
var STORAGE_RELATIVE_PATH = join("storages", "dsh-usage-dashboard", "breaker.json");
|
|
326
|
+
function breakerSettingsPath(env = process.env) {
|
|
327
|
+
const configured = env.DSH_HOME?.trim();
|
|
328
|
+
const home = configured !== void 0 && configured !== "" ? configured : join(homedir(), ".dsh");
|
|
329
|
+
return join(home, STORAGE_RELATIVE_PATH);
|
|
330
|
+
}
|
|
331
|
+
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
332
|
+
var defaultWarn = (message) => {
|
|
333
|
+
console.warn(message);
|
|
334
|
+
};
|
|
335
|
+
function settingsOf(raw) {
|
|
336
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
337
|
+
const candidate = raw;
|
|
338
|
+
if (!Array.isArray(candidate.sessions)) return void 0;
|
|
339
|
+
if (candidate.enabled !== void 0 && typeof candidate.enabled !== "boolean") return void 0;
|
|
340
|
+
const ids = candidate.sessions.filter((entry) => typeof entry === "string");
|
|
341
|
+
return normalizeBreakerSettings({
|
|
342
|
+
enabled: candidate.enabled ?? true,
|
|
343
|
+
sessions: ids
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function loadBreakerSettings(options = {}) {
|
|
347
|
+
const filePath = options.filePath ?? breakerSettingsPath();
|
|
348
|
+
const warn = options.warn ?? defaultWarn;
|
|
349
|
+
let text;
|
|
350
|
+
try {
|
|
351
|
+
text = await readFile(filePath, "utf8");
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const code = error.code;
|
|
354
|
+
if (code !== "ENOENT") {
|
|
355
|
+
warn(`[${PLUGIN_LABEL}] \u8BFB\u53D6\u9AD8\u5CF0\u6682\u505C\u8BBE\u7F6E\u5931\u8D25\uFF0C\u6309\u9ED8\u8BA4\u503C\u5904\u7406\uFF1A${errorText(error)}`);
|
|
356
|
+
}
|
|
357
|
+
return { ...BREAKER_DEFAULT_SETTINGS };
|
|
358
|
+
}
|
|
359
|
+
let raw;
|
|
360
|
+
try {
|
|
361
|
+
raw = JSON.parse(text);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
warn(`[${PLUGIN_LABEL}] \u9AD8\u5CF0\u6682\u505C\u8BBE\u7F6E\u4E0D\u662F\u5408\u6CD5 JSON\uFF0C\u6309\u9ED8\u8BA4\u503C\u5904\u7406\uFF1A${errorText(error)}`);
|
|
364
|
+
return { ...BREAKER_DEFAULT_SETTINGS };
|
|
365
|
+
}
|
|
366
|
+
const settings = settingsOf(raw);
|
|
367
|
+
if (settings === void 0) {
|
|
368
|
+
warn(`[${PLUGIN_LABEL}] \u9AD8\u5CF0\u6682\u505C\u8BBE\u7F6E\u7ED3\u6784\u4E0D\u5408\u6CD5\uFF0C\u6309\u9ED8\u8BA4\u503C\u5904\u7406`);
|
|
369
|
+
return { ...BREAKER_DEFAULT_SETTINGS };
|
|
370
|
+
}
|
|
371
|
+
return settings;
|
|
372
|
+
}
|
|
373
|
+
async function saveBreakerSettings(settings, options = {}) {
|
|
374
|
+
const filePath = options.filePath ?? breakerSettingsPath();
|
|
375
|
+
const normalized = normalizeBreakerSettings(settings);
|
|
376
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
377
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
378
|
+
try {
|
|
379
|
+
await writeFile(tempPath, `${JSON.stringify(normalized, null, 2)}
|
|
380
|
+
`, "utf8");
|
|
381
|
+
await rename(tempPath, filePath);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
384
|
+
});
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/memo.ts
|
|
390
|
+
function memoize(ttlMs, compute) {
|
|
391
|
+
const state = { entry: void 0, inflight: void 0 };
|
|
392
|
+
const run = () => {
|
|
393
|
+
if (state.inflight !== void 0) return state.inflight;
|
|
394
|
+
const task = compute().then(
|
|
395
|
+
(value) => {
|
|
396
|
+
state.inflight = void 0;
|
|
397
|
+
if (value.ok) state.entry = { value, setAt: Date.now() };
|
|
398
|
+
return value;
|
|
399
|
+
},
|
|
400
|
+
(err) => {
|
|
401
|
+
state.inflight = void 0;
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
);
|
|
405
|
+
state.inflight = task;
|
|
406
|
+
return task;
|
|
407
|
+
};
|
|
408
|
+
return {
|
|
409
|
+
get: () => {
|
|
410
|
+
const hit = state.entry;
|
|
411
|
+
if (hit !== void 0 && hit.value.ok && Date.now() - hit.setAt < ttlMs) {
|
|
412
|
+
return Promise.resolve(hit.value);
|
|
413
|
+
}
|
|
414
|
+
return run();
|
|
415
|
+
},
|
|
416
|
+
refresh: () => run()
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/trust-fence.ts
|
|
421
|
+
function isLoopbackHostname(hostname) {
|
|
422
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
423
|
+
}
|
|
424
|
+
function isTrustedApiRequest(req) {
|
|
425
|
+
const host = req.headers.host;
|
|
426
|
+
if (host === void 0) return false;
|
|
427
|
+
let hostUrl;
|
|
428
|
+
try {
|
|
429
|
+
hostUrl = new URL(`http://${host}`);
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
434
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
435
|
+
const origin = req.headers.origin;
|
|
436
|
+
if (origin === void 0) return true;
|
|
437
|
+
try {
|
|
438
|
+
return new URL(origin).host === hostUrl.host;
|
|
439
|
+
} catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
155
444
|
// src/usage.ts
|
|
156
445
|
var pad2 = (n) => n < 10 ? `0${n}` : String(n);
|
|
157
446
|
function errorMessage(err) {
|
|
@@ -743,9 +1032,141 @@ var name = "dsh-usage-dashboard";
|
|
|
743
1032
|
var inject = ["webServer", "credentials", "sessionPersistence"];
|
|
744
1033
|
var BALANCE_TTL_MS = 6e4;
|
|
745
1034
|
var USAGE_TTL_MS = 5 * 6e4;
|
|
746
|
-
|
|
1035
|
+
var BREAKER_BODY_LIMIT_BYTES = 8 * 1024;
|
|
1036
|
+
var NO_STORE = { "cache-control": "no-store" };
|
|
1037
|
+
var errorText2 = (error) => error instanceof Error ? error.message : String(error);
|
|
1038
|
+
async function readBody(req, limit) {
|
|
1039
|
+
const chunks = [];
|
|
1040
|
+
let size = 0;
|
|
1041
|
+
try {
|
|
1042
|
+
for await (const chunk of req) {
|
|
1043
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1044
|
+
size += buffer.length;
|
|
1045
|
+
if (size > limit) {
|
|
1046
|
+
req.resume();
|
|
1047
|
+
return { kind: "too-large" };
|
|
1048
|
+
}
|
|
1049
|
+
chunks.push(buffer);
|
|
1050
|
+
}
|
|
1051
|
+
} catch {
|
|
1052
|
+
return { kind: "unreadable" };
|
|
1053
|
+
}
|
|
1054
|
+
return { kind: "ok", text: Buffer.concat(chunks).toString("utf8") };
|
|
1055
|
+
}
|
|
1056
|
+
function apply(ctx, options = {}) {
|
|
747
1057
|
const balanceMemo = memoize(BALANCE_TTL_MS, () => fetchBalance(ctx.credentials, ctx.get("deepseekAccount")));
|
|
748
1058
|
const usageMemo = memoize(USAGE_TTL_MS, () => fetchUsage(ctx.sessionPersistence));
|
|
1059
|
+
const seams = options ?? {};
|
|
1060
|
+
const warn = seams.warn ?? ((message) => {
|
|
1061
|
+
console.warn(message);
|
|
1062
|
+
});
|
|
1063
|
+
const now = seams.now ?? Date.now;
|
|
1064
|
+
const settingsFile = seams.settingsFile ?? breakerSettingsPath();
|
|
1065
|
+
const gate = new BreakerGate({ now, timers: seams.timers });
|
|
1066
|
+
let settingsTouched = false;
|
|
1067
|
+
void loadBreakerSettings({ filePath: settingsFile, warn }).then((settings) => {
|
|
1068
|
+
if (!settingsTouched) gate.update(settings);
|
|
1069
|
+
}).catch((error) => {
|
|
1070
|
+
warn(`[${name}] \u9AD8\u5CF0\u6682\u505C\u8BBE\u7F6E\u52A0\u8F7D\u5931\u8D25\uFF0C\u6309\u9ED8\u8BA4\u503C\u5904\u7406\uFF1A${errorText2(error)}`);
|
|
1071
|
+
});
|
|
1072
|
+
const projection = () => breakerProject(gate.settings, now(), gate.heldSessions);
|
|
1073
|
+
const hostEvents = ctx;
|
|
1074
|
+
ctx.effect(() => {
|
|
1075
|
+
const dispose = hostEvents.on("agent/pre-step", async (payload, next) => {
|
|
1076
|
+
try {
|
|
1077
|
+
await gate.hold(payload.agent?.id, payload.turn, payload.messages, payload.signal);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
warn(`[${name}] \u9AD8\u5CF0\u6682\u505C\u6302\u8D77\u5931\u8D25\uFF0C\u672C\u6B21 step \u76F4\u63A5\u653E\u884C\uFF1A${errorText2(error)}`);
|
|
1080
|
+
}
|
|
1081
|
+
return next();
|
|
1082
|
+
});
|
|
1083
|
+
return () => {
|
|
1084
|
+
dispose();
|
|
1085
|
+
gate.dispose();
|
|
1086
|
+
};
|
|
1087
|
+
}, "dsh-usage-dashboard: \u9AD8\u5CF0\u6682\u505C");
|
|
1088
|
+
const writeSettings = async (req, res) => {
|
|
1089
|
+
const body = await readBody(req, BREAKER_BODY_LIMIT_BYTES);
|
|
1090
|
+
if (body.kind === "too-large") {
|
|
1091
|
+
writeJson(res, 413, { ok: false, error: "\u8BF7\u6C42\u4F53\u8D85\u8FC7 8KB \u9650\u5236" }, NO_STORE);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (body.kind === "unreadable") {
|
|
1095
|
+
writeJson(res, 400, { ok: false, error: "\u65E0\u6CD5\u8BFB\u53D6\u8BF7\u6C42\u4F53" }, NO_STORE);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
let raw;
|
|
1099
|
+
try {
|
|
1100
|
+
raw = JSON.parse(body.text);
|
|
1101
|
+
} catch {
|
|
1102
|
+
writeJson(res, 400, { ok: false, error: "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON" }, NO_STORE);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (!isBreakerSettingsRequest(raw)) {
|
|
1106
|
+
writeJson(res, 400, { ok: false, error: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F\u5408\u6CD5\u7684\u5F00\u5173\u8BF7\u6C42" }, NO_STORE);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const previous = gate.settings;
|
|
1110
|
+
let next;
|
|
1111
|
+
if (raw.sessionId === void 0) {
|
|
1112
|
+
next = normalizeBreakerSettings({ enabled: raw.enabled, sessions: previous.sessions });
|
|
1113
|
+
} else {
|
|
1114
|
+
const sessions = new Set(previous.sessions);
|
|
1115
|
+
if (raw.enabled) sessions.add(raw.sessionId);
|
|
1116
|
+
else sessions.delete(raw.sessionId);
|
|
1117
|
+
next = normalizeBreakerSettings({ enabled: previous.enabled, sessions: [...sessions] });
|
|
1118
|
+
}
|
|
1119
|
+
settingsTouched = true;
|
|
1120
|
+
gate.update(next);
|
|
1121
|
+
try {
|
|
1122
|
+
await saveBreakerSettings(next, { filePath: settingsFile });
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
gate.update(previous);
|
|
1125
|
+
warn(`[${name}] \u9AD8\u5CF0\u6682\u505C\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25\uFF0C\u5DF2\u56DE\u9000\uFF1A${errorText2(error)}`);
|
|
1126
|
+
writeJson(res, 500, { ok: false, error: "\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25" }, NO_STORE);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
writeJson(res, 200, { ok: true, data: projection() }, NO_STORE);
|
|
1130
|
+
};
|
|
1131
|
+
const releaseHeld = async (req, res) => {
|
|
1132
|
+
const body = await readBody(req, BREAKER_BODY_LIMIT_BYTES);
|
|
1133
|
+
if (body.kind === "too-large") {
|
|
1134
|
+
writeJson(res, 413, { ok: false, error: "\u8BF7\u6C42\u4F53\u8D85\u8FC7 8KB \u9650\u5236" }, NO_STORE);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (body.kind === "unreadable") {
|
|
1138
|
+
writeJson(res, 400, { ok: false, error: "\u65E0\u6CD5\u8BFB\u53D6\u8BF7\u6C42\u4F53" }, NO_STORE);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
let target;
|
|
1142
|
+
if (body.text.trim() !== "") {
|
|
1143
|
+
let raw;
|
|
1144
|
+
try {
|
|
1145
|
+
raw = JSON.parse(body.text);
|
|
1146
|
+
} catch {
|
|
1147
|
+
writeJson(res, 400, { ok: false, error: "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON" }, NO_STORE);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1151
|
+
writeJson(res, 400, { ok: false, error: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61" }, NO_STORE);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const candidate = raw;
|
|
1155
|
+
if (Object.keys(candidate).some((key) => key !== "sessionId")) {
|
|
1156
|
+
writeJson(res, 400, { ok: false, error: "\u8BF7\u6C42\u5B57\u6BB5\u4E0D\u5408\u6CD5" }, NO_STORE);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (candidate.sessionId !== void 0) {
|
|
1160
|
+
if (typeof candidate.sessionId !== "string" || !isValidSessionId(candidate.sessionId)) {
|
|
1161
|
+
writeJson(res, 400, { ok: false, error: "\u4F1A\u8BDD id \u683C\u5F0F\u4E0D\u5408\u6CD5" }, NO_STORE);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
target = candidate.sessionId;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
gate.release(target);
|
|
1168
|
+
writeJson(res, 200, { ok: true, data: projection() }, NO_STORE);
|
|
1169
|
+
};
|
|
749
1170
|
ctx.effect(() => ctx.webServer.register({
|
|
750
1171
|
kind: "prefix",
|
|
751
1172
|
path: "/api/dsh-usage-dashboard",
|
|
@@ -754,12 +1175,38 @@ function apply(ctx) {
|
|
|
754
1175
|
writeJson(res, 403, { ok: false, error: "forbidden" });
|
|
755
1176
|
return;
|
|
756
1177
|
}
|
|
757
|
-
|
|
1178
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
1179
|
+
const pathname = url.pathname;
|
|
1180
|
+
const method = req.method ?? "GET";
|
|
1181
|
+
if (pathname === BREAKER_API.state) {
|
|
1182
|
+
if (method === "GET") {
|
|
1183
|
+
writeJson(res, 200, { ok: true, data: projection() }, NO_STORE);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (method === "PUT") {
|
|
1187
|
+
await writeSettings(req, res);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" }, { ...NO_STORE, allow: "GET, PUT" });
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
if (pathname === BREAKER_API.release) {
|
|
1194
|
+
if (method === "POST") {
|
|
1195
|
+
await releaseHeld(req, res);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" }, { ...NO_STORE, allow: "POST" });
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const knownReadPath = pathname === "/api/dsh-usage-dashboard/balance" || pathname === "/api/dsh-usage-dashboard/usage" || pathname === "/api/dsh-usage-dashboard/session";
|
|
1202
|
+
if (!knownReadPath) {
|
|
1203
|
+
writeJson(res, 404, { ok: false, error: "not found" });
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (method !== "GET") {
|
|
758
1207
|
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
759
1208
|
return;
|
|
760
1209
|
}
|
|
761
|
-
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
762
|
-
const pathname = url.pathname;
|
|
763
1210
|
const force = url.searchParams.get("refresh") === "1";
|
|
764
1211
|
if (pathname === "/api/dsh-usage-dashboard/balance") {
|
|
765
1212
|
const payload = await (force ? balanceMemo.refresh() : balanceMemo.get());
|
|
@@ -778,8 +1225,6 @@ function apply(ctx) {
|
|
|
778
1225
|
}
|
|
779
1226
|
const payload = await fetchSessionUsage(ctx.sessionPersistence, id);
|
|
780
1227
|
writeJson(res, 200, payload, { "cache-control": "no-store" });
|
|
781
|
-
} else {
|
|
782
|
-
writeJson(res, 404, { ok: false, error: "not found" });
|
|
783
1228
|
}
|
|
784
1229
|
}
|
|
785
1230
|
}), "dsh-usage-dashboard: /api routes");
|