@modesto_ma/dsh-channels 0.2.0 → 0.3.0

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/src/cleanup.ts CHANGED
@@ -1,35 +1,38 @@
1
1
  /**
2
- * Stale workspace cleanup: pure planning and application policy.
2
+ * Retention cleanup policy: pure planning and application over injected seams.
3
3
  *
4
- * The plugin creates one host workspace per (user × chat) pair below its
5
- * configured `baseDir` (see `workspaces.ts`). The host never removes those
6
- * directories on its own, so a long-running deployment slowly accumulates
7
- * attachments and agent output for conversations nobody uses anymore. This
8
- * module decides which plugin-created workspaces are stale and applies the
9
- * removal while deliberately owning no I/O: every filesystem and registry
10
- * effect is injected through `CleanupDeps`, which keeps the policy small and
11
- * testable and keeps the dangerous half (recursive deletion + registry
12
- * unregistration) in the caller.
4
+ * Conversations produce files below two kinds of directories the host never
5
+ * removes on its own:
13
6
  *
14
- * Activity precedence for one workspace:
7
+ * 1. **Per-topic session directories** (current layout): the effective working
8
+ * directory of one Feishu topic — `<user-workspace>/topics/<token>` or a Git
9
+ * worktree checkout. They are not registered workspaces, so their activity
10
+ * comes from route records/tombstones or from the directory's own `mtime`.
11
+ * 2. **Legacy one-workspace-per-topic directories** written by older plugin
12
+ * versions (`<baseDir>/topics/...`). Those are still registered host
13
+ * workspaces and are unregistered when removed.
15
14
  *
16
- * 1. the newest `lastActivityAt` among its active routes; a workspace with a
17
- * live session is never offered for deletion,
18
- * 2. otherwise the newest tombstone that names the workspace
19
- * (`lastActivityAt`, falling back to `expiredAt` for tombstones written
20
- * before routes recorded their workspace path),
21
- * 3. otherwise the directory's own `mtime` (an orphan workspace).
15
+ * User workspaces themselves (the durable project each user/chat is grouped
16
+ * under) are never candidates. This module owns no I/O: every filesystem and
17
+ * registry effect is injected through `CleanupDeps`, so the policy stays small
18
+ * and testable while the dangerous half (recursive deletion + registry
19
+ * unregistration) stays in the caller.
22
20
  *
23
- * Tombstones written before this feature carry neither `workspacePath` nor
24
- * `lastActivityAt`, so they cannot be attributed to a workspace; such a
25
- * workspace falls through to the `mtime` rule instead. A workspace with no
26
- * usable activity evidence at all (missing or unreadable directory) is never
27
- * offered for deletion: irreversible removal requires positive evidence.
21
+ * Activity precedence:
22
+ *
23
+ * 1. the newest `lastActivityAt` among the active routes bound to the target;
24
+ * a target with a live Session is never offered for deletion,
25
+ * 2. otherwise the newest tombstone that names it,
26
+ * 3. otherwise the directory's own `mtime` (an orphan directory).
27
+ *
28
+ * A directory with no usable activity evidence at all (missing/unreadable) is
29
+ * never offered for deletion: irreversible removal requires positive evidence.
28
30
  *
29
31
  * @module dsh-channels/cleanup
30
32
  */
31
33
 
32
34
  import path from 'node:path'
35
+ import { legacyTopicWorkspaceRoot } from './workspaces.ts'
33
36
 
34
37
  /** Values the `workspaceRetention` config accepts. `off` disables cleanup. */
35
38
  export const WORKSPACE_RETENTION_VALUES = ['off', '7d', '30d', '90d', '180d'] as const
@@ -60,6 +63,14 @@ export function retentionMs(value: WorkspaceRetention): number | undefined {
60
63
  /** Title prefixes `workspaces.ts` gives plugin-created workspaces. */
61
64
  const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
62
65
 
66
+ /**
67
+ * Whether a title was produced by this plugin (as opposed to a project the
68
+ * operator registered by hand). Cleanup only ever touches plugin workspaces.
69
+ */
70
+ function pluginWorkspaceTitle(title: string): boolean {
71
+ return PLUGIN_TITLE_PREFIXES.some(prefix => title.startsWith(prefix))
72
+ }
73
+
63
74
  /**
64
75
  * Whether `target` is strictly below `baseDir`.
65
76
  *
@@ -67,8 +78,8 @@ const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
67
78
  * relative value cannot escape `baseDir`; the base directory itself and
68
79
  * siblings sharing a name prefix (e.g. `base-other`) are not "inside". The
69
80
  * check is lexical on purpose: this module owns no I/O. The caller's
70
- * `removeWorkspace` should still refuse symlinked escapes with a realpath
71
- * check before deleting.
81
+ * `removeWorkspace` / `removeSessionDir` must still refuse symlinked escapes
82
+ * with a realpath check before deleting.
72
83
  *
73
84
  * @param baseDir - directory the target must stay below.
74
85
  * @param target - candidate path.
@@ -83,9 +94,9 @@ export function isPathInside(baseDir: string, target: string): boolean {
83
94
  return relative !== '..' && !relative.startsWith(`..${path.sep}`)
84
95
  }
85
96
 
86
- /** Filesystem facts the caller measures for one workspace candidate. */
97
+ /** Filesystem facts the caller measures for one candidate directory. */
87
98
  export interface WorkspaceStats {
88
- /** Directory mtime in epoch ms — the activity fallback for orphan workspaces. */
99
+ /** Directory mtime in epoch ms — the activity fallback for orphan directories. */
89
100
  mtimeMs: number
90
101
  /** Total regular-file bytes below the directory. */
91
102
  bytes: number
@@ -93,7 +104,7 @@ export interface WorkspaceStats {
93
104
  files: number
94
105
  }
95
106
 
96
- /** Minimal workspace shape passed around by plan/apply (structurally `Workspace`). */
107
+ /** One registered host workspace (structurally `Workspace`). */
97
108
  export interface CleanupWorkspace {
98
109
  /** Host workspace-registry id (needed to unregister the record). */
99
110
  readonly id: string
@@ -109,8 +120,10 @@ export interface CleanupRoute {
109
120
  readonly key: string
110
121
  /** `thread` for a real Feishu topic, `chat` for a degraded chat route. */
111
122
  readonly kind: 'thread' | 'chat'
112
- /** Workspace directory this route's session works in. */
123
+ /** Workspace root this route's Session belongs to. */
113
124
  readonly workspacePath: string
125
+ /** Effective working directory (topic dir / worktree); the workspace path on legacy routes. */
126
+ readonly sessionDir?: string
114
127
  /** DSH session the route points at. */
115
128
  readonly sessionId: string
116
129
  /** Epoch ms of the last activity routed through the record. */
@@ -120,30 +133,50 @@ export interface CleanupRoute {
120
133
  /**
121
134
  * One retired-route tombstone.
122
135
  *
123
- * Older plugin versions stored only route identity, so `workspacePath` and
124
- * `lastActivityAt` stay optional and legacy tombstones remain parseable.
136
+ * Older plugin versions stored only route identity, so `workspacePath`,
137
+ * `sessionDir`, and `lastActivityAt` stay optional and legacy tombstones
138
+ * remain parseable.
125
139
  */
126
140
  export interface CleanupTombstone {
127
141
  /** Route key the tombstone replaced. */
128
142
  readonly key: string
129
143
  /** Workspace the retired route belonged to; absent in legacy tombstones. */
130
144
  readonly workspacePath?: string
145
+ /** Effective working directory of the retired route; absent in legacy tombstones. */
146
+ readonly sessionDir?: string
131
147
  /** Epoch ms when the route was retired. */
132
148
  readonly expiredAt: number
133
149
  /** Epoch ms of the last activity before retirement; absent in legacy tombstones. */
134
150
  readonly lastActivityAt?: number
135
151
  }
136
152
 
153
+ /**
154
+ * One per-topic directory discovered on disk below a live user workspace.
155
+ * Supplies the age evidence for directories whose route records are gone.
156
+ */
157
+ export interface ScannedSessionDir {
158
+ /** Absolute directory path. */
159
+ readonly path: string
160
+ /** Directory mtime in epoch ms. */
161
+ readonly mtimeMs: number
162
+ }
163
+
137
164
  /** A dependency seam that may be sync (registry tables) or async (filesystem). */
138
165
  export type MaybePromise<T> = T | Promise<T>
139
166
 
167
+ /** What one cleanup candidate removes. */
168
+ export type CleanupTarget =
169
+ | { readonly kind: 'workspace'; readonly workspace: CleanupWorkspace }
170
+ | { readonly kind: 'session-dir'; readonly path: string }
171
+
140
172
  /**
141
173
  * Every effect cleanup needs, injected so this module stays I/O-free.
142
174
  *
143
175
  * The service supplies the real implementations: the workspace registry for
144
176
  * `listWorkspaces`/`removeWorkspace`, the routing tables for
145
- * `activeRoutes`/`tombstones`, the live-agent set for `isLive`, and a
146
- * filesystem walk for `statWorkspace`.
177
+ * `activeRoutes`/`tombstones`, a scan of the live user workspaces for
178
+ * `listSessionDirs`, the live-agent set for `isLive`, and filesystem walks for
179
+ * `statWorkspace` plus the two removal operations.
147
180
  */
148
181
  export interface CleanupDeps {
149
182
  /** Workspace root the plugin owns; candidates must be strictly inside it. */
@@ -152,16 +185,20 @@ export interface CleanupDeps {
152
185
  readonly retention: WorkspaceRetention
153
186
  /** Every workspace currently registered with the host. */
154
187
  listWorkspaces(): MaybePromise<readonly CleanupWorkspace[]>
188
+ /** Per-topic directories discovered below the live user workspaces. */
189
+ listSessionDirs(): MaybePromise<readonly ScannedSessionDir[]>
155
190
  /** Every active route record. */
156
191
  activeRoutes(): MaybePromise<readonly CleanupRoute[]>
157
192
  /** Every retired-route tombstone. */
158
193
  tombstones(): MaybePromise<readonly CleanupTombstone[]>
159
- /** Whether a DSH session is still live; live workspaces are never offered. */
194
+ /** Whether a DSH session is still live; targets with live sessions are never offered. */
160
195
  isLive(sessionId: string): boolean
161
196
  /** Measure one directory; `undefined` when it cannot be read (missing, permission). */
162
- statWorkspace(workspacePath: string): MaybePromise<WorkspaceStats | undefined>
163
- /** Delete one workspace directory and unregister its registry record. */
197
+ statWorkspace(directory: string): MaybePromise<WorkspaceStats | undefined>
198
+ /** Delete one legacy workspace directory and unregister its registry record. */
164
199
  removeWorkspace(workspace: CleanupWorkspace): MaybePromise<void>
200
+ /** Delete one per-topic session directory (no registry record exists). */
201
+ removeSessionDir(directory: string): MaybePromise<void>
165
202
  /** Retire one active route into a tombstone (same bookkeeping as route TTL). */
166
203
  retireRoute(route: CleanupRoute): MaybePromise<void>
167
204
  /** Clock seam; defaults to `Date.now()`. */
@@ -170,117 +207,114 @@ export interface CleanupDeps {
170
207
  warn?(message: string): void
171
208
  }
172
209
 
173
- /** One workspace old enough to delete, with its measured footprint. */
210
+ /** One directory old enough to delete, with its measured footprint. */
174
211
  export interface CleanupCandidate {
175
- /** Workspace to delete. */
176
- readonly workspace: CleanupWorkspace
177
- /** Newest activity observed for the workspace (epoch ms). */
212
+ /** What gets removed and how. */
213
+ readonly target: CleanupTarget
214
+ /** Directory path (also the unit of failure). */
215
+ readonly path: string
216
+ /** Newest activity observed for the target (epoch ms). */
178
217
  readonly activityAt: number
179
218
  /** Regular-file bytes below the directory (0 when unreadable). */
180
219
  readonly bytes: number
181
220
  /** Regular-file count below the directory (0 when unreadable). */
182
221
  readonly files: number
183
- /** Active routes still pointing at the workspace; retired before removal. */
222
+ /** Active routes this candidate's directory owns (retired before removal). */
184
223
  readonly routes: readonly CleanupRoute[]
185
224
  }
186
225
 
187
- /** Result of one dry-run planning pass. */
226
+ /** Result of one planning pass; nothing has been deleted yet. */
188
227
  export interface CleanupPlan {
189
- /** Retention window used for this plan, in milliseconds. */
228
+ /** Configured retention window in milliseconds. */
190
229
  readonly retentionMs: number
191
- /** Activity cutoff (epoch ms); workspaces at or after it were kept. */
230
+ /** Epoch ms cutoff: activity older than this is stale. */
192
231
  readonly cutoff: number
193
- /** Eligible workspaces, oldest activity first. */
232
+ /** Deletable candidates, oldest first. */
194
233
  readonly candidates: readonly CleanupCandidate[]
195
- /** Plugin workspaces kept because a live session still uses them. */
234
+ /** Targets skipped because a Session using them is still live. */
196
235
  readonly skippedLive: number
197
236
  }
198
237
 
199
- /** One failed removal; the pass continues with the remaining candidates. */
238
+ /** One failed removal (the run continues with the remaining candidates). */
200
239
  export interface CleanupFailure {
201
- /** Workspace directory the failure belongs to. */
240
+ /** Directory that could not be removed. */
202
241
  readonly path: string
203
242
  /** Human-readable reason. */
204
243
  readonly message: string
205
244
  }
206
245
 
207
- /** Result of one apply pass. */
246
+ /** Aggregate outcome of one apply pass. */
208
247
  export interface CleanupOutcome {
209
- /** Workspaces whose directory was removed and registry record deleted. */
248
+ /** Legacy registered workspaces whose directory and record were removed. */
210
249
  readonly deletedWorkspaces: number
211
- /** Active routes retired before their workspace was removed. */
250
+ /** Per-topic session directories removed. */
251
+ readonly deletedSessionDirs: number
252
+ /** Active routes retired into tombstones. */
212
253
  readonly retiredRoutes: number
213
- /** Measured bytes of the deleted workspaces. */
254
+ /** Regular-file bytes reclaimed (measured before deletion). */
214
255
  readonly freedBytes: number
215
- /** Per-workspace failures; other candidates were still processed. */
256
+ /** Per-candidate failures; a failing candidate does not abort the run. */
216
257
  readonly failures: readonly CleanupFailure[]
217
258
  }
218
259
 
219
- /** Whether a host workspace title marks the workspace as plugin-created. */
220
- function pluginWorkspaceTitle(title: string): boolean {
221
- return PLUGIN_TITLE_PREFIXES.some(prefix => title.startsWith(prefix))
222
- }
260
+ // ---- planning -----------------------------------------------------------
223
261
 
224
- /** Lexical path equality (both sides resolved the same way as `isPathInside`). */
262
+ /** Lexically compare two paths (both resolved) without touching the disk. */
225
263
  function samePath(left: string, right: string): boolean {
226
264
  return path.resolve(left) === path.resolve(right)
227
265
  }
228
266
 
229
- /** Newest finite timestamp of a list, or `undefined` when none is usable. */
267
+ /** Whether a path is a filesystem root (never deletable). */
268
+ function isFilesystemRoot(target: string): boolean {
269
+ const resolved = path.resolve(target)
270
+ return path.parse(resolved).root === resolved
271
+ }
272
+
273
+ /** Newest value of a list, or `undefined` when empty. */
230
274
  function newestActivity(values: readonly number[]): number | undefined {
231
- let newest: number | undefined
232
- for (const value of values) {
233
- if (!Number.isFinite(value)) continue
234
- if (newest === undefined || value > newest) newest = value
235
- }
236
- return newest
275
+ return values.length === 0 ? undefined : Math.max(...values)
237
276
  }
238
277
 
239
- /** Count/byte values never go negative or non-finite on the outcome path. */
240
278
  function nonNegative(value: number): number {
241
279
  return Number.isFinite(value) && value > 0 ? value : 0
242
280
  }
243
281
 
244
- /** Best-effort error text (never a stack trace). */
245
- function errorMessage(error: unknown): string {
246
- return error instanceof Error ? error.message : String(error)
247
- }
248
-
249
- /** A filesystem root must never become the cleanup base directory. */
250
- function isFilesystemRoot(dir: string): boolean {
251
- return path.parse(dir).root === dir
282
+ async function readWorkspaceStats(
283
+ deps: CleanupDeps,
284
+ directory: string,
285
+ ): Promise<WorkspaceStats | undefined> {
286
+ try {
287
+ return await deps.statWorkspace(directory)
288
+ } catch (error) {
289
+ deps.warn?.(`dsh-channels: cleanup stats unavailable for ${directory}: ${String(error)}`)
290
+ return undefined
291
+ }
252
292
  }
253
293
 
254
- /** Liveness probe that fails safe: an unknowable session counts as live. */
255
294
  function sessionIsLive(deps: CleanupDeps, sessionId: string): boolean {
256
295
  try {
257
296
  return deps.isLive(sessionId)
258
297
  } catch (error) {
259
- deps.warn?.(`dsh-channels: workspace cleanup liveness check failed for ${sessionId}: ${errorMessage(error)}`)
298
+ deps.warn?.(`dsh-channels: cleanup liveness probe failed for ${sessionId}: ${String(error)}`)
260
299
  return true
261
300
  }
262
301
  }
263
302
 
264
- /** Measure one directory, tolerating a throwing or absent implementation. */
265
- async function readWorkspaceStats(
266
- deps: CleanupDeps,
267
- workspacePath: string,
268
- ): Promise<WorkspaceStats | undefined> {
269
- try {
270
- return await deps.statWorkspace(workspacePath)
271
- } catch (error) {
272
- deps.warn?.(`dsh-channels: workspace stats unavailable for ${workspacePath}: ${errorMessage(error)}`)
273
- return undefined
274
- }
303
+ function routesAreLive(deps: CleanupDeps, routes: readonly CleanupRoute[]): boolean {
304
+ return routes.some(route => sessionIsLive(deps, route.sessionId))
305
+ }
306
+
307
+ /** Effective directory of one route (workspace path on legacy routes). */
308
+ function directoryOfRoute(route: CleanupRoute): string {
309
+ return route.sessionDir ?? route.workspacePath
275
310
  }
276
311
 
277
312
  /**
278
- * Build the dry-run cleanup plan for the configured retention window.
313
+ * Decide which directories are stale, without deleting anything.
279
314
  *
280
- * Only plugin-created workspaces (`飞书私聊 ·` / `飞书群聊 ·` titles) strictly
281
- * below `baseDir` are considered. A workspace is a candidate when its newest
282
- * activity is older than the cutoff; its footprint is measured for the panel
283
- * preview and for `freedBytes` reporting.
315
+ * Only plugin-created targets strictly below `baseDir` are considered. User
316
+ * workspaces are never candidates; legacy one-workspace-per-topic directories
317
+ * are candidates only below `<baseDir>/topics/`.
284
318
  *
285
319
  * @param deps - injected effect seams (nothing is deleted here).
286
320
  * @returns the plan, or `undefined` when retention is off or the base
@@ -292,47 +326,64 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
292
326
 
293
327
  const baseDir = path.resolve(deps.baseDir)
294
328
  if (isFilesystemRoot(baseDir)) {
295
- deps.warn?.('dsh-channels: workspace cleanup disabled: baseDir is a filesystem root')
329
+ deps.warn?.('dsh-channels: retention cleanup disabled: baseDir is a filesystem root')
296
330
  return undefined
297
331
  }
332
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
298
333
 
299
334
  const now = deps.now?.() ?? Date.now()
300
335
  const cutoff = now - window
301
336
  const activeRoutes = await deps.activeRoutes()
302
337
  const tombstones = await deps.tombstones()
338
+ const workspaces = await deps.listWorkspaces()
303
339
  const candidates: CleanupCandidate[] = []
304
340
  let skippedLive = 0
305
341
 
306
- for (const workspace of await deps.listWorkspaces()) {
342
+ // Paths of workspaces that are durable user projects (never deletable).
343
+ const userWorkspacePaths = new Set<string>()
344
+ for (const workspace of workspaces) {
345
+ const resolved = path.resolve(workspace.path)
307
346
  if (!pluginWorkspaceTitle(workspace.title)) continue
308
- if (!isPathInside(baseDir, workspace.path)) continue
347
+ if (!isPathInside(baseDir, resolved)) continue
348
+ if (isPathInside(legacyRoot, resolved)) continue
349
+ userWorkspacePaths.add(resolved)
350
+ }
309
351
 
310
- const routes = activeRoutes.filter(route => samePath(route.workspacePath, workspace.path))
311
- if (routes.some(route => sessionIsLive(deps, route.sessionId))) {
352
+ // 1) Legacy one-workspace-per-topic directories.
353
+ for (const workspace of workspaces) {
354
+ if (!pluginWorkspaceTitle(workspace.title)) continue
355
+ const targetPath = path.resolve(workspace.path)
356
+ if (!isPathInside(baseDir, targetPath)) continue
357
+ if (!isPathInside(legacyRoot, targetPath)) continue
358
+
359
+ const routes = activeRoutes.filter(route =>
360
+ samePath(route.workspacePath, targetPath)
361
+ && (route.sessionDir === undefined || samePath(route.sessionDir, targetPath)))
362
+ if (routesAreLive(deps, routes)) {
312
363
  skippedLive++
313
364
  continue
314
365
  }
315
-
316
366
  let stats: WorkspaceStats | undefined
317
367
  let activityAt = newestActivity(routes.map(route => route.lastActivityAt))
318
368
  if (activityAt === undefined) {
319
369
  activityAt = newestActivity(
320
370
  tombstones
321
371
  .filter(tombstone => tombstone.workspacePath !== undefined
322
- && samePath(tombstone.workspacePath, workspace.path))
372
+ && samePath(tombstone.workspacePath, targetPath)
373
+ && (tombstone.sessionDir === undefined || samePath(tombstone.sessionDir, targetPath)))
323
374
  .map(tombstone => tombstone.lastActivityAt ?? tombstone.expiredAt),
324
375
  )
325
376
  }
326
377
  if (activityAt === undefined) {
327
378
  // Orphan workspace: the directory's own mtime is the only age evidence.
328
- stats = await readWorkspaceStats(deps, workspace.path)
379
+ stats = await readWorkspaceStats(deps, targetPath)
329
380
  activityAt = stats !== undefined && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined
330
381
  }
331
382
  if (activityAt === undefined || activityAt >= cutoff) continue
332
-
333
- stats ??= await readWorkspaceStats(deps, workspace.path)
383
+ stats ??= await readWorkspaceStats(deps, targetPath)
334
384
  candidates.push({
335
- workspace,
385
+ target: { kind: 'workspace', workspace },
386
+ path: workspace.path,
336
387
  activityAt,
337
388
  bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
338
389
  files: stats === undefined ? 0 : nonNegative(stats.files),
@@ -340,25 +391,93 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
340
391
  })
341
392
  }
342
393
 
394
+ // 2) Per-topic session directories of the current layout: union of route
395
+ // records, tombstones, and directories discovered on disk.
396
+ interface DirEvidence {
397
+ routes: CleanupRoute[]
398
+ tombstoneActivity: number[]
399
+ mtimeMs?: number
400
+ }
401
+ const dirs = new Map<string, DirEvidence>()
402
+ const evidenceFor = (directory: string): DirEvidence => {
403
+ const key = path.resolve(directory)
404
+ let evidence = dirs.get(key)
405
+ if (evidence === undefined) {
406
+ evidence = { routes: [], tombstoneActivity: [] }
407
+ dirs.set(key, evidence)
408
+ }
409
+ return evidence
410
+ }
411
+ /** Whether a directory is a plausible per-topic dir (never a workspace root). */
412
+ const acceptable = (directory: string): boolean => {
413
+ const resolved = path.resolve(directory)
414
+ if (isFilesystemRoot(resolved)) return false
415
+ if (!isPathInside(baseDir, resolved)) return false
416
+ if (resolved === legacyRoot || isPathInside(legacyRoot, resolved)) return false
417
+ if (userWorkspacePaths.has(resolved)) return false
418
+ return true
419
+ }
420
+ for (const route of activeRoutes) {
421
+ if (route.sessionDir === undefined || samePath(route.sessionDir, route.workspacePath)) continue
422
+ if (!acceptable(route.sessionDir)) continue
423
+ evidenceFor(route.sessionDir).routes.push(route)
424
+ }
425
+ for (const tombstone of tombstones) {
426
+ if (tombstone.sessionDir === undefined) continue
427
+ if (tombstone.workspacePath !== undefined && samePath(tombstone.sessionDir, tombstone.workspacePath)) continue
428
+ if (!acceptable(tombstone.sessionDir)) continue
429
+ evidenceFor(tombstone.sessionDir).tombstoneActivity.push(
430
+ tombstone.lastActivityAt ?? tombstone.expiredAt,
431
+ )
432
+ }
433
+ for (const scanned of await deps.listSessionDirs()) {
434
+ if (!acceptable(scanned.path)) continue
435
+ evidenceFor(scanned.path).mtimeMs = scanned.mtimeMs
436
+ }
437
+
438
+ for (const [directory, evidence] of dirs) {
439
+ if (routesAreLive(deps, evidence.routes)) {
440
+ skippedLive++
441
+ continue
442
+ }
443
+ let activityAt = newestActivity(evidence.routes.map(route => route.lastActivityAt))
444
+ activityAt ??= newestActivity(evidence.tombstoneActivity)
445
+ activityAt ??= evidence.mtimeMs
446
+ if (activityAt === undefined || activityAt >= cutoff) continue
447
+ const stats = await readWorkspaceStats(deps, directory)
448
+ // Nothing left on disk and no active route to retire: nothing to do.
449
+ if (stats === undefined && evidence.routes.length === 0) continue
450
+ candidates.push({
451
+ target: { kind: 'session-dir', path: directory },
452
+ path: directory,
453
+ activityAt,
454
+ bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
455
+ files: stats === undefined ? 0 : nonNegative(stats.files),
456
+ routes: evidence.routes,
457
+ })
458
+ }
459
+
343
460
  candidates.sort((left, right) =>
344
- left.activityAt - right.activityAt || left.workspace.path.localeCompare(right.workspace.path))
461
+ left.activityAt - right.activityAt || left.path.localeCompare(right.path))
345
462
  return { retentionMs: window, cutoff, candidates, skippedLive }
346
463
  }
347
464
 
465
+ // ---- application --------------------------------------------------------
466
+
348
467
  /**
349
- * Apply a plan: retire the candidate's active routes, then remove the
350
- * workspace directory and its registry record.
468
+ * Apply a plan: retire the candidate's active routes, then remove its
469
+ * directory (and, for legacy workspaces, the registry record).
351
470
  *
352
471
  * A candidate is the unit of failure: if retiring any of its routes fails, the
353
- * workspace is left untouched (route bookkeeping must not point at a deleted
472
+ * directory is left untouched (route bookkeeping must not point at a deleted
354
473
  * directory), the failure is collected, and the remaining candidates are still
355
474
  * processed. Paths are revalidated immediately before the destructive step, so
356
- * a stale or hand-built plan can never delete something outside `baseDir` (or
357
- * `baseDir` itself).
475
+ * a stale or hand-built plan can never delete `baseDir` itself, a user
476
+ * workspace, or something outside `baseDir`.
358
477
  *
359
478
  * @param deps - injected effect seams.
360
479
  * @param plan - plan produced by `planWorkspaceCleanup` (or an equivalent preview).
361
- * @returns aggregate counts plus per-workspace failures.
480
+ * @returns aggregate counts plus per-target failures.
362
481
  */
363
482
  export async function applyWorkspaceCleanup(
364
483
  deps: CleanupDeps,
@@ -366,33 +485,51 @@ export async function applyWorkspaceCleanup(
366
485
  ): Promise<CleanupOutcome> {
367
486
  const baseDir = path.resolve(deps.baseDir)
368
487
  const baseDirIsRoot = isFilesystemRoot(baseDir)
488
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
489
+ const registered = new Set(
490
+ (await deps.listWorkspaces())
491
+ .filter(workspace => pluginWorkspaceTitle(workspace.title))
492
+ .map(workspace => path.resolve(workspace.path)),
493
+ )
369
494
  const failures: CleanupFailure[] = []
370
495
  let deletedWorkspaces = 0
496
+ let deletedSessionDirs = 0
371
497
  let retiredRoutes = 0
372
498
  let freedBytes = 0
373
499
 
374
500
  for (const candidate of plan.candidates) {
375
- const target = candidate.workspace.path
501
+ const target = candidate.target
502
+ const targetPath = candidate.path
376
503
  // Defense in depth: never trust a plan object for the destructive step.
377
- if (baseDirIsRoot || !isPathInside(baseDir, target)) {
378
- failures.push({ path: target, message: 'refused: workspace path is outside the cleanup base directory' })
504
+ const insideBase = !baseDirIsRoot && isPathInside(baseDir, targetPath)
505
+ const inLegacyRoot = isPathInside(legacyRoot, targetPath)
506
+ const valid = target.kind === 'workspace'
507
+ ? insideBase && inLegacyRoot
508
+ : insideBase && !inLegacyRoot && !registered.has(path.resolve(targetPath))
509
+ if (!valid) {
510
+ failures.push({ path: targetPath, message: 'refused: path is not a cleanable plugin directory' })
379
511
  continue
380
512
  }
381
513
  try {
382
514
  for (const route of candidate.routes) {
383
- if (!samePath(route.workspacePath, target)) continue
515
+ if (!samePath(directoryOfRoute(route), targetPath)) continue
384
516
  await deps.retireRoute(route)
385
517
  retiredRoutes++
386
518
  }
387
- await deps.removeWorkspace(candidate.workspace)
388
- deletedWorkspaces++
519
+ if (target.kind === 'workspace') {
520
+ await deps.removeWorkspace(target.workspace)
521
+ deletedWorkspaces++
522
+ } else {
523
+ await deps.removeSessionDir(targetPath)
524
+ deletedSessionDirs++
525
+ }
389
526
  freedBytes += nonNegative(candidate.bytes)
390
527
  } catch (error) {
391
- const message = errorMessage(error)
392
- failures.push({ path: target, message })
393
- deps.warn?.(`dsh-channels: workspace cleanup failed for ${target}: ${message}`)
528
+ const message = error instanceof Error ? error.message : String(error)
529
+ failures.push({ path: targetPath, message })
530
+ deps.warn?.(`dsh-channels: cleanup failed for ${targetPath}: ${message}`)
394
531
  }
395
532
  }
396
533
 
397
- return { deletedWorkspaces, retiredRoutes, freedBytes, failures }
534
+ return { deletedWorkspaces, deletedSessionDirs, retiredRoutes, freedBytes, failures }
398
535
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 工作区清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
2
+ * 目录清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
3
3
  * 执行前二次确认,结果与部分失败逐条展示。`cleanup.error` 非空时
4
4
  * (例如未连接、无法扫描工作区)按钮天然禁用并显示原因。
5
5
  *
@@ -34,7 +34,8 @@ export function CleanupCard(props: {
34
34
  const facts = [
35
35
  { label: PANEL_COPY.cleanupRetention, value: retentionLabel(panel, cleanup) },
36
36
  { label: PANEL_COPY.cleanupAutoLabel, value: autoText },
37
- { label: PANEL_COPY.cleanupCandidates, value: `${cleanup.candidates} / ${cleanup.workspaces} 个` },
37
+ { label: PANEL_COPY.cleanupCandidates, value: `${cleanup.candidates} 个` },
38
+ { label: PANEL_COPY.cleanupWorkspaces, value: `${cleanup.workspaces} 个` },
38
39
  { label: PANEL_COPY.cleanupCandidateBytes, value: formatBytes(cleanup.candidateBytes) },
39
40
  ]
40
41
  const running = busy === 'cleanup'
@@ -63,6 +64,7 @@ export function CleanupCard(props: {
63
64
  ? (
64
65
  <span>
65
66
  {PANEL_COPY.cleanupDone}:{PANEL_COPY.cleanupDeleted} {result.deletedWorkspaces} 个,
67
+ {PANEL_COPY.cleanupDeletedDirs} {result.deletedSessionDirs} 个,
66
68
  {PANEL_COPY.cleanupRetired} {result.retiredRoutes} 条,
67
69
  {PANEL_COPY.cleanupFreed} {formatBytes(result.freedBytes)}。
68
70
  </span>
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 工作区清理卡。
2
+ * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 目录清理卡。
3
3
  * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
4
4
  *
5
5
  * @module dsh-channels/client/panel/workspace-tab