dsh-file-activity 0.5.7 → 0.5.9
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/CHANGELOG.md +50 -0
- package/README.md +29 -18
- package/lib/api-route.js +72 -76
- package/lib/bash-ops.js +65 -54
- package/lib/bash-parse.js +154 -162
- package/lib/client.js +739 -361
- package/lib/client.src.js +28 -21
- package/lib/cwd.js +6 -6
- package/lib/index.js +68 -83
- package/lib/media-route.js +119 -90
- package/lib/observer.js +30 -24
- package/lib/parts/api.part.js +1 -5
- package/lib/parts/apply.part.js +91 -82
- package/lib/parts/auto-open.part.js +83 -60
- package/lib/parts/document-previews.part.js +62 -0
- package/lib/parts/format.part.js +1 -2
- package/lib/parts/i18n.part.js +15 -1
- package/lib/parts/interceptor.part.js +16 -27
- package/lib/parts/preview-data.part.js +111 -0
- package/lib/parts/preview-float.part.js +276 -0
- package/lib/parts/rows.part.js +1 -11
- package/lib/parts/settings.part.js +71 -0
- package/lib/parts/store.part.js +19 -0
- package/lib/parts/styles.part.js +49 -3
- package/lib/parts/tree.part.js +1 -2
- package/lib/parts/view.part.js +72 -18
- package/lib/persist.js +137 -0
- package/lib/quota-trim.js +121 -0
- package/lib/quota.js +164 -0
- package/lib/state.js +336 -136
- package/lib/store.js +249 -76
- package/package.json +10 -6
- package/lib/parts/preview.part.js +0 -308
package/lib/parts/view.part.js
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
|
+
'use strict'
|
|
1
2
|
// ── view component ────────────────────────────────────────────────────
|
|
2
3
|
/** Shared empty bucket for sessions that have never loaded data (stable ref). */
|
|
3
4
|
const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
|
|
4
|
-
|
|
5
|
+
/** tab.visible out of the host's SidebarRightTabInfo; undefined when the
|
|
6
|
+
* information (or its shape) is unavailable. */
|
|
7
|
+
function readSeatVisible(info) {
|
|
8
|
+
if (info === null || typeof info !== 'object') return undefined
|
|
9
|
+
const tab = info.tab
|
|
10
|
+
if (tab === null || typeof tab !== 'object') return undefined
|
|
11
|
+
const visible = tab.visible
|
|
12
|
+
return typeof visible === 'boolean' ? visible : undefined
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve whether this seat is on screen.
|
|
16
|
+
*
|
|
17
|
+
* The native seat hands the component **no** `visible` prop: the host dispatches
|
|
18
|
+
* it as `renderSlot('sidebar.right.pane.tab', {}, { hookContext })`, so the owner
|
|
19
|
+
* props share is empty and everything live arrives through the injected
|
|
20
|
+
* `useTabInfo()` hook (SidebarRightTabInfo.tab.visible). Reading `props.visible`
|
|
21
|
+
* alone is therefore always `undefined` — the pre-#266 (`if (!visible) return`)
|
|
22
|
+
* guard was constant-true, so an opened panel never loaded and never polled
|
|
23
|
+
* (it only showed data after a manual Refresh).
|
|
24
|
+
*
|
|
25
|
+
* Order: the host hook first, then the legacy prop, then visible by default.
|
|
26
|
+
* Defaulting to visible is the deliberate failure direction (issue #266): if the
|
|
27
|
+
* hook is unavailable or the host shape changes again, the panel must still
|
|
28
|
+
* load — the cost is one polling panel, the alternative is a blank one.
|
|
29
|
+
*/
|
|
30
|
+
function useSeatVisible(useTabInfo, legacyVisible) {
|
|
31
|
+
const info = typeof useTabInfo === 'function' ? useTabInfo() : undefined
|
|
32
|
+
const seatVisible = readSeatVisible(info)
|
|
33
|
+
if (seatVisible !== undefined) return seatVisible
|
|
34
|
+
return legacyVisible !== false
|
|
35
|
+
}
|
|
5
36
|
/**
|
|
6
37
|
* Polling loader for one session: fetches stats on mount and on a fixed
|
|
7
38
|
* interval while visible, prefers the sidebar's authoritative session.cwd
|
|
@@ -44,7 +75,6 @@ function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError
|
|
|
44
75
|
}
|
|
45
76
|
}, [visible, sessionId, dataStore])
|
|
46
77
|
}
|
|
47
|
-
|
|
48
78
|
/** Error banner element, or null when the last load succeeded. */
|
|
49
79
|
function renderError(error) {
|
|
50
80
|
if (!error) return null
|
|
@@ -60,7 +90,6 @@ function renderError(error) {
|
|
|
60
90
|
strings.loadError(),
|
|
61
91
|
)
|
|
62
92
|
}
|
|
63
|
-
|
|
64
93
|
/** "最近访问" section: collapsible head with refresh/clear actions. */
|
|
65
94
|
function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
|
|
66
95
|
return createElement(
|
|
@@ -116,7 +145,6 @@ function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, o
|
|
|
116
145
|
),
|
|
117
146
|
)
|
|
118
147
|
}
|
|
119
|
-
|
|
120
148
|
/** "文件统计" section: the directory tree, or an empty hint. */
|
|
121
149
|
function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
|
|
122
150
|
return createElement(
|
|
@@ -132,20 +160,31 @@ function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
|
|
|
132
160
|
),
|
|
133
161
|
)
|
|
134
162
|
}
|
|
135
|
-
|
|
136
163
|
/**
|
|
137
164
|
* The file-activity tab. Each session renders only its own store bucket:
|
|
138
165
|
* a fresh conversation shows an empty list immediately, with no residue
|
|
139
166
|
* from the previous session. Clicking any file opens a FLOATING preview
|
|
140
167
|
* that reuses the sidebar's NATIVE viewer via matchFileViewer.
|
|
141
168
|
*/
|
|
142
|
-
function FileActivityView({
|
|
169
|
+
function FileActivityView({
|
|
170
|
+
ctx,
|
|
171
|
+
store,
|
|
172
|
+
scope,
|
|
173
|
+
sessionId: seatSessionId,
|
|
174
|
+
visible: legacyVisible,
|
|
175
|
+
useTabInfo,
|
|
176
|
+
dataStore,
|
|
177
|
+
}) {
|
|
143
178
|
const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
|
|
144
179
|
const [cwd, setCwd] = useState(scope?.cwd || '')
|
|
145
180
|
const [error, setError] = useState(false)
|
|
146
181
|
const [recentOpen, setRecentOpen] = useState(true)
|
|
147
182
|
const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
|
|
148
|
-
|
|
183
|
+
// Native seats pass sessionId directly; the legacy scope object still works.
|
|
184
|
+
const sessionId = seatSessionId ?? scope?.sessionId ?? ''
|
|
185
|
+
// issue #266 A: the native seat passes NO visible prop — visibility comes
|
|
186
|
+
// from the injected useTabInfo() (see useSeatVisible).
|
|
187
|
+
const visible = useSeatVisible(useTabInfo, legacyVisible)
|
|
149
188
|
const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
|
|
150
189
|
const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
|
|
151
190
|
useEffect(() => {
|
|
@@ -164,8 +203,10 @@ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
|
164
203
|
closePreviewOnHidden(visible, dataStore)
|
|
165
204
|
}, [visible, dataStore])
|
|
166
205
|
const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
|
|
167
|
-
|
|
168
|
-
|
|
206
|
+
// The floating preview itself lives in the root-scoped 'shell.overlay' seat
|
|
207
|
+
// (registered by registerPreviewOverlay), so this view only publishes the
|
|
208
|
+
// target — together with the owning session, which authorizes the routes.
|
|
209
|
+
const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path), sessionId } })
|
|
169
210
|
const onClear = () => clearSessionData(dataStore, sessionId)
|
|
170
211
|
const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
|
|
171
212
|
const recent = sessionData.recent ?? []
|
|
@@ -173,16 +214,29 @@ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
|
173
214
|
'div',
|
|
174
215
|
{ className: 'dfa' },
|
|
175
216
|
renderError(error),
|
|
217
|
+
renderDegraded(),
|
|
176
218
|
renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
|
|
177
219
|
renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Degradation notice: shown when this activation failed to register one of the
|
|
224
|
+
* host extension points (registerTabType / documentPreviews / auto-open). The
|
|
225
|
+
* panel is also marked with data-dfa-degraded so the e2e suite can assert the
|
|
226
|
+
* failure instead of hunting a missing tab.
|
|
227
|
+
* @returns the notice element, or null when nothing degraded.
|
|
228
|
+
*/
|
|
229
|
+
function renderDegraded() {
|
|
230
|
+
try {
|
|
231
|
+
const markers = typeof document === 'undefined' ? undefined : document.documentElement?.dataset
|
|
232
|
+
if (markers === undefined || markers === null || markers.dfaDegraded === undefined) return null
|
|
233
|
+
} catch {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
return createElement(
|
|
237
|
+
'div',
|
|
238
|
+
{ className: 'dfa-degraded', 'data-dfa-degraded': '1' },
|
|
239
|
+
createElement('div', { className: 'dfa-degraded-title' }, strings.tabUnavailable()),
|
|
240
|
+
createElement('div', { className: 'dfa-degraded-hint' }, strings.tabUnavailableHint()),
|
|
187
241
|
)
|
|
188
242
|
}
|
package/lib/persist.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-file-activity — JSON Lines 增量持久化(issue #197 写放大整改)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是增量 append(方案 A)而不是「节流后的全量快照」:本插件状态是
|
|
5
|
+
* 累积字典,旧实现每次防抖落盘都 JSON.stringify 整个状态(审计实测单次
|
|
6
|
+
* 1,396,407 B / 写放大 3,665×)。一次记录的信息量恰好等于状态增量(一条
|
|
7
|
+
* 事件 = 一次 fold),所以增量落盘字节 ≈ 事件本体字节。
|
|
8
|
+
*
|
|
9
|
+
* 数据不丢的论证:
|
|
10
|
+
* - 事件行 `{s,p,o,t}` 携带重放所需的全部信息(会话 / 路径 / 操作 / 时间);
|
|
11
|
+
* 冷启动按行序重放 = 运行时状态的精确重现(delete 行同样可重放);
|
|
12
|
+
* - 行阈值触发 compact:用**当前内存态**生成快照行(元信息行 + 每会话一行
|
|
13
|
+
* `{s,d}` + 结束行)并原子重写(tmp+rename),文件大小有界、启动不回归;
|
|
14
|
+
* - 崩溃安全:append 是 O(新增) 的,最坏丢最后一个防抖窗口(≤ flushMs);
|
|
15
|
+
* compact 由 rename 保证原子(旧文件或新文件,不会半截)。
|
|
16
|
+
*
|
|
17
|
+
* 护栏(显式,不再裸奔):
|
|
18
|
+
* - `minIntervalMs`:快照超过阈值体积后生效的最小写间隔(分级节流);
|
|
19
|
+
* - `maxBytes`:快照字节硬上限(超限拒绝写 + warn,不静默);
|
|
20
|
+
* - `compactLines`:追加行阈值(按状态体积自适应,文件大小因此有界)。
|
|
21
|
+
*/
|
|
22
|
+
import { jsonlAppender } from 'dsh-shared';
|
|
23
|
+
import { LOG_PREFIX } from './quota.js';
|
|
24
|
+
/** 追加落盘的防抖窗口(高频突发合并成一次 append)。 */
|
|
25
|
+
const FLUSH_MS = 500;
|
|
26
|
+
/** 追加行数阈值(默认;createStore 按状态体积自适应上调)。 */
|
|
27
|
+
const COMPACT_LINES = 3000;
|
|
28
|
+
/** compact 快照字节硬上限(状态上界 ~2MB;超限拒绝写并告警)。 */
|
|
29
|
+
const SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024;
|
|
30
|
+
/** 快照超过该体积后才启用最小写间隔(小快照高频重写成本可忽略)。 */
|
|
31
|
+
const SNAPSHOT_THROTTLE_MIN_BYTES = 64 * 1024;
|
|
32
|
+
/** 快照最小写间隔:体积每翻倍 +1s(大快照低频重写,写放大有界)。 */
|
|
33
|
+
const SNAPSHOT_MIN_INTERVAL_MS = 1000;
|
|
34
|
+
/** 一条记录 → 事件行(键序固定 s/p/o/t:落盘字节 = 事件本体字节)。 */
|
|
35
|
+
function eventLine(record) {
|
|
36
|
+
return JSON.stringify({ s: record.sessionId, p: record.path, o: record.op, t: record.time });
|
|
37
|
+
}
|
|
38
|
+
/** compact 快照行组:元信息行(含统计)+ 每会话一行 + 结束行(结束行让尾部事件区显式开始)。 */
|
|
39
|
+
function snapshotLines(state) {
|
|
40
|
+
const lines = [JSON.stringify({ m: 1, v: state.version, t: Date.now(), stats: state.stats })];
|
|
41
|
+
for (const [sessionId, session] of Object.entries(state.sessions)) {
|
|
42
|
+
lines.push(JSON.stringify({ s: sessionId, d: session }));
|
|
43
|
+
}
|
|
44
|
+
lines.push('=');
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 建立持久化编排:append 走 jsonl 增量;达行阈值时 appender 调 onCompact,
|
|
49
|
+
* 宿主用**实时 state**(stateOf 回调)生成快照行并原子重写。
|
|
50
|
+
*/
|
|
51
|
+
export function createPersist(options, stateOf) {
|
|
52
|
+
const logger = options.ctx.logger;
|
|
53
|
+
const guard = {
|
|
54
|
+
file: options.file,
|
|
55
|
+
minIntervalMs: options.snapshotMinIntervalMs ?? SNAPSHOT_MIN_INTERVAL_MS,
|
|
56
|
+
maxBytes: options.snapshotMaxBytes ?? SNAPSHOT_MAX_BYTES,
|
|
57
|
+
logger,
|
|
58
|
+
};
|
|
59
|
+
let handle;
|
|
60
|
+
handle = (options.factory ?? defaultAppender)(options.file, {
|
|
61
|
+
flushMs: options.flushMs ?? FLUSH_MS,
|
|
62
|
+
compactLines: options.compactLines ?? COMPACT_LINES,
|
|
63
|
+
logger,
|
|
64
|
+
prefix: LOG_PREFIX,
|
|
65
|
+
onCompact: () => writeSnapshot(handle, stateOf(), guard),
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
file: options.file,
|
|
69
|
+
appender: handle,
|
|
70
|
+
append: (record) => handle.append(JSON.parse(eventLine(record))),
|
|
71
|
+
compact: (state) => writeSnapshot(handle, state, guard),
|
|
72
|
+
flush: () => handle.flush(),
|
|
73
|
+
dispose: () => handle.dispose(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** 文件最近一次成功快照时间(节流窗口用)。 */
|
|
77
|
+
const lastSnapshotAt = new Map();
|
|
78
|
+
/** 分级节流:小快照不受最小间隔约束(否则 compact 会被自己节流掉)。 */
|
|
79
|
+
function throttleFor(file, bytes, guard) {
|
|
80
|
+
if (bytes <= SNAPSHOT_THROTTLE_MIN_BYTES)
|
|
81
|
+
return 0;
|
|
82
|
+
const factor = Math.max(1, Math.ceil(Math.log2(bytes / SNAPSHOT_THROTTLE_MIN_BYTES)));
|
|
83
|
+
return guard.minIntervalMs * factor;
|
|
84
|
+
}
|
|
85
|
+
/** 护栏判定:被拦时 warn 并返回 true(调用方跳过写盘,不静默)。 */
|
|
86
|
+
function guarded(guard, bytes, now) {
|
|
87
|
+
if (bytes > guard.maxBytes) {
|
|
88
|
+
guard.logger?.warn(LOG_PREFIX + ' compact rejected (' + bytes + 'B > maxBytes=' + guard.maxBytes + '): ' + guard.file);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const window = throttleFor(guard.file, bytes, guard);
|
|
92
|
+
if (window > 0 && now - (lastSnapshotAt.get(guard.file) ?? 0) < window) {
|
|
93
|
+
guard.logger?.warn(LOG_PREFIX + ' compact throttled (minIntervalMs=' + window + '): ' + guard.file);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 原子 compact 快照:走 dsh-shared jsonlAppender 的 snapshot(tmp+rename 原子
|
|
100
|
+
* 重写 + 清挂起队列 + 计入 stats);护栏由本层显式判定,拦截/失败均返回 false
|
|
101
|
+
* 并告警(不静默)。
|
|
102
|
+
*/
|
|
103
|
+
async function writeSnapshot(handle, state, guard) {
|
|
104
|
+
const lines = snapshotLines(state);
|
|
105
|
+
const bytes = Buffer.byteLength(lines.join('\n') + '\n', 'utf8');
|
|
106
|
+
if (guarded(guard, bytes, Date.now()))
|
|
107
|
+
return false;
|
|
108
|
+
lastSnapshotAt.set(guard.file, Date.now());
|
|
109
|
+
try {
|
|
110
|
+
await handle.snapshot(lines);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
guard.logger?.warn(LOG_PREFIX + ' compact failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** appender 统计(缺接口/字段名差异时回退 0,绝不产生 NaN —— NaN 会让预算判断失效)。 */
|
|
119
|
+
export function readAppenderStats(appender) {
|
|
120
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
121
|
+
try {
|
|
122
|
+
const stats = appender.stats();
|
|
123
|
+
// dsh-shared jsonlAppender 暴露 total(累计追加行数),兼容 events 命名。
|
|
124
|
+
return {
|
|
125
|
+
bytesWritten: num(stats.bytesWritten),
|
|
126
|
+
writes: num(stats.writes),
|
|
127
|
+
events: num(stats.total ?? stats.events),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { bytesWritten: 0, writes: 0, events: 0 };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** 默认 appender:dsh-shared 的 jsonlAppender(增量 append + 防抖 + compact 回调)。 */
|
|
135
|
+
function defaultAppender(file, options) {
|
|
136
|
+
return jsonlAppender(file, options);
|
|
137
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-file-activity — 加载规整与配额收敛(issue #197)。
|
|
3
|
+
*
|
|
4
|
+
* 旧状态文件(全量 JSON 快照)可能远超新上限:首次升级启动即按配额收敛,
|
|
5
|
+
* 淘汰动作同样计数 + warn,不做静默丢弃。
|
|
6
|
+
*/
|
|
7
|
+
import { RECENT_LIMIT, countPaths, emptyEvicted, enforceSessionLimit, enforceSessionPathLimit, enforceTotalPathLimit, } from './quota.js';
|
|
8
|
+
/** NUL 字符(路径合法性判定,禁止内嵌空字节)。 */
|
|
9
|
+
const NUL = String.fromCharCode(0);
|
|
10
|
+
/** 计数器规整:非法值归零、时间戳可选保留。 */
|
|
11
|
+
function sanitizeCounters(raw) {
|
|
12
|
+
if (raw === null || typeof raw !== 'object')
|
|
13
|
+
return null;
|
|
14
|
+
const num = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
15
|
+
const counters = { read: num(raw.read), create: num(raw.create), modify: num(raw.modify) };
|
|
16
|
+
if (typeof raw.firstSeen === 'number')
|
|
17
|
+
counters.firstSeen = raw.firstSeen;
|
|
18
|
+
if (typeof raw.lastSeen === 'number')
|
|
19
|
+
counters.lastSeen = raw.lastSeen;
|
|
20
|
+
return counters;
|
|
21
|
+
}
|
|
22
|
+
/** recent 规整:过滤非法条目 → 时间降序 → 按 path 去重 → 截断到 RECENT_LIMIT。 */
|
|
23
|
+
function normalizeRecent(raw) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const valid = raw.filter((entry) => typeof entry?.path === 'string' && entry.path !== '' && !entry.path.includes(NUL));
|
|
26
|
+
valid.sort((a, b) => (typeof b.time === 'number' ? b.time : 0) - (typeof a.time === 'number' ? a.time : 0));
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const entry of valid) {
|
|
29
|
+
if (seen.has(entry.path))
|
|
30
|
+
continue;
|
|
31
|
+
seen.add(entry.path);
|
|
32
|
+
out.push({
|
|
33
|
+
path: entry.path,
|
|
34
|
+
op: String(entry.op ?? 'read'),
|
|
35
|
+
time: typeof entry.time === 'number' ? entry.time : 0,
|
|
36
|
+
});
|
|
37
|
+
if (out.length >= RECENT_LIMIT)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** counts 规整:非法 path / 非法计数器丢弃。 */
|
|
43
|
+
function normalizeCounts(raw) {
|
|
44
|
+
const counts = {};
|
|
45
|
+
let dropped = 0;
|
|
46
|
+
for (const [path, rawCounters] of Object.entries(raw ?? {})) {
|
|
47
|
+
if (typeof path !== 'string' || path === '' || path.includes(NUL)) {
|
|
48
|
+
dropped += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const counters = sanitizeCounters(rawCounters);
|
|
52
|
+
if (counters === null) {
|
|
53
|
+
dropped += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
counts[path] = counters;
|
|
57
|
+
}
|
|
58
|
+
return { counts, dropped };
|
|
59
|
+
}
|
|
60
|
+
/** known 与 counts 对齐(非法条目丢弃、缺失项按 firstSeen 补齐)。 */
|
|
61
|
+
function alignKnown(raw, counts) {
|
|
62
|
+
const known = {};
|
|
63
|
+
let changed = false;
|
|
64
|
+
for (const [path, at] of Object.entries(raw ?? {})) {
|
|
65
|
+
if (typeof at === 'number' && path !== '' && !path.includes(NUL))
|
|
66
|
+
known[path] = at;
|
|
67
|
+
else
|
|
68
|
+
changed = true;
|
|
69
|
+
}
|
|
70
|
+
if (Object.keys(known).length !== Object.keys(counts).length)
|
|
71
|
+
changed = true;
|
|
72
|
+
for (const path of Object.keys(counts)) {
|
|
73
|
+
if (typeof known[path] === 'number')
|
|
74
|
+
continue;
|
|
75
|
+
known[path] = counts[path].firstSeen ?? 0;
|
|
76
|
+
}
|
|
77
|
+
return { known, changed };
|
|
78
|
+
}
|
|
79
|
+
/** 会话规整:recent 去重截断 → counts 过滤 → known 与 counts 对齐 → 单会话 LRU 上限。 */
|
|
80
|
+
function trimSession(raw, sessionId, limits, evicted, logger) {
|
|
81
|
+
const rawRecent = Array.isArray(raw.recent) ? raw.recent : [];
|
|
82
|
+
const recent = normalizeRecent(rawRecent);
|
|
83
|
+
const { counts, dropped } = normalizeCounts(raw.counts);
|
|
84
|
+
const { known, changed } = alignKnown(raw.known, counts);
|
|
85
|
+
const session = { known, counts, recent };
|
|
86
|
+
const before = Object.keys(counts).length;
|
|
87
|
+
enforceSessionPathLimit(session, sessionId, limits, evicted, logger);
|
|
88
|
+
const trimmed = recent.length !== rawRecent.length || dropped > 0 || changed || Object.keys(session.counts).length !== before;
|
|
89
|
+
return { session, trimmed };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 加载后按配额规整(旧状态文件可能远超上限:首次升级启动即收敛到边界)。
|
|
93
|
+
* 顺序:逐会话规整 → 会话数上限 → 全局路径上限。
|
|
94
|
+
*/
|
|
95
|
+
export function trimToQuota(loaded, limits, logger) {
|
|
96
|
+
const evicted = emptyEvicted();
|
|
97
|
+
let trimmed = false;
|
|
98
|
+
const sessions = {};
|
|
99
|
+
for (const [sessionId, raw] of Object.entries(loaded.sessions ?? {})) {
|
|
100
|
+
if (raw === null || typeof raw !== 'object' || sessionId === '') {
|
|
101
|
+
trimmed = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const result = trimSession(raw, sessionId, limits, evicted, logger);
|
|
105
|
+
if (result.trimmed)
|
|
106
|
+
trimmed = true;
|
|
107
|
+
sessions[sessionId] = result.session;
|
|
108
|
+
}
|
|
109
|
+
const state = { version: 1, sessions, stats: { evicted } };
|
|
110
|
+
const sessionsBefore = Object.keys(sessions).length;
|
|
111
|
+
enforceSessionLimit(state, limits, evicted, logger);
|
|
112
|
+
if (Object.keys(state.sessions).length !== sessionsBefore)
|
|
113
|
+
trimmed = true;
|
|
114
|
+
const pathsBefore = countPaths(state);
|
|
115
|
+
enforceTotalPathLimit(state, limits, evicted, logger);
|
|
116
|
+
if (countPaths(state) !== pathsBefore)
|
|
117
|
+
trimmed = true;
|
|
118
|
+
if (evicted.sessions + evicted.paths + evicted.pathsTotal > 0)
|
|
119
|
+
trimmed = true;
|
|
120
|
+
return { state, trimmed, evicted };
|
|
121
|
+
}
|
package/lib/quota.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** 每会话路径上限(LRU:超限淘汰最久未活动路径)。 */
|
|
2
|
+
const MAX_PATHS_PER_SESSION = 300;
|
|
3
|
+
/** 全局路径上限(跨会话总条目数)。 */
|
|
4
|
+
const MAX_PATHS_TOTAL = 20000;
|
|
5
|
+
/** 会话数上限(淘汰最久未活动会话)。 */
|
|
6
|
+
const MAX_SESSIONS = 64;
|
|
7
|
+
/** 每会话 recent 历史条目上限(LRU,保持既有语义)。 */
|
|
8
|
+
export const RECENT_LIMIT = 5;
|
|
9
|
+
/** 日志前缀(淘汰 warn 可见)。 */
|
|
10
|
+
export const LOG_PREFIX = '[dsh-file-activity]';
|
|
11
|
+
/** 默认配额。 */
|
|
12
|
+
export function defaultLimits() {
|
|
13
|
+
return { maxPathsPerSession: MAX_PATHS_PER_SESSION, maxPathsTotal: MAX_PATHS_TOTAL, maxSessions: MAX_SESSIONS };
|
|
14
|
+
}
|
|
15
|
+
/** 用默认值补齐(undefined / 非有限值回退默认)。 */
|
|
16
|
+
export function normalizeLimits(input) {
|
|
17
|
+
const pick = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
18
|
+
return {
|
|
19
|
+
maxPathsPerSession: pick(input?.maxPathsPerSession, MAX_PATHS_PER_SESSION),
|
|
20
|
+
maxPathsTotal: pick(input?.maxPathsTotal, MAX_PATHS_TOTAL),
|
|
21
|
+
maxSessions: pick(input?.maxSessions, MAX_SESSIONS),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** 空淘汰计数。 */
|
|
25
|
+
export function emptyEvicted() {
|
|
26
|
+
return { sessions: 0, paths: 0, pathsTotal: 0 };
|
|
27
|
+
}
|
|
28
|
+
/** 空会话桶。 */
|
|
29
|
+
export function createSession() {
|
|
30
|
+
return { known: {}, counts: {}, recent: [] };
|
|
31
|
+
}
|
|
32
|
+
/** 计数器最近活动时间(lastSeen → firstSeen → 0)。 */
|
|
33
|
+
function lastSeenOf(counters) {
|
|
34
|
+
if (counters === undefined || counters === null)
|
|
35
|
+
return 0;
|
|
36
|
+
if (typeof counters.lastSeen === 'number')
|
|
37
|
+
return counters.lastSeen;
|
|
38
|
+
if (typeof counters.firstSeen === 'number')
|
|
39
|
+
return counters.firstSeen;
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
/** 会话最近活动时间:recent 最新条目时间与所有计数器 lastSeen 的最大值。 */
|
|
43
|
+
function activityOf(session) {
|
|
44
|
+
let newest = 0;
|
|
45
|
+
if (Array.isArray(session.recent)) {
|
|
46
|
+
for (const entry of session.recent) {
|
|
47
|
+
if (typeof entry?.time === 'number' && entry.time > newest)
|
|
48
|
+
newest = entry.time;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const counters of Object.values(session.counts ?? {})) {
|
|
52
|
+
const at = lastSeenOf(counters);
|
|
53
|
+
if (at > newest)
|
|
54
|
+
newest = at;
|
|
55
|
+
}
|
|
56
|
+
return newest;
|
|
57
|
+
}
|
|
58
|
+
/** 淘汰日志节流:首次 + 每 10 次(避免长会话下 warn 刷屏,但绝不静默)。 */
|
|
59
|
+
function shouldLogEvicted(total) {
|
|
60
|
+
return total === 1 || total % 10 === 0;
|
|
61
|
+
}
|
|
62
|
+
/** 全局路径总数。 */
|
|
63
|
+
export function countPaths(state) {
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const session of Object.values(state.sessions))
|
|
66
|
+
total += Object.keys(session.counts).length;
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
/** 单个会话内路径超限:LRU 淘汰 lastSeen 最旧者(known/counts 同步)。 */
|
|
70
|
+
export function enforceSessionPathLimit(session, sessionId, limits, evicted, logger) {
|
|
71
|
+
const overflow = Object.keys(session.counts).length - limits.maxPathsPerSession;
|
|
72
|
+
if (overflow <= 0)
|
|
73
|
+
return;
|
|
74
|
+
const oldest = Object.entries(session.counts)
|
|
75
|
+
.map(([path, counters]) => ({ path, lastSeen: lastSeenOf(counters) }))
|
|
76
|
+
.sort((a, b) => a.lastSeen - b.lastSeen)
|
|
77
|
+
.slice(0, overflow);
|
|
78
|
+
for (const item of oldest) {
|
|
79
|
+
delete session.counts[item.path];
|
|
80
|
+
delete session.known[item.path];
|
|
81
|
+
}
|
|
82
|
+
evicted.paths += overflow;
|
|
83
|
+
if (shouldLogEvicted(evicted.paths)) {
|
|
84
|
+
logger?.warn(LOG_PREFIX +
|
|
85
|
+
' evict path: ' +
|
|
86
|
+
overflow +
|
|
87
|
+
' LRU path(s) from session ' +
|
|
88
|
+
sessionId +
|
|
89
|
+
' (cap=' +
|
|
90
|
+
limits.maxPathsPerSession +
|
|
91
|
+
', evicted=' +
|
|
92
|
+
evicted.paths +
|
|
93
|
+
')');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 全局路径超限:LRU 淘汰全局 lastSeen 最旧者(跨会话扫描,known/counts 同步)。 */
|
|
97
|
+
export function enforceTotalPathLimit(state, limits, evicted, logger) {
|
|
98
|
+
const overflow = countPaths(state) - limits.maxPathsTotal;
|
|
99
|
+
if (overflow <= 0)
|
|
100
|
+
return;
|
|
101
|
+
const candidates = [];
|
|
102
|
+
for (const [sessionId, session] of Object.entries(state.sessions)) {
|
|
103
|
+
for (const [path, counters] of Object.entries(session.counts)) {
|
|
104
|
+
candidates.push({ sessionId, path, lastSeen: lastSeenOf(counters) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
candidates.sort((a, b) => a.lastSeen - b.lastSeen);
|
|
108
|
+
let removed = 0;
|
|
109
|
+
for (const item of candidates) {
|
|
110
|
+
if (removed >= overflow)
|
|
111
|
+
break;
|
|
112
|
+
const session = state.sessions[item.sessionId];
|
|
113
|
+
if (session === undefined || session.counts[item.path] === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
delete session.counts[item.path];
|
|
116
|
+
delete session.known[item.path];
|
|
117
|
+
removed += 1;
|
|
118
|
+
}
|
|
119
|
+
evicted.pathsTotal += removed;
|
|
120
|
+
if (shouldLogEvicted(evicted.pathsTotal)) {
|
|
121
|
+
logger?.warn(LOG_PREFIX +
|
|
122
|
+
' evict path: ' +
|
|
123
|
+
removed +
|
|
124
|
+
' LRU path(s) globally (cap=' +
|
|
125
|
+
limits.maxPathsTotal +
|
|
126
|
+
', evicted=' +
|
|
127
|
+
evicted.pathsTotal +
|
|
128
|
+
')');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** 会话数超限:淘汰最久未活动会话(整会话桶删除,known/counts/recent 一并释放)。 */
|
|
132
|
+
export function enforceSessionLimit(state, limits, evicted, logger) {
|
|
133
|
+
const overflow = Object.keys(state.sessions).length - limits.maxSessions;
|
|
134
|
+
if (overflow <= 0)
|
|
135
|
+
return;
|
|
136
|
+
const oldest = Object.entries(state.sessions)
|
|
137
|
+
.map(([sessionId, session]) => ({ sessionId, activity: activityOf(session) }))
|
|
138
|
+
.sort((a, b) => a.activity - b.activity)
|
|
139
|
+
.slice(0, overflow);
|
|
140
|
+
for (const item of oldest)
|
|
141
|
+
delete state.sessions[item.sessionId];
|
|
142
|
+
evicted.sessions += overflow;
|
|
143
|
+
if (shouldLogEvicted(evicted.sessions)) {
|
|
144
|
+
logger?.warn(LOG_PREFIX +
|
|
145
|
+
' evict session: ' +
|
|
146
|
+
overflow +
|
|
147
|
+
' least-recently-active session(s) (cap=' +
|
|
148
|
+
limits.maxSessions +
|
|
149
|
+
', evicted=' +
|
|
150
|
+
evicted.sessions +
|
|
151
|
+
')');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 运行时记录后的配额执行顺序:全局路径 → 单会话路径 → 会话数。
|
|
156
|
+
* 淘汰计数累加到 evicted(调用方负责写入 state.stats 并落日志)。
|
|
157
|
+
*/
|
|
158
|
+
export function enforceQuota(state, sessionId, limits, evicted, logger) {
|
|
159
|
+
enforceTotalPathLimit(state, limits, evicted, logger);
|
|
160
|
+
const session = state.sessions[sessionId];
|
|
161
|
+
if (session !== undefined)
|
|
162
|
+
enforceSessionPathLimit(session, sessionId, limits, evicted, logger);
|
|
163
|
+
enforceSessionLimit(state, limits, evicted, logger);
|
|
164
|
+
}
|