@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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Types of the `feishuTopics` service: another DSH plugin starts a
3
+ * group-shared Feishu topic Session from an external trigger (for example
4
+ * 观象's log-platform analyze API).
5
+ *
6
+ * One launch posts a root card into an authorized group, opens a topic below
7
+ * it, creates the Session in a workspace below the channel's `baseDir`, and
8
+ * streams the first turn (the prompt, verbatim) in that topic. The root card's
9
+ * status tag follows the first turn (进行中 → 已完成 / 已停止 / 失败) and turns
10
+ * 已中断 when DSH restarts before it settles. Every member admitted in the
11
+ * group may continue the topic.
12
+ *
13
+ * This module has no runtime exports, and the package subpath
14
+ * `@modesto_ma/dsh-channels/topics` exposes only its types. A consumer imports
15
+ * them with `import type`, looks the service up per call with
16
+ * `ctx.get('feishuTopics')` (absent while dsh-channels is not loaded or
17
+ * reloading), and checks {@link FeishuTopicService.version} before calling.
18
+ *
19
+ * @module dsh-channels/topics/contract
20
+ */
21
+
22
+ /** Name the service is provided under (`ctx.get('feishuTopics')`). */
23
+ export type FeishuTopicsServiceName = 'feishuTopics'
24
+
25
+ /**
26
+ * Version of this contract. It changes only when a change would make an older
27
+ * consumer misbehave; a consumer refuses a provider whose version it does not
28
+ * know.
29
+ */
30
+ export type FeishuTopicsVersion = 1
31
+
32
+ /** One run of the root card's lead sentence. */
33
+ export interface TopicCardText {
34
+ /** Plain text; Markdown characters are shown literally. */
35
+ readonly text: string
36
+ /** Shown in bold. */
37
+ readonly bold?: boolean
38
+ }
39
+
40
+ /** One linked row of the root card's table. */
41
+ export interface TopicCardLink {
42
+ /** Row label. */
43
+ readonly name: string
44
+ /** `http(s)` address opened by the row's link. */
45
+ readonly url: string
46
+ }
47
+
48
+ /** What the root card shows besides its status. */
49
+ export interface TopicRootCard {
50
+ /** Header title. */
51
+ readonly title: string
52
+ /**
53
+ * Word naming the work in the status texts, e.g. `分析` gives the tag
54
+ * `分析中` and the footer `分析已完成,结果见话题…`.
55
+ */
56
+ readonly taskName: string
57
+ /** Lead sentence, shown after an @-mention of the requester. */
58
+ readonly lead: readonly TopicCardText[]
59
+ /** Optional table of links (at most 50 rows). */
60
+ readonly links?: {
61
+ /** Header of the label column. */
62
+ readonly nameHeader: string
63
+ /** Header of the link column. */
64
+ readonly linkHeader: string
65
+ readonly rows: readonly TopicCardLink[]
66
+ }
67
+ }
68
+
69
+ /** One topic to start. */
70
+ export interface TopicLaunchSpec {
71
+ /**
72
+ * Idempotency key (1–200 visible ASCII characters). A launch whose key was
73
+ * already accepted answers that launch's result instead of posting again.
74
+ * Keys are shared by every caller; a caller that may collide with another
75
+ * prefixes its keys.
76
+ */
77
+ readonly key: string
78
+ /**
79
+ * Caller-defined fingerprint of the request behind {@link key}, compared on
80
+ * replay: the same key with another fingerprint is refused (`key-conflict`).
81
+ */
82
+ readonly fingerprint: string
83
+ /** Target group chat (`oc_…`). It must be an authorized group, or the channel must run in `open` access mode. */
84
+ readonly chatId: string
85
+ /** Feishu `user_id` of the person the launch is for: @-mentioned on the root card and recorded as the route's requester. */
86
+ readonly requesterUserId: string
87
+ readonly card: TopicRootCard
88
+ /**
89
+ * Workspace of the Session: directory segments below the channel's
90
+ * `baseDir` (1–8 segments of `A-Z a-z 0-9 . _ -`, the first not one of the
91
+ * channel's own `p2p`, `groups`, `topics`, `_staging`), and its title in
92
+ * the DSH workspace list. Launches with the same segments share the
93
+ * workspace, each in its own topic directory.
94
+ */
95
+ readonly workspace: {
96
+ readonly segments: readonly string[]
97
+ readonly title: string
98
+ }
99
+ /** First user message of the Session, sent verbatim (at most 100 000 characters). */
100
+ readonly prompt: string
101
+ }
102
+
103
+ /**
104
+ * Why a launch was refused. Nothing was posted for `invalid`,
105
+ * `not-connected`, `forbidden-chat`, and `key-conflict`; `send-failed` means
106
+ * the root card could not be posted.
107
+ */
108
+ export type TopicLaunchFailureCode =
109
+ /** The spec breaks a rule documented on {@link TopicLaunchSpec}. */
110
+ | 'invalid'
111
+ /** The Feishu connection is not established, or the channel is shutting down. */
112
+ | 'not-connected'
113
+ /** The target group is not authorized. */
114
+ | 'forbidden-chat'
115
+ /** The key was accepted before with another fingerprint. */
116
+ | 'key-conflict'
117
+ /** Feishu refused the root card (usually: the bot is not in the group). */
118
+ | 'send-failed'
119
+
120
+ /** Outcome of {@link FeishuTopicService.launch}. */
121
+ export type TopicLaunchResult =
122
+ | {
123
+ readonly ok: true
124
+ /** Whether this answers an earlier launch with the same key. */
125
+ readonly replayed: boolean
126
+ readonly chatId: string
127
+ /** Root card message id; the topic hangs below it. */
128
+ readonly messageId: string
129
+ }
130
+ | {
131
+ readonly ok: false
132
+ readonly code: TopicLaunchFailureCode
133
+ /** Readable reason (Chinese). */
134
+ readonly message: string
135
+ }
136
+
137
+ /** The `feishuTopics` service. */
138
+ export interface FeishuTopicService {
139
+ readonly version: FeishuTopicsVersion
140
+ /**
141
+ * Post the root card and start the topic. The promise settles once the
142
+ * root card is posted and recorded; the topic, Session, and first turn start
143
+ * in the background, and a failure there ends on the root card (失败, with
144
+ * the reason) instead of rejecting.
145
+ * @param spec - the topic to start.
146
+ * @returns the root card, or why nothing was started.
147
+ */
148
+ launch(spec: TopicLaunchSpec): Promise<TopicLaunchResult>
149
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The `feishuTopics` service: other plugins start group-shared topics (see
3
+ * `./contract.ts`).
4
+ *
5
+ * Synchronously (before {@link TopicLauncher.launch} settles): spec
6
+ * validation, service state, target-group permission, idempotency replay, and
7
+ * the root card (进行中). In the background: the topic (opened by the first
8
+ * streaming card, replied in thread below the root card), the launch workspace
9
+ * and its per-topic directory, the Session, the group-shared route, and the
10
+ * first turn: the prompt verbatim, streamed into that card like any topic
11
+ * turn. When the first turn settles, the root card's status follows it
12
+ * (已完成 / 已停止 / 失败), and the launch record keeps the same status. At
13
+ * channel start, records still `running` belong to topics a previous instance
14
+ * never finished; {@link TopicLauncher.markInterrupted} marks their cards
15
+ * 已中断.
16
+ *
17
+ * The topic is reserved in the inbound router from its creation until the
18
+ * route and the first prompt are published, so replies posted meanwhile queue
19
+ * behind the first turn. Follow-ups then use the ordinary topic routing: the
20
+ * route is `shared`, so every member admitted in the group may continue it. A
21
+ * failure before the first turn (including a disconnect meanwhile) marks the
22
+ * root card failed with the reason, closes the streaming card, and leaves no
23
+ * route.
24
+ *
25
+ * @module dsh-channels/topics/launcher
26
+ */
27
+
28
+ import { randomUUID } from 'node:crypto'
29
+ import { z } from 'zod'
30
+ import type { AgentHandle, HostContext, SessionId } from '../dsh-types.ts'
31
+ import type { FeishuConfig } from '../config.ts'
32
+ import { buildCard, buildTopicRootCard, initialStreamingCard } from '../cards.ts'
33
+ import type { TopicStatus } from '../cards.ts'
34
+ import type { RouteOperationQueue } from '../commands.ts'
35
+ import { FEISHU_USER_ID_PATTERN, rootCardOf } from '../domain.ts'
36
+ import type { FeishuDomainHandle, RouteRecord, TopicLaunchRecord } from '../domain.ts'
37
+ import type { FeishuChannel } from '../feishu-channel.ts'
38
+ import { feishuUserMessage } from '../routing/inbound.ts'
39
+ import { enterTopic, placementServices, planTopic } from '../session/isolation.ts'
40
+ import type { TopicPlacement } from '../session/isolation.ts'
41
+ import type { SessionManager } from '../session/manager.ts'
42
+ import type { InflightRepair } from '../session/repair.ts'
43
+ import type { RouteStore } from '../session/routes.ts'
44
+ import type { TurnStreamer } from '../streamer.ts'
45
+ import { ensureLaunchWorkspace, RESERVED_WORKSPACE_ROOTS, topicTokenFor } from '../workspaces.ts'
46
+ import type {
47
+ FeishuTopicService, FeishuTopicsServiceName, FeishuTopicsVersion, TopicLaunchFailureCode,
48
+ TopicLaunchResult, TopicLaunchSpec, TopicRootCard,
49
+ } from './contract.ts'
50
+
51
+ /** Name the launcher is provided under. */
52
+ export const FEISHU_TOPICS_SERVICE: FeishuTopicsServiceName = 'feishuTopics'
53
+
54
+ /** Contract version the launcher implements. */
55
+ export const FEISHU_TOPICS_VERSION: FeishuTopicsVersion = 1
56
+
57
+ const text = (max: number) => z.string().min(1).max(max).refine(value => value.trim() !== '', 'must not be blank')
58
+
59
+ /** Workspace path segment: path-safe, never `.` or `..`. */
60
+ const WORKSPACE_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
61
+
62
+ /** Feishu group chat id (`oc_…`). */
63
+ const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
64
+
65
+ /** Launch spec validation: the rules {@link TopicLaunchSpec} documents. */
66
+ const topicLaunchSpecSchema = z.object({
67
+ key: z.string().regex(/^[\x21-\x7E]{1,200}$/u, 'must be 1-200 visible ASCII characters'),
68
+ fingerprint: z.string().min(1).max(512),
69
+ chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
70
+ requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, 'must be a Feishu user_id').max(128),
71
+ card: z.object({
72
+ title: text(100),
73
+ taskName: text(20),
74
+ lead: z.array(z.object({ text: z.string().max(1000), bold: z.boolean().optional() }).strict()).min(1).max(20),
75
+ links: z.object({
76
+ nameHeader: text(20),
77
+ linkHeader: text(20),
78
+ rows: z.array(z.object({
79
+ name: text(200),
80
+ url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
81
+ }).strict()).max(50),
82
+ }).strict().optional(),
83
+ }).strict(),
84
+ workspace: z.object({
85
+ segments: z.array(z.string().regex(WORKSPACE_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -')).min(1).max(8)
86
+ .refine(segments => !RESERVED_WORKSPACE_ROOTS.includes(segments[0] ?? ''), `must not start with ${RESERVED_WORKSPACE_ROOTS.join(', ')}`),
87
+ title: text(200),
88
+ }).strict(),
89
+ prompt: text(100_000),
90
+ }).strict()
91
+
92
+ /** Collaborators of the launcher (late-bound where the service swaps them per connection). */
93
+ export interface TopicLauncherDeps {
94
+ ctx: HostContext
95
+ config: FeishuConfig
96
+ started(): boolean
97
+ channel(): FeishuChannel | undefined
98
+ domain(): FeishuDomainHandle | undefined
99
+ routes: RouteStore
100
+ repair: InflightRepair
101
+ sessions: SessionManager
102
+ streamer(): TurnStreamer | undefined
103
+ /** Shared per-route FIFO (the first turn must precede any follow-up). */
104
+ queue: RouteOperationQueue
105
+ /**
106
+ * Reserve an opened topic until its route is published, so replies posted
107
+ * meanwhile wait instead of being dropped or starting a competing Session.
108
+ * The returned closer is called exactly once.
109
+ */
110
+ reserveTopic(threadId: string): () => void
111
+ /** Whether a launch may post into the group. */
112
+ mayLaunchIn(chatId: string): boolean
113
+ }
114
+
115
+ /** One accepted launch still posting its root card. */
116
+ interface PendingAccept {
117
+ readonly fingerprint: string
118
+ readonly result: Promise<TopicLaunchResult>
119
+ }
120
+
121
+ /** Readable reason of an error value. */
122
+ function messageOf(error: unknown): string {
123
+ return error instanceof Error ? error.message : String(error)
124
+ }
125
+
126
+ export class TopicLauncher implements FeishuTopicService {
127
+ readonly version = FEISHU_TOPICS_VERSION
128
+ /** Idempotency key → launch still being accepted (concurrent retries share it). */
129
+ private readonly accepting = new Map<string, PendingAccept>()
130
+ /** Background topics still starting (drained on shutdown). */
131
+ private readonly running = new Set<Promise<void>>()
132
+ /** Whether launches are admitted: from {@link open} after start until {@link close}. */
133
+ private admitting = false
134
+
135
+ constructor(private readonly deps: TopicLauncherDeps) {}
136
+
137
+ /**
138
+ * Start admitting launches. Called at channel start after
139
+ * {@link markInterrupted}, so a new launch is never marked interrupted.
140
+ */
141
+ open(): void {
142
+ this.admitting = true
143
+ }
144
+
145
+ /** Refuse launches (`not-connected`) ahead of shutdown. */
146
+ close(): void {
147
+ this.admitting = false
148
+ }
149
+
150
+ async launch(spec: TopicLaunchSpec): Promise<TopicLaunchResult> {
151
+ const parsed = topicLaunchSpecSchema.safeParse(spec)
152
+ if (!parsed.success) {
153
+ const details = parsed.error.issues
154
+ .map(issue => `${issue.path.join('.') || '(spec)'}: ${issue.message}`)
155
+ .join('; ')
156
+ return refused('invalid', `话题参数不合法:${details}`)
157
+ }
158
+ const valid = parsed.data
159
+ const pending = this.accepting.get(valid.key)
160
+ if (pending !== undefined) {
161
+ if (pending.fingerprint !== valid.fingerprint) return reusedKey()
162
+ return await pending.result
163
+ }
164
+ const result = this.accept(valid)
165
+ this.accepting.set(valid.key, { fingerprint: valid.fingerprint, result })
166
+ try {
167
+ return await result
168
+ } finally {
169
+ this.accepting.delete(valid.key)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Wait for every acceptance in flight, then every background topic that is
175
+ * still starting. Called after `started` turns false, so each one ends on
176
+ * its failure card or, when its first prompt was already sent, as an
177
+ * ordinary topic.
178
+ */
179
+ async drain(): Promise<void> {
180
+ // Acceptances still sending the root card or writing the record finish
181
+ // first; each one adds its launch to `running` before it settles.
182
+ await Promise.allSettled([...this.accepting.values()].map(pending => pending.result))
183
+ await Promise.allSettled([...this.running])
184
+ }
185
+
186
+ /**
187
+ * Mark every launch still `running` as interrupted, on its card and in its
188
+ * record. Called at channel start before admission opens, so each such
189
+ * record belongs to a previous instance whose first turn never settled. A
190
+ * card that cannot be updated (deleted, recalled) is logged and still
191
+ * recorded as interrupted, so it is not retried on every start.
192
+ */
193
+ async markInterrupted(): Promise<void> {
194
+ const channel = this.deps.channel()
195
+ const domain = this.deps.domain()
196
+ if (channel === undefined || domain === undefined) return
197
+ for (const [key, record] of [...domain.topicLaunches.entries()]) {
198
+ if (record.status !== 'running') continue
199
+ const root = rootCardOf(record)
200
+ if (root !== undefined) {
201
+ await channel.updateCard(record.messageId, buildTopicRootCard(root.requesterUserId, root.card, { kind: 'interrupted' }))
202
+ .catch((error: unknown) => {
203
+ this.deps.ctx.logger?.warn?.('dsh-channels: marking topic card %s interrupted failed: %s', key, String(error))
204
+ })
205
+ }
206
+ await this.recordStatus(key, 'interrupted')
207
+ }
208
+ }
209
+
210
+ private async accept(spec: TopicLaunchSpec): Promise<TopicLaunchResult> {
211
+ const channel = this.deps.channel()
212
+ const domain = this.deps.domain()
213
+ if (!this.admitting || !this.deps.started() || channel === undefined || domain === undefined) {
214
+ return refused('not-connected', '飞书渠道尚未连接,请稍后重试')
215
+ }
216
+ const stored = domain.topicLaunches.get(spec.key)
217
+ if (stored !== undefined) {
218
+ if (stored.bodyHash !== spec.fingerprint) return reusedKey()
219
+ return { ok: true, replayed: true, chatId: stored.chatId, messageId: stored.messageId }
220
+ }
221
+ if (!this.deps.mayLaunchIn(spec.chatId)) {
222
+ return refused('forbidden-chat', '目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加')
223
+ }
224
+ const card = copyCard(spec.card)
225
+ let rootId: string
226
+ try {
227
+ rootId = (await channel.send(spec.chatId, {
228
+ card: buildTopicRootCard(spec.requesterUserId, card, { kind: 'running' }),
229
+ })).messageId
230
+ } catch (error) {
231
+ return refused('send-failed', `${spec.card.taskName}卡片发送失败(机器人需在目标群内):${messageOf(error)}`)
232
+ }
233
+ try {
234
+ await domain.topicLaunches.put(spec.key, {
235
+ key: spec.key, bodyHash: spec.fingerprint, chatId: spec.chatId, messageId: rootId, acceptedAt: Date.now(),
236
+ requesterUserId: spec.requesterUserId, card, status: 'running',
237
+ })
238
+ } catch (error) {
239
+ // The card is visible already; a lost record only means a retry of this
240
+ // key would post a second topic.
241
+ this.deps.ctx.logger?.warn?.('dsh-channels: recording topic launch %s failed: %s', spec.key, String(error))
242
+ }
243
+ const run = this.start(channel, spec, card, rootId)
244
+ this.running.add(run)
245
+ void run.finally(() => { this.running.delete(run) })
246
+ return { ok: true, replayed: false, chatId: spec.chatId, messageId: rootId }
247
+ }
248
+
249
+ /** Start the topic below an accepted root card; every failure ends on the cards. */
250
+ private async start(
251
+ channel: FeishuChannel,
252
+ spec: TopicLaunchSpec,
253
+ card: TopicRootCard,
254
+ rootId: string,
255
+ ): Promise<void> {
256
+ const warn = (message: string, ...args: unknown[]): void => {
257
+ this.deps.ctx.logger?.warn?.(message, ...args)
258
+ }
259
+ const showStatus = async (status: TopicStatus): Promise<void> => {
260
+ await channel.updateCard(rootId, buildTopicRootCard(spec.requesterUserId, card, status)).catch((error: unknown) => {
261
+ warn('dsh-channels: updating topic card %s failed: %s', rootId, String(error))
262
+ })
263
+ await this.recordStatus(spec.key, status.kind)
264
+ }
265
+ const sessionId = randomUUID() as SessionId
266
+ let handle: AgentHandle | undefined
267
+ let cardId: string | undefined
268
+ let threadId: string | undefined
269
+ let releaseTopic: (() => void) | undefined
270
+ let published = false
271
+ try {
272
+ const topic = await this.openTopic(channel, spec.chatId, rootId, sessionId)
273
+ cardId = topic.cardId
274
+ threadId = topic.threadId
275
+ releaseTopic = this.deps.reserveTopic(threadId)
276
+ const services = placementServices(this.deps.ctx)
277
+ const workspace = await ensureLaunchWorkspace(
278
+ this.deps.ctx, this.deps.config.baseDir, spec.workspace.segments, spec.workspace.title,
279
+ )
280
+ const topicToken = topicTokenFor(threadId, 'thread', sessionId)
281
+ const plan = await planTopic(services, this.deps.config, workspace.path, topicToken, warn)
282
+ this.assertStarted()
283
+ handle = await this.deps.sessions.createSession(sessionId, plan.cwd)
284
+ const placement: TopicPlacement = await enterTopic(
285
+ services, handle.agent, plan, workspace.path, topicToken, warn,
286
+ )
287
+ if (plan.cwd === workspace.path) {
288
+ void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
289
+ warn('dsh-channels: attaching topic session %s to workspace failed: %s', sessionId, String(error))
290
+ })
291
+ }
292
+ await this.deps.sessions.mountGuidance(handle.agent, placement.sessionDir)
293
+ this.assertStarted()
294
+
295
+ const createdAt = handle.agent.session.header.createdAt
296
+ const record: RouteRecord = {
297
+ key: threadId,
298
+ kind: 'thread',
299
+ sessionId,
300
+ chatId: spec.chatId,
301
+ chatType: 'group',
302
+ senderUserId: spec.requesterUserId,
303
+ workspacePath: workspace.path,
304
+ sessionDir: placement.sessionDir,
305
+ ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
306
+ cardMessageId: cardId,
307
+ createdAt,
308
+ lastActivityAt: createdAt,
309
+ sessionHistory: [{ sessionId, createdAt }],
310
+ shared: true,
311
+ }
312
+ await this.deps.repair.write({
313
+ key: cardId,
314
+ sessionId,
315
+ chatId: spec.chatId,
316
+ triggerMessageId: rootId,
317
+ cards: [{ messageId: cardId, mode: 'card' }],
318
+ startedAt: Date.now(),
319
+ attempts: 0,
320
+ })
321
+ const agent = handle.agent
322
+ const routeKey = threadId
323
+ const streamCardId = cardId
324
+ // Route, stream binding, and prompt land in one queue slot, so a reply
325
+ // posted in the topic meanwhile is delivered after the first turn.
326
+ await this.deps.queue.run(routeKey, async () => {
327
+ this.assertStarted()
328
+ await this.deps.routes.put(routeKey, record)
329
+ published = true
330
+ this.deps.streamer()?.attach(agent.session, streamCardId, rootId, undefined, {
331
+ onSettled: outcome => { void showStatus({ kind: outcome }) },
332
+ })
333
+ agent.followup(feishuUserMessage(spec.prompt))
334
+ })
335
+ } catch (error) {
336
+ const reason = messageOf(error)
337
+ warn('dsh-channels: topic %s failed to start: %s', spec.key, reason)
338
+ if (published && threadId !== undefined) {
339
+ await this.deps.routes.remove(threadId).catch((removeError: unknown) => {
340
+ warn('dsh-channels: removing topic route %s failed: %s', threadId, String(removeError))
341
+ })
342
+ }
343
+ await handle?.dispose().catch(() => {})
344
+ if (cardId !== undefined) {
345
+ void this.deps.repair.clear(cardId)
346
+ await channel.updateCard(cardId, buildCard({
347
+ phase: 'error',
348
+ reasoning: '',
349
+ body: `❌ ${card.taskName}未能开始:${reason}`,
350
+ stats: { sessionId, inputTokens: 0, outputTokens: 0 },
351
+ })).catch(() => {})
352
+ }
353
+ await showStatus({ kind: 'failed', reason })
354
+ } finally {
355
+ releaseTopic?.()
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Open the topic: the first streaming card, replied in thread below the
361
+ * root card. The topic id is read from that card (or the root card, in
362
+ * topic groups).
363
+ */
364
+ private async openTopic(
365
+ channel: FeishuChannel,
366
+ chatId: string,
367
+ rootId: string,
368
+ sessionId: SessionId,
369
+ ): Promise<{ cardId: string; threadId: string }> {
370
+ const sent = await channel.send(chatId, { card: initialStreamingCard(sessionId) }, {
371
+ replyTo: rootId, replyInThread: true,
372
+ })
373
+ const threadId = await channel.threadIdOfMessage(sent.messageId).catch(() => undefined)
374
+ ?? await channel.threadIdOfMessage(rootId).catch(() => undefined)
375
+ if (threadId === undefined) {
376
+ await channel.updateCard(sent.messageId, buildCard({
377
+ phase: 'error',
378
+ reasoning: '',
379
+ body: '❌ 无法在目标群创建话题',
380
+ stats: { sessionId, inputTokens: 0, outputTokens: 0 },
381
+ })).catch(() => {})
382
+ throw new Error('无法在目标群创建话题(读取话题 id 失败)')
383
+ }
384
+ return { cardId: sent.messageId, threadId }
385
+ }
386
+
387
+ /** Store a root card status in the launch record (best-effort; the card already shows it). */
388
+ private async recordStatus(key: string, status: TopicLaunchRecord['status']): Promise<void> {
389
+ const domain = this.deps.domain()
390
+ const record = domain?.topicLaunches.get(key)
391
+ if (domain === undefined || record === undefined) return
392
+ try {
393
+ await domain.topicLaunches.put(key, { ...record, status })
394
+ } catch (error) {
395
+ this.deps.ctx.logger?.warn?.('dsh-channels: recording topic %s status failed: %s', key, String(error))
396
+ }
397
+ }
398
+
399
+ private assertStarted(): void {
400
+ if (!this.deps.started()) throw new Error('飞书渠道已断开,已取消')
401
+ }
402
+ }
403
+
404
+ /** A detached copy of the card, so the caller cannot change what is recorded. */
405
+ function copyCard(card: TopicRootCard): NonNullable<TopicLaunchRecord['card']> {
406
+ return {
407
+ title: card.title,
408
+ taskName: card.taskName,
409
+ lead: card.lead.map(run => (run.bold === undefined ? { text: run.text } : { text: run.text, bold: run.bold })),
410
+ ...(card.links === undefined
411
+ ? {}
412
+ : {
413
+ links: {
414
+ nameHeader: card.links.nameHeader,
415
+ linkHeader: card.links.linkHeader,
416
+ rows: card.links.rows.map(row => ({ name: row.name, url: row.url })),
417
+ },
418
+ }),
419
+ }
420
+ }
421
+
422
+ function refused(code: TopicLaunchFailureCode, message: string): TopicLaunchResult {
423
+ return { ok: false, code, message }
424
+ }
425
+
426
+ function reusedKey(): TopicLaunchResult {
427
+ return refused('key-conflict', '幂等键已被内容不同的请求使用')
428
+ }
package/src/workspaces.ts CHANGED
@@ -5,7 +5,8 @@
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
+ * - `feishuTopics` launch: workspace `<baseDir>/<caller segments…>/` (观象's
9
+ * log analyses use `<baseDir>/analyze/<app>/<errorCode>/`)
9
10
  *
10
11
  * The workspace root is the Session header cwd: it keeps the Session grouped
11
12
  * under the user's project in the GUI and stays the sandbox write boundary.
@@ -235,31 +236,37 @@ export async function ensureWorkspace(
235
236
  }
236
237
 
237
238
  /**
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.
239
+ * First path segments this module lays out itself; a `feishuTopics` launch
240
+ * workspace may not start with one of them.
242
241
  */
243
- export function analyzeWorkspacePathFor(baseDir: string, app: string, errorCode: string): string {
244
- return path.join(baseDir, 'analyze', safeSegment(app, 'app'), safeSegment(errorCode, 'errorCode'))
245
- }
242
+ export const RESERVED_WORKSPACE_ROOTS: readonly string[] = ['p2p', 'groups', 'topics', '_staging']
246
243
 
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}`
244
+ /**
245
+ * Workspace directory of one `feishuTopics` launch:
246
+ * `<baseDir>/<segments…>/`. Launches with the same segments share it, each in
247
+ * its own topic directory.
248
+ * @throws when a segment is unsafe or the first one is reserved.
249
+ */
250
+ export function launchWorkspacePathFor(baseDir: string, segments: readonly string[]): string {
251
+ if (segments.length === 0) throw new Error('dsh-channels: a launch workspace needs at least one path segment')
252
+ const safe = segments.map(segment => safeSegment(segment, 'launch workspace segment'))
253
+ if (RESERVED_WORKSPACE_ROOTS.includes(safe[0]!)) {
254
+ throw new Error(`dsh-channels: launch workspace may not start with reserved segment ${JSON.stringify(safe[0])}`)
255
+ }
256
+ return path.join(baseDir, ...safe)
250
257
  }
251
258
 
252
259
  /**
253
- * Ensure one analysis subject's workspace exists and is registered (idempotent
254
- * like {@link ensureWorkspace}).
260
+ * Ensure one launch workspace exists and is registered (idempotent like
261
+ * {@link ensureWorkspace}).
255
262
  */
256
- export async function ensureAnalyzeWorkspace(
263
+ export async function ensureLaunchWorkspace(
257
264
  ctx: HostContext,
258
265
  baseDir: string,
259
- app: string,
260
- errorCode: string,
266
+ segments: readonly string[],
267
+ title: string,
261
268
  ): Promise<Workspace> {
262
- const dir = analyzeWorkspacePathFor(baseDir, app, errorCode)
269
+ const dir = launchWorkspacePathFor(baseDir, segments)
263
270
  await mkdir(dir, { recursive: true })
264
- return ctx.workspaceRegistry.create(dir, analyzeWorkspaceTitleFor(app, errorCode))
271
+ return ctx.workspaceRegistry.create(dir, title)
265
272
  }