Skip to main content

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}