Back to feed

sledtools/pika branch #105

pika-orch-incus-cleanup-23

Declare staged Linux payload specs

Target branch: master

Merge Commit: 25c22bbfb84a8ca4dabb2873f2516cad475f6de0

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

6 passed

head 140c2dc2fa0e12a75b67b1d4370a07969167fa65 · queued 2026-03-26 02:13:44 · 6 lane(s)

queued 13s · ran 29s

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 refactors the staged Linux Rust CI target configuration in the pikaci crate by replacing loose scalar fields (workspace_deps_output_name, workspace_build_output_name, workspace_deps_installable, workspace_build_installable) on StagedLinuxRustTargetConfig with a structured payload_specs: [StagedLinuxRustTargetPayloadSpec; 2] array. A new StagedLinuxRustTargetPayloadSpec struct bundles each payload's role, output name, and Nix installable into a single unit. Accessor methods on StagedLinuxRustLane and StagedLinuxRustTarget are updated to derive values from the payload spec array rather than from individual config fields. The CLI output loop, construction sites for every target variant, and all associated tests are migrated to the new representation. This consolidation removes duplicated field pairs, makes the relationship between role and its attributes explicit, and prepares the model for future extensibility (e.g., adding a third payload stage) without scattering new fields across the config struct.

Tutorial Steps

Introduce the StagedLinuxRustTargetPayloadSpec struct and constructor

Intent: Define a first-class type that pairs a payload role with its output name and Nix installable, replacing the four loose fields on StagedLinuxRustTargetConfig. A helper constructor function keeps the call sites readable.

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

Evidence
@@ -162,20 +162,45 @@ pub struct StagedLinuxRustTargetConfig {
@@ +170,25 @@ pub struct StagedLinuxRustTargetPayloadSpec {

The core of this change is a new struct and a builder function added to model.rs.

Struct definition

#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub struct StagedLinuxRustTargetPayloadSpec {
    pub role: StagedLinuxRustPayloadRole,
    pub output_name: &'static str,
    pub nix_installable: &'static str,
}

Each spec captures exactly the three pieces of data that previously lived as separate scalar fields on StagedLinuxRustTargetConfig:

Old fieldNew location
workspace_deps_output_namepayload_specs[0].output_name
workspace_deps_installablepayload_specs[0].nix_installable
workspace_build_output_namepayload_specs[1].output_name
workspace_build_installablepayload_specs[1].nix_installable

StagedLinuxRustPayloadRole also gains Serialize/Deserialize derives with #[serde(rename_all = "snake_case")] so the specs can round-trip through JSON.

Constructor helper

fn staged_linux_target_payload_specs(
    workspace_deps_output_name: &'static str,
    workspace_deps_installable: &'static str,
    workspace_build_output_name: &'static str,
    workspace_build_installable: &'static str,
) -> [StagedLinuxRustTargetPayloadSpec; 2] { ... }

This private function keeps every config() match arm concise — it accepts the same four values that used to be individual fields and returns the fixed-size array.

Replace scalar fields on StagedLinuxRustTargetConfig

Intent: Swap the four individual fields for a single `payload_specs` array field on the config struct, keeping the struct's surface area smaller and the role/attribute relationship explicit.

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

Evidence
@@ -162,20 +162,45 @@ pub struct StagedLinuxRustTargetConfig {
-    pub workspace_deps_output_name: &'static str,
-    pub workspace_build_output_name: &'static str,
+    pub payload_specs: [StagedLinuxRustTargetPayloadSpec; 2],
-    pub workspace_deps_installable: &'static str,
-    pub workspace_build_installable: &'static str,

StagedLinuxRustTargetConfig loses four fields and gains one:

pub struct StagedLinuxRustTargetConfig {
    // ... unchanged fields ...
    pub payload_specs: [StagedLinuxRustTargetPayloadSpec; 2],
    pub workspace_output_system: &'static str,
    pub shadow_recipe: &'static str,
}

Because the array is fixed-size [...; 2], the struct remains Copy and stack-allocated — no heap allocation is introduced.

Migrate all target variant config() arms to use the constructor

Intent: Update every match arm in `StagedLinuxRustTarget::config()` to call `staged_linux_target_payload_specs(...)` instead of setting four individual fields.

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

Evidence
@@ -417,11 +456,13 @@ impl StagedLinuxRustTarget {
-                workspace_deps_output_name: "ci.x86_64-linux.workspaceDeps",
-                workspace_build_output_name: "ci.x86_64-linux.workspaceBuild",
+                payload_specs: staged_linux_target_payload_specs(
+                    "ci.x86_64-linux.workspaceDeps",
+                    ".#ci.x86_64-linux.workspaceDeps",
+                    "ci.x86_64-linux.workspaceBuild",
+                    ".#ci.x86_64-linux.workspaceBuild",
+                ),
@@ -513,15 +568,35 @@ impl StagedLinuxRustTarget {

All nine target variants (PreMergePikaRust, PreMergePikaFollowup, PreMergeAgentContracts, PreMergeNotifications, PreMergeFixtureRust, PreMergeRmp, PreMergePikachatRust, PreMergePikachatTypescript, PreMergePikachatOpenclawE2e) are updated uniformly. For example:

Self::PreMergePikaRust => StagedLinuxRustTargetConfig {
    // ...
    payload_specs: staged_linux_target_payload_specs(
        "ci.x86_64-linux.workspaceDeps",
        ".#ci.x86_64-linux.workspaceDeps",
        "ci.x86_64-linux.workspaceBuild",
        ".#ci.x86_64-linux.workspaceBuild",
    ),
    // ...
},

The pattern is identical across all arms — only the Nix attribute paths differ.

Add payload_specs / payload_spec / payload_nix_installable accessors

Intent: Provide ergonomic lookup methods on both StagedLinuxRustLane and StagedLinuxRustTarget so callers can query by role without indexing into the array manually.

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

Evidence
@@ -296,17 +323,29 @@ impl StagedLinuxRustLane {
+    pub fn payload_specs(self) -> [StagedLinuxRustTargetPayloadSpec; 2] {
+    pub fn payload_spec(
+    pub fn payload_nix_installable(self, role: StagedLinuxRustPayloadRole) -> &'static str {
@@ -513,15 +568,35 @@ impl StagedLinuxRustTarget {
+    pub fn payload_specs(self) -> [StagedLinuxRustTargetPayloadSpec; 2] {
+    pub fn payload_spec(
+    pub fn payload_nix_installable(self, role: StagedLinuxRustPayloadRole) -> &'static str {

Three new methods appear on both StagedLinuxRustLane and StagedLinuxRustTarget:

// Return the full two-element spec array
pub fn payload_specs(self) -> [StagedLinuxRustTargetPayloadSpec; 2]

// Look up a single spec by role (panics if missing — impossible with the fixed array)
pub fn payload_spec(self, role: StagedLinuxRustPayloadRole) -> StagedLinuxRustTargetPayloadSpec

// Convenience: get the Nix installable string for a role
pub fn payload_nix_installable(self, role: StagedLinuxRustPayloadRole) -> &'static str

Existing accessors like workspace_deps_output_name() and workspace_build_output_name() on StagedLinuxRustLane now delegate to payload_spec(role).output_name instead of reading the (now-removed) config fields directly.

The previous payload_roles() method is preserved but now derives from payload_specs().map(|spec| spec.role), and payload_output_name(role) delegates to payload_spec(role).output_name.

Update the CLI output loop in main.rs

Intent: Replace the two hardcoded println! calls for workspace_deps_installable and workspace_build_installable with a loop over payload_specs, making the output format dynamic and consistent with the new data model.

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

Evidence
@@ -220,14 +220,13 @@ fn main() -> anyhow::Result<()> {
-                println!(
-                    "workspace_deps_installable={}",
-                    config.workspace_deps_installable
-                );
-                println!(
-                    "workspace_build_installable={}",
-                    config.workspace_build_installable
-                );
+                for payload_spec in config.payload_specs {
+                    println!(
+                        "payload_{}_installable={}",
+                        payload_spec.role.prepare_node_suffix(),
+                        payload_spec.nix_installable
+                    );
+                }

The CLI's show-target-config subcommand previously emitted two fixed key-value lines:

workspace_deps_installable=...
workspace_build_installable=...

It now iterates over config.payload_specs and emits:

payload_workspace_deps_installable=...
payload_workspace_build_installable=...

The key name is derived from role.prepare_node_suffix(), which returns "workspace_deps" or "workspace_build". This is a minor output format change — downstream consumers that parse this output will need to match the new payload_*_installable keys.

Refactor staged_linux_payload_specs in run.rs to use payload_specs()

Intent: Switch the runtime payload construction from iterating over roles to iterating over the full payload specs, reading output_name and role directly from each spec instead of calling separate lane accessor methods.

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

Evidence
@@ -1716,16 +1716,19 @@ fn staged_linux_payload_specs(
-    lane.payload_roles()
+    lane.payload_specs()
-        .map(|role| StagedPreparedPayload {
-            prepare_node_id: format!("prepare-{prefix}-{}", role.prepare_node_suffix()),
-            installable: staged_linux_rust_installable(snapshot_dir, lane, role),
-            output_name: lane.payload_output_name(role),
-            local_mount_path: role.local_mount_path(job_dir),
+        .map(|payload_spec| StagedPreparedPayload {
+            prepare_node_id: format!(
+                "prepare-{prefix}-{}",
+                payload_spec.role.prepare_node_suffix()
+            ),
+            installable: staged_linux_rust_installable(snapshot_dir, lane, payload_spec.role),
+            output_name: payload_spec.output_name,
+            local_mount_path: payload_spec.role.local_mount_path(job_dir),

In run.rs, the staged_linux_payload_specs function is the runtime consumer that builds StagedPreparedPayload values for each stage of the CI pipeline.

Before this change it called lane.payload_roles() and then separately looked up lane.payload_output_name(role). Now it calls lane.payload_specs() and reads output_name directly from the spec:

lane.payload_specs()
    .into_iter()
    .map(|payload_spec| StagedPreparedPayload {
        output_name: payload_spec.output_name,   // direct access
        // ...
    })

This eliminates the indirection of asking the lane to re-derive data that already lives in the spec.

Update all tests to use the new accessor API

Intent: Migrate test assertions from accessing config struct fields directly to calling the payload_nix_installable(role) method and indexing into payload_specs for JSON assertions.

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

Evidence
@@ -2434,11 +2433,11 @@ mod tests {
-            payload["workspace_deps_installable"],
+            payload["payload_specs"][0]["nix_installable"],
@@ -1034,11 +1109,11 @@ mod tests {
-            notifications_config.workspace_deps_installable,
+            notifications.payload_nix_installable(StagedLinuxRustPayloadRole::WorkspaceDeps),
@@ -1197,16 +1272,19 @@ mod tests {
-            let config = target.config();
+            let workspace_deps_installable =
+                target.payload_nix_installable(StagedLinuxRustPayloadRole::WorkspaceDeps);

Tests in both main.rs and model.rs are updated:

Serialization test (main.rs): JSON assertions now index into the payload_specs array:

assert_eq!(
    payload["payload_specs"][0]["nix_installable"],
    ".#ci.x86_64-linux.workspaceDeps"
);

Config accessor tests (model.rs): All assertions that previously read config.workspace_deps_installable or config.workspace_build_installable now call the typed accessor:

assert_eq!(
    notifications.payload_nix_installable(StagedLinuxRustPayloadRole::WorkspaceDeps),
    ".#ci.x86_64-linux.notificationsWorkspaceDeps"
);

Allowlist loop test: The test that verifies all installables appear in the strict staged remote helper now binds the installable strings via target.payload_nix_installable(role) before asserting, keeping the error messages readable.

This covers every target: notifications, followup, fixture, pikachat (Rust, TypeScript, OpenClaw), RMP, agent contracts, and core pika.

Diff