@modesto_ma/dsh-channels 0.1.0 → 0.3.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 +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- 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 +256 -119
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- 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 +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
package/src/cleanup.ts
CHANGED
|
@@ -1,35 +1,38 @@
|
|
|
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.
|
|
28
30
|
*
|
|
29
31
|
* @module dsh-channels/cleanup
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
34
|
import path from 'node:path'
|
|
35
|
+
import { legacyTopicWorkspaceRoot } from './workspaces.ts'
|
|
33
36
|
|
|
34
37
|
/** Values the `workspaceRetention` config accepts. `off` disables cleanup. */
|
|
35
38
|
export const WORKSPACE_RETENTION_VALUES = ['off', '7d', '30d', '90d', '180d'] as const
|
|
@@ -60,6 +63,14 @@ export function retentionMs(value: WorkspaceRetention): number | undefined {
|
|
|
60
63
|
/** Title prefixes `workspaces.ts` gives plugin-created workspaces. */
|
|
61
64
|
const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
|
|
62
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
|
+
|
|
63
74
|
/**
|
|
64
75
|
* Whether `target` is strictly below `baseDir`.
|
|
65
76
|
*
|
|
@@ -67,8 +78,8 @@ const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
|
|
|
67
78
|
* relative value cannot escape `baseDir`; the base directory itself and
|
|
68
79
|
* siblings sharing a name prefix (e.g. `base-other`) are not "inside". The
|
|
69
80
|
* check is lexical on purpose: this module owns no I/O. The caller's
|
|
70
|
-
* `removeWorkspace`
|
|
71
|
-
* check before deleting.
|
|
81
|
+
* `removeWorkspace` / `removeSessionDir` must still refuse symlinked escapes
|
|
82
|
+
* with a realpath check before deleting.
|
|
72
83
|
*
|
|
73
84
|
* @param baseDir - directory the target must stay below.
|
|
74
85
|
* @param target - candidate path.
|
|
@@ -83,9 +94,9 @@ export function isPathInside(baseDir: string, target: string): boolean {
|
|
|
83
94
|
return relative !== '..' && !relative.startsWith(`..${path.sep}`)
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
/** Filesystem facts the caller measures for one
|
|
97
|
+
/** Filesystem facts the caller measures for one candidate directory. */
|
|
87
98
|
export interface WorkspaceStats {
|
|
88
|
-
/** Directory mtime in epoch ms — the activity fallback for orphan
|
|
99
|
+
/** Directory mtime in epoch ms — the activity fallback for orphan directories. */
|
|
89
100
|
mtimeMs: number
|
|
90
101
|
/** Total regular-file bytes below the directory. */
|
|
91
102
|
bytes: number
|
|
@@ -93,7 +104,7 @@ export interface WorkspaceStats {
|
|
|
93
104
|
files: number
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
/**
|
|
107
|
+
/** One registered host workspace (structurally `Workspace`). */
|
|
97
108
|
export interface CleanupWorkspace {
|
|
98
109
|
/** Host workspace-registry id (needed to unregister the record). */
|
|
99
110
|
readonly id: string
|
|
@@ -109,8 +120,10 @@ export interface CleanupRoute {
|
|
|
109
120
|
readonly key: string
|
|
110
121
|
/** `thread` for a real Feishu topic, `chat` for a degraded chat route. */
|
|
111
122
|
readonly kind: 'thread' | 'chat'
|
|
112
|
-
/** Workspace
|
|
123
|
+
/** Workspace root this route's Session belongs to. */
|
|
113
124
|
readonly workspacePath: string
|
|
125
|
+
/** Effective working directory (topic dir / worktree); the workspace path on legacy routes. */
|
|
126
|
+
readonly sessionDir?: string
|
|
114
127
|
/** DSH session the route points at. */
|
|
115
128
|
readonly sessionId: string
|
|
116
129
|
/** Epoch ms of the last activity routed through the record. */
|
|
@@ -120,30 +133,50 @@ export interface CleanupRoute {
|
|
|
120
133
|
/**
|
|
121
134
|
* One retired-route tombstone.
|
|
122
135
|
*
|
|
123
|
-
* Older plugin versions stored only route identity, so `workspacePath
|
|
124
|
-
* `lastActivityAt` stay optional and legacy tombstones
|
|
136
|
+
* Older plugin versions stored only route identity, so `workspacePath`,
|
|
137
|
+
* `sessionDir`, and `lastActivityAt` stay optional and legacy tombstones
|
|
138
|
+
* remain parseable.
|
|
125
139
|
*/
|
|
126
140
|
export interface CleanupTombstone {
|
|
127
141
|
/** Route key the tombstone replaced. */
|
|
128
142
|
readonly key: string
|
|
129
143
|
/** Workspace the retired route belonged to; absent in legacy tombstones. */
|
|
130
144
|
readonly workspacePath?: string
|
|
145
|
+
/** Effective working directory of the retired route; absent in legacy tombstones. */
|
|
146
|
+
readonly sessionDir?: string
|
|
131
147
|
/** Epoch ms when the route was retired. */
|
|
132
148
|
readonly expiredAt: number
|
|
133
149
|
/** Epoch ms of the last activity before retirement; absent in legacy tombstones. */
|
|
134
150
|
readonly lastActivityAt?: number
|
|
135
151
|
}
|
|
136
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
|
+
|
|
137
164
|
/** A dependency seam that may be sync (registry tables) or async (filesystem). */
|
|
138
165
|
export type MaybePromise<T> = T | Promise<T>
|
|
139
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
|
+
|
|
140
172
|
/**
|
|
141
173
|
* Every effect cleanup needs, injected so this module stays I/O-free.
|
|
142
174
|
*
|
|
143
175
|
* The service supplies the real implementations: the workspace registry for
|
|
144
176
|
* `listWorkspaces`/`removeWorkspace`, the routing tables for
|
|
145
|
-
* `activeRoutes`/`tombstones`, the live
|
|
146
|
-
*
|
|
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.
|
|
147
180
|
*/
|
|
148
181
|
export interface CleanupDeps {
|
|
149
182
|
/** Workspace root the plugin owns; candidates must be strictly inside it. */
|
|
@@ -152,16 +185,20 @@ export interface CleanupDeps {
|
|
|
152
185
|
readonly retention: WorkspaceRetention
|
|
153
186
|
/** Every workspace currently registered with the host. */
|
|
154
187
|
listWorkspaces(): MaybePromise<readonly CleanupWorkspace[]>
|
|
188
|
+
/** Per-topic directories discovered below the live user workspaces. */
|
|
189
|
+
listSessionDirs(): MaybePromise<readonly ScannedSessionDir[]>
|
|
155
190
|
/** Every active route record. */
|
|
156
191
|
activeRoutes(): MaybePromise<readonly CleanupRoute[]>
|
|
157
192
|
/** Every retired-route tombstone. */
|
|
158
193
|
tombstones(): MaybePromise<readonly CleanupTombstone[]>
|
|
159
|
-
/** Whether a DSH session is still live; live
|
|
194
|
+
/** Whether a DSH session is still live; targets with live sessions are never offered. */
|
|
160
195
|
isLive(sessionId: string): boolean
|
|
161
196
|
/** Measure one directory; `undefined` when it cannot be read (missing, permission). */
|
|
162
|
-
statWorkspace(
|
|
163
|
-
/** Delete one workspace directory and unregister its registry record. */
|
|
197
|
+
statWorkspace(directory: string): MaybePromise<WorkspaceStats | undefined>
|
|
198
|
+
/** Delete one legacy workspace directory and unregister its registry record. */
|
|
164
199
|
removeWorkspace(workspace: CleanupWorkspace): MaybePromise<void>
|
|
200
|
+
/** Delete one per-topic session directory (no registry record exists). */
|
|
201
|
+
removeSessionDir(directory: string): MaybePromise<void>
|
|
165
202
|
/** Retire one active route into a tombstone (same bookkeeping as route TTL). */
|
|
166
203
|
retireRoute(route: CleanupRoute): MaybePromise<void>
|
|
167
204
|
/** Clock seam; defaults to `Date.now()`. */
|
|
@@ -170,117 +207,114 @@ export interface CleanupDeps {
|
|
|
170
207
|
warn?(message: string): void
|
|
171
208
|
}
|
|
172
209
|
|
|
173
|
-
/** One
|
|
210
|
+
/** One directory old enough to delete, with its measured footprint. */
|
|
174
211
|
export interface CleanupCandidate {
|
|
175
|
-
/**
|
|
176
|
-
readonly
|
|
177
|
-
/**
|
|
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). */
|
|
178
217
|
readonly activityAt: number
|
|
179
218
|
/** Regular-file bytes below the directory (0 when unreadable). */
|
|
180
219
|
readonly bytes: number
|
|
181
220
|
/** Regular-file count below the directory (0 when unreadable). */
|
|
182
221
|
readonly files: number
|
|
183
|
-
/** Active routes
|
|
222
|
+
/** Active routes this candidate's directory owns (retired before removal). */
|
|
184
223
|
readonly routes: readonly CleanupRoute[]
|
|
185
224
|
}
|
|
186
225
|
|
|
187
|
-
/** Result of one
|
|
226
|
+
/** Result of one planning pass; nothing has been deleted yet. */
|
|
188
227
|
export interface CleanupPlan {
|
|
189
|
-
/**
|
|
228
|
+
/** Configured retention window in milliseconds. */
|
|
190
229
|
readonly retentionMs: number
|
|
191
|
-
/**
|
|
230
|
+
/** Epoch ms cutoff: activity older than this is stale. */
|
|
192
231
|
readonly cutoff: number
|
|
193
|
-
/**
|
|
232
|
+
/** Deletable candidates, oldest first. */
|
|
194
233
|
readonly candidates: readonly CleanupCandidate[]
|
|
195
|
-
/**
|
|
234
|
+
/** Targets skipped because a Session using them is still live. */
|
|
196
235
|
readonly skippedLive: number
|
|
197
236
|
}
|
|
198
237
|
|
|
199
|
-
/** One failed removal
|
|
238
|
+
/** One failed removal (the run continues with the remaining candidates). */
|
|
200
239
|
export interface CleanupFailure {
|
|
201
|
-
/**
|
|
240
|
+
/** Directory that could not be removed. */
|
|
202
241
|
readonly path: string
|
|
203
242
|
/** Human-readable reason. */
|
|
204
243
|
readonly message: string
|
|
205
244
|
}
|
|
206
245
|
|
|
207
|
-
/**
|
|
246
|
+
/** Aggregate outcome of one apply pass. */
|
|
208
247
|
export interface CleanupOutcome {
|
|
209
|
-
/**
|
|
248
|
+
/** Legacy registered workspaces whose directory and record were removed. */
|
|
210
249
|
readonly deletedWorkspaces: number
|
|
211
|
-
/**
|
|
250
|
+
/** Per-topic session directories removed. */
|
|
251
|
+
readonly deletedSessionDirs: number
|
|
252
|
+
/** Active routes retired into tombstones. */
|
|
212
253
|
readonly retiredRoutes: number
|
|
213
|
-
/**
|
|
254
|
+
/** Regular-file bytes reclaimed (measured before deletion). */
|
|
214
255
|
readonly freedBytes: number
|
|
215
|
-
/** Per-
|
|
256
|
+
/** Per-candidate failures; a failing candidate does not abort the run. */
|
|
216
257
|
readonly failures: readonly CleanupFailure[]
|
|
217
258
|
}
|
|
218
259
|
|
|
219
|
-
|
|
220
|
-
function pluginWorkspaceTitle(title: string): boolean {
|
|
221
|
-
return PLUGIN_TITLE_PREFIXES.some(prefix => title.startsWith(prefix))
|
|
222
|
-
}
|
|
260
|
+
// ---- planning -----------------------------------------------------------
|
|
223
261
|
|
|
224
|
-
/**
|
|
262
|
+
/** Lexically compare two paths (both resolved) without touching the disk. */
|
|
225
263
|
function samePath(left: string, right: string): boolean {
|
|
226
264
|
return path.resolve(left) === path.resolve(right)
|
|
227
265
|
}
|
|
228
266
|
|
|
229
|
-
/**
|
|
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. */
|
|
230
274
|
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
|
|
275
|
+
return values.length === 0 ? undefined : Math.max(...values)
|
|
237
276
|
}
|
|
238
277
|
|
|
239
|
-
/** Count/byte values never go negative or non-finite on the outcome path. */
|
|
240
278
|
function nonNegative(value: number): number {
|
|
241
279
|
return Number.isFinite(value) && value > 0 ? value : 0
|
|
242
280
|
}
|
|
243
281
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
}
|
|
252
292
|
}
|
|
253
293
|
|
|
254
|
-
/** Liveness probe that fails safe: an unknowable session counts as live. */
|
|
255
294
|
function sessionIsLive(deps: CleanupDeps, sessionId: string): boolean {
|
|
256
295
|
try {
|
|
257
296
|
return deps.isLive(sessionId)
|
|
258
297
|
} catch (error) {
|
|
259
|
-
deps.warn?.(`dsh-channels:
|
|
298
|
+
deps.warn?.(`dsh-channels: cleanup liveness probe failed for ${sessionId}: ${String(error)}`)
|
|
260
299
|
return true
|
|
261
300
|
}
|
|
262
301
|
}
|
|
263
302
|
|
|
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
|
-
}
|
|
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
|
|
275
310
|
}
|
|
276
311
|
|
|
277
312
|
/**
|
|
278
|
-
*
|
|
313
|
+
* Decide which directories are stale, without deleting anything.
|
|
279
314
|
*
|
|
280
|
-
* Only plugin-created
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* preview and for `freedBytes` reporting.
|
|
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/`.
|
|
284
318
|
*
|
|
285
319
|
* @param deps - injected effect seams (nothing is deleted here).
|
|
286
320
|
* @returns the plan, or `undefined` when retention is off or the base
|
|
@@ -292,47 +326,64 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
|
|
|
292
326
|
|
|
293
327
|
const baseDir = path.resolve(deps.baseDir)
|
|
294
328
|
if (isFilesystemRoot(baseDir)) {
|
|
295
|
-
deps.warn?.('dsh-channels:
|
|
329
|
+
deps.warn?.('dsh-channels: retention cleanup disabled: baseDir is a filesystem root')
|
|
296
330
|
return undefined
|
|
297
331
|
}
|
|
332
|
+
const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
|
|
298
333
|
|
|
299
334
|
const now = deps.now?.() ?? Date.now()
|
|
300
335
|
const cutoff = now - window
|
|
301
336
|
const activeRoutes = await deps.activeRoutes()
|
|
302
337
|
const tombstones = await deps.tombstones()
|
|
338
|
+
const workspaces = await deps.listWorkspaces()
|
|
303
339
|
const candidates: CleanupCandidate[] = []
|
|
304
340
|
let skippedLive = 0
|
|
305
341
|
|
|
306
|
-
|
|
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)
|
|
307
346
|
if (!pluginWorkspaceTitle(workspace.title)) continue
|
|
308
|
-
if (!isPathInside(baseDir,
|
|
347
|
+
if (!isPathInside(baseDir, resolved)) continue
|
|
348
|
+
if (isPathInside(legacyRoot, resolved)) continue
|
|
349
|
+
userWorkspacePaths.add(resolved)
|
|
350
|
+
}
|
|
309
351
|
|
|
310
|
-
|
|
311
|
-
|
|
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)) {
|
|
312
363
|
skippedLive++
|
|
313
364
|
continue
|
|
314
365
|
}
|
|
315
|
-
|
|
316
366
|
let stats: WorkspaceStats | undefined
|
|
317
367
|
let activityAt = newestActivity(routes.map(route => route.lastActivityAt))
|
|
318
368
|
if (activityAt === undefined) {
|
|
319
369
|
activityAt = newestActivity(
|
|
320
370
|
tombstones
|
|
321
371
|
.filter(tombstone => tombstone.workspacePath !== undefined
|
|
322
|
-
&& samePath(tombstone.workspacePath,
|
|
372
|
+
&& samePath(tombstone.workspacePath, targetPath)
|
|
373
|
+
&& (tombstone.sessionDir === undefined || samePath(tombstone.sessionDir, targetPath)))
|
|
323
374
|
.map(tombstone => tombstone.lastActivityAt ?? tombstone.expiredAt),
|
|
324
375
|
)
|
|
325
376
|
}
|
|
326
377
|
if (activityAt === undefined) {
|
|
327
378
|
// Orphan workspace: the directory's own mtime is the only age evidence.
|
|
328
|
-
stats = await readWorkspaceStats(deps,
|
|
379
|
+
stats = await readWorkspaceStats(deps, targetPath)
|
|
329
380
|
activityAt = stats !== undefined && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined
|
|
330
381
|
}
|
|
331
382
|
if (activityAt === undefined || activityAt >= cutoff) continue
|
|
332
|
-
|
|
333
|
-
stats ??= await readWorkspaceStats(deps, workspace.path)
|
|
383
|
+
stats ??= await readWorkspaceStats(deps, targetPath)
|
|
334
384
|
candidates.push({
|
|
335
|
-
workspace,
|
|
385
|
+
target: { kind: 'workspace', workspace },
|
|
386
|
+
path: workspace.path,
|
|
336
387
|
activityAt,
|
|
337
388
|
bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
|
|
338
389
|
files: stats === undefined ? 0 : nonNegative(stats.files),
|
|
@@ -340,25 +391,93 @@ export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPl
|
|
|
340
391
|
})
|
|
341
392
|
}
|
|
342
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
|
+
|
|
343
460
|
candidates.sort((left, right) =>
|
|
344
|
-
left.activityAt - right.activityAt || left.
|
|
461
|
+
left.activityAt - right.activityAt || left.path.localeCompare(right.path))
|
|
345
462
|
return { retentionMs: window, cutoff, candidates, skippedLive }
|
|
346
463
|
}
|
|
347
464
|
|
|
465
|
+
// ---- application --------------------------------------------------------
|
|
466
|
+
|
|
348
467
|
/**
|
|
349
|
-
* Apply a plan: retire the candidate's active routes, then remove
|
|
350
|
-
*
|
|
468
|
+
* Apply a plan: retire the candidate's active routes, then remove its
|
|
469
|
+
* directory (and, for legacy workspaces, the registry record).
|
|
351
470
|
*
|
|
352
471
|
* A candidate is the unit of failure: if retiring any of its routes fails, the
|
|
353
|
-
*
|
|
472
|
+
* directory is left untouched (route bookkeeping must not point at a deleted
|
|
354
473
|
* directory), the failure is collected, and the remaining candidates are still
|
|
355
474
|
* processed. Paths are revalidated immediately before the destructive step, so
|
|
356
|
-
* a stale or hand-built plan can never delete
|
|
357
|
-
* `baseDir
|
|
475
|
+
* a stale or hand-built plan can never delete `baseDir` itself, a user
|
|
476
|
+
* workspace, or something outside `baseDir`.
|
|
358
477
|
*
|
|
359
478
|
* @param deps - injected effect seams.
|
|
360
479
|
* @param plan - plan produced by `planWorkspaceCleanup` (or an equivalent preview).
|
|
361
|
-
* @returns aggregate counts plus per-
|
|
480
|
+
* @returns aggregate counts plus per-target failures.
|
|
362
481
|
*/
|
|
363
482
|
export async function applyWorkspaceCleanup(
|
|
364
483
|
deps: CleanupDeps,
|
|
@@ -366,33 +485,51 @@ export async function applyWorkspaceCleanup(
|
|
|
366
485
|
): Promise<CleanupOutcome> {
|
|
367
486
|
const baseDir = path.resolve(deps.baseDir)
|
|
368
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
|
+
)
|
|
369
494
|
const failures: CleanupFailure[] = []
|
|
370
495
|
let deletedWorkspaces = 0
|
|
496
|
+
let deletedSessionDirs = 0
|
|
371
497
|
let retiredRoutes = 0
|
|
372
498
|
let freedBytes = 0
|
|
373
499
|
|
|
374
500
|
for (const candidate of plan.candidates) {
|
|
375
|
-
const target = candidate.
|
|
501
|
+
const target = candidate.target
|
|
502
|
+
const targetPath = candidate.path
|
|
376
503
|
// Defense in depth: never trust a plan object for the destructive step.
|
|
377
|
-
|
|
378
|
-
|
|
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' })
|
|
379
511
|
continue
|
|
380
512
|
}
|
|
381
513
|
try {
|
|
382
514
|
for (const route of candidate.routes) {
|
|
383
|
-
if (!samePath(route
|
|
515
|
+
if (!samePath(directoryOfRoute(route), targetPath)) continue
|
|
384
516
|
await deps.retireRoute(route)
|
|
385
517
|
retiredRoutes++
|
|
386
518
|
}
|
|
387
|
-
|
|
388
|
-
|
|
519
|
+
if (target.kind === 'workspace') {
|
|
520
|
+
await deps.removeWorkspace(target.workspace)
|
|
521
|
+
deletedWorkspaces++
|
|
522
|
+
} else {
|
|
523
|
+
await deps.removeSessionDir(targetPath)
|
|
524
|
+
deletedSessionDirs++
|
|
525
|
+
}
|
|
389
526
|
freedBytes += nonNegative(candidate.bytes)
|
|
390
527
|
} catch (error) {
|
|
391
|
-
const message =
|
|
392
|
-
failures.push({ path:
|
|
393
|
-
deps.warn?.(`dsh-channels:
|
|
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}`)
|
|
394
531
|
}
|
|
395
532
|
}
|
|
396
533
|
|
|
397
|
-
return { deletedWorkspaces, retiredRoutes, freedBytes, failures }
|
|
534
|
+
return { deletedWorkspaces, deletedSessionDirs, retiredRoutes, freedBytes, failures }
|
|
398
535
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CONFIG_ROUTE,
|
|
16
16
|
CREDENTIALS_ROUTE,
|
|
17
17
|
PANEL_ROUTE,
|
|
18
|
+
PICK_DIRECTORY_ROUTE,
|
|
18
19
|
RECONNECT_ROUTE,
|
|
19
20
|
STATUS_ROUTE,
|
|
20
21
|
} from '../settings/contract.ts'
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
CredentialField,
|
|
30
31
|
EditableValues,
|
|
31
32
|
PanelWire,
|
|
33
|
+
PickDirectoryResponse,
|
|
32
34
|
ReconnectResponse,
|
|
33
35
|
SaveConfigResponse,
|
|
34
36
|
SaveCredentialsRequest,
|
|
@@ -159,6 +161,24 @@ export async function runCleanup(): Promise<CleanupResponse> {
|
|
|
159
161
|
return JSON.parse(text) as CleanupResponse
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Ask the Host to open its native directory chooser (the panel's「选择目录…」
|
|
166
|
+
* button). The request stays open while the OS dialog does; a cancel comes back
|
|
167
|
+
* as `{ ok: true, canceled: true }`, not as an error.
|
|
168
|
+
*/
|
|
169
|
+
export async function pickDirectory(): Promise<PickDirectoryResponse> {
|
|
170
|
+
const response = await request(PICK_DIRECTORY_ROUTE, { method: 'POST' })
|
|
171
|
+
const text = await response.text()
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(text) as PickDirectoryResponse
|
|
175
|
+
} catch {
|
|
176
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return JSON.parse(text) as PickDirectoryResponse
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
/** Store provided credential values (the request/response never carry stored values). */
|
|
163
183
|
export async function saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
|
|
164
184
|
const response = await request(CREDENTIALS_ROUTE, {
|
package/src/client/catalog.ts
CHANGED