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;
28pub mod autonomous;
29pub mod registry;
30mod state;
31pub mod store;
32pub mod supervisor;
33
34pub use accounting::GoalAccounting;
35pub use autonomous::{AutonomousDriver, AutonomousSession, AutonomousState, SupervisorVerdict};
36pub use registry::AutonomousRegistry;
37pub use state::GoalStatus;
38pub use store::{GoalError, GoalStore};
39pub use supervisor::GoalSupervisor;
40
41use chrono::{DateTime, Utc};
42
43/// A persisted long-horizon goal row.
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct Goal {
46 /// Unique identifier (UUID v4 string).
47 pub id: String,
48 /// User-provided goal text (max `[goals] max_text_chars` chars at creation time).
49 pub text: String,
50 /// Current FSM status.
51 pub status: GoalStatus,
52 /// Optional token budget. `None` means unlimited.
53 pub token_budget: Option<i64>,
54 /// Number of conversation turns completed under this goal.
55 pub turns_used: i64,
56 /// Total tokens consumed across all turns under this goal.
57 pub tokens_used: i64,
58 /// When the goal was created.
59 pub created_at: DateTime<Utc>,
60 /// Last mutation time; used as a CAS guard for stale-update detection.
61 pub updated_at: DateTime<Utc>,
62 /// When the goal reached `Completed` or `Cleared`. `None` while active or paused.
63 pub completed_at: Option<DateTime<Utc>>,
64}
65
66/// Lightweight, cross-crate snapshot of an active goal.
67///
68/// Carries only what TUI and command handlers need, without pulling `zeph-core` into `zeph-tui`.
69#[derive(Debug, Clone, serde::Serialize)]
70pub struct GoalSnapshot {
71 /// UUID string of the goal.
72 pub id: String,
73 /// Goal text, pre-validated to fit within `max_text_chars`.
74 pub text: String,
75 /// Current FSM status.
76 pub status: GoalStatus,
77 /// Number of turns completed.
78 pub turns_used: u64,
79 /// Total tokens consumed.
80 pub tokens_used: u64,
81 /// Optional token budget (`None` = unlimited).
82 pub token_budget: Option<u64>,
83}
84
85impl From<Goal> for GoalSnapshot {
86 fn from(g: Goal) -> Self {
87 Self {
88 id: g.id,
89 text: g.text,
90 status: g.status,
91 turns_used: g.turns_used.max(0).cast_unsigned(),
92 tokens_used: g.tokens_used.max(0).cast_unsigned(),
93 token_budget: g.token_budget.map(|b| b.max(0).cast_unsigned()),
94 }
95 }
96}