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