diff --git a/Cargo.lock b/Cargo.lock index be06cc8e..bd45cd48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ahash 0.8.12", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1cfd865a..8ddaf5e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-internal" -version = "0.5.0" +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/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..8e275645 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,11 @@ 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, } impl ContextItem { @@ -68,6 +82,7 @@ impl ContextItem { kind, path, content_id, + content_preview: None, }) } } @@ -150,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 new file mode 100644 index 00000000..8b1c02c6 --- /dev/null +++ b/src/internal/object/intent.rs @@ -0,0 +1,169 @@ +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::InvalidIntentObject(e.to_string())) + } + + fn get_type(&self) -> ObjectType { + ObjectType::Intent + } + + fn get_size(&self) -> usize { + 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> { + serde_json::to_vec(self).map_err(|e| GitError::InvalidIntentObject(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..e75d80e9 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,13 @@ pub trait ObjectTrait: Send + Sync + Display { fn get_size(&self) -> usize; 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 0832ac6d..5d8c3862 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,26 @@ 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; + } + + 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 { @@ -223,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> { @@ -253,5 +286,28 @@ 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()); + } + + #[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 cf0be4fb..0fe92b96 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,27 +93,34 @@ impl Plan { header: Header::new(ObjectType::Plan, repo_id, created_by)?, run_id, plan_version: 1, + previous_plan_id: None, steps: Vec::new(), }) } /// 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: Some(self.header.object_id()), steps: Vec::new(), }) } @@ -128,6 +137,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 +148,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 { @@ -156,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> { @@ -175,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()); @@ -189,5 +214,15 @@ 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_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 52d1e0d3..1af9fc4f 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -24,6 +24,28 @@ 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, +} + +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)] @@ -33,8 +55,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 +79,8 @@ impl Provenance { provider: provider.into(), model: model.into(), parameters: None, + temperature: None, + max_tokens: None, token_usage: None, }) } @@ -71,11 +101,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 +134,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; } } @@ -107,7 +166,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> { @@ -127,10 +192,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/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 f541a6e3..6f48ea4a 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); } @@ -269,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> { @@ -297,6 +313,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/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 c5be1ef5..98d6f863 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,9 +277,28 @@ impl ObjectType { ObjectType::Provenance => true, ObjectType::Run => true, ObjectType::Task => true, + ObjectType::Intent => true, 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 @@ -530,6 +557,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. @@ -546,6 +577,7 @@ impl ArtifactRef { /// // specific fields... /// } /// ``` + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Header { /// Global unique ID (UUID v7) @@ -558,6 +590,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 +620,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 +656,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 +705,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 +723,14 @@ 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(); match compute_integrity_hash(object) { Ok(checksum) => { self.checksum = Some(checksum); Ok(()) } Err(err) => { - self.checksum = previous; + self.checksum = previous_checksum; Err(err) } } @@ -886,6 +930,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..44efaf28 100644 --- a/src/internal/pack/decode.rs +++ b/src/internal/pack/decode.rs @@ -325,18 +325,6 @@ impl Pack { crc32, ))) } - ObjectType::ContextSnapshot - | ObjectType::Decision - | ObjectType::Evidence - | ObjectType::PatchSet - | ObjectType::Plan - | ObjectType::Provenance - | ObjectType::Run - | ObjectType::Task - | 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; @@ -397,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; }