1#![cfg(feature = "client")]
64
65use std::{
66 collections::{BTreeMap, HashMap, HashSet},
67 fs,
68 path::{Path, PathBuf},
69 time::{SystemTime, UNIX_EPOCH},
70};
71
72use anyhow::{Context, Result, anyhow};
73use objects::fs_atomic::write_file_atomic;
74use objects::object::{
75 Attribution, CollabOpId, CollaborationAnchor, CollaborationIdempotencyKey,
76 CollaborationOperationBodyV1, CollaborationOperationEnvelope, DiscussionRecordId,
77 DiscussionTurnV1, MaterializedDiscussion, Principal, StateId, VisibilityTier,
78};
79use objects::store::ObjectStore;
80use repo::{CollaborationStore, Repository, mark_legacy_discussions_migrated};
81use serde::{Deserialize, Serialize};
82
83use crate::client::HostedGrpcClient;
84use heddle_client::grpc_hosted::{HostedDiscussion, HostedDiscussionTurn};
85
86const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_6469_7363_7573_7379_6e63);
89
90#[derive(Debug, Default, Serialize, Deserialize)]
91struct HostedMirror {
92 #[serde(default)]
94 repos: BTreeMap<String, RepoMirror>,
95}
96
97#[derive(Debug, Default, Serialize, Deserialize)]
98struct RepoMirror {
99 #[serde(default)]
100 discussions: Vec<MirrorEntry>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104struct MirrorEntry {
105 local_id: String,
107 server_id: String,
109 #[serde(default)]
111 links: Vec<TurnLink>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115struct TurnLink {
116 local_turn_id: String,
119 server_ordinal: usize,
121}
122
123struct LocalTurn {
125 turn_id: String,
126 body: String,
127 author_name: String,
128 author_email: String,
129 occurred_at_ms: i64,
130 is_self: bool,
131}
132
133fn turn_identity(op_id: &CollabOpId, index_within_op: usize) -> String {
134 format!("{}#{index_within_op}", op_id.to_string_full())
135}
136
137fn mirror_path(heddle_dir: &Path) -> PathBuf {
138 heddle_dir.join("collaboration").join("hosted-mirror.json")
139}
140
141fn load_mirror(heddle_dir: &Path) -> Result<HostedMirror> {
142 match fs::read(mirror_path(heddle_dir)) {
143 Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted discussion mirror map"),
144 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(HostedMirror::default()),
145 Err(error) => Err(error).context("read hosted discussion mirror map"),
146 }
147}
148
149fn save_mirror(heddle_dir: &Path, mirror: &HostedMirror) -> Result<()> {
150 let path = mirror_path(heddle_dir);
151 if let Some(parent) = path.parent() {
152 fs::create_dir_all(parent).context("create collaboration dir")?;
153 }
154 let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted discussion mirror map")?;
155 write_file_atomic(&path, &bytes).context("write hosted discussion mirror map")?;
156 Ok(())
157}
158
159fn open_op_id(repo_path: &str, local_id: &str) -> String {
160 uuid::Uuid::new_v5(&OP_NAMESPACE, format!("open:{repo_path}:{local_id}").as_bytes()).to_string()
161}
162
163fn append_op_id(repo_path: &str, server_id: &str, turn_id: &str) -> String {
164 uuid::Uuid::new_v5(
165 &OP_NAMESPACE,
166 format!("append:{repo_path}:{server_id}:{turn_id}").as_bytes(),
167 )
168 .to_string()
169}
170
171fn now_ms() -> i64 {
172 SystemTime::now()
173 .duration_since(UNIX_EPOCH)
174 .map(|d| d.as_millis() as i64)
175 .unwrap_or(0)
176}
177
178fn collect_local_turns(
182 store: &CollaborationStore,
183 discussion: &MaterializedDiscussion,
184 self_attr: Option<&Attribution>,
185) -> Result<Vec<LocalTurn>> {
186 let mut per_op: HashMap<CollabOpId, usize> = HashMap::new();
187 let mut op_author: HashMap<CollabOpId, (Principal, i64)> = HashMap::new();
188 let mut turns = Vec::with_capacity(discussion.turns.len());
189 for (op_id, turn) in &discussion.turns {
190 let index_within_op = {
191 let slot = per_op.entry(*op_id).or_insert(0);
192 let value = *slot;
193 *slot += 1;
194 value
195 };
196 let (principal, occurred_at_ms) = match op_author.get(op_id) {
197 Some(cached) => cached.clone(),
198 None => {
199 let decoded = store
200 .read_operation(op_id)
201 .context("read collaboration operation")?
202 .ok_or_else(|| anyhow!("collaboration operation {op_id} missing"))?;
203 let entry = (
204 decoded.operation.author.principal.clone(),
205 decoded.operation.occurred_at_ms,
206 );
207 op_author.insert(*op_id, entry.clone());
208 entry
209 }
210 };
211 let is_self = self_attr.is_some_and(|attr| principals_match(&principal, &attr.principal));
214 turns.push(LocalTurn {
215 turn_id: turn_identity(op_id, index_within_op),
216 body: turn.body.clone(),
217 author_name: principal.name.clone(),
218 author_email: principal.email.clone(),
219 occurred_at_ms,
220 is_self,
221 });
222 }
223 Ok(turns)
224}
225
226pub async fn push_discussions(
230 repo: &Repository,
231 client: &mut HostedGrpcClient,
232 repo_path: &str,
233) -> Result<usize> {
234 let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
235 let materialized = store.materialize().context("materialize local discussions")?;
236 if materialized.discussions.is_empty() {
237 return Ok(0);
238 }
239 let self_attr = repo.get_attribution().ok();
240
241 let mut mirror = load_mirror(repo.heddle_dir())?;
242 let mut synced = 0usize;
243
244 for (discussion_id, discussion) in &materialized.discussions {
245 let result = push_one(
246 client,
247 &store,
248 repo,
249 repo_path,
250 &mut mirror,
251 self_attr.as_ref(),
252 &discussion_id.to_string(),
253 discussion,
254 )
255 .await;
256 save_mirror(repo.heddle_dir(), &mirror)?;
259 match result {
260 Ok(true) => synced += 1,
261 Ok(false) => {}
262 Err(error) => {
263 eprintln!(
264 "{} hosted discussion {}: {error:#}",
265 crate::cli::style::warn_marker(),
266 discussion_id
267 );
268 }
269 }
270 }
271
272 Ok(synced)
273}
274
275#[allow(clippy::too_many_arguments)]
276async fn push_one(
277 client: &mut HostedGrpcClient,
278 store: &CollaborationStore,
279 repo: &Repository,
280 repo_path: &str,
281 mirror: &mut HostedMirror,
282 self_attr: Option<&Attribution>,
283 local_id: &str,
284 discussion: &MaterializedDiscussion,
285) -> Result<bool> {
286 let CollaborationAnchor::Symbol {
287 state_id,
288 path,
289 symbol,
290 } = &discussion.anchor
291 else {
292 return Ok(false);
294 };
295 let Some(state) = repo
296 .store()
297 .get_state(state_id)
298 .context("load discussion anchor state")?
299 else {
300 return Ok(false);
301 };
302 let change_id = state.change_id;
303 let visibility = discussion.visibility.as_str().to_string();
304
305 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
306 let entry_index = repo_mirror
307 .discussions
308 .iter()
309 .position(|entry| entry.local_id == local_id);
310 let linked: HashSet<String> = match entry_index {
311 Some(i) => repo_mirror.discussions[i]
312 .links
313 .iter()
314 .map(|link| link.local_turn_id.clone())
315 .collect(),
316 None => HashSet::new(),
317 };
318
319 let local_turns = collect_local_turns(store, discussion, self_attr)?;
321 let mut candidates: Vec<(String, String)> = Vec::new(); let mut skipped_foreign = 0usize;
323 for turn in &local_turns {
324 if linked.contains(&turn.turn_id) {
325 continue;
326 }
327 if !turn.is_self {
328 skipped_foreign += 1;
330 continue;
331 }
332 candidates.push((turn.turn_id.clone(), turn.body.clone()));
333 }
334 if skipped_foreign > 0 {
335 eprintln!(
338 "{} hosted discussion {local_id}: {skipped_foreign} unlinked turn(s) not attributed to the local principal were left unpublished",
339 crate::cli::style::warn_marker(),
340 );
341 }
342 if candidates.is_empty() {
343 return Ok(false);
344 }
345
346 match entry_index {
347 None => {
348 let (open_turn_id, open_body) = candidates[0].clone();
349 let hosted = client
350 .open_discussion(
351 repo_path,
352 change_id,
353 path,
354 symbol,
355 &open_body,
356 &visibility,
357 open_op_id(repo_path, local_id),
358 )
359 .await
360 .with_context(|| format!("open hosted discussion for {local_id}"))?;
361 let server_id = hosted.id.clone();
362 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
363 repo_mirror.discussions.push(MirrorEntry {
364 local_id: local_id.to_string(),
365 server_id: server_id.clone(),
366 links: vec![TurnLink {
367 local_turn_id: open_turn_id,
368 server_ordinal: 0,
369 }],
370 });
371 let index = repo_mirror.discussions.len() - 1;
372 for (turn_id, body) in &candidates[1..] {
373 let hosted = client
374 .append_turn(
375 repo_path,
376 &server_id,
377 body,
378 append_op_id(repo_path, &server_id, turn_id),
379 )
380 .await
381 .with_context(|| format!("append hosted turn for {local_id}"))?;
382 push_link(
383 mirror,
384 repo_path,
385 index,
386 turn_id.clone(),
387 hosted.turns.len().saturating_sub(1),
388 );
389 }
390 Ok(true)
391 }
392 Some(index) => {
393 let server_id = mirror.repos[repo_path].discussions[index].server_id.clone();
394 for (turn_id, body) in &candidates {
395 let hosted = client
396 .append_turn(
397 repo_path,
398 &server_id,
399 body,
400 append_op_id(repo_path, &server_id, turn_id),
401 )
402 .await
403 .with_context(|| format!("append hosted turn for {local_id}"))?;
404 push_link(
405 mirror,
406 repo_path,
407 index,
408 turn_id.clone(),
409 hosted.turns.len().saturating_sub(1),
410 );
411 }
412 Ok(true)
413 }
414 }
415}
416
417pub async fn pull_discussions(
421 repo: &Repository,
422 client: &mut HostedGrpcClient,
423 repo_path: &str,
424) -> Result<usize> {
425 mark_legacy_discussions_migrated(repo).context("claim legacy discussion migration marker")?;
433
434 let Some(head_state) = repo.head().context("resolve repository head")? else {
435 return Ok(0);
437 };
438 let Some(state) = repo
439 .store()
440 .get_state(&head_state)
441 .context("load head state")?
442 else {
443 return Ok(0);
444 };
445 let change_id = state.change_id;
446
447 let hosted = client
448 .list_discussions_by_state(repo_path, change_id, "all")
449 .await
450 .context("list hosted discussions")?;
451 if hosted.is_empty() {
452 return Ok(0);
453 }
454 let hosted_username = client.authenticated_username();
457
458 let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
459 let self_attr = repo.get_attribution().ok();
460 let mut mirror = load_mirror(repo.heddle_dir())?;
461 let mut changed = 0usize;
462
463 for discussion in hosted {
464 let result = pull_one(
465 &store,
466 repo_path,
467 &mut mirror,
468 head_state,
469 hosted_username.as_deref(),
470 self_attr.as_ref(),
471 &discussion,
472 );
473 save_mirror(repo.heddle_dir(), &mirror)?;
474 match result {
475 Ok(true) => changed += 1,
476 Ok(false) => {}
477 Err(error) => {
478 eprintln!(
479 "{} hosted discussion {}: {error:#}",
480 crate::cli::style::warn_marker(),
481 discussion.id
482 );
483 }
484 }
485 }
486
487 Ok(changed)
488}
489
490#[allow(clippy::too_many_arguments)]
491fn pull_one(
492 store: &CollaborationStore,
493 repo_path: &str,
494 mirror: &mut HostedMirror,
495 head_state: StateId,
496 hosted_username: Option<&str>,
497 self_attr: Option<&Attribution>,
498 discussion: &HostedDiscussion,
499) -> Result<bool> {
500 if discussion.turns.is_empty() {
501 return Ok(false);
502 }
503 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
504 let entry_index = repo_mirror
505 .discussions
506 .iter()
507 .position(|entry| entry.server_id == discussion.id);
508
509 match entry_index {
510 None => {
511 let local_id = DiscussionRecordId::generate();
512 let anchor = CollaborationAnchor::Symbol {
513 state_id: discussion.opened_against_state.unwrap_or(head_state),
514 path: discussion.file.clone(),
515 symbol: discussion.symbol.clone(),
516 };
517 let title = derive_title(&discussion.turns[0].body, &discussion.symbol);
518 let visibility = parse_visibility_token(&discussion.visibility);
519
520 let first = &discussion.turns[0];
521 let open_op = write_local_operation(
522 store,
523 local_id,
524 Vec::new(),
525 turn_attribution(first),
526 turn_ms(first),
527 CollaborationOperationBodyV1::Open {
528 title,
529 anchor,
530 visibility,
531 turn: turn_body(first)?,
532 },
533 )?;
534 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
537 repo_mirror.discussions.push(MirrorEntry {
538 local_id: local_id.to_string(),
539 server_id: discussion.id.clone(),
540 links: vec![TurnLink {
541 local_turn_id: turn_identity(&open_op, 0),
542 server_ordinal: 0,
543 }],
544 });
545 let index = repo_mirror.discussions.len() - 1;
546
547 let mut heads = vec![open_op];
548 for (ordinal, turn) in discussion.turns.iter().enumerate().skip(1) {
549 let op_id = write_local_operation(
550 store,
551 local_id,
552 heads.clone(),
553 turn_attribution(turn),
554 turn_ms(turn),
555 CollaborationOperationBodyV1::AppendTurn { turn: turn_body(turn)? },
556 )?;
557 heads = vec![op_id];
558 push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
559 }
560 Ok(true)
561 }
562 Some(index) => {
563 let local_id: DiscussionRecordId = repo_mirror.discussions[index]
564 .local_id
565 .parse()
566 .map_err(|e| anyhow!("mirror map has an invalid local discussion id: {e}"))?;
567 let linked_ordinals: HashSet<usize> = repo_mirror.discussions[index]
568 .links
569 .iter()
570 .map(|link| link.server_ordinal)
571 .collect();
572 let linked_turn_ids: HashSet<String> = repo_mirror.discussions[index]
573 .links
574 .iter()
575 .map(|link| link.local_turn_id.clone())
576 .collect();
577
578 let existing = store
579 .materialize_discussion(&local_id)
580 .context("materialize mirrored discussion")?
581 .ok_or_else(|| anyhow!("mirrored discussion {local_id} missing locally"))?;
582 let mut heads: Vec<CollabOpId> = existing.heads.iter().copied().collect();
583 let mut available: Vec<LocalTurn> = collect_local_turns(store, &existing, self_attr)?
586 .into_iter()
587 .filter(|turn| !linked_turn_ids.contains(&turn.turn_id))
588 .collect();
589
590 let mut changed = false;
591 for (ordinal, server_turn) in discussion.turns.iter().enumerate() {
592 if linked_ordinals.contains(&ordinal) {
593 continue;
594 }
595 if let Some(pos) = reconcile(&available, server_turn, hosted_username) {
596 let local = available.swap_remove(pos);
597 push_link(mirror, repo_path, index, local.turn_id, ordinal);
598 changed = true;
599 continue;
600 }
601 let op_id = write_local_operation(
602 store,
603 local_id,
604 heads.clone(),
605 turn_attribution(server_turn),
606 turn_ms(server_turn),
607 CollaborationOperationBodyV1::AppendTurn { turn: turn_body(server_turn)? },
608 )?;
609 heads = vec![op_id];
610 push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
611 changed = true;
612 }
613 Ok(changed)
614 }
615 }
616}
617
618fn reconcile(
622 available: &[LocalTurn],
623 server_turn: &HostedDiscussionTurn,
624 hosted_username: Option<&str>,
625) -> Option<usize> {
626 let server_ms = server_turn.posted_at_secs.saturating_mul(1000);
627 available.iter().position(|local| {
628 if local.body != server_turn.body {
629 return false;
630 }
631 let pushed_by_us = local.is_self
634 && hosted_username.is_some_and(|username| username == server_turn.author_name);
635 let pulled_before = local.author_name == server_turn.author_name
638 && local.author_email == server_turn.author_email
639 && local.occurred_at_ms == server_ms;
640 pushed_by_us || pulled_before
641 })
642}
643
644fn push_link(
645 mirror: &mut HostedMirror,
646 repo_path: &str,
647 index: usize,
648 local_turn_id: String,
649 server_ordinal: usize,
650) {
651 if let Some(entry) = mirror
652 .repos
653 .get_mut(repo_path)
654 .and_then(|repo_mirror| repo_mirror.discussions.get_mut(index))
655 {
656 entry.links.push(TurnLink {
657 local_turn_id,
658 server_ordinal,
659 });
660 }
661}
662
663fn principals_match(a: &Principal, b: &Principal) -> bool {
664 a.name == b.name && a.email == b.email
665}
666
667fn write_local_operation(
668 store: &CollaborationStore,
669 discussion_id: DiscussionRecordId,
670 parents: Vec<CollabOpId>,
671 author: Attribution,
672 occurred_at_ms: i64,
673 body: CollaborationOperationBodyV1,
674) -> Result<CollabOpId> {
675 let key = CollaborationIdempotencyKey::new(uuid::Uuid::new_v4().to_string())
676 .map_err(|e| anyhow!("invalid idempotency key: {e}"))?;
677 let operation =
678 CollaborationOperationEnvelope::new(discussion_id, parents, key, author, occurred_at_ms, body)
679 .map_err(|e| anyhow!("build collaboration operation: {e}"))?;
680 Ok(store
681 .write_operation(&operation)
682 .context("write collaboration operation")?
683 .operation_id)
684}
685
686fn turn_body(turn: &HostedDiscussionTurn) -> Result<DiscussionTurnV1> {
687 DiscussionTurnV1::new(turn.body.clone()).map_err(|e| anyhow!("invalid discussion turn: {e}"))
688}
689
690fn turn_attribution(turn: &HostedDiscussionTurn) -> Attribution {
691 Attribution::human(Principal::new(
692 turn.author_name.clone(),
693 turn.author_email.clone(),
694 ))
695}
696
697fn turn_ms(turn: &HostedDiscussionTurn) -> i64 {
698 if turn.posted_at_secs > 0 {
699 turn.posted_at_secs.saturating_mul(1000)
700 } else {
701 now_ms()
702 }
703}
704
705fn derive_title(body: &str, symbol: &str) -> String {
706 body.lines()
707 .map(str::trim)
708 .find(|line| !line.is_empty())
709 .unwrap_or(symbol)
710 .to_string()
711}
712
713fn parse_visibility_token(token: &str) -> VisibilityTier {
714 match token {
715 "public" => VisibilityTier::Public,
716 "internal" => VisibilityTier::Internal,
717 "team_scoped" => VisibilityTier::TeamScoped {
718 team_id: String::new(),
719 },
720 "restricted" => VisibilityTier::Restricted {
721 scope_label: String::new(),
722 },
723 "private" => VisibilityTier::Private {
724 scope_label: String::new(),
725 },
726 _ => VisibilityTier::Internal,
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use objects::object::{
733 CollaborationAnchor, CollaborationIdempotencyKey, CollaborationOperationBodyV1,
734 CollaborationOperationEnvelope, DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId,
735 LegacyDiscussionResolutionV1, LegacySourceLocator, Principal, StateAttachmentId, StateId,
736 VisibilityTier,
737 };
738 use objects::object::{Attribution, ContentHash};
739
740 use super::*;
741
742 fn local(body: &str, author_name: &str, author_email: &str, is_self: bool, ms: i64) -> LocalTurn {
743 LocalTurn {
744 turn_id: format!("co-{author_name}#0"),
745 body: body.to_string(),
746 author_name: author_name.to_string(),
747 author_email: author_email.to_string(),
748 occurred_at_ms: ms,
749 is_self,
750 }
751 }
752
753 fn server(body: &str, author_name: &str, author_email: &str, posted_at_secs: i64) -> HostedDiscussionTurn {
754 HostedDiscussionTurn {
755 author_name: author_name.to_string(),
756 author_email: author_email.to_string(),
757 body: body.to_string(),
758 posted_at_secs,
759 }
760 }
761
762 #[test]
766 fn reconcile_rejects_identical_body_across_authors() {
767 let available = vec![local("lgtm", "alice", "alice@x", true, 111)];
769 let st = server("lgtm", "bob", "", 5);
771 assert_eq!(
772 reconcile(&available, &st, Some("alice")),
773 None,
774 "a self turn must not link to a DIFFERENT author's identical body (rule i needs our username to be the server author)"
775 );
776 }
777
778 #[test]
781 fn reconcile_links_turn_we_pushed() {
782 let available = vec![local("ship it", "alice-local", "alice@x", true, 111)];
783 let st = server("ship it", "alice", "", 9); assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
785 }
786
787 #[test]
790 fn reconcile_links_turn_we_pulled() {
791 let available = vec![local("+1", "bob", "bob@x", false, 7000)]; let st = server("+1", "bob", "bob@x", 7);
793 assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
794 let st_other = server("+1", "carol", "carol@x", 7);
796 assert_eq!(reconcile(&available, &st_other, Some("alice")), None);
797 }
798
799 #[test]
803 fn legacy_imported_multi_turn_op_has_distinct_identities_and_keys() {
804 let temp = tempfile::TempDir::new().unwrap();
805 let store = CollaborationStore::open(temp.path()).unwrap();
806 let discussion_id: DiscussionRecordId =
807 "disc-018f47ea-4a54-7c89-b012-3456789abcde".parse().unwrap();
808 let author = Attribution::human(Principal::new("Importer", "importer@x"));
809 let anchor = CollaborationAnchor::Symbol {
810 state_id: StateId::from_bytes([1; 32]),
811 path: "src/lib.rs".to_string(),
812 symbol: "run".to_string(),
813 };
814 let op = CollaborationOperationEnvelope::new(
815 discussion_id,
816 Vec::new(),
817 CollaborationIdempotencyKey::new("legacy-1").unwrap(),
818 author.clone(),
819 1_000,
820 CollaborationOperationBodyV1::LegacyImported {
821 source: LegacySourceLocator::new(
822 StateId::from_bytes([1; 32]),
823 StateAttachmentId::from_hash(ContentHash::from_bytes([4; 32])),
824 ContentHash::from_bytes([5; 32]),
825 ),
826 legacy_discussion_id: LegacyDiscussionId::new("legacy-1".to_string()).unwrap(),
827 aliases: Vec::new(),
828 title: "run".to_string(),
829 anchor,
830 visibility: VisibilityTier::Internal,
831 turns: vec![
832 DiscussionTurnV1::new("turn one").unwrap(),
833 DiscussionTurnV1::new("turn two").unwrap(),
834 DiscussionTurnV1::new("turn three").unwrap(),
835 ],
836 resolution: LegacyDiscussionResolutionV1::Open,
837 },
838 )
839 .unwrap();
840 store.write_operation(&op).unwrap();
841
842 let materialized = store.materialize_discussion(&discussion_id).unwrap().unwrap();
843 assert_eq!(materialized.turns.len(), 3);
844 let self_attr = Attribution::human(Principal::new("Importer", "importer@x"));
845 let turns = collect_local_turns(&store, &materialized, Some(&self_attr)).unwrap();
846
847 let ids: HashSet<&String> = turns.iter().map(|t| &t.turn_id).collect();
849 assert_eq!(ids.len(), 3, "multi-turn op must yield distinct turn ids");
850 let keys: HashSet<String> = turns
852 .iter()
853 .map(|t| append_op_id("ns/repo", "server-1", &t.turn_id))
854 .collect();
855 assert_eq!(keys.len(), 3, "each turn must get a distinct idempotency key");
856 assert!(turns.iter().all(|t| t.is_self));
858 }
859
860 #[test]
862 fn collect_local_turns_fails_closed_without_self_principal() {
863 let temp = tempfile::TempDir::new().unwrap();
864 let store = CollaborationStore::open(temp.path()).unwrap();
865 let discussion_id = DiscussionRecordId::generate();
866 let op = CollaborationOperationEnvelope::new(
867 discussion_id,
868 Vec::new(),
869 CollaborationIdempotencyKey::new("k").unwrap(),
870 Attribution::human(Principal::new("Ada", "ada@x")),
871 1,
872 CollaborationOperationBodyV1::Open {
873 title: "t".to_string(),
874 anchor: CollaborationAnchor::Symbol {
875 state_id: StateId::from_bytes([2; 32]),
876 path: "a.rs".to_string(),
877 symbol: "a".to_string(),
878 },
879 visibility: VisibilityTier::Internal,
880 turn: DiscussionTurnV1::new("hi").unwrap(),
881 },
882 )
883 .unwrap();
884 store.write_operation(&op).unwrap();
885 let materialized = store.materialize_discussion(&discussion_id).unwrap().unwrap();
886 let turns = collect_local_turns(&store, &materialized, None).unwrap();
887 assert!(
888 turns.iter().all(|t| !t.is_self),
889 "with no local principal, no turn may be classified as ours"
890 );
891 }
892}