@modesto_ma/dsh-channels 0.2.0 → 0.4.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 +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- 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 +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- 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 +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +74 -31
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,15 +49,15 @@ 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
|
-
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
56
|
-
RouteRecord,
|
|
59
|
+
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, PendingAttachmentRecord,
|
|
60
|
+
PendingAttachmentsTable, ProcessedRecord, RawDomainHandle, RouteRecord,
|
|
57
61
|
} from './domain.ts'
|
|
58
62
|
import { RouteStore } from './session/routes.ts'
|
|
59
63
|
import { InflightRepair } from './session/repair.ts'
|
|
@@ -61,6 +65,17 @@ import { SessionManager } from './session/manager.ts'
|
|
|
61
65
|
import type { LiveAgent, RenewCommit } from './session/manager.ts'
|
|
62
66
|
import { CardDelivery } from './feishu/delivery.ts'
|
|
63
67
|
import { AttachmentIntake } from './feishu/inbound-attachments.ts'
|
|
68
|
+
import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
|
|
69
|
+
import { PendingAttachmentStore } from './feishu/pending-attachments.ts'
|
|
70
|
+
import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
|
|
71
|
+
import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
|
|
72
|
+
import {
|
|
73
|
+
MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
|
|
74
|
+
buildQuoteContextBlock, clampInt, knownMessageOfItems, knownMessagesOfHistory, quoteItemsOf,
|
|
75
|
+
readQuoteItems,
|
|
76
|
+
} from './feishu/message-read.ts'
|
|
77
|
+
import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
|
|
78
|
+
import type { AttachmentStore } from './host/attachments.ts'
|
|
64
79
|
import { InboundRouter } from './routing/inbound.ts'
|
|
65
80
|
import type { RequestPolicyController } from './session/request-policy.ts'
|
|
66
81
|
import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
|
|
@@ -82,7 +97,9 @@ import type { FeishuConfig } from './config.ts'
|
|
|
82
97
|
import type {
|
|
83
98
|
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
84
99
|
} from './settings/contract.ts'
|
|
85
|
-
import
|
|
100
|
+
import { mergeOverlay } from './settings/overlay.ts'
|
|
101
|
+
import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
|
|
102
|
+
import { legacyTopicWorkspaceRoot } from './workspaces.ts'
|
|
86
103
|
|
|
87
104
|
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
88
105
|
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
@@ -122,6 +139,29 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
122
139
|
const accessGroupsRaw = domain.table('access_groups')
|
|
123
140
|
const accessUsersRaw = domain.table('access_users')
|
|
124
141
|
const accessRequestsRaw = domain.table('access_requests')
|
|
142
|
+
// Pending attachment metadata is a table ADDED to the domain at the same
|
|
143
|
+
// version (see `pendingAttachmentRecordSchema`); hosts that cannot accept the
|
|
144
|
+
// addition leave this undefined and the registry degrades to its memory TTL
|
|
145
|
+
// fallback without taking the whole storage domain down.
|
|
146
|
+
let pendingAttachmentsRaw: ReturnType<RawDomainHandle['table']> | undefined
|
|
147
|
+
try {
|
|
148
|
+
pendingAttachmentsRaw = domain.table('pending_attachments')
|
|
149
|
+
} catch (error) {
|
|
150
|
+
ctx.logger?.warn?.('dsh-channels: pending attachment table unavailable: %s', String(error))
|
|
151
|
+
}
|
|
152
|
+
const pendingAttachments: PendingAttachmentsTable | undefined = pendingAttachmentsRaw === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: {
|
|
155
|
+
get: key => pendingAttachmentsRaw.get(key) as PendingAttachmentRecord | undefined,
|
|
156
|
+
put: (key, value) => pendingAttachmentsRaw.put(key, value),
|
|
157
|
+
update: (key, fn) => pendingAttachmentsRaw.update(
|
|
158
|
+
key,
|
|
159
|
+
current => fn(current as PendingAttachmentRecord),
|
|
160
|
+
),
|
|
161
|
+
delete: key => pendingAttachmentsRaw.delete(key),
|
|
162
|
+
entries: () =>
|
|
163
|
+
pendingAttachmentsRaw.entries() as IterableIterator<[string, PendingAttachmentRecord]>,
|
|
164
|
+
}
|
|
125
165
|
return {
|
|
126
166
|
routes: {
|
|
127
167
|
get: key => table.get(key) as RouteRecord | undefined,
|
|
@@ -165,6 +205,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
165
205
|
delete: key => accessRequestsRaw.delete(key),
|
|
166
206
|
entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
|
|
167
207
|
},
|
|
208
|
+
pendingAttachments,
|
|
168
209
|
getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
|
|
169
210
|
setGlobal: value => domain.global.set(value),
|
|
170
211
|
close: () => domain.close(),
|
|
@@ -265,10 +306,36 @@ export class FeishuService {
|
|
|
265
306
|
private readonly delivery: CardDelivery
|
|
266
307
|
/** Inbound attachment archival (Feishu resources → workspace files). */
|
|
267
308
|
private readonly attachments: AttachmentIntake
|
|
309
|
+
/**
|
|
310
|
+
* Backend-selecting inbound storage facade. The native path stays lazy in
|
|
311
|
+
* admission (prefetch images + manifest); this facade owns the legacy
|
|
312
|
+
* workspace download used by `attachmentStore: 'archive'` and by the
|
|
313
|
+
* fallback when the host does not mount `ctx.attachments`.
|
|
314
|
+
*/
|
|
315
|
+
private readonly inboundAttachmentStore: InboundAttachmentStore
|
|
316
|
+
/**
|
|
317
|
+
* Silently registered attachment metadata (`pending_attachments` table):
|
|
318
|
+
* admission-layer registration, prompt manifest reads, fetch authorization
|
|
319
|
+
* whitelist, and the TTL/cap sweep wired into the periodic record GC.
|
|
320
|
+
*/
|
|
321
|
+
private readonly pendingAttachments: PendingAttachmentStore
|
|
322
|
+
/**
|
|
323
|
+
* Read-only Feishu message access (P2): the quoted-message excerpt and the
|
|
324
|
+
* first-turn topic history. Constructed before the inbound router so the
|
|
325
|
+
* turn assembly can reach it; it resolves the live channel lazily.
|
|
326
|
+
*/
|
|
327
|
+
private readonly messageReader = new MessageReader({ channel: () => this.channel })
|
|
268
328
|
/** Inbound routing: dedup, admission, decision tree, session establishment. */
|
|
269
329
|
private readonly inbound: InboundRouter
|
|
270
330
|
/** Agent/session lifecycle: live registry, resume, presets, renew. */
|
|
271
331
|
private readonly sessions: SessionManager
|
|
332
|
+
/**
|
|
333
|
+
* Model-facing Feishu tools. `feishu_attachment_fetch` plus the shared
|
|
334
|
+
* per-route known-message whitelist the admission, quote and history layers
|
|
335
|
+
* populate; `feishu_topic_messages` reads the current topic on demand.
|
|
336
|
+
*/
|
|
337
|
+
private readonly attachmentFetchTools: AttachmentFetchToolRuntime
|
|
338
|
+
private readonly topicMessagesTools: TopicMessagesToolRuntime
|
|
272
339
|
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
273
340
|
private readonly routeOperations = new RouteOperationQueue()
|
|
274
341
|
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
@@ -293,6 +360,10 @@ export class FeishuService {
|
|
|
293
360
|
private runningConfig: FeishuConfig | undefined
|
|
294
361
|
/** Guards overlapping panel-triggered reconnects. */
|
|
295
362
|
private restarting = false
|
|
363
|
+
/** One-shot warning for a native deployment without the host attachment library. */
|
|
364
|
+
private warnedNativeAttachmentsUnmounted = false
|
|
365
|
+
/** One-shot warning for silently degraded quote/history reads (missing scope). */
|
|
366
|
+
private warnedPromptReadDegraded = false
|
|
296
367
|
private unlisten: (() => void) | undefined
|
|
297
368
|
/** Disposers for host-scoped listeners registered by `start()`. */
|
|
298
369
|
private readonly unlistenHost: Array<() => boolean> = []
|
|
@@ -350,6 +421,24 @@ export class FeishuService {
|
|
|
350
421
|
persistRoute: (key, record) => this.routes.put(key, record),
|
|
351
422
|
started: () => this.started,
|
|
352
423
|
detachStream: sessionId => this.streamer?.detach(sessionId),
|
|
424
|
+
mountTools: (agentCtx, sessionId) => {
|
|
425
|
+
// The fetch tool only makes sense for the lazy native backend: in
|
|
426
|
+
// archive deployments (explicit, missing host library, or fetch tool
|
|
427
|
+
// disabled) every attachment is downloaded before the turn and the
|
|
428
|
+
// Agent reads the workspace copy, so mounting the tool would only
|
|
429
|
+
// advertise an operation that can never be authorized.
|
|
430
|
+
//
|
|
431
|
+
// The topic-messages tool is backend-independent: quoted excerpts and
|
|
432
|
+
// topic history are read-only, so it is mounted for both backends and
|
|
433
|
+
// self-gates on `feishuTools.topicMessages`. Both mounts start
|
|
434
|
+
// together and resolve as one promise (no serial blocking).
|
|
435
|
+
const mounts: Array<Promise<void>> = []
|
|
436
|
+
if (this.effectiveAttachmentBackend() === 'native') {
|
|
437
|
+
mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
|
|
438
|
+
}
|
|
439
|
+
mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
|
|
440
|
+
return Promise.all(mounts).then(() => undefined)
|
|
441
|
+
},
|
|
353
442
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
354
443
|
})
|
|
355
444
|
this.attachments = new AttachmentIntake({
|
|
@@ -357,6 +446,65 @@ export class FeishuService {
|
|
|
357
446
|
channel: () => this.channel,
|
|
358
447
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
359
448
|
})
|
|
449
|
+
this.inboundAttachmentStore = new InboundAttachmentStore({
|
|
450
|
+
enabled: () => this.config.acceptAttachments,
|
|
451
|
+
// Effective backend, not the raw config: `store()` is only called after
|
|
452
|
+
// admission already decided `archive`, so a native-configured deployment
|
|
453
|
+
// with the fetch tool disabled must download to the workspace here too.
|
|
454
|
+
mode: () => this.effectiveAttachmentBackend(),
|
|
455
|
+
service: () => this.nativeAttachmentService(),
|
|
456
|
+
channel: () => this.channel,
|
|
457
|
+
archive: this.attachments,
|
|
458
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
459
|
+
})
|
|
460
|
+
this.pendingAttachments = new PendingAttachmentStore({
|
|
461
|
+
// Undefined storage domain/table → the registry's memory TTL fallback.
|
|
462
|
+
table: () => this.domain?.pendingAttachments,
|
|
463
|
+
enabled: () => this.config.acceptAttachments,
|
|
464
|
+
ttlMs: () => this.config.pendingAttachmentTtlMs,
|
|
465
|
+
maxPerScope: () => this.config.pendingAttachmentLimit,
|
|
466
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
467
|
+
})
|
|
468
|
+
this.attachmentFetchTools = new AttachmentFetchToolRuntime({
|
|
469
|
+
config: () => this.config,
|
|
470
|
+
channel: () => this.channel,
|
|
471
|
+
pending: () => this.pendingAttachments,
|
|
472
|
+
routeOfSession: sessionId => {
|
|
473
|
+
const record = this.routes.findBySession(sessionId)
|
|
474
|
+
if (record === undefined) return undefined
|
|
475
|
+
return {
|
|
476
|
+
key: record.key,
|
|
477
|
+
kind: record.kind,
|
|
478
|
+
chatId: record.chatId,
|
|
479
|
+
chatType: record.chatType,
|
|
480
|
+
senderOpenId: record.senderOpenId,
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
484
|
+
})
|
|
485
|
+
this.topicMessagesTools = new TopicMessagesToolRuntime({
|
|
486
|
+
config: () => this.config,
|
|
487
|
+
channel: () => this.channel,
|
|
488
|
+
routeOfSession: sessionId => {
|
|
489
|
+
const record = this.routes.findBySession(sessionId)
|
|
490
|
+
if (record === undefined) return undefined
|
|
491
|
+
return {
|
|
492
|
+
key: record.key,
|
|
493
|
+
kind: record.kind,
|
|
494
|
+
chatId: record.chatId,
|
|
495
|
+
chatType: record.chatType,
|
|
496
|
+
senderOpenId: record.senderOpenId,
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
noteKnownMessage: (route, message, source) => {
|
|
500
|
+
this.attachmentFetchTools.noteKnownMessage(route, message, source)
|
|
501
|
+
},
|
|
502
|
+
isKnownMessage: (route, messageId) => this.attachmentFetchTools.isKnownMessage(route, messageId),
|
|
503
|
+
// The tool description must not promise a fetch tool that archive
|
|
504
|
+
// deployments do not mount; the runtime appends a degradation note.
|
|
505
|
+
fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
|
|
506
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
507
|
+
})
|
|
360
508
|
this.delivery = new CardDelivery({
|
|
361
509
|
channel: () => this.channel,
|
|
362
510
|
started: () => this.started,
|
|
@@ -392,7 +540,23 @@ export class FeishuService {
|
|
|
392
540
|
repair: this.repair,
|
|
393
541
|
delivery: this.delivery,
|
|
394
542
|
sessions: this.sessions,
|
|
395
|
-
|
|
543
|
+
attachmentMode: () => this.config.attachmentStore,
|
|
544
|
+
nativeAttachments: () => this.nativeAttachmentService(),
|
|
545
|
+
attachmentStore: this.inboundAttachmentStore,
|
|
546
|
+
pendingAttachments: this.pendingAttachments,
|
|
547
|
+
noteKnownAttachments: (record, message, source) => {
|
|
548
|
+
this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
|
|
549
|
+
},
|
|
550
|
+
messageRead: {
|
|
551
|
+
quote: input => this.readQuoteContext(input),
|
|
552
|
+
history: input => this.readHistoryContext(input),
|
|
553
|
+
},
|
|
554
|
+
prefetchImages: () => ({
|
|
555
|
+
enabled: this.config.prefetchImages,
|
|
556
|
+
maxImageBytes: this.config.prefetchImageMaxBytes,
|
|
557
|
+
maxTotalBytes: this.config.prefetchTotalMaxBytes,
|
|
558
|
+
timeoutMs: this.config.prefetchTimeoutMs,
|
|
559
|
+
}),
|
|
396
560
|
access: this.accessController,
|
|
397
561
|
queue: this.routeOperations,
|
|
398
562
|
streamer: () => this.streamer,
|
|
@@ -401,6 +565,222 @@ export class FeishuService {
|
|
|
401
565
|
})
|
|
402
566
|
}
|
|
403
567
|
|
|
568
|
+
/** Effective inbound attachment backend (matches the inbound router). */
|
|
569
|
+
private effectiveAttachmentBackend(): 'native' | 'archive' {
|
|
570
|
+
return effectiveAttachmentMode(
|
|
571
|
+
this.config.attachmentStore,
|
|
572
|
+
this.nativeAttachmentService(),
|
|
573
|
+
this.config.feishuTools.attachmentFetch,
|
|
574
|
+
this.config.acceptAttachments,
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Resolve the host's native attachment library from the plugin scope.
|
|
580
|
+
*
|
|
581
|
+
* The service is registered host-wide (`@deepseek-ai/dsh-attachment-local`),
|
|
582
|
+
* so the safe `ctx.get` lookup resolves it even though this plugin does not
|
|
583
|
+
* declare `attachments` in its cordis `inject` list (the per-Agent scopes
|
|
584
|
+
* still inject it for the fetch tool). A missing service reads as
|
|
585
|
+
* `undefined` and switches admission to the legacy archive backend.
|
|
586
|
+
*/
|
|
587
|
+
private nativeAttachmentService(): AttachmentStore | undefined {
|
|
588
|
+
const service = resolveAttachmentService(this.ctx)
|
|
589
|
+
if (service === undefined && this.config.attachmentStore === 'native'
|
|
590
|
+
&& !this.warnedNativeAttachmentsUnmounted) {
|
|
591
|
+
// The facade now receives the effective backend, so its own downgrade
|
|
592
|
+
// warning cannot fire: report the missing library once from the resolver.
|
|
593
|
+
this.warnedNativeAttachmentsUnmounted = true
|
|
594
|
+
this.ctx.logger?.warn?.(
|
|
595
|
+
'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,'
|
|
596
|
+
+ '入站附件回退话题 attachments/ 工作区落盘',
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
return service
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
|
|
604
|
+
*
|
|
605
|
+
* The admission and quote layers call this after their access gates for the
|
|
606
|
+
* triggering / quoted message; the tool otherwise only authorizes messages
|
|
607
|
+
* visible in the durable pending-attachment registry. Registering is a pure
|
|
608
|
+
* in-memory operation (one LRU entry per route) and never fails the caller.
|
|
609
|
+
*/
|
|
610
|
+
noteKnownAttachmentMessage(
|
|
611
|
+
record: RouteRecord,
|
|
612
|
+
message: KnownAttachmentMessageInput,
|
|
613
|
+
source: KnownAttachmentSource = 'trigger',
|
|
614
|
+
): void {
|
|
615
|
+
this.attachmentFetchTools.noteKnownMessage({
|
|
616
|
+
key: record.key,
|
|
617
|
+
kind: record.kind,
|
|
618
|
+
chatId: record.chatId,
|
|
619
|
+
chatType: record.chatType,
|
|
620
|
+
senderOpenId: record.senderOpenId,
|
|
621
|
+
}, message, source)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- read-only message context (quoted excerpt / topic history) -------
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Channel-plane identity of one route for message reads (structurally the
|
|
628
|
+
* same as the attachment-tool route: key/kind/chat/sender).
|
|
629
|
+
*/
|
|
630
|
+
private messageReadRouteOf(record: RouteRecord): MessageReadRoute {
|
|
631
|
+
return {
|
|
632
|
+
key: record.key,
|
|
633
|
+
kind: record.kind,
|
|
634
|
+
chatId: record.chatId,
|
|
635
|
+
chatType: record.chatType,
|
|
636
|
+
senderOpenId: record.senderOpenId,
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolve one quoted message into the bounded `<feishu_quote>` block and the
|
|
642
|
+
* whitelist registration of the quoted message (merged-forward containers
|
|
643
|
+
* keep their container id, per the P0 download rule).
|
|
644
|
+
*
|
|
645
|
+
* The quoted message must belong to this route's visible container: another
|
|
646
|
+
* chat is always rejected; on a thread route a quoted message of a
|
|
647
|
+
* *different* topic is rejected as well (a main-timeline message carries no
|
|
648
|
+
* thread id and stays allowed — quotes happen before the bot owns a topic);
|
|
649
|
+
* on a degraded chat route any topic message is rejected (a chat route must
|
|
650
|
+
* not read another topic of the same group).
|
|
651
|
+
*
|
|
652
|
+
* The read budget is the short prompt-enrichment budget, never the model
|
|
653
|
+
* tool timeout. Read failures (missing scope, deleted message, channel down)
|
|
654
|
+
* degrade to `undefined`: the turn runs without an excerpt (AC6).
|
|
655
|
+
*/
|
|
656
|
+
private async readQuoteContext(input: {
|
|
657
|
+
messageId: string
|
|
658
|
+
record: RouteRecord
|
|
659
|
+
}): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
|
|
660
|
+
try {
|
|
661
|
+
const result = await readQuoteItems(this.messageReader, input.messageId, {
|
|
662
|
+
timeoutMs: this.config.promptContextTimeoutMs,
|
|
663
|
+
})
|
|
664
|
+
if (!result.ok) {
|
|
665
|
+
this.reportDegradedRead('quoted message', input.messageId, result.reason)
|
|
666
|
+
return undefined
|
|
667
|
+
}
|
|
668
|
+
const container: RawMessageItem | undefined = quoteItemsOf(result.value)
|
|
669
|
+
if (container === undefined) return undefined
|
|
670
|
+
if (!this.quotedMessageBelongsToRoute(input.record, container)) {
|
|
671
|
+
this.ctx.logger?.debug?.(
|
|
672
|
+
'dsh-channels: dropping quoted message %s from another chat/topic', input.messageId,
|
|
673
|
+
)
|
|
674
|
+
return undefined
|
|
675
|
+
}
|
|
676
|
+
const block = buildQuoteContextBlock(result.value, {
|
|
677
|
+
attachmentFetchAvailable: this.attachmentFetchMounted(),
|
|
678
|
+
})
|
|
679
|
+
if (block === '') return undefined
|
|
680
|
+
const message = knownMessageOfItems(result.value)
|
|
681
|
+
return { block, ...(message === undefined ? {} : { message }) }
|
|
682
|
+
} catch (error) {
|
|
683
|
+
// Defensive: the reader reports structured failures; a raw throw is still
|
|
684
|
+
// only ever a missing excerpt, never a failed turn.
|
|
685
|
+
this.ctx.logger?.debug?.(
|
|
686
|
+
'dsh-channels: reading quoted message %s failed: %s', input.messageId, String(error),
|
|
687
|
+
)
|
|
688
|
+
return undefined
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Whether one quoted message may be shown/authorized for one route. */
|
|
693
|
+
private quotedMessageBelongsToRoute(record: RouteRecord, container: RawMessageItem): boolean {
|
|
694
|
+
// Fail closed on a missing/foreign chat id: every real Feishu item carries
|
|
695
|
+
// one, so an absent id is a malformed response, never a legitimate quote.
|
|
696
|
+
if (container.chatId === undefined || container.chatId !== record.chatId) return false
|
|
697
|
+
// A main-timeline message has no thread id; a message of another topic must
|
|
698
|
+
// never leak into this route's excerpt or whitelist. A degraded chat route
|
|
699
|
+
// (no topic) must reject topic messages entirely, even in the same chat.
|
|
700
|
+
if (record.kind === 'thread') {
|
|
701
|
+
return container.threadId === undefined || container.threadId === record.key
|
|
702
|
+
}
|
|
703
|
+
return container.threadId === undefined
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Resolve the bounded first-turn `<feishu_history>` block plus the whitelist
|
|
708
|
+
* registrations of the history messages (chronological order).
|
|
709
|
+
*
|
|
710
|
+
* `limit` is `historyBootstrapLimit` (0/negative → no read at all). The page
|
|
711
|
+
* is requested newest-first so a long topic yields the most recent messages;
|
|
712
|
+
* excluded ids (the trigger and the streaming card), deleted messages, and
|
|
713
|
+
* messages outside the route container are filtered before the newest
|
|
714
|
+
* `limit` rows are reversed into chronological order for rendering. The read
|
|
715
|
+
* uses the short prompt-enrichment budget and degrades to `undefined` (AC6).
|
|
716
|
+
*/
|
|
717
|
+
private async readHistoryContext(input: {
|
|
718
|
+
record: RouteRecord
|
|
719
|
+
limit: number
|
|
720
|
+
excludeMessageIds: readonly string[]
|
|
721
|
+
}): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined> {
|
|
722
|
+
if (!Number.isFinite(input.limit) || input.limit <= 0) return undefined
|
|
723
|
+
try {
|
|
724
|
+
const route = this.messageReadRouteOf(input.record)
|
|
725
|
+
const pageSize = clampInt(
|
|
726
|
+
input.limit + input.excludeMessageIds.length,
|
|
727
|
+
1,
|
|
728
|
+
TOPIC_MESSAGES_MAX_LIMIT,
|
|
729
|
+
)
|
|
730
|
+
const result = await this.messageReader.listRoute(route, {
|
|
731
|
+
pageSize,
|
|
732
|
+
order: 'newest-first',
|
|
733
|
+
timeoutMs: this.config.promptContextTimeoutMs,
|
|
734
|
+
})
|
|
735
|
+
if (!result.ok) {
|
|
736
|
+
this.reportDegradedRead('topic history', input.record.key, result.reason)
|
|
737
|
+
return undefined
|
|
738
|
+
}
|
|
739
|
+
const excluded = new Set(input.excludeMessageIds)
|
|
740
|
+
const recent = result.value.items
|
|
741
|
+
.filter(item => !item.deleted && !excluded.has(item.messageId))
|
|
742
|
+
.slice(0, input.limit)
|
|
743
|
+
.reverse()
|
|
744
|
+
const block = buildHistoryContextBlock(recent, {
|
|
745
|
+
maxMessages: input.limit,
|
|
746
|
+
attachmentFetchAvailable: this.attachmentFetchMounted(),
|
|
747
|
+
})
|
|
748
|
+
if (block === '') return undefined
|
|
749
|
+
return { block, messages: knownMessagesOfHistory(recent) }
|
|
750
|
+
} catch (error) {
|
|
751
|
+
this.ctx.logger?.debug?.(
|
|
752
|
+
'dsh-channels: reading topic history of %s failed: %s', input.record.key, String(error),
|
|
753
|
+
)
|
|
754
|
+
return undefined
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Whether this deployment mounts the lazy attachment fetch tool. */
|
|
759
|
+
private attachmentFetchMounted(): boolean {
|
|
760
|
+
return this.effectiveAttachmentBackend() === 'native'
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Report one silently degraded prompt read: debug per occurrence (expected
|
|
765
|
+
* when a deployment misses a scope) plus ONE warn per service lifetime so an
|
|
766
|
+
* operator can find the cause without log spam.
|
|
767
|
+
*/
|
|
768
|
+
private reportDegradedRead(kind: string, target: string, reason: string): void {
|
|
769
|
+
if (this.warnedPromptReadDegraded) {
|
|
770
|
+
this.ctx.logger?.debug?.(
|
|
771
|
+
'dsh-channels: %s read of %s failed (degraded): %s', kind, target, reason,
|
|
772
|
+
)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
this.warnedPromptReadDegraded = true
|
|
776
|
+
this.ctx.logger?.warn?.(
|
|
777
|
+
'dsh-channels: %s(%s)读取失败,已静默降级(不带引用/历史上下文继续回合):%s。'
|
|
778
|
+
+ '如持续出现请检查飞书应用的消息读取权限(如 im:message:readonly / im:message.group_msg),'
|
|
779
|
+
+ '或确认该消息未被撤回、网络正常',
|
|
780
|
+
kind, target, reason,
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
404
784
|
// ---- lifecycle / Web settings panel ----------------------------------
|
|
405
785
|
|
|
406
786
|
/**
|
|
@@ -506,7 +886,7 @@ export class FeishuService {
|
|
|
506
886
|
/** Re-read the Web overlay over the profile config (no channel touch). */
|
|
507
887
|
private async loadOverlay(): Promise<void> {
|
|
508
888
|
const overlay = this.overlay === undefined ? {} : await this.overlay.load()
|
|
509
|
-
this.replaceConfig(
|
|
889
|
+
this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
|
|
510
890
|
}
|
|
511
891
|
|
|
512
892
|
/**
|
|
@@ -525,8 +905,8 @@ export class FeishuService {
|
|
|
525
905
|
* Apply an already-validated overlay over the profile config. Fields read
|
|
526
906
|
* per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
|
|
527
907
|
*/
|
|
528
|
-
applyOverlay(overlay:
|
|
529
|
-
this.replaceConfig(
|
|
908
|
+
applyOverlay(overlay: OverlayPatch): void {
|
|
909
|
+
this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
|
|
530
910
|
this.refreshRetentionTimer()
|
|
531
911
|
}
|
|
532
912
|
|
|
@@ -716,12 +1096,12 @@ export class FeishuService {
|
|
|
716
1096
|
return value
|
|
717
1097
|
}
|
|
718
1098
|
|
|
719
|
-
// ----
|
|
1099
|
+
// ---- retention cleanup ------------------------------------------------
|
|
720
1100
|
|
|
721
1101
|
/**
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
1102
|
+
* Retention preview for the Web panel: how many plugin workspaces exist,
|
|
1103
|
+
* how many directories are past the configured window, and their measured
|
|
1104
|
+
* footprint. Never deletes anything.
|
|
725
1105
|
*/
|
|
726
1106
|
async cleanupPreview(): Promise<{
|
|
727
1107
|
retention: string
|
|
@@ -741,7 +1121,7 @@ export class FeishuService {
|
|
|
741
1121
|
}
|
|
742
1122
|
if (this.domain === undefined) {
|
|
743
1123
|
// No connection: routes live in the closed storage domain, so a preview
|
|
744
|
-
// would mistake every
|
|
1124
|
+
// would mistake every directory for a candidate. Report why instead.
|
|
745
1125
|
return {
|
|
746
1126
|
...base,
|
|
747
1127
|
workspaces: 0,
|
|
@@ -765,43 +1145,57 @@ export class FeishuService {
|
|
|
765
1145
|
}
|
|
766
1146
|
|
|
767
1147
|
/**
|
|
768
|
-
* Delete every stale plugin
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
1148
|
+
* Delete every stale plugin directory the configured retention allows: the
|
|
1149
|
+
* per-topic working directories of current conversations and the legacy
|
|
1150
|
+
* one-workspace-per-topic directories of older versions. User workspaces are
|
|
1151
|
+
* never deleted.
|
|
1152
|
+
*
|
|
1153
|
+
* Active routes bound to a removed directory are retired first (thread
|
|
1154
|
+
* routes leave a tombstone), so an old topic fails with the explicit expired
|
|
1155
|
+
* card instead of resuming into a missing directory. DSH Session logs are not
|
|
1156
|
+
* touched.
|
|
772
1157
|
*/
|
|
773
1158
|
async runWorkspaceCleanup(): Promise<{
|
|
774
1159
|
ok: boolean
|
|
775
1160
|
deletedWorkspaces: number
|
|
1161
|
+
deletedSessionDirs: number
|
|
776
1162
|
retiredRoutes: number
|
|
777
1163
|
freedBytes: number
|
|
778
1164
|
failures: string[]
|
|
779
1165
|
error?: string
|
|
780
1166
|
}> {
|
|
781
|
-
const empty = {
|
|
1167
|
+
const empty = {
|
|
1168
|
+
ok: false,
|
|
1169
|
+
deletedWorkspaces: 0,
|
|
1170
|
+
deletedSessionDirs: 0,
|
|
1171
|
+
retiredRoutes: 0,
|
|
1172
|
+
freedBytes: 0,
|
|
1173
|
+
failures: [] as string[],
|
|
1174
|
+
}
|
|
782
1175
|
if (this.domain === undefined) {
|
|
783
1176
|
// Without the storage domain the route table is unreadable, so a cleanup
|
|
784
|
-
// could mistake live
|
|
1177
|
+
// could mistake live directories for candidates and fail to retire their
|
|
785
1178
|
// routes. Refuse rather than rely on the panel disabling the button.
|
|
786
1179
|
return { ...empty, error: '飞书连接建立后才能清理工作区' }
|
|
787
1180
|
}
|
|
788
1181
|
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
789
|
-
return { ...empty, error: '
|
|
1182
|
+
return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
|
|
790
1183
|
}
|
|
791
|
-
if (this.cleanupRunning) return { ...empty, error: '
|
|
1184
|
+
if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
|
|
792
1185
|
this.cleanupRunning = true
|
|
793
1186
|
try {
|
|
794
1187
|
const deps = this.cleanupDeps()
|
|
795
1188
|
const plan = await planWorkspaceCleanup(deps)
|
|
796
|
-
if (plan === undefined) return { ...empty, error: '
|
|
1189
|
+
if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
|
|
797
1190
|
const outcome = await applyWorkspaceCleanup(deps, plan)
|
|
798
1191
|
this.ctx.logger?.info?.(
|
|
799
|
-
'dsh-channels:
|
|
800
|
-
outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
|
|
1192
|
+
'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
|
|
1193
|
+
outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
|
|
801
1194
|
)
|
|
802
1195
|
return {
|
|
803
1196
|
ok: true,
|
|
804
1197
|
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
1198
|
+
deletedSessionDirs: outcome.deletedSessionDirs,
|
|
805
1199
|
retiredRoutes: outcome.retiredRoutes,
|
|
806
1200
|
freedBytes: outcome.freedBytes,
|
|
807
1201
|
failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
|
|
@@ -826,39 +1220,27 @@ export class FeishuService {
|
|
|
826
1220
|
baseDir,
|
|
827
1221
|
retention: this.config.workspaceRetention,
|
|
828
1222
|
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
829
|
-
|
|
1223
|
+
listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
|
|
1224
|
+
activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
|
|
830
1225
|
key,
|
|
831
1226
|
kind: record.kind,
|
|
832
1227
|
workspacePath: record.workspacePath,
|
|
1228
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
833
1229
|
sessionId: record.sessionId,
|
|
834
1230
|
lastActivityAt: record.lastActivityAt,
|
|
835
1231
|
})),
|
|
836
|
-
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
|
|
1232
|
+
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
|
|
837
1233
|
key,
|
|
838
1234
|
...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
|
|
1235
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
839
1236
|
expiredAt: record.expiredAt,
|
|
840
1237
|
...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
|
|
841
1238
|
})),
|
|
842
1239
|
isLive: sessionId => this.sessions.get(sessionId) !== undefined
|
|
843
1240
|
|| this.ctx.agents.get(sessionId as SessionId) !== undefined,
|
|
844
|
-
statWorkspace:
|
|
1241
|
+
statWorkspace: directory => measureWorkspace(directory),
|
|
845
1242
|
removeWorkspace: async workspace => {
|
|
846
|
-
|
|
847
|
-
if (!isPathInside(baseDir, target)) {
|
|
848
|
-
throw new Error(`refusing to delete a path outside baseDir (${target})`)
|
|
849
|
-
}
|
|
850
|
-
// Re-verify the physical path: a symlinked ancestor inside baseDir
|
|
851
|
-
// must not let the recursive delete escape it.
|
|
852
|
-
try {
|
|
853
|
-
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
|
|
854
|
-
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
855
|
-
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
856
|
-
}
|
|
857
|
-
} catch (error) {
|
|
858
|
-
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
859
|
-
// A missing workspace is already effectively removed (rm is idempotent).
|
|
860
|
-
}
|
|
861
|
-
await rm(target, { recursive: true, force: true })
|
|
1243
|
+
await this.deleteDirectoryInsideBase(baseDir, workspace.path)
|
|
862
1244
|
try {
|
|
863
1245
|
await this.ctx.workspaceRegistry.delete(workspace.id)
|
|
864
1246
|
} catch (error) {
|
|
@@ -867,14 +1249,83 @@ export class FeishuService {
|
|
|
867
1249
|
)
|
|
868
1250
|
}
|
|
869
1251
|
},
|
|
1252
|
+
removeSessionDir: async directory => {
|
|
1253
|
+
await this.deleteDirectoryInsideBase(baseDir, directory)
|
|
1254
|
+
},
|
|
870
1255
|
retireRoute: async route => {
|
|
871
1256
|
if (route.kind === 'thread') await this.routes.retire(route.key)
|
|
872
1257
|
else await this.routes.remove(route.key)
|
|
1258
|
+
this.attachmentFetchTools.forgetRoute(route.key)
|
|
873
1259
|
},
|
|
874
1260
|
warn: message => this.ctx.logger?.warn?.(message),
|
|
875
1261
|
}
|
|
876
1262
|
}
|
|
877
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* Scan the live user workspaces for per-topic working directories that no
|
|
1266
|
+
* route record points at anymore (for example the old route of a degraded
|
|
1267
|
+
* chat conversation was deleted by TTL retirement). Route/tombstone evidence
|
|
1268
|
+
* wins for age; the scan mtime only covers true orphans.
|
|
1269
|
+
*/
|
|
1270
|
+
private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
|
|
1271
|
+
const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
|
|
1272
|
+
const roots: string[] = []
|
|
1273
|
+
for (const workspace of this.ctx.workspaceRegistry.list()) {
|
|
1274
|
+
if (!this.isPluginWorkspace(workspace)) continue
|
|
1275
|
+
const resolved = path.resolve(workspace.path)
|
|
1276
|
+
// Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
|
|
1277
|
+
if (isPathInside(legacyRoot, resolved)) continue
|
|
1278
|
+
roots.push(resolved)
|
|
1279
|
+
}
|
|
1280
|
+
const scanned: ScannedSessionDir[] = []
|
|
1281
|
+
for (const root of roots) {
|
|
1282
|
+
for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
|
|
1283
|
+
const directory = path.join(root, relative)
|
|
1284
|
+
let entries
|
|
1285
|
+
try {
|
|
1286
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
1287
|
+
} catch {
|
|
1288
|
+
continue
|
|
1289
|
+
}
|
|
1290
|
+
for (const entry of entries) {
|
|
1291
|
+
if (!entry.isDirectory()) continue
|
|
1292
|
+
const candidate = path.join(directory, entry.name)
|
|
1293
|
+
try {
|
|
1294
|
+
const info = await lstat(candidate)
|
|
1295
|
+
if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
|
|
1296
|
+
} catch {
|
|
1297
|
+
// Removed or unreadable between readdir and lstat; skip it.
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return scanned
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Recursively delete one plugin directory below `baseDir`.
|
|
1307
|
+
*
|
|
1308
|
+
* The lexical check refuses paths outside `baseDir`; the realpath check then
|
|
1309
|
+
* refuses a symlinked ancestor that would let the recursive delete escape
|
|
1310
|
+
* the plugin root. A missing directory is already effectively removed.
|
|
1311
|
+
*/
|
|
1312
|
+
private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
|
|
1313
|
+
const resolved = path.resolve(target)
|
|
1314
|
+
if (!isPathInside(baseDir, resolved)) {
|
|
1315
|
+
throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
|
|
1319
|
+
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
1320
|
+
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
1321
|
+
}
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
1324
|
+
// A missing directory is already effectively removed (rm is idempotent).
|
|
1325
|
+
}
|
|
1326
|
+
await rm(resolved, { recursive: true, force: true })
|
|
1327
|
+
}
|
|
1328
|
+
|
|
878
1329
|
/**
|
|
879
1330
|
* (Re)arm the daily retention timer to match the live config. `runNow`
|
|
880
1331
|
* performs one pass immediately; it is only used at startup so saving any
|
|
@@ -890,7 +1341,7 @@ export class FeishuService {
|
|
|
890
1341
|
|| retentionMs(this.config.workspaceRetention) === undefined) return
|
|
891
1342
|
const sweep = (): void => {
|
|
892
1343
|
void this.runWorkspaceCleanup().then(result => {
|
|
893
|
-
if (!result.ok && result.error !== '
|
|
1344
|
+
if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
|
|
894
1345
|
this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
|
|
895
1346
|
}
|
|
896
1347
|
})
|
|
@@ -1183,6 +1634,7 @@ export class FeishuService {
|
|
|
1183
1634
|
this.retentionTimer = undefined
|
|
1184
1635
|
}
|
|
1185
1636
|
await this.sessions.disposeAll()
|
|
1637
|
+
this.attachmentFetchTools.clear()
|
|
1186
1638
|
this.routeOperations.clear()
|
|
1187
1639
|
this.inbound.clear()
|
|
1188
1640
|
this.routes.clear()
|
|
@@ -1724,7 +2176,7 @@ export class FeishuService {
|
|
|
1724
2176
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
1725
2177
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
1726
2178
|
currentSessionId: renewAdmission.commit.current.sessionId,
|
|
1727
|
-
|
|
2179
|
+
sessionDir: sessionDirOf(renewAdmission.commit.current),
|
|
1728
2180
|
}))
|
|
1729
2181
|
return
|
|
1730
2182
|
}
|
|
@@ -1744,7 +2196,7 @@ export class FeishuService {
|
|
|
1744
2196
|
context,
|
|
1745
2197
|
extras: {
|
|
1746
2198
|
permissionPreset: this.permissionPresetOf(session),
|
|
1747
|
-
|
|
2199
|
+
sessionDir: sessionDirOf(current),
|
|
1748
2200
|
uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
|
|
1749
2201
|
liveAgents: this.sessions.liveCount(),
|
|
1750
2202
|
routeCount: this.routes.size(),
|
|
@@ -1753,7 +2205,10 @@ export class FeishuService {
|
|
|
1753
2205
|
return
|
|
1754
2206
|
}
|
|
1755
2207
|
if (spec.name === 'workspace' && result.kind === 'success') {
|
|
1756
|
-
const resolved = resolveWorkDir(live.agent.session.header.cwd,
|
|
2208
|
+
const resolved = resolveWorkDir(live.agent.session.header.cwd, {
|
|
2209
|
+
workspacePath: current.workspacePath,
|
|
2210
|
+
...(current.sessionDir === undefined ? {} : { sessionDir: current.sessionDir }),
|
|
2211
|
+
})
|
|
1757
2212
|
if (resolved.kind === 'success') {
|
|
1758
2213
|
await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
|
|
1759
2214
|
return
|
|
@@ -1940,13 +2395,18 @@ export class FeishuService {
|
|
|
1940
2395
|
|
|
1941
2396
|
|
|
1942
2397
|
/**
|
|
1943
|
-
* GC pass over the routing domain.
|
|
2398
|
+
* GC pass over the routing domain. Three independent parts:
|
|
1944
2399
|
*
|
|
1945
2400
|
* 1. Dedup records (unconditional): `processed` records and in-process
|
|
1946
2401
|
* guard entries older than `PROCESSED_RETENTION_MS` are dropped —
|
|
1947
2402
|
* redelivery windows are minutes, so a day of retention is ample and
|
|
1948
2403
|
* keeps the table bounded regardless of the route TTL setting.
|
|
1949
|
-
* 2.
|
|
2404
|
+
* 2. Pending attachment metadata (unconditional): rows past
|
|
2405
|
+
* `pendingAttachmentTtlMs` and rows above `pendingAttachmentLimit` per
|
|
2406
|
+
* `(chat, thread?, sender)` scope are removed. Independent of the route
|
|
2407
|
+
* TTL: the registry keeps its own 7-day default even when route retirement
|
|
2408
|
+
* is disabled.
|
|
2409
|
+
* 3. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
|
|
1950
2410
|
* are retired into minimal persistent tombstones before their active
|
|
1951
2411
|
* records are deleted. A later message in that visible topic is then
|
|
1952
2412
|
* rejected instead of silently creating a context-free Session. Degraded
|
|
@@ -1979,6 +2439,18 @@ export class FeishuService {
|
|
|
1979
2439
|
}
|
|
1980
2440
|
this.inbound.pruneAccepted(processedCutoff)
|
|
1981
2441
|
|
|
2442
|
+
try {
|
|
2443
|
+
const pruned = await this.pendingAttachments.prune()
|
|
2444
|
+
if (pruned.removedExpired > 0 || pruned.removedOverLimit > 0) {
|
|
2445
|
+
this.ctx.logger?.info?.(
|
|
2446
|
+
'dsh-channels: record GC removed %d expired and %d over-limit pending attachment record(s)',
|
|
2447
|
+
pruned.removedExpired, pruned.removedOverLimit,
|
|
2448
|
+
)
|
|
2449
|
+
}
|
|
2450
|
+
} catch (error) {
|
|
2451
|
+
this.ctx.logger?.warn?.('dsh-channels: pending attachment GC failed: %s', String(error))
|
|
2452
|
+
}
|
|
2453
|
+
|
|
1982
2454
|
const ttlMs = this.config.routeTtlMs
|
|
1983
2455
|
if (ttlMs === 0) {
|
|
1984
2456
|
if (removedProcessed > 0) {
|
|
@@ -1999,6 +2471,7 @@ export class FeishuService {
|
|
|
1999
2471
|
} else {
|
|
2000
2472
|
await this.routes.remove(key)
|
|
2001
2473
|
}
|
|
2474
|
+
this.attachmentFetchTools.forgetRoute(key)
|
|
2002
2475
|
removedRoutes++
|
|
2003
2476
|
} catch (error) {
|
|
2004
2477
|
this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
|
|
@@ -2006,6 +2479,19 @@ export class FeishuService {
|
|
|
2006
2479
|
}
|
|
2007
2480
|
}
|
|
2008
2481
|
|
|
2482
|
+
// Reconcile the attachment-fetch whitelist with the active route set: the
|
|
2483
|
+
// inbound TTL path retires a thread route directly (tombstone), so its
|
|
2484
|
+
// in-memory whitelist bucket would otherwise live until process exit.
|
|
2485
|
+
const droppedToolRoutes = this.attachmentFetchTools.pruneRoutes(
|
|
2486
|
+
routeKey => this.routes.get(routeKey) !== undefined,
|
|
2487
|
+
)
|
|
2488
|
+
if (droppedToolRoutes > 0) {
|
|
2489
|
+
this.ctx.logger?.info?.(
|
|
2490
|
+
'dsh-channels: record GC dropped %d stale attachment-tool route bucket(s)',
|
|
2491
|
+
droppedToolRoutes,
|
|
2492
|
+
)
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2009
2495
|
const inflight = this.domain?.inflight
|
|
2010
2496
|
if (inflight !== undefined) {
|
|
2011
2497
|
for (const [key, snapshot] of [...inflight.entries()]) {
|