@gehennawu/dsh-service 1.9.2 → 1.9.4

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/index.js CHANGED
@@ -112,8 +112,20 @@ 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 = 5
115
+ const USAGE_INDEX_VERSION = 7
116
116
  const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
117
+ // 桶口径已对齐(= 当前折叠口径)的最低索引版本:口径在 v5 定稿,v5/v6 的折叠函数与当前
118
+ // **逐字节相同**(v6 只加了 detached / failedSessions 记账字段),故加载 v5 及以后的桶可
119
+ // 直接沿用;**低于它的必须重折**(v4 及更早把「无 usage 的步骤」也计为步骤)。
120
+ // 反向写法(「非当前版本就重折」「不在已知表里就重折」)会把纯记账升版当成口径变更:
121
+ // v5→v6 那次折叠函数一字未改,却把全部会话拖进全量重读,旧日志读不动时就变成每次刷新都
122
+ // 重读重报(真机 132 会话全量重读、44 个误报——它们 revision 未变,本可安全跳过)。
123
+ // 新增索引版本号时**无需**改动本常量:新版本默认落在「已对齐」一侧。
124
+ const USAGE_FOLD_ALIGNED_FROM_INDEX_VERSION = 5
125
+ // 报错滚动窗口:一个常量同时约束「折叠时是否收录」(recentErrorTime)、「出参前修剪」
126
+ // (publicUsage)与「落盘前修剪」(refreshUsageIndex),三者必须一致,否则会出现
127
+ // 收录了却被下一轮清掉的半衰状态。窗口只影响报错计数,与永久保留的用量无关。
128
+ const USAGE_ERROR_WINDOW_MS = 48 * 60 * 60 * 1000
117
129
  // 官方右栏文件编辑(v1.6 用户点名):浏览器只送资源地址,宿主解析会话后按 ctx.fs +
118
130
  // sandboxPolicy 读写;单文件上限双向生效(读取、保存、撤销通道都受它约束)。
119
131
  const FILE_RESOURCE_PREFIX = 'dsh-resource://file/session/'
@@ -1045,6 +1057,27 @@ function localDayForHour(hour, timezoneOffsetMinutes = 0) {
1045
1057
  return new Date(utcTime - offset * 60 * 1000).toISOString().slice(0, 10)
1046
1058
  }
1047
1059
 
1060
+ // 小时桶 → 本地「星期 × 小时」格键(打卡图)。与 localDayForHour 同一套时区折算:
1061
+ // 先按客户端偏移平移 UTC 时刻,再用 getUTC* 读取平移后的本地分量,不依赖宿主所在时区。
1062
+ // weekday 以周一为 0,与客户端热力日历的行序一致。
1063
+ function localWeekdayHourForHour(hour, timezoneOffsetMinutes = 0) {
1064
+ const offset = Number.isFinite(Number(timezoneOffsetMinutes)) ? Math.max(-840, Math.min(840, Number(timezoneOffsetMinutes))) : 0
1065
+ const utcTime = Date.parse(`${hour}:00:00.000Z`)
1066
+ if (!Number.isFinite(utcTime)) return undefined
1067
+ const shifted = new Date(utcTime - offset * 60 * 1000)
1068
+ return { weekday: (shifted.getUTCDay() + 6) % 7, hour: shifted.getUTCHours() }
1069
+ }
1070
+
1071
+ // 项目文件夹被删时只在「按项目」维度隐藏其入口;总量、日期桶与模型明细仍保留该段历史用量。
1072
+ function usageProjectMissing(project) {
1073
+ if (typeof project?.path !== 'string' || project.path.length === 0) return false
1074
+ try {
1075
+ return !existsSync(project.path)
1076
+ } catch (_) {
1077
+ return false
1078
+ }
1079
+ }
1080
+
1048
1081
  function emptyUsageTotals() {
1049
1082
  return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
1050
1083
  }
@@ -1091,13 +1124,42 @@ function revisionKey(revision) {
1091
1124
  }
1092
1125
 
1093
1126
  function createUsageIndex() {
1094
- return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {} }
1127
+ return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {}, failedSessions: [] }
1128
+ }
1129
+
1130
+ // 版本迁移:**只在确知折叠口径落后时才重折**(见 USAGE_FOLD_ALIGNED_FROM_INDEX_VERSION)。
1131
+ // 所有旧摘要一律保留——未标记 detached 的条目可能源文件已在升级前被删除(此前未标 detached),
1132
+ // 也可能源文件仍在;绝不能在迁移时丢弃,更不能无差别重折。口径已对齐的版本直接沿用旧桶
1133
+ // (revision 未变的会话连读都不读);只有口径落后才打 rebuildPending 强制从 seq 0 重折。标记
1134
+ // 跨重启保留:源已删则转 detached 留全历史,源仍在则重折成功才替换、失败保留旧摘要下次再试。
1135
+ function migrateUsageIndex(parsed) {
1136
+ const fromIndexVersion = Number.isSafeInteger(parsed?.version) ? parsed.version : 0
1137
+ const needsRefold = fromIndexVersion < USAGE_FOLD_ALIGNED_FROM_INDEX_VERSION
1138
+ const sessions = {}
1139
+ if (parsed?.sessions !== null && typeof parsed?.sessions === 'object') {
1140
+ for (const [id, session] of Object.entries(parsed.sessions)) {
1141
+ if (session !== null && typeof session === 'object') {
1142
+ const migrated = structuredClone(session)
1143
+ // detached 条目没有重折材料(源文件已删),永远只保留不重折。
1144
+ if (needsRefold && migrated.detached !== true) {
1145
+ migrated.rebuildPending = true
1146
+ } else {
1147
+ // 口径未变(或条目不可重折):清掉历史升级可能残留的待重建标记,否则它会让这个
1148
+ // 会话在每次刷新时被反复全量重读——旧日志读不动时正是「每次刷新都报失败」的来源。
1149
+ delete migrated.rebuildPending
1150
+ }
1151
+ sessions[id] = migrated
1152
+ }
1153
+ }
1154
+ }
1155
+ return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions, failedSessions: [] }
1095
1156
  }
1096
1157
 
1097
1158
  async function loadUsageIndex(dshHome) {
1098
1159
  try {
1099
1160
  const parsed = JSON.parse(await readFile(join(dshHome, USAGE_INDEX_FILE), 'utf8'))
1100
- if (parsed?.version !== USAGE_INDEX_VERSION || typeof parsed.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
1161
+ if (typeof parsed?.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
1162
+ if (parsed.version !== USAGE_INDEX_VERSION) return migrateUsageIndex(parsed)
1101
1163
  return parsed
1102
1164
  } catch (error) {
1103
1165
  if (error?.code === 'ENOENT') return createUsageIndex()
@@ -1160,42 +1222,130 @@ function normalizeQuotaAllowedHosts(raw) {
1160
1222
  return out
1161
1223
  }
1162
1224
 
1225
+ // 重置卡到期串的两种「无时区」写法:纯日期与 datetime-local(面板录入格式)。
1226
+ // 两者都不带偏移,intl 语义是「用户墙上时间的这个点」,因此必须有客户端时区才能换算成绝对时刻。
1227
+ const RESET_CARD_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
1228
+ const RESET_CARD_NAIVE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/
1229
+
1230
+ /** 客户端时区偏移(分钟,JS getTimezoneOffset 语义)→ 合法范围整数;非法一律视为「未知」(undefined)。 */
1231
+ function quotaTimezoneOffset(value) {
1232
+ if (typeof value !== 'number' || !Number.isInteger(value) || Math.abs(value) > 840) return undefined
1233
+ return value
1234
+ }
1235
+
1236
+ function quotaTimeZone(value) {
1237
+ if (typeof value !== 'string' || value.length > 100) return undefined
1238
+ try { return new Intl.DateTimeFormat('en', { timeZone: value }).resolvedOptions().timeZone } catch (_) { return undefined }
1239
+ }
1240
+
1241
+ // 用目标日期的 IANA 时区规则解释存量墙上时间;不存在的 DST 跳跃时刻保留待人工修正。
1242
+ function resetCardWallTimeMs(raw, timeZone) {
1243
+ const wallText = raw.length === 10 ? `${raw}T23:59:59.999` : raw
1244
+ const wall = Date.parse(`${wallText}Z`)
1245
+ if (!Number.isFinite(wall)) return null
1246
+ const formatter = new Intl.DateTimeFormat('en-GB', {
1247
+ timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
1248
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
1249
+ })
1250
+ const wallAt = (at) => {
1251
+ const p = Object.fromEntries(formatter.formatToParts(new Date(at)).map(({ type, value }) => [type, value]))
1252
+ return Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`) + ((at % 1000) + 1000) % 1000
1253
+ }
1254
+ const candidates = []
1255
+ // 在目标墙上时刻两侧采样候选偏移,覆盖 DST 两档;反向映射相等才接受,避免跳跃日静默纠正。
1256
+ for (const delta of [-36, 0, 36]) {
1257
+ const sample = wall + delta * 3600000
1258
+ const candidate = wall - (wallAt(sample) - sample)
1259
+ if (wallAt(candidate) === wall) candidates.push(candidate)
1260
+ }
1261
+ return candidates.length === 0 ? null : Math.min(...candidates)
1262
+ }
1263
+
1163
1264
  /**
1164
1265
  * 重置卡到期时刻(毫秒):
1165
- * - 带时刻的 ISO 串(`2026-09-30T08:00` / 含秒、时区)→ Date.parse 的真实时刻;
1166
- * - 只到日的串(`2026-09-30`)→ **该自然日的 23:59:59.999**。裸日期被 Date.parse 当作当日
1167
- * 零点 UTC,会让「9-30 到期」的卡在 9-30 零点刚过就判过期,与用户「当天还有效」的直觉相反。
1266
+ * - 带偏移/时区的绝对串(`2026-09-30T08:00:00Z`、`+08:00`)→ Date.parse 的真实时刻,与时区无关;
1267
+ * - 只到日的串(`2026-09-30`)→ **该自然日在客户端时区里的 23:59:59.999**。裸日期被 Date.parse
1268
+ * 当作当日零点 UTC,会让「9-30 到期」的卡在 9-30 零点刚过就判过期,与「当天还有效」的直觉相反。
1269
+ * - datetime-local 串(`2026-09-30T08:00`)→ 该墙上时间在**客户端时区**里的绝对时刻。
1270
+ * 此前按宿主时区(容器 UTC)解释,与浏览器渲染各算一套:UTC+8 用户在界面上看到「已过期」,
1271
+ * 宿主却仍判未过期——既不自动移除,还继续当临期卡提示。
1272
+ * - 优先使用客户端 IANA 时区的目标日期规则;仅旧客户端缺时区名时兼容合法固定偏移。
1273
+ * - 无时区形态且没有合法时区信息 → null(宁可留着也不按宿主时区删卡/标过期)。
1168
1274
  * - 缺失/无法解析 → null(永不过期,只手动移除)。
1169
1275
  */
1170
- function resetCardExpiryMs(expiresAt) {
1276
+ function resetCardExpiryMs(expiresAt, timezoneOffsetMinutes, timeZone) {
1171
1277
  if (typeof expiresAt !== 'string') return null
1172
1278
  const raw = expiresAt.trim()
1173
1279
  if (raw === '') return null
1280
+ if (RESET_CARD_DATE_RE.test(raw) || RESET_CARD_NAIVE_RE.test(raw)) {
1281
+ // 显式提供的时区非法时不得降级成可能错误的偏移,更不能触发删除。
1282
+ if (timeZone !== undefined) {
1283
+ const zone = quotaTimeZone(timeZone)
1284
+ return zone === undefined ? null : resetCardWallTimeMs(raw, zone)
1285
+ }
1286
+ const offset = quotaTimezoneOffset(timezoneOffsetMinutes)
1287
+ if (offset === undefined) return null
1288
+ // 把「墙上时间」按 offset 平移成 UTC:getTimezoneOffset 是「本地→UTC 需要加的分钟数」,
1289
+ // 故 UTC = 墙上时间 + offset(UTC+8 → -480 → 减 8 小时)。
1290
+ const wall = Date.parse(raw.length === 10 ? `${raw}T00:00:00.000Z` : `${raw}Z`)
1291
+ if (!Number.isFinite(wall)) return null
1292
+ const endOfDay = raw.length === 10 ? 24 * 60 * 60 * 1000 - 1 : 0
1293
+ return wall + endOfDay + offset * 60 * 1000
1294
+ }
1295
+ // 非标准无偏移串不得落回宿主本地 Date.parse(例如空格代替 T)。
1296
+ 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
1297
  const parsed = Date.parse(raw)
1175
- if (!Number.isFinite(parsed)) return null
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
1298
+ return Number.isFinite(parsed) ? parsed : null
1179
1299
  }
1180
1300
 
1181
1301
  /** 已过期(到期时刻严格早于 now)的重置卡:宿主读配置时自动剔除。 */
1182
- function isResetCardExpired(card, now = Date.now()) {
1183
- const at = resetCardExpiryMs(card?.expiresAt)
1302
+ function isResetCardExpired(card, now = Date.now(), timezoneOffsetMinutes, timeZone) {
1303
+ const at = resetCardExpiryMs(card?.expiresAt, timezoneOffsetMinutes, timeZone)
1184
1304
  return at !== null && at < now
1185
1305
  }
1186
1306
 
1187
1307
  /**
1188
1308
  * 自动移除已过期的重置卡。读路径(refreshQuotaConfigCache)在加载后调用:
1189
1309
  * 有剔除才落盘,避免每次读都写文件。返回值同时告诉调用方「是否发生过变更」。
1310
+ * 无时区形态的卡必须带上客户端偏移才参与判定,否则会按宿主时区误删。
1190
1311
  */
1191
- function pruneExpiredResetCards(config, now = Date.now()) {
1312
+ function pruneExpiredResetCards(config, now = Date.now(), timezoneOffsetMinutes, timeZone) {
1192
1313
  if (!Array.isArray(config?.resetCards) || config.resetCards.length === 0) return false
1193
- const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now))
1314
+ const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now, timezoneOffsetMinutes, timeZone))
1194
1315
  if (kept.length === config.resetCards.length) return false
1195
1316
  config.resetCards = kept
1196
1317
  return true
1197
1318
  }
1198
1319
 
1320
+ /**
1321
+ * 把无时区的 datetime-local 到期串固化成绝对时刻(ISO,带 Z):一次迁移,之后任何时区都读得一致。
1322
+ * 纯日期串刻意保持原样——「9-30 到期」是本地日期承诺,改成绝对时刻会在界面上多出一个时间点,
1323
+ * 且它每次判定都用客户端时区的目标日期规则算当日末尾,无需固化。
1324
+ * 拿不到时区信息时原样返回,无法解析时同样原样返回(保留人工可核查的原文)。
1325
+ */
1326
+ function canonicalResetCardExpiresAt(expiresAt, timezoneOffsetMinutes, timeZone) {
1327
+ if (typeof expiresAt !== 'string') return expiresAt
1328
+ const raw = expiresAt.trim()
1329
+ if (!RESET_CARD_NAIVE_RE.test(raw)) return expiresAt
1330
+ const at = resetCardExpiryMs(raw, timezoneOffsetMinutes, timeZone)
1331
+ return at === null ? expiresAt : new Date(at).toISOString()
1332
+ }
1333
+
1334
+ /** 把配置里所有无时区 datetime 卡固化为绝对时刻。有改写才返回 true(调用方据此落盘)。 */
1335
+ function normalizeResetCardInstants(config, timezoneOffsetMinutes, timeZone) {
1336
+ if (!Array.isArray(config?.resetCards)) return false
1337
+ let changed = false
1338
+ for (const card of config.resetCards) {
1339
+ if (card === null || typeof card !== 'object') continue
1340
+ const next = canonicalResetCardExpiresAt(card.expiresAt, timezoneOffsetMinutes, timeZone)
1341
+ if (next !== card.expiresAt) {
1342
+ card.expiresAt = next
1343
+ changed = true
1344
+ }
1345
+ }
1346
+ return changed
1347
+ }
1348
+
1199
1349
  // 重置卡条目(v0.19 手填过渡方案:官方无 API Key 可查的端点):provider 必填,label/expiresAt 可选;
1200
1350
  // id 缺失时按原始位置合成稳定 id(老数据兼容),写入口生成的 id 原样保留。
1201
1351
  // v1.9.1 起另可带 account(CLIProxyAPI 多账号细分),缺省 = 整 provider 共享的卡,老数据零迁移。
@@ -1954,7 +2104,7 @@ function usageFailure(event) {
1954
2104
  }
1955
2105
 
1956
2106
  function recentErrorTime(time) {
1957
- return Number.isFinite(time) && time >= Date.now() - 24 * 60 * 60 * 1000
2107
+ return Number.isFinite(time) && time >= Date.now() - USAGE_ERROR_WINDOW_MS
1958
2108
  }
1959
2109
 
1960
2110
  function addUsageError(session, event, model) {
@@ -2064,8 +2214,14 @@ function validSessionOffset(value) {
2064
2214
  // 不感知运行时差异。backup 的 readRaw 物理字节 seam 新公共面不再提供(物理整树 tar 回退
2065
2215
  // 即 0.1.5 语义),不做双形态。
2066
2216
  function persistenceListSnapshots(persistence) {
2067
- if (typeof persistence.list === 'function') return persistence.list()
2068
- return persistence.listSnapshots()
2217
+ // 两个方法在 0.1.1-rc.2 ~ 0.1.2-rc.1 上**同时在位且语义不同**:listSnapshots() 给
2218
+ // {header, revision},list() 只给裸 header(当时 list 的契约就是「只列 metadata」)。
2219
+ // 先探测 list() 会拿到裸 header,消费方读 record.header.id 即 undefined.id 抛错——
2220
+ // 旧宿主上表现为模型统计永远建不起索引(其余功能不依赖这个形状,故只有统计坏掉)。
2221
+ // 0.1.3-alpha.2 起反过来:listSnapshots 移除,list() 的返回值升级成
2222
+ // {header, revision, sizeBytes}。故按「语义正确者优先」探测,与版本号无关。
2223
+ if (typeof persistence.listSnapshots === 'function') return persistence.listSnapshots()
2224
+ return persistence.list()
2069
2225
  }
2070
2226
 
2071
2227
  async function persistenceReadSlice(persistence, header, fromSeq) {
@@ -2145,14 +2301,21 @@ function usageFailedSessions(index) {
2145
2301
 
2146
2302
  function publicUsage(index, timezoneOffsetMinutes = 0) {
2147
2303
  const failedSessions = usageFailedSessions(index)
2148
- const indexedSessions = Object.keys(index.sessions).length
2149
- const result = { updatedAt: index.updatedAt, indexedSessions, successfulSessions: indexedSessions - failedSessions.filter((failure) => failure.stale).length, failedSessions, totals: emptyUsageTotals(), days: {}, errors: { models: [], tools: [] } }
2304
+ const allSessions = Object.values(index.sessions)
2305
+ // 保留条目仍计入总量与日期桶(它们就是历史用量),但在会话计数里单列,
2306
+ // 否则界面上的「已索引 N 个会话」会包含磁盘上已不存在的会话。
2307
+ const detachedSessions = allSessions.filter((session) => session.detached === true).length
2308
+ const indexedSessions = allSessions.length
2309
+ const result = { updatedAt: index.updatedAt, indexedSessions, detachedSessions, successfulSessions: indexedSessions - failedSessions.filter((failure) => failure.stale).length, failedSessions, totals: emptyUsageTotals(), days: {}, hours: {}, errors: { models: [], tools: [] } }
2150
2310
  const projects = new Map()
2311
+ const projectVisibility = new Map()
2151
2312
  const modelErrors = new Map()
2152
2313
  const toolErrors = new Map()
2153
- const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
2154
- for (const session of Object.values(index.sessions)) {
2314
+ const recentCutoff = Date.now() - USAGE_ERROR_WINDOW_MS
2315
+ for (const session of allSessions) {
2155
2316
  projects.set(session.project.id, session.project)
2317
+ // 项目文件夹被删只影响「按项目」筛选入口的可见性;用量本身照旧进总量与日期桶。
2318
+ if (!projectVisibility.has(session.project.id)) projectVisibility.set(session.project.id, !usageProjectMissing(session.project))
2156
2319
  pruneSessionErrors(session, recentCutoff)
2157
2320
  for (const error of Object.values(session.modelErrors || {})) {
2158
2321
  const count = error.recentTimes.length
@@ -2181,6 +2344,17 @@ function publicUsage(index, timezoneOffsetMinutes = 0) {
2181
2344
  projectBucket.models.set(model.id, modelBucket)
2182
2345
  }
2183
2346
  dayBucket.projects.set(session.project.id, projectBucket)
2347
+ // 打卡图(星期 × 小时):与 days 同为「已有原始数据的一次重排」,不新增持久化字段。
2348
+ // 只带 totals 与项目拆分(不带模型明细),实测体积约为带模型版本的 1/2。
2349
+ const slot = localWeekdayHourForHour(hour, timezoneOffsetMinutes)
2350
+ if (slot !== undefined) {
2351
+ const key = `${slot.weekday}-${slot.hour}`
2352
+ const hourBucket = result.hours[key] || (result.hours[key] = { weekday: slot.weekday, hour: slot.hour, totals: emptyUsageTotals(), projects: new Map() })
2353
+ addUsageTotals(hourBucket.totals, source.totals)
2354
+ const hourProject = hourBucket.projects.get(session.project.id) || { id: session.project.id, title: session.project.title, totals: emptyUsageTotals() }
2355
+ addUsageTotals(hourProject.totals, source.totals)
2356
+ hourBucket.projects.set(session.project.id, hourProject)
2357
+ }
2184
2358
  }
2185
2359
  }
2186
2360
  const errorSort = (a, b) => b.count - a.count || a.key.localeCompare(b.key) || a.projectId.localeCompare(b.projectId)
@@ -2197,6 +2371,13 @@ function publicUsage(index, timezoneOffsetMinutes = 0) {
2197
2371
  models: [...project.models.values()].map((model) => ({ ...model, totals: finishTotals(model.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
2198
2372
  })).sort((a, b) => a.title.localeCompare(b.title))
2199
2373
  }
2374
+ result.hours = Object.values(result.hours).map((bucket) => ({
2375
+ ...bucket,
2376
+ totals: finishTotals(bucket.totals),
2377
+ projects: [...bucket.projects.values()].map((project) => ({ ...project, totals: finishTotals(project.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
2378
+ })).sort((a, b) => a.weekday - b.weekday || a.hour - b.hour)
2379
+ // missing 只对「项目文件夹已不存在」的条目为真,客户端据此隐藏项目分页按钮。
2380
+ result.projects = result.projects.map((project) => ({ ...project, missing: projectVisibility.get(project.id) === false }))
2200
2381
  return result
2201
2382
  }
2202
2383
 
@@ -2216,23 +2397,45 @@ async function refreshUsageIndex(ctx, dshHome, currentIndex) {
2216
2397
  const index = structuredClone(currentIndex)
2217
2398
  index.failedSessions = []
2218
2399
  const liveIds = new Set(snapshots.map((record) => String(record.header.id)))
2219
- for (const id of Object.keys(index.sessions)) if (!liveIds.has(id)) delete index.sessions[id]
2220
- const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
2400
+ // 保留策略:会话被删(或项目文件夹被删)都不再扣减已统计的贡献——条目永久留在索引里,
2401
+ // 只打 detached 标记供界面区分,hours/lastSeq 等桶数据一字不动;会话回来时自动摘掉标记。
2402
+ // 这样总量与热力图始终是「索引建立以来的完整历史」,不再随删除缩水。
2403
+ const detachedAt = Date.now()
2404
+ for (const [id, session] of Object.entries(index.sessions)) {
2405
+ if (liveIds.has(id)) {
2406
+ if (session.detached === true) {
2407
+ delete session.detached
2408
+ delete session.detachedAt
2409
+ }
2410
+ continue
2411
+ }
2412
+ if (session.detached !== true) {
2413
+ session.detached = true
2414
+ session.detachedAt = detachedAt
2415
+ }
2416
+ if (session.rebuildPending) {
2417
+ delete session.rebuildPending
2418
+ }
2419
+ }
2420
+ const recentCutoff = Date.now() - USAGE_ERROR_WINDOW_MS
2221
2421
  for (const session of Object.values(index.sessions)) pruneSessionErrors(session, recentCutoff)
2222
2422
  for (const record of snapshots) {
2223
2423
  const id = String(record.header.id)
2224
2424
  const revision = revisionKey(record.revision)
2225
2425
  const previous = index.sessions[id]
2226
- if (previous?.revision === revision) continue
2227
- const fromSeq = usageReadStart(previous)
2426
+ const isRebuild = previous?.rebuildPending === true
2427
+ if (previous?.revision === revision && !isRebuild) continue
2428
+ const fromSeq = isRebuild ? 0 : usageReadStart(previous)
2228
2429
  let stage = 'read'
2229
2430
  try {
2230
2431
  const read = await persistenceReadSlice(persistence, record.header, fromSeq)
2231
2432
  const inheritedEventCount = inheritedEventCountFor(record, read)
2232
2433
  const events = usageReadEvents(read)
2233
2434
  stage = 'fold'
2234
- const next = foldUsageEvents(ctx, record, previous === undefined ? undefined : structuredClone(previous), events, inheritedEventCount)
2435
+ const foldBase = isRebuild ? undefined : (previous === undefined ? undefined : structuredClone(previous))
2436
+ const next = foldUsageEvents(ctx, record, foldBase, events, inheritedEventCount)
2235
2437
  next.revision = revision
2438
+ delete next.rebuildPending
2236
2439
  index.sessions[id] = next
2237
2440
  } catch (error) {
2238
2441
  index.failedSessions.push(usageSessionFailure(id, error, stage, previous !== undefined))
@@ -2607,7 +2810,7 @@ function runtimeEnvCheck(runtimeEnv) {
2607
2810
  return { id: 'runtime-env', status: 'info', detail: 'unknown' }
2608
2811
  }
2609
2812
 
2610
- async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2813
+ async function collectDiagnostics(ctx, dshHome, runtimeEnv, options = {}) {
2611
2814
  const checks = []
2612
2815
  const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
2613
2816
 
@@ -2689,6 +2892,27 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2689
2892
  if (!compatReport.available) add('plugin-compat', 'info', 'unavailable')
2690
2893
  else checks.push(pluginCompatCheckItem(compatReport))
2691
2894
 
2895
+ // 使用统计索引(工作区实现,未发布):统计链路自身的健康度。单会话折读失败会让用量静默少算,
2896
+ // 此前只在模型统计页的全局警告里可见,诊断清单没有任何一项覆盖它。
2897
+ // 功能关闭时宿主根本不刷新索引(usage/usage-refresh 均受 modelUsage 门控),报「失败」
2898
+ // 只会是陈旧误导,故整项省略而不是降级成 info 行。
2899
+ // 追加在清单尾部:不动前五项 id 顺序契约(既有测试与客户端渲染顺序都依赖它)。
2900
+ if (options.usageEnabled === true) {
2901
+ let index = null
2902
+ try { index = await options.usageIndex } catch (_) { index = null }
2903
+ if (index === null || typeof index !== 'object') add('usage-index', 'info', 'unavailable')
2904
+ else {
2905
+ const failed = usageFailedSessions(index).length
2906
+ const indexed = index.sessions !== null && typeof index.sessions === 'object' ? Object.keys(index.sessions).length : 0
2907
+ const updatedAt = Number.isFinite(Number(index.updatedAt)) ? Number(index.updatedAt) : 0
2908
+ // updatedAt=0 = 从未建立索引(用户还没打开过模型统计页):不是故障,报 info。
2909
+ // 失败会话是「部分成功」语义(沿用 usage 页同款口径):warning 而非 error——统计仍可用,
2910
+ // 只是少算了这些会话,不点亮诊断标签 ⚠ 的 error 档。
2911
+ if (updatedAt === 0) add('usage-index', 'info', 'never')
2912
+ else add('usage-index', failed > 0 ? 'warning' : 'ok', `${failed}:${indexed}:${updatedAt}`)
2913
+ }
2914
+ }
2915
+
2692
2916
  let status = 'ok'
2693
2917
  // advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
2694
2918
  if (checks.some((check) => check.status === 'error')) status = 'error'
@@ -2724,41 +2948,6 @@ async function collectHealth(ctx) {
2724
2948
  }
2725
2949
  }
2726
2950
 
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
2951
  function collectActiveWork(ctx) {
2763
2952
  const agentsService = ctx.get('agents')
2764
2953
  const jobsService = ctx.get('jobs')
@@ -4954,12 +5143,29 @@ function apply(ctx) {
4954
5143
  let quotaConfigLastCheckedAt = 0
4955
5144
  let quotaConfigMtimeMs = 0
4956
5145
  const quotaConfigPath = join(dshHome, QUOTA_CONFIG_FILE)
5146
+ // 最近一次请求上带的客户端时区偏移:无时区形态的重置卡到期串(纯日期 / datetime-local)
5147
+ // 必须按「用户墙上时间」解释,宿主容器时区(多为 UTC)不是用户的时区。额度/健康等读路径
5148
+ // 都带这个字段,记录最后一次即可;拿不到时宁可不判定(见 resetCardExpiryMs),也不按错时区删卡。
5149
+ let quotaClientOffset
5150
+ let quotaClientTimeZone
5151
+ const rememberQuotaClientOffset = (payload) => {
5152
+ if (payload?.timeZone !== undefined || payload?.timezoneOffsetMinutes !== undefined) {
5153
+ quotaClientOffset = quotaTimezoneOffset(payload?.timezoneOffsetMinutes)
5154
+ // 显式非法时区留给判定器拒绝;旧客户端只带偏移时不得沿用另一客户端的时区。
5155
+ quotaClientTimeZone = payload?.timeZone
5156
+ }
5157
+ return quotaClientOffset
5158
+ }
4957
5159
  // 过期重置卡自动移除:剔除内存快照里的过期条目,有变更才落盘(避免每次读都写文件)。
4958
5160
  // 读失败不影响本次结果——内存已剔除,下次读再试。TTL 快路径、TTL 到期后 mtime 未变、
4959
5161
  // 重新加载三条路都要过这关:mtime 未变就永远不会重新加载,缺了这一路,「停在概览页
4960
5162
  // 只靠 health 轮询」时运行期到点的卡在内存里清不掉。
4961
5163
  const pruneExpiredQuotaResetCards = async () => {
4962
- if (!pruneExpiredResetCards(quotaConfig)) return
5164
+ const offset = quotaClientOffset
5165
+ // 先固化无时区 datetime 卡(一次迁移,之后任何时区都读得一致),再按偏移剔除过期卡。
5166
+ const canonicalized = normalizeResetCardInstants(quotaConfig, offset, quotaClientTimeZone)
5167
+ const pruned = pruneExpiredResetCards(quotaConfig, Date.now(), offset, quotaClientTimeZone)
5168
+ if (!canonicalized && !pruned) return
4963
5169
  try {
4964
5170
  await saveQuotaConfig(dshHome, quotaConfig)
4965
5171
  quotaConfigMtimeMs = (await stat(quotaConfigPath)).mtimeMs
@@ -5255,16 +5461,16 @@ function apply(ctx) {
5255
5461
  } },
5256
5462
  'health': { handle: async (payload, rpcEndpoint) => {
5257
5463
  const value = await collectHealth(ctx)
5258
- // 剩余 24 小时内到期的重置卡(额度功能开启时才算):概览「可行动项」的 info 提示数据源。
5259
- // 走 health 而非 quota,是因为概览不打开额度页也该看到提醒,而 health 是面板的常驻轮询。
5260
- if (featureEnabled('quotaLookup')) {
5261
- value.resetCardsExpiringSoon = await collectResetCardsExpiringSoon(refreshQuotaConfigCache)
5262
- }
5464
+ // 概览不再提示重置卡到期(用户裁决整条去掉)。但 health 仍是面板最常打的读路径,
5465
+ // 借它推进「过期卡自动移除」:额度功能开启时读一次配置(内部按客户端时区固化+剔除)。
5466
+ if (featureEnabled('quotaLookup')) await refreshQuotaConfigCache()
5263
5467
  return { ok: true, value }
5264
5468
 
5265
5469
  } },
5266
5470
  'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
5267
- return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
5471
+ // usageIndex 传的是闭包里的同一个 promise(不在检查里重读磁盘 JSON——索引可达数百 KB);
5472
+ // modelUsage 关闭时整个 usage-index 检查项缺席,避免用陈旧索引报假故障。
5473
+ return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv, { usageEnabled: featureEnabled('modelUsage'), usageIndex: usageIndexPromise }) }
5268
5474
 
5269
5475
  } },
5270
5476
  'plugin-restart': { feature: 'healthDiagnostics', audit: true, handle: async (payload, rpcEndpoint) => {
@@ -5410,12 +5616,17 @@ function apply(ctx) {
5410
5616
  coreRoutes,
5411
5617
  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
5618
  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 }),
5619
+ 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
5620
  createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
5415
5621
  createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, setBackupProgress, withBackupLock, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
5416
5622
  ])
5417
5623
 
5418
- const dispatchRpc = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
5624
+ const dispatchEndpoint = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
5625
+ // 只接受额度时区端点的上下文;usage 的固定偏移不能覆盖额度的 IANA 时区规则。
5626
+ const dispatchRpc = (endpoint, payload) => {
5627
+ if (endpoint === 'quota' || endpoint === 'quota-reset-card' || endpoint === 'health') rememberQuotaClientOffset(payload)
5628
+ return dispatchEndpoint(endpoint, payload)
5629
+ }
5419
5630
  try {
5420
5631
  ensureConnectionRpcWebServerSeam(ctx)
5421
5632
  ctx.connection.rpc.handle('/dsh-service', dispatchRpc, { authority: 'loopback' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
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
- if (typeof payload?.expiresAt === 'string' && payload.expiresAt.trim() !== '') card.expiresAt = payload.expiresAt.trim().slice(0, 32)
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 } }