cc-viewer 1.9.1 → 1.9.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/dist/assets/App-DJ4yNvwz.css +1 -0
- package/dist/assets/App-DPRn6oGn.js +2 -0
- package/dist/assets/{MdxEditorPanel-DjwOQDh0.js → MdxEditorPanel-Ci4ldhd7.js} +1 -1
- package/dist/assets/{Mobile-BD-hefIH.js → Mobile-Dq-KcO1s.js} +1 -1
- package/dist/assets/{ProxyStatsModal-pYa_0QVL.js → ProxyStatsModal-CUFpqt1s.js} +1 -1
- package/dist/assets/index-BLzca-ta.css +1 -0
- package/dist/assets/{index-DkIhbLjr.js → index-Ci38xZiX.js} +2 -2
- package/dist/assets/seqResourceLoaders-B0p7e_lM.js +2 -0
- package/dist/assets/seqResourceLoaders-BsMFNfvR.css +41 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/lib/v2/layout.js +25 -0
- package/server/pty-manager.js +11 -2
- package/server/routes/resume.js +160 -5
- package/server/routes/workspaces.js +8 -2
- package/server/workspace-registry.js +109 -40
- package/dist/assets/App-DM-fbhNd.js +0 -2
- package/dist/assets/App-DOZI5HME.css +0 -1
- package/dist/assets/index-Dz36p_bA.css +0 -1
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +0 -41
- package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +0 -2
package/server/pty-manager.js
CHANGED
|
@@ -1060,8 +1060,13 @@ export function killPty() {
|
|
|
1060
1060
|
* same dead-state a natural exit produces). Records whose process already
|
|
1061
1061
|
* exited are never re-anchor targets.
|
|
1062
1062
|
*
|
|
1063
|
+
* The killed record's persistent `cwd` is captured BEFORE the kill and
|
|
1064
|
+
* returned so the close route can tell "the BOUND project was closed" apart
|
|
1065
|
+
* from "a parallel project was closed" by exact-cwd comparison against
|
|
1066
|
+
* CCV_PROJECT_DIR (never by basename — same-name twins would false-hit).
|
|
1067
|
+
*
|
|
1063
1068
|
* @returns {{ ok:boolean, key?:string, killedActive?:boolean,
|
|
1064
|
-
* reattachedTo?:string|null, reason?:string }}
|
|
1069
|
+
* reattachedTo?:string|null, cwd?:string, reason?:string }}
|
|
1065
1070
|
*/
|
|
1066
1071
|
export function killPtyFor({ cwd, project, instanceKey } = {}) {
|
|
1067
1072
|
// Multi-instance forced disambiguation (2026-10-06): a project-name-only close that matches
|
|
@@ -1076,6 +1081,10 @@ export function killPtyFor({ cwd, project, instanceKey } = {}) {
|
|
|
1076
1081
|
if (!key) return { ok: false, reason: 'not-found' };
|
|
1077
1082
|
const s = ptys.get(key);
|
|
1078
1083
|
if (!s) return { ok: false, reason: 'not-found' };
|
|
1084
|
+
// Capture the record's ORIGINAL spawn cwd before the kill (never realpath'd —
|
|
1085
|
+
// both sides of the comparison stay in the same path space; a realpath would
|
|
1086
|
+
// split /tmp vs /private/tmp and miss the bound-cwd match).
|
|
1087
|
+
const killedCwd = s.cwd || s.currentWorkspacePath || s.lastWorkspacePath || '';
|
|
1079
1088
|
_killPtyRecord(s);
|
|
1080
1089
|
const killedActive = key === activePtyKey;
|
|
1081
1090
|
let reattachedTo = null;
|
|
@@ -1085,7 +1094,7 @@ export function killPtyFor({ cwd, project, instanceKey } = {}) {
|
|
|
1085
1094
|
}
|
|
1086
1095
|
if (reattachedTo) activePtyKey = reattachedTo;
|
|
1087
1096
|
}
|
|
1088
|
-
return { ok: true, key, killedActive, reattachedTo };
|
|
1097
|
+
return { ok: true, key, killedActive, reattachedTo, cwd: killedCwd };
|
|
1089
1098
|
}
|
|
1090
1099
|
|
|
1091
1100
|
// Kill EVERY main PTY across all projects (workspaces stop / process teardown). The no-arg
|
package/server/routes/resume.js
CHANGED
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
// POST /api/live-processes/close { project }
|
|
18
18
|
// Kill that project's main PTY (the web header tab's × button). Admin-only
|
|
19
19
|
// (loopback or authenticated remote admin); 403 otherwise, 404 when the
|
|
20
|
-
// project has no record. The record is kept for a later re-launch.
|
|
20
|
+
// project has no record. The record is kept for a later re-launch. When the
|
|
21
|
+
// killed project WAS the bound one and a survivor is still live, the server
|
|
22
|
+
// re-binds its workspace identity (_projectName + CCV_PROJECT_DIR) to that
|
|
23
|
+
// survivor (a same-name/same-cwd twin degrades this to a dir-only rebind),
|
|
24
|
+
// broadcasts workspace_started(rebound:true) so every client lands on the
|
|
25
|
+
// survivor, and the response carries `rebound: { project, cwd }`.
|
|
21
26
|
//
|
|
22
27
|
// POST /api/resume-session { sessionUuid, project?, instanceKey? }
|
|
23
28
|
// TRUE /resume (2026-10-06): inject `/resume <uuid>` into the target
|
|
@@ -34,11 +39,18 @@
|
|
|
34
39
|
// this handler is a thin HTTP wrapper.
|
|
35
40
|
|
|
36
41
|
import { LOG_DIR } from '../../findcc.js';
|
|
37
|
-
import { _projectName, _v2Writer } from '../interceptor.js';
|
|
42
|
+
import { _projectName, _v2Writer, initForWorkspace } from '../interceptor.js';
|
|
43
|
+
// Static import (not the route-local dynamic import) so the close-rebind helper
|
|
44
|
+
// can call listLivePtys synchronously — a static import keeps the single shared
|
|
45
|
+
// module instance, and module state is shared either way.
|
|
46
|
+
import { listLivePtys } from '../pty-manager.js';
|
|
38
47
|
import { listResumeSessions } from '../lib/v2/resume-list.js';
|
|
39
48
|
import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
|
|
49
|
+
import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
50
|
+
import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
|
|
40
51
|
import { isAdminReq } from '../lib/is-admin.js';
|
|
41
52
|
import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
|
|
53
|
+
import { sseWrite } from '../lib/wire-compress.js';
|
|
42
54
|
import { reportSwallowed } from '@ccv/core/error-report';
|
|
43
55
|
|
|
44
56
|
function sendJson(res, code, obj) {
|
|
@@ -166,8 +178,11 @@ function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
166
178
|
// possible with a mere view token). 404 when the project has no record. When
|
|
167
179
|
// the killed PTY was the terminal's active attachment, the server re-anchors
|
|
168
180
|
// the attachment to another live record internally (killedActive in the
|
|
169
|
-
// response)
|
|
170
|
-
//
|
|
181
|
+
// response). When the killed project WAS the bound one and a survivor is still
|
|
182
|
+
// live, the server ALSO re-binds its workspace identity (_projectName +
|
|
183
|
+
// CCV_PROJECT_DIR) to that survivor and broadcasts workspace_started(rebound:true)
|
|
184
|
+
// so every client lands on the survivor (the "当前项目" label would otherwise
|
|
185
|
+
// stay on the just-closed project); the response then carries `rebound`.
|
|
171
186
|
function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
172
187
|
// Two gates, both required: admin identity (loopback or authenticated remote
|
|
173
188
|
// admin) AND same-origin browser context. The server intentionally answers
|
|
@@ -210,7 +225,18 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
210
225
|
sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
|
|
211
226
|
return;
|
|
212
227
|
}
|
|
213
|
-
|
|
228
|
+
// Bound-project rebound (2026-10): when the closed PTY WAS the server's bound
|
|
229
|
+
// project, the binding (projectName / CCV_PROJECT_DIR / log dir) would otherwise
|
|
230
|
+
// keep pointing at a dead project — the "当前项目" label stays on the just-closed
|
|
231
|
+
// name while the survivor is left as a mere chip. Rebind to a survivor and
|
|
232
|
+
// broadcast workspace_started so every client (including the closing tab) lands
|
|
233
|
+
// on the survivor. Judgment is by the killed record's ORIGINAL cwd vs
|
|
234
|
+
// CCV_PROJECT_DIR — never by basename, or a same-name twin close would false-hit.
|
|
235
|
+
let rebound = null;
|
|
236
|
+
try {
|
|
237
|
+
rebound = _maybeRebindAfterClose(r, deps);
|
|
238
|
+
} catch (err) { reportSwallowed('resume-route.close-rebind', err); }
|
|
239
|
+
sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
|
|
214
240
|
} catch (err) {
|
|
215
241
|
reportSwallowed('resume-route.close', err);
|
|
216
242
|
sendJson(res, 500, { ok: false, reason: 'close-failed' });
|
|
@@ -218,6 +244,135 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
|
|
|
218
244
|
});
|
|
219
245
|
}
|
|
220
246
|
|
|
247
|
+
// Bound-project rebound helper (2026-10). After killPtyFor removed the BOUND
|
|
248
|
+
// project's PTY, pick a surviving live claude record and re-bind the server's
|
|
249
|
+
// workspace identity to it. A full rebind additionally broadcasts
|
|
250
|
+
// workspace_started (with `rebound:true` / `reboundFrom`) so the affected SSE
|
|
251
|
+
// clients re-scope to the survivor; a DIR-ONLY rebind (a same-name twin owns
|
|
252
|
+
// the name's session space) moves CCV_PROJECT_DIR without broadcasting.
|
|
253
|
+
// Returns { project, cwd } when a rebind happened, else null (closed project
|
|
254
|
+
// was not the bound one / no survivor / a same-cwd twin still lives / Electron
|
|
255
|
+
// multi-tab).
|
|
256
|
+
// May throw only BEFORE the rebind is applied (a pre-state-change failure →
|
|
257
|
+
// the caller wraps it into a 200 with rebound:null, which is accurate then);
|
|
258
|
+
// after the state change it never throws (the restart side-effects are each
|
|
259
|
+
// wrapped), so a returned rebound always matches reality.
|
|
260
|
+
//
|
|
261
|
+
// ORDERING INVARIANT: the broadcast (full rebind only) is emitted BEFORE the
|
|
262
|
+
// best-effort feed/timer restarts, and the whole kill→rebind→broadcast block
|
|
263
|
+
// is synchronous (no await) — under Node's single thread no interleaved
|
|
264
|
+
// request can observe a half-applied rebind, and a throwing side-effect can no
|
|
265
|
+
// longer strand the "binding moved but nobody was told" state (the broadcast
|
|
266
|
+
// has already gone out by then).
|
|
267
|
+
function _maybeRebindAfterClose(killResult, deps) {
|
|
268
|
+
// Electron multi-tab: the manager process does NOT own log init (the tab
|
|
269
|
+
// workers do); mirroring workspaces.js:56, skip rebind entirely there.
|
|
270
|
+
if (process.env.CCV_ELECTRON_MULTITAB === '1') return null;
|
|
271
|
+
const killedCwd = typeof killResult.cwd === 'string' ? killResult.cwd : '';
|
|
272
|
+
const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
273
|
+
// Exact-cwd equality, no realpath — both sides stay in the same path space
|
|
274
|
+
// (a realpath would split /tmp vs /private/tmp and miss the hit).
|
|
275
|
+
if (!killedCwd || killedCwd !== boundCwd) return null;
|
|
276
|
+
|
|
277
|
+
// Pick the survivor: a still-live CLAUDE record (listLivePtys also returns
|
|
278
|
+
// 'shell' scratch PTYs, which /api/live-processes itself filters out of its
|
|
279
|
+
// own processes[]), preferring the record killPtyFor just re-anchored the
|
|
280
|
+
// terminal attachment to (reattachedTo), then the terminal's live attachment
|
|
281
|
+
// (isActive — preserves the launch-time invariant bound == attached), else
|
|
282
|
+
// the first live claude record in Map order.
|
|
283
|
+
const live = listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
|
|
284
|
+
if (!live.length) return null;
|
|
285
|
+
// A same-cwd TWIN of the closed bound project still lives → the bound
|
|
286
|
+
// identity (same project name, same log dir) is already correct for it;
|
|
287
|
+
// rebinding away would only wipe that twin's session bindings for nothing.
|
|
288
|
+
if (live.some((p) => p.cwd === killedCwd)) return null;
|
|
289
|
+
const survivor = live.find((p) => p.key === killResult.reattachedTo)
|
|
290
|
+
|| live.find((p) => p.isActive)
|
|
291
|
+
|| live[0];
|
|
292
|
+
if (!survivor || !survivor.cwd) return null;
|
|
293
|
+
|
|
294
|
+
const killedName = projectKeyForCwd(killedCwd);
|
|
295
|
+
const survivorProject = projectKeyForCwd(survivor.cwd);
|
|
296
|
+
// initForWorkspace's scoped resetSessions keys on the SANITIZED project name
|
|
297
|
+
// (v2-writer.js). So any time the CLOSED project's name still has a live
|
|
298
|
+
// record (a same-name twin in another dir, or the survivor itself is a
|
|
299
|
+
// same-name twin), running initForWorkspace would wipe that LIVE project's
|
|
300
|
+
// session bindings. In every such case the project NAME (and thus the log
|
|
301
|
+
// dir) does not change — so do a DIR-ONLY rebind instead: point
|
|
302
|
+
// CCV_PROJECT_DIR at the survivor (file-tree/git/skills/allowlist roots follow
|
|
303
|
+
// the live project) while skipping initForWorkspace entirely.
|
|
304
|
+
const closedNameStillLive = live.some((p) => projectKeyForCwd(p.cwd) === killedName);
|
|
305
|
+
const survivorIsSameName = survivorProject === killedName;
|
|
306
|
+
const dirOnly = closedNameStillLive || survivorIsSameName;
|
|
307
|
+
|
|
308
|
+
let result;
|
|
309
|
+
if (dirOnly) {
|
|
310
|
+
// _projectName / _logDir stay (same sanitized name — already correct);
|
|
311
|
+
// only the bound ROOT moves to the surviving twin's directory. No broadcast
|
|
312
|
+
// either (see below): the name — and with it every client's SSE scope stamp
|
|
313
|
+
// — is unchanged, so there is nothing to re-scope.
|
|
314
|
+
result = { projectName: killedName };
|
|
315
|
+
} else {
|
|
316
|
+
result = initForWorkspace(survivor.cwd);
|
|
317
|
+
}
|
|
318
|
+
process.env.CCV_PROJECT_DIR = survivor.cwd;
|
|
319
|
+
// The file-access allowlist caches its roots (including the bound project
|
|
320
|
+
// dir) and is only invalidated on register/remove or LOG_DIR change — none of
|
|
321
|
+
// which fire on a close-rebind. Bump it so the survivor's dir is an allowed
|
|
322
|
+
// root even when it never went through this server's registerWorkspace.
|
|
323
|
+
try { bumpWorkspacesVersion(); } catch (err) { reportSwallowed('resume-route.close-rebind.roots', err); }
|
|
324
|
+
|
|
325
|
+
// Broadcast workspace_started to EVERY SSE client (same fan-out as launch,
|
|
326
|
+
// workspaces.js:101-107) BEFORE the throwable side-effects below, so a
|
|
327
|
+
// failure there can't leave "binding moved but clients untold" — but ONLY for
|
|
328
|
+
// a full rebind. A dir-only rebind keeps the project NAME — and with it every
|
|
329
|
+
// client's SSE scope stamp — moving only the bound ROOT to the survivor's dir,
|
|
330
|
+
// so there is nothing for any client to re-scope: a broadcast would needlessly
|
|
331
|
+
// hard-reset every same-name viewer mid-stream for a root-only change.
|
|
332
|
+
// Deliberately NO load_start/load_chunk/load_end replay: those handlers have
|
|
333
|
+
// no project guard on the client (AppBase.jsx:1990-2148) and would pour the
|
|
334
|
+
// survivor's transcript into a tab that is viewing a THIRD project. Each
|
|
335
|
+
// AFFECTED client re-scopes and cold-loads its own view on receipt (the
|
|
336
|
+
// `rebound:true` marker + its own local view state decide; see the
|
|
337
|
+
// workspace_started handler in AppBase.jsx).
|
|
338
|
+
if (!dirOnly) {
|
|
339
|
+
const payload = `event: workspace_started\ndata: ${JSON.stringify({
|
|
340
|
+
projectName: result.projectName,
|
|
341
|
+
path: survivor.cwd,
|
|
342
|
+
claudeProjectModel: readClaudeProjectModel(survivor.cwd),
|
|
343
|
+
rebound: true,
|
|
344
|
+
// The project name this client's SSE scope was stamped with before the
|
|
345
|
+
// rebind. Clients whose SSE (or an attach/parallel view) is still pinned
|
|
346
|
+
// to it are the affected domain and must reset + reconnect; a tab viewing
|
|
347
|
+
// an unrelated project must keep its view and its live feed.
|
|
348
|
+
reboundFrom: killedName,
|
|
349
|
+
})}\n\n`;
|
|
350
|
+
const clients = (deps && deps.clients) || [];
|
|
351
|
+
clients.forEach((client) => {
|
|
352
|
+
try { sseWrite(client, payload); } catch (err) { reportSwallowed('resume-route.close-rebind.write', err); }
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Best-effort feed/timer restarts for the new bound project. Without
|
|
357
|
+
// startLogWatch the survivor's writer activity hits "no feed → drop" and the
|
|
358
|
+
// real-time pane never updates until a manual refresh; statsWorker/
|
|
359
|
+
// streamingStatusTimer mirror workspaces.js:71-75. All deps.* are
|
|
360
|
+
// optional-chained so unit tests need only stub what they assert on. Each is
|
|
361
|
+
// individually wrapped AND runs AFTER the broadcast: if one throws it is
|
|
362
|
+
// reported and swallowed, never turning the (already-applied) rebind into a
|
|
363
|
+
// misleading rebound:null on the 200 response.
|
|
364
|
+
if (deps && typeof deps.startLogWatch === 'function') {
|
|
365
|
+
try { deps.startLogWatch(); } catch (err) { reportSwallowed('resume-route.close-rebind.logwatch', err); }
|
|
366
|
+
}
|
|
367
|
+
if (deps && !deps.statsWorker && typeof deps.startStatsWorker === 'function') {
|
|
368
|
+
try { deps.startStatsWorker(); } catch (err) { reportSwallowed('resume-route.close-rebind.stats', err); }
|
|
369
|
+
}
|
|
370
|
+
if (deps && typeof deps.startStreamingStatusTimer === 'function') {
|
|
371
|
+
try { deps.startStreamingStatusTimer(); } catch (err) { reportSwallowed('resume-route.close-rebind.stream', err); }
|
|
372
|
+
}
|
|
373
|
+
return { project: result.projectName, cwd: survivor.cwd };
|
|
374
|
+
}
|
|
375
|
+
|
|
221
376
|
const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
222
377
|
|
|
223
378
|
// POST /api/resume-session { sessionUuid, project?, instanceKey? } — TRUE /resume:
|
|
@@ -21,9 +21,15 @@ import { sseWrite } from '../lib/wire-compress.js';
|
|
|
21
21
|
|
|
22
22
|
function workspacesList(req, res, parsedUrl, isLocal, deps) {
|
|
23
23
|
import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
|
|
24
|
-
|
|
24
|
+
// ?limit=N(2026-10-08):parsedUrl 是 WHATWG URL,只有 .searchParams 没有 .query。
|
|
25
|
+
// 先排序后富化,只算前 N 个的 dirSize/isDiscardable。默认 0(全部),上限 50
|
|
26
|
+
// 与 resume.js 的对齐。total 是富化前总数,预留给将来「按需加载」的前端。
|
|
27
|
+
const rawLimit = parsedUrl?.searchParams?.get('limit');
|
|
28
|
+
const parsedLimit = rawLimit != null ? Number(rawLimit) : 0;
|
|
29
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(Math.floor(parsedLimit), 50) : 0;
|
|
30
|
+
const { workspaces, total } = await getWorkspaces({ limit });
|
|
25
31
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
26
|
-
res.end(JSON.stringify({ workspaces, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
|
|
32
|
+
res.end(JSON.stringify({ workspaces, total, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
|
|
27
33
|
}).catch(err => {
|
|
28
34
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
29
35
|
res.end(JSON.stringify({ error: err.message }));
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// Workspace Registry - 工作区持久化管理
|
|
2
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
3
3
|
import { statSync } from 'node:fs';
|
|
4
4
|
import { mutateJson, readJsonSafe, writeJsonAtomic } from './lib/json-store.js';
|
|
5
|
-
import {
|
|
5
|
+
import { dirSizeAsync } from './lib/v2/layout.js';
|
|
6
6
|
import { isDiscardableSession } from './lib/v2/session-select.js';
|
|
7
7
|
import { join, basename, resolve, dirname } from 'node:path';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
9
|
import { LOG_DIR } from '../findcc.js';
|
|
10
10
|
|
|
11
|
+
// 与 stats-worker.js 的磁盘缓存对齐(2026-10-08):getWorkspaces 富化时优先读
|
|
12
|
+
// LOG_DIR/<project>/<project>.json 的 files["sessions/<sid>"],以 journalSize+lastModified
|
|
13
|
+
// 作新鲜度 key;命中即跳过 dirSizeSync。版本不一致或 key 缺失则回退到实时遍历。
|
|
14
|
+
// 版本号必须与 stats-worker.js 的 STATS_VERSION 保持一致,否则旧缓存被错误信任。
|
|
15
|
+
const STATS_CACHE_VERSION = 12;
|
|
16
|
+
|
|
11
17
|
// 动态获取(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
12
18
|
function getWorkspacesFile() { return join(LOG_DIR, 'workspaces.json'); }
|
|
13
19
|
|
|
@@ -97,45 +103,108 @@ export function saveWorkspaces(list) {
|
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
export async function getWorkspaces() {
|
|
106
|
+
export async function getWorkspaces(opts = {}) {
|
|
107
|
+
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : null;
|
|
101
108
|
const list = loadWorkspaces();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
109
|
+
// 先按 lastUsed 降序(几乎免费,只是 8.5 KB JSON 的内存排序),再切片、再富化。
|
|
110
|
+
// 不调换顺序的话:全量富化 40 个工作区 ≈ 3.3s;先切片后富化,limit=5 ≈ 200ms。
|
|
111
|
+
const sorted = list.slice().sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
|
|
112
|
+
const total = sorted.length;
|
|
113
|
+
const toEnrich = limit ? sorted.slice(0, limit) : sorted;
|
|
114
|
+
// 一次性读出涉及项目的 stats 磁盘缓存(每个项目一次读盘,富化期全部命中内存)。
|
|
115
|
+
// Map + Promise.all 的 has/set 不防重:同一 projectName 的多个 entry 在 await 之前都通过 has 检查,
|
|
116
|
+
// 导致重复读盘。先 Set 去重再读。
|
|
117
|
+
const uniqueProjects = [...new Set(toEnrich.map((w) => w.projectName))];
|
|
118
|
+
const cacheByProject = new Map();
|
|
119
|
+
await Promise.all(uniqueProjects.map(async (projectName) => {
|
|
120
|
+
cacheByProject.set(projectName, await _readStatsCache(projectName));
|
|
121
|
+
}));
|
|
122
|
+
const enriched = await Promise.all(toEnrich.map((w) => _enrichWorkspace(w, cacheByProject.get(w.projectName))));
|
|
123
|
+
return { workspaces: enriched, total };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 读一个工作区的 stats 磁盘缓存(若存在且版本匹配)。
|
|
128
|
+
* 缓存 schema:LOG_DIR/<project>/<project>.json 的 files["sessions/<sid>"] = {
|
|
129
|
+
* size, journalSize, lastModified, ...
|
|
130
|
+
* },以 (journalSize, lastModified) 作新鲜度 key —— session 文件一变动,journal.jsonl
|
|
131
|
+
* 的 size/mtime 就变,缓存自然 miss 走实时遍历。
|
|
132
|
+
* 返回 files 对象;失败/版本不匹配返回 null(调用方整体回退到实时遍历)。
|
|
133
|
+
*/
|
|
134
|
+
async function _readStatsCache(projectName) {
|
|
135
|
+
// 防御:projectName 来自 workspaces.json(用户可手改),registerWorkspace 写入时只保证
|
|
136
|
+
// basename 级别消毒。若 workspaces.json 被改成包含 '/' / '\\' / '..' 的 projectName,
|
|
137
|
+
// 这里的 join 会越出 LOG_DIR。命中即拒,回退到实时遍历(性能降级但安全)。
|
|
138
|
+
if (typeof projectName !== 'string'
|
|
139
|
+
|| projectName.includes('/')
|
|
140
|
+
|| projectName.includes('\\')
|
|
141
|
+
|| projectName.includes('..')) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const statsFile = join(LOG_DIR, projectName, `${projectName}.json`);
|
|
146
|
+
const raw = await readFile(statsFile, 'utf-8');
|
|
147
|
+
const parsed = JSON.parse(raw);
|
|
148
|
+
if (!parsed || parsed._v !== STATS_CACHE_VERSION || !parsed.files || typeof parsed.files !== 'object') return null;
|
|
149
|
+
return parsed.files;
|
|
150
|
+
} catch {
|
|
151
|
+
return null; // 缓存不存在/损坏都算 miss,调用方走实时路径
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 富化单个工作区的日志统计。命中 stats 缓存的 session 直接用缓存的 size;
|
|
157
|
+
* 未命中的 session 用 dirSizeAsync 异步递归(不阻塞事件循环,SSE/PTY 不再被冻结)。
|
|
158
|
+
* isDiscardableSession 仍是同步调用 —— 它只读 meta.json + 扫 journal 头几行,
|
|
159
|
+
* 远比 dirSizeSync 便宜;完全异步化留作后续。
|
|
160
|
+
*/
|
|
161
|
+
async function _enrichWorkspace(w, statsCache) {
|
|
162
|
+
// wire-v2 (1.7.0): logs live in per-session dirs under sessions/. The v1
|
|
163
|
+
// *.jsonl glob is kept as unmigratedV1Count — the combined logCount must
|
|
164
|
+
// stay non-zero for a declined-migration workspace, because the launcher's
|
|
165
|
+
// `logCount > 0 → auto -c` heuristic hangs off it (WorkspaceList.jsx).
|
|
166
|
+
let sessionCount = 0;
|
|
167
|
+
let unmigratedV1Count = 0;
|
|
168
|
+
let totalSize = 0;
|
|
169
|
+
const logDir = join(LOG_DIR, w.projectName);
|
|
170
|
+
try {
|
|
171
|
+
const files = await readdir(logDir);
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
if (f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl')) {
|
|
174
|
+
unmigratedV1Count++;
|
|
175
|
+
try { totalSize += (await stat(join(logDir, f))).size; } catch { }
|
|
118
176
|
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
177
|
+
}
|
|
178
|
+
} catch { }
|
|
179
|
+
try {
|
|
180
|
+
const sids = await readdir(join(logDir, 'sessions'), { withFileTypes: true });
|
|
181
|
+
for (const e of sids) {
|
|
182
|
+
if (!e.isDirectory()) continue;
|
|
183
|
+
const sessionDir = join(logDir, 'sessions', e.name);
|
|
184
|
+
const unitKey = `sessions/${e.name}`;
|
|
185
|
+
try {
|
|
186
|
+
const journalStat = await stat(join(sessionDir, 'journal.jsonl'));
|
|
187
|
+
// 缓存命中条件与 stats-worker 相同:journalSize + lastModified 双 key。
|
|
188
|
+
// 命中即非 discardable(stats-worker v11+ 的缓存条目写入前已过滤 probe-only session),
|
|
189
|
+
// 所以命中路径可以跳过 isDiscardableSession 的同步读 —— 省 ~200ms(实测 1787 session 的
|
|
190
|
+
// 热路径成本)。未命中再走原顺序:先判 probe,再递归 dirSizeAsync。
|
|
191
|
+
const cached = statsCache?.[unitKey];
|
|
192
|
+
const lastModified = journalStat.mtime.toISOString();
|
|
193
|
+
if (cached && cached.journalSize === journalStat.size && cached.lastModified === lastModified && typeof cached.size === 'number') {
|
|
194
|
+
totalSize += cached.size;
|
|
134
195
|
sessionCount++;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// Quota-probe orphans must not count: logCount>0 drives the
|
|
199
|
+
// auto -c heuristic, and a probe-only workspace would auto-continue
|
|
200
|
+
// into a conversation that does not exist (2026-07-16).
|
|
201
|
+
if (isDiscardableSession(sessionDir)) continue;
|
|
202
|
+
// Folder size, not journal size — conv/blob/response files carry
|
|
203
|
+
// most of a session's bytes (journal alone undercounts ~12x).
|
|
204
|
+
totalSize += await dirSizeAsync(sessionDir);
|
|
205
|
+
sessionCount++;
|
|
206
|
+
} catch { /* dir without a journal is not a session yet */ }
|
|
207
|
+
}
|
|
208
|
+
} catch { }
|
|
209
|
+
return { ...w, logCount: sessionCount + unmigratedV1Count, sessionCount, unmigratedV1Count, totalSize };
|
|
141
210
|
}
|