@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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. 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
+ }