zeph_core/goal/mod.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Long-horizon goal lifecycle subsystem.
5//!
6//! A goal is a persistent user intent that spans multiple turns. At most one goal
7//! can be `Active` at a time. The subsystem tracks token consumption per turn and
8//! injects an `<active_goal>` block into the volatile system-prompt region.
9//!
10//! ## Architecture
11//!
12//! - [`GoalStatus`] — FSM states with valid transition table.
13//! - [`Goal`] / [`GoalSnapshot`] — full DB row vs. lightweight cross-crate view.
14//! - [`GoalStore`] — SQLite/Postgres-backed persistence with transactional `create()`.
15//! - [`GoalAccounting`] — per-turn token accounting service.
16//!
17//! ## Invariants
18//!
19//! - **G1**: At most one `Active` goal per database. Enforced jointly by the partial
20//! unique index and the transactional `GoalStore::create`.
21//! - **G2**: Stale transitions return [`GoalError::StaleUpdate`]; handlers refetch silently.
22//! - **G3**: `<active_goal>` block appears only after `<!-- cache:volatile -->` in the
23//! system prompt. Enforced by a snapshot test in `context/assembly.rs`.
24//! - **G4**: `GoalAccounting::on_turn_complete` is fire-and-forget. DB write failures
25//! log a `WARN` and never abort the turn.
26
27mod accounting;
28mod state;
29pub mod store;
30
31pub use accounting::GoalAccounting;
32pub use state::GoalStatus;
33pub use store::{GoalError, GoalStore};
34
35use chrono::{DateTime, Utc};
36
37/// A persisted long-horizon goal row.
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub struct Goal {
40 /// Unique identifier (UUID v4 string).
41 pub id: String,
42 /// User-provided goal text (max `[goals] max_text_chars` chars at creation time).
43 pub text: String,
44 /// Current FSM status.
45 pub status: GoalStatus,
46 /// Optional token budget. `None` means unlimited.
47 pub token_budget: Option<i64>,
48 /// Number of conversation turns completed under this goal.
49 pub turns_used: i64,
50 /// Total tokens consumed across all turns under this goal.
51 pub tokens_used: i64,
52 /// When the goal was created.
53 pub created_at: DateTime<Utc>,
54 /// Last mutation time; used as a CAS guard for stale-update detection.
55 pub updated_at: DateTime<Utc>,
56 /// When the goal reached `Completed` or `Cleared`. `None` while active or paused.
57 pub completed_at: Option<DateTime<Utc>>,
58}
59
60/// Lightweight, cross-crate snapshot of an active goal.
61///
62/// Carries only what TUI and command handlers need, without pulling `zeph-core` into `zeph-tui`.
63#[derive(Debug, Clone, serde::Serialize)]
64pub struct GoalSnapshot {
65 /// UUID string of the goal.
66 pub id: String,
67 /// Goal text, pre-validated to fit within `max_text_chars`.
68 pub text: String,
69 /// Current FSM status.
70 pub status: GoalStatus,
71 /// Number of turns completed.
72 pub turns_used: u64,
73 /// Total tokens consumed.
74 pub tokens_used: u64,
75 /// Optional token budget (`None` = unlimited).
76 pub token_budget: Option<u64>,
77}
78
79impl From<Goal> for GoalSnapshot {
80 fn from(g: Goal) -> Self {
81 Self {
82 id: g.id,
83 text: g.text,
84 status: g.status,
85 turns_used: g.turns_used.max(0).cast_unsigned(),
86 tokens_used: g.tokens_used.max(0).cast_unsigned(),
87 token_budget: g.token_budget.map(|b| b.max(0).cast_unsigned()),
88 }
89 }
90}