From e8181d1e781a98accadd3cfb2c8ce5f1f3de43d1 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Mon, 16 Feb 2026 23:40:19 +0800 Subject: [PATCH 1/5] Apply AI object and context fixes Signed-off-by: Quanyi Ma --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/errors.rs | 4 + src/internal/object/blob.rs | 7 +- src/internal/object/context.rs | 12 +++ src/internal/object/intent.rs | 163 ++++++++++++++++++++++++++++++ src/internal/object/mod.rs | 6 ++ src/internal/object/patchset.rs | 16 +++ src/internal/object/plan.rs | 16 +++ src/internal/object/provenance.rs | 73 +++++++++++-- src/internal/object/task.rs | 11 ++ src/internal/object/types.rs | 53 +++++++++- src/internal/pack/decode.rs | 1 + 13 files changed, 352 insertions(+), 14 deletions(-) create mode 100644 src/internal/object/intent.rs diff --git a/Cargo.lock b/Cargo.lock index be06cc8e..394b13c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.5.0" +version = "0.5.1" dependencies = [ "ahash 0.8.12", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1cfd865a..5848ca8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-internal" -version = "0.5.0" +version = "0.5.1" edition = "2024" license = "MIT" description = "Git-Internal is a high-performance Rust library for encoding and decoding Git internal objects and Pack files." diff --git a/src/errors.rs b/src/errors.rs index 987f451d..d564c5f9 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -90,6 +90,10 @@ pub enum GitError { #[error("Not a valid agent task object: {0}")] InvalidTaskObject(String), + /// Malformed intent object. + #[error("Not a valid agent intent object: {0}")] + InvalidIntentObject(String), + /// Malformed tool invocation object. #[error("Not a valid agent tool invocation object: {0}")] InvalidToolInvocationObject(String), diff --git a/src/internal/object/blob.rs b/src/internal/object/blob.rs index b500f64f..f9b3f4cb 100644 --- a/src/internal/object/blob.rs +++ b/src/internal/object/blob.rs @@ -105,7 +105,10 @@ impl Blob { #[cfg(test)] mod tests { use super::*; - use crate::hash::{HashKind, set_hash_kind_for_test}; + use crate::{ + hash::{HashKind, set_hash_kind_for_test}, + internal::object::ObjectTrait, + }; /// Test creating a Blob from content string #[test] @@ -117,6 +120,8 @@ mod tests { blob.id.to_string(), "5dd01c177f5d7d1be5346a5bc18a569a7410c2ef" ); + let hash_from_trait = blob.object_hash().unwrap(); + assert_eq!(hash_from_trait.to_string(), blob.id.to_string()); } /// Test creating a Blob from content string using SHA-256 hash algorithm. diff --git a/src/internal/object/context.rs b/src/internal/object/context.rs index 38248618..c90afe66 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -44,6 +44,15 @@ pub enum SelectionStrategy { pub enum ContextItemKind { /// A regular file in the repository. File, + /// A URL (web page, API endpoint, etc.). + Url, + /// A free-form text snippet (e.g. doc fragment, note). + Snippet, + /// Command or terminal output. + Command, + /// Image or other binary visual content. + Image, + Other(String), } /// Context item describing a single input. @@ -52,6 +61,8 @@ pub struct ContextItem { pub kind: ContextItemKind, pub path: String, pub content_id: IntegrityHash, + #[serde(default)] + pub content_preview: Option, } impl ContextItem { @@ -68,6 +79,7 @@ impl ContextItem { kind, path, content_id, + content_preview: None, }) } } diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs new file mode 100644 index 00000000..b5142eaa --- /dev/null +++ b/src/internal/object/intent.rs @@ -0,0 +1,163 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + integrity::IntegrityHash, + types::{ActorRef, Header, ObjectType}, + }, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum IntentStatus { + Draft, + Active, + Completed, + Cancelled, +} + +impl IntentStatus { + pub fn as_str(&self) -> &'static str { + match self { + IntentStatus::Draft => "draft", + IntentStatus::Active => "active", + IntentStatus::Completed => "completed", + IntentStatus::Cancelled => "cancelled", + } + } +} + +impl fmt::Display for IntentStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Intent { + #[serde(flatten)] + header: Header, + content: String, + parent_id: Option, + root_id: Option, + task_id: Option, + result_commit_sha: Option, + status: IntentStatus, +} + +impl Intent { + pub fn new( + repo_id: Uuid, + created_by: ActorRef, + content: impl Into, + ) -> Result { + Ok(Self { + header: Header::new(ObjectType::Intent, repo_id, created_by)?, + content: content.into(), + parent_id: None, + root_id: None, + task_id: None, + result_commit_sha: None, + status: IntentStatus::Draft, + }) + } + + pub fn header(&self) -> &Header { + &self.header + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn parent_id(&self) -> Option { + self.parent_id + } + + pub fn root_id(&self) -> Option { + self.root_id + } + + pub fn task_id(&self) -> Option { + self.task_id + } + + pub fn result_commit_sha(&self) -> Option<&IntegrityHash> { + self.result_commit_sha.as_ref() + } + + pub fn status(&self) -> &IntentStatus { + &self.status + } + + pub fn set_parent_id(&mut self, parent_id: Option) { + self.parent_id = parent_id; + } + + pub fn set_root_id(&mut self, root_id: Option) { + self.root_id = root_id; + } + + pub fn set_task_id(&mut self, task_id: Option) { + self.task_id = task_id; + } + + pub fn set_result_commit_sha(&mut self, sha: Option) { + self.result_commit_sha = sha; + } + + pub fn set_status(&mut self, status: IntentStatus) { + self.status = status; + } +} + +impl fmt::Display for Intent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Intent: {}", self.header.object_id()) + } +} + +impl ObjectTrait for Intent { + fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result + where + Self: Sized, + { + serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string())) + } + + fn get_type(&self) -> ObjectType { + ObjectType::Intent + } + + fn get_size(&self) -> usize { + serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + } + + fn to_data(&self) -> Result, GitError> { + serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_intent_creation() { + let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); + let actor = ActorRef::human("jackie").expect("actor"); + let intent = Intent::new(repo_id, actor, "Refactor login flow").expect("intent"); + + assert_eq!(intent.header().object_type(), &ObjectType::Intent); + assert_eq!(intent.status(), &IntentStatus::Draft); + assert!(intent.parent_id().is_none()); + assert!(intent.root_id().is_none()); + assert!(intent.task_id().is_none()); + } +} diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index a6aad8cc..56020a49 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -6,6 +6,7 @@ pub mod context; pub mod decision; pub mod evidence; pub mod integrity; +pub mod intent; pub mod note; pub mod patchset; pub mod plan; @@ -58,4 +59,9 @@ pub trait ObjectTrait: Send + Sync + Display { fn get_size(&self) -> usize; fn to_data(&self) -> Result, GitError>; + + fn object_hash(&self) -> Result { + let data = self.to_data()?; + Ok(ObjectHash::from_type_and_data(self.get_type(), &data)) + } } diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index 0832ac6d..524d0d94 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -124,6 +124,8 @@ pub struct PatchSet { diff_artifact: Option, #[serde(default)] touched_files: Vec, + #[serde(default)] + supersedes_patchset_ids: Vec, rationale: Option, apply_status: ApplyStatus, } @@ -146,6 +148,7 @@ impl PatchSet { diff_format: DiffFormat::UnifiedDiff, diff_artifact: None, touched_files: Vec::new(), + supersedes_patchset_ids: Vec::new(), rationale: None, apply_status: ApplyStatus::Proposed, }) @@ -179,6 +182,10 @@ impl PatchSet { &self.touched_files } + pub fn supersedes_patchset_ids(&self) -> &[Uuid] { + &self.supersedes_patchset_ids + } + pub fn rationale(&self) -> Option<&str> { self.rationale.as_deref() } @@ -202,6 +209,14 @@ impl PatchSet { pub fn set_apply_status(&mut self, apply_status: ApplyStatus) { self.apply_status = apply_status; } + + pub fn add_supersedes_patchset_id(&mut self, patchset_id: Uuid) { + self.supersedes_patchset_ids.push(patchset_id); + } + + pub fn set_supersedes_patchset_ids(&mut self, patchset_ids: Vec) { + self.supersedes_patchset_ids = patchset_ids; + } } impl fmt::Display for PatchSet { @@ -253,5 +268,6 @@ mod tests { assert_eq!(patchset.diff_format(), &DiffFormat::UnifiedDiff); assert_eq!(patchset.apply_status(), &ApplyStatus::Proposed); assert!(patchset.touched_files().is_empty()); + assert!(patchset.supersedes_patchset_ids().is_empty()); } } diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index cf0be4fb..caad7366 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -81,6 +81,8 @@ pub struct Plan { /// Plan version starts at 1 and must increase by 1 for each update. plan_version: u32, #[serde(default)] + previous_plan_id: Option, + #[serde(default)] steps: Vec, } @@ -91,6 +93,7 @@ impl Plan { header: Header::new(ObjectType::Plan, repo_id, created_by)?, run_id, plan_version: 1, + previous_plan_id: None, steps: Vec::new(), }) } @@ -112,6 +115,7 @@ impl Plan { header: Header::new(ObjectType::Plan, repo_id, created_by)?, run_id, plan_version: next_version, + previous_plan_id: None, steps: Vec::new(), }) } @@ -128,6 +132,10 @@ impl Plan { self.plan_version } + pub fn previous_plan_id(&self) -> Option { + self.previous_plan_id + } + pub fn steps(&self) -> &[PlanStep] { &self.steps } @@ -135,6 +143,10 @@ impl Plan { pub fn add_step(&mut self, step: PlanStep) { self.steps.push(step); } + + pub fn set_previous_plan_id(&mut self, previous_plan_id: Option) { + self.previous_plan_id = previous_plan_id; + } } impl fmt::Display for Plan { @@ -189,5 +201,9 @@ mod tests { assert!(plan_v3.plan_version() > plan_v2.plan_version()); assert!(plan_v2.plan_version() > plan_v1.plan_version()); + + assert!(plan_v1.previous_plan_id().is_none()); + assert!(plan_v2.previous_plan_id().is_none()); + assert!(plan_v3.previous_plan_id().is_none()); } } diff --git a/src/internal/object/provenance.rs b/src/internal/object/provenance.rs index 52d1e0d3..b95cb9aa 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -24,6 +24,15 @@ use crate::{ }, }; +/// Normalized token usage across providers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TokenUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cost_usd: Option, +} + /// Provenance object for model/provider metadata. /// Captures model/provider settings and usage. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,8 +42,14 @@ pub struct Provenance { run_id: Uuid, provider: String, model: String, + #[serde(default)] parameters: Option, - token_usage: Option, + #[serde(default)] + temperature: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + token_usage: Option, } impl Provenance { @@ -51,6 +66,8 @@ impl Provenance { provider: provider.into(), model: model.into(), parameters: None, + temperature: None, + max_tokens: None, token_usage: None, }) } @@ -71,11 +88,32 @@ impl Provenance { &self.model } + /// Provider-specific raw parameters payload. pub fn parameters(&self) -> Option<&serde_json::Value> { self.parameters.as_ref() } - pub fn token_usage(&self) -> Option<&serde_json::Value> { + /// Normalized temperature if available. + pub fn temperature(&self) -> Option { + self.temperature.or_else(|| { + self.parameters + .as_ref() + .and_then(|p| p.get("temperature")) + .and_then(|v| v.as_f64()) + }) + } + + /// Normalized max_tokens if available. + pub fn max_tokens(&self) -> Option { + self.max_tokens.or_else(|| { + self.parameters + .as_ref() + .and_then(|p| p.get("max_tokens")) + .and_then(|v| v.as_u64()) + }) + } + + pub fn token_usage(&self) -> Option<&TokenUsage> { self.token_usage.as_ref() } @@ -83,7 +121,15 @@ impl Provenance { self.parameters = parameters; } - pub fn set_token_usage(&mut self, token_usage: Option) { + pub fn set_temperature(&mut self, temperature: Option) { + self.temperature = temperature; + } + + pub fn set_max_tokens(&mut self, max_tokens: Option) { + self.max_tokens = max_tokens; + } + + pub fn set_token_usage(&mut self, token_usage: Option) { self.token_usage = token_usage; } } @@ -127,10 +173,25 @@ mod tests { let mut provenance = Provenance::new(repo_id, actor, run_id, "openai", "gpt-4").expect("provenance"); - provenance.set_parameters(Some(serde_json::json!({"temperature": 0.2}))); - provenance.set_token_usage(Some(serde_json::json!({"input": 10, "output": 5}))); + provenance.set_parameters(Some( + serde_json::json!({"temperature": 0.2, "max_tokens": 128}), + )); + provenance.set_temperature(Some(0.2)); + provenance.set_max_tokens(Some(128)); + provenance.set_token_usage(Some(TokenUsage { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + cost_usd: Some(0.001), + })); assert!(provenance.parameters().is_some()); - assert!(provenance.token_usage().is_some()); + assert_eq!(provenance.temperature(), Some(0.2)); + assert_eq!(provenance.max_tokens(), Some(128)); + let usage = provenance.token_usage().expect("token usage"); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.output_tokens, 5); + assert_eq!(usage.total_tokens, 15); + assert_eq!(usage.cost_usd, Some(0.001)); } } diff --git a/src/internal/object/task.rs b/src/internal/object/task.rs index f541a6e3..eb1693c7 100644 --- a/src/internal/object/task.rs +++ b/src/internal/object/task.rs @@ -157,6 +157,7 @@ pub struct Task { #[serde(default)] acceptance_criteria: Vec, requested_by: Option, + intent_id: Option, #[serde(default)] dependencies: Vec, status: TaskStatus, @@ -184,6 +185,7 @@ impl Task { constraints: Vec::new(), acceptance_criteria: Vec::new(), requested_by: None, + intent_id: None, dependencies: Vec::new(), status: TaskStatus::Draft, }) @@ -217,6 +219,10 @@ impl Task { self.requested_by.as_ref() } + pub fn intent_id(&self) -> Option { + self.intent_id + } + pub fn dependencies(&self) -> &[Uuid] { &self.dependencies } @@ -241,6 +247,10 @@ impl Task { self.requested_by = requested_by; } + pub fn set_intent_id(&mut self, intent_id: Option) { + self.intent_id = intent_id; + } + pub fn add_dependency(&mut self, task_id: Uuid) { self.dependencies.push(task_id); } @@ -297,6 +307,7 @@ mod tests { assert_eq!(task.goal_type(), Some(&GoalType::Bugfix)); assert_eq!(task.dependencies().len(), 1); assert_eq!(task.dependencies()[0], dep_id); + assert!(task.intent_id().is_none()); } #[test] diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index c5be1ef5..f9959ae6 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -62,6 +62,7 @@ pub enum ObjectType { Provenance, Run, Task, + Intent, ToolInvocation, } @@ -77,6 +78,7 @@ const PLAN_OBJECT_TYPE: &[u8] = b"plan"; const PROVENANCE_OBJECT_TYPE: &[u8] = b"provenance"; const RUN_OBJECT_TYPE: &[u8] = b"run"; const TASK_OBJECT_TYPE: &[u8] = b"task"; +const INTENT_OBJECT_TYPE: &[u8] = b"intent"; const TOOL_INVOCATION_OBJECT_TYPE: &[u8] = b"invocation"; /// Display trait for Git objects type @@ -98,6 +100,7 @@ impl Display for ObjectType { ObjectType::Provenance => write!(f, "provenance"), ObjectType::Run => write!(f, "run"), ObjectType::Task => write!(f, "task"), + ObjectType::Intent => write!(f, "intent"), ObjectType::ToolInvocation => write!(f, "invocation"), } } @@ -156,6 +159,7 @@ impl ObjectType { ObjectType::Provenance => PROVENANCE_OBJECT_TYPE, ObjectType::Run => RUN_OBJECT_TYPE, ObjectType::Task => TASK_OBJECT_TYPE, + ObjectType::Intent => INTENT_OBJECT_TYPE, ObjectType::ToolInvocation => TOOL_INVOCATION_OBJECT_TYPE, _ => panic!("can put compute the delta hash value"), } @@ -176,6 +180,7 @@ impl ObjectType { "provenance" => Ok(ObjectType::Provenance), "run" => Ok(ObjectType::Run), "task" => Ok(ObjectType::Task), + "intent" => Ok(ObjectType::Intent), "invocation" => Ok(ObjectType::ToolInvocation), _ => Err(GitError::InvalidObjectType(s.to_string())), } @@ -198,6 +203,7 @@ impl ObjectType { ]), // provenance ObjectType::Run => Ok(vec![0x72, 0x75, 0x6e]), // run ObjectType::Task => Ok(vec![0x74, 0x61, 0x73, 0x6b]), // task + ObjectType::Intent => Ok(vec![0x69, 0x6e, 0x74, 0x65, 0x6e, 0x74]), // intent ObjectType::ToolInvocation => Ok(vec![ 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, ]), // invocation @@ -223,7 +229,8 @@ impl ObjectType { ObjectType::Provenance => 13, ObjectType::Run => 14, ObjectType::Task => 15, - ObjectType::ToolInvocation => 16, + ObjectType::Intent => 16, + ObjectType::ToolInvocation => 17, } } @@ -245,7 +252,8 @@ impl ObjectType { 13 => Ok(ObjectType::Provenance), 14 => Ok(ObjectType::Run), 15 => Ok(ObjectType::Task), - 16 => Ok(ObjectType::ToolInvocation), + 16 => Ok(ObjectType::Intent), + 17 => Ok(ObjectType::ToolInvocation), _ => Err(GitError::InvalidObjectType(format!( "Invalid object type number: {number}" ))), @@ -269,6 +277,7 @@ impl ObjectType { ObjectType::Provenance => true, ObjectType::Run => true, ObjectType::Task => true, + ObjectType::Intent => true, ObjectType::ToolInvocation => true, } } @@ -546,6 +555,10 @@ impl ArtifactRef { /// // specific fields... /// } /// ``` +fn default_updated_at() -> DateTime { + Utc::now() +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Header { /// Global unique ID (UUID v7) @@ -558,6 +571,8 @@ pub struct Header { repo_id: Uuid, /// Creation time created_at: DateTime, + #[serde(default = "default_updated_at")] + updated_at: DateTime, /// Creator created_by: ActorRef, /// Visibility (fixed to private for Libra) @@ -586,12 +601,14 @@ impl Header { repo_id: Uuid, created_by: ActorRef, ) -> Result { + let now = Utc::now(); Ok(Self { object_id: Uuid::now_v7(), object_type, schema_version: 1, repo_id, - created_at: Utc::now(), + created_at: now, + updated_at: now, created_by, visibility: Visibility::Private, tags: HashMap::new(), @@ -620,6 +637,10 @@ impl Header { self.created_at } + pub fn updated_at(&self) -> DateTime { + self.updated_at + } + pub fn created_by(&self) -> &ActorRef { &self.created_by } @@ -665,6 +686,10 @@ impl Header { self.created_at = created_at; } + pub fn set_updated_at(&mut self, updated_at: DateTime) { + self.updated_at = updated_at; + } + pub fn set_visibility(&mut self, visibility: Visibility) { self.visibility = visibility; } @@ -679,14 +704,17 @@ impl Header { /// /// This is typically called just before storing the object to ensure `checksum` matches content. pub fn seal(&mut self, object: &T) -> Result<(), serde_json::Error> { - let previous = self.checksum.take(); + let previous_checksum = self.checksum.take(); + let previous_updated_at = self.updated_at; match compute_integrity_hash(object) { Ok(checksum) => { self.checksum = Some(checksum); + self.updated_at = Utc::now(); Ok(()) } Err(err) => { - self.checksum = previous; + self.checksum = previous_checksum; + self.updated_at = previous_updated_at; Err(err) } } @@ -886,6 +914,21 @@ mod tests { assert_eq!(header.checksum().expect("checksum"), &expected); } + #[test] + fn test_header_updated_at_on_seal() { + let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); + let actor = ActorRef::human("jackie").expect("actor"); + let mut header = Header::new(ObjectType::Task, repo_id, actor).expect("header"); + + let before = header.updated_at(); + let content = serde_json::json!({"key": "value"}); + + header.seal(&content).expect("seal"); + + let after = header.updated_at(); + assert!(after >= before); + } + #[test] fn test_empty_actor_id() { let result = ActorRef::new(ActorKind::Human, " "); diff --git a/src/internal/pack/decode.rs b/src/internal/pack/decode.rs index 765d1fb7..c8174b18 100644 --- a/src/internal/pack/decode.rs +++ b/src/internal/pack/decode.rs @@ -333,6 +333,7 @@ impl Pack { | ObjectType::Provenance | ObjectType::Run | ObjectType::Task + | ObjectType::Intent | ObjectType::ToolInvocation => { // Wait for encode to implement corresponding compression Ok(None) From cb7a42608cec2b97237a1fb98bbae362c530b18e Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Tue, 17 Feb 2026 00:14:54 +0800 Subject: [PATCH 2/5] ai-objects: apply review feedback and tighten invariants Signed-off-by: Quanyi Ma --- src/internal/object/context.rs | 11 ++++++++- src/internal/object/decision.rs | 8 +++++- src/internal/object/evidence.rs | 8 +++++- src/internal/object/intent.rs | 8 +++++- src/internal/object/mod.rs | 4 +++ src/internal/object/patchset.rs | 41 ++++++++++++++++++++++++++++++- src/internal/object/plan.rs | 35 +++++++++++++++++--------- src/internal/object/provenance.rs | 22 ++++++++++++++++- src/internal/object/run.rs | 8 +++++- src/internal/object/task.rs | 8 +++++- src/internal/object/tool.rs | 8 +++++- src/internal/object/types.rs | 9 +++---- 12 files changed, 145 insertions(+), 25 deletions(-) diff --git a/src/internal/object/context.rs b/src/internal/object/context.rs index c90afe66..8e275645 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -61,6 +61,9 @@ pub struct ContextItem { pub kind: ContextItemKind, pub path: String, pub content_id: IntegrityHash, + /// Optional preview/summary of the content (for example, first 200 characters). + /// Used for display without loading the full content via `content_id`. + /// Should be kept under 500 characters for performance. #[serde(default)] pub content_preview: Option, } @@ -162,7 +165,13 @@ impl ObjectTrait for ContextSnapshot { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute ContextSnapshot size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/decision.rs b/src/internal/object/decision.rs index a0ffbdec..1cc81a37 100644 --- a/src/internal/object/decision.rs +++ b/src/internal/object/decision.rs @@ -180,7 +180,13 @@ impl ObjectTrait for Decision { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Decision size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/evidence.rs b/src/internal/object/evidence.rs index 886c2601..1279ff5d 100644 --- a/src/internal/object/evidence.rs +++ b/src/internal/object/evidence.rs @@ -184,7 +184,13 @@ impl ObjectTrait for Evidence { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Evidence size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index b5142eaa..1087381e 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -136,7 +136,13 @@ impl ObjectTrait for Intent { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Intent size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index 56020a49..e75d80e9 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -60,6 +60,10 @@ pub trait ObjectTrait: Send + Sync + Display { fn to_data(&self) -> Result, GitError>; + /// Computes the object hash from serialized data. + /// + /// Default implementation serializes the object and computes the hash from that data. + /// Override only if you need custom hash computation or caching. fn object_hash(&self) -> Result { let data = self.to_data()?; Ok(ObjectHash::from_type_and_data(self.get_type(), &data)) diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index 524d0d94..78003e35 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -217,6 +217,18 @@ impl PatchSet { pub fn set_supersedes_patchset_ids(&mut self, patchset_ids: Vec) { self.supersedes_patchset_ids = patchset_ids; } + + pub fn validate_supersedes(&self) -> Result<(), GitError> { + if self + .supersedes_patchset_ids + .contains(&self.header.object_id()) + { + return Err(GitError::InvalidPatchSetObject( + "PatchSet cannot supersede itself".to_string(), + )); + } + Ok(()) + } } impl fmt::Display for PatchSet { @@ -238,7 +250,13 @@ impl ObjectTrait for PatchSet { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute PatchSet size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { @@ -270,4 +288,25 @@ mod tests { assert!(patchset.touched_files().is_empty()); assert!(patchset.supersedes_patchset_ids().is_empty()); } + + #[test] + fn test_patchset_validate_supersedes_self_reference() { + let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); + let actor = ActorRef::agent("test-agent").expect("actor"); + let run_id = Uuid::from_u128(0x1); + let base_hash = test_hash_hex(); + + let mut patchset = + PatchSet::new(repo_id, actor, run_id, &base_hash, 1).expect("patchset"); + let self_id = patchset.header().object_id(); + patchset.add_supersedes_patchset_id(self_id); + + let err = patchset.validate_supersedes().expect_err("should be invalid"); + match err { + GitError::InvalidPatchSetObject(msg) => { + assert!(msg.contains("supersede"), "unexpected message: {msg}"); + } + other => panic!("unexpected error: {other}"), + } + } } diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index caad7366..cff5fbb8 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -100,22 +100,27 @@ impl Plan { /// Create the next version of a plan. /// + /// Links the new plan back to the previous one via `previous_plan_id`. + /// /// # Arguments - /// * `previous_version` - The version number of the plan being updated. + /// * `repo_id` - Repository the plan belongs to. + /// * `created_by` - Actor creating the new version. + /// * `run_id` - Run this plan is associated with. pub fn new_next( + &self, repo_id: Uuid, created_by: ActorRef, run_id: Uuid, - previous_version: u32, ) -> Result { - let next_version = previous_version + let next_version = self + .plan_version .checked_add(1) .ok_or_else(|| "plan_version overflow".to_string())?; Ok(Self { header: Header::new(ObjectType::Plan, repo_id, created_by)?, run_id, plan_version: next_version, - previous_plan_id: None, + previous_plan_id: Some(self.header.object_id()), steps: Vec::new(), }) } @@ -168,7 +173,13 @@ impl ObjectTrait for Plan { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Plan size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { @@ -187,10 +198,12 @@ mod tests { let run_id = Uuid::from_u128(0x1); let plan_v1 = Plan::new(repo_id, actor.clone(), run_id).expect("plan"); - let plan_v2 = - Plan::new_next(repo_id, actor.clone(), run_id, plan_v1.plan_version()).expect("plan"); - let plan_v3 = - Plan::new_next(repo_id, actor.clone(), run_id, plan_v2.plan_version()).expect("plan"); + let plan_v2 = plan_v1 + .new_next(repo_id, actor.clone(), run_id) + .expect("plan"); + let plan_v3 = plan_v2 + .new_next(repo_id, actor.clone(), run_id) + .expect("plan"); let mut plans = [plan_v2.clone(), plan_v1.clone(), plan_v3.clone()]; plans.sort_by_key(|plan| plan.plan_version()); @@ -203,7 +216,7 @@ mod tests { assert!(plan_v2.plan_version() > plan_v1.plan_version()); assert!(plan_v1.previous_plan_id().is_none()); - assert!(plan_v2.previous_plan_id().is_none()); - assert!(plan_v3.previous_plan_id().is_none()); + assert_eq!(plan_v2.previous_plan_id(), Some(plan_v1.header().object_id())); + assert_eq!(plan_v3.previous_plan_id(), Some(plan_v2.header().object_id())); } } diff --git a/src/internal/object/provenance.rs b/src/internal/object/provenance.rs index b95cb9aa..80e1495d 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -33,6 +33,20 @@ pub struct TokenUsage { pub cost_usd: Option, } +impl TokenUsage { + pub fn is_consistent(&self) -> bool { + self.total_tokens == self.input_tokens + self.output_tokens + } + + pub fn cost_per_token(&self) -> Option { + if self.total_tokens == 0 { + return None; + } + self.cost_usd + .map(|cost| cost / self.total_tokens as f64) + } +} + /// Provenance object for model/provider metadata. /// Captures model/provider settings and usage. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -153,7 +167,13 @@ impl ObjectTrait for Provenance { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Provenance size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/run.rs b/src/internal/object/run.rs index 38763491..071ff977 100644 --- a/src/internal/object/run.rs +++ b/src/internal/object/run.rs @@ -227,7 +227,13 @@ impl ObjectTrait for Run { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Run size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/task.rs b/src/internal/object/task.rs index eb1693c7..6f48ea4a 100644 --- a/src/internal/object/task.rs +++ b/src/internal/object/task.rs @@ -279,7 +279,13 @@ impl ObjectTrait for Task { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute Task size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/tool.rs b/src/internal/object/tool.rs index b64a9c66..912a109b 100644 --- a/src/internal/object/tool.rs +++ b/src/internal/object/tool.rs @@ -173,7 +173,13 @@ impl ObjectTrait for ToolInvocation { } fn get_size(&self) -> usize { - serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0) + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute ToolInvocation size: {}", e); + 0 + } + } } fn to_data(&self) -> Result, GitError> { diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index f9959ae6..34444851 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -539,6 +539,10 @@ impl ArtifactRef { } } +fn default_updated_at() -> DateTime { + Utc::now() +} + /// Header shared by all AI Process Objects. /// /// Contains standard metadata like ID, type, creator, and timestamps. @@ -555,9 +559,6 @@ impl ArtifactRef { /// // specific fields... /// } /// ``` -fn default_updated_at() -> DateTime { - Utc::now() -} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Header { @@ -705,7 +706,6 @@ impl Header { /// This is typically called just before storing the object to ensure `checksum` matches content. pub fn seal(&mut self, object: &T) -> Result<(), serde_json::Error> { let previous_checksum = self.checksum.take(); - let previous_updated_at = self.updated_at; match compute_integrity_hash(object) { Ok(checksum) => { self.checksum = Some(checksum); @@ -714,7 +714,6 @@ impl Header { } Err(err) => { self.checksum = previous_checksum; - self.updated_at = previous_updated_at; Err(err) } } From 8656d439d4924618d152e8cb50f84d9a7ce46aac Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Tue, 17 Feb 2026 09:59:09 +0800 Subject: [PATCH 3/5] Fix fmt Signed-off-by: Quanyi Ma --- src/internal/object/patchset.rs | 7 ++++--- src/internal/object/plan.rs | 10 ++++++++-- src/internal/object/provenance.rs | 3 +-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index 78003e35..5d8c3862 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -296,12 +296,13 @@ mod tests { let run_id = Uuid::from_u128(0x1); let base_hash = test_hash_hex(); - let mut patchset = - PatchSet::new(repo_id, actor, run_id, &base_hash, 1).expect("patchset"); + let mut patchset = PatchSet::new(repo_id, actor, run_id, &base_hash, 1).expect("patchset"); let self_id = patchset.header().object_id(); patchset.add_supersedes_patchset_id(self_id); - let err = patchset.validate_supersedes().expect_err("should be invalid"); + let err = patchset + .validate_supersedes() + .expect_err("should be invalid"); match err { GitError::InvalidPatchSetObject(msg) => { assert!(msg.contains("supersede"), "unexpected message: {msg}"); diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index cff5fbb8..0fe92b96 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -216,7 +216,13 @@ mod tests { assert!(plan_v2.plan_version() > plan_v1.plan_version()); assert!(plan_v1.previous_plan_id().is_none()); - assert_eq!(plan_v2.previous_plan_id(), Some(plan_v1.header().object_id())); - assert_eq!(plan_v3.previous_plan_id(), Some(plan_v2.header().object_id())); + assert_eq!( + plan_v2.previous_plan_id(), + Some(plan_v1.header().object_id()) + ); + assert_eq!( + plan_v3.previous_plan_id(), + Some(plan_v2.header().object_id()) + ); } } diff --git a/src/internal/object/provenance.rs b/src/internal/object/provenance.rs index 80e1495d..1af9fc4f 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -42,8 +42,7 @@ impl TokenUsage { if self.total_tokens == 0 { return None; } - self.cost_usd - .map(|cost| cost / self.total_tokens as f64) + self.cost_usd.map(|cost| cost / self.total_tokens as f64) } } From d0b7d319463afa93041a2acd1739c1f1fdc24a60 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Tue, 17 Feb 2026 10:09:47 +0800 Subject: [PATCH 4/5] Fix critical issues from PR #98 review - Fix Header::seal() checksum integrity bug: remove updated_at mutation after checksum computation so the checksum matches the final object state - Fix Intent error variants: use InvalidIntentObject instead of generic InvalidObjectInfo in from_bytes() and to_data() - Fix semantic versioning: bump to 0.6.0 (not 0.5.1) since PR #98 added new public types, enum variants, and trait methods Co-Authored-By: Claude Opus 4.6 Signed-off-by: Quanyi Ma --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/internal/object/intent.rs | 4 ++-- src/internal/object/types.rs | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 394b13c7..bd45cd48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.5.1" +version = "0.6.0" dependencies = [ "ahash 0.8.12", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5848ca8f..8ddaf5e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-internal" -version = "0.5.1" +version = "0.6.0" edition = "2024" license = "MIT" description = "Git-Internal is a high-performance Rust library for encoding and decoding Git internal objects and Pack files." diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index 1087381e..8b1c02c6 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -128,7 +128,7 @@ impl ObjectTrait for Intent { where Self: Sized, { - serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string())) + serde_json::from_slice(data).map_err(|e| GitError::InvalidIntentObject(e.to_string())) } fn get_type(&self) -> ObjectType { @@ -146,7 +146,7 @@ impl ObjectTrait for Intent { } fn to_data(&self) -> Result, GitError> { - serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string())) + serde_json::to_vec(self).map_err(|e| GitError::InvalidIntentObject(e.to_string())) } } diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index 34444851..01a8992a 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -709,7 +709,6 @@ impl Header { match compute_integrity_hash(object) { Ok(checksum) => { self.checksum = Some(checksum); - self.updated_at = Utc::now(); Ok(()) } Err(err) => { From b09f8d20b9b32f46b5d6d27ef44bd170b2a7e9db Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Tue, 17 Feb 2026 10:21:34 +0800 Subject: [PATCH 5/5] Reject AI object types in pack encode/decode paths AI extension types (ContextSnapshot, Decision, etc.) use u8 IDs >= 8 which cannot fit in the 3-bit pack header type field (values 1-7). Previously, the decode path silently returned Ok(None) for these types (dead code since from_pack_type_u8 already rejects them), and the parallel_encode path lacked an early check, letting AI objects fail deep in encode_one_object with a generic error. - Add ObjectType::is_ai_object() helper for classifying AI extensions - Replace silent Ok(None) in decode with explicit InvalidPackFile error - Add early AI type rejection in parallel_encode before batching Co-Authored-By: Claude Opus 4.6 Signed-off-by: Quanyi Ma --- src/internal/object/types.rs | 18 ++++++++++++++++++ src/internal/pack/decode.rs | 19 ++++++------------- src/internal/pack/encode.rs | 6 ++++++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index 01a8992a..98d6f863 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -281,6 +281,24 @@ impl ObjectType { ObjectType::ToolInvocation => true, } } + + /// Returns `true` if this type is an AI extension object (not representable + /// in the 3-bit Git pack header). + pub fn is_ai_object(&self) -> bool { + matches!( + self, + ObjectType::ContextSnapshot + | ObjectType::Decision + | ObjectType::Evidence + | ObjectType::PatchSet + | ObjectType::Plan + | ObjectType::Provenance + | ObjectType::Run + | ObjectType::Task + | ObjectType::Intent + | ObjectType::ToolInvocation + ) + } } /// Actor kind enum diff --git a/src/internal/pack/decode.rs b/src/internal/pack/decode.rs index c8174b18..44efaf28 100644 --- a/src/internal/pack/decode.rs +++ b/src/internal/pack/decode.rs @@ -325,19 +325,6 @@ impl Pack { crc32, ))) } - ObjectType::ContextSnapshot - | ObjectType::Decision - | ObjectType::Evidence - | ObjectType::PatchSet - | ObjectType::Plan - | ObjectType::Provenance - | ObjectType::Run - | ObjectType::Task - | ObjectType::Intent - | ObjectType::ToolInvocation => { - // Wait for encode to implement corresponding compression - Ok(None) - } ObjectType::OffsetDelta | ObjectType::OffsetZstdelta => { let (delta_offset, bytes) = utils::read_offset_encoding(&mut reader).unwrap(); *offset += bytes; @@ -398,6 +385,12 @@ impl Pack { is_delta_in_pack: true, })) } + // AI object types (ContextSnapshot, Decision, etc.) use u8 IDs >= 8 + // and cannot appear in a pack file (3-bit type field only holds 1-7). + // `from_pack_type_u8` already rejects them, but guard explicitly here. + other => Err(GitError::InvalidPackFile(format!( + "AI object type `{other}` cannot appear in a pack file" + ))), } } diff --git a/src/internal/pack/encode.rs b/src/internal/pack/encode.rs index 3dceb8eb..67682b82 100644 --- a/src/internal/pack/encode.rs +++ b/src/internal/pack/encode.rs @@ -621,6 +621,12 @@ impl PackEncoder { for _ in 0..batch_size { match entry_rx.recv().await { Some(entry) => { + if entry.inner.obj_type.is_ai_object() { + return Err(GitError::PackEncodeError(format!( + "AI object type `{}` cannot be encoded in a pack file", + entry.inner.obj_type + ))); + } batch_entries.push(entry.inner); self.process_index += 1; }