From 617aadc836df9c2f0eb6d234204dd72a5d0fb3d0 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 5 Mar 2026 01:12:39 +0800 Subject: [PATCH 1/9] chore: align intent pipeline lifecycle and comments Signed-off-by: Quanyi Ma --- ...ical Report on SHA-256 Hash Computation.md | 593 ------------ docs/GIT_OBJECTS.md | 71 -- docs/GIT_PROTOCOL_GUIDE.md | 319 ------- docs/ai.md | 860 ------------------ src/internal/object/context.rs | 18 +- src/internal/object/decision.rs | 18 +- src/internal/object/evidence.rs | 15 +- src/internal/object/intent.rs | 235 +++-- src/internal/object/patchset.rs | 16 +- src/internal/object/pipeline.rs | 63 +- src/internal/object/plan.rs | 57 +- src/internal/object/provenance.rs | 14 +- src/internal/object/run.rs | 46 +- src/internal/object/task.rs | 24 +- src/internal/object/tool.rs | 15 +- src/internal/object/types.rs | 210 +---- 16 files changed, 336 insertions(+), 2238 deletions(-) delete mode 100644 docs/Analytical Report on SHA-256 Hash Computation.md delete mode 100644 docs/GIT_OBJECTS.md delete mode 100644 docs/GIT_PROTOCOL_GUIDE.md delete mode 100644 docs/ai.md diff --git a/docs/Analytical Report on SHA-256 Hash Computation.md b/docs/Analytical Report on SHA-256 Hash Computation.md deleted file mode 100644 index fe639892..00000000 --- a/docs/Analytical Report on SHA-256 Hash Computation.md +++ /dev/null @@ -1,593 +0,0 @@ -# Background and Problem Overview - -Since its inception, Git has relied on **SHA-1** as its core hash algorithm for naming all objects (commits, trees, blobs, and tags). The 160-bit hash values are represented as 40-character hexadecimal strings and are deeply embedded in repository storage, index structures, network protocols, and a wide range of tooling. - -However, practical collision attacks against **SHA-1** have already been demonstrated, and its security no longer meets long-term requirements. The Git community has outlined a migration path from **SHA-1** to **SHA-256** in the [_hash-function-transition_](https://git-scm.com/docs/hash-function-transition) document, and introduced experimental features such as `--object-format=sha256` in the 2.x series, with the plan to gradually adopt **SHA-256** as the default object format for newly created repositories in Git 3.0. - -In the current technology stack, the low-level library **git-internal**, the Git client **Libra**, and the monolithic repository **Mega** all hard-code the **SHA-1** algorithm, which leads to a concrete requirement for supporting multiple hash algorithms. - -Consequently, the current issues can be summarized as follows: - -1. **git-internal** contains extensive **hard-coded SHA-1** logic (fixed 20-byte / 40-character hashes) and lacks a unified abstraction layer for hash functions. - -2. **Libra** only implements **pack index v1**, and its protocol layer implicitly assumes “40-character hashes” during fetch/push discovery, making it incompatible with **SHA-256** and unable to negotiate multiple hash algorithms. - -3. As a monolithic repository, **Mega** historically treats its hash strategy as an implicit constant. The chosen hash algorithm is not explicitly expressed in the context, and there is no clear “initialize once, use globally” policy for hash configuration. - -# Project Objectives -The overall objective of this project is: -> To introduce systematic **SHA-256** support and a multi-hash mechanism for **git-internal**, **Libra**, and **Mega**—**without introducing major breaking changes to existing APIs**—thereby laying an extensible foundation for the Git 3.0 era. - -Concretely, the goals can be decomposed as follows: - -**git-internal Layer** -   - Evolve from “hard-coded **SHA-1**” to a unified hash abstraction that supports at least **SHA-1** and **SHA-256**, including: - -     - Abstracting `HashKind` (hash algorithm type) and `ObjectHash` (concrete hash value type); - -     - Providing unified APIs for hash computation, encoding, and decoding; - -     - Replacing low-level assumptions such as fixed `[u8; 20]` layouts. - -**Libra Client Layer** -   - Enable **Libra** to become a Git client that **understands multiple hash algorithms and interoperates with both SHA-1 and SHA-256 repositories**: - -     - Initialize `HashKind` in command entry points based on `core.objectFormat`, `extensions.objectFormat`, and `compatObjectFormat`; - -     - Remove the implicit 40-character hash assumption in the protocol layer (fetch/push discovery), and instead parse hashes via `HashKind::hex_len()` together with `ObjectHash::from_str`; - -     - Evolve the index side from `build_index_v1` to **pack index v3/v2**, supporting concurrent indexing of SHA-1 and SHA-256 and laying the groundwork for bidirectional mappings (sha1 <-> sha256). - -**Mega Monolithic Repository Layer** -   - Make Mega’s hash policy explicit as **“a single `objectFormat` chosen at repository creation time”**: - -     - Do not implement cross-algorithm conversion (no need for `compatObjectFormat`); - -     - During application initialization, set `hash_kind` once via `AppContext`, and ensure that all subsequent business logic accesses hash information exclusively through the shared context. - -**Compatibility and Evolution** - -   - Preserve the ability to access existing **SHA-1** repositories; - -   - Reserve extension points for potential future hash algorithms (such as BLAKE3), without over-engineering the design. - -# Design Overview -## Current State of git-internal -In the current **git-internal** implementation, the hash algorithm is essentially hard-coded as **SHA-1 with a fixed 20-byte representation**. For example: -```rust -fn from_bytes(data: &[u8], hash: SHA1) -> Result -``` -This leads to several issues: -1. **Algorithm Cannot Be Switched** - -   All object IDs are statically bound to **SHA-1**. As soon as we want to support **SHA-256**, a large number of type signatures must be modified across the codebase. - -2. **Lack of a Unified Abstraction Layer** - -   Different core modules in the library (such as commits, blobs, indexes, and protocol-related components) each make their own assumptions about hash formats and handle them in ad-hoc ways, without a single unified entry point or abstraction. - -3. **Difficult to Support “Multiple Algorithms Coexisting”** - -   In the short term, a repository may indeed use only a single hash algorithm. However, according to Git upstream’s `hash-function-transition` design, Git needs to understand both **SHA-1** and **SHA-256** in certain scenarios (for example, maintaining bidirectional mapping tables between the two) -### Usage Scenarios: Inferable vs Non-Inferable Hash Types - -Before designing the refactoring plan, we need to distinguish between two categories of usage scenarios, as this directly affects how the APIs should be designed. -#### 1. Non-Inferable Types — Must Be Explicitly Passed or Provided via Context (e.g., `commit.rs`) - -```rust -pub fn new( - tree_id: SHA1, - parent_commit_ids: Vec, - message: &str, -) -> Commit { - let mut commit = Commit { - id: SHA1::default(), - // ... - }; - let hash = SHA1::from_type_and_data(ObjectType::Commit, &commit.to_data().unwrap()); - commit.id = hash; - commit -}``` -Here, Commit::new has no knowledge of whether the _current repository_ is using **SHA-1** or **SHA-256**. All it knows is: -- It needs to construct a commit object; - -- It needs to assign an ID to that commit. - -If we simply replace SHA1 with an enum like HashKind or a generic ObjectHash, but still do not provide any contextual information, the function has **no way to decide which concrete hash algorithm to use**. -Possible approaches include: -1. Add a hash_kind: HashKind parameter to all relevant functions – but this would propagate a large number of additional parameters across the codebase and hurt API simplicity. - -2. Introduce a “repository context” (such as a Repository object or a global configuration) that provides the currently active hash algorithm. - -For the low-level **git-internal** library, a more reasonable solution is to introduce an accessible “current hash algorithm” state (see the global configuration approach discussed later), so that we avoid an explosion of function signature changes. -#### **2. Inferable Types — Can Be Deduced from Input Length (e.g.,**  **core.rs**) -```rust -async fn get_blob( - &self, - object_hash: &str, -) -> Result { - let data = self.get_object(object_hash).await?; - let hash = SHA1::from_str(object_hash) - .map_err(|e| ProtocolError::repository_error(format!("Invalid hash format: {}", e)))?; - crate::internal::object::blob::Blob::from_bytes(&data, hash) - .map_err(|e| ProtocolError::repository_error(format!("Failed to parse blob: {}", e))) -} -``` -In scenarios like this, object_hash is a hexadecimal string, and we can infer the hash algorithm based on its length: -- Length 40 → treat it as **SHA-1**; - -- Length 64 → treat it as **SHA-256**. - -With this approach, the function does not need an additional “algorithm” parameter. Instead, it only needs to inspect the length of object_hash, then delegate to a unified ObjectHash::from_str implementation. - -For this class of use cases, we can keep the existing API surface largely unchanged and encapsulate all branching logic inside hash.rs. - -### Design Principles: Abstraction Inspired by the C Implementation -In Git’s [C implementation](https://github.com/git/git), there is a core structure: -```c -struct git_hash_algo { - const char *name; // ("sha1", "sha256") - uint32_t format_id; // format ID - size_t rawsz; // hash_len (20 or 32) - size_t hexsz; // hex_len (40 or 64) - size_t blksz; // block_size - - void (*init_fn)(struct git_hash_ctx *); - void (*clone_fn)(...); - void (*update_fn)(...); - void (*final_fn)(...); - void (*final_oid_fn)(...); -}; -``` -The core idea behind this design can be summarized as: -- Use a **single struct to describe both “algorithm metadata” and “algorithm interface”**; - -- Each algorithm provides its own git_hash_algo instance; - -- The rest of the code accesses the active algorithm solely via a pointer like struct git_hash_algo *the_hash_algo. - -In Rust, there are two typical abstraction approaches: -1. **Trait + dynamic dispatch** (e.g. dyn HashAlgo with Box) - -2. **Enum +** **match** based static dispatch - -Given that: -- The number of algorithms is small (currently only **SHA-1** and **SHA-256**, and likely only a few more in the future); - -- We want to avoid virtual tables and heap allocations; - -- The low-level library targets performance-sensitive scenarios, and match branches are easily optimized into constant-time paths by the compiler; - -it is more appropriate to adopt a design based on **“a couple of enums plus helper functions”** rather than traits with dynamic dispatch. - -### Hash Abstraction Design: `HashKind` and `ObjectHash` - -Here, we deliberately separate **“algorithm kind”** from **“concrete hash value”** into two enums: -- `HashKind` - - Only concerns *which* algorithm is currently selected; - - Used for configuration, branching logic, retrieving length information, etc. - -- `ObjectHash` - - The structure that actually stores hash values, and the one most function parameters will use; - - Can hold either a `[u8; 20]` or a `[u8; 32]` representation in a single type. - -#### `HashKind`: Describing the Active Algorithm Type - -```rust -#[derive(Debug, Clone, Copy, Default)] -pub enum HashKind { - #[default] - Sha1, - Sha256, -} - -impl HashKind { - /// Returns the raw hash length in bytes (20 or 32). - pub const fn size(&self) -> usize { - match self { - HashKind::Sha1 => 20, - HashKind::Sha256 => 32, - } - } - - /// Returns the hex string length (40 or 64). - pub const fn hex_len(&self) -> usize { - match self { - HashKind::Sha1 => 40, - HashKind::Sha256 => 64, - } - } -}``` -- **Single responsibility**: it only encodes “which algorithm is being used” and does not hold any actual hash bytes; - -- Provides basic algorithm-related metadata (byte length / hex length) for other structures to consume; - -- In most cases, it can serve as a configuration enum for “current repository settings”, initialized from configuration files or environment variables. - -#### **ObjectHash: Enum Carrying Actual Object IDs** -```rust -#[derive(Clone, Copy, Eq, PartialEq, Hash)] -pub enum ObjectHash { - Sha1([u8; 20]), - Sha256([u8; 32]), -} - -impl Default for ObjectHash { - fn default() -> Self { - ObjectHash::Sha1([0u8; 20]) - } -} - -impl ObjectHash { - /// Returns the algorithm kind corresponding to this hash. - pub fn kind(&self) -> HashKind { - match self { - ObjectHash::Sha1(_) => HashKind::Sha1, - ObjectHash::Sha256(_) => HashKind::Sha256, - } - } - - /// Returns the hash length in bytes. - pub fn size(&self) -> usize { - self.kind().size() - } - - /// Computes a hash for the given data based on the global configuration. - pub fn new(data: &[u8]) -> ObjectHash { - match get_hash_kind() { - HashKind::Sha1 => { - let h = sha1::Sha1::digest(data); - let mut bytes = [0u8; 20]; - bytes.copy_from_slice(h.as_ref()); - ObjectHash::Sha1(bytes) - } - HashKind::Sha256 => { - let h = sha2::Sha256::digest(data); - let mut bytes = [0u8; 32]; - bytes.copy_from_slice(h.as_ref()); - ObjectHash::Sha256(bytes) - } - } - } - - /// Parses a hash from a hex string (auto-detecting the algorithm by length). - pub fn from_hex(s: &str) -> Result { - // Pseudocode: branch on length + decode - // if len == 40 => Sha1, if len == 64 => Sha256, otherwise return error - #![allow(unused)] - unimplemented!() - } - - /// Converts the hash into a hex string. - pub fn to_hex(&self) -> String { - // Pseudocode: encode the internal byte array - #![allow(unused)] - unimplemented!() - } -} -``` -**Benefits of this design** -1. **Clear type semantics** - - - Seeing HashKind immediately signals that it does not contain an actual hash, but only represents a policy choice; - - - Seeing ObjectHash makes it clear that this is “the real hash value” that can be attached to commits, trees, blobs, and other objects. - -2. **Supports coexistence of multiple algorithms** - - The same process can simultaneously hold ObjectHash::Sha1 and ObjectHash::Sha256 values—for example, when reading objects of different formats from remote repositories. This is aligned with upstream Git’s design for maintaining **bidirectional mapping tables** between SHA-1 and SHA-256. - -3. **Zero or near-zero runtime overhead** - - The compiler can efficiently optimize match statements, avoiding virtual tables and dynamic dispatch. As a result, this abstraction is suitable for performance-sensitive low-level code. -### Global Configuration: Selecting the Algorithm Without Changing APIs - -In order to let low-level code know *“which hash algorithm the current repository is using”* **without** adding a `hash_kind: HashKind` parameter everywhere, we can follow upstream Git’s approach of using a global pointer and introduce a **global configuration / thread-local configuration** in Rust. For example: - -```rust -/// Sets the hash kind for the current thread. -pub fn set_hash_kind(kind: HashKind) { - HASH_KIND.with(|h| { - *h.borrow_mut() = kind; - }); -} - -/// Retrieves the hash kind for the current thread. -pub fn get_hash_kind() -> HashKind { - HASH_KIND.with(|h| *h.borrow()) -}``` -The call flow is as follows: - -1. Upper layers (for example Repository::open or a CLI command entry point) parse .git/config or other configuration sources: - - - If extensions.objectFormat = sha256, then set HashKind::Sha256; - - - Otherwise, fall back to the default HashKind::Sha1. - - -2. The chosen value is written into the global / contextual state. - -3. All functions like ObjectHash::new(data) then only depend on this **“current global algorithm”**, and do not require an extra parameter. - -This approach satisfies the requirement of **“keeping the APIs as stable as possible”**, while ensuring that all low-level components **read the active hash algorithm from a single, unified source**. - -### Concrete Refactoring Suggestions for git-internal Core Modules - -#### Index Writing Logic (`git-internal/src/internal/index.rs`) - -```rust -pub fn to_file(&self, path: impl AsRef) -> Result<(), GitError> { - // ... - let mut hash = Sha1::new(); - // ... - entry_bytes.write_all(&entry.hash.0)?; - // ... - let padding = 8 - ((22 + entry.name.len()) % 8); - entry_bytes.write_all(&vec![0; padding])?; - file.write_all(&entry_bytes)?; - hash.update(&entry_bytes); - - // Extensions - // check sum - let file_hash: [u8; 20] = hash.finalize().into(); - file.write_all(&file_hash)?; - Ok(()) -}``` -1. **Streaming hash computation depends on the concrete algorithm type** - - - The current implementation hard-codes Sha1::new(). This needs to be changed to select either SHA-1 or SHA-256 based on get_hash_kind(), so that the streaming hash computation respects the active algorithm. - - -2. **Hash-length-dependent padding and trailer size** - - - The expression padding = 8 - ((22 + entry.name.len()) % 8) currently bakes in assumptions about the fixed layout, such as “20-byte hash + 2-byte flags”, which only holds for SHA-1. This logic must be adjusted so that it works correctly when the hash length changes for SHA-256. - - - The final checksum file_hash: [u8; 20] must also be updated to derive its size dynamically from HashKind::size(), rather than assuming a fixed 20-byte hash. -#### Object Modules (`src/internal/object/blob.rs`, etc.) - -```rust -fn from_bytes(data: &[u8], hash: SHA1) -> Result -where - Self: Sized, -{ - Ok(Blob { - id: hash, - data: data.to_vec(), - }) -}``` -For functions of this kind, the logic itself is very simple: they simply assign the hash to a field in the struct. The refactoring approach is straightforward: - -- Replace the parameter type **SHA-1** with ObjectHash; - -- Keep the rest of the logic almost unchanged; - -- For certain helper functions that are only used internally, we may optionally add additional where-constraints (for example, enforcing that ObjectHash matches a specific encoding requirement). - -These changes are essentially **type-level substitutions**. While the risk is relatively low for each individual call site, the impact is broad: the refactor requires a systematic sweep over all places that hard-code **SHA-1** as a type. - -## ## Impact Analysis on Libra - -#### 1. Global Initialization of `HashKind` and Refactoring of Command Entry Points -The official `hash-function-transition` documentation provides a typical configuration for a SHA-256 repository: -```ini -[core] - repositoryFormatVersion = 1 -[extensions] - objectFormat = sha256 - compatObjectFormat = sha1 -``` -In Libra, we should introduce a unified initialization routine at the command entry layer: -- Read the current repository’s core.repositoryFormatVersion, extensions.objectFormat, and extensions.compatObjectFormat; - -- Set the global HashKind in **git-internal** based on objectFormat; - -- Propagate compatObjectFormat (used for cross-algorithm compatibility) down into lower layers, where it can later be leveraged to build an ObjectIdMap (for example, backed by a dedicated SQLite table). - -**Additional Note:** -Any code that **generates or parses hashes** must only run _after_ this initialization step has completed. This logic should be wrapped in a helper function (e.g., an initialization guard) to avoid duplication. - -In the initial refactor, we only introduced a single global variable in **git-internal** to represent the active objectFormat. However, to support compatibility between different hash algorithms, we now need to extend this design and introduce additional state (e.g., a separate variable to represent compatObjectFormat), as will be discussed in later sections. -#### 2. Support for Pack Index v3 and `ObjectIdMap` -According to Git’s official [hash-function-transition](https://git-scm.com/docs/hash-function-transition) documentation, the pack index v3 (`.idx`) format for multiple hash functions introduces an explicit header that describes the index version, object count, and the number of supported hash algorithms: -```ini -- 4 bytes: pack index signature `\377t0c` -- 4 bytes: version number (e.g., `3`) -- 4 bytes: header size (including signature and version) -- 4 bytes: “number of objects in the pack” -- 4 bytes: “number of object formats (hash functions) in this index” (for example, `2` if both SHA-1 and SHA-256 are present) -``` -The header is followed by per-algorithm tables for object IDs, offsets, and related structures. Each hash function gets its own set of tables, allowing **the same object to be referenced by multiple hash algorithms within a single index**. The core purpose is to let **one `.idx` file store names for multiple hash algorithms**, providing a **standard on-disk structure for algorithm migration and compatibility**. - -However, there is currently no readily available reference implementation of v3 in our codebase. We need to implement it manually based on the documentation, or first finish v1/v2 support and then extend it to v3. - -In the **Libra** repository, only a single-hash implementation exists today (`libra/src/command/index_pack.rs`): - -```rust -/// Build index file for pack file, version 1 -/// -/// [pack-format](https://git-scm.com/docs/pack-format) -pub fn build_index_v1(pack_file: &str, index_file: &str) -> Result<(), GitError> { - let pack_path = PathBuf::from(pack_file); - let tmp_path = pack_path.parent().unwrap(); - // ... -}``` -Once Git 3.0 introduces **SHA-256** and cross-algorithm compatibility, **Libra must evolve to support pack index v3** in order to manage **both SHA-1 and SHA-256 object names in a single index file**. - -Within **Libra** itself (since pack indexing and object rewriting logic currently live inside Libra), the following evolutions are required: -1. Add a build_index_v3 implementation inside Libra: - - - Emit the v3 header (signature, version, object count, number of hash algorithms); - - - For the hash algorithm indicated by the global configuration, as well as for the compatible algorithm (e.g., SHA-1 and SHA-256), maintain a dedicated set of tables for each, as specified in the [pack index](https://git-scm.com/docs/hash-function-transition)![Attachment.tiff](file:///Attachment.tiff) documentation; - - - Ensure that, for each object in the pack, **both algorithm-specific IDs** are recorded in the index. - -2. Introduce an ObjectIdMap abstraction so that the **Libra** process can efficiently perform bidirectional conversions: -```rust -struct ObjectIdMap { - by_sha1: HashMap, // sha1 -> sha256 - by_sha256: HashMap, // sha256 -> sha1 -} -``` -The contents of this map can be populated from parsed index data or queried from the database (since **Libra** replaces some of the original internal storage with SQLite). In-memory maps should ideally only hold mappings relevant to the current operation, while the full mapping set should be persisted in the database. -#### 3. Protocol Layer: Fetch as an Example -Consider the following scenario: - -> Local **Libra** repository: `objectFormat = sha256`, `compatObjectFormat = sha1` -> (primary storage algorithm is SHA-256, with SHA-1 as a compatibility format) -> Remote Git server: legacy SHA-1 repository that only knows 40-character SHA-1 IDs. - -In the current architecture, **Libra implements `index-pack` and related object-processing logic by itself**, so the following steps all happen inside **Libra**, without depending on external **git-internal** helpers. Precisely because of this, **git-internal** can remain relatively minimal and only provide the basic primitives. -##### 1. Session Initialization: Determining Three `HashKind`s -When a `fetch` command starts, Libra needs to: - -1. Open the local repository and read `.git/config`: - - - Parse `extensions.objectFormat` and set `PrimaryHashKind` (used for local object storage and ID computation); - - Parse `extensions.compatObjectFormat` and set `CompatHashKind` (if present, this describes the hash type stored in the translation table). - -2. Based on the remote capability negotiation, determine the **`WireHashKind` (the hash type used on the wire for transmission and parsing)**: - - - Legacy servers that only support **SHA-1** → `WireHashKind = Sha1`; - - In the future, servers may support **SHA-256**, in which case `WireHashKind` might also be `Sha256`. - -In most transition scenarios, the local repository is **SHA-256**-based, while the remote is still **SHA-1**-only. In that case we typically have: - -- `PrimaryHashKind = Sha256` -- `CompatHashKind = Some(Sha1)` -- `WireHashKind = Sha1` - -##### 2. Protocol Phase -In the current implementation, Libra’s `internal/protocol` code parses remote references by slicing fixed-length ID segments, which obviously only works for **SHA-1**. - -Once **SHA-256** is supported, we must: - -1. Use `WireHashKind::hex_len()` to determine the length of IDs on the wire, instead of hard-coding `40`; -2. Serialize all `have` / `want` lines according to `WireHashKind`, ensuring that on-the-wire IDs are fully compatible with the remote’s expectations; -3. Whenever Libra needs to map a remote ID to a local object, it should go through `ObjectIdMap` and dedicated conversion helpers. - -##### 3. Pack-Processing Phase -According to Git’s [hash-function-transition](https://git-scm.com/docs/hash-function-transition), when the local repository is **SHA-256** and the remote repository is **SHA-1**, the key steps during `fetch` are: -1. **Receive a SHA-1 Pack** - - - Libra receives a “pure SHA-1” pack from the server in the traditional way, where all object IDs, delta references, etc. are expressed in **SHA-1**. - -2. **Run `index-pack` and Perform Topological Sorting (Inside Libra)** - - - In `libra/src/command/index_pack.rs` or related modules: - - Run `index-pack` over the SHA-1 pack to obtain all objects and their **SHA-1** IDs; - - Perform a **topological sort** over commits / trees / tags so that “referenced objects come before their referrers”; - - `blob` objects can be handled separately, since they do not reference other objects. - -3. **Rewrite Object Contents into SHA-256 Form** - - In the current Libra architecture, this step also happens inside Libra itself (not in git-internal): - - - For **blob** objects: - - The “SHA-1 content” and “SHA-256 content” are **byte-for-byte identical**; only the hash function differs; - - Libra can compute both SHA-1 and SHA-256 over the same object body and store the mapping in the translation table. - - - For **tree / commit / tag** objects: - - First obtain the “SHA-1 content”, which includes **SHA-1** references to other objects; - - Use `ObjectIdMap` to replace all internal **SHA-1** references with their corresponding **SHA-256** IDs, thereby constructing the **SHA-256** version of the content; - - Prepend the appropriate object header to this SHA-256 content and compute the **SHA-256** object ID; - - Write the rewritten object into a new local **SHA-256** pack; - - At the same time, record the mapping `sha1 <-> sha256` both in the v3 index and in the in-memory `ObjectIdMap`. - -4. **Write Pack Index v3 and `loose-object-idx`** - - - After all objects have been processed, Libra invokes its own `build_index_v3` implementation to: - - Build fan-out / ID / offset tables for both the primary and compatibility algorithms; - - Write the `.idx` file and update the `loose-object-idx` index for unpacked (loose) objects. - -5. **Clean Up the Temporary SHA-1 Pack** - - - The **SHA-1** pack is only an intermediate format and can be removed once the rewrite process is complete; - - Locally, we only keep the **SHA-256** pack, the v3 index, and the translation table. -## Mega: Fixed Hash Policy and Context Design for a Monolithic Repository -The situation for **Mega** is different from **Libra**: -- It is a **monolithic repository**, and can internally adopt a single hash algorithm (preferably **SHA-256**) in a uniform way; -- It does not need to interoperate with **SHA-1** repositories, nor act as a “general-purpose Git client”; -- Therefore, a simpler and more explicit strategy is acceptable and even preferable. - -Under these conditions, repeating Libra’s pattern of “reading configuration and propagating `HashKind` everywhere” becomes unnecessarily heavy. Typical issues include: - -- Re-reading configuration in every low-level operation (writing objects, computing diffs, creating commits); -- Polluting API signatures with extra `hash_kind: HashKind` parameters, increasing cognitive load; -- In multi-threaded scenarios, repeatedly accessing configuration can introduce additional locking and runtime overhead. - -### One-Time `hash_kind` Initialization in `AppContext` -A more appropriate approach for **Mega** is to introduce a **global application context** (`Context`) that is initialized only once when the application or worker thread starts. In the `context` module, there is already a struct: - -```rust -pub struct AppContext { - pub storage: jupiter::storage::Storage, - pub vault: vault::integration::vault_core::VaultCore, - pub config: Arc, -}``` -We propose refactoring it as follows: -- In AppContext::new (or the main initialization entry point): - - - Read objectFormat from configuration or repository metadata (defaulting to sha256); - - - Call git-internal’s set_hash_kind(HashKind::Sha256), and store the chosen hash_kind as a field inside AppContext. - -The conventions are: -- Business logic must **not** independently read configuration files to determine the hash algorithm; - -- All hash-related logic obtains the algorithm either from ctx.hash_kind or via the global get_hash_kind() provided by **git-internal**; - -- The context is shared across threads as Arc< AppContext> and treated as immutable. -# Project Roadmap - -The project can be advanced in the following phases: - -1. **Phase 1: Refactoring Abstractions in git-internal** - - Implement the `HashKind` / `ObjectHash` abstraction; - - Introduce global / thread-local `set_hash_kind` / `get_hash_kind`; - - Refactor core modules (objects, index, protocol primitives) to remove `[u8; 20]` and hard-coded 40-byte assumptions. - -2. **Phase 2: Baseline Adaptation in Libra** - - Initialize `HashKind` in a unified way at all command entry points; - - Refactor protocol parsing based on `hex_len` and `ObjectHash::from_str`; - - Verify that interactions with existing SHA-1 repositories behave correctly. - -3. **Phase 3: Pack Index v3 and Compatibility Support** - - Implement pack index v3 in Libra; - - Introduce `ObjectIdMap` and enable SHA-1 ↔ SHA-256 mapping end to end; - - Complete cross-algorithm `fetch` / `push` flows within Libra. - -4. **Phase 4: Context Refactoring in Mega** - - Extend `AppContext` to include `hash_kind` and initialize it once during startup; - - Remove all direct “hash algorithm detection” logic inside Mega and route everything through the shared context; - - Validate performance and behavioral stability under a “single SHA-256 strategy”. - -# Expected Benefits - -1. **Improved Security** - - Add support for SHA-256, eliminating practical SHA-1 collision risks and aligning with the evolution direction of Git 3.0. - -2. **Enhanced Architectural Evolvability** - - By introducing the `HashKind` / `ObjectHash` abstraction, the cost of adding new hash algorithms in the future is constrained to a manageable scope. - -3. **Clearer Separation of Responsibilities** - - **git-internal**: provides low-level multi-hash abstractions and indexing capabilities; - - **Libra**: implements multi-hash interoperability as a general-purpose Git client; - - **Mega**: chooses a hash policy once at the business boundary and keeps internal logic simple and stable. - -4. **Alignment with Upstream Community** - - Designs such as pack index v3 and `compatObjectFormat` remain compatible with upstream Git, laying the groundwork for integrating additional community tools and future protocol extensions. - -# References - -- [Git 2.45: Preliminary Support for SHA-1 and SHA-256 Interoperability](https://github.blog/open-source/git/highlights-from-git-2-45/#preliminary-support-for-sha-1-and-sha-256-interoperability) -- [hash-function-transition](https://git-scm.com/docs/hash-function-transition) -- [git-init](https://git-scm.com/docs/git-init/2.38.0) -- [BreakingChanges](https://git-scm.com/docs/BreakingChanges) -- [Git Source](https://github.com/git/git) -- [gitoxide](https://github.com/GitoxideLabs/gitoxide) -- [libgit2: SHA-256 Discussion](https://github.com/libgit2/libgit2/discussions/5840) -- [pack-format](https://git-scm.com/docs/pack-format) -- [Why Google Stores Billions of Lines of Code in a Single Repository](https://cacm.acm.org/research/why-google-stores-billions-of-lines-of-code-in-a-single-repository/) -- [protocol capabilities](https://git-scm.com/docs/protocol-capabilities) -- [protocol v2](https://git-scm.com/docs/protocol-v2) diff --git a/docs/GIT_OBJECTS.md b/docs/GIT_OBJECTS.md deleted file mode 100644 index 88fb84a6..00000000 --- a/docs/GIT_OBJECTS.md +++ /dev/null @@ -1,71 +0,0 @@ -# Git Object Reference - -This document summarizes the object formats supported by git-internal, how IDs are hashed, and how they map to canonical Git formats, based on the implementations in `internal/object`. - -## Common Format and Hashing - -- Storage format: ` \0`, where `type` is `blob/tree/commit/tag` and `size` is the raw data length (decimal string). -- Hashing: `ObjectHash::from_type_and_data(ObjectType, data)` produces an ID using the current thread hash algorithm (Currently supports SHA-1 and SHA-256); switch via `set_hash_kind` / `set_hash_kind_for_test`. -- Types: `ObjectType` offers `to_string`/`to_u8`/`from_u8`/`from_string`, covering base objects (Commit/Tree/Blob/Tag) and delta objects (OffsetDelta/HashDelta/OffsetZstdDelta—extension). -- Serialization: Each object’s `to_data` returns `\0`; `ObjectHash::to_string()` emits hex, `to_data()` returns raw bytes. - -## Blob - -- Location: `object/blob.rs`. -- Meaning: File content snapshot, no path/permission (those live in Tree). -- Structure: `Blob { id: ObjectHash, data: Vec }`. -- Build: `Blob::from_content` / `from_content_bytes` auto-compute the hash; `from_bytes(data, hash)` parses with a known hash. -- Serialize: `to_data()` returns raw content (header is implied when hashing with `ObjectHash::from_type_and_data`). - -## Tree and TreeItem - -- Location: `object/tree.rs`. -- TreeItem format: `" \0"`; modes include `100644`/`100755`/`120000`/`160000`/`40000` (gitlink). -- Structure: `TreeItem { mode: TreeItemMode, id: ObjectHash, name: String }`; `Tree { id, tree_items: Vec }`. -- Build: `Tree::from_tree_items(items)` computes the tree hash; `rehash` recomputes after modifications. -- Parse: `Tree::from_bytes(data, hash)` splits IDs using current hash length (20/32 bytes); TreeItem parsing has a GBK fallback for non-UTF-8 names. - -## Commit - -- Location: `object/commit.rs`. -- Field order: `tree `, zero or more `parent `, `author `, `committer `, blank line, then message (may include signatures). -- Structure: `Commit { id, tree_id, parent_commit_ids, author, committer, message }`. -- Build: `Commit::new` (explicit signatures) or `from_tree_id` (convenience with current-time signatures); both use `ObjectHash::from_type_and_data` to derive the ID. -- Parse: `from_bytes` splits lines and uses `Signature::from_data` for author/committer. -- Helper: `format_message` skips PGP signature blocks or returns the first non-empty line. - -## Tag (Annotated) - -- Location: `object/tag.rs`. -- Format: - `object ` - `type ` - `tag ` - `tagger ` - `` (after a blank line) -- Structure: `Tag { id, object_hash, object_type, tag_name, tagger, message }`. -- Build: `Tag::new(object_hash, object_type, tag_name, tagger, message)`; hash is computed from serialized content. -- Parse: `from_bytes` validates UTF-8 and errors on invalid fields; `to_data` emits the format above. - -## Note - -- Location: `object/note.rs`. -- Meaning: An annotation attached to an object; internally treated as a Blob (`get_type` returns Blob), and hashed using Blob rules. -- Build/Parse: `Note::from_content(content)` builds a note for a placeholder target; `Note::new(target_object_id, content)` associates it to a specific object. Use `from_bytes` to parse existing data. - -## Signature - -- Location: `object/signature.rs`. -- Layout: ` `, where `role` is `author`/`committer`/`tagger` (`SignatureType`). -- Functions: `Signature::from_data` parses a byte sequence; `to_data` serializes; `new` creates a signature with a given role/name/email (caller supplies time). - -## Helpers and Common Types - -- Location: `object/utils.rs`. -- Contents: Currently minimal; most shared I/O/hash helpers live in top-level `utils.rs`. - -## Pack/Protocol Integration - -- Pack decode yields `Entry` with `obj_type`, `hash`, `data`; these can be parsed by the object modules above. -- Pack encode expects `ObjectHash` plus raw data; `PackEncoder` uses `ObjectType` to craft headers and validate hashes. -- Protocol (upload-pack/receive-pack) cares about object ID/type consistency; content parsing is left to the caller or higher layers as needed. diff --git a/docs/GIT_PROTOCOL_GUIDE.md b/docs/GIT_PROTOCOL_GUIDE.md deleted file mode 100644 index 727d1fcf..00000000 --- a/docs/GIT_PROTOCOL_GUIDE.md +++ /dev/null @@ -1,319 +0,0 @@ -# Git Protocol Abstraction Design and Implementation - -## 1. Project Overview - -The git-internal library implements a transport layer abstraction for the Git smart protocol, separating HTTP and SSH protocol handling from monorepo business code and adapting to any business system through Trait interfaces. - -## 2. Architecture Design - -### 2.1 Layered Architecture - -``` -┌─────────────────────────────────────┐ -│ Business System Integration │ ← Implement Trait interfaces -├─────────────────────────────────────┤ -│ Transport Protocol Adapters │ ← HTTP/SSH handlers -├─────────────────────────────────────┤ -│ Git Smart Protocol Core │ ← Protocol logic implementation -├─────────────────────────────────────┤ -│ Pack File Processing Layer │ ← Object packing/unpacking -└─────────────────────────────────────┘ -``` - -### 2.2 Core Abstraction Interfaces - -**RepositoryAccess Trait** - -- Provides storage layer abstraction, isolating business logic -- Supports reference management, object access, and Pack operations -- Can adapt to any storage backend (filesystem, database, etc.) - -**AuthenticationService Trait** - -- Unified authentication interface supporting HTTP and SSH -- Can integrate with any authentication system (OAuth, JWT, public key, etc.) - -**GitProtocol Core** - -- Transport-agnostic protocol implementation -- Unified info/refs, upload-pack, receive-pack interfaces - -## 3. Implementation Status - -### 3.1 Completed Features - -**Core Protocol** - -- Complete Git smart protocol v1 implementation -- Reference advertisement and capability negotiation -- upload-pack service (clone/fetch operations) -- receive-pack service (push operations) -- Pack file generation and parsing - -**Transport Layer** - -- HTTP transport adapter (request parsing, streaming responses) -- SSH transport adapter (command parsing, authentication integration) -- Transport protocol abstraction (unified interface) - -**Data Processing** - -- Side-band multiplexing -- Progress reporting mechanism -- Object parsing (blob, commit, tree) -- Reference updates and validation - -**Authentication System** - -- HTTP authentication (header-based) -- SSH authentication (public key verification) -- Pluggable authentication architecture - -## 4. Module Organization - -``` -src/protocol/ -├── core.rs # Main abstractions (Trait definitions, GitProtocol) -├── http.rs # HTTP transport adapter -├── ssh.rs # SSH transport adapter -├── smart.rs # Git smart protocol implementation -├── pack.rs # Pack generation and processing -├── types.rs # Protocol types and error definitions -├── utils.rs # Protocol utility functions -└── mod.rs # Module exports -``` - -## 5. HTTP Protocol Abstraction - -### 5.1 Design Features - -- Request path parsing and repository location -- Standard Git HTTP content type handling -- Streaming responses for large repository transfers -- Error mapping to HTTP status codes - -### 5.2 Main Functions - -- `handle_info_refs`: Process reference query requests -- `handle_upload_pack`: Process clone/fetch requests -- `handle_receive_pack`: Process push requests -- `authenticate_http`: HTTP authentication integration - -## 6. SSH Protocol Abstraction - -### 6.1 Design Features - -- Git command line parsing (git-upload-pack, git-receive-pack) -- Repository path extraction and validation -- Direct protocol mapping without HTTP overhead -- Public key authentication integration - -### 6.2 Main Functions - -- Command parsing and dispatching -- Repository path extraction -- Protocol operation mapping -- SSH authentication integration - -## 7. Trait Adaptation Solution - -### 7.1 Storage Adaptation - -Adapt any storage system through RepositoryAccess Trait: - -- Filesystem storage -- Database storage -- Cloud storage services -- Distributed storage - -### 7.2 Authentication Adaptation - -Integrate any authentication system through AuthenticationService Trait: - -- Traditional username/password -- OAuth/JWT tokens -- SSH public key authentication -- Enterprise SSO systems - -### 7.3 Framework Agnostic - -- No dependency on specific web frameworks -- No binding to specific SSH libraries -- No database choice restrictions -- No forced authentication schemes - -## 8. Error Handling and Types - -### 8.1 Protocol Error Types - -- InvalidService: Invalid service request -- RepositoryNotFound: Repository does not exist -- Unauthorized: Authentication failure -- InvalidRequest: Request format error -- Other I/O and internal errors - -### 8.2 Transport Mapping - -Each transport layer is responsible for mapping protocol errors to appropriate transport error formats (HTTP status codes, SSH error messages, etc.). - -## 9. Capabilities and Features - -### 9.1 Supported Git Capabilities - -- side-band-64k: Multiplexed data streams -- ofs-delta: Offset delta objects -- report-status: Push status reporting -- multi_ack_detailed: Detailed acknowledgment negotiation -- no-done: Optimized negotiation flow - -### 9.2 Protocol Features - -- Complete want/have negotiation -- Incremental Pack transmission -- Progress reporting -- Reference update validation - -### 9.3 Protocol Evolution: v0 / v1 / v2 Comparison - -#### Version Overview - -Git Smart Protocol has evolved through three major versions. -Each version refines how the client and server exchange repository data, -especially for large-scale or latency-sensitive environments. - -| Feature | v0 (Legacy) | v1 (Mainstream) | v2 (Modern) | -|----------|--------------|----------------|-------------| -| Capability Negotiation | ❌ None | ✅ Introduced | ✅ Refined command-based negotiation | -| Protocol Framing | Raw Stream | pkt-line framing | pkt-line with structured commands | -| HTTP Support | Partial | Full | Optimized and proxy-friendly | -| Extensibility | Limited | Basic | Modular and command-oriented | -| Command Granularity | Combined flow | Sequential phases | Independent commands | -| Performance | Low | Medium | High (fewer RTTs, less data transfer) | -| Server Complexity | Simple | Moderate | Structured and modular | -| Status in Git Ecosystem | Deprecated | Widely used | Supported in modern Git servers (>=2.18) | - -#### Version Highlights - -##### **v0 (Legacy Smart Protocol)** -- Early “smart” mode over TCP or SSH before capability negotiation existed. -- Used simple request/response sequences (`upload-pack` / `receive-pack`) - without flexible negotiation or side-band streaming. -- Now considered obsolete and unsupported in most Git servers. - -##### **v1 (Current Mainstream)** -- Introduced in Git 1.7+. -- Added capability negotiation, side-band multiplexing, and better error mapping. -- Enables features such as: - - `multi_ack_detailed`, `side-band-64k`, `ofs-delta`, `report-status`, `no-done` -- Currently the most widely deployed version (e.g., GitHub, GitLab, Gitea). - -##### **v2 (Command-Based Modern Protocol)** -- Introduced in Git 2.18 (2018), designed for extensibility and performance. -- Transforms the protocol from stream-based negotiation to a **command-driven request model**: - - Client sends specific commands instead of phase-based state machines. - - Server replies with structured sections per command. -- Ideal for HTTP(S) transports and proxy environments. - - -### 9.4 Protocol v2 Negotiation and Commands - -#### 1. Initial Capability Exchange - -The client initiates a request with: -``` -GET /info/refs?service=git-upload-pack -Git-Protocol: version=2 -``` - -Server responds with a list of supported capabilities and commands: -``` -version 2 -ls-refs -fetch=filter -server-option -session-id=deadbeef -agent=git/2.45.0 -``` - -#### 2. Supported v2 Commands - -| Command | Purpose | Description | -|----------|----------|-------------| -| `ls-refs` | List references | Returns branches, tags, and HEAD information with filtering. | -| `fetch` | Clone / fetch data | Supports partial and shallow clones; incremental packfile delivery. | -| `push` | Push updates | Negotiates reference updates and receives new objects. | -| `server-option` | Custom server parameters | Allows additional server-side settings before command execution. | -| `agent` | Version identification | Reports client/server version for debugging and analytics. | - -#### 3. Example: v2 Fetch Flow - -``` -C: command=ls-refs -C: agent=git/2.45.0 -C: end -S: ref refs/heads/main 1234abcd -S: symref=HEAD refs/heads/main -S: end - -C: command=fetch -C: want 1234abcd -C: filter blob:none -C: done -S: packfile data... -S: end -``` - -### 9.5 Migration and Upgrade Recommendations - -#### For Protocol Implementers -- Maintain backward compatibility: support v1 and v2 simultaneously. -- Allow version negotiation via the `Git-Protocol` header or SSH command environment. -- Gradually deprecate v0 handling to reduce code complexity. - -#### For System Integrators -- Use v2 for large-scale repositories or latency-sensitive deployments. -- v2’s `ls-refs` and `fetch=filter` reduce bandwidth and server CPU usage. -- Implement adaptive fallback to v1 for older Git clients. - -#### For `git-internal` Library -- Current status: **Full v1 compliance** -- Recommended next steps: - 1. Introduce protocol negotiation layer detecting `Git-Protocol: version=2` - 2. Implement minimal `ls-refs` and `fetch` command handlers - 3. Extend `RepositoryAccess` and `GitProtocol` traits to support v2 command flow - 4. Add benchmark suite comparing v1 vs v2 RTT and bandwidth efficiency - -### 9.6 Summary - -| Category | v1 | v2 | -|-----------|----|----| -| Negotiation | Capabilities exchange via `info/refs` | Explicit version header (`Git-Protocol: version=2`) | -| Command Model | Sequential phase-based | Independent modular commands | -| Extensibility | Limited (capability list) | Open-ended (new commands possible) | -| Efficiency | Moderate | High (fewer round trips, less data) | -| Implementation Complexity | Medium | Higher but cleaner abstraction | - -## 10. Integration Guide - -### 10.1 Implementation Steps - -1. Implement RepositoryAccess Trait to connect storage system -2. Implement AuthenticationService Trait to connect authentication system -3. Create HTTP/SSH handler instances -4. Route requests to appropriate handlers in framework - -### 10.2 Design Principles - -- Separation of concerns: Protocol logic decoupled from business logic -- Interface abstraction: Pluggable architecture through Traits -- Transport agnostic: Same protocol logic supports multiple transports -- Performance focused: Streaming processing, memory efficient - -## 11. Summary - -The git-internal library successfully implements Git protocol transport layer abstraction, separating protocol handling from business logic through clear Trait interfaces. This design supports: - -- **Complete protocol implementation**: Full Git smart protocol v1 functionality -- **Flexible integration solution**: Can adapt to any storage and authentication system -- **Transport layer abstraction**: Unified HTTP and SSH handling -- **High-performance design**: Streaming processing and memory optimization diff --git a/docs/ai.md b/docs/ai.md deleted file mode 100644 index 111cbf56..00000000 --- a/docs/ai.md +++ /dev/null @@ -1,860 +0,0 @@ -# AI Object Model Reference - -This document describes the AI object model in git-internal: the types, -lifecycle, relationships, and usage patterns for AI-assisted code generation -workflows. All types live under `src/internal/object/`. - -## Overview - -The AI object model extends Git's native object types (Blob, Tree, Commit, Tag) -with a set of workflow objects that capture the full lifecycle of an AI-assisted -code change — from the initial user request to the final committed patch. - -``` -User input - │ - ▼ - Intent ──▶ Plan ──▶ Task ──▶ Run ──▶ PatchSet ──▶ Decision - │ - ├──▶ ToolInvocation (action log) - ├──▶ Evidence (validation results) - └──▶ Provenance (LLM metadata) -``` - -Context is tracked by two complementary mechanisms: - -- **ContextSnapshot** — static capture of files/URLs/snippets at Run start -- **ContextPipeline** — dynamic accumulation of incremental context frames - throughout execution - -## End-to-End Flow - -``` - ① User input - │ - ▼ - ② Intent (Draft → Active) - │ - ├──▶ ContextPipeline ← seeded with IntentAnalysis frame - │ - ▼ - ③ Plan (pipeline, fwindow, steps) - │ - ├─ PlanStep₀ (inline) - ├─ PlanStep₁ ──task──▶ sub-Task (recursive) - └─ PlanStep₂ (inline) - │ - ▼ - ④ Task (Draft → Running) - │ - ▼ - ⑤ Run (Created → Patching → Validating → Completed/Failed) - │ - ├──▶ Provenance (1:1, LLM config + token usage) - ├──▶ ContextSnapshot (optional, static context at start) - │ - │ ┌─── agent execution loop ───┐ - │ │ │ - │ │ ⑥ ToolInvocation (1:N) │ ← action log - │ │ │ │ - │ │ ▼ │ - │ │ ⑦ PatchSet (Proposed) │ ← candidate diff - │ │ │ │ - │ │ ▼ │ - │ │ ⑧ Evidence (1:N) │ ← test/lint/build - │ │ │ │ - │ │ ├─ pass ─────────────┘ - │ │ └─ fail → new PatchSet (retry within Run) - │ └────────────────────────────┘ - │ - ▼ - ⑨ Decision (terminal verdict) - │ - ├─ Commit → apply PatchSet, record result_commit - ├─ Retry → create new Run ⑤ for same Task - ├─ Abandon → mark Task as Failed - ├─ Checkpoint → save state, resume later - └─ Rollback → revert applied PatchSet - │ - ▼ - ⑩ Intent (Completed) ← commit recorded -``` - -### Steps - -1. **User input** — the user provides a natural-language request. - -2. **Intent** — captures the raw prompt and the AI's structured interpretation. - Status transitions from `Draft` (prompt only) to `Active` (analysis - complete). Supports conversational refinement via `parent` chain. - -3. **Plan** — a sequence of `PlanStep`s derived from the Intent. References a - `ContextPipeline` and records the visible frame range (`fwindow`). Steps - track consumed/produced frames by stable ID (`iframes`/`oframes`). A step may spawn a - sub-Task for recursive decomposition. Plans form a revision chain via - `previous`. - -4. **Task** — a unit of work with title, constraints, and acceptance criteria. - May link back to its originating Intent. Accumulates Runs in `runs` - (chronological execution history). - -5. **Run** — a single execution attempt of a Task. Records the baseline - `commit`, the Plan version being executed (snapshot reference), and the host - `environment`. A `Provenance` (1:1) captures the LLM configuration and - token usage. - -6. **ToolInvocation** — the finest-grained record: one per tool call (read - file, run command, etc.). Forms a chronological action log for the Run. - Tracks file I/O via `io_footprint`. - -7. **PatchSet** — a candidate diff generated by the agent. Contains the diff - `artifact`, file-level `touched` summary, and `rationale`. Starts as - `Proposed`; transitions to `Applied` or `Rejected`. Ordering is by - position in `Run.patchsets`. - -8. **Evidence** — output of a validation tool (test, lint, build) run against a - PatchSet. One per tool invocation. Carries `exit_code`, `summary`, and - `report_artifacts`. Feeds into the Decision. - -9. **Decision** — the terminal verdict of a Run. Selects a PatchSet to apply - (`Commit`), retries the Task (`Retry`), gives up (`Abandon`), saves - progress (`Checkpoint`), or reverts (`Rollback`). Records `rationale` - and `result_commit_sha`. - -10. **Intent completed** — the orchestrator records the final git commit in - `Intent.commit` and transitions status to `Completed`. - -## Object Relationship Summary - -| From | Field | To | Cardinality | -|------|-------|----|-------------| -| Intent | `parent` | Intent | 0..1 | -| Intent | `plan` | Plan | 0..1 | -| Plan | `previous` | Plan | 0..1 | -| Plan | `pipeline` | ContextPipeline | 0..1 | -| PlanStep | `task` | Task | 0..1 | -| Task | `parent` | Task | 0..1 | -| Task | `intent` | Intent | 0..1 | -| Task | `runs` | Run | 0..N | -| Task | `dependencies` | Task | 0..N | -| Run | `task` | Task | 1 | -| Run | `plan` | Plan | 0..1 | -| Run | `snapshot` | ContextSnapshot | 0..1 | -| Run | `patchsets` | PatchSet | 0..N | -| PatchSet | `run` | Run | 1 | -| Evidence | `run_id` | Run | 1 | -| Evidence | `patchset_id` | PatchSet | 0..1 | -| Decision | `run_id` | Run | 1 | -| Decision | `chosen_patchset_id` | PatchSet | 0..1 | -| Provenance | `run_id` | Run | 1 | -| ToolInvocation | `run_id` | Run | 1 | - -## Object Details - -### Intent (`intent.rs`) - -The **entry point** of every AI-assisted workflow. Captures the verbatim user -request (`prompt`) and the AI's structured interpretation (`content`). - -#### Status Transitions - -``` -Draft ──▶ Active ──▶ Completed - │ │ - └──────────┴──▶ Cancelled -``` - -- **Draft**: Prompt recorded, AI has not analyzed it yet. `content = None`. -- **Active**: AI interpretation available in `content`. Plan, Tasks, and Runs - may be in progress. -- **Completed**: All downstream work finished. `commit` contains the result - git commit hash. -- **Cancelled**: Abandoned before completion (user interrupt, timeout, budget). - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata (ID, type, timestamps, creator) | -| `prompt` | `String` | Verbatim user input, immutable after creation | -| `content` | `Option` | AI-analyzed interpretation; `None` in Draft | -| `parent` | `Option` | Predecessor Intent for conversational refinement | -| `commit` | `Option` | Result git commit, set at step ⑩ | -| `plan` | `Option` | Latest Plan revision derived from this Intent | -| `statuses` | `Vec` | Append-only status transition history | - -#### Conversational Refinement - -``` -Intent₀ ("Add pagination") - ▲ - │ parent -Intent₁ ("Also add cursor-based pagination") - ▲ - │ parent -Intent₂ ("Use opaque cursors, not offsets") -``` - -Each follow-up Intent links to its predecessor via `parent`, forming a -singly-linked list from newest to oldest. - -#### Usage - -```rust -use git_internal::internal::object::intent::{Intent, IntentStatus}; -use git_internal::internal::object::types::ActorRef; - -// 1. Create from user input -let actor = ActorRef::human("alice")?; -let mut intent = Intent::new(actor, "Add pagination to the user list API")?; -assert_eq!(intent.status(), &IntentStatus::Draft); - -// 2. AI analyzes the prompt -intent.set_content(Some("Add offset/limit pagination to GET /users".into())); -intent.set_status(IntentStatus::Active); - -// 3. After execution completes -intent.set_status_with_reason(IntentStatus::Completed, "All tasks finished"); -``` - ---- - -### Plan (`plan.rs`) - -A sequence of `PlanStep`s derived from an Intent's analyzed content. Defines -*what* to do (strategy and decomposition); `Run` handles *how* to execute. - -#### Revision Chain - -When the agent encounters obstacles, it creates a revised Plan via -`Plan::new_revision`. Each revision links back via `previous`: - -``` -Intent.plan ──▶ Plan_v3 (latest) - │ previous - ▼ - Plan_v2 - │ previous - ▼ - Plan_v1 (original, previous = None) -``` - -`Intent.plan` always points to the latest revision. Each `Run.plan` is a -**snapshot reference** that never changes. - -#### Context Range - -A Plan references a `ContextPipeline` via `pipeline` and records the visible -frame range `fwindow = (start, end)` — the half-open range `[start..end)` of -frames that were visible at creation time. - -``` -ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] - ^^^^^^^^^^^^^^^^ - fwindow = (0, 4) -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `previous` | `Option` | Predecessor Plan in revision chain | -| `pipeline` | `Option` | ContextPipeline used as context basis | -| `fwindow` | `Option<(u32, u32)>` | Visible frame range `[start..end)` | -| `steps` | `Vec` | Ordered sequence of steps | - -#### PlanStep - -Each step describes one unit of work within a Plan. - -| Field | Type | Description | -|-------|------|-------------| -| `description` | `String` | What this step should accomplish | -| `inputs` | `Option` | Expected inputs (JSON) | -| `outputs` | `Option` | Outputs after execution (JSON) | -| `checks` | `Option` | Validation criteria (JSON) | -| `iframes` | `Vec` | Stable pipeline frame IDs consumed (survives eviction) | -| `oframes` | `Vec` | Stable pipeline frame IDs produced (survives eviction) | -| `task` | `Option` | Sub-Task for recursive decomposition | -| `statuses` | `Vec` | Append-only status history | - -Step status transitions: - -``` -Pending ──▶ Progressing ──▶ Completed - │ │ - ├─────────────┴──▶ Failed - └──────────────────▶ Skipped -``` - -#### Recursive Decomposition - -A step can spawn a sub-Task via its `task` field: - -``` -Plan - ├─ Step₀ (inline — executed by current Run) - ├─ Step₁ ──task──▶ Task₁ - │ └─ Run → Plan - │ ├─ Step₁₋₀ - │ └─ Step₁₋₁ - └─ Step₂ (inline) -``` - -#### Usage - -```rust -use git_internal::internal::object::plan::{Plan, PlanStep}; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::agent("planner")?; - -// Create initial plan -let mut plan = Plan::new(actor.clone())?; -plan.set_pipeline(Some(pipeline_id)); -plan.set_fwindow(Some((0, 3))); - -// Add steps -let mut step = PlanStep::new("Refactor auth module"); -step.set_iframes(vec![0, 1]); // consumed frames 0 and 1 -plan.add_step(step); - -// Create a revision -let plan_v2 = plan.new_revision(actor)?; -assert_eq!(plan_v2.previous(), Some(plan.header().object_id())); -``` - ---- - -### Task (`task.rs`) - -A unit of work with constraints and acceptance criteria. The **stable -identity** for a piece of work — persists across retries and replanning. - -#### Status Transitions - -``` -Draft ──▶ Running ──▶ Done - │ │ - ├──────────┴──▶ Failed - └──────────────▶ Cancelled -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `title` | `String` | Short summary (< 100 chars) | -| `description` | `Option` | Extended context | -| `goal` | `Option` | Work classification (Feature, Bugfix, etc.) | -| `constraints` | `Vec` | Hard rules the solution must satisfy | -| `acceptance_criteria` | `Vec` | Testable success conditions | -| `requester` | `Option` | Who requested the work | -| `parent` | `Option` | Parent Task (for sub-Tasks) | -| `intent` | `Option` | Originating Intent | -| `runs` | `Vec` | Chronological execution history | -| `dependencies` | `Vec` | Tasks that must complete first | -| `status` | `TaskStatus` | Current lifecycle status | - -#### GoalType - -`Feature`, `Bugfix`, `Refactor`, `Docs`, `Perf`, `Test`, `Chore`, `Build`, -`Ci`, `Style`, `Other(String)`. - -#### Replanning - -The Task stays the same across Plan revisions. Each Run records which Plan -version it executed: - -``` -Task (constant) Intent (constant, plan updated) - │ └─ plan ──▶ Plan_v2 (latest) - └─ runs: - Run₀ ──plan──▶ Plan_v1 (snapshot: original plan) - Run₁ ──plan──▶ Plan_v2 (snapshot: revised plan) -``` - -#### Usage - -```rust -use git_internal::internal::object::task::{Task, GoalType}; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::human("user")?; -let mut task = Task::new(actor, "Refactor Login", Some(GoalType::Refactor))?; - -task.add_constraint("Must use JWT"); -task.add_acceptance_criterion("All tests pass"); -task.set_intent(Some(intent_id)); - -// After Run completes -task.add_run(run_id); -task.set_status(TaskStatus::Done); -``` - ---- - -### Run (`run.rs`) - -A single execution attempt of a Task. Captures the execution context -(baseline commit, environment, Plan version) and accumulates artifacts -(PatchSets, Evidence, ToolInvocations) during execution. - -#### Status Transitions - -``` -Created ──▶ Patching ──▶ Validating ──▶ Completed - │ │ - └──────────────┴──▶ Failed -``` - -- **Created**: Run initialized, environment captured. -- **Patching**: Agent is generating code changes / tool calls. -- **Validating**: Agent has produced a PatchSet and is running tests/lint. -- **Completed**: Decision has been created. -- **Failed**: Unrecoverable error. `Run.error` has details. - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `task` | `Uuid` | Owning Task (mandatory) | -| `plan` | `Option` | Plan version being executed (snapshot) | -| `commit` | `IntegrityHash` | Baseline git commit | -| `status` | `RunStatus` | Current lifecycle status | -| `snapshot` | `Option` | ContextSnapshot at Run start | -| `patchsets` | `Vec` | Candidate diffs (chronological) | -| `metrics` | `Option` | Execution metrics (JSON) | -| `error` | `Option` | Error message if Failed | -| `environment` | `Option` | Host OS/arch/cwd | - -#### Associated Objects (by `run_id`) - -| Object | Cardinality | Purpose | -|--------|-------------|---------| -| Provenance | 1:1 | LLM config + token usage | -| ToolInvocation | 1:N | Chronological action log | -| Evidence | 1:N | Validation results (test/lint/build) | -| Decision | 1:1 | Terminal verdict | - -#### Usage - -```rust -use git_internal::internal::object::run::Run; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::agent("orchestrator")?; -let mut run = Run::new(actor, task_id, "abc123def...")?; -run.set_plan(Some(plan_id)); - -// Agent generates patches -run.set_status(RunStatus::Patching); -run.add_patchset(patchset_id); - -// Validation phase -run.set_status(RunStatus::Validating); - -// Completed -run.set_status(RunStatus::Completed); -``` - ---- - -### PatchSet (`patchset.rs`) - -A candidate diff generated by the agent during a Run. The atomic unit of -code modification — every change the agent wants to make is packaged as a -PatchSet. - -#### Lifecycle - -``` - (created) ──▶ Proposed - │ - ┌──────────────┼──────────────┐ - │ │ │ - ▼ ▼ │ - Applied Rejected │ - │ │ - ▼ │ - agent generates new PatchSet - appended to Run.patchsets -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run` | `Uuid` | Owning Run | -| `commit` | `IntegrityHash` | Baseline commit this diff applies to | -| `format` | `DiffFormat` | `Unified` or `GitDiff` | -| `artifact` | `Option` | Reference to stored diff content | -| `touched` | `Vec` | File-level change summary | -| `rationale` | `Option` | Agent's explanation of what/why changed | -| `apply_status` | `ApplyStatus` | `Proposed`, `Applied`, or `Rejected` | - -#### Rationale - -The `rationale` field bridges the gap between the Task/Plan (high-level intent) -and the raw diff (low-level changes). It is primarily written by the agent and -may be overridden by a human reviewer via `set_rationale()`. - ---- - -### Evidence (`evidence.rs`) - -Output of a single validation step (test, lint, build) run against a PatchSet. -One Evidence per tool invocation. - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | Owning Run | -| `patchset_id` | `Option` | PatchSet being validated (`None` for run-level checks) | -| `kind` | `EvidenceKind` | `Test`, `Lint`, `Build`, or `Other(String)` | -| `tool` | `String` | Tool name (e.g. "cargo", "eslint", "pytest") | -| `command` | `Option` | Full command line for reproducibility | -| `exit_code` | `Option` | Process exit code (0 = success) | -| `summary` | `Option` | Short result summary | -| `report_artifacts` | `Vec` | Full report files (logs, coverage, JUnit XML) | - ---- - -### Decision (`decision.rs`) - -The **terminal verdict** of a Run. Created once per Run at the end of -execution. - -#### Decision Types - -| Type | Action | `chosen_patchset_id` | `result_commit_sha` | -|------|--------|---------------------|---------------------| -| `Commit` | Apply the chosen PatchSet | Set | Set after commit | -| `Checkpoint` | Save intermediate progress | — | — | -| `Abandon` | Give up on the Task | — | — | -| `Retry` | Create new Run for same Task | — | — | -| `Rollback` | Revert applied PatchSet | — | — | - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | The Run this Decision concludes | -| `decision_type` | `DecisionType` | Verdict (Commit/Retry/Abandon/etc.) | -| `chosen_patchset_id` | `Option` | Selected PatchSet (for Commit) | -| `result_commit_sha` | `Option` | Git commit hash after applying | -| `checkpoint_id` | `Option` | Saved state ID (for Checkpoint) | -| `rationale` | `Option` | Explanation of why this decision was made | - ---- - -### Provenance (`provenance.rs`) - -Records **how** a Run was executed: which LLM provider, model, and parameters -were used, and how many tokens were consumed. Created once per Run (1:1). - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | The Run this Provenance describes | -| `provider` | `String` | LLM provider ("openai", "anthropic", "local") | -| `model` | `String` | Model ID ("gpt-4", "claude-opus-4-20250514") | -| `parameters` | `Option` | Provider-specific raw parameters (JSON) | -| `temperature` | `Option` | Sampling temperature (0.0 = deterministic) | -| `max_tokens` | `Option` | Maximum generation length | -| `token_usage` | `Option` | Token consumption and cost | - -#### TokenUsage - -| Field | Type | Description | -|-------|------|-------------| -| `input_tokens` | `u64` | Tokens in the prompt/input | -| `output_tokens` | `u64` | Tokens in the completion/output | -| `total_tokens` | `u64` | `input_tokens + output_tokens` | -| `cost_usd` | `Option` | Estimated cost in USD | - ---- - -### ToolInvocation (`tool.rs`) - -The finest-grained record: one per tool call made by the agent during a Run. - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | Owning Run | -| `tool_name` | `String` | Registered tool name ("read_file", "bash", etc.) | -| `io_footprint` | `Option` | Files read/written | -| `args` | `Value` | Arguments (JSON, tool-dependent schema) | -| `status` | `ToolStatus` | `Ok` or `Error` | -| `result_summary` | `Option` | Short output summary | -| `artifacts` | `Vec` | Full output files | - -#### IoFootprint - -| Field | Type | Description | -|-------|------|-------------| -| `paths_read` | `Vec` | Files read (repo-relative) | -| `paths_written` | `Vec` | Files written (repo-relative) | - ---- - -### ContextSnapshot (`context.rs`) - -A **static** capture of the codebase and external resources the agent observed -when a Run began. Point-in-time — does not change after creation. - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `selection_strategy` | `SelectionStrategy` | `Explicit` (user-chosen) or `Heuristic` (auto-selected) | -| `items` | `Vec` | Context items (files, URLs, snippets, etc.) | -| `summary` | `Option` | Aggregated summary | - -#### ContextItem - -Each item has three layers: - -- **`path`** — human-readable locator (repo path, URL, command, label) -- **`blob`** — Git blob hash pointing to full content at capture time -- **`preview`** — truncated text for quick display - -| Kind | `path` example | `blob` content | -|------|----------------|----------------| -| `File` | `src/main.rs` | Same blob in git tree (zero extra storage) | -| `Url` | `https://docs.rs/...` | Fetched page content | -| `Snippet` | `"design notes"` | Snippet text | -| `Command` | `cargo test` | Command output | -| `Image` | `screenshot.png` | Image binary | - -#### Blob Retention - -Blobs referenced only by AI objects are not reachable in git's DAG and will be -pruned by `git gc`. For non-File items, applications must choose a retention -strategy: - -| Strategy | Approach | -|----------|----------| -| Ref anchoring | `refs/ai/blobs/` | -| Orphan commit | `refs/ai/uploads` tree | -| Keep pack | `.keep` marker on pack file | -| Custom GC mark | Scan AI objects during GC | - ---- - -### ContextPipeline (`pipeline.rs`) - -A **dynamic** context container that accumulates incremental `ContextFrame`s -throughout the workflow. Solves the context-forgetting problem in long-running -tasks. - -#### Lifecycle - -``` -Intent (Active) ← content analyzed - │ - ▼ -ContextPipeline created ← seeded with IntentAnalysis frame - │ - ▼ -Plan created ← Plan.pipeline → Pipeline, Plan.fwindow = range - │ steps execute - ▼ -Frames accumulate ← StepSummary, CodeChange, ToolCall, ... - │ - ▼ -Replan? → new Plan with updated fwindow -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `frames` | `Vec` | Chronologically ordered frames | -| `next_frame_id` | `u64` | Monotonic counter for stable frame IDs | -| `max_frames` | `u32` | Max frames before eviction (0 = unlimited) | -| `global_summary` | `Option` | Aggregated summary | - -#### ContextFrame - -| Field | Type | Description | -|-------|------|-------------| -| `frame_id` | `u64` | Stable monotonic ID (survives eviction) | -| `kind` | `FrameKind` | IntentAnalysis, StepSummary, CodeChange, etc. | -| `summary` | `String` | Compact human-readable summary | -| `data` | `Option` | Structured payload (JSON) | -| `created_at` | `DateTime` | When this frame was created | -| `token_estimate` | `Option` | Estimated tokens for budgeting | - -#### FrameKind - -| Kind | Protected | Description | -|------|-----------|-------------| -| `IntentAnalysis` | Yes | Seed frame from Intent analysis | -| `StepSummary` | No | Summary after a PlanStep completes | -| `CodeChange` | No | Code change digest (files, diff stats) | -| `SystemState` | No | System/environment state snapshot | -| `ErrorRecovery` | No | Error recovery context | -| `Checkpoint` | Yes | Explicit save-point | -| `ToolCall` | No | External tool invocation result | -| `Other(String)` | No | Application-defined | - -Protected frames survive eviction when `max_frames` is exceeded. - -#### Eviction - -When `max_frames > 0` and the limit is exceeded, `push_frame` removes the -oldest non-protected frame. `IntentAnalysis` and `Checkpoint` frames are -never evicted. - -#### Step-Frame Association - -Steps track their relationship to frames via stable frame IDs: - -``` -ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅] - │ │ ▲ - ╰────╯ │ - iframes=[0,1] oframes=[4] - ╰── Step₀ ──╯ -``` - -- `iframes` — stable `frame_id`s of frames the step consumed as context -- `oframes` — stable `frame_id`s of frames the step produced - -Frame IDs are monotonic integers assigned by `push_frame`. Unlike Vec -indices, IDs survive eviction — a step's `iframes` remain valid even -after older frames are removed. Look up frames via `frame_by_id`. - -All association is owned by the step; `ContextFrame` has no back-references. - -#### Usage - -```rust -use git_internal::internal::object::pipeline::{ContextPipeline, FrameKind}; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::agent("orchestrator")?; - -// 1. Create pipeline after Intent content is analyzed -let mut pipeline = ContextPipeline::new(actor)?; - -// 2. Seed with the Intent's analyzed content — returns stable frame_id -let seed_id = pipeline.push_frame( - FrameKind::IntentAnalysis, - "Add offset/limit pagination to GET /users with default page size 20", -); - -// 3. Create a Plan referencing this pipeline -// plan.set_pipeline(Some(pipeline.header().object_id())); -// plan.set_fwindow(Some((0, pipeline.frames().len() as u32))); - -// 4. As steps complete, push incremental frames -let step_id = pipeline.push_frame(FrameKind::StepSummary, "Refactored auth module"); - -// 5. Track on PlanStep side using stable frame IDs: -// step.set_iframes(vec![seed_id]); -// step.set_oframes(vec![step_id]); - -// 6. Look up frame by ID (survives eviction) -// pipeline.frame_by_id(seed_id); -``` - -## Common Header - -All AI objects share a common `Header` (flattened into the JSON via `#[serde(flatten)]`): - -| Field | Type | Description | -|-------|------|-------------| -| `object_id` | `Uuid` | Globally unique ID (UUID v7, time-ordered) | -| `object_type` | `ObjectType` | Discriminator (Intent, Plan, Task, etc.) | -| `header_version` | `u32` | Format version of the Header struct | -| `schema_version` | `u32` | Per-object-type schema version | -| `created_at` | `DateTime` | Creation timestamp | -| `updated_at` | `DateTime` | Last modification timestamp | -| `created_by` | `ActorRef` | Who created this object | -| `visibility` | `Visibility` | `Private` or `Public` | -| `tags` | `HashMap` | Free-form search tags | -| `external_ids` | `HashMap` | External ID mapping | -| `checksum` | `Option` | Content checksum (set by `seal()`) | - -### ActorRef - -Identifies who created or triggered an action: - -| Kind | Factory | Example | -|------|---------|---------| -| `Human` | `ActorRef::human("alice")` | A user | -| `Agent` | `ActorRef::agent("coder")` | An AI agent | -| `System` | `ActorRef::system("scheduler")` | Infrastructure | -| `McpClient` | `ActorRef::mcp_client("vscode")` | MCP client | - -### ArtifactRef - -Reference to external content stored outside the AI object: - -| Field | Type | Description | -|-------|------|-------------| -| `store` | `String` | Storage backend ("local", "s3") | -| `key` | `String` | Storage key or path | -| `content_type` | `Option` | MIME type | -| `size_bytes` | `Option` | Size in bytes | -| `hash` | `Option` | Content hash (SHA-256) | -| `expires_at` | `Option>` | Expiration time | - -Supports integrity verification via `verify_integrity(content)` and -content deduplication via `content_eq(other)`. - -## Serialization - -All AI objects implement `ObjectTrait` with JSON serialization: - -- **`from_bytes(data, hash)`** — deserialize from JSON bytes -- **`to_data()`** — serialize to JSON bytes -- **`get_type()`** — returns the `ObjectType` discriminator -- **`get_size()`** — returns the serialized size -- **`object_hash()`** — computes the content-addressable hash - -AI object types are registered in `ObjectType` with numeric IDs 8–18 and -string identifiers for serialization (e.g. `"intent"`, `"plan"`, `"task"`). - -> **Pack encoding**: AI objects cannot be encoded in standard Git pack headers -> (which only support 3-bit type values 1–7). They must use the AI-specific -> storage layer. `ObjectType::to_pack_type_u8()` returns an error for AI types. - -## Design Principles - -1. **Append-only history**: Status changes (Intent, PlanStep) and execution - records (Task.runs, Run.patchsets) are append-only — entries are never - removed or mutated. - -2. **Snapshot references**: Run.plan records the Plan version at execution - time. Intent.plan always points to the latest revision. This allows - replanning without invalidating in-progress Runs. - -3. **Bidirectional references**: Key relationships are bidirectional for - efficient traversal (Task ↔ Run, Run ↔ PatchSet). Other relationships - are unidirectional with reverse lookup by scanning (Evidence → Run, - Decision → Run). - -4. **Serde conventions**: Optional fields use `#[serde(default, skip_serializing_if)]`. - Empty Vecs use `skip_serializing_if = "Vec::is_empty"`. Field renames use - `#[serde(alias = "old_name")]` for backward compatibility. - -5. **Context separation**: Static context (ContextSnapshot) vs. dynamic - context (ContextPipeline) serve complementary needs — reproducibility - vs. continuity. diff --git a/src/internal/object/context.rs b/src/internal/object/context.rs index 0ff8b040..fbb5938e 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -10,12 +10,18 @@ //! # Position in Lifecycle //! //! ```text -//! ⑤ Run ──snapshot──▶ ContextSnapshot (optional, static) +//! ② Intent (Active) //! │ -//! ├──▶ ContextPipeline (dynamic, via Plan.pipeline) -//! │ -//! ▼ -//! ⑥ ToolInvocations ... +//! └─ plans → ContextPipeline (dynamic) +//! │ +//! │ ③ Plan references pipeline + fwindow +//! ▼ +//! ⑤ Run created +//! │ +//! └─ context snapshot captured ──▶ ContextSnapshot (optional, static) +//! │ +//! ▼ +//! Reproducible execution baseline //! ``` //! //! A ContextSnapshot is created at step ⑤ when the Run is initialized. @@ -140,6 +146,7 @@ pub enum ContextItemKind { /// module documentation for the three-layer design (`path` / `blob` / /// `preview`) and blob retention strategies. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ContextItem { /// The kind of content this item represents. Determines how /// `path` and `blob` should be interpreted. @@ -194,6 +201,7 @@ impl ContextItem { /// snippets, etc. the agent had access to. See module documentation /// for lifecycle position, item design, and blob retention. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ContextSnapshot { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] diff --git a/src/internal/object/decision.rs b/src/internal/object/decision.rs index 4ac7c017..519d160d 100644 --- a/src/internal/object/decision.rs +++ b/src/internal/object/decision.rs @@ -8,11 +8,18 @@ //! # Position in Lifecycle //! //! ```text -//! Task ──runs──▶ Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...] -//! │ -//! └──(terminal)──▶ Decision -//! ├── chosen_patchset ──▶ PatchSet -//! └── evidence (via Evidence.decision) +//! ⑤ Run +//! ├─ PatchSet* (⑦) +//! ├─ Evidence* (⑧) +//! └─▶ ⑨ Decision (terminal for this Run) +//! │ +//! ├─ Commit → applied patch recorded in Intent/Task context +//! ├─ Checkpoint→ saved progress +//! ├─ Retry → new Run for same Task +//! └─ Abandon/Rollback → stop or revert +//! │ +//! ▼ +//! ⑩ Intent terminalization //! ``` //! //! A Decision is created **once per Run**, at the end of execution. @@ -128,6 +135,7 @@ impl From<&str> for DecisionType { /// Created once per Run at the end of execution. See module /// documentation for lifecycle position and decision type semantics. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Decision { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] diff --git a/src/internal/object/evidence.rs b/src/internal/object/evidence.rs index d1d75633..ac625aaa 100644 --- a/src/internal/object/evidence.rs +++ b/src/internal/object/evidence.rs @@ -8,13 +8,13 @@ //! # Position in Lifecycle //! //! ```text -//! Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...] -//! │ │ -//! │ ▼ -//! └──────────────▶ Evidence (run_id + optional patchset_id) -//! │ -//! ▼ -//! Decision (uses Evidence to justify verdict) +//! ⑥ ToolInvocation / ⑦ PatchSet +//! │ │ +//! │ ▼ +//! └──────────▶ Evidence (run_id + optional patchset_id) +//! │ +//! ▼ +//! ⑨ Decision (verdict justification) //! ``` //! //! Evidence is produced **during** a Run, typically after a PatchSet is @@ -101,6 +101,7 @@ impl From<&str> for EvidenceKind { /// exist for the same PatchSet (one per validation tool). See module /// documentation for lifecycle position. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Evidence { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index 0a36218c..77b07755 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -1,77 +1,64 @@ -//! AI Intent Definition +//! AI Intent definition and lifecycle contract. //! -//! An [`Intent`] is the **entry point** of every AI-assisted workflow — it -//! captures the raw user request (`prompt`) and the AI's structured -//! interpretation of that request (`content`). The Intent is the first -//! object created (step ① → ②) and the last one completed (step ⑩) in -//! the end-to-end flow described in [`mod.rs`](super). +//! This module defines the `Intent` object used by the AI workflow and +//! keeps the raw user request anchored to all downstream objects. It +//! follows the same step chain in `docs/agent-workflow.md`: +//! `User Query -> Intent -> Plan -> Task -> Run -> (ToolInvocation, PatchSet, +//! Evidence) -> Decision -> Intent complete`. //! -//! # Position in Lifecycle +//! The workflow-level structured understanding is captured as `IntentSpec` +//! (`goal`, `constraints`, `risk_level`) and serialized in the `spec` +//! field. The full historical state is an append-only `statuses` vector; +//! each transition writes a new [`StatusEntry`] with an immutable timestamp. //! -//! ```text -//! ① User input (natural-language request) -//! │ -//! ▼ -//! ② Intent (Draft) ← prompt recorded, content = None -//! │ AI analysis -//! ▼ -//! Intent (Active) ← content filled, plan linked -//! │ -//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame -//! │ -//! ▼ -//! ③ Plan (derived from content) -//! │ -//! ▼ -//! ④–⑨ Task → Run → PatchSet → Evidence → Decision -//! │ -//! ▼ -//! ⑩ Intent (Completed) ← commit recorded -//! ``` -//! -//! ## Conversational Refinement +//! # Position in the AI workflow //! //! ```text -//! Intent₀ ("Add pagination") -//! ▲ -//! │ parent -//! Intent₁ ("Also add cursor-based pagination") -//! ▲ -//! │ parent -//! Intent₂ ("Use opaque cursors, not offsets") +//! User Query +//! │ +//! ▼ +//! ② Intent (Draft → Active) +//! │ +//! ├─ Draft: prompt captured, `spec=None` +//! └─ Active: AI has produced `IntentSpec` +//! └─ Direct link to ContextPipeline (`context_pipeline`) +//! +//! ▼ +//! ③ Plan (workflow decomposition) +//! │ +//! ▼ +//! ④ Task +//! │ +//! ▼ +//! ⑤ Run(s) +//! ├─ ⑥ ToolInvocation* +//! ├─ ⑦ PatchSet* +//! ├─ ⑧ Evidence* +//! └─ ⑨ Decision +//! │ +//! ▼ +//! ⑩ Intent terminal state (Completed / Cancelled) //! ``` //! -//! Each follow-up Intent links to its predecessor via `parent`, -//! forming a singly-linked list from newest to oldest. This -//! preserves the full conversational history without mutating -//! earlier Intents. -//! -//! ## Status Transitions +//! ## Conversational Refinement //! +//! `Intent` objects can form a parent chain to represent iterative user +//! refinement. Each follow-up `Intent` stores a `parent` UUID and does not +//! mutate its predecessor. //! ```text -//! Draft ──▶ Active ──▶ Completed -//! │ │ -//! └──────────┴──▶ Cancelled +//! Intent_N -> parent -> Intent_(N-1) -> ... -> Intent_0 //! ``` //! -//! Status changes are **append-only**: each transition pushes a -//! [`StatusEntry`] onto the `statuses` vector. The current status -//! is always the last entry. This design preserves the full -//! transition history with timestamps and optional reasons. -//! //! # Purpose //! -//! - **Traceability**: Links the original human request to all -//! downstream artifacts (Plan, Tasks, Runs, PatchSets). Reviewers -//! can trace any code change back to the Intent that motivated it. -//! - **Reproducibility**: Stores both the verbatim prompt and the -//! AI's interpretation, allowing re-analysis with different models -//! or parameters. -//! - **Conversational Context**: The `parent` chain captures iterative -//! refinement, so the agent can understand how the user's request -//! evolved over multiple exchanges. -//! - **Completion Tracking**: The `commit` field closes the loop by -//! recording which git commit satisfied the Intent. +//! - **Traceability**: links the original request to every downstream +//! object. +//! - **Reproducibility**: keeps both raw `prompt` and structured analysis in +//! `spec` for re-analysis with different models/parameters. +//! - **Risk-aware execution**: `risk_level` in `IntentSpec` is used by the +//! workflow to drive review thresholds and automation gates. +//! - **Completion closing**: `commit` records the exact repository commit +//! that satisfies the request when status reaches `Completed`. use std::fmt; @@ -85,6 +72,7 @@ use crate::{ internal::object::{ ObjectTrait, integrity::IntegrityHash, + pipeline::ContextPipeline, types::{ActorRef, Header, ObjectType}, }, }; @@ -93,7 +81,7 @@ use crate::{ /// /// Valid transitions (see module docs for diagram): /// -/// - `Draft` → `Active`: AI has analyzed the prompt and filled `content`. +/// - `Draft` → `Active`: AI has analyzed the prompt and filled `spec`. /// - `Active` → `Completed`: All downstream Tasks finished successfully /// and the result commit has been recorded in `Intent.commit`. /// - `Draft` → `Cancelled`: User abandoned the request before AI analysis. @@ -105,9 +93,9 @@ use crate::{ #[serde(rename_all = "snake_case")] pub enum IntentStatus { /// Initial state. The `prompt` has been captured but the AI has not - /// yet analyzed it — `Intent.content` is `None`. + /// yet analyzed it — `Intent.spec` is `None`. Draft, - /// AI interpretation is available in `Intent.content`. Downstream + /// AI interpretation is available in `Intent.spec`. Downstream /// objects (Plan, Tasks, Runs) may be in progress. Active, /// The Intent has been fully satisfied. `Intent.commit` should @@ -143,6 +131,7 @@ impl fmt::Display for IntentStatus { /// forming an append-only audit log. The current status is always /// `statuses.last().status`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct StatusEntry { /// The [`IntentStatus`] that was entered by this transition. status: IntentStatus, @@ -190,11 +179,30 @@ impl StatusEntry { /// The entry point of every AI-assisted workflow. /// /// An `Intent` captures both the verbatim user input (`prompt`) and the -/// AI's structured understanding of that input (`content`). It is +/// AI's structured understanding of that input (`spec`). It is /// created at step ② and completed at step ⑩ of the end-to-end flow. /// See module documentation for lifecycle position, status transitions, /// and conversational refinement. + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(transparent)] +pub struct IntentSpec(pub serde_json::Value); + +impl From for IntentSpec { + fn from(value: String) -> Self { + Self(serde_json::Value::String(value)) + } +} + +impl From<&str> for IntentSpec { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Intent { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -211,15 +219,15 @@ pub struct Intent { /// `None` while the Intent is in `Draft` status — the AI has not /// yet processed the prompt. Set to `Some(...)` when the AI /// completes its analysis, at which point the status should - /// transition to `Active`. The content typically includes: + /// transition to `Active`. The spec typically includes: /// - Disambiguated requirements /// - Identified scope (which files, modules, APIs are affected) /// - Inferred constraints or acceptance criteria /// - /// Unlike `prompt`, `content` is the AI's output and may be + /// Unlike `prompt`, `spec` is the AI's output and may be /// regenerated if the analysis is re-run. #[serde(default, skip_serializing_if = "Option::is_none")] - content: Option, + spec: Option, /// Link to a predecessor Intent for conversational refinement. /// /// Forms a singly-linked list from newest to oldest: each @@ -240,10 +248,16 @@ pub struct Intent { /// status should be `Completed`. #[serde(default, skip_serializing_if = "Option::is_none")] commit: Option, + /// Link to the [`ContextPipeline`](super::pipeline::ContextPipeline) + /// reserved for this Intent. + /// + /// `Intent::new` creates the pipeline eagerly and stores its ID + /// here for one-step traversal from Intent → ContextPipeline. + context_pipeline: Uuid, /// Link to the [`Plan`](super::plan::Plan) derived from this /// Intent. /// - /// Set after the AI analyzes `content` and produces a Plan at + /// Set after the AI analyzes `spec` and produces a Plan at /// step ③. Always points to the **latest** Plan revision — if /// the Plan is revised (via `Plan.previous` chain), this field /// is updated to the newest version. `None` while no Plan has @@ -267,18 +281,31 @@ pub struct Intent { impl Intent { /// Create a new intent in `Draft` status from a raw user prompt. /// - /// The `content` field is initially `None` — call [`set_content`](Intent::set_content) + /// A [`ContextPipeline`](super::pipeline::ContextPipeline) is + /// created first, then linked into the returned Intent. + /// + /// The `spec` field is initially `None` — call [`set_spec`](Intent::set_spec) /// after the AI has analyzed the prompt. - pub fn new(created_by: ActorRef, prompt: impl Into) -> Result { - Ok(Self { - header: Header::new(ObjectType::Intent, created_by)?, - prompt: prompt.into(), - content: None, - parent: None, - commit: None, - plan: None, - statuses: vec![StatusEntry::new(IntentStatus::Draft, None)], - }) + pub fn new( + created_by: ActorRef, + prompt: impl Into, + ) -> Result<(Self, ContextPipeline), String> { + let pipeline = ContextPipeline::new(created_by.clone())?; + let pipeline_id = pipeline.header().object_id(); + + Ok(( + Self { + header: Header::new(ObjectType::Intent, created_by)?, + prompt: prompt.into(), + spec: None, + parent: None, + commit: None, + context_pipeline: pipeline_id, + plan: None, + statuses: vec![StatusEntry::new(IntentStatus::Draft, None)], + }, + pipeline, + )) } /// Returns a reference to the common header. @@ -291,14 +318,18 @@ impl Intent { &self.prompt } - /// Returns the AI-analyzed content, if available. - pub fn content(&self) -> Option<&str> { - self.content.as_deref() + /// Returns the AI-analyzed spec, if available. + pub fn spec(&self) -> Option<&IntentSpec> { + self.spec.as_ref() } - /// Sets the AI-analyzed content. - pub fn set_content(&mut self, content: Option) { - self.content = content; + /// Sets the AI-analyzed spec. + pub fn set_spec(&mut self, spec: Option) { + let is_draft = matches!(self.status(), Some(&IntentStatus::Draft)); + self.spec = spec; + if self.spec.is_some() && is_draft { + self.set_status(IntentStatus::Active); + } } /// Returns the parent intent ID, if this is part of a refinement chain. @@ -311,6 +342,11 @@ impl Intent { self.commit.as_ref() } + /// Returns the linked ContextPipeline ID. + pub fn context_pipeline(&self) -> Uuid { + self.context_pipeline + } + /// Returns the current lifecycle status (the last entry in the history). /// /// Returns `None` only if `statuses` is empty, which should not @@ -340,6 +376,11 @@ impl Intent { self.plan } + /// Sets the linked ContextPipeline ID. + pub fn set_context_pipeline(&mut self, context_pipeline: Uuid) { + self.context_pipeline = context_pipeline; + } + /// Associates this intent with a [`Plan`](super::plan::Plan). pub fn set_plan(&mut self, plan: Option) { self.plan = plan; @@ -397,22 +438,26 @@ mod tests { #[test] fn test_intent_creation() { let actor = ActorRef::human("jackie").expect("actor"); - let mut intent = Intent::new(actor, "Refactor login flow").expect("intent"); + let (mut intent, pipeline) = Intent::new(actor, "Refactor login flow").expect("intent"); assert_eq!(intent.header().object_type(), &ObjectType::Intent); assert_eq!(intent.prompt(), "Refactor login flow"); - assert!(intent.content().is_none()); + assert!(intent.spec().is_none()); assert_eq!(intent.status(), Some(&IntentStatus::Draft)); assert!(intent.parent().is_none()); + assert_eq!(intent.context_pipeline(), pipeline.header().object_id()); assert!(intent.plan().is_none()); - intent.set_content(Some("Restructure the authentication module".to_string())); - assert_eq!( - intent.content(), - Some("Restructure the authentication module") - ); + let spec = IntentSpec(serde_json::json!({ + "objective": "Restructure the authentication module", + "scope": ["auth module"], + "constraints": ["keep API contract stable"] + })); + intent.set_spec(Some(spec.clone())); + assert_eq!(intent.spec(), Some(&spec)); + assert_eq!(intent.status(), Some(&IntentStatus::Active)); - // After content is analyzed, a Plan can be linked + // After spec is analyzed, a Plan can be linked let plan_id = Uuid::from_u128(0x42); intent.set_plan(Some(plan_id)); assert_eq!(intent.plan(), Some(plan_id)); @@ -421,7 +466,7 @@ mod tests { #[test] fn test_statuses() { let actor = ActorRef::human("jackie").expect("actor"); - let mut intent = Intent::new(actor, "Fix bug").expect("intent"); + let (mut intent, _) = Intent::new(actor, "Fix bug").expect("intent"); // Initial state: one Draft entry assert_eq!(intent.statuses().len(), 1); diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index 6f13719a..ded1a3dd 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -8,9 +8,15 @@ //! # Relationships //! //! ```text -//! Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...] +//! ⑤ Run +//! └─ patchsets ──▶ [PatchSet₀, PatchSet₁, ...] //! │ -//! └──run──▶ Run (back-reference) +//! └── run ──▶ Run (back-reference) +//! │ +//! └─ decision branch after validation +//! │ +//! ├─ evidence pass → Decision Commit +//! └─ evidence fail → emit new PatchSet //! ``` //! //! - **Run** (bidirectional): `Run.patchsets` holds the forward reference @@ -133,6 +139,7 @@ pub enum ChangeType { /// /// Provides a quick overview of what files are modified without parsing the full diff. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TouchedFile { pub path: String, pub change_type: ChangeType, @@ -166,6 +173,7 @@ impl TouchedFile { /// [`Run.patchsets`](super::run::Run). The PatchSet itself does not /// carry a generation number or supersession list. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PatchSet { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -182,7 +190,6 @@ pub struct PatchSet { /// `GitDiff` extends it with binary file support, rename detection, /// and mode-change headers. The orchestrator sets this at creation /// time based on the tool that generated the diff. - #[serde(alias = "diff_format")] format: DiffFormat, /// Reference to the actual diff content in object storage. /// @@ -191,7 +198,6 @@ pub struct PatchSet { /// `None` while the diff is still being generated; set once the /// agent finishes producing the patch. Consumers fetch the artifact, /// then interpret it according to `format`. - #[serde(alias = "diff_artifact")] artifact: Option, /// Lightweight summary of files modified in this PatchSet. /// @@ -201,7 +207,7 @@ pub struct PatchSet { /// downloading or parsing the full diff artifact. The list is /// populated incrementally as the agent produces changes and should /// be consistent with the actual diff content. - #[serde(default, alias = "touched_files")] + #[serde(default)] touched: Vec, /// Human-readable explanation of the changes in this PatchSet. /// diff --git a/src/internal/object/pipeline.rs b/src/internal/object/pipeline.rs index 94c23d70..93c2f02f 100644 --- a/src/internal/object/pipeline.rs +++ b/src/internal/object/pipeline.rs @@ -9,42 +9,43 @@ //! # Position in Lifecycle //! //! ```text -//! ② Intent (Active) ← content analyzed +//! ② Intent created (ContextPipeline created eagerly and retained) //! │ //! ▼ -//! ContextPipeline created ← seeded with IntentAnalysis frame +//! ContextPipeline +//! │ +//! ├─ seeded by [`IntentAnalysis`](FrameKind::IntentAnalysis) after spec +//! │ analysis //! │ //! ▼ -//! ③ Plan (Plan.pipeline → Pipeline, Plan.fwindow = visible range) -//! │ steps execute +//! ③ Plan references pipeline + fwindow +//! │ StepSummary / CodeChange / ToolCall frames appended //! ▼ -//! Frames accumulate ← StepSummary, CodeChange, ToolCall, ... +//! ④ Task/Run //! │ -//! ▼ -//! Replan? → new Plan with updated fwindow +//! └─ Replan? → new Plan with updated fwindow //! ``` //! -//! The pipeline is created *after* an Intent's content is analyzed -//! (step ②) but *before* a Plan exists. The initial -//! [`IntentAnalysis`](FrameKind::IntentAnalysis) frame captures the -//! AI's structured interpretation, which serves as the foundation -//! for Plan creation. The [`Plan`](super::plan::Plan) then references -//! this pipeline via `pipeline` and records the visible frame range -//! via `fwindow`. During execution, frames accumulate to track -//! step-by-step progress. +//! The pipeline is created when the Intent is created and linked from +//! `Intent.context_pipeline`. After the spec is analyzed, it is seeded +//! with a seed [`IntentAnalysis`](FrameKind::IntentAnalysis) frame, then +//! referenced by `Plan.pipeline` as the context basis for planning. //! //! # Relationship to Other Objects //! //! ```text -//! Intent ──plan──→ Plan ──pipeline──→ ContextPipeline -//! │ │ -//! [PlanStep₀, ...] [IntentAnalysis, StepSummary, ...] -//! │ ▲ -//! iframes/oframes ──────────────┘ +//! Intent ──context_pipeline──→ ContextPipeline +//! │ │ +//! └─plan────────────────────────────→ Plan ──pipeline──→ ContextPipeline +//! │ │ +//! [PlanStep₀, ...] [IntentAnalysis, StepSummary, ...] +//! │ ▲ +//! iframes/oframes ───────────────────────────────┘ //! ``` //! //! | From | Field | To | Notes | //! |------|-------|----|-------| +//! | Intent | `context_pipeline` | ContextPipeline | 1 | //! | Plan | `pipeline` | ContextPipeline | 0..1 | //! | PlanStep | `iframes` | ContextFrame IDs | consumed context | //! | PlanStep | `oframes` | ContextFrame IDs | produced context | @@ -94,9 +95,9 @@ use crate::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FrameKind { - /// Initial context derived from an Intent's analyzed content. + /// Initial context derived from an Intent's analyzed spec. /// - /// Created when the AI fills in the `content` field on an Intent, + /// Created when the AI fills in the `spec` field on an Intent, /// serving as the foundation for subsequent Plan creation. This /// is the **seed frame** — always the first frame in a pipeline. /// **Protected from eviction.** @@ -163,6 +164,7 @@ impl fmt::Display for FrameKind { /// them; that association is tracked on the step side via `iframes` /// and `oframes`. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ContextFrame { /// Stable monotonic identifier for this frame. /// @@ -263,13 +265,15 @@ impl ContextFrame { /// A dynamic context pipeline that accumulates /// [`ContextFrame`]s throughout an AI workflow. /// -/// Created when an [`Intent`](super::intent::Intent)'s content is -/// first analyzed, seeded with an -/// [`IntentAnalysis`](FrameKind::IntentAnalysis) frame. The -/// [`Plan`](super::plan::Plan) references this pipeline via +/// Created during [`Intent`](super::intent::Intent) creation and +/// retained for context continuity across planning and execution. +/// After the intent is analyzed (`spec` becomes available), it is +/// seeded with an [`IntentAnalysis`](FrameKind::IntentAnalysis) frame. +/// The [`Plan`](super::plan::Plan) then references this pipeline via /// `pipeline` as its context basis. See module documentation for /// lifecycle position, eviction rules, and purpose. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ContextPipeline { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -311,9 +315,10 @@ pub struct ContextPipeline { impl ContextPipeline { /// Create a new empty pipeline. /// - /// After creation, seed it with an [`IntentAnalysis`](FrameKind::IntentAnalysis) - /// frame, then create a [`Plan`](super::plan::Plan) that references this - /// pipeline via `pipeline`. + /// The returned pipeline has no frames initially. + /// It is expected to be seeded later after an + /// [`Intent`](super::intent::Intent) is analyzed and then + /// referenced by a [`Plan`](super::plan::Plan). pub fn new(created_by: ActorRef) -> Result { Ok(Self { header: Header::new(ObjectType::ContextPipeline, created_by)?, diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index 1b0a7400..ee2bcc6f 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -1,7 +1,7 @@ //! AI Plan Definition //! //! A [`Plan`] is a sequence of [`PlanStep`]s derived from an -//! [`Intent`](super::intent::Intent)'s analyzed content. It defines +//! [`Intent`](super::intent::Intent)'s analyzed spec. It defines //! *what* to do — the strategy and decomposition — while //! [`Run`](super::run::Run) handles *how* to execute it. The Plan is //! step ③ in the end-to-end flow described in [`mod.rs`](super). @@ -9,19 +9,18 @@ //! # Position in Lifecycle //! //! ```text -//! ② Intent (Active) ← content analyzed +//! ② Intent (Active, spec present) //! │ -//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame +//! ├─ seeds ContextPipeline with IntentAnalysis //! │ -//! ▼ -//! ③ Plan (pipeline, fwindow, steps) -//! │ -//! ├─ PlanStep₀ (inline) -//! ├─ PlanStep₁ ──task──▶ sub-Task (recursive) -//! └─ PlanStep₂ (inline) -//! │ -//! ▼ -//! ④ Task ──runs──▶ Run ──plan──▶ Plan (snapshot reference) +//! └─→ ③ Plan (pipeline, fwindow, steps) +//! │ +//! ├─ PlanStep₀ (inline) +//! ├─ PlanStep₁ ──task──▶ sub-Task +//! └─ PlanStep₂ (inline) +//! │ +//! ▼ +//! ④ Task (execution target) //! ``` //! //! # Revision Chain @@ -195,6 +194,7 @@ impl fmt::Display for StepStatus { /// Each transition appends a new entry; entries are never removed /// or mutated, forming an append-only audit log. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct StepStatusEntry { /// The [`StepStatus`] that was entered by this transition. status: StepStatus, @@ -230,12 +230,6 @@ impl StepStatusEntry { } } -/// Default for [`PlanStep::statuses`] when deserializing legacy data -/// that lacks the `statuses` field. -fn default_step_statuses() -> Vec { - vec![StepStatusEntry::new(StepStatus::Pending, None)] -} - /// A single step within a [`Plan`], describing one unit of work. /// /// Steps are executed in order by the agent. Each step can be either @@ -243,12 +237,11 @@ fn default_step_statuses() -> Vec { /// (spawning a sub-Task via the `task` field). See module documentation /// for context tracking and recursive decomposition details. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct PlanStep { /// Human-readable description of what this step should accomplish. /// - /// Set once at creation. The `alias = "intent"` supports legacy - /// serialized data where this field was named `intent`. - #[serde(alias = "intent")] + /// Human-readable description of the step. description: String, /// Expected inputs for this step as a JSON value. /// @@ -303,14 +296,9 @@ pub struct PlanStep { task: Option, /// Append-only chronological history of status transitions. /// - /// Initialized with a single `Pending` entry at creation. The - /// current status is always `statuses.last().status`. - /// - /// `#[serde(default)]` ensures backward compatibility with the - /// legacy schema that used a single `status: PlanStatus` field. - /// When deserializing old data that lacks `statuses`, the default - /// produces a single `Pending` entry. - #[serde(default = "default_step_statuses")] + /// Initialized with a single `Pending` entry in [`PlanStep::new`]. + /// This field is required when deserializing. Omit it only in + /// data produced by `PlanStep::new` during in-memory construction. statuses: Vec, } @@ -414,7 +402,7 @@ impl PlanStep { } } -/// A sequence of steps derived from an Intent's analyzed content. +/// A sequence of steps derived from an Intent's analyzed spec. /// /// A Plan is a pure planning artifact — it defines *what* to do, not /// *how* to execute. It is step ③ in the end-to-end flow. A @@ -422,6 +410,7 @@ impl PlanStep { /// execute it. See module documentation for revision chain, context /// range, and recursive decomposition details. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct Plan { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -440,7 +429,7 @@ pub struct Plan { /// The [`ContextPipeline`](super::pipeline::ContextPipeline) that /// served as the context basis for this Plan. /// - /// Set when the Plan is created from an Intent's analyzed content. + /// Set when the Plan is created from an Intent's analyzed spec. /// The pipeline contains the [`ContextFrame`](super::pipeline::ContextFrame)s /// that informed this Plan's decomposition. `None` when no pipeline /// was used (e.g. a manually created Plan). @@ -639,12 +628,12 @@ mod tests { } #[test] - fn test_plan_step_deserializes_legacy_intent_field() { + fn test_plan_step_deserializes_description_field() { let step: PlanStep = serde_json::from_value(json!({ - "intent": "run tests", + "description": "run tests", "statuses": [{"status": "pending", "changed_at": "2026-01-01T00:00:00Z"}] })) - .expect("deserialize legacy step"); + .expect("deserialize step"); assert_eq!(step.description(), "run tests"); } diff --git a/src/internal/object/provenance.rs b/src/internal/object/provenance.rs index 8808c0e3..9ee6bcc1 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -9,11 +9,13 @@ //! # Position in Lifecycle //! //! ```text -//! Run ──(1:1)──▶ Provenance -//! │ -//! ├── patchsets ──▶ [PatchSet₀, ...] -//! ├── evidence ──▶ [Evidence₀, ...] -//! └── decision ──▶ Decision +//! ⑤ Run +//! ├─ (1:1)──▶ Provenance (LLM config + token usage) +//! ├─ patchsets ──▶ [PatchSet₀, ...] +//! ├─ evidence ──▶ [Evidence₀, ...] +//! └─ decision ──▶ Decision +//! │ +//! └─ ties execution trace back to Intent / Task outcomes //! ``` //! //! A Provenance is created **once per Run**, typically at run start @@ -53,6 +55,7 @@ use crate::{ /// from different LLM providers (OpenAI, Anthropic, etc.) can be /// compared directly. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct TokenUsage { /// Number of tokens in the prompt / input. pub input_tokens: u64, @@ -85,6 +88,7 @@ impl TokenUsage { /// Created once per Run. See module documentation for lifecycle /// position and purpose. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Provenance { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] diff --git a/src/internal/object/run.rs b/src/internal/object/run.rs index f33d7c69..2c520efa 100644 --- a/src/internal/object/run.rs +++ b/src/internal/object/run.rs @@ -12,31 +12,31 @@ //! # Position in Lifecycle //! //! ```text -//! ④ Task ──runs──▶ [Run₀, Run₁, ...] -//! │ -//! ▼ +//! ④ Task +//! │ +//! └──runs──▶ [Run₀, Run₁, ...] +//! │ +//! ▼ //! ⑤ Run (Created → Patching → Validating → Completed/Failed) //! │ -//! ├──task──▶ Task (mandatory, 1:1) -//! ├──plan──▶ Plan (snapshot reference) -//! ├──snapshot──▶ ContextSnapshot (optional) +//! ├─ task ──▶ Task (mandatory, 1:1) +//! ├─ plan ──▶ Plan (snapshot reference) +//! ├─ snapshot ──▶ ContextSnapshot (optional, static) +//! ├─ provenance ──▶ Provenance (1:1, sibling) //! │ -//! │ ┌─── agent execution loop ───┐ -//! │ │ │ -//! │ │ ⑥ ToolInvocation (1:N) │ -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑦ PatchSet (Proposed) │ -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑧ Evidence (1:N) │ -//! │ │ │ │ -//! │ │ ├─ pass ─────────────┘ -//! │ │ └─ fail → new PatchSet -//! │ └────────────────────────────┘ -//! │ -//! ▼ -//! ⑨ Decision (terminal verdict) +//! │ ┌─── verification loop ───┐ +//! │ │ │ +//! │ │ ⑥ ToolInvocation (1:N) │ +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ ⑦ PatchSet (Proposed) │ +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ ⑧ Evidence (1:N) │ +//! │ │ │ │ +//! │ │ ├─ pass ───────┴────▶ ⑨ Decision +//! │ │ └─ fail → retry in same Run/next Run +//! │ └────────────────────────┘ //! ``` //! //! # Status Transitions @@ -140,6 +140,7 @@ impl fmt::Display for RunStatus { /// `extra` map allows capturing additional environment details /// (e.g. tool versions, environment variables) without schema changes. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Environment { /// Operating system identifier (e.g. "macos", "linux", "windows"). pub os: String, @@ -176,6 +177,7 @@ impl Environment { /// See module documentation for lifecycle, relationships, and /// status transitions. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Run { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] diff --git a/src/internal/object/task.rs b/src/internal/object/task.rs index 7aece93a..285f22cf 100644 --- a/src/internal/object/task.rs +++ b/src/internal/object/task.rs @@ -9,15 +9,18 @@ //! //! ```text //! ③ Plan ──steps──▶ [PlanStep₀, PlanStep₁, ...] -//! │ -//! ├─ inline (no task) -//! └─ task ──▶ ④ Task -//! │ -//! ├──▶ Run₀ ──plan──▶ Plan_v1 -//! ├──▶ Run₁ ──plan──▶ Plan_v2 -//! │ -//! ▼ -//! ⑤ Run (execution) +//! │ +//! ├─ inline (no task) +//! └─ task ──▶ ④ Task +//! │ +//! ├──▶ Run₀ ──plan──▶ Plan_v1 +//! ├──▶ Run₁ ──plan──▶ Plan_v2 +//! │ +//! ▼ +//! ⑤ Run (execution loop) +//! │ +//! ├─ ToolInvocation* → PatchSet* → Evidence* +//! └─ Decision //! ``` //! //! # Status Transitions @@ -203,6 +206,7 @@ impl FromStr for GoalType { /// See module documentation for lifecycle, relationships, and /// replanning semantics. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Task { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -511,9 +515,7 @@ mod tests { "object_type": "task", "schema_version": 1, "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z", "created_by": {"kind": "human", "id": "jackie"}, - "visibility": "private", "title": "old task", "status": "draft" }); diff --git a/src/internal/object/tool.rs b/src/internal/object/tool.rs index 364a45c6..b94864fd 100644 --- a/src/internal/object/tool.rs +++ b/src/internal/object/tool.rs @@ -9,13 +9,12 @@ //! # Position in Lifecycle //! //! ```text -//! Run ──patchsets──▶ [PatchSet₀, ...] -//! │ -//! ├── evidence ──▶ [Evidence₀, ...] -//! │ -//! └── tool invocations ──▶ [ToolInvocation₀, ToolInvocation₁, ...] -//! │ -//! └── io_footprint (paths read/written) +//! ⑤ Run +//! └─ execution loop ──▶ [ToolInvocation₀, ToolInvocation₁, ...] (⑥) +//! │ +//! ├─ updates io_footprint (paths read/written) +//! ├─ supports PatchSet generation (⑦) +//! └─ feeds Evidence (⑧) //! ``` //! //! ToolInvocations are produced **throughout** a Run, one per tool @@ -79,6 +78,7 @@ impl fmt::Display for ToolStatus { /// Used for dependency tracking (which inputs influenced which /// outputs) and for cache invalidation on incremental re-runs. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct IoFootprint { /// Paths the tool read during execution (e.g. source files, /// config files). Relative to the repository root. @@ -96,6 +96,7 @@ pub struct IoFootprint { /// ToolInvocations within a Run forms the agent's action log. See /// module documentation for lifecycle position. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ToolInvocation { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index 00faf536..a95e5b8c 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -3,29 +3,15 @@ //! This module defines the common metadata header shared by all AI process objects //! and the object type enumeration used across pack/object modules. -use std::{ - collections::HashMap, - fmt::{self, Display}, -}; +use std::fmt::{self, Display}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::integrity::{IntegrityHash, compute_integrity_hash}; +use super::integrity::IntegrityHash; use crate::errors::GitError; -/// Visibility of an AI process object. -/// -/// Determines whether the object is accessible only within the project (Private) -/// or can be shared externally (Public). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Visibility { - Private, - Public, -} - /// In Git, each object type is assigned a unique integer value, which is used to identify the /// type of the object in Git repositories. /// @@ -41,7 +27,7 @@ pub enum Visibility { /// is stored as a hash of the base object. /// /// By assigning unique integer values to each Git object type, Git can easily and efficiently -/// identify the type of an object and perform the appropriate operations on it. when parsing a Git +/// identify the type of an object and perform the appropriate operations on it. When parsing a Git /// repository, Git can use the integer value of an object's type to determine how to parse /// the object's content. #[derive(PartialEq, Eq, Hash, Debug, Clone, Copy, Serialize, Deserialize)] @@ -83,7 +69,7 @@ const INTENT_OBJECT_TYPE: &[u8] = b"intent"; const TOOL_INVOCATION_OBJECT_TYPE: &[u8] = b"invocation"; const CONTEXT_PIPELINE_OBJECT_TYPE: &[u8] = b"pipeline"; -/// Display trait for Git objects type +/// Display conversion for object type values. impl Display for ObjectType { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { @@ -109,7 +95,7 @@ impl Display for ObjectType { } } -/// Display trait for Git objects type +/// Object type conversion helpers. impl ObjectType { /// Convert object type to 3-bit pack header type id. /// @@ -174,7 +160,7 @@ impl ObjectType { } } - /// Parses a string representation of a Git object type and returns an ObjectType value + /// Parse a string representation of a Git object type and return an ObjectType value. pub fn from_string(s: &str) -> Result { match s { "blob" => Ok(ObjectType::Blob), @@ -304,7 +290,7 @@ impl ObjectType { } } -/// Actor kind enum +/// Actor kind enum. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ActorKind { @@ -355,13 +341,13 @@ impl From<&str> for ActorKind { /// Actor reference (who created/triggered). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ActorRef { - /// Kind: human/agent/system/mcp_client + /// Actor kind: human, agent, system, mcp_client, or other. kind: ActorKind, - /// Subject ID (user/agent name or client ID) + /// Subject ID (user name, agent name, or client ID). id: String, - /// Display name (optional) + /// Display name (optional). display_name: Option, - /// Auth context (optional, Libra usually empty) + /// Auth context (optional). auth_context: Option, } @@ -438,20 +424,20 @@ impl ActorRef { } } -/// Artifact reference (external content). +/// External content artifact reference. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ArtifactRef { - /// Store type: local_fs/s3 + /// Store type (for example, local_fs or s3). store: String, - /// Storage key (e.g., path or object key) + /// Storage key (for example, file path or object key). key: String, - /// MIME type (optional) + /// MIME type (optional). content_type: Option, - /// Size in bytes (optional) + /// Size in bytes (optional). size_bytes: Option, - /// Content hash (strongly recommended) + /// Content hash (strongly recommended). hash: Option, - /// Expiration time (optional) + /// Expiration time (optional). expires_at: Option>, } @@ -529,7 +515,7 @@ impl ArtifactRef { self.expires_at = expires_at; } - /// Verify if the provided content matches the stored checksum + /// Verify whether the provided content matches the stored checksum. #[must_use = "handle integrity verification result"] pub fn verify_integrity(&self, content: &[u8]) -> Result { let stored_hash = self @@ -540,7 +526,7 @@ impl ArtifactRef { Ok(IntegrityHash::compute(content) == *stored_hash) } - /// Check if two artifacts have the same content based on checksum + /// Check whether two artifacts have the same content based on checksum. #[must_use] pub fn content_eq(&self, other: &Self) -> Option { match (&self.hash, &other.hash) { @@ -549,7 +535,7 @@ impl ArtifactRef { } } - /// Check if the artifact has expired + /// Check whether the artifact has expired. #[must_use] pub fn is_expired(&self) -> bool { if let Some(expires_at) = self.expires_at { @@ -560,14 +546,21 @@ impl ArtifactRef { } } +/// Header version compatibility helper. +/// Keeps behavior unchanged while preserving a safe default when older serialized data +/// omits `header_version`. fn default_header_version() -> u32 { - 1 + if CURRENT_HEADER_VERSION == 1 { + CURRENT_HEADER_VERSION + } else { + 1 + } } /// Current header format version for newly created objects. pub const CURRENT_HEADER_VERSION: u32 = 1; -/// Header shared by all AI Process Objects. +/// Header shared by all AI objects. /// /// Contains standard metadata like ID, type, creator, and timestamps. /// @@ -586,49 +579,32 @@ pub const CURRENT_HEADER_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Header { - /// Global unique ID (UUID v7) + /// Global unique ID (UUID v7). object_id: Uuid, - /// Object type (task/run/patchset/...) + /// Object type (for example, task, run, patchset). object_type: ObjectType, /// Format version of the Header struct itself. - /// Defaults to 1 when deserializing old data that lacks this field. + /// Defaults to 1 only when processing compatibility paths for + /// pre-compat versions. #[serde(default = "default_header_version")] header_version: u32, /// Per-object-type schema version for body fields. schema_version: u32, - /// Creation time + /// Creation time. created_at: DateTime, - /// Last modification time. - /// - /// When deserializing legacy data that lacks this field, falls back - /// to `created_at` for deterministic behavior (see custom - /// `Deserialize` impl below). - updated_at: DateTime, - /// Creator + /// Creator. created_by: ActorRef, - /// Visibility (fixed to private for Libra) - visibility: Visibility, - /// Search tags - #[serde(default)] - tags: HashMap, - /// External ID mapping - #[serde(default)] - external_ids: HashMap, - /// Content checksum (optional) - #[serde(default)] - checksum: Option, } -/// Custom `Deserialize` for [`Header`] so that a missing `updated_at` -/// falls back to `created_at` instead of `Utc::now()`. This avoids -/// nondeterministic metadata when loading legacy objects that predate -/// the `updated_at` field. +/// Custom `Deserialize` for [`Header`] that materializes only +/// current header fields. impl<'de> Deserialize<'de> for Header { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct RawHeader { object_id: Uuid, object_type: ObjectType, @@ -636,15 +612,7 @@ impl<'de> Deserialize<'de> for Header { header_version: u32, schema_version: u32, created_at: DateTime, - updated_at: Option>, created_by: ActorRef, - visibility: Visibility, - #[serde(default)] - tags: HashMap, - #[serde(default)] - external_ids: HashMap, - #[serde(default)] - checksum: Option, } let raw = RawHeader::deserialize(deserializer)?; @@ -654,12 +622,7 @@ impl<'de> Deserialize<'de> for Header { header_version: raw.header_version, schema_version: raw.schema_version, created_at: raw.created_at, - updated_at: raw.updated_at.unwrap_or(raw.created_at), created_by: raw.created_by, - visibility: raw.visibility, - tags: raw.tags, - external_ids: raw.external_ids, - checksum: raw.checksum, }) } } @@ -679,12 +642,7 @@ impl Header { header_version: CURRENT_HEADER_VERSION, schema_version: 1, created_at: now, - updated_at: now, created_by, - visibility: Visibility::Private, - tags: HashMap::new(), - external_ids: HashMap::new(), - checksum: None, }) } @@ -708,34 +666,10 @@ impl Header { self.created_at } - pub fn updated_at(&self) -> DateTime { - self.updated_at - } - pub fn created_by(&self) -> &ActorRef { &self.created_by } - pub fn visibility(&self) -> &Visibility { - &self.visibility - } - - pub fn tags(&self) -> &HashMap { - &self.tags - } - - pub fn tags_mut(&mut self) -> &mut HashMap { - &mut self.tags - } - - pub fn external_ids(&self) -> &HashMap { - &self.external_ids - } - - pub fn external_ids_mut(&mut self) -> &mut HashMap { - &mut self.external_ids - } - pub fn set_object_id(&mut self, object_id: Uuid) { self.object_id = object_id; } @@ -764,40 +698,6 @@ impl Header { pub fn set_created_at(&mut self, created_at: DateTime) { 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; - } - - /// Accessor for checksum - pub fn checksum(&self) -> Option<&IntegrityHash> { - self.checksum.as_ref() - } - - /// Seal the header by calculating and setting the checksum of the provided object. - /// The checksum field is temporarily cleared to keep sealing idempotent. - /// Also updates `updated_at` to the current time, since sealing - /// represents a semantic modification of the object. - /// - /// 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(); - match compute_integrity_hash(object) { - Ok(checksum) => { - self.checksum = Some(checksum); - self.updated_at = Utc::now(); - Ok(()) - } - Err(err) => { - self.checksum = previous_checksum; - Err(err) - } - } - } } #[cfg(test)] @@ -905,9 +805,7 @@ mod tests { "object_type": "task", "schema_version": 1, "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z", - "created_by": {"kind": "human", "id": "jackie"}, - "visibility": "private" + "created_by": {"kind": "human", "id": "jackie"} }"#; let header: Header = serde_json::from_str(json).unwrap(); assert_eq!(header.header_version(), 1); @@ -1011,34 +909,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_header_seal() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut header = Header::new(ObjectType::Task, actor).expect("header"); - - let content = serde_json::json!({"key": "value"}); - header.seal(&content).expect("seal"); - - assert!(header.checksum().is_some()); - let expected = - crate::internal::object::integrity::compute_integrity_hash(&content).expect("checksum"); - assert_eq!(header.checksum().expect("checksum"), &expected); - } - - #[test] - fn test_header_updated_at_on_seal() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut header = Header::new(ObjectType::Task, 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, " "); From 20edaea076a9e0d38759517b19afc6a012f0e426 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 5 Mar 2026 01:21:36 +0800 Subject: [PATCH 2/9] docs: add agent and protocol documentation set Signed-off-by: Quanyi Ma --- docs/agent-workflow.md | 265 ++++++ docs/agent.md | 860 ++++++++++++++++++ ...ical-report-on-sha-256-hash-computation.md | 593 ++++++++++++ docs/git-object.md | 71 ++ docs/git-protocol-guide.md | 319 +++++++ 5 files changed, 2108 insertions(+) create mode 100644 docs/agent-workflow.md create mode 100644 docs/agent.md create mode 100644 docs/analytical-report-on-sha-256-hash-computation.md create mode 100644 docs/git-object.md create mode 100644 docs/git-protocol-guide.md diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md new file mode 100644 index 00000000..5f3f6de5 --- /dev/null +++ b/docs/agent-workflow.md @@ -0,0 +1,265 @@ +# Agent Workflow: From Intent to Release + +This document outlines the lifecycle of an AI agent task execution, from initial user query to final code release. The process is designed to be robust, secure, and verifiable, incorporating automated planning, execution, verification, and auditing. + +## Workflow Overview + +```mermaid +graph TD + UserQuery[User Query] --> Phase0[Phase 0: Input Preprocessing] + Phase0 --> Phase1[Phase 1: Planning] + Phase1 --> Phase2[Phase 2: Execution] + Phase2 --> Phase3[Phase 3: Validation] + Phase3 --> Phase4[Phase 4: Release] +``` + +```text +══════════════════════════════════════════════════════ + Phase 0: Input Preprocessing + ══════════════════════════════════════════════════════ + User Query + ↓ + ├─ Extract Intent, Constraints, Quality Goals + ├─ Identify Risk Level (Affects Human-in-the-loop threshold) + └─ Standardized Output: IntentSpec { goal, constraints, risk_level } + ↓ + ├─ Create Isolated Sandbox Environment + ├─ Initialize ContextState (Codebase Snapshot + Dep Graph + Type Info) + └─ Lightweight SCA Baseline Scan (Record initial dependency security state) + ══════════════════════════════════════════════════════ + Phase 1: Planning & Task Graph Generation + ══════════════════════════════════════════════════════ + IntentSpec + ContextState + ↓ + [Orchestrator Agent] + ├─ Generate TaskGraph (DAG) + │ Each Task includes: + │ - task_id, description + │ - dependencies: [task_id] + │ - expected_outputs (Interface contracts for parallel task negotiation) + │ - risk_score (Determines if manual review is needed) + └─ Output ExecutionPlan { task_graph, parallel_groups, checkpoints } + ══════════════════════════════════════════════════════ + Phase 2: Task Execution (Main Loop) + ══════════════════════════════════════════════════════ + Scheduler drives execution in topological order, running independent tasks in parallel: + For each ready Task (parallel where dependencies allow): + ┌─ [Context Compaction] + │ ├─ Load verified state from prerequisite tasks + │ ├─ If parallel branches converge: Merge Contexts + │ │ Conflict Detection → Conflict Exists → + │ │ ├─ Auto-resolvable → Merge & Continue + │ │ └─ Unresolvable → Suspend → Human Review + │ └─ Inject Incremental ContextDelta (Not full rebuild) + │ + ├─ [Context Injection] + │ ├─ Semantic Retrieval of code snippets, docs, historical PatchSets + │ └─ Inject Task-level Security Constraints + │ + ├─ [Coder Agent: Code Generation] + │ └─ ToolInvocation → Generate/Modify code in Sandbox + │ + ├─ [Verification Loop] (3 Layers) + │ │ + │ ├─ Layer 1: Static Verification + │ │ ├─ Compilation Check + │ │ ├─ Linter / Type Check + │ │ ├─ Lightweight SAST (Scan only changed files) + │ │ └─ Incremental SCA (Check only new/changed deps) + │ │ + │ ├─ Layer 2: Dynamic Verification + │ │ ├─ Unit Tests (Sandbox) + │ │ └─ Logic & Spec Review + │ │ Issue Found → Generate ReviewComment → Retry + │ │ + │ └─ Layer 3: Retry Control + │ ├─ Any Layer Fails → Diagnose + │ │ └─ Generate Fix Prompt → Return to Code Generation + │ │ Retry Count < N → Continue + │ │ Retry Count = N → [Final Failure Routing]: + │ │ ├─ Downgradable (Non-core Task) → Skip + Warning + │ │ ├─ Replannable (Bypassable dep) → Trigger Replan + │ │ └─ Blocking Failure → Suspend → Human Intervention + │ └─ All Layers Pass → Task Verified + │ + └─ [Staging & Provenance] + ├─ Write PatchSet to Task Staging Area (Not merged to Main Sandbox yet) + ├─ Update ContextState: ContextState.apply_delta(task_id, patch) + └─ Record Task Provenance (Input Snapshot, Exec Chain, Output Hash) + ─── After Parallel Task Group Completes ────────────────── + [Incremental Integration Verification] (Post-batch, not per-task) + ├─ Merge all staging PatchSets in batch → Main Sandbox + ├─ Run Interface Contract Verification (Check expected_outputs) + ├─ Run Cross-module Integration Test Subset + └─ Validation Failed → (Replan remaining tasks) + (Triggered after each batch integration) + └─ Evaluate if prerequisites for remaining tasks still hold + Needed → Revise TaskGraph (Modify unexecuted parts only) + Not Needed → Continue to next batch + ══════════════════════════════════════════════════════ + Phase 3: System-level Verification & Security Audit + ══════════════════════════════════════════════════════ + [Global Validation] + ├─ End-to-End Integration Tests + ├─ Performance Benchmarks (Compare vs Baseline, Regression Check) + └─ Compatibility Tests (API Backward Compatibility, Data Formats) + ↓ + ├─ Full SAST (Cover all changed files) + ├─ Full SCA (Complete dependency tree review) + ├─ IDOR / Injection Risk Scans + └─ Compliance Check (License, Privacy Data Handling) + │ + ↓ Issue Found → Locate Task → Trigger Task-level Fix (Back to Phase 2) + ↓ All Pass + (Generated only after success) + ├─ Aggregate all Task Provenance → Batch-level Complete Exec Chain + ├─ Hash & Sign Full ContextState Snapshot + ├─ Record: IntentSpec → TaskGraph → PatchSets → TestResults → SecurityReport + └─ Write to Append-only Audit Log (Support rollback to any Task boundary) + [Documentation Generation] + ├─ Auto-generate Change Log based on PatchSet + Task Description + └─ Update API Docs, CHANGELOG + ══════════════════════════════════════════════════════ + Phase 4: Decision & Release + ══════════════════════════════════════════════════════ + ├─ Aggregate Risk Score (IntentSpec.risk_level + Security Report + Scope) + │ + ├─ Low Risk → Auto-Merge + │ └─ commit → Production Repo + Sync Docs + Trigger CD Pipeline + │ + └─ High Risk → Human-in-the-loop Review + ├─ Show: Change Summary, Exec Chain, Security Report, Impact Analysis + ├─ Options: Approve / Reject / Request Changes (Re-exec) + └─ Approve → commit → Production Repo + Sync Docs + Trigger CD Pipeline +``` + +## Phase 0: Input Preprocessing + +The entry point where raw user input is transformed into a structured, actionable specification. + +1. **Intent Extraction**: + - Analyzes the `User Query` to identify the functional intent, specific constraints, and quality goals. + - Outputs a structured `IntentSpec` containing `{ goal, constraints, risk_level }`. + +2. **Risk Assessment**: + - Evaluates the potential impact of the request. + - Assigns a risk level (Low, Medium, High) which determines the threshold for human intervention in later stages. + +3. **Environment Setup**: + - **Sandbox Creation**: Establishes an isolated environment to prevent side effects during execution. + - **Context Initialization**: Captures a snapshot of the codebase, builds a dependency graph, and extracts type information (`ContextState`). + - **Baseline SCA**: Performs a lightweight Software Composition Analysis to record the initial security posture of dependencies. + +## Phase 1: Planning & Task Graph Generation + +The Orchestrator Agent translates the high-level intent into a concrete execution plan. + +1. **Task Graph Generation**: + - The Orchestrator takes the `IntentSpec` and `ContextState`. + - Generates a **TaskGraph** (Directed Acyclic Graph). + - Each Task includes: + - `task_id`, `description`. + - `dependencies`: List of prerequisite task IDs. + - `expected_outputs`: Interface contracts for boundary negotiation between parallel tasks. + - `risk_score`: Determines if the specific task requires manual review. + +2. **Execution Plan Output**: + - Produces an `ExecutionPlan` containing the `task_graph`, identified `parallel_groups`, and necessary `checkpoints`. + +## Phase 2: Task Execution (Main Loop) + +The core execution phase where the Scheduler drives the workflow. Tasks are executed in topological order, with independent tasks running in parallel. + +### For each ready Task (or Parallel Group): + +1. **Context Compaction**: + - Loads the verified state from prerequisite tasks. + - **Merge Contexts**: If multiple parallel branches converge, their contexts are merged. + - *Conflict Detection*: If conflicts arise, the system attempts automatic resolution. If unsolvable, it suspends for Human Review. + - Injects the incremental `ContextDelta` rather than rebuilding the full context. + +2. **Context Injection**: + - Retrieves semantically relevant code snippets, documentation, and historical PatchSets. + - Injects task-specific security constraints and guidelines into the prompt. + +3. **Code Generation (Coder Agent)**: + - The Coder Agent invokes tools to generate or modify code within the sandbox. + +4. **Verification Loop (Three Layers)**: + - **Layer 1: Static Verification**: + - Compilation checks. + - Linter and type checking. + - Lightweight SAST (Static Application Security Testing) on changed files. + - Incremental SCA on new/changed dependencies. + - **Layer 2: Dynamic Verification**: + - Runs unit tests in the isolated sandbox. + - Performs logical and specification reviews. + - *Failure*: Generates a ReviewComment and enters the retry loop. + - **Layer 3: Retry Control**: + - If any layer fails, the system diagnoses the error and generates a correction prompt. + - *Retry Logic*: Returns to Code Generation up to `N` times. + - *Final Failure Routing*: + - **Downgrade**: If non-core, skip and record a warning. + - **Replan**: If dependencies can be bypassed, trigger a replan. + - **Block**: If critical, suspend for Human Intervention. + - *Success*: Task is marked as verified. + +5. **Staging & Provenance**: + - Writes the resulting `PatchSet` to a task-specific staging area (not yet merged to the main sandbox). + - Updates `ContextState` via `apply_delta(task_id, patch)`. + - Records **Task Provenance**: Input snapshot, execution chain, and output hash. + +### Incremental Integration (Post-Batch) + +After a group of parallel tasks completes: + +1. **Merge & Verify**: + - Merges all staging PatchSets from the batch into the main sandbox. + - Validates `expected_outputs` against the interface contracts. + - Runs a subset of cross-module integration tests. + - *Failure*: Triggers replanning for remaining tasks. + +2. **Dynamic Replanning**: + - Evaluates if the prerequisites for the remaining tasks in the graph still hold based on the current `ContextState`. + - **Adjust**: Modifies the `TaskGraph` (only unexecuted parts) if necessary. + - **Continue**: Proceeds to the next batch if valid. + +## Phase 3: System-level Verification & Security Audit + +Once all tasks are complete, the system performs a comprehensive validation. + +1. **Global Validation**: + - **End-to-End Testing**: Verifies full system flows. + - **Performance Benchmarking**: Compares against baselines to detect regressions. + - **Compatibility Testing**: Checks API backward compatibility and data formats. + +2. **Security Audit**: + - **Full SAST**: Scans all changed files. + - **Full SCA**: Reviews the complete dependency tree. + - **Risk Scans**: Checks for privilege escalation and injection vulnerabilities. + - **Compliance**: Verifies license compliance and privacy data handling. + + *Feedback*: If issues are found, the system locates the specific responsible Task and triggers a fix (returning to Phase 2). + +3. **Finalization & Documentation**: + - **Provenance Aggregation**: Combines all Task Provenance records into a batch-level execution chain. + - **Snapshot**: Computes a hash of the final `ContextState` and signs it. + - **Audit Log**: specific records (`IntentSpec` → `TaskGraph` → `PatchSets` → `TestResults` → `SecurityReport`) are written to an append-only log. + - **Doc Generation**: Automatically generates change descriptions, updates API docs, and `CHANGELOG`. + +## Phase 4: Decision & Release + +The final gatekeeping phase before deployment. + +1. **Risk Aggregation**: + - Combines the initial `IntentSpec.risk_level`, the Security Report, and the scope of changes to calculate a final risk score. + +2. **Decision Path**: + - **Low Risk → Auto-Merge**: + - Commits code to the production repository. + - Synchronizes documentation. + - Triggers the CD (Continuous Deployment) Pipeline. + - **High Risk → Human-in-the-loop Review**: + - **Presentation**: Displays change summary, execution chain, security report, and impact analysis to a human reviewer. + - **Options**: Approve, Reject, or Request Changes (re-execute with feedback). + - **Approval**: Upon approval, proceeds to commit, sync docs, and trigger CD. diff --git a/docs/agent.md b/docs/agent.md new file mode 100644 index 00000000..111cbf56 --- /dev/null +++ b/docs/agent.md @@ -0,0 +1,860 @@ +# AI Object Model Reference + +This document describes the AI object model in git-internal: the types, +lifecycle, relationships, and usage patterns for AI-assisted code generation +workflows. All types live under `src/internal/object/`. + +## Overview + +The AI object model extends Git's native object types (Blob, Tree, Commit, Tag) +with a set of workflow objects that capture the full lifecycle of an AI-assisted +code change — from the initial user request to the final committed patch. + +``` +User input + │ + ▼ + Intent ──▶ Plan ──▶ Task ──▶ Run ──▶ PatchSet ──▶ Decision + │ + ├──▶ ToolInvocation (action log) + ├──▶ Evidence (validation results) + └──▶ Provenance (LLM metadata) +``` + +Context is tracked by two complementary mechanisms: + +- **ContextSnapshot** — static capture of files/URLs/snippets at Run start +- **ContextPipeline** — dynamic accumulation of incremental context frames + throughout execution + +## End-to-End Flow + +``` + ① User input + │ + ▼ + ② Intent (Draft → Active) + │ + ├──▶ ContextPipeline ← seeded with IntentAnalysis frame + │ + ▼ + ③ Plan (pipeline, fwindow, steps) + │ + ├─ PlanStep₀ (inline) + ├─ PlanStep₁ ──task──▶ sub-Task (recursive) + └─ PlanStep₂ (inline) + │ + ▼ + ④ Task (Draft → Running) + │ + ▼ + ⑤ Run (Created → Patching → Validating → Completed/Failed) + │ + ├──▶ Provenance (1:1, LLM config + token usage) + ├──▶ ContextSnapshot (optional, static context at start) + │ + │ ┌─── agent execution loop ───┐ + │ │ │ + │ │ ⑥ ToolInvocation (1:N) │ ← action log + │ │ │ │ + │ │ ▼ │ + │ │ ⑦ PatchSet (Proposed) │ ← candidate diff + │ │ │ │ + │ │ ▼ │ + │ │ ⑧ Evidence (1:N) │ ← test/lint/build + │ │ │ │ + │ │ ├─ pass ─────────────┘ + │ │ └─ fail → new PatchSet (retry within Run) + │ └────────────────────────────┘ + │ + ▼ + ⑨ Decision (terminal verdict) + │ + ├─ Commit → apply PatchSet, record result_commit + ├─ Retry → create new Run ⑤ for same Task + ├─ Abandon → mark Task as Failed + ├─ Checkpoint → save state, resume later + └─ Rollback → revert applied PatchSet + │ + ▼ + ⑩ Intent (Completed) ← commit recorded +``` + +### Steps + +1. **User input** — the user provides a natural-language request. + +2. **Intent** — captures the raw prompt and the AI's structured interpretation. + Status transitions from `Draft` (prompt only) to `Active` (analysis + complete). Supports conversational refinement via `parent` chain. + +3. **Plan** — a sequence of `PlanStep`s derived from the Intent. References a + `ContextPipeline` and records the visible frame range (`fwindow`). Steps + track consumed/produced frames by stable ID (`iframes`/`oframes`). A step may spawn a + sub-Task for recursive decomposition. Plans form a revision chain via + `previous`. + +4. **Task** — a unit of work with title, constraints, and acceptance criteria. + May link back to its originating Intent. Accumulates Runs in `runs` + (chronological execution history). + +5. **Run** — a single execution attempt of a Task. Records the baseline + `commit`, the Plan version being executed (snapshot reference), and the host + `environment`. A `Provenance` (1:1) captures the LLM configuration and + token usage. + +6. **ToolInvocation** — the finest-grained record: one per tool call (read + file, run command, etc.). Forms a chronological action log for the Run. + Tracks file I/O via `io_footprint`. + +7. **PatchSet** — a candidate diff generated by the agent. Contains the diff + `artifact`, file-level `touched` summary, and `rationale`. Starts as + `Proposed`; transitions to `Applied` or `Rejected`. Ordering is by + position in `Run.patchsets`. + +8. **Evidence** — output of a validation tool (test, lint, build) run against a + PatchSet. One per tool invocation. Carries `exit_code`, `summary`, and + `report_artifacts`. Feeds into the Decision. + +9. **Decision** — the terminal verdict of a Run. Selects a PatchSet to apply + (`Commit`), retries the Task (`Retry`), gives up (`Abandon`), saves + progress (`Checkpoint`), or reverts (`Rollback`). Records `rationale` + and `result_commit_sha`. + +10. **Intent completed** — the orchestrator records the final git commit in + `Intent.commit` and transitions status to `Completed`. + +## Object Relationship Summary + +| From | Field | To | Cardinality | +|------|-------|----|-------------| +| Intent | `parent` | Intent | 0..1 | +| Intent | `plan` | Plan | 0..1 | +| Plan | `previous` | Plan | 0..1 | +| Plan | `pipeline` | ContextPipeline | 0..1 | +| PlanStep | `task` | Task | 0..1 | +| Task | `parent` | Task | 0..1 | +| Task | `intent` | Intent | 0..1 | +| Task | `runs` | Run | 0..N | +| Task | `dependencies` | Task | 0..N | +| Run | `task` | Task | 1 | +| Run | `plan` | Plan | 0..1 | +| Run | `snapshot` | ContextSnapshot | 0..1 | +| Run | `patchsets` | PatchSet | 0..N | +| PatchSet | `run` | Run | 1 | +| Evidence | `run_id` | Run | 1 | +| Evidence | `patchset_id` | PatchSet | 0..1 | +| Decision | `run_id` | Run | 1 | +| Decision | `chosen_patchset_id` | PatchSet | 0..1 | +| Provenance | `run_id` | Run | 1 | +| ToolInvocation | `run_id` | Run | 1 | + +## Object Details + +### Intent (`intent.rs`) + +The **entry point** of every AI-assisted workflow. Captures the verbatim user +request (`prompt`) and the AI's structured interpretation (`content`). + +#### Status Transitions + +``` +Draft ──▶ Active ──▶ Completed + │ │ + └──────────┴──▶ Cancelled +``` + +- **Draft**: Prompt recorded, AI has not analyzed it yet. `content = None`. +- **Active**: AI interpretation available in `content`. Plan, Tasks, and Runs + may be in progress. +- **Completed**: All downstream work finished. `commit` contains the result + git commit hash. +- **Cancelled**: Abandoned before completion (user interrupt, timeout, budget). + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata (ID, type, timestamps, creator) | +| `prompt` | `String` | Verbatim user input, immutable after creation | +| `content` | `Option` | AI-analyzed interpretation; `None` in Draft | +| `parent` | `Option` | Predecessor Intent for conversational refinement | +| `commit` | `Option` | Result git commit, set at step ⑩ | +| `plan` | `Option` | Latest Plan revision derived from this Intent | +| `statuses` | `Vec` | Append-only status transition history | + +#### Conversational Refinement + +``` +Intent₀ ("Add pagination") + ▲ + │ parent +Intent₁ ("Also add cursor-based pagination") + ▲ + │ parent +Intent₂ ("Use opaque cursors, not offsets") +``` + +Each follow-up Intent links to its predecessor via `parent`, forming a +singly-linked list from newest to oldest. + +#### Usage + +```rust +use git_internal::internal::object::intent::{Intent, IntentStatus}; +use git_internal::internal::object::types::ActorRef; + +// 1. Create from user input +let actor = ActorRef::human("alice")?; +let mut intent = Intent::new(actor, "Add pagination to the user list API")?; +assert_eq!(intent.status(), &IntentStatus::Draft); + +// 2. AI analyzes the prompt +intent.set_content(Some("Add offset/limit pagination to GET /users".into())); +intent.set_status(IntentStatus::Active); + +// 3. After execution completes +intent.set_status_with_reason(IntentStatus::Completed, "All tasks finished"); +``` + +--- + +### Plan (`plan.rs`) + +A sequence of `PlanStep`s derived from an Intent's analyzed content. Defines +*what* to do (strategy and decomposition); `Run` handles *how* to execute. + +#### Revision Chain + +When the agent encounters obstacles, it creates a revised Plan via +`Plan::new_revision`. Each revision links back via `previous`: + +``` +Intent.plan ──▶ Plan_v3 (latest) + │ previous + ▼ + Plan_v2 + │ previous + ▼ + Plan_v1 (original, previous = None) +``` + +`Intent.plan` always points to the latest revision. Each `Run.plan` is a +**snapshot reference** that never changes. + +#### Context Range + +A Plan references a `ContextPipeline` via `pipeline` and records the visible +frame range `fwindow = (start, end)` — the half-open range `[start..end)` of +frames that were visible at creation time. + +``` +ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] + ^^^^^^^^^^^^^^^^ + fwindow = (0, 4) +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `previous` | `Option` | Predecessor Plan in revision chain | +| `pipeline` | `Option` | ContextPipeline used as context basis | +| `fwindow` | `Option<(u32, u32)>` | Visible frame range `[start..end)` | +| `steps` | `Vec` | Ordered sequence of steps | + +#### PlanStep + +Each step describes one unit of work within a Plan. + +| Field | Type | Description | +|-------|------|-------------| +| `description` | `String` | What this step should accomplish | +| `inputs` | `Option` | Expected inputs (JSON) | +| `outputs` | `Option` | Outputs after execution (JSON) | +| `checks` | `Option` | Validation criteria (JSON) | +| `iframes` | `Vec` | Stable pipeline frame IDs consumed (survives eviction) | +| `oframes` | `Vec` | Stable pipeline frame IDs produced (survives eviction) | +| `task` | `Option` | Sub-Task for recursive decomposition | +| `statuses` | `Vec` | Append-only status history | + +Step status transitions: + +``` +Pending ──▶ Progressing ──▶ Completed + │ │ + ├─────────────┴──▶ Failed + └──────────────────▶ Skipped +``` + +#### Recursive Decomposition + +A step can spawn a sub-Task via its `task` field: + +``` +Plan + ├─ Step₀ (inline — executed by current Run) + ├─ Step₁ ──task──▶ Task₁ + │ └─ Run → Plan + │ ├─ Step₁₋₀ + │ └─ Step₁₋₁ + └─ Step₂ (inline) +``` + +#### Usage + +```rust +use git_internal::internal::object::plan::{Plan, PlanStep}; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::agent("planner")?; + +// Create initial plan +let mut plan = Plan::new(actor.clone())?; +plan.set_pipeline(Some(pipeline_id)); +plan.set_fwindow(Some((0, 3))); + +// Add steps +let mut step = PlanStep::new("Refactor auth module"); +step.set_iframes(vec![0, 1]); // consumed frames 0 and 1 +plan.add_step(step); + +// Create a revision +let plan_v2 = plan.new_revision(actor)?; +assert_eq!(plan_v2.previous(), Some(plan.header().object_id())); +``` + +--- + +### Task (`task.rs`) + +A unit of work with constraints and acceptance criteria. The **stable +identity** for a piece of work — persists across retries and replanning. + +#### Status Transitions + +``` +Draft ──▶ Running ──▶ Done + │ │ + ├──────────┴──▶ Failed + └──────────────▶ Cancelled +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `title` | `String` | Short summary (< 100 chars) | +| `description` | `Option` | Extended context | +| `goal` | `Option` | Work classification (Feature, Bugfix, etc.) | +| `constraints` | `Vec` | Hard rules the solution must satisfy | +| `acceptance_criteria` | `Vec` | Testable success conditions | +| `requester` | `Option` | Who requested the work | +| `parent` | `Option` | Parent Task (for sub-Tasks) | +| `intent` | `Option` | Originating Intent | +| `runs` | `Vec` | Chronological execution history | +| `dependencies` | `Vec` | Tasks that must complete first | +| `status` | `TaskStatus` | Current lifecycle status | + +#### GoalType + +`Feature`, `Bugfix`, `Refactor`, `Docs`, `Perf`, `Test`, `Chore`, `Build`, +`Ci`, `Style`, `Other(String)`. + +#### Replanning + +The Task stays the same across Plan revisions. Each Run records which Plan +version it executed: + +``` +Task (constant) Intent (constant, plan updated) + │ └─ plan ──▶ Plan_v2 (latest) + └─ runs: + Run₀ ──plan──▶ Plan_v1 (snapshot: original plan) + Run₁ ──plan──▶ Plan_v2 (snapshot: revised plan) +``` + +#### Usage + +```rust +use git_internal::internal::object::task::{Task, GoalType}; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::human("user")?; +let mut task = Task::new(actor, "Refactor Login", Some(GoalType::Refactor))?; + +task.add_constraint("Must use JWT"); +task.add_acceptance_criterion("All tests pass"); +task.set_intent(Some(intent_id)); + +// After Run completes +task.add_run(run_id); +task.set_status(TaskStatus::Done); +``` + +--- + +### Run (`run.rs`) + +A single execution attempt of a Task. Captures the execution context +(baseline commit, environment, Plan version) and accumulates artifacts +(PatchSets, Evidence, ToolInvocations) during execution. + +#### Status Transitions + +``` +Created ──▶ Patching ──▶ Validating ──▶ Completed + │ │ + └──────────────┴──▶ Failed +``` + +- **Created**: Run initialized, environment captured. +- **Patching**: Agent is generating code changes / tool calls. +- **Validating**: Agent has produced a PatchSet and is running tests/lint. +- **Completed**: Decision has been created. +- **Failed**: Unrecoverable error. `Run.error` has details. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `task` | `Uuid` | Owning Task (mandatory) | +| `plan` | `Option` | Plan version being executed (snapshot) | +| `commit` | `IntegrityHash` | Baseline git commit | +| `status` | `RunStatus` | Current lifecycle status | +| `snapshot` | `Option` | ContextSnapshot at Run start | +| `patchsets` | `Vec` | Candidate diffs (chronological) | +| `metrics` | `Option` | Execution metrics (JSON) | +| `error` | `Option` | Error message if Failed | +| `environment` | `Option` | Host OS/arch/cwd | + +#### Associated Objects (by `run_id`) + +| Object | Cardinality | Purpose | +|--------|-------------|---------| +| Provenance | 1:1 | LLM config + token usage | +| ToolInvocation | 1:N | Chronological action log | +| Evidence | 1:N | Validation results (test/lint/build) | +| Decision | 1:1 | Terminal verdict | + +#### Usage + +```rust +use git_internal::internal::object::run::Run; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::agent("orchestrator")?; +let mut run = Run::new(actor, task_id, "abc123def...")?; +run.set_plan(Some(plan_id)); + +// Agent generates patches +run.set_status(RunStatus::Patching); +run.add_patchset(patchset_id); + +// Validation phase +run.set_status(RunStatus::Validating); + +// Completed +run.set_status(RunStatus::Completed); +``` + +--- + +### PatchSet (`patchset.rs`) + +A candidate diff generated by the agent during a Run. The atomic unit of +code modification — every change the agent wants to make is packaged as a +PatchSet. + +#### Lifecycle + +``` + (created) ──▶ Proposed + │ + ┌──────────────┼──────────────┐ + │ │ │ + ▼ ▼ │ + Applied Rejected │ + │ │ + ▼ │ + agent generates new PatchSet + appended to Run.patchsets +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run` | `Uuid` | Owning Run | +| `commit` | `IntegrityHash` | Baseline commit this diff applies to | +| `format` | `DiffFormat` | `Unified` or `GitDiff` | +| `artifact` | `Option` | Reference to stored diff content | +| `touched` | `Vec` | File-level change summary | +| `rationale` | `Option` | Agent's explanation of what/why changed | +| `apply_status` | `ApplyStatus` | `Proposed`, `Applied`, or `Rejected` | + +#### Rationale + +The `rationale` field bridges the gap between the Task/Plan (high-level intent) +and the raw diff (low-level changes). It is primarily written by the agent and +may be overridden by a human reviewer via `set_rationale()`. + +--- + +### Evidence (`evidence.rs`) + +Output of a single validation step (test, lint, build) run against a PatchSet. +One Evidence per tool invocation. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | Owning Run | +| `patchset_id` | `Option` | PatchSet being validated (`None` for run-level checks) | +| `kind` | `EvidenceKind` | `Test`, `Lint`, `Build`, or `Other(String)` | +| `tool` | `String` | Tool name (e.g. "cargo", "eslint", "pytest") | +| `command` | `Option` | Full command line for reproducibility | +| `exit_code` | `Option` | Process exit code (0 = success) | +| `summary` | `Option` | Short result summary | +| `report_artifacts` | `Vec` | Full report files (logs, coverage, JUnit XML) | + +--- + +### Decision (`decision.rs`) + +The **terminal verdict** of a Run. Created once per Run at the end of +execution. + +#### Decision Types + +| Type | Action | `chosen_patchset_id` | `result_commit_sha` | +|------|--------|---------------------|---------------------| +| `Commit` | Apply the chosen PatchSet | Set | Set after commit | +| `Checkpoint` | Save intermediate progress | — | — | +| `Abandon` | Give up on the Task | — | — | +| `Retry` | Create new Run for same Task | — | — | +| `Rollback` | Revert applied PatchSet | — | — | + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | The Run this Decision concludes | +| `decision_type` | `DecisionType` | Verdict (Commit/Retry/Abandon/etc.) | +| `chosen_patchset_id` | `Option` | Selected PatchSet (for Commit) | +| `result_commit_sha` | `Option` | Git commit hash after applying | +| `checkpoint_id` | `Option` | Saved state ID (for Checkpoint) | +| `rationale` | `Option` | Explanation of why this decision was made | + +--- + +### Provenance (`provenance.rs`) + +Records **how** a Run was executed: which LLM provider, model, and parameters +were used, and how many tokens were consumed. Created once per Run (1:1). + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | The Run this Provenance describes | +| `provider` | `String` | LLM provider ("openai", "anthropic", "local") | +| `model` | `String` | Model ID ("gpt-4", "claude-opus-4-20250514") | +| `parameters` | `Option` | Provider-specific raw parameters (JSON) | +| `temperature` | `Option` | Sampling temperature (0.0 = deterministic) | +| `max_tokens` | `Option` | Maximum generation length | +| `token_usage` | `Option` | Token consumption and cost | + +#### TokenUsage + +| Field | Type | Description | +|-------|------|-------------| +| `input_tokens` | `u64` | Tokens in the prompt/input | +| `output_tokens` | `u64` | Tokens in the completion/output | +| `total_tokens` | `u64` | `input_tokens + output_tokens` | +| `cost_usd` | `Option` | Estimated cost in USD | + +--- + +### ToolInvocation (`tool.rs`) + +The finest-grained record: one per tool call made by the agent during a Run. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | Owning Run | +| `tool_name` | `String` | Registered tool name ("read_file", "bash", etc.) | +| `io_footprint` | `Option` | Files read/written | +| `args` | `Value` | Arguments (JSON, tool-dependent schema) | +| `status` | `ToolStatus` | `Ok` or `Error` | +| `result_summary` | `Option` | Short output summary | +| `artifacts` | `Vec` | Full output files | + +#### IoFootprint + +| Field | Type | Description | +|-------|------|-------------| +| `paths_read` | `Vec` | Files read (repo-relative) | +| `paths_written` | `Vec` | Files written (repo-relative) | + +--- + +### ContextSnapshot (`context.rs`) + +A **static** capture of the codebase and external resources the agent observed +when a Run began. Point-in-time — does not change after creation. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `selection_strategy` | `SelectionStrategy` | `Explicit` (user-chosen) or `Heuristic` (auto-selected) | +| `items` | `Vec` | Context items (files, URLs, snippets, etc.) | +| `summary` | `Option` | Aggregated summary | + +#### ContextItem + +Each item has three layers: + +- **`path`** — human-readable locator (repo path, URL, command, label) +- **`blob`** — Git blob hash pointing to full content at capture time +- **`preview`** — truncated text for quick display + +| Kind | `path` example | `blob` content | +|------|----------------|----------------| +| `File` | `src/main.rs` | Same blob in git tree (zero extra storage) | +| `Url` | `https://docs.rs/...` | Fetched page content | +| `Snippet` | `"design notes"` | Snippet text | +| `Command` | `cargo test` | Command output | +| `Image` | `screenshot.png` | Image binary | + +#### Blob Retention + +Blobs referenced only by AI objects are not reachable in git's DAG and will be +pruned by `git gc`. For non-File items, applications must choose a retention +strategy: + +| Strategy | Approach | +|----------|----------| +| Ref anchoring | `refs/ai/blobs/` | +| Orphan commit | `refs/ai/uploads` tree | +| Keep pack | `.keep` marker on pack file | +| Custom GC mark | Scan AI objects during GC | + +--- + +### ContextPipeline (`pipeline.rs`) + +A **dynamic** context container that accumulates incremental `ContextFrame`s +throughout the workflow. Solves the context-forgetting problem in long-running +tasks. + +#### Lifecycle + +``` +Intent (Active) ← content analyzed + │ + ▼ +ContextPipeline created ← seeded with IntentAnalysis frame + │ + ▼ +Plan created ← Plan.pipeline → Pipeline, Plan.fwindow = range + │ steps execute + ▼ +Frames accumulate ← StepSummary, CodeChange, ToolCall, ... + │ + ▼ +Replan? → new Plan with updated fwindow +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `frames` | `Vec` | Chronologically ordered frames | +| `next_frame_id` | `u64` | Monotonic counter for stable frame IDs | +| `max_frames` | `u32` | Max frames before eviction (0 = unlimited) | +| `global_summary` | `Option` | Aggregated summary | + +#### ContextFrame + +| Field | Type | Description | +|-------|------|-------------| +| `frame_id` | `u64` | Stable monotonic ID (survives eviction) | +| `kind` | `FrameKind` | IntentAnalysis, StepSummary, CodeChange, etc. | +| `summary` | `String` | Compact human-readable summary | +| `data` | `Option` | Structured payload (JSON) | +| `created_at` | `DateTime` | When this frame was created | +| `token_estimate` | `Option` | Estimated tokens for budgeting | + +#### FrameKind + +| Kind | Protected | Description | +|------|-----------|-------------| +| `IntentAnalysis` | Yes | Seed frame from Intent analysis | +| `StepSummary` | No | Summary after a PlanStep completes | +| `CodeChange` | No | Code change digest (files, diff stats) | +| `SystemState` | No | System/environment state snapshot | +| `ErrorRecovery` | No | Error recovery context | +| `Checkpoint` | Yes | Explicit save-point | +| `ToolCall` | No | External tool invocation result | +| `Other(String)` | No | Application-defined | + +Protected frames survive eviction when `max_frames` is exceeded. + +#### Eviction + +When `max_frames > 0` and the limit is exceeded, `push_frame` removes the +oldest non-protected frame. `IntentAnalysis` and `Checkpoint` frames are +never evicted. + +#### Step-Frame Association + +Steps track their relationship to frames via stable frame IDs: + +``` +ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅] + │ │ ▲ + ╰────╯ │ + iframes=[0,1] oframes=[4] + ╰── Step₀ ──╯ +``` + +- `iframes` — stable `frame_id`s of frames the step consumed as context +- `oframes` — stable `frame_id`s of frames the step produced + +Frame IDs are monotonic integers assigned by `push_frame`. Unlike Vec +indices, IDs survive eviction — a step's `iframes` remain valid even +after older frames are removed. Look up frames via `frame_by_id`. + +All association is owned by the step; `ContextFrame` has no back-references. + +#### Usage + +```rust +use git_internal::internal::object::pipeline::{ContextPipeline, FrameKind}; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::agent("orchestrator")?; + +// 1. Create pipeline after Intent content is analyzed +let mut pipeline = ContextPipeline::new(actor)?; + +// 2. Seed with the Intent's analyzed content — returns stable frame_id +let seed_id = pipeline.push_frame( + FrameKind::IntentAnalysis, + "Add offset/limit pagination to GET /users with default page size 20", +); + +// 3. Create a Plan referencing this pipeline +// plan.set_pipeline(Some(pipeline.header().object_id())); +// plan.set_fwindow(Some((0, pipeline.frames().len() as u32))); + +// 4. As steps complete, push incremental frames +let step_id = pipeline.push_frame(FrameKind::StepSummary, "Refactored auth module"); + +// 5. Track on PlanStep side using stable frame IDs: +// step.set_iframes(vec![seed_id]); +// step.set_oframes(vec![step_id]); + +// 6. Look up frame by ID (survives eviction) +// pipeline.frame_by_id(seed_id); +``` + +## Common Header + +All AI objects share a common `Header` (flattened into the JSON via `#[serde(flatten)]`): + +| Field | Type | Description | +|-------|------|-------------| +| `object_id` | `Uuid` | Globally unique ID (UUID v7, time-ordered) | +| `object_type` | `ObjectType` | Discriminator (Intent, Plan, Task, etc.) | +| `header_version` | `u32` | Format version of the Header struct | +| `schema_version` | `u32` | Per-object-type schema version | +| `created_at` | `DateTime` | Creation timestamp | +| `updated_at` | `DateTime` | Last modification timestamp | +| `created_by` | `ActorRef` | Who created this object | +| `visibility` | `Visibility` | `Private` or `Public` | +| `tags` | `HashMap` | Free-form search tags | +| `external_ids` | `HashMap` | External ID mapping | +| `checksum` | `Option` | Content checksum (set by `seal()`) | + +### ActorRef + +Identifies who created or triggered an action: + +| Kind | Factory | Example | +|------|---------|---------| +| `Human` | `ActorRef::human("alice")` | A user | +| `Agent` | `ActorRef::agent("coder")` | An AI agent | +| `System` | `ActorRef::system("scheduler")` | Infrastructure | +| `McpClient` | `ActorRef::mcp_client("vscode")` | MCP client | + +### ArtifactRef + +Reference to external content stored outside the AI object: + +| Field | Type | Description | +|-------|------|-------------| +| `store` | `String` | Storage backend ("local", "s3") | +| `key` | `String` | Storage key or path | +| `content_type` | `Option` | MIME type | +| `size_bytes` | `Option` | Size in bytes | +| `hash` | `Option` | Content hash (SHA-256) | +| `expires_at` | `Option>` | Expiration time | + +Supports integrity verification via `verify_integrity(content)` and +content deduplication via `content_eq(other)`. + +## Serialization + +All AI objects implement `ObjectTrait` with JSON serialization: + +- **`from_bytes(data, hash)`** — deserialize from JSON bytes +- **`to_data()`** — serialize to JSON bytes +- **`get_type()`** — returns the `ObjectType` discriminator +- **`get_size()`** — returns the serialized size +- **`object_hash()`** — computes the content-addressable hash + +AI object types are registered in `ObjectType` with numeric IDs 8–18 and +string identifiers for serialization (e.g. `"intent"`, `"plan"`, `"task"`). + +> **Pack encoding**: AI objects cannot be encoded in standard Git pack headers +> (which only support 3-bit type values 1–7). They must use the AI-specific +> storage layer. `ObjectType::to_pack_type_u8()` returns an error for AI types. + +## Design Principles + +1. **Append-only history**: Status changes (Intent, PlanStep) and execution + records (Task.runs, Run.patchsets) are append-only — entries are never + removed or mutated. + +2. **Snapshot references**: Run.plan records the Plan version at execution + time. Intent.plan always points to the latest revision. This allows + replanning without invalidating in-progress Runs. + +3. **Bidirectional references**: Key relationships are bidirectional for + efficient traversal (Task ↔ Run, Run ↔ PatchSet). Other relationships + are unidirectional with reverse lookup by scanning (Evidence → Run, + Decision → Run). + +4. **Serde conventions**: Optional fields use `#[serde(default, skip_serializing_if)]`. + Empty Vecs use `skip_serializing_if = "Vec::is_empty"`. Field renames use + `#[serde(alias = "old_name")]` for backward compatibility. + +5. **Context separation**: Static context (ContextSnapshot) vs. dynamic + context (ContextPipeline) serve complementary needs — reproducibility + vs. continuity. diff --git a/docs/analytical-report-on-sha-256-hash-computation.md b/docs/analytical-report-on-sha-256-hash-computation.md new file mode 100644 index 00000000..fe639892 --- /dev/null +++ b/docs/analytical-report-on-sha-256-hash-computation.md @@ -0,0 +1,593 @@ +# Background and Problem Overview + +Since its inception, Git has relied on **SHA-1** as its core hash algorithm for naming all objects (commits, trees, blobs, and tags). The 160-bit hash values are represented as 40-character hexadecimal strings and are deeply embedded in repository storage, index structures, network protocols, and a wide range of tooling. + +However, practical collision attacks against **SHA-1** have already been demonstrated, and its security no longer meets long-term requirements. The Git community has outlined a migration path from **SHA-1** to **SHA-256** in the [_hash-function-transition_](https://git-scm.com/docs/hash-function-transition) document, and introduced experimental features such as `--object-format=sha256` in the 2.x series, with the plan to gradually adopt **SHA-256** as the default object format for newly created repositories in Git 3.0. + +In the current technology stack, the low-level library **git-internal**, the Git client **Libra**, and the monolithic repository **Mega** all hard-code the **SHA-1** algorithm, which leads to a concrete requirement for supporting multiple hash algorithms. + +Consequently, the current issues can be summarized as follows: + +1. **git-internal** contains extensive **hard-coded SHA-1** logic (fixed 20-byte / 40-character hashes) and lacks a unified abstraction layer for hash functions. + +2. **Libra** only implements **pack index v1**, and its protocol layer implicitly assumes “40-character hashes” during fetch/push discovery, making it incompatible with **SHA-256** and unable to negotiate multiple hash algorithms. + +3. As a monolithic repository, **Mega** historically treats its hash strategy as an implicit constant. The chosen hash algorithm is not explicitly expressed in the context, and there is no clear “initialize once, use globally” policy for hash configuration. + +# Project Objectives +The overall objective of this project is: +> To introduce systematic **SHA-256** support and a multi-hash mechanism for **git-internal**, **Libra**, and **Mega**—**without introducing major breaking changes to existing APIs**—thereby laying an extensible foundation for the Git 3.0 era. + +Concretely, the goals can be decomposed as follows: + +**git-internal Layer** +   - Evolve from “hard-coded **SHA-1**” to a unified hash abstraction that supports at least **SHA-1** and **SHA-256**, including: + +     - Abstracting `HashKind` (hash algorithm type) and `ObjectHash` (concrete hash value type); + +     - Providing unified APIs for hash computation, encoding, and decoding; + +     - Replacing low-level assumptions such as fixed `[u8; 20]` layouts. + +**Libra Client Layer** +   - Enable **Libra** to become a Git client that **understands multiple hash algorithms and interoperates with both SHA-1 and SHA-256 repositories**: + +     - Initialize `HashKind` in command entry points based on `core.objectFormat`, `extensions.objectFormat`, and `compatObjectFormat`; + +     - Remove the implicit 40-character hash assumption in the protocol layer (fetch/push discovery), and instead parse hashes via `HashKind::hex_len()` together with `ObjectHash::from_str`; + +     - Evolve the index side from `build_index_v1` to **pack index v3/v2**, supporting concurrent indexing of SHA-1 and SHA-256 and laying the groundwork for bidirectional mappings (sha1 <-> sha256). + +**Mega Monolithic Repository Layer** +   - Make Mega’s hash policy explicit as **“a single `objectFormat` chosen at repository creation time”**: + +     - Do not implement cross-algorithm conversion (no need for `compatObjectFormat`); + +     - During application initialization, set `hash_kind` once via `AppContext`, and ensure that all subsequent business logic accesses hash information exclusively through the shared context. + +**Compatibility and Evolution** + +   - Preserve the ability to access existing **SHA-1** repositories; + +   - Reserve extension points for potential future hash algorithms (such as BLAKE3), without over-engineering the design. + +# Design Overview +## Current State of git-internal +In the current **git-internal** implementation, the hash algorithm is essentially hard-coded as **SHA-1 with a fixed 20-byte representation**. For example: +```rust +fn from_bytes(data: &[u8], hash: SHA1) -> Result +``` +This leads to several issues: +1. **Algorithm Cannot Be Switched** + +   All object IDs are statically bound to **SHA-1**. As soon as we want to support **SHA-256**, a large number of type signatures must be modified across the codebase. + +2. **Lack of a Unified Abstraction Layer** + +   Different core modules in the library (such as commits, blobs, indexes, and protocol-related components) each make their own assumptions about hash formats and handle them in ad-hoc ways, without a single unified entry point or abstraction. + +3. **Difficult to Support “Multiple Algorithms Coexisting”** + +   In the short term, a repository may indeed use only a single hash algorithm. However, according to Git upstream’s `hash-function-transition` design, Git needs to understand both **SHA-1** and **SHA-256** in certain scenarios (for example, maintaining bidirectional mapping tables between the two) +### Usage Scenarios: Inferable vs Non-Inferable Hash Types + +Before designing the refactoring plan, we need to distinguish between two categories of usage scenarios, as this directly affects how the APIs should be designed. +#### 1. Non-Inferable Types — Must Be Explicitly Passed or Provided via Context (e.g., `commit.rs`) + +```rust +pub fn new( + tree_id: SHA1, + parent_commit_ids: Vec, + message: &str, +) -> Commit { + let mut commit = Commit { + id: SHA1::default(), + // ... + }; + let hash = SHA1::from_type_and_data(ObjectType::Commit, &commit.to_data().unwrap()); + commit.id = hash; + commit +}``` +Here, Commit::new has no knowledge of whether the _current repository_ is using **SHA-1** or **SHA-256**. All it knows is: +- It needs to construct a commit object; + +- It needs to assign an ID to that commit. + +If we simply replace SHA1 with an enum like HashKind or a generic ObjectHash, but still do not provide any contextual information, the function has **no way to decide which concrete hash algorithm to use**. +Possible approaches include: +1. Add a hash_kind: HashKind parameter to all relevant functions – but this would propagate a large number of additional parameters across the codebase and hurt API simplicity. + +2. Introduce a “repository context” (such as a Repository object or a global configuration) that provides the currently active hash algorithm. + +For the low-level **git-internal** library, a more reasonable solution is to introduce an accessible “current hash algorithm” state (see the global configuration approach discussed later), so that we avoid an explosion of function signature changes. +#### **2. Inferable Types — Can Be Deduced from Input Length (e.g.,**  **core.rs**) +```rust +async fn get_blob( + &self, + object_hash: &str, +) -> Result { + let data = self.get_object(object_hash).await?; + let hash = SHA1::from_str(object_hash) + .map_err(|e| ProtocolError::repository_error(format!("Invalid hash format: {}", e)))?; + crate::internal::object::blob::Blob::from_bytes(&data, hash) + .map_err(|e| ProtocolError::repository_error(format!("Failed to parse blob: {}", e))) +} +``` +In scenarios like this, object_hash is a hexadecimal string, and we can infer the hash algorithm based on its length: +- Length 40 → treat it as **SHA-1**; + +- Length 64 → treat it as **SHA-256**. + +With this approach, the function does not need an additional “algorithm” parameter. Instead, it only needs to inspect the length of object_hash, then delegate to a unified ObjectHash::from_str implementation. + +For this class of use cases, we can keep the existing API surface largely unchanged and encapsulate all branching logic inside hash.rs. + +### Design Principles: Abstraction Inspired by the C Implementation +In Git’s [C implementation](https://github.com/git/git), there is a core structure: +```c +struct git_hash_algo { + const char *name; // ("sha1", "sha256") + uint32_t format_id; // format ID + size_t rawsz; // hash_len (20 or 32) + size_t hexsz; // hex_len (40 or 64) + size_t blksz; // block_size + + void (*init_fn)(struct git_hash_ctx *); + void (*clone_fn)(...); + void (*update_fn)(...); + void (*final_fn)(...); + void (*final_oid_fn)(...); +}; +``` +The core idea behind this design can be summarized as: +- Use a **single struct to describe both “algorithm metadata” and “algorithm interface”**; + +- Each algorithm provides its own git_hash_algo instance; + +- The rest of the code accesses the active algorithm solely via a pointer like struct git_hash_algo *the_hash_algo. + +In Rust, there are two typical abstraction approaches: +1. **Trait + dynamic dispatch** (e.g. dyn HashAlgo with Box) + +2. **Enum +** **match** based static dispatch + +Given that: +- The number of algorithms is small (currently only **SHA-1** and **SHA-256**, and likely only a few more in the future); + +- We want to avoid virtual tables and heap allocations; + +- The low-level library targets performance-sensitive scenarios, and match branches are easily optimized into constant-time paths by the compiler; + +it is more appropriate to adopt a design based on **“a couple of enums plus helper functions”** rather than traits with dynamic dispatch. + +### Hash Abstraction Design: `HashKind` and `ObjectHash` + +Here, we deliberately separate **“algorithm kind”** from **“concrete hash value”** into two enums: +- `HashKind` + - Only concerns *which* algorithm is currently selected; + - Used for configuration, branching logic, retrieving length information, etc. + +- `ObjectHash` + - The structure that actually stores hash values, and the one most function parameters will use; + - Can hold either a `[u8; 20]` or a `[u8; 32]` representation in a single type. + +#### `HashKind`: Describing the Active Algorithm Type + +```rust +#[derive(Debug, Clone, Copy, Default)] +pub enum HashKind { + #[default] + Sha1, + Sha256, +} + +impl HashKind { + /// Returns the raw hash length in bytes (20 or 32). + pub const fn size(&self) -> usize { + match self { + HashKind::Sha1 => 20, + HashKind::Sha256 => 32, + } + } + + /// Returns the hex string length (40 or 64). + pub const fn hex_len(&self) -> usize { + match self { + HashKind::Sha1 => 40, + HashKind::Sha256 => 64, + } + } +}``` +- **Single responsibility**: it only encodes “which algorithm is being used” and does not hold any actual hash bytes; + +- Provides basic algorithm-related metadata (byte length / hex length) for other structures to consume; + +- In most cases, it can serve as a configuration enum for “current repository settings”, initialized from configuration files or environment variables. + +#### **ObjectHash: Enum Carrying Actual Object IDs** +```rust +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub enum ObjectHash { + Sha1([u8; 20]), + Sha256([u8; 32]), +} + +impl Default for ObjectHash { + fn default() -> Self { + ObjectHash::Sha1([0u8; 20]) + } +} + +impl ObjectHash { + /// Returns the algorithm kind corresponding to this hash. + pub fn kind(&self) -> HashKind { + match self { + ObjectHash::Sha1(_) => HashKind::Sha1, + ObjectHash::Sha256(_) => HashKind::Sha256, + } + } + + /// Returns the hash length in bytes. + pub fn size(&self) -> usize { + self.kind().size() + } + + /// Computes a hash for the given data based on the global configuration. + pub fn new(data: &[u8]) -> ObjectHash { + match get_hash_kind() { + HashKind::Sha1 => { + let h = sha1::Sha1::digest(data); + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(h.as_ref()); + ObjectHash::Sha1(bytes) + } + HashKind::Sha256 => { + let h = sha2::Sha256::digest(data); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(h.as_ref()); + ObjectHash::Sha256(bytes) + } + } + } + + /// Parses a hash from a hex string (auto-detecting the algorithm by length). + pub fn from_hex(s: &str) -> Result { + // Pseudocode: branch on length + decode + // if len == 40 => Sha1, if len == 64 => Sha256, otherwise return error + #![allow(unused)] + unimplemented!() + } + + /// Converts the hash into a hex string. + pub fn to_hex(&self) -> String { + // Pseudocode: encode the internal byte array + #![allow(unused)] + unimplemented!() + } +} +``` +**Benefits of this design** +1. **Clear type semantics** + + - Seeing HashKind immediately signals that it does not contain an actual hash, but only represents a policy choice; + + - Seeing ObjectHash makes it clear that this is “the real hash value” that can be attached to commits, trees, blobs, and other objects. + +2. **Supports coexistence of multiple algorithms** + + The same process can simultaneously hold ObjectHash::Sha1 and ObjectHash::Sha256 values—for example, when reading objects of different formats from remote repositories. This is aligned with upstream Git’s design for maintaining **bidirectional mapping tables** between SHA-1 and SHA-256. + +3. **Zero or near-zero runtime overhead** + + The compiler can efficiently optimize match statements, avoiding virtual tables and dynamic dispatch. As a result, this abstraction is suitable for performance-sensitive low-level code. +### Global Configuration: Selecting the Algorithm Without Changing APIs + +In order to let low-level code know *“which hash algorithm the current repository is using”* **without** adding a `hash_kind: HashKind` parameter everywhere, we can follow upstream Git’s approach of using a global pointer and introduce a **global configuration / thread-local configuration** in Rust. For example: + +```rust +/// Sets the hash kind for the current thread. +pub fn set_hash_kind(kind: HashKind) { + HASH_KIND.with(|h| { + *h.borrow_mut() = kind; + }); +} + +/// Retrieves the hash kind for the current thread. +pub fn get_hash_kind() -> HashKind { + HASH_KIND.with(|h| *h.borrow()) +}``` +The call flow is as follows: + +1. Upper layers (for example Repository::open or a CLI command entry point) parse .git/config or other configuration sources: + + - If extensions.objectFormat = sha256, then set HashKind::Sha256; + + - Otherwise, fall back to the default HashKind::Sha1. + + +2. The chosen value is written into the global / contextual state. + +3. All functions like ObjectHash::new(data) then only depend on this **“current global algorithm”**, and do not require an extra parameter. + +This approach satisfies the requirement of **“keeping the APIs as stable as possible”**, while ensuring that all low-level components **read the active hash algorithm from a single, unified source**. + +### Concrete Refactoring Suggestions for git-internal Core Modules + +#### Index Writing Logic (`git-internal/src/internal/index.rs`) + +```rust +pub fn to_file(&self, path: impl AsRef) -> Result<(), GitError> { + // ... + let mut hash = Sha1::new(); + // ... + entry_bytes.write_all(&entry.hash.0)?; + // ... + let padding = 8 - ((22 + entry.name.len()) % 8); + entry_bytes.write_all(&vec![0; padding])?; + file.write_all(&entry_bytes)?; + hash.update(&entry_bytes); + + // Extensions + // check sum + let file_hash: [u8; 20] = hash.finalize().into(); + file.write_all(&file_hash)?; + Ok(()) +}``` +1. **Streaming hash computation depends on the concrete algorithm type** + + - The current implementation hard-codes Sha1::new(). This needs to be changed to select either SHA-1 or SHA-256 based on get_hash_kind(), so that the streaming hash computation respects the active algorithm. + + +2. **Hash-length-dependent padding and trailer size** + + - The expression padding = 8 - ((22 + entry.name.len()) % 8) currently bakes in assumptions about the fixed layout, such as “20-byte hash + 2-byte flags”, which only holds for SHA-1. This logic must be adjusted so that it works correctly when the hash length changes for SHA-256. + + - The final checksum file_hash: [u8; 20] must also be updated to derive its size dynamically from HashKind::size(), rather than assuming a fixed 20-byte hash. +#### Object Modules (`src/internal/object/blob.rs`, etc.) + +```rust +fn from_bytes(data: &[u8], hash: SHA1) -> Result +where + Self: Sized, +{ + Ok(Blob { + id: hash, + data: data.to_vec(), + }) +}``` +For functions of this kind, the logic itself is very simple: they simply assign the hash to a field in the struct. The refactoring approach is straightforward: + +- Replace the parameter type **SHA-1** with ObjectHash; + +- Keep the rest of the logic almost unchanged; + +- For certain helper functions that are only used internally, we may optionally add additional where-constraints (for example, enforcing that ObjectHash matches a specific encoding requirement). + +These changes are essentially **type-level substitutions**. While the risk is relatively low for each individual call site, the impact is broad: the refactor requires a systematic sweep over all places that hard-code **SHA-1** as a type. + +## ## Impact Analysis on Libra + +#### 1. Global Initialization of `HashKind` and Refactoring of Command Entry Points +The official `hash-function-transition` documentation provides a typical configuration for a SHA-256 repository: +```ini +[core] + repositoryFormatVersion = 1 +[extensions] + objectFormat = sha256 + compatObjectFormat = sha1 +``` +In Libra, we should introduce a unified initialization routine at the command entry layer: +- Read the current repository’s core.repositoryFormatVersion, extensions.objectFormat, and extensions.compatObjectFormat; + +- Set the global HashKind in **git-internal** based on objectFormat; + +- Propagate compatObjectFormat (used for cross-algorithm compatibility) down into lower layers, where it can later be leveraged to build an ObjectIdMap (for example, backed by a dedicated SQLite table). + +**Additional Note:** +Any code that **generates or parses hashes** must only run _after_ this initialization step has completed. This logic should be wrapped in a helper function (e.g., an initialization guard) to avoid duplication. + +In the initial refactor, we only introduced a single global variable in **git-internal** to represent the active objectFormat. However, to support compatibility between different hash algorithms, we now need to extend this design and introduce additional state (e.g., a separate variable to represent compatObjectFormat), as will be discussed in later sections. +#### 2. Support for Pack Index v3 and `ObjectIdMap` +According to Git’s official [hash-function-transition](https://git-scm.com/docs/hash-function-transition) documentation, the pack index v3 (`.idx`) format for multiple hash functions introduces an explicit header that describes the index version, object count, and the number of supported hash algorithms: +```ini +- 4 bytes: pack index signature `\377t0c` +- 4 bytes: version number (e.g., `3`) +- 4 bytes: header size (including signature and version) +- 4 bytes: “number of objects in the pack” +- 4 bytes: “number of object formats (hash functions) in this index” (for example, `2` if both SHA-1 and SHA-256 are present) +``` +The header is followed by per-algorithm tables for object IDs, offsets, and related structures. Each hash function gets its own set of tables, allowing **the same object to be referenced by multiple hash algorithms within a single index**. The core purpose is to let **one `.idx` file store names for multiple hash algorithms**, providing a **standard on-disk structure for algorithm migration and compatibility**. + +However, there is currently no readily available reference implementation of v3 in our codebase. We need to implement it manually based on the documentation, or first finish v1/v2 support and then extend it to v3. + +In the **Libra** repository, only a single-hash implementation exists today (`libra/src/command/index_pack.rs`): + +```rust +/// Build index file for pack file, version 1 +/// +/// [pack-format](https://git-scm.com/docs/pack-format) +pub fn build_index_v1(pack_file: &str, index_file: &str) -> Result<(), GitError> { + let pack_path = PathBuf::from(pack_file); + let tmp_path = pack_path.parent().unwrap(); + // ... +}``` +Once Git 3.0 introduces **SHA-256** and cross-algorithm compatibility, **Libra must evolve to support pack index v3** in order to manage **both SHA-1 and SHA-256 object names in a single index file**. + +Within **Libra** itself (since pack indexing and object rewriting logic currently live inside Libra), the following evolutions are required: +1. Add a build_index_v3 implementation inside Libra: + + - Emit the v3 header (signature, version, object count, number of hash algorithms); + + - For the hash algorithm indicated by the global configuration, as well as for the compatible algorithm (e.g., SHA-1 and SHA-256), maintain a dedicated set of tables for each, as specified in the [pack index](https://git-scm.com/docs/hash-function-transition)![Attachment.tiff](file:///Attachment.tiff) documentation; + + - Ensure that, for each object in the pack, **both algorithm-specific IDs** are recorded in the index. + +2. Introduce an ObjectIdMap abstraction so that the **Libra** process can efficiently perform bidirectional conversions: +```rust +struct ObjectIdMap { + by_sha1: HashMap, // sha1 -> sha256 + by_sha256: HashMap, // sha256 -> sha1 +} +``` +The contents of this map can be populated from parsed index data or queried from the database (since **Libra** replaces some of the original internal storage with SQLite). In-memory maps should ideally only hold mappings relevant to the current operation, while the full mapping set should be persisted in the database. +#### 3. Protocol Layer: Fetch as an Example +Consider the following scenario: + +> Local **Libra** repository: `objectFormat = sha256`, `compatObjectFormat = sha1` +> (primary storage algorithm is SHA-256, with SHA-1 as a compatibility format) +> Remote Git server: legacy SHA-1 repository that only knows 40-character SHA-1 IDs. + +In the current architecture, **Libra implements `index-pack` and related object-processing logic by itself**, so the following steps all happen inside **Libra**, without depending on external **git-internal** helpers. Precisely because of this, **git-internal** can remain relatively minimal and only provide the basic primitives. +##### 1. Session Initialization: Determining Three `HashKind`s +When a `fetch` command starts, Libra needs to: + +1. Open the local repository and read `.git/config`: + + - Parse `extensions.objectFormat` and set `PrimaryHashKind` (used for local object storage and ID computation); + - Parse `extensions.compatObjectFormat` and set `CompatHashKind` (if present, this describes the hash type stored in the translation table). + +2. Based on the remote capability negotiation, determine the **`WireHashKind` (the hash type used on the wire for transmission and parsing)**: + + - Legacy servers that only support **SHA-1** → `WireHashKind = Sha1`; + - In the future, servers may support **SHA-256**, in which case `WireHashKind` might also be `Sha256`. + +In most transition scenarios, the local repository is **SHA-256**-based, while the remote is still **SHA-1**-only. In that case we typically have: + +- `PrimaryHashKind = Sha256` +- `CompatHashKind = Some(Sha1)` +- `WireHashKind = Sha1` + +##### 2. Protocol Phase +In the current implementation, Libra’s `internal/protocol` code parses remote references by slicing fixed-length ID segments, which obviously only works for **SHA-1**. + +Once **SHA-256** is supported, we must: + +1. Use `WireHashKind::hex_len()` to determine the length of IDs on the wire, instead of hard-coding `40`; +2. Serialize all `have` / `want` lines according to `WireHashKind`, ensuring that on-the-wire IDs are fully compatible with the remote’s expectations; +3. Whenever Libra needs to map a remote ID to a local object, it should go through `ObjectIdMap` and dedicated conversion helpers. + +##### 3. Pack-Processing Phase +According to Git’s [hash-function-transition](https://git-scm.com/docs/hash-function-transition), when the local repository is **SHA-256** and the remote repository is **SHA-1**, the key steps during `fetch` are: +1. **Receive a SHA-1 Pack** + + - Libra receives a “pure SHA-1” pack from the server in the traditional way, where all object IDs, delta references, etc. are expressed in **SHA-1**. + +2. **Run `index-pack` and Perform Topological Sorting (Inside Libra)** + + - In `libra/src/command/index_pack.rs` or related modules: + - Run `index-pack` over the SHA-1 pack to obtain all objects and their **SHA-1** IDs; + - Perform a **topological sort** over commits / trees / tags so that “referenced objects come before their referrers”; + - `blob` objects can be handled separately, since they do not reference other objects. + +3. **Rewrite Object Contents into SHA-256 Form** + + In the current Libra architecture, this step also happens inside Libra itself (not in git-internal): + + - For **blob** objects: + - The “SHA-1 content” and “SHA-256 content” are **byte-for-byte identical**; only the hash function differs; + - Libra can compute both SHA-1 and SHA-256 over the same object body and store the mapping in the translation table. + + - For **tree / commit / tag** objects: + - First obtain the “SHA-1 content”, which includes **SHA-1** references to other objects; + - Use `ObjectIdMap` to replace all internal **SHA-1** references with their corresponding **SHA-256** IDs, thereby constructing the **SHA-256** version of the content; + - Prepend the appropriate object header to this SHA-256 content and compute the **SHA-256** object ID; + - Write the rewritten object into a new local **SHA-256** pack; + - At the same time, record the mapping `sha1 <-> sha256` both in the v3 index and in the in-memory `ObjectIdMap`. + +4. **Write Pack Index v3 and `loose-object-idx`** + + - After all objects have been processed, Libra invokes its own `build_index_v3` implementation to: + - Build fan-out / ID / offset tables for both the primary and compatibility algorithms; + - Write the `.idx` file and update the `loose-object-idx` index for unpacked (loose) objects. + +5. **Clean Up the Temporary SHA-1 Pack** + + - The **SHA-1** pack is only an intermediate format and can be removed once the rewrite process is complete; + - Locally, we only keep the **SHA-256** pack, the v3 index, and the translation table. +## Mega: Fixed Hash Policy and Context Design for a Monolithic Repository +The situation for **Mega** is different from **Libra**: +- It is a **monolithic repository**, and can internally adopt a single hash algorithm (preferably **SHA-256**) in a uniform way; +- It does not need to interoperate with **SHA-1** repositories, nor act as a “general-purpose Git client”; +- Therefore, a simpler and more explicit strategy is acceptable and even preferable. + +Under these conditions, repeating Libra’s pattern of “reading configuration and propagating `HashKind` everywhere” becomes unnecessarily heavy. Typical issues include: + +- Re-reading configuration in every low-level operation (writing objects, computing diffs, creating commits); +- Polluting API signatures with extra `hash_kind: HashKind` parameters, increasing cognitive load; +- In multi-threaded scenarios, repeatedly accessing configuration can introduce additional locking and runtime overhead. + +### One-Time `hash_kind` Initialization in `AppContext` +A more appropriate approach for **Mega** is to introduce a **global application context** (`Context`) that is initialized only once when the application or worker thread starts. In the `context` module, there is already a struct: + +```rust +pub struct AppContext { + pub storage: jupiter::storage::Storage, + pub vault: vault::integration::vault_core::VaultCore, + pub config: Arc, +}``` +We propose refactoring it as follows: +- In AppContext::new (or the main initialization entry point): + + - Read objectFormat from configuration or repository metadata (defaulting to sha256); + + - Call git-internal’s set_hash_kind(HashKind::Sha256), and store the chosen hash_kind as a field inside AppContext. + +The conventions are: +- Business logic must **not** independently read configuration files to determine the hash algorithm; + +- All hash-related logic obtains the algorithm either from ctx.hash_kind or via the global get_hash_kind() provided by **git-internal**; + +- The context is shared across threads as Arc< AppContext> and treated as immutable. +# Project Roadmap + +The project can be advanced in the following phases: + +1. **Phase 1: Refactoring Abstractions in git-internal** + - Implement the `HashKind` / `ObjectHash` abstraction; + - Introduce global / thread-local `set_hash_kind` / `get_hash_kind`; + - Refactor core modules (objects, index, protocol primitives) to remove `[u8; 20]` and hard-coded 40-byte assumptions. + +2. **Phase 2: Baseline Adaptation in Libra** + - Initialize `HashKind` in a unified way at all command entry points; + - Refactor protocol parsing based on `hex_len` and `ObjectHash::from_str`; + - Verify that interactions with existing SHA-1 repositories behave correctly. + +3. **Phase 3: Pack Index v3 and Compatibility Support** + - Implement pack index v3 in Libra; + - Introduce `ObjectIdMap` and enable SHA-1 ↔ SHA-256 mapping end to end; + - Complete cross-algorithm `fetch` / `push` flows within Libra. + +4. **Phase 4: Context Refactoring in Mega** + - Extend `AppContext` to include `hash_kind` and initialize it once during startup; + - Remove all direct “hash algorithm detection” logic inside Mega and route everything through the shared context; + - Validate performance and behavioral stability under a “single SHA-256 strategy”. + +# Expected Benefits + +1. **Improved Security** + - Add support for SHA-256, eliminating practical SHA-1 collision risks and aligning with the evolution direction of Git 3.0. + +2. **Enhanced Architectural Evolvability** + - By introducing the `HashKind` / `ObjectHash` abstraction, the cost of adding new hash algorithms in the future is constrained to a manageable scope. + +3. **Clearer Separation of Responsibilities** + - **git-internal**: provides low-level multi-hash abstractions and indexing capabilities; + - **Libra**: implements multi-hash interoperability as a general-purpose Git client; + - **Mega**: chooses a hash policy once at the business boundary and keeps internal logic simple and stable. + +4. **Alignment with Upstream Community** + - Designs such as pack index v3 and `compatObjectFormat` remain compatible with upstream Git, laying the groundwork for integrating additional community tools and future protocol extensions. + +# References + +- [Git 2.45: Preliminary Support for SHA-1 and SHA-256 Interoperability](https://github.blog/open-source/git/highlights-from-git-2-45/#preliminary-support-for-sha-1-and-sha-256-interoperability) +- [hash-function-transition](https://git-scm.com/docs/hash-function-transition) +- [git-init](https://git-scm.com/docs/git-init/2.38.0) +- [BreakingChanges](https://git-scm.com/docs/BreakingChanges) +- [Git Source](https://github.com/git/git) +- [gitoxide](https://github.com/GitoxideLabs/gitoxide) +- [libgit2: SHA-256 Discussion](https://github.com/libgit2/libgit2/discussions/5840) +- [pack-format](https://git-scm.com/docs/pack-format) +- [Why Google Stores Billions of Lines of Code in a Single Repository](https://cacm.acm.org/research/why-google-stores-billions-of-lines-of-code-in-a-single-repository/) +- [protocol capabilities](https://git-scm.com/docs/protocol-capabilities) +- [protocol v2](https://git-scm.com/docs/protocol-v2) diff --git a/docs/git-object.md b/docs/git-object.md new file mode 100644 index 00000000..88fb84a6 --- /dev/null +++ b/docs/git-object.md @@ -0,0 +1,71 @@ +# Git Object Reference + +This document summarizes the object formats supported by git-internal, how IDs are hashed, and how they map to canonical Git formats, based on the implementations in `internal/object`. + +## Common Format and Hashing + +- Storage format: ` \0`, where `type` is `blob/tree/commit/tag` and `size` is the raw data length (decimal string). +- Hashing: `ObjectHash::from_type_and_data(ObjectType, data)` produces an ID using the current thread hash algorithm (Currently supports SHA-1 and SHA-256); switch via `set_hash_kind` / `set_hash_kind_for_test`. +- Types: `ObjectType` offers `to_string`/`to_u8`/`from_u8`/`from_string`, covering base objects (Commit/Tree/Blob/Tag) and delta objects (OffsetDelta/HashDelta/OffsetZstdDelta—extension). +- Serialization: Each object’s `to_data` returns `\0`; `ObjectHash::to_string()` emits hex, `to_data()` returns raw bytes. + +## Blob + +- Location: `object/blob.rs`. +- Meaning: File content snapshot, no path/permission (those live in Tree). +- Structure: `Blob { id: ObjectHash, data: Vec }`. +- Build: `Blob::from_content` / `from_content_bytes` auto-compute the hash; `from_bytes(data, hash)` parses with a known hash. +- Serialize: `to_data()` returns raw content (header is implied when hashing with `ObjectHash::from_type_and_data`). + +## Tree and TreeItem + +- Location: `object/tree.rs`. +- TreeItem format: `" \0"`; modes include `100644`/`100755`/`120000`/`160000`/`40000` (gitlink). +- Structure: `TreeItem { mode: TreeItemMode, id: ObjectHash, name: String }`; `Tree { id, tree_items: Vec }`. +- Build: `Tree::from_tree_items(items)` computes the tree hash; `rehash` recomputes after modifications. +- Parse: `Tree::from_bytes(data, hash)` splits IDs using current hash length (20/32 bytes); TreeItem parsing has a GBK fallback for non-UTF-8 names. + +## Commit + +- Location: `object/commit.rs`. +- Field order: `tree `, zero or more `parent `, `author `, `committer `, blank line, then message (may include signatures). +- Structure: `Commit { id, tree_id, parent_commit_ids, author, committer, message }`. +- Build: `Commit::new` (explicit signatures) or `from_tree_id` (convenience with current-time signatures); both use `ObjectHash::from_type_and_data` to derive the ID. +- Parse: `from_bytes` splits lines and uses `Signature::from_data` for author/committer. +- Helper: `format_message` skips PGP signature blocks or returns the first non-empty line. + +## Tag (Annotated) + +- Location: `object/tag.rs`. +- Format: + `object ` + `type ` + `tag ` + `tagger ` + `` (after a blank line) +- Structure: `Tag { id, object_hash, object_type, tag_name, tagger, message }`. +- Build: `Tag::new(object_hash, object_type, tag_name, tagger, message)`; hash is computed from serialized content. +- Parse: `from_bytes` validates UTF-8 and errors on invalid fields; `to_data` emits the format above. + +## Note + +- Location: `object/note.rs`. +- Meaning: An annotation attached to an object; internally treated as a Blob (`get_type` returns Blob), and hashed using Blob rules. +- Build/Parse: `Note::from_content(content)` builds a note for a placeholder target; `Note::new(target_object_id, content)` associates it to a specific object. Use `from_bytes` to parse existing data. + +## Signature + +- Location: `object/signature.rs`. +- Layout: ` `, where `role` is `author`/`committer`/`tagger` (`SignatureType`). +- Functions: `Signature::from_data` parses a byte sequence; `to_data` serializes; `new` creates a signature with a given role/name/email (caller supplies time). + +## Helpers and Common Types + +- Location: `object/utils.rs`. +- Contents: Currently minimal; most shared I/O/hash helpers live in top-level `utils.rs`. + +## Pack/Protocol Integration + +- Pack decode yields `Entry` with `obj_type`, `hash`, `data`; these can be parsed by the object modules above. +- Pack encode expects `ObjectHash` plus raw data; `PackEncoder` uses `ObjectType` to craft headers and validate hashes. +- Protocol (upload-pack/receive-pack) cares about object ID/type consistency; content parsing is left to the caller or higher layers as needed. diff --git a/docs/git-protocol-guide.md b/docs/git-protocol-guide.md new file mode 100644 index 00000000..727d1fcf --- /dev/null +++ b/docs/git-protocol-guide.md @@ -0,0 +1,319 @@ +# Git Protocol Abstraction Design and Implementation + +## 1. Project Overview + +The git-internal library implements a transport layer abstraction for the Git smart protocol, separating HTTP and SSH protocol handling from monorepo business code and adapting to any business system through Trait interfaces. + +## 2. Architecture Design + +### 2.1 Layered Architecture + +``` +┌─────────────────────────────────────┐ +│ Business System Integration │ ← Implement Trait interfaces +├─────────────────────────────────────┤ +│ Transport Protocol Adapters │ ← HTTP/SSH handlers +├─────────────────────────────────────┤ +│ Git Smart Protocol Core │ ← Protocol logic implementation +├─────────────────────────────────────┤ +│ Pack File Processing Layer │ ← Object packing/unpacking +└─────────────────────────────────────┘ +``` + +### 2.2 Core Abstraction Interfaces + +**RepositoryAccess Trait** + +- Provides storage layer abstraction, isolating business logic +- Supports reference management, object access, and Pack operations +- Can adapt to any storage backend (filesystem, database, etc.) + +**AuthenticationService Trait** + +- Unified authentication interface supporting HTTP and SSH +- Can integrate with any authentication system (OAuth, JWT, public key, etc.) + +**GitProtocol Core** + +- Transport-agnostic protocol implementation +- Unified info/refs, upload-pack, receive-pack interfaces + +## 3. Implementation Status + +### 3.1 Completed Features + +**Core Protocol** + +- Complete Git smart protocol v1 implementation +- Reference advertisement and capability negotiation +- upload-pack service (clone/fetch operations) +- receive-pack service (push operations) +- Pack file generation and parsing + +**Transport Layer** + +- HTTP transport adapter (request parsing, streaming responses) +- SSH transport adapter (command parsing, authentication integration) +- Transport protocol abstraction (unified interface) + +**Data Processing** + +- Side-band multiplexing +- Progress reporting mechanism +- Object parsing (blob, commit, tree) +- Reference updates and validation + +**Authentication System** + +- HTTP authentication (header-based) +- SSH authentication (public key verification) +- Pluggable authentication architecture + +## 4. Module Organization + +``` +src/protocol/ +├── core.rs # Main abstractions (Trait definitions, GitProtocol) +├── http.rs # HTTP transport adapter +├── ssh.rs # SSH transport adapter +├── smart.rs # Git smart protocol implementation +├── pack.rs # Pack generation and processing +├── types.rs # Protocol types and error definitions +├── utils.rs # Protocol utility functions +└── mod.rs # Module exports +``` + +## 5. HTTP Protocol Abstraction + +### 5.1 Design Features + +- Request path parsing and repository location +- Standard Git HTTP content type handling +- Streaming responses for large repository transfers +- Error mapping to HTTP status codes + +### 5.2 Main Functions + +- `handle_info_refs`: Process reference query requests +- `handle_upload_pack`: Process clone/fetch requests +- `handle_receive_pack`: Process push requests +- `authenticate_http`: HTTP authentication integration + +## 6. SSH Protocol Abstraction + +### 6.1 Design Features + +- Git command line parsing (git-upload-pack, git-receive-pack) +- Repository path extraction and validation +- Direct protocol mapping without HTTP overhead +- Public key authentication integration + +### 6.2 Main Functions + +- Command parsing and dispatching +- Repository path extraction +- Protocol operation mapping +- SSH authentication integration + +## 7. Trait Adaptation Solution + +### 7.1 Storage Adaptation + +Adapt any storage system through RepositoryAccess Trait: + +- Filesystem storage +- Database storage +- Cloud storage services +- Distributed storage + +### 7.2 Authentication Adaptation + +Integrate any authentication system through AuthenticationService Trait: + +- Traditional username/password +- OAuth/JWT tokens +- SSH public key authentication +- Enterprise SSO systems + +### 7.3 Framework Agnostic + +- No dependency on specific web frameworks +- No binding to specific SSH libraries +- No database choice restrictions +- No forced authentication schemes + +## 8. Error Handling and Types + +### 8.1 Protocol Error Types + +- InvalidService: Invalid service request +- RepositoryNotFound: Repository does not exist +- Unauthorized: Authentication failure +- InvalidRequest: Request format error +- Other I/O and internal errors + +### 8.2 Transport Mapping + +Each transport layer is responsible for mapping protocol errors to appropriate transport error formats (HTTP status codes, SSH error messages, etc.). + +## 9. Capabilities and Features + +### 9.1 Supported Git Capabilities + +- side-band-64k: Multiplexed data streams +- ofs-delta: Offset delta objects +- report-status: Push status reporting +- multi_ack_detailed: Detailed acknowledgment negotiation +- no-done: Optimized negotiation flow + +### 9.2 Protocol Features + +- Complete want/have negotiation +- Incremental Pack transmission +- Progress reporting +- Reference update validation + +### 9.3 Protocol Evolution: v0 / v1 / v2 Comparison + +#### Version Overview + +Git Smart Protocol has evolved through three major versions. +Each version refines how the client and server exchange repository data, +especially for large-scale or latency-sensitive environments. + +| Feature | v0 (Legacy) | v1 (Mainstream) | v2 (Modern) | +|----------|--------------|----------------|-------------| +| Capability Negotiation | ❌ None | ✅ Introduced | ✅ Refined command-based negotiation | +| Protocol Framing | Raw Stream | pkt-line framing | pkt-line with structured commands | +| HTTP Support | Partial | Full | Optimized and proxy-friendly | +| Extensibility | Limited | Basic | Modular and command-oriented | +| Command Granularity | Combined flow | Sequential phases | Independent commands | +| Performance | Low | Medium | High (fewer RTTs, less data transfer) | +| Server Complexity | Simple | Moderate | Structured and modular | +| Status in Git Ecosystem | Deprecated | Widely used | Supported in modern Git servers (>=2.18) | + +#### Version Highlights + +##### **v0 (Legacy Smart Protocol)** +- Early “smart” mode over TCP or SSH before capability negotiation existed. +- Used simple request/response sequences (`upload-pack` / `receive-pack`) + without flexible negotiation or side-band streaming. +- Now considered obsolete and unsupported in most Git servers. + +##### **v1 (Current Mainstream)** +- Introduced in Git 1.7+. +- Added capability negotiation, side-band multiplexing, and better error mapping. +- Enables features such as: + - `multi_ack_detailed`, `side-band-64k`, `ofs-delta`, `report-status`, `no-done` +- Currently the most widely deployed version (e.g., GitHub, GitLab, Gitea). + +##### **v2 (Command-Based Modern Protocol)** +- Introduced in Git 2.18 (2018), designed for extensibility and performance. +- Transforms the protocol from stream-based negotiation to a **command-driven request model**: + - Client sends specific commands instead of phase-based state machines. + - Server replies with structured sections per command. +- Ideal for HTTP(S) transports and proxy environments. + + +### 9.4 Protocol v2 Negotiation and Commands + +#### 1. Initial Capability Exchange + +The client initiates a request with: +``` +GET /info/refs?service=git-upload-pack +Git-Protocol: version=2 +``` + +Server responds with a list of supported capabilities and commands: +``` +version 2 +ls-refs +fetch=filter +server-option +session-id=deadbeef +agent=git/2.45.0 +``` + +#### 2. Supported v2 Commands + +| Command | Purpose | Description | +|----------|----------|-------------| +| `ls-refs` | List references | Returns branches, tags, and HEAD information with filtering. | +| `fetch` | Clone / fetch data | Supports partial and shallow clones; incremental packfile delivery. | +| `push` | Push updates | Negotiates reference updates and receives new objects. | +| `server-option` | Custom server parameters | Allows additional server-side settings before command execution. | +| `agent` | Version identification | Reports client/server version for debugging and analytics. | + +#### 3. Example: v2 Fetch Flow + +``` +C: command=ls-refs +C: agent=git/2.45.0 +C: end +S: ref refs/heads/main 1234abcd +S: symref=HEAD refs/heads/main +S: end + +C: command=fetch +C: want 1234abcd +C: filter blob:none +C: done +S: packfile data... +S: end +``` + +### 9.5 Migration and Upgrade Recommendations + +#### For Protocol Implementers +- Maintain backward compatibility: support v1 and v2 simultaneously. +- Allow version negotiation via the `Git-Protocol` header or SSH command environment. +- Gradually deprecate v0 handling to reduce code complexity. + +#### For System Integrators +- Use v2 for large-scale repositories or latency-sensitive deployments. +- v2’s `ls-refs` and `fetch=filter` reduce bandwidth and server CPU usage. +- Implement adaptive fallback to v1 for older Git clients. + +#### For `git-internal` Library +- Current status: **Full v1 compliance** +- Recommended next steps: + 1. Introduce protocol negotiation layer detecting `Git-Protocol: version=2` + 2. Implement minimal `ls-refs` and `fetch` command handlers + 3. Extend `RepositoryAccess` and `GitProtocol` traits to support v2 command flow + 4. Add benchmark suite comparing v1 vs v2 RTT and bandwidth efficiency + +### 9.6 Summary + +| Category | v1 | v2 | +|-----------|----|----| +| Negotiation | Capabilities exchange via `info/refs` | Explicit version header (`Git-Protocol: version=2`) | +| Command Model | Sequential phase-based | Independent modular commands | +| Extensibility | Limited (capability list) | Open-ended (new commands possible) | +| Efficiency | Moderate | High (fewer round trips, less data) | +| Implementation Complexity | Medium | Higher but cleaner abstraction | + +## 10. Integration Guide + +### 10.1 Implementation Steps + +1. Implement RepositoryAccess Trait to connect storage system +2. Implement AuthenticationService Trait to connect authentication system +3. Create HTTP/SSH handler instances +4. Route requests to appropriate handlers in framework + +### 10.2 Design Principles + +- Separation of concerns: Protocol logic decoupled from business logic +- Interface abstraction: Pluggable architecture through Traits +- Transport agnostic: Same protocol logic supports multiple transports +- Performance focused: Streaming processing, memory efficient + +## 11. Summary + +The git-internal library successfully implements Git protocol transport layer abstraction, separating protocol handling from business logic through clear Trait interfaces. This design supports: + +- **Complete protocol implementation**: Full Git smart protocol v1 functionality +- **Flexible integration solution**: Can adapt to any storage and authentication system +- **Transport layer abstraction**: Unified HTTP and SSH handling +- **High-performance design**: Streaming processing and memory optimization From 733b4d9815f183d89a4f8709678a373119462f03 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Fri, 6 Mar 2026 00:05:08 +0800 Subject: [PATCH 3/9] refactor: replace plan fwindow with iframes and update docs Signed-off-by: Quanyi Ma --- Cargo.lock | 2 +- Cargo.toml | 4 +- docs/agent-workflow.md | 72 +++++-- docs/agent.md | 97 +++++++--- src/internal/object/context.rs | 2 +- src/internal/object/intent.rs | 326 ++++++++++++++++++++++++++++---- src/internal/object/mod.rs | 8 +- src/internal/object/pipeline.rs | 6 +- src/internal/object/plan.rs | 67 ++++--- 9 files changed, 470 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 417c2019..fcedf014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -946,7 +946,7 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.6.1" +version = "0.7.0" dependencies = [ "ahash 0.8.12", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index dda939c2..dc6b9dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-internal" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = ["Eli Ma "] description = "High-performance Rust library for Git internal objects, Pack files, and AI-assisted development objects (Intent, Plan, Task, Run, Evidence, Decision) with delta compression, streaming I/O, and smart protocol support." @@ -11,7 +11,7 @@ readme = "README.md" homepage = "https://libra.tools" repository = "https://github.com/web3infra-foundation/libra" license = "MIT" -exclude = ["tests", ".github"] +exclude = ["tests", ".github", ".claude", ".devcontainer", "examples"] [badges] maintenance = { status = "actively-developed" } diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index 5f3f6de5..cdaef3b8 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -120,19 +120,69 @@ graph TD ├─ Auto-generate Change Log based on PatchSet + Task Description └─ Update API Docs, CHANGELOG ══════════════════════════════════════════════════════ - Phase 4: Decision & Release - ══════════════════════════════════════════════════════ - ├─ Aggregate Risk Score (IntentSpec.risk_level + Security Report + Scope) - │ - ├─ Low Risk → Auto-Merge - │ └─ commit → Production Repo + Sync Docs + Trigger CD Pipeline - │ - └─ High Risk → Human-in-the-loop Review - ├─ Show: Change Summary, Exec Chain, Security Report, Impact Analysis - ├─ Options: Approve / Reject / Request Changes (Re-exec) - └─ Approve → commit → Production Repo + Sync Docs + Trigger CD Pipeline + Phase 4: Decision & Release + ══════════════════════════════════════════════════════ + ├─ Aggregate Risk Score (IntentSpec.risk_level + Security Report + Scope) + │ + ├─ Low Risk → Auto-Merge + │ └─ commit → Production Repo + Sync Docs + Trigger CD Pipeline + │ + └─ High Risk → Human-in-the-loop Review + ├─ Show: Change Summary, Exec Chain, Security Report, Impact Analysis + ├─ Options: Approve / Reject / Request Changes (Re-exec) + └─ Approve → commit → Production Repo + Sync Docs + Trigger CD Pipeline +``` + +## Threaded Conversation Thread Model (v1 Draft) + +Thread is designed as a separate object to persist conversation-level history and +all related Intents. + +### Thread object (draft fields) + +| Field | Type | Description | +|---|---|---| +| `header` | `Header` | Thread metadata (id/type/timestamps/creator). | +| `title` | `Option` | Human-readable thread title. | +| `owner` | `ActorRef` | Conversation creator. | +| `participants` | `Vec` | Optional actor members (agent + humans). | +| `intent_ids` | `Vec` | Ordered list of Intents attached to this thread. | +| `head_intent_ids` | `Vec` | Current branch heads after merges/refinement. | +| `latest_intent_id` | `Option` | Canonical latest Intent for default resume. | +| `metadata` | `Option` | Strategy hints / routing policies. | +| `archived` | `bool` | Read-only flag after terminal workflow. | + +### Draft relation graph + +```text +Thread ──intent_ids──→ Intent (ordered) + │ ├─parents─→ Intent + │ ├─parents─→ Intent (merge style) + │ └─thread_id (optional, migration-safe) + │ + └─head_intent_ids───→ latest branching endpoints + +Intent + └─ thread_id (0..1, optional) → Thread ``` +### Migration steps + +1. Add `Thread` object schema and optional `thread_id` on `Intent`. +2. When a new root intent is created: + - create a new thread, + - set `Intent.thread_id` to that thread, + - write intent ID into `thread.intent_ids`. +3. Rebuild history for existing intents in batches: + - walk `Intent.parents` DAG to infer existing conversation roots, + - create one thread per root, + - fill `intent_ids`, `head_intent_ids`, `latest_intent_id`, + - persist `thread_id` for migrated intents. +4. Keep read path tolerant: if `thread_id` is absent, derive session by + parent chain and deterministic timestamp policy (`header.created_at`). +5. Gradually enforce `thread_id` at creation once writer-side migrations are + fully rolled out. + ## Phase 0: Input Preprocessing The entry point where raw user input is transformed into a structured, actionable specification. diff --git a/docs/agent.md b/docs/agent.md index 111cbf56..0479c4d0 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -38,7 +38,7 @@ Context is tracked by two complementary mechanisms: ├──▶ ContextPipeline ← seeded with IntentAnalysis frame │ ▼ - ③ Plan (pipeline, fwindow, steps) + ③ Plan (pipeline, iframes, steps) │ ├─ PlanStep₀ (inline) ├─ PlanStep₁ ──task──▶ sub-Task (recursive) @@ -86,11 +86,12 @@ Context is tracked by two complementary mechanisms: 2. **Intent** — captures the raw prompt and the AI's structured interpretation. Status transitions from `Draft` (prompt only) to `Active` (analysis - complete). Supports conversational refinement via `parent` chain. + complete). Supports conversational refinement via the `parents` set. 3. **Plan** — a sequence of `PlanStep`s derived from the Intent. References a - `ContextPipeline` and records the visible frame range (`fwindow`). Steps - track consumed/produced frames by stable ID (`iframes`/`oframes`). A step may spawn a + `ContextPipeline` and records the consumed/derived stable frame IDs + (`iframes`). Steps track consumed/produced frames by stable ID + (`iframes`/`oframes`). A step may spawn a sub-Task for recursive decomposition. Plans form a revision chain via `previous`. @@ -128,8 +129,10 @@ Context is tracked by two complementary mechanisms: | From | Field | To | Cardinality | |------|-------|----|-------------| -| Intent | `parent` | Intent | 0..1 | +| Intent | `parents` | Intent | 0..N | +| Intent | `thread_id` | Thread | 0..1 | | Intent | `plan` | Plan | 0..1 | +| Thread | `head_intent_ids` | Intent | 0..N | | Plan | `previous` | Plan | 0..1 | | Plan | `pipeline` | ContextPipeline | 0..1 | | PlanStep | `task` | Task | 0..1 | @@ -154,7 +157,7 @@ Context is tracked by two complementary mechanisms: ### Intent (`intent.rs`) The **entry point** of every AI-assisted workflow. Captures the verbatim user -request (`prompt`) and the AI's structured interpretation (`content`). +request (`prompt`) and the AI's structured interpretation (`spec`). #### Status Transitions @@ -164,8 +167,8 @@ Draft ──▶ Active ──▶ Completed └──────────┴──▶ Cancelled ``` -- **Draft**: Prompt recorded, AI has not analyzed it yet. `content = None`. -- **Active**: AI interpretation available in `content`. Plan, Tasks, and Runs +- **Draft**: Prompt recorded, AI has not analyzed it yet. `spec = None`. +- **Active**: AI interpretation available in `spec`. Plan, Tasks, and Runs may be in progress. - **Completed**: All downstream work finished. `commit` contains the result git commit hash. @@ -177,8 +180,9 @@ Draft ──▶ Active ──▶ Completed |-------|------|-------------| | `header` | `Header` | Common metadata (ID, type, timestamps, creator) | | `prompt` | `String` | Verbatim user input, immutable after creation | -| `content` | `Option` | AI-analyzed interpretation; `None` in Draft | -| `parent` | `Option` | Predecessor Intent for conversational refinement | +| `spec` | `Option` | AI-analyzed interpretation; `None` in Draft | +| `parents` | `Vec` | Predecessor Intents for conversational refinement | +| `thread_id` | `Option` | Owner thread for conversation-level history | | `commit` | `Option` | Result git commit, set at step ⑩ | | `plan` | `Option` | Latest Plan revision derived from this Intent | | `statuses` | `Vec` | Append-only status transition history | @@ -188,15 +192,19 @@ Draft ──▶ Active ──▶ Completed ``` Intent₀ ("Add pagination") ▲ - │ parent + │ parents (single predecessor in simple case) Intent₁ ("Also add cursor-based pagination") ▲ - │ parent + │ parents Intent₂ ("Use opaque cursors, not offsets") + ▲ + │ parents (branch merge point) +Intent₃ ("Add pagination + cursor docs") ``` -Each follow-up Intent links to its predecessor via `parent`, forming a -singly-linked list from newest to oldest. +Each follow-up Intent links to one or more predecessors via `parents`, +forming a refinement DAG. A single-thread view can still be rendered via +`thread_id` + `Thread.head_intent_ids`. #### Usage @@ -210,7 +218,7 @@ let mut intent = Intent::new(actor, "Add pagination to the user list API")?; assert_eq!(intent.status(), &IntentStatus::Draft); // 2. AI analyzes the prompt -intent.set_content(Some("Add offset/limit pagination to GET /users".into())); +intent.set_spec(Some("Add offset/limit pagination to GET /users".into())); intent.set_status(IntentStatus::Active); // 3. After execution completes @@ -219,6 +227,46 @@ intent.set_status_with_reason(IntentStatus::Completed, "All tasks finished"); --- +### Thread (`thread` / 草案) + +Thread is a conversation-level aggregate that keeps all related Intents in one +container and records the active branch heads for resumable sessions. + +#### Draft fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Thread metadata (id/type/timestamps, creator) | +| `title` | `Option` | Human-readable thread title | +| `owner` | `ActorRef` | Conversation owner / initiator | +| `intent_ids` | `Vec` | All Intents that belong to this thread | +| `head_intent_ids` | `Vec` | Active refinement heads (non-finalized branches) | +| `latest_intent_id` | `Option` | Canonical head for default continue/resume | +| `metadata` | `Option` | Optional workflow metadata and policies | +| `archived` | `bool` | Marks closed threads for read-only behavior | + +#### Draft relation graph + +```text +Thread ──intent_ids──→ Intent₀ + ├─intent_ids──→ Intent₁ + ├─intent_ids──→ Intent₂ + └─head_intent_ids──→ {Intent₁, Intent₂} +Intent.thread_id ─────────────→ Thread +``` + +#### Draft migration + +1. Add `Thread` schema and optional `thread_id` on `Intent`. +2. For each existing conversation root intent, create one thread and append its + full descendant subtree by `parents` ancestry order. +3. Populate `head_intent_ids` by selecting non-parented leaves in each thread. +4. Populate `latest_intent_id` from policy (e.g. `max(created_at)` or caller-selected). +5. Keep reads compatible with existing un-threaded intents until all producers set + `thread_id`. + +--- + ### Plan (`plan.rs`) A sequence of `PlanStep`s derived from an Intent's analyzed content. Defines @@ -244,14 +292,13 @@ Intent.plan ──▶ Plan_v3 (latest) #### Context Range -A Plan references a `ContextPipeline` via `pipeline` and records the visible -frame range `fwindow = (start, end)` — the half-open range `[start..end)` of -frames that were visible at creation time. +A Plan references a `ContextPipeline` via `pipeline` and records the frame IDs +that fed plan generation in `iframes`. ``` ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] - ^^^^^^^^^^^^^^^^ - fwindow = (0, 4) + │ │ │ + iframes = [0, 1, 2, 3] ``` #### Fields @@ -261,7 +308,7 @@ ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] | `header` | `Header` | Common metadata | | `previous` | `Option` | Predecessor Plan in revision chain | | `pipeline` | `Option` | ContextPipeline used as context basis | -| `fwindow` | `Option<(u32, u32)>` | Visible frame range `[start..end)` | +| `iframes` | `Vec` | Stable frame IDs of frames used for plan derivation | | `steps` | `Vec` | Ordered sequence of steps | #### PlanStep @@ -313,7 +360,7 @@ let actor = ActorRef::agent("planner")?; // Create initial plan let mut plan = Plan::new(actor.clone())?; plan.set_pipeline(Some(pipeline_id)); -plan.set_fwindow(Some((0, 3))); +plan.set_iframes(vec![0, 1, 2, 3]); // Add steps let mut step = PlanStep::new("Refactor auth module"); @@ -670,13 +717,13 @@ Intent (Active) ← content analyzed ContextPipeline created ← seeded with IntentAnalysis frame │ ▼ -Plan created ← Plan.pipeline → Pipeline, Plan.fwindow = range +Plan created ← Plan.pipeline → Pipeline, Plan.iframes = [0,1,2,3] │ steps execute ▼ Frames accumulate ← StepSummary, CodeChange, ToolCall, ... │ ▼ -Replan? → new Plan with updated fwindow +Replan? → new Plan with updated iframes ``` #### Fields @@ -761,7 +808,7 @@ let seed_id = pipeline.push_frame( // 3. Create a Plan referencing this pipeline // plan.set_pipeline(Some(pipeline.header().object_id())); -// plan.set_fwindow(Some((0, pipeline.frames().len() as u32))); +// plan.set_iframes(vec![0, 1, 2, 3]); // 4. As steps complete, push incremental frames let step_id = pipeline.push_frame(FrameKind::StepSummary, "Refactored auth module"); diff --git a/src/internal/object/context.rs b/src/internal/object/context.rs index fbb5938e..86a7e99e 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -14,7 +14,7 @@ //! │ //! └─ plans → ContextPipeline (dynamic) //! │ -//! │ ③ Plan references pipeline + fwindow +//! │ ③ Plan references pipeline + iframes //! ▼ //! ⑤ Run created //! │ diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index 77b07755..bf23b808 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -42,11 +42,66 @@ //! //! ## Conversational Refinement //! -//! `Intent` objects can form a parent chain to represent iterative user -//! refinement. Each follow-up `Intent` stores a `parent` UUID and does not -//! mutate its predecessor. +//! `Intent` objects can form a parent DAG to represent iterative user +//! refinement. Each follow-up `Intent` stores one or more parent `Intent` +//! IDs (for merges/branching) and does not mutate its predecessors. //! ```text -//! Intent_N -> parent -> Intent_(N-1) -> ... -> Intent_0 +//! Intent_N -> parent/parents -> Intent_(N-1) -> ... -> Intent_0 +//! ``` +//! +//! ## Headless example: fork and walk a chain +//! +//! From one intent, create multiple follow-up intents, and query all +//! descendants from a root intent. +//! +//! ```rust,ignore +//! use git_internal::internal::object::intent::Intent; +//! use git_internal::internal::object::types::ActorRef; +//! use std::collections::HashSet; +//! use uuid::Uuid; +//! +//! let actor = ActorRef::human("agent").unwrap(); +//! let (root, _) = Intent::new(actor.clone(), "Support new tracing mode").unwrap(); +//! +//! // 1) Fork: multiple Intents from the same parent. +//! let (opt_a, _) = Intent::new_revision_chain( +//! actor.clone(), +//! "Add event-level tracing", +//! &[&root], +//! ) +//! .unwrap(); +//! let (opt_b, _) = Intent::new_revision_chain( +//! actor.clone(), +//! "Add span-level tracing", +//! &[&root], +//! ) +//! .unwrap(); +//! let (merged, _) = Intent::new_revision_chain( +//! actor, +//! "Merge event + span design", +//! &[&opt_a, &opt_b], +//! ) +//! .unwrap(); +//! +//! // 2) Query: find every descendant under the root intent. +//! let all = vec![root, opt_a, opt_b, merged]; +//! let mut frontier = vec![all[0].header().object_id()]; +//! let mut visited: HashSet = HashSet::new(); +//! let mut descendants = Vec::new(); +//! +//! while let Some(current) = frontier.pop() { +//! for candidate in all.iter() { +//! let candidate_id = candidate.header().object_id(); +//! if visited.contains(&candidate_id) { +//! continue; +//! } +//! if candidate.parents().iter().any(|p| *p == current) { +//! descendants.push(candidate_id); +//! frontier.push(candidate_id); +//! visited.insert(candidate_id); +//! } +//! } +//! } //! ``` //! //! # Purpose @@ -59,6 +114,52 @@ //! workflow to drive review thresholds and automation gates. //! - **Completion closing**: `commit` records the exact repository commit //! that satisfies the request when status reaches `Completed`. +//! +//! # Thread draft (for full conversation records) +//! +//! To preserve complete conversational records, a Thread aggregate can be +//! introduced as a lightweight container object. This draft is intentionally +//! versioned separately from `Intent`, so migration can be rolled out safely. +//! +//! ## Thread object fields (draft) +//! +//! | Field | Type | Description | +//! |---|---|---| +//! | `header` | `Header` | Thread metadata (id/type/timestamps/creator). | +//! | `title` | `Option` | Human-readable conversation title. | +//! | `owner` | `ActorRef` | Conversation owner / initiator. | +//! | `participants` | `Vec` | Optional actor list for collaboration. | +//! | `intent_ids` | `Vec` | All intents under this thread, ordered by creation. | +//! | `head_intent_ids` | `Vec` | Current non-finalized branch tips (one or more). | +//! | `metadata` | `Option` | Optional strategy, routing, policy tags. | +//! | `archived` | `bool` | Marks historical threads as read-only for UI. | +//! | `latest_intent_id` | `Option` | Latest materialized intent in canonical path. | +//! +//! ## Draft relation graph +//! +//! ```text +//! Thread ──intent_ids──→ Intent₀ +//! ├─intent_ids──→ Intent₁ ──parents──→ Intent₀ +//! ├─intent_ids──→ Intent₂ ──parents──→ Intent₁ +//! └─intent_ids──→ Intent₃ ──parents──→ Intent₀/Intent₁ (merge) +//! Intent.thread_id (1:1 link once introduced) +//! ``` +//! +//! ## Migration sketch +//! +//! 1. Add `Thread` object definition and add optional `thread_id` field to +//! `Intent` payload. +//! 2. Add `Thread` on demand: when a new root intent arrives, create one thread +//! and append the intent to `intent_ids`. +//! 3. Backfill existing intents in batches: +//! - group intents by `prompt`/request source correlation, +//! - create one thread per group, +//! - set `Intent.thread_id` accordingly, +//! - populate `intent_ids`, `head_intent_ids`, and `latest_intent_id`. +//! 4. Keep parent chain resolution (`parents`) intact as fallback for objects +//! created before migration. +//! 5. For future writes, require `thread_id` while keeping reads backward +//! compatible with unset field. use std::fmt; @@ -77,6 +178,22 @@ use crate::{ }, }; +trait ParentLike { + fn parent_id(&self) -> Uuid; +} + +impl ParentLike for Uuid { + fn parent_id(&self) -> Uuid { + *self + } +} + +impl ParentLike for &Intent { + fn parent_id(&self) -> Uuid { + self.header.object_id() + } +} + /// Status of an Intent through its lifecycle. /// /// Valid transitions (see module docs for diagram): @@ -207,6 +324,16 @@ pub struct Intent { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, + /// Links to predecessor Intents for conversational refinement. + /// + /// Acts like commit parents: one `Intent` can point to multiple + /// predecessors. The orchestrator can walk the parent set recursively + /// to reconstruct the full refinement graph/history. + /// + /// Example: Intent₃ can reference Intent₁ and Intent₂ if both provide + /// usable context for follow-up synthesis. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + parents: Vec, /// Verbatim natural-language request from the user. /// /// This is the unmodified input exactly as the user typed it (e.g. @@ -228,26 +355,6 @@ pub struct Intent { /// regenerated if the analysis is re-run. #[serde(default, skip_serializing_if = "Option::is_none")] spec: Option, - /// Link to a predecessor Intent for conversational refinement. - /// - /// Forms a singly-linked list from newest to oldest: each - /// follow-up Intent points to the Intent it refines. `None` for - /// the first Intent in a conversation. The orchestrator can walk - /// the `parent` chain to reconstruct the full conversational - /// history and provide prior context to the AI. - /// - /// Example chain: Intent₂ → Intent₁ → Intent₀ (root, parent=None). - #[serde(default, skip_serializing_if = "Option::is_none")] - parent: Option, - /// Git commit hash recorded when this Intent is fulfilled. - /// - /// Set by the orchestrator at step ⑩ after the - /// [`Decision`](super::decision::Decision) applies the final - /// PatchSet. `None` while the Intent is in progress (`Draft` or - /// `Active`) or if it was `Cancelled`. When set, the Intent's - /// status should be `Completed`. - #[serde(default, skip_serializing_if = "Option::is_none")] - commit: Option, /// Link to the [`ContextPipeline`](super::pipeline::ContextPipeline) /// reserved for this Intent. /// @@ -264,6 +371,15 @@ pub struct Intent { /// been created yet. #[serde(default, skip_serializing_if = "Option::is_none")] plan: Option, + /// Git commit hash recorded when this Intent is fulfilled. + /// + /// Set by the orchestrator at step ⑩ after the + /// [`Decision`](super::decision::Decision) applies the final + /// PatchSet. `None` while the Intent is in progress (`Draft` or + /// `Active`) or if it was `Cancelled`. When set, the Intent's + /// status should be `Completed`. + #[serde(default, skip_serializing_if = "Option::is_none")] + commit: Option, /// Append-only chronological history of status transitions. /// /// Initialized with a single `Draft` entry at creation. Each call @@ -298,7 +414,7 @@ impl Intent { header: Header::new(ObjectType::Intent, created_by)?, prompt: prompt.into(), spec: None, - parent: None, + parents: Vec::new(), commit: None, context_pipeline: pipeline_id, plan: None, @@ -332,9 +448,102 @@ impl Intent { } } - /// Returns the parent intent ID, if this is part of a refinement chain. - pub fn parent(&self) -> Option { - self.parent + /// Create a new intent as a revision from an existing intent. + /// + /// This keeps a single-element parent chain by default (`parent_id`). + /// Additional parents can be added with [`add_parent`](Intent::add_parent) + /// for merge-style refinements. + pub fn new_revision_from( + created_by: ActorRef, + prompt: impl Into, + parent: &Self, + ) -> Result<(Self, ContextPipeline), String> { + Self::new_revision_chain(created_by, prompt, &[parent]) + } + + /// Create a new revision intent with multiple parents in one step. + /// + /// The function accepts either ID-based or intent-based parent inputs + /// and automatically deduplicates parents. + /// + /// - For ID-based input: `Intent::new_revision_chain(actor, prompt, &[id1, id2])` + /// - For intent-based input: `Intent::new_revision_chain(actor, prompt, &[&intent1, &intent2])` + /// + /// # Examples + /// + /// ```rust + /// use git_internal::internal::object::intent::Intent; + /// use git_internal::internal::object::types::ActorRef; + /// use uuid::Uuid; + /// + /// let actor = ActorRef::human("agent").expect("actor"); + /// let (base, _) = Intent::new(actor.clone(), "Build API rate-limiting").expect("base"); + /// + /// // 1) single parent (same as `new_revision_from`) + /// let (r1, _) = Intent::new_revision_chain( + /// actor.clone(), + /// "Split into middleware + config", + /// &[&base], + /// ).expect("revision"); + /// + /// // 2) multi-parent merge from existing intents + /// let extra_parent = Uuid::from_u128(0x1001); + /// let (_merged, _) = Intent::new_revision_chain( + /// actor.clone(), + /// "Merge constraints from policy + implementation feedback", + /// &[&base, &r1], + /// ).expect("merge"); + /// ``` + /// + /// # Chained updates + /// + /// ```rust + /// use git_internal::internal::object::intent::Intent; + /// use git_internal::internal::object::types::ActorRef; + /// use uuid::Uuid; + /// + /// let actor = ActorRef::human("agent").expect("actor"); + /// let (first, _) = Intent::new(actor.clone(), "A").expect("first"); + /// let (second, _) = Intent::new_revision_chain( + /// actor.clone(), + /// "B", + /// &[Uuid::from_u128(0x2001)], + /// ).expect("second"); + /// let (_third, _) = Intent::new_revision_chain( + /// actor, + /// "C", + /// &[&first, &second], + /// ).expect("third"); + /// ``` + #[allow(private_bounds)] + pub fn new_revision_chain( + created_by: ActorRef, + prompt: impl Into, + parents: &[P], + ) -> Result<(Self, ContextPipeline), String> { + let (mut intent, pipeline) = Self::new(created_by, prompt)?; + for p in parents { + intent.add_parent(p.parent_id()); + } + Ok((intent, pipeline)) + } + + /// Returns the parent Intent IDs, if this is part of a refinement graph. + pub fn parents(&self) -> &[Uuid] { + &self.parents + } + + /// Add a parent Intent ID with dedupe and self-parent guard. + /// + /// Duplicate parent IDs are ignored to keep the parent vector + /// canonical for deterministic serialization and simple diffs. + pub fn add_parent(&mut self, parent_id: Uuid) { + if parent_id == self.header.object_id() { + return; + } + if !self.parents.contains(&parent_id) { + self.parents.push(parent_id); + } } /// Returns the result commit SHA, if the intent has been fulfilled. @@ -361,9 +570,9 @@ impl Intent { &self.statuses } - /// Links this intent to a parent intent for conversational refinement. - pub fn set_parent(&mut self, parent: Option) { - self.parent = parent; + /// Sets the parent Intent IDs for conversational refinement. + pub fn set_parents(&mut self, parents: Vec) { + self.parents = parents; } /// Records the git commit SHA that fulfilled this intent. @@ -438,13 +647,14 @@ mod tests { #[test] fn test_intent_creation() { let actor = ActorRef::human("jackie").expect("actor"); - let (mut intent, pipeline) = Intent::new(actor, "Refactor login flow").expect("intent"); + let (mut intent, pipeline) = + Intent::new(actor.clone(), "Refactor login flow").expect("intent"); assert_eq!(intent.header().object_type(), &ObjectType::Intent); assert_eq!(intent.prompt(), "Refactor login flow"); assert!(intent.spec().is_none()); assert_eq!(intent.status(), Some(&IntentStatus::Draft)); - assert!(intent.parent().is_none()); + assert!(intent.parents().is_empty()); assert_eq!(intent.context_pipeline(), pipeline.header().object_id()); assert!(intent.plan().is_none()); @@ -461,6 +671,56 @@ mod tests { let plan_id = Uuid::from_u128(0x42); intent.set_plan(Some(plan_id)); assert_eq!(intent.plan(), Some(plan_id)); + + // Multiple parents are supported for merge-style refinement. + let mut intent = intent; + intent.set_parents(vec![Uuid::from_u128(0x43), Uuid::from_u128(0x44)]); + assert_eq!(intent.parents().len(), 2); + assert_eq!(intent.parents()[0], Uuid::from_u128(0x43)); + assert_eq!(intent.parents()[1], Uuid::from_u128(0x44)); + + // new_revision_from automatically links the previous intent. + let (followup, _) = Intent::new_revision_from( + actor.clone(), + "Refactor login flow with stricter constraints", + &intent, + ) + .expect("intent revision"); + assert_eq!(followup.parents(), &[intent.header().object_id()]); + + // new_revision_chain accepts either parent IDs... + let merge_parent_a = Uuid::from_u128(0x201); + let merge_parent_b = Uuid::from_u128(0x202); + let (merge_from_ids, _) = Intent::new_revision_chain( + actor.clone(), + "Refactor login flow with merged context", + &[merge_parent_a, merge_parent_b, merge_parent_a], + ) + .expect("intent revision by ids"); + assert_eq!(merge_from_ids.parents(), &[merge_parent_a, merge_parent_b]); + + // ...or parent intent references... + let (merge_from_intents, _) = Intent::new_revision_chain( + actor.clone(), + "Refactor login flow with intent merge", + &[&intent, &followup], + ) + .expect("intent revision by intents"); + assert_eq!( + merge_from_intents.parents(), + &[intent.header().object_id(), followup.header().object_id()] + ); + + // add_parent deduplicates and ignores self-parent. + let (mut with_helpers, _) = + Intent::new(actor.clone(), "parent merge helper").expect("intent helper"); + let p1 = Uuid::from_u128(0x101); + let p2 = Uuid::from_u128(0x102); + with_helpers.add_parent(p1); + with_helpers.add_parent(p1); + with_helpers.add_parent(p2); + with_helpers.add_parent(with_helpers.header().object_id()); + assert_eq!(with_helpers.parents(), &[p1, p2]); } #[test] diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index 5d04c07b..573b23ff 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -17,7 +17,7 @@ //! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame //! │ //! ▼ -//! ③ Plan (pipeline, fwindow, steps) +//! ③ Plan (pipeline, iframes, steps) //! │ //! ├─ PlanStep₀ (inline) //! ├─ PlanStep₁ ──task──▶ sub-Task (recursive) @@ -71,7 +71,7 @@ //! 3. **[`Plan`](plan::Plan)** — a sequence of //! [`PlanStep`](plan::PlanStep)s derived from the Intent. References //! a [`ContextPipeline`](pipeline::ContextPipeline) and records the -//! visible frame range (`fwindow`). Steps track consumed/produced +//! consumed/derived stable frame IDs (`iframes`). Steps track consumed/produced //! frames by stable ID (`iframes`/`oframes`). A step may spawn a sub-Task for //! recursive decomposition. Plans form a revision chain via //! `previous`. @@ -115,8 +115,10 @@ //! //! | From | Field | To | Cardinality | //! |------|-------|----|-------------| -//! | Intent | `parent` | Intent | 0..1 | +//! | Intent | `parents` | Intent | 0..N | //! | Intent | `plan` | Plan | 0..1 | +//! | Intent | `thread_id` | Thread | 0..1 | +//! | Thread | `head_intent_ids` | Intent | 0..N | //! | Plan | `previous` | Plan | 0..1 | //! | Plan | `pipeline` | ContextPipeline | 0..1 | //! | PlanStep | `task` | Task | 0..1 | diff --git a/src/internal/object/pipeline.rs b/src/internal/object/pipeline.rs index 93c2f02f..ee37c694 100644 --- a/src/internal/object/pipeline.rs +++ b/src/internal/object/pipeline.rs @@ -18,12 +18,12 @@ //! │ analysis //! │ //! ▼ -//! ③ Plan references pipeline + fwindow +//! ③ Plan references pipeline + iframes //! │ StepSummary / CodeChange / ToolCall frames appended //! ▼ //! ④ Task/Run //! │ -//! └─ Replan? → new Plan with updated fwindow +//! └─ Replan? → new Plan with updated iframes //! ``` //! //! The pipeline is created when the Intent is created and linked from @@ -70,7 +70,7 @@ //! - **Bounded Memory**: `max_frames` + eviction ensures the pipeline //! doesn't grow unboundedly in long-running workflows. //! - **Replan Support**: When replanning occurs, a new Plan can -//! reference the same pipeline with an updated `fwindow` that +//! reference the same pipeline with an updated `iframes` that //! includes frames accumulated since the previous plan. use std::fmt; diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index ee2bcc6f..2994f40c 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -13,7 +13,7 @@ //! │ //! ├─ seeds ContextPipeline with IntentAnalysis //! │ -//! └─→ ③ Plan (pipeline, fwindow, steps) +//! └─→ ③ Plan (pipeline, iframes, steps) //! │ //! ├─ PlanStep₀ (inline) //! ├─ PlanStep₁ ──task──▶ sub-Task @@ -48,20 +48,19 @@ //! # Context Range //! //! A Plan references a [`ContextPipeline`](super::pipeline::ContextPipeline) -//! via `pipeline` and records the visible frame range `fwindow = (start, -//! end)` — the half-open range `[start..end)` of frames that were -//! visible when this Plan was created. This enables retrospective -//! analysis: given the context the agent saw, was the plan a reasonable +//! via `pipeline` and records stable `frame_id`s in `iframes` for the +//! plan-generation context. This enables retrospective analysis: +//! given the context frames the agent saw, was the plan a reasonable //! decomposition? //! //! ```text //! ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] -//! ^^^^^^^^^^^^^^^^ -//! fwindow = (0, 4) +//! │ │ +//! iframes = [0, 1] //! ``` //! -//! When replanning occurs, a new Plan is created with an updated frame -//! range that includes frames accumulated since the previous plan. +//! When replanning occurs, a new Plan is created with an updated frame-id +//! basis that includes newly appended context frames since the previous plan. //! //! # Steps //! @@ -120,7 +119,7 @@ //! //! - **Decomposition**: Breaks a complex Intent into manageable, //! ordered steps that an agent can execute sequentially. -//! - **Context Scoping**: `pipeline` + `fwindow` record exactly what +//! - **Context Scoping**: `pipeline` + `iframes` record exactly what //! context the Plan was derived from. Step-level `iframes`/`oframes` //! track fine-grained context flow. //! - **Versioning**: The `previous` revision chain preserves the full @@ -435,15 +434,15 @@ pub struct Plan { /// was used (e.g. a manually created Plan). #[serde(default, skip_serializing_if = "Option::is_none")] pipeline: Option, - /// Frame visibility window `(start, end)`. + /// Stable frame IDs from the context pipeline that this plan was + /// derived from. /// - /// A half-open range `[start..end)` into the pipeline's frame list - /// that was visible when this Plan was created. Enables - /// retrospective analysis: given the context the agent saw, was the - /// decomposition reasonable? `None` when `pipeline` is not set or - /// when the entire pipeline was visible. - #[serde(default, skip_serializing_if = "Option::is_none")] - fwindow: Option<(u32, u32)>, + /// Equivalent to a captured context frame vector, using + /// `ContextFrame::frame_id` (stable under eviction), so replay and + /// audit remain valid even if early frames are removed from the + /// visible window. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + iframes: Vec, /// Ordered sequence of steps to execute. /// /// Steps are executed in order (index 0 first). Each step can be @@ -461,7 +460,7 @@ impl Plan { header: Header::new(ObjectType::Plan, created_by)?, previous: None, pipeline: None, - fwindow: None, + iframes: Vec::new(), steps: Vec::new(), }) } @@ -472,7 +471,7 @@ impl Plan { header: Header::new(ObjectType::Plan, created_by)?, previous: Some(self.header.object_id()), pipeline: None, - fwindow: None, + iframes: Vec::new(), steps: Vec::new(), }) } @@ -507,16 +506,14 @@ impl Plan { self.pipeline = pipeline; } - /// Returns the frame window `(start, end)` — the half-open range - /// `[start..end)` of pipeline frames visible when this plan was created. - pub fn fwindow(&self) -> Option<(u32, u32)> { - self.fwindow + /// Returns the stable frame IDs this plan was derived from. + pub fn iframes(&self) -> &[u64] { + &self.iframes } - /// Sets the frame window `(start, end)` — the half-open range - /// `[start..end)` of pipeline frames visible when this plan was created. - pub fn set_fwindow(&mut self, fwindow: Option<(u32, u32)>) { - self.fwindow = fwindow; + /// Sets the stable frame IDs this plan was derived from. + pub fn set_iframes(&mut self, iframes: Vec) { + self.iframes = iframes; } } @@ -580,19 +577,19 @@ mod tests { } #[test] - fn test_plan_pipeline_and_fwindow() { + fn test_plan_pipeline_and_iframes() { let actor = ActorRef::human("jackie").expect("actor"); let mut plan = Plan::new(actor).expect("plan"); assert!(plan.pipeline().is_none()); - assert!(plan.fwindow().is_none()); + assert!(plan.iframes().is_empty()); let pipeline_id = Uuid::from_u128(0x42); plan.set_pipeline(Some(pipeline_id)); - plan.set_fwindow(Some((0, 3))); + plan.set_iframes(vec![0, 1, 2]); assert_eq!(plan.pipeline(), Some(pipeline_id)); - assert_eq!(plan.fwindow(), Some((0, 3))); + assert_eq!(plan.iframes(), &[0, 1, 2]); } #[test] @@ -691,11 +688,11 @@ mod tests { } #[test] - fn test_plan_fwindow_serde_roundtrip() { + fn test_plan_iframes_serde_roundtrip() { let actor = ActorRef::human("jackie").expect("actor"); let mut plan = Plan::new(actor).expect("plan"); plan.set_pipeline(Some(Uuid::from_u128(0x99))); - plan.set_fwindow(Some((2, 7))); + plan.set_iframes(vec![2, 3]); let mut step = PlanStep::new("step 0"); step.set_iframes(vec![2, 3]); @@ -705,7 +702,7 @@ mod tests { let data = plan.to_data().expect("serialize"); let restored = Plan::from_bytes(&data, ObjectHash::default()).expect("deserialize"); - assert_eq!(restored.fwindow(), Some((2, 7))); + assert_eq!(restored.iframes(), &[2, 3]); assert_eq!(restored.steps()[0].iframes(), &[2, 3]); assert_eq!(restored.steps()[0].oframes(), &[7]); } From 0d1124315a32229c18dcb87e4c3e7e5f4064d068 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Sat, 7 Mar 2026 22:01:19 +0800 Subject: [PATCH 4/9] docs: expand agent object documentation Signed-off-by: Quanyi Ma --- docs/agent-workflow.md | 582 ++++++++-------- docs/agent.md | 1052 ++++++----------------------- src/errors.rs | 6 +- src/internal/object/context.rs | 34 +- src/internal/object/decision.rs | 35 +- src/internal/object/evidence.rs | 34 +- src/internal/object/intent.rs | 739 ++++---------------- src/internal/object/mod.rs | 285 ++++---- src/internal/object/patchset.rs | 249 ++----- src/internal/object/pipeline.rs | 591 ---------------- src/internal/object/plan.rs | 746 +++++--------------- src/internal/object/provenance.rs | 188 ++---- src/internal/object/run.rs | 320 ++------- src/internal/object/task.rs | 382 +++-------- src/internal/object/tool.rs | 35 +- src/internal/object/types.rs | 668 +++++------------- 16 files changed, 1541 insertions(+), 4405 deletions(-) delete mode 100644 src/internal/object/pipeline.rs diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index cdaef3b8..af968b86 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -1,6 +1,29 @@ # Agent Workflow: From Intent to Release -This document outlines the lifecycle of an AI agent task execution, from initial user query to final code release. The process is designed to be robust, secure, and verifiable, incorporating automated planning, execution, verification, and auditing. +This document defines the runtime workflow after the snapshot / event / +Libra split described in `docs/agent.md`. + +## Workflow Contract + +- `git-internal` snapshot objects store immutable definitions and + revisioned structure. +- `git-internal` event objects store append-only execution facts. +- Libra owns mutable runtime state: scheduler queues, thread / + workspace state, selected plan head, live context window, and reverse + indexes. + +The workflow must not depend on rewriting parent snapshot objects to +append runtime history. + +## Phase-to-Layer Mapping + +| Phase | Libra runtime / projection | Snapshot writes (`git-internal`) | Event writes (`git-internal`) | +|---|---|---|---| +| Phase 0 | Thread / Workspace setup, live context bootstrap | `Intent`, optional `ContextSnapshot` | none | +| Phase 1 | Scheduler plan selection, ready queue, checkpoints | `Plan`, `Task` | none | +| Phase 2 | live context window, staging area, retry / replan loop | `Run`, `PatchSet`, `Provenance` | `TaskEvent`, `RunEvent`, `PlanStepEvent`, `ToolInvocation`, `Evidence`, `ContextFrame`, `RunUsage` | +| Phase 3 | audit indexing, release candidate view | optional final `ContextSnapshot` | `Evidence`, `Decision`, terminal `TaskEvent` / `RunEvent` / `IntentEvent` | +| Phase 4 | review UI, current thread / workspace pointers | none | `Decision`, optional terminal `IntentEvent` | ## Workflow Overview @@ -14,302 +37,303 @@ graph TD ``` ```text -══════════════════════════════════════════════════════ - Phase 0: Input Preprocessing - ══════════════════════════════════════════════════════ - User Query - ↓ - ├─ Extract Intent, Constraints, Quality Goals - ├─ Identify Risk Level (Affects Human-in-the-loop threshold) - └─ Standardized Output: IntentSpec { goal, constraints, risk_level } - ↓ - ├─ Create Isolated Sandbox Environment - ├─ Initialize ContextState (Codebase Snapshot + Dep Graph + Type Info) - └─ Lightweight SCA Baseline Scan (Record initial dependency security state) - ══════════════════════════════════════════════════════ - Phase 1: Planning & Task Graph Generation - ══════════════════════════════════════════════════════ - IntentSpec + ContextState - ↓ - [Orchestrator Agent] - ├─ Generate TaskGraph (DAG) - │ Each Task includes: - │ - task_id, description - │ - dependencies: [task_id] - │ - expected_outputs (Interface contracts for parallel task negotiation) - │ - risk_score (Determines if manual review is needed) - └─ Output ExecutionPlan { task_graph, parallel_groups, checkpoints } - ══════════════════════════════════════════════════════ - Phase 2: Task Execution (Main Loop) - ══════════════════════════════════════════════════════ - Scheduler drives execution in topological order, running independent tasks in parallel: - For each ready Task (parallel where dependencies allow): - ┌─ [Context Compaction] - │ ├─ Load verified state from prerequisite tasks - │ ├─ If parallel branches converge: Merge Contexts - │ │ Conflict Detection → Conflict Exists → - │ │ ├─ Auto-resolvable → Merge & Continue - │ │ └─ Unresolvable → Suspend → Human Review - │ └─ Inject Incremental ContextDelta (Not full rebuild) - │ - ├─ [Context Injection] - │ ├─ Semantic Retrieval of code snippets, docs, historical PatchSets - │ └─ Inject Task-level Security Constraints - │ - ├─ [Coder Agent: Code Generation] - │ └─ ToolInvocation → Generate/Modify code in Sandbox - │ - ├─ [Verification Loop] (3 Layers) - │ │ - │ ├─ Layer 1: Static Verification - │ │ ├─ Compilation Check - │ │ ├─ Linter / Type Check - │ │ ├─ Lightweight SAST (Scan only changed files) - │ │ └─ Incremental SCA (Check only new/changed deps) - │ │ - │ ├─ Layer 2: Dynamic Verification - │ │ ├─ Unit Tests (Sandbox) - │ │ └─ Logic & Spec Review - │ │ Issue Found → Generate ReviewComment → Retry - │ │ - │ └─ Layer 3: Retry Control - │ ├─ Any Layer Fails → Diagnose - │ │ └─ Generate Fix Prompt → Return to Code Generation - │ │ Retry Count < N → Continue - │ │ Retry Count = N → [Final Failure Routing]: - │ │ ├─ Downgradable (Non-core Task) → Skip + Warning - │ │ ├─ Replannable (Bypassable dep) → Trigger Replan - │ │ └─ Blocking Failure → Suspend → Human Intervention - │ └─ All Layers Pass → Task Verified - │ - └─ [Staging & Provenance] - ├─ Write PatchSet to Task Staging Area (Not merged to Main Sandbox yet) - ├─ Update ContextState: ContextState.apply_delta(task_id, patch) - └─ Record Task Provenance (Input Snapshot, Exec Chain, Output Hash) - ─── After Parallel Task Group Completes ────────────────── - [Incremental Integration Verification] (Post-batch, not per-task) - ├─ Merge all staging PatchSets in batch → Main Sandbox - ├─ Run Interface Contract Verification (Check expected_outputs) - ├─ Run Cross-module Integration Test Subset - └─ Validation Failed → (Replan remaining tasks) - (Triggered after each batch integration) - └─ Evaluate if prerequisites for remaining tasks still hold - Needed → Revise TaskGraph (Modify unexecuted parts only) - Not Needed → Continue to next batch - ══════════════════════════════════════════════════════ - Phase 3: System-level Verification & Security Audit - ══════════════════════════════════════════════════════ - [Global Validation] - ├─ End-to-End Integration Tests - ├─ Performance Benchmarks (Compare vs Baseline, Regression Check) - └─ Compatibility Tests (API Backward Compatibility, Data Formats) - ↓ - ├─ Full SAST (Cover all changed files) - ├─ Full SCA (Complete dependency tree review) - ├─ IDOR / Injection Risk Scans - └─ Compliance Check (License, Privacy Data Handling) - │ - ↓ Issue Found → Locate Task → Trigger Task-level Fix (Back to Phase 2) - ↓ All Pass - (Generated only after success) - ├─ Aggregate all Task Provenance → Batch-level Complete Exec Chain - ├─ Hash & Sign Full ContextState Snapshot - ├─ Record: IntentSpec → TaskGraph → PatchSets → TestResults → SecurityReport - └─ Write to Append-only Audit Log (Support rollback to any Task boundary) - [Documentation Generation] - ├─ Auto-generate Change Log based on PatchSet + Task Description - └─ Update API Docs, CHANGELOG - ══════════════════════════════════════════════════════ - Phase 4: Decision & Release - ══════════════════════════════════════════════════════ - ├─ Aggregate Risk Score (IntentSpec.risk_level + Security Report + Scope) - │ - ├─ Low Risk → Auto-Merge - │ └─ commit → Production Repo + Sync Docs + Trigger CD Pipeline - │ - └─ High Risk → Human-in-the-loop Review - ├─ Show: Change Summary, Exec Chain, Security Report, Impact Analysis - ├─ Options: Approve / Reject / Request Changes (Re-exec) - └─ Approve → commit → Production Repo + Sync Docs + Trigger CD Pipeline +══════════════════════════════════════════════════════════════════ + Phase 0: Input Preprocessing + ══════════════════════════════════════════════════════════════════ + User Query + ↓ + ├─ Extract Intent, Constraints, Quality Goals + ├─ Identify Risk Level + ├─ Persist Intent snapshot (immutable request / spec revision) + ├─ Create initial ContextSnapshot if a stable baseline is needed + └─ Initialize Libra runtime context + - Thread / Workspace projection + - live context window + - reverse indexes for retrieval + +══════════════════════════════════════════════════════════════════ + Phase 1: Planning + ══════════════════════════════════════════════════════════════════ + Intent[S] + runtime context[Libra] + ↓ + [Orchestrator Agent] + ├─ Create Plan snapshot(s) + │ - Plan.parents expresses replan / merge history + │ - Plan.steps captures immutable step structure + ├─ Create Task snapshots for delegated work units + └─ Libra derives: + - ready queue + - parallel groups + - checkpoints + - selected plan head + +══════════════════════════════════════════════════════════════════ + Phase 2: Execution + ══════════════════════════════════════════════════════════════════ + For each ready Task / PlanStep: + ├─ Libra prepares runtime context + │ - load prerequisite outputs + │ - merge selected ContextFrame records + │ - retrieve code/docs/history + │ - stage sandbox state + │ + ├─ Persist Run snapshot + Provenance snapshot + │ + ├─ Append execution facts + │ - TaskEvent / RunEvent + │ - PlanStepEvent + │ - ToolInvocation + │ - Evidence + │ - ContextFrame + │ - RunUsage + │ + ├─ Persist candidate outputs as immutable PatchSet snapshots + │ + └─ Libra maintains mutable control state + - retry counters + - staging area + - batch integration state + - replanning decisions + +══════════════════════════════════════════════════════════════════ + Phase 3: Validation & Audit + ══════════════════════════════════════════════════════════════════ + ├─ Run system-level validation and security audit + ├─ Append Evidence / RunEvent / TaskEvent / Decision records + ├─ Optionally persist final ContextSnapshot + └─ Libra reconstructs release candidate and audit views from + immutable snapshots + events + +══════════════════════════════════════════════════════════════════ + Phase 4: Decision & Release + ══════════════════════════════════════════════════════════════════ + ├─ Low risk: auto-merge + ├─ High risk: human review in Libra UI + ├─ Record final Decision / IntentEvent if applicable + └─ Libra advances current thread / workspace pointers ``` -## Threaded Conversation Thread Model (v1 Draft) +## Libra Thread Projection and Scheduler State -Thread is designed as a separate object to persist conversation-level history and -all related Intents. +Thread and Scheduler state belong to Libra, not to `git-internal` +snapshots. They track the current conversational view and current +execution view over immutable objects. -### Thread object (draft fields) +### Thread projection | Field | Type | Description | |---|---|---| -| `header` | `Header` | Thread metadata (id/type/timestamps/creator). | +| `thread_id` | `Uuid` | Libra-side primary key. | | `title` | `Option` | Human-readable thread title. | | `owner` | `ActorRef` | Conversation creator. | -| `participants` | `Vec` | Optional actor members (agent + humans). | -| `intent_ids` | `Vec` | Ordered list of Intents attached to this thread. | -| `head_intent_ids` | `Vec` | Current branch heads after merges/refinement. | -| `latest_intent_id` | `Option` | Canonical latest Intent for default resume. | -| `metadata` | `Option` | Strategy hints / routing policies. | -| `archived` | `bool` | Read-only flag after terminal workflow. | +| `participants` | `Vec` | Agent + human members. | +| `intent_ids` | `Vec` | Ordered projection of Intents in the thread. | +| `head_intent_ids` | `Vec` | Current branch heads in the Intent DAG. | +| `latest_intent_id` | `Option` | Default resume target. | +| `metadata` | `Option` | Routing and UI hints. | +| `archived` | `bool` | Read-only marker for closed threads. | + +### Scheduler state -### Draft relation graph +| Field | Type | Description | +|---|---|---| +| `selected_plan_id` | `Option` | Current canonical Plan head in the UI. | +| `current_plan_heads` | `Vec` | Active plan leaves under review or execution. | +| `active_task_id` | `Option` | Task currently emphasized by the scheduler / UI. | +| `active_run_id` | `Option` | Live execution attempt, if any. | +| `live_context_window` | `Vec` | Current visible `ContextFrame` ids. | + +### Projection relation graph ```text -Thread ──intent_ids──→ Intent (ordered) - │ ├─parents─→ Intent - │ ├─parents─→ Intent (merge style) - │ └─thread_id (optional, migration-safe) - │ - └─head_intent_ids───→ latest branching endpoints - -Intent - └─ thread_id (0..1, optional) → Thread +Thread[L] --------intent_ids--------> Intent[S] +Thread[L] --------head_intent_ids---> Intent[S] +Thread[L] --------latest_intent_id--> Intent[S] + +Scheduler[L] ----selected_plan_id---> Plan[S] +Scheduler[L] ----current_plan_heads-> Plan[S] +Scheduler[L] ----active_task_id-----> Task[S] +Scheduler[L] ----active_run_id------> Run[S] +Scheduler[L] ----live_context_window> ContextFrame[E] ``` -### Migration steps - -1. Add `Thread` object schema and optional `thread_id` on `Intent`. -2. When a new root intent is created: - - create a new thread, - - set `Intent.thread_id` to that thread, - - write intent ID into `thread.intent_ids`. -3. Rebuild history for existing intents in batches: - - walk `Intent.parents` DAG to infer existing conversation roots, - - create one thread per root, - - fill `intent_ids`, `head_intent_ids`, `latest_intent_id`, - - persist `thread_id` for migrated intents. -4. Keep read path tolerant: if `thread_id` is absent, derive session by - parent chain and deterministic timestamp policy (`header.created_at`). -5. Gradually enforce `thread_id` at creation once writer-side migrations are - fully rolled out. +### Projection rebuild policy + +1. Libra creates / updates Thread rows and Scheduler state when new + Intents, Plans, Tasks, and Runs appear. +2. Rebuild is always possible from immutable `Intent`, `Plan`, `Task`, + `Run`, `ContextFrame`, and related event streams. +3. Missing projection rows must not block read access; Libra can rebuild + from object history. ## Phase 0: Input Preprocessing -The entry point where raw user input is transformed into a structured, actionable specification. - -1. **Intent Extraction**: - - Analyzes the `User Query` to identify the functional intent, specific constraints, and quality goals. - - Outputs a structured `IntentSpec` containing `{ goal, constraints, risk_level }`. - -2. **Risk Assessment**: - - Evaluates the potential impact of the request. - - Assigns a risk level (Low, Medium, High) which determines the threshold for human intervention in later stages. - -3. **Environment Setup**: - - **Sandbox Creation**: Establishes an isolated environment to prevent side effects during execution. - - **Context Initialization**: Captures a snapshot of the codebase, builds a dependency graph, and extracts type information (`ContextState`). - - **Baseline SCA**: Performs a lightweight Software Composition Analysis to record the initial security posture of dependencies. - -## Phase 1: Planning & Task Graph Generation - -The Orchestrator Agent translates the high-level intent into a concrete execution plan. - -1. **Task Graph Generation**: - - The Orchestrator takes the `IntentSpec` and `ContextState`. - - Generates a **TaskGraph** (Directed Acyclic Graph). - - Each Task includes: - - `task_id`, `description`. - - `dependencies`: List of prerequisite task IDs. - - `expected_outputs`: Interface contracts for boundary negotiation between parallel tasks. - - `risk_score`: Determines if the specific task requires manual review. - -2. **Execution Plan Output**: - - Produces an `ExecutionPlan` containing the `task_graph`, identified `parallel_groups`, and necessary `checkpoints`. - -## Phase 2: Task Execution (Main Loop) - -The core execution phase where the Scheduler drives the workflow. Tasks are executed in topological order, with independent tasks running in parallel. - -### For each ready Task (or Parallel Group): - -1. **Context Compaction**: - - Loads the verified state from prerequisite tasks. - - **Merge Contexts**: If multiple parallel branches converge, their contexts are merged. - - *Conflict Detection*: If conflicts arise, the system attempts automatic resolution. If unsolvable, it suspends for Human Review. - - Injects the incremental `ContextDelta` rather than rebuilding the full context. - -2. **Context Injection**: - - Retrieves semantically relevant code snippets, documentation, and historical PatchSets. - - Injects task-specific security constraints and guidelines into the prompt. - -3. **Code Generation (Coder Agent)**: - - The Coder Agent invokes tools to generate or modify code within the sandbox. - -4. **Verification Loop (Three Layers)**: - - **Layer 1: Static Verification**: - - Compilation checks. - - Linter and type checking. - - Lightweight SAST (Static Application Security Testing) on changed files. - - Incremental SCA on new/changed dependencies. - - **Layer 2: Dynamic Verification**: - - Runs unit tests in the isolated sandbox. - - Performs logical and specification reviews. - - *Failure*: Generates a ReviewComment and enters the retry loop. - - **Layer 3: Retry Control**: - - If any layer fails, the system diagnoses the error and generates a correction prompt. - - *Retry Logic*: Returns to Code Generation up to `N` times. - - *Final Failure Routing*: - - **Downgrade**: If non-core, skip and record a warning. - - **Replan**: If dependencies can be bypassed, trigger a replan. - - **Block**: If critical, suspend for Human Intervention. - - *Success*: Task is marked as verified. - -5. **Staging & Provenance**: - - Writes the resulting `PatchSet` to a task-specific staging area (not yet merged to the main sandbox). - - Updates `ContextState` via `apply_delta(task_id, patch)`. - - Records **Task Provenance**: Input snapshot, execution chain, and output hash. +The entry point transforms raw user input into a structured request and +initial runtime context. + +1. **Intent Extraction**: + - Analyze the `User Query` to identify the user goal, constraints, + and quality requirements. + - Produce `IntentSpec { goal, constraints, risk_level }`. + +2. **Intent Snapshot Write**: + - Persist an immutable `Intent` snapshot for the initial request or + analyzed spec revision. + - If the request is refined later, create a new `Intent` revision and + link it through `Intent.parents`. + +3. **Risk Assessment**: + - Evaluate impact and sensitivity. + - Store the active risk view in Libra; terminal workflow outcomes are + later captured by `IntentEvent`. + +4. **Environment Setup**: + - Create an isolated sandbox. + - Persist an initial `ContextSnapshot` only when a stable baseline is + worth keeping. + - Initialize Libra Thread state, Scheduler state, reverse indexes, + and the live context window. This replaces the old mutable + `ContextPipeline` model. + +## Phase 1: Planning + +The Orchestrator translates the current `Intent` revision into immutable +plan and task definitions, while Libra derives the mutable scheduling +view. + +1. **Plan Construction**: + - Read the active `Intent` snapshot and relevant context material. + - Persist a base `Plan` snapshot: + - `Plan.intent` links the Plan to its `Intent`. + - `Plan.parents` records replan or merge history. + - `Plan.steps` defines immutable step structure. + - If planning branches into multiple candidates, each candidate is a + separate `Plan` snapshot. Their current visibility belongs to + Libra's `current_plan_heads`. + +2. **Task Construction**: + - Persist `Task` snapshots for delegated work units. + - `Task.dependencies`, `Task.parent`, `Task.intent`, and + `Task.origin_step_id` remain immutable provenance links. + +3. **Scheduler Projection**: + - Libra derives the runtime Task graph, parallel groups, checkpoints, + ready queue, and selected plan head from `Plan` + `Task` + snapshots. + - There is no mutable `ExecutionPlan` object in `git-internal`. + +## Phase 2: Execution + +The Scheduler executes ready Tasks in topological order. Independent +Tasks can run in parallel, but all mutable coordination remains in +Libra. + +### For each ready Task (or parallel group) + +1. **Runtime Context Preparation**: + - Load prerequisite outputs from immutable `PatchSet`, + `ContextSnapshot`, and `ContextFrame` records. + - Merge branch-local context in Libra when parallel branches + converge. + - Detect conflicts in Libra. Auto-resolve when safe; otherwise + suspend for human review. + +2. **Run Start**: + - Persist a `Run` snapshot for the execution attempt. + - Persist `Provenance` for provider / model / parameter settings. + - Append initial `TaskEvent` / `RunEvent` entries for execution + start. + +3. **Code Generation and Tool Use**: + - The Coder Agent invokes tools inside the sandbox. + - Each tool call is stored as a `ToolInvocation` event. + - New incremental context is stored as immutable `ContextFrame` + events, not by mutating a shared pipeline object. + +4. **Verification Loop**: + - Static checks, tests, logic review, and security checks produce + `Evidence` events. + - Step progress is recorded via `PlanStepEvent`, including + `consumed_frames` and `produced_frames`. + - Failures append more `RunEvent` / `TaskEvent` records. + - Retry counters and retry routing remain in Libra. + - If execution changes the remaining strategy, persist a new `Plan` + revision rather than mutating the old one. + +5. **Patch Production**: + - Each candidate diff is stored as a new immutable `PatchSet` + snapshot with its own `sequence`. + - Acceptance, rejection, or final selection is not written back onto + the `PatchSet`; it is expressed later by `Decision`. + +6. **Usage and Cost Capture**: + - Persist `RunUsage` after the attempt or batch completes. ### Incremental Integration (Post-Batch) -After a group of parallel tasks completes: - -1. **Merge & Verify**: - - Merges all staging PatchSets from the batch into the main sandbox. - - Validates `expected_outputs` against the interface contracts. - - Runs a subset of cross-module integration tests. - - *Failure*: Triggers replanning for remaining tasks. - -2. **Dynamic Replanning**: - - Evaluates if the prerequisites for the remaining tasks in the graph still hold based on the current `ContextState`. - - **Adjust**: Modifies the `TaskGraph` (only unexecuted parts) if necessary. - - **Continue**: Proceeds to the next batch if valid. +After a parallel group completes: + +1. **Batch Merge in Libra**: + - Libra merges staging PatchSets into the main sandbox view. + - Libra validates interface contracts and runs batch integration + tests. + +2. **Immutable Audit Trail**: + - Integration verification emits `Evidence` and, if needed, + additional `RunEvent` / `TaskEvent` records. + - If the remaining graph is no longer valid, persist a new `Plan` + snapshot revision and update Libra scheduler state. + +## Phase 3: System-level Validation and Audit + +Once all planned work is complete, the system performs release-level +validation and assembles the final audit chain. + +1. **Global Validation**: + - Run end-to-end tests, performance benchmarks, and compatibility + checks. + - Record results as `Evidence`. + +2. **Security Audit**: + - Run full SAST, full SCA, and focused security / compliance checks. + - Record findings as `Evidence`. + - If issues are found, Libra routes control back to Phase 2 and + persists any resulting replan as new `Plan` snapshots. + +3. **Final Snapshot / Event Assembly**: + - Persist a final `ContextSnapshot` when a stable release candidate + snapshot is needed. + - Append terminal `RunEvent`, `TaskEvent`, and optional `IntentEvent` + records. + - The audit chain is reconstructed from immutable objects: + `Intent` -> `Plan` -> `Task` -> `Run` -> `PatchSet` / + `Evidence` / `Decision` / `ContextFrame`. + +## Phase 4: Decision and Release + +The final gate decides whether the release candidate is accepted. + +1. **Risk Aggregation**: + - Libra combines the original request risk, execution findings, + validation evidence, and scope of change into the current review + view. + +2. **Decision Path**: + - **Low Risk -> Auto-Merge**: + - create the final repository commit, + - persist `Decision`, + - optionally append an `IntentEvent` for completion, + - advance Thread / Scheduler state in Libra. + - **High Risk -> Human Review**: + - Libra presents change summary, audit chain, evidence, and impact + analysis, + - reviewer chooses approve / reject / request changes, + - approval persists `Decision` and advances Libra projections. + +## Summary Rule -## Phase 3: System-level Verification & Security Audit - -Once all tasks are complete, the system performs a comprehensive validation. - -1. **Global Validation**: - - **End-to-End Testing**: Verifies full system flows. - - **Performance Benchmarking**: Compares against baselines to detect regressions. - - **Compatibility Testing**: Checks API backward compatibility and data formats. - -2. **Security Audit**: - - **Full SAST**: Scans all changed files. - - **Full SCA**: Reviews the complete dependency tree. - - **Risk Scans**: Checks for privilege escalation and injection vulnerabilities. - - **Compliance**: Verifies license compliance and privacy data handling. - - *Feedback*: If issues are found, the system locates the specific responsible Task and triggers a fix (returning to Phase 2). - -3. **Finalization & Documentation**: - - **Provenance Aggregation**: Combines all Task Provenance records into a batch-level execution chain. - - **Snapshot**: Computes a hash of the final `ContextState` and signs it. - - **Audit Log**: specific records (`IntentSpec` → `TaskGraph` → `PatchSets` → `TestResults` → `SecurityReport`) are written to an append-only log. - - **Doc Generation**: Automatically generates change descriptions, updates API docs, and `CHANGELOG`. - -## Phase 4: Decision & Release - -The final gatekeeping phase before deployment. - -1. **Risk Aggregation**: - - Combines the initial `IntentSpec.risk_level`, the Security Report, and the scope of changes to calculate a final risk score. - -2. **Decision Path**: - - **Low Risk → Auto-Merge**: - - Commits code to the production repository. - - Synchronizes documentation. - - Triggers the CD (Continuous Deployment) Pipeline. - - **High Risk → Human-in-the-loop Review**: - - **Presentation**: Displays change summary, execution chain, security report, and impact analysis to a human reviewer. - - **Options**: Approve, Reject, or Request Changes (re-execute with feedback). - - **Approval**: Upon approval, proceeds to commit, sync docs, and trigger CD. +```text +1. Snapshot stores "what it is" +2. Event stores "what happened" +3. Libra stores "what is current" +``` diff --git a/docs/agent.md b/docs/agent.md index 0479c4d0..9e6d1996 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,907 +1,239 @@ # AI Object Model Reference -This document describes the AI object model in git-internal: the types, -lifecycle, relationships, and usage patterns for AI-assisted code generation -workflows. All types live under `src/internal/object/`. +This document describes the AI object model in `git-internal` after the +snapshot / event / Libra split. -## Overview +## Design Principle -The AI object model extends Git's native object types (Blob, Tree, Commit, Tag) -with a set of workflow objects that capture the full lifecycle of an AI-assisted -code change — from the initial user request to the final committed patch. +`git-internal` stores immutable historical facts. -``` -User input - │ - ▼ - Intent ──▶ Plan ──▶ Task ──▶ Run ──▶ PatchSet ──▶ Decision - │ - ├──▶ ToolInvocation (action log) - ├──▶ Evidence (validation results) - └──▶ Provenance (LLM metadata) -``` - -Context is tracked by two complementary mechanisms: - -- **ContextSnapshot** — static capture of files/URLs/snippets at Run start -- **ContextPipeline** — dynamic accumulation of incremental context frames - throughout execution - -## End-to-End Flow - -``` - ① User input - │ - ▼ - ② Intent (Draft → Active) - │ - ├──▶ ContextPipeline ← seeded with IntentAnalysis frame - │ - ▼ - ③ Plan (pipeline, iframes, steps) - │ - ├─ PlanStep₀ (inline) - ├─ PlanStep₁ ──task──▶ sub-Task (recursive) - └─ PlanStep₂ (inline) - │ - ▼ - ④ Task (Draft → Running) - │ - ▼ - ⑤ Run (Created → Patching → Validating → Completed/Failed) - │ - ├──▶ Provenance (1:1, LLM config + token usage) - ├──▶ ContextSnapshot (optional, static context at start) - │ - │ ┌─── agent execution loop ───┐ - │ │ │ - │ │ ⑥ ToolInvocation (1:N) │ ← action log - │ │ │ │ - │ │ ▼ │ - │ │ ⑦ PatchSet (Proposed) │ ← candidate diff - │ │ │ │ - │ │ ▼ │ - │ │ ⑧ Evidence (1:N) │ ← test/lint/build - │ │ │ │ - │ │ ├─ pass ─────────────┘ - │ │ └─ fail → new PatchSet (retry within Run) - │ └────────────────────────────┘ - │ - ▼ - ⑨ Decision (terminal verdict) - │ - ├─ Commit → apply PatchSet, record result_commit - ├─ Retry → create new Run ⑤ for same Task - ├─ Abandon → mark Task as Failed - ├─ Checkpoint → save state, resume later - └─ Rollback → revert applied PatchSet - │ - ▼ - ⑩ Intent (Completed) ← commit recorded -``` - -### Steps - -1. **User input** — the user provides a natural-language request. - -2. **Intent** — captures the raw prompt and the AI's structured interpretation. - Status transitions from `Draft` (prompt only) to `Active` (analysis - complete). Supports conversational refinement via the `parents` set. - -3. **Plan** — a sequence of `PlanStep`s derived from the Intent. References a - `ContextPipeline` and records the consumed/derived stable frame IDs - (`iframes`). Steps track consumed/produced frames by stable ID - (`iframes`/`oframes`). A step may spawn a - sub-Task for recursive decomposition. Plans form a revision chain via - `previous`. - -4. **Task** — a unit of work with title, constraints, and acceptance criteria. - May link back to its originating Intent. Accumulates Runs in `runs` - (chronological execution history). - -5. **Run** — a single execution attempt of a Task. Records the baseline - `commit`, the Plan version being executed (snapshot reference), and the host - `environment`. A `Provenance` (1:1) captures the LLM configuration and - token usage. - -6. **ToolInvocation** — the finest-grained record: one per tool call (read - file, run command, etc.). Forms a chronological action log for the Run. - Tracks file I/O via `io_footprint`. - -7. **PatchSet** — a candidate diff generated by the agent. Contains the diff - `artifact`, file-level `touched` summary, and `rationale`. Starts as - `Proposed`; transitions to `Applied` or `Rejected`. Ordering is by - position in `Run.patchsets`. - -8. **Evidence** — output of a validation tool (test, lint, build) run against a - PatchSet. One per tool invocation. Carries `exit_code`, `summary`, and - `report_artifacts`. Feeds into the Decision. - -9. **Decision** — the terminal verdict of a Run. Selects a PatchSet to apply - (`Commit`), retries the Task (`Retry`), gives up (`Abandon`), saves - progress (`Checkpoint`), or reverts (`Rollback`). Records `rationale` - and `result_commit_sha`. - -10. **Intent completed** — the orchestrator records the final git commit in - `Intent.commit` and transitions status to `Completed`. - -## Object Relationship Summary - -| From | Field | To | Cardinality | -|------|-------|----|-------------| -| Intent | `parents` | Intent | 0..N | -| Intent | `thread_id` | Thread | 0..1 | -| Intent | `plan` | Plan | 0..1 | -| Thread | `head_intent_ids` | Intent | 0..N | -| Plan | `previous` | Plan | 0..1 | -| Plan | `pipeline` | ContextPipeline | 0..1 | -| PlanStep | `task` | Task | 0..1 | -| Task | `parent` | Task | 0..1 | -| Task | `intent` | Intent | 0..1 | -| Task | `runs` | Run | 0..N | -| Task | `dependencies` | Task | 0..N | -| Run | `task` | Task | 1 | -| Run | `plan` | Plan | 0..1 | -| Run | `snapshot` | ContextSnapshot | 0..1 | -| Run | `patchsets` | PatchSet | 0..N | -| PatchSet | `run` | Run | 1 | -| Evidence | `run_id` | Run | 1 | -| Evidence | `patchset_id` | PatchSet | 0..1 | -| Decision | `run_id` | Run | 1 | -| Decision | `chosen_patchset_id` | PatchSet | 0..1 | -| Provenance | `run_id` | Run | 1 | -| ToolInvocation | `run_id` | Run | 1 | - -## Object Details - -### Intent (`intent.rs`) - -The **entry point** of every AI-assisted workflow. Captures the verbatim user -request (`prompt`) and the AI's structured interpretation (`spec`). - -#### Status Transitions - -``` -Draft ──▶ Active ──▶ Completed - │ │ - └──────────┴──▶ Cancelled -``` - -- **Draft**: Prompt recorded, AI has not analyzed it yet. `spec = None`. -- **Active**: AI interpretation available in `spec`. Plan, Tasks, and Runs - may be in progress. -- **Completed**: All downstream work finished. `commit` contains the result - git commit hash. -- **Cancelled**: Abandoned before completion (user interrupt, timeout, budget). - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata (ID, type, timestamps, creator) | -| `prompt` | `String` | Verbatim user input, immutable after creation | -| `spec` | `Option` | AI-analyzed interpretation; `None` in Draft | -| `parents` | `Vec` | Predecessor Intents for conversational refinement | -| `thread_id` | `Option` | Owner thread for conversation-level history | -| `commit` | `Option` | Result git commit, set at step ⑩ | -| `plan` | `Option` | Latest Plan revision derived from this Intent | -| `statuses` | `Vec` | Append-only status transition history | - -#### Conversational Refinement - -``` -Intent₀ ("Add pagination") - ▲ - │ parents (single predecessor in simple case) -Intent₁ ("Also add cursor-based pagination") - ▲ - │ parents -Intent₂ ("Use opaque cursors, not offsets") - ▲ - │ parents (branch merge point) -Intent₃ ("Add pagination + cursor docs") -``` - -Each follow-up Intent links to one or more predecessors via `parents`, -forming a refinement DAG. A single-thread view can still be rendered via -`thread_id` + `Thread.head_intent_ids`. - -#### Usage +- **Snapshot objects** answer: "what was stored at this revision?" +- **Event objects** answer: "what happened later?" +- **Libra projections** answer: "what is the system's current view?" -```rust -use git_internal::internal::object::intent::{Intent, IntentStatus}; -use git_internal::internal::object::types::ActorRef; +High-frequency runtime state must not be accumulated by rewriting parent +objects in `git-internal`. -// 1. Create from user input -let actor = ActorRef::human("alice")?; -let mut intent = Intent::new(actor, "Add pagination to the user list API")?; -assert_eq!(intent.status(), &IntentStatus::Draft); - -// 2. AI analyzes the prompt -intent.set_spec(Some("Add offset/limit pagination to GET /users".into())); -intent.set_status(IntentStatus::Active); - -// 3. After execution completes -intent.set_status_with_reason(IntentStatus::Completed, "All tasks finished"); -``` - ---- - -### Thread (`thread` / 草案) - -Thread is a conversation-level aggregate that keeps all related Intents in one -container and records the active branch heads for resumable sessions. - -#### Draft fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Thread metadata (id/type/timestamps, creator) | -| `title` | `Option` | Human-readable thread title | -| `owner` | `ActorRef` | Conversation owner / initiator | -| `intent_ids` | `Vec` | All Intents that belong to this thread | -| `head_intent_ids` | `Vec` | Active refinement heads (non-finalized branches) | -| `latest_intent_id` | `Option` | Canonical head for default continue/resume | -| `metadata` | `Option` | Optional workflow metadata and policies | -| `archived` | `bool` | Marks closed threads for read-only behavior | - -#### Draft relation graph +## Three-Layer ASCII Diagram ```text -Thread ──intent_ids──→ Intent₀ - ├─intent_ids──→ Intent₁ - ├─intent_ids──→ Intent₂ - └─head_intent_ids──→ {Intent₁, Intent₂} -Intent.thread_id ─────────────→ Thread -``` - -#### Draft migration - -1. Add `Thread` schema and optional `thread_id` on `Intent`. -2. For each existing conversation root intent, create one thread and append its - full descendant subtree by `parents` ancestry order. -3. Populate `head_intent_ids` by selecting non-parented leaves in each thread. -4. Populate `latest_intent_id` from policy (e.g. `max(created_at)` or caller-selected). -5. Keep reads compatible with existing un-threaded intents until all producers set - `thread_id`. - ---- - -### Plan (`plan.rs`) - -A sequence of `PlanStep`s derived from an Intent's analyzed content. Defines -*what* to do (strategy and decomposition); `Run` handles *how* to execute. - -#### Revision Chain - -When the agent encounters obstacles, it creates a revised Plan via -`Plan::new_revision`. Each revision links back via `previous`: - -``` -Intent.plan ──▶ Plan_v3 (latest) - │ previous - ▼ - Plan_v2 - │ previous - ▼ - Plan_v1 (original, previous = None) -``` - -`Intent.plan` always points to the latest revision. Each `Run.plan` is a -**snapshot reference** that never changes. ++--------------------------------------------------------------------------------------+ +| Libra [L] | +|--------------------------------------------------------------------------------------| +| Thread / Scheduler / UI / Query Index | +| | +| current_intent_id | +| selected_plan_id | +| current_plan_heads[] | +| active_task_id / active_run_id | +| task_latest_run_id | +| run_latest_patchset_id | +| live_context_window | +| reverse indexes: intent->plans, task->runs, run->events, run->patchsets, ... | ++--------------------------------------------+-----------------------------------------+ + | + v ++--------------------------------------------------------------------------------------+ +| git-internal : Event [E] | +|--------------------------------------------------------------------------------------| +| IntentEvent / TaskEvent / RunEvent / PlanStepEvent / RunUsage | +| ToolInvocation / Evidence / Decision / ContextFrame | +| | +| Rule: every event is append-only; no parent object is rewritten to append history | ++--------------------------------------------+-----------------------------------------+ + | + v ++--------------------------------------------------------------------------------------+ +| git-internal : Snapshot [S] | +|--------------------------------------------------------------------------------------| +| Intent / Plan / Task / Run / PatchSet / ContextSnapshot / Provenance | +| | +| Rule: a snapshot only answers "what it is at this revision" | ++--------------------------------------------------------------------------------------+ +``` + +## Libra Layer Terms + +The Libra layer is not part of `git-internal` object storage. It holds +the current operational view reconstructed from immutable snapshots and +events. + +### Thread + +Conversation-level projection over related `Intent` snapshots. + +- groups the `Intent` DAG for one ongoing discussion or task stream +- stores the current resume target, branch heads, and thread-local + metadata +- can always be rebuilt from immutable history plus Libra-side + projection records + +### Scheduler + +Runtime orchestrator that turns immutable history into executable work. + +- selects the active `Plan` head and computes current ready work +- tracks active `Task` / `Run`, retry routing, and replanning decisions +- manages the live execution order without rewriting snapshot objects + +### UI + +User-facing presentation layer over the current system view. + +- shows the active thread, selected plan, task / run progress, and audit + evidence +- reads from Libra projections and immutable history +- does not define historical truth; it only renders the current view + +### Query Index + +Rebuildable lookup and denormalized access structures used for fast +queries. + +- examples: `intent -> plans`, `task -> runs`, `run -> events`, + `run -> patchsets` +- optimized for retrieval, filtering, and dashboard queries +- not part of the immutable object graph and can be recomputed + if needed + +## Main Object Relationships -#### Context Range - -A Plan references a `ContextPipeline` via `pipeline` and records the frame IDs -that fed plan generation in `iframes`. - -``` -ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] - │ │ │ - iframes = [0, 1, 2, 3] -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `previous` | `Option` | Predecessor Plan in revision chain | -| `pipeline` | `Option` | ContextPipeline used as context basis | -| `iframes` | `Vec` | Stable frame IDs of frames used for plan derivation | -| `steps` | `Vec` | Ordered sequence of steps | - -#### PlanStep - -Each step describes one unit of work within a Plan. - -| Field | Type | Description | -|-------|------|-------------| -| `description` | `String` | What this step should accomplish | -| `inputs` | `Option` | Expected inputs (JSON) | -| `outputs` | `Option` | Outputs after execution (JSON) | -| `checks` | `Option` | Validation criteria (JSON) | -| `iframes` | `Vec` | Stable pipeline frame IDs consumed (survives eviction) | -| `oframes` | `Vec` | Stable pipeline frame IDs produced (survives eviction) | -| `task` | `Option` | Sub-Task for recursive decomposition | -| `statuses` | `Vec` | Append-only status history | - -Step status transitions: - -``` -Pending ──▶ Progressing ──▶ Completed - │ │ - ├─────────────┴──▶ Failed - └──────────────────▶ Skipped -``` - -#### Recursive Decomposition - -A step can spawn a sub-Task via its `task` field: - -``` -Plan - ├─ Step₀ (inline — executed by current Run) - ├─ Step₁ ──task──▶ Task₁ - │ └─ Run → Plan - │ ├─ Step₁₋₀ - │ └─ Step₁₋₁ - └─ Step₂ (inline) -``` - -#### Usage - -```rust -use git_internal::internal::object::plan::{Plan, PlanStep}; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::agent("planner")?; - -// Create initial plan -let mut plan = Plan::new(actor.clone())?; -plan.set_pipeline(Some(pipeline_id)); -plan.set_iframes(vec![0, 1, 2, 3]); - -// Add steps -let mut step = PlanStep::new("Refactor auth module"); -step.set_iframes(vec![0, 1]); // consumed frames 0 and 1 -plan.add_step(step); - -// Create a revision -let plan_v2 = plan.new_revision(actor)?; -assert_eq!(plan_v2.previous(), Some(plan.header().object_id())); -``` - ---- - -### Task (`task.rs`) - -A unit of work with constraints and acceptance criteria. The **stable -identity** for a piece of work — persists across retries and replanning. - -#### Status Transitions - -``` -Draft ──▶ Running ──▶ Done - │ │ - ├──────────┴──▶ Failed - └──────────────▶ Cancelled -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `title` | `String` | Short summary (< 100 chars) | -| `description` | `Option` | Extended context | -| `goal` | `Option` | Work classification (Feature, Bugfix, etc.) | -| `constraints` | `Vec` | Hard rules the solution must satisfy | -| `acceptance_criteria` | `Vec` | Testable success conditions | -| `requester` | `Option` | Who requested the work | -| `parent` | `Option` | Parent Task (for sub-Tasks) | -| `intent` | `Option` | Originating Intent | -| `runs` | `Vec` | Chronological execution history | -| `dependencies` | `Vec` | Tasks that must complete first | -| `status` | `TaskStatus` | Current lifecycle status | - -#### GoalType - -`Feature`, `Bugfix`, `Refactor`, `Docs`, `Perf`, `Test`, `Chore`, `Build`, -`Ci`, `Style`, `Other(String)`. - -#### Replanning - -The Task stays the same across Plan revisions. Each Run records which Plan -version it executed: - -``` -Task (constant) Intent (constant, plan updated) - │ └─ plan ──▶ Plan_v2 (latest) - └─ runs: - Run₀ ──plan──▶ Plan_v1 (snapshot: original plan) - Run₁ ──plan──▶ Plan_v2 (snapshot: revised plan) -``` - -#### Usage - -```rust -use git_internal::internal::object::task::{Task, GoalType}; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::human("user")?; -let mut task = Task::new(actor, "Refactor Login", Some(GoalType::Refactor))?; - -task.add_constraint("Must use JWT"); -task.add_acceptance_criterion("All tests pass"); -task.set_intent(Some(intent_id)); - -// After Run completes -task.add_run(run_id); -task.set_status(TaskStatus::Done); -``` - ---- - -### Run (`run.rs`) - -A single execution attempt of a Task. Captures the execution context -(baseline commit, environment, Plan version) and accumulates artifacts -(PatchSets, Evidence, ToolInvocations) during execution. - -#### Status Transitions - -``` -Created ──▶ Patching ──▶ Validating ──▶ Completed - │ │ - └──────────────┴──▶ Failed -``` - -- **Created**: Run initialized, environment captured. -- **Patching**: Agent is generating code changes / tool calls. -- **Validating**: Agent has produced a PatchSet and is running tests/lint. -- **Completed**: Decision has been created. -- **Failed**: Unrecoverable error. `Run.error` has details. - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `task` | `Uuid` | Owning Task (mandatory) | -| `plan` | `Option` | Plan version being executed (snapshot) | -| `commit` | `IntegrityHash` | Baseline git commit | -| `status` | `RunStatus` | Current lifecycle status | -| `snapshot` | `Option` | ContextSnapshot at Run start | -| `patchsets` | `Vec` | Candidate diffs (chronological) | -| `metrics` | `Option` | Execution metrics (JSON) | -| `error` | `Option` | Error message if Failed | -| `environment` | `Option` | Host OS/arch/cwd | - -#### Associated Objects (by `run_id`) - -| Object | Cardinality | Purpose | -|--------|-------------|---------| -| Provenance | 1:1 | LLM config + token usage | -| ToolInvocation | 1:N | Chronological action log | -| Evidence | 1:N | Validation results (test/lint/build) | -| Decision | 1:1 | Terminal verdict | - -#### Usage - -```rust -use git_internal::internal::object::run::Run; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::agent("orchestrator")?; -let mut run = Run::new(actor, task_id, "abc123def...")?; -run.set_plan(Some(plan_id)); - -// Agent generates patches -run.set_status(RunStatus::Patching); -run.add_patchset(patchset_id); - -// Validation phase -run.set_status(RunStatus::Validating); - -// Completed -run.set_status(RunStatus::Completed); -``` - ---- +```text +Snapshot layer +============== + +Intent[S] --parents------------------------> Intent[S] +Plan[S] --intent_id----------------------> Intent[S] +Plan[S] --parents------------------------> Plan[S] +Task[S] --intent_id?---------------------> Intent[S] +Task[S] --parent_task_id?----------------> Task[S] +Task[S] --origin_step_id?---------------> Plan[S].step_id +Run[S] --task_id------------------------> Task[S] +Run[S] --plan_id?-----------------------> Plan[S] +Run[S] --context_snapshot_id?-----------> ContextSnapshot[S] +PatchSet[S] --run_id---------------------> Run[S] +Provenance[S] --run_id---------------------> Run[S] -### PatchSet (`patchset.rs`) +Event layer +=========== -A candidate diff generated by the agent during a Run. The atomic unit of -code modification — every change the agent wants to make is packaged as a -PatchSet. +IntentEvent[E] --intent_id---------------> Intent[S] +TaskEvent[E] --task_id-----------------> Task[S] +RunEvent[E] --run_id------------------> Run[S] +RunUsage[E] --run_id------------------> Run[S] +PlanStepEvent[E] --plan_id-----------------> Plan[S] +PlanStepEvent[E] --step_id-----------------> Plan[S].step_id +PlanStepEvent[E] --run_id------------------> Run[S] +ToolInvocation[E] --run_id-----------------> Run[S] +Evidence[E] --run_id-----------------> Run[S] +Evidence[E] --patchset_id?----------> PatchSet[S] +Decision[E] --run_id-----------------> Run[S] +Decision[E] --chosen_patchset_id?---> PatchSet[S] +ContextFrame[E] --run_id? / plan_id? / step_id? --> Run[S] / Plan[S] / Plan[S].step_id -#### Lifecycle +Libra layer +=========== +Thread[L] / Scheduler[L] + -> current_intent_id + -> selected_plan_id + -> current_plan_heads[] + -> active_task_id / active_run_id + -> live_context_window + -> reverse indexes over all [S] and [E] ``` - (created) ──▶ Proposed - │ - ┌──────────────┼──────────────┐ - │ │ │ - ▼ ▼ │ - Applied Rejected │ - │ │ - ▼ │ - agent generates new PatchSet - appended to Run.patchsets -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run` | `Uuid` | Owning Run | -| `commit` | `IntegrityHash` | Baseline commit this diff applies to | -| `format` | `DiffFormat` | `Unified` or `GitDiff` | -| `artifact` | `Option` | Reference to stored diff content | -| `touched` | `Vec` | File-level change summary | -| `rationale` | `Option` | Agent's explanation of what/why changed | -| `apply_status` | `ApplyStatus` | `Proposed`, `Applied`, or `Rejected` | - -#### Rationale - -The `rationale` field bridges the gap between the Task/Plan (high-level intent) -and the raw diff (low-level changes). It is primarily written by the agent and -may be overridden by a human reviewer via `set_rationale()`. - ---- - -### Evidence (`evidence.rs`) - -Output of a single validation step (test, lint, build) run against a PatchSet. -One Evidence per tool invocation. - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | Owning Run | -| `patchset_id` | `Option` | PatchSet being validated (`None` for run-level checks) | -| `kind` | `EvidenceKind` | `Test`, `Lint`, `Build`, or `Other(String)` | -| `tool` | `String` | Tool name (e.g. "cargo", "eslint", "pytest") | -| `command` | `Option` | Full command line for reproducibility | -| `exit_code` | `Option` | Process exit code (0 = success) | -| `summary` | `Option` | Short result summary | -| `report_artifacts` | `Vec` | Full report files (logs, coverage, JUnit XML) | - ---- - -### Decision (`decision.rs`) - -The **terminal verdict** of a Run. Created once per Run at the end of -execution. - -#### Decision Types - -| Type | Action | `chosen_patchset_id` | `result_commit_sha` | -|------|--------|---------------------|---------------------| -| `Commit` | Apply the chosen PatchSet | Set | Set after commit | -| `Checkpoint` | Save intermediate progress | — | — | -| `Abandon` | Give up on the Task | — | — | -| `Retry` | Create new Run for same Task | — | — | -| `Rollback` | Revert applied PatchSet | — | — | - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | The Run this Decision concludes | -| `decision_type` | `DecisionType` | Verdict (Commit/Retry/Abandon/etc.) | -| `chosen_patchset_id` | `Option` | Selected PatchSet (for Commit) | -| `result_commit_sha` | `Option` | Git commit hash after applying | -| `checkpoint_id` | `Option` | Saved state ID (for Checkpoint) | -| `rationale` | `Option` | Explanation of why this decision was made | - ---- - -### Provenance (`provenance.rs`) - -Records **how** a Run was executed: which LLM provider, model, and parameters -were used, and how many tokens were consumed. Created once per Run (1:1). - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | The Run this Provenance describes | -| `provider` | `String` | LLM provider ("openai", "anthropic", "local") | -| `model` | `String` | Model ID ("gpt-4", "claude-opus-4-20250514") | -| `parameters` | `Option` | Provider-specific raw parameters (JSON) | -| `temperature` | `Option` | Sampling temperature (0.0 = deterministic) | -| `max_tokens` | `Option` | Maximum generation length | -| `token_usage` | `Option` | Token consumption and cost | - -#### TokenUsage - -| Field | Type | Description | -|-------|------|-------------| -| `input_tokens` | `u64` | Tokens in the prompt/input | -| `output_tokens` | `u64` | Tokens in the completion/output | -| `total_tokens` | `u64` | `input_tokens + output_tokens` | -| `cost_usd` | `Option` | Estimated cost in USD | - ---- - -### ToolInvocation (`tool.rs`) -The finest-grained record: one per tool call made by the agent during a Run. +## Placement Rules -#### Fields +### Snapshot objects in `git-internal` -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `run_id` | `Uuid` | Owning Run | -| `tool_name` | `String` | Registered tool name ("read_file", "bash", etc.) | -| `io_footprint` | `Option` | Files read/written | -| `args` | `Value` | Arguments (JSON, tool-dependent schema) | -| `status` | `ToolStatus` | `Ok` or `Error` | -| `result_summary` | `Option` | Short output summary | -| `artifacts` | `Vec` | Full output files | +- `Intent` +- `Plan` +- `Task` +- `Run` +- `PatchSet` +- `ContextSnapshot` +- `Provenance` -#### IoFootprint +### Event objects in `git-internal` -| Field | Type | Description | -|-------|------|-------------| -| `paths_read` | `Vec` | Files read (repo-relative) | -| `paths_written` | `Vec` | Files written (repo-relative) | +- `IntentEvent` +- `TaskEvent` +- `RunEvent` +- `PlanStepEvent` +- `RunUsage` +- `ToolInvocation` +- `Evidence` +- `Decision` +- `ContextFrame` ---- +### Runtime / projection state in Libra -### ContextSnapshot (`context.rs`) +- current selected plan head +- active task / active run +- thread heads / latest intent +- live context window +- reverse indexes and query acceleration -A **static** capture of the codebase and external resources the agent observed -when a Run began. Point-in-time — does not change after creation. +## Object Notes -#### Fields +### Intent -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `selection_strategy` | `SelectionStrategy` | `Explicit` (user-chosen) or `Heuristic` (auto-selected) | -| `items` | `Vec` | Context items (files, URLs, snippets, etc.) | -| `summary` | `Option` | Aggregated summary | +Snapshot of the user request and optional analyzed spec. -#### ContextItem +- keep: `parents`, `prompt`, `spec` +- do not keep in snapshot: mutable status log, selected plan pointer, final commit pointer +- lifecycle belongs to `IntentEvent` -Each item has three layers: +### Plan -- **`path`** — human-readable locator (repo path, URL, command, label) -- **`blob`** — Git blob hash pointing to full content at capture time -- **`preview`** — truncated text for quick display +Snapshot of the strategy and step structure. -| Kind | `path` example | `blob` content | -|------|----------------|----------------| -| `File` | `src/main.rs` | Same blob in git tree (zero extra storage) | -| `Url` | `https://docs.rs/...` | Fetched page content | -| `Snippet` | `"design notes"` | Snippet text | -| `Command` | `cargo test` | Command output | -| `Image` | `screenshot.png` | Image binary | +- keep: `intent`, `parents`, `context_frames`, `steps` +- `PlanStep.step_id` is the stable logical step identity across Plan revisions +- execution-time step state belongs to `PlanStepEvent` -#### Blob Retention +### Task -Blobs referenced only by AI objects are not reachable in git's DAG and will be -pruned by `git gc`. For non-File items, applications must choose a retention -strategy: +Stable work definition. -| Strategy | Approach | -|----------|----------| -| Ref anchoring | `refs/ai/blobs/` | -| Orphan commit | `refs/ai/uploads` tree | -| Keep pack | `.keep` marker on pack file | -| Custom GC mark | Scan AI objects during GC | +- keep: title, description, goal, constraints, acceptance criteria, requester +- keep canonical provenance links: `intent`, `parent`, `origin_step_id`, `dependencies` +- runtime progress belongs to `TaskEvent` ---- +### Run -### ContextPipeline (`pipeline.rs`) +Execution-attempt envelope. -A **dynamic** context container that accumulates incremental `ContextFrame`s -throughout the workflow. Solves the context-forgetting problem in long-running -tasks. +- keep: `task`, `plan`, `commit`, `snapshot`, `environment` +- phase changes, failure details, and metrics belong to `RunEvent` +- usage/cost belongs to `RunUsage` -#### Lifecycle +### PatchSet -``` -Intent (Active) ← content analyzed - │ - ▼ -ContextPipeline created ← seeded with IntentAnalysis frame - │ - ▼ -Plan created ← Plan.pipeline → Pipeline, Plan.iframes = [0,1,2,3] - │ steps execute - ▼ -Frames accumulate ← StepSummary, CodeChange, ToolCall, ... - │ - ▼ -Replan? → new Plan with updated iframes -``` - -#### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `header` | `Header` | Common metadata | -| `frames` | `Vec` | Chronologically ordered frames | -| `next_frame_id` | `u64` | Monotonic counter for stable frame IDs | -| `max_frames` | `u32` | Max frames before eviction (0 = unlimited) | -| `global_summary` | `Option` | Aggregated summary | - -#### ContextFrame - -| Field | Type | Description | -|-------|------|-------------| -| `frame_id` | `u64` | Stable monotonic ID (survives eviction) | -| `kind` | `FrameKind` | IntentAnalysis, StepSummary, CodeChange, etc. | -| `summary` | `String` | Compact human-readable summary | -| `data` | `Option` | Structured payload (JSON) | -| `created_at` | `DateTime` | When this frame was created | -| `token_estimate` | `Option` | Estimated tokens for budgeting | +Candidate diff snapshot. -#### FrameKind +- keep: `run`, `sequence`, `commit`, `format`, `artifact`, `touched`, `rationale` +- acceptance/rejection belongs to `Decision` or Libra projection -| Kind | Protected | Description | -|------|-----------|-------------| -| `IntentAnalysis` | Yes | Seed frame from Intent analysis | -| `StepSummary` | No | Summary after a PlanStep completes | -| `CodeChange` | No | Code change digest (files, diff stats) | -| `SystemState` | No | System/environment state snapshot | -| `ErrorRecovery` | No | Error recovery context | -| `Checkpoint` | Yes | Explicit save-point | -| `ToolCall` | No | External tool invocation result | -| `Other(String)` | No | Application-defined | +### Provenance -Protected frames survive eviction when `max_frames` is exceeded. +Immutable model/provider configuration for one run. -#### Eviction +- keep: provider/model/parameters/temperature/max_tokens +- usage belongs to `RunUsage` -When `max_frames > 0` and the limit is exceeded, `push_frame` removes the -oldest non-protected frame. `IntentAnalysis` and `Checkpoint` frames are -never evicted. - -#### Step-Frame Association - -Steps track their relationship to frames via stable frame IDs: - -``` -ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅] - │ │ ▲ - ╰────╯ │ - iframes=[0,1] oframes=[4] - ╰── Step₀ ──╯ -``` +### ContextFrame -- `iframes` — stable `frame_id`s of frames the step consumed as context -- `oframes` — stable `frame_id`s of frames the step produced +Immutable incremental context record. -Frame IDs are monotonic integers assigned by `push_frame`. Unlike Vec -indices, IDs survive eviction — a step's `iframes` remain valid even -after older frames are removed. Look up frames via `frame_by_id`. +- replaces the old mutable `ContextPipeline` runtime container +- referenced by `Plan.context_frames` and `PlanStepEvent.consumed_frames` / `produced_frames` -All association is owned by the step; `ContextFrame` has no back-references. +## Summary Rule -#### Usage - -```rust -use git_internal::internal::object::pipeline::{ContextPipeline, FrameKind}; -use git_internal::internal::object::types::ActorRef; - -let actor = ActorRef::agent("orchestrator")?; - -// 1. Create pipeline after Intent content is analyzed -let mut pipeline = ContextPipeline::new(actor)?; - -// 2. Seed with the Intent's analyzed content — returns stable frame_id -let seed_id = pipeline.push_frame( - FrameKind::IntentAnalysis, - "Add offset/limit pagination to GET /users with default page size 20", -); - -// 3. Create a Plan referencing this pipeline -// plan.set_pipeline(Some(pipeline.header().object_id())); -// plan.set_iframes(vec![0, 1, 2, 3]); - -// 4. As steps complete, push incremental frames -let step_id = pipeline.push_frame(FrameKind::StepSummary, "Refactored auth module"); - -// 5. Track on PlanStep side using stable frame IDs: -// step.set_iframes(vec![seed_id]); -// step.set_oframes(vec![step_id]); - -// 6. Look up frame by ID (survives eviction) -// pipeline.frame_by_id(seed_id); +```text +1. Snapshot stores "what it is" +2. Event stores "what happened" +3. Libra stores "what is current" ``` - -## Common Header - -All AI objects share a common `Header` (flattened into the JSON via `#[serde(flatten)]`): - -| Field | Type | Description | -|-------|------|-------------| -| `object_id` | `Uuid` | Globally unique ID (UUID v7, time-ordered) | -| `object_type` | `ObjectType` | Discriminator (Intent, Plan, Task, etc.) | -| `header_version` | `u32` | Format version of the Header struct | -| `schema_version` | `u32` | Per-object-type schema version | -| `created_at` | `DateTime` | Creation timestamp | -| `updated_at` | `DateTime` | Last modification timestamp | -| `created_by` | `ActorRef` | Who created this object | -| `visibility` | `Visibility` | `Private` or `Public` | -| `tags` | `HashMap` | Free-form search tags | -| `external_ids` | `HashMap` | External ID mapping | -| `checksum` | `Option` | Content checksum (set by `seal()`) | - -### ActorRef - -Identifies who created or triggered an action: - -| Kind | Factory | Example | -|------|---------|---------| -| `Human` | `ActorRef::human("alice")` | A user | -| `Agent` | `ActorRef::agent("coder")` | An AI agent | -| `System` | `ActorRef::system("scheduler")` | Infrastructure | -| `McpClient` | `ActorRef::mcp_client("vscode")` | MCP client | - -### ArtifactRef - -Reference to external content stored outside the AI object: - -| Field | Type | Description | -|-------|------|-------------| -| `store` | `String` | Storage backend ("local", "s3") | -| `key` | `String` | Storage key or path | -| `content_type` | `Option` | MIME type | -| `size_bytes` | `Option` | Size in bytes | -| `hash` | `Option` | Content hash (SHA-256) | -| `expires_at` | `Option>` | Expiration time | - -Supports integrity verification via `verify_integrity(content)` and -content deduplication via `content_eq(other)`. - -## Serialization - -All AI objects implement `ObjectTrait` with JSON serialization: - -- **`from_bytes(data, hash)`** — deserialize from JSON bytes -- **`to_data()`** — serialize to JSON bytes -- **`get_type()`** — returns the `ObjectType` discriminator -- **`get_size()`** — returns the serialized size -- **`object_hash()`** — computes the content-addressable hash - -AI object types are registered in `ObjectType` with numeric IDs 8–18 and -string identifiers for serialization (e.g. `"intent"`, `"plan"`, `"task"`). - -> **Pack encoding**: AI objects cannot be encoded in standard Git pack headers -> (which only support 3-bit type values 1–7). They must use the AI-specific -> storage layer. `ObjectType::to_pack_type_u8()` returns an error for AI types. - -## Design Principles - -1. **Append-only history**: Status changes (Intent, PlanStep) and execution - records (Task.runs, Run.patchsets) are append-only — entries are never - removed or mutated. - -2. **Snapshot references**: Run.plan records the Plan version at execution - time. Intent.plan always points to the latest revision. This allows - replanning without invalidating in-progress Runs. - -3. **Bidirectional references**: Key relationships are bidirectional for - efficient traversal (Task ↔ Run, Run ↔ PatchSet). Other relationships - are unidirectional with reverse lookup by scanning (Evidence → Run, - Decision → Run). - -4. **Serde conventions**: Optional fields use `#[serde(default, skip_serializing_if)]`. - Empty Vecs use `skip_serializing_if = "Vec::is_empty"`. Field renames use - `#[serde(alias = "old_name")]` for backward compatibility. - -5. **Context separation**: Static context (ContextSnapshot) vs. dynamic - context (ContextPipeline) serve complementary needs — reproducibility - vs. continuity. diff --git a/src/errors.rs b/src/errors.rs index 0841822e..48259d85 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -98,9 +98,9 @@ pub enum GitError { #[error("Not a valid agent tool invocation object: {0}")] InvalidToolInvocationObject(String), - /// Malformed context pipeline object. - #[error("Not a valid agent context pipeline object: {0}")] - InvalidContextPipelineObject(String), + /// Malformed context frame object. + #[error("Not a valid agent context frame object: {0}")] + InvalidContextFrameObject(String), /// Malformed or unsupported index (.idx) file. #[error("The `{0}` is not a valid idx file.")] diff --git a/src/internal/object/context.rs b/src/internal/object/context.rs index 86a7e99e..30897867 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -2,19 +2,27 @@ //! //! A [`ContextSnapshot`] is an optional static capture of the codebase //! and external resources that an agent observed when a -//! [`Run`](super::run::Run) began. Unlike the dynamic -//! [`ContextPipeline`](super::pipeline::ContextPipeline) (which -//! accumulates frames during execution), a ContextSnapshot is a -//! **point-in-time** record that does not change after creation. +//! [`Run`](super::run::Run) began. Unlike the incremental +//! [`ContextFrame`](super::context_frame::ContextFrame) event stream, +//! a ContextSnapshot is a **point-in-time** record that does not +//! change after creation. +//! +//! # How Libra should use this object +//! +//! - Create a `ContextSnapshot` only when a stable, reproducible +//! baseline is worth preserving for a run. +//! - Populate its items completely before persistence. +//! - Keep the live, moving context window in Libra and express +//! incremental changes through `ContextFrame`. //! //! # Position in Lifecycle //! //! ```text //! ② Intent (Active) //! │ -//! └─ plans → ContextPipeline (dynamic) +//! └─ ③ Plan references ContextFrame IDs used for planning //! │ -//! │ ③ Plan references pipeline + iframes +//! │ incremental ContextFrame events may continue later //! ▼ //! ⑤ Run created //! │ @@ -25,10 +33,11 @@ //! ``` //! //! A ContextSnapshot is created at step ⑤ when the Run is initialized. -//! It complements the ContextPipeline: the snapshot captures the +//! It complements incremental ContextFrame events: the snapshot captures the //! **initial** state (what files, URLs, snippets the agent sees at -//! start), while the pipeline tracks **incremental** context changes -//! during execution. +//! start), while ContextFrame events record **incremental** context +//! changes during execution. Libra may additionally maintain a live +//! runtime context window as a projection over those immutable frames. //! //! # Items //! @@ -177,6 +186,7 @@ pub struct ContextItem { } impl ContextItem { + /// Create a new draft context item with the given kind and locator. pub fn new(kind: ContextItemKind, path: impl Into) -> Result { let path = path.into(); if path.trim().is_empty() { @@ -190,6 +200,7 @@ impl ContextItem { }) } + /// Set or clear the blob hash referencing the full captured content. pub fn set_blob(&mut self, blob: Option) { self.blob = blob; } @@ -199,7 +210,8 @@ impl ContextItem { /// /// Created once per Run (optional). Records which files, URLs, /// snippets, etc. the agent had access to. See module documentation -/// for lifecycle position, item design, and blob retention. +/// for lifecycle position, item design, blob retention, and Libra +/// calling guidance. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ContextSnapshot { @@ -227,6 +239,8 @@ pub struct ContextSnapshot { } impl ContextSnapshot { + /// Create a new empty context snapshot with the given selection + /// strategy. pub fn new( created_by: ActorRef, selection_strategy: SelectionStrategy, diff --git a/src/internal/object/decision.rs b/src/internal/object/decision.rs index 519d160d..4d21d060 100644 --- a/src/internal/object/decision.rs +++ b/src/internal/object/decision.rs @@ -57,6 +57,16 @@ //! ├─ Retry ──▶ create new Run for same Task //! └─ Rollback ──▶ revert applied PatchSet //! ``` +//! +//! # How Libra should use this object +//! +//! - Create one terminal `Decision` per `Run`. +//! - Fill `chosen_patchset_id`, `result_commit_sha`, `checkpoint_id`, +//! and `rationale` before persistence as appropriate for the verdict. +//! - Use the decision to advance thread heads, selected plan, release +//! status, and UI state in Libra projections. +//! - Do not encode those mutable current-state choices back onto +//! `Intent`, `Task`, `Run`, or `PatchSet`. use std::fmt; @@ -133,7 +143,8 @@ impl From<&str> for DecisionType { /// Terminal verdict of a [`Run`](super::run::Run). /// /// Created once per Run at the end of execution. See module -/// documentation for lifecycle position and decision type semantics. +/// documentation for lifecycle position, decision type semantics, and +/// Libra calling guidance. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Decision { @@ -155,7 +166,9 @@ pub struct Decision { /// application. /// /// Set when `decision_type` is `Commit` — identifies which - /// PatchSet from `Run.patchsets` was chosen. `None` for + /// PatchSet in the same Run scope was chosen. Ordering between + /// multiple candidates is expressed by `PatchSet.sequence`, not by + /// a mutable `Run.patchsets` list. `None` for /// `Abandon`, `Retry`, `Rollback`, or when no suitable PatchSet /// exists. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -187,7 +200,7 @@ pub struct Decision { } impl Decision { - /// Create a new decision object + /// Create a new terminal decision for the given run. pub fn new( created_by: ActorRef, run_id: Uuid, @@ -204,46 +217,57 @@ impl Decision { }) } + /// Return the immutable header for this decision. pub fn header(&self) -> &Header { &self.header } + /// Return the owning run id. pub fn run_id(&self) -> Uuid { self.run_id } + /// Return the decision type. pub fn decision_type(&self) -> &DecisionType { &self.decision_type } + /// Return the chosen patchset id, if any. pub fn chosen_patchset_id(&self) -> Option { self.chosen_patchset_id } + /// Return the resulting repository commit hash, if any. pub fn result_commit_sha(&self) -> Option<&IntegrityHash> { self.result_commit_sha.as_ref() } + /// Return the checkpoint id, if any. pub fn checkpoint_id(&self) -> Option<&str> { self.checkpoint_id.as_deref() } + /// Return the human-readable rationale, if present. pub fn rationale(&self) -> Option<&str> { self.rationale.as_deref() } + /// Set or clear the chosen patchset id. pub fn set_chosen_patchset_id(&mut self, chosen_patchset_id: Option) { self.chosen_patchset_id = chosen_patchset_id; } + /// Set or clear the resulting repository commit hash. pub fn set_result_commit_sha(&mut self, result_commit_sha: Option) { self.result_commit_sha = result_commit_sha; } + /// Set or clear the checkpoint id. pub fn set_checkpoint_id(&mut self, checkpoint_id: Option) { self.checkpoint_id = checkpoint_id; } + /// Set or clear the human-readable rationale. pub fn set_rationale(&mut self, rationale: Option) { self.rationale = rationale; } @@ -284,6 +308,11 @@ impl ObjectTrait for Decision { #[cfg(test)] mod tests { + // Coverage: + // - terminal decision field access + // - chosen patchset / result commit attachment + // - checkpoint and rationale mutation + use super::*; #[test] diff --git a/src/internal/object/evidence.rs b/src/internal/object/evidence.rs index ac625aaa..ebe5df16 100644 --- a/src/internal/object/evidence.rs +++ b/src/internal/object/evidence.rs @@ -33,6 +33,17 @@ //! - **Decision Support**: The [`Decision`](super::decision::Decision) //! references Evidence to justify committing or rejecting changes. //! Reviewers can inspect Evidence to understand why a verdict was made. +//! +//! # How Libra should use this object +//! +//! - Create one `Evidence` object per validation tool execution or +//! report. +//! - Attach `patchset_id` when the validation targets a specific +//! candidate diff. +//! - Use `summary`, `exit_code`, and `report_artifacts` for the durable +//! audit record. +//! - Derive pass/fail dashboards and gating status in Libra; do not +//! rewrite `PatchSet` or `Run` snapshots with validation summaries. use std::fmt; @@ -99,7 +110,7 @@ impl From<&str> for EvidenceKind { /// /// One Evidence per tool invocation. Multiple Evidence objects may /// exist for the same PatchSet (one per validation tool). See module -/// documentation for lifecycle position. +/// documentation for lifecycle position and Libra calling guidance. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Evidence { @@ -158,6 +169,8 @@ pub struct Evidence { } impl Evidence { + /// Create a new validation evidence record for the given run and + /// validation category. pub fn new( created_by: ActorRef, run_id: Uuid, @@ -177,58 +190,72 @@ impl Evidence { }) } + /// Return the immutable header for this evidence object. pub fn header(&self) -> &Header { &self.header } + /// Return the owning run id. pub fn run_id(&self) -> Uuid { self.run_id } + /// Return the validated patchset id, if present. pub fn patchset_id(&self) -> Option { self.patchset_id } + /// Return the validation category. pub fn kind(&self) -> &EvidenceKind { &self.kind } + /// Return the tool name that produced this evidence. pub fn tool(&self) -> &str { &self.tool } + /// Return the executed command line, if present. pub fn command(&self) -> Option<&str> { self.command.as_deref() } + /// Return the process exit code, if present. pub fn exit_code(&self) -> Option { self.exit_code } + /// Return the short human-readable summary, if present. pub fn summary(&self) -> Option<&str> { self.summary.as_deref() } + /// Return the persistent report artifacts. pub fn report_artifacts(&self) -> &[ArtifactRef] { &self.report_artifacts } + /// Set or clear the validated patchset id. pub fn set_patchset_id(&mut self, patchset_id: Option) { self.patchset_id = patchset_id; } + /// Set or clear the executed command line. pub fn set_command(&mut self, command: Option) { self.command = command; } + /// Set or clear the process exit code. pub fn set_exit_code(&mut self, exit_code: Option) { self.exit_code = exit_code; } + /// Set or clear the short human-readable summary. pub fn set_summary(&mut self, summary: Option) { self.summary = summary; } + /// Append one persistent validation report artifact. pub fn add_report_artifact(&mut self, artifact: ArtifactRef) { self.report_artifacts.push(artifact); } @@ -269,6 +296,11 @@ impl ObjectTrait for Evidence { #[cfg(test)] mod tests { + // Coverage: + // - evidence field access + // - optional patchset association + // - command, exit-code, summary, and report artifact storage + use super::*; #[test] diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index bf23b808..ee226773 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -1,169 +1,33 @@ -//! AI Intent definition and lifecycle contract. +//! AI Intent snapshot. //! -//! This module defines the `Intent` object used by the AI workflow and -//! keeps the raw user request anchored to all downstream objects. It -//! follows the same step chain in `docs/agent-workflow.md`: -//! `User Query -> Intent -> Plan -> Task -> Run -> (ToolInvocation, PatchSet, -//! Evidence) -> Decision -> Intent complete`. +//! `Intent` is the immutable entry point of the agent workflow. It +//! captures one revision of the user's request plus the optional +//! analyzed `IntentSpec`. //! -//! The workflow-level structured understanding is captured as `IntentSpec` -//! (`goal`, `constraints`, `risk_level`) and serialized in the `spec` -//! field. The full historical state is an append-only `statuses` vector; -//! each transition writes a new [`StatusEntry`] with an immutable timestamp. +//! # How to use this object //! -//! # Position in the AI workflow +//! - Create a root `Intent` when Libra accepts a new user request. +//! - Create a new `Intent` revision when the request is refined, +//! branched, or merged; link earlier revisions through `parents`. +//! - Fill `spec` before persistence if analysis has already produced a +//! structured request. //! -//! ```text -//! User Query -//! │ -//! ▼ -//! ② Intent (Draft → Active) -//! │ -//! ├─ Draft: prompt captured, `spec=None` -//! └─ Active: AI has produced `IntentSpec` -//! └─ Direct link to ContextPipeline (`context_pipeline`) -//! -//! ▼ -//! ③ Plan (workflow decomposition) -//! │ -//! ▼ -//! ④ Task -//! │ -//! ▼ -//! ⑤ Run(s) -//! ├─ ⑥ ToolInvocation* -//! ├─ ⑦ PatchSet* -//! ├─ ⑧ Evidence* -//! └─ ⑨ Decision -//! │ -//! ▼ -//! ⑩ Intent terminal state (Completed / Cancelled) -//! ``` +//! # How it works with other objects //! -//! ## Conversational Refinement +//! - `Plan.intent` points back to the `Intent` that the plan belongs to. +//! - `Task.intent` may point back to the originating `Intent`. +//! - `IntentEvent` records lifecycle facts such as analyzed / +//! completed / cancelled. //! -//! `Intent` objects can form a parent DAG to represent iterative user -//! refinement. Each follow-up `Intent` stores one or more parent `Intent` -//! IDs (for merges/branching) and does not mutate its predecessors. -//! ```text -//! Intent_N -> parent/parents -> Intent_(N-1) -> ... -> Intent_0 -//! ``` +//! # How Libra should call it //! -//! ## Headless example: fork and walk a chain -//! -//! From one intent, create multiple follow-up intents, and query all -//! descendants from a root intent. -//! -//! ```rust,ignore -//! use git_internal::internal::object::intent::Intent; -//! use git_internal::internal::object::types::ActorRef; -//! use std::collections::HashSet; -//! use uuid::Uuid; -//! -//! let actor = ActorRef::human("agent").unwrap(); -//! let (root, _) = Intent::new(actor.clone(), "Support new tracing mode").unwrap(); -//! -//! // 1) Fork: multiple Intents from the same parent. -//! let (opt_a, _) = Intent::new_revision_chain( -//! actor.clone(), -//! "Add event-level tracing", -//! &[&root], -//! ) -//! .unwrap(); -//! let (opt_b, _) = Intent::new_revision_chain( -//! actor.clone(), -//! "Add span-level tracing", -//! &[&root], -//! ) -//! .unwrap(); -//! let (merged, _) = Intent::new_revision_chain( -//! actor, -//! "Merge event + span design", -//! &[&opt_a, &opt_b], -//! ) -//! .unwrap(); -//! -//! // 2) Query: find every descendant under the root intent. -//! let all = vec![root, opt_a, opt_b, merged]; -//! let mut frontier = vec![all[0].header().object_id()]; -//! let mut visited: HashSet = HashSet::new(); -//! let mut descendants = Vec::new(); -//! -//! while let Some(current) = frontier.pop() { -//! for candidate in all.iter() { -//! let candidate_id = candidate.header().object_id(); -//! if visited.contains(&candidate_id) { -//! continue; -//! } -//! if candidate.parents().iter().any(|p| *p == current) { -//! descendants.push(candidate_id); -//! frontier.push(candidate_id); -//! visited.insert(candidate_id); -//! } -//! } -//! } -//! ``` -//! -//! # Purpose -//! -//! - **Traceability**: links the original request to every downstream -//! object. -//! - **Reproducibility**: keeps both raw `prompt` and structured analysis in -//! `spec` for re-analysis with different models/parameters. -//! - **Risk-aware execution**: `risk_level` in `IntentSpec` is used by the -//! workflow to drive review thresholds and automation gates. -//! - **Completion closing**: `commit` records the exact repository commit -//! that satisfies the request when status reaches `Completed`. -//! -//! # Thread draft (for full conversation records) -//! -//! To preserve complete conversational records, a Thread aggregate can be -//! introduced as a lightweight container object. This draft is intentionally -//! versioned separately from `Intent`, so migration can be rolled out safely. -//! -//! ## Thread object fields (draft) -//! -//! | Field | Type | Description | -//! |---|---|---| -//! | `header` | `Header` | Thread metadata (id/type/timestamps/creator). | -//! | `title` | `Option` | Human-readable conversation title. | -//! | `owner` | `ActorRef` | Conversation owner / initiator. | -//! | `participants` | `Vec` | Optional actor list for collaboration. | -//! | `intent_ids` | `Vec` | All intents under this thread, ordered by creation. | -//! | `head_intent_ids` | `Vec` | Current non-finalized branch tips (one or more). | -//! | `metadata` | `Option` | Optional strategy, routing, policy tags. | -//! | `archived` | `bool` | Marks historical threads as read-only for UI. | -//! | `latest_intent_id` | `Option` | Latest materialized intent in canonical path. | -//! -//! ## Draft relation graph -//! -//! ```text -//! Thread ──intent_ids──→ Intent₀ -//! ├─intent_ids──→ Intent₁ ──parents──→ Intent₀ -//! ├─intent_ids──→ Intent₂ ──parents──→ Intent₁ -//! └─intent_ids──→ Intent₃ ──parents──→ Intent₀/Intent₁ (merge) -//! Intent.thread_id (1:1 link once introduced) -//! ``` -//! -//! ## Migration sketch -//! -//! 1. Add `Thread` object definition and add optional `thread_id` field to -//! `Intent` payload. -//! 2. Add `Thread` on demand: when a new root intent arrives, create one thread -//! and append the intent to `intent_ids`. -//! 3. Backfill existing intents in batches: -//! - group intents by `prompt`/request source correlation, -//! - create one thread per group, -//! - set `Intent.thread_id` accordingly, -//! - populate `intent_ids`, `head_intent_ids`, and `latest_intent_id`. -//! 4. Keep parent chain resolution (`parents`) intact as fallback for objects -//! created before migration. -//! 5. For future writes, require `thread_id` while keeping reads backward -//! compatible with unset field. +//! Libra should persist a new `Intent` for every semantic revision of +//! the request, then keep "current thread head", "selected plan", and +//! other mutable session state in Libra projections rather than on the +//! `Intent` object itself. use std::fmt; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -172,371 +36,146 @@ use crate::{ hash::ObjectHash, internal::object::{ ObjectTrait, - integrity::IntegrityHash, - pipeline::ContextPipeline, types::{ActorRef, Header, ObjectType}, }, }; -trait ParentLike { - fn parent_id(&self) -> Uuid; -} - -impl ParentLike for Uuid { - fn parent_id(&self) -> Uuid { - *self - } -} - -impl ParentLike for &Intent { - fn parent_id(&self) -> Uuid { - self.header.object_id() - } -} - -/// Status of an Intent through its lifecycle. -/// -/// Valid transitions (see module docs for diagram): +/// Structured request payload derived from the free-form prompt. /// -/// - `Draft` → `Active`: AI has analyzed the prompt and filled `spec`. -/// - `Active` → `Completed`: All downstream Tasks finished successfully -/// and the result commit has been recorded in `Intent.commit`. -/// - `Draft` → `Cancelled`: User abandoned the request before AI analysis. -/// - `Active` → `Cancelled`: User or orchestrator cancelled during -/// planning/execution (e.g. timeout, user interrupt, budget exceeded). -/// -/// Reverse transitions (e.g. `Active` → `Draft`) are not expected. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum IntentStatus { - /// Initial state. The `prompt` has been captured but the AI has not - /// yet analyzed it — `Intent.spec` is `None`. - Draft, - /// AI interpretation is available in `Intent.spec`. Downstream - /// objects (Plan, Tasks, Runs) may be in progress. - Active, - /// The Intent has been fully satisfied. `Intent.commit` should - /// contain the SHA of the git commit that fulfils the request. - Completed, - /// The Intent was abandoned before completion. A reason should be - /// recorded in the [`StatusEntry`] that carries this status. - Cancelled, -} +/// `IntentSpec` remains intentionally schema-agnostic at the storage +/// layer. Libra can impose additional application-level conventions on +/// top of the raw JSON payload. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(transparent)] +pub struct IntentSpec(pub serde_json::Value); -impl IntentStatus { - /// Returns the snake_case string representation. - pub fn as_str(&self) -> &'static str { - match self { - IntentStatus::Draft => "draft", - IntentStatus::Active => "active", - IntentStatus::Completed => "completed", - IntentStatus::Cancelled => "cancelled", - } +impl From for IntentSpec { + fn from(value: String) -> Self { + Self(serde_json::Value::String(value)) } } -impl fmt::Display for IntentStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) +impl From<&str> for IntentSpec { + fn from(value: &str) -> Self { + Self::from(value.to_string()) } } -/// A single entry in the Intent's status history. -/// -/// Each status transition appends a new `StatusEntry` to -/// `Intent.statuses`. The entries are never removed or mutated, -/// forming an append-only audit log. The current status is always -/// `statuses.last().status`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct StatusEntry { - /// The [`IntentStatus`] that was entered by this transition. - status: IntentStatus, - /// UTC timestamp of when this transition occurred. - /// - /// Automatically set to `Utc::now()` by [`StatusEntry::new`]. - /// Timestamps across entries in the same Intent are monotonically - /// non-decreasing. - changed_at: DateTime, - /// Optional human-readable reason for the transition. - /// - /// Recommended for `Cancelled` (why the request was abandoned) and - /// `Completed` (summary of what was achieved). May be `None` for - /// routine transitions like `Draft` → `Active`. - #[serde(default, skip_serializing_if = "Option::is_none")] - reason: Option, +pub trait ParentLike { + fn parent_id(&self) -> Uuid; } -impl StatusEntry { - /// Creates a new status entry timestamped to now. - pub fn new(status: IntentStatus, reason: Option) -> Self { - Self { - status, - changed_at: Utc::now(), - reason, - } - } - - /// The status that was entered. - pub fn status(&self) -> &IntentStatus { - &self.status - } - - /// When the transition occurred. - pub fn changed_at(&self) -> DateTime { - self.changed_at +impl ParentLike for Uuid { + fn parent_id(&self) -> Uuid { + *self } +} - /// Optional reason for the transition. - pub fn reason(&self) -> Option<&str> { - self.reason.as_deref() +impl ParentLike for &Uuid { + fn parent_id(&self) -> Uuid { + **self } } -/// The entry point of every AI-assisted workflow. -/// -/// An `Intent` captures both the verbatim user input (`prompt`) and the -/// AI's structured understanding of that input (`spec`). It is -/// created at step ② and completed at step ⑩ of the end-to-end flow. -/// See module documentation for lifecycle position, status transitions, -/// and conversational refinement. - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -#[serde(transparent)] -pub struct IntentSpec(pub serde_json::Value); - -impl From for IntentSpec { - fn from(value: String) -> Self { - Self(serde_json::Value::String(value)) +impl ParentLike for Intent { + fn parent_id(&self) -> Uuid { + self.header.object_id() } } -impl From<&str> for IntentSpec { - fn from(value: &str) -> Self { - Self::from(value.to_string()) +impl ParentLike for &Intent { + fn parent_id(&self) -> Uuid { + self.header.object_id() } } +/// Immutable request/spec revision. +/// +/// One stored `Intent` answers "what request revision existed here?". +/// It does not answer "what is the current thread head?" or "which plan +/// is currently selected?" because those are Libra projection concerns. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Intent { - /// Common header (object ID, type, timestamps, creator, etc.). + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. #[serde(flatten)] header: Header, - /// Links to predecessor Intents for conversational refinement. + /// Parent intent revisions that this revision directly derives from. /// - /// Acts like commit parents: one `Intent` can point to multiple - /// predecessors. The orchestrator can walk the parent set recursively - /// to reconstruct the full refinement graph/history. - /// - /// Example: Intent₃ can reference Intent₁ and Intent₂ if both provide - /// usable context for follow-up synthesis. + /// Multiple parents allow merge-style intent history similar to a + /// commit DAG. #[serde(default, skip_serializing_if = "Vec::is_empty")] parents: Vec, - /// Verbatim natural-language request from the user. - /// - /// This is the unmodified input exactly as the user typed it (e.g. - /// "Add pagination to the user list API"). It is set once at - /// creation and never changed, preserving the original request for - /// auditing and potential re-analysis with a different model. + /// Original free-form user request captured for this revision. prompt: String, - /// AI-analyzed structured interpretation of `prompt`. - /// - /// `None` while the Intent is in `Draft` status — the AI has not - /// yet processed the prompt. Set to `Some(...)` when the AI - /// completes its analysis, at which point the status should - /// transition to `Active`. The spec typically includes: - /// - Disambiguated requirements - /// - Identified scope (which files, modules, APIs are affected) - /// - Inferred constraints or acceptance criteria - /// - /// Unlike `prompt`, `spec` is the AI's output and may be - /// regenerated if the analysis is re-run. + /// Structured interpretation of `prompt`, when Libra or an agent has + /// already produced one at persistence time. #[serde(default, skip_serializing_if = "Option::is_none")] spec: Option, - /// Link to the [`ContextPipeline`](super::pipeline::ContextPipeline) - /// reserved for this Intent. - /// - /// `Intent::new` creates the pipeline eagerly and stores its ID - /// here for one-step traversal from Intent → ContextPipeline. - context_pipeline: Uuid, - /// Link to the [`Plan`](super::plan::Plan) derived from this - /// Intent. - /// - /// Set after the AI analyzes `spec` and produces a Plan at - /// step ③. Always points to the **latest** Plan revision — if - /// the Plan is revised (via `Plan.previous` chain), this field - /// is updated to the newest version. `None` while no Plan has - /// been created yet. - #[serde(default, skip_serializing_if = "Option::is_none")] - plan: Option, - /// Git commit hash recorded when this Intent is fulfilled. - /// - /// Set by the orchestrator at step ⑩ after the - /// [`Decision`](super::decision::Decision) applies the final - /// PatchSet. `None` while the Intent is in progress (`Draft` or - /// `Active`) or if it was `Cancelled`. When set, the Intent's - /// status should be `Completed`. - #[serde(default, skip_serializing_if = "Option::is_none")] - commit: Option, - /// Append-only chronological history of status transitions. - /// - /// Initialized with a single `Draft` entry at creation. Each call - /// to [`set_status`](Intent::set_status) or - /// [`set_status_with_reason`](Intent::set_status_with_reason) - /// pushes a new [`StatusEntry`]. The current status is always - /// `statuses.last().status`. Entries are never removed or mutated. - /// - /// This design preserves the full transition timeline with - /// timestamps and optional reasons, enabling audit and duration - /// analysis (e.g. time spent in `Active` before `Completed`). - statuses: Vec, } impl Intent { - /// Create a new intent in `Draft` status from a raw user prompt. - /// - /// A [`ContextPipeline`](super::pipeline::ContextPipeline) is - /// created first, then linked into the returned Intent. - /// - /// The `spec` field is initially `None` — call [`set_spec`](Intent::set_spec) - /// after the AI has analyzed the prompt. - pub fn new( - created_by: ActorRef, - prompt: impl Into, - ) -> Result<(Self, ContextPipeline), String> { - let pipeline = ContextPipeline::new(created_by.clone())?; - let pipeline_id = pipeline.header().object_id(); - - Ok(( - Self { - header: Header::new(ObjectType::Intent, created_by)?, - prompt: prompt.into(), - spec: None, - parents: Vec::new(), - commit: None, - context_pipeline: pipeline_id, - plan: None, - statuses: vec![StatusEntry::new(IntentStatus::Draft, None)], - }, - pipeline, - )) - } - - /// Returns a reference to the common header. - pub fn header(&self) -> &Header { - &self.header - } - - /// Returns the raw user prompt. - pub fn prompt(&self) -> &str { - &self.prompt + /// Create a new root intent revision from a free-form user prompt. + pub fn new(created_by: ActorRef, prompt: impl Into) -> Result { + Ok(Self { + header: Header::new(ObjectType::Intent, created_by)?, + parents: Vec::new(), + prompt: prompt.into(), + spec: None, + }) } - /// Returns the AI-analyzed spec, if available. - pub fn spec(&self) -> Option<&IntentSpec> { - self.spec.as_ref() - } - - /// Sets the AI-analyzed spec. - pub fn set_spec(&mut self, spec: Option) { - let is_draft = matches!(self.status(), Some(&IntentStatus::Draft)); - self.spec = spec; - if self.spec.is_some() && is_draft { - self.set_status(IntentStatus::Active); - } - } - - /// Create a new intent as a revision from an existing intent. + /// Create a new intent revision from a single parent intent. /// - /// This keeps a single-element parent chain by default (`parent_id`). - /// Additional parents can be added with [`add_parent`](Intent::add_parent) - /// for merge-style refinements. + /// This is the common helper for linear refinement. pub fn new_revision_from( created_by: ActorRef, prompt: impl Into, parent: &Self, - ) -> Result<(Self, ContextPipeline), String> { + ) -> Result { Self::new_revision_chain(created_by, prompt, &[parent]) } - /// Create a new revision intent with multiple parents in one step. - /// - /// The function accepts either ID-based or intent-based parent inputs - /// and automatically deduplicates parents. + /// Create a new intent revision from multiple parent intents. /// - /// - For ID-based input: `Intent::new_revision_chain(actor, prompt, &[id1, id2])` - /// - For intent-based input: `Intent::new_revision_chain(actor, prompt, &[&intent1, &intent2])` - /// - /// # Examples - /// - /// ```rust - /// use git_internal::internal::object::intent::Intent; - /// use git_internal::internal::object::types::ActorRef; - /// use uuid::Uuid; - /// - /// let actor = ActorRef::human("agent").expect("actor"); - /// let (base, _) = Intent::new(actor.clone(), "Build API rate-limiting").expect("base"); - /// - /// // 1) single parent (same as `new_revision_from`) - /// let (r1, _) = Intent::new_revision_chain( - /// actor.clone(), - /// "Split into middleware + config", - /// &[&base], - /// ).expect("revision"); - /// - /// // 2) multi-parent merge from existing intents - /// let extra_parent = Uuid::from_u128(0x1001); - /// let (_merged, _) = Intent::new_revision_chain( - /// actor.clone(), - /// "Merge constraints from policy + implementation feedback", - /// &[&base, &r1], - /// ).expect("merge"); - /// ``` - /// - /// # Chained updates - /// - /// ```rust - /// use git_internal::internal::object::intent::Intent; - /// use git_internal::internal::object::types::ActorRef; - /// use uuid::Uuid; - /// - /// let actor = ActorRef::human("agent").expect("actor"); - /// let (first, _) = Intent::new(actor.clone(), "A").expect("first"); - /// let (second, _) = Intent::new_revision_chain( - /// actor.clone(), - /// "B", - /// &[Uuid::from_u128(0x2001)], - /// ).expect("second"); - /// let (_third, _) = Intent::new_revision_chain( - /// actor, - /// "C", - /// &[&first, &second], - /// ).expect("third"); - /// ``` + /// Use this when Libra merges several prior intent branches into a + /// new request/spec revision. #[allow(private_bounds)] pub fn new_revision_chain( created_by: ActorRef, prompt: impl Into, parents: &[P], - ) -> Result<(Self, ContextPipeline), String> { - let (mut intent, pipeline) = Self::new(created_by, prompt)?; - for p in parents { - intent.add_parent(p.parent_id()); + ) -> Result { + let mut intent = Self::new(created_by, prompt)?; + for parent in parents { + intent.add_parent(parent.parent_id()); } - Ok((intent, pipeline)) + Ok(intent) + } + + /// Return the immutable header for this intent revision. + pub fn header(&self) -> &Header { + &self.header } - /// Returns the parent Intent IDs, if this is part of a refinement graph. + /// Return the direct parent intent ids of this revision. pub fn parents(&self) -> &[Uuid] { &self.parents } - /// Add a parent Intent ID with dedupe and self-parent guard. - /// - /// Duplicate parent IDs are ignored to keep the parent vector - /// canonical for deterministic serialization and simple diffs. + /// Return the original user prompt stored on this revision. + pub fn prompt(&self) -> &str { + &self.prompt + } + + /// Return the structured request payload, if one was stored. + pub fn spec(&self) -> Option<&IntentSpec> { + self.spec.as_ref() + } + + /// Add one parent link if it is not already present and is not self. pub fn add_parent(&mut self, parent_id: Uuid) { if parent_id == self.header.object_id() { return; @@ -546,64 +185,15 @@ impl Intent { } } - /// Returns the result commit SHA, if the intent has been fulfilled. - pub fn commit(&self) -> Option<&IntegrityHash> { - self.commit.as_ref() - } - - /// Returns the linked ContextPipeline ID. - pub fn context_pipeline(&self) -> Uuid { - self.context_pipeline - } - - /// Returns the current lifecycle status (the last entry in the history). - /// - /// Returns `None` only if `statuses` is empty, which should not - /// happen for objects created via [`Intent::new`] (seeds with - /// `Draft`), but may occur for malformed deserialized data. - pub fn status(&self) -> Option<&IntentStatus> { - self.statuses.last().map(|e| &e.status) - } - - /// Returns the full chronological status history. - pub fn statuses(&self) -> &[StatusEntry] { - &self.statuses - } - - /// Sets the parent Intent IDs for conversational refinement. + /// Replace the parent set for this in-memory revision before + /// persistence. pub fn set_parents(&mut self, parents: Vec) { self.parents = parents; } - /// Records the git commit SHA that fulfilled this intent. - pub fn set_commit(&mut self, sha: Option) { - self.commit = sha; - } - - /// Returns the associated Plan ID, if a Plan has been derived from this intent. - pub fn plan(&self) -> Option { - self.plan - } - - /// Sets the linked ContextPipeline ID. - pub fn set_context_pipeline(&mut self, context_pipeline: Uuid) { - self.context_pipeline = context_pipeline; - } - - /// Associates this intent with a [`Plan`](super::plan::Plan). - pub fn set_plan(&mut self, plan: Option) { - self.plan = plan; - } - - /// Transitions the intent to a new lifecycle status, appending to the history. - pub fn set_status(&mut self, status: IntentStatus) { - self.statuses.push(StatusEntry::new(status, None)); - } - - /// Transitions the intent to a new lifecycle status with a reason. - pub fn set_status_with_reason(&mut self, status: IntentStatus, reason: impl Into) { - self.statuses - .push(StatusEntry::new(status, Some(reason.into()))); + /// Set or clear the structured spec for this in-memory revision. + pub fn set_spec(&mut self, spec: Option) { + self.spec = spec; } } @@ -644,115 +234,40 @@ impl ObjectTrait for Intent { mod tests { use super::*; + // Coverage: + // - root intent construction defaults + // - revision graph creation for single-parent and multi-parent flows + // - structured spec assignment before persistence + #[test] fn test_intent_creation() { let actor = ActorRef::human("jackie").expect("actor"); - let (mut intent, pipeline) = - Intent::new(actor.clone(), "Refactor login flow").expect("intent"); + let intent = Intent::new(actor, "Add pagination").expect("intent"); - assert_eq!(intent.header().object_type(), &ObjectType::Intent); - assert_eq!(intent.prompt(), "Refactor login flow"); - assert!(intent.spec().is_none()); - assert_eq!(intent.status(), Some(&IntentStatus::Draft)); + assert_eq!(intent.prompt(), "Add pagination"); assert!(intent.parents().is_empty()); - assert_eq!(intent.context_pipeline(), pipeline.header().object_id()); - assert!(intent.plan().is_none()); - - let spec = IntentSpec(serde_json::json!({ - "objective": "Restructure the authentication module", - "scope": ["auth module"], - "constraints": ["keep API contract stable"] - })); - intent.set_spec(Some(spec.clone())); - assert_eq!(intent.spec(), Some(&spec)); - assert_eq!(intent.status(), Some(&IntentStatus::Active)); - - // After spec is analyzed, a Plan can be linked - let plan_id = Uuid::from_u128(0x42); - intent.set_plan(Some(plan_id)); - assert_eq!(intent.plan(), Some(plan_id)); - - // Multiple parents are supported for merge-style refinement. - let mut intent = intent; - intent.set_parents(vec![Uuid::from_u128(0x43), Uuid::from_u128(0x44)]); - assert_eq!(intent.parents().len(), 2); - assert_eq!(intent.parents()[0], Uuid::from_u128(0x43)); - assert_eq!(intent.parents()[1], Uuid::from_u128(0x44)); - - // new_revision_from automatically links the previous intent. - let (followup, _) = Intent::new_revision_from( - actor.clone(), - "Refactor login flow with stricter constraints", - &intent, - ) - .expect("intent revision"); - assert_eq!(followup.parents(), &[intent.header().object_id()]); - - // new_revision_chain accepts either parent IDs... - let merge_parent_a = Uuid::from_u128(0x201); - let merge_parent_b = Uuid::from_u128(0x202); - let (merge_from_ids, _) = Intent::new_revision_chain( - actor.clone(), - "Refactor login flow with merged context", - &[merge_parent_a, merge_parent_b, merge_parent_a], - ) - .expect("intent revision by ids"); - assert_eq!(merge_from_ids.parents(), &[merge_parent_a, merge_parent_b]); - - // ...or parent intent references... - let (merge_from_intents, _) = Intent::new_revision_chain( - actor.clone(), - "Refactor login flow with intent merge", - &[&intent, &followup], - ) - .expect("intent revision by intents"); + assert!(intent.spec().is_none()); + } + + #[test] + fn test_intent_revision_graph() { + let actor = ActorRef::human("jackie").expect("actor"); + let root = Intent::new(actor.clone(), "A").expect("intent"); + let branch_a = Intent::new_revision_from(actor.clone(), "B", &root).expect("intent"); + let branch_b = Intent::new_revision_chain(actor, "C", &[&root, &branch_a]).expect("intent"); + + assert_eq!(branch_a.parents(), &[root.header().object_id()]); assert_eq!( - merge_from_intents.parents(), - &[intent.header().object_id(), followup.header().object_id()] + branch_b.parents(), + &[root.header().object_id(), branch_a.header().object_id()] ); - - // add_parent deduplicates and ignores self-parent. - let (mut with_helpers, _) = - Intent::new(actor.clone(), "parent merge helper").expect("intent helper"); - let p1 = Uuid::from_u128(0x101); - let p2 = Uuid::from_u128(0x102); - with_helpers.add_parent(p1); - with_helpers.add_parent(p1); - with_helpers.add_parent(p2); - with_helpers.add_parent(with_helpers.header().object_id()); - assert_eq!(with_helpers.parents(), &[p1, p2]); } #[test] - fn test_statuses() { + fn test_spec_assignment() { let actor = ActorRef::human("jackie").expect("actor"); - let (mut intent, _) = Intent::new(actor, "Fix bug").expect("intent"); - - // Initial state: one Draft entry - assert_eq!(intent.statuses().len(), 1); - assert_eq!(intent.status(), Some(&IntentStatus::Draft)); - - // Transition to Active - intent.set_status(IntentStatus::Active); - assert_eq!(intent.status(), Some(&IntentStatus::Active)); - assert_eq!(intent.statuses().len(), 2); - - // Transition to Completed with reason - intent.set_status_with_reason(IntentStatus::Completed, "All tasks done"); - assert_eq!(intent.status(), Some(&IntentStatus::Completed)); - assert_eq!(intent.statuses().len(), 3); - - // Verify full history - let history = intent.statuses(); - assert_eq!(history[0].status(), &IntentStatus::Draft); - assert!(history[0].reason().is_none()); - assert_eq!(history[1].status(), &IntentStatus::Active); - assert!(history[1].reason().is_none()); - assert_eq!(history[2].status(), &IntentStatus::Completed); - assert_eq!(history[2].reason(), Some("All tasks done")); - - // Timestamps are ordered - assert!(history[1].changed_at() >= history[0].changed_at()); - assert!(history[2].changed_at() >= history[1].changed_at()); + let mut intent = Intent::new(actor, "A").expect("intent"); + intent.set_spec(Some("structured spec".into())); + assert_eq!(intent.spec(), Some(&IntentSpec::from("structured spec"))); } } diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index 573b23ff..c540ecf8 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -1,159 +1,173 @@ //! Object model definitions for Git blobs, trees, commits, tags, and -//! supporting traits that let the pack/zlib layers create strongly typed -//! values from raw bytes. +//! AI workflow objects. //! -//! AI objects are also defined here, as they are a fundamental part of -//! the system and need to be accessible across multiple modules without -//! circular dependencies. +//! This module is the storage-layer contract for `git-internal`. +//! Git-native objects (`Blob`, `Tree`, `Commit`, `Tag`) model repository +//! content, while the AI objects model immutable workflow history that +//! Libra orchestrates on top. //! -//! # AI Object End-to-End Flow +//! # How Libra should use this module +//! +//! Libra should treat every AI object here as an immutable record: +//! +//! - construct the object in memory, +//! - populate optional fields before persistence, +//! - persist it once, +//! - derive current state later from object history plus Libra +//! projections. +//! +//! Libra should not store scheduler state, selected heads, active UI +//! focus, or query caches in these objects. Those belong to Libra's own +//! runtime and index layer. +//! +//! AI workflow objects are split into three layers: +//! +//! - **Snapshot objects** in `git-internal` answer "what was the stored +//! fact at this revision?" +//! - **Event objects** in `git-internal` answer "what happened later?" +//! - **Libra projections** answer "what is the system's current view?" +//! +//! # Relationship Design Standard +//! +//! Relationship fields follow a simple storage rule: +//! +//! - Store the canonical ownership edge on the child object when the +//! relationship is a historical fact. +//! - Low-frequency, strongly aggregated relationships that benefit +//! from fast parent-to-children traversal may additionally keep a +//! reverse convenience link. +//! - High-frequency, high-cardinality, event-stream relationships +//! should remain single-directional to avoid turning parent objects +//! into rewrite hotspots. +//! +//! # Three-Layer Design //! //! ```text -//! ① User input -//! │ -//! ▼ -//! ② Intent (Draft → Active) -//! │ -//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame -//! │ -//! ▼ -//! ③ Plan (pipeline, iframes, steps) -//! │ -//! ├─ PlanStep₀ (inline) -//! ├─ PlanStep₁ ──task──▶ sub-Task (recursive) -//! └─ PlanStep₂ (inline) -//! │ -//! ▼ -//! ④ Task (Draft → Running) -//! │ -//! ▼ -//! ⑤ Run (Created → Patching → Validating → Completed/Failed) -//! │ -//! ├──▶ Provenance (1:1, LLM config + token usage) -//! ├──▶ ContextSnapshot (optional, static context at start) -//! │ -//! │ ┌─── agent execution loop ───┐ -//! │ │ │ -//! │ │ ⑥ ToolInvocation (1:N) │ ← action log -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑦ PatchSet (Proposed) │ ← candidate diff -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑧ Evidence (1:N) │ ← test/lint/build -//! │ │ │ │ -//! │ │ ├─ pass ──────────────┘ -//! │ │ └─ fail → new PatchSet (retry within Run) -//! │ └─────────────────────────────┘ -//! │ -//! ▼ -//! ⑨ Decision (terminal verdict) -//! │ -//! ├─ Commit → apply PatchSet, record result_commit -//! ├─ Retry → create new Run ⑤ for same Task -//! ├─ Abandon → mark Task as Failed -//! ├─ Checkpoint → save state, resume later -//! └─ Rollback → revert applied PatchSet -//! │ -//! ▼ -//! ⑩ Intent (Completed) ← commit recorded +//! +------------------------------------------------------------------+ +//! | Libra projection / runtime | +//! |------------------------------------------------------------------| +//! | thread heads / selected_plan_id / active_run / scheduler state | +//! | live context window / UI focus / query indexes | +//! +--------------------------------+---------------------------------+ +//! | +//! v +//! +------------------------------------------------------------------+ +//! | git-internal event objects | +//! |------------------------------------------------------------------| +//! | IntentEvent / TaskEvent / RunEvent / PlanStepEvent / RunUsage | +//! | ToolInvocation / Evidence / Decision / ContextFrame | +//! +--------------------------------+---------------------------------+ +//! | +//! v +//! +------------------------------------------------------------------+ +//! | git-internal snapshot objects | +//! |------------------------------------------------------------------| +//! | Intent / Plan / Task / Run / PatchSet / ContextSnapshot | +//! | Provenance | +//! +------------------------------------------------------------------+ //! ``` //! -//! ## Steps -//! -//! 1. **User input** — the user provides a natural-language request. -//! -//! 2. **[`Intent`](intent::Intent)** — captures the raw prompt and the -//! AI's structured interpretation. Status transitions from `Draft` -//! (prompt only) to `Active` (analysis complete). Supports -//! conversational refinement via `parent` chain. -//! -//! 3. **[`Plan`](plan::Plan)** — a sequence of -//! [`PlanStep`](plan::PlanStep)s derived from the Intent. References -//! a [`ContextPipeline`](pipeline::ContextPipeline) and records the -//! consumed/derived stable frame IDs (`iframes`). Steps track consumed/produced -//! frames by stable ID (`iframes`/`oframes`). A step may spawn a sub-Task for -//! recursive decomposition. Plans form a revision chain via -//! `previous`. -//! -//! 4. **[`Task`](task::Task)** — a unit of work with title, constraints, -//! and acceptance criteria. May link back to its originating Intent. -//! Accumulates Runs in `runs` (chronological execution history). -//! -//! 5. **[`Run`](run::Run)** — a single execution attempt of a Task. -//! Records the baseline `commit`, the Plan version being executed -//! (snapshot reference), and the host `environment`. A -//! [`Provenance`](provenance::Provenance) (1:1) captures the LLM -//! configuration and token usage. -//! -//! 6. **[`ToolInvocation`](tool::ToolInvocation)** — the finest-grained -//! record: one per tool call (read file, run command, etc.). Forms -//! a chronological action log for the Run. Tracks file I/O via -//! `io_footprint`. -//! -//! 7. **[`PatchSet`](patchset::PatchSet)** — a candidate diff generated -//! by the agent. Contains the diff `artifact`, file-level `touched` -//! summary, and `rationale`. Starts as `Proposed`; transitions to -//! `Applied` or `Rejected`. Ordering is by position in -//! `Run.patchsets`. -//! -//! 8. **[`Evidence`](evidence::Evidence)** — output of a validation tool -//! (test, lint, build) run against a PatchSet. One per tool -//! invocation. Carries `exit_code`, `summary`, and -//! `report_artifacts`. Feeds into the Decision. -//! -//! 9. **[`Decision`](decision::Decision)** — the terminal verdict of a -//! Run. Selects a PatchSet to apply (`Commit`), retries the Task -//! (`Retry`), gives up (`Abandon`), saves progress (`Checkpoint`), -//! or reverts (`Rollback`). Records `rationale` and -//! `result_commit_sha`. -//! -//! 10. **Intent completed** — the orchestrator records the final git -//! commit in `Intent.commit` and transitions status to `Completed`. +//! # Main Object Relationships +//! +//! ```text +//! Snapshot layer +//! ============== +//! +//! Intent --parents----------------------------> Intent +//! Plan --intent-----------------------------> Intent +//! Plan --parents----------------------------> Plan +//! Task --intent?----------------------------> Intent +//! Task --parent?----------------------------> Task +//! Task --origin_step_id?-------------------> PlanStep.step_id +//! Run --task-------------------------------> Task +//! Run --plan?------------------------------> Plan +//! Run --snapshot?--------------------------> ContextSnapshot +//! PatchSet --run----------------------------> Run +//! Provenance --run_id-------------------------> Run +//! +//! Event layer +//! =========== +//! +//! IntentEvent --intent_id-------------------> Intent +//! TaskEvent --task_id---------------------> Task +//! RunEvent --run_id----------------------> Run +//! RunUsage --run_id----------------------> Run +//! PlanStepEvent --plan_id + step_id + run_id-> Plan / Run / PlanStep +//! ToolInvocation--run_id----------------------> Run +//! Evidence --run_id / patchset_id?-------> Run / PatchSet +//! Decision --run_id / chosen_patchset_id?> Run / PatchSet +//! ContextFrame --run_id? / plan_id? / step_id?> Run / Plan / PlanStep +//! ``` +//! +//! # Libra read / write pattern +//! +//! A typical Libra call flow looks like this: +//! +//! 1. write snapshot objects when a new immutable revision is defined +//! (`Intent`, `Plan`, `Task`, `Run`, `PatchSet`, `ContextSnapshot`, +//! `Provenance`); +//! 2. append event objects as execution progresses +//! (`IntentEvent`, `TaskEvent`, `RunEvent`, `PlanStepEvent`, +//! `RunUsage`, `ToolInvocation`, `Evidence`, `Decision`, +//! `ContextFrame`); +//! 3. rebuild current state in Libra from those immutable objects plus +//! its own `Thread`, `Scheduler`, `UI`, and `Query Index` +//! projections. //! //! ## Object Relationship Summary //! //! | From | Field | To | Cardinality | //! |------|-------|----|-------------| //! | Intent | `parents` | Intent | 0..N | -//! | Intent | `plan` | Plan | 0..1 | -//! | Intent | `thread_id` | Thread | 0..1 | -//! | Thread | `head_intent_ids` | Intent | 0..N | -//! | Plan | `previous` | Plan | 0..1 | -//! | Plan | `pipeline` | ContextPipeline | 0..1 | -//! | PlanStep | `task` | Task | 0..1 | +//! | Plan | `intent` | Intent | 1 canonical | +//! | Plan | `parents` | Plan | 0..N | +//! | Plan | `context_frames` | ContextFrame | 0..N | //! | Task | `parent` | Task | 0..1 | //! | Task | `intent` | Intent | 0..1 | -//! | Task | `runs` | Run | 0..N | +//! | Task | `origin_step_id` | PlanStep.step_id | 0..1 | //! | Task | `dependencies` | Task | 0..N | //! | Run | `task` | Task | 1 | //! | Run | `plan` | Plan | 0..1 | //! | Run | `snapshot` | ContextSnapshot | 0..1 | -//! | Run | `patchsets` | PatchSet | 0..N | //! | PatchSet | `run` | Run | 1 | +//! | Provenance | `run_id` | Run | 1 | +//! | IntentEvent | `intent_id` | Intent | 1 | +//! | TaskEvent | `task_id` | Task | 1 | +//! | RunEvent | `run_id` | Run | 1 | +//! | RunUsage | `run_id` | Run | 1 | +//! | PlanStepEvent | `plan_id` | Plan | 1 | +//! | PlanStepEvent | `step_id` | PlanStep.step_id | 1 | +//! | PlanStepEvent | `run_id` | Run | 1 | +//! | ToolInvocation | `run_id` | Run | 1 | //! | Evidence | `run_id` | Run | 1 | //! | Evidence | `patchset_id` | PatchSet | 0..1 | //! | Decision | `run_id` | Run | 1 | //! | Decision | `chosen_patchset_id` | PatchSet | 0..1 | -//! | Provenance | `run_id` | Run | 1 | -//! | ToolInvocation | `run_id` | Run | 1 | +//! | ContextFrame | `run_id` | Run | 0..1 | +//! | ContextFrame | `plan_id` | Plan | 0..1 | +//! | ContextFrame | `step_id` | PlanStep.step_id | 0..1 | //! pub mod blob; pub mod commit; pub mod context; +pub mod context_frame; pub mod decision; pub mod evidence; pub mod integrity; pub mod intent; +pub mod intent_event; pub mod note; pub mod patchset; -pub mod pipeline; pub mod plan; +pub mod plan_step_event; pub mod provenance; pub mod run; +pub mod run_event; +pub mod run_usage; pub mod signature; pub mod tag; pub mod task; +pub mod task_event; pub mod tool; pub mod tree; pub mod types; @@ -164,9 +178,11 @@ use std::{ io::{BufRead, Read}, }; +use sha1::Digest; + use crate::{ errors::GitError, - hash::ObjectHash, + hash::{ObjectHash, get_hash_kind}, internal::{object::types::ObjectType, zlib::stream::inflate::ReadBoxed}, }; @@ -199,12 +215,37 @@ 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)) + let object_type = self.get_type(); + let type_bytes = object_type.to_bytes().ok_or_else(|| { + GitError::InvalidObjectType(format!( + "object type `{}` cannot be hashed as a loose object", + object_type + )) + })?; + + Ok(match get_hash_kind() { + crate::hash::HashKind::Sha1 => { + let mut hash = sha1::Sha1::new(); + hash.update(type_bytes); + hash.update(b" "); + hash.update(data.len().to_string()); + hash.update(b"\0"); + hash.update(&data); + let result: [u8; 20] = hash.finalize().into(); + ObjectHash::Sha1(result) + } + crate::hash::HashKind::Sha256 => { + let mut hash = sha2::Sha256::new(); + hash.update(type_bytes); + hash.update(b" "); + hash.update(data.len().to_string()); + hash.update(b"\0"); + hash.update(&data); + let result: [u8; 32] = hash.finalize().into(); + ObjectHash::Sha256(result) + } + }) } } diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index ded1a3dd..45fafe9a 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -1,75 +1,28 @@ -//! AI PatchSet Definition +//! AI PatchSet snapshot. //! -//! A `PatchSet` represents a proposed set of code changes (diffs) generated -//! by an agent during a [`Run`](super::run::Run). It is the atomic unit of -//! code modification in the AI workflow — every change the agent wants to -//! make to the repository is packaged as a PatchSet. +//! `PatchSet` stores one immutable candidate diff produced during a +//! `Run`. //! -//! # Relationships +//! # How to use this object //! -//! ```text -//! ⑤ Run -//! └─ patchsets ──▶ [PatchSet₀, PatchSet₁, ...] -//! │ -//! └── run ──▶ Run (back-reference) -//! │ -//! └─ decision branch after validation -//! │ -//! ├─ evidence pass → Decision Commit -//! └─ evidence fail → emit new PatchSet -//! ``` +//! - Create one `PatchSet` per candidate diff worth retaining. +//! - Use `sequence` to preserve ordering between multiple candidates in +//! the same run. +//! - Attach diff artifacts, touched files, and rationale before +//! persistence. //! -//! - **Run** (bidirectional): `Run.patchsets` holds the forward reference -//! (chronological generation history), `PatchSet.run` is the back-reference. +//! # How it works with other objects //! -//! # Lifecycle +//! - `Run` is the canonical owner through `PatchSet.run`. +//! - `Evidence` may validate a specific patchset via `patchset_id`. +//! - `Decision` selects the chosen patchset, if any. //! -//! ```text -//! ┌──────────┐ agent produces diff ┌──────────┐ -//! │ (created)│ ───────────────────────▶ │ Proposed │ -//! └──────────┘ └────┬─────┘ -//! │ -//! ┌───────────────────┼───────────────────┐ -//! │ validation/review │ │ -//! ▼ passes ▼ fails │ -//! ┌─────────┐ ┌──────────┐ │ -//! │ Applied │ │ Rejected │ │ -//! └─────────┘ └────┬─────┘ │ -//! │ │ -//! ▼ │ -//! agent generates new PatchSet │ -//! appended to Run.patchsets │ -//! ``` +//! # How Libra should call it //! -//! 1. **Creation**: The orchestrator calls `PatchSet::new()`, which sets -//! `apply_status` to `Proposed`. At this point `artifact` is `None` -//! and `touched` is empty. -//! 2. **Diff generation**: The agent produces a diff against `commit` -//! (the baseline Git commit). It sets `artifact` to point to the -//! stored diff content, populates `touched` with a file-level -//! summary, writes a `rationale`, and records the `format`. -//! 3. **Review / validation**: The orchestrator or a human reviewer -//! inspects the PatchSet. Automated checks (tests, linting) may run. -//! 4. **Applied**: If the diff passes, the orchestrator commits it to -//! the repository and transitions `apply_status` to `Applied`. -//! 5. **Rejected**: If the diff fails validation or is rejected by a -//! reviewer, `apply_status` becomes `Rejected`. The agent may then -//! generate a new PatchSet appended to `Run.patchsets`. -//! -//! # Ordering -//! -//! PatchSet ordering is determined by position in `Run.patchsets`. If a -//! PatchSet is rejected, the agent generates a new PatchSet and appends -//! it to the Vec. The last entry is always the most recent attempt. -//! -//! # Content -//! -//! The actual diff content is stored as an [`ArtifactRef`] (via the -//! `artifact` field), while [`TouchedFile`] (via the `touched` field) -//! provides a lightweight file-level summary for UI and indexing. -//! The `format` field indicates how to parse the artifact content -//! (unified diff or git diff). The `rationale` field carries the -//! agent's explanation of what was changed and why. +//! Libra should use `PatchSet` as immutable staging history. Acceptance, +//! rejection, or promotion to repository commit should be represented by +//! `Decision` and Libra projections rather than by mutating the +//! `PatchSet`. use std::fmt; @@ -86,45 +39,13 @@ use crate::{ }, }; -/// Patch application status. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ApplyStatus { - /// Patch is generated but not yet applied to the repo. - Proposed, - /// Patch has been applied (committed) to the repo. - Applied, - /// Patch was rejected by validation or user. - Rejected, -} - -impl ApplyStatus { - pub fn as_str(&self) -> &'static str { - match self { - ApplyStatus::Proposed => "proposed", - ApplyStatus::Applied => "applied", - ApplyStatus::Rejected => "rejected", - } - } -} - -impl fmt::Display for ApplyStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// Diff format for patch content. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DiffFormat { - /// Standard unified diff format. UnifiedDiff, - /// Git-specific diff format (with binary support etc). GitDiff, } -/// Type of change for a file. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ChangeType { @@ -135,19 +56,21 @@ pub enum ChangeType { Copy, } -/// Touched file summary in a patchset. -/// -/// Provides a quick overview of what files are modified without parsing the full diff. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TouchedFile { + /// Repository-relative path affected by the candidate diff. pub path: String, + /// Coarse change category for the touched file. pub change_type: ChangeType, + /// Number of added lines attributed to this file in the patch. pub lines_added: u32, + /// Number of deleted lines attributed to this file in the patch. pub lines_deleted: u32, } impl TouchedFile { + /// Create one touched-file summary entry for a patchset. pub fn new( path: impl Into, change_type: ChangeType, @@ -167,153 +90,115 @@ impl TouchedFile { } } -/// PatchSet object containing a candidate diff. +/// Immutable candidate diff snapshot for one `Run`. /// -/// Ordering between PatchSets is determined by their position in -/// [`Run.patchsets`](super::run::Run). The PatchSet itself does not -/// carry a generation number or supersession list. +/// A `PatchSet` stores the proposed change and its metadata, while the +/// higher-level verdict about whether that change is accepted lives +/// elsewhere. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PatchSet { - /// Common header (object ID, type, timestamps, creator, etc.). + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. #[serde(flatten)] header: Header, - /// The [`Run`](super::run::Run) that generated this PatchSet. - /// `Run.patchsets` holds the forward reference and ordering. + /// Canonical owning run for this candidate diff. run: Uuid, - /// Git commit hash the diff is based on. + /// Ordering of this candidate among patchsets produced by the same + /// run. + sequence: u32, + /// Repository integrity hash representing the diff baseline or + /// associated commit context. commit: IntegrityHash, - /// Diff format used for the patch content (e.g. unified diff, git diff). - /// - /// Determines how the diff stored in `artifact` should be parsed. - /// `UnifiedDiff` is the standard format produced by `diff -u`; - /// `GitDiff` extends it with binary file support, rename detection, - /// and mode-change headers. The orchestrator sets this at creation - /// time based on the tool that generated the diff. + /// Diff serialization format used for the stored patch candidate. format: DiffFormat, - /// Reference to the actual diff content in object storage. - /// - /// Points to an [`ArtifactRef`] whose payload contains the full - /// diff text (or binary patch) in the encoding described by `format`. - /// `None` while the diff is still being generated; set once the - /// agent finishes producing the patch. Consumers fetch the artifact, - /// then interpret it according to `format`. + /// Optional artifact pointer to the full diff payload. + #[serde(default, skip_serializing_if = "Option::is_none")] artifact: Option, - /// Lightweight summary of files modified in this PatchSet. - /// - /// Each [`TouchedFile`] records a path, change type (add/modify/ - /// delete/rename/copy), and line-count deltas. This allows UIs and - /// indexing pipelines to display a file-level overview without - /// downloading or parsing the full diff artifact. The list is - /// populated incrementally as the agent produces changes and should - /// be consistent with the actual diff content. + /// File-level summary of paths touched by the candidate diff. #[serde(default)] touched: Vec, - /// Human-readable explanation of the changes in this PatchSet. - /// - /// Serves a role analogous to a commit message or PR description, - /// bridging the gap between the high-level goal (Task/Plan) and - /// the raw diff (artifact). - /// - /// **Primary author**: the agent executing the Run. After producing - /// the diff, the agent summarises **what was changed and why** and - /// writes it here. A human reviewer may later overwrite or refine - /// the text via `set_rationale()` if the agent's explanation is - /// insufficient. - /// - /// When a Run produces multiple PatchSets (successive attempts), - /// each rationale captures the reasoning behind that specific - /// attempt, e.g.: - /// - /// - PatchSet₀: "Replaced session auth with JWT — breaks backward compat" - /// - PatchSet₁: "Gradual migration: accept both auth schemes" - /// - /// `None` only when the PatchSet is still being generated or the - /// agent did not provide an explanation. Reviewers should treat a - /// missing rationale as a signal to inspect the diff more carefully. + /// Optional human-readable rationale for why this candidate was + /// generated. + #[serde(default, skip_serializing_if = "Option::is_none")] rationale: Option, - /// Current application status of this PatchSet. - /// - /// Tracks whether the diff has been applied to the repository: - /// - /// - **`Proposed`** (initial): The diff has been generated but not - /// yet committed. The orchestrator or a human reviewer can inspect - /// the artifact, run validation, and decide whether to apply. - /// - **`Applied`**: The diff has been committed to the repository. - /// Once applied, the PatchSet is immutable — further changes - /// require a new PatchSet in the same Run. - /// - **`Rejected`**: The diff was rejected by automated validation - /// (e.g. tests failed) or by a human reviewer. The agent may - /// generate a new PatchSet appended to `Run.patchsets` to retry. - /// - /// Transitions: `Proposed → Applied` or `Proposed → Rejected`. - /// No other transitions are valid. - apply_status: ApplyStatus, } impl PatchSet { - /// Create a new patchset object. + /// Create a new patchset candidate for the given run. pub fn new(created_by: ActorRef, run: Uuid, commit: impl AsRef) -> Result { let commit = commit.as_ref().parse()?; Ok(Self { header: Header::new(ObjectType::PatchSet, created_by)?, run, + sequence: 0, commit, format: DiffFormat::UnifiedDiff, artifact: None, touched: Vec::new(), rationale: None, - apply_status: ApplyStatus::Proposed, }) } + /// Return the immutable header for this patchset. pub fn header(&self) -> &Header { &self.header } + /// Return the canonical owning run id. pub fn run(&self) -> Uuid { self.run } + /// Return the patchset ordering number within the run. + pub fn sequence(&self) -> u32 { + self.sequence + } + + /// Set the patchset ordering number before persistence. + pub fn set_sequence(&mut self, sequence: u32) { + self.sequence = sequence; + } + + /// Return the associated integrity hash. pub fn commit(&self) -> &IntegrityHash { &self.commit } + /// Return the diff serialization format. pub fn format(&self) -> &DiffFormat { &self.format } + /// Return the diff artifact pointer, if present. pub fn artifact(&self) -> Option<&ArtifactRef> { self.artifact.as_ref() } + /// Return the touched-file summary entries. pub fn touched(&self) -> &[TouchedFile] { &self.touched } + /// Return the human-readable patch rationale, if present. pub fn rationale(&self) -> Option<&str> { self.rationale.as_deref() } - pub fn apply_status(&self) -> &ApplyStatus { - &self.apply_status - } - + /// Set or clear the diff artifact pointer. pub fn set_artifact(&mut self, artifact: Option) { self.artifact = artifact; } + /// Append one touched-file summary entry. pub fn add_touched(&mut self, file: TouchedFile) { self.touched.push(file); } + /// Set or clear the human-readable rationale. pub fn set_rationale(&mut self, rationale: Option) { self.rationale = rationale; } - - pub fn set_apply_status(&mut self, apply_status: ApplyStatus) { - self.apply_status = apply_status; - } } impl fmt::Display for PatchSet { @@ -353,6 +238,10 @@ impl ObjectTrait for PatchSet { mod tests { use super::*; + // Coverage: + // - patchset creation defaults + // - canonical run link, ordering default, and diff-format default + fn test_hash_hex() -> String { IntegrityHash::compute(b"ai-process-test").to_hex() } @@ -367,8 +256,8 @@ mod tests { assert_eq!(patchset.header().object_type(), &ObjectType::PatchSet); assert_eq!(patchset.run(), run); + assert_eq!(patchset.sequence(), 0); assert_eq!(patchset.format(), &DiffFormat::UnifiedDiff); - assert_eq!(patchset.apply_status(), &ApplyStatus::Proposed); assert!(patchset.touched().is_empty()); } } diff --git a/src/internal/object/pipeline.rs b/src/internal/object/pipeline.rs deleted file mode 100644 index ee37c694..00000000 --- a/src/internal/object/pipeline.rs +++ /dev/null @@ -1,591 +0,0 @@ -//! Dynamic Context Pipeline -//! -//! A [`ContextPipeline`] solves the context-forgetting problem in -//! long-running AI tasks. Instead of relying solely on a static -//! [`ContextSnapshot`](super::context::ContextSnapshot) captured at -//! Run start, a ContextPipeline accumulates incremental -//! [`ContextFrame`]s throughout the workflow. -//! -//! # Position in Lifecycle -//! -//! ```text -//! ② Intent created (ContextPipeline created eagerly and retained) -//! │ -//! ▼ -//! ContextPipeline -//! │ -//! ├─ seeded by [`IntentAnalysis`](FrameKind::IntentAnalysis) after spec -//! │ analysis -//! │ -//! ▼ -//! ③ Plan references pipeline + iframes -//! │ StepSummary / CodeChange / ToolCall frames appended -//! ▼ -//! ④ Task/Run -//! │ -//! └─ Replan? → new Plan with updated iframes -//! ``` -//! -//! The pipeline is created when the Intent is created and linked from -//! `Intent.context_pipeline`. After the spec is analyzed, it is seeded -//! with a seed [`IntentAnalysis`](FrameKind::IntentAnalysis) frame, then -//! referenced by `Plan.pipeline` as the context basis for planning. -//! -//! # Relationship to Other Objects -//! -//! ```text -//! Intent ──context_pipeline──→ ContextPipeline -//! │ │ -//! └─plan────────────────────────────→ Plan ──pipeline──→ ContextPipeline -//! │ │ -//! [PlanStep₀, ...] [IntentAnalysis, StepSummary, ...] -//! │ ▲ -//! iframes/oframes ───────────────────────────────┘ -//! ``` -//! -//! | From | Field | To | Notes | -//! |------|-------|----|-------| -//! | Intent | `context_pipeline` | ContextPipeline | 1 | -//! | Plan | `pipeline` | ContextPipeline | 0..1 | -//! | PlanStep | `iframes` | ContextFrame IDs | consumed context | -//! | PlanStep | `oframes` | ContextFrame IDs | produced context | -//! -//! The pipeline itself has no back-references — it is a passive -//! container. [`PlanStep`](super::plan::PlanStep)s own the -//! association via `iframes` and `oframes`. -//! -//! # Eviction -//! -//! When `max_frames > 0` and the limit is exceeded, the oldest -//! evictable frame is removed. `IntentAnalysis` and `Checkpoint` -//! frames are **protected** from eviction — they always survive. -//! -//! # Purpose -//! -//! - **Context Continuity**: Maintains a rolling window of high-value -//! context for the agent's working memory across Plan steps. -//! - **Incremental Updates**: Unlike the static ContextSnapshot, the -//! pipeline grows as work progresses, capturing step summaries, -//! code changes, and tool results. -//! - **Bounded Memory**: `max_frames` + eviction ensures the pipeline -//! doesn't grow unboundedly in long-running workflows. -//! - **Replan Support**: When replanning occurs, a new Plan can -//! reference the same pipeline with an updated `iframes` that -//! includes frames accumulated since the previous plan. - -use std::fmt; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::{ - errors::GitError, - hash::ObjectHash, - internal::object::{ - ObjectTrait, - types::{ActorRef, Header, ObjectType}, - }, -}; - -/// The kind of context captured in a [`ContextFrame`]. -/// -/// Determines how the frame's `summary` and `data` should be -/// interpreted. `IntentAnalysis` and `Checkpoint` are protected -/// from eviction when `max_frames` is exceeded. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum FrameKind { - /// Initial context derived from an Intent's analyzed spec. - /// - /// Created when the AI fills in the `spec` field on an Intent, - /// serving as the foundation for subsequent Plan creation. This - /// is the **seed frame** — always the first frame in a pipeline. - /// **Protected from eviction.** - IntentAnalysis, - /// Summary produced after a [`PlanStep`](super::plan::PlanStep) - /// completes. Captures what the step accomplished so that - /// subsequent steps have context. - StepSummary, - /// Code change digest (e.g. files modified, diff stats). - /// Typically produced alongside a - /// [`PatchSet`](super::patchset::PatchSet). - CodeChange, - /// System or environment state snapshot (e.g. memory usage, - /// disk space, running services). - SystemState, - /// Context captured during error recovery. Records what went - /// wrong and what corrective action was taken, so that subsequent - /// steps don't repeat the same mistakes. - ErrorRecovery, - /// Explicit save-point created by user or system. - /// **Protected from eviction.** Used for long-running workflows - /// where the agent may be paused and resumed. - Checkpoint, - /// Result of an external tool invocation (MCP service, function - /// call, REST API, CLI command, etc.). - /// - /// Intentionally protocol-agnostic: MCP is one transport for - /// tool calls, but agents may also invoke tools via direct - /// function calls, HTTP APIs, or shell commands. Protocol-specific - /// details (server name, tool name, arguments, result preview) - /// belong in `ContextFrame.data`. - ToolCall, - /// Application-defined context type not covered by the variants - /// above. - Other(String), -} - -impl FrameKind { - pub fn as_str(&self) -> &str { - match self { - FrameKind::IntentAnalysis => "intent_analysis", - FrameKind::StepSummary => "step_summary", - FrameKind::CodeChange => "code_change", - FrameKind::SystemState => "system_state", - FrameKind::ErrorRecovery => "error_recovery", - FrameKind::Checkpoint => "checkpoint", - FrameKind::ToolCall => "tool_call", - FrameKind::Other(s) => s.as_str(), - } - } -} - -impl fmt::Display for FrameKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// A single context frame — a compact summary captured at a point in -/// time during the AI workflow. -/// -/// Frames are **passive data records**. They carry no back-references -/// to the [`PlanStep`](super::plan::PlanStep) that consumed or produced -/// them; that association is tracked on the step side via `iframes` -/// and `oframes`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ContextFrame { - /// Stable monotonic identifier for this frame. - /// - /// Assigned by [`ContextPipeline::push_frame`] from a monotonic - /// counter (`next_frame_id`). Unlike Vec indices, frame IDs remain - /// stable across eviction — [`PlanStep`](super::plan::PlanStep)s - /// reference frames by ID via `iframes` and `oframes`. - frame_id: u64, - /// The kind of context this frame captures. - /// - /// Determines how `summary` and `data` should be interpreted. - /// Also affects eviction: `IntentAnalysis` and `Checkpoint` - /// frames are protected. - kind: FrameKind, - /// Compact human-readable summary of this frame's content. - /// - /// Should be concise (a few sentences). For example: - /// - IntentAnalysis: "Add pagination to GET /users with limit/offset" - /// - StepSummary: "Refactored auth module, 3 files changed" - /// - CodeChange: "Modified src/api.rs (+42 -15)" - summary: String, - /// Structured data payload for machine consumption. - /// - /// Schema depends on `kind`. For example: - /// - CodeChange: `{"files": ["src/api.rs"], "insertions": 42, "deletions": 15}` - /// - ToolCall: `{"tool": "search", "args": {...}, "result_preview": "..."}` - /// - /// `None` when the `summary` is sufficient and no structured - /// data is needed. - #[serde(default, skip_serializing_if = "Option::is_none")] - data: Option, - /// UTC timestamp of when this frame was created. - /// - /// Automatically set to `Utc::now()` by [`ContextFrame::new`]. - /// Frames within a pipeline are chronologically ordered. - created_at: DateTime, - /// Estimated token count for context-window budgeting. - /// - /// Used by the orchestrator to decide how many frames fit in - /// the LLM's context window. `None` when the estimate hasn't - /// been computed. See - /// [`ContextPipeline::total_token_estimate`] for aggregation. - #[serde(default, skip_serializing_if = "Option::is_none")] - token_estimate: Option, -} - -impl ContextFrame { - /// Create a new frame with the given kind and summary. - /// - /// `frame_id` is typically assigned by - /// [`ContextPipeline::push_frame`]; callers building frames - /// manually can pass any unique monotonic value. - pub fn new(frame_id: u64, kind: FrameKind, summary: impl Into) -> Self { - Self { - frame_id, - kind, - summary: summary.into(), - data: None, - created_at: Utc::now(), - token_estimate: None, - } - } - - /// Returns this frame's stable ID. - pub fn frame_id(&self) -> u64 { - self.frame_id - } - - pub fn kind(&self) -> &FrameKind { - &self.kind - } - - pub fn summary(&self) -> &str { - &self.summary - } - - pub fn data(&self) -> Option<&serde_json::Value> { - self.data.as_ref() - } - - pub fn created_at(&self) -> DateTime { - self.created_at - } - - pub fn token_estimate(&self) -> Option { - self.token_estimate - } - - pub fn set_data(&mut self, data: Option) { - self.data = data; - } - - pub fn set_token_estimate(&mut self, token_estimate: Option) { - self.token_estimate = token_estimate; - } -} - -/// A dynamic context pipeline that accumulates -/// [`ContextFrame`]s throughout an AI workflow. -/// -/// Created during [`Intent`](super::intent::Intent) creation and -/// retained for context continuity across planning and execution. -/// After the intent is analyzed (`spec` becomes available), it is -/// seeded with an [`IntentAnalysis`](FrameKind::IntentAnalysis) frame. -/// The [`Plan`](super::plan::Plan) then references this pipeline via -/// `pipeline` as its context basis. See module documentation for -/// lifecycle position, eviction rules, and purpose. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ContextPipeline { - /// Common header (object ID, type, timestamps, creator, etc.). - #[serde(flatten)] - header: Header, - /// Chronologically ordered context frames. - /// - /// New frames are appended via [`push_frame`](ContextPipeline::push_frame). - /// If `max_frames > 0` and the limit is exceeded, the oldest - /// evictable frame is removed (see eviction rules in module docs). - /// [`PlanStep`](super::plan::PlanStep)s reference frames by stable - /// `frame_id` via `iframes` and `oframes`. Frame IDs are monotonic - /// and survive eviction (indices do not). - #[serde(default)] - frames: Vec, - /// Monotonic counter for assigning stable [`ContextFrame::frame_id`]s. - /// - /// Incremented by [`push_frame`](ContextPipeline::push_frame) each - /// time a frame is added. Never decremented, even after eviction. - #[serde(default)] - next_frame_id: u64, - /// Maximum number of active frames before eviction kicks in. - /// - /// `0` means unlimited (no eviction). When the frame count - /// exceeds this limit, the oldest non-protected frame is removed. - /// `IntentAnalysis` and `Checkpoint` frames are protected and - /// never evicted. - #[serde(default)] - max_frames: u32, - /// Aggregated human-readable summary across all frames. - /// - /// Maintained by the orchestrator as a high-level overview of - /// the pipeline's accumulated context. Useful for quickly - /// understanding the overall progress without reading individual - /// frames. `None` when no summary has been set. - #[serde(default, skip_serializing_if = "Option::is_none")] - global_summary: Option, -} - -impl ContextPipeline { - /// Create a new empty pipeline. - /// - /// The returned pipeline has no frames initially. - /// It is expected to be seeded later after an - /// [`Intent`](super::intent::Intent) is analyzed and then - /// referenced by a [`Plan`](super::plan::Plan). - pub fn new(created_by: ActorRef) -> Result { - Ok(Self { - header: Header::new(ObjectType::ContextPipeline, created_by)?, - frames: Vec::new(), - next_frame_id: 0, - max_frames: 0, - global_summary: None, - }) - } - - pub fn header(&self) -> &Header { - &self.header - } - - /// Returns all frames in chronological order. - pub fn frames(&self) -> &[ContextFrame] { - &self.frames - } - - pub fn max_frames(&self) -> u32 { - self.max_frames - } - - pub fn global_summary(&self) -> Option<&str> { - self.global_summary.as_deref() - } - - pub fn set_max_frames(&mut self, max_frames: u32) { - self.max_frames = max_frames; - } - - pub fn set_global_summary(&mut self, summary: Option) { - self.global_summary = summary; - } - - /// Append a frame, assigning it a stable `frame_id`. - /// - /// Returns the assigned `frame_id`. If `max_frames > 0` and the - /// limit is exceeded, the oldest evictable (non-IntentAnalysis, - /// non-Checkpoint) frame is removed to make room. Eviction does - /// not affect the IDs of surviving frames. - pub fn push_frame(&mut self, kind: FrameKind, summary: impl Into) -> u64 { - let frame = ContextFrame::new(self.next_frame_id, kind, summary); - self.push_frame_raw(frame) - } - - /// Append a pre-built frame, overwriting its `frame_id` with the - /// next monotonic ID. Returns the assigned `frame_id`. - /// - /// Use this when you need to set properties (e.g. `token_estimate`, - /// `data`) on the frame before pushing. - pub fn push_frame_raw(&mut self, mut frame: ContextFrame) -> u64 { - let id = self.next_frame_id; - self.next_frame_id += 1; - frame.frame_id = id; - self.frames.push(frame); - self.evict_if_needed(); - id - } - - /// Look up a frame by its stable `frame_id`. - /// - /// Returns `None` if the frame has been evicted or the ID is invalid. - pub fn frame_by_id(&self, frame_id: u64) -> Option<&ContextFrame> { - self.frames.iter().find(|f| f.frame_id == frame_id) - } - - /// Returns frames that contribute to the active context window - /// (i.e. all current frames after any eviction has been applied). - pub fn active_frames(&self) -> &[ContextFrame] { - &self.frames - } - - /// Total estimated tokens across all frames. - pub fn total_token_estimate(&self) -> u64 { - self.frames.iter().filter_map(|f| f.token_estimate).sum() - } - - /// Evict the oldest evictable frame if over the limit. - /// - /// `IntentAnalysis` and `Checkpoint` frames are protected from eviction. - fn evict_if_needed(&mut self) { - if self.max_frames == 0 { - return; - } - while self.frames.len() > self.max_frames as usize { - // Find the first evictable frame (not IntentAnalysis or Checkpoint) - if let Some(pos) = self.frames.iter().position(|f| { - f.kind != FrameKind::Checkpoint && f.kind != FrameKind::IntentAnalysis - }) { - self.frames.remove(pos); - } else { - // All frames are protected — nothing to evict - break; - } - } - } -} - -impl fmt::Display for ContextPipeline { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ContextPipeline: {}", self.header.object_id()) - } -} - -impl ObjectTrait for ContextPipeline { - fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result - where - Self: Sized, - { - serde_json::from_slice(data) - .map_err(|e| GitError::InvalidContextPipelineObject(e.to_string())) - } - - fn get_type(&self) -> ObjectType { - ObjectType::ContextPipeline - } - - fn get_size(&self) -> usize { - match serde_json::to_vec(self) { - Ok(v) => v.len(), - Err(e) => { - tracing::warn!("failed to compute ContextPipeline size: {}", e); - 0 - } - } - } - - fn to_data(&self) -> Result, GitError> { - serde_json::to_vec(self).map_err(|e| GitError::InvalidContextPipelineObject(e.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_pipeline() -> ContextPipeline { - let actor = ActorRef::agent("orchestrator").expect("actor"); - ContextPipeline::new(actor).expect("pipeline") - } - - #[test] - fn test_pipeline_creation() { - let pipeline = make_pipeline(); - - assert_eq!( - pipeline.header().object_type(), - &ObjectType::ContextPipeline - ); - assert!(pipeline.frames().is_empty()); - assert_eq!(pipeline.max_frames(), 0); - assert!(pipeline.global_summary().is_none()); - } - - #[test] - fn test_push_and_retrieve_frames() { - let mut pipeline = make_pipeline(); - - let mut f1 = ContextFrame::new(0, FrameKind::StepSummary, "Completed auth refactor"); - f1.set_token_estimate(Some(200)); - let id0 = pipeline.push_frame_raw(f1); - - let id1 = pipeline.push_frame(FrameKind::CodeChange, "Modified 3 files, +120 -45 lines"); - - let mut f3 = ContextFrame::new(0, FrameKind::Checkpoint, "User save-point"); - f3.set_data(Some(serde_json::json!({"key": "value"}))); - let id2 = pipeline.push_frame_raw(f3); - - assert_eq!(pipeline.frames().len(), 3); - assert_eq!(id0, 0); - assert_eq!(id1, 1); - assert_eq!(id2, 2); - assert_eq!( - pipeline.frame_by_id(0).unwrap().kind(), - &FrameKind::StepSummary - ); - assert_eq!( - pipeline.frame_by_id(1).unwrap().kind(), - &FrameKind::CodeChange - ); - assert_eq!( - pipeline.frame_by_id(2).unwrap().kind(), - &FrameKind::Checkpoint - ); - assert!(pipeline.frame_by_id(2).unwrap().data().is_some()); - } - - #[test] - fn test_max_frames_eviction() { - let mut pipeline = make_pipeline(); - pipeline.set_max_frames(3); - - // Push a checkpoint (should survive eviction) - let cp_id = pipeline.push_frame(FrameKind::Checkpoint, "save-point"); - // Push regular frames - let s1_id = pipeline.push_frame(FrameKind::StepSummary, "step 1"); - pipeline.push_frame(FrameKind::StepSummary, "step 2"); - assert_eq!(pipeline.frames().len(), 3); - - // This push exceeds max_frames → oldest non-Checkpoint ("step 1") is evicted - pipeline.push_frame(FrameKind::CodeChange, "code change"); - assert_eq!(pipeline.frames().len(), 3); - - // Checkpoint survived, "step 1" was evicted - assert!(pipeline.frame_by_id(cp_id).is_some()); - assert!(pipeline.frame_by_id(s1_id).is_none()); // evicted - assert_eq!(pipeline.frames()[0].kind(), &FrameKind::Checkpoint); - assert_eq!(pipeline.frames()[1].summary(), "step 2"); - assert_eq!(pipeline.frames()[2].summary(), "code change"); - } - - #[test] - fn test_total_token_estimate() { - let mut pipeline = make_pipeline(); - - let mut f1 = ContextFrame::new(0, FrameKind::StepSummary, "s1"); - f1.set_token_estimate(Some(100)); - pipeline.push_frame_raw(f1); - - let mut f2 = ContextFrame::new(0, FrameKind::StepSummary, "s2"); - f2.set_token_estimate(Some(250)); - pipeline.push_frame_raw(f2); - - // Frame without token estimate - pipeline.push_frame(FrameKind::Checkpoint, "cp"); - - assert_eq!(pipeline.total_token_estimate(), 350); - } - - #[test] - fn test_serialization_roundtrip() { - let mut pipeline = make_pipeline(); - pipeline.set_global_summary(Some("Overall progress summary".to_string())); - - let mut frame = ContextFrame::new(0, FrameKind::StepSummary, "did stuff"); - frame.set_token_estimate(Some(150)); - frame.set_data(Some(serde_json::json!({"files": ["a.rs", "b.rs"]}))); - pipeline.push_frame_raw(frame); - - let data = pipeline.to_data().expect("serialize"); - let restored = - ContextPipeline::from_bytes(&data, ObjectHash::default()).expect("deserialize"); - - assert_eq!(restored.frames().len(), 1); - assert_eq!(restored.frames()[0].frame_id(), 0); - assert_eq!(restored.frames()[0].summary(), "did stuff"); - assert_eq!(restored.frames()[0].token_estimate(), Some(150)); - assert_eq!(restored.global_summary(), Some("Overall progress summary")); - } - - #[test] - fn test_intent_analysis_frame_survives_eviction() { - let mut pipeline = make_pipeline(); - pipeline.set_max_frames(2); - - // Seed with IntentAnalysis (protected) - let ia_id = pipeline.push_frame(FrameKind::IntentAnalysis, "AI analysis of user intent"); - let s1_id = pipeline.push_frame(FrameKind::StepSummary, "step 1"); - assert_eq!(pipeline.frames().len(), 2); - - // Adding another frame should evict "step 1", not IntentAnalysis - pipeline.push_frame(FrameKind::CodeChange, "code change"); - assert_eq!(pipeline.frames().len(), 2); - assert!(pipeline.frame_by_id(ia_id).is_some()); - assert!(pipeline.frame_by_id(s1_id).is_none()); // evicted - assert_eq!(pipeline.frames()[0].kind(), &FrameKind::IntentAnalysis); - assert_eq!(pipeline.frames()[1].summary(), "code change"); - } -} diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index 2994f40c..abacdad8 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -1,136 +1,35 @@ -//! AI Plan Definition +//! AI Plan snapshot. //! -//! A [`Plan`] is a sequence of [`PlanStep`]s derived from an -//! [`Intent`](super::intent::Intent)'s analyzed spec. It defines -//! *what* to do — the strategy and decomposition — while -//! [`Run`](super::run::Run) handles *how* to execute it. The Plan is -//! step ③ in the end-to-end flow described in [`mod.rs`](super). +//! `Plan` stores one immutable planning revision for an `Intent`. It +//! records the chosen strategy, the stable step structure, and the +//! frozen planning context used to derive that strategy. //! -//! # Position in Lifecycle +//! # How to use this object //! -//! ```text -//! ② Intent (Active, spec present) -//! │ -//! ├─ seeds ContextPipeline with IntentAnalysis -//! │ -//! └─→ ③ Plan (pipeline, iframes, steps) -//! │ -//! ├─ PlanStep₀ (inline) -//! ├─ PlanStep₁ ──task──▶ sub-Task -//! └─ PlanStep₂ (inline) -//! │ -//! ▼ -//! ④ Task (execution target) -//! ``` +//! - Create a base `Plan` after analyzing an `Intent`. +//! - Create a new `Plan` revision when replanning is needed. +//! - Use multi-parent revisions to represent merged planning branches. +//! - Freeze planning-time context through `context_frames`. //! -//! # Revision Chain +//! # How it works with other objects //! -//! When the agent encounters obstacles or learns new information, it -//! creates a revised Plan via [`new_revision`](Plan::new_revision). -//! Each revision links back to its predecessor via `previous`, forming -//! a singly-linked revision chain. The [`Intent`](super::intent::Intent) -//! always points to the **latest** revision: +//! - `Intent` is the canonical owner via `Plan.intent`. +//! - `Task.origin_step_id` points back to the logical step that spawned +//! delegated work. +//! - `PlanStepEvent` records runtime step status, produced context, +//! outputs, and spawned tasks. +//! - `ContextFrame` stores incremental context facts referenced by the +//! plan or step events. //! -//! ```text -//! Intent.plan ──▶ Plan_v3 (latest) -//! │ previous -//! ▼ -//! Plan_v2 -//! │ previous -//! ▼ -//! Plan_v1 (original, previous = None) -//! ``` +//! # How Libra should call it //! -//! Each [`Run`](super::run::Run) records the specific Plan version it -//! executed via a **snapshot reference** (`Run.plan`), which never -//! changes after creation. -//! -//! # Context Range -//! -//! A Plan references a [`ContextPipeline`](super::pipeline::ContextPipeline) -//! via `pipeline` and records stable `frame_id`s in `iframes` for the -//! plan-generation context. This enables retrospective analysis: -//! given the context frames the agent saw, was the plan a reasonable -//! decomposition? -//! -//! ```text -//! ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] -//! │ │ -//! iframes = [0, 1] -//! ``` -//! -//! When replanning occurs, a new Plan is created with an updated frame-id -//! basis that includes newly appended context frames since the previous plan. -//! -//! # Steps -//! -//! Each [`PlanStep`] has a `description` (what to do) and a status -//! history (`statuses`) tracking every lifecycle transition with -//! timestamps and optional reasons, following the same append-only -//! pattern used by [`Intent`](super::intent::Intent). -//! -//! ## Step Context Tracking -//! -//! Each step tracks its relationship to pipeline frames via two -//! ID vectors: -//! -//! - `iframes` — stable `frame_id`s of frames the step **consumed** -//! as context. -//! - `oframes` — stable `frame_id`s of frames the step **produced** -//! (e.g. `StepSummary`, `CodeChange`). -//! -//! Frame IDs are monotonic integers assigned by -//! [`ContextPipeline::push_frame`](super::pipeline::ContextPipeline::push_frame). -//! Unlike Vec indices, IDs survive eviction — a step's `iframes` -//! remain valid even after older frames are evicted from the pipeline. -//! Look up frames via -//! [`ContextPipeline::frame_by_id`](super::pipeline::ContextPipeline::frame_by_id). -//! -//! All context association is owned by the step side; -//! [`ContextFrame`](super::pipeline::ContextFrame) itself is a passive -//! data record with no back-references. -//! -//! ```text -//! ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅] -//! │ │ ▲ -//! ╰────╯ │ -//! iframes=[0,1] oframes=[4] -//! ╰── Step₀ ──╯ -//! ``` -//! -//! ## Recursive Decomposition -//! -//! A step can optionally spawn a sub-[`Task`](super::task::Task) via -//! its `task` field. When set, the step delegates execution to an -//! independent Task with its own Run / Intent / Plan lifecycle, -//! enabling recursive work breakdown: -//! -//! ```text -//! Plan -//! ├─ Step₀ (inline — executed by current Run) -//! ├─ Step₁ ──task──▶ Task₁ -//! │ └─ Run → Plan -//! │ ├─ Step₁₋₀ -//! │ └─ Step₁₋₁ -//! └─ Step₂ (inline) -//! ``` -//! -//! # Purpose -//! -//! - **Decomposition**: Breaks a complex Intent into manageable, -//! ordered steps that an agent can execute sequentially. -//! - **Context Scoping**: `pipeline` + `iframes` record exactly what -//! context the Plan was derived from. Step-level `iframes`/`oframes` -//! track fine-grained context flow. -//! - **Versioning**: The `previous` revision chain preserves the full -//! planning history, enabling comparison of strategies across -//! attempts. -//! - **Recursive Delegation**: Steps can spawn sub-Tasks for complex -//! sub-problems, enabling divide-and-conquer workflows. +//! Libra should write a new `Plan` whenever the strategy itself changes. +//! Libra should not mutate a stored plan to reflect execution progress; +//! instead it should append `PlanStepEvent` objects and keep the active +//! plan head in scheduler state. use std::fmt; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -143,377 +42,185 @@ use crate::{ }, }; -/// Lifecycle status of a [`PlanStep`]. -/// -/// Valid transitions: -/// ```text -/// Pending ──▶ Progressing ──▶ Completed -/// │ │ -/// ├─────────────┴──▶ Failed -/// └──────────────────▶ Skipped -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum StepStatus { - /// Step is waiting to be executed. Initial state. - Pending, - /// Step is currently being executed by the agent. - Progressing, - /// Step finished successfully. Outputs and `oframes` should be set. - Completed, - /// Step encountered an unrecoverable error. A reason should be - /// recorded in the [`StepStatusEntry`] that carries this status. - Failed, - /// Step was skipped (e.g. no longer necessary after replanning, - /// or pre-condition not met). Not an error — the Plan continues. - Skipped, -} - -impl StepStatus { - pub fn as_str(&self) -> &'static str { - match self { - StepStatus::Pending => "pending", - StepStatus::Progressing => "progressing", - StepStatus::Completed => "completed", - StepStatus::Failed => "failed", - StepStatus::Skipped => "skipped", - } - } -} - -impl fmt::Display for StepStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// A single entry in a step's status history. -/// -/// Mirrors [`StatusEntry`](super::intent::StatusEntry) in Intent. -/// Each transition appends a new entry; entries are never removed -/// or mutated, forming an append-only audit log. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct StepStatusEntry { - /// The [`StepStatus`] that was entered by this transition. - status: StepStatus, - /// UTC timestamp of when this transition occurred. - changed_at: DateTime, - /// Optional human-readable reason for the transition. - /// - /// Recommended for `Failed` (error details) and `Skipped` - /// (why the step was deemed unnecessary). - #[serde(default, skip_serializing_if = "Option::is_none")] - reason: Option, -} - -impl StepStatusEntry { - pub fn new(status: StepStatus, reason: Option) -> Self { - Self { - status, - changed_at: Utc::now(), - reason, - } - } - - pub fn status(&self) -> &StepStatus { - &self.status - } - - pub fn changed_at(&self) -> DateTime { - self.changed_at - } - - pub fn reason(&self) -> Option<&str> { - self.reason.as_deref() - } -} - -/// A single step within a [`Plan`], describing one unit of work. +/// Immutable step definition inside a `Plan`. /// -/// Steps are executed in order by the agent. Each step can be either -/// **inline** (executed directly by the current Run) or **delegated** -/// (spawning a sub-Task via the `task` field). See module documentation -/// for context tracking and recursive decomposition details. +/// `PlanStep` describes what a logical step is supposed to do. Runtime +/// facts for that step belong to `PlanStepEvent`, not to this struct. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct PlanStep { - /// Human-readable description of what this step should accomplish. + /// Stable logical step identity across Plan revisions. /// - /// Human-readable description of the step. + /// `step_id` is the cross-revision identity for the logical step. + /// One concrete stored step snapshot is identified by the pair + /// `(Plan.header.object_id(), step_id)`. + step_id: Uuid, + /// Human-readable description of what this step is supposed to do. description: String, - /// Expected inputs for this step as a JSON value. - /// - /// Schema is step-dependent. For example, a "refactor" step might - /// list `{"files": ["src/auth.rs"]}`. `None` when the step has no - /// explicit inputs (e.g. a discovery step). + /// Optional structured inputs expected by this step definition. #[serde(default, skip_serializing_if = "Option::is_none")] inputs: Option, - /// Expected outputs for this step as a JSON value. - /// - /// Populated after execution completes. For example, - /// `{"files_modified": ["src/auth.rs", "src/lib.rs"]}`. `None` - /// while the step is `Pending` or `Progressing`, or when the step - /// produces no structured output. - #[serde(default, skip_serializing_if = "Option::is_none")] - outputs: Option, - /// Validation criteria for this step as a JSON value. - /// - /// Defines what must pass for the step to be considered successful. - /// For example, `{"tests": "cargo test", "lint": "cargo clippy"}`. - /// `None` when no explicit checks are defined. + /// Optional structured checks or completion criteria for this step. #[serde(default, skip_serializing_if = "Option::is_none")] checks: Option, - /// Indices into the pipeline's frame list that this step **consumed** - /// as input context. - /// - /// Set when the step begins execution. Values are stable - /// [`ContextFrame::frame_id`](super::pipeline::ContextFrame::frame_id)s - /// (not Vec indices), so they survive pipeline eviction. Look up - /// frames via - /// [`ContextPipeline::frame_by_id`](super::pipeline::ContextPipeline::frame_by_id). - /// Empty when no prior context was consumed. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - iframes: Vec, - /// Stable frame IDs in the pipeline that this step **produced** - /// as output context. - /// - /// Set after the step completes. The step pushes new frames (e.g. - /// `StepSummary`, `CodeChange`) to the pipeline and records the - /// returned `frame_id`s here. Empty when the step produced no - /// context frames. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - oframes: Vec, - /// Optional sub-[`Task`](super::task::Task) spawned for this step. - /// - /// When set, the step delegates execution to an independent Task - /// with its own Run / Intent / Plan lifecycle (recursive - /// decomposition). The sub-Task's `parent` field points back to - /// the owning Task. When `None`, the step is executed inline by - /// the current Run. - #[serde(default, skip_serializing_if = "Option::is_none")] - task: Option, - /// Append-only chronological history of status transitions. - /// - /// Initialized with a single `Pending` entry in [`PlanStep::new`]. - /// This field is required when deserializing. Omit it only in - /// data produced by `PlanStep::new` during in-memory construction. - statuses: Vec, } impl PlanStep { + /// Create a new logical plan step with a fresh stable step id. pub fn new(description: impl Into) -> Self { Self { + step_id: Uuid::now_v7(), description: description.into(), inputs: None, - outputs: None, checks: None, - iframes: Vec::new(), - oframes: Vec::new(), - task: None, - statuses: vec![StepStatusEntry::new(StepStatus::Pending, None)], } } + /// Return the stable logical step id. + pub fn step_id(&self) -> Uuid { + self.step_id + } + + /// Return the human-readable step description. pub fn description(&self) -> &str { &self.description } + /// Return the structured input contract for this step, if present. pub fn inputs(&self) -> Option<&serde_json::Value> { self.inputs.as_ref() } - pub fn outputs(&self) -> Option<&serde_json::Value> { - self.outputs.as_ref() - } - + /// Return the structured checks for this step, if present. pub fn checks(&self) -> Option<&serde_json::Value> { self.checks.as_ref() } - /// Returns the current step status (last entry in the history). - /// - /// Returns `None` only if `statuses` is empty, which should not - /// happen for objects created via [`PlanStep::new`] (seeds with - /// `Pending`), but may occur for malformed deserialized data. - pub fn status(&self) -> Option<&StepStatus> { - self.statuses.last().map(|e| &e.status) - } - - /// Returns the full chronological status history. - pub fn statuses(&self) -> &[StepStatusEntry] { - &self.statuses - } - - /// Transitions the step to a new status, appending to the history. - pub fn set_status(&mut self, status: StepStatus) { - self.statuses.push(StepStatusEntry::new(status, None)); - } - - /// Transitions the step to a new status with a reason. - pub fn set_status_with_reason(&mut self, status: StepStatus, reason: impl Into) { - self.statuses - .push(StepStatusEntry::new(status, Some(reason.into()))); - } - + /// Set or clear the structured inputs for this in-memory step. pub fn set_inputs(&mut self, inputs: Option) { self.inputs = inputs; } - pub fn set_outputs(&mut self, outputs: Option) { - self.outputs = outputs; - } - + /// Set or clear the structured checks for this in-memory step. pub fn set_checks(&mut self, checks: Option) { self.checks = checks; } - - /// Returns the pipeline frame IDs this step consumed as input context. - pub fn iframes(&self) -> &[u64] { - &self.iframes - } - - /// Returns the pipeline frame IDs this step produced as output context. - pub fn oframes(&self) -> &[u64] { - &self.oframes - } - - /// Records the pipeline frame IDs this step consumed as input. - pub fn set_iframes(&mut self, ids: Vec) { - self.iframes = ids; - } - - /// Records the pipeline frame IDs this step produced as output. - pub fn set_oframes(&mut self, ids: Vec) { - self.oframes = ids; - } - - /// Returns the sub-Task ID if this step has been elevated to an - /// independent Task. - pub fn task(&self) -> Option { - self.task - } - - /// Elevates this step to an independent sub-Task, or clears the - /// association by passing `None`. - pub fn set_task(&mut self, task: Option) { - self.task = task; - } } -/// A sequence of steps derived from an Intent's analyzed spec. +/// Immutable planning revision for one `Intent`. /// -/// A Plan is a pure planning artifact — it defines *what* to do, not -/// *how* to execute. It is step ③ in the end-to-end flow. A -/// [`Run`](super::run::Run) then references the Plan via `plan` to -/// execute it. See module documentation for revision chain, context -/// range, and recursive decomposition details. +/// A `Plan` may form a DAG through `parents`, allowing Libra to model +/// linear replanning as well as multi-branch plan merges without losing +/// history. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Plan { - /// Common header (object ID, type, timestamps, creator, etc.). + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. #[serde(flatten)] header: Header, - /// Link to the predecessor Plan in the revision chain. - /// - /// Forms a singly-linked list from newest to oldest: each revised - /// Plan points to the Plan it supersedes. `None` for the initial - /// (first) Plan. The [`Intent`](super::intent::Intent) always - /// points to the latest revision via `Intent.plan`. - /// - /// Use [`new_revision`](Plan::new_revision) to create a successor - /// that automatically sets this field. - #[serde(default, skip_serializing_if = "Option::is_none")] - previous: Option, - /// The [`ContextPipeline`](super::pipeline::ContextPipeline) that - /// served as the context basis for this Plan. - /// - /// Set when the Plan is created from an Intent's analyzed spec. - /// The pipeline contains the [`ContextFrame`](super::pipeline::ContextFrame)s - /// that informed this Plan's decomposition. `None` when no pipeline - /// was used (e.g. a manually created Plan). - #[serde(default, skip_serializing_if = "Option::is_none")] - pipeline: Option, - /// Stable frame IDs from the context pipeline that this plan was - /// derived from. - /// - /// Equivalent to a captured context frame vector, using - /// `ContextFrame::frame_id` (stable under eviction), so replay and - /// audit remain valid even if early frames are removed from the - /// visible window. + /// Canonical owning intent for this planning revision. + intent: Uuid, + /// Parent plan revisions from which this plan directly derives. #[serde(default, skip_serializing_if = "Vec::is_empty")] - iframes: Vec, - /// Ordered sequence of steps to execute. - /// - /// Steps are executed in order (index 0 first). Each step can be - /// inline (executed by the current Run) or delegated (spawning a - /// sub-Task via `PlanStep.task`). Empty when the Plan has just been - /// created and steps haven't been added yet. + parents: Vec, + /// Immutable context-frame snapshot used for plan derivation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + context_frames: Vec, + /// Immutable step structure chosen for this plan revision. #[serde(default)] steps: Vec, } impl Plan { - /// Create a new initial plan (no predecessor). - pub fn new(created_by: ActorRef) -> Result { + /// Create a new root plan revision for the given intent. + pub fn new(created_by: ActorRef, intent: Uuid) -> Result { Ok(Self { header: Header::new(ObjectType::Plan, created_by)?, - previous: None, - pipeline: None, - iframes: Vec::new(), + intent, + parents: Vec::new(), + context_frames: Vec::new(), steps: Vec::new(), }) } - /// Create a revised plan that links back to this one. + /// Create a new child plan revision from this plan as the only + /// parent. pub fn new_revision(&self, created_by: ActorRef) -> Result { - Ok(Self { - header: Header::new(ObjectType::Plan, created_by)?, - previous: Some(self.header.object_id()), - pipeline: None, - iframes: Vec::new(), - steps: Vec::new(), - }) + Self::new_revision_chain(created_by, &[self]) + } + + /// Create a new plan revision from a single explicit parent. + pub fn new_revision_from(created_by: ActorRef, parent: &Self) -> Result { + Self::new_revision_chain(created_by, &[parent]) + } + + /// Create a new plan revision from multiple parents. + /// + /// All parents must belong to the same intent. + pub fn new_revision_chain(created_by: ActorRef, parents: &[&Self]) -> Result { + let first_parent = parents + .first() + .ok_or_else(|| "plan revision chain requires at least one parent".to_string())?; + let mut plan = Self::new(created_by, first_parent.intent)?; + for parent in parents { + if parent.intent != first_parent.intent { + return Err("plan parents must belong to the same intent".to_string()); + } + plan.add_parent(parent.header.object_id()); + } + Ok(plan) } + /// Return the immutable header for this plan revision. pub fn header(&self) -> &Header { &self.header } - pub fn previous(&self) -> Option { - self.previous + /// Return the canonical owning intent id. + pub fn intent(&self) -> Uuid { + self.intent } - pub fn steps(&self) -> &[PlanStep] { - &self.steps + /// Return the direct parent plan ids. + pub fn parents(&self) -> &[Uuid] { + &self.parents } - pub fn add_step(&mut self, step: PlanStep) { - self.steps.push(step); + /// Add one parent link if it is not already present and is not self. + pub fn add_parent(&mut self, parent_id: Uuid) { + if parent_id == self.header.object_id() { + return; + } + if !self.parents.contains(&parent_id) { + self.parents.push(parent_id); + } } - pub fn set_previous(&mut self, previous: Option) { - self.previous = previous; + /// Replace the parent set for this in-memory plan revision. + pub fn set_parents(&mut self, parents: Vec) { + self.parents = parents; } - /// Returns the pipeline that served as context basis for this plan. - pub fn pipeline(&self) -> Option { - self.pipeline + /// Return the planning-time context frame ids frozen into this plan. + pub fn context_frames(&self) -> &[Uuid] { + &self.context_frames } - /// Sets the pipeline that serves as the context basis for this plan. - pub fn set_pipeline(&mut self, pipeline: Option) { - self.pipeline = pipeline; + /// Replace the planning-time context frame set for this in-memory + /// plan revision. + pub fn set_context_frames(&mut self, context_frames: Vec) { + self.context_frames = context_frames; } - /// Returns the stable frame IDs this plan was derived from. - pub fn iframes(&self) -> &[u64] { - &self.iframes + /// Return the immutable step definitions stored in this plan. + pub fn steps(&self) -> &[PlanStep] { + &self.steps } - /// Sets the stable frame IDs this plan was derived from. - pub fn set_iframes(&mut self, iframes: Vec) { - self.iframes = iframes; + /// Append one logical step definition to this in-memory plan. + pub fn add_step(&mut self, step: PlanStep) { + self.steps.push(step); } } @@ -556,83 +263,70 @@ mod tests { use super::*; + // Coverage: + // - single-parent and multi-parent plan revision DAG behaviour + // - parent deduplication and self-link rejection + // - mixed-intent merge rejection + // - plan-level frozen context frame assignment + // - serde compatibility for step descriptions + #[test] - fn test_plan_revision_chain() { + fn test_plan_revision_graph() { let actor = ActorRef::human("jackie").expect("actor"); - - let plan_v1 = Plan::new(actor.clone()).expect("plan"); + let intent_id = Uuid::from_u128(0x10); + let plan_v1 = Plan::new(actor.clone(), intent_id).expect("plan"); let plan_v2 = plan_v1.new_revision(actor.clone()).expect("plan"); - let plan_v3 = plan_v2.new_revision(actor.clone()).expect("plan"); + let plan_v2b = Plan::new_revision_from(actor.clone(), &plan_v1).expect("plan"); + let plan_v3 = Plan::new_revision_chain(actor, &[&plan_v2, &plan_v2b]).expect("plan"); - // Initial plan has no predecessor - assert!(plan_v1.previous().is_none()); - - // Revision chain links back correctly - assert_eq!(plan_v2.previous(), Some(plan_v1.header().object_id())); - assert_eq!(plan_v3.previous(), Some(plan_v2.header().object_id())); - - // Chronological ordering via header timestamps - assert!(plan_v2.header().created_at() >= plan_v1.header().created_at()); - assert!(plan_v3.header().created_at() >= plan_v2.header().created_at()); + assert!(plan_v1.parents().is_empty()); + assert_eq!(plan_v2.parents(), &[plan_v1.header().object_id()]); + assert_eq!( + plan_v3.parents(), + &[plan_v2.header().object_id(), plan_v2b.header().object_id()] + ); + assert_eq!(plan_v3.intent(), intent_id); } #[test] - fn test_plan_pipeline_and_iframes() { + fn test_plan_add_parent_dedupes_and_ignores_self() { let actor = ActorRef::human("jackie").expect("actor"); - let mut plan = Plan::new(actor).expect("plan"); - - assert!(plan.pipeline().is_none()); - assert!(plan.iframes().is_empty()); + let mut plan = Plan::new(actor, Uuid::from_u128(0x11)).expect("plan"); + let parent_a = Uuid::from_u128(0x41); + let parent_b = Uuid::from_u128(0x42); - let pipeline_id = Uuid::from_u128(0x42); - plan.set_pipeline(Some(pipeline_id)); - plan.set_iframes(vec![0, 1, 2]); + plan.add_parent(parent_a); + plan.add_parent(parent_a); + plan.add_parent(parent_b); + plan.add_parent(plan.header().object_id()); - assert_eq!(plan.pipeline(), Some(pipeline_id)); - assert_eq!(plan.iframes(), &[0, 1, 2]); + assert_eq!(plan.parents(), &[parent_a, parent_b]); } #[test] - fn test_plan_step_statuses() { - let mut step = PlanStep::new("run tests"); - - // Initial state: one Pending entry - assert_eq!(step.statuses().len(), 1); - assert_eq!(step.status(), Some(&StepStatus::Pending)); - - // Transition to Progressing - step.set_status(StepStatus::Progressing); - assert_eq!(step.status(), Some(&StepStatus::Progressing)); - assert_eq!(step.statuses().len(), 2); - - // Transition to Completed with reason - step.set_status_with_reason(StepStatus::Completed, "all checks passed"); - assert_eq!(step.status(), Some(&StepStatus::Completed)); - assert_eq!(step.statuses().len(), 3); - - // Verify full history - let history = step.statuses(); - assert_eq!(history[0].status(), &StepStatus::Pending); - assert!(history[0].reason().is_none()); - assert_eq!(history[1].status(), &StepStatus::Progressing); - assert!(history[1].reason().is_none()); - assert_eq!(history[2].status(), &StepStatus::Completed); - assert_eq!(history[2].reason(), Some("all checks passed")); - - // Timestamps are ordered - assert!(history[1].changed_at() >= history[0].changed_at()); - assert!(history[2].changed_at() >= history[1].changed_at()); + fn test_plan_revision_chain_rejects_mixed_intents() { + let actor = ActorRef::human("jackie").expect("actor"); + let plan_a = Plan::new(actor.clone(), Uuid::from_u128(0x100)).expect("plan"); + let plan_b = Plan::new(actor, Uuid::from_u128(0x200)).expect("plan"); + + let err = Plan::new_revision_chain( + ActorRef::human("jackie").expect("actor"), + &[&plan_a, &plan_b], + ) + .expect_err("mixed intents should fail"); + + assert!(err.contains("same intent")); } #[test] - fn test_plan_step_deserializes_description_field() { - let step: PlanStep = serde_json::from_value(json!({ - "description": "run tests", - "statuses": [{"status": "pending", "changed_at": "2026-01-01T00:00:00Z"}] - })) - .expect("deserialize step"); + fn test_plan_context_frames() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut plan = Plan::new(actor, Uuid::from_u128(0x12)).expect("plan"); + let frame_a = Uuid::from_u128(0x51); + let frame_b = Uuid::from_u128(0x52); - assert_eq!(step.description(), "run tests"); + plan.set_context_frames(vec![frame_a, frame_b]); + assert_eq!(plan.context_frames(), &[frame_a, frame_b]); } #[test] @@ -644,105 +338,19 @@ mod tests { value.get("description").and_then(|v| v.as_str()), Some("run tests") ); - assert!(value.get("intent").is_none()); - } - - #[test] - fn test_plan_step_context_frames() { - let mut step = PlanStep::new("refactor auth module"); - - // Initially empty - assert!(step.iframes().is_empty()); - assert!(step.oframes().is_empty()); - - // Step consumed frames 0 and 1 as input context - step.set_iframes(vec![0, 1]); - // Step produced frame 2 as output - step.set_oframes(vec![2]); - - assert_eq!(step.iframes(), &[0, 1]); - assert_eq!(step.oframes(), &[2]); - } - - #[test] - fn test_plan_step_context_frames_serde_roundtrip() { - let mut step = PlanStep::new("deploy"); - step.set_iframes(vec![0, 3]); - step.set_oframes(vec![4, 5]); - - let value = serde_json::to_value(&step).expect("serialize"); - let restored: PlanStep = serde_json::from_value(value).expect("deserialize"); - - assert_eq!(restored.iframes(), &[0, 3]); - assert_eq!(restored.oframes(), &[4, 5]); - } - - #[test] - fn test_plan_step_empty_frames_omitted_in_json() { - let step = PlanStep::new("noop"); - let value = serde_json::to_value(&step).expect("serialize"); - - // Empty vecs should be omitted (skip_serializing_if = "Vec::is_empty") - assert!(value.get("iframes").is_none()); - assert!(value.get("oframes").is_none()); - } - - #[test] - fn test_plan_iframes_serde_roundtrip() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut plan = Plan::new(actor).expect("plan"); - plan.set_pipeline(Some(Uuid::from_u128(0x99))); - plan.set_iframes(vec![2, 3]); - - let mut step = PlanStep::new("step 0"); - step.set_iframes(vec![2, 3]); - step.set_oframes(vec![7]); - plan.add_step(step); - - let data = plan.to_data().expect("serialize"); - let restored = Plan::from_bytes(&data, ObjectHash::default()).expect("deserialize"); - - assert_eq!(restored.iframes(), &[2, 3]); - assert_eq!(restored.steps()[0].iframes(), &[2, 3]); - assert_eq!(restored.steps()[0].oframes(), &[7]); - } - - #[test] - fn test_plan_step_subtask() { - let mut step = PlanStep::new("design OAuth flow"); - - // Initially no sub-task - assert!(step.task().is_none()); - - // Elevate to independent sub-Task - let sub_task_id = Uuid::from_u128(0xAB); - step.set_task(Some(sub_task_id)); - assert_eq!(step.task(), Some(sub_task_id)); - - // Clear association - step.set_task(None); - assert!(step.task().is_none()); + assert!(value.get("step_id").is_some()); } #[test] - fn test_plan_step_subtask_serde_roundtrip() { - let mut step = PlanStep::new("implement auth module"); - let sub_task_id = Uuid::from_u128(0xCD); - step.set_task(Some(sub_task_id)); - - let value = serde_json::to_value(&step).expect("serialize"); - assert!(value.get("task").is_some()); - - let restored: PlanStep = serde_json::from_value(value).expect("deserialize"); - assert_eq!(restored.task(), Some(sub_task_id)); - } - - #[test] - fn test_plan_step_no_subtask_omitted_in_json() { - let step = PlanStep::new("inline step"); - let value = serde_json::to_value(&step).expect("serialize"); + fn test_plan_step_deserializes_description_field() { + let step_id = Uuid::from_u128(0x501); + let step: PlanStep = serde_json::from_value(json!({ + "step_id": step_id, + "description": "run tests" + })) + .expect("deserialize step"); - // None task should be omitted (skip_serializing_if) - assert!(value.get("task").is_none()); + assert_eq!(step.step_id(), step_id); + assert_eq!(step.description(), "run tests"); } } diff --git a/src/internal/object/provenance.rs b/src/internal/object/provenance.rs index 9ee6bcc1..abeba820 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -1,39 +1,26 @@ -//! AI Provenance Definition +//! AI Provenance snapshot. //! -//! A `Provenance` records **how** a [`Run`](super::run::Run) was executed: -//! which LLM provider, model, and parameters were used, and how many -//! tokens were consumed. It is the "lab notebook" for AI execution — -//! capturing the exact configuration so results can be reproduced, -//! compared, and accounted for. +//! `Provenance` records the immutable model/provider configuration used +//! for a `Run`. //! -//! # Position in Lifecycle +//! # How to use this object //! -//! ```text -//! ⑤ Run -//! ├─ (1:1)──▶ Provenance (LLM config + token usage) -//! ├─ patchsets ──▶ [PatchSet₀, ...] -//! ├─ evidence ──▶ [Evidence₀, ...] -//! └─ decision ──▶ Decision -//! │ -//! └─ ties execution trace back to Intent / Task outcomes -//! ``` +//! - Create `Provenance` when Libra has chosen the provider, model, and +//! generation parameters for a run. +//! - Populate optional sampling and parameter fields before +//! persistence. +//! - Keep it immutable after writing; usage and cost belong elsewhere. //! -//! A Provenance is created **once per Run**, typically at run start -//! when the orchestrator selects the model and provider. Token usage -//! (`token_usage`) is populated after the Run completes. The -//! Provenance is a sibling of PatchSet, Evidence, and Decision — -//! all attached to the same Run but serving different purposes. +//! # How it works with other objects //! -//! # Purpose +//! - `Run` is the canonical owner via `run_id`. +//! - `RunUsage` stores tokens and cost for the same run. //! -//! - **Reproducibility**: Given the same model, parameters, and -//! [`ContextSnapshot`](super::context::ContextSnapshot), the agent -//! should produce equivalent results. -//! - **Cost Accounting**: `token_usage.cost_usd` enables per-Run and -//! per-Task cost tracking and budgeting. -//! - **Optimization**: Comparing Provenance across Runs of the same -//! Task reveals which model/parameter combinations yield better -//! results or lower cost. +//! # How Libra should call it +//! +//! Libra should write `Provenance` once near run start, then later write +//! `RunUsage` when consumption totals are known. Do not backfill usage +//! onto the provenance snapshot. use std::fmt; @@ -49,99 +36,33 @@ use crate::{ }, }; -/// Normalized token usage across providers. -/// -/// All fields use a provider-neutral representation so that usage -/// from different LLM providers (OpenAI, Anthropic, etc.) can be -/// compared directly. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct TokenUsage { - /// Number of tokens in the prompt / input. - pub input_tokens: u64, - /// Number of tokens in the completion / output. - pub output_tokens: u64, - /// `input_tokens + output_tokens`. Stored explicitly for quick - /// aggregation; [`is_consistent`](TokenUsage::is_consistent) - /// verifies the invariant. - pub total_tokens: u64, - /// Estimated cost in USD for this usage, if the provider reports - /// pricing. `None` when pricing data is unavailable. - 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) - } -} - -/// LLM provider/model configuration and usage for a single Run. -/// -/// Created once per Run. See module documentation for lifecycle -/// position and purpose. +/// Immutable provider/model configuration for one execution attempt. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Provenance { - /// Common header (object ID, type, timestamps, creator, etc.). + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. #[serde(flatten)] header: Header, - /// The [`Run`](super::run::Run) this Provenance describes. - /// - /// Every Provenance belongs to exactly one Run. The Run does not - /// store a back-reference; lookup is done by scanning or indexing. + /// Canonical owning run for this provider/model configuration. run_id: Uuid, - /// LLM provider identifier (e.g. "openai", "anthropic", "local"). - /// - /// Used together with `model` to fully identify the AI backend. - /// The value is a free-form string; no enum is imposed because - /// new providers appear frequently. + /// Provider identifier, such as `openai`. provider: String, - /// Model identifier as returned by the provider (e.g. - /// "gpt-4", "claude-opus-4-20250514", "llama-3-70b"). - /// - /// Should match the provider's official model ID so that results - /// can be correlated with the provider's documentation and pricing. + /// Model identifier, such as `gpt-5`. model: String, - /// Provider-specific raw parameters payload. - /// - /// A catch-all JSON object for parameters that don't have - /// dedicated fields (e.g. `top_p`, `frequency_penalty`, custom - /// system prompts). `None` when no extra parameters were set. - /// `temperature` and `max_tokens` are extracted into dedicated - /// fields for convenience but may also appear here. + /// Provider-specific structured parameters captured as raw JSON. #[serde(default, skip_serializing_if = "Option::is_none")] parameters: Option, - /// Sampling temperature used for generation. - /// - /// `0.0` = deterministic, higher = more creative. `None` if the - /// provider default was used. The getter falls back to - /// `parameters.temperature` when this field is not set. + /// Optional top-level temperature convenience field. #[serde(default, skip_serializing_if = "Option::is_none")] temperature: Option, - /// Maximum number of tokens the model was allowed to generate. - /// - /// `None` if the provider default was used. The getter falls back - /// to `parameters.max_tokens` when this field is not set. + /// Optional top-level max token convenience field. #[serde(default, skip_serializing_if = "Option::is_none")] max_tokens: Option, - /// Token consumption and cost for this Run. - /// - /// Populated after the Run completes. `None` while the Run is - /// still in progress or if the provider does not report usage. - /// See [`TokenUsage`] for field details. - #[serde(default, skip_serializing_if = "Option::is_none")] - token_usage: Option, } impl Provenance { + /// Create a new provider/model configuration record for one run. pub fn new( created_by: ActorRef, run_id: Uuid, @@ -156,32 +77,36 @@ impl Provenance { parameters: None, temperature: None, max_tokens: None, - token_usage: None, }) } + /// Return the immutable header for this provenance record. pub fn header(&self) -> &Header { &self.header } + /// Return the canonical owning run id. pub fn run_id(&self) -> Uuid { self.run_id } + /// Return the provider identifier. pub fn provider(&self) -> &str { &self.provider } + /// Return the model identifier. pub fn model(&self) -> &str { &self.model } - /// Provider-specific raw parameters payload. + /// Return the raw structured parameters, if present. pub fn parameters(&self) -> Option<&serde_json::Value> { self.parameters.as_ref() } - /// Normalized temperature if available. + /// Return the effective temperature, checking the explicit field + /// first and the raw parameters second. pub fn temperature(&self) -> Option { self.temperature.or_else(|| { self.parameters @@ -191,7 +116,8 @@ impl Provenance { }) } - /// Normalized max_tokens if available. + /// Return the effective max token limit, checking the explicit field + /// first and the raw parameters second. pub fn max_tokens(&self) -> Option { self.max_tokens.or_else(|| { self.parameters @@ -201,25 +127,20 @@ impl Provenance { }) } - pub fn token_usage(&self) -> Option<&TokenUsage> { - self.token_usage.as_ref() - } - + /// Set or clear the raw structured provider parameters. pub fn set_parameters(&mut self, parameters: Option) { self.parameters = parameters; } + /// Set or clear the top-level temperature field. pub fn set_temperature(&mut self, temperature: Option) { self.temperature = temperature; } + /// Set or clear the top-level max token field. 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; - } } impl fmt::Display for Provenance { @@ -259,31 +180,24 @@ impl ObjectTrait for Provenance { mod tests { use super::*; + // Coverage: + // - canonical run/provider/model storage + // - fallback lookup of temperature and max_tokens from parameters + #[test] fn test_provenance_fields() { - let actor = ActorRef::agent("test-agent").expect("actor"); - let run_id = Uuid::from_u128(0x1); + let actor = ActorRef::agent("planner").expect("actor"); + let run_id = Uuid::from_u128(0x42); + let mut provenance = Provenance::new(actor, run_id, "openai", "gpt-5").expect("prov"); - let mut provenance = Provenance::new(actor, run_id, "openai", "gpt-4").expect("provenance"); provenance.set_parameters(Some( - serde_json::json!({"temperature": 0.2, "max_tokens": 128}), + serde_json::json!({"temperature": 0.2, "max_tokens": 2048}), )); - 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_eq!(provenance.run_id(), run_id); + assert_eq!(provenance.provider(), "openai"); + assert_eq!(provenance.model(), "gpt-5"); 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)); + assert_eq!(provenance.max_tokens(), Some(2048)); } } diff --git a/src/internal/object/run.rs b/src/internal/object/run.rs index 2c520efa..bb0af8fa 100644 --- a/src/internal/object/run.rs +++ b/src/internal/object/run.rs @@ -1,77 +1,27 @@ -//! AI Run Definition +//! AI Run snapshot. //! -//! A [`Run`] is a single execution attempt of a -//! [`Task`](super::task::Task). It captures the execution context -//! (baseline commit, environment, Plan version) and accumulates -//! artifacts ([`PatchSet`](super::patchset::PatchSet)s, -//! [`Evidence`](super::evidence::Evidence), -//! [`ToolInvocation`](super::tool::ToolInvocation)s) during execution. -//! The Run is step ⑤ in the end-to-end flow described in -//! [`mod.rs`](super). +//! `Run` stores one immutable execution attempt for a `Task`. //! -//! # Position in Lifecycle +//! # How to use this object //! -//! ```text -//! ④ Task -//! │ -//! └──runs──▶ [Run₀, Run₁, ...] -//! │ -//! ▼ -//! ⑤ Run (Created → Patching → Validating → Completed/Failed) -//! │ -//! ├─ task ──▶ Task (mandatory, 1:1) -//! ├─ plan ──▶ Plan (snapshot reference) -//! ├─ snapshot ──▶ ContextSnapshot (optional, static) -//! ├─ provenance ──▶ Provenance (1:1, sibling) -//! │ -//! │ ┌─── verification loop ───┐ -//! │ │ │ -//! │ │ ⑥ ToolInvocation (1:N) │ -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑦ PatchSet (Proposed) │ -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑧ Evidence (1:N) │ -//! │ │ │ │ -//! │ │ ├─ pass ───────┴────▶ ⑨ Decision -//! │ │ └─ fail → retry in same Run/next Run -//! │ └────────────────────────┘ -//! ``` +//! - Create a `Run` when Libra starts a new execution attempt. +//! - Set the selected `Plan`, optional `ContextSnapshot`, and runtime +//! `Environment` before persistence. +//! - Create a fresh `Run` for retries instead of mutating a prior run. //! -//! # Status Transitions +//! # How it works with other objects //! -//! ```text -//! Created ──▶ Patching ──▶ Validating ──▶ Completed -//! │ │ -//! └──────────────┴──▶ Failed -//! ``` +//! - `Provenance` records model/provider configuration for the run. +//! - `ToolInvocation`, `RunEvent`, `PlanStepEvent`, `Evidence`, +//! `PatchSet`, `Decision`, and `RunUsage` all attach to `Run`. +//! - `Decision` is the terminal verdict for a run. //! -//! # Relationships +//! # How Libra should call it //! -//! | Field | Target | Cardinality | Notes | -//! |-------|--------|-------------|-------| -//! | `task` | Task | 1 | Mandatory owning Task | -//! | `plan` | Plan | 0..1 | Snapshot reference (frozen at Run start) | -//! | `snapshot` | ContextSnapshot | 0..1 | Static context at Run start | -//! | `patchsets` | PatchSet | 0..N | Candidate diffs, chronological | -//! -//! Reverse references (by `run_id`): -//! - `Provenance.run_id` → this Run (1:1, LLM config) -//! - `ToolInvocation.run_id` → this Run (1:N, action log) -//! - `Evidence.run_id` → this Run (1:N, validation results) -//! - `Decision.run_id` → this Run (1:1, terminal verdict) -//! -//! # Purpose -//! -//! - **Execution Context**: Records the baseline `commit`, host -//! `environment`, and Plan version so that results can be -//! reproduced. -//! - **Artifact Collection**: Accumulates PatchSets (candidate diffs) -//! during the agent execution loop. -//! - **Isolation**: Each Run is independent — a retry creates a new -//! Run with potentially different parameters, without mutating the -//! previous Run's state. +//! Libra should treat `Run` as the execution envelope and keep "active +//! run", retries, and scheduling state in Libra. Execution progress, +//! metrics, and failures must be appended as event objects rather than +//! written back onto the run snapshot. use std::{collections::HashMap, fmt}; @@ -88,73 +38,28 @@ use crate::{ }, }; -/// Lifecycle status of a [`Run`]. +/// Best-effort runtime environment capture for one `Run`. /// -/// See module docs for the status transition diagram. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum RunStatus { - /// Run has been created but the agent has not started execution. - /// Environment and baseline commit are captured at this point. - Created, - /// Agent is actively generating code changes. One or more - /// [`ToolInvocation`](super::tool::ToolInvocation)s are being - /// produced. - Patching, - /// Agent has produced a candidate - /// [`PatchSet`](super::patchset::PatchSet) and is running - /// validation tools (tests, lint, build). One or more - /// [`Evidence`](super::evidence::Evidence) objects are being - /// produced. - Validating, - /// Agent has finished successfully. A - /// [`Decision`](super::decision::Decision) has been created. - Completed, - /// Agent encountered an unrecoverable error. `Run.error` should - /// contain the error message. - Failed, -} - -impl RunStatus { - pub fn as_str(&self) -> &'static str { - match self { - RunStatus::Created => "created", - RunStatus::Patching => "patching", - RunStatus::Validating => "validating", - RunStatus::Completed => "completed", - RunStatus::Failed => "failed", - } - } -} - -impl fmt::Display for RunStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// Host environment snapshot captured at Run creation time. -/// -/// Records the OS, CPU architecture, and working directory so that -/// results can be correlated with the execution environment. The -/// `extra` map allows capturing additional environment details -/// (e.g. tool versions, environment variables) without schema changes. +/// This is a lightweight reproducibility aid. Libra may augment or +/// normalize these values before persistence if it needs stricter +/// environment tracking. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Environment { - /// Operating system identifier (e.g. "macos", "linux", "windows"). + /// Operating system identifier captured for the run environment. pub os: String, - /// CPU architecture (e.g. "aarch64", "x86_64"). + /// CPU architecture identifier captured for the run environment. pub arch: String, - /// Current working directory at Run creation time. + /// Working directory from which the run was started. pub cwd: String, - /// Additional environment details (tool versions, etc.). + /// Additional application-defined environment metadata. #[serde(flatten)] pub extra: HashMap, } impl Environment { - /// Create a new environment object from the current system environment + /// Capture a best-effort environment snapshot from the current + /// process. pub fn capture() -> Self { Self { os: std::env::consts::OS.to_string(), @@ -170,102 +75,36 @@ impl Environment { } } -/// A single execution attempt of a [`Task`](super::task::Task). +/// Immutable execution-attempt envelope. /// -/// A Run captures the execution context and accumulates artifacts -/// during the agent's work. It is step ⑤ in the end-to-end flow. -/// See module documentation for lifecycle, relationships, and -/// status transitions. +/// A stored `Run` says "this attempt existed against this task / plan / +/// commit baseline". It does not itself accumulate logs or status +/// transitions after persistence. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Run { - /// Common header (object ID, type, timestamps, creator, etc.). + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. #[serde(flatten)] header: Header, - /// The [`Task`](super::task::Task) this Run belongs to. - /// - /// Mandatory — every Run is an execution attempt of exactly one - /// Task. `Task.runs` holds the reverse reference. This field is - /// set at creation and never changes. + /// Canonical owning task for this execution attempt. task: Uuid, - /// The [`Plan`](super::plan::Plan) this Run is executing. - /// - /// This is a **snapshot reference**: it records the specific Plan - /// version that was active when this Run started. After - /// replanning, existing Runs keep their original `plan` unchanged - /// — only new Runs reference the revised Plan. - /// `Intent.plan` always points to the latest revision, but a Run - /// may be executing an older version. `None` when no Plan was - /// associated (e.g. ad-hoc execution without formal planning). + /// Optional selected plan revision used by this attempt. #[serde(default, skip_serializing_if = "Option::is_none")] plan: Option, - /// Git commit hash of the working tree when this Run started. - /// - /// Serves as the baseline for all code changes: the agent reads - /// files at this commit, and the resulting - /// [`PatchSet`](super::patchset::PatchSet) diffs are relative to - /// it. If the Run fails and a new Run is created, the new Run - /// may start from a different commit (e.g. after upstream changes - /// are pulled). + /// Baseline repository integrity hash from which execution started. commit: IntegrityHash, - /// Current lifecycle status. - /// - /// Transitions follow the sequence: - /// `Created → Patching → Validating → Completed` (happy path), - /// or `→ Failed` from any active state. The orchestrator advances - /// the status as the agent progresses through execution phases. - status: RunStatus, - /// Optional [`ContextSnapshot`](super::context::ContextSnapshot) - /// captured at Run creation time. - /// - /// Records the file tree, documentation fragments, and other - /// static context the agent observed when the Run began. Used - /// for reproducibility: given the same snapshot and Plan, the - /// agent should produce equivalent results. `None` when no - /// snapshot was captured. + /// Optional static context snapshot captured at run start. #[serde(default, skip_serializing_if = "Option::is_none")] snapshot: Option, - /// Chronological list of [`PatchSet`](super::patchset::PatchSet) - /// IDs generated during this Run. - /// - /// Append-only — each new PatchSet is pushed to the end. The - /// last entry is the most recent candidate. A Run may produce - /// multiple PatchSets when the agent iterates on validation - /// failures (step ⑦ → ⑧ retry loop). Empty when no PatchSet - /// has been generated yet. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - patchsets: Vec, - /// Execution metrics (token usage, timing, etc.). - /// - /// Free-form JSON for metrics not captured by - /// [`Provenance`](super::provenance::Provenance). For example, - /// wall-clock duration, number of tool calls, or retry count. - /// `None` when no metrics are available. - #[serde(default, skip_serializing_if = "Option::is_none")] - metrics: Option, - /// Error message if the Run failed. - /// - /// Set when `status` transitions to `Failed`. Contains a - /// human-readable description of what went wrong. `None` while - /// the Run is in progress or completed successfully. - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - /// Host [`Environment`] snapshot captured at Run creation time. - /// - /// Automatically populated by [`Run::new`] via - /// [`Environment::capture`]. Records OS, architecture, and - /// working directory for reproducibility. + /// Optional execution environment metadata. #[serde(default, skip_serializing_if = "Option::is_none")] environment: Option, } impl Run { - /// Create a new Run. - /// - /// # Arguments - /// * `created_by` - Actor (usually the Orchestrator) - /// * `task` - The Task this run belongs to - /// * `commit` - The Git commit hash of the checkout + /// Create a new execution attempt for the given task and commit + /// baseline. pub fn new(created_by: ActorRef, task: Uuid, commit: impl AsRef) -> Result { let commit = commit.as_ref().parse()?; Ok(Self { @@ -273,81 +112,50 @@ impl Run { task, plan: None, commit, - status: RunStatus::Created, snapshot: None, - patchsets: Vec::new(), - metrics: None, - error: None, environment: Some(Environment::capture()), }) } + /// Return the immutable header for this run. pub fn header(&self) -> &Header { &self.header } + /// Return the canonical owning task id. pub fn task(&self) -> Uuid { self.task } - /// Returns the Plan this Run is executing, if set. + /// Return the selected plan revision, if one was stored. pub fn plan(&self) -> Option { self.plan } - /// Sets the Plan this Run will execute. + /// Set or clear the selected plan revision for this in-memory run. pub fn set_plan(&mut self, plan: Option) { self.plan = plan; } + /// Return the baseline repository integrity hash. pub fn commit(&self) -> &IntegrityHash { &self.commit } - pub fn status(&self) -> &RunStatus { - &self.status - } - + /// Return the static context snapshot id, if present. pub fn snapshot(&self) -> Option { self.snapshot } - /// Returns the chronological list of PatchSet IDs generated during this Run. - pub fn patchsets(&self) -> &[Uuid] { - &self.patchsets - } - - pub fn metrics(&self) -> Option<&serde_json::Value> { - self.metrics.as_ref() - } - - pub fn error(&self) -> Option<&str> { - self.error.as_deref() - } - - pub fn environment(&self) -> Option<&Environment> { - self.environment.as_ref() - } - - pub fn set_status(&mut self, status: RunStatus) { - self.status = status; - } - + /// Set or clear the static context snapshot link for this in-memory + /// run. pub fn set_snapshot(&mut self, snapshot: Option) { self.snapshot = snapshot; } - /// Appends a PatchSet ID to this Run's generation history. - pub fn add_patchset(&mut self, patchset_id: Uuid) { - self.patchsets.push(patchset_id); - } - - pub fn set_metrics(&mut self, metrics: Option) { - self.metrics = metrics; - } - - pub fn set_error(&mut self, error: Option) { - self.error = error; + /// Return the captured execution environment, if present. + pub fn environment(&self) -> Option<&Environment> { + self.environment.as_ref() } } @@ -388,6 +196,10 @@ impl ObjectTrait for Run { mod tests { use super::*; + // Coverage: + // - new run creation captures a non-empty environment snapshot + // - plan and context snapshot links can be assigned before storage + fn test_hash_hex() -> String { IntegrityHash::compute(b"ai-process-test").to_hex() } @@ -396,14 +208,26 @@ mod tests { fn test_new_objects_creation() { let actor = ActorRef::agent("test-agent").expect("actor"); let base_hash = test_hash_hex(); + let run = Run::new(actor, Uuid::from_u128(0x1), &base_hash).expect("run"); - // Run with environment (auto captured) - let run = Run::new(actor.clone(), Uuid::from_u128(0x1), &base_hash).expect("run"); - - let env = run.environment().unwrap(); - // Check if it captured real values (assuming we are running on some OS) + let env = run.environment().expect("environment"); assert!(!env.os.is_empty()); assert!(!env.arch.is_empty()); assert!(!env.cwd.is_empty()); } + + #[test] + fn test_run_plan_and_snapshot() { + let actor = ActorRef::agent("test-agent").expect("actor"); + let base_hash = test_hash_hex(); + let mut run = Run::new(actor, Uuid::from_u128(0x1), &base_hash).expect("run"); + let plan_id = Uuid::from_u128(0x10); + let snapshot_id = Uuid::from_u128(0x20); + + run.set_plan(Some(plan_id)); + run.set_snapshot(Some(snapshot_id)); + + assert_eq!(run.plan(), Some(plan_id)); + assert_eq!(run.snapshot(), Some(snapshot_id)); + } } diff --git a/src/internal/object/task.rs b/src/internal/object/task.rs index 285f22cf..5109f6f3 100644 --- a/src/internal/object/task.rs +++ b/src/internal/object/task.rs @@ -1,79 +1,30 @@ -//! AI Task Definition +//! AI Task snapshot. //! -//! A [`Task`] is a unit of work to be performed by an AI agent. It is -//! step ④ in the end-to-end flow described in [`mod.rs`](super) — the -//! stable identity for a piece of work, independent of how many times -//! it is attempted (Runs) or how the strategy evolves (Plan revisions). +//! `Task` stores a stable unit of work derived from a plan or created by +//! Libra as a delegated work item. //! -//! # Position in Lifecycle +//! # How to use this object //! -//! ```text -//! ③ Plan ──steps──▶ [PlanStep₀, PlanStep₁, ...] -//! │ -//! ├─ inline (no task) -//! └─ task ──▶ ④ Task -//! │ -//! ├──▶ Run₀ ──plan──▶ Plan_v1 -//! ├──▶ Run₁ ──plan──▶ Plan_v2 -//! │ -//! ▼ -//! ⑤ Run (execution loop) -//! │ -//! ├─ ToolInvocation* → PatchSet* → Evidence* -//! └─ Decision -//! ``` +//! - Create a `Task` when a `PlanStep` needs its own durable execution +//! unit. +//! - Fill `parent`, `intent`, `origin_step_id`, and `dependencies` +//! before persistence if those provenance links are known. +//! - Keep the stored object stable; define a new task snapshot only when +//! the work definition itself changes. //! -//! # Status Transitions +//! # How it works with other objects //! -//! ```text -//! Draft ──▶ Running ──▶ Done -//! │ │ -//! ├──────────┴──▶ Failed -//! └──────────────▶ Cancelled -//! ``` +//! - `Task.origin_step_id` links the task back to the stable +//! `PlanStep.step_id`. +//! - `Run.task` links execution attempts to the task. +//! - `TaskEvent` records lifecycle changes such as running / blocked / +//! done / failed. //! -//! # Relationships +//! # How Libra should call it //! -//! | Field | Target | Cardinality | Notes | -//! |-------|--------|-------------|-------| -//! | `parent` | Task | 0..1 | Back-reference to parent Task for sub-Tasks | -//! | `intent` | Intent | 0..1 | Originating user request | -//! | `runs` | Run | 0..N | Chronological execution history | -//! | `dependencies` | Task | 0..N | Must complete before this Task starts | -//! -//! Reverse references: -//! - `PlanStep.task` → this Task (forward link from Plan) -//! - `Run.task` → this Task (each Run knows its owner) -//! -//! # Replanning -//! -//! When a Run fails or the agent determines the plan needs revision, -//! a new [`Plan`](super::plan::Plan) revision is created. The **Task -//! stays the same** — it is the stable identity for the work. Only -//! the strategy (Plan) evolves: -//! -//! ```text -//! Task (constant) Intent (constant, plan updated) -//! │ └─ plan ──▶ Plan_v2 (latest) -//! └─ runs: -//! Run₀ ──plan──▶ Plan_v1 (snapshot: original plan) -//! Run₁ ──plan──▶ Plan_v2 (snapshot: revised plan) -//! ``` -//! -//! # Purpose -//! -//! - **Stable Identity**: The Task persists across retries and -//! replanning. All Runs, regardless of which Plan version they -//! executed, belong to the same Task. -//! - **Scope Definition**: `constraints` and `acceptance_criteria` -//! define what the agent must and must not do, and how success is -//! measured. -//! - **Hierarchy**: `parent` enables recursive decomposition — a -//! PlanStep can spawn a sub-Task, which in turn has its own Plan -//! and Runs. -//! - **Dependency Management**: `dependencies` enables ordering -//! between sibling Tasks (e.g. "implement API before writing -//! tests"). +//! Libra should derive ready queues, dependency resolution, and current +//! task status from `Task` plus `TaskEvent` and `Run` history. Those +//! mutable scheduling views do not belong on the `Task` object itself. use std::{fmt, str::FromStr}; @@ -89,53 +40,6 @@ use crate::{ }, }; -/// Lifecycle status of a [`Task`]. -/// -/// See module docs for the status transition diagram. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TaskStatus { - /// Initial state. Task definition is in progress — title, - /// constraints, and acceptance criteria may still be changing. - Draft, - /// An agent (via a [`Run`](super::run::Run)) is actively working - /// on this Task. At least one Run in `Task.runs` is active. - Running, - /// Task completed successfully. All acceptance criteria met and - /// the final PatchSet has been committed. - Done, - /// Task failed to complete after all retry attempts. The - /// [`Decision`](super::decision::Decision) of the last Run - /// explains the failure. - Failed, - /// Task was cancelled by the user or orchestrator before - /// completion (e.g. timeout, budget exceeded, user interrupt). - Cancelled, -} - -impl TaskStatus { - pub fn as_str(&self) -> &'static str { - match self { - TaskStatus::Draft => "draft", - TaskStatus::Running => "running", - TaskStatus::Done => "done", - TaskStatus::Failed => "failed", - TaskStatus::Cancelled => "cancelled", - } - } -} - -impl fmt::Display for TaskStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// Classification of the work a [`Task`] aims to accomplish. -/// -/// Helps agents choose appropriate strategies and tools. For example, -/// a `Bugfix` task might prioritize reading test output, while a -/// `Refactor` task might focus on code structure analysis. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GoalType { @@ -149,12 +53,12 @@ pub enum GoalType { Build, Ci, Style, - /// Catch-all for goal categories not covered by the predefined - /// variants. The inner string is the custom category name. Other(String), } impl GoalType { + /// Return the canonical snake_case storage/display form for the goal + /// type. pub fn as_str(&self) -> &str { match self { GoalType::Feature => "feature", @@ -167,7 +71,7 @@ impl GoalType { GoalType::Build => "build", GoalType::Ci => "ci", GoalType::Style => "style", - GoalType::Other(s) => s.as_str(), + GoalType::Other(value) => value.as_str(), } } } @@ -198,117 +102,53 @@ impl FromStr for GoalType { } } -/// A unit of work with constraints and success criteria. +/// Stable work definition used by the scheduler and execution layer. /// -/// A Task can be **top-level** (created directly from a user request) -/// or a **sub-Task** (spawned by a [`PlanStep`](super::plan::PlanStep) -/// for recursive decomposition). It is step ④ in the end-to-end flow. -/// See module documentation for lifecycle, relationships, and -/// replanning semantics. +/// `Task` answers "what work should be done?" rather than "what is the +/// current runtime status?". Current status is reconstructed from event +/// history in Libra. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Task { - /// Common header (object ID, type, timestamps, creator, etc.). + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. #[serde(flatten)] header: Header, - /// Short human-readable summary of the work to be done. - /// - /// Analogous to a git commit subject line or a Jira ticket title. - /// Should be concise (under 100 characters) and describe the - /// desired outcome, not the method. Set once at creation. + /// Short task title suitable for queues and summaries. title: String, - /// Extended description providing additional context. - /// - /// May include background information, links to relevant docs or - /// issues, and any details that don't fit in `title`. `None` when - /// the title is self-explanatory. + /// Optional longer-form explanation of the work item. #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, - /// Classification of the work (Feature, Bugfix, Refactor, etc.). - /// - /// Helps agents choose appropriate strategies. For example, a - /// `Bugfix` task might prioritize reading test output, while a - /// `Docs` task focuses on documentation files. `None` when the - /// category is unclear or not relevant. + /// Optional coarse work classification used by Libra and UI layers. #[serde(default, skip_serializing_if = "Option::is_none")] goal: Option, - /// Hard constraints the solution must satisfy. - /// - /// Each entry is a natural-language rule (e.g. "Must use JWT", - /// "No breaking API changes", "Keep backward compatibility with - /// v2"). The agent must verify all constraints are met before - /// marking the Task as `Done`. Empty when there are no constraints. + /// Explicit constraints that the executor must respect. #[serde(default, skip_serializing_if = "Vec::is_empty")] constraints: Vec, - /// Criteria that must be met for the Task to be considered done. - /// - /// Each entry is a testable condition (e.g. "All tests pass", - /// "Coverage >= 80%", "No clippy warnings"). The - /// [`Evidence`](super::evidence::Evidence) produced during a Run - /// should demonstrate that these criteria are satisfied. Empty - /// when success is implied (e.g. "just do it"). + /// Concrete acceptance criteria that define task completion. #[serde(default, skip_serializing_if = "Vec::is_empty")] acceptance_criteria: Vec, - /// The actor who requested this work. - /// - /// May differ from `created_by` in the header when an agent - /// creates a Task on behalf of a user. For example, the - /// orchestrator (`created_by = system`) might create a Task - /// requested by a human (`requester = human`). `None` when the - /// requester is the same as the creator. + /// Optional actor on whose behalf the task was requested. #[serde(default, skip_serializing_if = "Option::is_none")] requester: Option, - /// Parent Task that spawned this sub-Task. - /// - /// Provides O(1) reverse navigation from a sub-Task back to its - /// parent. Set when a [`PlanStep`](super::plan::PlanStep) creates - /// a sub-Task via `PlanStep.task`. `None` for top-level Tasks. - /// - /// The forward direction is `PlanStep.task → sub-Task`; this field - /// is the corresponding back-reference. + /// Optional parent task id when the task was decomposed from another + /// durable task. #[serde(default, skip_serializing_if = "Option::is_none")] parent: Option, - /// Back-reference to the [`Intent`](super::intent::Intent) that - /// motivated this Task. - /// - /// Provides O(1) reverse navigation from work unit to the - /// originating user request: - /// - **Top-level Task**: points to the root Intent. - /// - **Sub-Task with own analysis**: points to a new sub-Intent. - /// - **Sub-Task (pure delegation)**: `None` — context is already - /// captured in the parent PlanStep's `iframes`/`oframes`. + /// Optional originating intent id for cross-object provenance. #[serde(default, skip_serializing_if = "Option::is_none")] intent: Option, - /// Chronological list of [`Run`](super::run::Run) IDs that have - /// executed (or are executing) this Task. - /// - /// Append-only — each new Run is pushed to the end. The last - /// entry is the most recent attempt. A Task may have multiple - /// Runs due to retries (after a `Decision::Retry`) or parallel - /// execution experiments. Empty when no Run has been created yet. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - runs: Vec, - /// Other Tasks that must complete before this one can start. - /// - /// Used for ordering sibling Tasks within a Plan (e.g. "implement - /// API" must complete before "write integration tests"). Empty - /// when there are no ordering constraints. + /// Optional stable plan-step id that originally spawned this task. + #[serde(default, skip_serializing_if = "Option::is_none")] + origin_step_id: Option, + /// Other task ids that must complete before this task becomes ready. #[serde(default, skip_serializing_if = "Vec::is_empty")] dependencies: Vec, - /// Current lifecycle status. - /// - /// Updated by the orchestrator as the Task progresses. See - /// [`TaskStatus`] for valid transitions and semantics. - status: TaskStatus, } impl Task { - /// Create a new Task. - /// - /// # Arguments - /// * `created_by` - Actor creating the task - /// * `title` - Short summary of the task - /// * `goal` - Optional classification (Feature, Bugfix, etc.) + /// Create a new task definition with the given title and optional + /// goal type. pub fn new( created_by: ActorRef, title: impl Into, @@ -324,98 +164,107 @@ impl Task { requester: None, parent: None, intent: None, - runs: Vec::new(), + origin_step_id: None, dependencies: Vec::new(), - status: TaskStatus::Draft, }) } + /// Return the immutable header for this task definition. pub fn header(&self) -> &Header { &self.header } + /// Return the short task title. pub fn title(&self) -> &str { &self.title } + /// Return the longer-form task description, if present. pub fn description(&self) -> Option<&str> { self.description.as_deref() } + /// Return the coarse work classification, if present. pub fn goal(&self) -> Option<&GoalType> { self.goal.as_ref() } + /// Return the explicit task constraints. pub fn constraints(&self) -> &[String] { &self.constraints } + /// Return the acceptance criteria for task completion. pub fn acceptance_criteria(&self) -> &[String] { &self.acceptance_criteria } + /// Return the requesting actor, if one was stored. pub fn requester(&self) -> Option<&ActorRef> { self.requester.as_ref() } - /// Returns the parent Task ID, if this is a sub-Task. + /// Return the parent task id, if this task was derived from another + /// task. pub fn parent(&self) -> Option { self.parent } + /// Return the originating intent id, if present. pub fn intent(&self) -> Option { self.intent } - /// Returns the chronological list of Run IDs for this task. - pub fn runs(&self) -> &[Uuid] { - &self.runs + /// Return the stable plan-step id that originally spawned this task, + /// if present. + pub fn origin_step_id(&self) -> Option { + self.origin_step_id } + /// Return the task dependency list. pub fn dependencies(&self) -> &[Uuid] { &self.dependencies } - pub fn status(&self) -> &TaskStatus { - &self.status - } - + /// Set or clear the long-form task description. pub fn set_description(&mut self, description: Option) { self.description = description; } + /// Append one execution constraint to this task definition. pub fn add_constraint(&mut self, constraint: impl Into) { self.constraints.push(constraint.into()); } + /// Append one acceptance criterion to this task definition. pub fn add_acceptance_criterion(&mut self, criterion: impl Into) { self.acceptance_criteria.push(criterion.into()); } + /// Set or clear the requesting actor for this task. pub fn set_requester(&mut self, requester: Option) { self.requester = requester; } + /// Set or clear the parent task link. pub fn set_parent(&mut self, parent: Option) { self.parent = parent; } + /// Set or clear the originating intent link. pub fn set_intent(&mut self, intent: Option) { self.intent = intent; } - /// Appends a Run ID to the execution history. - pub fn add_run(&mut self, run_id: Uuid) { - self.runs.push(run_id); + /// Set or clear the originating stable plan-step link. + pub fn set_origin_step_id(&mut self, origin_step_id: Option) { + self.origin_step_id = origin_step_id; } + /// Append one prerequisite task id to the dependency list. pub fn add_dependency(&mut self, task_id: Uuid) { self.dependencies.push(task_id); } - - pub fn set_status(&mut self, status: TaskStatus) { - self.status = status; - } } impl fmt::Display for Task { @@ -454,87 +303,48 @@ impl ObjectTrait for Task { #[cfg(test)] mod tests { use super::*; - use crate::internal::object::types::ActorKind; #[test] fn test_task_creation() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut task = Task::new(actor, "Fix bug", Some(GoalType::Bugfix)).expect("task"); - - // Test dependencies - let dep_id = Uuid::from_u128(0x00000000000000000000000000000001); - task.add_dependency(dep_id); + let actor = ActorRef::agent("worker").expect("actor"); + let task = Task::new(actor, "Implement pagination", Some(GoalType::Feature)).expect("task"); - assert_eq!(task.header().object_type(), &ObjectType::Task); - assert_eq!(task.status(), &TaskStatus::Draft); - assert_eq!(task.goal(), Some(&GoalType::Bugfix)); - assert_eq!(task.dependencies().len(), 1); - assert_eq!(task.dependencies()[0], dep_id); - assert!(task.intent().is_none()); - } - - #[test] - fn test_task_goal_optional() { - let actor = ActorRef::human("jackie").expect("actor"); - let task = Task::new(actor, "Write docs", None).expect("task"); - - assert!(task.goal().is_none()); + assert_eq!(task.title(), "Implement pagination"); + assert_eq!(task.goal(), Some(&GoalType::Feature)); + assert!(task.origin_step_id().is_none()); } #[test] fn test_task_requester() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut task = Task::new(actor.clone(), "Fix bug", Some(GoalType::Bugfix)).expect("task"); - - task.set_requester(Some(ActorRef::mcp_client("vscode-client").expect("actor"))); - - assert!(task.requester().is_some()); - assert_eq!(task.requester().unwrap().kind(), &ActorKind::McpClient); + let actor = ActorRef::agent("worker").expect("actor"); + let requester = ActorRef::human("alice").expect("requester"); + let mut task = Task::new(actor, "Implement pagination", None).expect("task"); + task.set_requester(Some(requester.clone())); + assert_eq!(task.requester(), Some(&requester)); } #[test] - fn test_task_runs() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut task = Task::new(actor, "Fix bug", Some(GoalType::Bugfix)).expect("task"); - - assert!(task.runs().is_empty()); - - let run1 = Uuid::from_u128(0x10); - let run2 = Uuid::from_u128(0x20); - task.add_run(run1); - task.add_run(run2); - - assert_eq!(task.runs(), &[run1, run2]); + fn test_task_goal_optional() { + let actor = ActorRef::agent("worker").expect("actor"); + let task = Task::new(actor, "Investigate", None).expect("task"); + assert!(task.goal().is_none()); } #[test] - fn test_task_from_bytes_without_header_version() { - // Old format data without header_version — should still parse - let json = serde_json::json!({ - "object_id": "01234567-89ab-cdef-0123-456789abcdef", - "object_type": "task", - "schema_version": 1, - "created_at": "2026-01-01T00:00:00Z", - "created_by": {"kind": "human", "id": "jackie"}, - "title": "old task", - "status": "draft" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let task = - Task::from_bytes(&bytes, ObjectHash::default()).expect("should parse old format"); - assert_eq!(task.title(), "old task"); - assert_eq!(task.header().header_version(), 1); + fn test_task_origin_step_id() { + let actor = ActorRef::agent("worker").expect("actor"); + let mut task = Task::new(actor, "Implement pagination", None).expect("task"); + let step_id = Uuid::from_u128(0x1234); + task.set_origin_step_id(Some(step_id)); + assert_eq!(task.origin_step_id(), Some(step_id)); } #[test] - fn test_task_serialization_includes_header_version() { - let actor = ActorRef::human("jackie").expect("actor"); - let task = Task::new(actor, "New task", None).expect("task"); - let data = task.to_data().expect("serialize"); - let value: serde_json::Value = serde_json::from_slice(&data).unwrap(); - assert_eq!( - value["header_version"], - crate::internal::object::types::CURRENT_HEADER_VERSION - ); + fn test_task_dependencies() { + let actor = ActorRef::agent("worker").expect("actor"); + let mut task = Task::new(actor, "Implement pagination", None).expect("task"); + let dep = Uuid::from_u128(0xAA); + task.add_dependency(dep); + assert_eq!(task.dependencies(), &[dep]); } } diff --git a/src/internal/object/tool.rs b/src/internal/object/tool.rs index b94864fd..abf8b030 100644 --- a/src/internal/object/tool.rs +++ b/src/internal/object/tool.rs @@ -23,6 +23,17 @@ //! Decision (which concludes a Run), ToolInvocations are low-level //! operational records. //! +//! # How Libra should use this object +//! +//! - Create one `ToolInvocation` per tool call. +//! - Populate arguments, I/O footprint, status, summaries, and +//! artifacts before persistence. +//! - Reconstruct the per-run action log by querying all tool +//! invocations for the same `run_id`, typically ordered by +//! `header.created_at`. +//! - Keep current orchestration state such as "next tool to run" in +//! Libra rather than in this object. +//! //! # Purpose //! //! - **Audit Trail**: Allows reconstructing exactly what the agent did @@ -58,6 +69,8 @@ pub enum ToolStatus { } impl ToolStatus { + /// Return the canonical snake_case storage/display form for the tool + /// status. pub fn as_str(&self) -> &'static str { match self { ToolStatus::Ok => "ok", @@ -94,7 +107,8 @@ pub struct IoFootprint { /// /// One ToolInvocation per tool call. The chronological sequence of /// ToolInvocations within a Run forms the agent's action log. See -/// module documentation for lifecycle position. +/// module documentation for lifecycle position and Libra calling +/// guidance. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ToolInvocation { @@ -152,6 +166,7 @@ pub struct ToolInvocation { } impl ToolInvocation { + /// Create a new tool invocation record for one run-local tool call. pub fn new( created_by: ActorRef, run_id: Uuid, @@ -169,54 +184,67 @@ impl ToolInvocation { }) } + /// Return the immutable header for this tool invocation. pub fn header(&self) -> &Header { &self.header } + /// Return the owning run id. pub fn run_id(&self) -> Uuid { self.run_id } + /// Return the registered tool name. pub fn tool_name(&self) -> &str { &self.tool_name } + /// Return the file I/O footprint, if captured. pub fn io_footprint(&self) -> Option<&IoFootprint> { self.io_footprint.as_ref() } + /// Return the raw JSON arguments passed to the tool. pub fn args(&self) -> &serde_json::Value { &self.args } + /// Return the tool execution status. pub fn status(&self) -> &ToolStatus { &self.status } + /// Return the short human-readable output summary, if present. pub fn result_summary(&self) -> Option<&str> { self.result_summary.as_deref() } + /// Return artifact references produced by the tool call. pub fn artifacts(&self) -> &[ArtifactRef] { &self.artifacts } + /// Set or clear the file I/O footprint. pub fn set_io_footprint(&mut self, io_footprint: Option) { self.io_footprint = io_footprint; } + /// Replace the raw JSON arguments. pub fn set_args(&mut self, args: serde_json::Value) { self.args = args; } + /// Set the tool execution status. pub fn set_status(&mut self, status: ToolStatus) { self.status = status; } + /// Set or clear the short human-readable output summary. pub fn set_result_summary(&mut self, result_summary: Option) { self.result_summary = result_summary; } + /// Append one persistent artifact reference. pub fn add_artifact(&mut self, artifact: ArtifactRef) { self.artifacts.push(artifact); } @@ -257,6 +285,11 @@ impl ObjectTrait for ToolInvocation { #[cfg(test)] mod tests { + // Coverage: + // - tool invocation field access + // - I/O footprint persistence + // - artifact attachment and status mutation + use super::*; #[test] diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index a95e5b8c..f32874ae 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -12,24 +12,6 @@ use uuid::Uuid; use super::integrity::IntegrityHash; use crate::errors::GitError; -/// In Git, each object type is assigned a unique integer value, which is used to identify the -/// type of the object in Git repositories. -/// -/// * `Blob` (1): A Git object that stores the content of a file. -/// * `Tree` (2): A Git object that represents a directory or a folder in a Git repository. -/// * `Commit` (3): A Git object that represents a commit in a Git repository, which contains -/// information such as the author, committer, commit message, and parent commits. -/// * `Tag` (4): A Git object that represents a tag in a Git repository, which is used to mark a -/// specific point in the Git history. -/// * `OffsetDelta` (6): A Git object that represents a delta between two objects, where the delta -/// is stored as an offset to the base object. -/// * `HashDelta` (7): A Git object that represents a delta between two objects, where the delta -/// is stored as a hash of the base object. -/// -/// By assigning unique integer values to each Git object type, Git can easily and efficiently -/// identify the type of an object and perform the appropriate operations on it. When parsing a Git -/// repository, Git can use the integer value of an object's type to determine how to parse -/// the object's content. #[derive(PartialEq, Eq, Hash, Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ObjectType { @@ -37,7 +19,7 @@ pub enum ObjectType { Tree, Blob, Tag, - OffsetZstdelta, // Private extension for Zstandard-compressed delta objects + OffsetZstdelta, OffsetDelta, HashDelta, ContextSnapshot, @@ -50,7 +32,12 @@ pub enum ObjectType { Task, Intent, ToolInvocation, - ContextPipeline, + ContextFrame, + IntentEvent, + TaskEvent, + RunEvent, + PlanStepEvent, + RunUsage, } const COMMIT_OBJECT_TYPE: &[u8] = b"commit"; @@ -67,9 +54,13 @@ 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"; -const CONTEXT_PIPELINE_OBJECT_TYPE: &[u8] = b"pipeline"; +const CONTEXT_FRAME_OBJECT_TYPE: &[u8] = b"context_frame"; +const INTENT_EVENT_OBJECT_TYPE: &[u8] = b"intent_event"; +const TASK_EVENT_OBJECT_TYPE: &[u8] = b"task_event"; +const RUN_EVENT_OBJECT_TYPE: &[u8] = b"run_event"; +const PLAN_STEP_EVENT_OBJECT_TYPE: &[u8] = b"plan_step_event"; +const RUN_USAGE_OBJECT_TYPE: &[u8] = b"run_usage"; -/// Display conversion for object type values. impl Display for ObjectType { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { @@ -90,18 +81,17 @@ impl Display for ObjectType { ObjectType::Task => write!(f, "task"), ObjectType::Intent => write!(f, "intent"), ObjectType::ToolInvocation => write!(f, "invocation"), - ObjectType::ContextPipeline => write!(f, "pipeline"), + ObjectType::ContextFrame => write!(f, "context_frame"), + ObjectType::IntentEvent => write!(f, "intent_event"), + ObjectType::TaskEvent => write!(f, "task_event"), + ObjectType::RunEvent => write!(f, "run_event"), + ObjectType::PlanStepEvent => write!(f, "plan_step_event"), + ObjectType::RunUsage => write!(f, "run_usage"), } } } -/// Object type conversion helpers. impl ObjectType { - /// Convert object type to 3-bit pack header type id. - /// - /// Git pack headers only carry 3 type bits (values 0..=7). AI object - /// types are not representable in this field and must not be written - /// as regular base objects in a pack entry. pub fn to_pack_type_u8(&self) -> Result { match self { ObjectType::Commit => Ok(1), @@ -118,7 +108,6 @@ impl ObjectType { } } - /// Decode 3-bit pack header type id to object type. pub fn from_pack_type_u8(number: u8) -> Result { match number { 1 => Ok(ObjectType::Commit), @@ -134,11 +123,6 @@ impl ObjectType { } } - /// Returns the loose-object type header bytes (e.g. `b"commit"`, `b"blob"`). - /// - /// Delta types (`OffsetDelta`, `HashDelta`, `OffsetZstdelta`) only - /// exist inside pack files and have no loose-object representation. - /// Passing a delta type is a logic error and returns `None`. pub fn to_bytes(&self) -> Option<&[u8]> { match self { ObjectType::Commit => Some(COMMIT_OBJECT_TYPE), @@ -155,12 +139,16 @@ impl ObjectType { ObjectType::Task => Some(TASK_OBJECT_TYPE), ObjectType::Intent => Some(INTENT_OBJECT_TYPE), ObjectType::ToolInvocation => Some(TOOL_INVOCATION_OBJECT_TYPE), - ObjectType::ContextPipeline => Some(CONTEXT_PIPELINE_OBJECT_TYPE), + ObjectType::ContextFrame => Some(CONTEXT_FRAME_OBJECT_TYPE), + ObjectType::IntentEvent => Some(INTENT_EVENT_OBJECT_TYPE), + ObjectType::TaskEvent => Some(TASK_EVENT_OBJECT_TYPE), + ObjectType::RunEvent => Some(RUN_EVENT_OBJECT_TYPE), + ObjectType::PlanStepEvent => Some(PLAN_STEP_EVENT_OBJECT_TYPE), + ObjectType::RunUsage => Some(RUN_USAGE_OBJECT_TYPE), ObjectType::OffsetDelta | ObjectType::HashDelta | ObjectType::OffsetZstdelta => None, } } - /// Parse a string representation of a Git object type and return an ObjectType value. pub fn from_string(s: &str) -> Result { match s { "blob" => Ok(ObjectType::Blob), @@ -177,45 +165,49 @@ impl ObjectType { "task" => Ok(ObjectType::Task), "intent" => Ok(ObjectType::Intent), "invocation" => Ok(ObjectType::ToolInvocation), - "pipeline" => Ok(ObjectType::ContextPipeline), + "context_frame" => Ok(ObjectType::ContextFrame), + "intent_event" => Ok(ObjectType::IntentEvent), + "task_event" => Ok(ObjectType::TaskEvent), + "run_event" => Ok(ObjectType::RunEvent), + "plan_step_event" => Ok(ObjectType::PlanStepEvent), + "run_usage" => Ok(ObjectType::RunUsage), _ => Err(GitError::InvalidObjectType(s.to_string())), } } - /// Convert an object type to a byte array. pub fn to_data(self) -> Result, GitError> { match self { - ObjectType::Blob => Ok(vec![0x62, 0x6c, 0x6f, 0x62]), // blob - ObjectType::Tree => Ok(vec![0x74, 0x72, 0x65, 0x65]), // tree - ObjectType::Commit => Ok(vec![0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74]), // commit - ObjectType::Tag => Ok(vec![0x74, 0x61, 0x67]), // tag - ObjectType::ContextSnapshot => Ok(vec![0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74]), // snapshot - ObjectType::Decision => Ok(vec![0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e]), // decision - ObjectType::Evidence => Ok(vec![0x65, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65]), // evidence - ObjectType::PatchSet => Ok(vec![0x70, 0x61, 0x74, 0x63, 0x68, 0x73, 0x65, 0x74]), // patchset - ObjectType::Plan => Ok(vec![0x70, 0x6c, 0x61, 0x6e]), // plan - ObjectType::Provenance => Ok(vec![ - 0x70, 0x72, 0x6f, 0x76, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, - ]), // 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 - ObjectType::ContextPipeline => Ok(vec![0x70, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65]), // pipeline + ObjectType::Blob => Ok(b"blob".to_vec()), + ObjectType::Tree => Ok(b"tree".to_vec()), + ObjectType::Commit => Ok(b"commit".to_vec()), + ObjectType::Tag => Ok(b"tag".to_vec()), + ObjectType::ContextSnapshot => Ok(b"snapshot".to_vec()), + ObjectType::Decision => Ok(b"decision".to_vec()), + ObjectType::Evidence => Ok(b"evidence".to_vec()), + ObjectType::PatchSet => Ok(b"patchset".to_vec()), + ObjectType::Plan => Ok(b"plan".to_vec()), + ObjectType::Provenance => Ok(b"provenance".to_vec()), + ObjectType::Run => Ok(b"run".to_vec()), + ObjectType::Task => Ok(b"task".to_vec()), + ObjectType::Intent => Ok(b"intent".to_vec()), + ObjectType::ToolInvocation => Ok(b"invocation".to_vec()), + ObjectType::ContextFrame => Ok(b"context_frame".to_vec()), + ObjectType::IntentEvent => Ok(b"intent_event".to_vec()), + ObjectType::TaskEvent => Ok(b"task_event".to_vec()), + ObjectType::RunEvent => Ok(b"run_event".to_vec()), + ObjectType::PlanStepEvent => Ok(b"plan_step_event".to_vec()), + ObjectType::RunUsage => Ok(b"run_usage".to_vec()), _ => Err(GitError::InvalidObjectType(self.to_string())), } } - /// Convert an object type to a number. pub fn to_u8(&self) -> u8 { match self { ObjectType::Commit => 1, ObjectType::Tree => 2, ObjectType::Blob => 3, ObjectType::Tag => 4, - ObjectType::OffsetZstdelta => 5, // Type 5 is reserved in standard Git packs; we use it for Zstd delta objects. + ObjectType::OffsetZstdelta => 5, ObjectType::OffsetDelta => 6, ObjectType::HashDelta => 7, ObjectType::ContextSnapshot => 8, @@ -228,11 +220,15 @@ impl ObjectType { ObjectType::Task => 15, ObjectType::Intent => 16, ObjectType::ToolInvocation => 17, - ObjectType::ContextPipeline => 18, + ObjectType::ContextFrame => 18, + ObjectType::IntentEvent => 19, + ObjectType::TaskEvent => 20, + ObjectType::RunEvent => 21, + ObjectType::PlanStepEvent => 22, + ObjectType::RunUsage => 23, } } - /// Convert a number to an object type. pub fn from_u8(number: u8) -> Result { match number { 1 => Ok(ObjectType::Commit), @@ -252,17 +248,18 @@ impl ObjectType { 15 => Ok(ObjectType::Task), 16 => Ok(ObjectType::Intent), 17 => Ok(ObjectType::ToolInvocation), - 18 => Ok(ObjectType::ContextPipeline), + 18 => Ok(ObjectType::ContextFrame), + 19 => Ok(ObjectType::IntentEvent), + 20 => Ok(ObjectType::TaskEvent), + 21 => Ok(ObjectType::RunEvent), + 22 => Ok(ObjectType::PlanStepEvent), + 23 => Ok(ObjectType::RunUsage), _ => Err(GitError::InvalidObjectType(format!( "Invalid object type number: {number}" ))), } } - /// Returns `true` if this type is a base Git object that can appear - /// as a delta target in pack files. AI object types return `false` - /// because they cannot be encoded in pack files and should never - /// participate in delta window selection. pub fn is_base(&self) -> bool { matches!( self, @@ -270,8 +267,6 @@ impl ObjectType { ) } - /// 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, @@ -285,7 +280,12 @@ impl ObjectType { | ObjectType::Task | ObjectType::Intent | ObjectType::ToolInvocation - | ObjectType::ContextPipeline + | ObjectType::ContextFrame + | ObjectType::IntentEvent + | ObjectType::TaskEvent + | ObjectType::RunEvent + | ObjectType::PlanStepEvent + | ObjectType::RunUsage ) } } @@ -338,45 +338,43 @@ impl From<&str> for ActorKind { } } -/// Actor reference (who created/triggered). +/// Actor reference. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ActorRef { - /// Actor kind: human, agent, system, mcp_client, or other. kind: ActorKind, - /// Subject ID (user name, agent name, or client ID). id: String, - /// Display name (optional). + #[serde(default, skip_serializing_if = "Option::is_none")] display_name: Option, - /// Auth context (optional). - auth_context: Option, } impl ActorRef { - /// Create a new ActorRef with validation. pub fn new(kind: impl Into, id: impl Into) -> Result { - let id_str = id.into(); - if id_str.trim().is_empty() { - return Err("Actor ID cannot be empty".to_string()); + let id = id.into(); + if id.trim().is_empty() { + return Err("actor id cannot be empty".to_string()); } Ok(Self { kind: kind.into(), - id: id_str, + id, display_name: None, - auth_context: None, }) } - /// Create an MCP client actor reference (MCP writes must use this). - pub fn new_for_mcp(id: impl Into) -> Result { - Self::new(ActorKind::McpClient, id) + pub fn human(id: impl Into) -> Result { + Self::new(ActorKind::Human, id) } - /// Validate that this actor is an MCP client. - pub fn ensure_mcp_client(&self) -> Result<(), String> { - if self.kind != ActorKind::McpClient { - return Err("MCP writes must use mcp_client actor kind".to_string()); - } - Ok(()) + pub fn agent(id: impl Into) -> Result { + Self::new(ActorKind::Agent, id) + } + + pub fn system(id: impl Into) -> Result { + Self::new(ActorKind::System, id) + } + + pub fn mcp_client(id: impl Into) -> Result { + Self::new(ActorKind::McpClient, id) } pub fn kind(&self) -> &ActorKind { @@ -391,54 +389,17 @@ impl ActorRef { self.display_name.as_deref() } - pub fn auth_context(&self) -> Option<&str> { - self.auth_context.as_deref() - } - pub fn set_display_name(&mut self, display_name: Option) { self.display_name = display_name; } - - pub fn set_auth_context(&mut self, auth_context: Option) { - self.auth_context = auth_context; - } - - /// Create a human actor reference. - pub fn human(id: impl Into) -> Result { - Self::new(ActorKind::Human, id) - } - - /// Create an agent actor reference. - pub fn agent(name: impl Into) -> Result { - Self::new(ActorKind::Agent, name) - } - - /// Create a system component actor reference. - pub fn system(component: impl Into) -> Result { - Self::new(ActorKind::System, component) - } - - /// Create an MCP client actor reference. - pub fn mcp_client(client_id: impl Into) -> Result { - Self::new(ActorKind::McpClient, client_id) - } } -/// External content artifact reference. +/// Reference to an external artifact. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ArtifactRef { - /// Store type (for example, local_fs or s3). store: String, - /// Storage key (for example, file path or object key). key: String, - /// MIME type (optional). - content_type: Option, - /// Size in bytes (optional). - size_bytes: Option, - /// Content hash (strongly recommended). - hash: Option, - /// Expiration time (optional). - expires_at: Option>, } impl ArtifactRef { @@ -446,19 +407,12 @@ impl ArtifactRef { let store = store.into(); let key = key.into(); if store.trim().is_empty() { - return Err("store cannot be empty".to_string()); + return Err("artifact store cannot be empty".to_string()); } if key.trim().is_empty() { - return Err("key cannot be empty".to_string()); + return Err("artifact key cannot be empty".to_string()); } - Ok(Self { - store, - key, - content_type: None, - size_bytes: None, - hash: None, - expires_at: None, - }) + Ok(Self { store, key }) } pub fn store(&self) -> &str { @@ -468,180 +422,28 @@ impl ArtifactRef { pub fn key(&self) -> &str { &self.key } - - pub fn content_type(&self) -> Option<&str> { - self.content_type.as_deref() - } - - pub fn size_bytes(&self) -> Option { - self.size_bytes - } - - pub fn hash(&self) -> Option<&IntegrityHash> { - self.hash.as_ref() - } - - pub fn expires_at(&self) -> Option> { - self.expires_at - } - - /// Calculate hash for the given content bytes. - pub fn compute_hash(content: &[u8]) -> IntegrityHash { - IntegrityHash::compute(content) - } - - /// Set the hash directly. - pub fn with_hash(mut self, hash: IntegrityHash) -> Self { - self.hash = Some(hash); - self - } - - /// Set the hash from a hex string. - pub fn with_hash_hex(mut self, hash: impl AsRef) -> Result { - let hash = hash.as_ref().parse()?; - self.hash = Some(hash); - Ok(self) - } - - pub fn set_content_type(&mut self, content_type: Option) { - self.content_type = content_type; - } - - pub fn set_size_bytes(&mut self, size_bytes: Option) { - self.size_bytes = size_bytes; - } - - pub fn set_expires_at(&mut self, expires_at: Option>) { - self.expires_at = expires_at; - } - - /// Verify whether the provided content matches the stored checksum. - #[must_use = "handle integrity verification result"] - pub fn verify_integrity(&self, content: &[u8]) -> Result { - let stored_hash = self - .hash - .as_ref() - .ok_or_else(|| "No hash stored in ArtifactRef".to_string())?; - - Ok(IntegrityHash::compute(content) == *stored_hash) - } - - /// Check whether two artifacts have the same content based on checksum. - #[must_use] - pub fn content_eq(&self, other: &Self) -> Option { - match (&self.hash, &other.hash) { - (Some(a), Some(b)) => Some(a == b), - _ => None, - } - } - - /// Check whether the artifact has expired. - #[must_use] - pub fn is_expired(&self) -> bool { - if let Some(expires_at) = self.expires_at { - expires_at < Utc::now() - } else { - false - } - } } -/// Header version compatibility helper. -/// Keeps behavior unchanged while preserving a safe default when older serialized data -/// omits `header_version`. -fn default_header_version() -> u32 { - if CURRENT_HEADER_VERSION == 1 { - CURRENT_HEADER_VERSION - } else { - 1 - } -} - -/// Current header format version for newly created objects. -pub const CURRENT_HEADER_VERSION: u32 = 1; - -/// Header shared by all AI objects. -/// -/// Contains standard metadata like ID, type, creator, and timestamps. -/// -/// # Usage -/// -/// Every AI object struct should flatten this header: -/// -/// ```rust,ignore -/// #[derive(Serialize, Deserialize)] -/// pub struct AIObject { -/// #[serde(flatten)] -/// header: Header, -/// // specific fields... -/// } -/// ``` - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +/// Shared object header for AI objects. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct Header { - /// Global unique ID (UUID v7). object_id: Uuid, - /// Object type (for example, task, run, patchset). object_type: ObjectType, - /// Format version of the Header struct itself. - /// Defaults to 1 only when processing compatibility paths for - /// pre-compat versions. - #[serde(default = "default_header_version")] - header_version: u32, - /// Per-object-type schema version for body fields. - schema_version: u32, - /// Creation time. + version: u8, created_at: DateTime, - /// Creator. created_by: ActorRef, } -/// Custom `Deserialize` for [`Header`] that materializes only -/// current header fields. -impl<'de> Deserialize<'de> for Header { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct RawHeader { - object_id: Uuid, - object_type: ObjectType, - #[serde(default = "default_header_version")] - header_version: u32, - schema_version: u32, - created_at: DateTime, - created_by: ActorRef, - } - - let raw = RawHeader::deserialize(deserializer)?; - Ok(Header { - object_id: raw.object_id, - object_type: raw.object_type, - header_version: raw.header_version, - schema_version: raw.schema_version, - created_at: raw.created_at, - created_by: raw.created_by, - }) - } -} +const CURRENT_HEADER_VERSION: u8 = 1; impl Header { - /// Create a new Header with default values. - /// - /// # Arguments - /// - /// * `object_type` - The specific type of the AI object. - /// * `created_by` - The actor (human/agent) creating this object. pub fn new(object_type: ObjectType, created_by: ActorRef) -> Result { - let now = Utc::now(); Ok(Self { object_id: Uuid::now_v7(), object_type, - header_version: CURRENT_HEADER_VERSION, - schema_version: 1, - created_at: now, + version: CURRENT_HEADER_VERSION, + created_at: Utc::now(), created_by, }) } @@ -654,12 +456,8 @@ impl Header { &self.object_type } - pub fn header_version(&self) -> u32 { - self.header_version - } - - pub fn schema_version(&self) -> u32 { - self.schema_version + pub fn version(&self) -> u8 { + self.version } pub fn created_at(&self) -> DateTime { @@ -670,260 +468,124 @@ impl Header { &self.created_by } - pub fn set_object_id(&mut self, object_id: Uuid) { - self.object_id = object_id; - } - - pub fn set_object_type(&mut self, object_type: ObjectType) -> Result<(), String> { - self.object_type = object_type; - Ok(()) - } - - pub fn set_header_version(&mut self, header_version: u32) -> Result<(), String> { - if header_version == 0 { - return Err("header_version must be greater than 0".to_string()); + pub fn set_version(&mut self, version: u8) -> Result<(), String> { + if version == 0 { + return Err("header version must be non-zero".to_string()); } - self.header_version = header_version; + self.version = version; Ok(()) } - pub fn set_schema_version(&mut self, schema_version: u32) -> Result<(), String> { - if schema_version == 0 { - return Err("schema_version must be greater than 0".to_string()); - } - self.schema_version = schema_version; - Ok(()) - } - - pub fn set_created_at(&mut self, created_at: DateTime) { - self.created_at = created_at; + pub fn checksum(&self) -> IntegrityHash { + let bytes = serde_json::to_vec(self).expect("header serialization"); + IntegrityHash::compute(&bytes) } } #[cfg(test)] mod tests { - use chrono::{DateTime, Utc}; - use uuid::Uuid; - - use crate::internal::object::types::{ - ActorKind, ActorRef, ArtifactRef, Header, IntegrityHash, ObjectType, - }; + use super::*; - /// Verify ObjectType::Blob converts to its ASCII byte representation "blob". #[test] - fn test_object_type_to_data() { - let blob = ObjectType::Blob; - let blob_bytes = blob.to_data().unwrap(); - assert_eq!(blob_bytes, vec![0x62, 0x6c, 0x6f, 0x62]); + fn test_actor_kind_serialization() { + let value = serde_json::to_string(&ActorKind::McpClient).expect("serialize"); + assert_eq!(value, "\"mcp_client\""); } - /// Verify parsing "tree" string returns ObjectType::Tree. #[test] - fn test_object_type_from_string() { - assert_eq!(ObjectType::from_string("blob").unwrap(), ObjectType::Blob); - assert_eq!(ObjectType::from_string("tree").unwrap(), ObjectType::Tree); - assert_eq!( - ObjectType::from_string("commit").unwrap(), - ObjectType::Commit - ); - assert_eq!(ObjectType::from_string("tag").unwrap(), ObjectType::Tag); - assert_eq!( - ObjectType::from_string("snapshot").unwrap(), - ObjectType::ContextSnapshot - ); - assert_eq!( - ObjectType::from_string("decision").unwrap(), - ObjectType::Decision - ); - assert_eq!( - ObjectType::from_string("evidence").unwrap(), - ObjectType::Evidence - ); - assert_eq!( - ObjectType::from_string("patchset").unwrap(), - ObjectType::PatchSet - ); - assert_eq!(ObjectType::from_string("plan").unwrap(), ObjectType::Plan); - assert_eq!( - ObjectType::from_string("provenance").unwrap(), - ObjectType::Provenance - ); - assert_eq!(ObjectType::from_string("run").unwrap(), ObjectType::Run); - assert_eq!(ObjectType::from_string("task").unwrap(), ObjectType::Task); - assert_eq!( - ObjectType::from_string("invocation").unwrap(), - ObjectType::ToolInvocation - ); - - assert!(ObjectType::from_string("invalid_type").is_err()); - } + fn test_actor_ref() { + let mut actor = ActorRef::human("alice").expect("actor"); + actor.set_display_name(Some("Alice".to_string())); - /// Verify ObjectType::Commit converts to pack type number 1. - #[test] - fn test_object_type_to_u8() { - let commit = ObjectType::Commit; - let commit_number = commit.to_u8(); - assert_eq!(commit_number, 1); + assert_eq!(actor.kind(), &ActorKind::Human); + assert_eq!(actor.id(), "alice"); + assert_eq!(actor.display_name(), Some("Alice")); } - /// Verify pack type number 4 parses to ObjectType::Tag. #[test] - fn test_object_type_from_u8() { - let tag_number = 4; - let tag = ObjectType::from_u8(tag_number).unwrap(); - assert_eq!(tag, ObjectType::Tag); + fn test_empty_actor_id() { + let err = ActorRef::human(" ").expect_err("empty actor id must fail"); + assert!(err.contains("actor id")); } #[test] fn test_header_serialization() { - let actor = ActorRef::human("jackie").expect("actor"); - let header = Header::new(ObjectType::Task, actor).expect("header"); + let actor = ActorRef::human("alice").expect("actor"); + let header = Header::new(ObjectType::Intent, actor).expect("header"); + let json = serde_json::to_value(&header).expect("serialize"); - let json = serde_json::to_string(&header).unwrap(); - let deserialized: Header = serde_json::from_str(&json).unwrap(); - - assert_eq!(header.object_id(), deserialized.object_id()); - assert_eq!(header.object_type(), deserialized.object_type()); - assert_eq!(header.header_version(), deserialized.header_version()); + assert_eq!(json["object_type"], "intent"); + assert_eq!(json["version"], 1); } #[test] fn test_header_version_new_uses_current() { - let actor = ActorRef::human("jackie").expect("actor"); - let header = Header::new(ObjectType::Task, actor).expect("header"); - assert_eq!( - header.header_version(), - crate::internal::object::types::CURRENT_HEADER_VERSION - ); - } - - #[test] - fn test_header_version_defaults_on_missing() { - // Simulate old serialized data without header_version - let json = r#"{ - "object_id": "01234567-89ab-cdef-0123-456789abcdef", - "object_type": "task", - "schema_version": 1, - "created_at": "2026-01-01T00:00:00Z", - "created_by": {"kind": "human", "id": "jackie"} - }"#; - let header: Header = serde_json::from_str(json).unwrap(); - assert_eq!(header.header_version(), 1); + let actor = ActorRef::human("alice").expect("actor"); + let header = Header::new(ObjectType::Plan, actor).expect("header"); + assert_eq!(header.version(), CURRENT_HEADER_VERSION); } #[test] fn test_header_version_setter_rejects_zero() { - let actor = ActorRef::human("jackie").expect("actor"); + let actor = ActorRef::human("alice").expect("actor"); let mut header = Header::new(ObjectType::Task, actor).expect("header"); - assert!(header.set_header_version(0).is_err()); - assert!(header.set_header_version(3).is_ok()); - assert_eq!(header.header_version(), 3); + let err = header.set_version(0).expect_err("zero must fail"); + assert!(err.contains("non-zero")); } #[test] - fn test_actor_ref() { - let actor = ActorRef::agent("coder").expect("actor"); - assert_eq!(actor.kind(), &ActorKind::Agent); - assert_eq!(actor.id(), "coder"); - - let sys = ActorRef::system("scheduler").expect("system"); - assert_eq!(sys.kind(), &ActorKind::System); - - let client = ActorRef::mcp_client("vscode").expect("client"); - assert_eq!(client.kind(), &ActorKind::McpClient); - assert!(client.ensure_mcp_client().is_ok()); - - let non_mcp = ActorRef::human("jackie").expect("actor"); - assert!(non_mcp.ensure_mcp_client().is_err()); + fn test_header_checksum() { + let actor = ActorRef::human("alice").expect("actor"); + let header = Header::new(ObjectType::Run, actor).expect("header"); + assert!(!header.checksum().to_hex().is_empty()); } #[test] - fn test_actor_kind_serialization() { - let k = ActorKind::McpClient; - let s = serde_json::to_string(&k).unwrap(); - assert_eq!(s, "\"mcp_client\""); + fn test_object_type_from_u8() { + assert_eq!( + ObjectType::from_u8(18).expect("type"), + ObjectType::ContextFrame + ); + } - let k2: ActorKind = serde_json::from_str("\"system\"").unwrap(); - assert_eq!(k2, ActorKind::System); + #[test] + fn test_object_type_to_u8() { + assert_eq!(ObjectType::RunUsage.to_u8(), 23); } #[test] - fn test_header_checksum() { - let actor = ActorRef::human("jackie").expect("actor"); - let mut header = Header::new(ObjectType::Task, actor).expect("header"); - // Fix time for deterministic checksum - header.set_created_at( - DateTime::parse_from_rfc3339("2026-02-10T00:00:00Z") - .unwrap() - .with_timezone(&Utc), + fn test_object_type_from_string() { + assert_eq!( + ObjectType::from_string("plan_step_event").expect("type"), + ObjectType::PlanStepEvent ); - header.set_object_id(Uuid::from_u128(0x00000000000000000000000000000001)); - - let checksum = - crate::internal::object::integrity::compute_integrity_hash(&header).expect("checksum"); - assert_eq!(checksum.to_hex().len(), 64); // SHA256 length - - // Ensure changes change checksum - header - .set_object_type(ObjectType::Run) - .expect("object_type"); - let checksum2 = - crate::internal::object::integrity::compute_integrity_hash(&header).expect("checksum"); - assert_ne!(checksum, checksum2); } #[test] - fn test_artifact_checksum() { - let content = b"hello world"; - let hash = ArtifactRef::compute_hash(content); - // echo -n "hello world" | shasum -a 256 - let expected_str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; - assert_eq!(hash.to_hex(), expected_str); - - let artifact = ArtifactRef::new("s3", "key") - .expect("artifact") - .with_hash(hash); - assert_eq!(artifact.hash(), Some(&hash)); - - // Integrity check - assert!(artifact.verify_integrity(content).unwrap()); - assert!(!artifact.verify_integrity(b"wrong").unwrap()); - - // Deduplication - let artifact2 = ArtifactRef::new("local", "other/path") - .expect("artifact") - .with_hash(IntegrityHash::compute(content)); - assert_eq!(artifact.content_eq(&artifact2), Some(true)); - - let artifact3 = ArtifactRef::new("s3", "diff") - .expect("artifact") - .with_hash(ArtifactRef::compute_hash(b"diff")); - assert_eq!(artifact.content_eq(&artifact3), Some(false)); + fn test_object_type_to_data() { + assert_eq!( + ObjectType::IntentEvent.to_data().expect("data"), + b"intent_event".to_vec() + ); } #[test] fn test_invalid_checksum() { - let result = ArtifactRef::new("s3", "key") - .expect("artifact") - .with_hash_hex("bad_hash"); - assert!(result.is_err()); + let err = ObjectType::from_string("unknown").expect_err("must fail"); + assert!(matches!(err, GitError::InvalidObjectType(_))); } #[test] - fn test_empty_actor_id() { - let result = ActorRef::new(ActorKind::Human, " "); - assert!(result.is_err()); + fn test_artifact_checksum() { + let artifact = ArtifactRef::new("local", "artifact-key").expect("artifact"); + assert_eq!(artifact.store(), "local"); + assert_eq!(artifact.key(), "artifact-key"); } #[test] fn test_artifact_expiration() { - let mut artifact = ArtifactRef::new("s3", "key").expect("artifact"); - assert!(!artifact.is_expired()); - - artifact.set_expires_at(Some(Utc::now() - chrono::Duration::hours(1))); - assert!(artifact.is_expired()); - - artifact.set_expires_at(Some(Utc::now() + chrono::Duration::hours(1))); - assert!(!artifact.is_expired()); + let artifact = ArtifactRef::new("s3", "bucket/key").expect("artifact"); + assert_eq!(artifact.store(), "s3"); + assert_eq!(artifact.key(), "bucket/key"); } } From 0109846072b48211bd488106650dd36d703f1fd7 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Sat, 7 Mar 2026 22:04:11 +0800 Subject: [PATCH 5/9] feat: add agent event objects Signed-off-by: Quanyi Ma --- src/internal/object/context_frame.rs | 260 +++++++++++++++++++++++ src/internal/object/intent_event.rs | 173 +++++++++++++++ src/internal/object/plan_step_event.rs | 280 +++++++++++++++++++++++++ src/internal/object/run_event.rs | 204 ++++++++++++++++++ src/internal/object/run_usage.rs | 167 +++++++++++++++ src/internal/object/task_event.rs | 171 +++++++++++++++ 6 files changed, 1255 insertions(+) create mode 100644 src/internal/object/context_frame.rs create mode 100644 src/internal/object/intent_event.rs create mode 100644 src/internal/object/plan_step_event.rs create mode 100644 src/internal/object/run_event.rs create mode 100644 src/internal/object/run_usage.rs create mode 100644 src/internal/object/task_event.rs diff --git a/src/internal/object/context_frame.rs b/src/internal/object/context_frame.rs new file mode 100644 index 00000000..d84be9c5 --- /dev/null +++ b/src/internal/object/context_frame.rs @@ -0,0 +1,260 @@ +//! Immutable context-frame event object. +//! +//! `ContextFrame` stores one durable piece of incremental workflow +//! context. +//! +//! # How to use this object +//! +//! - Create one frame whenever an incremental context fact should +//! survive history: intent analysis, step summary, code change, +//! checkpoint, tool call, or recovery note. +//! - Attach `run_id`, `plan_id`, and `step_id` when known so the frame +//! can be joined back to execution history. +//! - Persist each frame independently instead of mutating a shared +//! pipeline object. +//! +//! # How it works with other objects +//! +//! - `Plan.context_frames` freezes the planning-time context set. +//! - `PlanStepEvent.consumed_frames` and `produced_frames` express +//! runtime context flow. +//! - Libra's live context window is a projection over stored frame IDs. +//! +//! # How Libra should call it +//! +//! Libra should store every durable context increment as its own +//! `ContextFrame`, then maintain a separate in-memory or database-backed +//! window of which frame IDs are currently active. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + types::{ActorRef, Header, ObjectType}, + }, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FrameKind { + IntentAnalysis, + StepSummary, + CodeChange, + SystemState, + ErrorRecovery, + Checkpoint, + ToolCall, + Other(String), +} + +impl FrameKind { + /// Return the canonical snake_case storage/display form for the + /// frame kind. + pub fn as_str(&self) -> &str { + match self { + FrameKind::IntentAnalysis => "intent_analysis", + FrameKind::StepSummary => "step_summary", + FrameKind::CodeChange => "code_change", + FrameKind::SystemState => "system_state", + FrameKind::ErrorRecovery => "error_recovery", + FrameKind::Checkpoint => "checkpoint", + FrameKind::ToolCall => "tool_call", + FrameKind::Other(value) => value.as_str(), + } + } +} + +impl fmt::Display for FrameKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Immutable incremental context record. +/// +/// A `ContextFrame` is append-only history, not a mutable slot in a +/// buffer. Current visibility of frames is a Libra concern. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContextFrame { + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. + #[serde(flatten)] + header: Header, + /// Optional run that emitted or owns this frame. + #[serde(default, skip_serializing_if = "Option::is_none")] + run_id: Option, + /// Optional plan revision associated with this frame. + #[serde(default, skip_serializing_if = "Option::is_none")] + plan_id: Option, + /// Optional stable logical plan-step id associated with this frame. + #[serde(default, skip_serializing_if = "Option::is_none")] + step_id: Option, + /// Coarse semantic kind of context carried by this frame. + kind: FrameKind, + /// Human-readable short description of the context increment. + summary: String, + /// Optional structured payload with additional frame details. + #[serde(default, skip_serializing_if = "Option::is_none")] + data: Option, + /// Optional approximate token footprint for budgeting and retrieval. + #[serde(default, skip_serializing_if = "Option::is_none")] + token_estimate: Option, +} + +impl ContextFrame { + /// Create a new incremental context frame with the given kind and + /// summary. + pub fn new( + created_by: ActorRef, + kind: FrameKind, + summary: impl Into, + ) -> Result { + Ok(Self { + header: Header::new(ObjectType::ContextFrame, created_by)?, + run_id: None, + plan_id: None, + step_id: None, + kind, + summary: summary.into(), + data: None, + token_estimate: None, + }) + } + + /// Return the immutable header for this context frame. + pub fn header(&self) -> &Header { + &self.header + } + + /// Return the associated run id, if present. + pub fn run_id(&self) -> Option { + self.run_id + } + + /// Return the associated plan id, if present. + pub fn plan_id(&self) -> Option { + self.plan_id + } + + /// Return the associated stable plan-step id, if present. + pub fn step_id(&self) -> Option { + self.step_id + } + + /// Return the semantic frame kind. + pub fn kind(&self) -> &FrameKind { + &self.kind + } + + /// Return the short human-readable frame summary. + pub fn summary(&self) -> &str { + &self.summary + } + + /// Return the structured payload, if present. + pub fn data(&self) -> Option<&serde_json::Value> { + self.data.as_ref() + } + + /// Return the approximate token footprint, if present. + pub fn token_estimate(&self) -> Option { + self.token_estimate + } + + /// Set or clear the associated run id. + pub fn set_run_id(&mut self, run_id: Option) { + self.run_id = run_id; + } + + /// Set or clear the associated plan id. + pub fn set_plan_id(&mut self, plan_id: Option) { + self.plan_id = plan_id; + } + + /// Set or clear the associated stable plan-step id. + pub fn set_step_id(&mut self, step_id: Option) { + self.step_id = step_id; + } + + /// Set or clear the structured payload. + pub fn set_data(&mut self, data: Option) { + self.data = data; + } + + /// Set or clear the approximate token footprint. + pub fn set_token_estimate(&mut self, token_estimate: Option) { + self.token_estimate = token_estimate; + } +} + +impl fmt::Display for ContextFrame { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ContextFrame: {}", self.header.object_id()) + } +} + +impl ObjectTrait for ContextFrame { + fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result + where + Self: Sized, + { + serde_json::from_slice(data).map_err(|e| GitError::InvalidContextFrameObject(e.to_string())) + } + + fn get_type(&self) -> ObjectType { + ObjectType::ContextFrame + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute ContextFrame size: {}", e); + 0 + } + } + } + + fn to_data(&self) -> Result, GitError> { + serde_json::to_vec(self).map_err(|e| GitError::InvalidContextFrameObject(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Coverage: + // - run/plan/step association links + // - frame payload storage + // - token estimate capture + + #[test] + fn test_context_frame_fields() { + let actor = ActorRef::agent("planner").expect("actor"); + let mut frame = + ContextFrame::new(actor, FrameKind::StepSummary, "Updated API").expect("frame"); + let run_id = Uuid::from_u128(0x10); + let plan_id = Uuid::from_u128(0x11); + let step_id = Uuid::from_u128(0x12); + + frame.set_run_id(Some(run_id)); + frame.set_plan_id(Some(plan_id)); + frame.set_step_id(Some(step_id)); + frame.set_data(Some(serde_json::json!({"files": ["src/lib.rs"]}))); + frame.set_token_estimate(Some(128)); + + assert_eq!(frame.run_id(), Some(run_id)); + assert_eq!(frame.plan_id(), Some(plan_id)); + assert_eq!(frame.step_id(), Some(step_id)); + assert_eq!(frame.kind(), &FrameKind::StepSummary); + assert_eq!(frame.token_estimate(), Some(128)); + } +} diff --git a/src/internal/object/intent_event.rs b/src/internal/object/intent_event.rs new file mode 100644 index 00000000..24e962c0 --- /dev/null +++ b/src/internal/object/intent_event.rs @@ -0,0 +1,173 @@ +//! Intent lifecycle event. +//! +//! `IntentEvent` records append-only lifecycle facts for an `Intent`. +//! +//! # How to use this object +//! +//! - Append an event when an intent is analyzed, completed, or +//! cancelled. +//! - Include `result_commit` only when the lifecycle transition produced +//! a repository commit. +//! - Keep the `Intent` snapshot immutable; lifecycle belongs here. +//! +//! # How it works with other objects +//! +//! - `IntentEvent.intent_id` attaches the event to an `Intent`. +//! - `Decision` and final repository actions may feed data such as +//! `result_commit`. +//! +//! # How Libra should call it +//! +//! Libra should derive the current intent lifecycle state from the most +//! recent relevant `IntentEvent`, not by mutating the `Intent` object. + +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 IntentEventKind { + Analyzed, + Completed, + Cancelled, +} + +/// Append-only lifecycle fact for one `Intent`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IntentEvent { + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. + #[serde(flatten)] + header: Header, + /// Canonical target intent for this lifecycle fact. + intent_id: Uuid, + /// Lifecycle transition kind being recorded. + kind: IntentEventKind, + /// Optional human-readable explanation for the transition. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + /// Optional resulting repository commit associated with the event. + #[serde(default, skip_serializing_if = "Option::is_none")] + result_commit: Option, +} + +impl IntentEvent { + /// Create a new lifecycle event for the given intent. + pub fn new( + created_by: ActorRef, + intent_id: Uuid, + kind: IntentEventKind, + ) -> Result { + Ok(Self { + header: Header::new(ObjectType::IntentEvent, created_by)?, + intent_id, + kind, + reason: None, + result_commit: None, + }) + } + + /// Return the immutable header for this event. + pub fn header(&self) -> &Header { + &self.header + } + + /// Return the canonical target intent id. + pub fn intent_id(&self) -> Uuid { + self.intent_id + } + + /// Return the lifecycle transition kind. + pub fn kind(&self) -> &IntentEventKind { + &self.kind + } + + /// Return the human-readable explanation, if present. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } + + /// Return the resulting repository commit, if present. + pub fn result_commit(&self) -> Option<&IntegrityHash> { + self.result_commit.as_ref() + } + + /// Set or clear the human-readable explanation. + pub fn set_reason(&mut self, reason: Option) { + self.reason = reason; + } + + /// Set or clear the resulting repository commit. + pub fn set_result_commit(&mut self, result_commit: Option) { + self.result_commit = result_commit; + } +} + +impl fmt::Display for IntentEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "IntentEvent: {}", self.header.object_id()) + } +} + +impl ObjectTrait for IntentEvent { + 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::IntentEvent + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute IntentEvent size: {}", e); + 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::*; + + // Coverage: + // - completed intent event construction + // - optional rationale and result-commit attachment + + #[test] + fn test_intent_event_fields() { + let actor = ActorRef::agent("planner").expect("actor"); + let mut event = IntentEvent::new(actor, Uuid::from_u128(0x1), IntentEventKind::Completed) + .expect("event"); + let hash = IntegrityHash::compute(b"commit"); + event.set_reason(Some("done".to_string())); + event.set_result_commit(Some(hash.clone())); + + assert_eq!(event.kind(), &IntentEventKind::Completed); + assert_eq!(event.reason(), Some("done")); + assert_eq!(event.result_commit(), Some(&hash)); + } +} diff --git a/src/internal/object/plan_step_event.rs b/src/internal/object/plan_step_event.rs new file mode 100644 index 00000000..fb85dadc --- /dev/null +++ b/src/internal/object/plan_step_event.rs @@ -0,0 +1,280 @@ +//! Plan-step execution event. +//! +//! `PlanStepEvent` is the runtime bridge between immutable planning +//! structure and actual execution. +//! +//! # How to use this object +//! +//! - Append a new event whenever a logical plan step changes execution +//! state inside a run. +//! - Use `consumed_frames` and `produced_frames` to document context +//! flow. +//! - Set `spawned_task_id` when the step delegates work to a durable +//! `Task`. +//! - Set `outputs` when the step produced structured runtime output. +//! +//! # How it works with other objects +//! +//! - `plan_id` points to the immutable `Plan` revision. +//! - `step_id` points to the stable `PlanStep.step_id`. +//! - `run_id` ties the execution fact to the specific attempt. +//! - `ContextFrame` IDs describe what context the step consumed and +//! produced. +//! +//! # How Libra should call it +//! +//! Libra should reconstruct current step state from the ordered +//! `PlanStepEvent` stream rather than mutating `PlanStep` inside the +//! stored plan snapshot. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + types::{ActorRef, Header, ObjectType}, + }, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlanStepStatus { + Pending, + Progressing, + Completed, + Failed, + Skipped, +} + +impl PlanStepStatus { + /// Return the canonical snake_case storage/display form for the step + /// status. + pub fn as_str(&self) -> &'static str { + match self { + PlanStepStatus::Pending => "pending", + PlanStepStatus::Progressing => "progressing", + PlanStepStatus::Completed => "completed", + PlanStepStatus::Failed => "failed", + PlanStepStatus::Skipped => "skipped", + } + } +} + +impl fmt::Display for PlanStepStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Append-only execution fact for one logical plan step in one `Run`. +/// +/// The pair `(plan_id, step_id)` identifies the logical step revision, +/// while `run_id` identifies the execution attempt that produced this +/// event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanStepEvent { + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. + #[serde(flatten)] + header: Header, + /// Immutable plan revision that owns the referenced logical step. + plan_id: Uuid, + /// Stable logical step id inside the owning plan family. + step_id: Uuid, + /// Concrete execution attempt that produced this step event. + run_id: Uuid, + /// Runtime status recorded for the step at this point in the run. + status: PlanStepStatus, + /// Optional human-readable explanation for this status transition. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + /// Context frame ids consumed while executing the step. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + consumed_frames: Vec, + /// Context frame ids produced while executing the step. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + produced_frames: Vec, + /// Optional durable task spawned from this step. + #[serde(default, skip_serializing_if = "Option::is_none")] + spawned_task_id: Option, + /// Optional structured runtime outputs produced by the step. + #[serde(default, skip_serializing_if = "Option::is_none")] + outputs: Option, +} + +impl PlanStepEvent { + /// Create a new execution event for one logical plan step inside one + /// run. + pub fn new( + created_by: ActorRef, + plan_id: Uuid, + step_id: Uuid, + run_id: Uuid, + status: PlanStepStatus, + ) -> Result { + Ok(Self { + header: Header::new(ObjectType::PlanStepEvent, created_by)?, + plan_id, + step_id, + run_id, + status, + reason: None, + consumed_frames: Vec::new(), + produced_frames: Vec::new(), + spawned_task_id: None, + outputs: None, + }) + } + + /// Return the immutable header for this event. + pub fn header(&self) -> &Header { + &self.header + } + + /// Return the owning plan revision id. + pub fn plan_id(&self) -> Uuid { + self.plan_id + } + + /// Return the stable logical step id. + pub fn step_id(&self) -> Uuid { + self.step_id + } + + /// Return the concrete execution attempt id. + pub fn run_id(&self) -> Uuid { + self.run_id + } + + /// Return the recorded runtime status. + pub fn status(&self) -> &PlanStepStatus { + &self.status + } + + /// Return the human-readable explanation, if present. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } + + /// Return the context frame ids consumed by the step. + pub fn consumed_frames(&self) -> &[Uuid] { + &self.consumed_frames + } + + /// Return the context frame ids produced by the step. + pub fn produced_frames(&self) -> &[Uuid] { + &self.produced_frames + } + + /// Return the durable spawned task id, if present. + pub fn spawned_task_id(&self) -> Option { + self.spawned_task_id + } + + /// Return the structured runtime outputs, if present. + pub fn outputs(&self) -> Option<&serde_json::Value> { + self.outputs.as_ref() + } + + /// Set or clear the human-readable explanation. + pub fn set_reason(&mut self, reason: Option) { + self.reason = reason; + } + + /// Replace the consumed context frame set. + pub fn set_consumed_frames(&mut self, consumed_frames: Vec) { + self.consumed_frames = consumed_frames; + } + + /// Replace the produced context frame set. + pub fn set_produced_frames(&mut self, produced_frames: Vec) { + self.produced_frames = produced_frames; + } + + /// Set or clear the durable spawned task id. + pub fn set_spawned_task_id(&mut self, spawned_task_id: Option) { + self.spawned_task_id = spawned_task_id; + } + + /// Set or clear the structured runtime outputs. + pub fn set_outputs(&mut self, outputs: Option) { + self.outputs = outputs; + } +} + +impl fmt::Display for PlanStepEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PlanStepEvent: {}", self.header.object_id()) + } +} + +impl ObjectTrait for PlanStepEvent { + 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::PlanStepEvent + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute PlanStepEvent size: {}", e); + 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::*; + + // Coverage: + // - completed step-event creation + // - consumed/produced context frame flow + // - spawned task linkage and structured outputs + + #[test] + fn test_plan_step_event_fields() { + let actor = ActorRef::agent("planner").expect("actor"); + let mut event = PlanStepEvent::new( + actor, + Uuid::from_u128(0x1), + Uuid::from_u128(0x2), + Uuid::from_u128(0x3), + PlanStepStatus::Completed, + ) + .expect("event"); + let frame_a = Uuid::from_u128(0x10); + let frame_b = Uuid::from_u128(0x11); + let task_id = Uuid::from_u128(0x20); + + event.set_reason(Some("done".to_string())); + event.set_consumed_frames(vec![frame_a]); + event.set_produced_frames(vec![frame_b]); + event.set_spawned_task_id(Some(task_id)); + event.set_outputs(Some(serde_json::json!({"files": ["src/lib.rs"]}))); + + assert_eq!(event.status(), &PlanStepStatus::Completed); + assert_eq!(event.reason(), Some("done")); + assert_eq!(event.consumed_frames(), &[frame_a]); + assert_eq!(event.produced_frames(), &[frame_b]); + assert_eq!(event.spawned_task_id(), Some(task_id)); + } +} diff --git a/src/internal/object/run_event.rs b/src/internal/object/run_event.rs new file mode 100644 index 00000000..15b15f39 --- /dev/null +++ b/src/internal/object/run_event.rs @@ -0,0 +1,204 @@ +//! Run lifecycle event. +//! +//! `RunEvent` records append-only execution-phase facts for a `Run`. +//! +//! # How to use this object +//! +//! - Append events as a run moves through creation, patching, +//! validation, checkpoints, completion, or failure. +//! - Attach `error`, `metrics`, and `patchset_id` when they belong to +//! that phase transition. +//! - Do not mutate the `Run` snapshot to reflect phase changes. +//! +//! # How it works with other objects +//! +//! - `RunEvent.run_id` points at the execution envelope. +//! - `patchset_id` can associate a run-phase fact with a candidate +//! patchset. +//! - `Decision` normally appears after the terminal run events. +//! +//! # How Libra should call it +//! +//! Libra should derive the current run phase from the latest events and +//! scheduler state, while treating `Run` itself as the immutable attempt +//! record. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + types::{ActorRef, Header, ObjectType}, + }, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunEventKind { + Created, + Patching, + Validating, + Completed, + Failed, + Checkpointed, +} + +/// Append-only execution-phase fact for one `Run`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunEvent { + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. + #[serde(flatten)] + header: Header, + /// Canonical target run for this execution-phase fact. + run_id: Uuid, + /// Execution-phase transition kind being recorded. + kind: RunEventKind, + /// Optional human-readable explanation of the phase change. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + /// Optional human-readable error summary for failure cases. + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + /// Optional structured metrics captured for this event. + #[serde(default, skip_serializing_if = "Option::is_none")] + metrics: Option, + /// Optional patchset associated with this run-phase fact. + #[serde(default, skip_serializing_if = "Option::is_none")] + patchset_id: Option, +} + +impl RunEvent { + /// Create a new execution-phase event for the given run. + pub fn new(created_by: ActorRef, run_id: Uuid, kind: RunEventKind) -> Result { + Ok(Self { + header: Header::new(ObjectType::RunEvent, created_by)?, + run_id, + kind, + reason: None, + error: None, + metrics: None, + patchset_id: None, + }) + } + + /// Return the immutable header for this event. + pub fn header(&self) -> &Header { + &self.header + } + + /// Return the canonical target run id. + pub fn run_id(&self) -> Uuid { + self.run_id + } + + /// Return the execution-phase transition kind. + pub fn kind(&self) -> &RunEventKind { + &self.kind + } + + /// Return the human-readable reason, if present. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } + + /// Return the human-readable error message, if present. + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + /// Return structured metrics, if present. + pub fn metrics(&self) -> Option<&serde_json::Value> { + self.metrics.as_ref() + } + + /// Return the associated patchset id, if present. + pub fn patchset_id(&self) -> Option { + self.patchset_id + } + + /// Set or clear the human-readable reason. + pub fn set_reason(&mut self, reason: Option) { + self.reason = reason; + } + + /// Set or clear the human-readable error message. + pub fn set_error(&mut self, error: Option) { + self.error = error; + } + + /// Set or clear the structured metrics payload. + pub fn set_metrics(&mut self, metrics: Option) { + self.metrics = metrics; + } + + /// Set or clear the associated patchset id. + pub fn set_patchset_id(&mut self, patchset_id: Option) { + self.patchset_id = patchset_id; + } +} + +impl fmt::Display for RunEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RunEvent: {}", self.header.object_id()) + } +} + +impl ObjectTrait for RunEvent { + 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::RunEvent + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute RunEvent size: {}", e); + 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::*; + + // Coverage: + // - failed run-event creation + // - rationale, error message, metrics, and patchset association + + #[test] + fn test_run_event_fields() { + let actor = ActorRef::agent("planner").expect("actor"); + let mut event = + RunEvent::new(actor, Uuid::from_u128(0x1), RunEventKind::Failed).expect("event"); + let patchset_id = Uuid::from_u128(0x2); + event.set_reason(Some("validation failed".to_string())); + event.set_error(Some("cargo test failed".to_string())); + event.set_metrics(Some(serde_json::json!({"duration_ms": 1200}))); + event.set_patchset_id(Some(patchset_id)); + + assert_eq!(event.kind(), &RunEventKind::Failed); + assert_eq!(event.reason(), Some("validation failed")); + assert_eq!(event.error(), Some("cargo test failed")); + assert_eq!(event.patchset_id(), Some(patchset_id)); + } +} diff --git a/src/internal/object/run_usage.rs b/src/internal/object/run_usage.rs new file mode 100644 index 00000000..4d334bc2 --- /dev/null +++ b/src/internal/object/run_usage.rs @@ -0,0 +1,167 @@ +//! Run usage / cost event. +//! +//! `RunUsage` stores immutable usage totals for a `Run`. +//! +//! # How to use this object +//! +//! - Create it after the run, model call batch, or accounting phase has +//! produced stable token totals. +//! - Keep it append-only; if Libra needs additional rollups, compute +//! them in projections. +//! +//! # How it works with other objects +//! +//! - `run_id` links usage to the owning `Run`. +//! - `Provenance` supplies the corresponding provider/model +//! configuration. +//! +//! # How Libra should call it +//! +//! Libra should aggregate analytics, quotas, and billing views from +//! stored `RunUsage` records instead of backfilling usage into +//! `Provenance` or `Run`. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + types::{ActorRef, Header, ObjectType}, + }, +}; + +/// Immutable token / cost summary for one `Run`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RunUsage { + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. + #[serde(flatten)] + header: Header, + /// Canonical owning run for this usage summary. + run_id: Uuid, + /// Input tokens consumed by the run or model-call batch. + input_tokens: u64, + /// Output tokens produced by the run or model-call batch. + output_tokens: u64, + /// Precomputed total tokens for quick reads and validation. + total_tokens: u64, + /// Optional billing estimate in USD. + #[serde(default, skip_serializing_if = "Option::is_none")] + cost_usd: Option, +} + +impl RunUsage { + /// Create a new immutable usage summary for one run. + pub fn new( + created_by: ActorRef, + run_id: Uuid, + input_tokens: u64, + output_tokens: u64, + cost_usd: Option, + ) -> Result { + Ok(Self { + header: Header::new(ObjectType::RunUsage, created_by)?, + run_id, + input_tokens, + output_tokens, + total_tokens: input_tokens + output_tokens, + cost_usd, + }) + } + + /// Return the immutable header for this usage record. + pub fn header(&self) -> &Header { + &self.header + } + + /// Return the canonical owning run id. + pub fn run_id(&self) -> Uuid { + self.run_id + } + + /// Return the input token count. + pub fn input_tokens(&self) -> u64 { + self.input_tokens + } + + /// Return the output token count. + pub fn output_tokens(&self) -> u64 { + self.output_tokens + } + + /// Return the total token count. + pub fn total_tokens(&self) -> u64 { + self.total_tokens + } + + /// Return the billing estimate in USD, if present. + pub fn cost_usd(&self) -> Option { + self.cost_usd + } + + /// Validate that the stored total matches input plus output. + pub fn is_consistent(&self) -> bool { + self.total_tokens == self.input_tokens + self.output_tokens + } +} + +impl fmt::Display for RunUsage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RunUsage: {}", self.header.object_id()) + } +} + +impl ObjectTrait for RunUsage { + 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::RunUsage + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute RunUsage size: {}", e); + 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::*; + + // Coverage: + // - usage summary totals + // - consistency check + // - optional billing estimate storage + + #[test] + fn test_run_usage_fields() { + let actor = ActorRef::agent("planner").expect("actor"); + let usage = RunUsage::new(actor, Uuid::from_u128(0x1), 100, 40, Some(0.12)).expect("usage"); + + assert_eq!(usage.input_tokens(), 100); + assert_eq!(usage.output_tokens(), 40); + assert_eq!(usage.total_tokens(), 140); + assert!(usage.is_consistent()); + assert_eq!(usage.cost_usd(), Some(0.12)); + } +} diff --git a/src/internal/object/task_event.rs b/src/internal/object/task_event.rs new file mode 100644 index 00000000..3f577c6e --- /dev/null +++ b/src/internal/object/task_event.rs @@ -0,0 +1,171 @@ +//! Task lifecycle event. +//! +//! `TaskEvent` records append-only lifecycle changes for a `Task`. +//! +//! # How to use this object +//! +//! - Append events as the scheduler creates, starts, blocks, finishes, +//! fails, or cancels task execution. +//! - Set `run_id` when the event is associated with a concrete `Run`. +//! - Keep the `Task` snapshot itself stable. +//! +//! # How it works with other objects +//! +//! - `TaskEvent.task_id` points at the durable task definition. +//! - `run_id` optionally links the state change to a specific execution +//! attempt. +//! +//! # How Libra should call it +//! +//! Libra should reconstruct current task status from event history and +//! scheduler state instead of storing mutable task status inside +//! `Task`. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + types::{ActorRef, Header, ObjectType}, + }, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskEventKind { + Created, + Running, + Blocked, + Done, + Failed, + Cancelled, +} + +/// Append-only lifecycle fact for one `Task`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TaskEvent { + /// Common object header carrying the immutable object id, type, + /// creator, and timestamps. + #[serde(flatten)] + header: Header, + /// Canonical target task for this lifecycle fact. + task_id: Uuid, + /// Lifecycle transition kind being recorded. + kind: TaskEventKind, + /// Optional human-readable explanation for the transition. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + /// Optional run associated with the transition. + #[serde(default, skip_serializing_if = "Option::is_none")] + run_id: Option, +} + +impl TaskEvent { + /// Create a new lifecycle event for the given task. + pub fn new(created_by: ActorRef, task_id: Uuid, kind: TaskEventKind) -> Result { + Ok(Self { + header: Header::new(ObjectType::TaskEvent, created_by)?, + task_id, + kind, + reason: None, + run_id: None, + }) + } + + /// Return the immutable header for this event. + pub fn header(&self) -> &Header { + &self.header + } + + /// Return the canonical target task id. + pub fn task_id(&self) -> Uuid { + self.task_id + } + + /// Return the lifecycle transition kind. + pub fn kind(&self) -> &TaskEventKind { + &self.kind + } + + /// Return the human-readable explanation, if present. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } + + /// Return the associated run id, if present. + pub fn run_id(&self) -> Option { + self.run_id + } + + /// Set or clear the human-readable explanation. + pub fn set_reason(&mut self, reason: Option) { + self.reason = reason; + } + + /// Set or clear the associated run id. + pub fn set_run_id(&mut self, run_id: Option) { + self.run_id = run_id; + } +} + +impl fmt::Display for TaskEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "TaskEvent: {}", self.header.object_id()) + } +} + +impl ObjectTrait for TaskEvent { + 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::TaskEvent + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute TaskEvent size: {}", e); + 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::*; + + // Coverage: + // - task-event creation for running state + // - optional rationale and associated run linking + + #[test] + fn test_task_event_fields() { + let actor = ActorRef::agent("planner").expect("actor"); + let mut event = + TaskEvent::new(actor, Uuid::from_u128(0x1), TaskEventKind::Running).expect("event"); + let run_id = Uuid::from_u128(0x2); + event.set_reason(Some("agent started".to_string())); + event.set_run_id(Some(run_id)); + + assert_eq!(event.kind(), &TaskEventKind::Running); + assert_eq!(event.reason(), Some("agent started")); + assert_eq!(event.run_id(), Some(run_id)); + } +} From e5372c0441736d9dcdd9f8ecd8fba326ed9ce047 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Sat, 7 Mar 2026 22:26:47 +0800 Subject: [PATCH 6/9] refactor: address review feedback on object model - Remove clone-on-copy in intent_event.rs - Delegate ObjectTrait::object_hash to ObjectHash::from_type_and_data - Add ObjectType round-trip tests for variant coverage - Include mismatched intent IDs in Plan error message - Replace ParentLike trait with direct &[Uuid] parameter Co-Authored-By: Claude Opus 4.6 Signed-off-by: Quanyi Ma --- src/internal/object/intent.rs | 46 ++++----------- src/internal/object/intent_event.rs | 2 +- src/internal/object/mod.rs | 35 +----------- src/internal/object/plan.rs | 5 +- src/internal/object/types.rs | 86 +++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 70 deletions(-) diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index ee226773..c53e9f09 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -62,34 +62,6 @@ impl From<&str> for IntentSpec { } } -pub trait ParentLike { - fn parent_id(&self) -> Uuid; -} - -impl ParentLike for Uuid { - fn parent_id(&self) -> Uuid { - *self - } -} - -impl ParentLike for &Uuid { - fn parent_id(&self) -> Uuid { - **self - } -} - -impl ParentLike for Intent { - fn parent_id(&self) -> Uuid { - self.header.object_id() - } -} - -impl ParentLike for &Intent { - fn parent_id(&self) -> Uuid { - self.header.object_id() - } -} - /// Immutable request/spec revision. /// /// One stored `Intent` answers "what request revision existed here?". @@ -135,22 +107,21 @@ impl Intent { prompt: impl Into, parent: &Self, ) -> Result { - Self::new_revision_chain(created_by, prompt, &[parent]) + Self::new_revision_chain(created_by, prompt, &[parent.header.object_id()]) } /// Create a new intent revision from multiple parent intents. /// /// Use this when Libra merges several prior intent branches into a /// new request/spec revision. - #[allow(private_bounds)] - pub fn new_revision_chain( + pub fn new_revision_chain( created_by: ActorRef, prompt: impl Into, - parents: &[P], + parent_ids: &[Uuid], ) -> Result { let mut intent = Self::new(created_by, prompt)?; - for parent in parents { - intent.add_parent(parent.parent_id()); + for id in parent_ids { + intent.add_parent(*id); } Ok(intent) } @@ -254,7 +225,12 @@ mod tests { let actor = ActorRef::human("jackie").expect("actor"); let root = Intent::new(actor.clone(), "A").expect("intent"); let branch_a = Intent::new_revision_from(actor.clone(), "B", &root).expect("intent"); - let branch_b = Intent::new_revision_chain(actor, "C", &[&root, &branch_a]).expect("intent"); + let branch_b = Intent::new_revision_chain( + actor, + "C", + &[root.header().object_id(), branch_a.header().object_id()], + ) + .expect("intent"); assert_eq!(branch_a.parents(), &[root.header().object_id()]); assert_eq!( diff --git a/src/internal/object/intent_event.rs b/src/internal/object/intent_event.rs index 24e962c0..a4634195 100644 --- a/src/internal/object/intent_event.rs +++ b/src/internal/object/intent_event.rs @@ -164,7 +164,7 @@ mod tests { .expect("event"); let hash = IntegrityHash::compute(b"commit"); event.set_reason(Some("done".to_string())); - event.set_result_commit(Some(hash.clone())); + event.set_result_commit(Some(hash)); assert_eq!(event.kind(), &IntentEventKind::Completed); assert_eq!(event.reason(), Some("done")); diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index c540ecf8..fc605702 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -178,11 +178,9 @@ use std::{ io::{BufRead, Read}, }; -use sha1::Digest; - use crate::{ errors::GitError, - hash::{ObjectHash, get_hash_kind}, + hash::ObjectHash, internal::{object::types::ObjectType, zlib::stream::inflate::ReadBoxed}, }; @@ -217,35 +215,6 @@ pub trait ObjectTrait: Send + Sync + Display { fn object_hash(&self) -> Result { let data = self.to_data()?; - let object_type = self.get_type(); - let type_bytes = object_type.to_bytes().ok_or_else(|| { - GitError::InvalidObjectType(format!( - "object type `{}` cannot be hashed as a loose object", - object_type - )) - })?; - - Ok(match get_hash_kind() { - crate::hash::HashKind::Sha1 => { - let mut hash = sha1::Sha1::new(); - hash.update(type_bytes); - hash.update(b" "); - hash.update(data.len().to_string()); - hash.update(b"\0"); - hash.update(&data); - let result: [u8; 20] = hash.finalize().into(); - ObjectHash::Sha1(result) - } - crate::hash::HashKind::Sha256 => { - let mut hash = sha2::Sha256::new(); - hash.update(type_bytes); - hash.update(b" "); - hash.update(data.len().to_string()); - hash.update(b"\0"); - hash.update(&data); - let result: [u8; 32] = hash.finalize().into(); - ObjectHash::Sha256(result) - } - }) + Ok(ObjectHash::from_type_and_data(self.get_type(), &data)) } } diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index abacdad8..60cba58a 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -165,7 +165,10 @@ impl Plan { let mut plan = Self::new(created_by, first_parent.intent)?; for parent in parents { if parent.intent != first_parent.intent { - return Err("plan parents must belong to the same intent".to_string()); + return Err(format!( + "plan parents must belong to the same intent: expected {}, got {}", + first_parent.intent, parent.intent + )); } plan.add_parent(parent.header.object_id()); } diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index f32874ae..72f944b1 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -569,6 +569,92 @@ mod tests { ); } + /// All ObjectType variants for exhaustive testing. + /// Update this list whenever a new variant is added to ObjectType. + const ALL_VARIANTS: &[ObjectType] = &[ + ObjectType::Commit, + ObjectType::Tree, + ObjectType::Blob, + ObjectType::Tag, + ObjectType::OffsetZstdelta, + ObjectType::OffsetDelta, + ObjectType::HashDelta, + ObjectType::ContextSnapshot, + ObjectType::Decision, + ObjectType::Evidence, + ObjectType::PatchSet, + ObjectType::Plan, + ObjectType::Provenance, + ObjectType::Run, + ObjectType::Task, + ObjectType::Intent, + ObjectType::ToolInvocation, + ObjectType::ContextFrame, + ObjectType::IntentEvent, + ObjectType::TaskEvent, + ObjectType::RunEvent, + ObjectType::PlanStepEvent, + ObjectType::RunUsage, + ]; + + #[test] + fn test_to_u8_from_u8_round_trip() { + for variant in ALL_VARIANTS { + let n = variant.to_u8(); + let recovered = ObjectType::from_u8(n) + .unwrap_or_else(|_| panic!("from_u8({n}) failed for {variant}")); + assert_eq!( + *variant, recovered, + "to_u8/from_u8 round-trip mismatch for {variant}" + ); + } + } + + #[test] + fn test_display_from_string_round_trip() { + // Delta types have no string representation in from_string, skip them. + let skip = [ + ObjectType::OffsetZstdelta, + ObjectType::OffsetDelta, + ObjectType::HashDelta, + ]; + for variant in ALL_VARIANTS { + if skip.contains(variant) { + continue; + } + let s = variant.to_string(); + let recovered = ObjectType::from_string(&s) + .unwrap_or_else(|_| panic!("from_string({s:?}) failed for {variant}")); + assert_eq!( + *variant, recovered, + "Display/from_string round-trip mismatch for {variant}" + ); + } + } + + #[test] + fn test_to_bytes_to_data_consistency() { + for variant in ALL_VARIANTS { + if let Some(bytes) = variant.to_bytes() { + let data = variant + .to_data() + .unwrap_or_else(|_| panic!("to_data failed for {variant}")); + assert_eq!(bytes, &data[..], "to_bytes/to_data mismatch for {variant}"); + } + } + } + + #[test] + fn test_all_variants_count() { + // If you add a new ObjectType variant, add it to ALL_VARIANTS above + // and update this count. + assert_eq!( + ALL_VARIANTS.len(), + 23, + "ALL_VARIANTS count mismatch — did you add a new ObjectType variant?" + ); + } + #[test] fn test_invalid_checksum() { let err = ObjectType::from_string("unknown").expect_err("must fail"); From 7134ed993509e62115159f76ca905f8518e6a4ce Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Sun, 8 Mar 2026 01:22:19 +0800 Subject: [PATCH 7/9] Document and refine intent workflow model Signed-off-by: Quanyi Ma --- Cargo.toml | 2 +- docs/agent.md | 23 +++- src/internal/object/context_frame.rs | 25 +++- src/internal/object/intent.rs | 38 ++++++ src/internal/object/intent_event.rs | 54 +++++++- src/internal/object/mod.rs | 7 ++ src/internal/object/plan.rs | 9 +- src/internal/object/types.rs | 181 +++++++++++++++++++++++++-- 8 files changed, 320 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dc6b9dc4..7ddeadb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ sha2 = "0.10.9" crc32fast = "1.4" ring = "0.17.8" serde_json = "1.0.149" -zstd-sys = { version = "2.0.16+zstd.1.5.7", features = ["experimental"] } +zstd-sys = { version = "2.0.16", features = ["experimental"] } sea-orm = { version = "1.1.17", features = ["sqlx-sqlite"] } flate2 = { version = "1.1.9", features = ["zlib"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/docs/agent.md b/docs/agent.md index 9e6d1996..eb9909fe 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -90,8 +90,8 @@ User-facing presentation layer over the current system view. Rebuildable lookup and denormalized access structures used for fast queries. -- examples: `intent -> plans`, `task -> runs`, `run -> events`, - `run -> patchsets` +- examples: `intent -> plans`, `intent -> analysis_context_frames`, + `task -> runs`, `run -> events`, `run -> patchsets` - optimized for retrieval, filtering, and dashboard queries - not part of the immutable object graph and can be recomputed if needed @@ -103,7 +103,9 @@ Snapshot layer ============== Intent[S] --parents------------------------> Intent[S] +Intent[S] --analysis_context_frames-------> ContextFrame[E] Plan[S] --intent_id----------------------> Intent[S] +Plan[S] --context_frames-----------------> ContextFrame[E] Plan[S] --parents------------------------> Plan[S] Task[S] --intent_id?---------------------> Intent[S] Task[S] --parent_task_id?----------------> Task[S] @@ -118,6 +120,8 @@ Event layer =========== IntentEvent[E] --intent_id---------------> Intent[S] +IntentEvent[E] --next_intent_id?---------> Intent[S] +ContextFrame[E] --intent_id?--------------> Intent[S] TaskEvent[E] --task_id-----------------> Task[S] RunEvent[E] --run_id------------------> Run[S] RunUsage[E] --run_id------------------> Run[S] @@ -167,6 +171,10 @@ Thread[L] / Scheduler[L] - `Decision` - `ContextFrame` +`IntentEvent.next_intent_id` is a recommendation edge for "what +Intent should be handled next after this one completed". It does not +replace `Intent.parents`, which remains the semantic revision lineage. + ### Runtime / projection state in Libra - current selected plan head @@ -181,15 +189,19 @@ Thread[L] / Scheduler[L] Snapshot of the user request and optional analyzed spec. -- keep: `parents`, `prompt`, `spec` +- keep: `parents`, `prompt`, `spec`, `analysis_context_frames` - do not keep in snapshot: mutable status log, selected plan pointer, final commit pointer - lifecycle belongs to `IntentEvent` +- `analysis_context_frames` freezes the context used to derive this + `IntentSpec` revision ### Plan Snapshot of the strategy and step structure. - keep: `intent`, `parents`, `context_frames`, `steps` +- `context_frames` is planning-time context used to derive the plan + from the `IntentSpec`, not prompt-analysis context - `PlanStep.step_id` is the stable logical step identity across Plan revisions - execution-time step state belongs to `PlanStepEvent` @@ -228,7 +240,10 @@ Immutable model/provider configuration for one run. Immutable incremental context record. - replaces the old mutable `ContextPipeline` runtime container -- referenced by `Plan.context_frames` and `PlanStepEvent.consumed_frames` / `produced_frames` +- referenced by `Intent.analysis_context_frames`, + `Plan.context_frames`, and `PlanStepEvent.consumed_frames` / + `produced_frames` +- `intent_id` can attach a frame directly to the intent-analysis phase ## Summary Rule diff --git a/src/internal/object/context_frame.rs b/src/internal/object/context_frame.rs index d84be9c5..d201aa6c 100644 --- a/src/internal/object/context_frame.rs +++ b/src/internal/object/context_frame.rs @@ -8,13 +8,15 @@ //! - Create one frame whenever an incremental context fact should //! survive history: intent analysis, step summary, code change, //! checkpoint, tool call, or recovery note. -//! - Attach `run_id`, `plan_id`, and `step_id` when known so the frame -//! can be joined back to execution history. +//! - Attach `intent_id`, `run_id`, `plan_id`, and `step_id` when known +//! so the frame can be joined back to analysis or execution history. //! - Persist each frame independently instead of mutating a shared //! pipeline object. //! //! # How it works with other objects //! +//! - `Intent.analysis_context_frames` freezes the analysis-time context +//! set used to derive one `IntentSpec` revision. //! - `Plan.context_frames` freezes the planning-time context set. //! - `PlanStepEvent.consumed_frames` and `produced_frames` express //! runtime context flow. @@ -87,6 +89,9 @@ pub struct ContextFrame { /// creator, and timestamps. #[serde(flatten)] header: Header, + /// Optional intent revision that emitted or owns this frame. + #[serde(default, skip_serializing_if = "Option::is_none")] + intent_id: Option, /// Optional run that emitted or owns this frame. #[serde(default, skip_serializing_if = "Option::is_none")] run_id: Option, @@ -118,6 +123,7 @@ impl ContextFrame { ) -> Result { Ok(Self { header: Header::new(ObjectType::ContextFrame, created_by)?, + intent_id: None, run_id: None, plan_id: None, step_id: None, @@ -133,6 +139,11 @@ impl ContextFrame { &self.header } + /// Return the associated intent id, if present. + pub fn intent_id(&self) -> Option { + self.intent_id + } + /// Return the associated run id, if present. pub fn run_id(&self) -> Option { self.run_id @@ -168,6 +179,11 @@ impl ContextFrame { self.token_estimate } + /// Set or clear the associated intent id. + pub fn set_intent_id(&mut self, intent_id: Option) { + self.intent_id = intent_id; + } + /// Set or clear the associated run id. pub fn set_run_id(&mut self, run_id: Option) { self.run_id = run_id; @@ -232,7 +248,7 @@ mod tests { use super::*; // Coverage: - // - run/plan/step association links + // - intent/run/plan/step association links // - frame payload storage // - token estimate capture @@ -241,16 +257,19 @@ mod tests { let actor = ActorRef::agent("planner").expect("actor"); let mut frame = ContextFrame::new(actor, FrameKind::StepSummary, "Updated API").expect("frame"); + let intent_id = Uuid::from_u128(0x0f); let run_id = Uuid::from_u128(0x10); let plan_id = Uuid::from_u128(0x11); let step_id = Uuid::from_u128(0x12); + frame.set_intent_id(Some(intent_id)); frame.set_run_id(Some(run_id)); frame.set_plan_id(Some(plan_id)); frame.set_step_id(Some(step_id)); frame.set_data(Some(serde_json::json!({"files": ["src/lib.rs"]}))); frame.set_token_estimate(Some(128)); + assert_eq!(frame.intent_id(), Some(intent_id)); assert_eq!(frame.run_id(), Some(run_id)); assert_eq!(frame.plan_id(), Some(plan_id)); assert_eq!(frame.step_id(), Some(step_id)); diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index c53e9f09..5f45ba0e 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -11,11 +11,15 @@ //! branched, or merged; link earlier revisions through `parents`. //! - Fill `spec` before persistence if analysis has already produced a //! structured request. +//! - Freeze analysis-time context through `analysis_context_frames` +//! when `ContextFrame`s were used to derive the `IntentSpec`. //! //! # How it works with other objects //! //! - `Plan.intent` points back to the `Intent` that the plan belongs to. //! - `Task.intent` may point back to the originating `Intent`. +//! - `analysis_context_frames` freezes the context used to derive the +//! stored `IntentSpec`. //! - `IntentEvent` records lifecycle facts such as analyzed / //! completed / cancelled. //! @@ -86,6 +90,13 @@ pub struct Intent { /// already produced one at persistence time. #[serde(default, skip_serializing_if = "Option::is_none")] spec: Option, + /// Immutable context-frame snapshot used while deriving `spec`. + /// + /// This is distinct from `Plan.context_frames`: these frames belong + /// to the prompt-analysis / intent-spec phase rather than the + /// plan-generation phase. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + analysis_context_frames: Vec, } impl Intent { @@ -96,6 +107,7 @@ impl Intent { parents: Vec::new(), prompt: prompt.into(), spec: None, + analysis_context_frames: Vec::new(), }) } @@ -146,6 +158,12 @@ impl Intent { self.spec.as_ref() } + /// Return the analysis-time context frame ids frozen onto this + /// revision. + pub fn analysis_context_frames(&self) -> &[Uuid] { + &self.analysis_context_frames + } + /// Add one parent link if it is not already present and is not self. pub fn add_parent(&mut self, parent_id: Uuid) { if parent_id == self.header.object_id() { @@ -166,6 +184,12 @@ impl Intent { pub fn set_spec(&mut self, spec: Option) { self.spec = spec; } + + /// Replace the analysis-time context frame set for this in-memory + /// revision before persistence. + pub fn set_analysis_context_frames(&mut self, analysis_context_frames: Vec) { + self.analysis_context_frames = analysis_context_frames; + } } impl fmt::Display for Intent { @@ -209,6 +233,7 @@ mod tests { // - root intent construction defaults // - revision graph creation for single-parent and multi-parent flows // - structured spec assignment before persistence + // - frozen analysis-time context-frame references #[test] fn test_intent_creation() { @@ -218,6 +243,7 @@ mod tests { assert_eq!(intent.prompt(), "Add pagination"); assert!(intent.parents().is_empty()); assert!(intent.spec().is_none()); + assert!(intent.analysis_context_frames().is_empty()); } #[test] @@ -246,4 +272,16 @@ mod tests { intent.set_spec(Some("structured spec".into())); assert_eq!(intent.spec(), Some(&IntentSpec::from("structured spec"))); } + + #[test] + fn test_analysis_context_frames() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut intent = Intent::new(actor, "A").expect("intent"); + let frame_a = Uuid::from_u128(0x10); + let frame_b = Uuid::from_u128(0x11); + + intent.set_analysis_context_frames(vec![frame_a, frame_b]); + + assert_eq!(intent.analysis_context_frames(), &[frame_a, frame_b]); + } } diff --git a/src/internal/object/intent_event.rs b/src/internal/object/intent_event.rs index a4634195..ab87994e 100644 --- a/src/internal/object/intent_event.rs +++ b/src/internal/object/intent_event.rs @@ -8,11 +8,16 @@ //! cancelled. //! - Include `result_commit` only when the lifecycle transition produced //! a repository commit. +//! - Include `next_intent_id` on a completed event when completion +//! recommends that Libra continue with a follow-up `Intent`. //! - Keep the `Intent` snapshot immutable; lifecycle belongs here. //! //! # How it works with other objects //! //! - `IntentEvent.intent_id` attaches the event to an `Intent`. +//! - `IntentEvent.next_intent_id` can point at a recommended follow-up +//! `Intent`, but it does not replace `Intent.parents` revision +//! history. //! - `Decision` and final repository actions may feed data such as //! `result_commit`. //! @@ -39,9 +44,16 @@ use crate::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum IntentEventKind { + /// The intent has been analyzed into a structured interpretation. Analyzed, + /// The intent finished successfully. Completed, + /// The intent was cancelled before completion. Cancelled, + /// A forward-compatible lifecycle label that this binary does not + /// recognize yet. + #[serde(untagged)] + Other(String), } /// Append-only lifecycle fact for one `Intent`. @@ -62,6 +74,14 @@ pub struct IntentEvent { /// Optional resulting repository commit associated with the event. #[serde(default, skip_serializing_if = "Option::is_none")] result_commit: Option, + /// Optional recommended follow-up intent to work on next. + /// + /// This is a recommendation edge emitted when the current intent is + /// completed and the system wants to suggest the next request to + /// process. It does not express revision lineage; semantic revision + /// history still belongs in `Intent.parents`. + #[serde(default, skip_serializing_if = "Option::is_none")] + next_intent_id: Option, } impl IntentEvent { @@ -77,6 +97,7 @@ impl IntentEvent { kind, reason: None, result_commit: None, + next_intent_id: None, }) } @@ -105,6 +126,11 @@ impl IntentEvent { self.result_commit.as_ref() } + /// Return the recommended follow-up intent id, if present. + pub fn next_intent_id(&self) -> Option { + self.next_intent_id + } + /// Set or clear the human-readable explanation. pub fn set_reason(&mut self, reason: Option) { self.reason = reason; @@ -114,6 +140,11 @@ impl IntentEvent { pub fn set_result_commit(&mut self, result_commit: Option) { self.result_commit = result_commit; } + + /// Set or clear the recommended follow-up intent id. + pub fn set_next_intent_id(&mut self, next_intent_id: Option) { + self.next_intent_id = next_intent_id; + } } impl fmt::Display for IntentEvent { @@ -155,19 +186,40 @@ mod tests { // Coverage: // - completed intent event construction - // - optional rationale and result-commit attachment + // - optional rationale, result-commit attachment, and next-intent recommendation + // - forward-compatible parsing of unknown lifecycle labels #[test] fn test_intent_event_fields() { + // Scenario: a completed event preserves its kind and optional + // reason/result-commit metadata and recommended follow-up + // intent after in-memory mutation. let actor = ActorRef::agent("planner").expect("actor"); let mut event = IntentEvent::new(actor, Uuid::from_u128(0x1), IntentEventKind::Completed) .expect("event"); let hash = IntegrityHash::compute(b"commit"); + let next_intent_id = Uuid::from_u128(0x2); event.set_reason(Some("done".to_string())); event.set_result_commit(Some(hash)); + event.set_next_intent_id(Some(next_intent_id)); assert_eq!(event.kind(), &IntentEventKind::Completed); assert_eq!(event.reason(), Some("done")); assert_eq!(event.result_commit(), Some(&hash)); + assert_eq!(event.next_intent_id(), Some(next_intent_id)); + } + + #[test] + fn test_intent_event_kind_accepts_unknown_string() { + // Scenario: deserializing an unrecognized lifecycle label falls + // back to `Other(String)` so newer producers remain compatible + // with older binaries. + let kind: IntentEventKind = + serde_json::from_str("\"waiting_for_human_review\"").expect("kind"); + + assert_eq!( + kind, + IntentEventKind::Other("waiting_for_human_review".to_string()) + ); } } diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index fc605702..6a8de42b 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -74,7 +74,9 @@ //! ============== //! //! Intent --parents----------------------------> Intent +//! Intent --analysis_context_frames-----------> ContextFrame //! Plan --intent-----------------------------> Intent +//! Plan --context_frames---------------------> ContextFrame //! Plan --parents----------------------------> Plan //! Task --intent?----------------------------> Intent //! Task --parent?----------------------------> Task @@ -89,6 +91,8 @@ //! =========== //! //! IntentEvent --intent_id-------------------> Intent +//! IntentEvent --next_intent_id?-------------> Intent +//! ContextFrame --intent_id?------------------> Intent //! TaskEvent --task_id---------------------> Task //! RunEvent --run_id----------------------> Run //! RunUsage --run_id----------------------> Run @@ -119,6 +123,7 @@ //! | From | Field | To | Cardinality | //! |------|-------|----|-------------| //! | Intent | `parents` | Intent | 0..N | +//! | Intent | `analysis_context_frames` | ContextFrame | 0..N | //! | Plan | `intent` | Intent | 1 canonical | //! | Plan | `parents` | Plan | 0..N | //! | Plan | `context_frames` | ContextFrame | 0..N | @@ -132,6 +137,8 @@ //! | PatchSet | `run` | Run | 1 | //! | Provenance | `run_id` | Run | 1 | //! | IntentEvent | `intent_id` | Intent | 1 | +//! | IntentEvent | `next_intent_id` | Intent | 0..1 recommended follow-up | +//! | ContextFrame | `intent_id` | Intent | 0..1 | //! | TaskEvent | `task_id` | Task | 1 | //! | RunEvent | `run_id` | Run | 1 | //! | RunUsage | `run_id` | Run | 1 | diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index 60cba58a..617d262f 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -10,6 +10,8 @@ //! - Create a new `Plan` revision when replanning is needed. //! - Use multi-parent revisions to represent merged planning branches. //! - Freeze planning-time context through `context_frames`. +//! - Keep analysis-time context on the owning `Intent`; do not reuse +//! this field for prompt-analysis inputs. //! //! # How it works with other objects //! @@ -124,7 +126,12 @@ pub struct Plan { /// Parent plan revisions from which this plan directly derives. #[serde(default, skip_serializing_if = "Vec::is_empty")] parents: Vec, - /// Immutable context-frame snapshot used for plan derivation. + /// Immutable planning-time context-frame snapshot used for plan + /// derivation. + /// + /// This is distinct from `Intent.analysis_context_frames`, which + /// captures prompt-analysis context used while deriving the + /// `IntentSpec`. #[serde(default, skip_serializing_if = "Vec::is_empty")] context_frames: Vec, /// Immutable step structure chosen for this plan revision. diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index 72f944b1..c7410615 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -1,7 +1,26 @@ -//! Object type enumeration and AI Object Header Definition. +//! Object type, actor, artifact, and header primitives. //! -//! This module defines the common metadata header shared by all AI process objects -//! and the object type enumeration used across pack/object modules. +//! This module centralizes the low-level metadata shared across the +//! object layer: +//! +//! - `ObjectType` defines every persisted object family and the +//! conversions needed by pack encoding, loose-object style headers, +//! JSON payloads, and internal numeric ids. +//! - `ActorKind` and `ActorRef` identify who created an object. +//! - `ArtifactRef` links an object to content stored outside the Git +//! object database. +//! - `Header` is the immutable metadata envelope embedded into AI +//! objects such as `Intent`, `Plan`, or `Run`. +//! +//! One important distinction in this module is that object types live in +//! multiple identifier spaces: +//! +//! - Git pack header type bits support only core Git types and delta +//! entries. +//! - String/byte names are used for textual encodings and AI object +//! persistence. +//! - `to_u8` / `from_u8` provide a crate-local stable numeric mapping +//! that covers every variant. use std::fmt::{self, Display}; @@ -12,34 +31,64 @@ use uuid::Uuid; use super::integrity::IntegrityHash; use crate::errors::GitError; +/// Canonical object kind shared across Git-native and AI-native objects. +/// +/// The first seven variants mirror Git pack semantics. The remaining +/// variants describe the application's AI workflow objects. #[derive(PartialEq, Eq, Hash, Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ObjectType { + /// A Git commit object. Commit = 1, + /// A Git tree object. Tree, + /// A Git blob object. Blob, + /// A Git tag object. Tag, + /// A pack entry encoded as a zstd-compressed offset delta. OffsetZstdelta, + /// A pack entry encoded as an offset delta. OffsetDelta, + /// A pack entry encoded as a reference delta. HashDelta, + /// A captured slice of conversational or execution context. ContextSnapshot, + /// A recorded decision made by an agent or system. Decision, + /// Supporting evidence attached to a decision or plan. Evidence, + /// A persisted set of file or content changes. PatchSet, + /// A multi-step plan derived from an intent. Plan, + /// Provenance metadata for generated outputs or workflow state. Provenance, + /// A concrete run/execution of an agent workflow. Run, + /// A task belonging to a run or plan. Task, + /// An immutable revision of the user's request. Intent, + /// A persisted record of a tool call. ToolInvocation, + /// A frame of structured context injected into execution. ContextFrame, + /// A lifecycle event attached to an intent. IntentEvent, + /// A lifecycle event attached to a task. TaskEvent, + /// A lifecycle event attached to a run. RunEvent, + /// A lifecycle event attached to an individual plan step. PlanStepEvent, + /// Usage/accounting information recorded for a run. RunUsage, } +// Canonical byte labels used when an object type needs a stable textual +// wire representation. Delta variants intentionally do not have entries +// here because they are pack-only encodings rather than named objects. const COMMIT_OBJECT_TYPE: &[u8] = b"commit"; const TREE_OBJECT_TYPE: &[u8] = b"tree"; const BLOB_OBJECT_TYPE: &[u8] = b"blob"; @@ -92,6 +141,10 @@ impl Display for ObjectType { } impl ObjectType { + /// Convert to the 3-bit pack header type used by Git pack entries. + /// + /// Only Git-native base objects and delta encodings are valid in + /// this space. AI objects do not have a pack-header representation. pub fn to_pack_type_u8(&self) -> Result { match self { ObjectType::Commit => Ok(1), @@ -108,6 +161,7 @@ impl ObjectType { } } + /// Parse a Git pack header type number into an `ObjectType`. pub fn from_pack_type_u8(number: u8) -> Result { match number { 1 => Ok(ObjectType::Commit), @@ -123,6 +177,10 @@ impl ObjectType { } } + /// Return the canonical borrowed byte label for named object types. + /// + /// Delta entries return `None` because they are represented by pack + /// type bits rather than textual object names. pub fn to_bytes(&self) -> Option<&[u8]> { match self { ObjectType::Commit => Some(COMMIT_OBJECT_TYPE), @@ -149,6 +207,7 @@ impl ObjectType { } } + /// Parse the canonical textual object name used in persisted data. pub fn from_string(s: &str) -> Result { match s { "blob" => Ok(ObjectType::Blob), @@ -175,6 +234,9 @@ impl ObjectType { } } + /// Return the canonical textual object name as owned bytes. + /// + /// This is the owned allocation counterpart to `to_bytes()`. pub fn to_data(self) -> Result, GitError> { match self { ObjectType::Blob => Ok(b"blob".to_vec()), @@ -201,6 +263,10 @@ impl ObjectType { } } + /// Convert to the crate-local stable numeric identifier. + /// + /// Unlike pack type bits, this mapping covers every variant in the + /// enum, including AI object kinds. pub fn to_u8(&self) -> u8 { match self { ObjectType::Commit => 1, @@ -229,6 +295,7 @@ impl ObjectType { } } + /// Parse the crate-local stable numeric identifier. pub fn from_u8(number: u8) -> Result { match number { 1 => Ok(ObjectType::Commit), @@ -260,6 +327,7 @@ impl ObjectType { } } + /// Return `true` when the type is one of the four base Git objects. pub fn is_base(&self) -> bool { matches!( self, @@ -267,6 +335,7 @@ impl ObjectType { ) } + /// Return `true` when the type belongs to the AI object family. pub fn is_ai_object(&self) -> bool { matches!( self, @@ -290,14 +359,19 @@ impl ObjectType { } } -/// Actor kind enum. +/// High-level category of the actor that created an object. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ActorKind { + /// A human user. Human, + /// An autonomous or semi-autonomous agent. Agent, + /// A platform-managed system actor. System, + /// An external MCP client acting through the platform. McpClient, + /// A forward-compatible custom actor label. #[serde(untagged)] Other(String), } @@ -338,17 +412,24 @@ impl From<&str> for ActorKind { } } -/// Actor reference. +/// Reference to the actor that created or owns an object. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ActorRef { + /// Coarse actor category used for routing and display. kind: ActorKind, + /// Stable actor identifier within the actor kind namespace. id: String, + /// Optional human-friendly label for logs or UIs. #[serde(default, skip_serializing_if = "Option::is_none")] display_name: Option, } impl ActorRef { + /// Create a new actor reference. + /// + /// Empty or whitespace-only ids are rejected because object headers + /// rely on this identity being present and stable. pub fn new(kind: impl Into, id: impl Into) -> Result { let id = id.into(); if id.trim().is_empty() { @@ -361,48 +442,62 @@ impl ActorRef { }) } + /// Convenience constructor for a human actor. pub fn human(id: impl Into) -> Result { Self::new(ActorKind::Human, id) } + /// Convenience constructor for an agent actor. pub fn agent(id: impl Into) -> Result { Self::new(ActorKind::Agent, id) } + /// Convenience constructor for a system actor. pub fn system(id: impl Into) -> Result { Self::new(ActorKind::System, id) } + /// Convenience constructor for an MCP client actor. pub fn mcp_client(id: impl Into) -> Result { Self::new(ActorKind::McpClient, id) } + /// Return the actor category. pub fn kind(&self) -> &ActorKind { &self.kind } + /// Return the stable actor id. pub fn id(&self) -> &str { &self.id } + /// Return the optional UI/display label. pub fn display_name(&self) -> Option<&str> { self.display_name.as_deref() } + /// Set or clear the optional UI/display label. pub fn set_display_name(&mut self, display_name: Option) { self.display_name = display_name; } } -/// Reference to an external artifact. +/// Reference to an artifact stored outside the object database. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ArtifactRef { + /// External store identifier, such as `s3` or `local`. store: String, + /// Store-specific lookup key. key: String, } impl ArtifactRef { + /// Create a new external artifact reference. + /// + /// Both store and key must be non-empty so the reference remains + /// resolvable after persistence. pub fn new(store: impl Into, key: impl Into) -> Result { let store = store.into(); let key = key.into(); @@ -415,29 +510,38 @@ impl ArtifactRef { Ok(Self { store, key }) } + /// Return the external store identifier. pub fn store(&self) -> &str { &self.store } + /// Return the store-specific lookup key. pub fn key(&self) -> &str { &self.key } } -/// Shared object header for AI objects. +/// Shared immutable metadata header embedded into AI objects. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Header { + /// Unique object id generated at creation time. object_id: Uuid, + /// The persisted object kind. object_type: ObjectType, + /// Schema/header version for forward compatibility. version: u8, + /// Creation timestamp captured in UTC. created_at: DateTime, + /// Actor that created the object. created_by: ActorRef, } +/// Current header schema version written by new objects. const CURRENT_HEADER_VERSION: u8 = 1; impl Header { + /// Build a fresh header for a new AI object instance. pub fn new(object_type: ObjectType, created_by: ActorRef) -> Result { Ok(Self { object_id: Uuid::now_v7(), @@ -448,26 +552,35 @@ impl Header { }) } + /// Return the immutable object id. pub fn object_id(&self) -> Uuid { self.object_id } + /// Return the persisted object kind. pub fn object_type(&self) -> &ObjectType { &self.object_type } + /// Return the header schema version. pub fn version(&self) -> u8 { self.version } + /// Return the creation timestamp in UTC. pub fn created_at(&self) -> DateTime { self.created_at } + /// Return the actor that created the object. pub fn created_by(&self) -> &ActorRef { &self.created_by } + /// Override the header schema version for migration/testing paths. + /// + /// Version `0` is reserved as invalid so callers cannot accidentally + /// produce an uninitialized-looking header. pub fn set_version(&mut self, version: u8) -> Result<(), String> { if version == 0 { return Err("header version must be non-zero".to_string()); @@ -476,6 +589,10 @@ impl Header { Ok(()) } + /// Compute an integrity hash over the serialized header payload. + /// + /// This is useful when callers need a compact fingerprint of the + /// immutable metadata without hashing the full enclosing object. pub fn checksum(&self) -> IntegrityHash { let bytes = serde_json::to_vec(self).expect("header serialization"); IntegrityHash::compute(&bytes) @@ -486,14 +603,24 @@ impl Header { mod tests { use super::*; + // Coverage: + // - actor kind serde shape and actor reference validation + // - header defaults, serialization, version rules, and checksum generation + // - object-type conversions across string, bytes, pack ids, and internal ids + // - artifact reference construction for different backing stores + #[test] fn test_actor_kind_serialization() { + // Scenario: built-in actor kinds must serialize to the canonical + // snake_case wire value expected by persisted JSON payloads. let value = serde_json::to_string(&ActorKind::McpClient).expect("serialize"); assert_eq!(value, "\"mcp_client\""); } #[test] fn test_actor_ref() { + // Scenario: a valid actor reference preserves kind/id and allows + // an optional display name to be attached for UI use. let mut actor = ActorRef::human("alice").expect("actor"); actor.set_display_name(Some("Alice".to_string())); @@ -504,12 +631,16 @@ mod tests { #[test] fn test_empty_actor_id() { + // Scenario: whitespace-only actor ids are rejected so headers + // cannot be created with missing creator identity. let err = ActorRef::human(" ").expect_err("empty actor id must fail"); assert!(err.contains("actor id")); } #[test] fn test_header_serialization() { + // Scenario: a serialized header emits the canonical object type + // string and the default schema version for new objects. let actor = ActorRef::human("alice").expect("actor"); let header = Header::new(ObjectType::Intent, actor).expect("header"); let json = serde_json::to_value(&header).expect("serialize"); @@ -520,6 +651,8 @@ mod tests { #[test] fn test_header_version_new_uses_current() { + // Scenario: newly created headers always start at the current + // schema version constant rather than requiring manual setup. let actor = ActorRef::human("alice").expect("actor"); let header = Header::new(ObjectType::Plan, actor).expect("header"); assert_eq!(header.version(), CURRENT_HEADER_VERSION); @@ -527,6 +660,8 @@ mod tests { #[test] fn test_header_version_setter_rejects_zero() { + // Scenario: callers cannot downgrade the header version to the + // reserved invalid value `0`. let actor = ActorRef::human("alice").expect("actor"); let mut header = Header::new(ObjectType::Task, actor).expect("header"); let err = header.set_version(0).expect_err("zero must fail"); @@ -535,6 +670,8 @@ mod tests { #[test] fn test_header_checksum() { + // Scenario: checksum generation succeeds for a normal header and + // yields a non-empty digest string. let actor = ActorRef::human("alice").expect("actor"); let header = Header::new(ObjectType::Run, actor).expect("header"); assert!(!header.checksum().to_hex().is_empty()); @@ -542,6 +679,8 @@ mod tests { #[test] fn test_object_type_from_u8() { + // Scenario: the internal numeric object type id maps back to the + // expected AI object variant. assert_eq!( ObjectType::from_u8(18).expect("type"), ObjectType::ContextFrame @@ -550,11 +689,15 @@ mod tests { #[test] fn test_object_type_to_u8() { + // Scenario: AI-only variants still have a stable internal numeric + // id even though they are not valid Git pack header types. assert_eq!(ObjectType::RunUsage.to_u8(), 23); } #[test] fn test_object_type_from_string() { + // Scenario: persisted textual type labels decode to the matching + // enum variant for event-style AI objects. assert_eq!( ObjectType::from_string("plan_step_event").expect("type"), ObjectType::PlanStepEvent @@ -563,14 +706,18 @@ mod tests { #[test] fn test_object_type_to_data() { + // Scenario: textual serialization to owned bytes uses the same + // canonical label stored in object payloads. assert_eq!( ObjectType::IntentEvent.to_data().expect("data"), b"intent_event".to_vec() ); } - /// All ObjectType variants for exhaustive testing. - /// Update this list whenever a new variant is added to ObjectType. + /// All `ObjectType` variants for exhaustive conversion coverage. + /// + /// Update this list whenever a new enum variant is added so the + /// round-trip tests continue to exercise the full surface area. const ALL_VARIANTS: &[ObjectType] = &[ ObjectType::Commit, ObjectType::Tree, @@ -599,6 +746,8 @@ mod tests { #[test] fn test_to_u8_from_u8_round_trip() { + // Scenario: every variant can make a full round-trip through the + // crate-local numeric id without loss of information. for variant in ALL_VARIANTS { let n = variant.to_u8(); let recovered = ObjectType::from_u8(n) @@ -612,6 +761,9 @@ mod tests { #[test] fn test_display_from_string_round_trip() { + // Scenario: every named object type round-trips through + // Display/from_string; delta variants are excluded because they + // intentionally have no textual name parser. // Delta types have no string representation in from_string, skip them. let skip = [ ObjectType::OffsetZstdelta, @@ -634,6 +786,8 @@ mod tests { #[test] fn test_to_bytes_to_data_consistency() { + // Scenario: borrowed and owned textual encodings stay identical + // for every object type that has a canonical byte label. for variant in ALL_VARIANTS { if let Some(bytes) = variant.to_bytes() { let data = variant @@ -646,6 +800,9 @@ mod tests { #[test] fn test_all_variants_count() { + // Scenario: the exhaustive variant list stays in sync with the + // enum definition, preventing silent coverage gaps in the + // round-trip tests above. // If you add a new ObjectType variant, add it to ALL_VARIANTS above // and update this count. assert_eq!( @@ -657,12 +814,16 @@ mod tests { #[test] fn test_invalid_checksum() { + // Scenario: unknown textual object type labels fail with the + // expected invalid-type error instead of defaulting silently. let err = ObjectType::from_string("unknown").expect_err("must fail"); assert!(matches!(err, GitError::InvalidObjectType(_))); } #[test] fn test_artifact_checksum() { + // Scenario: an artifact reference backed by the local store keeps + // the caller-provided store/key pair unchanged after creation. let artifact = ArtifactRef::new("local", "artifact-key").expect("artifact"); assert_eq!(artifact.store(), "local"); assert_eq!(artifact.key(), "artifact-key"); @@ -670,6 +831,8 @@ mod tests { #[test] fn test_artifact_expiration() { + // Scenario: artifact references are storage-agnostic, so an S3 + // style store/key pair is accepted and exposed unchanged. let artifact = ArtifactRef::new("s3", "bucket/key").expect("artifact"); assert_eq!(artifact.store(), "s3"); assert_eq!(artifact.key(), "bucket/key"); From 44ccbbdec2f6f903a41e5572a3ade8d74974d064 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Sun, 8 Mar 2026 01:31:17 +0800 Subject: [PATCH 8/9] ci: add crates.io publish workflow on tag push Co-Authored-By: Claude Opus 4.6 Signed-off-by: Quanyi Ma --- .github/workflows/publish.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..a383d86a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish to crates.io + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Verify tag version matches Cargo.toml + run: | + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + TAG_VERSION="${GITHUB_REF_NAME#v}" + if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)" + exit 1 + fi + + - name: Publish to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} From cb12e1b69c357da2ff9406f72e7f51420a0fa157 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Sun, 8 Mar 2026 01:34:49 +0800 Subject: [PATCH 9/9] ci: add least-privilege permissions to publish workflow Signed-off-by: Quanyi Ma --- .github/workflows/publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a383d86a..78642948 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,9 @@ on: tags: - "v*" +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest