@modesto_ma/dsh-channels 0.0.0-stage → 0.1.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 +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
package/src/cleanup.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale workspace cleanup: pure planning and application policy.
|
|
3
|
+
*
|
|
4
|
+
* The plugin creates one host workspace per (user × chat) pair below its
|
|
5
|
+
* configured `baseDir` (see `workspaces.ts`). The host never removes those
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Activity precedence for one workspace:
|
|
15
|
+
*
|
|
16
|
+
* 1. the newest `lastActivityAt` among its active routes; a workspace with a
|
|
17
|
+
* live session is never offered for deletion,
|
|
18
|
+
* 2. otherwise the newest tombstone that names the workspace
|
|
19
|
+
* (`lastActivityAt`, falling back to `expiredAt` for tombstones written
|
|
20
|
+
* before routes recorded their workspace path),
|
|
21
|
+
* 3. otherwise the directory's own `mtime` (an orphan workspace).
|
|
22
|
+
*
|
|
23
|
+
* Tombstones written before this feature carry neither `workspacePath` nor
|
|
24
|
+
* `lastActivityAt`, so they cannot be attributed to a workspace; such a
|
|
25
|
+
* workspace falls through to the `mtime` rule instead. A workspace with no
|
|
26
|
+
* usable activity evidence at all (missing or unreadable directory) is never
|
|
27
|
+
* offered for deletion: irreversible removal requires positive evidence.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-channels/cleanup
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from 'node:path'
|
|
33
|
+
|
|
34
|
+
/** Values the `workspaceRetention` config accepts. `off` disables cleanup. */
|
|
35
|
+
export const WORKSPACE_RETENTION_VALUES = ['off', '7d', '30d', '90d', '180d'] as const
|
|
36
|
+
|
|
37
|
+
/** One workspace-retention setting. */
|
|
38
|
+
export type WorkspaceRetention = (typeof WORKSPACE_RETENTION_VALUES)[number]
|
|
39
|
+
|
|
40
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
41
|
+
|
|
42
|
+
/** Days represented by every non-`off` retention value. */
|
|
43
|
+
const RETENTION_DAYS: Readonly<Record<Exclude<WorkspaceRetention, 'off'>, number>> = {
|
|
44
|
+
'7d': 7,
|
|
45
|
+
'30d': 30,
|
|
46
|
+
'90d': 90,
|
|
47
|
+
'180d': 180,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert a retention setting to its window in milliseconds.
|
|
52
|
+
*
|
|
53
|
+
* @param value - configured retention value.
|
|
54
|
+
* @returns the window in milliseconds, or `undefined` when cleanup is off.
|
|
55
|
+
*/
|
|
56
|
+
export function retentionMs(value: WorkspaceRetention): number | undefined {
|
|
57
|
+
return value === 'off' ? undefined : RETENTION_DAYS[value] * DAY_MS
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Title prefixes `workspaces.ts` gives plugin-created workspaces. */
|
|
61
|
+
const PLUGIN_TITLE_PREFIXES = ['飞书私聊 ·', '飞书群聊 ·'] as const
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether `target` is strictly below `baseDir`.
|
|
65
|
+
*
|
|
66
|
+
* Both sides are normalized lexically (`path.resolve`), so a `..` segment or a
|
|
67
|
+
* relative value cannot escape `baseDir`; the base directory itself and
|
|
68
|
+
* siblings sharing a name prefix (e.g. `base-other`) are not "inside". The
|
|
69
|
+
* check is lexical on purpose: this module owns no I/O. The caller's
|
|
70
|
+
* `removeWorkspace` should still refuse symlinked escapes with a realpath
|
|
71
|
+
* check before deleting.
|
|
72
|
+
*
|
|
73
|
+
* @param baseDir - directory the target must stay below.
|
|
74
|
+
* @param target - candidate path.
|
|
75
|
+
* @returns `true` when `target` is strictly inside `baseDir`.
|
|
76
|
+
*/
|
|
77
|
+
export function isPathInside(baseDir: string, target: string): boolean {
|
|
78
|
+
const base = path.resolve(baseDir)
|
|
79
|
+
const resolved = path.resolve(target)
|
|
80
|
+
if (base === resolved) return false
|
|
81
|
+
const relative = path.relative(base, resolved)
|
|
82
|
+
if (relative === '' || path.isAbsolute(relative)) return false
|
|
83
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Filesystem facts the caller measures for one workspace candidate. */
|
|
87
|
+
export interface WorkspaceStats {
|
|
88
|
+
/** Directory mtime in epoch ms — the activity fallback for orphan workspaces. */
|
|
89
|
+
mtimeMs: number
|
|
90
|
+
/** Total regular-file bytes below the directory. */
|
|
91
|
+
bytes: number
|
|
92
|
+
/** Regular-file count below the directory. */
|
|
93
|
+
files: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Minimal workspace shape passed around by plan/apply (structurally `Workspace`). */
|
|
97
|
+
export interface CleanupWorkspace {
|
|
98
|
+
/** Host workspace-registry id (needed to unregister the record). */
|
|
99
|
+
readonly id: string
|
|
100
|
+
/** Workspace directory. */
|
|
101
|
+
readonly path: string
|
|
102
|
+
/** Host display title; plugin-created workspaces use the `飞书…` prefixes. */
|
|
103
|
+
readonly title: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One active route record, reduced to the facts cleanup needs. */
|
|
107
|
+
export interface CleanupRoute {
|
|
108
|
+
/** Route key (thread_id or `chat:<chatId>`). */
|
|
109
|
+
readonly key: string
|
|
110
|
+
/** `thread` for a real Feishu topic, `chat` for a degraded chat route. */
|
|
111
|
+
readonly kind: 'thread' | 'chat'
|
|
112
|
+
/** Workspace directory this route's session works in. */
|
|
113
|
+
readonly workspacePath: string
|
|
114
|
+
/** DSH session the route points at. */
|
|
115
|
+
readonly sessionId: string
|
|
116
|
+
/** Epoch ms of the last activity routed through the record. */
|
|
117
|
+
readonly lastActivityAt: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One retired-route tombstone.
|
|
122
|
+
*
|
|
123
|
+
* Older plugin versions stored only route identity, so `workspacePath` and
|
|
124
|
+
* `lastActivityAt` stay optional and legacy tombstones remain parseable.
|
|
125
|
+
*/
|
|
126
|
+
export interface CleanupTombstone {
|
|
127
|
+
/** Route key the tombstone replaced. */
|
|
128
|
+
readonly key: string
|
|
129
|
+
/** Workspace the retired route belonged to; absent in legacy tombstones. */
|
|
130
|
+
readonly workspacePath?: string
|
|
131
|
+
/** Epoch ms when the route was retired. */
|
|
132
|
+
readonly expiredAt: number
|
|
133
|
+
/** Epoch ms of the last activity before retirement; absent in legacy tombstones. */
|
|
134
|
+
readonly lastActivityAt?: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A dependency seam that may be sync (registry tables) or async (filesystem). */
|
|
138
|
+
export type MaybePromise<T> = T | Promise<T>
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every effect cleanup needs, injected so this module stays I/O-free.
|
|
142
|
+
*
|
|
143
|
+
* The service supplies the real implementations: the workspace registry for
|
|
144
|
+
* `listWorkspaces`/`removeWorkspace`, the routing tables for
|
|
145
|
+
* `activeRoutes`/`tombstones`, the live-agent set for `isLive`, and a
|
|
146
|
+
* filesystem walk for `statWorkspace`.
|
|
147
|
+
*/
|
|
148
|
+
export interface CleanupDeps {
|
|
149
|
+
/** Workspace root the plugin owns; candidates must be strictly inside it. */
|
|
150
|
+
readonly baseDir: string
|
|
151
|
+
/** Retention value from config (`off` disables planning). */
|
|
152
|
+
readonly retention: WorkspaceRetention
|
|
153
|
+
/** Every workspace currently registered with the host. */
|
|
154
|
+
listWorkspaces(): MaybePromise<readonly CleanupWorkspace[]>
|
|
155
|
+
/** Every active route record. */
|
|
156
|
+
activeRoutes(): MaybePromise<readonly CleanupRoute[]>
|
|
157
|
+
/** Every retired-route tombstone. */
|
|
158
|
+
tombstones(): MaybePromise<readonly CleanupTombstone[]>
|
|
159
|
+
/** Whether a DSH session is still live; live workspaces are never offered. */
|
|
160
|
+
isLive(sessionId: string): boolean
|
|
161
|
+
/** Measure one directory; `undefined` when it cannot be read (missing, permission). */
|
|
162
|
+
statWorkspace(workspacePath: string): MaybePromise<WorkspaceStats | undefined>
|
|
163
|
+
/** Delete one workspace directory and unregister its registry record. */
|
|
164
|
+
removeWorkspace(workspace: CleanupWorkspace): MaybePromise<void>
|
|
165
|
+
/** Retire one active route into a tombstone (same bookkeeping as route TTL). */
|
|
166
|
+
retireRoute(route: CleanupRoute): MaybePromise<void>
|
|
167
|
+
/** Clock seam; defaults to `Date.now()`. */
|
|
168
|
+
now?(): number
|
|
169
|
+
/** Warning seam for best-effort failures; defaults to silence. */
|
|
170
|
+
warn?(message: string): void
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One workspace old enough to delete, with its measured footprint. */
|
|
174
|
+
export interface CleanupCandidate {
|
|
175
|
+
/** Workspace to delete. */
|
|
176
|
+
readonly workspace: CleanupWorkspace
|
|
177
|
+
/** Newest activity observed for the workspace (epoch ms). */
|
|
178
|
+
readonly activityAt: number
|
|
179
|
+
/** Regular-file bytes below the directory (0 when unreadable). */
|
|
180
|
+
readonly bytes: number
|
|
181
|
+
/** Regular-file count below the directory (0 when unreadable). */
|
|
182
|
+
readonly files: number
|
|
183
|
+
/** Active routes still pointing at the workspace; retired before removal. */
|
|
184
|
+
readonly routes: readonly CleanupRoute[]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Result of one dry-run planning pass. */
|
|
188
|
+
export interface CleanupPlan {
|
|
189
|
+
/** Retention window used for this plan, in milliseconds. */
|
|
190
|
+
readonly retentionMs: number
|
|
191
|
+
/** Activity cutoff (epoch ms); workspaces at or after it were kept. */
|
|
192
|
+
readonly cutoff: number
|
|
193
|
+
/** Eligible workspaces, oldest activity first. */
|
|
194
|
+
readonly candidates: readonly CleanupCandidate[]
|
|
195
|
+
/** Plugin workspaces kept because a live session still uses them. */
|
|
196
|
+
readonly skippedLive: number
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One failed removal; the pass continues with the remaining candidates. */
|
|
200
|
+
export interface CleanupFailure {
|
|
201
|
+
/** Workspace directory the failure belongs to. */
|
|
202
|
+
readonly path: string
|
|
203
|
+
/** Human-readable reason. */
|
|
204
|
+
readonly message: string
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Result of one apply pass. */
|
|
208
|
+
export interface CleanupOutcome {
|
|
209
|
+
/** Workspaces whose directory was removed and registry record deleted. */
|
|
210
|
+
readonly deletedWorkspaces: number
|
|
211
|
+
/** Active routes retired before their workspace was removed. */
|
|
212
|
+
readonly retiredRoutes: number
|
|
213
|
+
/** Measured bytes of the deleted workspaces. */
|
|
214
|
+
readonly freedBytes: number
|
|
215
|
+
/** Per-workspace failures; other candidates were still processed. */
|
|
216
|
+
readonly failures: readonly CleanupFailure[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Whether a host workspace title marks the workspace as plugin-created. */
|
|
220
|
+
function pluginWorkspaceTitle(title: string): boolean {
|
|
221
|
+
return PLUGIN_TITLE_PREFIXES.some(prefix => title.startsWith(prefix))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Lexical path equality (both sides resolved the same way as `isPathInside`). */
|
|
225
|
+
function samePath(left: string, right: string): boolean {
|
|
226
|
+
return path.resolve(left) === path.resolve(right)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Newest finite timestamp of a list, or `undefined` when none is usable. */
|
|
230
|
+
function newestActivity(values: readonly number[]): number | undefined {
|
|
231
|
+
let newest: number | undefined
|
|
232
|
+
for (const value of values) {
|
|
233
|
+
if (!Number.isFinite(value)) continue
|
|
234
|
+
if (newest === undefined || value > newest) newest = value
|
|
235
|
+
}
|
|
236
|
+
return newest
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Count/byte values never go negative or non-finite on the outcome path. */
|
|
240
|
+
function nonNegative(value: number): number {
|
|
241
|
+
return Number.isFinite(value) && value > 0 ? value : 0
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Best-effort error text (never a stack trace). */
|
|
245
|
+
function errorMessage(error: unknown): string {
|
|
246
|
+
return error instanceof Error ? error.message : String(error)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A filesystem root must never become the cleanup base directory. */
|
|
250
|
+
function isFilesystemRoot(dir: string): boolean {
|
|
251
|
+
return path.parse(dir).root === dir
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Liveness probe that fails safe: an unknowable session counts as live. */
|
|
255
|
+
function sessionIsLive(deps: CleanupDeps, sessionId: string): boolean {
|
|
256
|
+
try {
|
|
257
|
+
return deps.isLive(sessionId)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
deps.warn?.(`dsh-channels: workspace cleanup liveness check failed for ${sessionId}: ${errorMessage(error)}`)
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Measure one directory, tolerating a throwing or absent implementation. */
|
|
265
|
+
async function readWorkspaceStats(
|
|
266
|
+
deps: CleanupDeps,
|
|
267
|
+
workspacePath: string,
|
|
268
|
+
): Promise<WorkspaceStats | undefined> {
|
|
269
|
+
try {
|
|
270
|
+
return await deps.statWorkspace(workspacePath)
|
|
271
|
+
} catch (error) {
|
|
272
|
+
deps.warn?.(`dsh-channels: workspace stats unavailable for ${workspacePath}: ${errorMessage(error)}`)
|
|
273
|
+
return undefined
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Build the dry-run cleanup plan for the configured retention window.
|
|
279
|
+
*
|
|
280
|
+
* Only plugin-created workspaces (`飞书私聊 ·` / `飞书群聊 ·` titles) strictly
|
|
281
|
+
* below `baseDir` are considered. A workspace is a candidate when its newest
|
|
282
|
+
* activity is older than the cutoff; its footprint is measured for the panel
|
|
283
|
+
* preview and for `freedBytes` reporting.
|
|
284
|
+
*
|
|
285
|
+
* @param deps - injected effect seams (nothing is deleted here).
|
|
286
|
+
* @returns the plan, or `undefined` when retention is off or the base
|
|
287
|
+
* directory is unusable (filesystem root).
|
|
288
|
+
*/
|
|
289
|
+
export async function planWorkspaceCleanup(deps: CleanupDeps): Promise<CleanupPlan | undefined> {
|
|
290
|
+
const window = retentionMs(deps.retention)
|
|
291
|
+
if (window === undefined) return undefined
|
|
292
|
+
|
|
293
|
+
const baseDir = path.resolve(deps.baseDir)
|
|
294
|
+
if (isFilesystemRoot(baseDir)) {
|
|
295
|
+
deps.warn?.('dsh-channels: workspace cleanup disabled: baseDir is a filesystem root')
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const now = deps.now?.() ?? Date.now()
|
|
300
|
+
const cutoff = now - window
|
|
301
|
+
const activeRoutes = await deps.activeRoutes()
|
|
302
|
+
const tombstones = await deps.tombstones()
|
|
303
|
+
const candidates: CleanupCandidate[] = []
|
|
304
|
+
let skippedLive = 0
|
|
305
|
+
|
|
306
|
+
for (const workspace of await deps.listWorkspaces()) {
|
|
307
|
+
if (!pluginWorkspaceTitle(workspace.title)) continue
|
|
308
|
+
if (!isPathInside(baseDir, workspace.path)) continue
|
|
309
|
+
|
|
310
|
+
const routes = activeRoutes.filter(route => samePath(route.workspacePath, workspace.path))
|
|
311
|
+
if (routes.some(route => sessionIsLive(deps, route.sessionId))) {
|
|
312
|
+
skippedLive++
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let stats: WorkspaceStats | undefined
|
|
317
|
+
let activityAt = newestActivity(routes.map(route => route.lastActivityAt))
|
|
318
|
+
if (activityAt === undefined) {
|
|
319
|
+
activityAt = newestActivity(
|
|
320
|
+
tombstones
|
|
321
|
+
.filter(tombstone => tombstone.workspacePath !== undefined
|
|
322
|
+
&& samePath(tombstone.workspacePath, workspace.path))
|
|
323
|
+
.map(tombstone => tombstone.lastActivityAt ?? tombstone.expiredAt),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (activityAt === undefined) {
|
|
327
|
+
// Orphan workspace: the directory's own mtime is the only age evidence.
|
|
328
|
+
stats = await readWorkspaceStats(deps, workspace.path)
|
|
329
|
+
activityAt = stats !== undefined && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined
|
|
330
|
+
}
|
|
331
|
+
if (activityAt === undefined || activityAt >= cutoff) continue
|
|
332
|
+
|
|
333
|
+
stats ??= await readWorkspaceStats(deps, workspace.path)
|
|
334
|
+
candidates.push({
|
|
335
|
+
workspace,
|
|
336
|
+
activityAt,
|
|
337
|
+
bytes: stats === undefined ? 0 : nonNegative(stats.bytes),
|
|
338
|
+
files: stats === undefined ? 0 : nonNegative(stats.files),
|
|
339
|
+
routes,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
candidates.sort((left, right) =>
|
|
344
|
+
left.activityAt - right.activityAt || left.workspace.path.localeCompare(right.workspace.path))
|
|
345
|
+
return { retentionMs: window, cutoff, candidates, skippedLive }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Apply a plan: retire the candidate's active routes, then remove the
|
|
350
|
+
* workspace directory and its registry record.
|
|
351
|
+
*
|
|
352
|
+
* A candidate is the unit of failure: if retiring any of its routes fails, the
|
|
353
|
+
* workspace is left untouched (route bookkeeping must not point at a deleted
|
|
354
|
+
* directory), the failure is collected, and the remaining candidates are still
|
|
355
|
+
* processed. Paths are revalidated immediately before the destructive step, so
|
|
356
|
+
* a stale or hand-built plan can never delete something outside `baseDir` (or
|
|
357
|
+
* `baseDir` itself).
|
|
358
|
+
*
|
|
359
|
+
* @param deps - injected effect seams.
|
|
360
|
+
* @param plan - plan produced by `planWorkspaceCleanup` (or an equivalent preview).
|
|
361
|
+
* @returns aggregate counts plus per-workspace failures.
|
|
362
|
+
*/
|
|
363
|
+
export async function applyWorkspaceCleanup(
|
|
364
|
+
deps: CleanupDeps,
|
|
365
|
+
plan: CleanupPlan,
|
|
366
|
+
): Promise<CleanupOutcome> {
|
|
367
|
+
const baseDir = path.resolve(deps.baseDir)
|
|
368
|
+
const baseDirIsRoot = isFilesystemRoot(baseDir)
|
|
369
|
+
const failures: CleanupFailure[] = []
|
|
370
|
+
let deletedWorkspaces = 0
|
|
371
|
+
let retiredRoutes = 0
|
|
372
|
+
let freedBytes = 0
|
|
373
|
+
|
|
374
|
+
for (const candidate of plan.candidates) {
|
|
375
|
+
const target = candidate.workspace.path
|
|
376
|
+
// Defense in depth: never trust a plan object for the destructive step.
|
|
377
|
+
if (baseDirIsRoot || !isPathInside(baseDir, target)) {
|
|
378
|
+
failures.push({ path: target, message: 'refused: workspace path is outside the cleanup base directory' })
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
for (const route of candidate.routes) {
|
|
383
|
+
if (!samePath(route.workspacePath, target)) continue
|
|
384
|
+
await deps.retireRoute(route)
|
|
385
|
+
retiredRoutes++
|
|
386
|
+
}
|
|
387
|
+
await deps.removeWorkspace(candidate.workspace)
|
|
388
|
+
deletedWorkspaces++
|
|
389
|
+
freedBytes += nonNegative(candidate.bytes)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
const message = errorMessage(error)
|
|
392
|
+
failures.push({ path: target, message })
|
|
393
|
+
deps.warn?.(`dsh-channels: workspace cleanup failed for ${target}: ${message}`)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { deletedWorkspaces, retiredRoutes, freedBytes, failures }
|
|
398
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin HTTP client for the settings panel. No typert contract: the
|
|
3
|
+
* Host half registers plain exact routes and this module fetches them.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-channels/client/api
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ACCESS_GROUPS_ROUTE,
|
|
10
|
+
ACCESS_REQUESTS_ROUTE,
|
|
11
|
+
ACCESS_ROUTE,
|
|
12
|
+
ACCESS_USERS_ROUTE,
|
|
13
|
+
CATALOG_ROUTE,
|
|
14
|
+
CLEANUP_ROUTE,
|
|
15
|
+
CONFIG_ROUTE,
|
|
16
|
+
CREDENTIALS_ROUTE,
|
|
17
|
+
PANEL_ROUTE,
|
|
18
|
+
RECONNECT_ROUTE,
|
|
19
|
+
STATUS_ROUTE,
|
|
20
|
+
} from '../settings/contract.ts'
|
|
21
|
+
import type {
|
|
22
|
+
AccessMutationResponse,
|
|
23
|
+
AccessWire,
|
|
24
|
+
CatalogWire,
|
|
25
|
+
ChannelStatusWire,
|
|
26
|
+
CleanupResponse,
|
|
27
|
+
ClearConfigResponse,
|
|
28
|
+
ClearCredentialResponse,
|
|
29
|
+
CredentialField,
|
|
30
|
+
EditableValues,
|
|
31
|
+
PanelWire,
|
|
32
|
+
ReconnectResponse,
|
|
33
|
+
SaveConfigResponse,
|
|
34
|
+
SaveCredentialsRequest,
|
|
35
|
+
SaveCredentialsResponse,
|
|
36
|
+
} from '../settings/contract.ts'
|
|
37
|
+
|
|
38
|
+
function messageOf(error: unknown): string {
|
|
39
|
+
return error instanceof Error ? error.message : String(error)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pull the JSON `error` field out of a failed response body. */
|
|
43
|
+
function errorFromBody(status: number, text: string): string {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(text) as { error?: unknown }
|
|
46
|
+
if (typeof parsed.error === 'string' && parsed.error !== '') return parsed.error
|
|
47
|
+
} catch {
|
|
48
|
+
// Not JSON — fall through to the HTTP status.
|
|
49
|
+
}
|
|
50
|
+
return `HTTP ${status}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function request(path: string, init?: RequestInit): Promise<Response> {
|
|
54
|
+
try {
|
|
55
|
+
return await fetch(path, {
|
|
56
|
+
cache: 'no-store',
|
|
57
|
+
credentials: 'same-origin',
|
|
58
|
+
...init,
|
|
59
|
+
headers: { accept: 'application/json', ...(init?.headers ?? {}) },
|
|
60
|
+
})
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error(`无法连接设置接口:${messageOf(error)}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
67
|
+
const text = await response.text()
|
|
68
|
+
if (!response.ok) throw new Error(errorFromBody(response.status, text))
|
|
69
|
+
return JSON.parse(text) as T
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Full panel payload (status + editable config + read-only facts). */
|
|
73
|
+
export async function fetchPanel(): Promise<PanelWire> {
|
|
74
|
+
return readJson<PanelWire>(await request(PANEL_ROUTE))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Status-only payload polled while the page is open. */
|
|
78
|
+
export async function fetchStatus(): Promise<ChannelStatusWire> {
|
|
79
|
+
return readJson<ChannelStatusWire>(await request(STATUS_ROUTE))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Model-selector catalog for one route: providers, the provider's models, the
|
|
84
|
+
* model's reasoning efforts, and the preset roster. The client re-queries it
|
|
85
|
+
* whenever the draft provider/model changes.
|
|
86
|
+
*/
|
|
87
|
+
export async function fetchCatalog(provider?: string, model?: string): Promise<CatalogWire> {
|
|
88
|
+
const params = new URLSearchParams()
|
|
89
|
+
if (provider !== undefined && provider !== '') params.set('provider', provider)
|
|
90
|
+
if (model !== undefined && model !== '') params.set('model', model)
|
|
91
|
+
const query = params.toString()
|
|
92
|
+
return readJson<CatalogWire>(await request(query === '' ? CATALOG_ROUTE : `${CATALOG_ROUTE}?${query}`))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Persist a partial edit. Validation failures come back as `{ok:false}`. */
|
|
96
|
+
export async function saveConfig(values: EditableValues): Promise<SaveConfigResponse> {
|
|
97
|
+
const response = await request(CONFIG_ROUTE, {
|
|
98
|
+
method: 'PUT',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ values }),
|
|
101
|
+
})
|
|
102
|
+
const text = await response.text()
|
|
103
|
+
if (response.status === 400) {
|
|
104
|
+
// Validation/IO refusal: the body is already the SaveConfigResponse.
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text) as SaveConfigResponse
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) throw new Error(errorFromBody(response.status, text))
|
|
112
|
+
return JSON.parse(text) as SaveConfigResponse
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Drop every Web override and return the refreshed panel. */
|
|
116
|
+
export async function clearConfig(): Promise<ClearConfigResponse> {
|
|
117
|
+
const response = await request(CONFIG_ROUTE, { method: 'DELETE' })
|
|
118
|
+
const text = await response.text()
|
|
119
|
+
if (response.status === 400) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(text) as ClearConfigResponse
|
|
122
|
+
} catch {
|
|
123
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!response.ok) throw new Error(errorFromBody(response.status, text))
|
|
127
|
+
return JSON.parse(text) as ClearConfigResponse
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Reopen the Feishu long connection (interrupts in-flight replies). */
|
|
131
|
+
export async function reconnect(): Promise<ReconnectResponse> {
|
|
132
|
+
const response = await request(RECONNECT_ROUTE, { method: 'POST' })
|
|
133
|
+
const text = await response.text()
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(text) as ReconnectResponse
|
|
137
|
+
} catch {
|
|
138
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return JSON.parse(text) as ReconnectResponse
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run one workspace cleanup. No request body: the Host derives the candidate
|
|
146
|
+
* set from the effective retention config. Irreversible, so the caller asks
|
|
147
|
+
* for confirmation first; partial failures come back inside `result.failures`.
|
|
148
|
+
*/
|
|
149
|
+
export async function runCleanup(): Promise<CleanupResponse> {
|
|
150
|
+
const response = await request(CLEANUP_ROUTE, { method: 'POST' })
|
|
151
|
+
const text = await response.text()
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(text) as CleanupResponse
|
|
155
|
+
} catch {
|
|
156
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return JSON.parse(text) as CleanupResponse
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Store provided credential values (the request/response never carry stored values). */
|
|
163
|
+
export async function saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
|
|
164
|
+
const response = await request(CREDENTIALS_ROUTE, {
|
|
165
|
+
method: 'PUT',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify(values),
|
|
168
|
+
})
|
|
169
|
+
const text = await response.text()
|
|
170
|
+
if (response.status === 400) {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(text) as SaveCredentialsResponse
|
|
173
|
+
} catch {
|
|
174
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!response.ok) throw new Error(errorFromBody(response.status, text))
|
|
178
|
+
return JSON.parse(text) as SaveCredentialsResponse
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Remove the stored value behind one credential reference. */
|
|
182
|
+
export async function clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
|
|
183
|
+
const response = await request(CREDENTIALS_ROUTE, {
|
|
184
|
+
method: 'DELETE',
|
|
185
|
+
headers: { 'content-type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ field }),
|
|
187
|
+
})
|
|
188
|
+
const text = await response.text()
|
|
189
|
+
if (response.status === 400) {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(text) as ClearCredentialResponse
|
|
192
|
+
} catch {
|
|
193
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!response.ok) throw new Error(errorFromBody(response.status, text))
|
|
197
|
+
return JSON.parse(text) as ClearCredentialResponse
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Admission snapshot: mode, administrator, whitelists, pending requests. */
|
|
201
|
+
export async function fetchAccess(): Promise<AccessWire> {
|
|
202
|
+
return readJson<AccessWire>(await request(ACCESS_ROUTE))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One access write: a 400 body already is the wire union; others throw. */
|
|
206
|
+
async function mutateAccess(
|
|
207
|
+
path: string,
|
|
208
|
+
method: 'POST' | 'DELETE',
|
|
209
|
+
body: unknown,
|
|
210
|
+
): Promise<AccessMutationResponse> {
|
|
211
|
+
const response = await request(path, {
|
|
212
|
+
method,
|
|
213
|
+
headers: { 'content-type': 'application/json' },
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
})
|
|
216
|
+
const text = await response.text()
|
|
217
|
+
if (response.status === 400) {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(text) as AccessMutationResponse
|
|
220
|
+
} catch {
|
|
221
|
+
return { ok: false, error: errorFromBody(response.status, text) }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!response.ok) throw new Error(errorFromBody(response.status, text))
|
|
225
|
+
return JSON.parse(text) as AccessMutationResponse
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Authorize one group; resolves with the refreshed access snapshot. */
|
|
229
|
+
export async function addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
|
|
230
|
+
return mutateAccess(
|
|
231
|
+
ACCESS_GROUPS_ROUTE,
|
|
232
|
+
'POST',
|
|
233
|
+
name === undefined || name === '' ? { chatId } : { chatId, name },
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Revoke one group authorization (takes effect on the next message). */
|
|
238
|
+
export async function removeAccessGroup(chatId: string): Promise<AccessMutationResponse> {
|
|
239
|
+
return mutateAccess(ACCESS_GROUPS_ROUTE, 'DELETE', { chatId })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Authorize one user open_id (global across authorized groups). */
|
|
243
|
+
export async function addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse> {
|
|
244
|
+
return mutateAccess(
|
|
245
|
+
ACCESS_USERS_ROUTE,
|
|
246
|
+
'POST',
|
|
247
|
+
name === undefined || name === '' ? { openId } : { openId, name },
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Revoke one user authorization (takes effect on the next message). */
|
|
252
|
+
export async function removeAccessUser(openId: string): Promise<AccessMutationResponse> {
|
|
253
|
+
return mutateAccess(ACCESS_USERS_ROUTE, 'DELETE', { openId })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Settle one pending authorization request (same resolver as the Feishu card). */
|
|
257
|
+
export async function resolveAccessRequest(
|
|
258
|
+
token: string,
|
|
259
|
+
decision: 'allow' | 'deny',
|
|
260
|
+
): Promise<AccessMutationResponse> {
|
|
261
|
+
return mutateAccess(ACCESS_REQUESTS_ROUTE, 'POST', { token, decision })
|
|
262
|
+
}
|