返回 CodeWhale
liveness.rs
根目录 / crates / tui / src / work_graph / liveness.rs
1 //! Owner-facing lifecycle adapter vocabulary.
2 //!
3 //! Owners keep their existing registries, ledgers, and process handles. They
4 //! translate those records into this small vocabulary; `WorkRuntime`
5 //! persists the resulting observation through the graph reducer. No adapter
6 //! infers liveness from UI state.
7
8 use super::{AcceptanceRequirement, EvidenceRef, OperationObservation, OwnerState, Ts};
9 use crate::fleet::ledger::{FleetTaskLedgerStatus, FleetTaskState};
10 use crate::task_manager::TaskStatus;
11 use chrono::{DateTime, Utc};
12 use codewhale_lane::{LaneRecord, LaneStatus};
13
14 /// Spawn intent registered before an owner starts work.
15 #[derive(Debug, Clone, PartialEq)]
16 pub struct OperationIntent {
17 pub external: String,
18 pub title: String,
19 pub durable: bool,
20 pub source: String,
21 pub call_id: String,
22 pub acceptance: Vec<AcceptanceRequirement>,
23 }
24
25 impl OperationIntent {
26 #[must_use]
27 pub fn new(
28 external: impl Into<String>,
29 title: impl Into<String>,
30 durable: bool,
31 source: impl Into<String>,
32 call_id: impl Into<String>,
33 ) -> Self {
34 Self {
35 external: external.into(),
36 title: title.into(),
37 durable,
38 source: source.into(),
39 call_id: call_id.into(),
40 acceptance: Vec::new(),
41 }
42 }
43
44 #[must_use]
45 pub fn with_acceptance(mut self, acceptance: Vec<AcceptanceRequirement>) -> Self {
46 self.acceptance = acceptance;
47 self
48 }
49 }
50
51 /// One authoritative owner snapshot. `seq` must be monotonic within the
52 /// external binding; replaying the same `(binding, seq)` is a reducer no-op.
53 #[derive(Debug, Clone, PartialEq)]
54 pub struct OperationOwnerSnapshot {
55 pub external: String,
56 pub state: OwnerState,
57 pub seq: u64,
58 pub observed_at: Ts,
59 pub output: Option<EvidenceRef>,
60 }
61
62 impl OperationOwnerSnapshot {
63 #[must_use]
64 pub fn new(external: impl Into<String>, state: OwnerState, seq: u64, observed_at: Ts) -> Self {
65 Self {
66 external: external.into(),
67 state,
68 seq,
69 observed_at,
70 output: None,
71 }
72 }
73
74 #[must_use]
75 pub fn with_output(mut self, output: EvidenceRef) -> Self {
76 self.output = Some(output);
77 self
78 }
79
80 #[must_use]
81 pub fn into_observation(self) -> OperationObservation {
82 OperationObservation::OwnerReported {
83 state: self.state,
84 seq: self.seq,
85 at: self.observed_at,
86 output: self.output,
87 }
88 }
89 }
90
91 /// Translate a durable task record or summary without duplicating lifecycle
92 /// semantics across the model tool, periodic TUI refresh, and engine restore.
93 #[must_use]
94 pub fn task_owner_snapshot(
95 id: &str,
96 status: TaskStatus,
97 lifecycle_seq: u64,
98 created_at: DateTime<Utc>,
99 started_at: Option<DateTime<Utc>>,
100 ended_at: Option<DateTime<Utc>>,
101 ) -> OperationOwnerSnapshot {
102 let state = match status {
103 TaskStatus::Queued => OwnerState::Initializing,
104 TaskStatus::Running => OwnerState::Running,
105 TaskStatus::Completed => OwnerState::Completed,
106 TaskStatus::Failed => OwnerState::Failed,
107 TaskStatus::Canceled => OwnerState::Cancelled,
108 };
109 let observed_at = ended_at
110 .or(started_at)
111 .unwrap_or(created_at)
112 .timestamp_millis();
113 OperationOwnerSnapshot::new(format!("task:{id}"), state, lifecycle_seq, observed_at)
114 }
115
116 /// Translate the replayed Fleet task ledger. Live worker enrichment never
117 /// overrides this durable task projection.
118 #[must_use]
119 pub fn fleet_task_owner_snapshot(task: &FleetTaskState, observed_at: Ts) -> OperationOwnerSnapshot {
120 let state = match task.status {
121 FleetTaskLedgerStatus::Enqueued => OwnerState::Initializing,
122 FleetTaskLedgerStatus::Leased => OwnerState::Running,
123 FleetTaskLedgerStatus::Completed => OwnerState::Completed,
124 FleetTaskLedgerStatus::Failed => OwnerState::Failed,
125 FleetTaskLedgerStatus::Cancelled => OwnerState::Cancelled,
126 };
127 OperationOwnerSnapshot::new(
128 format!("fleet:{}/{}", task.entry.run_id.0, task.entry.task_id),
129 state,
130 task.lifecycle_seq.max(1),
131 observed_at,
132 )
133 }
134
135 /// Translate a durable Lane registry record without inspecting backend
136 /// processes. Backend reconciliation must first update the registry; the
137 /// registry remains the owner presented to the graph.
138 #[must_use]
139 pub fn lane_owner_snapshot(record: &LaneRecord, observed_at: Ts) -> OperationOwnerSnapshot {
140 let state = match record.status {
141 LaneStatus::Pending => OwnerState::Initializing,
142 LaneStatus::Running => OwnerState::Running,
143 LaneStatus::Stopped => OwnerState::Cancelled,
144 LaneStatus::Failed => OwnerState::Failed,
145 LaneStatus::Completed => OwnerState::Completed,
146 };
147 OperationOwnerSnapshot::new(
148 format!("lane:{}", record.id),
149 state,
150 record.lifecycle_seq.max(1),
151 observed_at,
152 )
153 }
154
155 #[cfg(test)]
156 mod tests {
157 use super::*;
158
159 #[test]
160 fn task_owner_snapshot_exhaustively_maps_status_and_prefers_terminal_time() {
161 let created_at = DateTime::from_timestamp_millis(10).expect("created timestamp");
162 let started_at = DateTime::from_timestamp_millis(20).expect("started timestamp");
163 let ended_at = DateTime::from_timestamp_millis(30).expect("ended timestamp");
164 let cases = [
165 (TaskStatus::Queued, OwnerState::Initializing),
166 (TaskStatus::Running, OwnerState::Running),
167 (TaskStatus::Completed, OwnerState::Completed),
168 (TaskStatus::Failed, OwnerState::Failed),
169 (TaskStatus::Canceled, OwnerState::Cancelled),
170 ];
171
172 for (status, expected) in cases {
173 let snapshot = task_owner_snapshot(
174 "task-id",
175 status,
176 7,
177 created_at,
178 Some(started_at),
179 Some(ended_at),
180 );
181 assert_eq!(snapshot.external, "task:task-id");
182 assert_eq!(snapshot.state, expected);
183 assert_eq!(snapshot.seq, 7);
184 assert_eq!(snapshot.observed_at, 30);
185 }
186 }
187
188 #[test]
189 fn task_owner_snapshot_falls_back_from_started_to_created_time() {
190 let created_at = DateTime::from_timestamp_millis(10).expect("created timestamp");
191 let started_at = DateTime::from_timestamp_millis(20).expect("started timestamp");
192
193 let started = task_owner_snapshot(
194 "started",
195 TaskStatus::Running,
196 2,
197 created_at,
198 Some(started_at),
199 None,
200 );
201 let queued = task_owner_snapshot("queued", TaskStatus::Queued, 1, created_at, None, None);
202
203 assert_eq!(started.observed_at, 20);
204 assert_eq!(queued.observed_at, 10);
205 }
206 }
207
207 lines RUST