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