@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.1

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 (58) hide show
  1. package/README.md +73 -55
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +33 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +173 -287
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +3001 -2627
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +12 -12
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +115 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/client/api.ts +5 -25
  26. package/src/client/panel/access-card.tsx +24 -15
  27. package/src/client/panel/access-tab.tsx +3 -3
  28. package/src/client/panel/connection-tab.tsx +70 -1
  29. package/src/client/panel/index.tsx +11 -62
  30. package/src/client/panel/shared.tsx +0 -23
  31. package/src/client/panel/workspace-tab.tsx +2 -18
  32. package/src/client/styles.ts +2 -0
  33. package/src/commands/registry.ts +2 -2
  34. package/src/config.ts +25 -56
  35. package/src/domain.ts +28 -125
  36. package/src/feishu/inbound-attachments.ts +1 -1
  37. package/src/feishu/manifest.ts +8 -31
  38. package/src/feishu/message-read.ts +19 -22
  39. package/src/feishu/scopes.ts +171 -0
  40. package/src/feishu/send-file.ts +543 -0
  41. package/src/feishu/slash-commands.ts +134 -0
  42. package/src/feishu/tools.ts +72 -112
  43. package/src/feishu-channel.ts +270 -9
  44. package/src/index.ts +5 -4
  45. package/src/interactions/approvals.ts +187 -7
  46. package/src/interactions/pending.ts +3 -3
  47. package/src/routing/inbound.ts +73 -106
  48. package/src/service.ts +463 -438
  49. package/src/session/manager.ts +1 -1
  50. package/src/session/routes.ts +2 -3
  51. package/src/settings/contract.ts +68 -85
  52. package/src/settings/routes.ts +3 -17
  53. package/src/settings/surface.ts +37 -88
  54. package/src/streamer.ts +94 -5
  55. package/src/workspaces.ts +21 -29
  56. package/src/cleanup.ts +0 -546
  57. package/src/client/panel/cleanup-card.tsx +0 -106
  58. package/src/feishu/pending-attachments.ts +0 -562
package/src/cleanup.ts DELETED
@@ -1,546 +0,0 @@
1
- /**
2
- * Retention cleanup policy: pure planning and application over injected seams.
3
- *
4
- * Conversations produce files below two kinds of directories the host never
5
- * removes on its own:
6
- *
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.
14
- *
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.
20
- *
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.
36
- *
37
- * @module dsh-channels/cleanup
38
- */
39
-
40
- import path from 'node:path'
41
- import { legacyTopicWorkspaceRoot, stagingRoot } from './workspaces.ts'
42
-
43
- /** Values the `workspaceRetention` config accepts. `off` disables cleanup. */
44
- export const WORKSPACE_RETENTION_VALUES = ['off', '7d', '30d', '90d', '180d'] as const
45
-
46
- /** One workspace-retention setting. */
47
- export type WorkspaceRetention = (typeof WORKSPACE_RETENTION_VALUES)[number]
48
-
49
- const DAY_MS = 24 * 60 * 60 * 1000
50
-
51
- /** Days represented by every non-`off` retention value. */
52
- const RETENTION_DAYS: Readonly<Record<Exclude<WorkspaceRetention, 'off'>, number>> = {
53
- '7d': 7,
54
- '30d': 30,
55
- '90d': 90,
56
- '180d': 180,
57
- }
58
-
59
- /**
60
- * Convert a retention setting to its window in milliseconds.
61
- *
62
- * @param value - configured retention value.
63
- * @returns the window in milliseconds, or `undefined` when cleanup is off.
64
- */
65
- export function retentionMs(value: WorkspaceRetention): number | undefined {
66
- return value === 'off' ? undefined : RETENTION_DAYS[value] * DAY_MS
67
- }
68
-
69
- /** Title prefixes `workspaces.ts` gives plugin-created workspaces. */
70
- const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
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
-
80
- /**
81
- * Whether `target` is strictly below `baseDir`.
82
- *
83
- * Both sides are normalized lexically (`path.resolve`), so a `..` segment or a
84
- * relative value cannot escape `baseDir`; the base directory itself and
85
- * siblings sharing a name prefix (e.g. `base-other`) are not "inside". The
86
- * check is lexical on purpose: this module owns no I/O. The caller's
87
- * `removeWorkspace` / `removeSessionDir` must still refuse symlinked escapes
88
- * with a realpath check before deleting.
89
- *
90
- * @param baseDir - directory the target must stay below.
91
- * @param target - candidate path.
92
- * @returns `true` when `target` is strictly inside `baseDir`.
93
- */
94
- export function isPathInside(baseDir: string, target: string): boolean {
95
- const base = path.resolve(baseDir)
96
- const resolved = path.resolve(target)
97
- if (base === resolved) return false
98
- const relative = path.relative(base, resolved)
99
- if (relative === '' || path.isAbsolute(relative)) return false
100
- return relative !== '..' && !relative.startsWith(`..${path.sep}`)
101
- }
102
-
103
- /** Filesystem facts the caller measures for one candidate directory. */
104
- export interface WorkspaceStats {
105
- /** Directory mtime in epoch ms — the activity fallback for orphan directories. */
106
- mtimeMs: number
107
- /** Total regular-file bytes below the directory. */
108
- bytes: number
109
- /** Regular-file count below the directory. */
110
- files: number
111
- }
112
-
113
- /** One registered host workspace (structurally `Workspace`). */
114
- export interface CleanupWorkspace {
115
- /** Host workspace-registry id (needed to unregister the record). */
116
- readonly id: string
117
- /** Workspace directory. */
118
- readonly path: string
119
- /** Host display title; plugin-created workspaces use the `飞书…` prefixes. */
120
- readonly title: string
121
- }
122
-
123
- /** One active route record, reduced to the facts cleanup needs. */
124
- export interface CleanupRoute {
125
- /** Route key (thread_id or `chat:<chatId>`). */
126
- readonly key: string
127
- /** `thread` for a real Feishu topic, `chat` for a degraded chat route. */
128
- readonly kind: 'thread' | 'chat'
129
- /** Workspace root this route's Session belongs to. */
130
- readonly workspacePath: string
131
- /** Effective working directory (topic dir / worktree); the workspace path on legacy routes. */
132
- readonly sessionDir?: string
133
- /** DSH session the route points at. */
134
- readonly sessionId: string
135
- /** Epoch ms of the last activity routed through the record. */
136
- readonly lastActivityAt: number
137
- }
138
-
139
- /**
140
- * One retired-route tombstone.
141
- *
142
- * Older plugin versions stored only route identity, so `workspacePath`,
143
- * `sessionDir`, and `lastActivityAt` stay optional and legacy tombstones
144
- * remain parseable.
145
- */
146
- export interface CleanupTombstone {
147
- /** Route key the tombstone replaced. */
148
- readonly key: string
149
- /** Workspace the retired route belonged to; absent in legacy tombstones. */
150
- readonly workspacePath?: string
151
- /** Effective working directory of the retired route; absent in legacy tombstones. */
152
- readonly sessionDir?: string
153
- /** Epoch ms when the route was retired. */
154
- readonly expiredAt: number
155
- /** Epoch ms of the last activity before retirement; absent in legacy tombstones. */
156
- readonly lastActivityAt?: number
157
- }
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
-
170
- /** A dependency seam that may be sync (registry tables) or async (filesystem). */
171
- export type MaybePromise<T> = T | Promise<T>
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
-
178
- /**
179
- * Every effect cleanup needs, injected so this module stays I/O-free.
180
- *
181
- * The service supplies the real implementations: the workspace registry for
182
- * `listWorkspaces`/`removeWorkspace`, the routing tables for
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.
186
- */
187
- export interface CleanupDeps {
188
- /** Workspace root the plugin owns; candidates must be strictly inside it. */
189
- readonly baseDir: string
190
- /** Retention value from config (`off` disables planning). */
191
- readonly retention: WorkspaceRetention
192
- /** Every workspace currently registered with the host. */
193
- listWorkspaces(): MaybePromise<readonly CleanupWorkspace[]>
194
- /** Per-topic directories discovered below the live user workspaces. */
195
- listSessionDirs(): MaybePromise<readonly ScannedSessionDir[]>
196
- /** Every active route record. */
197
- activeRoutes(): MaybePromise<readonly CleanupRoute[]>
198
- /** Every retired-route tombstone. */
199
- tombstones(): MaybePromise<readonly CleanupTombstone[]>
200
- /** Whether a DSH session is still live; targets with live sessions are never offered. */
201
- isLive(sessionId: string): boolean
202
- /** Measure one directory; `undefined` when it cannot be read (missing, permission). */
203
- statWorkspace(directory: string): MaybePromise<WorkspaceStats | undefined>
204
- /** Delete one legacy workspace directory and unregister its registry record. */
205
- removeWorkspace(workspace: CleanupWorkspace): MaybePromise<void>
206
- /** Delete one per-topic session directory (no registry record exists). */
207
- removeSessionDir(directory: string): MaybePromise<void>
208
- /** Retire one active route into a tombstone (same bookkeeping as route TTL). */
209
- retireRoute(route: CleanupRoute): MaybePromise<void>
210
- /** Clock seam; defaults to `Date.now()`. */
211
- now?(): number
212
- /** Warning seam for best-effort failures; defaults to silence. */
213
- warn?(message: string): void
214
- }
215
-
216
- /** One directory old enough to delete, with its measured footprint. */
217
- export interface CleanupCandidate {
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). */
223
- readonly activityAt: number
224
- /** Regular-file bytes below the directory (0 when unreadable). */
225
- readonly bytes: number
226
- /** Regular-file count below the directory (0 when unreadable). */
227
- readonly files: number
228
- /** Active routes this candidate's directory owns (retired before removal). */
229
- readonly routes: readonly CleanupRoute[]
230
- }
231
-
232
- /** Result of one planning pass; nothing has been deleted yet. */
233
- export interface CleanupPlan {
234
- /** Configured retention window in milliseconds. */
235
- readonly retentionMs: number
236
- /** Epoch ms cutoff: activity older than this is stale. */
237
- readonly cutoff: number
238
- /** Deletable candidates, oldest first. */
239
- readonly candidates: readonly CleanupCandidate[]
240
- /** Targets skipped because a Session using them is still live. */
241
- readonly skippedLive: number
242
- }
243
-
244
- /** One failed removal (the run continues with the remaining candidates). */
245
- export interface CleanupFailure {
246
- /** Directory that could not be removed. */
247
- readonly path: string
248
- /** Human-readable reason. */
249
- readonly message: string
250
- }
251
-
252
- /** Aggregate outcome of one apply pass. */
253
- export interface CleanupOutcome {
254
- /** Legacy registered workspaces whose directory and record were removed. */
255
- readonly deletedWorkspaces: number
256
- /** Per-topic session directories removed. */
257
- readonly deletedSessionDirs: number
258
- /** Active routes retired into tombstones. */
259
- readonly retiredRoutes: number
260
- /** Regular-file bytes reclaimed (measured before deletion). */
261
- readonly freedBytes: number
262
- /** Per-candidate failures; a failing candidate does not abort the run. */
263
- readonly failures: readonly CleanupFailure[]
264
- }
265
-
266
- // ---- planning -----------------------------------------------------------
267
-
268
- /** Lexically compare two paths (both resolved) without touching the disk. */
269
- function samePath(left: string, right: string): boolean {
270
- return path.resolve(left) === path.resolve(right)
271
- }
272
-
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. */
280
- function newestActivity(values: readonly number[]): number | undefined {
281
- return values.length === 0 ? undefined : Math.max(...values)
282
- }
283
-
284
- function nonNegative(value: number): number {
285
- return Number.isFinite(value) && value > 0 ? value : 0
286
- }
287
-
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
- }
298
- }
299
-
300
- function sessionIsLive(deps: CleanupDeps, sessionId: string): boolean {
301
- try {
302
- return deps.isLive(sessionId)
303
- } catch (error) {
304
- deps.warn?.(`dsh-channels: cleanup liveness probe failed for ${sessionId}: ${String(error)}`)
305
- return true
306
- }
307
- }
308
-
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
316
- }
317
-
318
- /**
319
- * Decide which directories are stale, without deleting anything.
320
- *
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/`.
324
- *
325
- * @param deps - injected effect seams (nothing is deleted here).
326
- * @returns the plan, or `undefined` when retention is off or the base
327
- * directory is unusable (filesystem root).
328
- */
329
- export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPlan | undefined> {
330
- const window = retentionMs(deps.retention)
331
- if (window === undefined) return undefined
332
-
333
- const baseDir = path.resolve(deps.baseDir)
334
- if (isFilesystemRoot(baseDir)) {
335
- deps.warn?.('dsh-channels: retention cleanup disabled: baseDir is a filesystem root')
336
- return undefined
337
- }
338
- const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
339
- const stagingDir = stagingRoot(baseDir)
340
-
341
- const now = deps.now?.() ?? Date.now()
342
- const cutoff = now - window
343
- const activeRoutes = await deps.activeRoutes()
344
- const tombstones = await deps.tombstones()
345
- const workspaces = await deps.listWorkspaces()
346
- const candidates: CleanupCandidate[] = []
347
- let skippedLive = 0
348
-
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)
353
- if (!pluginWorkspaceTitle(workspace.title)) continue
354
- if (!isPathInside(baseDir, resolved)) continue
355
- if (isPathInside(legacyRoot, resolved)) continue
356
- userWorkspacePaths.add(resolved)
357
- }
358
-
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)) {
370
- skippedLive++
371
- continue
372
- }
373
- let stats: WorkspaceStats | undefined
374
- let activityAt = newestActivity(routes.map(route => route.lastActivityAt))
375
- if (activityAt === undefined) {
376
- activityAt = newestActivity(
377
- tombstones
378
- .filter(tombstone => tombstone.workspacePath !== undefined
379
- && samePath(tombstone.workspacePath, targetPath)
380
- && (tombstone.sessionDir === undefined || samePath(tombstone.sessionDir, targetPath)))
381
- .map(tombstone => tombstone.lastActivityAt ?? tombstone.expiredAt),
382
- )
383
- }
384
- if (activityAt === undefined) {
385
- // Orphan workspace: the directory's own mtime is the only age evidence.
386
- stats = await readWorkspaceStats(deps, targetPath)
387
- activityAt = stats !== undefined && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined
388
- }
389
- if (activityAt === undefined || activityAt >= cutoff) continue
390
- stats ??= await readWorkspaceStats(deps, targetPath)
391
- candidates.push({
392
- target: { kind: 'workspace', workspace },
393
- path: workspace.path,
394
- activityAt,
395
- bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
396
- files: stats === undefined ? 0 : nonNegative(stats.files),
397
- routes,
398
- })
399
- }
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
-
469
- candidates.sort((left, right) =>
470
- left.activityAt - right.activityAt || left.path.localeCompare(right.path))
471
- return { retentionMs: window, cutoff, candidates, skippedLive }
472
- }
473
-
474
- // ---- application --------------------------------------------------------
475
-
476
- /**
477
- * Apply a plan: retire the candidate's active routes, then remove its
478
- * directory (and, for legacy workspaces, the registry record).
479
- *
480
- * A candidate is the unit of failure: if retiring any of its routes fails, the
481
- * directory is left untouched (route bookkeeping must not point at a deleted
482
- * directory), the failure is collected, and the remaining candidates are still
483
- * processed. Paths are revalidated immediately before the destructive step, so
484
- * a stale or hand-built plan can never delete `baseDir` itself, a user
485
- * workspace, or something outside `baseDir`.
486
- *
487
- * @param deps - injected effect seams.
488
- * @param plan - plan produced by `planWorkspaceCleanup` (or an equivalent preview).
489
- * @returns aggregate counts plus per-target failures.
490
- */
491
- export async function applyWorkspaceCleanup(
492
- deps: CleanupDeps,
493
- plan: CleanupPlan,
494
- ): Promise<CleanupOutcome> {
495
- const baseDir = path.resolve(deps.baseDir)
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
- )
504
- const failures: CleanupFailure[] = []
505
- let deletedWorkspaces = 0
506
- let deletedSessionDirs = 0
507
- let retiredRoutes = 0
508
- let freedBytes = 0
509
-
510
- for (const candidate of plan.candidates) {
511
- const target = candidate.target
512
- const targetPath = candidate.path
513
- // Defense in depth: never trust a plan object for the destructive step.
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' })
522
- continue
523
- }
524
- try {
525
- for (const route of candidate.routes) {
526
- if (!samePath(directoryOfRoute(route), targetPath)) continue
527
- await deps.retireRoute(route)
528
- retiredRoutes++
529
- }
530
- if (target.kind === 'workspace') {
531
- await deps.removeWorkspace(target.workspace)
532
- deletedWorkspaces++
533
- } else {
534
- await deps.removeSessionDir(targetPath)
535
- deletedSessionDirs++
536
- }
537
- freedBytes += nonNegative(candidate.bytes)
538
- } catch (error) {
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}`)
542
- }
543
- }
544
-
545
- return { deletedWorkspaces, deletedSessionDirs, retiredRoutes, freedBytes, failures }
546
- }
@@ -1,106 +0,0 @@
1
- /**
2
- * 目录清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
3
- * 执行前二次确认,结果与部分失败逐条展示。`cleanup.error` 非空时
4
- * (例如未连接、无法扫描工作区)按钮天然禁用并显示原因。
5
- *
6
- * @module dsh-channels/client/panel/cleanup-card
7
- */
8
-
9
- import { PANEL_COPY } from '../../settings/contract.ts'
10
- import type { CleanupPreviewWire, CleanupRunWire, PanelWire } from '../../settings/contract.ts'
11
- import { formatBytes, retentionLabel } from './shared.tsx'
12
- import type { BusyAction } from './shared.tsx'
13
-
14
- export function CleanupCard(props: {
15
- panel: PanelWire
16
- cleanup: CleanupPreviewWire
17
- busy: BusyAction | null
18
- locked: boolean
19
- result: CleanupRunWire | null
20
- error: string | null
21
- onRun: () => void
22
- }): JSX.Element {
23
- const { panel, cleanup, busy, locked, result, error, onRun } = props
24
- const autoText = cleanup.retention === 'off'
25
- ? PANEL_COPY.cleanupAutoInactive
26
- : cleanup.auto ? PANEL_COPY.cleanupAutoOn : PANEL_COPY.cleanupAutoOff
27
- const blocked = cleanup.error !== undefined
28
- ? `${PANEL_COPY.cleanupPreviewFailed}:${cleanup.error}`
29
- : cleanup.retention === 'off'
30
- ? PANEL_COPY.cleanupDisabledOff
31
- : cleanup.candidates === 0
32
- ? PANEL_COPY.cleanupDisabledEmpty
33
- : null
34
- const facts = [
35
- { label: PANEL_COPY.cleanupRetention, value: retentionLabel(panel, cleanup) },
36
- { label: PANEL_COPY.cleanupAutoLabel, value: autoText },
37
- { label: PANEL_COPY.cleanupCandidates, value: `${cleanup.candidates} 个` },
38
- { label: PANEL_COPY.cleanupWorkspaces, value: `${cleanup.workspaces} 个` },
39
- { label: PANEL_COPY.cleanupCandidateBytes, value: formatBytes(cleanup.candidateBytes) },
40
- ]
41
- const running = busy === 'cleanup'
42
- const failed = result !== null && (!result.ok || error !== null)
43
- return (
44
- <section className="dsh-channels-cfg__card">
45
- <div className="dsh-channels-cfg__cardHead">
46
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.cleanupCard}</span>
47
- <span className="dsh-channels-cfg__cardMeta">{retentionLabel(panel, cleanup)}</span>
48
- </div>
49
- <span className="dsh-channels-cfg__help">{PANEL_COPY.cleanupCardHelp}</span>
50
- <div className="dsh-channels-cfg__facts">
51
- {facts.map(fact => (
52
- <div className="dsh-channels-cfg__fact" key={fact.label}>
53
- <span className="dsh-channels-cfg__factLabel">{fact.label}</span>
54
- <span className="dsh-channels-cfg__factValue">{fact.value}</span>
55
- </div>
56
- ))}
57
- </div>
58
- {blocked === null ? null : <span className="dsh-channels-cfg__help">{blocked}</span>}
59
- {result === null
60
- ? null
61
- : (
62
- <div className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${failed ? 'error' : 'ok'}`}>
63
- {result.ok
64
- ? (
65
- <span>
66
- {PANEL_COPY.cleanupDone}:{PANEL_COPY.cleanupDeleted} {result.deletedWorkspaces} 个,
67
- {PANEL_COPY.cleanupDeletedDirs} {result.deletedSessionDirs} 个,
68
- {PANEL_COPY.cleanupRetired} {result.retiredRoutes} 条,
69
- {PANEL_COPY.cleanupFreed} {formatBytes(result.freedBytes)}。
70
- </span>
71
- )
72
- : (
73
- <span>
74
- {PANEL_COPY.cleanupFailed}
75
- {result.error === undefined ? '' : `:${result.error}`}
76
- </span>
77
- )}
78
- {result.failures.length === 0
79
- ? null
80
- : (
81
- <>
82
- <span>{PANEL_COPY.cleanupFailures}</span>
83
- <ul className="dsh-channels-cfg__issues">
84
- {result.failures.map((failure, index) => (
85
- <li key={`${index}-${failure}`} className="dsh-channels-cfg__mono">{failure}</li>
86
- ))}
87
- </ul>
88
- </>
89
- )}
90
- </div>
91
- )}
92
- {error === null ? null : <span className="dsh-channels-cfg__fieldError">{error}</span>}
93
- <div className="dsh-channels-cfg__actions">
94
- <button
95
- type="button"
96
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--danger"
97
- onClick={onRun}
98
- disabled={running || busy !== null || locked || blocked !== null}
99
- >
100
- {running ? PANEL_COPY.cleanupRunning : PANEL_COPY.cleanupNow}
101
- </button>
102
- <span className="dsh-channels-cfg__help">{PANEL_COPY.cleanupConfirmHint}</span>
103
- </div>
104
- </section>
105
- )
106
- }