Back to feed

sledtools/pika branch #86

pika-cloud-followup

Share lifecycle status decode helpers

Target branch: master

Merge Commit: b021779de00e08b1d312bfcd818295e7b53dc720

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #111 success

5 passed

head 12e36bf40a83060c483c6e578b490009099d75a3 · queued 2026-03-26 01:01:36 · 5 lane(s)

queued 12s · ran 1m 59s

check-notifications · success check-agent-contracts · success check-pikachat · success check-apple-host-sanity · success check-fixture · success

Summary

This branch extracts and shares lifecycle-status serialization helpers from pika-server into the pika-cloud crate, following the same pattern already established for decode_runtime_terminal_result and encode_runtime_terminal_result_pretty. Two new public functions — decode_runtime_status and encode_runtime_status_pretty — are added to pika-cloud::lifecycle, re-exported from the crate root, and then consumed in pika-server::agent_api through a thin decode_guest_lifecycle_status wrapper that replaces a previous inline serde_json::from_slice call. Corresponding round-trip and negative-case unit tests are added in both crates to lock in the contract.

Tutorial Steps

Add encode/decode helpers for RuntimeStatusSnapshot

Intent: Provide shared, symmetrical serialization helpers for `RuntimeStatusSnapshot` in the `pika-cloud` crate so that every consumer deserializes lifecycle status through a well-known entry point instead of calling `serde_json` directly.

Affected files: crates/pika-cloud/src/lifecycle.rs

Evidence
@@ -129,6 +129,14 @@ pub fn decode_runtime_terminal_result(bytes: &[u8]) -> serde_json::Result<Runtim
     serde_json::from_slice(bytes)
 }
 
+pub fn encode_runtime_status_pretty(status: &RuntimeStatusSnapshot) -> serde_json::Result<Vec<u8>> {
+    serde_json::to_vec_pretty(status)
+}
+
+pub fn decode_runtime_status(bytes: &[u8]) -> serde_json::Result<RuntimeStatusSnapshot> {
+    serde_json::from_slice(bytes)
+}

Two new public functions are introduced in crates/pika-cloud/src/lifecycle.rs:132-138, mirroring the existing decode_runtime_terminal_result / encode_runtime_terminal_result_pretty pair:

  • encode_runtime_status_pretty — serializes a RuntimeStatusSnapshot to pretty-printed JSON bytes.
  • decode_runtime_status — deserializes JSON bytes back into a RuntimeStatusSnapshot.

Both delegate directly to serde_json and return its Result, keeping the API surface thin while centralizing the serialization contract in one place.

Re-export the new helpers from the crate root

Intent: Make `decode_runtime_status` and `encode_runtime_status_pretty` accessible via `pika_cloud::` so downstream crates can import them without reaching into the `lifecycle` module.

Affected files: crates/pika-cloud/src/lib.rs

Evidence
@@ -11,7 +11,8 @@ pub use incus::{
 };
 pub use lifecycle::{
     LIFECYCLE_SCHEMA_VERSION, LifecycleEvent, LifecycleState, RuntimeResultStatus,
-    RuntimeStatusSnapshot, RuntimeTerminalResult, decode_runtime_terminal_result,
+    RuntimeStatusSnapshot, RuntimeTerminalResult, decode_runtime_status,
+    decode_runtime_terminal_result, encode_runtime_status_pretty,
     encode_runtime_terminal_result_pretty, runtime_terminal_result_for_exit_code,
 };

The pub use lifecycle::{…} block in crates/pika-cloud/src/lib.rs:14-15 is extended with decode_runtime_status and encode_runtime_status_pretty, keeping the re-exports in alphabetical order alongside the previously existing terminal-result helpers.

Add round-trip and negative-case tests in pika-cloud

Intent: Verify that the new helpers correctly round-trip a valid `RuntimeStatusSnapshot` and reject payloads with unknown enum variants, preventing silent data corruption.

Affected files: crates/pika-cloud/src/lifecycle.rs

Evidence
@@ -168,6 +176,38 @@ mod tests {
         assert!(encoded.contains("\"provisioning\""));
     }
 
+    #[test]
+    fn runtime_status_helpers_round_trip() {
+        let status = RuntimeStatusSnapshot {
+            schema_version: LIFECYCLE_SCHEMA_VERSION,
+            state: RuntimeState::Ready,
+            updated_at: "2026-03-25T20:00:00Z".to_string(),
+            message: "guest declared readiness".to_string(),
+            boot_id: Some("boot-123".to_string()),
+            details: Some(serde_json::json!({ "service": "openclaw" })),
+        };
+
+        let encoded = encode_runtime_status_pretty(&status).expect("encode");
+        let decoded = decode_runtime_status(&encoded).expect("decode");
+
+        assert_eq!(decoded, status);
+    }
+
+    #[test]
+    fn runtime_status_rejects_unknown_state() {

Two new tests are added to the existing mod tests block in crates/pika-cloud/src/lifecycle.rs:

  1. runtime_status_helpers_round_trip — constructs a RuntimeStatusSnapshot with all optional fields populated, encodes it via encode_runtime_status_pretty, decodes the result via decode_runtime_status, and asserts structural equality.
  2. runtime_status_rejects_unknown_state — feeds a JSON payload containing "state": "passed" (an invalid variant) into decode_runtime_status and asserts the error message includes "unknown variant", confirming the strict enum deserialization contract.

Consume the shared decoder in pika-server via a local wrapper

Intent: Replace the inline `serde_json::from_slice::<RuntimeStatusSnapshot>(…)` call in the server with a thin local function that delegates to the shared `decode_runtime_status` helper, consolidating the deserialization path and making the call site self-documenting.

Affected files: crates/pika-server/src/agent_api.rs

Evidence
@@ -36,8 +36,8 @@ use crate::nostr_auth::{
 };
 use crate::{RequestContext, State};
 use pika_cloud::{
-    incus_mount_device_config, incus_runtime_config, AgentProvisionRequest, AgentStartupPhase,
-    IncusProvisionParams, IncusRuntimeConfig, IncusRuntimePlan, LifecycleState,
+    decode_runtime_status, incus_mount_device_config, incus_runtime_config, AgentProvisionRequest,
+    AgentStartupPhase, IncusProvisionParams, IncusRuntimeConfig, IncusRuntimePlan, LifecycleState,
@@ -1896,7 +1896,7 @@ impl IncusManagedRuntimeProvider {
                 return None;
             }
         };
-        match serde_json::from_slice::<RuntimeStatusSnapshot>(&status_bytes) {
+        match decode_guest_lifecycle_status(&status_bytes) {
@@ -2452,6 +2452,10 @@ impl IncusManagedRuntimeProvider {
     }
 }
 
+fn decode_guest_lifecycle_status(status_bytes: &[u8]) -> serde_json::Result<RuntimeStatusSnapshot> {
+    decode_runtime_status(status_bytes)
+}

Three changes land in crates/pika-server/src/agent_api.rs:

  1. Importdecode_runtime_status is added to the pika_cloud::{…} import block (line 39).
  2. Call site — The match expression in IncusManagedRuntimeProvider at line 1899 now calls decode_guest_lifecycle_status(&status_bytes) instead of the previous inline serde_json::from_slice::<RuntimeStatusSnapshot>(…).
  3. Wrapper function — A new private decode_guest_lifecycle_status function (line 2455) delegates to decode_runtime_status. This thin wrapper gives the server a named seam for lifecycle decoding, which also makes the function independently testable without constructing the full provider.

Add server-side negative test for the lifecycle decoder

Intent: Ensure the server's decode wrapper correctly propagates deserialization failures for malformed lifecycle payloads, mirroring the negative test in pika-cloud.

Affected files: crates/pika-server/src/agent_api.rs

Evidence
@@ -4291,6 +4295,21 @@ mod tests {
         assert_eq!(err, "boot_id mismatch");
     }
 
+    #[test]
+    fn decode_guest_lifecycle_status_rejects_malformed_snapshot() {
+        let err = decode_guest_lifecycle_status(
+            br#"{
+                "schema_version": 1,
+                "state": "passed",
+                "updated_at": "2026-03-25T20:00:00Z",
+                "message": "guest declared readiness"
+            }"#,
+        )
+        .expect_err("malformed status should fail");
+
+        assert!(err.to_string().contains("unknown variant"));
+    }

A new test decode_guest_lifecycle_status_rejects_malformed_snapshot is added to the mod tests block in crates/pika-server/src/agent_api.rs:4298. It feeds the same invalid "state": "passed" payload used in the pika-cloud test, confirming the server wrapper surfaces the "unknown variant" error from the shared decoder. This ensures the server never silently accepts an unrecognized lifecycle state.

Diff