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 field | New location |
workspace_deps_output_name | payload_specs[0].output_name |
workspace_deps_installable | payload_specs[0].nix_installable |
workspace_build_output_name | payload_specs[1].output_name |
workspace_build_installable | payload_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.