@modesto_ma/dsh-channels 0.6.0 → 0.8.0-beta.1

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.
@@ -1,125 +0,0 @@
1
- /**
2
- * Wire contract of the log-platform analyze API.
3
- *
4
- * `POST /api/dsh-channels.logalyze` on the DSH Web server, with
5
- * `Authorization: Bearer <token>`, an `Idempotency-Key` header, and a JSON
6
- * body naming the requester, the trigger time, the subject (application +
7
- * error code), the log-platform detail pages, the target group, and the
8
- * prompt. A valid request posts the analysis root card into the group and
9
- * answers `202`; the analysis then streams in a topic below that card. Log
10
- * packages are the prompt's business: the analysis skill fetches whatever the
11
- * prompt names, and the detail pages are only listed on the card.
12
- *
13
- * @module dsh-channels/analyze/contract
14
- */
15
-
16
- import { createHash } from 'node:crypto'
17
- import { z } from 'zod'
18
- import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
19
-
20
- /**
21
- * Exact DSH Web route of the analyze API. It follows the host's plain-HTTP
22
- * `/api/<name>.<method>` naming; an exact route wins over the `/api` RPC prefix
23
- * route, so the request carries this API's bearer token, not a DSH login.
24
- */
25
- export const ANALYZE_ROUTE = '/api/dsh-channels.logalyze'
26
-
27
- /** Largest accepted request body (the prompt dominates it). */
28
- export const ANALYZE_MAX_BODY_BYTES = 1024 * 1024
29
-
30
- /** Largest accepted prompt, in characters. */
31
- export const ANALYZE_MAX_PROMPT_CHARS = 100_000
32
-
33
- /**
34
- * `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
35
- * a platform can use a UUID or its own `type:id` token.
36
- */
37
- export const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u
38
-
39
- /**
40
- * Application / error-code segment: it names a workspace directory
41
- * (`<baseDir>/analyze/<app>/<errorCode>`), so only path-safe characters pass.
42
- */
43
- const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
44
-
45
- /** Most detail pages one request may list on its root card. */
46
- export const ANALYZE_MAX_LOG_DETAILS = 50
47
-
48
- /** Feishu group chat id (`oc_…`). */
49
- const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
50
-
51
- /** JSON body of one analyze request. */
52
- export const analyzeRequestSchema = z.object({
53
- /** Feishu user_id of the person who started the analysis on the platform. */
54
- requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, 'must be a Feishu user_id').max(128),
55
- /** When the analysis was started on the platform, shown verbatim on the root card. */
56
- triggeredAt: z.string().min(1).max(64).refine(value => value.trim() !== '', 'must not be blank'),
57
- /** Application the logs belong to (workspace directory segment). */
58
- app: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
59
- /** Error code of the logs (workspace directory segment). */
60
- errorCode: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
61
- /** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
62
- logDetails: z.array(z.object({
63
- /** Row label. */
64
- name: z.string().min(1).max(200).refine(value => value.trim() !== '', 'must not be blank'),
65
- /** Detail page address. */
66
- url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
67
- }).strict()).min(1).max(ANALYZE_MAX_LOG_DETAILS),
68
- /** Target group chat (`oc_…`); must be an authorized group. */
69
- chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
70
- /** Prompt forwarded verbatim as the analysis Session's first user message. */
71
- prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS)
72
- .refine(value => value.trim() !== '', 'must not be blank'),
73
- }).strict()
74
-
75
- export type AnalyzeRequest = z.infer<typeof analyzeRequestSchema>
76
-
77
- /** Body of an accepted (or replayed) request. */
78
- export interface AnalyzeAccepted {
79
- readonly ok: true
80
- /** Target group chat id. */
81
- readonly chatId: string
82
- /** Root card message id; the analysis topic hangs below it. */
83
- readonly messageId: string
84
- }
85
-
86
- /** Body of a refused request. */
87
- export interface AnalyzeRefused {
88
- readonly ok: false
89
- /** Readable reason (Chinese, shown to platform operators). */
90
- readonly error: string
91
- }
92
-
93
- /** One HTTP answer of the analyze handler. */
94
- export interface AnalyzeResponse {
95
- readonly status: number
96
- readonly body: AnalyzeAccepted | AnalyzeRefused
97
- }
98
-
99
- /**
100
- * Fingerprint of one validated request, used to tell a retry of the same
101
- * request from a reused `Idempotency-Key`. Field order is fixed.
102
- * @param request - the validated request.
103
- * @returns SHA-256 hex digest.
104
- */
105
- export function analyzeRequestHash(request: AnalyzeRequest): string {
106
- const canonical = JSON.stringify([
107
- request.requesterUserId, request.triggeredAt, request.app, request.errorCode,
108
- request.logDetails.map(detail => [detail.name, detail.url]), request.chatId, request.prompt,
109
- ])
110
- return createHash('sha256').update(canonical).digest('hex')
111
- }
112
-
113
- /**
114
- * Validate one decoded JSON body.
115
- * @param raw - the decoded body.
116
- * @returns the request, or a readable list of field errors.
117
- */
118
- export function parseAnalyzeRequest(raw: unknown): { ok: true; value: AnalyzeRequest } | { ok: false; error: string } {
119
- const parsed = analyzeRequestSchema.safeParse(raw)
120
- if (parsed.success) return { ok: true, value: parsed.data }
121
- const details = parsed.error.issues
122
- .map(issue => `${issue.path.join('.') || '(body)'}: ${issue.message}`)
123
- .join('; ')
124
- return { ok: false, error: `请求参数不合法:${details}` }
125
- }
@@ -1,376 +0,0 @@
1
- /**
2
- * Log-platform analysis launcher: the business side of the analyze API.
3
- *
4
- * Synchronously (before the `202`): service state, target-group permission,
5
- * `Idempotency-Key` replay, and the root card (分析中). In the background: the
6
- * topic (opened by the first streaming card, replied in thread below the root
7
- * card), the `<baseDir>/analyze/<app>/<errorCode>` workspace and its per-topic
8
- * directory, the Session, the group-shared route, and the first turn: the
9
- * platform prompt verbatim, streamed into that card like any topic turn. When
10
- * the first turn settles, the root card's status follows it (已完成 / 已停止 /
11
- * 失败), and the request record keeps the same status. At channel start,
12
- * records still `running` belong to analyses a previous instance never
13
- * finished; {@link AnalyzeLauncher.markInterrupted} marks their cards 已中断.
14
- * Log packages are fetched by the analysis skill the prompt invokes.
15
- *
16
- * The topic is reserved in the inbound router from its creation until the
17
- * route and the first prompt are published, so replies posted meanwhile queue
18
- * behind the first turn. Follow-ups then use the ordinary topic routing: the
19
- * route is `shared`, so every member admitted in the group may continue it. A
20
- * failure before the first turn (including a disconnect meanwhile) marks the
21
- * root card failed with the reason, closes the streaming card, and leaves no
22
- * route.
23
- *
24
- * @module dsh-channels/analyze/launcher
25
- */
26
-
27
- import { randomUUID } from 'node:crypto'
28
- import type { AgentHandle, HostContext, SessionId } from '../dsh-types.ts'
29
- import type { FeishuConfig } from '../config.ts'
30
- import { buildAnalyzeRootCard, buildCard, initialStreamingCard } from '../cards.ts'
31
- import type { AnalyzeStatus, AnalyzeSubject } from '../cards.ts'
32
- import type { RouteOperationQueue } from '../commands.ts'
33
- import type { AnalyzeRequestRecord, FeishuDomainHandle, RouteRecord } from '../domain.ts'
34
- import type { FeishuChannel } from '../feishu-channel.ts'
35
- import { feishuUserMessage } from '../routing/inbound.ts'
36
- import { enterTopic, placementServices, planTopic } from '../session/isolation.ts'
37
- import type { TopicPlacement } from '../session/isolation.ts'
38
- import type { SessionManager } from '../session/manager.ts'
39
- import type { InflightRepair } from '../session/repair.ts'
40
- import type { RouteStore } from '../session/routes.ts'
41
- import type { TurnStreamer } from '../streamer.ts'
42
- import { ensureAnalyzeWorkspace, topicTokenFor } from '../workspaces.ts'
43
- import { analyzeRequestHash } from './contract.ts'
44
- import type { AnalyzeAccepted, AnalyzeRequest, AnalyzeResponse } from './contract.ts'
45
- import type { AnalyzeHandler } from './server.ts'
46
-
47
- /** Collaborators of the launcher (late-bound where the service swaps them per connection). */
48
- export interface AnalyzeLauncherDeps {
49
- ctx: HostContext
50
- config: FeishuConfig
51
- started(): boolean
52
- channel(): FeishuChannel | undefined
53
- domain(): FeishuDomainHandle | undefined
54
- routes: RouteStore
55
- repair: InflightRepair
56
- sessions: SessionManager
57
- streamer(): TurnStreamer | undefined
58
- /** Shared per-route FIFO (the first turn must precede any follow-up). */
59
- queue: RouteOperationQueue
60
- /**
61
- * Reserve an opened topic until its route is published, so replies posted
62
- * meanwhile wait instead of being dropped or starting a competing Session.
63
- * The returned closer is called exactly once.
64
- */
65
- reserveTopic(threadId: string): () => void
66
- /** Whether the analyze API may post into the group. */
67
- mayAnalyzeIn(chatId: string): boolean
68
- /** Resolve the configured bearer token; undefined when unresolvable. */
69
- resolveToken(): Promise<string | undefined>
70
- }
71
-
72
- /** One accepted request still answering its `202`. */
73
- interface PendingAccept {
74
- readonly bodyHash: string
75
- readonly response: Promise<AnalyzeResponse>
76
- }
77
-
78
- /** Readable reason of an error value. */
79
- function messageOf(error: unknown): string {
80
- return error instanceof Error ? error.message : String(error)
81
- }
82
-
83
- export class AnalyzeLauncher implements AnalyzeHandler {
84
- /** `Idempotency-Key` → request still being accepted (concurrent retries share it). */
85
- private readonly accepting = new Map<string, PendingAccept>()
86
- /** Background analyses still starting (drained on shutdown). */
87
- private readonly running = new Set<Promise<void>>()
88
- /** Whether requests are admitted: from {@link open} after start until {@link close}. */
89
- private admitting = false
90
-
91
- constructor(private readonly deps: AnalyzeLauncherDeps) {}
92
-
93
- /**
94
- * Start admitting requests. Called at channel start after
95
- * {@link markInterrupted}, so a new request is never marked interrupted.
96
- */
97
- open(): void {
98
- this.admitting = true
99
- }
100
-
101
- /** Stop admitting requests (503) ahead of shutdown. */
102
- close(): void {
103
- this.admitting = false
104
- }
105
-
106
- enabled(): boolean {
107
- return this.deps.config.analyzeEnabled
108
- }
109
-
110
- async token(): Promise<string | undefined> {
111
- return await this.deps.resolveToken()
112
- }
113
-
114
- async analyze(request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse> {
115
- const bodyHash = analyzeRequestHash(request)
116
- const pending = this.accepting.get(idempotencyKey)
117
- if (pending !== undefined) {
118
- if (pending.bodyHash !== bodyHash) return reusedKey()
119
- return await pending.response
120
- }
121
- const response = this.accept(request, idempotencyKey, bodyHash)
122
- this.accepting.set(idempotencyKey, { bodyHash, response })
123
- try {
124
- return await response
125
- } finally {
126
- this.accepting.delete(idempotencyKey)
127
- }
128
- }
129
-
130
- /**
131
- * Wait for every acceptance in flight, then every background analysis that
132
- * is still starting. Called after `started` turns false, so each one ends on
133
- * its failure card or, when its first prompt was already sent, as an
134
- * ordinary topic.
135
- */
136
- async drain(): Promise<void> {
137
- // Acceptances still sending the root card or writing the record finish
138
- // first; each one adds its launch to `running` before it settles.
139
- await Promise.allSettled([...this.accepting.values()].map(pending => pending.response))
140
- await Promise.allSettled([...this.running])
141
- }
142
-
143
- /**
144
- * Mark every analysis still `running` as interrupted, on its card and in
145
- * its record. Called at channel start before admission opens, so each
146
- * such record belongs to a previous instance whose first turn never
147
- * settled. A card that cannot be updated (deleted, recalled) is logged and
148
- * still recorded as interrupted, so it is not retried on every start.
149
- */
150
- async markInterrupted(): Promise<void> {
151
- const channel = this.deps.channel()
152
- const domain = this.deps.domain()
153
- if (channel === undefined || domain === undefined) return
154
- for (const [key, record] of [...domain.analyzeRequests.entries()]) {
155
- if (record.status !== 'running') continue
156
- await channel.updateCard(record.messageId, buildAnalyzeRootCard(record.subject, { kind: 'interrupted' }))
157
- .catch((error: unknown) => {
158
- this.deps.ctx.logger?.warn?.('dsh-channels: marking analysis card %s interrupted failed: %s', key, String(error))
159
- })
160
- await this.recordStatus(key, 'interrupted')
161
- }
162
- }
163
-
164
- private async accept(request: AnalyzeRequest, key: string, bodyHash: string): Promise<AnalyzeResponse> {
165
- const channel = this.deps.channel()
166
- const domain = this.deps.domain()
167
- if (!this.admitting || !this.deps.started() || channel === undefined || domain === undefined) {
168
- return refused(503, '飞书渠道尚未连接,请稍后重试')
169
- }
170
- const stored = domain.analyzeRequests.get(key)
171
- if (stored !== undefined) {
172
- if (stored.bodyHash !== bodyHash) return reusedKey()
173
- return { status: 202, body: { ok: true, chatId: stored.chatId, messageId: stored.messageId } }
174
- }
175
- if (!this.deps.mayAnalyzeIn(request.chatId)) {
176
- return refused(403, '目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加')
177
- }
178
- const subject = subjectOf(request)
179
- let rootId: string
180
- try {
181
- rootId = (await channel.send(request.chatId, {
182
- card: buildAnalyzeRootCard(subject, { kind: 'running' }),
183
- })).messageId
184
- } catch (error) {
185
- return refused(502, `分析卡片发送失败(机器人需在目标群内):${messageOf(error)}`)
186
- }
187
- const body: AnalyzeAccepted = { ok: true, chatId: request.chatId, messageId: rootId }
188
- try {
189
- await domain.analyzeRequests.put(key, {
190
- key, bodyHash, chatId: request.chatId, messageId: rootId, acceptedAt: Date.now(),
191
- subject: { ...subject, logDetails: [...subject.logDetails] }, status: 'running',
192
- })
193
- } catch (error) {
194
- // The card is visible already; a lost record only means a retry of this
195
- // key would post a second analysis.
196
- this.deps.ctx.logger?.warn?.('dsh-channels: recording analyze request %s failed: %s', key, String(error))
197
- }
198
- const run = this.launch(channel, request, subject, rootId, key)
199
- this.running.add(run)
200
- void run.finally(() => { this.running.delete(run) })
201
- return { status: 202, body }
202
- }
203
-
204
- /** Start the analysis below an accepted root card; every failure ends on the cards. */
205
- private async launch(
206
- channel: FeishuChannel,
207
- request: AnalyzeRequest,
208
- subject: AnalyzeSubject,
209
- rootId: string,
210
- key: string,
211
- ): Promise<void> {
212
- const warn = (message: string, ...args: unknown[]): void => {
213
- this.deps.ctx.logger?.warn?.(message, ...args)
214
- }
215
- const showStatus = async (status: AnalyzeStatus): Promise<void> => {
216
- await channel.updateCard(rootId, buildAnalyzeRootCard(subject, status)).catch((error: unknown) => {
217
- warn('dsh-channels: updating analysis card %s failed: %s', rootId, String(error))
218
- })
219
- await this.recordStatus(key, status.kind)
220
- }
221
- const sessionId = randomUUID() as SessionId
222
- let handle: AgentHandle | undefined
223
- let cardId: string | undefined
224
- let threadId: string | undefined
225
- let releaseTopic: (() => void) | undefined
226
- let published = false
227
- try {
228
- const topic = await this.openTopic(channel, request.chatId, rootId, sessionId)
229
- cardId = topic.cardId
230
- threadId = topic.threadId
231
- releaseTopic = this.deps.reserveTopic(threadId)
232
- const services = placementServices(this.deps.ctx)
233
- const workspace = await ensureAnalyzeWorkspace(
234
- this.deps.ctx, this.deps.config.baseDir, request.app, request.errorCode,
235
- )
236
- const topicToken = topicTokenFor(threadId, 'thread', sessionId)
237
- const plan = await planTopic(services, this.deps.config, workspace.path, topicToken, warn)
238
- this.assertStarted()
239
- handle = await this.deps.sessions.createSession(sessionId, plan.cwd)
240
- const placement: TopicPlacement = await enterTopic(
241
- services, handle.agent, plan, workspace.path, topicToken, warn,
242
- )
243
- if (plan.cwd === workspace.path) {
244
- void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
245
- warn('dsh-channels: attaching analysis session %s to workspace failed: %s', sessionId, String(error))
246
- })
247
- }
248
- await this.deps.sessions.mountGuidance(handle.agent, placement.sessionDir)
249
- this.assertStarted()
250
-
251
- const createdAt = handle.agent.session.header.createdAt
252
- const record: RouteRecord = {
253
- key: threadId,
254
- kind: 'thread',
255
- sessionId,
256
- chatId: request.chatId,
257
- chatType: 'group',
258
- senderUserId: request.requesterUserId,
259
- workspacePath: workspace.path,
260
- sessionDir: placement.sessionDir,
261
- ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
262
- cardMessageId: cardId,
263
- createdAt,
264
- lastActivityAt: createdAt,
265
- sessionHistory: [{ sessionId, createdAt }],
266
- shared: true,
267
- }
268
- await this.deps.repair.write({
269
- key: cardId,
270
- sessionId,
271
- chatId: request.chatId,
272
- triggerMessageId: rootId,
273
- cards: [{ messageId: cardId, mode: 'card' }],
274
- startedAt: Date.now(),
275
- attempts: 0,
276
- })
277
- const agent = handle.agent
278
- const routeKey = threadId
279
- const streamCardId = cardId
280
- // Route, stream binding, and prompt land in one queue slot, so a reply
281
- // posted in the topic meanwhile is delivered after the first turn.
282
- await this.deps.queue.run(routeKey, async () => {
283
- this.assertStarted()
284
- await this.deps.routes.put(routeKey, record)
285
- published = true
286
- this.deps.streamer()?.attach(agent.session, streamCardId, rootId, undefined, {
287
- onSettled: outcome => { void showStatus({ kind: outcome }) },
288
- })
289
- agent.followup(feishuUserMessage(request.prompt))
290
- })
291
- } catch (error) {
292
- const reason = messageOf(error)
293
- warn('dsh-channels: analysis for %s/%s failed to start: %s', request.app, request.errorCode, reason)
294
- if (published && threadId !== undefined) {
295
- await this.deps.routes.remove(threadId).catch((removeError: unknown) => {
296
- warn('dsh-channels: removing analysis route %s failed: %s', threadId, String(removeError))
297
- })
298
- }
299
- await handle?.dispose().catch(() => {})
300
- if (cardId !== undefined) {
301
- void this.deps.repair.clear(cardId)
302
- await channel.updateCard(cardId, buildCard({
303
- phase: 'error',
304
- reasoning: '',
305
- body: `❌ 分析未能开始:${reason}`,
306
- stats: { sessionId, inputTokens: 0, outputTokens: 0 },
307
- })).catch(() => {})
308
- }
309
- await showStatus({ kind: 'failed', reason })
310
- } finally {
311
- releaseTopic?.()
312
- }
313
- }
314
-
315
- /**
316
- * Open the analysis topic: the first streaming card, replied in thread below
317
- * the root card. The topic id is read from that card (or the root card, in
318
- * topic groups).
319
- */
320
- private async openTopic(
321
- channel: FeishuChannel,
322
- chatId: string,
323
- rootId: string,
324
- sessionId: SessionId,
325
- ): Promise<{ cardId: string; threadId: string }> {
326
- const sent = await channel.send(chatId, { card: initialStreamingCard(sessionId) }, {
327
- replyTo: rootId, replyInThread: true,
328
- })
329
- const threadId = await channel.threadIdOfMessage(sent.messageId).catch(() => undefined)
330
- ?? await channel.threadIdOfMessage(rootId).catch(() => undefined)
331
- if (threadId === undefined) {
332
- await channel.updateCard(sent.messageId, buildCard({
333
- phase: 'error',
334
- reasoning: '',
335
- body: '❌ 无法在目标群创建话题',
336
- stats: { sessionId, inputTokens: 0, outputTokens: 0 },
337
- })).catch(() => {})
338
- throw new Error('无法在目标群创建话题(读取话题 id 失败)')
339
- }
340
- return { cardId: sent.messageId, threadId }
341
- }
342
-
343
- /** Store a root card status in the request record (best-effort; the card already shows it). */
344
- private async recordStatus(key: string, status: AnalyzeRequestRecord['status']): Promise<void> {
345
- const domain = this.deps.domain()
346
- const record = domain?.analyzeRequests.get(key)
347
- if (domain === undefined || record === undefined) return
348
- try {
349
- await domain.analyzeRequests.put(key, { ...record, status })
350
- } catch (error) {
351
- this.deps.ctx.logger?.warn?.('dsh-channels: recording analysis %s status failed: %s', key, String(error))
352
- }
353
- }
354
-
355
- private assertStarted(): void {
356
- if (!this.deps.started()) throw new Error('飞书渠道已断开,分析已取消')
357
- }
358
- }
359
-
360
- function subjectOf(request: AnalyzeRequest): AnalyzeSubject {
361
- return {
362
- requesterUserId: request.requesterUserId,
363
- triggeredAt: request.triggeredAt,
364
- app: request.app,
365
- errorCode: request.errorCode,
366
- logDetails: request.logDetails,
367
- }
368
- }
369
-
370
- function refused(status: number, error: string): AnalyzeResponse {
371
- return { status, body: { ok: false, error } }
372
- }
373
-
374
- function reusedKey(): AnalyzeResponse {
375
- return refused(409, 'Idempotency-Key 已被内容不同的请求使用')
376
- }