@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4

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 +72 -15
  2. package/docs/attachment-pipeline-v2.md +2 -2
  3. package/docs/feishu-onboarding.md +4 -3
  4. package/lib/client.js +369 -94
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7847 -5293
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/panel/access-card.tsx +96 -70
  18. package/src/client/panel/analyze-token-card.tsx +70 -0
  19. package/src/client/panel/index.tsx +72 -10
  20. package/src/client/panel/sessions-tab.tsx +7 -4
  21. package/src/client/panel/shared.tsx +12 -0
  22. package/src/client/panel/wiki-tab.tsx +62 -0
  23. package/src/config.ts +52 -14
  24. package/src/domain.ts +55 -0
  25. package/src/feishu/scopes.ts +19 -1
  26. package/src/feishu/tools.ts +3 -1
  27. package/src/feishu/wiki/a1.ts +97 -0
  28. package/src/feishu/wiki/api.ts +21 -6
  29. package/src/feishu/wiki/base-api.ts +267 -0
  30. package/src/feishu/wiki/base-tools.ts +423 -0
  31. package/src/feishu/wiki/shared.ts +284 -0
  32. package/src/feishu/wiki/sheet-api.ts +250 -0
  33. package/src/feishu/wiki/sheet-tools.ts +493 -0
  34. package/src/feishu/wiki/tools.ts +102 -189
  35. package/src/feishu-channel.ts +14 -1
  36. package/src/health.ts +12 -0
  37. package/src/host/agent.ts +5 -1
  38. package/src/index.ts +1 -1
  39. package/src/interactions/pending.ts +11 -2
  40. package/src/routing/inbound.ts +57 -14
  41. package/src/service.ts +161 -28
  42. package/src/settings/contract.ts +37 -6
  43. package/src/settings/routes.ts +66 -14
  44. package/src/settings/surface.ts +69 -20
  45. package/src/streamer.ts +156 -7
  46. package/src/workspaces.ts +31 -0
package/src/streamer.ts CHANGED
@@ -35,6 +35,12 @@
35
35
  * on the card. Ownership checks and the cancellation itself belong to the
36
36
  * caller.
37
37
  *
38
+ * Queued turns: a turn attached behind the session's active turn renders as
39
+ * a queued card (notice + steer button) until it becomes the front.
40
+ * `steerTargetOf()` resolves a clicked queued card; `mergeQueued()` lets the
41
+ * caller move the queued message into the running turn and retires the
42
+ * queued card, handing its trigger reaction to the running turn.
43
+ *
38
44
  * The first visible text delta flips the panel title 推理中 → 回复中; the
39
45
  * final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
40
46
  * only runs when the content actually changed. Segment sends are serialized
@@ -48,8 +54,8 @@
48
54
  * @module dsh-channels/streamer
49
55
  */
50
56
 
51
- import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
52
- import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
57
+ import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason, UserMessage } from './dsh-types.ts'
58
+ import { buildCard, buildMergedCard, segmentCardContent, segmentCardOptions } from './cards.ts'
53
59
  import type { CardState, PanelPhase, StopControl } from './cards.ts'
54
60
  import type { FeishuChannel } from './feishu-channel.ts'
55
61
  import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
@@ -147,13 +153,40 @@ interface SegmentCard {
147
153
  nextRetryAt: number
148
154
  }
149
155
 
156
+ /** A Feishu message whose Typing reaction swaps to DONE/No when a turn settles. */
157
+ interface TriggerReaction {
158
+ triggerMessageId: string
159
+ /** Reaction id of the Typing reaction (undefined when the add failed). */
160
+ reactionId: string | undefined
161
+ }
162
+
163
+ /** Settled outcome of one turn, as its card shows it. */
164
+ export type TurnOutcome = 'done' | 'stopped' | 'error'
165
+
166
+ /** Per-turn options of {@link TurnStreamer.attach}. */
167
+ export interface AttachOptions {
168
+ /** Called once when the turn's card reaches its final state. */
169
+ readonly onSettled?: (outcome: TurnOutcome) => void
170
+ /**
171
+ * The prompt this turn will run; only a turn that carries it can be
172
+ * steered into the running turn while queued.
173
+ */
174
+ readonly message?: UserMessage
175
+ }
176
+
150
177
  interface TurnState {
178
+ /** `AttachOptions.onSettled`. */
179
+ onSettled: ((outcome: TurnOutcome) => void) | undefined
151
180
  /** Turn number once known; undefined until the first `step/start`. */
152
181
  turn: number | undefined
153
182
  /** Message whose reaction carries Typing → DONE/No. */
154
183
  triggerMessageId: string
155
184
  /** Reaction id of the Typing reaction (undefined when the add failed). */
156
185
  reactionId: string | undefined
186
+ /** Prompt queued for this turn; a queued card can steer it into the running turn. */
187
+ message: UserMessage | undefined
188
+ /** Triggers of queued messages merged into this turn (settle together). */
189
+ mergedTriggers: TriggerReaction[]
157
190
  phase: PanelPhase
158
191
  reasoning: string
159
192
  /** Request header produced during THIS turn; never inherited from history. */
@@ -216,6 +249,8 @@ interface TurnState {
216
249
  stopRequested: boolean
217
250
  /** A non-owner clicked stop; the owner-only notice renders under the button. */
218
251
  stopDenied: boolean
252
+ /** A non-owner clicked the queued card's steer button. */
253
+ queuedDenied: boolean
219
254
  }
220
255
 
221
256
  /** Card/body lengths captured at one stream attempt's `start` frame. */
@@ -327,8 +362,21 @@ export class TurnStreamer {
327
362
  this.heartbeatMs = heartbeatMs
328
363
  }
329
364
 
330
- /** Bind a freshly sent reply card to a session; seeds the stats fold from the log. */
331
- attach(session: Session, cardMessageId: string, triggerMessageId: string, reactionId: string | undefined): void {
365
+ /**
366
+ * Bind a freshly sent reply card to a session; seeds the stats fold from the log.
367
+ * @param session - the Session whose next turn renders into the card.
368
+ * @param cardMessageId - the main card message (patched in place).
369
+ * @param triggerMessageId - the message carrying the Typing → DONE/No reaction.
370
+ * @param reactionId - the Typing reaction to remove, when one was added.
371
+ * @param options - per-turn options.
372
+ */
373
+ attach(
374
+ session: Session,
375
+ cardMessageId: string,
376
+ triggerMessageId: string,
377
+ reactionId: string | undefined,
378
+ options: AttachOptions = {},
379
+ ): void {
332
380
  this.sessions.set(session.id, session)
333
381
  let fold = this.folds.get(session.id)
334
382
  if (fold === undefined) {
@@ -337,9 +385,12 @@ export class TurnStreamer {
337
385
  }
338
386
  const queue = this.bySession.get(session.id) ?? []
339
387
  queue.push({
388
+ onSettled: options.onSettled,
340
389
  turn: undefined,
341
390
  triggerMessageId,
342
391
  reactionId,
392
+ message: options.message,
393
+ mergedTriggers: [],
343
394
  phase: 'thinking',
344
395
  reasoning: '',
345
396
  header: undefined,
@@ -377,6 +428,7 @@ export class TurnStreamer {
377
428
  attempts: new Map(),
378
429
  stopRequested: false,
379
430
  stopDenied: false,
431
+ queuedDenied: false,
380
432
  })
381
433
  this.bySession.set(session.id, queue)
382
434
  const state = queue[queue.length - 1]!
@@ -424,6 +476,94 @@ export class TurnStreamer {
424
476
  return undefined
425
477
  }
426
478
 
479
+ /**
480
+ * Whether the session has an unsettled turn that a new message would queue
481
+ * behind.
482
+ * @param sessionId - the session to inspect.
483
+ * @returns true while a turn is streaming or queued.
484
+ */
485
+ hasLiveTurn(sessionId: string): boolean {
486
+ return this.front(sessionId) !== undefined
487
+ }
488
+
489
+ /**
490
+ * The queued turn whose card is `messageId`, or undefined when the card is
491
+ * not a queued card (active, settled, merged, detached, or unknown).
492
+ * @param messageId Feishu message id of a clicked card.
493
+ * @returns The owning session id and the queued prompt.
494
+ */
495
+ steerTargetOf(messageId: string): { sessionId: string; message: UserMessage } | undefined {
496
+ const queued = this.queuedState(messageId)
497
+ return queued === undefined ? undefined : { sessionId: queued.sessionId, message: queued.message }
498
+ }
499
+
500
+ /** The steerable queued turn whose main card is `messageId`. */
501
+ private queuedState(messageId: string): { sessionId: string; state: TurnState; message: UserMessage } | undefined {
502
+ for (const [sessionId, queue] of this.bySession) {
503
+ const front = this.front(sessionId)
504
+ const state = queue.find(entry => !entry.finalized && entry !== front && entry.cards[0]?.messageId === messageId)
505
+ if (state?.message !== undefined) return { sessionId, state, message: state.message }
506
+ }
507
+ return undefined
508
+ }
509
+
510
+ /**
511
+ * Move the queued turn of `messageId` into the session's running turn.
512
+ * Refused (false) while the running turn is stopping, or when `steer`
513
+ * reports the message was no longer pending. On success the queued card
514
+ * becomes the merged notice, and its trigger reaction settles with the
515
+ * running turn.
516
+ * @param messageId - the queued card the owner clicked.
517
+ * @param steer - moves the message in the agent inbox; returns false when
518
+ * the driver already claimed it.
519
+ * @returns whether the message moved.
520
+ */
521
+ mergeQueued(messageId: string, steer: (sessionId: string, message: UserMessage) => boolean): boolean {
522
+ const target = this.queuedState(messageId)
523
+ if (target === undefined) return false
524
+ const { sessionId, state } = target
525
+ const front = this.front(sessionId)!
526
+ if (front.stopRequested || !steer(sessionId, target.message)) return false
527
+ const queue = this.bySession.get(sessionId)!
528
+ queue.splice(queue.indexOf(state), 1)
529
+ state.disposed = true
530
+ if (state.timer !== undefined) clearTimeout(state.timer)
531
+ if (state.heartbeatTimer !== undefined) clearTimeout(state.heartbeatTimer)
532
+ front.mergedTriggers.push(
533
+ { triggerMessageId: state.triggerMessageId, reactionId: state.reactionId },
534
+ ...state.mergedTriggers,
535
+ )
536
+ void this.settleMergedCard(sessionId, messageId)
537
+ return true
538
+ }
539
+
540
+ /**
541
+ * Patch a merged queued card into its terminal notice, with up to two
542
+ * retries (1s / 2s backoff) so a transient failure does not leave a stale
543
+ * queued card whose button no longer acts.
544
+ */
545
+ private async settleMergedCard(sessionId: string, messageId: string): Promise<void> {
546
+ let lastError: unknown
547
+ for (let attempt = 0; attempt < 3; attempt++) {
548
+ try {
549
+ await this.channel.updateCard(messageId, buildMergedCard())
550
+ return
551
+ } catch (error) {
552
+ lastError = error
553
+ if (attempt < 2) await this.delay(1_000 * 2 ** attempt)
554
+ }
555
+ }
556
+ this.hooks.onError(sessionId, lastError)
557
+ }
558
+
559
+ /** Add the owner-only notice under a queued card's steer button (once). */
560
+ markQueuedDenied(messageId: string): void {
561
+ const target = this.queuedState(messageId)
562
+ if (target === undefined || target.state.queuedDenied) return
563
+ target.state.queuedDenied = true
564
+ this.markDirty(target.sessionId, target.state)
565
+ }
566
+
427
567
  /**
428
568
  * Show (or clear) the approval wait on the session's live turn: the tool
429
569
  * line reads `⏳ 等待你审批:bash` while any approval card is undecided.
@@ -958,7 +1098,8 @@ export class TurnStreamer {
958
1098
  const kind = this.noteKind('tool', tool, state.toolKind)
959
1099
  if (kind !== undefined) state.toolKind = kind
960
1100
  }
961
- const stop: StopControl | undefined = !active || state.finalized || this.front(sessionId) !== state
1101
+ const queued = !state.finalized && this.front(sessionId) !== state
1102
+ const stop: StopControl | undefined = !active || state.finalized || queued
962
1103
  ? undefined
963
1104
  : state.stopRequested ? 'stopping' : 'ready'
964
1105
  return {
@@ -968,6 +1109,7 @@ export class TurnStreamer {
968
1109
  status: active ? state.status : '',
969
1110
  tool: active ? state.tool : '',
970
1111
  ...(stop === undefined ? {} : { stop, stopDenied: state.stopDenied }),
1112
+ ...(queued ? { queued, queuedDenied: state.queuedDenied } : {}),
971
1113
  stats: this.statsFor(sessionId, state),
972
1114
  }
973
1115
  }
@@ -1190,7 +1332,7 @@ export class TurnStreamer {
1190
1332
  }
1191
1333
 
1192
1334
  // Reaction swap: Typing → DONE on success, Typing → No on failure.
1193
- void this.swapReaction(state, failure !== undefined)
1335
+ for (const trigger of [state, ...state.mergedTriggers]) void this.swapReaction(trigger, failure !== undefined)
1194
1336
 
1195
1337
  // Prune the finalized state lazily (kept until the next attach so the
1196
1338
  // front-scan skips it, then removed).
@@ -1205,9 +1347,16 @@ export class TurnStreamer {
1205
1347
  })
1206
1348
 
1207
1349
  this.hooks.onTurnFinalized(sessionId, state.turn ?? 0, state.cards[0]?.messageId ?? '')
1350
+ if (state.onSettled !== undefined) {
1351
+ try {
1352
+ state.onSettled(state.phase === 'error' ? 'error' : state.phase === 'stopped' ? 'stopped' : 'done')
1353
+ } catch (error) {
1354
+ this.hooks.onError(sessionId, error)
1355
+ }
1356
+ }
1208
1357
  }
1209
1358
 
1210
- private async swapReaction(state: TurnState, failed: boolean): Promise<void> {
1359
+ private async swapReaction(state: TriggerReaction, failed: boolean): Promise<void> {
1211
1360
  try {
1212
1361
  if (state.reactionId !== undefined) {
1213
1362
  await this.channel.removeReaction(state.triggerMessageId, state.reactionId)
package/src/workspaces.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * - p2p: workspace `<baseDir>/p2p/<user_id>/`
6
6
  * - group: workspace `<baseDir>/groups/<chat_id>/<user_id>/`
7
7
  * - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
8
+ * - log-platform analysis: workspace `<baseDir>/analyze/<app>/<errorCode>/`
8
9
  *
9
10
  * The workspace root is the Session header cwd: it keeps the Session grouped
10
11
  * under the user's project in the GUI and stays the sandbox write boundary.
@@ -232,3 +233,33 @@ export async function ensureWorkspace(
232
233
  workspaceTitleFor(chatType, chatId, senderUserId),
233
234
  )
234
235
  }
236
+
237
+ /**
238
+ * Workspace directory of one log-platform analysis subject:
239
+ * `<baseDir>/analyze/<app>/<errorCode>/`. Every analysis of the same
240
+ * application and error code is grouped there, each in its own topic
241
+ * directory.
242
+ */
243
+ export function analyzeWorkspacePathFor(baseDir: string, app: string, errorCode: string): string {
244
+ return path.join(baseDir, 'analyze', safeSegment(app, 'app'), safeSegment(errorCode, 'errorCode'))
245
+ }
246
+
247
+ /** Workspace title of one analysis subject in the DSH workspace list. */
248
+ export function analyzeWorkspaceTitleFor(app: string, errorCode: string): string {
249
+ return `日志分析 · ${app} · ${errorCode}`
250
+ }
251
+
252
+ /**
253
+ * Ensure one analysis subject's workspace exists and is registered (idempotent
254
+ * like {@link ensureWorkspace}).
255
+ */
256
+ export async function ensureAnalyzeWorkspace(
257
+ ctx: HostContext,
258
+ baseDir: string,
259
+ app: string,
260
+ errorCode: string,
261
+ ): Promise<Workspace> {
262
+ const dir = analyzeWorkspacePathFor(baseDir, app, errorCode)
263
+ await mkdir(dir, { recursive: true })
264
+ return ctx.workspaceRegistry.create(dir, analyzeWorkspaceTitleFor(app, errorCode))
265
+ }