@zzusp/ccsm 1.0.0 → 1.0.2
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/LICENSE +21 -21
- package/README.md +236 -232
- package/dist/assets/DiskUsage-BY6XwffG.js +2 -0
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
- package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
- package/dist/assets/index-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
- package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
- package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
- package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
- package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
- package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
- package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
- package/dist/assets/vendor-Cs8vYp-N.js +27 -0
- package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
- package/dist/favicon.svg +7 -7
- package/dist/index.html +30 -30
- package/package.json +24 -11
- package/server/index.ts +4 -0
- package/server/lib/active-sessions.test.ts +119 -0
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -0
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -0
- package/server/lib/claude-paths.ts +43 -36
- package/server/lib/cleanup-suggestions.ts +131 -0
- package/server/lib/constants.ts +8 -7
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -0
- package/server/lib/delete.ts +192 -203
- package/server/lib/disk-usage.ts +81 -83
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -0
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -0
- package/server/lib/modified-files.ts +228 -0
- package/server/lib/open-folder.ts +47 -40
- package/server/lib/parse-jsonl.ts +160 -107
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -0
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -0
- package/server/lib/safe-remove.ts +25 -0
- package/server/lib/scan.ts +289 -183
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- package/server/lib/update.ts +67 -0
- package/server/lib/version.test.ts +39 -0
- package/server/lib/version.ts +117 -0
- package/server/routes/disk-cleanup.ts +54 -0
- package/server/routes/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -81
- package/server/routes/version.ts +34 -0
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -2
- package/shared/types.ts +513 -359
- package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
- package/dist/assets/ImportPage-b8NORa8b.js.map +0 -1
- package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +0 -1
- package/dist/assets/index-DLATR3tZ.js +0 -5
- package/dist/assets/index-DLATR3tZ.js.map +0 -1
- package/dist/assets/index-DLDtbkux.css +0 -1
- package/dist/assets/vendor-CH80ylbS.js +0 -19
- package/dist/assets/vendor-CH80ylbS.js.map +0 -1
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { buildActiveSessionMap } from './active-sessions.ts';
|
|
4
|
-
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
5
|
-
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
6
|
-
import { deleteSessions } from './delete.ts';
|
|
7
|
-
import { isSafeId } from './safe-id.ts';
|
|
8
|
-
import type { DeleteProjectResult, SkippedItem } from '../types.ts';
|
|
9
|
-
|
|
10
|
-
const JSONL_EXT = '.jsonl';
|
|
11
|
-
|
|
12
|
-
export async function deleteProject(projectId: string): Promise<DeleteProjectResult> {
|
|
13
|
-
if (!isSafeId(projectId)) {
|
|
14
|
-
return {
|
|
15
|
-
deleted: [],
|
|
16
|
-
skipped: [{ projectId, sessionId: '', reason: 'invalid project id' }],
|
|
17
|
-
historyLinesRemoved: 0,
|
|
18
|
-
projectDirRemoved: false,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const projectDir = path.join(PATHS.projects, projectId);
|
|
23
|
-
if (!isUnderClaudeRoot(projectDir)) {
|
|
24
|
-
return {
|
|
25
|
-
deleted: [],
|
|
26
|
-
skipped: [{ projectId, sessionId: '', reason: 'path escapes ~/.claude' }],
|
|
27
|
-
historyLinesRemoved: 0,
|
|
28
|
-
projectDirRemoved: false,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
if (!fs.existsSync(projectDir)) {
|
|
32
|
-
return {
|
|
33
|
-
deleted: [],
|
|
34
|
-
skipped: [{ projectId, sessionId: '', reason: 'project directory does not exist' }],
|
|
35
|
-
historyLinesRemoved: 0,
|
|
36
|
-
projectDirRemoved: false,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const sessionIds: string[] = [];
|
|
41
|
-
for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
|
|
42
|
-
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
43
|
-
sessionIds.push(ent.name.slice(0, -JSONL_EXT.length));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// All-or-nothing precheck: refuse to touch any session if even one is live or
|
|
48
|
-
// recently active. Confirmed by the user — partial deletes leave the project
|
|
49
|
-
// half-cleared, which is more confusing than a clean "try again later".
|
|
50
|
-
const liveMap = buildActiveSessionMap();
|
|
51
|
-
const blockers: SkippedItem[] = [];
|
|
52
|
-
for (const sid of sessionIds) {
|
|
53
|
-
if (liveMap.has(sid)) {
|
|
54
|
-
blockers.push({
|
|
55
|
-
projectId,
|
|
56
|
-
sessionId: sid,
|
|
57
|
-
reason: `live PID ${liveMap.get(sid)} owns this session`,
|
|
58
|
-
});
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
const jsonlPath = path.join(projectDir, `${sid}${JSONL_EXT}`);
|
|
62
|
-
try {
|
|
63
|
-
if (Date.now() - fs.statSync(jsonlPath).mtimeMs < RECENT_ACTIVITY_WINDOW_MS) {
|
|
64
|
-
blockers.push({
|
|
65
|
-
projectId,
|
|
66
|
-
sessionId: sid,
|
|
67
|
-
reason: 'jsonl modified within the last 5 minutes — could still be in use',
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
} catch {
|
|
71
|
-
/* missing file is fine — deleteSessions will skip it */
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (blockers.length > 0) {
|
|
76
|
-
return {
|
|
77
|
-
deleted: [],
|
|
78
|
-
skipped: blockers,
|
|
79
|
-
historyLinesRemoved: 0,
|
|
80
|
-
projectDirRemoved: false,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const result = await deleteSessions(
|
|
85
|
-
sessionIds.map((sessionId) => ({ projectId, sessionId })),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
let projectDirRemoved = false;
|
|
89
|
-
if (result.skipped.length === 0) {
|
|
90
|
-
try {
|
|
91
|
-
// Recursive remove also catches any orphan subdirs whose .jsonl was missing.
|
|
92
|
-
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
93
|
-
projectDirRemoved = true;
|
|
94
|
-
} catch {
|
|
95
|
-
/* leave dir for manual cleanup */
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return { ...result, projectDirRemoved };
|
|
100
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildActiveSessionMap } from './active-sessions.ts';
|
|
4
|
+
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
5
|
+
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
6
|
+
import { deleteSessions } from './delete.ts';
|
|
7
|
+
import { isSafeId } from './safe-id.ts';
|
|
8
|
+
import type { DeleteProjectResult, SkippedItem } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
const JSONL_EXT = '.jsonl';
|
|
11
|
+
|
|
12
|
+
export async function deleteProject(projectId: string): Promise<DeleteProjectResult> {
|
|
13
|
+
if (!isSafeId(projectId)) {
|
|
14
|
+
return {
|
|
15
|
+
deleted: [],
|
|
16
|
+
skipped: [{ projectId, sessionId: '', reason: 'invalid project id' }],
|
|
17
|
+
historyLinesRemoved: 0,
|
|
18
|
+
projectDirRemoved: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
23
|
+
if (!isUnderClaudeRoot(projectDir)) {
|
|
24
|
+
return {
|
|
25
|
+
deleted: [],
|
|
26
|
+
skipped: [{ projectId, sessionId: '', reason: 'path escapes ~/.claude' }],
|
|
27
|
+
historyLinesRemoved: 0,
|
|
28
|
+
projectDirRemoved: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!fs.existsSync(projectDir)) {
|
|
32
|
+
return {
|
|
33
|
+
deleted: [],
|
|
34
|
+
skipped: [{ projectId, sessionId: '', reason: 'project directory does not exist' }],
|
|
35
|
+
historyLinesRemoved: 0,
|
|
36
|
+
projectDirRemoved: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sessionIds: string[] = [];
|
|
41
|
+
for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
|
|
42
|
+
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
43
|
+
sessionIds.push(ent.name.slice(0, -JSONL_EXT.length));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// All-or-nothing precheck: refuse to touch any session if even one is live or
|
|
48
|
+
// recently active. Confirmed by the user — partial deletes leave the project
|
|
49
|
+
// half-cleared, which is more confusing than a clean "try again later".
|
|
50
|
+
const liveMap = buildActiveSessionMap();
|
|
51
|
+
const blockers: SkippedItem[] = [];
|
|
52
|
+
for (const sid of sessionIds) {
|
|
53
|
+
if (liveMap.has(sid)) {
|
|
54
|
+
blockers.push({
|
|
55
|
+
projectId,
|
|
56
|
+
sessionId: sid,
|
|
57
|
+
reason: `live PID ${liveMap.get(sid)} owns this session`,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const jsonlPath = path.join(projectDir, `${sid}${JSONL_EXT}`);
|
|
62
|
+
try {
|
|
63
|
+
if (Date.now() - fs.statSync(jsonlPath).mtimeMs < RECENT_ACTIVITY_WINDOW_MS) {
|
|
64
|
+
blockers.push({
|
|
65
|
+
projectId,
|
|
66
|
+
sessionId: sid,
|
|
67
|
+
reason: 'jsonl modified within the last 5 minutes — could still be in use',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* missing file is fine — deleteSessions will skip it */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (blockers.length > 0) {
|
|
76
|
+
return {
|
|
77
|
+
deleted: [],
|
|
78
|
+
skipped: blockers,
|
|
79
|
+
historyLinesRemoved: 0,
|
|
80
|
+
projectDirRemoved: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result = await deleteSessions(
|
|
85
|
+
sessionIds.map((sessionId) => ({ projectId, sessionId })),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
let projectDirRemoved = false;
|
|
89
|
+
if (result.skipped.length === 0) {
|
|
90
|
+
try {
|
|
91
|
+
// Recursive remove also catches any orphan subdirs whose .jsonl was missing.
|
|
92
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
93
|
+
projectDirRemoved = true;
|
|
94
|
+
} catch {
|
|
95
|
+
/* leave dir for manual cleanup */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ...result, projectDirRemoved };
|
|
100
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// delete.ts 的关键不变量是"5 处级联 + 2 类安全网":
|
|
7
|
+
// 级联 = projects/<id>.jsonl + projects/<id>/ + file-history/<id>/ + session-env/<id>/
|
|
8
|
+
// + history.jsonl 里的行 + sessions/<pid>.json
|
|
9
|
+
// 安全网 = live PID OR 5 分钟内 mtime → 直接跳过
|
|
10
|
+
// 每次都 mock claude-paths.ts 把 PATHS 指到一个独立 tmp dir,
|
|
11
|
+
// 保证测试不会读到真实 ~/.claude。
|
|
12
|
+
|
|
13
|
+
let fakeRoot: string;
|
|
14
|
+
let fakePaths: {
|
|
15
|
+
root: string;
|
|
16
|
+
projects: string;
|
|
17
|
+
fileHistory: string;
|
|
18
|
+
sessionEnv: string;
|
|
19
|
+
sessions: string;
|
|
20
|
+
history: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
vi.mock('./claude-paths.ts', () => {
|
|
24
|
+
// 注意:vi.mock 是 hoist 的,工厂里不能引用模块作用域变量。
|
|
25
|
+
// 改用 process.env.CCSM_TEST_ROOT 桥接,afterEach 时清掉。
|
|
26
|
+
return {
|
|
27
|
+
get PATHS() {
|
|
28
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
29
|
+
return {
|
|
30
|
+
root,
|
|
31
|
+
projects: path.join(root, 'projects'),
|
|
32
|
+
fileHistory: path.join(root, 'file-history'),
|
|
33
|
+
sessionEnv: path.join(root, 'session-env'),
|
|
34
|
+
sessions: path.join(root, 'sessions'),
|
|
35
|
+
history: path.join(root, 'history.jsonl'),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
39
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
40
|
+
const resolved = path.resolve(target);
|
|
41
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
42
|
+
},
|
|
43
|
+
getCacheDir(): string {
|
|
44
|
+
return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-delete-test-'));
|
|
51
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
52
|
+
fakePaths = {
|
|
53
|
+
root: fakeRoot,
|
|
54
|
+
projects: path.join(fakeRoot, 'projects'),
|
|
55
|
+
fileHistory: path.join(fakeRoot, 'file-history'),
|
|
56
|
+
sessionEnv: path.join(fakeRoot, 'session-env'),
|
|
57
|
+
sessions: path.join(fakeRoot, 'sessions'),
|
|
58
|
+
history: path.join(fakeRoot, 'history.jsonl'),
|
|
59
|
+
};
|
|
60
|
+
fs.mkdirSync(fakePaths.projects, { recursive: true });
|
|
61
|
+
fs.mkdirSync(fakePaths.fileHistory, { recursive: true });
|
|
62
|
+
fs.mkdirSync(fakePaths.sessionEnv, { recursive: true });
|
|
63
|
+
fs.mkdirSync(fakePaths.sessions, { recursive: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
69
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
interface SessionFixture {
|
|
73
|
+
projectId: string;
|
|
74
|
+
sessionId: string;
|
|
75
|
+
jsonlPath: string;
|
|
76
|
+
subdirPath: string;
|
|
77
|
+
fhPath: string;
|
|
78
|
+
sePath: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 在 fake root 下铺出一份 5 个位置都存在的 session 文件布局。*/
|
|
82
|
+
function makeSession(opts: { sessionId?: string; projectId?: string; mtimeMs?: number } = {}): SessionFixture {
|
|
83
|
+
const sessionId = opts.sessionId ?? 'sid-' + Math.random().toString(36).slice(2, 10);
|
|
84
|
+
const projectId = opts.projectId ?? '-Users-alice-proj';
|
|
85
|
+
const projectDir = path.join(fakePaths.projects, projectId);
|
|
86
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
89
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'user', sessionId }) + '\n');
|
|
90
|
+
|
|
91
|
+
const subdirPath = path.join(projectDir, sessionId);
|
|
92
|
+
fs.mkdirSync(subdirPath, { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(subdirPath, 'notes.md'), 'sub');
|
|
94
|
+
|
|
95
|
+
const fhPath = path.join(fakePaths.fileHistory, sessionId);
|
|
96
|
+
fs.mkdirSync(fhPath, { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(fhPath, 'a.txt'), 'fh');
|
|
98
|
+
|
|
99
|
+
const sePath = path.join(fakePaths.sessionEnv, sessionId);
|
|
100
|
+
fs.mkdirSync(sePath, { recursive: true });
|
|
101
|
+
fs.writeFileSync(path.join(sePath, 'env.json'), '{}');
|
|
102
|
+
|
|
103
|
+
// mtime 拨到一小时前,绕开 5 分钟安全网
|
|
104
|
+
const mtime = opts.mtimeMs ?? Date.now() - 60 * 60 * 1000;
|
|
105
|
+
fs.utimesSync(jsonlPath, mtime / 1000, mtime / 1000);
|
|
106
|
+
|
|
107
|
+
return { projectId, sessionId, jsonlPath, subdirPath, fhPath, sePath };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeHistory(lines: Array<Record<string, unknown>>): void {
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
fakePaths.history,
|
|
113
|
+
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writePidFile(pid: number, sessionId: string, cwd = '/Users/alice/proj'): string {
|
|
118
|
+
const file = path.join(fakePaths.sessions, `${pid}.json`);
|
|
119
|
+
fs.writeFileSync(file, JSON.stringify({ pid, sessionId, cwd }));
|
|
120
|
+
return file;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe('deleteSessions:5 处级联清理', () => {
|
|
124
|
+
it('一次 delete 删 jsonl + subdir + file-history + session-env + history 行 + 死 PID 文件', async () => {
|
|
125
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
126
|
+
const s = makeSession();
|
|
127
|
+
|
|
128
|
+
// history.jsonl:留两条同 sid、一条别的
|
|
129
|
+
writeHistory([
|
|
130
|
+
{ sessionId: s.sessionId, project: '/Users/alice/proj', timestamp: 'T1', display: 'q1' },
|
|
131
|
+
{ sessionId: s.sessionId, project: '/Users/alice/proj', timestamp: 'T2', display: 'q2' },
|
|
132
|
+
{ sessionId: 'sid-other', project: '/Users/alice/proj', timestamp: 'T3', display: 'q3' },
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// 一个已死 PID 文件:进程不存在,但 sessions/<pid>.json 还在
|
|
136
|
+
const deadPid = 999999;
|
|
137
|
+
const pidFile = writePidFile(deadPid, s.sessionId);
|
|
138
|
+
|
|
139
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
140
|
+
|
|
141
|
+
expect(res.deleted).toHaveLength(1);
|
|
142
|
+
expect(res.skipped).toHaveLength(0);
|
|
143
|
+
expect(res.historyLinesRemoved).toBe(2);
|
|
144
|
+
|
|
145
|
+
expect(fs.existsSync(s.jsonlPath)).toBe(false);
|
|
146
|
+
expect(fs.existsSync(s.subdirPath)).toBe(false);
|
|
147
|
+
expect(fs.existsSync(s.fhPath)).toBe(false);
|
|
148
|
+
expect(fs.existsSync(s.sePath)).toBe(false);
|
|
149
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
150
|
+
|
|
151
|
+
// history.jsonl 的非目标行保留
|
|
152
|
+
const remaining = fs
|
|
153
|
+
.readFileSync(fakePaths.history, 'utf8')
|
|
154
|
+
.split(/\r?\n/)
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((l) => JSON.parse(l) as { sessionId: string });
|
|
157
|
+
expect(remaining).toHaveLength(1);
|
|
158
|
+
expect(remaining[0]!.sessionId).toBe('sid-other');
|
|
159
|
+
|
|
160
|
+
expect(res.deleted[0]!.cleaned).toEqual(
|
|
161
|
+
expect.arrayContaining([
|
|
162
|
+
'projects/<id>.jsonl',
|
|
163
|
+
'projects/<id>/',
|
|
164
|
+
'file-history/<id>/',
|
|
165
|
+
'session-env/<id>/',
|
|
166
|
+
]),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('history.jsonl 没有目标 sid 时不重写(atomic 三步不触发)', async () => {
|
|
171
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
172
|
+
const s = makeSession();
|
|
173
|
+
writeHistory([{ sessionId: 'sid-other', project: '/x', timestamp: 'T0', display: 'q' }]);
|
|
174
|
+
const before = fs.readFileSync(fakePaths.history, 'utf8');
|
|
175
|
+
|
|
176
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
177
|
+
expect(res.historyLinesRemoved).toBe(0);
|
|
178
|
+
expect(fs.readFileSync(fakePaths.history, 'utf8')).toBe(before);
|
|
179
|
+
|
|
180
|
+
// 没有遗留的 .tmp-clean
|
|
181
|
+
const stray = fs.readdirSync(fakeRoot).filter((n) => n.startsWith('history.jsonl.tmp-'));
|
|
182
|
+
expect(stray).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('deleteSessions:安全网', () => {
|
|
187
|
+
it('id 不合法(含 .. / 斜杠 / 点开头)直接 skip', async () => {
|
|
188
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
189
|
+
const res = await deleteSessions([
|
|
190
|
+
{ projectId: 'p', sessionId: '../escape' },
|
|
191
|
+
{ projectId: 'p', sessionId: '.hidden' },
|
|
192
|
+
]);
|
|
193
|
+
expect(res.deleted).toHaveLength(0);
|
|
194
|
+
expect(res.skipped).toHaveLength(2);
|
|
195
|
+
expect(res.skipped.every((s) => s.reason === 'invalid id')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('jsonl 在 5 分钟内被改过则跳过(可能仍在用)', async () => {
|
|
199
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
200
|
+
// mtime = now,落在 5 分钟窗口内
|
|
201
|
+
const s = makeSession({ mtimeMs: Date.now() });
|
|
202
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
203
|
+
expect(res.deleted).toHaveLength(0);
|
|
204
|
+
expect(res.skipped).toHaveLength(1);
|
|
205
|
+
expect(res.skipped[0]!.reason).toMatch(/within the last 5 minutes/);
|
|
206
|
+
// 文件仍在
|
|
207
|
+
expect(fs.existsSync(s.jsonlPath)).toBe(true);
|
|
208
|
+
expect(fs.existsSync(s.fhPath)).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('sessionId 出现在仍存活的 PID 文件中则跳过', async () => {
|
|
212
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
213
|
+
const s = makeSession();
|
|
214
|
+
// 用当前进程的 pid 当"活进程",process.kill(pid, 0) 必为 true
|
|
215
|
+
writePidFile(process.pid, s.sessionId);
|
|
216
|
+
|
|
217
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
218
|
+
expect(res.deleted).toHaveLength(0);
|
|
219
|
+
expect(res.skipped).toHaveLength(1);
|
|
220
|
+
expect(res.skipped[0]!.reason).toMatch(/live PID/);
|
|
221
|
+
expect(fs.existsSync(s.jsonlPath)).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('一批里混合 OK / live / recent,逐条独立判定', async () => {
|
|
225
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
226
|
+
const ok = makeSession({ sessionId: 'sid-ok' });
|
|
227
|
+
const liveSid = 'sid-live';
|
|
228
|
+
const live = makeSession({ sessionId: liveSid });
|
|
229
|
+
writePidFile(process.pid, liveSid);
|
|
230
|
+
const recent = makeSession({ sessionId: 'sid-recent', mtimeMs: Date.now() });
|
|
231
|
+
|
|
232
|
+
const res = await deleteSessions([
|
|
233
|
+
{ projectId: ok.projectId, sessionId: ok.sessionId },
|
|
234
|
+
{ projectId: live.projectId, sessionId: live.sessionId },
|
|
235
|
+
{ projectId: recent.projectId, sessionId: recent.sessionId },
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
expect(res.deleted.map((d) => d.sessionId)).toEqual(['sid-ok']);
|
|
239
|
+
expect(res.skipped.map((s) => s.sessionId).sort()).toEqual(['sid-live', 'sid-recent']);
|
|
240
|
+
expect(fs.existsSync(ok.jsonlPath)).toBe(false);
|
|
241
|
+
expect(fs.existsSync(live.jsonlPath)).toBe(true);
|
|
242
|
+
expect(fs.existsSync(recent.jsonlPath)).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
});
|