diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..78642948 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,31 @@ +name: Publish to crates.io + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +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 }} diff --git a/Cargo.lock b/Cargo.lock index 1466ee10..708c3810 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..7ddeadb2 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" } @@ -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-workflow.md b/docs/agent-workflow.md new file mode 100644 index 00000000..af968b86 --- /dev/null +++ b/docs/agent-workflow.md @@ -0,0 +1,339 @@ +# Agent Workflow: From Intent to Release + +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 + +```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 + ├─ 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 +``` + +## Libra Thread Projection and Scheduler State + +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 projection + +| Field | Type | Description | +|---|---|---| +| `thread_id` | `Uuid` | Libra-side primary key. | +| `title` | `Option` | Human-readable thread title. | +| `owner` | `ActorRef` | Conversation creator. | +| `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 + +| 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[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] +``` + +### 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 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 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 + +```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 new file mode 100644 index 00000000..eb9909fe --- /dev/null +++ b/docs/agent.md @@ -0,0 +1,254 @@ +# AI Object Model Reference + +This document describes the AI object model in `git-internal` after the +snapshot / event / Libra split. + +## Design Principle + +`git-internal` stores immutable historical facts. + +- **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?" + +High-frequency runtime state must not be accumulated by rewriting parent +objects in `git-internal`. + +## Three-Layer ASCII Diagram + +```text ++--------------------------------------------------------------------------------------+ +| 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`, `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 + +## Main Object Relationships + +```text +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] +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] + +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] +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 + +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] +``` + +## Placement Rules + +### Snapshot objects in `git-internal` + +- `Intent` +- `Plan` +- `Task` +- `Run` +- `PatchSet` +- `ContextSnapshot` +- `Provenance` + +### Event objects in `git-internal` + +- `IntentEvent` +- `TaskEvent` +- `RunEvent` +- `PlanStepEvent` +- `RunUsage` +- `ToolInvocation` +- `Evidence` +- `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 +- active task / active run +- thread heads / latest intent +- live context window +- reverse indexes and query acceleration + +## Object Notes + +### Intent + +Snapshot of the user request and optional analyzed 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` + +### Task + +Stable work definition. + +- keep: title, description, goal, constraints, acceptance criteria, requester +- keep canonical provenance links: `intent`, `parent`, `origin_step_id`, `dependencies` +- runtime progress belongs to `TaskEvent` + +### Run + +Execution-attempt envelope. + +- keep: `task`, `plan`, `commit`, `snapshot`, `environment` +- phase changes, failure details, and metrics belong to `RunEvent` +- usage/cost belongs to `RunUsage` + +### PatchSet + +Candidate diff snapshot. + +- keep: `run`, `sequence`, `commit`, `format`, `artifact`, `touched`, `rationale` +- acceptance/rejection belongs to `Decision` or Libra projection + +### Provenance + +Immutable model/provider configuration for one run. + +- keep: provider/model/parameters/temperature/max_tokens +- usage belongs to `RunUsage` + +### ContextFrame + +Immutable incremental context record. + +- replaces the old mutable `ContextPipeline` runtime container +- 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 + +```text +1. Snapshot stores "what it is" +2. Event stores "what happened" +3. Libra stores "what is current" +``` 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/docs/Analytical Report on SHA-256 Hash Computation.md b/docs/analytical-report-on-sha-256-hash-computation.md similarity index 100% rename from docs/Analytical Report on SHA-256 Hash Computation.md rename to docs/analytical-report-on-sha-256-hash-computation.md diff --git a/docs/GIT_OBJECTS.md b/docs/git-object.md similarity index 100% rename from docs/GIT_OBJECTS.md rename to docs/git-object.md diff --git a/docs/GIT_PROTOCOL_GUIDE.md b/docs/git-protocol-guide.md similarity index 100% rename from docs/GIT_PROTOCOL_GUIDE.md rename to docs/git-protocol-guide.md 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 0ff8b040..30897867 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -2,27 +2,42 @@ //! //! 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 -//! ⑤ Run ──snapshot──▶ ContextSnapshot (optional, static) -//! │ -//! ├──▶ ContextPipeline (dynamic, via Plan.pipeline) +//! ② Intent (Active) //! │ -//! ▼ -//! ⑥ ToolInvocations ... +//! └─ ③ Plan references ContextFrame IDs used for planning +//! │ +//! │ incremental ContextFrame events may continue later +//! ▼ +//! ⑤ Run created +//! │ +//! └─ context snapshot captured ──▶ ContextSnapshot (optional, static) +//! │ +//! ▼ +//! Reproducible execution baseline //! ``` //! //! 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 //! @@ -140,6 +155,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. @@ -170,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() { @@ -183,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; } @@ -192,8 +210,10 @@ 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 { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -219,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/context_frame.rs b/src/internal/object/context_frame.rs new file mode 100644 index 00000000..d201aa6c --- /dev/null +++ b/src/internal/object/context_frame.rs @@ -0,0 +1,279 @@ +//! 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 `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. +//! - 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 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, + /// 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)?, + intent_id: None, + 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 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 + } + + /// 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 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; + } + + /// 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: + // - intent/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 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)); + assert_eq!(frame.kind(), &FrameKind::StepSummary); + assert_eq!(frame.token_estimate(), Some(128)); + } +} diff --git a/src/internal/object/decision.rs b/src/internal/object/decision.rs index 4ac7c017..4d21d060 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. @@ -50,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; @@ -126,8 +143,10 @@ 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 { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -147,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")] @@ -179,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, @@ -196,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; } @@ -276,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 d1d75633..ebe5df16 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 @@ -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,8 +110,9 @@ 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 { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -157,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, @@ -176,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); } @@ -268,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 0a36218c..5f45ba0e 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -1,81 +1,37 @@ -//! AI Intent Definition +//! AI Intent snapshot. //! -//! 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). +//! `Intent` is the immutable entry point of the agent workflow. It +//! captures one revision of the user's request plus the optional +//! analyzed `IntentSpec`. //! -//! # Position in Lifecycle +//! # How to use this object //! -//! ```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 -//! ``` +//! - 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. +//! - Freeze analysis-time context through `analysis_context_frames` +//! when `ContextFrame`s were used to derive the `IntentSpec`. //! -//! ## Conversational Refinement +//! # How it works with other objects //! -//! ```text -//! Intent₀ ("Add pagination") -//! ▲ -//! │ parent -//! Intent₁ ("Also add cursor-based pagination") -//! ▲ -//! │ parent -//! Intent₂ ("Use opaque cursors, not offsets") -//! ``` +//! - `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. //! -//! 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. +//! # How Libra should call it //! -//! ## Status Transitions -//! -//! ```text -//! Draft ──▶ Active ──▶ Completed -//! │ │ -//! └──────────┴──▶ Cancelled -//! ``` -//! -//! 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. +//! 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; @@ -84,276 +40,155 @@ use crate::{ hash::ObjectHash, internal::object::{ ObjectTrait, - integrity::IntegrityHash, types::{ActorRef, Header, ObjectType}, }, }; -/// 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 `content`. -/// - `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.content` is `None`. - Draft, - /// AI interpretation is available in `Intent.content`. 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, -} - -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", - } +/// `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 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. +/// Immutable request/spec revision. /// -/// 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)] -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, -} - -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 - } - - /// Optional reason for the transition. - pub fn reason(&self) -> Option<&str> { - self.reason.as_deref() - } -} - -/// 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 -/// created at step ② and completed at step ⑩ of the end-to-end flow. -/// See module documentation for lifecycle position, status transitions, -/// and conversational refinement. +/// 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, - /// Verbatim natural-language request from the user. + /// Parent intent revisions that this revision directly derives from. /// - /// 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. + /// Multiple parents allow merge-style intent history similar to a + /// commit DAG. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + parents: Vec, + /// 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 content 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 - /// regenerated if the analysis is re-run. - #[serde(default, skip_serializing_if = "Option::is_none")] - content: 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`. + /// Structured interpretation of `prompt`, when Libra or an agent has + /// already produced one at persistence time. #[serde(default, skip_serializing_if = "Option::is_none")] - commit: Option, - /// Link to the [`Plan`](super::plan::Plan) derived from this - /// Intent. + spec: Option, + /// Immutable context-frame snapshot used while deriving `spec`. /// - /// Set after the AI analyzes `content` 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, - /// 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, + /// 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 { - /// Create a new intent in `Draft` status from a raw user prompt. - /// - /// The `content` field is initially `None` — call [`set_content`](Intent::set_content) - /// after the AI has analyzed the 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(), - content: None, - parent: None, - commit: None, - plan: None, - statuses: vec![StatusEntry::new(IntentStatus::Draft, None)], + spec: None, + analysis_context_frames: Vec::new(), }) } - /// 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 - } - - /// Returns the AI-analyzed content, if available. - pub fn content(&self) -> Option<&str> { - self.content.as_deref() - } - - /// Sets the AI-analyzed content. - pub fn set_content(&mut self, content: Option) { - self.content = content; + /// Create a new intent revision from a single parent intent. + /// + /// This is the common helper for linear refinement. + pub fn new_revision_from( + created_by: ActorRef, + prompt: impl Into, + parent: &Self, + ) -> Result { + Self::new_revision_chain(created_by, prompt, &[parent.header.object_id()]) } - /// Returns the parent intent ID, if this is part of a refinement chain. - pub fn parent(&self) -> Option { - self.parent + /// Create a new intent revision from multiple parent intents. + /// + /// Use this when Libra merges several prior intent branches into a + /// new request/spec revision. + pub fn new_revision_chain( + created_by: ActorRef, + prompt: impl Into, + parent_ids: &[Uuid], + ) -> Result { + let mut intent = Self::new(created_by, prompt)?; + for id in parent_ids { + intent.add_parent(*id); + } + Ok(intent) } - /// Returns the result commit SHA, if the intent has been fulfilled. - pub fn commit(&self) -> Option<&IntegrityHash> { - self.commit.as_ref() + /// Return the immutable header for this intent revision. + pub fn header(&self) -> &Header { + &self.header } - /// 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) + /// Return the direct parent intent ids of this revision. + pub fn parents(&self) -> &[Uuid] { + &self.parents } - /// Returns the full chronological status history. - pub fn statuses(&self) -> &[StatusEntry] { - &self.statuses + /// Return the original user prompt stored on this revision. + pub fn prompt(&self) -> &str { + &self.prompt } - /// Links this intent to a parent intent for conversational refinement. - pub fn set_parent(&mut self, parent: Option) { - self.parent = parent; + /// Return the structured request payload, if one was stored. + pub fn spec(&self) -> Option<&IntentSpec> { + self.spec.as_ref() } - /// Records the git commit SHA that fulfilled this intent. - pub fn set_commit(&mut self, sha: Option) { - self.commit = sha; + /// Return the analysis-time context frame ids frozen onto this + /// revision. + pub fn analysis_context_frames(&self) -> &[Uuid] { + &self.analysis_context_frames } - /// Returns the associated Plan ID, if a Plan has been derived from this intent. - pub fn plan(&self) -> Option { - self.plan + /// 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); + } } - /// Associates this intent with a [`Plan`](super::plan::Plan). - pub fn set_plan(&mut self, plan: Option) { - self.plan = plan; + /// Replace the parent set for this in-memory revision before + /// persistence. + pub fn set_parents(&mut self, parents: Vec) { + self.parents = parents; } - /// 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)); + /// Set or clear the structured spec for this in-memory revision. + pub fn set_spec(&mut self, spec: Option) { + self.spec = spec; } - /// 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()))); + /// 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; } } @@ -394,60 +229,59 @@ 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 + // - frozen analysis-time context-frame references + #[test] fn test_intent_creation() { let actor = ActorRef::human("jackie").expect("actor"); - let mut intent = Intent::new(actor, "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.content().is_none()); - assert_eq!(intent.status(), Some(&IntentStatus::Draft)); - assert!(intent.parent().is_none()); - assert!(intent.plan().is_none()); + assert_eq!(intent.prompt(), "Add pagination"); + assert!(intent.parents().is_empty()); + assert!(intent.spec().is_none()); + assert!(intent.analysis_context_frames().is_empty()); + } - intent.set_content(Some("Restructure the authentication module".to_string())); + #[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.header().object_id(), branch_a.header().object_id()], + ) + .expect("intent"); + + assert_eq!(branch_a.parents(), &[root.header().object_id()]); assert_eq!( - intent.content(), - Some("Restructure the authentication module") + branch_b.parents(), + &[root.header().object_id(), branch_a.header().object_id()] ); + } - // After content 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)); + #[test] + fn test_spec_assignment() { + let actor = ActorRef::human("jackie").expect("actor"); + 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"))); } #[test] - fn test_statuses() { + fn test_analysis_context_frames() { 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"); + 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 new file mode 100644 index 00000000..ab87994e --- /dev/null +++ b/src/internal/object/intent_event.rs @@ -0,0 +1,225 @@ +//! 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. +//! - 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`. +//! +//! # 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 { + /// 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`. +#[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, + /// 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 { + /// 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, + next_intent_id: 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() + } + + /// 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; + } + + /// Set or clear the resulting repository commit. + 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 { + 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, 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 5d04c07b..6a8de42b 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -1,157 +1,180 @@ //! 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, 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 +//! +------------------------------------------------------------------+ +//! | 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 -//! 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`](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 +//! Intent --analysis_context_frames-----------> ContextFrame +//! Plan --intent-----------------------------> Intent +//! Plan --context_frames---------------------> ContextFrame +//! 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 +//! IntentEvent --next_intent_id?-------------> Intent +//! ContextFrame --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 | `parent` | Intent | 0..1 | -//! | Intent | `plan` | Plan | 0..1 | -//! | Plan | `previous` | Plan | 0..1 | -//! | Plan | `pipeline` | ContextPipeline | 0..1 | -//! | PlanStep | `task` | Task | 0..1 | +//! | 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 | //! | 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 | +//! | 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 | +//! | 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; @@ -197,10 +220,6 @@ pub trait ObjectTrait: Send + Sync + Display { fn to_data(&self) -> Result, GitError>; - /// Computes the object hash from serialized data. - /// - /// Default implementation serializes the object and computes the hash from that data. - /// Override only if you need custom hash computation or caching. fn object_hash(&self) -> Result { let data = self.to_data()?; Ok(ObjectHash::from_type_and_data(self.get_type(), &data)) diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index 6f13719a..45fafe9a 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -1,69 +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) -//! ``` +//! - 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; @@ -80,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 { @@ -129,18 +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, @@ -160,154 +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. - #[serde(alias = "diff_format")] + /// 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`. - #[serde(alias = "diff_artifact")] + /// 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. - #[serde(default, alias = "touched_files")] + /// 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 { @@ -347,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() } @@ -361,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 94c23d70..00000000 --- a/src/internal/object/pipeline.rs +++ /dev/null @@ -1,586 +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 (Active) ← content analyzed -//! │ -//! ▼ -//! ContextPipeline created ← seeded with IntentAnalysis frame -//! │ -//! ▼ -//! ③ Plan (Plan.pipeline → Pipeline, Plan.fwindow = visible range) -//! │ steps execute -//! ▼ -//! Frames accumulate ← StepSummary, CodeChange, ToolCall, ... -//! │ -//! ▼ -//! 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. -//! -//! # Relationship to Other Objects -//! -//! ```text -//! Intent ──plan──→ Plan ──pipeline──→ ContextPipeline -//! │ │ -//! [PlanStep₀, ...] [IntentAnalysis, StepSummary, ...] -//! │ ▲ -//! iframes/oframes ──────────────┘ -//! ``` -//! -//! | From | Field | To | Notes | -//! |------|-------|----|-------| -//! | 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 `fwindow` 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 content. - /// - /// Created when the AI fills in the `content` 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)] -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 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 -/// `pipeline` as its context basis. See module documentation for -/// lifecycle position, eviction rules, and purpose. -#[derive(Debug, Clone, Serialize, Deserialize)] -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. - /// - /// After creation, seed it with an [`IntentAnalysis`](FrameKind::IntentAnalysis) - /// frame, then create a [`Plan`](super::plan::Plan) that references this - /// pipeline via `pipeline`. - 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 1b0a7400..617d262f 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -1,138 +1,37 @@ -//! AI Plan Definition +//! AI Plan snapshot. //! -//! A [`Plan`] is a sequence of [`PlanStep`]s derived from an -//! [`Intent`](super::intent::Intent)'s analyzed content. 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) ← content analyzed -//! │ -//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame -//! │ -//! ▼ -//! ③ Plan (pipeline, fwindow, steps) -//! │ -//! ├─ PlanStep₀ (inline) -//! ├─ PlanStep₁ ──task──▶ sub-Task (recursive) -//! └─ PlanStep₂ (inline) -//! │ -//! ▼ -//! ④ Task ──runs──▶ Run ──plan──▶ Plan (snapshot reference) -//! ``` +//! - 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`. +//! - Keep analysis-time context on the owning `Intent`; do not reuse +//! this field for prompt-analysis inputs. //! -//! # 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 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 -//! decomposition? -//! -//! ```text -//! ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] -//! ^^^^^^^^^^^^^^^^ -//! fwindow = (0, 4) -//! ``` -//! -//! When replanning occurs, a new Plan is created with an updated frame -//! range that includes frames accumulated 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` + `fwindow` 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; @@ -145,389 +44,193 @@ 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)] -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() - } -} - -/// 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. +/// 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. /// - /// Set once at creation. The `alias = "intent"` supports legacy - /// serialized data where this field was named `intent`. - #[serde(alias = "intent")] + /// `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 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")] - 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 content. +/// 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 content. - /// 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, - /// Frame visibility window `(start, end)`. - /// - /// 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)>, - /// Ordered sequence of steps to execute. + /// 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")] + parents: Vec, + /// Immutable planning-time context-frame snapshot used for plan + /// derivation. /// - /// 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. + /// 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. #[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, - fwindow: None, + 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, - fwindow: None, - 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(format!( + "plan parents must belong to the same intent: expected {}, got {}", + first_parent.intent, parent.intent + )); + } + 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 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 + /// Return the immutable step definitions stored in this plan. + pub fn steps(&self) -> &[PlanStep] { + &self.steps } - /// 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; + /// Append one logical step definition to this in-memory plan. + pub fn add_step(&mut self, step: PlanStep) { + self.steps.push(step); } } @@ -570,83 +273,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"); - - // 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())); + 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"); - // 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_fwindow() { + 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.fwindow().is_none()); + 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_fwindow(Some((0, 3))); + 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.fwindow(), Some((0, 3))); + 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_legacy_intent_field() { - let step: PlanStep = serde_json::from_value(json!({ - "intent": "run tests", - "statuses": [{"status": "pending", "changed_at": "2026-01-01T00:00:00Z"}] - })) - .expect("deserialize legacy 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] @@ -658,105 +348,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]); + assert!(value.get("step_id").is_some()); } #[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_fwindow_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))); - - 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.fwindow(), Some((2, 7))); - 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()); - } - - #[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/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/provenance.rs b/src/internal/object/provenance.rs index 8808c0e3..abeba820 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -1,37 +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 -//! │ -//! ├── patchsets ──▶ [PatchSet₀, ...] -//! ├── evidence ──▶ [Evidence₀, ...] -//! └── decision ──▶ Decision -//! ``` +//! - 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; @@ -47,97 +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)] -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, @@ -152,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 @@ -187,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 @@ -197,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 { @@ -255,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 f33d7c69..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) -//! │ -//! │ ┌─── agent execution loop ───┐ -//! │ │ │ -//! │ │ ⑥ ToolInvocation (1:N) │ -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑦ PatchSet (Proposed) │ -//! │ │ │ │ -//! │ │ ▼ │ -//! │ │ ⑧ Evidence (1:N) │ -//! │ │ │ │ -//! │ │ ├─ pass ─────────────┘ -//! │ │ └─ fail → new PatchSet -//! │ └────────────────────────────┘ -//! │ -//! ▼ -//! ⑨ Decision (terminal verdict) -//! ``` +//! - 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,72 +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(), @@ -169,101 +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 { @@ -271,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() } } @@ -386,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() } @@ -394,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/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.rs b/src/internal/object/task.rs index 7aece93a..5109f6f3 100644 --- a/src/internal/object/task.rs +++ b/src/internal/object/task.rs @@ -1,76 +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) -//! ``` +//! - 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}; @@ -86,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 { @@ -146,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", @@ -164,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(), } } } @@ -195,116 +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, @@ -320,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 { @@ -450,89 +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", - "updated_at": "2026-01-01T00:00:00Z", - "created_by": {"kind": "human", "id": "jackie"}, - "visibility": "private", - "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/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)); + } +} diff --git a/src/internal/object/tool.rs b/src/internal/object/tool.rs index 364a45c6..abf8b030 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 @@ -24,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 @@ -59,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", @@ -79,6 +91,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. @@ -94,8 +107,10 @@ 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 { /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] @@ -151,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, @@ -168,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); } @@ -256,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 00faf536..c7410615 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -1,72 +1,94 @@ -//! 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::{ - 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. +/// Canonical object kind shared across Git-native and AI-native objects. /// -/// 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. -/// -/// * `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. +/// 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, - OffsetZstdelta, // Private extension for Zstandard-compressed delta objects + /// 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, - ContextPipeline, + /// 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"; @@ -81,9 +103,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 trait for Git objects type impl Display for ObjectType { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { @@ -104,18 +130,21 @@ 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"), } } } -/// Display trait for Git objects type impl ObjectType { - /// Convert object type to 3-bit pack header type id. + /// Convert to the 3-bit pack header type used by Git pack entries. /// - /// 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. + /// 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), @@ -132,7 +161,7 @@ impl ObjectType { } } - /// Decode 3-bit pack header type id to object type. + /// 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), @@ -148,11 +177,10 @@ impl ObjectType { } } - /// Returns the loose-object type header bytes (e.g. `b"commit"`, `b"blob"`). + /// Return the canonical borrowed byte label for named object types. /// - /// 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`. + /// 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), @@ -169,12 +197,17 @@ 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, } } - /// Parses a string representation of a Git object type and returns an ObjectType value + /// Parse the canonical textual object name used in persisted data. pub fn from_string(s: &str) -> Result { match s { "blob" => Ok(ObjectType::Blob), @@ -191,45 +224,56 @@ 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. + /// 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(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. + /// 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, 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, @@ -242,11 +286,16 @@ 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. + /// Parse the crate-local stable numeric identifier. pub fn from_u8(number: u8) -> Result { match number { 1 => Ok(ObjectType::Commit), @@ -266,17 +315,19 @@ 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. + /// Return `true` when the type is one of the four base Git objects. pub fn is_base(&self) -> bool { matches!( self, @@ -284,8 +335,7 @@ impl ObjectType { ) } - /// Returns `true` if this type is an AI extension object (not representable - /// in the 3-bit Git pack header). + /// Return `true` when the type belongs to the AI object family. pub fn is_ai_object(&self) -> bool { matches!( self, @@ -299,19 +349,29 @@ impl ObjectType { | ObjectType::Task | ObjectType::Intent | ObjectType::ToolInvocation - | ObjectType::ContextPipeline + | ObjectType::ContextFrame + | ObjectType::IntentEvent + | ObjectType::TaskEvent + | ObjectType::RunEvent + | ObjectType::PlanStepEvent + | ObjectType::RunUsage ) } } -/// 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), } @@ -352,708 +412,429 @@ impl From<&str> for ActorKind { } } -/// Actor reference (who created/triggered). +/// Reference to the actor that created or owns an object. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ActorRef { - /// Kind: human/agent/system/mcp_client + /// Coarse actor category used for routing and display. kind: ActorKind, - /// Subject ID (user/agent name or client ID) + /// Stable actor identifier within the actor kind namespace. id: String, - /// Display name (optional) + /// Optional human-friendly label for logs or UIs. + #[serde(default, skip_serializing_if = "Option::is_none")] display_name: Option, - /// Auth context (optional, Libra usually empty) - auth_context: Option, } impl ActorRef { - /// Create a new ActorRef with validation. + /// 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_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) + /// Convenience constructor for a human actor. + 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(()) + /// 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() } - pub fn auth_context(&self) -> Option<&str> { - self.auth_context.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; } - - 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) - } } -/// Artifact reference (external content). +/// Reference to an artifact stored outside the object database. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ArtifactRef { - /// Store type: local_fs/s3 + /// External store identifier, such as `s3` or `local`. store: String, - /// Storage key (e.g., path or object key) + /// Store-specific lookup 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 { + /// 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(); 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 }) } + /// 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 } - - 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 if 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 if 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 if 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 - } - } -} - -fn default_header_version() -> u32 { - 1 } -/// Current header format version for newly created objects. -pub const CURRENT_HEADER_VERSION: u32 = 1; - -/// Header shared by all AI Process 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 immutable metadata header embedded into AI objects. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct Header { - /// Global unique ID (UUID v7) + /// Unique object id generated at creation time. object_id: Uuid, - /// Object type (task/run/patchset/...) + /// The persisted object kind. object_type: ObjectType, - /// Format version of the Header struct itself. - /// Defaults to 1 when deserializing old data that lacks this field. - #[serde(default = "default_header_version")] - header_version: u32, - /// Per-object-type schema version for body fields. - schema_version: u32, - /// Creation time + /// Schema/header version for forward compatibility. + version: u8, + /// Creation timestamp captured in UTC. 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 + /// Actor that created the object. 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. -impl<'de> Deserialize<'de> for Header { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct RawHeader { - object_id: Uuid, - object_type: ObjectType, - #[serde(default = "default_header_version")] - 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)?; - 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, - 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, - }) - } -} +/// Current header schema version written by new objects. +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. + /// Build a fresh header for a new AI object instance. 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, - updated_at: now, + version: CURRENT_HEADER_VERSION, + created_at: Utc::now(), created_by, - visibility: Visibility::Private, - tags: HashMap::new(), - external_ids: HashMap::new(), - checksum: None, }) } + /// 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 } - pub fn header_version(&self) -> u32 { - self.header_version - } - - pub fn schema_version(&self) -> u32 { - self.schema_version + /// 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 } - pub fn updated_at(&self) -> DateTime { - self.updated_at - } - + /// Return the actor that created the object. 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; - } - - 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()); - } - self.header_version = header_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()); + /// 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()); } - self.schema_version = schema_version; + self.version = version; Ok(()) } - 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. + /// Compute an integrity hash over the serialized header payload. /// - /// 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) - } - } + /// 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) } } #[cfg(test)] mod tests { - use chrono::{DateTime, Utc}; - use uuid::Uuid; + use super::*; - use crate::internal::object::types::{ - ActorKind, ActorRef, ArtifactRef, Header, IntegrityHash, ObjectType, - }; + // 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 - /// 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() { + // 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\""); } - /// 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() { + // 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())); - /// 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() { + // 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() { - let actor = ActorRef::human("jackie").expect("actor"); - let header = Header::new(ObjectType::Task, actor).expect("header"); - - let json = serde_json::to_string(&header).unwrap(); - let deserialized: Header = serde_json::from_str(&json).unwrap(); + // 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"); - 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", - "updated_at": "2026-01-01T00:00:00Z", - "created_by": {"kind": "human", "id": "jackie"}, - "visibility": "private" - }"#; - let header: Header = serde_json::from_str(json).unwrap(); - assert_eq!(header.header_version(), 1); + // 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); } #[test] fn test_header_version_setter_rejects_zero() { - let actor = ActorRef::human("jackie").expect("actor"); + // 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"); - 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() { + // 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()); } #[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() { + // 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 + ); + } - let k2: ActorKind = serde_json::from_str("\"system\"").unwrap(); - assert_eq!(k2, ActorKind::System); + #[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_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() { + // 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 ); - 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() { + // 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 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, + 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_invalid_checksum() { - let result = ArtifactRef::new("s3", "key") - .expect("artifact") - .with_hash_hex("bad_hash"); - assert!(result.is_err()); + 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) + .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_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); + 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, + 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_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"}); + 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 + .to_data() + .unwrap_or_else(|_| panic!("to_data failed for {variant}")); + assert_eq!(bytes, &data[..], "to_bytes/to_data mismatch for {variant}"); + } + } + } - header.seal(&content).expect("seal"); + #[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!( + ALL_VARIANTS.len(), + 23, + "ALL_VARIANTS count mismatch — did you add a new ObjectType variant?" + ); + } - let after = header.updated_at(); - assert!(after >= before); + #[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_empty_actor_id() { - let result = ActorRef::new(ActorKind::Human, " "); - assert!(result.is_err()); + 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"); } #[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()); + // 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"); } }