@gehennawu/dsh-service 1.9.2 → 1.9.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.en.md +14 -7
- package/README.md +14 -7
- package/client.js +1 -1
- package/index.js +260 -70
- package/package.json +1 -1
- package/quota-routes.js +10 -1
package/index.js
CHANGED
|
@@ -112,8 +112,12 @@ const MAX_BACKUP_COMPRESSED_BYTES = 512 * 1024 * 1024
|
|
|
112
112
|
const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
113
113
|
const backupIdSecret = randomBytes(32)
|
|
114
114
|
const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
|
|
115
|
-
const USAGE_INDEX_VERSION =
|
|
115
|
+
const USAGE_INDEX_VERSION = 6
|
|
116
116
|
const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
|
|
117
|
+
// 报错滚动窗口:一个常量同时约束「折叠时是否收录」(recentErrorTime)、「出参前修剪」
|
|
118
|
+
// (publicUsage)与「落盘前修剪」(refreshUsageIndex),三者必须一致,否则会出现
|
|
119
|
+
// 收录了却被下一轮清掉的半衰状态。窗口只影响报错计数,与永久保留的用量无关。
|
|
120
|
+
const USAGE_ERROR_WINDOW_MS = 48 * 60 * 60 * 1000
|
|
117
121
|
// 官方右栏文件编辑(v1.6 用户点名):浏览器只送资源地址,宿主解析会话后按 ctx.fs +
|
|
118
122
|
// sandboxPolicy 读写;单文件上限双向生效(读取、保存、撤销通道都受它约束)。
|
|
119
123
|
const FILE_RESOURCE_PREFIX = 'dsh-resource://file/session/'
|
|
@@ -1045,6 +1049,27 @@ function localDayForHour(hour, timezoneOffsetMinutes = 0) {
|
|
|
1045
1049
|
return new Date(utcTime - offset * 60 * 1000).toISOString().slice(0, 10)
|
|
1046
1050
|
}
|
|
1047
1051
|
|
|
1052
|
+
// 小时桶 → 本地「星期 × 小时」格键(打卡图)。与 localDayForHour 同一套时区折算:
|
|
1053
|
+
// 先按客户端偏移平移 UTC 时刻,再用 getUTC* 读取平移后的本地分量,不依赖宿主所在时区。
|
|
1054
|
+
// weekday 以周一为 0,与客户端热力日历的行序一致。
|
|
1055
|
+
function localWeekdayHourForHour(hour, timezoneOffsetMinutes = 0) {
|
|
1056
|
+
const offset = Number.isFinite(Number(timezoneOffsetMinutes)) ? Math.max(-840, Math.min(840, Number(timezoneOffsetMinutes))) : 0
|
|
1057
|
+
const utcTime = Date.parse(`${hour}:00:00.000Z`)
|
|
1058
|
+
if (!Number.isFinite(utcTime)) return undefined
|
|
1059
|
+
const shifted = new Date(utcTime - offset * 60 * 1000)
|
|
1060
|
+
return { weekday: (shifted.getUTCDay() + 6) % 7, hour: shifted.getUTCHours() }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// 项目文件夹被删时只在「按项目」维度隐藏其入口;总量、日期桶与模型明细仍保留该段历史用量。
|
|
1064
|
+
function usageProjectMissing(project) {
|
|
1065
|
+
if (typeof project?.path !== 'string' || project.path.length === 0) return false
|
|
1066
|
+
try {
|
|
1067
|
+
return !existsSync(project.path)
|
|
1068
|
+
} catch (_) {
|
|
1069
|
+
return false
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1048
1073
|
function emptyUsageTotals() {
|
|
1049
1074
|
return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
1050
1075
|
}
|
|
@@ -1091,13 +1116,35 @@ function revisionKey(revision) {
|
|
|
1091
1116
|
}
|
|
1092
1117
|
|
|
1093
1118
|
function createUsageIndex() {
|
|
1094
|
-
return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {} }
|
|
1119
|
+
return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {}, failedSessions: [] }
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// 版本迁移:折读口径变更时旧桶需要重折,但所有旧摘要必须保留,直到确认 live 且重折成功才替换。
|
|
1123
|
+
// 未标记 detached 的条目可能源文件已在升级前被删除(此前未标 detached),也可能源文件仍在但需要重折;
|
|
1124
|
+
// 绝不能在迁移时直接丢弃。我们给所有非 detached 条目打上显式的 rebuildPending 标记并跨重启保留:
|
|
1125
|
+
// 若后续刷新时发现源文件已删除,自动转为 detached 保留完整历史;
|
|
1126
|
+
// 若源文件仍在,全量重折成功后才替换旧摘要并清除标记,重折失败则继续保留旧摘要。
|
|
1127
|
+
function migrateUsageIndex(parsed) {
|
|
1128
|
+
const sessions = {}
|
|
1129
|
+
if (parsed?.sessions !== null && typeof parsed?.sessions === 'object') {
|
|
1130
|
+
for (const [id, session] of Object.entries(parsed.sessions)) {
|
|
1131
|
+
if (session !== null && typeof session === 'object') {
|
|
1132
|
+
const migrated = structuredClone(session)
|
|
1133
|
+
if (migrated.detached !== true) {
|
|
1134
|
+
migrated.rebuildPending = true
|
|
1135
|
+
}
|
|
1136
|
+
sessions[id] = migrated
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions, failedSessions: [] }
|
|
1095
1141
|
}
|
|
1096
1142
|
|
|
1097
1143
|
async function loadUsageIndex(dshHome) {
|
|
1098
1144
|
try {
|
|
1099
1145
|
const parsed = JSON.parse(await readFile(join(dshHome, USAGE_INDEX_FILE), 'utf8'))
|
|
1100
|
-
if (
|
|
1146
|
+
if (typeof parsed?.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
|
|
1147
|
+
if (parsed.version !== USAGE_INDEX_VERSION) return migrateUsageIndex(parsed)
|
|
1101
1148
|
return parsed
|
|
1102
1149
|
} catch (error) {
|
|
1103
1150
|
if (error?.code === 'ENOENT') return createUsageIndex()
|
|
@@ -1160,42 +1207,130 @@ function normalizeQuotaAllowedHosts(raw) {
|
|
|
1160
1207
|
return out
|
|
1161
1208
|
}
|
|
1162
1209
|
|
|
1210
|
+
// 重置卡到期串的两种「无时区」写法:纯日期与 datetime-local(面板录入格式)。
|
|
1211
|
+
// 两者都不带偏移,intl 语义是「用户墙上时间的这个点」,因此必须有客户端时区才能换算成绝对时刻。
|
|
1212
|
+
const RESET_CARD_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
1213
|
+
const RESET_CARD_NAIVE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/
|
|
1214
|
+
|
|
1215
|
+
/** 客户端时区偏移(分钟,JS getTimezoneOffset 语义)→ 合法范围整数;非法一律视为「未知」(undefined)。 */
|
|
1216
|
+
function quotaTimezoneOffset(value) {
|
|
1217
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || Math.abs(value) > 840) return undefined
|
|
1218
|
+
return value
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function quotaTimeZone(value) {
|
|
1222
|
+
if (typeof value !== 'string' || value.length > 100) return undefined
|
|
1223
|
+
try { return new Intl.DateTimeFormat('en', { timeZone: value }).resolvedOptions().timeZone } catch (_) { return undefined }
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// 用目标日期的 IANA 时区规则解释存量墙上时间;不存在的 DST 跳跃时刻保留待人工修正。
|
|
1227
|
+
function resetCardWallTimeMs(raw, timeZone) {
|
|
1228
|
+
const wallText = raw.length === 10 ? `${raw}T23:59:59.999` : raw
|
|
1229
|
+
const wall = Date.parse(`${wallText}Z`)
|
|
1230
|
+
if (!Number.isFinite(wall)) return null
|
|
1231
|
+
const formatter = new Intl.DateTimeFormat('en-GB', {
|
|
1232
|
+
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
1233
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
1234
|
+
})
|
|
1235
|
+
const wallAt = (at) => {
|
|
1236
|
+
const p = Object.fromEntries(formatter.formatToParts(new Date(at)).map(({ type, value }) => [type, value]))
|
|
1237
|
+
return Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`) + ((at % 1000) + 1000) % 1000
|
|
1238
|
+
}
|
|
1239
|
+
const candidates = []
|
|
1240
|
+
// 在目标墙上时刻两侧采样候选偏移,覆盖 DST 两档;反向映射相等才接受,避免跳跃日静默纠正。
|
|
1241
|
+
for (const delta of [-36, 0, 36]) {
|
|
1242
|
+
const sample = wall + delta * 3600000
|
|
1243
|
+
const candidate = wall - (wallAt(sample) - sample)
|
|
1244
|
+
if (wallAt(candidate) === wall) candidates.push(candidate)
|
|
1245
|
+
}
|
|
1246
|
+
return candidates.length === 0 ? null : Math.min(...candidates)
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1163
1249
|
/**
|
|
1164
1250
|
* 重置卡到期时刻(毫秒):
|
|
1165
|
-
* -
|
|
1166
|
-
* - 只到日的串(`2026-09-30`)→
|
|
1167
|
-
*
|
|
1251
|
+
* - 带偏移/时区的绝对串(`2026-09-30T08:00:00Z`、`+08:00`)→ Date.parse 的真实时刻,与时区无关;
|
|
1252
|
+
* - 只到日的串(`2026-09-30`)→ **该自然日在客户端时区里的 23:59:59.999**。裸日期被 Date.parse
|
|
1253
|
+
* 当作当日零点 UTC,会让「9-30 到期」的卡在 9-30 零点刚过就判过期,与「当天还有效」的直觉相反。
|
|
1254
|
+
* - datetime-local 串(`2026-09-30T08:00`)→ 该墙上时间在**客户端时区**里的绝对时刻。
|
|
1255
|
+
* 此前按宿主时区(容器 UTC)解释,与浏览器渲染各算一套:UTC+8 用户在界面上看到「已过期」,
|
|
1256
|
+
* 宿主却仍判未过期——既不自动移除,还继续当临期卡提示。
|
|
1257
|
+
* - 优先使用客户端 IANA 时区的目标日期规则;仅旧客户端缺时区名时兼容合法固定偏移。
|
|
1258
|
+
* - 无时区形态且没有合法时区信息 → null(宁可留着也不按宿主时区删卡/标过期)。
|
|
1168
1259
|
* - 缺失/无法解析 → null(永不过期,只手动移除)。
|
|
1169
1260
|
*/
|
|
1170
|
-
function resetCardExpiryMs(expiresAt) {
|
|
1261
|
+
function resetCardExpiryMs(expiresAt, timezoneOffsetMinutes, timeZone) {
|
|
1171
1262
|
if (typeof expiresAt !== 'string') return null
|
|
1172
1263
|
const raw = expiresAt.trim()
|
|
1173
1264
|
if (raw === '') return null
|
|
1265
|
+
if (RESET_CARD_DATE_RE.test(raw) || RESET_CARD_NAIVE_RE.test(raw)) {
|
|
1266
|
+
// 显式提供的时区非法时不得降级成可能错误的偏移,更不能触发删除。
|
|
1267
|
+
if (timeZone !== undefined) {
|
|
1268
|
+
const zone = quotaTimeZone(timeZone)
|
|
1269
|
+
return zone === undefined ? null : resetCardWallTimeMs(raw, zone)
|
|
1270
|
+
}
|
|
1271
|
+
const offset = quotaTimezoneOffset(timezoneOffsetMinutes)
|
|
1272
|
+
if (offset === undefined) return null
|
|
1273
|
+
// 把「墙上时间」按 offset 平移成 UTC:getTimezoneOffset 是「本地→UTC 需要加的分钟数」,
|
|
1274
|
+
// 故 UTC = 墙上时间 + offset(UTC+8 → -480 → 减 8 小时)。
|
|
1275
|
+
const wall = Date.parse(raw.length === 10 ? `${raw}T00:00:00.000Z` : `${raw}Z`)
|
|
1276
|
+
if (!Number.isFinite(wall)) return null
|
|
1277
|
+
const endOfDay = raw.length === 10 ? 24 * 60 * 60 * 1000 - 1 : 0
|
|
1278
|
+
return wall + endOfDay + offset * 60 * 1000
|
|
1279
|
+
}
|
|
1280
|
+
// 非标准无偏移串不得落回宿主本地 Date.parse(例如空格代替 T)。
|
|
1281
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/i.test(raw)) return null
|
|
1174
1282
|
const parsed = Date.parse(raw)
|
|
1175
|
-
|
|
1176
|
-
// 纯日期形态(YYYY-MM-DD,无 T/时刻):顺延到当日末尾。
|
|
1177
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return parsed + 24 * 60 * 60 * 1000 - 1
|
|
1178
|
-
return parsed
|
|
1283
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
1179
1284
|
}
|
|
1180
1285
|
|
|
1181
1286
|
/** 已过期(到期时刻严格早于 now)的重置卡:宿主读配置时自动剔除。 */
|
|
1182
|
-
function isResetCardExpired(card, now = Date.now()) {
|
|
1183
|
-
const at = resetCardExpiryMs(card?.expiresAt)
|
|
1287
|
+
function isResetCardExpired(card, now = Date.now(), timezoneOffsetMinutes, timeZone) {
|
|
1288
|
+
const at = resetCardExpiryMs(card?.expiresAt, timezoneOffsetMinutes, timeZone)
|
|
1184
1289
|
return at !== null && at < now
|
|
1185
1290
|
}
|
|
1186
1291
|
|
|
1187
1292
|
/**
|
|
1188
1293
|
* 自动移除已过期的重置卡。读路径(refreshQuotaConfigCache)在加载后调用:
|
|
1189
1294
|
* 有剔除才落盘,避免每次读都写文件。返回值同时告诉调用方「是否发生过变更」。
|
|
1295
|
+
* 无时区形态的卡必须带上客户端偏移才参与判定,否则会按宿主时区误删。
|
|
1190
1296
|
*/
|
|
1191
|
-
function pruneExpiredResetCards(config, now = Date.now()) {
|
|
1297
|
+
function pruneExpiredResetCards(config, now = Date.now(), timezoneOffsetMinutes, timeZone) {
|
|
1192
1298
|
if (!Array.isArray(config?.resetCards) || config.resetCards.length === 0) return false
|
|
1193
|
-
const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now))
|
|
1299
|
+
const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now, timezoneOffsetMinutes, timeZone))
|
|
1194
1300
|
if (kept.length === config.resetCards.length) return false
|
|
1195
1301
|
config.resetCards = kept
|
|
1196
1302
|
return true
|
|
1197
1303
|
}
|
|
1198
1304
|
|
|
1305
|
+
/**
|
|
1306
|
+
* 把无时区的 datetime-local 到期串固化成绝对时刻(ISO,带 Z):一次迁移,之后任何时区都读得一致。
|
|
1307
|
+
* 纯日期串刻意保持原样——「9-30 到期」是本地日期承诺,改成绝对时刻会在界面上多出一个时间点,
|
|
1308
|
+
* 且它每次判定都用客户端时区的目标日期规则算当日末尾,无需固化。
|
|
1309
|
+
* 拿不到时区信息时原样返回,无法解析时同样原样返回(保留人工可核查的原文)。
|
|
1310
|
+
*/
|
|
1311
|
+
function canonicalResetCardExpiresAt(expiresAt, timezoneOffsetMinutes, timeZone) {
|
|
1312
|
+
if (typeof expiresAt !== 'string') return expiresAt
|
|
1313
|
+
const raw = expiresAt.trim()
|
|
1314
|
+
if (!RESET_CARD_NAIVE_RE.test(raw)) return expiresAt
|
|
1315
|
+
const at = resetCardExpiryMs(raw, timezoneOffsetMinutes, timeZone)
|
|
1316
|
+
return at === null ? expiresAt : new Date(at).toISOString()
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** 把配置里所有无时区 datetime 卡固化为绝对时刻。有改写才返回 true(调用方据此落盘)。 */
|
|
1320
|
+
function normalizeResetCardInstants(config, timezoneOffsetMinutes, timeZone) {
|
|
1321
|
+
if (!Array.isArray(config?.resetCards)) return false
|
|
1322
|
+
let changed = false
|
|
1323
|
+
for (const card of config.resetCards) {
|
|
1324
|
+
if (card === null || typeof card !== 'object') continue
|
|
1325
|
+
const next = canonicalResetCardExpiresAt(card.expiresAt, timezoneOffsetMinutes, timeZone)
|
|
1326
|
+
if (next !== card.expiresAt) {
|
|
1327
|
+
card.expiresAt = next
|
|
1328
|
+
changed = true
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return changed
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1199
1334
|
// 重置卡条目(v0.19 手填过渡方案:官方无 API Key 可查的端点):provider 必填,label/expiresAt 可选;
|
|
1200
1335
|
// id 缺失时按原始位置合成稳定 id(老数据兼容),写入口生成的 id 原样保留。
|
|
1201
1336
|
// v1.9.1 起另可带 account(CLIProxyAPI 多账号细分),缺省 = 整 provider 共享的卡,老数据零迁移。
|
|
@@ -1954,7 +2089,7 @@ function usageFailure(event) {
|
|
|
1954
2089
|
}
|
|
1955
2090
|
|
|
1956
2091
|
function recentErrorTime(time) {
|
|
1957
|
-
return Number.isFinite(time) && time >= Date.now() -
|
|
2092
|
+
return Number.isFinite(time) && time >= Date.now() - USAGE_ERROR_WINDOW_MS
|
|
1958
2093
|
}
|
|
1959
2094
|
|
|
1960
2095
|
function addUsageError(session, event, model) {
|
|
@@ -2145,14 +2280,21 @@ function usageFailedSessions(index) {
|
|
|
2145
2280
|
|
|
2146
2281
|
function publicUsage(index, timezoneOffsetMinutes = 0) {
|
|
2147
2282
|
const failedSessions = usageFailedSessions(index)
|
|
2148
|
-
const
|
|
2149
|
-
|
|
2283
|
+
const allSessions = Object.values(index.sessions)
|
|
2284
|
+
// 保留条目仍计入总量与日期桶(它们就是历史用量),但在会话计数里单列,
|
|
2285
|
+
// 否则界面上的「已索引 N 个会话」会包含磁盘上已不存在的会话。
|
|
2286
|
+
const detachedSessions = allSessions.filter((session) => session.detached === true).length
|
|
2287
|
+
const indexedSessions = allSessions.length
|
|
2288
|
+
const result = { updatedAt: index.updatedAt, indexedSessions, detachedSessions, successfulSessions: indexedSessions - failedSessions.filter((failure) => failure.stale).length, failedSessions, totals: emptyUsageTotals(), days: {}, hours: {}, errors: { models: [], tools: [] } }
|
|
2150
2289
|
const projects = new Map()
|
|
2290
|
+
const projectVisibility = new Map()
|
|
2151
2291
|
const modelErrors = new Map()
|
|
2152
2292
|
const toolErrors = new Map()
|
|
2153
|
-
const recentCutoff = Date.now() -
|
|
2154
|
-
for (const session of
|
|
2293
|
+
const recentCutoff = Date.now() - USAGE_ERROR_WINDOW_MS
|
|
2294
|
+
for (const session of allSessions) {
|
|
2155
2295
|
projects.set(session.project.id, session.project)
|
|
2296
|
+
// 项目文件夹被删只影响「按项目」筛选入口的可见性;用量本身照旧进总量与日期桶。
|
|
2297
|
+
if (!projectVisibility.has(session.project.id)) projectVisibility.set(session.project.id, !usageProjectMissing(session.project))
|
|
2156
2298
|
pruneSessionErrors(session, recentCutoff)
|
|
2157
2299
|
for (const error of Object.values(session.modelErrors || {})) {
|
|
2158
2300
|
const count = error.recentTimes.length
|
|
@@ -2181,6 +2323,17 @@ function publicUsage(index, timezoneOffsetMinutes = 0) {
|
|
|
2181
2323
|
projectBucket.models.set(model.id, modelBucket)
|
|
2182
2324
|
}
|
|
2183
2325
|
dayBucket.projects.set(session.project.id, projectBucket)
|
|
2326
|
+
// 打卡图(星期 × 小时):与 days 同为「已有原始数据的一次重排」,不新增持久化字段。
|
|
2327
|
+
// 只带 totals 与项目拆分(不带模型明细),实测体积约为带模型版本的 1/2。
|
|
2328
|
+
const slot = localWeekdayHourForHour(hour, timezoneOffsetMinutes)
|
|
2329
|
+
if (slot !== undefined) {
|
|
2330
|
+
const key = `${slot.weekday}-${slot.hour}`
|
|
2331
|
+
const hourBucket = result.hours[key] || (result.hours[key] = { weekday: slot.weekday, hour: slot.hour, totals: emptyUsageTotals(), projects: new Map() })
|
|
2332
|
+
addUsageTotals(hourBucket.totals, source.totals)
|
|
2333
|
+
const hourProject = hourBucket.projects.get(session.project.id) || { id: session.project.id, title: session.project.title, totals: emptyUsageTotals() }
|
|
2334
|
+
addUsageTotals(hourProject.totals, source.totals)
|
|
2335
|
+
hourBucket.projects.set(session.project.id, hourProject)
|
|
2336
|
+
}
|
|
2184
2337
|
}
|
|
2185
2338
|
}
|
|
2186
2339
|
const errorSort = (a, b) => b.count - a.count || a.key.localeCompare(b.key) || a.projectId.localeCompare(b.projectId)
|
|
@@ -2197,6 +2350,13 @@ function publicUsage(index, timezoneOffsetMinutes = 0) {
|
|
|
2197
2350
|
models: [...project.models.values()].map((model) => ({ ...model, totals: finishTotals(model.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
2198
2351
|
})).sort((a, b) => a.title.localeCompare(b.title))
|
|
2199
2352
|
}
|
|
2353
|
+
result.hours = Object.values(result.hours).map((bucket) => ({
|
|
2354
|
+
...bucket,
|
|
2355
|
+
totals: finishTotals(bucket.totals),
|
|
2356
|
+
projects: [...bucket.projects.values()].map((project) => ({ ...project, totals: finishTotals(project.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
2357
|
+
})).sort((a, b) => a.weekday - b.weekday || a.hour - b.hour)
|
|
2358
|
+
// missing 只对「项目文件夹已不存在」的条目为真,客户端据此隐藏项目分页按钮。
|
|
2359
|
+
result.projects = result.projects.map((project) => ({ ...project, missing: projectVisibility.get(project.id) === false }))
|
|
2200
2360
|
return result
|
|
2201
2361
|
}
|
|
2202
2362
|
|
|
@@ -2216,23 +2376,45 @@ async function refreshUsageIndex(ctx, dshHome, currentIndex) {
|
|
|
2216
2376
|
const index = structuredClone(currentIndex)
|
|
2217
2377
|
index.failedSessions = []
|
|
2218
2378
|
const liveIds = new Set(snapshots.map((record) => String(record.header.id)))
|
|
2219
|
-
|
|
2220
|
-
|
|
2379
|
+
// 保留策略:会话被删(或项目文件夹被删)都不再扣减已统计的贡献——条目永久留在索引里,
|
|
2380
|
+
// 只打 detached 标记供界面区分,hours/lastSeq 等桶数据一字不动;会话回来时自动摘掉标记。
|
|
2381
|
+
// 这样总量与热力图始终是「索引建立以来的完整历史」,不再随删除缩水。
|
|
2382
|
+
const detachedAt = Date.now()
|
|
2383
|
+
for (const [id, session] of Object.entries(index.sessions)) {
|
|
2384
|
+
if (liveIds.has(id)) {
|
|
2385
|
+
if (session.detached === true) {
|
|
2386
|
+
delete session.detached
|
|
2387
|
+
delete session.detachedAt
|
|
2388
|
+
}
|
|
2389
|
+
continue
|
|
2390
|
+
}
|
|
2391
|
+
if (session.detached !== true) {
|
|
2392
|
+
session.detached = true
|
|
2393
|
+
session.detachedAt = detachedAt
|
|
2394
|
+
}
|
|
2395
|
+
if (session.rebuildPending) {
|
|
2396
|
+
delete session.rebuildPending
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
const recentCutoff = Date.now() - USAGE_ERROR_WINDOW_MS
|
|
2221
2400
|
for (const session of Object.values(index.sessions)) pruneSessionErrors(session, recentCutoff)
|
|
2222
2401
|
for (const record of snapshots) {
|
|
2223
2402
|
const id = String(record.header.id)
|
|
2224
2403
|
const revision = revisionKey(record.revision)
|
|
2225
2404
|
const previous = index.sessions[id]
|
|
2226
|
-
|
|
2227
|
-
|
|
2405
|
+
const isRebuild = previous?.rebuildPending === true
|
|
2406
|
+
if (previous?.revision === revision && !isRebuild) continue
|
|
2407
|
+
const fromSeq = isRebuild ? 0 : usageReadStart(previous)
|
|
2228
2408
|
let stage = 'read'
|
|
2229
2409
|
try {
|
|
2230
2410
|
const read = await persistenceReadSlice(persistence, record.header, fromSeq)
|
|
2231
2411
|
const inheritedEventCount = inheritedEventCountFor(record, read)
|
|
2232
2412
|
const events = usageReadEvents(read)
|
|
2233
2413
|
stage = 'fold'
|
|
2234
|
-
const
|
|
2414
|
+
const foldBase = isRebuild ? undefined : (previous === undefined ? undefined : structuredClone(previous))
|
|
2415
|
+
const next = foldUsageEvents(ctx, record, foldBase, events, inheritedEventCount)
|
|
2235
2416
|
next.revision = revision
|
|
2417
|
+
delete next.rebuildPending
|
|
2236
2418
|
index.sessions[id] = next
|
|
2237
2419
|
} catch (error) {
|
|
2238
2420
|
index.failedSessions.push(usageSessionFailure(id, error, stage, previous !== undefined))
|
|
@@ -2607,7 +2789,7 @@ function runtimeEnvCheck(runtimeEnv) {
|
|
|
2607
2789
|
return { id: 'runtime-env', status: 'info', detail: 'unknown' }
|
|
2608
2790
|
}
|
|
2609
2791
|
|
|
2610
|
-
async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
|
|
2792
|
+
async function collectDiagnostics(ctx, dshHome, runtimeEnv, options = {}) {
|
|
2611
2793
|
const checks = []
|
|
2612
2794
|
const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
|
|
2613
2795
|
|
|
@@ -2689,6 +2871,27 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
|
|
|
2689
2871
|
if (!compatReport.available) add('plugin-compat', 'info', 'unavailable')
|
|
2690
2872
|
else checks.push(pluginCompatCheckItem(compatReport))
|
|
2691
2873
|
|
|
2874
|
+
// 使用统计索引(工作区实现,未发布):统计链路自身的健康度。单会话折读失败会让用量静默少算,
|
|
2875
|
+
// 此前只在模型统计页的全局警告里可见,诊断清单没有任何一项覆盖它。
|
|
2876
|
+
// 功能关闭时宿主根本不刷新索引(usage/usage-refresh 均受 modelUsage 门控),报「失败」
|
|
2877
|
+
// 只会是陈旧误导,故整项省略而不是降级成 info 行。
|
|
2878
|
+
// 追加在清单尾部:不动前五项 id 顺序契约(既有测试与客户端渲染顺序都依赖它)。
|
|
2879
|
+
if (options.usageEnabled === true) {
|
|
2880
|
+
let index = null
|
|
2881
|
+
try { index = await options.usageIndex } catch (_) { index = null }
|
|
2882
|
+
if (index === null || typeof index !== 'object') add('usage-index', 'info', 'unavailable')
|
|
2883
|
+
else {
|
|
2884
|
+
const failed = usageFailedSessions(index).length
|
|
2885
|
+
const indexed = index.sessions !== null && typeof index.sessions === 'object' ? Object.keys(index.sessions).length : 0
|
|
2886
|
+
const updatedAt = Number.isFinite(Number(index.updatedAt)) ? Number(index.updatedAt) : 0
|
|
2887
|
+
// updatedAt=0 = 从未建立索引(用户还没打开过模型统计页):不是故障,报 info。
|
|
2888
|
+
// 失败会话是「部分成功」语义(沿用 usage 页同款口径):warning 而非 error——统计仍可用,
|
|
2889
|
+
// 只是少算了这些会话,不点亮诊断标签 ⚠ 的 error 档。
|
|
2890
|
+
if (updatedAt === 0) add('usage-index', 'info', 'never')
|
|
2891
|
+
else add('usage-index', failed > 0 ? 'warning' : 'ok', `${failed}:${indexed}:${updatedAt}`)
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2692
2895
|
let status = 'ok'
|
|
2693
2896
|
// advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
|
|
2694
2897
|
if (checks.some((check) => check.status === 'error')) status = 'error'
|
|
@@ -2724,41 +2927,6 @@ async function collectHealth(ctx) {
|
|
|
2724
2927
|
}
|
|
2725
2928
|
}
|
|
2726
2929
|
|
|
2727
|
-
/**
|
|
2728
|
-
* 概览重置卡到期提示的窗口(设计定稿参数,只写这一处):到期时刻距今 **≤24 小时** 即提示。
|
|
2729
|
-
* 用剩余时长而不是「当天」日历窗:纯日期卡本就顺延到当日 23:59:59.999、当天窗 ≈24h,
|
|
2730
|
-
* 但精确时刻卡(datetime-local)在当天窗下只剩 0:00 到到期时刻的一截(可能仅几十分钟),
|
|
2731
|
-
* 错过即被读路径自动清理、再无补看机会;剩余时长窗让两种录入方式都保证 ≥24h 曝光。
|
|
2732
|
-
*/
|
|
2733
|
-
const QUOTA_RESET_CARD_EXPIRING_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
2734
|
-
|
|
2735
|
-
/**
|
|
2736
|
-
* 剩余 ≤24h 到期的重置卡清单(概览 info 提示的数据源)。读路径的 prune 正常时不会剩下
|
|
2737
|
-
* 过期卡,这里再防御性排除一次(at < now 与 isResetCardExpired 同口径)——宁可少提示,
|
|
2738
|
-
* 绝不把已过期的卡当「即将到期」展示。返回 [{ provider, label }];无命中返回空数组。
|
|
2739
|
-
*/
|
|
2740
|
-
async function collectResetCardsExpiringSoon(refreshQuotaConfigCache, now = Date.now()) {
|
|
2741
|
-
let config
|
|
2742
|
-
try {
|
|
2743
|
-
config = await refreshQuotaConfigCache()
|
|
2744
|
-
} catch (_) {
|
|
2745
|
-
return []
|
|
2746
|
-
}
|
|
2747
|
-
const cards = Array.isArray(config?.resetCards) ? config.resetCards : []
|
|
2748
|
-
if (cards.length === 0) return []
|
|
2749
|
-
const horizon = now + QUOTA_RESET_CARD_EXPIRING_WINDOW_MS
|
|
2750
|
-
const hits = []
|
|
2751
|
-
for (const card of cards) {
|
|
2752
|
-
const at = resetCardExpiryMs(card?.expiresAt)
|
|
2753
|
-
if (at === null || at < now || at > horizon) continue
|
|
2754
|
-
hits.push({
|
|
2755
|
-
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
2756
|
-
label: typeof card.label === 'string' ? card.label : '',
|
|
2757
|
-
})
|
|
2758
|
-
}
|
|
2759
|
-
return hits
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
2930
|
function collectActiveWork(ctx) {
|
|
2763
2931
|
const agentsService = ctx.get('agents')
|
|
2764
2932
|
const jobsService = ctx.get('jobs')
|
|
@@ -4954,12 +5122,29 @@ function apply(ctx) {
|
|
|
4954
5122
|
let quotaConfigLastCheckedAt = 0
|
|
4955
5123
|
let quotaConfigMtimeMs = 0
|
|
4956
5124
|
const quotaConfigPath = join(dshHome, QUOTA_CONFIG_FILE)
|
|
5125
|
+
// 最近一次请求上带的客户端时区偏移:无时区形态的重置卡到期串(纯日期 / datetime-local)
|
|
5126
|
+
// 必须按「用户墙上时间」解释,宿主容器时区(多为 UTC)不是用户的时区。额度/健康等读路径
|
|
5127
|
+
// 都带这个字段,记录最后一次即可;拿不到时宁可不判定(见 resetCardExpiryMs),也不按错时区删卡。
|
|
5128
|
+
let quotaClientOffset
|
|
5129
|
+
let quotaClientTimeZone
|
|
5130
|
+
const rememberQuotaClientOffset = (payload) => {
|
|
5131
|
+
if (payload?.timeZone !== undefined || payload?.timezoneOffsetMinutes !== undefined) {
|
|
5132
|
+
quotaClientOffset = quotaTimezoneOffset(payload?.timezoneOffsetMinutes)
|
|
5133
|
+
// 显式非法时区留给判定器拒绝;旧客户端只带偏移时不得沿用另一客户端的时区。
|
|
5134
|
+
quotaClientTimeZone = payload?.timeZone
|
|
5135
|
+
}
|
|
5136
|
+
return quotaClientOffset
|
|
5137
|
+
}
|
|
4957
5138
|
// 过期重置卡自动移除:剔除内存快照里的过期条目,有变更才落盘(避免每次读都写文件)。
|
|
4958
5139
|
// 读失败不影响本次结果——内存已剔除,下次读再试。TTL 快路径、TTL 到期后 mtime 未变、
|
|
4959
5140
|
// 重新加载三条路都要过这关:mtime 未变就永远不会重新加载,缺了这一路,「停在概览页
|
|
4960
5141
|
// 只靠 health 轮询」时运行期到点的卡在内存里清不掉。
|
|
4961
5142
|
const pruneExpiredQuotaResetCards = async () => {
|
|
4962
|
-
|
|
5143
|
+
const offset = quotaClientOffset
|
|
5144
|
+
// 先固化无时区 datetime 卡(一次迁移,之后任何时区都读得一致),再按偏移剔除过期卡。
|
|
5145
|
+
const canonicalized = normalizeResetCardInstants(quotaConfig, offset, quotaClientTimeZone)
|
|
5146
|
+
const pruned = pruneExpiredResetCards(quotaConfig, Date.now(), offset, quotaClientTimeZone)
|
|
5147
|
+
if (!canonicalized && !pruned) return
|
|
4963
5148
|
try {
|
|
4964
5149
|
await saveQuotaConfig(dshHome, quotaConfig)
|
|
4965
5150
|
quotaConfigMtimeMs = (await stat(quotaConfigPath)).mtimeMs
|
|
@@ -5255,16 +5440,16 @@ function apply(ctx) {
|
|
|
5255
5440
|
} },
|
|
5256
5441
|
'health': { handle: async (payload, rpcEndpoint) => {
|
|
5257
5442
|
const value = await collectHealth(ctx)
|
|
5258
|
-
//
|
|
5259
|
-
//
|
|
5260
|
-
if (featureEnabled('quotaLookup'))
|
|
5261
|
-
value.resetCardsExpiringSoon = await collectResetCardsExpiringSoon(refreshQuotaConfigCache)
|
|
5262
|
-
}
|
|
5443
|
+
// 概览不再提示重置卡到期(用户裁决整条去掉)。但 health 仍是面板最常打的读路径,
|
|
5444
|
+
// 借它推进「过期卡自动移除」:额度功能开启时读一次配置(内部按客户端时区固化+剔除)。
|
|
5445
|
+
if (featureEnabled('quotaLookup')) await refreshQuotaConfigCache()
|
|
5263
5446
|
return { ok: true, value }
|
|
5264
5447
|
|
|
5265
5448
|
} },
|
|
5266
5449
|
'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
|
|
5267
|
-
|
|
5450
|
+
// usageIndex 传的是闭包里的同一个 promise(不在检查里重读磁盘 JSON——索引可达数百 KB);
|
|
5451
|
+
// modelUsage 关闭时整个 usage-index 检查项缺席,避免用陈旧索引报假故障。
|
|
5452
|
+
return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv, { usageEnabled: featureEnabled('modelUsage'), usageIndex: usageIndexPromise }) }
|
|
5268
5453
|
|
|
5269
5454
|
} },
|
|
5270
5455
|
'plugin-restart': { feature: 'healthDiagnostics', audit: true, handle: async (payload, rpcEndpoint) => {
|
|
@@ -5410,12 +5595,17 @@ function apply(ctx) {
|
|
|
5410
5595
|
coreRoutes,
|
|
5411
5596
|
createSkillsRoutes({ ctx, describeJobs, dshHome, makeDescribeJobLogger, registerSkillCall, serializeSkillsIndexWrite, skillFileCache, skillsActiveControllers, skillsBatchRef, skillsIndexRef, SKILL_DESCRIPTION_MAX_CHARS, SKILL_USAGE_MAX_CHARS, bodyHashOf, describeSkillDraft, evaluateSkillFile, fixLegacySkillInvocationKeys, listSkillModels, locateSkillFrontmatter, mutateSkillEntryById, name, normalizeSkillDescribeLang, publicSkillEntry, rpcTechnicalFailure, sanitizeSkillDraftText, scanSkillEntries, selectSkillBatchCandidates, setSkillInvocationKey }),
|
|
5412
5597
|
createSessionsRoutes({ ctx, dshHome, sessionBytesCache, sessionDeletePlans, sessionTitleCache, sessionTitlesReady, sessionViewCache, SESSIONS_BYTES_MAX_IDS, SESSIONS_DELETE_PLAN_TTL_MS, SESSIONS_VIEW_PAGE_SIZE, listSessionsForManage, loadDeletedSessions, name, resolveSessionBytesForIds, resolveSessionForDelete, rpcFailure, rpcTechnicalFailure, saveDeletedSessions, searchSessionsContent, sessionExists, sessionIsLive, viewSessionPage }),
|
|
5413
|
-
createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
|
|
5598
|
+
createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, canonicalResetCardExpiresAt, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
|
|
5414
5599
|
createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
|
|
5415
5600
|
createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, setBackupProgress, withBackupLock, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
|
|
5416
5601
|
])
|
|
5417
5602
|
|
|
5418
|
-
const
|
|
5603
|
+
const dispatchEndpoint = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
|
|
5604
|
+
// 只接受额度时区端点的上下文;usage 的固定偏移不能覆盖额度的 IANA 时区规则。
|
|
5605
|
+
const dispatchRpc = (endpoint, payload) => {
|
|
5606
|
+
if (endpoint === 'quota' || endpoint === 'quota-reset-card' || endpoint === 'health') rememberQuotaClientOffset(payload)
|
|
5607
|
+
return dispatchEndpoint(endpoint, payload)
|
|
5608
|
+
}
|
|
5419
5609
|
try {
|
|
5420
5610
|
ensureConnectionRpcWebServerSeam(ctx)
|
|
5421
5611
|
ctx.connection.rpc.handle('/dsh-service', dispatchRpc, { authority: 'loopback' })
|
package/package.json
CHANGED
package/quota-routes.js
CHANGED
|
@@ -17,6 +17,7 @@ export function createQuotaRoutes({
|
|
|
17
17
|
MAX_QUOTA_RESET_CARDS,
|
|
18
18
|
MAX_QUOTA_RESET_CARDS_PER_PROVIDER,
|
|
19
19
|
MAX_QUOTA_RESET_CARD_ACCOUNT,
|
|
20
|
+
canonicalResetCardExpiresAt,
|
|
20
21
|
QUOTA_ADAPTER_BY_KIND,
|
|
21
22
|
name,
|
|
22
23
|
quotaCredentialConfigured,
|
|
@@ -207,7 +208,15 @@ export function createQuotaRoutes({
|
|
|
207
208
|
const card = { id: `rc-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`, provider: providerName }
|
|
208
209
|
if (accountName !== '') card.account = accountName
|
|
209
210
|
if (typeof payload?.label === 'string' && payload.label.trim() !== '') card.label = payload.label.trim().slice(0, 40)
|
|
210
|
-
|
|
211
|
+
// 新客户端发送按目标日期解析的 ISO;存量客户端裸串按请求时区固化,不能依赖宿主时区。
|
|
212
|
+
// 缺少时区信息或无法解析时保留原文,不做无依据的改写。
|
|
213
|
+
if (typeof payload?.expiresAt === 'string' && payload.expiresAt.trim() !== '') {
|
|
214
|
+
card.expiresAt = String(canonicalResetCardExpiresAt(
|
|
215
|
+
payload.expiresAt.trim(),
|
|
216
|
+
payload?.timezoneOffsetMinutes,
|
|
217
|
+
payload?.timeZone,
|
|
218
|
+
)).slice(0, 32)
|
|
219
|
+
}
|
|
211
220
|
config.resetCards = [...allCards, card]
|
|
212
221
|
}
|
|
213
222
|
return { value: { ok: true } }
|