@modesto_ma/dsh-channels 0.7.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.
- package/README.md +31 -47
- package/lib/client.js +44 -122
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -558
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/access/controller.ts +3 -3
- package/src/cards/index.ts +1 -1
- package/src/cards/topic.ts +104 -0
- package/src/client/origin.tsx +1 -1
- package/src/client/panel/advanced-tab.tsx +5 -29
- package/src/client/panel/index.tsx +2 -39
- package/src/client/panel/shared.tsx +0 -12
- package/src/config.ts +26 -16
- package/src/domain.ts +89 -32
- package/src/host/core.ts +32 -1
- package/src/host/services.ts +5 -0
- package/src/index.ts +11 -4
- package/src/routing/inbound.ts +1 -1
- package/src/service.ts +20 -25
- package/src/settings/contract.ts +6 -21
- package/src/settings/overlay.ts +3 -1
- package/src/settings/routes.ts +2 -3
- package/src/settings/surface.ts +1 -14
- package/src/streamer.ts +91 -5
- package/src/topics/contract.ts +149 -0
- package/src/topics/launcher.ts +428 -0
- package/src/workspaces.ts +25 -18
- package/src/analyze/contract.ts +0 -126
- package/src/analyze/launcher.ts +0 -376
- package/src/analyze/server.ts +0 -207
- package/src/cards/analyze.ts +0 -116
package/src/analyze/contract.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wire contract of the log-platform analyze API.
|
|
3
|
-
*
|
|
4
|
-
* `POST /open-api/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 sits outside DSH's `/api`
|
|
22
|
-
* namespace so a gateway in front of DSH can expose it separately from the
|
|
23
|
-
* login-protected DSH API; the request carries this API's bearer token, not a
|
|
24
|
-
* DSH login.
|
|
25
|
-
*/
|
|
26
|
-
export const ANALYZE_ROUTE = '/open-api/logalyze'
|
|
27
|
-
|
|
28
|
-
/** Largest accepted request body (the prompt dominates it). */
|
|
29
|
-
export const ANALYZE_MAX_BODY_BYTES = 1024 * 1024
|
|
30
|
-
|
|
31
|
-
/** Largest accepted prompt, in characters. */
|
|
32
|
-
export const ANALYZE_MAX_PROMPT_CHARS = 100_000
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
|
|
36
|
-
* a platform can use a UUID or its own `type:id` token.
|
|
37
|
-
*/
|
|
38
|
-
export const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Application / error-code segment: it names a workspace directory
|
|
42
|
-
* (`<baseDir>/analyze/<app>/<errorCode>`), so only path-safe characters pass.
|
|
43
|
-
*/
|
|
44
|
-
const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
|
|
45
|
-
|
|
46
|
-
/** Most detail pages one request may list on its root card. */
|
|
47
|
-
export const ANALYZE_MAX_LOG_DETAILS = 50
|
|
48
|
-
|
|
49
|
-
/** Feishu group chat id (`oc_…`). */
|
|
50
|
-
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
|
|
51
|
-
|
|
52
|
-
/** JSON body of one analyze request. */
|
|
53
|
-
export const analyzeRequestSchema = z.object({
|
|
54
|
-
/** Feishu user_id of the person who started the analysis on the platform. */
|
|
55
|
-
requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, 'must be a Feishu user_id').max(128),
|
|
56
|
-
/** When the analysis was started on the platform, shown verbatim on the root card. */
|
|
57
|
-
triggeredAt: z.string().min(1).max(64).refine(value => value.trim() !== '', 'must not be blank'),
|
|
58
|
-
/** Application the logs belong to (workspace directory segment). */
|
|
59
|
-
app: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
|
|
60
|
-
/** Error code of the logs (workspace directory segment). */
|
|
61
|
-
errorCode: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
|
|
62
|
-
/** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
|
|
63
|
-
logDetails: z.array(z.object({
|
|
64
|
-
/** Row label. */
|
|
65
|
-
name: z.string().min(1).max(200).refine(value => value.trim() !== '', 'must not be blank'),
|
|
66
|
-
/** Detail page address. */
|
|
67
|
-
url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
|
|
68
|
-
}).strict()).min(1).max(ANALYZE_MAX_LOG_DETAILS),
|
|
69
|
-
/** Target group chat (`oc_…`); must be an authorized group. */
|
|
70
|
-
chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
|
|
71
|
-
/** Prompt forwarded verbatim as the analysis Session's first user message. */
|
|
72
|
-
prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS)
|
|
73
|
-
.refine(value => value.trim() !== '', 'must not be blank'),
|
|
74
|
-
}).strict()
|
|
75
|
-
|
|
76
|
-
export type AnalyzeRequest = z.infer<typeof analyzeRequestSchema>
|
|
77
|
-
|
|
78
|
-
/** Body of an accepted (or replayed) request. */
|
|
79
|
-
export interface AnalyzeAccepted {
|
|
80
|
-
readonly ok: true
|
|
81
|
-
/** Target group chat id. */
|
|
82
|
-
readonly chatId: string
|
|
83
|
-
/** Root card message id; the analysis topic hangs below it. */
|
|
84
|
-
readonly messageId: string
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Body of a refused request. */
|
|
88
|
-
export interface AnalyzeRefused {
|
|
89
|
-
readonly ok: false
|
|
90
|
-
/** Readable reason (Chinese, shown to platform operators). */
|
|
91
|
-
readonly error: string
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** One HTTP answer of the analyze handler. */
|
|
95
|
-
export interface AnalyzeResponse {
|
|
96
|
-
readonly status: number
|
|
97
|
-
readonly body: AnalyzeAccepted | AnalyzeRefused
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Fingerprint of one validated request, used to tell a retry of the same
|
|
102
|
-
* request from a reused `Idempotency-Key`. Field order is fixed.
|
|
103
|
-
* @param request - the validated request.
|
|
104
|
-
* @returns SHA-256 hex digest.
|
|
105
|
-
*/
|
|
106
|
-
export function analyzeRequestHash(request: AnalyzeRequest): string {
|
|
107
|
-
const canonical = JSON.stringify([
|
|
108
|
-
request.requesterUserId, request.triggeredAt, request.app, request.errorCode,
|
|
109
|
-
request.logDetails.map(detail => [detail.name, detail.url]), request.chatId, request.prompt,
|
|
110
|
-
])
|
|
111
|
-
return createHash('sha256').update(canonical).digest('hex')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Validate one decoded JSON body.
|
|
116
|
-
* @param raw - the decoded body.
|
|
117
|
-
* @returns the request, or a readable list of field errors.
|
|
118
|
-
*/
|
|
119
|
-
export function parseAnalyzeRequest(raw: unknown): { ok: true; value: AnalyzeRequest } | { ok: false; error: string } {
|
|
120
|
-
const parsed = analyzeRequestSchema.safeParse(raw)
|
|
121
|
-
if (parsed.success) return { ok: true, value: parsed.data }
|
|
122
|
-
const details = parsed.error.issues
|
|
123
|
-
.map(issue => `${issue.path.join('.') || '(body)'}: ${issue.message}`)
|
|
124
|
-
.join('; ')
|
|
125
|
-
return { ok: false, error: `请求参数不合法:${details}` }
|
|
126
|
-
}
|
package/src/analyze/launcher.ts
DELETED
|
@@ -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
|
-
}
|