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