Skip to main content

agentplane/testkit/
older_shape.rs

1//! An export as a build one record shape older would have written it.
2//!
3//! No build writes an older version: the writer stamps
4//! [`RecordKind::version`](crate::journal::RecordKind::version) and nothing
5//! selects another, which is the point of it. So a rehearsal of an upgrade
6//! across a shape change starts from bytes crafted here, out of an export this
7//! build wrote. [`older_shape`] rewrites every `StepStarted` record to the older
8//! shape — its `skill` field named `name`, at version [`OLDER`] — and re-links
9//! the file, so every hash, every sealing record's `chain_head`, every run's
10//! seal and the header's root agree with the rewritten bytes. What is left is
11//! the shape change alone.
12//!
13//! [`LiftsTheOlderShape`] is the upcaster the newer build ships for that
14//! change, and [`ReadsOnlyTheOlderShape`] is the reader the older build
15//! shipped: it reads `StepStarted` at [`OLDER`] and nothing newer.
16
17use serde_json::{Value, json};
18
19use crate::core::{Digest, StoreError};
20use crate::journal::Upcaster;
21
22/// The record version the crafted shape is written at.
23pub const OLDER: u16 = 0;
24
25/// The kind whose shape the crafted export moves.
26const MOVED: &str = "StepStarted";
27
28/// `export`, with every `StepStarted` record at the older shape and the file
29/// re-linked around it.
30///
31/// # Panics
32///
33/// If `export` is not an export this build wrote, or carries no `StepStarted`
34/// record — a fixture that moved nothing would rehearse nothing.
35#[must_use]
36pub fn older_shape(export: &str) -> String {
37    let mut moved = 0usize;
38    let older = relinked(export, |wire| {
39        if wire["kind"] == json!(MOVED) {
40            let object = wire.as_object_mut().expect("a record is an object");
41            let skill = object
42                .remove("skill")
43                .expect("a StepStarted names its skill");
44            object.insert("name".into(), skill);
45            object.insert("v".into(), json!(OLDER));
46            moved += 1;
47        }
48    });
49    assert!(moved > 0, "the export carries no {MOVED} record to move");
50    older
51}
52
53/// Re-link every record in an export after `edit` has rewritten some of them.
54///
55/// The file comes back exactly as internally consistent as its writer's own
56/// output: each record's bytes canonical, each hash over its own bytes and its
57/// predecessor, each sealing record's `chain_head`, each run block's seal and
58/// the header's root. What is left different is only what `edit` did.
59///
60/// # Panics
61///
62/// If a line of `export` is not JSON, or a record line carries no wire bytes.
63pub fn relinked(export: &str, mut edit: impl FnMut(&mut Value)) -> String {
64    let mut lines: Vec<Value> = export
65        .lines()
66        .map(|line| serde_json::from_str::<Value>(line).expect("every export line is JSON"))
67        .collect();
68    let mut prev = Digest::ZERO;
69    let mut block: Option<usize> = None;
70    for at in 0..lines.len() {
71        if lines[at]["kind"] == json!("agentplane.export.run") {
72            prev = Digest::ZERO;
73            block = Some(at);
74            continue;
75        }
76        if lines[at].get("kind").is_some() {
77            continue;
78        }
79        let line = &mut lines[at];
80        let mut wire: Value =
81            serde_json::from_str(line["raw"].as_str().expect("wire bytes")).expect("json");
82        edit(&mut wire);
83        if wire["kind"] == json!("RunConcluded") {
84            wire["chain_head"] = json!(prev);
85        }
86        let raw = String::from_utf8(crate::core::canon::value_bytes(&wire)).expect("utf-8");
87        let hash = Digest::chain(prev, raw.as_bytes());
88        line["body"] = wire;
89        line["prev_hash"] = json!(prev);
90        line["hash"] = json!(hash);
91        line["raw"] = json!(raw);
92        prev = hash;
93        if let Some(b) = block
94            && lines[b].get("seal").is_some()
95        {
96            lines[b]["seal"] = json!(hash);
97        }
98    }
99    let mut seals: Vec<(u64, Digest)> = lines
100        .iter()
101        .filter(|v| v["kind"] == json!("agentplane.export.run"))
102        .filter_map(|v| {
103            Some((
104                v.get("index")?.as_u64()?,
105                serde_json::from_value(v.get("seal")?.clone()).ok()?,
106            ))
107        })
108        .collect();
109    seals.sort_by_key(|(i, _)| *i);
110    let root = crate::core::merkle::root(
111        &seals
112            .iter()
113            .map(|(_, s)| crate::core::merkle::leaf_hash(s))
114            .collect::<Vec<_>>(),
115    );
116    if let Some(header) = lines
117        .iter_mut()
118        .find(|l| l["kind"] == json!("agentplane.export"))
119    {
120        header["checkpoint"]["root"] = json!(root);
121    }
122    let mut out = lines
123        .iter()
124        .map(|l| serde_json::to_string(l).expect("serialises"))
125        .collect::<Vec<_>>()
126        .join("\n");
127    out.push('\n');
128    out
129}
130
131/// The upcaster a newer build ships for the crafted shape change: it lifts a
132/// `StepStarted` at [`OLDER`] to this build's shape and refuses every other
133/// version it is asked about.
134#[derive(Debug, Clone, Copy, Default)]
135pub struct LiftsTheOlderShape;
136
137impl Upcaster for LiftsTheOlderShape {
138    fn current_version(&self, _kind: &str) -> u16 {
139        1
140    }
141
142    fn upcast(&self, kind: &str, version: u16, mut payload: Value) -> Result<Value, StoreError> {
143        if kind != MOVED || version != OLDER {
144            return Err(StoreError::UnknownRecordVersion {
145                kind: kind.to_owned(),
146                version,
147                reads: 1,
148            });
149        }
150        let object = payload
151            .as_object_mut()
152            .ok_or_else(|| StoreError::Backend("a record is an object".into()))?;
153        let name = object.remove("name").unwrap_or(Value::Null);
154        object.insert("skill".into(), name);
155        object.insert("v".into(), json!(1));
156        Ok(payload)
157    }
158}
159
160/// The reader the older build shipped: `StepStarted` at [`OLDER`], every
161/// other kind at 1, and nothing lifted — so a record the newer build writes is
162/// a version it has never heard of.
163#[derive(Debug, Clone, Copy, Default)]
164pub struct ReadsOnlyTheOlderShape;
165
166impl Upcaster for ReadsOnlyTheOlderShape {
167    fn current_version(&self, kind: &str) -> u16 {
168        if kind == MOVED { OLDER } else { 1 }
169    }
170
171    fn upcast(&self, kind: &str, version: u16, _payload: Value) -> Result<Value, StoreError> {
172        Err(StoreError::UnknownRecordVersion {
173            kind: kind.to_owned(),
174            version,
175            reads: self.current_version(kind),
176        })
177    }
178}