@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent/session lifecycle for this channel.
|
|
3
|
+
*
|
|
4
|
+
* Owns the live-agent registry (including agents BORROWED from another owner),
|
|
5
|
+
* cold resume serialization, agent-preset composition, the per-session
|
|
6
|
+
* reasoning-effort policy, and the `/renew` transaction that atomically binds
|
|
7
|
+
* a new Session to an existing Feishu route.
|
|
8
|
+
*
|
|
9
|
+
* `agents.create()` never applies a preset on its own — the caller composes
|
|
10
|
+
* the agent's world through the `setup` callback, which the factory awaits
|
|
11
|
+
* before publishing the session. In web-profile deployments the model-facing
|
|
12
|
+
* tools exist only inside agent presets (the host disables the tool rows at
|
|
13
|
+
* the host layer), so sessions created without mounting one reach the model
|
|
14
|
+
* with an empty tool list: the agent writes `<tool_calls>` as plain text and
|
|
15
|
+
* stops. Web-UI sessions mount the default preset via `session.create`;
|
|
16
|
+
* channels sessions must do the same through `setup`, and record the preset id
|
|
17
|
+
* on the header meta exactly like the host's own `composeAgent` does.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-channels/session/manager
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
import type {
|
|
24
|
+
Agent, AgentContext, AgentHandle, AgentPresets, AgentSetup, HostContext, Session, SessionId,
|
|
25
|
+
} from '../dsh-types.ts'
|
|
26
|
+
import type { FeishuConfig } from '../config.ts'
|
|
27
|
+
import { mountChannelCommands } from '../commands.ts'
|
|
28
|
+
import { appendSessionHistory } from '../domain.ts'
|
|
29
|
+
import type { RouteRecord } from '../domain.ts'
|
|
30
|
+
import { mountChannelGuidance } from '../feishu/agent-context.ts'
|
|
31
|
+
import { attachmentsDir } from '../feishu/inbound-attachments.ts'
|
|
32
|
+
import { installRequestPolicy } from './request-policy.ts'
|
|
33
|
+
import type { RequestPolicyController } from './request-policy.ts'
|
|
34
|
+
import { workspaceTitleFor } from '../workspaces.ts'
|
|
35
|
+
|
|
36
|
+
/** One live agent plus this plugin's ownership handle. */
|
|
37
|
+
export interface LiveAgent {
|
|
38
|
+
agent: Agent
|
|
39
|
+
handle: AgentHandle
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A committed `/renew`: the old and new route records plus the old handle. */
|
|
43
|
+
export interface RenewCommit {
|
|
44
|
+
previous: RouteRecord
|
|
45
|
+
current: RouteRecord
|
|
46
|
+
oldLive: LiveAgent
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `agents.create`/`resume` need to compose one agent from a preset. */
|
|
50
|
+
export interface PresetComposition {
|
|
51
|
+
/** Preset id recorded on the session header meta (absent without a roster). */
|
|
52
|
+
agentPreset?: string
|
|
53
|
+
/** Mounts the resolved preset inside the agent factory's setup. */
|
|
54
|
+
setup?: AgentSetup
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SessionManagerDeps {
|
|
58
|
+
ctx: HostContext
|
|
59
|
+
config: FeishuConfig
|
|
60
|
+
/** Persist one route record (the `/renew` commit writes the new binding). */
|
|
61
|
+
persistRoute(key: string, record: RouteRecord): Promise<void>
|
|
62
|
+
/** Whether the service still accepts work (shutdown race guard). */
|
|
63
|
+
started(): boolean
|
|
64
|
+
/** Drop streamer state for a session whose owner handle is being released. */
|
|
65
|
+
detachStream(sessionId: string): void
|
|
66
|
+
warn(message: string, ...args: unknown[]): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wrap a live agent owned by someone else (web UI api-remote resolver, a
|
|
71
|
+
* previous plugin instance, a won cold-resume race). The borrow is fully
|
|
72
|
+
* deliverable through `followup`; disposal releases only the channel-owned
|
|
73
|
+
* scoped policy and never the foreign Agent itself.
|
|
74
|
+
*/
|
|
75
|
+
function borrowLiveAgent(agent: Agent, release: () => void = () => {}): LiveAgent {
|
|
76
|
+
return { agent, handle: { agent, dispose: async () => { release() } } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class SessionManager {
|
|
80
|
+
private readonly live = new Map<string, LiveAgent>()
|
|
81
|
+
private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
|
|
82
|
+
private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
|
|
83
|
+
|
|
84
|
+
constructor(private readonly deps: SessionManagerDeps) {}
|
|
85
|
+
|
|
86
|
+
/** Live agent entry for a session, when this plugin holds one. */
|
|
87
|
+
get(sessionId: string): LiveAgent | undefined {
|
|
88
|
+
return this.live.get(sessionId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Number of live agent entries this plugin owns or borrows. */
|
|
92
|
+
liveCount(): number {
|
|
93
|
+
return this.live.size
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Provider/model/token caps sessions this plugin creates. */
|
|
97
|
+
agentOptions(): Record<string, unknown> {
|
|
98
|
+
const options: Record<string, unknown> = {}
|
|
99
|
+
const { provider, model, maxTokens } = this.deps.config
|
|
100
|
+
if (provider !== undefined) options.provider = provider
|
|
101
|
+
if (model !== undefined) options.model = model
|
|
102
|
+
if (maxTokens !== undefined) options.maxTokens = maxTokens
|
|
103
|
+
return options
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create the root agent for one freshly routed session and register it live.
|
|
108
|
+
* The caller owns the workspace attachment and the failure card.
|
|
109
|
+
*/
|
|
110
|
+
async createSession(
|
|
111
|
+
sessionId: SessionId,
|
|
112
|
+
cwd: string,
|
|
113
|
+
agentOptions: Record<string, unknown> = this.agentOptions(),
|
|
114
|
+
): Promise<AgentHandle> {
|
|
115
|
+
const composition = await this.presetComposition(undefined, true, attachmentsDir(cwd))
|
|
116
|
+
const handle = await this.deps.ctx.agents.create({
|
|
117
|
+
sessionId,
|
|
118
|
+
meta: {
|
|
119
|
+
cwd,
|
|
120
|
+
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
|
|
121
|
+
},
|
|
122
|
+
agentOptions,
|
|
123
|
+
setup: composition.setup,
|
|
124
|
+
})
|
|
125
|
+
this.requestPolicy(handle.agent)
|
|
126
|
+
this.live.set(sessionId, { agent: handle.agent, handle })
|
|
127
|
+
return handle
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Live agent for a route: this plugin's own → a foreign live agent (borrow)
|
|
132
|
+
* → a serialized cold resume.
|
|
133
|
+
*
|
|
134
|
+
* The session may already be live under another owner — the web UI's
|
|
135
|
+
* api-remote resolver, a previous plugin instance whose shutdown is still
|
|
136
|
+
* draining, or a concurrent cold resume that won the race. `agents.resume`
|
|
137
|
+
* refuses a live session ("cannot prepare … while it is live"), so BORROW
|
|
138
|
+
* the live agent instead: `followup` delivery works identically and the
|
|
139
|
+
* owning side keeps the dispose responsibility.
|
|
140
|
+
*/
|
|
141
|
+
async ensureLive(record: RouteRecord): Promise<LiveAgent> {
|
|
142
|
+
const sessionId = record.sessionId
|
|
143
|
+
const own = this.live.get(sessionId)
|
|
144
|
+
if (own !== undefined) return own
|
|
145
|
+
|
|
146
|
+
const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
|
|
147
|
+
if (foreign !== undefined) return this.borrow(sessionId, foreign)
|
|
148
|
+
|
|
149
|
+
let pending = this.pendingResume.get(sessionId)
|
|
150
|
+
if (pending === undefined) {
|
|
151
|
+
pending = this.resume(record).finally(() => {
|
|
152
|
+
this.pendingResume.delete(sessionId)
|
|
153
|
+
})
|
|
154
|
+
this.pendingResume.set(sessionId, pending)
|
|
155
|
+
}
|
|
156
|
+
return await pending
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Borrow one foreign live agent and mount this channel's scoped policy. */
|
|
160
|
+
private borrow(sessionId: string, agent: Agent): LiveAgent {
|
|
161
|
+
const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
|
|
162
|
+
this.live.set(sessionId, borrowed)
|
|
163
|
+
return borrowed
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Cold-resume one persisted session, losing a resume race gracefully. */
|
|
167
|
+
private async resume(record: RouteRecord): Promise<LiveAgent> {
|
|
168
|
+
const sessionId = record.sessionId as SessionId
|
|
169
|
+
try {
|
|
170
|
+
// Compose the resumed world from the same preset resolution as creation
|
|
171
|
+
// (config override or roster default). A session persisted before the
|
|
172
|
+
// preset fix has no preset recorded on its header; re-resolving here is
|
|
173
|
+
// what repairs it on the first resume after a restart.
|
|
174
|
+
const composition = await this.presetComposition(
|
|
175
|
+
undefined, true, attachmentsDir(record.workspacePath),
|
|
176
|
+
)
|
|
177
|
+
const handle = await this.deps.ctx.agents.resume({
|
|
178
|
+
resumeSessionId: sessionId,
|
|
179
|
+
agentOptions: this.agentOptions(),
|
|
180
|
+
setup: composition.setup,
|
|
181
|
+
})
|
|
182
|
+
if (!this.deps.started()) {
|
|
183
|
+
// Shutdown raced this in-flight resume: dispose the just-published
|
|
184
|
+
// agent so it cannot leak into the live registry of the next instance
|
|
185
|
+
// (whose every resume would then fail as "while it is live").
|
|
186
|
+
void handle.dispose().catch(() => {})
|
|
187
|
+
throw new Error('dsh-channels: session resume aborted by shutdown')
|
|
188
|
+
}
|
|
189
|
+
this.requestPolicy(handle.agent)
|
|
190
|
+
const liveAgent: LiveAgent = { agent: handle.agent, handle }
|
|
191
|
+
this.live.set(sessionId, liveAgent)
|
|
192
|
+
return liveAgent
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// Lost a cold-resume race: another owner published the session while our
|
|
195
|
+
// resume was in flight. Borrow the winner instead of failing the turn.
|
|
196
|
+
const winner = this.deps.ctx.agents.get(sessionId)
|
|
197
|
+
if (winner !== undefined) return this.borrow(sessionId, winner)
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Mount one mutable Session-aware request policy outside every other wrapper. */
|
|
203
|
+
mountRequestPolicy(agentCtx: AgentContext, session?: Session): () => void {
|
|
204
|
+
const controller = installRequestPolicy(agentCtx, {
|
|
205
|
+
effort: this.deps.config.reasoningEffort,
|
|
206
|
+
provider: this.deps.config.provider,
|
|
207
|
+
model: this.deps.config.model,
|
|
208
|
+
})
|
|
209
|
+
if (session !== undefined) controller.sync(session.snapshotEvents())
|
|
210
|
+
this.policies.set(agentCtx, controller)
|
|
211
|
+
return () => {
|
|
212
|
+
controller.dispose()
|
|
213
|
+
if (this.policies.get(agentCtx) === controller) this.policies.delete(agentCtx)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Resolve and synchronize the policy composed for this exact Agent scope. */
|
|
218
|
+
requestPolicy(agent: Agent): RequestPolicyController {
|
|
219
|
+
const controller = this.policies.get(agent.ctx)
|
|
220
|
+
if (controller === undefined) {
|
|
221
|
+
throw new Error('dsh-channels: request policy is not mounted for this Agent')
|
|
222
|
+
}
|
|
223
|
+
controller.sync(agent.session.snapshotEvents())
|
|
224
|
+
return controller
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Create, workspace-attach, and atomically publish one renewed route. */
|
|
228
|
+
async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
|
|
229
|
+
const sessionId = randomUUID() as SessionId
|
|
230
|
+
const preset = this.effectivePreset(oldLive.agent.session)
|
|
231
|
+
const composition = await this.presetComposition(preset, false, attachmentsDir(record.workspacePath))
|
|
232
|
+
const workspace = await this.deps.ctx.workspaceRegistry.create(
|
|
233
|
+
record.workspacePath,
|
|
234
|
+
workspaceTitleFor(
|
|
235
|
+
record.chatType, record.chatId, record.senderOpenId,
|
|
236
|
+
record.kind === 'thread' ? record.key : record.sessionId,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
let newHandle: AgentHandle | undefined
|
|
241
|
+
let attached = false
|
|
242
|
+
try {
|
|
243
|
+
newHandle = await this.deps.ctx.agents.create({
|
|
244
|
+
sessionId,
|
|
245
|
+
meta: {
|
|
246
|
+
cwd: record.workspacePath,
|
|
247
|
+
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
|
|
248
|
+
},
|
|
249
|
+
agentOptions: { ...oldLive.agent.options },
|
|
250
|
+
setup: composition.setup,
|
|
251
|
+
})
|
|
252
|
+
this.requestPolicy(newHandle.agent)
|
|
253
|
+
await workspace.attachSession(sessionId)
|
|
254
|
+
attached = true
|
|
255
|
+
const createdAt = newHandle.agent.session.header.createdAt
|
|
256
|
+
|
|
257
|
+
const current: RouteRecord = {
|
|
258
|
+
...record,
|
|
259
|
+
sessionId,
|
|
260
|
+
sessionHistory: appendSessionHistory(record, sessionId, createdAt),
|
|
261
|
+
lastActivityAt: Date.now(),
|
|
262
|
+
}
|
|
263
|
+
await this.deps.persistRoute(record.key, current)
|
|
264
|
+
this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
|
|
265
|
+
return { previous: record, current, oldLive }
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (attached) {
|
|
268
|
+
await Promise.resolve(workspace.detachSession(sessionId)).catch((detachError: unknown) => {
|
|
269
|
+
this.deps.warn(
|
|
270
|
+
'dsh-channels: rolling back workspace attachment for session %s failed: %s',
|
|
271
|
+
sessionId, String(detachError),
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
await newHandle?.dispose().catch(() => {})
|
|
276
|
+
throw error
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Release only this plugin's old handle, after `command/done` has landed. */
|
|
281
|
+
async releaseRenewed(commit: RenewCommit): Promise<void> {
|
|
282
|
+
const oldSessionId = commit.previous.sessionId
|
|
283
|
+
if (this.live.get(oldSessionId) === commit.oldLive) {
|
|
284
|
+
this.live.delete(oldSessionId)
|
|
285
|
+
this.deps.detachStream(oldSessionId)
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await commit.oldLive.handle.dispose()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.deps.warn(
|
|
291
|
+
'dsh-channels: releasing renewed session %s handle failed: %s',
|
|
292
|
+
oldSessionId, String(error),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Stop and dispose every live agent this plugin owns. */
|
|
298
|
+
async disposeAll(): Promise<void> {
|
|
299
|
+
for (const { handle } of this.live.values()) {
|
|
300
|
+
await handle.dispose().catch(() => {})
|
|
301
|
+
}
|
|
302
|
+
this.live.clear()
|
|
303
|
+
this.pendingResume.clear()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve the agent-preset composition for sessions this plugin creates or
|
|
308
|
+
* resumes. The id resolves BEFORE the session exists because the session
|
|
309
|
+
* boundary snapshots `meta` before asynchronous setup begins; mounting stays
|
|
310
|
+
* inside `setup`, where a failure rolls the whole creation back.
|
|
311
|
+
*/
|
|
312
|
+
private async presetComposition(
|
|
313
|
+
requestedPreset: string | undefined = this.deps.config.agentPreset,
|
|
314
|
+
useRosterDefault = true,
|
|
315
|
+
attachmentsDir?: string,
|
|
316
|
+
): Promise<PresetComposition> {
|
|
317
|
+
// Read through ctx.get (safe lookup): only the web profile ships a roster,
|
|
318
|
+
// and direct property access on a cordis context throws for services the
|
|
319
|
+
// plugin did not inject.
|
|
320
|
+
const presets = this.deps.ctx.get?.('agentPresets') as AgentPresets | undefined
|
|
321
|
+
const channelCommandsOnly = async (agentCtx: AgentContext): Promise<void> => {
|
|
322
|
+
// `prepend` is essential: standing preset listeners may have been
|
|
323
|
+
// mounted before this Agent existed, so setup registration order alone
|
|
324
|
+
// cannot guarantee that the explicit channel value wins.
|
|
325
|
+
this.mountRequestPolicy(agentCtx)
|
|
326
|
+
await mountChannelCommands(agentCtx)
|
|
327
|
+
await mountChannelGuidance(agentCtx, attachmentsDir)
|
|
328
|
+
}
|
|
329
|
+
if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
|
|
330
|
+
return { setup: channelCommandsOnly }
|
|
331
|
+
}
|
|
332
|
+
const resolved = await presets.resolve(requestedPreset)
|
|
333
|
+
return {
|
|
334
|
+
agentPreset: resolved.id,
|
|
335
|
+
setup: async (agentCtx) => {
|
|
336
|
+
this.mountRequestPolicy(agentCtx)
|
|
337
|
+
await presets.mount(agentCtx, resolved.id)
|
|
338
|
+
await mountChannelCommands(agentCtx)
|
|
339
|
+
await mountChannelGuidance(agentCtx, attachmentsDir)
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Effective preset after folding any durable runtime preset switch. */
|
|
345
|
+
private effectivePreset(session: Session): string | undefined {
|
|
346
|
+
let selected = session.header.agentPreset
|
|
347
|
+
for (const event of session.snapshotEvents()) {
|
|
348
|
+
if (event.type === 'agent-preset/selected') selected = event.data.agentPreset
|
|
349
|
+
}
|
|
350
|
+
return selected
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight turn snapshots and the startup repair pass.
|
|
3
|
+
*
|
|
4
|
+
* Every streaming turn records the credentials needed to settle its cards if
|
|
5
|
+
* the process dies mid-turn (trigger message, Typing reaction, segment card
|
|
6
|
+
* ids). The snapshot is keyed by the turn's MAIN card message id — unique per
|
|
7
|
+
* turn — so overlapping turns never overwrite each other. On the next boot
|
|
8
|
+
* `repairAll()` patches each interrupted card to its terminal state and drops
|
|
9
|
+
* the snapshot.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-channels/session/repair
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { buildInterruptionCard } from '../cards/streaming.ts'
|
|
15
|
+
import type { InflightSnapshot } from '../domain.ts'
|
|
16
|
+
import type { FeishuChannel } from '../feishu-channel.ts'
|
|
17
|
+
import type { FeishuDomainHandle } from '../domain.ts'
|
|
18
|
+
|
|
19
|
+
export interface InflightRepairDeps {
|
|
20
|
+
/** The opened domain handle, or undefined before `start()` / after shutdown. */
|
|
21
|
+
domain(): FeishuDomainHandle | undefined
|
|
22
|
+
/** The live channel, or undefined before `start()` / after shutdown. */
|
|
23
|
+
channel(): FeishuChannel | undefined
|
|
24
|
+
/** Route retirement TTL; snapshots older than it are the GC pass's job. */
|
|
25
|
+
ttlMs(): number
|
|
26
|
+
info(message: string, ...args: unknown[]): void
|
|
27
|
+
warn(message: string, ...args: unknown[]): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class InflightRepair {
|
|
31
|
+
constructor(private readonly deps: InflightRepairDeps) {}
|
|
32
|
+
|
|
33
|
+
/** Write the turn's repair snapshot. Keyed by the main card message id. */
|
|
34
|
+
async write(snapshot: InflightSnapshot): Promise<void> {
|
|
35
|
+
await this.deps.domain()?.inflight.put(snapshot.key, snapshot)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Retire a snapshot (turn finalized / creation failed). Never throws. */
|
|
39
|
+
async clear(key: string): Promise<void> {
|
|
40
|
+
if (key === '') return
|
|
41
|
+
try {
|
|
42
|
+
await this.deps.domain()?.inflight.delete(key)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.deps.warn('dsh-channels: clearing inflight snapshot %s failed: %s', key, String(error))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Outstanding repair snapshots (health diagnostics). */
|
|
49
|
+
count(): number {
|
|
50
|
+
let total = 0
|
|
51
|
+
try {
|
|
52
|
+
const table = this.deps.domain()?.inflight
|
|
53
|
+
if (table !== undefined) for (const _entry of table.entries()) total++
|
|
54
|
+
} catch {
|
|
55
|
+
// The storage dependency is reported separately; never expose details.
|
|
56
|
+
}
|
|
57
|
+
return total
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fold a resolved segment card id into the turn's snapshot (atomic
|
|
62
|
+
* read-modify-write). A missing snapshot means the turn already finalized
|
|
63
|
+
* (the finalize delete won the race) — silently skipped.
|
|
64
|
+
*/
|
|
65
|
+
async recordSegment(
|
|
66
|
+
mainCardMessageId: string,
|
|
67
|
+
segmentIndex: number,
|
|
68
|
+
messageId: string,
|
|
69
|
+
mode: 'card' | 'text',
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (mainCardMessageId === '') return
|
|
72
|
+
try {
|
|
73
|
+
await this.deps.domain()?.inflight.update(mainCardMessageId, snapshot => {
|
|
74
|
+
// The snapshot starts with only the main card; segment sends are
|
|
75
|
+
// serialized in order, so a delivery for index `cards.length` is the
|
|
76
|
+
// next segment (append), anything below replaces, and a gap beyond
|
|
77
|
+
// the tail cannot happen under the serialized chain (ignored).
|
|
78
|
+
const cards = [...snapshot.cards]
|
|
79
|
+
if (segmentIndex === cards.length) {
|
|
80
|
+
cards.push({ messageId, mode })
|
|
81
|
+
} else if (segmentIndex >= 0 && segmentIndex < cards.length) {
|
|
82
|
+
cards[segmentIndex] = { messageId, mode }
|
|
83
|
+
} else {
|
|
84
|
+
return snapshot
|
|
85
|
+
}
|
|
86
|
+
return { ...snapshot, cards }
|
|
87
|
+
})
|
|
88
|
+
} catch (error) {
|
|
89
|
+
this.deps.warn(
|
|
90
|
+
'dsh-channels: updating inflight snapshot %s failed: %s', mainCardMessageId, String(error),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Startup repair pass: settle every card a previous instance left
|
|
97
|
+
* mid-flight. Runs in the background right after the long connection is
|
|
98
|
+
* established; each snapshot is independent and best-effort.
|
|
99
|
+
*
|
|
100
|
+
* Per snapshot: patch every `card`-mode card to the terminal interrupted
|
|
101
|
+
* state (the partial body is gone with the old process; patch is full
|
|
102
|
+
* replacement), best-effort swap Typing → DONE, then delete. A failed card
|
|
103
|
+
* patch keeps the snapshot for one more attempt (capped at 2) so a
|
|
104
|
+
* transient failure at boot does not lose the repair, while a medium that
|
|
105
|
+
* permanently rejects the patch cannot trigger an endless retry loop.
|
|
106
|
+
*/
|
|
107
|
+
async repairAll(): Promise<void> {
|
|
108
|
+
const table = this.deps.domain()?.inflight
|
|
109
|
+
if (table === undefined) return
|
|
110
|
+
const snapshots = [...table.entries()]
|
|
111
|
+
if (snapshots.length === 0) return
|
|
112
|
+
// With GC enabled, snapshots older than the TTL are the GC pass's job:
|
|
113
|
+
// repairing an ancient card would surprise the user, so repair skips
|
|
114
|
+
// them and lets the GC drop them silently (both passes run at boot).
|
|
115
|
+
const ttlMs = this.deps.ttlMs()
|
|
116
|
+
const cutoff = ttlMs === 0 ? undefined : Date.now() - ttlMs
|
|
117
|
+
const due = snapshots.filter(([, snapshot]) => cutoff === undefined || snapshot.startedAt >= cutoff)
|
|
118
|
+
if (due.length === 0) return
|
|
119
|
+
this.deps.info('dsh-channels: repairing %d interrupted turn(s)', due.length)
|
|
120
|
+
for (const [key, snapshot] of due) {
|
|
121
|
+
try {
|
|
122
|
+
await this.repairSnapshot(snapshot)
|
|
123
|
+
await table.delete(key)
|
|
124
|
+
this.deps.info('dsh-channels: repaired interrupted turn card %s', key)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.deps.warn('dsh-channels: repair of turn card %s failed: %s', key, String(error))
|
|
127
|
+
const attempts = snapshot.attempts + 1
|
|
128
|
+
try {
|
|
129
|
+
if (attempts >= 2) {
|
|
130
|
+
await table.delete(key)
|
|
131
|
+
} else {
|
|
132
|
+
await table.put(key, { ...snapshot, attempts })
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// The domain may already be closing (shutdown raced the repair).
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Patch the cards of one interrupted turn to their terminal state. */
|
|
142
|
+
private async repairSnapshot(snapshot: InflightSnapshot): Promise<void> {
|
|
143
|
+
const channel = this.deps.channel()
|
|
144
|
+
if (channel === undefined) throw new Error('dsh-channels: channel not started')
|
|
145
|
+
for (const [index, card] of snapshot.cards.entries()) {
|
|
146
|
+
if (card.mode !== 'card') continue
|
|
147
|
+
// Full replacement: the interrupted card's streamed content lived only
|
|
148
|
+
// in the previous process. Continuation cards keep their segment header.
|
|
149
|
+
const options = index === 0 ? {} : { panel: false, segment: index + 1 }
|
|
150
|
+
await channel.updateCard(card.messageId, buildInterruptionCard(options))
|
|
151
|
+
}
|
|
152
|
+
if (snapshot.reactionId !== undefined) {
|
|
153
|
+
await channel.removeReaction(snapshot.triggerMessageId, snapshot.reactionId).catch(() => {})
|
|
154
|
+
}
|
|
155
|
+
await channel.addReaction(snapshot.triggerMessageId, 'DONE').catch(() => {})
|
|
156
|
+
}
|
|
157
|
+
}
|