Back to feed

sledtools/pika branch #97

pika-cloud-lifecycle-loaders

Add shared lifecycle artifact loaders

Target branch: master

Merge Commit: 43f822d167719b965d4cc7ef9d4027e43d83f2fb

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 #123 success

7 passed

head 003c344182d50e27a9002613f5c0952b1ee9c074 · queued 2026-03-26 01:41:21 · 7 lane(s)

queued 7s · ran 2m 02s

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

Summary

This branch consolidates scattered, ad-hoc lifecycle artifact loading logic into a shared set of loader functions inside the pika-cloud crate. Previously, both pika-server and pikaci maintained their own private helpers (decode_guest_lifecycle_status and load_guest_terminal_result) for reading and decoding runtime status, terminal result, and event files. The branch replaces these with canonical load_runtime_status, load_runtime_terminal_result, and load_runtime_events functions backed by a new RuntimeLifecycleLoadError enum that provides structured, context-rich error messages including file paths, source names, and line numbers. A now-redundant LifecycleEventKind enum is collapsed into a type alias for RuntimeState, and the removed LifecycleEventKind-to-RuntimeState conversion is eliminated as it was a trivial identity mapping. Comprehensive unit tests cover all new loaders and error variants.

Tutorial Steps

Remove redundant LifecycleEventKind enum and its From impl

Intent: The `LifecycleEventKind` enum was a 1:1 mirror of `RuntimeState` with a trivial identity `From` conversion. Removing it and replacing it with a type alias eliminates unnecessary indirection while preserving backward compatibility for any downstream code that references the name.

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

Evidence
@@ -18,38 +23,6 @@ pub enum RuntimeState {
     Completed,
 }
 
-#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum LifecycleEventKind {
-    Requested,
-    Provisioning,
-    ...
@@ -153,6 +270,15 @@ pub type RuntimeTerminalResult = LifecycleTerminalResult;
+pub type LifecycleEventKind = RuntimeState;

The LifecycleEventKind enum duplicated every variant of RuntimeState and existed only to be converted back via From<LifecycleEventKind> for RuntimeState, which was an identity mapping. Both the enum and the impl are removed, and LifecycleEventKind becomes a simple type alias:

pub type LifecycleEventKind = RuntimeState;

A new test confirms the alias is equivalent:

#[test]
fn lifecycle_event_kind_alias_matches_runtime_state() {
    let kind: LifecycleEventKind = RuntimeState::Ready;
    assert_eq!(kind, RuntimeState::Ready);
}

This avoids the maintenance burden of keeping two identical enum definitions in sync.

Introduce RuntimeLifecycleLoadError for structured diagnostics

Intent: Provide a single error type that distinguishes file-read failures from JSON decode failures and carries context such as file paths, source names, and line numbers, replacing generic `anyhow` / `serde_json::Error` chains scattered across consumers.

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

Evidence
@@ -104,6 +77,78 @@ impl LifecycleTerminalStatus {
+#[derive(Debug)]
+pub enum RuntimeLifecycleLoadError {
+    Read {
+        path: PathBuf,
+        source: io::Error,
+    },
+    DecodeStatus {
+        source_name: String,
+        source: serde_json::Error,
+    },
+    DecodeTerminalResult {
+        source_name: String,
+        source: serde_json::Error,
+    },
+    DecodeEventLine {
+        path: PathBuf,
+        line: usize,
+        source: serde_json::Error,
+    },
+}

A new RuntimeLifecycleLoadError enum is added with four variants covering the distinct failure modes when loading lifecycle artifacts:

VariantContext carriedWhen it fires
ReadPathBuf + io::Errorfs::read / fs::read_to_string fails
DecodeStatussource name + serde_json::ErrorStatus JSON is malformed
DecodeTerminalResultsource name + serde_json::ErrorTerminal result JSON is malformed
DecodeEventLinePathBuf + 1-based line number + serde_json::ErrorA single NDJSON event line fails to parse

Both fmt::Display and std::error::Error (with source() delegation) are implemented manually, ensuring the error messages are human-readable and the causal chain is preserved for anyhow or similar wrappers.

The type is re-exported from lib.rs so consumers outside the crate can match on it if needed.

Add artifact-aware decode wrappers: decode_runtime_status_artifact and decode_runtime_terminal_result_artifact

Intent: Wrap the existing zero-context `decode_runtime_status` and `decode_runtime_terminal_result` functions with versions that accept a `source_name` and return `RuntimeLifecycleLoadError`, providing a richer error without breaking the raw decoders.

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

Evidence
@@ -129,6 +174,19 @@ pub fn decode_runtime_terminal_result(bytes: &[u8]) -> serde_json::Result<Runtim
+pub fn decode_runtime_terminal_result_artifact(
+    source_name: impl Into<String>,
+    bytes: &[u8],
+) -> Result<RuntimeTerminalResult, RuntimeLifecycleLoadError> {
@@ -145,6 +203,65 @@ pub fn decode_runtime_status(bytes: &[u8]) -> serde_json::Result<RuntimeStatusSn
+pub fn decode_runtime_status_artifact(
+    source_name: impl Into<String>,
+    bytes: &[u8],
+) -> Result<RuntimeStatusSnapshot, RuntimeLifecycleLoadError> {

Two new public functions sit alongside the existing raw decoders:

pub fn decode_runtime_status_artifact(
    source_name: impl Into<String>,
    bytes: &[u8],
) -> Result<RuntimeStatusSnapshot, RuntimeLifecycleLoadError>

pub fn decode_runtime_terminal_result_artifact(
    source_name: impl Into<String>,
    bytes: &[u8],
) -> Result<RuntimeTerminalResult, RuntimeLifecycleLoadError>

They delegate to the original serde_json::from_slice decoders and map the error into the appropriate RuntimeLifecycleLoadError variant, attaching the caller-supplied source_name (typically a file path or description). This pattern keeps the raw decoders available for callers who already have their own error handling while giving higher-level callers an easy upgrade path.

Add file-based loader functions: load_runtime_status, load_runtime_terminal_result, load_runtime_events

Intent: Provide complete read-from-disk-and-decode functions that encapsulate `fs::read` + decode in a single call, replacing ad-hoc two-step patterns in downstream crates.

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

Evidence
@@ -145,6 +203,65 @@ pub fn decode_runtime_status(bytes: &[u8]) -> serde_json::Result<RuntimeStatusSn
+pub fn load_runtime_status(
+    path: impl AsRef<Path>,
+) -> Result<RuntimeStatusSnapshot, RuntimeLifecycleLoadError> {
@@ +225,0 +225,15 @@
+pub fn load_runtime_terminal_result(
+    path: impl AsRef<Path>,
+) -> Result<RuntimeTerminalResult, RuntimeLifecycleLoadError> {
@@ +240,0 +240,25 @@
+pub fn load_runtime_events(
+    path: impl AsRef<Path>,
+) -> Result<Vec<LifecycleEvent>, RuntimeLifecycleLoadError> {

Three new loader functions handle the full read-decode lifecycle:

  1. load_runtime_status — reads a status JSON file and decodes it into RuntimeStatusSnapshot.
  2. load_runtime_terminal_result — reads a terminal result JSON file and decodes it into RuntimeTerminalResult.
  3. load_runtime_events — reads an NDJSON events file line-by-line, skipping blank lines, and decodes each into a LifecycleEvent. On failure it reports the 1-based line number.

All three follow the same pattern:

pub fn load_runtime_status(path: impl AsRef<Path>) -> Result<RuntimeStatusSnapshot, RuntimeLifecycleLoadError> {
    let path = path.as_ref();
    let bytes = fs::read(path).map_err(|source| RuntimeLifecycleLoadError::Read {
        path: path.to_path_buf(),
        source,
    })?;
    decode_runtime_status_artifact(path.display().to_string(), &bytes)
}

The load_runtime_events function deserves special attention: it iterates with enumerate(), adds 1 for human-friendly line numbers, and skips empty lines to tolerate trailing newlines in NDJSON files.

Migrate pika-server to decode_runtime_status_artifact and remove decode_guest_lifecycle_status

Intent: Replace the private `decode_guest_lifecycle_status` wrapper in `pika-server` with the shared `decode_runtime_status_artifact`, eliminating duplicated logic and gaining better error context.

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

Evidence
@@ -36,11 +36,11 @@
-    decode_runtime_status, incus_mount_device_config,
+    decode_runtime_status_artifact, incus_mount_device_config,
@@ -1896,7 +1896,7 @@
-        match decode_guest_lifecycle_status(&status_bytes) {
+        match decode_runtime_status_artifact(&status_path, &status_bytes) {
@@ -2452,10 +2452,6 @@
-fn decode_guest_lifecycle_status(status_bytes: &[u8]) -> serde_json::Result<RuntimeStatusSnapshot> {
-    decode_runtime_status(status_bytes)
-}

In agent_api.rs the private one-liner decode_guest_lifecycle_status simply forwarded to decode_runtime_status. It is deleted and replaced with a direct call to the new shared decode_runtime_status_artifact, passing &status_path as the source name:

// Before
match decode_guest_lifecycle_status(&status_bytes) { ... }

// After
match decode_runtime_status_artifact(&status_path, &status_bytes) { ... }

The import list is updated accordingly, swapping decode_runtime_status for decode_runtime_status_artifact. The existing test is renamed from decode_guest_lifecycle_status_rejects_malformed_snapshot to decode_runtime_status_artifact_rejects_malformed_snapshot and updated to pass STATUS_PATH as the source name argument.

Migrate pikaci executor to load_runtime_terminal_result and remove load_guest_terminal_result

Intent: Replace the private `load_guest_terminal_result` helper in the CI executor with the shared `load_runtime_terminal_result`, removing duplicated read+decode logic.

Affected files: crates/pikaci/src/executor.rs

Evidence
@@ -12,8 +12,8 @@
-    RuntimeResultStatus, RuntimeTerminalResult, STATUS_PATH, decode_runtime_terminal_result,
-    encode_runtime_terminal_result_pretty, runtime_terminal_result_for_exit_code,
+    RuntimeResultStatus, RuntimeTerminalResult, STATUS_PATH, encode_runtime_terminal_result_pretty,
+    load_runtime_terminal_result, runtime_terminal_result_for_exit_code,
@@ -1014,7 +1014,8 @@
-        let guest_result = load_guest_terminal_result(&ctx.job_dir.join("artifacts/result.json"))?;
+        let guest_result = load_runtime_terminal_result(ctx.job_dir.join("artifacts/result.json"))
+            .with_context(|| "load guest terminal result".to_string())?;
@@ -1725,11 +1727,6 @@
-fn load_guest_terminal_result(path: &Path) -> anyhow::Result<RuntimeTerminalResult> {
-    let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
-    decode_runtime_terminal_result(&bytes).with_context(|| format!("decode {}", path.display()))
-}

The pikaci executor had its own private load_guest_terminal_result that performed fs::read followed by decode_runtime_terminal_result, wrapping each step with anyhow::Context. This is replaced by a single call to the shared load_runtime_terminal_result:

// Before
let guest_result = load_guest_terminal_result(&ctx.job_dir.join("artifacts/result.json"))?;

// After
let guest_result = load_runtime_terminal_result(ctx.job_dir.join("artifacts/result.json"))
    .with_context(|| "load guest terminal result".to_string())?;

Note the subtle API difference: the shared function takes impl AsRef<Path> (owned values accepted), so the & is dropped. The .with_context() call wraps the RuntimeLifecycleLoadError in anyhow to stay consistent with the executor's existing error strategy. Both call sites in the executor (run_remote_linux_vm_job and run_tart_job) are updated identically, and the now-unused decode_runtime_terminal_result import is removed.

Add comprehensive tests for new loaders and error variants

Intent: Ensure all new code paths are exercised: the type alias, artifact decode wrappers, file-based loaders, and every error variant's Display output.

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

Evidence
@@ +315,13 @@
+    fn lifecycle_event_kind_alias_matches_runtime_state()
@@ +384,37 @@
+    fn decode_runtime_status_artifact_reports_source_name()
@@ +400,0 +400,10 @@
+    fn load_runtime_status_reports_missing_path()
@@ +456,39 @@
+    fn load_runtime_terminal_result_reports_path_on_decode_failure()
@@ +475,0 +475,20 @@
+    fn load_runtime_events_reports_line_number()

Six new tests are added across the pika-cloud and pika-server crates:

TestWhat it verifies
lifecycle_event_kind_alias_matches_runtime_stateThe type alias is assignment-compatible
decode_runtime_status_artifact_reports_source_nameError message includes the supplied source path
load_runtime_status_reports_missing_pathRead variant error includes the file path for a non-existent file
load_runtime_terminal_result_reports_path_on_decode_failureWrites malformed JSON to a temp file and checks the error contains the path
load_runtime_events_reports_line_numberWrites two NDJSON lines (second is invalid) and asserts the error says "line 2"
decode_runtime_status_artifact_rejects_malformed_snapshot (pika-server)Renamed from the old decode_guest_lifecycle_status test, now passes STATUS_PATH as source name

A helper temp_test_path generates unique temporary file names using nanosecond timestamps to avoid collisions when tests run in parallel. Tests that create files clean up after themselves with fs::remove_file.

Diff