返回 CodeWhale
model.rs
根目录 / crates / tui / src / work_graph / model.rs
1 //! Core work-graph data model.
2 //!
3 //! One graph carries plan, todo, operations, evidence, and approvals; every
4 //! user-visible projection derives from it and never writes back. The model is
5 //! plain data — no threads, no service objects — mutated only through the
6 //! reducer in [`super::reducer`].
7 //!
8 //! Design notes where the cutover spec is silent:
9 //! - Timestamps are a plain `i64` of milliseconds since the Unix epoch
10 //! ([`Ts`]); the reducer never reads clocks, so callers supply them.
11 //! - [`WorkEdge`] is `{id, kind, from, to}` — the minimal directed labeled
12 //! edge. `Contains` points parent → child; `Verifies` points evidence →
13 //! verified node; `Supersedes` points replacement → superseded.
14 //! - Evidence payloads live on Evidence-kind nodes via [`WorkNode::evidence`];
15 //! verification checks walk `Verifies` edges to those nodes.
16 //! - [`BoundedVec`] / [`BoundedSet`] are small deterministic FIFO containers
17 //! (oldest entry evicted first); no hashing, so iteration order is stable.
18
19 use serde::{Deserialize, Deserializer, Serialize};
20
21 use crate::config::ApiProvider;
22
23 use super::events::{ChangeReceipt, ObservationSummary, WorkGraphProposal};
24 use super::ids::{BindingId, WorkEdgeId, WorkNodeId};
25
26 /// Milliseconds since the Unix epoch (UTC). Supplied by callers via
27 /// [`super::ChangeCtx`]; the reducer never reads clocks itself.
28 pub type Ts = i64;
29
30 /// Current snapshot schema version.
31 pub const SCHEMA_VERSION: u32 = 1;
32
33 /// Bounded change-history window kept on the snapshot.
34 pub const HISTORY_CAP: usize = 256;
35
36 /// Bounded user-visible configuration activity kept on the snapshot.
37 pub const ACTIVITY_CAP: usize = 256;
38
39 /// Bounded idempotency-key dedup window kept on the snapshot.
40 pub const SEEN_KEYS_CAP: usize = 1024;
41
42 /// Canonical reasoning-effort tiers recorded as configuration facts. This is
43 /// deliberately an enum rather than free-form text so Work Graph activity can
44 /// never become a side channel for model reasoning.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46 #[serde(rename_all = "snake_case")]
47 pub enum ReasoningEffortTier {
48 Off,
49 Low,
50 Medium,
51 High,
52 Auto,
53 Max,
54 /// Thinking is enabled, but the provider route exposes no supported
55 /// effort tiers. This is an effective receipt, never a requested setting.
56 ThinkingEnabledGranularityUnavailable,
57 /// The configured route exposes no verified reasoning-control contract.
58 /// This is an effective receipt, never a requested setting.
59 Unavailable,
60 }
61
62 /// Bounded, receipt-only activity attached to the session graph.
63 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64 #[serde(tag = "kind", rename_all = "snake_case")]
65 pub enum WorkActivityEvent {
66 ReasoningEffortChanged {
67 requested: ReasoningEffortTier,
68 effective: ReasoningEffortTier,
69 /// Immutable routing kind, distinct from the exact provider identity.
70 /// A custom table may legally use a built-in slug as its identity.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 provider_kind: Option<ApiProvider>,
73 provider: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 endpoint_identity: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 model: Option<String>,
78 ts: Ts,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 operation: Option<WorkNodeId>,
81 },
82 }
83
84 #[derive(Deserialize)]
85 #[serde(tag = "kind", rename_all = "snake_case")]
86 enum WorkActivityEventWire {
87 ReasoningEffortChanged {
88 requested: ReasoningEffortTier,
89 effective: ReasoningEffortTier,
90 #[serde(default)]
91 provider_kind: Option<ApiProvider>,
92 provider: String,
93 #[serde(default)]
94 endpoint_identity: Option<String>,
95 #[serde(default)]
96 model: Option<String>,
97 ts: Ts,
98 #[serde(default)]
99 operation: Option<WorkNodeId>,
100 },
101 }
102
103 impl<'de> Deserialize<'de> for WorkActivityEvent {
104 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
105 where
106 D: Deserializer<'de>,
107 {
108 match WorkActivityEventWire::deserialize(deserializer)? {
109 WorkActivityEventWire::ReasoningEffortChanged {
110 requested,
111 mut effective,
112 provider_kind,
113 provider,
114 endpoint_identity,
115 model,
116 ts,
117 operation,
118 } => {
119 // Pre-provenance snapshots cannot prove what route received
120 // the control. Keep them loadable, but never preserve a
121 // claimed effective tier as if kind/endpoint/model were known.
122 // In particular, reparsing `provider` is unsafe because a
123 // custom table may legally be named after a built-in slug.
124 if provider_kind.is_none() || endpoint_identity.is_none() || model.is_none() {
125 effective = ReasoningEffortTier::Unavailable;
126 }
127 Ok(Self::ReasoningEffortChanged {
128 requested,
129 effective,
130 provider_kind,
131 provider,
132 endpoint_identity,
133 model,
134 ts,
135 operation,
136 })
137 }
138 }
139 }
140 }
141
142 /// Return the only valid effective receipt for routes whose reasoning-control
143 /// dialect is narrower than the generic provider normalization.
144 #[must_use]
145 pub(crate) fn constrained_effective_reasoning_for_route(
146 requested: ReasoningEffortTier,
147 provider: ApiProvider,
148 endpoint_identity: &str,
149 model: &str,
150 ) -> Option<ReasoningEffortTier> {
151 use ReasoningEffortTier::{
152 Auto, High, Low, Medium, Off, ThinkingEnabledGranularityUnavailable, Unavailable,
153 };
154
155 if provider == ApiProvider::Zai {
156 if !crate::config::is_exact_zai_chat_route(provider, endpoint_identity) {
157 return Some(Unavailable);
158 }
159 if crate::config::is_exact_zai_tiered_effort_route(provider, endpoint_identity, model) {
160 return Some(match requested {
161 Low | Medium => High,
162 other => other,
163 });
164 }
165 if crate::config::is_exact_known_zai_reasoning_route(provider, endpoint_identity, model) {
166 return Some(match requested {
167 Off | Auto => requested,
168 _ => ThinkingEnabledGranularityUnavailable,
169 });
170 }
171 return Some(Unavailable);
172 }
173
174 if provider == ApiProvider::Minimax {
175 if crate::config::is_exact_minimax_m3_route(provider, endpoint_identity, model) {
176 return Some(match requested {
177 Off | Auto => requested,
178 _ => ThinkingEnabledGranularityUnavailable,
179 });
180 }
181 return Some(Unavailable);
182 }
183
184 if provider == ApiProvider::MinimaxAnthropic {
185 if crate::config::is_exact_minimax_anthropic_m3_route(provider, endpoint_identity, model) {
186 return Some(match requested {
187 Off | Auto => requested,
188 _ => ThinkingEnabledGranularityUnavailable,
189 });
190 }
191 return Some(Unavailable);
192 }
193
194 // A named OpenAI-compatible endpoint has no verified reasoning dialect
195 // merely because it has a bounded URL and model string. Until immutable
196 // route provenance carries a validated capability contract, its effective
197 // tier must remain unavailable.
198 if provider == ApiProvider::Custom {
199 return Some(Unavailable);
200 }
201
202 if crate::config::is_exact_kimi_code_k3_route(provider, endpoint_identity, model) {
203 return Some(match requested {
204 Off => Low,
205 other => other,
206 });
207 }
208 if crate::config::is_exact_direct_moonshot_k3_route(provider, endpoint_identity, model) {
209 return Some(match requested {
210 Off => Low,
211 Medium => High,
212 other => other,
213 });
214 }
215
216 None
217 }
218
219 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220 #[serde(rename_all = "snake_case")]
221 pub enum NodeKind {
222 Objective,
223 PlanStep,
224 Operation,
225 Evidence,
226 Blocker,
227 Approval,
228 RuntimeRef,
229 LaneRef,
230 }
231
232 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233 #[serde(rename_all = "snake_case")]
234 pub enum EdgeKind {
235 Contains,
236 DependsOn,
237 Blocks,
238 Produces,
239 Verifies,
240 RunsOn,
241 RequiresApproval,
242 Supersedes,
243 }
244
245 /// Node lifecycle state. The load-bearing distinction: [`NodeState::Completed`]
246 /// means an operation *ended*; only [`NodeState::Verified`] — reachable solely
247 /// when an evidence path satisfies every acceptance requirement — means done.
248 /// An ended process is never proof its acceptance criteria hold.
249 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250 #[serde(rename_all = "snake_case")]
251 pub enum NodeState {
252 Ready,
253 /// The owner has accepted spawn intent but has not yet reported a live
254 /// handle. Registering this state before process creation prevents work
255 /// from existing outside the graph during the spawn window.
256 Initializing,
257 Active,
258 Waiting,
259 Blocked,
260 /// Operation ended — NOT done.
261 Completed,
262 /// Evidence path satisfies acceptance — this is "done".
263 Verified,
264 /// The owner can no longer confirm the process (distinct from
265 /// silent-but-live; a confirmed-live silent job stays `Active`).
266 Stale,
267 Superseded,
268 Cancelled,
269 Failed,
270 }
271
272 impl NodeState {
273 /// Terminal states protected by invariant V9: never overwritten except
274 /// via an explicit `Supersede` change or a reconcile-rule change.
275 #[must_use]
276 pub fn is_terminal(self) -> bool {
277 matches!(
278 self,
279 NodeState::Verified | NodeState::Superseded | NodeState::Cancelled
280 )
281 }
282
283 /// Live states for invariant V2 (no orphaned live work).
284 #[must_use]
285 pub fn is_live(self) -> bool {
286 matches!(
287 self,
288 NodeState::Initializing | NodeState::Active | NodeState::Waiting
289 )
290 }
291 }
292
293 /// Fieldless discriminant of [`EvidenceKind`], used by acceptance
294 /// requirements so they can match a kind without naming payload values.
295 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
296 #[serde(rename_all = "snake_case")]
297 pub enum EvidenceKindTag {
298 ToolRun,
299 Artifact,
300 TestSummary,
301 Receipt,
302 Approval,
303 Route,
304 WebCitation,
305 }
306
307 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308 #[serde(rename_all = "snake_case")]
309 pub enum EvidenceKind {
310 ToolRun,
311 Artifact {
312 digest: String,
313 },
314 TestSummary,
315 Receipt {
316 owner: String,
317 },
318 Approval,
319 Route,
320 WebCitation {
321 ref_id: String,
322 url: String,
323 retrieved_at: String,
324 },
325 }
326
327 impl EvidenceKind {
328 #[must_use]
329 pub fn tag(&self) -> EvidenceKindTag {
330 match self {
331 EvidenceKind::ToolRun => EvidenceKindTag::ToolRun,
332 EvidenceKind::Artifact { .. } => EvidenceKindTag::Artifact,
333 EvidenceKind::TestSummary => EvidenceKindTag::TestSummary,
334 EvidenceKind::Receipt { .. } => EvidenceKindTag::Receipt,
335 EvidenceKind::Approval => EvidenceKindTag::Approval,
336 EvidenceKind::Route => EvidenceKindTag::Route,
337 EvidenceKind::WebCitation { .. } => EvidenceKindTag::WebCitation,
338 }
339 }
340 }
341
342 /// Reason an [`EvidenceRef`] could not be constructed.
343 #[derive(Debug, Clone, PartialEq, Eq)]
344 pub enum EvidenceRefError {
345 EmptyReference,
346 ReferenceTooLong { len: usize },
347 AbsolutePath,
348 HomeRelativePath,
349 ContainsWhitespaceOrControl,
350 LooksLikeKeyMaterial,
351 WebCitationReferenceMismatch,
352 InvalidWebCitationUrl,
353 InvalidWebCitationTimestamp,
354 }
355
356 impl std::fmt::Display for EvidenceRefError {
357 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358 match self {
359 EvidenceRefError::EmptyReference => write!(f, "evidence reference is empty"),
360 EvidenceRefError::ReferenceTooLong { len } => {
361 write!(f, "evidence reference too long ({len} chars)")
362 }
363 EvidenceRefError::AbsolutePath => {
364 write!(f, "evidence reference must not be an absolute path")
365 }
366 EvidenceRefError::HomeRelativePath => {
367 write!(f, "evidence reference must not be a home-relative path")
368 }
369 EvidenceRefError::ContainsWhitespaceOrControl => {
370 write!(
371 f,
372 "evidence reference must not contain whitespace or control chars"
373 )
374 }
375 EvidenceRefError::LooksLikeKeyMaterial => {
376 write!(f, "evidence reference must not embed key material")
377 }
378 EvidenceRefError::WebCitationReferenceMismatch => {
379 write!(f, "web citation reference must match its ref_id")
380 }
381 EvidenceRefError::InvalidWebCitationUrl => {
382 write!(f, "web citation URL must be HTTP(S) without credentials")
383 }
384 EvidenceRefError::InvalidWebCitationTimestamp => {
385 write!(f, "web citation retrieved_at must be RFC 3339")
386 }
387 }
388 }
389 }
390
391 impl std::error::Error for EvidenceRefError {}
392
393 const EVIDENCE_REFERENCE_MAX_LEN: usize = 512;
394
395 fn web_citation_url_has_sensitive_query(url: &reqwest::Url) -> bool {
396 url.query_pairs().any(|(name, _)| {
397 let name = name.to_ascii_lowercase();
398 matches!(
399 name.as_ref(),
400 "access_token"
401 | "api_key"
402 | "authorization"
403 | "auth"
404 | "credential"
405 | "key"
406 | "session"
407 | "session_id"
408 | "sig"
409 | "signature"
410 | "token"
411 | "x-amz-credential"
412 | "x-amz-signature"
413 | "x-goog-credential"
414 | "x-goog-signature"
415 ) || name.ends_with("_token")
416 || name.ends_with("_key")
417 })
418 }
419
420 /// Summary/reference-only pointer to evidence: a logical artifact ID, run ID,
421 /// or receipt handle — never absolute paths, never secrets, never raw logs or
422 /// reasoning text.
423 ///
424 /// Enforced by construction where feasible: fields are private, [`Self::new`]
425 /// is the only way to build one (serde routes through it via `try_from`), and
426 /// it rejects absolute/home paths, whitespace/control characters (which also
427 /// blocks pasted log or prose content), and PEM-style key-material markers.
428 /// `raw_bytes` records the pre-truncation size of the underlying output so
429 /// "still growing" and "stuck" stay distinguishable after truncation.
430 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431 #[serde(try_from = "EvidenceRefRaw", into = "EvidenceRefRaw")]
432 pub struct EvidenceRef {
433 kind: EvidenceKind,
434 reference: String,
435 raw_bytes: Option<u64>,
436 truncated: bool,
437 }
438
439 impl EvidenceRef {
440 pub fn new(
441 kind: EvidenceKind,
442 reference: impl Into<String>,
443 raw_bytes: Option<u64>,
444 truncated: bool,
445 ) -> Result<Self, EvidenceRefError> {
446 let reference = reference.into();
447 if reference.is_empty() {
448 return Err(EvidenceRefError::EmptyReference);
449 }
450 if reference.chars().count() > EVIDENCE_REFERENCE_MAX_LEN {
451 return Err(EvidenceRefError::ReferenceTooLong {
452 len: reference.chars().count(),
453 });
454 }
455 let mut chars = reference.chars();
456 let first = chars.next().unwrap_or('\0');
457 // Unix absolute, UNC/backslash, or `X:/`-style drive paths.
458 let drive_absolute = {
459 let bytes = reference.as_bytes();
460 bytes.len() >= 3
461 && bytes[0].is_ascii_alphabetic()
462 && bytes[1] == b':'
463 && (bytes[2] == b'/' || bytes[2] == b'\\')
464 };
465 if first == '/' || first == '\\' || drive_absolute {
466 return Err(EvidenceRefError::AbsolutePath);
467 }
468 if first == '~' {
469 return Err(EvidenceRefError::HomeRelativePath);
470 }
471 if reference
472 .chars()
473 .any(|c| c.is_whitespace() || c.is_control())
474 {
475 return Err(EvidenceRefError::ContainsWhitespaceOrControl);
476 }
477 if reference.contains("-----BEGIN") {
478 return Err(EvidenceRefError::LooksLikeKeyMaterial);
479 }
480 if let EvidenceKind::WebCitation {
481 ref_id,
482 url,
483 retrieved_at,
484 } = &kind
485 {
486 if ref_id != &reference {
487 return Err(EvidenceRefError::WebCitationReferenceMismatch);
488 }
489 let parsed = reqwest::Url::parse(url)
490 .ok()
491 .filter(|url| matches!(url.scheme(), "http" | "https"))
492 .filter(|url| url.host_str().is_some())
493 .filter(|url| url.username().is_empty() && url.password().is_none())
494 .filter(|url| !web_citation_url_has_sensitive_query(url));
495 if parsed.is_none() {
496 return Err(EvidenceRefError::InvalidWebCitationUrl);
497 }
498 if chrono::DateTime::parse_from_rfc3339(retrieved_at).is_err() {
499 return Err(EvidenceRefError::InvalidWebCitationTimestamp);
500 }
501 }
502 Ok(Self {
503 kind,
504 reference,
505 raw_bytes,
506 truncated,
507 })
508 }
509
510 #[must_use]
511 pub fn kind(&self) -> &EvidenceKind {
512 &self.kind
513 }
514
515 #[must_use]
516 pub fn reference(&self) -> &str {
517 &self.reference
518 }
519
520 /// Pre-truncation size of the underlying output, persisted on the node.
521 #[must_use]
522 pub fn raw_bytes(&self) -> Option<u64> {
523 self.raw_bytes
524 }
525
526 #[must_use]
527 pub fn truncated(&self) -> bool {
528 self.truncated
529 }
530 }
531
532 /// Serde shadow for [`EvidenceRef`] so deserialization re-runs constructor
533 /// validation instead of bypassing it.
534 #[derive(Debug, Clone, Serialize, Deserialize)]
535 pub struct EvidenceRefRaw {
536 kind: EvidenceKind,
537 reference: String,
538 raw_bytes: Option<u64>,
539 truncated: bool,
540 }
541
542 impl TryFrom<EvidenceRefRaw> for EvidenceRef {
543 type Error = EvidenceRefError;
544
545 fn try_from(raw: EvidenceRefRaw) -> Result<Self, Self::Error> {
546 EvidenceRef::new(raw.kind, raw.reference, raw.raw_bytes, raw.truncated)
547 }
548 }
549
550 impl From<EvidenceRef> for EvidenceRefRaw {
551 fn from(value: EvidenceRef) -> Self {
552 EvidenceRefRaw {
553 kind: value.kind,
554 reference: value.reference,
555 raw_bytes: value.raw_bytes,
556 truncated: value.truncated,
557 }
558 }
559 }
560
561 /// A requirement that must be satisfied by evidence before a node may be
562 /// [`NodeState::Verified`] (invariant V4).
563 ///
564 /// Deliberately minimal for this slice: one variant matching an evidence kind.
565 /// Richer predicates (thresholds, specific commands) can be added as variants
566 /// without touching the verification walk.
567 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568 #[serde(rename_all = "snake_case")]
569 pub enum AcceptanceRequirement {
570 /// Satisfied when at least one attached evidence item has this kind.
571 EvidenceOfKind { kind: EvidenceKindTag },
572 }
573
574 impl AcceptanceRequirement {
575 #[must_use]
576 pub fn is_satisfied_by(&self, evidence: &EvidenceRef) -> bool {
577 match self {
578 AcceptanceRequirement::EvidenceOfKind { kind } => evidence.kind().tag() == *kind,
579 }
580 }
581 }
582
583 /// Where a fact in the graph came from.
584 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
585 #[serde(rename_all = "snake_case")]
586 pub enum Provenance {
587 Import {
588 source_digest: String,
589 ordinal: Option<u32>,
590 },
591 ToolUpdate {
592 tool: String,
593 call_id: String,
594 },
595 RuntimeReconcile {
596 source: String,
597 observed_at: Ts,
598 },
599 UserEdit {
600 proposal_id: super::ids::ProposalId,
601 },
602 }
603
604 /// Binding from an Operation node to the external process that owns its
605 /// lifecycle. `external` uses the existing identity scheme verbatim:
606 /// `"task:{id}" | "shell:{id}" | "worker:{id}" | "workflow:{id}" |
607 /// "fleet:{run}/{task}" | "lane:{id}"` — the same strings the live work
608 /// surface already parses for actions, so bindings stay joinable with
609 /// today's owners without translation.
610 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611 pub struct OperationBinding {
612 pub external: String,
613 /// Whether the owner persists lifecycle records across restart. Shell
614 /// sessions are in-memory only (`durable == false`): after a restart they
615 /// become [`NodeState::Stale`], never silently "still running".
616 pub durable: bool,
617 #[serde(default)]
618 pub last_observation: Option<ObservationSummary>,
619 }
620
621 /// Returns true when `external` is well-formed under exactly one prefix of
622 /// the existing identity scheme.
623 #[must_use]
624 pub fn external_identity_is_well_formed(external: &str) -> bool {
625 fn plain(id: &str) -> bool {
626 !id.is_empty() && !id.chars().any(|c| c.is_whitespace() || c.is_control())
627 }
628 if let Some(rest) = external.strip_prefix("fleet:") {
629 return match rest.split_once('/') {
630 Some((run, task)) => plain(run) && plain(task),
631 None => false,
632 };
633 }
634 ["task:", "shell:", "worker:", "workflow:", "lane:"]
635 .iter()
636 .any(|prefix| external.strip_prefix(prefix).is_some_and(plain))
637 }
638
639 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
640 pub struct WorkNode {
641 pub id: WorkNodeId,
642 pub kind: NodeKind,
643 pub title: String,
644 pub state: NodeState,
645 /// Empty acceptance means [`NodeState::Completed`] may render as done;
646 /// non-empty acceptance makes `Verified` (evidence-gated) the only done.
647 pub acceptance: Vec<AcceptanceRequirement>,
648 /// Operation nodes only (invariant V3).
649 pub binding: Option<OperationBinding>,
650 /// Evidence-kind nodes only; the payload the `Verifies` walk reads.
651 pub evidence: Option<EvidenceRef>,
652 pub provenance: Provenance,
653 pub created_at: Ts,
654 pub updated_at: Ts,
655 }
656
657 /// Directed labeled edge. Minimal by design; edge-level metadata can be
658 /// modeled as nodes (e.g. Approval) rather than edge payloads.
659 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660 pub struct WorkEdge {
661 pub id: WorkEdgeId,
662 pub kind: EdgeKind,
663 pub from: WorkNodeId,
664 pub to: WorkNodeId,
665 }
666
667 /// Graph-owned presentation metadata for the legacy Strategy/Plan surface.
668 ///
669 /// Plan steps themselves live as `PlanStep` nodes. These fields have no
670 /// first-class node equivalent yet, so they remain attached to the graph as
671 /// presentation metadata rather than living in a separately writable store.
672 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
673 pub struct CompatPlanMetadata {
674 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub title: Option<String>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub objective: Option<String>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub context_summary: Option<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub explanation: Option<String>,
682 #[serde(default, skip_serializing_if = "Vec::is_empty")]
683 pub sources_used: Vec<String>,
684 #[serde(default, skip_serializing_if = "Vec::is_empty")]
685 pub critical_files: Vec<String>,
686 #[serde(default, skip_serializing_if = "Vec::is_empty")]
687 pub constraints: Vec<String>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub recommended_approach: Option<String>,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub verification_plan: Option<String>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub risks_and_unknowns: Option<String>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub handoff_packet: Option<String>,
696 }
697
698 impl CompatPlanMetadata {
699 #[must_use]
700 pub fn is_empty(&self) -> bool {
701 self.title.is_none()
702 && self.objective.is_none()
703 && self.context_summary.is_none()
704 && self.explanation.is_none()
705 && self.sources_used.is_empty()
706 && self.critical_files.is_empty()
707 && self.constraints.is_empty()
708 && self.recommended_approach.is_none()
709 && self.verification_plan.is_none()
710 && self.risks_and_unknowns.is_none()
711 && self.handoff_packet.is_none()
712 }
713 }
714
715 /// One row in the legacy To-do projection.
716 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
717 pub struct CompatTodoBinding {
718 pub legacy_id: u32,
719 pub node: WorkNodeId,
720 /// When present, the legacy row aliases this ordinal in `plan_order`.
721 /// The retired invisible marker is never reconstructed; the graph keeps
722 /// the provenance explicitly while old readers receive clean content.
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub plan_index: Option<u32>,
725 }
726
727 /// Ordering and presentation state needed to derive old Plan/To-do snapshots.
728 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
729 pub struct CompatProjectionState {
730 #[serde(default, skip_serializing_if = "CompatPlanMetadata::is_empty")]
731 pub plan: CompatPlanMetadata,
732 #[serde(default, skip_serializing_if = "Vec::is_empty")]
733 pub plan_order: Vec<WorkNodeId>,
734 #[serde(default, skip_serializing_if = "Vec::is_empty")]
735 pub todos: Vec<CompatTodoBinding>,
736 }
737
738 impl CompatProjectionState {
739 #[must_use]
740 pub fn is_empty(&self) -> bool {
741 self.plan.is_empty() && self.plan_order.is_empty() && self.todos.is_empty()
742 }
743 }
744
745 /// Idempotency key for owner-reported observations: `(binding, seq)`. Applied
746 /// changes carrying a key already inside the snapshot's dedup window become
747 /// receipts without effect, so replayed runtime events cannot double-apply.
748 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
749 pub struct IdempotencyKey {
750 pub binding: BindingId,
751 pub seq: u64,
752 }
753
754 /// Deterministic FIFO vector bounded at `N`: pushing beyond capacity evicts
755 /// the oldest entry. Kept as a plain `Vec` so ordering (and serialization) is
756 /// stable. The bound is re-checked by validation (V8), so an oversized
757 /// deserialized snapshot fails closed rather than growing unbounded.
758 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
759 #[serde(transparent)]
760 pub struct BoundedVec<T, const N: usize> {
761 items: Vec<T>,
762 }
763
764 impl<T, const N: usize> BoundedVec<T, N> {
765 #[must_use]
766 pub fn new() -> Self {
767 Self { items: Vec::new() }
768 }
769
770 pub fn push_bounded(&mut self, item: T) {
771 if self.items.len() >= N {
772 self.items.remove(0);
773 }
774 self.items.push(item);
775 }
776
777 #[must_use]
778 pub fn len(&self) -> usize {
779 self.items.len()
780 }
781
782 #[must_use]
783 pub fn is_empty(&self) -> bool {
784 self.items.is_empty()
785 }
786
787 #[must_use]
788 pub fn last(&self) -> Option<&T> {
789 self.items.last()
790 }
791
792 pub fn iter(&self) -> std::slice::Iter<'_, T> {
793 self.items.iter()
794 }
795
796 #[must_use]
797 pub const fn capacity() -> usize {
798 N
799 }
800 }
801
802 impl<T, const N: usize> Default for BoundedVec<T, N> {
803 fn default() -> Self {
804 Self::new()
805 }
806 }
807
808 /// Deterministic FIFO set bounded at `N`: inserting a duplicate is a no-op;
809 /// inserting beyond capacity evicts the oldest member. Linear scans keep it
810 /// hash-free and iteration-order stable for reproducible serialization.
811 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
812 #[serde(transparent)]
813 pub struct BoundedSet<T, const N: usize> {
814 items: Vec<T>,
815 }
816
817 impl<T: PartialEq, const N: usize> BoundedSet<T, N> {
818 #[must_use]
819 pub fn new() -> Self {
820 Self { items: Vec::new() }
821 }
822
823 #[must_use]
824 pub fn contains(&self, item: &T) -> bool {
825 self.items.contains(item)
826 }
827
828 /// Returns true if the item was newly inserted.
829 pub fn insert(&mut self, item: T) -> bool {
830 if self.contains(&item) {
831 return false;
832 }
833 if self.items.len() >= N {
834 self.items.remove(0);
835 }
836 self.items.push(item);
837 true
838 }
839
840 #[must_use]
841 pub fn len(&self) -> usize {
842 self.items.len()
843 }
844
845 #[must_use]
846 pub fn is_empty(&self) -> bool {
847 self.items.is_empty()
848 }
849 }
850
851 impl<T: PartialEq, const N: usize> Default for BoundedSet<T, N> {
852 fn default() -> Self {
853 Self::new()
854 }
855 }
856
857 /// The whole graph as a value. Serialized opaquely inside session state by a
858 /// later slice; this slice keeps it standalone.
859 ///
860 /// `proposals` is a spec-silent addition: pending plan-diff proposals must
861 /// live somewhere the reducer can find them when `AcceptPlanDiff` arrives by
862 /// ID, and the snapshot is the only state the reducer sees.
863 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
864 pub struct WorkGraphSnapshot {
865 pub schema: u32,
866 pub revision: u64,
867 pub nodes: Vec<WorkNode>,
868 pub edges: Vec<WorkEdge>,
869 pub history: BoundedVec<ChangeReceipt, HISTORY_CAP>,
870 /// Configuration facts only; never prompts, output, or reasoning text.
871 #[serde(default, skip_serializing_if = "BoundedVec::is_empty")]
872 pub activities: BoundedVec<WorkActivityEvent, ACTIVITY_CAP>,
873 pub import_digest: Option<String>,
874 /// `(binding, seq)` dedup window for replayed runtime observations.
875 pub seen_keys: BoundedSet<IdempotencyKey, SEEN_KEYS_CAP>,
876 pub proposals: Vec<WorkGraphProposal>,
877 /// Graph-owned inputs for the fully populated legacy Plan/To-do views.
878 #[serde(default, skip_serializing_if = "CompatProjectionState::is_empty")]
879 pub compat: CompatProjectionState,
880 }
881
882 impl WorkGraphSnapshot {
883 #[must_use]
884 pub fn new() -> Self {
885 Self {
886 schema: SCHEMA_VERSION,
887 revision: 0,
888 nodes: Vec::new(),
889 edges: Vec::new(),
890 history: BoundedVec::new(),
891 activities: BoundedVec::new(),
892 import_digest: None,
893 seen_keys: BoundedSet::new(),
894 proposals: Vec::new(),
895 compat: CompatProjectionState::default(),
896 }
897 }
898
899 #[must_use]
900 pub fn node(&self, id: &WorkNodeId) -> Option<&WorkNode> {
901 self.nodes.iter().find(|n| &n.id == id)
902 }
903
904 pub(super) fn node_mut(&mut self, id: &WorkNodeId) -> Option<&mut WorkNode> {
905 self.nodes.iter_mut().find(|n| &n.id == id)
906 }
907
908 #[must_use]
909 pub fn edge(&self, id: &WorkEdgeId) -> Option<&WorkEdge> {
910 self.edges.iter().find(|e| &e.id == id)
911 }
912
913 /// "Done" for dependency/approval purposes: verified, or completed with
914 /// no acceptance requirements (nothing left to verify).
915 #[must_use]
916 pub fn node_is_done(node: &WorkNode) -> bool {
917 matches!(node.state, NodeState::Verified)
918 || (matches!(node.state, NodeState::Completed) && node.acceptance.is_empty())
919 }
920
921 #[must_use]
922 pub fn is_empty(&self) -> bool {
923 self.nodes.is_empty()
924 && self.edges.is_empty()
925 && self.history.is_empty()
926 && self.activities.is_empty()
927 && self.import_digest.is_none()
928 && self.proposals.is_empty()
929 && self.compat.is_empty()
930 }
931 }
932
933 impl Default for WorkGraphSnapshot {
934 fn default() -> Self {
935 Self::new()
936 }
937 }
938
938 lines RUST