Introduce `JobPlacementKind` and `JobRuntimeKind` enums
Intent: Replace the single `RunnerKind` enum with two orthogonal dimensions: *where* a job executes (`JobPlacementKind`: `Local` or `RemoteSsh`) and *how* it is isolated (`JobRuntimeKind`: `HostProcess`, `Incus`, or `Tart`). This lets every combination be expressed explicitly instead of overloading a single discriminant.
Affected files: crates/pikaci/src/model.rs
Evidence
@@ -274,10 +275,21 @@ pub fn run_job_on_runner(job: &JobSpec, ctx: &HostContext) -> anyhow::Result<JobOutcome> {
- match job.runner_kind() {
- RunnerKind::HostLocal => run_host_local_job(job, ctx),
- RunnerKind::RemoteLinuxVm => run_remote_linux_vm_job(job, ctx),
- RunnerKind::TartLocal => run_tart_job(job, ctx),
+ match job.runtime_kind() {
+ JobRuntimeKind::HostProcess => match job.placement_kind() {
@@ -5,7 +5,8 @@ use pikaci::{load_logs, load_run_bundle, load_run_record, LogKind, Logs, RunBund
- JobRecord, PreparedOutputsRecord, RemoteLinuxVmExecutionRecord, RunLifecycleEvent, RunStatus,
+ JobPlacementKind, JobRecord, JobRuntimeKind, PreparedOutputsRecord,
+ RemoteLinuxVmExecutionRecord, RunLifecycleEvent, RunStatus,
The old RunnerKind enum (HostLocal, RemoteLinuxVm, TartLocal) encoded both where a job runs and what virtualisation backend it uses in a single value. This branch splits it into two independent enums:
pub enum JobPlacementKind { Local, RemoteSsh }
pub enum JobRuntimeKind { HostProcess, Incus, Tart }
These are paired inside JobExecutionConfig, which exposes three canonical constants (HOST_LOCAL, REMOTE_SSH_INCUS, LOCAL_TART) for the combinations that are currently supported. The run_job_on_runner dispatcher now performs a two-level match — first on runtime_kind(), then on placement_kind() — and returns explicit bail!() errors for combinations that are not yet implemented (e.g. remote-SSH Tart, local Incus). This makes it straightforward to add new placement×runtime pairs later without touching existing arms.
Add `JobExecutionConfig` to `JobSpec` and wire up accessor helpers
Intent: Embed the new execution config on every job so that the executor can query placement and runtime independently, and add convenience accessors (`placement_kind()`, `runtime_kind()`, `mount_host_rust_toolchain()`) on `JobSpec`.
Affected files: crates/pikaci/src/model.rs, crates/pikaci/src/executor.rs, crates/pikaci/src/executor/incus.rs
Evidence
@@ -2503,6 +2518,34 @@ mod tests {
+ fn remote_incus_job_base() -> JobSpec {
+ JobSpec {
+ execution: JobExecutionConfig::REMOTE_SSH_INCUS,
+ ..
@@ -1498,7 +1513,7 @@ fn tart_tagged_share(path: &Path, read_only: bool, tag: &str) -> String {
- job.id == "tart-env-probe" || job.id.starts_with("tart-desktop")
+ job.mount_host_rust_toolchain()
JobSpec gains three new fields:
| Field | Type | Purpose |
execution | JobExecutionConfig | Carries JobPlacementKind + JobRuntimeKind |
host_setup_command | Option<&'static str> | Optional pre-flight command on the host (used by Tart jobs) |
mount_host_rust_toolchain | bool | Whether to bind-mount the host Rust toolchain into the guest |
The old magic string comparison job.id == "tart-env-probe" for deciding whether to mount the Rust toolchain is replaced by a clean job.mount_host_rust_toolchain() accessor at crates/pikaci/src/executor.rs:1516. Every test helper (remote_incus_job_base(), host_local_job_base(), tart_job_base()) is updated to supply the correct JobExecutionConfig constant so that tests compile and correctly exercise each combination.
Replace `staged_linux_rust_lane` with `staged_linux_command`
Intent: Decouple the execute-time command configuration from the lane identity. A `JobSpec` no longer carries a lane enum variant; it carries a `StagedLinuxCommandConfig` that holds only the information needed at execution time (snapshot profile, wrapper command, payload specs, workspace output system).
Affected files: crates/pikaci/src/model.rs, crates/pikaci/src/executor.rs, crates/pikaci/src/executor/incus.rs
Evidence
@@ -951,9 +963,11 @@ fn run_remote_linux_vm_job(job: &JobSpec, ctx: &HostContext) -> anyhow::Result<JobOutcome> {
- let lane = job
- .staged_linux_rust_lane()
- .ok_or_else(|| anyhow!("remote Linux VM execute requires a staged Linux Rust lane"))?;
+ let (guest_command, _) = compiled_guest_command(job);
+ let workspace_output_system = job
+ .staged_linux_command()
+ .map(|command| command.workspace_output_system)
@@ -2377,8 +2392,8 @@ fn cleanup_remote_linux_vm_runtime(
- if let Some(lane) = job.staged_linux_rust_lane() {
- return (lane.execute_wrapper_command().to_string(), false);
+ if let Some(command) = job.staged_linux_command() {
+ return (command.execute_wrapper_command.to_string(), false);
Previously JobSpec stored an Option<StagedLinuxRustLane> — an enum variant that encoded both which lane runs and how to run it. The branch replaces this with Option<StagedLinuxCommandConfig>, a plain data struct:
pub struct StagedLinuxCommandConfig {
pub snapshot_profile: StagedLinuxSnapshotProfile,
pub prepare_node_prefix: &'static str,
pub prepare_description: &'static str,
pub payload_specs: [StagedLinuxRustTargetPayloadSpec; 2],
pub execute_wrapper_command: &'static str,
pub workspace_output_system: &'static str,
}
This means the executor module (crates/pikaci/src/executor.rs) no longer needs to know anything about lane identity — it only consults the command config for the wrapper binary path, snapshot profile, and payload specs. The compiled_guest_command function, the Incus device configuration, and the remote snapshot directory logic all switch from .staged_linux_rust_lane() to .staged_linux_command().
Extract the staged-Linux target and lane catalog into `catalog.rs`
Intent: Move the full enumeration of build targets and CI lanes out of the library crate and into a binary-only module, keeping the library focused on execution primitives.
Affected files: crates/pikaci/src/catalog.rs, crates/pikaci/src/main.rs, crates/pikaci/src/lib.rs
Evidence
@@ -0,0 +1,424 @@
+use serde::Serialize;
+
+use pikaci::{
+ StagedLinuxCommandConfig, StagedLinuxRustPayloadRole, StagedLinuxRustTargetPayloadSpec,
+ StagedLinuxSnapshotProfile,
+};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum PikaStagedLinuxTarget {
@@ -1,11 +1,15 @@
+mod catalog;
+mod forge_manifest;
The new crates/pikaci/src/catalog.rs (424 lines) defines:
PikaStagedLinuxTarget — the 9 build-target variants (e.g. PreMergePikaRust, PreMergePikachatRust).
PikaStagedLinuxLane — the ~27 individual lane variants, each mapping to a target and a command config.
PikaStagedLinuxTargetConfig — per-target metadata (Nix installable paths, snapshot profile, shadow recipe).
PikaStagedLinuxTargetInfoJson — a JSON-serialisable projection used by the --json CLI flag.
Each lane's command_config() method constructs a StagedLinuxCommandConfig by combining the target's shared settings with a lane-specific execute_wrapper_command path. This keeps the full catalog close to the binary entry point (main.rs) while the library crate (lib.rs) re-exports only the execution-time types.
The lib.rs public API now exports JobExecutionConfig, JobPlacementKind, JobRuntimeKind, StagedLinuxCommandConfig, StagedLinuxRustPayloadRole, StagedLinuxRustTargetPayloadSpec, and StagedLinuxSnapshotProfile — but no longer exports StagedLinuxRustLane or StagedLinuxRustTarget.
Move path filters from Rust source to `ci/forge-lanes.toml`
Intent: Eliminate hundreds of lines of hard-coded per-target file-glob arrays from `main.rs` by loading them at runtime from an external TOML manifest. This makes path filters editable without recompiling the binary and keeps them co-located with the CI workflow definitions.
Affected files: crates/pikaci/src/forge_manifest.rs, crates/pikaci/src/main.rs, crates/pikaci/Cargo.toml, Cargo.lock
Evidence
@@ -0,0 +1,100 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use anyhow::{Context, anyhow};
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize)]
+struct ManifestFile {
+ version: u32,
+ #[serde(default)]
+ branch: LaneGroup,
@@ -400,23 +452,28 @@ fn rerun_target_with_output(
-fn staged_linux_target(target_id: &str) -> anyhow::Result<StagedLinuxRustTarget> {
- StagedLinuxRustTarget::from_target_id(target_id)
+fn staged_linux_target(target_id: &str) -> anyhow::Result<PikaStagedLinuxTarget> {
+ PikaStagedLinuxTarget::from_target_id(target_id)
@@ -12,5 +12,6 @@ serde_json = { workspace = true }
+toml = { workspace = true }
A new crates/pikaci/src/forge_manifest.rs module reads ci/forge-lanes.toml (located relative to CARGO_MANIFEST_DIR) and parses it with the toml crate (added as a workspace dependency).
The TOML structure is:
version = 1
[[branch.lanes]]
staged_linux_target = "pre-merge-pika-rust"
paths = ["rust/**", "Cargo.lock", ...]
The function branch_lane_paths_for_staged_target(target_id) returns Option<Vec<String>> — the path glob list for a given target, or None if the target isn't in the manifest.
In main.rs, the staged_linux_target_spec() helper drops its filters: &'static [&'static str] parameter. It now calls branch_lane_paths_for_staged_target and fails with a clear error if the manifest entry is missing. This removes approximately 200 lines of inline filter arrays from the match arms in target_spec(). Non-staged targets (e.g. tart-beachhead, android-sdk-probe) keep their inline filter lists via a static_filters() conversion helper.
Two tests in forge_manifest.rs verify that the manifest loads correctly for pre-merge-pika-rust and returns None for unknown targets.
Add `TargetSpec` filter type migration and job base factories
Intent: Unify how target specs express their path filters (switching from `&'static [&'static str]` to `Vec<String>`) and provide canonical job base constructors for each execution config.
Affected files: crates/pikaci/src/main.rs
Evidence
@@ -13,10 +17,58 @@ struct TargetSpec {
- filters: &'static [&'static str],
+ filters: Vec<String>,
@@ +26,+42 @@
+fn remote_incus_job_base() -> JobSpec {
+fn host_local_job_base() -> JobSpec {
+fn tart_job_base() -> JobSpec {
The TargetSpec.filters field changes from a borrowed static slice to an owned Vec<String>, since the TOML-loaded paths are heap-allocated. A static_filters() helper converts the remaining &'static [&'static str] literals for non-staged targets.
Three factory functions in main.rs construct canonical base JobSpec values:
remote_incus_job_base() — REMOTE_SSH_INCUS placement+runtime
host_local_job_base() — HOST_LOCAL placement+runtime
tart_job_base() — LOCAL_TART with a default host_setup_command for Xcode developer directory setup
All job definitions in the match arms now use struct update syntax (..remote_incus_job_base()) instead of repeating boilerplate fields. The Tart TART_HOST_SETUP_COMMAND constant is extracted at the top of the file so it can be shared across all Tart job specs.
Update test fixtures in `pika-git` and executor tests
Intent: Ensure all test code compiles against the new model by adding the `placement` and `runtime` fields to job record fixtures and switching test job constructors to use the new base factories.
Affected files: crates/pika-git/src/pikaci_store.rs, crates/pikaci/src/executor.rs, crates/pikaci/src/executor/incus.rs
Evidence
@@ -65,6 +66,8 @@ impl TestPikaciJobFixture {
+ placement: Some(JobPlacementKind::RemoteSsh),
+ runtime: Some(JobRuntimeKind::Incus),
@@ -2503,6 +2518,34 @@ mod tests {
+ fn remote_incus_job_base() -> JobSpec {
+ fn host_local_job_base() -> JobSpec {
@@ -644,10 +644,13 @@ pub(super) fn build_snapshot_mount_plan_for_test(
+ execution: super::super::JobExecutionConfig::REMOTE_SSH_INCUS,
+ host_setup_command: None,
+ mount_host_rust_toolchain: false,
TestPikaciJobFixture in crates/pika-git/src/pikaci_store.rs now sets placement: Some(JobPlacementKind::RemoteSsh) and runtime: Some(JobRuntimeKind::Incus) on every generated JobRecord. These Option-wrapped fields allow the serialised record format to remain backwards-compatible with older run bundles that lack the new fields.
In the executor test module, remote_incus_job_base() and host_local_job_base() mirror the main binary's factories. Every inline JobSpec literal in the test suite now uses ..remote_incus_job_base() or ..host_local_job_base() to fill in the execution config, replacing the old staged_linux_rust_lane: None field.
The Incus test helper build_snapshot_mount_plan_for_test adds the three new fields (execution, host_setup_command, mount_host_rust_toolchain) directly.
Add invariants check for catalog ↔ manifest consistency
Intent: Prevent the Rust catalog and the TOML manifest from drifting apart by adding a machine-enforced invariant that every staged target in the catalog has a corresponding entry in `ci/forge-lanes.toml`.
Affected files: invariants/invariants.toml, scripts/check_invariants.py
Evidence
@@ (new file) invariants/invariants.toml
@@ (new file) scripts/check_invariants.py
Two new files enforce structural consistency:
invariants/invariants.toml — declares the set of staged-Linux target IDs and the path to the forge-lanes manifest.
scripts/check_invariants.py — a Python script (runnable in CI) that:
- Parses the invariants file to get the expected target list.
- Parses
ci/forge-lanes.toml to get the actual staged_linux_target entries.
- Asserts that every expected target is present and that the manifest contains no unexpected targets.
This closes the loop: the Rust catalog.rs defines what targets exist, the TOML manifest provides their path filters, and the invariants script ensures neither side can add or remove a target without updating the other.
Improve remote Linux VM job logging
Intent: Include the actual guest command in the start-of-job log line so operators can identify what a remote VM job will run without cross-referencing the lane name.
Affected files: crates/pikaci/src/executor.rs
Evidence
@@ -985,11 +999,12 @@ fn run_remote_linux_vm_job(job: &JobSpec, ctx: &HostContext) -> anyhow::Result<J
- "[pikaci] starting remote Linux VM backend `{}` for staged lane `{}` on {} at {}",
+ "[pikaci] starting remote Linux VM backend `{}` for `{}` on {} at {}: {}",
remote_linux_vm_backend_label(backend),
- lane.workspace_output_system(),
+ workspace_output_system,
shared.remote_host,
- Utc::now().to_rfc3339()
+ Utc::now().to_rfc3339(),
+ guest_command
The log line emitted when a remote Linux VM job starts now includes the resolved guest command string as a trailing field. Previously it logged only the workspace_output_system (derived from the lane); now the compiled_guest_command() result is called at the top of run_remote_linux_vm_job and appended to the message. The label source also changes from lane.workspace_output_system() to workspace_output_system, a local variable derived from the staged_linux_command() config (or the literal "direct_guest_command" for non-staged jobs). This gives operators immediate visibility into what binary path will be invoked inside the VM.