Skip to main content

sqry_daemon_protocol/
revision.rs

1//! Revision-aware workspace wire types.
2//!
3//! The protocol crate is intentionally leaf-only, so these types describe
4//! selectors, resolved identities, handles, and daemon request/response
5//! payloads without depending on daemon internals or graph crates.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11/// Daemon-scoped stable identifier for a revision handle.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
13#[serde(transparent)]
14pub struct RevisionId(pub String);
15
16/// Opaque stable artifact identifier.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[serde(transparent)]
19pub struct ArtifactId(pub String);
20
21/// Digest of the manifest inputs that produced an [`ArtifactId`].
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields)]
24pub struct ArtifactInputDigest {
25    /// Manifest key schema version used to compute the digest.
26    pub schema_version: u32,
27    /// Hex-encoded digest over the canonical artifact manifest inputs.
28    pub digest: String,
29}
30
31/// Git object identifier format for the repository.
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum ObjectFormat {
35    /// SHA-1 Git object ids.
36    Sha1,
37    /// SHA-256 Git object ids.
38    Sha256,
39}
40
41/// Source bytes used while constructing a revision graph.
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum SourceByteMode {
45    /// Bytes are read directly from Git tree/blob objects.
46    RawGitObjects,
47    /// Bytes are read from a checkout and are keyed by checkout filters.
48    CheckoutBytes,
49    /// Bytes are captured from the dirty live workspace.
50    DirtySnapshot,
51}
52
53/// Caller-supplied revision selector.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
56pub enum RevisionSelector {
57    /// The currently loaded live workspace.
58    Live,
59    /// Point-in-time exact-byte dirty snapshot of the live workspace.
60    Dirty {
61        /// Include untracked files that are not ignored by Git.
62        include_untracked: bool,
63        /// Include ignored files. Defaults should keep this false.
64        include_ignored: bool,
65    },
66    /// Mutable Git ref, pinned to commit/tree at load time.
67    Ref {
68        /// Ref name or ref-like input accepted by the resolver.
69        name: String,
70    },
71    /// Immutable commit object id.
72    Commit {
73        /// Full object id in the repository object format.
74        oid: String,
75    },
76    /// Immutable tree object id.
77    Tree {
78        /// Full object id in the repository object format.
79        oid: String,
80    },
81    /// Explicit managed or operator-provided worktree.
82    Worktree {
83        /// Optional worktree root path.
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        path: Option<PathBuf>,
86        /// Optional daemon-managed worktree id.
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        worktree_id: Option<String>,
89    },
90}
91
92/// Redacted local repository identity carried on revision responses.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct RepositoryIdentity {
96    /// Stable daemon-local hash over repository identity inputs.
97    pub repo_identity_hash: String,
98    /// Git object id format for this repository.
99    pub object_format: ObjectFormat,
100    /// Optional redacted remote diagnostic fingerprint.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub remote_fingerprint: Option<String>,
103}
104
105/// Resolved immutable revision identity.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct ResolvedRevision {
109    /// Original caller selector.
110    pub selector: RevisionSelector,
111    /// Daemon-local repository identity.
112    pub repository: RepositoryIdentity,
113    /// Resolved commit object id when selector resolution has one.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub commit_oid: Option<String>,
116    /// Resolved tree object id.
117    pub tree_oid: String,
118    /// Git object id format.
119    pub object_format: ObjectFormat,
120    /// Source bytes used for graph construction.
121    pub source_byte_mode: SourceByteMode,
122    /// RFC3339 UTC timestamp when mutable selectors were pinned.
123    pub resolved_at: String,
124}
125
126/// Explicit query target for revision-aware daemon searches.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
129pub enum RevisionQueryTarget {
130    /// Query the currently loaded live workspace.
131    Live,
132    /// Query by resident revision handle.
133    RevisionId {
134        /// Loaded revision id.
135        revision_id: RevisionId,
136    },
137    /// Resolve and query this selector if a matching resident handle exists.
138    Selector {
139        /// Caller selector.
140        selector: RevisionSelector,
141    },
142}
143
144/// Revision metadata attached to revision-aware query responses.
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(deny_unknown_fields)]
147pub struct RevisionQueryMetadata {
148    /// Revision handle that served the query, absent for live legacy searches.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub revision_id: Option<RevisionId>,
151    /// Artifact that served the query.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub artifact_id: Option<ArtifactId>,
154    /// Fully resolved revision identity.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub resolved: Option<ResolvedRevision>,
157}
158
159/// Kind of resident revision handle.
160#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(rename_all = "snake_case")]
162pub enum ResidentHandleKind {
163    /// Existing live workspace handle.
164    LiveWorkspace,
165    /// Immutable Git tree/commit/ref graph.
166    ImmutableRevision,
167    /// Exact-byte dirty snapshot graph.
168    DirtySnapshot,
169    /// Explicit managed or operator worktree graph.
170    ManagedWorktree,
171}
172
173/// Resident revision lifecycle state.
174#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum RevisionLoadState {
177    /// Load has been accepted and is building.
178    Loading,
179    /// Graph is resident and queryable.
180    Loaded,
181    /// Load failed with a recorded diagnostic.
182    Failed,
183    /// Handle is currently being evicted.
184    Evicting,
185    /// Handle is no longer resident.
186    Unloaded,
187}
188
189/// Daemon status for one resident revision handle.
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(deny_unknown_fields)]
192pub struct RevisionStatus {
193    /// Resident revision id.
194    pub revision_id: RevisionId,
195    /// Handle kind.
196    pub handle_kind: ResidentHandleKind,
197    /// Resolved immutable identity.
198    pub resolved: ResolvedRevision,
199    /// Artifact backing this handle.
200    pub artifact_id: ArtifactId,
201    /// Manifest input digest for artifact verification.
202    pub artifact_inputs: ArtifactInputDigest,
203    /// Current lifecycle state.
204    pub state: RevisionLoadState,
205    /// Whether this handle is pinned against pruning/eviction.
206    pub pinned: bool,
207    /// Number of active queries using this handle.
208    pub active_queries: u64,
209    /// Estimated resident memory bytes.
210    pub memory_bytes: u64,
211    /// Last error associated with this handle, if any.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub last_error: Option<String>,
214}
215
216/// Request payload for `daemon/loadRevision`.
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(deny_unknown_fields)]
219pub struct LoadRevisionRequest {
220    /// Source root or repository root used to resolve the selector.
221    pub root: PathBuf,
222    /// Revision selector to load.
223    pub selector: RevisionSelector,
224    /// Source byte mode requested by the caller.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub source_byte_mode: Option<SourceByteMode>,
227    /// Pin the loaded handle against eviction/pruning.
228    #[serde(default)]
229    pub pin: bool,
230}
231
232/// Result payload for `daemon/loadRevision`.
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234#[serde(deny_unknown_fields)]
235pub struct LoadRevisionResult {
236    /// Loaded revision id.
237    pub revision_id: RevisionId,
238    /// Artifact backing the loaded handle.
239    pub artifact_id: ArtifactId,
240    /// Manifest input digest used to verify the artifact id.
241    pub artifact_inputs: ArtifactInputDigest,
242    /// Resolved revision identity.
243    pub resolved: ResolvedRevision,
244    /// Handle status after load.
245    pub status: RevisionStatus,
246}
247
248/// Request payload for `daemon/unloadRevision`.
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250#[serde(deny_unknown_fields)]
251pub struct UnloadRevisionRequest {
252    /// Resident revision id.
253    pub revision_id: RevisionId,
254    /// Force unload of a pinned handle.
255    #[serde(default)]
256    pub force: bool,
257}
258
259/// Result payload for `daemon/unloadRevision`.
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(deny_unknown_fields)]
262pub struct UnloadRevisionResult {
263    /// Resident revision id.
264    pub revision_id: RevisionId,
265    /// True when a resident handle was unloaded.
266    pub unloaded: bool,
267}
268
269/// Request payload for `daemon/listRevisions`.
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
271#[serde(deny_unknown_fields)]
272pub struct ListRevisionsRequest {
273    /// Optional repository root filter.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub root: Option<PathBuf>,
276    /// Include unloaded handles when the daemon retains status history.
277    #[serde(default)]
278    pub include_unloaded: bool,
279}
280
281/// Result payload for `daemon/listRevisions`.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(deny_unknown_fields)]
284pub struct ListRevisionsResult {
285    /// Matching revision statuses.
286    pub revisions: Vec<RevisionStatus>,
287}
288
289/// Request payload for `daemon/revisionStatus`.
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(deny_unknown_fields)]
292pub struct RevisionStatusRequest {
293    /// Resident revision id.
294    pub revision_id: RevisionId,
295}
296
297/// Request payload for `daemon/pruneRevisions`.
298#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
299#[serde(deny_unknown_fields)]
300pub struct PruneRevisionsRequest {
301    /// Optional repository root filter.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub root: Option<PathBuf>,
304    /// Actually remove prunable handles/artifacts.
305    #[serde(default)]
306    pub apply: bool,
307}
308
309/// One candidate returned by `daemon/pruneRevisions`.
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
311#[serde(deny_unknown_fields)]
312pub struct PruneRevisionCandidate {
313    /// Resident revision id.
314    pub revision_id: RevisionId,
315    /// Artifact that can be removed.
316    pub artifact_id: ArtifactId,
317    /// Bytes reclaimable if this candidate is pruned.
318    pub reclaimable_bytes: u64,
319    /// Human-readable reason.
320    pub reason: String,
321}
322
323/// One managed worktree candidate returned by `daemon/pruneRevisions`.
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325#[serde(deny_unknown_fields)]
326pub struct PruneWorktreeCandidate {
327    /// Managed worktree path.
328    pub path: PathBuf,
329    /// Bytes reclaimable if this worktree directory is removed.
330    pub reclaimable_bytes: u64,
331    /// Whether Git currently marks this worktree as locked.
332    pub locked: bool,
333    /// Human-readable reason.
334    pub reason: String,
335}
336
337/// One artifact or worktree that was considered but refused for pruning.
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339#[serde(deny_unknown_fields)]
340pub struct PruneRefusal {
341    /// Artifact protected from deletion, when applicable.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub artifact_id: Option<ArtifactId>,
344    /// Worktree protected from deletion, when applicable.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub worktree_path: Option<PathBuf>,
347    /// Human-readable refusal reason.
348    pub reason: String,
349}
350
351/// Result payload for `daemon/pruneRevisions`.
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(deny_unknown_fields)]
354pub struct PruneRevisionsResult {
355    /// Candidate list, populated for dry-run and apply.
356    pub candidates: Vec<PruneRevisionCandidate>,
357    /// Managed worktree candidates, populated for dry-run and apply.
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub worktree_candidates: Vec<PruneWorktreeCandidate>,
360    /// Artifacts or worktrees refused for pruning.
361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
362    pub refusals: Vec<PruneRefusal>,
363    /// Whether removal was applied.
364    pub applied: bool,
365    /// Total bytes reclaimed by this invocation.
366    pub reclaimed_bytes: u64,
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn sample_repo() -> RepositoryIdentity {
374        RepositoryIdentity {
375            repo_identity_hash: "repo-hash".to_owned(),
376            object_format: ObjectFormat::Sha1,
377            remote_fingerprint: Some("remote:redacted".to_owned()),
378        }
379    }
380
381    fn sample_resolved(selector: RevisionSelector) -> ResolvedRevision {
382        ResolvedRevision {
383            selector,
384            repository: sample_repo(),
385            commit_oid: Some("1111111111111111111111111111111111111111".to_owned()),
386            tree_oid: "2222222222222222222222222222222222222222".to_owned(),
387            object_format: ObjectFormat::Sha1,
388            source_byte_mode: SourceByteMode::RawGitObjects,
389            resolved_at: "2026-06-26T00:00:00Z".to_owned(),
390        }
391    }
392
393    fn sample_status() -> RevisionStatus {
394        let resolved = sample_resolved(RevisionSelector::Ref {
395            name: "main".to_owned(),
396        });
397        RevisionStatus {
398            revision_id: RevisionId("rev-1".to_owned()),
399            handle_kind: ResidentHandleKind::ImmutableRevision,
400            resolved,
401            artifact_id: ArtifactId("artifact-1".to_owned()),
402            artifact_inputs: ArtifactInputDigest {
403                schema_version: 1,
404                digest: "digest-1".to_owned(),
405            },
406            state: RevisionLoadState::Loaded,
407            pinned: false,
408            active_queries: 0,
409            memory_bytes: 128,
410            last_error: None,
411        }
412    }
413
414    #[test]
415    fn revision_selector_round_trips_all_variants() {
416        let selectors = [
417            RevisionSelector::Live,
418            RevisionSelector::Dirty {
419                include_untracked: true,
420                include_ignored: false,
421            },
422            RevisionSelector::Ref {
423                name: "refs/heads/main".to_owned(),
424            },
425            RevisionSelector::Commit {
426                oid: "a".repeat(40),
427            },
428            RevisionSelector::Tree {
429                oid: "b".repeat(40),
430            },
431            RevisionSelector::Worktree {
432                path: Some(PathBuf::from("/repo-agent")),
433                worktree_id: Some("agent-1".to_owned()),
434            },
435        ];
436
437        for selector in selectors {
438            let wire = serde_json::to_string(&selector).expect("serialize selector");
439            let back: RevisionSelector = serde_json::from_str(&wire).expect("deserialize selector");
440            assert_eq!(back, selector);
441        }
442    }
443
444    #[test]
445    fn resolved_revision_round_trips_with_identity_and_source_mode() {
446        let resolved = sample_resolved(RevisionSelector::Commit {
447            oid: "a".repeat(40),
448        });
449        let wire = serde_json::to_string(&resolved).expect("serialize");
450        assert!(wire.contains(r#""source_byte_mode":"raw_git_objects""#));
451        assert!(wire.contains(r#""object_format":"sha1""#));
452        let back: ResolvedRevision = serde_json::from_str(&wire).expect("deserialize");
453        assert_eq!(back, resolved);
454    }
455
456    #[test]
457    fn artifact_id_is_opaque_but_inputs_are_manifest_verifiable() {
458        let id = ArtifactId("repo/artifact-key".to_owned());
459        let digest = ArtifactInputDigest {
460            schema_version: 1,
461            digest: "canonical-manifest-sha256".to_owned(),
462        };
463        let wire = serde_json::json!({
464            "artifact_id": id,
465            "artifact_inputs": digest,
466        });
467        assert_eq!(wire["artifact_id"], "repo/artifact-key");
468        assert_eq!(
469            wire["artifact_inputs"]["digest"],
470            "canonical-manifest-sha256"
471        );
472    }
473
474    #[test]
475    fn status_and_load_result_round_trip() {
476        let status = sample_status();
477        let result = LoadRevisionResult {
478            revision_id: status.revision_id.clone(),
479            artifact_id: status.artifact_id.clone(),
480            artifact_inputs: status.artifact_inputs.clone(),
481            resolved: status.resolved.clone(),
482            status,
483        };
484        let wire = serde_json::to_string(&result).expect("serialize");
485        let back: LoadRevisionResult = serde_json::from_str(&wire).expect("deserialize");
486        assert_eq!(back, result);
487    }
488
489    #[test]
490    fn list_status_and_prune_requests_round_trip() {
491        let list = ListRevisionsRequest {
492            root: Some(PathBuf::from("/repo")),
493            include_unloaded: true,
494        };
495        let wire = serde_json::to_string(&list).expect("serialize list");
496        let back: ListRevisionsRequest = serde_json::from_str(&wire).expect("deserialize list");
497        assert_eq!(back, list);
498
499        let prune = PruneRevisionsRequest {
500            root: Some(PathBuf::from("/repo")),
501            apply: false,
502        };
503        let wire = serde_json::to_string(&prune).expect("serialize prune");
504        let back: PruneRevisionsRequest = serde_json::from_str(&wire).expect("deserialize prune");
505        assert_eq!(back, prune);
506    }
507
508    #[test]
509    fn revision_query_target_round_trips_without_path_leak() {
510        let target = RevisionQueryTarget::RevisionId {
511            revision_id: RevisionId("rev-1".to_owned()),
512        };
513        let wire = serde_json::to_string(&target).expect("serialize");
514        assert!(!wire.contains("/"));
515        let back: RevisionQueryTarget = serde_json::from_str(&wire).expect("deserialize");
516        assert_eq!(back, target);
517    }
518}