@modesto_ma/dsh-channels 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
package/src/service.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* - p2p: one workspace per (bot, user), all of that user's sessions inside;
|
|
7
7
|
* - group: one workspace per (chat, user), sessions grouped per user.
|
|
8
8
|
*
|
|
9
|
+
* Every topic's Session works in its own directory inside that workspace
|
|
10
|
+
* (`topics/<token>` or a Git worktree), so topics never mix files while the
|
|
11
|
+
* workspace list grows with users instead of conversations.
|
|
12
|
+
*
|
|
9
13
|
* Routing model:
|
|
10
14
|
* - `thread_id` → bound session (no @ needed inside an owned topic);
|
|
11
15
|
* - degraded chat-scoped keys when Feishu refuses topic replies;
|
|
@@ -45,11 +49,11 @@ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
|
|
|
45
49
|
import { commandSpec } from './commands/registry.ts'
|
|
46
50
|
import type { ChannelCommandSpec } from './commands/registry.ts'
|
|
47
51
|
import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
|
|
48
|
-
import { feishuDomain } from './domain.ts'
|
|
52
|
+
import { feishuDomain, sessionDirOf } from './domain.ts'
|
|
49
53
|
import {
|
|
50
54
|
applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
|
|
51
55
|
} from './cleanup.ts'
|
|
52
|
-
import type { CleanupDeps, WorkspaceStats } from './cleanup.ts'
|
|
56
|
+
import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
|
|
53
57
|
import type {
|
|
54
58
|
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
55
59
|
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
|
|
@@ -83,6 +87,7 @@ import type {
|
|
|
83
87
|
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
84
88
|
} from './settings/contract.ts'
|
|
85
89
|
import type { OverlayStore } from './settings/overlay.ts'
|
|
90
|
+
import { legacyTopicWorkspaceRoot } from './workspaces.ts'
|
|
86
91
|
|
|
87
92
|
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
88
93
|
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
@@ -281,13 +286,21 @@ export class FeishuService {
|
|
|
281
286
|
private started = false
|
|
282
287
|
/** Epoch ms when `start()` completed (uptime for `/status`). */
|
|
283
288
|
private startedAt: number | undefined
|
|
284
|
-
/** Last failed
|
|
289
|
+
/** Last failed connection attempt, surfaced in the Web settings panel. */
|
|
285
290
|
private lastStartError: string | undefined
|
|
291
|
+
/** Whether both Feishu application credentials currently resolve. */
|
|
292
|
+
private configured = false
|
|
293
|
+
/** Names of the missing credential references (never their values). */
|
|
294
|
+
private missingConfigItems: string[] = []
|
|
295
|
+
/** Guards the panel's explicit connect action (distinct from a restart). */
|
|
296
|
+
private connecting = false
|
|
286
297
|
/** Effective config captured at the last successful connect (restart-pending diff). */
|
|
287
298
|
private runningConfig: FeishuConfig | undefined
|
|
288
299
|
/** Guards overlapping panel-triggered reconnects. */
|
|
289
300
|
private restarting = false
|
|
290
301
|
private unlisten: (() => void) | undefined
|
|
302
|
+
/** Disposers for host-scoped listeners registered by `start()`. */
|
|
303
|
+
private readonly unlistenHost: Array<() => boolean> = []
|
|
291
304
|
/** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
|
|
292
305
|
private gcTimer: ReturnType<typeof setInterval> | undefined
|
|
293
306
|
/** Periodic workspace-retention timer (only armed when auto cleanup is on). */
|
|
@@ -396,27 +409,105 @@ export class FeishuService {
|
|
|
396
409
|
// ---- lifecycle / Web settings panel ----------------------------------
|
|
397
410
|
|
|
398
411
|
/**
|
|
399
|
-
* Load the Web overlay
|
|
400
|
-
*
|
|
401
|
-
*
|
|
412
|
+
* Load the Web overlay and open the long connection when — and only when —
|
|
413
|
+
* the Feishu application credentials are complete.
|
|
414
|
+
*
|
|
415
|
+
* An incomplete configuration is a normal state, not an error: the plugin
|
|
416
|
+
* stays loaded so the Web settings panel can guide the user through the
|
|
417
|
+
* credentials form, and no connection is attempted. A failed connection
|
|
418
|
+
* attempt is recorded for the panel instead of failing the plugin fiber.
|
|
402
419
|
*/
|
|
403
420
|
async boot(): Promise<void> {
|
|
421
|
+
if (this.connecting || this.restarting) return
|
|
422
|
+
// Acquire the lifecycle lock before the first await so a panel action can
|
|
423
|
+
// never interleave with the boot sequence.
|
|
424
|
+
this.connecting = true
|
|
404
425
|
try {
|
|
405
426
|
await this.loadOverlay()
|
|
427
|
+
await this.refreshReadiness()
|
|
428
|
+
if (!this.configured) {
|
|
429
|
+
this.lastStartError = undefined
|
|
430
|
+
this.runningConfig = undefined
|
|
431
|
+
this.ctx.logger?.info?.(
|
|
432
|
+
'dsh-channels: 飞书应用凭据未配置完整(缺少 %s),本次不连接飞书;'
|
|
433
|
+
+ '请在 Web 设置面板 → 飞书渠道中配置,保存后会自动连接',
|
|
434
|
+
this.missingConfigItems.join('、'),
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
await this.openConnection()
|
|
439
|
+
} catch {
|
|
440
|
+
// Recorded in `lastStartError`; the plugin itself stays loaded.
|
|
441
|
+
} finally {
|
|
442
|
+
this.connecting = false
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Refresh the cached configuration readiness from the credentials seam.
|
|
448
|
+
* Only presence is checked (describe/resolve), never values; the secrets
|
|
449
|
+
* themselves are resolved by `start()` when a connection is opened.
|
|
450
|
+
*/
|
|
451
|
+
async refreshReadiness(): Promise<void> {
|
|
452
|
+
const states = await this.credentialStates()
|
|
453
|
+
const missing: string[] = []
|
|
454
|
+
if (!states.appId.configured) missing.push(`${this.config.appId}(飞书 App ID)`)
|
|
455
|
+
if (!states.appSecret.configured) missing.push(`${this.config.appSecret}(飞书 App Secret)`)
|
|
456
|
+
this.missingConfigItems = missing
|
|
457
|
+
this.configured = missing.length === 0
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Open the connection with the live config. A failure tears the partial
|
|
462
|
+
* service down and records the reason for the panel before rethrowing it to
|
|
463
|
+
* the caller; the plugin fiber is never failed by a connection problem.
|
|
464
|
+
*/
|
|
465
|
+
private async openConnection(): Promise<void> {
|
|
466
|
+
try {
|
|
406
467
|
await this.start()
|
|
407
468
|
this.lastStartError = undefined
|
|
408
469
|
this.runningConfig = { ...this.config }
|
|
409
470
|
} catch (error) {
|
|
410
471
|
this.lastStartError = messageOf(error)
|
|
472
|
+
this.ctx.logger?.error?.('dsh-channels: 飞书连接失败:%s', String(error))
|
|
473
|
+
// Headless profiles have no settings panel; keep the failure visible on
|
|
474
|
+
// the console, symmetric with the success line at the end of `start()`.
|
|
475
|
+
console.error('[dsh-channels] feishu connection failed:', error)
|
|
411
476
|
try {
|
|
412
477
|
await this.shutdown()
|
|
413
478
|
} catch {
|
|
414
|
-
// The
|
|
479
|
+
// The connection error is the useful one.
|
|
415
480
|
}
|
|
416
481
|
throw error
|
|
417
482
|
}
|
|
418
483
|
}
|
|
419
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Panel action: open the long connection when the channel is stopped.
|
|
487
|
+
* Credentials are re-checked first — an incomplete pair throws a message the
|
|
488
|
+
* panel shows next to the credentials form instead of attempting a connect.
|
|
489
|
+
*/
|
|
490
|
+
async connect(): Promise<void> {
|
|
491
|
+
if (this.started) return
|
|
492
|
+
// Boot, an explicit connect and a restart all drive the same connection
|
|
493
|
+
// wiring; serialize them instead of racing two `start()` bodies. The lock
|
|
494
|
+
// is acquired synchronously, before any await.
|
|
495
|
+
if (this.connecting || this.restarting) throw new Error('飞书连接正在连接或重启,请稍候再试')
|
|
496
|
+
this.connecting = true
|
|
497
|
+
try {
|
|
498
|
+
// A stopped channel may have been edited on disk (the overlay file is
|
|
499
|
+
// writable by hand); connecting re-reads it like a restart does.
|
|
500
|
+
await this.loadOverlay()
|
|
501
|
+
await this.refreshReadiness()
|
|
502
|
+
if (!this.configured) {
|
|
503
|
+
throw new Error(`飞书应用凭据尚未配置完整:缺少 ${this.missingConfigItems.join('、')}`)
|
|
504
|
+
}
|
|
505
|
+
await this.openConnection()
|
|
506
|
+
} finally {
|
|
507
|
+
this.connecting = false
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
420
511
|
/** Re-read the Web overlay over the profile config (no channel touch). */
|
|
421
512
|
private async loadOverlay(): Promise<void> {
|
|
422
513
|
const overlay = this.overlay === undefined ? {} : await this.overlay.load()
|
|
@@ -444,26 +535,24 @@ export class FeishuService {
|
|
|
444
535
|
this.refreshRetentionTimer()
|
|
445
536
|
}
|
|
446
537
|
|
|
447
|
-
/**
|
|
538
|
+
/**
|
|
539
|
+
* Tear the connection down and bring it back with the latest config. When the
|
|
540
|
+
* credentials are no longer complete the channel simply stays stopped (no
|
|
541
|
+
* error): the panel then shows the configuration guide instead of a failure.
|
|
542
|
+
*/
|
|
448
543
|
async restart(): Promise<void> {
|
|
449
|
-
if (this.restarting) throw new Error('
|
|
544
|
+
if (this.restarting || this.connecting) throw new Error('飞书连接正在连接或重启,请稍候再试')
|
|
450
545
|
this.restarting = true
|
|
451
546
|
try {
|
|
452
547
|
await this.shutdown()
|
|
453
548
|
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.
|
|
549
|
+
await this.refreshReadiness()
|
|
550
|
+
if (!this.configured) {
|
|
551
|
+
this.lastStartError = undefined
|
|
552
|
+
this.runningConfig = undefined
|
|
553
|
+
return
|
|
465
554
|
}
|
|
466
|
-
|
|
555
|
+
await this.openConnection()
|
|
467
556
|
} finally {
|
|
468
557
|
this.restarting = false
|
|
469
558
|
}
|
|
@@ -471,6 +560,12 @@ export class FeishuService {
|
|
|
471
560
|
|
|
472
561
|
isRunning(): boolean { return this.started }
|
|
473
562
|
isRestarting(): boolean { return this.restarting }
|
|
563
|
+
/** True while the panel's explicit connect action is in flight. */
|
|
564
|
+
isConnecting(): boolean { return this.connecting }
|
|
565
|
+
/** Whether both Feishu application credentials currently resolve. */
|
|
566
|
+
isConfigured(): boolean { return this.configured }
|
|
567
|
+
/** Names of the missing configuration items (never values). */
|
|
568
|
+
missingConfig(): readonly string[] { return this.missingConfigItems }
|
|
474
569
|
startedAtMs(): number | undefined { return this.startedAt }
|
|
475
570
|
uptimeMs(): number | undefined {
|
|
476
571
|
return this.startedAt === undefined || !this.started ? undefined : Math.max(0, Date.now() - this.startedAt)
|
|
@@ -489,6 +584,9 @@ export class FeishuService {
|
|
|
489
584
|
const admin = this.config.adminOpenId
|
|
490
585
|
return {
|
|
491
586
|
mode: this.config.accessMode,
|
|
587
|
+
// Whitelists live in the storage domain, which is only open while the
|
|
588
|
+
// channel runs; an unconnected panel must not present them as empty.
|
|
589
|
+
loaded: this.domain !== undefined,
|
|
492
590
|
admin: {
|
|
493
591
|
...(admin === undefined ? {} : { openId: admin }),
|
|
494
592
|
configured: admin !== undefined,
|
|
@@ -529,7 +627,7 @@ export class FeishuService {
|
|
|
529
627
|
const id = this.cleanAccessTargetId(chatId)
|
|
530
628
|
if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
|
|
531
629
|
const admin = this.config.adminOpenId
|
|
532
|
-
if (admin === undefined) return { ok: false, error: '
|
|
630
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
|
|
533
631
|
try {
|
|
534
632
|
if (!this.accessStore.hasGroup(id)) {
|
|
535
633
|
await this.accessStore.addGroup({
|
|
@@ -563,7 +661,7 @@ export class FeishuService {
|
|
|
563
661
|
const id = this.cleanAccessUserId(openId)
|
|
564
662
|
if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
|
|
565
663
|
const admin = this.config.adminOpenId
|
|
566
|
-
if (admin === undefined) return { ok: false, error: '
|
|
664
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
|
|
567
665
|
try {
|
|
568
666
|
if (!this.accessStore.hasUser(id)) {
|
|
569
667
|
await this.accessStore.addUser({
|
|
@@ -597,7 +695,7 @@ export class FeishuService {
|
|
|
597
695
|
const requests = this.accessRequests
|
|
598
696
|
if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
|
|
599
697
|
const actor = this.config.adminOpenId
|
|
600
|
-
if (actor === undefined) return { ok: false, error: '
|
|
698
|
+
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
|
|
601
699
|
try {
|
|
602
700
|
const result = await requests.resolve(token, decision, actor)
|
|
603
701
|
if (!result.ok) return { ok: false, error: result.error }
|
|
@@ -623,12 +721,12 @@ export class FeishuService {
|
|
|
623
721
|
return value
|
|
624
722
|
}
|
|
625
723
|
|
|
626
|
-
// ----
|
|
724
|
+
// ---- retention cleanup ------------------------------------------------
|
|
627
725
|
|
|
628
726
|
/**
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
727
|
+
* Retention preview for the Web panel: how many plugin workspaces exist,
|
|
728
|
+
* how many directories are past the configured window, and their measured
|
|
729
|
+
* footprint. Never deletes anything.
|
|
632
730
|
*/
|
|
633
731
|
async cleanupPreview(): Promise<{
|
|
634
732
|
retention: string
|
|
@@ -646,6 +744,17 @@ export class FeishuService {
|
|
|
646
744
|
...(window === undefined ? {} : { thresholdMs: window }),
|
|
647
745
|
auto: this.config.workspaceRetentionAuto,
|
|
648
746
|
}
|
|
747
|
+
if (this.domain === undefined) {
|
|
748
|
+
// No connection: routes live in the closed storage domain, so a preview
|
|
749
|
+
// would mistake every directory for a candidate. Report why instead.
|
|
750
|
+
return {
|
|
751
|
+
...base,
|
|
752
|
+
workspaces: 0,
|
|
753
|
+
candidates: 0,
|
|
754
|
+
candidateBytes: 0,
|
|
755
|
+
error: '飞书连接建立后即可预览和清理工作区',
|
|
756
|
+
}
|
|
757
|
+
}
|
|
649
758
|
try {
|
|
650
759
|
const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
|
|
651
760
|
const plan = await planWorkspaceCleanup(this.cleanupDeps())
|
|
@@ -661,37 +770,57 @@ export class FeishuService {
|
|
|
661
770
|
}
|
|
662
771
|
|
|
663
772
|
/**
|
|
664
|
-
* Delete every stale plugin
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
773
|
+
* Delete every stale plugin directory the configured retention allows: the
|
|
774
|
+
* per-topic working directories of current conversations and the legacy
|
|
775
|
+
* one-workspace-per-topic directories of older versions. User workspaces are
|
|
776
|
+
* never deleted.
|
|
777
|
+
*
|
|
778
|
+
* Active routes bound to a removed directory are retired first (thread
|
|
779
|
+
* routes leave a tombstone), so an old topic fails with the explicit expired
|
|
780
|
+
* card instead of resuming into a missing directory. DSH Session logs are not
|
|
781
|
+
* touched.
|
|
668
782
|
*/
|
|
669
783
|
async runWorkspaceCleanup(): Promise<{
|
|
670
784
|
ok: boolean
|
|
671
785
|
deletedWorkspaces: number
|
|
786
|
+
deletedSessionDirs: number
|
|
672
787
|
retiredRoutes: number
|
|
673
788
|
freedBytes: number
|
|
674
789
|
failures: string[]
|
|
675
790
|
error?: string
|
|
676
791
|
}> {
|
|
677
|
-
const empty = {
|
|
792
|
+
const empty = {
|
|
793
|
+
ok: false,
|
|
794
|
+
deletedWorkspaces: 0,
|
|
795
|
+
deletedSessionDirs: 0,
|
|
796
|
+
retiredRoutes: 0,
|
|
797
|
+
freedBytes: 0,
|
|
798
|
+
failures: [] as string[],
|
|
799
|
+
}
|
|
800
|
+
if (this.domain === undefined) {
|
|
801
|
+
// Without the storage domain the route table is unreadable, so a cleanup
|
|
802
|
+
// could mistake live directories for candidates and fail to retire their
|
|
803
|
+
// routes. Refuse rather than rely on the panel disabling the button.
|
|
804
|
+
return { ...empty, error: '飞书连接建立后才能清理工作区' }
|
|
805
|
+
}
|
|
678
806
|
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
679
|
-
return { ...empty, error: '
|
|
807
|
+
return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
|
|
680
808
|
}
|
|
681
|
-
if (this.cleanupRunning) return { ...empty, error: '
|
|
809
|
+
if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
|
|
682
810
|
this.cleanupRunning = true
|
|
683
811
|
try {
|
|
684
812
|
const deps = this.cleanupDeps()
|
|
685
813
|
const plan = await planWorkspaceCleanup(deps)
|
|
686
|
-
if (plan === undefined) return { ...empty, error: '
|
|
814
|
+
if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
|
|
687
815
|
const outcome = await applyWorkspaceCleanup(deps, plan)
|
|
688
816
|
this.ctx.logger?.info?.(
|
|
689
|
-
'dsh-channels:
|
|
690
|
-
outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
|
|
817
|
+
'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
|
|
818
|
+
outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
|
|
691
819
|
)
|
|
692
820
|
return {
|
|
693
821
|
ok: true,
|
|
694
822
|
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
823
|
+
deletedSessionDirs: outcome.deletedSessionDirs,
|
|
695
824
|
retiredRoutes: outcome.retiredRoutes,
|
|
696
825
|
freedBytes: outcome.freedBytes,
|
|
697
826
|
failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
|
|
@@ -716,39 +845,27 @@ export class FeishuService {
|
|
|
716
845
|
baseDir,
|
|
717
846
|
retention: this.config.workspaceRetention,
|
|
718
847
|
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
719
|
-
|
|
848
|
+
listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
|
|
849
|
+
activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
|
|
720
850
|
key,
|
|
721
851
|
kind: record.kind,
|
|
722
852
|
workspacePath: record.workspacePath,
|
|
853
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
723
854
|
sessionId: record.sessionId,
|
|
724
855
|
lastActivityAt: record.lastActivityAt,
|
|
725
856
|
})),
|
|
726
|
-
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
|
|
857
|
+
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
|
|
727
858
|
key,
|
|
728
859
|
...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
|
|
860
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
729
861
|
expiredAt: record.expiredAt,
|
|
730
862
|
...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
|
|
731
863
|
})),
|
|
732
864
|
isLive: sessionId => this.sessions.get(sessionId) !== undefined
|
|
733
865
|
|| this.ctx.agents.get(sessionId as SessionId) !== undefined,
|
|
734
|
-
statWorkspace:
|
|
866
|
+
statWorkspace: directory => measureWorkspace(directory),
|
|
735
867
|
removeWorkspace: async workspace => {
|
|
736
|
-
|
|
737
|
-
if (!isPathInside(baseDir, target)) {
|
|
738
|
-
throw new Error(`refusing to delete a path outside baseDir (${target})`)
|
|
739
|
-
}
|
|
740
|
-
// Re-verify the physical path: a symlinked ancestor inside baseDir
|
|
741
|
-
// must not let the recursive delete escape it.
|
|
742
|
-
try {
|
|
743
|
-
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
|
|
744
|
-
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
745
|
-
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
746
|
-
}
|
|
747
|
-
} catch (error) {
|
|
748
|
-
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
749
|
-
// A missing workspace is already effectively removed (rm is idempotent).
|
|
750
|
-
}
|
|
751
|
-
await rm(target, { recursive: true, force: true })
|
|
868
|
+
await this.deleteDirectoryInsideBase(baseDir, workspace.path)
|
|
752
869
|
try {
|
|
753
870
|
await this.ctx.workspaceRegistry.delete(workspace.id)
|
|
754
871
|
} catch (error) {
|
|
@@ -757,6 +874,9 @@ export class FeishuService {
|
|
|
757
874
|
)
|
|
758
875
|
}
|
|
759
876
|
},
|
|
877
|
+
removeSessionDir: async directory => {
|
|
878
|
+
await this.deleteDirectoryInsideBase(baseDir, directory)
|
|
879
|
+
},
|
|
760
880
|
retireRoute: async route => {
|
|
761
881
|
if (route.kind === 'thread') await this.routes.retire(route.key)
|
|
762
882
|
else await this.routes.remove(route.key)
|
|
@@ -765,6 +885,71 @@ export class FeishuService {
|
|
|
765
885
|
}
|
|
766
886
|
}
|
|
767
887
|
|
|
888
|
+
/**
|
|
889
|
+
* Scan the live user workspaces for per-topic working directories that no
|
|
890
|
+
* route record points at anymore (for example the old route of a degraded
|
|
891
|
+
* chat conversation was deleted by TTL retirement). Route/tombstone evidence
|
|
892
|
+
* wins for age; the scan mtime only covers true orphans.
|
|
893
|
+
*/
|
|
894
|
+
private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
|
|
895
|
+
const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
|
|
896
|
+
const roots: string[] = []
|
|
897
|
+
for (const workspace of this.ctx.workspaceRegistry.list()) {
|
|
898
|
+
if (!this.isPluginWorkspace(workspace)) continue
|
|
899
|
+
const resolved = path.resolve(workspace.path)
|
|
900
|
+
// Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
|
|
901
|
+
if (isPathInside(legacyRoot, resolved)) continue
|
|
902
|
+
roots.push(resolved)
|
|
903
|
+
}
|
|
904
|
+
const scanned: ScannedSessionDir[] = []
|
|
905
|
+
for (const root of roots) {
|
|
906
|
+
for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
|
|
907
|
+
const directory = path.join(root, relative)
|
|
908
|
+
let entries
|
|
909
|
+
try {
|
|
910
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
911
|
+
} catch {
|
|
912
|
+
continue
|
|
913
|
+
}
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
if (!entry.isDirectory()) continue
|
|
916
|
+
const candidate = path.join(directory, entry.name)
|
|
917
|
+
try {
|
|
918
|
+
const info = await lstat(candidate)
|
|
919
|
+
if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
|
|
920
|
+
} catch {
|
|
921
|
+
// Removed or unreadable between readdir and lstat; skip it.
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return scanned
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Recursively delete one plugin directory below `baseDir`.
|
|
931
|
+
*
|
|
932
|
+
* The lexical check refuses paths outside `baseDir`; the realpath check then
|
|
933
|
+
* refuses a symlinked ancestor that would let the recursive delete escape
|
|
934
|
+
* the plugin root. A missing directory is already effectively removed.
|
|
935
|
+
*/
|
|
936
|
+
private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
|
|
937
|
+
const resolved = path.resolve(target)
|
|
938
|
+
if (!isPathInside(baseDir, resolved)) {
|
|
939
|
+
throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
|
|
943
|
+
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
944
|
+
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
945
|
+
}
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
948
|
+
// A missing directory is already effectively removed (rm is idempotent).
|
|
949
|
+
}
|
|
950
|
+
await rm(resolved, { recursive: true, force: true })
|
|
951
|
+
}
|
|
952
|
+
|
|
768
953
|
/**
|
|
769
954
|
* (Re)arm the daily retention timer to match the live config. `runNow`
|
|
770
955
|
* performs one pass immediately; it is only used at startup so saving any
|
|
@@ -780,7 +965,7 @@ export class FeishuService {
|
|
|
780
965
|
|| retentionMs(this.config.workspaceRetention) === undefined) return
|
|
781
966
|
const sweep = (): void => {
|
|
782
967
|
void this.runWorkspaceCleanup().then(result => {
|
|
783
|
-
if (!result.ok && result.error !== '
|
|
968
|
+
if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
|
|
784
969
|
this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
|
|
785
970
|
}
|
|
786
971
|
})
|
|
@@ -840,6 +1025,7 @@ export class FeishuService {
|
|
|
840
1025
|
} catch (error) {
|
|
841
1026
|
throw new Error(`写入凭据引用 ${ref} 失败:${messageOf(error)}`)
|
|
842
1027
|
}
|
|
1028
|
+
await this.refreshReadiness()
|
|
843
1029
|
}
|
|
844
1030
|
|
|
845
1031
|
/** Remove the stored value of one Feishu credential reference. */
|
|
@@ -854,6 +1040,7 @@ export class FeishuService {
|
|
|
854
1040
|
} catch (error) {
|
|
855
1041
|
throw new Error(`移除凭据引用 ${ref} 失败:${messageOf(error)}`)
|
|
856
1042
|
}
|
|
1043
|
+
await this.refreshReadiness()
|
|
857
1044
|
}
|
|
858
1045
|
|
|
859
1046
|
/** The overlay-independent ref name backing one editable credential slot. */
|
|
@@ -957,14 +1144,14 @@ export class FeishuService {
|
|
|
957
1144
|
questions.onCardAction(event)
|
|
958
1145
|
})
|
|
959
1146
|
|
|
960
|
-
this.ctx.on('session/event', (session, event) => {
|
|
1147
|
+
this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
|
|
961
1148
|
this.streamer?.onSessionEvent(session, event)
|
|
962
|
-
})
|
|
1149
|
+
}))
|
|
963
1150
|
// DSH removed the durable `assistant/chunk` event (format v2 embeds the
|
|
964
1151
|
// stream in the settlement); the live deltas arrive here instead.
|
|
965
|
-
this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
1152
|
+
this.unlistenHost.push(this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
966
1153
|
this.streamer?.onAssistantFrame(agent.session, frame)
|
|
967
|
-
})
|
|
1154
|
+
}))
|
|
968
1155
|
this.unlisten = channel.onMessage(message => {
|
|
969
1156
|
void this.inbound.handle(message).catch((error: unknown) => {
|
|
970
1157
|
this.ctx.logger?.warn?.('dsh-channels inbound error: %s', String(error))
|
|
@@ -1053,6 +1240,10 @@ export class FeishuService {
|
|
|
1053
1240
|
this.unlistenApproval = undefined
|
|
1054
1241
|
this.unlistenQuestions?.()
|
|
1055
1242
|
this.unlistenQuestions = undefined
|
|
1243
|
+
// Host-scoped listeners must be released on every teardown: a failed
|
|
1244
|
+
// connect retry or a reconnect calls `start()` again, and a leaked pair
|
|
1245
|
+
// would feed the same session events into the streamer twice.
|
|
1246
|
+
for (const dispose of this.unlistenHost.splice(0)) dispose()
|
|
1056
1247
|
// Withdraw in-flight approval questions (the host settles them cancelled).
|
|
1057
1248
|
this.approvalAnswerer?.dispose()
|
|
1058
1249
|
this.approvalAnswerer = undefined
|
|
@@ -1608,7 +1799,7 @@ export class FeishuService {
|
|
|
1608
1799
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
1609
1800
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
1610
1801
|
currentSessionId: renewAdmission.commit.current.sessionId,
|
|
1611
|
-
|
|
1802
|
+
sessionDir: sessionDirOf(renewAdmission.commit.current),
|
|
1612
1803
|
}))
|
|
1613
1804
|
return
|
|
1614
1805
|
}
|
|
@@ -1628,7 +1819,7 @@ export class FeishuService {
|
|
|
1628
1819
|
context,
|
|
1629
1820
|
extras: {
|
|
1630
1821
|
permissionPreset: this.permissionPresetOf(session),
|
|
1631
|
-
|
|
1822
|
+
sessionDir: sessionDirOf(current),
|
|
1632
1823
|
uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
|
|
1633
1824
|
liveAgents: this.sessions.liveCount(),
|
|
1634
1825
|
routeCount: this.routes.size(),
|
|
@@ -1637,7 +1828,10 @@ export class FeishuService {
|
|
|
1637
1828
|
return
|
|
1638
1829
|
}
|
|
1639
1830
|
if (spec.name === 'workspace' && result.kind === 'success') {
|
|
1640
|
-
const resolved = resolveWorkDir(live.agent.session.header.cwd,
|
|
1831
|
+
const resolved = resolveWorkDir(live.agent.session.header.cwd, {
|
|
1832
|
+
workspacePath: current.workspacePath,
|
|
1833
|
+
...(current.sessionDir === undefined ? {} : { sessionDir: current.sessionDir }),
|
|
1834
|
+
})
|
|
1641
1835
|
if (resolved.kind === 'success') {
|
|
1642
1836
|
await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
|
|
1643
1837
|
return
|