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