@modesto_ma/dsh-channels 0.1.0-beta.1 → 0.2.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 +39 -29
- package/docs/access-control-design.md +4 -4
- package/docs/feishu-onboarding.md +11 -7
- package/lib/client.js +1698 -875
- package/lib/client.js.map +1 -1
- package/lib/index.js +447 -78
- package/lib/index.js.map +1 -1
- package/package.json +16 -14
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +104 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +17 -4
- package/src/index.ts +24 -8
- package/src/service.ts +142 -26
- package/src/settings/contract.ts +207 -35
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +159 -21
- package/src/client/panel.tsx +0 -1475
package/src/config.ts
CHANGED
|
@@ -21,6 +21,10 @@ export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
|
|
|
21
21
|
/** Same shape as the host credentials seam's `CredentialRef` (shell identifier). */
|
|
22
22
|
export const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
23
23
|
|
|
24
|
+
/** Conventional credential-reference names used when the loader passes no config. */
|
|
25
|
+
export const DEFAULT_APP_ID_REF = 'FEISHU_APP_ID'
|
|
26
|
+
export const DEFAULT_APP_SECRET_REF = 'FEISHU_APP_SECRET'
|
|
27
|
+
|
|
24
28
|
/** Default route-table GC threshold: 30 days without activity. */
|
|
25
29
|
export const DEFAULT_ROUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
26
30
|
|
|
@@ -37,10 +41,10 @@ const credentialRefSchema = z.string().regex(
|
|
|
37
41
|
)
|
|
38
42
|
|
|
39
43
|
export const configSchema = z.object({
|
|
40
|
-
/** Feishu app id credential reference (
|
|
41
|
-
appId: credentialRefSchema,
|
|
42
|
-
/** Feishu app secret credential reference (
|
|
43
|
-
appSecret: credentialRefSchema,
|
|
44
|
+
/** Feishu app id credential reference (defaults to `FEISHU_APP_ID`). */
|
|
45
|
+
appId: credentialRefSchema.default(DEFAULT_APP_ID_REF),
|
|
46
|
+
/** Feishu app secret credential reference (defaults to `FEISHU_APP_SECRET`). */
|
|
47
|
+
appSecret: credentialRefSchema.default(DEFAULT_APP_SECRET_REF),
|
|
44
48
|
/**
|
|
45
49
|
* Root directory for per-(user, chat) workspace directories — the filesystem
|
|
46
50
|
* root where files produced by channel sessions land. Read per session
|
|
@@ -188,6 +192,15 @@ export const configSchema = z.object({
|
|
|
188
192
|
|
|
189
193
|
export type FeishuConfig = z.infer<typeof configSchema>
|
|
190
194
|
|
|
195
|
+
/**
|
|
196
|
+
* DSH loader-facing schema. The bundle patch inserts this plugin without a
|
|
197
|
+
* `config` object; the loader validates that row with `undefined` before the
|
|
198
|
+
* user's patch layer supplies values. Accept that absence and fill the same
|
|
199
|
+
* defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
|
|
200
|
+
* cannot fail with "expected object, received undefined".
|
|
201
|
+
*/
|
|
202
|
+
export const pluginConfigSchema = configSchema.prefault({})
|
|
203
|
+
|
|
191
204
|
/**
|
|
192
205
|
* Validate the raw loader config. Values stay credential *references*; the
|
|
193
206
|
* secrets themselves resolve through `ctx.credentials` at startup.
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { HostContext } from './dsh-types.ts'
|
|
17
17
|
import { FeishuService } from './service.ts'
|
|
18
|
-
import {
|
|
18
|
+
import { pluginConfigSchema, resolveConfig } from './config.ts'
|
|
19
19
|
import type { FeishuConfig } from './config.ts'
|
|
20
20
|
import { defaultChannelFactory } from './feishu-channel.ts'
|
|
21
21
|
import type { ChannelFactory } from './feishu-channel.ts'
|
|
@@ -23,6 +23,7 @@ import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
|
|
|
23
23
|
import { mountSettingsRoutes } from './settings/routes.ts'
|
|
24
24
|
import { SettingsSurface } from './settings/surface.ts'
|
|
25
25
|
import { createHostCatalog } from './settings/catalog.ts'
|
|
26
|
+
import { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
26
27
|
|
|
27
28
|
export type * from './config.ts'
|
|
28
29
|
export type * from './cards.ts'
|
|
@@ -38,12 +39,15 @@ export type * from './questions.ts'
|
|
|
38
39
|
export type * from './settings/contract.ts'
|
|
39
40
|
export * from './access/index.ts'
|
|
40
41
|
export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
|
|
41
|
-
export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
|
|
42
|
+
export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
|
|
43
|
+
export type { PickDirectoryResponse } from './settings/contract.ts'
|
|
42
44
|
export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
|
|
43
|
-
export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface } from './settings/surface.ts'
|
|
45
|
+
export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
|
|
44
46
|
export type { SettingsTarget } from './settings/surface.ts'
|
|
45
47
|
export { createHostCatalog } from './settings/catalog.ts'
|
|
46
48
|
export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
|
|
49
|
+
export { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
50
|
+
export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
47
51
|
export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
|
|
48
52
|
export type { HostWebServer } from './settings/routes.ts'
|
|
49
53
|
export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
|
|
@@ -83,7 +87,7 @@ export const inject = ['agents', 'commands', 'workspaceRegistry', 'storageDomain
|
|
|
83
87
|
* its `resolveConfig()` fallback for direct invocation (unit tests, embedded
|
|
84
88
|
* hosts) where config has not been pre-validated.
|
|
85
89
|
*/
|
|
86
|
-
export const Config =
|
|
90
|
+
export const Config = pluginConfigSchema
|
|
87
91
|
|
|
88
92
|
export interface FeishuPluginOptions extends FeishuConfig {
|
|
89
93
|
/** Test seam: substitute a channel implementation. */
|
|
@@ -106,16 +110,28 @@ export function apply(ctx: HostContext, config?: unknown): void {
|
|
|
106
110
|
)
|
|
107
111
|
const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
|
|
108
112
|
// Settings routes ride the host's web server when this deployment has one.
|
|
109
|
-
// The catalog resolves the panel's model selectors from live host services
|
|
113
|
+
// The catalog resolves the panel's model selectors from live host services;
|
|
114
|
+
// the optional native directory picker backs the workspace-root「选择目录…」
|
|
115
|
+
// button (absent in headless/remote compositions → plain text input).
|
|
110
116
|
const warn = (message: string, ...args: unknown[]): void => { ctx.logger?.warn?.(message, ...args) }
|
|
111
|
-
mountSettingsRoutes(ctx, new SettingsSurface(
|
|
117
|
+
mountSettingsRoutes(ctx, new SettingsSurface(
|
|
118
|
+
service,
|
|
119
|
+
overlay,
|
|
120
|
+
{ warn },
|
|
121
|
+
createHostCatalog(ctx, warn),
|
|
122
|
+
createSettingsDirectoryPicker(ctx),
|
|
123
|
+
))
|
|
112
124
|
ctx.effect(() => {
|
|
125
|
+
// Boot loads the Web overlay and connects only when the Feishu application
|
|
126
|
+
// credentials are complete; an incomplete configuration or a failed
|
|
127
|
+
// connection is a panel-visible state, not a plugin failure. Only an
|
|
128
|
+
// unexpected boot error (e.g. unreadable overlay) reaches this catch.
|
|
113
129
|
void service.boot().catch((error: unknown) => {
|
|
114
|
-
ctx.logger?.error?.('dsh-channels failed to
|
|
130
|
+
ctx.logger?.error?.('dsh-channels failed to boot: %s', String(error))
|
|
115
131
|
// Critical lifecycle line duplicated to the console: cordis logger
|
|
116
132
|
// messages emitted before a web exporter attaches are buffered and may
|
|
117
133
|
// never reach stdout, which makes real-device debugging guesswork.
|
|
118
|
-
console.error('[dsh-channels] failed to
|
|
134
|
+
console.error('[dsh-channels] failed to boot:', error)
|
|
119
135
|
})
|
|
120
136
|
return () => service.shutdown()
|
|
121
137
|
}, 'dsh-channels')
|
package/src/service.ts
CHANGED
|
@@ -281,13 +281,21 @@ export class FeishuService {
|
|
|
281
281
|
private started = false
|
|
282
282
|
/** Epoch ms when `start()` completed (uptime for `/status`). */
|
|
283
283
|
private startedAt: number | undefined
|
|
284
|
-
/** Last failed
|
|
284
|
+
/** Last failed connection attempt, surfaced in the Web settings panel. */
|
|
285
285
|
private lastStartError: string | undefined
|
|
286
|
+
/** Whether both Feishu application credentials currently resolve. */
|
|
287
|
+
private configured = false
|
|
288
|
+
/** Names of the missing credential references (never their values). */
|
|
289
|
+
private missingConfigItems: string[] = []
|
|
290
|
+
/** Guards the panel's explicit connect action (distinct from a restart). */
|
|
291
|
+
private connecting = false
|
|
286
292
|
/** Effective config captured at the last successful connect (restart-pending diff). */
|
|
287
293
|
private runningConfig: FeishuConfig | undefined
|
|
288
294
|
/** Guards overlapping panel-triggered reconnects. */
|
|
289
295
|
private restarting = false
|
|
290
296
|
private unlisten: (() => void) | undefined
|
|
297
|
+
/** Disposers for host-scoped listeners registered by `start()`. */
|
|
298
|
+
private readonly unlistenHost: Array<() => boolean> = []
|
|
291
299
|
/** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
|
|
292
300
|
private gcTimer: ReturnType<typeof setInterval> | undefined
|
|
293
301
|
/** Periodic workspace-retention timer (only armed when auto cleanup is on). */
|
|
@@ -396,27 +404,105 @@ export class FeishuService {
|
|
|
396
404
|
// ---- lifecycle / Web settings panel ----------------------------------
|
|
397
405
|
|
|
398
406
|
/**
|
|
399
|
-
* Load the Web overlay
|
|
400
|
-
*
|
|
401
|
-
*
|
|
407
|
+
* Load the Web overlay and open the long connection when — and only when —
|
|
408
|
+
* the Feishu application credentials are complete.
|
|
409
|
+
*
|
|
410
|
+
* An incomplete configuration is a normal state, not an error: the plugin
|
|
411
|
+
* stays loaded so the Web settings panel can guide the user through the
|
|
412
|
+
* credentials form, and no connection is attempted. A failed connection
|
|
413
|
+
* attempt is recorded for the panel instead of failing the plugin fiber.
|
|
402
414
|
*/
|
|
403
415
|
async boot(): Promise<void> {
|
|
416
|
+
if (this.connecting || this.restarting) return
|
|
417
|
+
// Acquire the lifecycle lock before the first await so a panel action can
|
|
418
|
+
// never interleave with the boot sequence.
|
|
419
|
+
this.connecting = true
|
|
404
420
|
try {
|
|
405
421
|
await this.loadOverlay()
|
|
422
|
+
await this.refreshReadiness()
|
|
423
|
+
if (!this.configured) {
|
|
424
|
+
this.lastStartError = undefined
|
|
425
|
+
this.runningConfig = undefined
|
|
426
|
+
this.ctx.logger?.info?.(
|
|
427
|
+
'dsh-channels: 飞书应用凭据未配置完整(缺少 %s),本次不连接飞书;'
|
|
428
|
+
+ '请在 Web 设置面板 → 飞书渠道中配置,保存后会自动连接',
|
|
429
|
+
this.missingConfigItems.join('、'),
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
await this.openConnection()
|
|
434
|
+
} catch {
|
|
435
|
+
// Recorded in `lastStartError`; the plugin itself stays loaded.
|
|
436
|
+
} finally {
|
|
437
|
+
this.connecting = false
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Refresh the cached configuration readiness from the credentials seam.
|
|
443
|
+
* Only presence is checked (describe/resolve), never values; the secrets
|
|
444
|
+
* themselves are resolved by `start()` when a connection is opened.
|
|
445
|
+
*/
|
|
446
|
+
async refreshReadiness(): Promise<void> {
|
|
447
|
+
const states = await this.credentialStates()
|
|
448
|
+
const missing: string[] = []
|
|
449
|
+
if (!states.appId.configured) missing.push(`${this.config.appId}(飞书 App ID)`)
|
|
450
|
+
if (!states.appSecret.configured) missing.push(`${this.config.appSecret}(飞书 App Secret)`)
|
|
451
|
+
this.missingConfigItems = missing
|
|
452
|
+
this.configured = missing.length === 0
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Open the connection with the live config. A failure tears the partial
|
|
457
|
+
* service down and records the reason for the panel before rethrowing it to
|
|
458
|
+
* the caller; the plugin fiber is never failed by a connection problem.
|
|
459
|
+
*/
|
|
460
|
+
private async openConnection(): Promise<void> {
|
|
461
|
+
try {
|
|
406
462
|
await this.start()
|
|
407
463
|
this.lastStartError = undefined
|
|
408
464
|
this.runningConfig = { ...this.config }
|
|
409
465
|
} catch (error) {
|
|
410
466
|
this.lastStartError = messageOf(error)
|
|
467
|
+
this.ctx.logger?.error?.('dsh-channels: 飞书连接失败:%s', String(error))
|
|
468
|
+
// Headless profiles have no settings panel; keep the failure visible on
|
|
469
|
+
// the console, symmetric with the success line at the end of `start()`.
|
|
470
|
+
console.error('[dsh-channels] feishu connection failed:', error)
|
|
411
471
|
try {
|
|
412
472
|
await this.shutdown()
|
|
413
473
|
} catch {
|
|
414
|
-
// The
|
|
474
|
+
// The connection error is the useful one.
|
|
415
475
|
}
|
|
416
476
|
throw error
|
|
417
477
|
}
|
|
418
478
|
}
|
|
419
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Panel action: open the long connection when the channel is stopped.
|
|
482
|
+
* Credentials are re-checked first — an incomplete pair throws a message the
|
|
483
|
+
* panel shows next to the credentials form instead of attempting a connect.
|
|
484
|
+
*/
|
|
485
|
+
async connect(): Promise<void> {
|
|
486
|
+
if (this.started) return
|
|
487
|
+
// Boot, an explicit connect and a restart all drive the same connection
|
|
488
|
+
// wiring; serialize them instead of racing two `start()` bodies. The lock
|
|
489
|
+
// is acquired synchronously, before any await.
|
|
490
|
+
if (this.connecting || this.restarting) throw new Error('飞书连接正在连接或重启,请稍候再试')
|
|
491
|
+
this.connecting = true
|
|
492
|
+
try {
|
|
493
|
+
// A stopped channel may have been edited on disk (the overlay file is
|
|
494
|
+
// writable by hand); connecting re-reads it like a restart does.
|
|
495
|
+
await this.loadOverlay()
|
|
496
|
+
await this.refreshReadiness()
|
|
497
|
+
if (!this.configured) {
|
|
498
|
+
throw new Error(`飞书应用凭据尚未配置完整:缺少 ${this.missingConfigItems.join('、')}`)
|
|
499
|
+
}
|
|
500
|
+
await this.openConnection()
|
|
501
|
+
} finally {
|
|
502
|
+
this.connecting = false
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
420
506
|
/** Re-read the Web overlay over the profile config (no channel touch). */
|
|
421
507
|
private async loadOverlay(): Promise<void> {
|
|
422
508
|
const overlay = this.overlay === undefined ? {} : await this.overlay.load()
|
|
@@ -444,26 +530,24 @@ export class FeishuService {
|
|
|
444
530
|
this.refreshRetentionTimer()
|
|
445
531
|
}
|
|
446
532
|
|
|
447
|
-
/**
|
|
533
|
+
/**
|
|
534
|
+
* Tear the connection down and bring it back with the latest config. When the
|
|
535
|
+
* credentials are no longer complete the channel simply stays stopped (no
|
|
536
|
+
* error): the panel then shows the configuration guide instead of a failure.
|
|
537
|
+
*/
|
|
448
538
|
async restart(): Promise<void> {
|
|
449
|
-
if (this.restarting) throw new Error('
|
|
539
|
+
if (this.restarting || this.connecting) throw new Error('飞书连接正在连接或重启,请稍候再试')
|
|
450
540
|
this.restarting = true
|
|
451
541
|
try {
|
|
452
542
|
await this.shutdown()
|
|
453
543
|
await this.loadOverlay()
|
|
454
|
-
await this.
|
|
455
|
-
this.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
// `start()` flips `started` before wiring; a failed reconnect must not
|
|
460
|
-
// leave the panel claiming a live connection.
|
|
461
|
-
try {
|
|
462
|
-
await this.shutdown()
|
|
463
|
-
} catch {
|
|
464
|
-
// The restart error is the useful one.
|
|
544
|
+
await this.refreshReadiness()
|
|
545
|
+
if (!this.configured) {
|
|
546
|
+
this.lastStartError = undefined
|
|
547
|
+
this.runningConfig = undefined
|
|
548
|
+
return
|
|
465
549
|
}
|
|
466
|
-
|
|
550
|
+
await this.openConnection()
|
|
467
551
|
} finally {
|
|
468
552
|
this.restarting = false
|
|
469
553
|
}
|
|
@@ -471,6 +555,12 @@ export class FeishuService {
|
|
|
471
555
|
|
|
472
556
|
isRunning(): boolean { return this.started }
|
|
473
557
|
isRestarting(): boolean { return this.restarting }
|
|
558
|
+
/** True while the panel's explicit connect action is in flight. */
|
|
559
|
+
isConnecting(): boolean { return this.connecting }
|
|
560
|
+
/** Whether both Feishu application credentials currently resolve. */
|
|
561
|
+
isConfigured(): boolean { return this.configured }
|
|
562
|
+
/** Names of the missing configuration items (never values). */
|
|
563
|
+
missingConfig(): readonly string[] { return this.missingConfigItems }
|
|
474
564
|
startedAtMs(): number | undefined { return this.startedAt }
|
|
475
565
|
uptimeMs(): number | undefined {
|
|
476
566
|
return this.startedAt === undefined || !this.started ? undefined : Math.max(0, Date.now() - this.startedAt)
|
|
@@ -489,6 +579,9 @@ export class FeishuService {
|
|
|
489
579
|
const admin = this.config.adminOpenId
|
|
490
580
|
return {
|
|
491
581
|
mode: this.config.accessMode,
|
|
582
|
+
// Whitelists live in the storage domain, which is only open while the
|
|
583
|
+
// channel runs; an unconnected panel must not present them as empty.
|
|
584
|
+
loaded: this.domain !== undefined,
|
|
492
585
|
admin: {
|
|
493
586
|
...(admin === undefined ? {} : { openId: admin }),
|
|
494
587
|
configured: admin !== undefined,
|
|
@@ -529,7 +622,7 @@ export class FeishuService {
|
|
|
529
622
|
const id = this.cleanAccessTargetId(chatId)
|
|
530
623
|
if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
|
|
531
624
|
const admin = this.config.adminOpenId
|
|
532
|
-
if (admin === undefined) return { ok: false, error: '
|
|
625
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
|
|
533
626
|
try {
|
|
534
627
|
if (!this.accessStore.hasGroup(id)) {
|
|
535
628
|
await this.accessStore.addGroup({
|
|
@@ -563,7 +656,7 @@ export class FeishuService {
|
|
|
563
656
|
const id = this.cleanAccessUserId(openId)
|
|
564
657
|
if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
|
|
565
658
|
const admin = this.config.adminOpenId
|
|
566
|
-
if (admin === undefined) return { ok: false, error: '
|
|
659
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
|
|
567
660
|
try {
|
|
568
661
|
if (!this.accessStore.hasUser(id)) {
|
|
569
662
|
await this.accessStore.addUser({
|
|
@@ -597,7 +690,7 @@ export class FeishuService {
|
|
|
597
690
|
const requests = this.accessRequests
|
|
598
691
|
if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
|
|
599
692
|
const actor = this.config.adminOpenId
|
|
600
|
-
if (actor === undefined) return { ok: false, error: '
|
|
693
|
+
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
|
|
601
694
|
try {
|
|
602
695
|
const result = await requests.resolve(token, decision, actor)
|
|
603
696
|
if (!result.ok) return { ok: false, error: result.error }
|
|
@@ -646,6 +739,17 @@ export class FeishuService {
|
|
|
646
739
|
...(window === undefined ? {} : { thresholdMs: window }),
|
|
647
740
|
auto: this.config.workspaceRetentionAuto,
|
|
648
741
|
}
|
|
742
|
+
if (this.domain === undefined) {
|
|
743
|
+
// No connection: routes live in the closed storage domain, so a preview
|
|
744
|
+
// would mistake every workspace for a candidate. Report why instead.
|
|
745
|
+
return {
|
|
746
|
+
...base,
|
|
747
|
+
workspaces: 0,
|
|
748
|
+
candidates: 0,
|
|
749
|
+
candidateBytes: 0,
|
|
750
|
+
error: '飞书连接建立后即可预览和清理工作区',
|
|
751
|
+
}
|
|
752
|
+
}
|
|
649
753
|
try {
|
|
650
754
|
const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
|
|
651
755
|
const plan = await planWorkspaceCleanup(this.cleanupDeps())
|
|
@@ -675,6 +779,12 @@ export class FeishuService {
|
|
|
675
779
|
error?: string
|
|
676
780
|
}> {
|
|
677
781
|
const empty = { ok: false, deletedWorkspaces: 0, retiredRoutes: 0, freedBytes: 0, failures: [] as string[] }
|
|
782
|
+
if (this.domain === undefined) {
|
|
783
|
+
// Without the storage domain the route table is unreadable, so a cleanup
|
|
784
|
+
// could mistake live workspaces for candidates and fail to retire their
|
|
785
|
+
// routes. Refuse rather than rely on the panel disabling the button.
|
|
786
|
+
return { ...empty, error: '飞书连接建立后才能清理工作区' }
|
|
787
|
+
}
|
|
678
788
|
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
679
789
|
return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
|
|
680
790
|
}
|
|
@@ -840,6 +950,7 @@ export class FeishuService {
|
|
|
840
950
|
} catch (error) {
|
|
841
951
|
throw new Error(`写入凭据引用 ${ref} 失败:${messageOf(error)}`)
|
|
842
952
|
}
|
|
953
|
+
await this.refreshReadiness()
|
|
843
954
|
}
|
|
844
955
|
|
|
845
956
|
/** Remove the stored value of one Feishu credential reference. */
|
|
@@ -854,6 +965,7 @@ export class FeishuService {
|
|
|
854
965
|
} catch (error) {
|
|
855
966
|
throw new Error(`移除凭据引用 ${ref} 失败:${messageOf(error)}`)
|
|
856
967
|
}
|
|
968
|
+
await this.refreshReadiness()
|
|
857
969
|
}
|
|
858
970
|
|
|
859
971
|
/** The overlay-independent ref name backing one editable credential slot. */
|
|
@@ -957,14 +1069,14 @@ export class FeishuService {
|
|
|
957
1069
|
questions.onCardAction(event)
|
|
958
1070
|
})
|
|
959
1071
|
|
|
960
|
-
this.ctx.on('session/event', (session, event) => {
|
|
1072
|
+
this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
|
|
961
1073
|
this.streamer?.onSessionEvent(session, event)
|
|
962
|
-
})
|
|
1074
|
+
}))
|
|
963
1075
|
// DSH removed the durable `assistant/chunk` event (format v2 embeds the
|
|
964
1076
|
// stream in the settlement); the live deltas arrive here instead.
|
|
965
|
-
this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
1077
|
+
this.unlistenHost.push(this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
966
1078
|
this.streamer?.onAssistantFrame(agent.session, frame)
|
|
967
|
-
})
|
|
1079
|
+
}))
|
|
968
1080
|
this.unlisten = channel.onMessage(message => {
|
|
969
1081
|
void this.inbound.handle(message).catch((error: unknown) => {
|
|
970
1082
|
this.ctx.logger?.warn?.('dsh-channels inbound error: %s', String(error))
|
|
@@ -1053,6 +1165,10 @@ export class FeishuService {
|
|
|
1053
1165
|
this.unlistenApproval = undefined
|
|
1054
1166
|
this.unlistenQuestions?.()
|
|
1055
1167
|
this.unlistenQuestions = undefined
|
|
1168
|
+
// Host-scoped listeners must be released on every teardown: a failed
|
|
1169
|
+
// connect retry or a reconnect calls `start()` again, and a leaked pair
|
|
1170
|
+
// would feed the same session events into the streamer twice.
|
|
1171
|
+
for (const dispose of this.unlistenHost.splice(0)) dispose()
|
|
1056
1172
|
// Withdraw in-flight approval questions (the host settles them cancelled).
|
|
1057
1173
|
this.approvalAnswerer?.dispose()
|
|
1058
1174
|
this.approvalAnswerer = undefined
|