liminal_server/agents/records.rs
1//! Durable agent metadata. Debug deliberately excludes all free text.
2
3use serde::{Deserialize, Serialize};
4
5/// A harness label, never a selector of server behaviour.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub enum AgentHarness {
8 /// Codex with channel wake.
9 #[serde(rename = "cdx")]
10 Cdx,
11 /// Claude Code.
12 #[serde(rename = "claude-code")]
13 ClaudeCode,
14 /// Codex through the ACP host.
15 #[serde(rename = "codex")]
16 Codex,
17 /// Gemini CLI through the ACP host.
18 #[serde(rename = "gemini-cli")]
19 GeminiCli,
20 /// `OpenCode` through the ACP host.
21 #[serde(rename = "opencode")]
22 Opencode,
23 /// Pi through its separately launched host.
24 #[serde(rename = "pi")]
25 Pi,
26 /// A seat's own station, placed by the seat on its first inbox listen
27 /// (ACCESS-013); whatever runs behind the seat is not the server's to name.
28 #[serde(rename = "seat")]
29 Seat,
30}
31
32/// Fields a person approves when creating an agent.
33#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct AgentSpec {
36 /// Its declared harness.
37 pub harness: AgentHarness,
38 /// One sentence stating its job.
39 pub purpose: String,
40 /// The service it builds, when it is a builder.
41 pub service: Option<String>,
42}
43
44/// Events retained forever in the exchange's store.
45#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(
47 tag = "event",
48 content = "detail",
49 rename_all = "snake_case",
50 deny_unknown_fields
51)]
52pub enum AgentEvent {
53 /// A person approved a new station.
54 Create(AgentSpec),
55 /// Lys answered the deliberate start.
56 Start,
57 /// Lys answered the deliberate stop.
58 Stop,
59 /// A verified ticket subscribed its inbox.
60 Present {
61 /// The connection's process id.
62 connection: u64,
63 },
64 /// That connection closed with this stable transport or disconnect name.
65 Gone {
66 /// The closed connection.
67 connection: u64,
68 /// The stable reason, never a transport's payload.
69 reason: String,
70 },
71 /// The host negotiated this session; old histories have no such event.
72 Session {
73 session: String,
74 protocol_version: u8,
75 load_session: bool,
76 },
77 /// An observed host ending never invents resume capability.
78 SessionClosed {
79 exit_status: i32,
80 session_state: String,
81 },
82 /// The station is permanently retired; its history remains.
83 Retire,
84}
85
86/// A transition always names its actor and time.
87#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct AgentTransition {
90 /// The actor proved by the pass, or the server for observed departures.
91 pub holder: String,
92 /// Epoch milliseconds of this transition.
93 pub at_ms: u64,
94 /// What changed.
95 pub change: AgentEvent,
96}
97
98/// One station, as returned after restoration.
99#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct AgentRecord {
102 /// One segment, exactly the Lys agent holder id.
103 pub name: String,
104 /// The profile's harness label.
105 pub harness: AgentHarness,
106 /// One sentence stating its job.
107 pub purpose: String,
108 /// The person who created it.
109 pub owner: String,
110 /// The service it builds.
111 pub service: Option<String>,
112 /// Epoch milliseconds of creation.
113 pub created_at_ms: u64,
114 /// Every transition, oldest first; retirement never removes one.
115 pub transitions: Vec<AgentTransition>,
116}
117
118impl AgentRecord {
119 /// Whether this station still occupies a configured slot.
120 #[must_use]
121 pub fn active(&self) -> bool {
122 self.transitions
123 .last()
124 .is_none_or(|transition| !matches!(transition.change, AgentEvent::Retire))
125 }
126
127 /// Its last observed connection; lifecycle requests do not invent presence.
128 #[must_use]
129 pub fn connection(&self) -> Option<u64> {
130 self.seat().map(|(connection, _)| connection)
131 }
132
133 /// The present connection and the epoch milliseconds it was admitted.
134 #[must_use]
135 pub fn seat(&self) -> Option<(u64, u64)> {
136 self.transitions
137 .iter()
138 .rev()
139 .find_map(|transition| match transition.change {
140 AgentEvent::Present { connection } => Some(Some((connection, transition.at_ms))),
141 AgentEvent::Gone { .. } | AgentEvent::Retire => Some(None),
142 _ => None,
143 })
144 .flatten()
145 }
146}
147
148impl std::fmt::Debug for AgentRecord {
149 fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 out.debug_struct("AgentRecord")
151 .field("harness", &self.harness)
152 .field("transitions", &self.transitions.len())
153 .finish_non_exhaustive()
154 }
155}
156
157/// Immutable prepared words, supplied only by the approved-draft executor.
158#[derive(Clone, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct AgentAct {
161 /// Name of the placed agent.
162 pub name: String,
163 /// The original verified holder, kept in the immutable draft.
164 pub holder: String,
165 /// The caller's responsible person, proved by Lys; never a request override.
166 pub owner: String,
167 /// Preparation time, in epoch milliseconds.
168 pub at_ms: u64,
169 /// The deliberate event.
170 pub change: AgentEvent,
171}
172
173impl AgentAct {
174 /// Prepares immutable words from verified claims. An agent's builders
175 /// count against its responsible person, so changing creator agent ids
176 /// cannot evade the person's configured bound.
177 ///
178 /// # Errors
179 /// The verified holder is neither a person nor an agent with a responsible person.
180 pub fn prepare(
181 name: String,
182 change: AgentEvent,
183 pass: &lys_pass::VerifiedPass,
184 at_ms: u64,
185 ) -> Result<Self, super::error::AgentError> {
186 let holder = &pass.claims().holder;
187 let owner = match holder.kind.as_str() {
188 "person" => holder.id.as_str(),
189 "agent" => holder
190 .responsible
191 .as_deref()
192 .ok_or(super::error::AgentError::Invalid(
193 "agent responsible person",
194 ))?,
195 _ => {
196 return Err(super::error::AgentError::Invalid(
197 "agent creation holder kind",
198 ));
199 }
200 };
201 if owner.is_empty() {
202 return Err(super::error::AgentError::Invalid(
203 "agent responsible person",
204 ));
205 }
206 Ok(Self {
207 name,
208 holder: holder.id.clone(),
209 owner: owner.into(),
210 at_ms,
211 change,
212 })
213 }
214}