返回 CodeWhale
events.rs
根目录 / crates / tui / src / work_graph / events.rs
1 //! Changes, observations, and receipts — the reducer's entire input/output
2 //! vocabulary.
3 //!
4 //! The reducer never reads clocks or RNG: [`ChangeCtx`] carries the timestamp,
5 //! the session identity used for deterministic ID derivation, and an optional
6 //! idempotency key. Same snapshot + same change + same ctx ⇒ same result,
7 //! always.
8 //!
9 //! Spec-silent shapes chosen minimally here (documented on each type):
10 //! [`WorkNodePatch`], [`WorkGraphProposal`], [`ApprovalRef`], and the
11 //! placeholder observation types that later slices' liveness adapters will
12 //! feed ([`OperationObservation`], [`OwnerState`], [`CancelOutcome`]).
13
14 use serde::{Deserialize, Serialize};
15
16 use super::ids::{ChangeId, ProposalId, WorkEdgeId, WorkNodeId};
17 use super::model::{
18 AcceptanceRequirement, CompatProjectionState, EvidenceRef, IdempotencyKey, NodeState,
19 OperationBinding, Provenance, Ts, WorkActivityEvent, WorkEdge, WorkNode,
20 };
21
22 /// A single mutation of the work graph. The reducer is the only write path;
23 /// UI, tools, and runtime adapters all speak this vocabulary.
24 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25 #[serde(rename_all = "snake_case")]
26 // Variants intentionally carry full payloads (a node, a proposal) rather than
27 // boxed indirection: changes are transient values, not stored long-term.
28 #[allow(clippy::large_enum_variant)]
29 pub enum WorkGraphChange {
30 AddNode {
31 node: WorkNode,
32 },
33 UpdateNode {
34 id: WorkNodeId,
35 patch: WorkNodePatch,
36 },
37 AddEdge {
38 edge: WorkEdge,
39 },
40 RemoveEdge {
41 id: WorkEdgeId,
42 },
43 BindOperation {
44 node: WorkNodeId,
45 binding: OperationBinding,
46 },
47 ReconcileOperation {
48 node: WorkNodeId,
49 obs: OperationObservation,
50 },
51 AttachEvidence {
52 node: WorkNodeId,
53 evidence: EvidenceRef,
54 },
55 ProposePlanDiff {
56 proposal: WorkGraphProposal,
57 },
58 /// Explicitly retire a pending proposal before a replacement is
59 /// proposed. This keeps repeated "Revise plan" turns reviewable without
60 /// silently rewriting or accumulating stale proposals.
61 WithdrawPlanDiff {
62 proposal_id: ProposalId,
63 },
64 AcceptPlanDiff {
65 proposal_id: ProposalId,
66 approval: ApprovalRef,
67 },
68 Supersede {
69 old: WorkNodeId,
70 replacement: WorkNodeId,
71 },
72 /// Atomically replace the inputs for the legacy Plan/To-do projections.
73 ReplaceCompatProjection {
74 compat: CompatProjectionState,
75 },
76 /// Record the canonical digest of a completed legacy import.
77 SetImportDigest {
78 digest: String,
79 },
80 /// Append one bounded, configuration-only activity receipt.
81 RecordActivity {
82 event: WorkActivityEvent,
83 },
84 }
85
86 impl WorkGraphChange {
87 /// Stable discriminant name recorded on receipts. Names only — receipts
88 /// never carry payload text.
89 #[must_use]
90 pub fn kind_name(&self) -> &'static str {
91 match self {
92 WorkGraphChange::AddNode { .. } => "add_node",
93 WorkGraphChange::UpdateNode { .. } => "update_node",
94 WorkGraphChange::AddEdge { .. } => "add_edge",
95 WorkGraphChange::RemoveEdge { .. } => "remove_edge",
96 WorkGraphChange::BindOperation { .. } => "bind_operation",
97 WorkGraphChange::ReconcileOperation { .. } => "reconcile_operation",
98 WorkGraphChange::AttachEvidence { .. } => "attach_evidence",
99 WorkGraphChange::ProposePlanDiff { .. } => "propose_plan_diff",
100 WorkGraphChange::WithdrawPlanDiff { .. } => "withdraw_plan_diff",
101 WorkGraphChange::AcceptPlanDiff { .. } => "accept_plan_diff",
102 WorkGraphChange::Supersede { .. } => "supersede",
103 WorkGraphChange::ReplaceCompatProjection { .. } => "replace_compat_projection",
104 WorkGraphChange::SetImportDigest { .. } => "set_import_digest",
105 WorkGraphChange::RecordActivity { .. } => "record_activity",
106 }
107 }
108 }
109
110 /// Partial update of a node. Spec-silent shape: `Option` per patchable field,
111 /// `None` meaning "leave unchanged". Identity, kind, binding, and evidence
112 /// are deliberately NOT patchable here — they move only through their
113 /// dedicated changes (`BindOperation`, `AttachEvidence`, `Supersede`).
114 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
115 pub struct WorkNodePatch {
116 pub title: Option<String>,
117 pub state: Option<NodeState>,
118 pub acceptance: Option<Vec<AcceptanceRequirement>>,
119 pub provenance: Option<Provenance>,
120 }
121
122 /// A reviewable plan diff. Spec-silent shape: explicit added/updated/removed
123 /// sets rather than nested changes, so the whole delta is inspectable before
124 /// acceptance and applies atomically (validated as one unit — no silent
125 /// mutation of objectives, dependencies, acceptance, or scope).
126 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127 pub struct WorkGraphProposal {
128 pub id: ProposalId,
129 #[serde(default)]
130 pub added_nodes: Vec<WorkNode>,
131 #[serde(default)]
132 pub added_edges: Vec<WorkEdge>,
133 #[serde(default)]
134 pub updated_nodes: Vec<ProposedNodeUpdate>,
135 #[serde(default)]
136 pub removed_nodes: Vec<WorkNodeId>,
137 #[serde(default)]
138 pub removed_edges: Vec<WorkEdgeId>,
139 /// Graph-owned inputs for the legacy Plan/To-do projections. This is part
140 /// of the reviewed scope delta and is applied atomically with the graph
141 /// changes. Older saved proposals deserialize with no replacement.
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub replacement_compat: Option<CompatProjectionState>,
144 }
145
146 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147 pub struct ProposedNodeUpdate {
148 pub id: WorkNodeId,
149 pub patch: WorkNodePatch,
150 }
151
152 /// Reference to the approval that accepted a plan diff. Spec-silent shape:
153 /// a reference-only string (approval receipt / user action handle), recorded
154 /// on the Approval node the acceptance creates.
155 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156 pub struct ApprovalRef {
157 pub reference: String,
158 }
159
160 /// Lifecycle state as reported by an operation's owner. Placeholder for the
161 /// liveness slice; present now so reducer signatures are final.
162 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163 #[serde(rename_all = "snake_case")]
164 pub enum OwnerState {
165 Initializing,
166 Running,
167 Waiting,
168 Completed,
169 Failed,
170 Cancelled,
171 }
172
173 /// Typed cancellation outcomes, mirroring real owner semantics (immediate
174 /// abort vs teardown-wait vs already-finished vs unknown-after-restart).
175 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176 #[serde(rename_all = "snake_case")]
177 pub enum CancelOutcome {
178 Requested,
179 Acknowledged,
180 Forced,
181 AlreadyFinished,
182 NotFound,
183 StaleUnknown,
184 }
185
186 /// An observation about a bound operation, produced by owner adapters (later
187 /// slice) and consumed by the reducer. The reducer applies these purely; it
188 /// never queries owners itself.
189 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190 #[serde(rename_all = "snake_case")]
191 pub enum OperationObservation {
192 /// Owner is authoritative. Idempotency key = `(binding, seq)`.
193 OwnerReported {
194 state: OwnerState,
195 seq: u64,
196 at: Ts,
197 /// Bounded logical output receipt. The reference never contains raw
198 /// logs or reasoning; `raw_bytes` preserves the pre-truncation size.
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 output: Option<EvidenceRef>,
201 },
202 /// No live handle for the binding (e.g. after restart). Never maps to
203 /// Active or Completed — only to Stale (fail toward honesty).
204 OwnerMissing {
205 checked_at: Ts,
206 },
207 CancelUpdate {
208 outcome: CancelOutcome,
209 at: Ts,
210 },
211 }
212
213 /// Compact record of the most recent observation, stored on the binding.
214 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215 pub struct ObservationSummary {
216 pub owner_state: OwnerState,
217 pub seq: u64,
218 pub observed_at: Ts,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub output: Option<EvidenceRef>,
221 }
222
223 /// Everything ambient the reducer needs, supplied by the caller so the
224 /// reducer itself stays pure: no clock reads, no RNG, no globals.
225 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226 pub struct ChangeCtx {
227 /// Session identity used for deterministic ID derivation.
228 pub session_id: String,
229 /// Timestamp to record for this change (milliseconds since Unix epoch).
230 pub now: Ts,
231 /// Present for owner-observation changes; duplicates inside the
232 /// snapshot's dedup window become no-op receipts.
233 pub idempotency_key: Option<IdempotencyKey>,
234 }
235
236 /// Receipt for an applied (or deduplicated) change. Bounded history of these
237 /// lives on the snapshot. Receipts carry discriminant names and identifiers
238 /// only — no payload text, no secrets.
239 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240 pub struct ChangeReceipt {
241 pub change_id: ChangeId,
242 pub revision: u64,
243 pub summary: String,
244 pub applied_at: Ts,
245 pub idempotency_key: Option<IdempotencyKey>,
246 pub no_op: bool,
247 }
248
249 impl ChangeReceipt {
250 #[must_use]
251 pub fn of(change: &WorkGraphChange, revision: u64, ctx: &ChangeCtx) -> Self {
252 let kind = change.kind_name();
253 ChangeReceipt {
254 change_id: ChangeId::derive(&ctx.session_id, &format!("change:{revision}:{kind}")),
255 revision,
256 summary: kind.to_string(),
257 applied_at: ctx.now,
258 idempotency_key: ctx.idempotency_key.clone(),
259 no_op: false,
260 }
261 }
262 }
263
263 lines RUST