@modesto_ma/dsh-channels 0.2.0 → 0.4.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.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
package/src/cleanup.ts CHANGED
@@ -1,35 +1,44 @@
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.
30
+ *
31
+ * Scope boundary (attachment_pipeline_v2, decisions D6/D12): cleanup only
32
+ * removes workspace / Agent-artifact directories. Inbound attachments are not
33
+ * workspace files — the native backend keeps them in the DSH attachment
34
+ * library, and the legacy `_staging` adoption area is explicitly excluded so a
35
+ * pending one-time adoption can never be destroyed by a retention sweep.
28
36
  *
29
37
  * @module dsh-channels/cleanup
30
38
  */
31
39
 
32
40
  import path from 'node:path'
41
+ import { legacyTopicWorkspaceRoot, stagingRoot } from './workspaces.ts'
33
42
 
34
43
  /** Values the `workspaceRetention` config accepts. `off` disables cleanup. */
35
44
  export const WORKSPACE_RETENTION_VALUES = ['off', '7d', '30d', '90d', '180d'] as const
@@ -60,6 +69,14 @@ export function retentionMs(value: WorkspaceRetention): number | undefined {
60
69
  /** Title prefixes `workspaces.ts` gives plugin-created workspaces. */
61
70
  const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
62
71
 
72
+ /**
73
+ * Whether a title was produced by this plugin (as opposed to a project the
74
+ * operator registered by hand). Cleanup only ever touches plugin workspaces.
75
+ */
76
+ function pluginWorkspaceTitle(title: string): boolean {
77
+ return PLUGIN_TITLE_PREFIXES.some(prefix => title.startsWith(prefix))
78
+ }
79
+
63
80
  /**
64
81
  * Whether `target` is strictly below `baseDir`.
65
82
  *
@@ -67,8 +84,8 @@ const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
67
84
  * relative value cannot escape `baseDir`; the base directory itself and
68
85
  * siblings sharing a name prefix (e.g. `base-other`) are not "inside". The
69
86
  * 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.
87
+ * `removeWorkspace` / `removeSessionDir` must still refuse symlinked escapes
88
+ * with a realpath check before deleting.
72
89
  *
73
90
  * @param baseDir - directory the target must stay below.
74
91
  * @param target - candidate path.
@@ -83,9 +100,9 @@ export function isPathInside(baseDir: string, target: string): boolean {
83
100
  return relative !== '..' && !relative.startsWith(`..${path.sep}`)
84
101
  }
85
102
 
86
- /** Filesystem facts the caller measures for one workspace candidate. */
103
+ /** Filesystem facts the caller measures for one candidate directory. */
87
104
  export interface WorkspaceStats {
88
- /** Directory mtime in epoch ms — the activity fallback for orphan workspaces. */
105
+ /** Directory mtime in epoch ms — the activity fallback for orphan directories. */
89
106
  mtimeMs: number
90
107
  /** Total regular-file bytes below the directory. */
91
108
  bytes: number
@@ -93,7 +110,7 @@ export interface WorkspaceStats {
93
110
  files: number
94
111
  }
95
112
 
96
- /** Minimal workspace shape passed around by plan/apply (structurally `Workspace`). */
113
+ /** One registered host workspace (structurally `Workspace`). */
97
114
  export interface CleanupWorkspace {
98
115
  /** Host workspace-registry id (needed to unregister the record). */
99
116
  readonly id: string
@@ -109,8 +126,10 @@ export interface CleanupRoute {
109
126
  readonly key: string
110
127
  /** `thread` for a real Feishu topic, `chat` for a degraded chat route. */
111
128
  readonly kind: 'thread' | 'chat'
112
- /** Workspace directory this route's session works in. */
129
+ /** Workspace root this route's Session belongs to. */
113
130
  readonly workspacePath: string
131
+ /** Effective working directory (topic dir / worktree); the workspace path on legacy routes. */
132
+ readonly sessionDir?: string
114
133
  /** DSH session the route points at. */
115
134
  readonly sessionId: string
116
135
  /** Epoch ms of the last activity routed through the record. */
@@ -120,30 +139,50 @@ export interface CleanupRoute {
120
139
  /**
121
140
  * One retired-route tombstone.
122
141
  *
123
- * Older plugin versions stored only route identity, so `workspacePath` and
124
- * `lastActivityAt` stay optional and legacy tombstones remain parseable.
142
+ * Older plugin versions stored only route identity, so `workspacePath`,
143
+ * `sessionDir`, and `lastActivityAt` stay optional and legacy tombstones
144
+ * remain parseable.
125
145
  */
126
146
  export interface CleanupTombstone {
127
147
  /** Route key the tombstone replaced. */
128
148
  readonly key: string
129
149
  /** Workspace the retired route belonged to; absent in legacy tombstones. */
130
150
  readonly workspacePath?: string
151
+ /** Effective working directory of the retired route; absent in legacy tombstones. */
152
+ readonly sessionDir?: string
131
153
  /** Epoch ms when the route was retired. */
132
154
  readonly expiredAt: number
133
155
  /** Epoch ms of the last activity before retirement; absent in legacy tombstones. */
134
156
  readonly lastActivityAt?: number
135
157
  }
136
158
 
159
+ /**
160
+ * One per-topic directory discovered on disk below a live user workspace.
161
+ * Supplies the age evidence for directories whose route records are gone.
162
+ */
163
+ export interface ScannedSessionDir {
164
+ /** Absolute directory path. */
165
+ readonly path: string
166
+ /** Directory mtime in epoch ms. */
167
+ readonly mtimeMs: number
168
+ }
169
+
137
170
  /** A dependency seam that may be sync (registry tables) or async (filesystem). */
138
171
  export type MaybePromise<T> = T | Promise<T>
139
172
 
173
+ /** What one cleanup candidate removes. */
174
+ export type CleanupTarget =
175
+ | { readonly kind: 'workspace'; readonly workspace: CleanupWorkspace }
176
+ | { readonly kind: 'session-dir'; readonly path: string }
177
+
140
178
  /**
141
179
  * Every effect cleanup needs, injected so this module stays I/O-free.
142
180
  *
143
181
  * The service supplies the real implementations: the workspace registry for
144
182
  * `listWorkspaces`/`removeWorkspace`, the routing tables for
145
- * `activeRoutes`/`tombstones`, the live-agent set for `isLive`, and a
146
- * filesystem walk for `statWorkspace`.
183
+ * `activeRoutes`/`tombstones`, a scan of the live user workspaces for
184
+ * `listSessionDirs`, the live-agent set for `isLive`, and filesystem walks for
185
+ * `statWorkspace` plus the two removal operations.
147
186
  */
148
187
  export interface CleanupDeps {
149
188
  /** Workspace root the plugin owns; candidates must be strictly inside it. */
@@ -152,16 +191,20 @@ export interface CleanupDeps {
152
191
  readonly retention: WorkspaceRetention
153
192
  /** Every workspace currently registered with the host. */
154
193
  listWorkspaces(): MaybePromise<readonly CleanupWorkspace[]>
194
+ /** Per-topic directories discovered below the live user workspaces. */
195
+ listSessionDirs(): MaybePromise<readonly ScannedSessionDir[]>
155
196
  /** Every active route record. */
156
197
  activeRoutes(): MaybePromise<readonly CleanupRoute[]>
157
198
  /** Every retired-route tombstone. */
158
199
  tombstones(): MaybePromise<readonly CleanupTombstone[]>
159
- /** Whether a DSH session is still live; live workspaces are never offered. */
200
+ /** Whether a DSH session is still live; targets with live sessions are never offered. */
160
201
  isLive(sessionId: string): boolean
161
202
  /** 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. */
203
+ statWorkspace(directory: string): MaybePromise<WorkspaceStats | undefined>
204
+ /** Delete one legacy workspace directory and unregister its registry record. */
164
205
  removeWorkspace(workspace: CleanupWorkspace): MaybePromise<void>
206
+ /** Delete one per-topic session directory (no registry record exists). */
207
+ removeSessionDir(directory: string): MaybePromise<void>
165
208
  /** Retire one active route into a tombstone (same bookkeeping as route TTL). */
166
209
  retireRoute(route: CleanupRoute): MaybePromise<void>
167
210
  /** Clock seam; defaults to `Date.now()`. */
@@ -170,117 +213,114 @@ export interface CleanupDeps {
170
213
  warn?(message: string): void
171
214
  }
172
215
 
173
- /** One workspace old enough to delete, with its measured footprint. */
216
+ /** One directory old enough to delete, with its measured footprint. */
174
217
  export interface CleanupCandidate {
175
- /** Workspace to delete. */
176
- readonly workspace: CleanupWorkspace
177
- /** Newest activity observed for the workspace (epoch ms). */
218
+ /** What gets removed and how. */
219
+ readonly target: CleanupTarget
220
+ /** Directory path (also the unit of failure). */
221
+ readonly path: string
222
+ /** Newest activity observed for the target (epoch ms). */
178
223
  readonly activityAt: number
179
224
  /** Regular-file bytes below the directory (0 when unreadable). */
180
225
  readonly bytes: number
181
226
  /** Regular-file count below the directory (0 when unreadable). */
182
227
  readonly files: number
183
- /** Active routes still pointing at the workspace; retired before removal. */
228
+ /** Active routes this candidate's directory owns (retired before removal). */
184
229
  readonly routes: readonly CleanupRoute[]
185
230
  }
186
231
 
187
- /** Result of one dry-run planning pass. */
232
+ /** Result of one planning pass; nothing has been deleted yet. */
188
233
  export interface CleanupPlan {
189
- /** Retention window used for this plan, in milliseconds. */
234
+ /** Configured retention window in milliseconds. */
190
235
  readonly retentionMs: number
191
- /** Activity cutoff (epoch ms); workspaces at or after it were kept. */
236
+ /** Epoch ms cutoff: activity older than this is stale. */
192
237
  readonly cutoff: number
193
- /** Eligible workspaces, oldest activity first. */
238
+ /** Deletable candidates, oldest first. */
194
239
  readonly candidates: readonly CleanupCandidate[]
195
- /** Plugin workspaces kept because a live session still uses them. */
240
+ /** Targets skipped because a Session using them is still live. */
196
241
  readonly skippedLive: number
197
242
  }
198
243
 
199
- /** One failed removal; the pass continues with the remaining candidates. */
244
+ /** One failed removal (the run continues with the remaining candidates). */
200
245
  export interface CleanupFailure {
201
- /** Workspace directory the failure belongs to. */
246
+ /** Directory that could not be removed. */
202
247
  readonly path: string
203
248
  /** Human-readable reason. */
204
249
  readonly message: string
205
250
  }
206
251
 
207
- /** Result of one apply pass. */
252
+ /** Aggregate outcome of one apply pass. */
208
253
  export interface CleanupOutcome {
209
- /** Workspaces whose directory was removed and registry record deleted. */
254
+ /** Legacy registered workspaces whose directory and record were removed. */
210
255
  readonly deletedWorkspaces: number
211
- /** Active routes retired before their workspace was removed. */
256
+ /** Per-topic session directories removed. */
257
+ readonly deletedSessionDirs: number
258
+ /** Active routes retired into tombstones. */
212
259
  readonly retiredRoutes: number
213
- /** Measured bytes of the deleted workspaces. */
260
+ /** Regular-file bytes reclaimed (measured before deletion). */
214
261
  readonly freedBytes: number
215
- /** Per-workspace failures; other candidates were still processed. */
262
+ /** Per-candidate failures; a failing candidate does not abort the run. */
216
263
  readonly failures: readonly CleanupFailure[]
217
264
  }
218
265
 
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
- }
266
+ // ---- planning -----------------------------------------------------------
223
267
 
224
- /** Lexical path equality (both sides resolved the same way as `isPathInside`). */
268
+ /** Lexically compare two paths (both resolved) without touching the disk. */
225
269
  function samePath(left: string, right: string): boolean {
226
270
  return path.resolve(left) === path.resolve(right)
227
271
  }
228
272
 
229
- /** Newest finite timestamp of a list, or `undefined` when none is usable. */
273
+ /** Whether a path is a filesystem root (never deletable). */
274
+ function isFilesystemRoot(target: string): boolean {
275
+ const resolved = path.resolve(target)
276
+ return path.parse(resolved).root === resolved
277
+ }
278
+
279
+ /** Newest value of a list, or `undefined` when empty. */
230
280
  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
281
+ return values.length === 0 ? undefined : Math.max(...values)
237
282
  }
238
283
 
239
- /** Count/byte values never go negative or non-finite on the outcome path. */
240
284
  function nonNegative(value: number): number {
241
285
  return Number.isFinite(value) && value > 0 ? value : 0
242
286
  }
243
287
 
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
288
+ async function readWorkspaceStats(
289
+ deps: CleanupDeps,
290
+ directory: string,
291
+ ): Promise<WorkspaceStats | undefined> {
292
+ try {
293
+ return await deps.statWorkspace(directory)
294
+ } catch (error) {
295
+ deps.warn?.(`dsh-channels: cleanup stats unavailable for ${directory}: ${String(error)}`)
296
+ return undefined
297
+ }
252
298
  }
253
299
 
254
- /** Liveness probe that fails safe: an unknowable session counts as live. */
255
300
  function sessionIsLive(deps: CleanupDeps, sessionId: string): boolean {
256
301
  try {
257
302
  return deps.isLive(sessionId)
258
303
  } catch (error) {
259
- deps.warn?.(`dsh-channels: workspace cleanup liveness check failed for ${sessionId}: ${errorMessage(error)}`)
304
+ deps.warn?.(`dsh-channels: cleanup liveness probe failed for ${sessionId}: ${String(error)}`)
260
305
  return true
261
306
  }
262
307
  }
263
308
 
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
- }
309
+ function routesAreLive(deps: CleanupDeps, routes: readonly CleanupRoute[]): boolean {
310
+ return routes.some(route => sessionIsLive(deps, route.sessionId))
311
+ }
312
+
313
+ /** Effective directory of one route (workspace path on legacy routes). */
314
+ function directoryOfRoute(route: CleanupRoute): string {
315
+ return route.sessionDir ?? route.workspacePath
275
316
  }
276
317
 
277
318
  /**
278
- * Build the dry-run cleanup plan for the configured retention window.
319
+ * Decide which directories are stale, without deleting anything.
279
320
  *
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.
321
+ * Only plugin-created targets strictly below `baseDir` are considered. User
322
+ * workspaces are never candidates; legacy one-workspace-per-topic directories
323
+ * are candidates only below `<baseDir>/topics/`.
284
324
  *
285
325
  * @param deps - injected effect seams (nothing is deleted here).
286
326
  * @returns the plan, or `undefined` when retention is off or the base
@@ -292,47 +332,65 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
292
332
 
293
333
  const baseDir = path.resolve(deps.baseDir)
294
334
  if (isFilesystemRoot(baseDir)) {
295
- deps.warn?.('dsh-channels: workspace cleanup disabled: baseDir is a filesystem root')
335
+ deps.warn?.('dsh-channels: retention cleanup disabled: baseDir is a filesystem root')
296
336
  return undefined
297
337
  }
338
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
339
+ const stagingDir = stagingRoot(baseDir)
298
340
 
299
341
  const now = deps.now?.() ?? Date.now()
300
342
  const cutoff = now - window
301
343
  const activeRoutes = await deps.activeRoutes()
302
344
  const tombstones = await deps.tombstones()
345
+ const workspaces = await deps.listWorkspaces()
303
346
  const candidates: CleanupCandidate[] = []
304
347
  let skippedLive = 0
305
348
 
306
- for (const workspace of await deps.listWorkspaces()) {
349
+ // Paths of workspaces that are durable user projects (never deletable).
350
+ const userWorkspacePaths = new Set<string>()
351
+ for (const workspace of workspaces) {
352
+ const resolved = path.resolve(workspace.path)
307
353
  if (!pluginWorkspaceTitle(workspace.title)) continue
308
- if (!isPathInside(baseDir, workspace.path)) continue
354
+ if (!isPathInside(baseDir, resolved)) continue
355
+ if (isPathInside(legacyRoot, resolved)) continue
356
+ userWorkspacePaths.add(resolved)
357
+ }
309
358
 
310
- const routes = activeRoutes.filter(route => samePath(route.workspacePath, workspace.path))
311
- if (routes.some(route => sessionIsLive(deps, route.sessionId))) {
359
+ // 1) Legacy one-workspace-per-topic directories.
360
+ for (const workspace of workspaces) {
361
+ if (!pluginWorkspaceTitle(workspace.title)) continue
362
+ const targetPath = path.resolve(workspace.path)
363
+ if (!isPathInside(baseDir, targetPath)) continue
364
+ if (!isPathInside(legacyRoot, targetPath)) continue
365
+
366
+ const routes = activeRoutes.filter(route =>
367
+ samePath(route.workspacePath, targetPath)
368
+ && (route.sessionDir === undefined || samePath(route.sessionDir, targetPath)))
369
+ if (routesAreLive(deps, routes)) {
312
370
  skippedLive++
313
371
  continue
314
372
  }
315
-
316
373
  let stats: WorkspaceStats | undefined
317
374
  let activityAt = newestActivity(routes.map(route => route.lastActivityAt))
318
375
  if (activityAt === undefined) {
319
376
  activityAt = newestActivity(
320
377
  tombstones
321
378
  .filter(tombstone => tombstone.workspacePath !== undefined
322
- && samePath(tombstone.workspacePath, workspace.path))
379
+ && samePath(tombstone.workspacePath, targetPath)
380
+ && (tombstone.sessionDir === undefined || samePath(tombstone.sessionDir, targetPath)))
323
381
  .map(tombstone => tombstone.lastActivityAt ?? tombstone.expiredAt),
324
382
  )
325
383
  }
326
384
  if (activityAt === undefined) {
327
385
  // Orphan workspace: the directory's own mtime is the only age evidence.
328
- stats = await readWorkspaceStats(deps, workspace.path)
386
+ stats = await readWorkspaceStats(deps, targetPath)
329
387
  activityAt = stats !== undefined && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined
330
388
  }
331
389
  if (activityAt === undefined || activityAt >= cutoff) continue
332
-
333
- stats ??= await readWorkspaceStats(deps, workspace.path)
390
+ stats ??= await readWorkspaceStats(deps, targetPath)
334
391
  candidates.push({
335
- workspace,
392
+ target: { kind: 'workspace', workspace },
393
+ path: workspace.path,
336
394
  activityAt,
337
395
  bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
338
396
  files: stats === undefined ? 0 : nonNegative(stats.files),
@@ -340,25 +398,95 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
340
398
  })
341
399
  }
342
400
 
401
+ // 2) Per-topic session directories of the current layout: union of route
402
+ // records, tombstones, and directories discovered on disk.
403
+ interface DirEvidence {
404
+ routes: CleanupRoute[]
405
+ tombstoneActivity: number[]
406
+ mtimeMs?: number
407
+ }
408
+ const dirs = new Map<string, DirEvidence>()
409
+ const evidenceFor = (directory: string): DirEvidence => {
410
+ const key = path.resolve(directory)
411
+ let evidence = dirs.get(key)
412
+ if (evidence === undefined) {
413
+ evidence = { routes: [], tombstoneActivity: [] }
414
+ dirs.set(key, evidence)
415
+ }
416
+ return evidence
417
+ }
418
+ /** Whether a directory is a plausible per-topic dir (never a workspace root). */
419
+ const acceptable = (directory: string): boolean => {
420
+ const resolved = path.resolve(directory)
421
+ if (isFilesystemRoot(resolved)) return false
422
+ if (!isPathInside(baseDir, resolved)) return false
423
+ if (resolved === legacyRoot || isPathInside(legacyRoot, resolved)) return false
424
+ // Legacy pre-topic staging (one-time adoption area): never a cleanup target.
425
+ if (resolved === stagingDir || isPathInside(stagingDir, resolved)) return false
426
+ if (userWorkspacePaths.has(resolved)) return false
427
+ return true
428
+ }
429
+ for (const route of activeRoutes) {
430
+ if (route.sessionDir === undefined || samePath(route.sessionDir, route.workspacePath)) continue
431
+ if (!acceptable(route.sessionDir)) continue
432
+ evidenceFor(route.sessionDir).routes.push(route)
433
+ }
434
+ for (const tombstone of tombstones) {
435
+ if (tombstone.sessionDir === undefined) continue
436
+ if (tombstone.workspacePath !== undefined && samePath(tombstone.sessionDir, tombstone.workspacePath)) continue
437
+ if (!acceptable(tombstone.sessionDir)) continue
438
+ evidenceFor(tombstone.sessionDir).tombstoneActivity.push(
439
+ tombstone.lastActivityAt ?? tombstone.expiredAt,
440
+ )
441
+ }
442
+ for (const scanned of await deps.listSessionDirs()) {
443
+ if (!acceptable(scanned.path)) continue
444
+ evidenceFor(scanned.path).mtimeMs = scanned.mtimeMs
445
+ }
446
+
447
+ for (const [directory, evidence] of dirs) {
448
+ if (routesAreLive(deps, evidence.routes)) {
449
+ skippedLive++
450
+ continue
451
+ }
452
+ let activityAt = newestActivity(evidence.routes.map(route => route.lastActivityAt))
453
+ activityAt ??= newestActivity(evidence.tombstoneActivity)
454
+ activityAt ??= evidence.mtimeMs
455
+ if (activityAt === undefined || activityAt >= cutoff) continue
456
+ const stats = await readWorkspaceStats(deps, directory)
457
+ // Nothing left on disk and no active route to retire: nothing to do.
458
+ if (stats === undefined && evidence.routes.length === 0) continue
459
+ candidates.push({
460
+ target: { kind: 'session-dir', path: directory },
461
+ path: directory,
462
+ activityAt,
463
+ bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
464
+ files: stats === undefined ? 0 : nonNegative(stats.files),
465
+ routes: evidence.routes,
466
+ })
467
+ }
468
+
343
469
  candidates.sort((left, right) =>
344
- left.activityAt - right.activityAt || left.workspace.path.localeCompare(right.workspace.path))
470
+ left.activityAt - right.activityAt || left.path.localeCompare(right.path))
345
471
  return { retentionMs: window, cutoff, candidates, skippedLive }
346
472
  }
347
473
 
474
+ // ---- application --------------------------------------------------------
475
+
348
476
  /**
349
- * Apply a plan: retire the candidate's active routes, then remove the
350
- * workspace directory and its registry record.
477
+ * Apply a plan: retire the candidate's active routes, then remove its
478
+ * directory (and, for legacy workspaces, the registry record).
351
479
  *
352
480
  * 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
481
+ * directory is left untouched (route bookkeeping must not point at a deleted
354
482
  * directory), the failure is collected, and the remaining candidates are still
355
483
  * 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).
484
+ * a stale or hand-built plan can never delete `baseDir` itself, a user
485
+ * workspace, or something outside `baseDir`.
358
486
  *
359
487
  * @param deps - injected effect seams.
360
488
  * @param plan - plan produced by `planWorkspaceCleanup` (or an equivalent preview).
361
- * @returns aggregate counts plus per-workspace failures.
489
+ * @returns aggregate counts plus per-target failures.
362
490
  */
363
491
  export async function applyWorkspaceCleanup(
364
492
  deps: CleanupDeps,
@@ -366,33 +494,53 @@ export async function applyWorkspaceCleanup(
366
494
  ): Promise<CleanupOutcome> {
367
495
  const baseDir = path.resolve(deps.baseDir)
368
496
  const baseDirIsRoot = isFilesystemRoot(baseDir)
497
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
498
+ const stagingDir = stagingRoot(baseDir)
499
+ const registered = new Set(
500
+ (await deps.listWorkspaces())
501
+ .filter(workspace => pluginWorkspaceTitle(workspace.title))
502
+ .map(workspace => path.resolve(workspace.path)),
503
+ )
369
504
  const failures: CleanupFailure[] = []
370
505
  let deletedWorkspaces = 0
506
+ let deletedSessionDirs = 0
371
507
  let retiredRoutes = 0
372
508
  let freedBytes = 0
373
509
 
374
510
  for (const candidate of plan.candidates) {
375
- const target = candidate.workspace.path
511
+ const target = candidate.target
512
+ const targetPath = candidate.path
376
513
  // 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' })
514
+ const insideBase = !baseDirIsRoot && isPathInside(baseDir, targetPath)
515
+ const inLegacyRoot = isPathInside(legacyRoot, targetPath)
516
+ const inStaging = isPathInside(stagingDir, targetPath)
517
+ const valid = target.kind === 'workspace'
518
+ ? insideBase && inLegacyRoot
519
+ : insideBase && !inLegacyRoot && !inStaging && !registered.has(path.resolve(targetPath))
520
+ if (!valid) {
521
+ failures.push({ path: targetPath, message: 'refused: path is not a cleanable plugin directory' })
379
522
  continue
380
523
  }
381
524
  try {
382
525
  for (const route of candidate.routes) {
383
- if (!samePath(route.workspacePath, target)) continue
526
+ if (!samePath(directoryOfRoute(route), targetPath)) continue
384
527
  await deps.retireRoute(route)
385
528
  retiredRoutes++
386
529
  }
387
- await deps.removeWorkspace(candidate.workspace)
388
- deletedWorkspaces++
530
+ if (target.kind === 'workspace') {
531
+ await deps.removeWorkspace(target.workspace)
532
+ deletedWorkspaces++
533
+ } else {
534
+ await deps.removeSessionDir(targetPath)
535
+ deletedSessionDirs++
536
+ }
389
537
  freedBytes += nonNegative(candidate.bytes)
390
538
  } catch (error) {
391
- const message = errorMessage(error)
392
- failures.push({ path: target, message })
393
- deps.warn?.(`dsh-channels: workspace cleanup failed for ${target}: ${message}`)
539
+ const message = error instanceof Error ? error.message : String(error)
540
+ failures.push({ path: targetPath, message })
541
+ deps.warn?.(`dsh-channels: cleanup failed for ${targetPath}: ${message}`)
394
542
  }
395
543
  }
396
544
 
397
- return { deletedWorkspaces, retiredRoutes, freedBytes, failures }
545
+ return { deletedWorkspaces, deletedSessionDirs, retiredRoutes, freedBytes, failures }
398
546
  }
@@ -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