@cassius0924/dsh-usage-dashboard 0.8.3 → 0.9.1
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 +13 -46
- package/lib/client.js +795 -1020
- package/lib/client.js.map +4 -4
- package/lib/index.js +61 -506
- package/lib/index.js.map +4 -4
- package/lib/types/client/api.d.ts +1 -8
- package/lib/types/client/dom.d.ts +11 -5
- package/lib/types/client/locales.d.ts +25 -34
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/context.d.ts +0 -36
- package/lib/types/contract.d.ts +0 -65
- package/lib/types/index.d.ts +16 -19
- package/package.json +2 -2
- package/lib/types/breaker-gate.d.ts +0 -94
- package/lib/types/breaker-settings.d.ts +0 -28
- package/lib/types/breaker.d.ts +0 -49
- package/lib/types/client/breaker-pill.d.ts +0 -21
- package/lib/types/client/breaker-state.d.ts +0 -8
- package/lib/types/client/breaker.d.ts +0 -60
package/lib/index.js
CHANGED
|
@@ -2,18 +2,61 @@
|
|
|
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
|
-
}
|
|
5
|
+
|
|
6
|
+
// src/memo.ts
|
|
7
|
+
function memoize(ttlMs, compute) {
|
|
8
|
+
const state = { entry: void 0, inflight: void 0 };
|
|
9
|
+
const run = () => {
|
|
10
|
+
if (state.inflight !== void 0) return state.inflight;
|
|
11
|
+
const task = compute().then(
|
|
12
|
+
(value) => {
|
|
13
|
+
state.inflight = void 0;
|
|
14
|
+
if (value.ok) state.entry = { value, setAt: Date.now() };
|
|
15
|
+
return value;
|
|
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
|
+
}
|
|
17
60
|
|
|
18
61
|
// src/pricing.ts
|
|
19
62
|
var PEAK_PRICING_FROM_MS = Date.UTC(2026, 7, 16, 16, 0, 0);
|
|
@@ -109,338 +152,6 @@ function pricingInfo(nowMs) {
|
|
|
109
152
|
};
|
|
110
153
|
}
|
|
111
154
|
|
|
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
|
-
|
|
444
155
|
// src/usage.ts
|
|
445
156
|
var pad2 = (n) => n < 10 ? `0${n}` : String(n);
|
|
446
157
|
function errorMessage(err) {
|
|
@@ -1032,141 +743,9 @@ var name = "dsh-usage-dashboard";
|
|
|
1032
743
|
var inject = ["webServer", "credentials", "sessionPersistence"];
|
|
1033
744
|
var BALANCE_TTL_MS = 6e4;
|
|
1034
745
|
var USAGE_TTL_MS = 5 * 6e4;
|
|
1035
|
-
|
|
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 = {}) {
|
|
746
|
+
function apply(ctx) {
|
|
1057
747
|
const balanceMemo = memoize(BALANCE_TTL_MS, () => fetchBalance(ctx.credentials, ctx.get("deepseekAccount")));
|
|
1058
748
|
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
|
-
};
|
|
1170
749
|
ctx.effect(() => ctx.webServer.register({
|
|
1171
750
|
kind: "prefix",
|
|
1172
751
|
path: "/api/dsh-usage-dashboard",
|
|
@@ -1175,38 +754,12 @@ function apply(ctx, options = {}) {
|
|
|
1175
754
|
writeJson(res, 403, { ok: false, error: "forbidden" });
|
|
1176
755
|
return;
|
|
1177
756
|
}
|
|
1178
|
-
|
|
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") {
|
|
757
|
+
if (req.method !== "GET") {
|
|
1207
758
|
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1208
759
|
return;
|
|
1209
760
|
}
|
|
761
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
762
|
+
const pathname = url.pathname;
|
|
1210
763
|
const force = url.searchParams.get("refresh") === "1";
|
|
1211
764
|
if (pathname === "/api/dsh-usage-dashboard/balance") {
|
|
1212
765
|
const payload = await (force ? balanceMemo.refresh() : balanceMemo.get());
|
|
@@ -1225,6 +778,8 @@ function apply(ctx, options = {}) {
|
|
|
1225
778
|
}
|
|
1226
779
|
const payload = await fetchSessionUsage(ctx.sessionPersistence, id);
|
|
1227
780
|
writeJson(res, 200, payload, { "cache-control": "no-store" });
|
|
781
|
+
} else {
|
|
782
|
+
writeJson(res, 404, { ok: false, error: "not found" });
|
|
1228
783
|
}
|
|
1229
784
|
}
|
|
1230
785
|
}), "dsh-usage-dashboard: /api routes");
|