helix_im/module/trait_impl.rs
1use super::*;
2
3#[derive(serde::Deserialize)]
4struct RuntimeIdentityPatch {
5 auth_user_id: Option<String>,
6 company_id: Option<String>,
7}
8
9impl Module for ImModule {
10 fn name(&self) -> &'static str {
11 "helix-im"
12 }
13
14 /// 路由判定:IM 模块处理所有 IM Inbound 帧和 IM 命令。
15 /// PortReply / Timer 不走此方法(通过 corr_map 定向路由)。
16 ///
17 /// MV3-G02e 草稿族(`im_save_draft` / `im_query_draft`)在此与下面的 `handle`
18 /// **同源放行**(`crate::draft::is_draft_command`):accepts 与 handle 各写一份命令名
19 /// 正是「query 族被 accepts 闸静默丢弃」旧事故的成因,本族不重复该反模式。
20 fn accepts(&self, tick: &Tick) -> bool {
21 if let Tick::Command(cmd) = tick {
22 if crate::draft::is_draft_command(cmd.name.as_ref()) {
23 return true;
24 }
25 }
26 acl::from_tick::accepts_tick(tick)
27 }
28
29 /// 处理一个 Tick,零 I/O,零 await。
30 fn handle(&mut self, tick: &Tick, now_ms: u64, out: &mut EffectSink) -> Result<(), CoreError> {
31 self.diagnostics.now_ms = now_ms;
32 let effect_start = out.as_slice().len();
33 let corr_floor = self.next_corr;
34 let recovery_reply = self.observe_sync_reply(tick);
35 match tick {
36 Tick::Inbound(bytes) => {
37 // 入口保留旧容错:坏 JSON inbound 是可丢 WS 噪音,不升级为 CoreError。
38 let Ok(frame) = crate::ws::WsFrame::parse(bytes.as_bytes()) else {
39 return Ok(());
40 };
41 self.dispatch_ws_frame(&frame, now_ms, out)?;
42 }
43
44 Tick::Command(cmd) => match cmd.name.as_ref() {
45 RUNTIME_IDENTITY_COMMAND => {
46 let identity: RuntimeIdentityPatch =
47 serde_json::from_slice(cmd.payload.as_ref()).map_err(|error| {
48 CoreError::ModuleError {
49 module: self.name(),
50 source: Box::new(crate::error::ImError::Parse(error.to_string())),
51 }
52 })?;
53 let auth_user_id = identity
54 .auth_user_id
55 .unwrap_or_else(|| self.config.auth_user_id.clone());
56 let company_id = identity
57 .company_id
58 .unwrap_or_else(|| self.config.company_id.clone());
59 if auth_user_id != self.config.auth_user_id
60 || company_id != self.config.company_id
61 {
62 self.finish_sync_observation(
63 crate::sync_observation::SyncResult::Cancelled,
64 );
65 self.cancel_category(out);
66 self.state.reset_message_v3_identity();
67 }
68 self.config.auth_user_id = auth_user_id;
69 self.config.company_id = company_id;
70 // Native may deliver the trusted runtime identity after the WS hello.
71 // The identity reset intentionally invalidates A's recovery window, but a
72 // live B transport must immediately open its own window; otherwise every
73 // startup sync commit is misclassified as ordinary online work and emits
74 // one channel-list event per channel.
75 if self.state.connection_id.is_some()
76 && !self.config.auth_user_id.is_empty()
77 && !self
78 .state
79 .recovery_session
80 .is_active_for(self.config.auth_user_id.as_str())
81 {
82 self.state
83 .recovery_session
84 .begin(self.config.auth_user_id.as_str());
85 }
86 }
87 "im_send_message" => {
88 self.handle_send_message(cmd.payload.as_ref(), now_ms, out)
89 .map_err(|e| CoreError::ModuleError {
90 module: self.name(),
91 source: Box::new(e),
92 })?;
93 }
94 "im_retry_upload" => {
95 crate::send::retry_upload::handle_retry_upload(self, cmd.payload.as_ref(), out)
96 .map_err(|e| CoreError::ModuleError {
97 module: self.name(),
98 source: Box::new(e),
99 })?;
100 }
101 "im_retry_send" => {
102 crate::send::retry_send::handle_retry_send(
103 self,
104 cmd.payload.as_ref(),
105 now_ms,
106 out,
107 )
108 .map_err(|e| CoreError::ModuleError {
109 module: self.name(),
110 source: Box::new(e),
111 })?;
112 }
113 // 连接控制(04 文档表 B `IMWS:reconnect`):helix-im 是 sans-IO,不持 transport——
114 // 只 emit `im:net:reconnect_requested` 控制信号,由 driver 观察后重连 NativeTransport
115 // (连接生命周期归 driver)。payload 无须解析(无参控制信号)。
116 "im_reconnect" => {
117 out.push(crate::acl::to_effect::emit_reconnect_requested());
118 }
119 "im_sync_channels" => {
120 crate::sync::explicit::handle(self, cmd.payload.as_ref(), out).map_err(
121 |e| CoreError::ModuleError {
122 module: self.name(),
123 source: Box::new(e),
124 },
125 )?;
126 }
127 "im_create_posts" if crate::forward::is_source_id_request(cmd.payload.as_ref()) => {
128 crate::forward::start(self, cmd.payload.as_ref(), now_ms, out).map_err(
129 |e| CoreError::ModuleError {
130 module: self.name(),
131 source: Box::new(e),
132 },
133 )?;
134 }
135 // MV3-G02e 草稿域:Effect 仅 Persist、零领域事件、结构化 Command Result。
136 // 必须排在 outbound / query 两个 guard 之前认领——草稿既不是 HTTP outbound,
137 // 也不在 query 闭集内,落到 `_` 分支就会退回「unhandled command」死代码态。
138 name if crate::draft::is_draft_command(name) => {
139 crate::draft::handle_command(self, name, cmd.payload.as_ref(), now_ms, out)
140 .map_err(|e| CoreError::ModuleError {
141 module: self.name(),
142 source: Box::new(e),
143 })?;
144 }
145 // 分类接龙独立authority,不复用文字接龙字段或确认规则。
146 name if crate::category_chain::is_command(name) => {
147 self.handle_category_command(name, cmd.payload.as_ref(), now_ms, out)
148 .map_err(|e| CoreError::ModuleError {
149 module: self.name(),
150 source: Box::new(e),
151 })?;
152 }
153 // 文字接龙命令拥有独立的 correlation/authority barrier,不能落入通用 fire-and-forget。
154 name if crate::chain::is_command(name) => {
155 self.handle_chain_command(name, cmd.payload.as_ref(), out)
156 .map_err(|e| CoreError::ModuleError {
157 module: self.name(),
158 source: Box::new(e),
159 })?;
160 }
161 // Tier 1 outbound 自驱动命令(read/revoke/leave/schedule/cancel/create/makeTopic):
162 // 纯 HTTP-fire,业务契约在 crate::commands(endpoint 锚现网真源)。
163 name if crate::commands::is_outbound(name) => {
164 if name == "im_create_schedule" {
165 match self.begin_schedule_media(cmd.payload.as_ref(), now_ms, out) {
166 Ok(true) => return Ok(()),
167 Ok(false) => {}
168 Err(source) => {
169 // 校验失败也必须结束关联等待,不能让界面等到 Host 超时。
170 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(
171 cmd.payload.as_ref(),
172 ) {
173 if let Some(req_id) = payload["req_id"].as_str() {
174 out.push(crate::read_relay::emit_read_error(
175 req_id,
176 "SCHEDULE_CONTENT_INVALID",
177 ));
178 return Ok(());
179 }
180 }
181 return Err(CoreError::ModuleError {
182 module: self.name(),
183 source: Box::new(source),
184 });
185 }
186 }
187 }
188 let corr = self.alloc_corr_internal();
189 let topic_request = (name == "im_make_topic")
190 .then(|| make_topic_request_context(cmd.payload.as_ref()))
191 .flatten();
192 let effects = match crate::commands::handle_outbound(
193 name,
194 cmd.payload.as_ref(),
195 self.config.api_base_url.as_str(),
196 // 第二网关 base(spec06 方案 A):vote/score 命令走它,既有命令忽略。
197 self.config.default_api_base_url.as_str(),
198 self.state.connection_id.as_deref(),
199 corr,
200 ) {
201 Ok(effects) => effects,
202 Err(error) if name == "im_make_topic" => {
203 if let Some((root_message_id, req_id, _)) = topic_request.as_ref() {
204 out.push(crate::acl::to_effect::emit_topic_operation_status(
205 req_id,
206 root_message_id,
207 "engine-rejected",
208 None,
209 Some("invalid-request"),
210 true,
211 ));
212 return Ok(());
213 }
214 return Err(CoreError::ModuleError {
215 module: self.name(),
216 source: Box::new(error),
217 });
218 }
219 Err(error) => {
220 return Err(CoreError::ModuleError {
221 module: self.name(),
222 source: Box::new(error),
223 });
224 }
225 };
226 // spec06 缺陷A:读族注册回灌上下文(PortReply 透传 `im:read:result{req_id,body}`;写族
227 // fire-and-forget 不注册)。S7:byIds 成员快照走 OutboundMembersByIds(额外 emit im:channel:members)。
228 if crate::commands::is_read(name) {
229 let explicit_req_id = crate::read_relay::read_req_id(cmd.payload.as_ref());
230 let increment_hydration = name == "im_channel_load_increment_by_channel_id";
231 if increment_hydration
232 && (self.config.auth_user_id.is_empty()
233 || self.config.company_id.is_empty())
234 {
235 return Err(CoreError::ModuleError {
236 module: self.name(),
237 source: Box::new(crate::ImError::Parse(
238 "increment hydration requires RuntimeAuth account and tenant"
239 .to_string(),
240 )),
241 });
242 }
243 // 读族只有明确 correlation 才允许回灌;hydration 兼容旧 driver 的同时,
244 // 由显式 transport req_id 决定是否发布 sender-only channelIncrement。
245 let emit_channel_increment =
246 increment_hydration && explicit_req_id.is_some();
247 let req_id = match explicit_req_id {
248 Some(req_id) => req_id,
249 None if increment_hydration => {
250 format!("increment-hydration-{}", corr.raw())
251 }
252 None => {
253 // 无 correlation 的旧读请求仍执行 HTTP,但回包不得进入任何 UI 投影。
254 for effect in effects {
255 out.push(effect);
256 }
257 return Ok(());
258 }
259 };
260 {
261 let ctx = if increment_hydration {
262 CorrelationContext::OutboundIncrementHydration {
263 req_id,
264 emit_channel_increment,
265 }
266 } else if name == "im_channels_members_by_ids" {
267 CorrelationContext::OutboundMembersByIds { req_id }
268 } else if name == "im_user_candidates" {
269 CorrelationContext::OutboundContactCandidates { req_id }
270 } else if name == "im_get_posts_after_index" {
271 // G11h initial-window 独立于 locate/exact:HTTP 成功必须先过 durable read-back。
272 let args = serde_json::from_slice::<serde_json::Value>(
273 cmd.payload.as_ref(),
274 )
275 .map_err(|error| {
276 CoreError::ModuleError {
277 module: self.name(),
278 source: Box::new(crate::ImError::Parse(error.to_string())),
279 }
280 })?;
281 if let Some(request) =
282 crate::outbound::posts::read::initial_window_request(
283 &args, name,
284 )
285 .map_err(|error| {
286 CoreError::ModuleError {
287 module: self.name(),
288 source: Box::new(error),
289 }
290 })?
291 {
292 CorrelationContext::OutboundInitialWindow {
293 req_id: request.req_id,
294 post_id: request.post_id,
295 page_size: request.page_size,
296 }
297 } else {
298 let channel_id = args
299 .get("channel_id")
300 .and_then(serde_json::Value::as_str)
301 .map(str::to_owned);
302 CorrelationContext::OutboundReadReply {
303 req_id,
304 command: name.to_string(),
305 channel_id,
306 }
307 }
308 } else if name == "im_get_posts" {
309 // G11g exact 查询独立于旧 locate 终态;locate 只保留兼容入参,不改变按 id 读回。
310 let requested_ids = serde_json::from_slice::<serde_json::Value>(
311 cmd.payload.as_ref(),
312 )
313 .ok()
314 .and_then(|value| {
315 crate::outbound::posts::read::exact_post_ids(&value, name).ok()
316 })
317 .unwrap_or_default();
318 CorrelationContext::OutboundExactPosts {
319 req_id,
320 requested_ids,
321 }
322 } else if name == "im_get_replies" || name == "im_get_reply_branch" {
323 // G12 回复族冻结请求语义,PortReply 只生成 typed thread event。
324 let request =
325 crate::render_ready_replies::ReplyProjectionRequest::from_command(
326 name,
327 cmd.payload.as_ref(),
328 corr.raw(),
329 self.config.auth_user_id.as_str(),
330 )
331 .ok_or_else(|| CoreError::ModuleError {
332 module: self.name(),
333 source: Box::new(crate::ImError::Parse(
334 "reply command requires a valid req_id and payload"
335 .to_string(),
336 )),
337 })?;
338 if request.mode
339 == crate::render_ready_replies::ReplyProjectionMode::Snapshot
340 && !request.root_hint.is_empty()
341 {
342 self.state
343 .reply_projection_revisions
344 .insert(request.root_hint.clone(), request.revision);
345 }
346 CorrelationContext::OutboundReplies { request }
347 } else if name == "im_post_read_list" {
348 CorrelationContext::OutboundPostReaders { req_id }
349 } else if name == "im_channel_load_post_pinned" {
350 let channel_id = crate::query::pinned_projection::parse_channel_id(
351 cmd.payload.as_ref(),
352 )
353 .map_err(|error| CoreError::ModuleError {
354 module: self.name(),
355 source: Box::new(error),
356 })?;
357 let account_id = self.config.auth_user_id.clone();
358 let projection_key =
359 crate::query::pinned_projection::projection_key(
360 account_id.as_str(),
361 channel_id,
362 )
363 .map_err(|error| {
364 CoreError::ModuleError {
365 module: self.name(),
366 source: Box::new(error),
367 }
368 })?;
369 let epoch = self
370 .state
371 .pinned_projection_epochs
372 .get(&channel_id)
373 .copied()
374 .unwrap_or(0);
375 CorrelationContext::OutboundPinnedReply {
376 req_id,
377 account_id,
378 channel_id,
379 projection_key,
380 epoch,
381 }
382 } else {
383 let channel_id = serde_json::from_slice::<serde_json::Value>(
384 cmd.payload.as_ref(),
385 )
386 .ok()
387 .and_then(|value| {
388 value
389 .get("channel_id")
390 .and_then(serde_json::Value::as_str)
391 .map(str::to_owned)
392 });
393 CorrelationContext::OutboundReadReply {
394 req_id,
395 command: name.to_string(),
396 channel_id,
397 }
398 };
399 self.state.corr_map.insert(corr, ctx);
400 }
401 } else if name == "im_create_posts" {
402 if let Some(req_id) = crate::read_relay::read_req_id(cmd.payload.as_ref()) {
403 self.state
404 .corr_map
405 .insert(corr, CorrelationContext::OutboundCreatePosts { req_id });
406 }
407 } else if name == "im_create_schedule" {
408 if let Ok(args) =
409 serde_json::from_slice::<serde_json::Value>(cmd.payload.as_ref())
410 {
411 if let Some(channel_id) = args
412 .get("channel_id")
413 .and_then(serde_json::Value::as_str)
414 .and_then(ChannelId::from_str)
415 {
416 let request_id = args
417 .get("req_id")
418 .and_then(serde_json::Value::as_str)
419 .filter(|value| !value.is_empty())
420 .map(str::to_string);
421 if let Some(request_id) = request_id.as_ref() {
422 self.state
423 .pending_schedule_requests
424 .insert(channel_id, request_id.clone());
425 }
426 self.state.corr_map.insert(
427 corr,
428 CorrelationContext::OutboundScheduleCreate {
429 channel_id,
430 request_id,
431 },
432 );
433 }
434 }
435 } else if name == "im_cancel_schedule" {
436 if let Ok(args) =
437 serde_json::from_slice::<serde_json::Value>(cmd.payload.as_ref())
438 {
439 if let Some(channel_id) = args
440 .get("channel_id")
441 .and_then(serde_json::Value::as_str)
442 .and_then(ChannelId::from_str)
443 {
444 let request_id = args
445 .get("req_id")
446 .and_then(serde_json::Value::as_str)
447 .filter(|value| !value.is_empty())
448 .map(str::to_string);
449 if let Some(request_id) = request_id.as_ref() {
450 self.state
451 .pending_schedule_cancel_requests
452 .insert(channel_id, request_id.clone());
453 }
454 self.state.corr_map.insert(
455 corr,
456 CorrelationContext::OutboundScheduleCancel {
457 channel_id,
458 request_id,
459 },
460 );
461 }
462 }
463 } else if name == "im_create_channel" {
464 if let Ok(args) =
465 serde_json::from_slice::<serde_json::Value>(cmd.payload.as_ref())
466 {
467 let request_id = args
468 .get("req_id")
469 .and_then(serde_json::Value::as_str)
470 .filter(|value| !value.is_empty())
471 .map(str::to_string);
472 if let Some(members) = args
473 .get("user_ids")
474 .and_then(serde_json::Value::as_array)
475 .map(|user_ids| {
476 user_ids
477 .iter()
478 .filter_map(serde_json::Value::as_str)
479 .map(|user_id| serde_json::json!({ "id": user_id }))
480 .collect::<Vec<_>>()
481 })
482 .filter(|members| !members.is_empty())
483 {
484 self.state.corr_map.insert(
485 corr,
486 CorrelationContext::OutboundChannelCreate {
487 members,
488 request_id,
489 },
490 );
491 }
492 }
493 } else if name == "im_make_topic" {
494 if let Some((root_message_id, req_id, display_name)) = topic_request {
495 self.state.corr_map.insert(
496 corr,
497 CorrelationContext::OutboundMakeTopic {
498 root_message_id: root_message_id.clone(),
499 req_id: req_id.clone(),
500 display_name,
501 },
502 );
503 out.push(crate::acl::to_effect::emit_topic_operation_status(
504 &req_id,
505 &root_message_id,
506 "remote-pending",
507 None,
508 None,
509 false,
510 ));
511 }
512 } else if name == "im_channel_change_info"
513 || name == "im_channel_change_notice"
514 || name == "im_channel_change_display_name"
515 || name == "im_channel_change_orient"
516 || name == "im_channel_change_permission"
517 {
518 if let Ok(value) =
519 serde_json::from_slice::<serde_json::Value>(cmd.payload.as_ref())
520 {
521 if let Some(channel_id) = value
522 .get("channel_id")
523 .and_then(|id| id.as_str())
524 .and_then(ChannelId::from_str)
525 {
526 let causation_id = value
527 .get("req_id")
528 .and_then(serde_json::Value::as_str)
529 .filter(|value| !value.is_empty())
530 .map(str::to_string);
531 self.state.corr_map.insert(
532 corr,
533 CorrelationContext::OutboundChannelSettings {
534 channel_id,
535 causation_id,
536 },
537 );
538 }
539 }
540 } else if name == "im_channel_leave" {
541 if let Some(channel_id) =
542 serde_json::from_slice::<serde_json::Value>(cmd.payload.as_ref())
543 .ok()
544 .and_then(|value| {
545 value
546 .get("channel_id")
547 .and_then(|id| id.as_str())
548 .map(str::to_string)
549 })
550 .and_then(|id| ChannelId::from_str(&id))
551 {
552 self.state
553 .corr_map
554 .insert(corr, CorrelationContext::OutboundLeave { channel_id });
555 }
556 }
557 for eff in effects {
558 out.push(eff);
559 }
560 }
561 // 投影/状态命令:最近消息由 helix-im 统一编排 local-first/远端 fallback。
562 name if crate::query::is_query(name) => {
563 self.handle_query_command(name, cmd.payload.as_ref(), out)
564 .map_err(|e| CoreError::ModuleError {
565 module: self.name(),
566 source: Box::new(e),
567 })?;
568 }
569 _ => tracing::debug!("im: unhandled command '{}'", cmd.name),
570 },
571
572 Tick::PortReply { corr, outcome } => {
573 let result = self.handle_port_reply(*corr, outcome, now_ms, out);
574 if recovery_reply && result.is_err() {
575 self.finish_sync_observation(crate::sync_observation::SyncResult::Failed);
576 }
577 result.map_err(|e| CoreError::ModuleError {
578 module: self.name(),
579 source: Box::new(e),
580 })?;
581 }
582
583 Tick::PortProgress { corr, progress } => {
584 self.handle_file_upload_progress(*corr, *progress, out)
585 .map_err(|e| CoreError::ModuleError {
586 module: self.name(),
587 source: Box::new(e),
588 })?;
589 }
590
591 Tick::Timer(id) => {
592 if self
593 .handle_category_timeout(*id, out)
594 .map_err(|e| CoreError::ModuleError {
595 module: self.name(),
596 source: Box::new(e),
597 })?
598 {
599 return Ok(());
600 }
601 if self.schedule_media_timeout(*id, out) {
602 return Ok(());
603 }
604 self.handle_timer(*id, now_ms, out)
605 .map_err(|e| CoreError::ModuleError {
606 module: self.name(),
607 source: Box::new(e),
608 })?;
609 }
610
611 Tick::Connected(_transport) => {
612 // A4:连接建立 ≠ 可用。现网 Go 服务端连上后**单向下发 hello 帧**;
613 // 必须等收到 hello(→ Connected + connectionId)才能 resync——
614 // 否则握手前发 sync/notify 会被 Go 401/403(缺 connectionId 身份头)。
615 // 故此处只置 Connecting,resync 推迟到 hello Inbound 分支(handle_hello)。
616 // Native reader 与 activation tick 来自两个异步源;hello 可能先入队。
617 // connectionId 是服务端握手的权威凭据,晚到的 transport tick 不得把
618 // 已握手状态从 Connected 降回 Connecting。
619 if self.state.connection_id.is_none() {
620 self.state.conn = ConnState::Connecting;
621 }
622 // Do not publish a runtime transition here: the server hello
623 // establishes the recovery epoch, and PersistOk is the only
624 // completion boundary for a V2 frame.
625 self.state.recovery_session.phase = crate::sync_session::RecoveryPhase::Comparing;
626 tracing::debug!("helix-im: transport connected, awaiting hello handshake");
627 }
628
629 Tick::Disconnected(_transport) => {
630 self.finish_sync_observation(crate::sync_observation::SyncResult::Cancelled);
631 self.state.increment_pull = None;
632 self.state.corr_map.retain(|_, context| {
633 !matches!(
634 context,
635 CorrelationContext::IncrementPullHttp
636 | CorrelationContext::IncrementPullPersist
637 )
638 });
639 self.state.conn = ConnState::Disconnected;
640 self.state.recovery_session.phase = crate::sync_session::RecoveryPhase::Failed;
641 self.state.reset_transport_query_session();
642 // 清除所有 channel 的 inflight_sync(连接断开,所有在途 Http 不会回报)
643 // 若不清除,重连后 proactive resync 会被 B1 守卫全部跳过
644 for ch in self.state.channels.values_mut() {
645 ch.inflight_sync = None;
646 ch.last_sync_from_seq = None;
647 }
648 // 断线清理(语义保真 = 旧 corr_to_sync.clear() + continuation_pending.clear()):
649 // 1. 清除 SyncPull(在途 sync Http corr,断线后不会再收到 PortReply)。
650 // 2. ChannelPersist 本体**保留**、仅把 wants_continuation 置 false——
651 // 对应的 Effect::Persist 是本地 SQLite 写(driver spawn_blocking 执行),
652 // 其 PortReply 与 WS 断线物理无关,断线后极可能照常完成并回报,
653 // 留着可继续推进 cursor(HX-C008 cursor 单调推进);只是不再续拉 sync。
654 // 旧代码 continuation_pending.clear() 仅清续拉意图、故意不清 corr_to_channel
655 // 本体(module.rs 路由1 仍能推 cursor),此处逐字节保真。
656 // 3. OptimisticSend / ScanCursors / ScanChannelProjections 不动(本地回报与连接无关)。
657 for ctx in self.state.corr_map.values_mut() {
658 if let CorrelationContext::ChannelPersist {
659 wants_continuation, ..
660 } = ctx
661 {
662 *wants_continuation = false;
663 }
664 }
665 self.state
666 .corr_map
667 .retain(|_, ctx| !matches!(ctx, CorrelationContext::SyncPull { .. }));
668 tracing::debug!("helix-im: disconnected, cleared inflight sync state");
669 }
670 }
671 self.observe_sync_tick(&out.as_slice()[effect_start..], corr_floor);
672 Ok(())
673 }
674
675 /// 启动:先 Scan `channel_event_cursor` 与 `channel` 投影,过滤终态后才触发 proactive sync。
676 fn on_start(&mut self, out: &mut EffectSink) -> Result<(), CoreError> {
677 self.diagnose(crate::diagnostics::Observation {
678 event: "login_sync_started",
679 stage: "store",
680 result: "started",
681 ..Default::default()
682 });
683 self.start_lifecycle(out)
684 .map_err(|e| CoreError::ModuleError {
685 module: self.name(),
686 source: Box::new(e),
687 })?;
688 self.state.media_recovery_ready = false;
689 self.state.pending_media_recovery_compensation = None;
690 let corr = self.alloc_corr_internal();
691 out.push(helix_core::Effect::Persist {
692 corr,
693 ops: vec![helix_core::effect::StorageOp::Scan(
694 helix_core::effect::ScanSpec {
695 table: "pending_media",
696 limit: None,
697 filter: None,
698 order_by: &[],
699 },
700 )],
701 });
702 self.state.pending_media_rehydrate_corr = Some(corr);
703 Ok(())
704 }
705
706 /// 停止:取消所有 timer + 关闭连接
707 fn on_stop(&mut self, out: &mut EffectSink) -> Result<(), CoreError> {
708 self.finish_sync_observation(crate::sync_observation::SyncResult::Cancelled);
709 self.cancel_category(out);
710 self.stop_schedule_media(out);
711 self.diagnose(crate::diagnostics::Observation {
712 event: "runtime_stopped",
713 result: "interrupted",
714 reason: "runtime_stopped",
715 ..Default::default()
716 });
717 self.stop_lifecycle(out)
718 .map_err(|e| CoreError::ModuleError {
719 module: self.name(),
720 source: Box::new(e),
721 })
722 }
723}
724
725/// 冻结 make-topic 的 root、request 与用户确认标题,供异步 authority/persist/readback 串联。
726fn make_topic_request_context(payload: &[u8]) -> Option<(String, String, String)> {
727 let value = serde_json::from_slice::<serde_json::Value>(payload).ok()?;
728 let root_message_id = value
729 .get("root_id")
730 .and_then(serde_json::Value::as_str)
731 .unwrap_or_default()
732 .to_string();
733 let req_id = value
734 .get("req_id")
735 .and_then(serde_json::Value::as_str)
736 .filter(|id| !id.is_empty())?
737 .to_string();
738 let display_name = value
739 .get("display_name")
740 .and_then(serde_json::Value::as_str)
741 .filter(|name| !name.is_empty())
742 .unwrap_or("话题")
743 .to_string();
744 Some((root_message_id, req_id, display_name))
745}