Back to feed

sledtools/pika branch #91

pika-orch-incus-cleanup-20

Centralize staged Linux payload roles

Target branch: master

Merge Commit: 894a7e9fdea908ffe6378a3c3812638020d90709

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

6 passed

head 9f0aea3e388b6766b80994f4e27eb526e605f6a7 · queued 2026-03-26 01:21:58 · 6 lane(s)

queued 14s · ran 30s

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

Summary

This branch centralizes the previously hard-coded staged Linux payload roles (workspace-deps and workspace-build) into a first-class enum, StagedLinuxRustPayloadRole. Before this change, run-plan construction in run.rs duplicated blocks of logic for each payload variant — one for dependencies and one for the build — with string literals scattered throughout. The refactor introduces the enum in model.rs with methods that return stable identifiers (node suffixes, mount directory names, device prefixes, and human-readable descriptions), adds a payload_roles() accessor on StagedLinuxRustLane, and rewrites the plan-building loop in run.rs to iterate over roles generically. The result is less duplicated code, a single source of truth for role metadata, and a structure that scales cleanly if additional payload roles are added in the future.

Tutorial Steps

Introduce the StagedLinuxRustPayloadRole enum

Intent: Define a central enum that enumerates the two payload roles (WorkspaceDeps and WorkspaceBuild) and exposes their associated string identifiers through methods rather than scattered literals.

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

Evidence
@@ -167,6 +167,36 @@ pub struct StagedLinuxRustTargetConfig {
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum StagedLinuxRustPayloadRole {
+    WorkspaceDeps,
+    WorkspaceBuild,
+}
+
+impl StagedLinuxRustPayloadRole {
+    pub fn prepare_node_suffix(self) -> &'static str {
+        match self {
+            Self::WorkspaceDeps => "workspace-deps",
+            Self::WorkspaceBuild => "workspace-build",
+        }
+    }
+
+    pub fn mount_dir_name(self) -> &'static str {
+        self.prepare_node_suffix()
+    }
+
+    pub fn device_prefix(self) -> &'static str {
+        self.prepare_node_suffix()
+    }
+
+    pub fn prepare_description(self) -> &'static str {
+        match self {
+            Self::WorkspaceDeps => "Build staged Linux Rust dependencies",
+            Self::WorkspaceBuild => "Build staged Linux Rust test artifacts",
+        }
+    }
+}

A new StagedLinuxRustPayloadRole enum is added directly after StagedLinuxRustTargetConfig. It carries two variants — WorkspaceDeps and WorkspaceBuild — and provides four accessor methods:

  • prepare_node_suffix — returns the kebab-case suffix used when constructing prepare-node IDs (e.g. "workspace-deps").
  • mount_dir_name — returns the directory name under staged-linux-rust/ where the payload is mounted. Currently delegates to prepare_node_suffix since the values are identical, but having a separate method preserves the semantic distinction.
  • device_prefix — same delegation, used when matching StagedPayloadMount entries.
  • prepare_description — returns a human-readable label used in plan-node descriptions.

By deriving Clone, Copy, Eq, PartialEq, the enum can be cheaply passed by value and compared in tests.

Add payload_roles() and payload_output_name() to StagedLinuxRustLane

Intent: Give each lane a canonical ordered list of its payload roles and a single dispatch method to resolve the Nix output name for a given role, replacing the two separate output-name methods that callers previously used directly.

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

Evidence
@@ -253,6 +283,20 @@ impl StagedLinuxRustLane {
+    pub fn payload_roles(self) -> [StagedLinuxRustPayloadRole; 2] {
+        [
+            StagedLinuxRustPayloadRole::WorkspaceDeps,
+            StagedLinuxRustPayloadRole::WorkspaceBuild,
+        ]
+    }
+
+    pub fn payload_output_name(self, role: StagedLinuxRustPayloadRole) -> &'static str {
+        match role {
+            StagedLinuxRustPayloadRole::WorkspaceDeps => self.workspace_deps_output_name(),
+            StagedLinuxRustPayloadRole::WorkspaceBuild => self.workspace_build_output_name(),
+        }
+    }

payload_roles() returns a fixed-size [StagedLinuxRustPayloadRole; 2] array. The ordering is significant: WorkspaceDeps comes first because the build step depends on the deps step, and the run-plan builder in run.rs will chain dependencies in iteration order.

payload_output_name() dispatches to the existing per-role output-name methods (workspace_deps_output_name / workspace_build_output_name). This provides a single entry point that the plan builder can call generically without knowing which role it is processing.

Add a contract test for the new payload-role API

Intent: Lock down the string values returned by the enum and the output-name mapping so that future changes to these identifiers break a test before they break a deployment.

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

Evidence
@@ -936,6 +980,34 @@ mod tests {
+    #[test]
+    fn staged_linux_payload_roles_use_stable_mount_and_output_contracts() {
+        let lane = StagedLinuxRustLane::NotificationsServerPackageTests;
+        let roles = lane.payload_roles();
+
+        assert_eq!(
+            roles,
+            [
+                StagedLinuxRustPayloadRole::WorkspaceDeps,
+                StagedLinuxRustPayloadRole::WorkspaceBuild,
+            ]
+        );
+        assert_eq!(roles[0].prepare_node_suffix(), "workspace-deps");
+        assert_eq!(roles[0].mount_dir_name(), "workspace-deps");
+        assert_eq!(roles[0].device_prefix(), "workspace-deps");
+        assert_eq!(
+            lane.payload_output_name(roles[0]),
+            "ci.x86_64-linux.notificationsWorkspaceDeps"
+        );
+        assert_eq!(roles[1].prepare_node_suffix(), "workspace-build");
+        assert_eq!(roles[1].mount_dir_name(), "workspace-build");
+        assert_eq!(roles[1].device_prefix(), "workspace-build");
+        assert_eq!(
+            lane.payload_output_name(roles[1]),
+            "ci.x86_64-linux.notificationsWorkspaceBuild"
+        );
+    }

The test picks the NotificationsServerPackageTests lane as a representative and asserts:

  1. payload_roles() returns the two roles in the expected order.
  2. Each role's prepare_node_suffix, mount_dir_name, and device_prefix return the expected kebab-case strings.
  3. payload_output_name for each role resolves to the full Nix output attribute path (e.g. ci.x86_64-linux.notificationsWorkspaceDeps).

These assertions act as a contract: if anyone changes a string constant, they will see a test failure that explicitly shows the expected value.

Replace duplicated plan-node blocks with a role-driven loop

Intent: Eliminate the copy-pasted blocks that separately handled workspace-deps and workspace-build prepare nodes, replacing them with a single `for role in lane.payload_roles()` loop that constructs nodes generically.

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

Evidence
@@ -1492,59 +1488,18 @@ fn build_run_plan(
-            let deps_node_id = format!("prepare-{prefix}-workspace-deps");
-            let build_node_id = format!("prepare-{prefix}-workspace-build");
+            let mut previous_node_id = None;
+            for role in lane.payload_roles() {
+                let node_id = format!("prepare-{prefix}-{}", role.prepare_node_suffix());
+                let installable = staged_linux_rust_installable(&snapshot.snapshot_dir, lane, role);
@@ -1555,16 +1510,17 @@ fn build_run_plan(
-                        id: build_node_id.clone(),
+                        id: node_id.clone(),
                         description: format!(
-                            "Build staged Linux Rust test artifacts for {}",
+                            "{} for {}",
+                            role.prepare_description(),
                             lane.shared_prepare_description()
                         ),
@@ -1573,31 +1529,27 @@ fn build_run_plan(
+                depends_on.push(node_id.clone());
+                previous_node_id = Some(node_id);
             }
-            depends_on.push(deps_node_id);
-            depends_on.push(build_node_id);

The most impactful change is in build_run_plan. Previously there were two nearly identical blocks: one constructing a prepare-…-workspace-deps node and another for prepare-…-workspace-build, each with duplicated PlannedPrepare / PlanNodeRecord::Prepare construction, mount-lookup logic, and dependency wiring.

The new code replaces both blocks with a single loop:

let mut previous_node_id = None;
for role in lane.payload_roles() {
    let node_id = format!("prepare-{prefix}-{}", role.prepare_node_suffix());
    ...
    previous_node_id = Some(node_id);
}

Key behavioral details preserved by the loop:

  • Dependency chaining: previous_node_id tracks the last node so that each subsequent role depends on the one before it (workspace-build depends on workspace-deps).
  • Description formatting: Uses role.prepare_description() instead of hard-coded strings, producing the same output ("Build staged Linux Rust dependencies for …").
  • Output name resolution: Calls lane.payload_output_name(role) instead of choosing between workspace_deps_output_name() and workspace_build_output_name().
  • Mount and device prefix lookup: Uses role.mount_dir_name() and role.device_prefix() instead of string literals.

Refactor staged_linux_rust_installable to accept a role instead of a boolean

Intent: Replace the boolean `deps_only` parameter with the typed `StagedLinuxRustPayloadRole` enum, making the function signature self-documenting and consistent with the rest of the refactor.

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

Evidence
@@ -1776,13 +1728,9 @@ fn remove_path_if_exists(path: &Path) -> anyhow::Result<()> {
 fn staged_linux_rust_installable(
     snapshot_dir: &Path,
     lane: StagedLinuxRustLane,
-    deps_only: bool,
+    role: StagedLinuxRustPayloadRole,
 ) -> String {
-    let output_name = if deps_only {
-        lane.workspace_deps_output_name()
-    } else {
-        lane.workspace_build_output_name()
-    };
+    let output_name = lane.payload_output_name(role);
     format!("path:{}#{output_name}", snapshot_dir.display())
 }

The helper function staged_linux_rust_installable previously took a bool parameter deps_only and used an if/else to select the output name. This is the classic boolean-parameter code smell — at the call site, true and false carry no inherent meaning.

The new signature accepts role: StagedLinuxRustPayloadRole, delegates to lane.payload_output_name(role), and removes the conditional entirely. The single call site in the loop naturally passes the current role value.

Refactor staged payload mount construction to use roles

Intent: Replace the two hand-written `StagedPayloadMount` structs with a role-driven iterator, ensuring the mount paths and device prefixes stay consistent with the enum's single source of truth.

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

Evidence
@@ -1468,21 +1469,16 @@ fn build_run_plan(
-                .map(|_| {
-                    vec![
-                        StagedPayloadMount {
+                .map(|lane| {
+                    lane.payload_roles()
+                        .into_iter()
+                        .map(|role| StagedPayloadMount {
                             local_mount_path: job_dir
                                 .join("staged-linux-rust")
-                                .join("workspace-deps"),
-                            device_prefix: "workspace-deps".to_string(),
-                        },
-                        StagedPayloadMount {
-                            local_mount_path: job_dir
-                                .join("staged-linux-rust")
-                                .join("workspace-build"),
-                            device_prefix: "workspace-build".to_string(),
-                        },
-                    ]
+                                .join(role.mount_dir_name()),
+                            device_prefix: role.device_prefix().to_string(),
+                        })
+                        .collect()

Earlier in build_run_plan, the staged_payload_mounts field was populated by manually constructing a two-element Vec with hard-coded "workspace-deps" and "workspace-build" strings.

The new code maps over lane.payload_roles() and builds each StagedPayloadMount from the role's mount_dir_name() and device_prefix() methods. This keeps mount construction in sync with the prepare-node loop — if a role's identifiers change, both locations update from the same enum method.

Diff