@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.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- 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 +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- 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/<
|
|
6
|
-
* - group: workspace `<baseDir>/groups/<chat_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
|
|
15
|
-
*
|
|
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
|
|
18
|
-
* `<baseDir>/_staging/<chatType>/<chat_id>/<
|
|
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 (`
|
|
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/<
|
|
145
|
-
* - group: `<baseDir>/groups/<chat_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
|
-
|
|
160
|
+
senderUserId: string,
|
|
156
161
|
): string {
|
|
157
|
-
const user = safeSegment(
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
173
|
-
return path.join(baseDir, '
|
|
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
|
-
|
|
198
|
+
senderUserId: string,
|
|
186
199
|
): string {
|
|
187
200
|
const chat = safeSegment(chatId, 'chatId')
|
|
188
|
-
const user = safeSegment(
|
|
189
|
-
return path.join(baseDir,
|
|
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
|
-
|
|
209
|
+
senderUserId: string,
|
|
197
210
|
): string {
|
|
198
211
|
return chatType === 'p2p'
|
|
199
|
-
? `飞书私聊 · ${
|
|
200
|
-
: `飞书群聊 · ${chatId} · ${
|
|
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
|
-
|
|
226
|
+
senderUserId: string,
|
|
214
227
|
): Promise<Workspace> {
|
|
215
|
-
const dir = workspacePathFor(baseDir, chatType, chatId,
|
|
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,
|
|
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
|
-
}
|