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/lib/state.js CHANGED
@@ -1,152 +1,350 @@
1
1
  /**
2
- * State model + persistence for file activity.
2
+ * State model + persistence loading for file activity.
3
3
  *
4
- * State (recent history + per-file counts) is kept per session and persisted
5
- * to $DSH_HOME/file-activity.json (atomic tmp+rename, debounced). Loading is
6
- * defensive: missing / corrupt / wrong-version files degrade to fresh state.
4
+ * 状态(recent 历史 + 每文件计数)按会话保存,持久化为 **JSON Lines** 增量日志
5
+ * (issue #197:旧实现每次防抖落盘全量重写 → 写放大 3,665×):
6
+ * - 事件行 `{"s","p","o","t"}`:一条记录一行,落盘字节 ≈ 事件本体字节;
7
+ * - compact 快照行:`~` 元信息行 + 每会话一行 `{"s","d"}` + `=` 结束行,
8
+ * 由宿主在行阈值触发时原子重写(文件大小有界、启动加载可重建)。
9
+ *
10
+ * 加载是防御性的:缺失 / 损坏 / 版本不符 → 空状态;单行解析失败跳过。
11
+ * 兼容升级前的单行全量 JSON 快照(整文件 JSON 解析成功即按老结构读取)。
12
+ */
13
+ import { readFile } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
15
+ import { createSession, defaultLimits, emptyEvicted } from './quota.js';
16
+ import { trimToQuota } from './quota-trim.js';
17
+ /** 快照元信息行标记 / 快照结束行标记(非会话数据行)。 */
18
+ const META = '~';
19
+ const END = '=';
20
+ /** NUL 字符(路径合法性判定)。 */
21
+ const NUL = String.fromCharCode(0);
22
+ /**
23
+ * 已删除的客户端挂载探针使用的合成会话 id(issue #266 D)。
24
+ *
25
+ * 0.5.8 之前客户端每次页面加载都往记录接口 POST 一条
26
+ * `{sessionId:'__probe__', path:'mounted'}`,于是每个浏览器加载都在状态文件里
27
+ * 留下一行永不清理的幽灵记录(实测累计 9 行、重启后仍在),与 #197「状态有界」
28
+ * 相悖。探针本身已被删除;这里把历史文件里的残留一并剔除,加载后的第一次
29
+ * compact 即让它在盘面上消失。
7
30
  */
8
- import { readFile } from 'node:fs/promises'
9
- import { homedir } from 'node:os'
10
-
11
- /** How many recent entries to keep per session (LRU: one entry per path). */
12
- const RECENT_LIMIT = 5
13
-
14
- /** State file: $DSH_HOME/file-activity.json (fallback: ~/.dsh/file-activity.json). */
31
+ const PROBE_SESSION = '__probe__';
32
+ /** State file: $DSH_HOME/file-activity.json(JSON Lines 格式)。 */
15
33
  export function stateFile() {
16
- const home = process.env.DSH_HOME
17
- if (typeof home === 'string' && home !== '') return `${home}/file-activity.json`
18
- return `${homedir()}/.dsh/file-activity.json`
34
+ const home = process.env.DSH_HOME;
35
+ if (typeof home === 'string' && home !== '')
36
+ return `${home}/file-activity.json`;
37
+ return `${homedir()}/.dsh/file-activity.json`;
19
38
  }
20
-
21
39
  /** Empty state document. */
22
40
  export function createState() {
23
- return { version: 1, sessions: {} }
24
- }
25
-
26
- /** Load persisted state (missing/corrupt file → fresh state). */
27
- export async function loadState(file) {
28
- try {
29
- const raw = await readFile(file, 'utf8')
30
- const parsed = JSON.parse(raw)
31
- if (parsed !== null && typeof parsed === 'object' && parsed.version === 1) return parsed
32
- } catch {
33
- // first run or unreadable file: start fresh
34
- }
35
- return createState()
36
- }
37
-
38
- /** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit' | 'delete'. */
39
- export function mapOp(op) {
40
- switch (op) {
41
- case 'write':
42
- return 'write'
43
- case 'edit':
44
- case 'str_replace_editor':
45
- return 'edit'
46
- case 'delete':
47
- return 'delete'
48
- case 'read':
49
- case 'read_image':
50
- default:
51
- return 'read'
52
- }
53
- }
54
-
41
+ return { version: 1, sessions: {}, stats: { evicted: emptyEvicted() } };
42
+ }
43
+ /** 整文件 JSON(升级前的全量快照格式)。结构不符返回 null。 */
44
+ export function parseLegacySnapshot(text) {
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(text);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
53
+ return null;
54
+ const doc = parsed;
55
+ if (doc.version !== 1 || doc.sessions === null || typeof doc.sessions !== 'object')
56
+ return null;
57
+ return { sessions: doc.sessions, legacy: true };
58
+ }
59
+ /** 事件行(记录行):`{"s","p","o","t"}`;非记录行返回 null。 */
60
+ function parseRecordLine(value) {
61
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
62
+ return null;
63
+ const row = value;
64
+ if (typeof row.s !== 'string' || typeof row.p !== 'string')
65
+ return null;
66
+ if (row.s === '' || row.p === '')
67
+ return null;
68
+ return { sessionId: row.s, path: row.p, op: String(row.o ?? 'read'), time: typeof row.t === 'number' ? row.t : 0 };
69
+ }
55
70
  /**
56
- * Fold one observed operation into the state.
57
- * 'write' is classified create vs modify through the per-session known-file
58
- * registry (first contact = create, later writes = modify); edits are always
59
- * modifies. 'delete' removes the file from stats entirely (disk state is the
60
- * truth: the file no longer exists) and records a single delete history entry.
61
- * Each file's counters also track firstSeen (first contact time, i.e.
62
- * creation time) and lastSeen (most recent activity time).
63
- * Returns true when a record was produced.
71
+ * 解析状态文件全部行 → { snapshot, records }:
72
+ * - snapshot:最后一次 compact 的会话表(`{s,d}` 行,取最后出现者);
73
+ * - records:文件内全部事件行(含快照之前的行;重放时快照优先,事件行幂等重放)。
64
74
  */
65
- export function applyRecord(state, sessionId, path, op, time) {
66
- if (!isValidRecordTarget(sessionId, path)) return false
67
- const session = state.sessions[sessionId] ?? (state.sessions[sessionId] = { known: {}, counts: {}, recent: [] })
68
- const timestamp = typeof time === 'number' ? time : Date.now()
69
- if (op === 'delete') return applyDelete(session, path, timestamp)
70
- const firstSeen = typeof session.known[path] === 'number' ? session.known[path] : timestamp
71
- const finalOp = classifyOp(op, session.known[path])
72
- session.known[path] = firstSeen
73
- const counts = session.counts[path] ?? (session.counts[path] = { read: 0, create: 0, modify: 0 })
74
- bumpCount(counts, finalOp, firstSeen, timestamp)
75
- pushRecent(session.recent, path, finalOp, timestamp)
76
- return true
77
- }
78
-
79
- /** 'delete': the file no longer exists on disk — drop it from stats and the
80
- * known-file registry, and record a single delete history entry. */
81
- function applyDelete(session, path, timestamp) {
82
- delete session.counts[path]
83
- delete session.known[path]
84
- pushRecent(session.recent, path, 'delete', timestamp)
85
- return true
75
+ export function parseStateFileText(text) {
76
+ const snapshot = {};
77
+ const records = [];
78
+ for (const entry of parseStateEntries(text)) {
79
+ if (entry.kind === 'record') {
80
+ records.push(entry.record);
81
+ continue;
82
+ }
83
+ const session = toSession(entry.data);
84
+ if (session !== null)
85
+ snapshot[entry.sessionId] = session;
86
+ }
87
+ return { snapshot, records };
86
88
  }
87
-
88
- /** Newest-first LRU history: one entry per path, cap at RECENT_LIMIT. */
89
- function pushRecent(recent, path, op, time) {
90
- const existing = recent.findIndex((entry) => entry.path === path)
91
- if (existing !== -1) recent.splice(existing, 1)
92
- recent.unshift({ path, op, time })
93
- if (recent.length > RECENT_LIMIT) recent.length = RECENT_LIMIT
89
+ /** 非负数字兜底。 */
90
+ function numOr0(value) {
91
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
92
+ }
93
+ /** 单行 JSON 解析(空行 / 损坏行 / 非对象 → null)。 */
94
+ function parseJsonLine(raw) {
95
+ const line = raw.trim();
96
+ if (line === '')
97
+ return null;
98
+ try {
99
+ const value = JSON.parse(line);
100
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
101
+ ? value
102
+ : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /** 一行 → 状态文件条目(快照行 `{s,d}` 或事件行 `{s,p,o,t}`)。 */
109
+ function toEntry(row) {
110
+ const data = row.d;
111
+ if (typeof row.s === 'string' && row.s !== '' && data !== null && typeof data === 'object' && !Array.isArray(data)) {
112
+ return { kind: 'snapshot', sessionId: row.s, data };
113
+ }
114
+ const record = parseRecordLine(row);
115
+ return record === null ? null : { kind: 'record', record };
116
+ }
117
+ /** 按行序解析状态文件:快照行(`{s,d}`)与事件行(`{s,p,o,t}`)保持文件顺序。 */
118
+ function parseStateEntries(text) {
119
+ const entries = [];
120
+ for (const raw of text.split('\n')) {
121
+ const row = parseJsonLine(raw);
122
+ if (row === null)
123
+ continue;
124
+ const entry = toEntry(row);
125
+ if (entry !== null)
126
+ entries.push(entry);
127
+ }
128
+ return entries;
129
+ }
130
+ /** 元信息行(`{"m":1,...}`):单行 JSON,独立于文件整体结构(兼容单行全量快照)。 */
131
+ const META_LINE = /\{"m":1[^\n]*\}/;
132
+ /** 从文本中提取快照统计(历次淘汰计数)。 */
133
+ function extractSnapshotStats(text) {
134
+ const match = META_LINE.exec(text);
135
+ if (match === null)
136
+ return undefined;
137
+ try {
138
+ const meta = JSON.parse(match[0]);
139
+ if (meta.stats === null || typeof meta.stats !== 'object')
140
+ return undefined;
141
+ return meta.stats;
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ }
147
+ /** 非数组的普通对象判定。 */
148
+ function isPlainObject(value) {
149
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
150
+ }
151
+ /** known 表规整(非数字时间 / 非法 path 丢弃)。 */
152
+ function toKnown(raw) {
153
+ const known = {};
154
+ if (!isPlainObject(raw))
155
+ return known;
156
+ for (const [path, at] of Object.entries(raw)) {
157
+ if (typeof at === 'number' && path !== '' && !path.includes(NUL))
158
+ known[path] = at;
159
+ }
160
+ return known;
94
161
  }
95
-
96
- /** A record target is valid when both ids are non-empty strings (no NUL). */
162
+ /** counts 表规整(非法 path / 非法计数器丢弃)。 */
163
+ function toCounts(raw) {
164
+ const counts = {};
165
+ if (!isPlainObject(raw))
166
+ return counts;
167
+ for (const [path, value] of Object.entries(raw)) {
168
+ if (path === '' || path.includes(NUL))
169
+ continue;
170
+ const counters = sanitizeCounters(value);
171
+ if (counters !== null)
172
+ counts[path] = counters;
173
+ }
174
+ return counts;
175
+ }
176
+ /** recent 列表规整(非法条目丢弃)。 */
177
+ function toRecent(raw) {
178
+ const recent = [];
179
+ if (!Array.isArray(raw))
180
+ return recent;
181
+ for (const entry of raw) {
182
+ if (!isPlainObject(entry))
183
+ continue;
184
+ const path = entry.path;
185
+ if (typeof path !== 'string' || path === '' || path.includes(NUL))
186
+ continue;
187
+ recent.push({ path, op: String(entry.op ?? 'read'), time: typeof entry.time === 'number' ? entry.time : 0 });
188
+ }
189
+ return recent;
190
+ }
191
+ /** 会话数据规整(结构非法返回 null)。 */
192
+ function toSession(raw) {
193
+ if (!isPlainObject(raw))
194
+ return null;
195
+ return { known: toKnown(raw.known), counts: toCounts(raw.counts), recent: toRecent(raw.recent) };
196
+ }
197
+ /** 计数器防御性读取(非法结构返回 null)。 */
198
+ function sanitizeCounters(raw) {
199
+ if (!isPlainObject(raw))
200
+ return null;
201
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
202
+ const counters = { read: num(raw.read), create: num(raw.create), modify: num(raw.modify) };
203
+ if (typeof raw.firstSeen === 'number')
204
+ counters.firstSeen = raw.firstSeen;
205
+ if (typeof raw.lastSeen === 'number')
206
+ counters.lastSeen = raw.lastSeen;
207
+ return counters;
208
+ }
209
+ /**
210
+ * Load persisted state (missing/corrupt file → fresh state), then converge to
211
+ * the entry caps(旧文件可能远超新上限:加载即按 LRU 淘汰并告警)。
212
+ * 返回 { state, trimmed, evicted }。
213
+ */
214
+ export async function loadState(file, limits = defaultLimits(), logger) {
215
+ let text = '';
216
+ try {
217
+ text = await readFile(file, 'utf8');
218
+ }
219
+ catch {
220
+ return { state: createState(), trimmed: false, evicted: emptyEvicted(), legacy: false };
221
+ }
222
+ const legacy = parseLegacySnapshot(text);
223
+ const sessions = legacy === null ? sessionsOfJsonl(text) : sessionsOfLegacy(legacy);
224
+ // issue #266 D: never resurrect the retired mount probe's ghost records.
225
+ delete sessions[PROBE_SESSION];
226
+ const result = trimToQuota({ version: 1, sessions }, limits, logger);
227
+ return { ...mergeSnapshotStats(result, legacy?.stats ?? extractSnapshotStats(text)), legacy: legacy !== null };
228
+ }
229
+ /** 旧格式(整文件 JSON 快照)→ 会话表。 */
230
+ function sessionsOfLegacy(legacy) {
231
+ const sessions = {};
232
+ for (const [sessionId, raw] of Object.entries(legacy.sessions)) {
233
+ const session = toSession(raw);
234
+ if (session !== null)
235
+ sessions[sessionId] = session;
236
+ }
237
+ return sessions;
238
+ }
239
+ /** JSON Lines → 会话表(快照行建立基线,事件行按序重放 = 运行时状态的精确重现)。 */
240
+ function sessionsOfJsonl(text) {
241
+ const state = createState();
242
+ for (const entry of parseStateEntries(text)) {
243
+ if (entry.kind === 'snapshot') {
244
+ const session = toSession(entry.data);
245
+ if (session !== null)
246
+ state.sessions[entry.sessionId] = session;
247
+ continue;
248
+ }
249
+ foldRecord(state, entry.record.sessionId, entry.record.path, entry.record.op, entry.record.time);
250
+ }
251
+ return state.sessions;
252
+ }
253
+ /** 历次淘汰计数随快照持久化:重启后计数不归零(可观测性连续)。 */
254
+ function mergeSnapshotStats(result, stats) {
255
+ if (stats === undefined)
256
+ return result;
257
+ result.evicted = {
258
+ sessions: numOr0(stats.evicted?.sessions) + result.evicted.sessions,
259
+ paths: numOr0(stats.evicted?.paths) + result.evicted.paths,
260
+ pathsTotal: numOr0(stats.evicted?.pathsTotal) + result.evicted.pathsTotal,
261
+ };
262
+ result.state.stats.evicted = { ...result.evicted };
263
+ return result;
264
+ }
265
+ /** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit' | 'delete'. */
266
+ export function mapOp(op) {
267
+ switch (op) {
268
+ case 'write':
269
+ return 'write';
270
+ case 'edit':
271
+ case 'str_replace_editor':
272
+ return 'edit';
273
+ case 'delete':
274
+ return 'delete';
275
+ case 'read':
276
+ case 'read_image':
277
+ default:
278
+ return 'read';
279
+ }
280
+ }
281
+ /** 记录目标合法性:两个 id 均为非空字符串(path 不含 NUL)。 */
97
282
  function isValidRecordTarget(sessionId, path) {
98
- return (
99
- typeof sessionId === 'string' && sessionId !== '' && typeof path === 'string' && path !== '' && !path.includes('\0')
100
- )
283
+ return (typeof sessionId === 'string' && sessionId !== '' && typeof path === 'string' && path !== '' && !path.includes(NUL));
101
284
  }
102
-
103
285
  /** 'write' → create/modify by the known-file registry; 'edit' → modify; else read. */
104
286
  function classifyOp(op, knownTime) {
105
- if (op === 'write') return knownTime ? 'modify' : 'create'
106
- if (op === 'edit') return 'modify'
107
- return 'read'
287
+ if (op === 'write')
288
+ return knownTime ? 'modify' : 'create';
289
+ if (op === 'edit')
290
+ return 'modify';
291
+ return 'read';
108
292
  }
109
-
110
293
  /** Increment the matching counter and refresh firstSeen/lastSeen. */
111
294
  function bumpCount(counts, finalOp, firstSeen, timestamp) {
112
- if (finalOp === 'create') counts.create += 1
113
- else if (finalOp === 'modify') counts.modify += 1
114
- else counts.read += 1
115
- counts.firstSeen = firstSeen
116
- counts.lastSeen = timestamp
295
+ if (finalOp === 'create')
296
+ counts.create += 1;
297
+ else if (finalOp === 'modify')
298
+ counts.modify += 1;
299
+ else
300
+ counts.read += 1;
301
+ counts.firstSeen = firstSeen;
302
+ counts.lastSeen = timestamp;
303
+ }
304
+ /** Newest-first LRU history: one entry per path, cap at RECENT_LIMIT. */
305
+ function pushRecent(recent, path, op, time) {
306
+ const existing = recent.findIndex((entry) => entry.path === path);
307
+ if (existing !== -1)
308
+ recent.splice(existing, 1);
309
+ recent.unshift({ path, op, time });
310
+ if (recent.length > 5)
311
+ recent.length = 5;
312
+ }
313
+ /** 'delete': the file no longer exists on disk — drop it from stats and the
314
+ * known-file registry, and record a single delete history entry. */
315
+ function applyDelete(session, path, timestamp) {
316
+ delete session.counts[path];
317
+ delete session.known[path];
318
+ pushRecent(session.recent, path, 'delete', timestamp);
319
+ return true;
117
320
  }
118
-
119
321
  /**
120
- * Normalize + trim a loaded state document in place: null/absent sessions are
121
- * reset to {}, pre-existing history is deduped by path and capped at
122
- * RECENT_LIMIT (LRU semantics: one entry per path, newest occurrence wins —
123
- * the array is newest-first, so the first occurrence of each path is kept).
124
- * Returns { state, trimmed } where trimmed reports whether anything changed.
322
+ * Fold one observed operation into the state (no persistence side effects; the
323
+ * caller persists the corresponding JSON Lines event).
324
+ * 'write' is classified create vs modify through the per-session known-file
325
+ * registry (first contact = create, later writes = modify); edits are always
326
+ * modifies. 'delete' removes the file from stats entirely and records a single
327
+ * delete history entry. Returns the created session bucket when newly added.
125
328
  */
126
- export function trimLoadedState(loaded) {
127
- if (loaded.sessions === undefined || loaded.sessions === null || typeof loaded.sessions !== 'object')
128
- loaded.sessions = {}
129
- let trimmed = false
130
- for (const session of Object.values(loaded.sessions)) {
131
- if (Array.isArray(session.recent)) {
132
- const seen = new Set()
133
- const deduped = session.recent.filter((entry) => {
134
- if (typeof entry?.path !== 'string' || entry.path === '') return false
135
- if (seen.has(entry.path)) return false
136
- seen.add(entry.path)
137
- return true
138
- })
139
- if (deduped.length !== session.recent.length) trimmed = true
140
- session.recent = deduped
141
- if (session.recent.length > RECENT_LIMIT) {
142
- session.recent.length = RECENT_LIMIT
143
- trimmed = true
144
- }
145
- }
146
- }
147
- return { state: loaded, trimmed }
148
- }
149
-
329
+ export function foldRecord(state, sessionId, path, op, time) {
330
+ if (!isValidRecordTarget(sessionId, path))
331
+ return null;
332
+ const created = state.sessions[sessionId] === undefined;
333
+ const session = state.sessions[sessionId] ?? (state.sessions[sessionId] = createSession());
334
+ const timestamp = typeof time === 'number' ? time : Date.now();
335
+ if (op === 'delete') {
336
+ applyDelete(session, path, timestamp);
337
+ return { session, created, newPath: false };
338
+ }
339
+ const firstSeen = typeof session.known[path] === 'number' ? session.known[path] : timestamp;
340
+ const finalOp = classifyOp(op, session.known[path]);
341
+ session.known[path] = firstSeen;
342
+ const existing = session.counts[path];
343
+ const counts = existing ?? (session.counts[path] = { read: 0, create: 0, modify: 0 });
344
+ bumpCount(counts, finalOp, firstSeen, timestamp);
345
+ pushRecent(session.recent, path, finalOp, timestamp);
346
+ return { session, created, newPath: existing === undefined };
347
+ }
150
348
  /**
151
349
  * Whether `path` appears in this session's recorded file activity (counts or
152
350
  * recent). The media route authorizes EXACTLY these paths — the record itself
@@ -154,12 +352,14 @@ export function trimLoadedState(loaded) {
154
352
  * expected, while arbitrary unrecorded paths stay refused.
155
353
  */
156
354
  export function isRecordedPath(state, sessionId, path) {
157
- const session = state.sessions[sessionId]
158
- if (session === undefined) return false
159
- if (session.counts !== undefined && typeof session.counts[path] === 'object' && session.counts[path] !== null)
160
- return true
161
- // Deleted files no longer exist on disk — a delete history entry must not
162
- // authorize media preview for them.
163
- if (Array.isArray(session.recent)) return session.recent.some((entry) => entry.path === path && entry.op !== 'delete')
164
- return false
355
+ const session = state.sessions[sessionId];
356
+ if (session === undefined)
357
+ return false;
358
+ if (session.counts !== undefined && typeof session.counts[path] === 'object' && session.counts[path] !== null)
359
+ return true;
360
+ // Deleted files no longer exist on disk — a delete history entry must not
361
+ // authorize media preview for them.
362
+ if (Array.isArray(session.recent))
363
+ return session.recent.some((entry) => entry.path === path && entry.op !== 'delete');
364
+ return false;
165
365
  }