@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.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Pending attachment registry: silent metadata registration for inbound
3
+ * messages that do not trigger a model turn.
4
+ *
5
+ * Product rule (decisions D2): attachment-only p2p messages, un-mentioned group
6
+ * messages, and attachment-only messages inside an already-bound topic are
7
+ * recorded as metadata only — no download, no reply, no session, no reaction.
8
+ * A later triggered message can list the most recent unconsumed entries in its
9
+ * prompt manifest and fetch them on demand; the fetch tool also uses the
10
+ * registry as its per-route authorization whitelist.
11
+ *
12
+ * Isolation is structural: every record carries a `scopeKey` derived from
13
+ * `(chat, thread?, sender)`. `list`/`get`/`consume` all require a route scope,
14
+ * so a known message id alone can never unlock another sender's, chat's, or
15
+ * topic's record. A thread route also sees the same sender's chat-scope rows of
16
+ * the same chat (a group file posted without an @ before the topic existed must
17
+ * remain reachable from the topic that the later @-mention creates — Roadmap
18
+ * AC-4); everything else is exactly scope-bound.
19
+ *
20
+ * This task deliberately does NOT call the registry from the inbound path — the
21
+ * admission layer (`admission_manifest_flow`) wires registration after its
22
+ * access gates. To keep that contract enforceable, `registerMessage` requires
23
+ * an explicit admission decision and is a no-op unless it says `admitted`,
24
+ * while `acceptAttachments=false` disables registration entirely.
25
+ *
26
+ * Durability: the primary backing table is `pending_attachments` in the
27
+ * `channels_routes` storage domain (added at the same domain version; the JSON
28
+ * backend initializes the missing declared table as empty). When the domain
29
+ * open succeeds but the host refuses that table (older/embedded host), the same
30
+ * TTL/cap/query semantics run over an in-process map instead; records then live
31
+ * only for this process and are lost on restart. That limitation is logged
32
+ * once.
33
+ *
34
+ * No I/O beyond the injected table: this module never touches the Feishu
35
+ * channel, the filesystem, or the native attachment library.
36
+ *
37
+ * @module dsh-channels/feishu/pending-attachments
38
+ */
39
+
40
+ import { pendingAttachmentRecordSchema } from '../domain.ts'
41
+ import type {
42
+ PendingAttachmentKind, PendingAttachmentRecord, PendingAttachmentResource, PendingAttachmentsTable,
43
+ } from '../domain.ts'
44
+ import type { NormalizedMessage } from '../feishu-channel.ts'
45
+ import {
46
+ EMPTY_POST_PLACEHOLDER, attachmentResources, stripAttachmentPlaceholders,
47
+ } from './inbound-attachments.ts'
48
+
49
+ /** Manifest rows shown to the model: only the most recent N unconsumed entries. */
50
+ export const PENDING_ATTACHMENT_MANIFEST_LIMIT = 5
51
+
52
+ /** Longest caption excerpt kept per record, in Unicode code points. */
53
+ export const PENDING_ATTACHMENT_TEXT_EXCERPT_MAX = 200
54
+
55
+ /** `(chat, thread?, sender)` isolation unit of one pending record. */
56
+ export interface PendingAttachmentScope {
57
+ /** Feishu chat id. */
58
+ readonly chatId: string
59
+ /** Feishu topic id; absent for the chat's main timeline. */
60
+ readonly threadId?: string
61
+ /** Normalized sender id (open_id) owning the scope. */
62
+ readonly senderOpenId: string
63
+ }
64
+
65
+ /**
66
+ * Stable, collision-free table value for one scope. Chat-scope and thread-scope
67
+ * keys carry different prefixes so a sender id can never alias a thread id.
68
+ */
69
+ export function pendingAttachmentScopeKey(scope: PendingAttachmentScope): string {
70
+ return scope.threadId === undefined
71
+ ? `chat:${scope.chatId}:${scope.senderOpenId}`
72
+ : `thread:${scope.chatId}:${scope.threadId}:${scope.senderOpenId}`
73
+ }
74
+
75
+ /**
76
+ * Whether one record is visible to a route query scope.
77
+ *
78
+ * The exact scope always matches. A thread-bound route additionally matches the
79
+ * same sender's chat-scope rows of the same chat: in a group, a file posted
80
+ * without an @ before the topic existed is registered without a `threadId`, yet
81
+ * the later @-triggered topic must be able to list and fetch it (Roadmap AC-4).
82
+ * Other topics, other senders, and other chats never match.
83
+ */
84
+ function recordInScope(record: PendingAttachmentRecord, scope: PendingAttachmentScope): boolean {
85
+ if (record.scopeKey === pendingAttachmentScopeKey(scope)) return true
86
+ if (scope.threadId === undefined) return false
87
+ return record.scopeKey === pendingAttachmentScopeKey({
88
+ chatId: scope.chatId,
89
+ senderOpenId: scope.senderOpenId,
90
+ })
91
+ }
92
+
93
+ /**
94
+ * The admission decision a registration must carry. Only an explicit
95
+ * `admitted: true` may write a record — the registry has no own admission
96
+ * policy, it just refuses to record anything that did not pass the caller's
97
+ * gate.
98
+ */
99
+ export type PendingAttachmentAdmission =
100
+ | { readonly admitted: true }
101
+ | { readonly admitted: false; readonly reason?: string }
102
+
103
+ /** Why a registration did not store anything (`stored: false`). */
104
+ export type PendingAttachmentSkipReason =
105
+ | 'disabled'
106
+ | 'not-admitted'
107
+ | 'not-attachment-message'
108
+ | 'invalid-message'
109
+ | 'duplicate'
110
+ | 'storage-error'
111
+
112
+ /** Outcome of one registration attempt. */
113
+ export type PendingAttachmentRegistration =
114
+ | {
115
+ readonly stored: true
116
+ readonly record: PendingAttachmentRecord
117
+ /** Older records evicted from the same scope to respect the per-scope cap. */
118
+ readonly evicted: number
119
+ }
120
+ | { readonly stored: false; readonly reason: PendingAttachmentSkipReason }
121
+
122
+ /** Backing store actually used by a registry instance. */
123
+ export type PendingAttachmentMode = 'durable' | 'memory'
124
+
125
+ /** Result of one TTL + per-scope-cap sweep (safe to run repeatedly). */
126
+ export interface PendingAttachmentPruneResult {
127
+ /** Records removed because they were older than the TTL. */
128
+ readonly removedExpired: number
129
+ /** Oldest records removed because their scope exceeded the row cap. */
130
+ readonly removedOverLimit: number
131
+ }
132
+
133
+ /** Every effect the registry needs, injected so tests can drive a fake clock. */
134
+ export interface PendingAttachmentStoreDeps {
135
+ /**
136
+ * Durable table handle. `undefined` = the host opened the storage domain but
137
+ * refused the added table → the registry degrades to the in-process fallback
138
+ * (a domain that cannot open at all fails service startup instead).
139
+ */
140
+ table(): PendingAttachmentsTable | undefined
141
+ /** `acceptAttachments`; `false` makes registration a no-op. */
142
+ enabled(): boolean
143
+ /** Retention window in ms; `<= 0` disables age pruning (the cap still applies). */
144
+ ttlMs(): number
145
+ /** Maximum rows kept per scope (>= 1). */
146
+ maxPerScope(): number
147
+ /** Clock seam; defaults to `Date.now()`. */
148
+ now?(): number
149
+ /** Warning sink (in-memory fallback, per-key write failures). Never throws. */
150
+ warn?(message: string, ...args: unknown[]): void
151
+ }
152
+
153
+ /** One extracted attachment message, before it becomes a durable record. */
154
+ export interface PendingAttachmentCandidate {
155
+ readonly messageId: string
156
+ readonly chatId: string
157
+ readonly chatType: 'p2p' | 'group'
158
+ readonly threadId?: string
159
+ readonly senderOpenId: string
160
+ readonly kind: PendingAttachmentKind
161
+ readonly resources: readonly PendingAttachmentResource[]
162
+ readonly textExcerpt?: string
163
+ }
164
+
165
+ /**
166
+ * Extract the attachable payload of one normalized message, or `undefined` when
167
+ * the message carries nothing the registry may track.
168
+ *
169
+ * Only image / file / rich-text-with-files / merged-forward messages qualify
170
+ * (audio, video and sticker payloads are out of scope). Feishu folders carry no
171
+ * downloadable bytes but are recorded with a `folder` marker so the fetch tool
172
+ * can answer with a structured "folder unsupported". A merged-forward container
173
+ * has no directly listed resources: children are downloaded through the
174
+ * record's own `messageId`, per the P0 container rule.
175
+ */
176
+ export function pendingAttachmentCandidate(
177
+ msg: NormalizedMessage,
178
+ ): PendingAttachmentCandidate | undefined {
179
+ if (msg.chatType !== 'p2p' && msg.chatType !== 'group') return undefined
180
+ const forwarded = msg.rawContentType === 'merge_forward'
181
+ const resources = attachmentResources(msg).map((resource): PendingAttachmentResource => ({
182
+ type: resource.type,
183
+ fileKey: resource.fileKey,
184
+ ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
185
+ ...(resource.folder === true ? { folder: true } : {}),
186
+ }))
187
+ let kind: PendingAttachmentKind
188
+ if (forwarded) {
189
+ kind = 'merge_forward'
190
+ } else if (msg.rawContentType === 'post') {
191
+ if (resources.length === 0) return undefined
192
+ kind = 'post'
193
+ } else if (msg.rawContentType === 'image' && resources.length > 0) {
194
+ kind = 'image'
195
+ } else if (msg.rawContentType === 'file' && resources.length > 0) {
196
+ kind = 'file'
197
+ } else {
198
+ return undefined
199
+ }
200
+ const excerpt = textExcerptOf(msg.content ?? '', forwarded)
201
+ return {
202
+ messageId: msg.messageId,
203
+ chatId: msg.chatId,
204
+ chatType: msg.chatType,
205
+ ...(msg.threadId === undefined ? {} : { threadId: msg.threadId }),
206
+ senderOpenId: msg.senderId,
207
+ kind,
208
+ resources,
209
+ ...(excerpt === undefined ? {} : { textExcerpt: excerpt }),
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Remaining caption text after attachment placeholders, truncated. A
215
+ * merged-forward record's content is an expanded transcript, not a caption, so
216
+ * it never contributes an excerpt (the manifest formats `kind` instead). The
217
+ * SDK's `[rich text message]` placeholder for a file-only rich-text message is
218
+ * treated as "no caption" exactly like the archive path does.
219
+ */
220
+ function textExcerptOf(content: string, forwarded: boolean): string | undefined {
221
+ if (forwarded) return undefined
222
+ const stripped = stripAttachmentPlaceholders(content, false).trim()
223
+ if (stripped === '' || stripped === EMPTY_POST_PLACEHOLDER) return undefined
224
+ const chars = [...stripped]
225
+ if (chars.length <= PENDING_ATTACHMENT_TEXT_EXCERPT_MAX) return stripped
226
+ return chars.slice(0, PENDING_ATTACHMENT_TEXT_EXCERPT_MAX).join('')
227
+ }
228
+
229
+ // ---- backing stores ------------------------------------------------------
230
+
231
+ /** Minimal storage contract shared by the durable table and the fallback map. */
232
+ interface PendingBacking {
233
+ readonly mode: PendingAttachmentMode
234
+ get(key: string): PendingAttachmentRecord | undefined
235
+ put(key: string, value: PendingAttachmentRecord): Promise<unknown>
236
+ update(key: string, fn: (current: PendingAttachmentRecord) => PendingAttachmentRecord): Promise<unknown>
237
+ delete(key: string): Promise<unknown>
238
+ entries(): IterableIterator<[string, PendingAttachmentRecord]>
239
+ }
240
+
241
+ /** Wrap one normalized domain table as a backing store. */
242
+ function tableBacking(table: PendingAttachmentsTable): PendingBacking {
243
+ return {
244
+ mode: 'durable',
245
+ get: key => table.get(key),
246
+ put: (key, value) => Promise.resolve(table.put(key, value)),
247
+ update: (key, fn) => table.update(key, current => fn(current)),
248
+ delete: key => Promise.resolve(table.delete(key)),
249
+ entries: () => table.entries(),
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Process-memory fallback for hosts without the added domain table. Same
255
+ * semantics as the durable table (`update` rejects on a missing key) so the
256
+ * registry code path is identical; records are lost when the process exits.
257
+ */
258
+ class MemoryBacking implements PendingBacking {
259
+ readonly mode = 'memory' as const
260
+ private readonly records = new Map<string, PendingAttachmentRecord>()
261
+
262
+ get(key: string): PendingAttachmentRecord | undefined {
263
+ return this.records.get(key)
264
+ }
265
+
266
+ async put(key: string, value: PendingAttachmentRecord): Promise<unknown> {
267
+ this.records.set(key, value)
268
+ return true
269
+ }
270
+
271
+ async update(
272
+ key: string,
273
+ fn: (current: PendingAttachmentRecord) => PendingAttachmentRecord,
274
+ ): Promise<unknown> {
275
+ const current = this.records.get(key)
276
+ if (current === undefined) throw new Error('missing-key')
277
+ const next = fn(current)
278
+ this.records.set(key, next)
279
+ return next
280
+ }
281
+
282
+ async delete(key: string): Promise<unknown> {
283
+ return this.records.delete(key)
284
+ }
285
+
286
+ entries(): IterableIterator<[string, PendingAttachmentRecord]> {
287
+ return this.records.entries()
288
+ }
289
+ }
290
+
291
+ // ---- registry ------------------------------------------------------------
292
+
293
+ /**
294
+ * Narrow registry API used by the admission layer (register), the prompt
295
+ * manifest (list) and the fetch tool (get/consume). The periodic record GC
296
+ * calls `prune` together with the other routing-domain sweeps.
297
+ */
298
+ export class PendingAttachmentStore {
299
+ private readonly deps: PendingAttachmentStoreDeps
300
+ private readonly memory = new MemoryBacking()
301
+ private warnedFallback = false
302
+
303
+ constructor(deps: PendingAttachmentStoreDeps) {
304
+ this.deps = deps
305
+ }
306
+
307
+ /** Backing mode of the current configuration (no side effects). */
308
+ mode(): PendingAttachmentMode {
309
+ return this.deps.table() === undefined ? 'memory' : 'durable'
310
+ }
311
+
312
+ /**
313
+ * Register one admitted attachment message. A no-op for non-attachment
314
+ * messages, a denied admission, `acceptAttachments=false`, and a message id
315
+ * that is already registered (storage-level idempotency; the inbound dedup
316
+ * table remains the authoritative redelivery guard).
317
+ *
318
+ * The durable write happens BEFORE evicting older rows of the same scope: a
319
+ * failed write leaves the scope untouched instead of losing an old row for
320
+ * nothing. A successful eviction restores the cap immediately; only a failed
321
+ * delete can leave the scope over the cap until the next sweep.
322
+ */
323
+ async registerMessage(
324
+ msg: NormalizedMessage,
325
+ admission: PendingAttachmentAdmission,
326
+ ): Promise<PendingAttachmentRegistration> {
327
+ if (!this.deps.enabled()) return { stored: false, reason: 'disabled' }
328
+ if (admission.admitted !== true) return { stored: false, reason: 'not-admitted' }
329
+ const candidate = pendingAttachmentCandidate(msg)
330
+ if (candidate === undefined) return { stored: false, reason: 'not-attachment-message' }
331
+ const now = this.now()
332
+ const backing = this.backing()
333
+ const existing = backing.get(candidate.messageId)
334
+ if (existing !== undefined && !this.expired(existing, now)) {
335
+ return { stored: false, reason: 'duplicate' }
336
+ }
337
+ const record = this.recordOf(candidate, now)
338
+ // A record written here must survive the next domain open, which validates
339
+ // every stored row against the schema; reject malformed input up front
340
+ // instead of poisoning the whole `channels_routes` domain.
341
+ const parsed = pendingAttachmentRecordSchema.safeParse(record)
342
+ if (!parsed.success) {
343
+ this.deps.warn?.(
344
+ 'dsh-channels: skipping malformed pending attachment metadata for message %s: %s',
345
+ record.messageId, parsed.error.issues[0]?.message ?? 'invalid record',
346
+ )
347
+ return { stored: false, reason: 'invalid-message' }
348
+ }
349
+ try {
350
+ await backing.put(record.key, record)
351
+ } catch (error) {
352
+ this.deps.warn?.(
353
+ 'dsh-channels: pending attachment registration failed for %s: %s',
354
+ record.messageId, String(error),
355
+ )
356
+ return { stored: false, reason: 'storage-error' }
357
+ }
358
+ const evicted = await this.evictOverCap(backing, record.scopeKey, now, record.key)
359
+ return { stored: true, record, evicted }
360
+ }
361
+
362
+ /**
363
+ * Most recent unconsumed records visible to one route scope, newest first.
364
+ * The prompt manifest shows at most `PENDING_ATTACHMENT_MANIFEST_LIMIT` rows;
365
+ * callers that need a different window pass an explicit limit (never above the
366
+ * per-scope cap). Expired and consumed rows are excluded.
367
+ *
368
+ * Visibility follows {@link recordInScope}: file-only messages posted in a
369
+ * group's main timeline before the topic existed are registered without a
370
+ * `threadId`, so the topic that a later @-mention creates still lists them
371
+ * (Roadmap AC-4). Other topics, senders, and chats stay invisible.
372
+ */
373
+ list(
374
+ scope: PendingAttachmentScope,
375
+ options: { limit?: number } = {},
376
+ ): PendingAttachmentRecord[] {
377
+ const now = this.now()
378
+ // A thread scope can see its own rows plus the same sender's chat-scope
379
+ // rows (see `recordInScope`), so at most two scopes' caps (2× the per-scope
380
+ // cap) can be visible; the requested manifest window is clamped to that.
381
+ const maxVisible = Math.max(1, this.deps.maxPerScope() * 2)
382
+ const requested = options.limit ?? PENDING_ATTACHMENT_MANIFEST_LIMIT
383
+ const limit = Math.max(1, Math.min(requested, maxVisible))
384
+ const backing = this.backing()
385
+ const rows: PendingAttachmentRecord[] = []
386
+ for (const [, record] of backing.entries()) {
387
+ if (!recordInScope(record, scope)) continue
388
+ if (record.consumed === true) continue
389
+ if (this.expired(record, now)) continue
390
+ rows.push(record)
391
+ }
392
+ rows.sort((left, right) =>
393
+ right.receivedAt - left.receivedAt || left.messageId.localeCompare(right.messageId))
394
+ return rows.slice(0, limit)
395
+ }
396
+
397
+ /**
398
+ * Look up one registered message id in one route scope (the fetch tool's
399
+ * authorization whitelist). Consumed rows still resolve — a fetch may be
400
+ * retried — but expired rows and records of another sender / chat / topic do
401
+ * not. Visibility follows {@link recordInScope}.
402
+ */
403
+ get(scope: PendingAttachmentScope, messageId: string): PendingAttachmentRecord | undefined {
404
+ const record = this.backing().get(messageId)
405
+ if (record === undefined) return undefined
406
+ if (!recordInScope(record, scope)) return undefined
407
+ return this.expired(record, this.now()) ? undefined : record
408
+ }
409
+
410
+ /**
411
+ * Mark one record consumed so it drops out of the prompt manifest. Returns
412
+ * the record (marked consumed), or `undefined` when absent, expired, or
413
+ * outside the caller's scope. If the write fails the pre-read record is
414
+ * returned anyway and the failure is only logged: the fetch itself must not
415
+ * fail because bookkeeping did.
416
+ */
417
+ async consume(
418
+ scope: PendingAttachmentScope,
419
+ messageId: string,
420
+ ): Promise<PendingAttachmentRecord | undefined> {
421
+ const backing = this.backing()
422
+ const current = backing.get(messageId)
423
+ if (current === undefined || this.expired(current, this.now())) return undefined
424
+ if (!recordInScope(current, scope)) return undefined
425
+ if (current.consumed === true) return current
426
+ try {
427
+ await backing.update(messageId, record => ({ ...record, consumed: true }))
428
+ return { ...current, consumed: true }
429
+ } catch (error) {
430
+ this.deps.warn?.(
431
+ 'dsh-channels: marking pending attachment %s consumed failed: %s',
432
+ messageId, String(error),
433
+ )
434
+ return current
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Remove expired rows and enforce the per-scope cap, oldest first. Runs at
440
+ * startup and on every periodic record-GC sweep; idempotent by design.
441
+ */
442
+ async prune(): Promise<PendingAttachmentPruneResult> {
443
+ const backing = this.backing()
444
+ const now = this.now()
445
+ const maxRows = Math.max(1, this.deps.maxPerScope())
446
+ let removedExpired = 0
447
+ const remaining: PendingAttachmentRecord[] = []
448
+ for (const [key, record] of [...backing.entries()]) {
449
+ if (this.expired(record, now)) {
450
+ if (await this.remove(backing, key)) removedExpired += 1
451
+ continue
452
+ }
453
+ remaining.push(record)
454
+ }
455
+ const byScope = new Map<string, PendingAttachmentRecord[]>()
456
+ for (const record of remaining) {
457
+ const rows = byScope.get(record.scopeKey)
458
+ if (rows === undefined) byScope.set(record.scopeKey, [record])
459
+ else rows.push(record)
460
+ }
461
+ let removedOverLimit = 0
462
+ for (const rows of byScope.values()) {
463
+ if (rows.length <= maxRows) continue
464
+ rows.sort((left, right) =>
465
+ left.receivedAt - right.receivedAt || left.messageId.localeCompare(right.messageId))
466
+ for (const record of rows.slice(0, rows.length - maxRows)) {
467
+ if (await this.remove(backing, record.key)) removedOverLimit += 1
468
+ }
469
+ }
470
+ return { removedExpired, removedOverLimit }
471
+ }
472
+
473
+ /** Resolve a backing store, warning once when durability is unavailable. */
474
+ private backing(): PendingBacking {
475
+ const table = this.deps.table()
476
+ if (table !== undefined) return tableBacking(table)
477
+ if (!this.warnedFallback) {
478
+ this.warnedFallback = true
479
+ this.deps.warn?.(
480
+ 'dsh-channels: pending attachment storage domain unavailable; '
481
+ + 'using a process-memory TTL fallback (records are lost on restart)',
482
+ )
483
+ }
484
+ return this.memory
485
+ }
486
+
487
+ private recordOf(candidate: PendingAttachmentCandidate, receivedAt: number): PendingAttachmentRecord {
488
+ const scopeKey = pendingAttachmentScopeKey({
489
+ chatId: candidate.chatId,
490
+ ...(candidate.threadId === undefined ? {} : { threadId: candidate.threadId }),
491
+ senderOpenId: candidate.senderOpenId,
492
+ })
493
+ return {
494
+ key: candidate.messageId,
495
+ scopeKey,
496
+ messageId: candidate.messageId,
497
+ chatId: candidate.chatId,
498
+ chatType: candidate.chatType,
499
+ ...(candidate.threadId === undefined ? {} : { threadId: candidate.threadId }),
500
+ senderOpenId: candidate.senderOpenId,
501
+ receivedAt,
502
+ kind: candidate.kind,
503
+ resources: [...candidate.resources],
504
+ ...(candidate.textExcerpt === undefined ? {} : { textExcerpt: candidate.textExcerpt }),
505
+ }
506
+ }
507
+
508
+ /**
509
+ * After a successful write, delete the oldest rows of one scope until the
510
+ * record fits in the cap again (the new record may briefly make the scope
511
+ * exceed the cap by one; a successful eviction restores the cap immediately,
512
+ * only a failed delete leaves the scope over until the next sweep). The just
513
+ * written `keepKey` is never a candidate: `receivedAt` ties (injected clock,
514
+ * clock rollback) would otherwise let the message-id tiebreak evict it.
515
+ */
516
+ private async evictOverCap(
517
+ backing: PendingBacking,
518
+ scopeKey: string,
519
+ now: number,
520
+ keepKey: string,
521
+ ): Promise<number> {
522
+ const maxRows = Math.max(1, this.deps.maxPerScope())
523
+ const rows: PendingAttachmentRecord[] = []
524
+ for (const [, record] of backing.entries()) {
525
+ if (record.scopeKey !== scopeKey) continue
526
+ if (this.expired(record, now)) continue
527
+ rows.push(record)
528
+ }
529
+ if (rows.length <= maxRows) return 0
530
+ const excess = rows.length - maxRows
531
+ const candidates = rows
532
+ .filter(record => record.key !== keepKey)
533
+ .sort((left, right) =>
534
+ left.receivedAt - right.receivedAt || left.messageId.localeCompare(right.messageId))
535
+ let evicted = 0
536
+ for (const record of candidates.slice(0, excess)) {
537
+ if (await this.remove(backing, record.key)) evicted += 1
538
+ }
539
+ return evicted
540
+ }
541
+
542
+ private async remove(backing: PendingBacking, key: string): Promise<boolean> {
543
+ try {
544
+ await backing.delete(key)
545
+ return true
546
+ } catch (error) {
547
+ this.deps.warn?.('dsh-channels: pruning pending attachment %s failed: %s', key, String(error))
548
+ return false
549
+ }
550
+ }
551
+
552
+ private now(): number {
553
+ return this.deps.now?.() ?? Date.now()
554
+ }
555
+
556
+ /** Expired exactly AT the TTL boundary (`receivedAt + ttl <= now`). */
557
+ private expired(record: PendingAttachmentRecord, now: number): boolean {
558
+ const ttl = this.deps.ttlMs()
559
+ if (ttl <= 0) return false
560
+ return record.receivedAt + ttl <= now
561
+ }
562
+ }