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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -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 +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  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 +43 -13
  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 +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
package/src/workspaces.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Workspace directory resolution: one DSH workspace per user, one working
3
3
  * directory per Feishu topic.
4
4
  *
5
- * - p2p: workspace `<baseDir>/p2p/<open_id>/`
6
- * - group: workspace `<baseDir>/groups/<chat_id>/<open_id>/`
5
+ * - p2p: workspace `<baseDir>/p2p/<user_id>/`
6
+ * - group: workspace `<baseDir>/groups/<chat_id>/<user_id>/`
7
7
  * - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
8
8
  *
9
9
  * The workspace root is the Session header cwd: it keeps the Session grouped
@@ -11,18 +11,19 @@
11
11
  * The per-topic directory is where the tools actually work (see
12
12
  * `./session/isolation.ts`), so files produced by different topics never mix.
13
13
  * Older versions registered one workspace per topic under `<baseDir>/topics/`;
14
- * those directories are kept verbatim for legacy routes and are only removed
15
- * by the retention cleanup, never migrated.
14
+ * those directories are kept verbatim for legacy routes and never migrated.
15
+ * The plugin never deletes workspace or topic directories; operators remove
16
+ * stale ones by hand (a missing topic directory is recreated on the next turn).
16
17
  *
17
18
  * Attachments received before a topic exists were staged by older plugin
18
- * versions below `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and
19
+ * versions below `<baseDir>/_staging/<chatType>/<chat_id>/<user_id>/` and
19
20
  * adopted into the topic directory once its first Session is created. The v2
20
21
  * pipeline stores inbound attachments in the DSH native attachment library and
21
22
  * never writes staging, so this is a one-release compatibility path only: it is
22
23
  * scheduled for removal in the release cycle after attachment_pipeline_v2 ships
23
24
  * (see Roadmap decisions D12 and docs/attachment-pipeline-v2.md).
24
25
  *
25
- * Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
26
+ * Feishu ids (`user_id`, `oc_…`) are safe path segments, but they are validated
26
27
  * anyway so a hostile id can never escape `baseDir`.
27
28
  *
28
29
  * @module dsh-channels/workspaces
@@ -145,8 +146,8 @@ export function worktreeBranchFor(topicToken: string): string {
145
146
  * Deterministic workspace directory for one user in one chat context: the
146
147
  * durable project the user's Sessions are grouped under.
147
148
  *
148
- * - p2p: `<baseDir>/p2p/<open_id>/`
149
- * - group: `<baseDir>/groups/<chat_id>/<open_id>/`
149
+ * - p2p: `<baseDir>/p2p/<user_id>/`
150
+ * - group: `<baseDir>/groups/<chat_id>/<user_id>/`
150
151
  *
151
152
  * These are the same paths older versions used as their shared (user × chat)
152
153
  * workspaces, so an upgraded deployment reuses the existing registry record
@@ -156,9 +157,9 @@ export function workspacePathFor(
156
157
  baseDir: string,
157
158
  chatType: 'p2p' | 'group',
158
159
  chatId: string,
159
- senderOpenId: string,
160
+ senderUserId: string,
160
161
  ): string {
161
- const user = safeSegment(senderOpenId, 'senderOpenId')
162
+ const user = safeSegment(senderUserId, 'senderUserId')
162
163
  if (chatType === 'p2p') return path.join(baseDir, 'p2p', user)
163
164
  return path.join(baseDir, 'groups', safeSegment(chatId, 'chatId'), user)
164
165
  }
@@ -168,20 +169,11 @@ export function topicDirFor(workspacePath: string, topicToken: string): string {
168
169
  return path.join(workspacePath, 'topics', safeSegment(topicToken, 'topic token'))
169
170
  }
170
171
 
171
- /**
172
- * Root of the legacy (one workspace per topic) directories. Legacy workspaces
173
- * live strictly below this path, which is how the retention cleanup tells them
174
- * apart from user workspaces that must never be deleted.
175
- */
176
- export function legacyTopicWorkspaceRoot(baseDir: string): string {
177
- return path.join(baseDir, 'topics')
178
- }
179
-
180
172
  /**
181
173
  * Legacy pre-topic attachment staging root.
182
174
  *
183
- * Only older plugin versions wrote here; the v2 native pipeline never does and
184
- * the retention cleanup never deletes it. Scheduled for removal together with
175
+ * Only older plugin versions wrote here; the v2 native pipeline never does.
176
+ * Scheduled for removal together with
185
177
  * the one-time adoption in the release cycle after attachment_pipeline_v2
186
178
  * (decisions D12).
187
179
  *
@@ -203,10 +195,10 @@ export function stagingPathFor(
203
195
  baseDir: string,
204
196
  chatType: 'p2p' | 'group',
205
197
  chatId: string,
206
- senderOpenId: string,
198
+ senderUserId: string,
207
199
  ): string {
208
200
  const chat = safeSegment(chatId, 'chatId')
209
- const user = safeSegment(senderOpenId, 'senderOpenId')
201
+ const user = safeSegment(senderUserId, 'senderUserId')
210
202
  return path.join(stagingRoot(baseDir), chatType, chat, user)
211
203
  }
212
204
 
@@ -214,11 +206,11 @@ export function stagingPathFor(
214
206
  export function workspaceTitleFor(
215
207
  chatType: 'p2p' | 'group',
216
208
  chatId: string,
217
- senderOpenId: string,
209
+ senderUserId: string,
218
210
  ): string {
219
211
  return chatType === 'p2p'
220
- ? `飞书私聊 · ${senderOpenId}`
221
- : `飞书群聊 · ${chatId} · ${senderOpenId}`
212
+ ? `飞书私聊 · ${senderUserId}`
213
+ : `飞书群聊 · ${chatId} · ${senderUserId}`
222
214
  }
223
215
 
224
216
  /**
@@ -231,12 +223,12 @@ export async function ensureWorkspace(
231
223
  baseDir: string,
232
224
  chatType: 'p2p' | 'group',
233
225
  chatId: string,
234
- senderOpenId: string,
226
+ senderUserId: string,
235
227
  ): Promise<Workspace> {
236
- const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId)
228
+ const dir = workspacePathFor(baseDir, chatType, chatId, senderUserId)
237
229
  await mkdir(dir, { recursive: true })
238
230
  return ctx.workspaceRegistry.create(
239
231
  dir,
240
- workspaceTitleFor(chatType, chatId, senderOpenId),
232
+ workspaceTitleFor(chatType, chatId, senderUserId),
241
233
  )
242
234
  }
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
- }