Skip to main content

cli/client/
context_sync.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Hosted context (annotation) sync bridge.
3//!
4//! Local context annotations live in per-state `Context` attachments
5//! (`ContextBlob`s keyed by target). The hosted weft `RepositoryService` speaks
6//! an id-keyed annotation model with the SAME `Annotation` shape, mutated
7//! through the caller-authenticated `SetContext`/`ReviseContext`/
8//! `SupersedeContext` RPCs and read back through `ListContext`/
9//! `GetContextHistory`. This module bridges the two, mirroring
10//! [`crate::client::discussion_sync`].
11//!
12//! ## Identity: annotation id AND per-revision id links
13//!
14//! A context annotation carries a globally-unique `annotation_id`, and each
15//! revision a `revision_id`. When the server ships an annotation back on a pull,
16//! the pack carries its `Annotation` verbatim, so a pulled annotation's local id
17//! (and each pulled revision's id) IS the server id. A locally-authored
18//! annotation/revision is minted with a fresh uuid, so its id differs from the
19//! server id the RPC assigns. The mirror map records BOTH:
20//! `local_annotation_id ↔ server_annotation_id` and, per annotation, a set of
21//! `local_revision_id ↔ server_revision_id` links.
22//!
23//! A prefix COUNT of "revisions synced" is wrong: with two clones concurrently
24//! revising one annotation, the linear server list interleaves their revisions,
25//! so a count both drops one author's revision and duplicates the other's on the
26//! next pull. Reconciliation is therefore by revision-id set difference in server
27//! order, and pull rebuilds the local revision list to match server order so the
28//! "current" revision agrees across clones.
29//!
30//! ## Reconciliation is author-aware, never body-alone
31//!
32//! Where an id link is missing (a lost/rebuilt mirror, or recovering a
33//! server-minted id), an unlinked server revision is matched to an unlinked
34//! local revision only under an explicit author rule — never body equality
35//! alone, which would cross-link two different authors' identical bodies
36//! (`"lgtm"`) and silently drop one:
37//! * (i) a revision WE pushed — the local revision is self-authored (its
38//!   attribution equals our local attribution) AND the server stamped it with
39//!   our own hosted username (`"{username} <>"`); or
40//! * (ii) a revision we previously PULLED — the local attribution + `created_at`
41//!   exactly equal the server revision's. Minted-annotation-id recovery after
42//!   `SetContext` uses the same author rule (weft returns only a count, not the
43//!   id): it adopts the annotation at the target whose attribution is our hosted
44//!   username, whose content matches, and whose id is not already linked.
45//!
46//! ## Idempotent, crash-safe create
47//!
48//! `SetContext` mints the id server-side and does not return it. Before the RPC
49//! the mirror records a `pending_create_op` (write-ahead, persisted to disk) used
50//! as the `client_operation_id`, so a retry after a crash replays (weft dedup)
51//! instead of duplicating, and recovery re-adopts the already-created annotation
52//! by author+content among ids NOT already linked — closing the "could not
53//! recover minted id" wedge.
54//!
55//! ## Supersession chains through the mirror
56//!
57//! `supersedes_annotation_id` is a LOCAL id; it is resolved to the SERVER id via
58//! the mirror before calling `SupersedeContext`, so superseding a previously
59//! pushed annotation marks the right server annotation superseded (rather than
60//! degrading to a plain create that leaves the old one Active on every clone).
61//!
62//! ## weft#638 limit (degrade gracefully, don't fix here)
63//!
64//! Each RPC advances the server head (context lives per-state); a no-HEAD repo is
65//! skipped. The mirror is saved after every annotation and on the error path,
66//! collect-and-continue, so one wedged annotation never aborts the rest or
67//! orphans a durable write. Scope: annotations only (`rm` is local-only).
68
69#![cfg(feature = "client")]
70
71use std::{
72    collections::{BTreeMap, HashSet},
73    fs,
74    path::{Path, PathBuf},
75};
76
77use anyhow::{Context, Result};
78use api::heddle::api::v1alpha1::{
79    AnnotationScope as ProtoScope, ContextAnnotation, ContextAnnotationKind,
80    ContextAnnotationStatus, LineRange, SymbolScope, annotation_scope::Scope,
81};
82use objects::{
83    fs_atomic::write_file_atomic,
84    object::{
85        Annotation, AnnotationKind, AnnotationRevision, AnnotationScope, AnnotationStatus,
86        ContentHash, ContextBlob, ContextTarget, State, StateId,
87    },
88    store::ObjectStore,
89};
90use repo::Repository;
91use serde::{Deserialize, Serialize};
92
93use crate::{
94    cli::commands::context::{context_root_for_state, put_context_attachment},
95    client::HostedClient,
96};
97
98// =========================================================================
99// Mirror map
100// =========================================================================
101
102#[derive(Debug, Default, Serialize, Deserialize)]
103struct HostedContextMirror {
104    #[serde(default)]
105    repos: BTreeMap<String, RepoContextMirror>,
106}
107
108#[derive(Debug, Default, Serialize, Deserialize)]
109struct RepoContextMirror {
110    #[serde(default)]
111    annotations: Vec<ContextMirrorEntry>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115struct ContextMirrorEntry {
116    /// Local `annotation_id`.
117    local_id: String,
118    /// Server `annotation_id`. Empty while a create is in flight.
119    #[serde(default)]
120    server_id: String,
121    /// `local_revision_id ↔ server_revision_id` links.
122    #[serde(default)]
123    revision_links: Vec<RevisionLink>,
124    /// Write-ahead: the `client_operation_id` a create RPC is (or was) issued
125    /// with, so a crash-retry replays rather than duplicating.
126    #[serde(default)]
127    pending_create_op: Option<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131struct RevisionLink {
132    local: String,
133    server: String,
134}
135
136fn mirror_path(heddle_dir: &Path) -> PathBuf {
137    heddle_dir
138        .join("collaboration")
139        .join("hosted-context-mirror.json")
140}
141
142fn load_mirror(heddle_dir: &Path) -> Result<HostedContextMirror> {
143    match fs::read(mirror_path(heddle_dir)) {
144        Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted context mirror map"),
145        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
146            Ok(HostedContextMirror::default())
147        }
148        Err(error) => Err(error).context("read hosted context mirror map"),
149    }
150}
151
152fn save_mirror(heddle_dir: &Path, mirror: &HostedContextMirror) -> Result<()> {
153    let path = mirror_path(heddle_dir);
154    if let Some(parent) = path.parent() {
155        fs::create_dir_all(parent).context("create collaboration dir")?;
156    }
157    let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted context mirror map")?;
158    write_file_atomic(&path, &bytes).context("write hosted context mirror map")?;
159    Ok(())
160}
161
162// --- mirror accessors ---
163
164fn entry_index(mirror: &HostedContextMirror, repo_path: &str, local_id: &str) -> Option<usize> {
165    mirror
166        .repos
167        .get(repo_path)?
168        .annotations
169        .iter()
170        .position(|entry| entry.local_id == local_id)
171}
172
173fn get_or_create_entry<'a>(
174    mirror: &'a mut HostedContextMirror,
175    repo_path: &str,
176    local_id: &str,
177) -> &'a mut ContextMirrorEntry {
178    let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
179    if let Some(index) = repo_mirror
180        .annotations
181        .iter()
182        .position(|entry| entry.local_id == local_id)
183    {
184        return &mut repo_mirror.annotations[index];
185    }
186    repo_mirror.annotations.push(ContextMirrorEntry {
187        local_id: local_id.to_string(),
188        ..Default::default()
189    });
190    repo_mirror.annotations.last_mut().expect("just pushed")
191}
192
193fn server_id_for_local(
194    mirror: &HostedContextMirror,
195    repo_path: &str,
196    local_id: &str,
197) -> Option<String> {
198    let entry = mirror
199        .repos
200        .get(repo_path)?
201        .annotations
202        .iter()
203        .find(|entry| entry.local_id == local_id)?;
204    (!entry.server_id.is_empty()).then(|| entry.server_id.clone())
205}
206
207fn local_id_for_server(
208    mirror: &HostedContextMirror,
209    repo_path: &str,
210    server_id: &str,
211) -> Option<String> {
212    mirror
213        .repos
214        .get(repo_path)?
215        .annotations
216        .iter()
217        .find(|entry| entry.server_id == server_id)
218        .map(|entry| entry.local_id.clone())
219}
220
221fn server_id_is_linked(mirror: &HostedContextMirror, repo_path: &str, server_id: &str) -> bool {
222    mirror.repos.get(repo_path).is_some_and(|repo_mirror| {
223        repo_mirror
224            .annotations
225            .iter()
226            .any(|entry| entry.server_id == server_id)
227    })
228}
229
230fn add_revision_link(
231    mirror: &mut HostedContextMirror,
232    repo_path: &str,
233    local_id: &str,
234    local_rev: String,
235    server_rev: String,
236) {
237    let entry = get_or_create_entry(mirror, repo_path, local_id);
238    if !entry
239        .revision_links
240        .iter()
241        .any(|link| link.local == local_rev && link.server == server_rev)
242    {
243        entry.revision_links.push(RevisionLink {
244            local: local_rev,
245            server: server_rev,
246        });
247    }
248}
249
250// =========================================================================
251// scope / kind converters
252// =========================================================================
253
254fn scope_to_proto(scope: &AnnotationScope) -> ProtoScope {
255    let inner = match scope {
256        AnnotationScope::File => Scope::File(true),
257        AnnotationScope::Symbol {
258            name,
259            resolved_lines,
260        } => Scope::Symbol(SymbolScope {
261            name: name.clone(),
262            resolved_start: resolved_lines.map(|(start, _)| start),
263            resolved_end: resolved_lines.map(|(_, end)| end),
264        }),
265        AnnotationScope::Lines(start, end) => Scope::Lines(LineRange {
266            start: *start,
267            end: *end,
268        }),
269    };
270    ProtoScope { scope: Some(inner) }
271}
272
273fn scope_from_proto(scope: Option<&ProtoScope>) -> AnnotationScope {
274    match scope.and_then(|s| s.scope.as_ref()) {
275        Some(Scope::File(_)) | None => AnnotationScope::File,
276        Some(Scope::Symbol(symbol)) => AnnotationScope::Symbol {
277            name: symbol.name.clone(),
278            resolved_lines: match (symbol.resolved_start, symbol.resolved_end) {
279                (Some(start), Some(end)) => Some((start, end)),
280                _ => None,
281            },
282        },
283        Some(Scope::Lines(range)) => AnnotationScope::Lines(range.start, range.end),
284    }
285}
286
287/// Scope identity ignoring `resolved_lines` (the server may resolve a symbol's
288/// lines even when the local scope has none), for matching recovered annotations.
289fn scope_ident(scope: &AnnotationScope) -> String {
290    match scope {
291        AnnotationScope::File => "file".to_string(),
292        AnnotationScope::Symbol { name, .. } => format!("symbol:{name}"),
293        AnnotationScope::Lines(start, end) => format!("lines:{start}-{end}"),
294    }
295}
296
297fn kind_to_proto(kind: AnnotationKind) -> ContextAnnotationKind {
298    match kind {
299        AnnotationKind::Constraint => ContextAnnotationKind::Constraint,
300        AnnotationKind::Invariant => ContextAnnotationKind::Invariant,
301        AnnotationKind::Rationale => ContextAnnotationKind::Rationale,
302    }
303}
304
305fn kind_from_proto(kind: i32) -> AnnotationKind {
306    match ContextAnnotationKind::try_from(kind).unwrap_or(ContextAnnotationKind::Rationale) {
307        ContextAnnotationKind::Constraint => AnnotationKind::Constraint,
308        ContextAnnotationKind::Invariant => AnnotationKind::Invariant,
309        ContextAnnotationKind::Rationale | ContextAnnotationKind::Unspecified => {
310            AnnotationKind::Rationale
311        }
312    }
313}
314
315fn status_from_proto(status: i32) -> AnnotationStatus {
316    match ContextAnnotationStatus::try_from(status).unwrap_or(ContextAnnotationStatus::Active) {
317        ContextAnnotationStatus::Superseded => AnnotationStatus::Superseded,
318        _ => AnnotationStatus::Active,
319    }
320}
321
322fn target_operands(target: &ContextTarget) -> (String, Option<String>) {
323    match target {
324        ContextTarget::File { path } => (path.clone(), None),
325        ContextTarget::State { state_id } => (String::new(), Some(state_id.to_string_full())),
326    }
327}
328
329fn content_hash_from_bytes(bytes: &Option<Vec<u8>>) -> Option<ContentHash> {
330    bytes
331        .as_ref()
332        .and_then(|raw| <[u8; 32]>::try_from(raw.as_slice()).ok())
333        .map(ContentHash::from_bytes)
334}
335
336fn state_id_from_proto(id: Option<&api::heddle::api::v1alpha1::StateId>) -> Option<StateId> {
337    id.and_then(|value| StateId::try_from_slice(&value.value).ok())
338}
339
340/// The attribution string weft stamps on our own hosted writes:
341/// `Principal::new(username, "")` renders as `"{username} <>"`.
342fn hosted_attribution(username: Option<&str>) -> Option<String> {
343    username.map(|name| format!("{name} <>"))
344}
345
346// =========================================================================
347// Push
348// =========================================================================
349
350/// Publish local annotations we authored to the hosted `RepositoryService`.
351pub async fn push_context(
352    repo: &Repository,
353    client: &mut HostedClient,
354    repo_path: &str,
355) -> Result<usize> {
356    let Some(head_id) = repo.head().context("resolve repository head")? else {
357        return Ok(0);
358    };
359    let Some(head_state) = repo
360        .store()
361        .get_state(&head_id)
362        .context("load head state")?
363    else {
364        return Ok(0);
365    };
366    let Some(context_root) = context_root_for_state(repo, &head_state)? else {
367        return Ok(0);
368    };
369    let entries = repo
370        .list_context_entries(&context_root, None)
371        .context("enumerate local context annotations")?;
372    if entries.is_empty() {
373        return Ok(0);
374    }
375
376    let user_config = crate::config::UserConfig::load_default().unwrap_or_default();
377    let self_local_attr = crate::cli::commands::snapshot::resolve_attribution(repo, &user_config)
378        .ok()
379        .map(|attribution| attribution.to_string());
380    let username = client.authenticated_username();
381
382    let server_ann_ids: HashSet<String> = list_server_annotations(client, repo_path)
383        .await?
384        .into_iter()
385        .map(|annotation| annotation.id)
386        .collect();
387
388    let heddle_dir = repo.heddle_dir().to_path_buf();
389    let mut mirror = load_mirror(&heddle_dir)?;
390    let mut synced = 0usize;
391    for entry in &entries {
392        for annotation in &entry.blob.annotations {
393            let result = push_one(
394                client,
395                repo_path,
396                &heddle_dir,
397                &entry.target,
398                annotation,
399                self_local_attr.as_deref(),
400                username.as_deref(),
401                &server_ann_ids,
402                &mut mirror,
403            )
404            .await;
405            save_mirror(&heddle_dir, &mirror)?;
406            match result {
407                Ok(true) => synced += 1,
408                Ok(false) => {}
409                Err(error) => {
410                    eprintln!(
411                        "{} hosted context {}: {error:#}",
412                        crate::cli::style::warn_marker(),
413                        annotation.annotation_id
414                    );
415                }
416            }
417        }
418    }
419    Ok(synced)
420}
421
422#[allow(clippy::too_many_arguments)]
423async fn push_one(
424    client: &mut HostedClient,
425    repo_path: &str,
426    heddle_dir: &Path,
427    target: &ContextTarget,
428    annotation: &Annotation,
429    self_local_attr: Option<&str>,
430    username: Option<&str>,
431    server_ann_ids: &HashSet<String>,
432    mirror: &mut HostedContextMirror,
433) -> Result<bool> {
434    // ---- 1. Resolve the server annotation id. ----
435    let mut created = false;
436    let server_id = if let Some(sid) =
437        server_id_for_local(mirror, repo_path, &annotation.annotation_id)
438    {
439        sid
440    } else if server_ann_ids.contains(&annotation.annotation_id) {
441        // Pulled / pack-delivered: local id IS the server id. Adopt it.
442        let entry = get_or_create_entry(mirror, repo_path, &annotation.annotation_id);
443        entry.server_id = annotation.annotation_id.clone();
444        annotation.annotation_id.clone()
445    } else {
446        // Genuinely new → create. Fail-closed self filter.
447        let first = annotation
448            .revisions
449            .first()
450            .context("annotation has no revisions")?;
451        let is_self = self_local_attr.is_some_and(|me| me == first.attribution);
452        if !is_self {
453            eprintln!(
454                "{} hosted context {}: not attributed to the local principal; left unpublished",
455                crate::cli::style::warn_marker(),
456                annotation.annotation_id
457            );
458            return Ok(false);
459        }
460        // Resolve the superseded LOCAL id → SERVER id through the mirror (P1-3).
461        let superseded_server = annotation
462            .supersedes_annotation_id
463            .as_ref()
464            .and_then(|local| {
465                server_id_for_local(mirror, repo_path, local)
466                    .or_else(|| server_ann_ids.contains(local).then(|| local.clone()))
467            });
468
469        // Write-ahead: persist a create nonce to DISK before the RPC so a
470        // crash-retry replays with the same client_operation_id (P2-5).
471        let op_id = {
472            let entry = get_or_create_entry(mirror, repo_path, &annotation.annotation_id);
473            if entry.pending_create_op.is_none() {
474                entry.pending_create_op = Some(uuid::Uuid::new_v4().to_string());
475            }
476            entry.pending_create_op.clone().expect("just set")
477        };
478        save_mirror(heddle_dir, mirror)?;
479
480        let (sid, first_server_rev) = create_on_server(
481            client,
482            repo_path,
483            target,
484            annotation,
485            superseded_server.as_deref(),
486            &op_id,
487            username,
488            mirror,
489        )
490        .await?;
491
492        {
493            let entry = get_or_create_entry(mirror, repo_path, &annotation.annotation_id);
494            entry.server_id = sid.clone();
495            entry.pending_create_op = None;
496        }
497        add_revision_link(
498            mirror,
499            repo_path,
500            &annotation.annotation_id,
501            annotation.revisions[0].revision_id.clone(),
502            first_server_rev,
503        );
504        created = true;
505        sid
506    };
507
508    // ---- 2. Sync revisions (linear, id-linked, author-aware recovery). ----
509    let pushed = sync_revisions_push(
510        client,
511        repo_path,
512        &server_id,
513        annotation,
514        self_local_attr,
515        username,
516        mirror,
517    )
518    .await?;
519
520    Ok(created || pushed > 0)
521}
522
523/// Create a fresh annotation server-side, returning `(server_annotation_id,
524/// first_server_revision_id)`.
525#[allow(clippy::too_many_arguments)]
526async fn create_on_server(
527    client: &mut HostedClient,
528    repo_path: &str,
529    target: &ContextTarget,
530    annotation: &Annotation,
531    superseded_server: Option<&str>,
532    op_id: &str,
533    username: Option<&str>,
534    mirror: &HostedContextMirror,
535) -> Result<(String, String)> {
536    let first = annotation
537        .revisions
538        .first()
539        .context("annotation has no revisions")?;
540    let (path, target_state_id) = target_operands(target);
541
542    let server_id = if let Some(superseded) = superseded_server {
543        let response = client
544            .supersede_context(
545                repo_path,
546                superseded,
547                if path.is_empty() {
548                    None
549                } else {
550                    Some(path.as_str())
551                },
552                target_state_id.as_deref(),
553                scope_to_proto(&annotation.scope),
554                first.tags.clone(),
555                &first.content,
556                None,
557                None,
558                kind_to_proto(first.kind),
559                op_id.to_string(),
560            )
561            .await
562            .with_context(|| format!("supersede hosted annotation {superseded}"))?;
563        response.new_annotation_id
564    } else {
565        client
566            .set_context(
567                repo_path,
568                &path,
569                target_state_id.as_deref(),
570                scope_to_proto(&annotation.scope),
571                kind_to_proto(first.kind),
572                first.tags.clone(),
573                &first.content,
574                None,
575                None,
576                op_id.to_string(),
577            )
578            .await
579            .with_context(|| format!("set hosted context for {}", annotation.annotation_id))?;
580
581        // Recover the minted id author-aware (weft returns only a count): the
582        // annotation at the target stamped with OUR hosted username, matching
583        // content + scope, whose id is not already linked (re-link safe).
584        let hosted = hosted_attribution(username);
585        list_server_targets(client, repo_path)
586            .await?
587            .into_iter()
588            .rev()
589            .find(|(candidate_target, candidate)| {
590                candidate_target == target
591                    && Some(candidate.attribution.as_str()) == hosted.as_deref()
592                    && candidate.content == first.content
593                    && scope_ident(&scope_from_proto(candidate.scope.as_ref()))
594                        == scope_ident(&annotation.scope)
595                    && !server_id_is_linked(mirror, repo_path, &candidate.id)
596            })
597            .map(|(_, candidate)| candidate.id)
598            .context("could not recover the minted annotation id (author + content)")?
599    };
600
601    let first_server_rev = first_server_revision_id(client, repo_path, &server_id).await?;
602    Ok((server_id, first_server_rev))
603}
604
605/// Forward local revisions the server does not yet hold. Returns the count
606/// actually pushed. Author-aware recovery links each minted server revision id.
607async fn sync_revisions_push(
608    client: &mut HostedClient,
609    repo_path: &str,
610    server_id: &str,
611    annotation: &Annotation,
612    self_local_attr: Option<&str>,
613    username: Option<&str>,
614    mirror: &mut HostedContextMirror,
615) -> Result<usize> {
616    let hosted = hosted_attribution(username);
617    let mut server_rev_ids: HashSet<String> = fetch_history(client, repo_path, server_id)
618        .await?
619        .into_iter()
620        .map(|rev| rev.revision_id)
621        .collect();
622    let linked_local: HashSet<String> = mirror
623        .repos
624        .get(repo_path)
625        .and_then(|m| {
626            m.annotations
627                .iter()
628                .find(|e| e.local_id == annotation.annotation_id)
629        })
630        .map(|entry| {
631            entry
632                .revision_links
633                .iter()
634                .map(|l| l.local.clone())
635                .collect()
636        })
637        .unwrap_or_default();
638
639    let mut pushed = 0usize;
640    for revision in &annotation.revisions {
641        if linked_local.contains(&revision.revision_id) {
642            continue;
643        }
644        if server_rev_ids.contains(&revision.revision_id) {
645            // Pulled / pack-delivered: local revision id IS the server id.
646            add_revision_link(
647                mirror,
648                repo_path,
649                &annotation.annotation_id,
650                revision.revision_id.clone(),
651                revision.revision_id.clone(),
652            );
653            continue;
654        }
655        // A local-only revision. Only forward our own (a foreign unlinked
656        // revision not on the server is anomalous — skip rather than mis-author).
657        if self_local_attr != Some(revision.attribution.as_str()) {
658            eprintln!(
659                "{} hosted context {}: unlinked revision not attributed to the local principal; left unpublished",
660                crate::cli::style::warn_marker(),
661                annotation.annotation_id
662            );
663            continue;
664        }
665        client
666            .revise_context(
667                repo_path,
668                server_id,
669                &revision.content,
670                revision.tags.clone(),
671                None,
672                None,
673                kind_to_proto(revision.kind),
674                revise_op_id(repo_path, server_id, &revision.revision_id),
675            )
676            .await
677            .with_context(|| format!("revise hosted annotation {server_id}"))?;
678
679        // Recover the minted revision id: the newly-appended server revision
680        // stamped with our hosted username + our content, not seen before.
681        let refreshed = fetch_history(client, repo_path, server_id).await?;
682        let minted = refreshed.iter().rev().find(|candidate| {
683            !server_rev_ids.contains(&candidate.revision_id)
684                && Some(candidate.attribution.as_str()) == hosted.as_deref()
685                && candidate.content == revision.content
686        });
687        match minted {
688            Some(candidate) => {
689                add_revision_link(
690                    mirror,
691                    repo_path,
692                    &annotation.annotation_id,
693                    revision.revision_id.clone(),
694                    candidate.revision_id.clone(),
695                );
696                server_rev_ids.insert(candidate.revision_id.clone());
697                pushed += 1;
698            }
699            None => {
700                eprintln!(
701                    "{} hosted context {}: could not recover the minted revision id",
702                    crate::cli::style::warn_marker(),
703                    annotation.annotation_id
704                );
705            }
706        }
707    }
708    Ok(pushed)
709}
710
711/// Oldest server revision id for an annotation (the create revision).
712async fn first_server_revision_id(
713    client: &mut HostedClient,
714    repo_path: &str,
715    server_id: &str,
716) -> Result<String> {
717    let revisions = fetch_history(client, repo_path, server_id).await?;
718    revisions
719        .into_iter()
720        .next()
721        .map(|revision| revision.revision_id)
722        .context("hosted annotation has no revisions")
723}
724
725// =========================================================================
726// Pull
727// =========================================================================
728
729/// Fetch hosted annotations for the head and reconcile them into the local
730/// `Context` attachment, rebuilding revision order to match the server.
731pub async fn pull_context(
732    repo: &Repository,
733    client: &mut HostedClient,
734    repo_path: &str,
735) -> Result<usize> {
736    let Some(head_id) = repo.head().context("resolve repository head")? else {
737        return Ok(0);
738    };
739    let Some(head_state) = repo
740        .store()
741        .get_state(&head_id)
742        .context("load head state")?
743    else {
744        return Ok(0);
745    };
746
747    let server = list_server_targets(client, repo_path).await?;
748    if server.is_empty() {
749        return Ok(0);
750    }
751
752    let user_config = crate::config::UserConfig::load_default().unwrap_or_default();
753    let self_local_attr = crate::cli::commands::snapshot::resolve_attribution(repo, &user_config)
754        .ok()
755        .map(|attribution| attribution.to_string());
756    let username = client.authenticated_username();
757
758    let heddle_dir = repo.heddle_dir().to_path_buf();
759    let mut mirror = load_mirror(&heddle_dir)?;
760    let mut changed = 0usize;
761    for (target, annotation) in server {
762        let result = pull_one(
763            repo,
764            client,
765            repo_path,
766            &head_state,
767            &target,
768            &annotation,
769            self_local_attr.as_deref(),
770            username.as_deref(),
771            &mut mirror,
772        )
773        .await;
774        save_mirror(&heddle_dir, &mirror)?;
775        match result {
776            Ok(true) => changed += 1,
777            Ok(false) => {}
778            Err(error) => {
779                eprintln!(
780                    "{} hosted context {}: {error:#}",
781                    crate::cli::style::warn_marker(),
782                    annotation.id
783                );
784            }
785        }
786    }
787    Ok(changed)
788}
789
790#[allow(clippy::too_many_arguments)]
791async fn pull_one(
792    repo: &Repository,
793    client: &mut HostedClient,
794    repo_path: &str,
795    head_state: &State,
796    target: &ContextTarget,
797    server: &ContextAnnotation,
798    self_local_attr: Option<&str>,
799    username: Option<&str>,
800    mirror: &mut HostedContextMirror,
801) -> Result<bool> {
802    let local_id =
803        local_id_for_server(mirror, repo_path, &server.id).unwrap_or_else(|| server.id.clone());
804    let server_revs = fetch_history(client, repo_path, &server.id).await?;
805
806    let context_root = context_root_for_state(repo, head_state)?;
807    let mut blob = match &context_root {
808        Some(root) => repo
809            .get_context_blob(root, target)?
810            .unwrap_or_else(|| ContextBlob::new(vec![])),
811        None => ContextBlob::new(vec![]),
812    };
813
814    let existing_index = blob
815        .annotations
816        .iter()
817        .position(|annotation| annotation.annotation_id == local_id);
818
819    let existing_revisions: Vec<AnnotationRevision> = existing_index
820        .map(|index| blob.annotations[index].revisions.clone())
821        .unwrap_or_default();
822    let existing_links: Vec<RevisionLink> = entry_index(mirror, repo_path, &local_id)
823        .map(|index| {
824            mirror.repos[repo_path].annotations[index]
825                .revision_links
826                .clone()
827        })
828        .unwrap_or_default();
829
830    let (new_revisions, new_links) = reconcile_revisions_pull(
831        &existing_revisions,
832        &server_revs,
833        &existing_links,
834        self_local_attr,
835        username,
836    );
837
838    {
839        let entry = get_or_create_entry(mirror, repo_path, &local_id);
840        entry.server_id = server.id.clone();
841        entry.revision_links = new_links;
842    }
843
844    let new_status = status_from_proto(server.status);
845    let changed = match existing_index {
846        Some(index) => {
847            let annotation = &mut blob.annotations[index];
848            let differs = annotation.revisions != new_revisions
849                || annotation.status != new_status
850                || annotation.supersedes_annotation_id != server.supersedes_annotation_id
851                || annotation.supersedes_rewrite_pct != server.supersedes_rewrite_pct;
852            annotation.revisions = new_revisions;
853            annotation.status = new_status;
854            annotation.supersedes_annotation_id = server.supersedes_annotation_id.clone();
855            annotation.supersedes_rewrite_pct = server.supersedes_rewrite_pct;
856            differs
857        }
858        None => {
859            blob.annotations.push(Annotation {
860                annotation_id: local_id.clone(),
861                scope: scope_from_proto(server.scope.as_ref()),
862                status: new_status,
863                revisions: new_revisions,
864                supersedes_annotation_id: server.supersedes_annotation_id.clone(),
865                supersedes_rewrite_pct: server.supersedes_rewrite_pct,
866                visibility: objects::object::VisibilityTier::default(),
867                resolved_from_discussion: None,
868            });
869            true
870        }
871    };
872
873    if !changed {
874        return Ok(false);
875    }
876    let new_root = repo.set_context_blob(context_root.as_ref(), target, &blob)?;
877    if context_root != Some(new_root) {
878        put_context_attachment(repo, head_state, Some(new_root))?;
879    }
880    Ok(true)
881}
882
883/// Rebuild the local revision list to match the server order, linking each
884/// server revision to a local revision (existing link, id equality, or
885/// author-aware reconcile) or materializing a new one. Purely-local revisions
886/// not yet on the server are preserved at the tail.
887fn reconcile_revisions_pull(
888    existing: &[AnnotationRevision],
889    server_revs: &[AnnotationRevision],
890    existing_links: &[RevisionLink],
891    self_local_attr: Option<&str>,
892    username: Option<&str>,
893) -> (Vec<AnnotationRevision>, Vec<RevisionLink>) {
894    let hosted = hosted_attribution(username);
895    let linked_local: HashSet<&str> = existing_links.iter().map(|l| l.local.as_str()).collect();
896    let mut link_by_server: std::collections::HashMap<&str, &str> =
897        std::collections::HashMap::new();
898    for link in existing_links {
899        link_by_server.insert(link.server.as_str(), link.local.as_str());
900    }
901
902    let mut consumed: HashSet<String> = HashSet::new();
903    let mut new_revisions: Vec<AnnotationRevision> = Vec::new();
904    let mut new_links: Vec<RevisionLink> = Vec::new();
905
906    for server_rev in server_revs {
907        // (a) existing link by server id.
908        if let Some(local_rev_id) = link_by_server.get(server_rev.revision_id.as_str())
909            && let Some(local) = existing.iter().find(|rev| {
910                rev.revision_id == *local_rev_id && !consumed.contains(&rev.revision_id)
911            })
912        {
913            consumed.insert(local.revision_id.clone());
914            new_revisions.push(local.clone());
915            new_links.push(RevisionLink {
916                local: local.revision_id.clone(),
917                server: server_rev.revision_id.clone(),
918            });
919            continue;
920        }
921        // (b) id equality (pack-delivered: local rev id == server rev id).
922        if let Some(local) = existing.iter().find(|rev| {
923            rev.revision_id == server_rev.revision_id && !consumed.contains(&rev.revision_id)
924        }) {
925            consumed.insert(local.revision_id.clone());
926            new_revisions.push(local.clone());
927            new_links.push(RevisionLink {
928                local: local.revision_id.clone(),
929                server: server_rev.revision_id.clone(),
930            });
931            continue;
932        }
933        // (c) author-aware reconcile against unlinked, unconsumed local revisions.
934        let candidate = existing.iter().find(|rev| {
935            !linked_local.contains(rev.revision_id.as_str())
936                && !consumed.contains(&rev.revision_id)
937                && rev.content == server_rev.content
938                && reconcile_ok(rev, server_rev, hosted.as_deref(), self_local_attr)
939        });
940        if let Some(local) = candidate {
941            consumed.insert(local.revision_id.clone());
942            new_revisions.push(local.clone());
943            new_links.push(RevisionLink {
944                local: local.revision_id.clone(),
945                server: server_rev.revision_id.clone(),
946            });
947            continue;
948        }
949        // (d) materialize a new local revision preserving the server id.
950        consumed.insert(server_rev.revision_id.clone());
951        new_revisions.push(server_rev.clone());
952        new_links.push(RevisionLink {
953            local: server_rev.revision_id.clone(),
954            server: server_rev.revision_id.clone(),
955        });
956    }
957
958    // Preserve purely-local revisions not yet on the server (unpushed edits), in
959    // their original order, at the tail.
960    for revision in existing {
961        if !consumed.contains(&revision.revision_id)
962            && !new_revisions
963                .iter()
964                .any(|rev| rev.revision_id == revision.revision_id)
965        {
966            new_revisions.push(revision.clone());
967        }
968    }
969
970    (new_revisions, new_links)
971}
972
973/// Author rule for reconciling an unlinked local revision with a server one —
974/// body equality is a precondition the caller already checked; this decides
975/// authorship. Never links on body alone.
976fn reconcile_ok(
977    local: &AnnotationRevision,
978    server: &AnnotationRevision,
979    hosted: Option<&str>,
980    self_local_attr: Option<&str>,
981) -> bool {
982    // (i) A revision WE pushed: locally self-authored AND server-stamped with our
983    // hosted username.
984    let pushed_by_us = self_local_attr == Some(local.attribution.as_str())
985        && hosted == Some(server.attribution.as_str());
986    // (ii) A revision we previously PULLED: local copied the server attribution +
987    // timestamp verbatim.
988    let pulled_before =
989        local.attribution == server.attribution && local.created_at == server.created_at;
990    pushed_by_us || pulled_before
991}
992
993// =========================================================================
994// Server enumeration
995// =========================================================================
996
997async fn list_server_annotations(
998    client: &mut HostedClient,
999    repo_path: &str,
1000) -> Result<Vec<ContextAnnotation>> {
1001    Ok(list_server_targets(client, repo_path)
1002        .await?
1003        .into_iter()
1004        .map(|(_, annotation)| annotation)
1005        .collect())
1006}
1007
1008async fn list_server_targets(
1009    client: &mut HostedClient,
1010    repo_path: &str,
1011) -> Result<Vec<(ContextTarget, ContextAnnotation)>> {
1012    let response = client
1013        .list_context(repo_path, None, None, None)
1014        .await
1015        .context("list hosted context")?;
1016    let mut out = Vec::new();
1017    for file in response.files {
1018        let Ok(target) = ContextTarget::file(&file.path) else {
1019            continue;
1020        };
1021        for annotation in file.annotations {
1022            out.push((target.clone(), annotation));
1023        }
1024    }
1025    for state in response.states {
1026        let Some(state_id) = state_id_from_proto(state.state_id.as_ref()) else {
1027            continue;
1028        };
1029        let target = ContextTarget::state(state_id);
1030        for annotation in state.annotations {
1031            out.push((target.clone(), annotation));
1032        }
1033    }
1034    Ok(out)
1035}
1036
1037/// `GetContextHistory` returns revisions newest-first; local storage is
1038/// oldest-first, so reverse.
1039async fn fetch_history(
1040    client: &mut HostedClient,
1041    repo_path: &str,
1042    annotation_id: &str,
1043) -> Result<Vec<AnnotationRevision>> {
1044    let history = client
1045        .get_context_history(repo_path, None, annotation_id)
1046        .await
1047        .with_context(|| format!("fetch hosted annotation history {annotation_id}"))?;
1048    let mut revisions: Vec<AnnotationRevision> = history
1049        .revisions
1050        .into_iter()
1051        .map(|revision| AnnotationRevision {
1052            revision_id: revision.revision_id,
1053            kind: kind_from_proto(revision.kind),
1054            content: revision.content,
1055            tags: revision.tags,
1056            attribution: revision.attribution,
1057            created_at: revision.created_at.map(|ts| ts.seconds).unwrap_or(0),
1058            source_hash: content_hash_from_bytes(&revision.source_hash),
1059            created_at_state: state_id_from_proto(revision.created_at_state.as_ref()),
1060        })
1061        .collect();
1062    revisions.reverse();
1063    Ok(revisions)
1064}
1065
1066// --- deterministic client-operation-ids (idempotent retry) ---
1067
1068const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_6374_785f_7379_6e63_0001);
1069
1070fn revise_op_id(repo_path: &str, server_id: &str, revision_id: &str) -> String {
1071    uuid::Uuid::new_v5(
1072        &OP_NAMESPACE,
1073        format!("revise:{repo_path}:{server_id}:{revision_id}").as_bytes(),
1074    )
1075    .to_string()
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    fn rev(id: &str, attr: &str, content: &str, created_at: i64) -> AnnotationRevision {
1083        AnnotationRevision {
1084            revision_id: id.to_string(),
1085            kind: AnnotationKind::Rationale,
1086            content: content.to_string(),
1087            tags: vec![],
1088            attribution: attr.to_string(),
1089            created_at,
1090            source_hash: None,
1091            created_at_state: None,
1092        }
1093    }
1094
1095    #[test]
1096    fn scope_round_trips_through_proto() {
1097        for scope in [
1098            AnnotationScope::File,
1099            AnnotationScope::Symbol {
1100                name: "run".to_string(),
1101                resolved_lines: Some((3, 9)),
1102            },
1103            AnnotationScope::Lines(10, 20),
1104        ] {
1105            assert_eq!(scope_from_proto(Some(&scope_to_proto(&scope))), scope);
1106        }
1107    }
1108
1109    #[test]
1110    fn kind_round_trips_through_proto() {
1111        for kind in [
1112            AnnotationKind::Constraint,
1113            AnnotationKind::Invariant,
1114            AnnotationKind::Rationale,
1115        ] {
1116            assert_eq!(kind_from_proto(kind_to_proto(kind) as i32), kind);
1117        }
1118    }
1119
1120    #[test]
1121    fn scope_ident_ignores_resolved_lines() {
1122        let a = AnnotationScope::Symbol {
1123            name: "greet".to_string(),
1124            resolved_lines: Some((1, 2)),
1125        };
1126        let b = AnnotationScope::Symbol {
1127            name: "greet".to_string(),
1128            resolved_lines: None,
1129        };
1130        assert_eq!(scope_ident(&a), scope_ident(&b));
1131    }
1132
1133    // P1-2: two clones concurrently revise one annotation. Alice already holds
1134    // r1 (pulled) and her own r3 (linked to server sA3); Bob's r2 arrives in the
1135    // middle. Pull must yield all three in SERVER order, keep Alice's local id
1136    // for sA3, materialize Bob's, and neither drop nor duplicate anything.
1137    #[test]
1138    fn pull_two_author_revisions_no_loss_no_dup_server_order() {
1139        let existing = vec![
1140            rev("sA1", "alice <>", "v1", 1), // pulled earlier (local id == server id)
1141            rev("rA3-local", "Alice <a@x>", "v3", 30), // Alice's own, linked to sA3
1142        ];
1143        let existing_links = vec![
1144            RevisionLink {
1145                local: "sA1".into(),
1146                server: "sA1".into(),
1147            },
1148            RevisionLink {
1149                local: "rA3-local".into(),
1150                server: "sA3".into(),
1151            },
1152        ];
1153        let server_revs = vec![
1154            rev("sA1", "alice <>", "v1", 1),
1155            rev("sB2", "bob <>", "v2-bob", 20), // Bob's revision, not yet local
1156            rev("sA3", "alice <>", "v3", 30),   // Alice's own, came back stamped
1157        ];
1158        let (new_revisions, new_links) = reconcile_revisions_pull(
1159            &existing,
1160            &server_revs,
1161            &existing_links,
1162            Some("Alice <a@x>"),
1163            Some("alice"),
1164        );
1165        let ids: Vec<&str> = new_revisions
1166            .iter()
1167            .map(|r| r.revision_id.as_str())
1168            .collect();
1169        // Server order, Alice's local id preserved for sA3, Bob materialized.
1170        assert_eq!(ids, vec!["sA1", "sB2", "rA3-local"]);
1171        let contents: Vec<&str> = new_revisions.iter().map(|r| r.content.as_str()).collect();
1172        assert_eq!(contents, vec!["v1", "v2-bob", "v3"]);
1173        // No duplicates.
1174        let unique: HashSet<&str> = ids.iter().copied().collect();
1175        assert_eq!(unique.len(), 3);
1176        // Every server revision is linked.
1177        assert_eq!(new_links.len(), 3);
1178    }
1179
1180    // P2-4 hazard at the revision layer: identical body from two DIFFERENT
1181    // authors must not cross-link. Alice's own local, unpushed "lgtm" must NOT be
1182    // consumed by Bob's server "lgtm"; Bob's materializes distinctly and Alice's
1183    // survives at the tail (so a later push still publishes it).
1184    #[test]
1185    fn pull_rejects_cross_author_identical_body() {
1186        let existing = vec![rev("rLocal", "Alice <a@x>", "lgtm", 5)];
1187        let server_revs = vec![rev("sBob", "bob <>", "lgtm", 9)];
1188        let (new_revisions, _links) = reconcile_revisions_pull(
1189            &existing,
1190            &server_revs,
1191            &[],
1192            Some("Alice <a@x>"),
1193            Some("alice"),
1194        );
1195        let ids: HashSet<&str> = new_revisions
1196            .iter()
1197            .map(|r| r.revision_id.as_str())
1198            .collect();
1199        assert_eq!(new_revisions.len(), 2, "both must survive, none collapsed");
1200        assert!(
1201            ids.contains("sBob"),
1202            "Bob's revision materialized distinctly"
1203        );
1204        assert!(ids.contains("rLocal"), "Alice's local revision preserved");
1205    }
1206
1207    // A revision WE pushed comes back stamped with our hosted username → links
1208    // (rule i), so pull does not duplicate it.
1209    #[test]
1210    fn pull_relinks_our_pushed_revision_after_lost_mirror() {
1211        let existing = vec![rev("rMine", "Alice <a@x>", "ship it", 40)];
1212        let server_revs = vec![rev("sMine", "alice <>", "ship it", 40)];
1213        let (new_revisions, links) = reconcile_revisions_pull(
1214            &existing,
1215            &server_revs,
1216            &[], // mirror lost
1217            Some("Alice <a@x>"),
1218            Some("alice"),
1219        );
1220        assert_eq!(new_revisions.len(), 1, "no duplicate of our own revision");
1221        assert_eq!(new_revisions[0].revision_id, "rMine", "local id preserved");
1222        assert_eq!(links[0].local, "rMine");
1223        assert_eq!(links[0].server, "sMine");
1224    }
1225
1226    #[test]
1227    fn reconcile_ok_rules() {
1228        // `hosted` is the server-stamped self attribution ("{username} <>"), as
1229        // `reconcile_revisions_pull` passes it.
1230        let hosted = Some("alice <>");
1231        let me = Some("Alice <a@x>");
1232        // (i) pushed by us: self-authored local + hosted-stamped server.
1233        assert!(reconcile_ok(
1234            &rev("l", "Alice <a@x>", "x", 1),
1235            &rev("s", "alice <>", "x", 999),
1236            hosted,
1237            me,
1238        ));
1239        // (i) fails when the server author is a DIFFERENT hosted user.
1240        assert!(!reconcile_ok(
1241            &rev("l", "Alice <a@x>", "x", 1),
1242            &rev("s", "bob <>", "x", 1),
1243            hosted,
1244            me,
1245        ));
1246        // (ii) pulled before: attribution + timestamp copied verbatim.
1247        assert!(reconcile_ok(
1248            &rev("l", "bob <>", "x", 7),
1249            &rev("s", "bob <>", "x", 7),
1250            hosted,
1251            me,
1252        ));
1253        // (ii) fails on a timestamp mismatch.
1254        assert!(!reconcile_ok(
1255            &rev("l", "bob <>", "x", 7),
1256            &rev("s", "bob <>", "x", 8),
1257            hosted,
1258            me,
1259        ));
1260    }
1261
1262    // P1-3: supersedes_annotation_id is a LOCAL id and must resolve to the SERVER
1263    // id through the mirror (both the pushed case, local != server, and the
1264    // pulled case, local == server).
1265    #[test]
1266    fn supersede_resolves_local_to_server_through_mirror() {
1267        let mut mirror = HostedContextMirror::default();
1268        // Pushed annotation: local uuid differs from the server id.
1269        get_or_create_entry(&mut mirror, "ns/repo", "local-old").server_id = "srv-old".into();
1270        // Pulled annotation: local id == server id.
1271        get_or_create_entry(&mut mirror, "ns/repo", "srv-pulled").server_id = "srv-pulled".into();
1272
1273        assert_eq!(
1274            server_id_for_local(&mirror, "ns/repo", "local-old"),
1275            Some("srv-old".to_string()),
1276        );
1277        assert_eq!(
1278            server_id_for_local(&mirror, "ns/repo", "srv-pulled"),
1279            Some("srv-pulled".to_string()),
1280        );
1281        // An in-flight create (empty server id) does not resolve.
1282        get_or_create_entry(&mut mirror, "ns/repo", "in-flight").pending_create_op =
1283            Some("op".into());
1284        assert_eq!(server_id_for_local(&mirror, "ns/repo", "in-flight"), None);
1285    }
1286
1287    // Minted-id recovery must not adopt a server id that is already linked to a
1288    // different local annotation (the re-link-safe / concurrent-writer guard).
1289    #[test]
1290    fn server_id_is_linked_detects_prior_links() {
1291        let mut mirror = HostedContextMirror::default();
1292        get_or_create_entry(&mut mirror, "ns/repo", "local-a").server_id = "srv-1".into();
1293        assert!(server_id_is_linked(&mirror, "ns/repo", "srv-1"));
1294        assert!(!server_id_is_linked(&mirror, "ns/repo", "srv-2"));
1295    }
1296}