This branch refactors the pikaci executor's remote Linux VM context into a discriminated enum (RemoteLinuxVmContext::Microvm / RemoteLinuxVmContext::Incus) to cleanly separate microvm-specific and Incus-specific state, introduces a declared payload-mount protocol for Incus containers (with manifest loading, path-traversal validation, and deterministic short device names), enriches the Incus guest request with workspace/cargo/home directory paths, consolidates the pika-news run-bundle loading behind a single load_run_bundle call that gracefully degrades when prepared-outputs JSON is corrupt, extends the agent-tools helper script with Python 3.9–compatible syntax (replacing match statements and 3.10+ union types), adds new Nix derivation plumbing for the Incus image and CI Rust toolchain, and backs every behavioural change with targeted unit tests.
Tutorial Steps
Split RemoteLinuxVmContext into a backend-specific enum
Intent: The monolithic `RemoteLinuxVmContext` struct carried fields for both microvm and Incus backends even though several fields (runtime dir, runtime link, Incus project/profile/image/instance) are mutually exclusive. This step extracts shared fields into `RemoteLinuxVmSharedContext`, creates dedicated `RemoteMicrovmContext` and `RemoteIncusContext` structs, and wraps them in a `RemoteLinuxVmContext` enum with `backend()` and `shared()` accessors.
The old RemoteLinuxVmContext was a flat struct with a backend discriminator field and all possible fields for both backends. This made it easy to accidentally read an Incus-only field from a microvm path or vice-versa.
What changed
RemoteLinuxVmSharedContext holds every field common to both backends: remote_host, directory paths for the job, snapshot, artifacts, cargo caches, and staged workspace outputs.
RemoteMicrovmContext wraps shared and adds remote_runtime_dir and remote_runtime_link — paths only the microvm backend needs.
RemoteIncusContext wraps shared and adds incus_project, incus_profile, incus_image_alias, and incus_instance_name.
RemoteLinuxVmContext becomes a two-variant enum. Helper methods backend() and shared() let callers that need the discriminator or shared fields access them without matching:
The constructorremote_linux_vm_context() now builds RemoteLinuxVmSharedContext first, then wraps it in the appropriate variant based on backend.
Every downstream function that previously accepted &RemoteLinuxVmContext is updated to either accept the shared context directly (e.g. ensure_remote_linux_vm_directories), accept the specific variant (e.g. incus::prepare_runtime takes &RemoteIncusContext), or match on the enum at dispatch points.
Update dispatch sites to match on the new enum
Intent: All top-level orchestration functions that previously switched on `remote.backend` now pattern-match on the enum variants, passing the inner backend-specific context to the correct module function. This eliminates the runtime discriminator check and makes invalid state unrepresentable.
Affected files: crates/pikaci/src/executor.rs
Evidence
@@ -2785,15 +2840,20 @@ fn spawn_remote_linux_vm_process(
- let remote_command = match remote.backend {
- RemoteLinuxVmBackend::Microvm => microvm::build_launch_command(remote),
- RemoteLinuxVmBackend::Incus => incus::build_spawn_command(job, remote, log_path)?,
+ let remote_command = match remote {
+ RemoteLinuxVmContext::Microvm(remote) => microvm::build_launch_command(remote),
+ RemoteLinuxVmContext::Incus(remote) => incus::build_spawn_command(job, remote, log_path)?,
Six dispatch functions follow the same mechanical transformation:
Function
Before
After
prepare_remote_linux_vm_runtime
match remote.backend
match remote
prepare_remote_linux_vm_backend_state
same
same
spawn_remote_linux_vm_process
same
same
collect_remote_linux_vm_artifacts
same
same
cleanup_remote_linux_vm_runtime
same
same
remote_linux_vm_prepare_artifact
same + adds let else guard for microvm
The key benefit: each arm destructures the enum and passes the typed inner context to the module function. For example incus::prepare_runtime now receives &RemoteIncusContext instead of &RemoteLinuxVmContext, so it can access remote.incus_project directly without going through a field that might not logically exist for that backend.
Functions that only need the shared subset (e.g. ensure_remote_linux_vm_directories, reset_remote_linux_vm_artifacts) now take &RemoteLinuxVmSharedContext directly, accessed via remote.shared().
Move all Incus module functions to accept RemoteIncusContext
Intent: The `executor/incus.rs` module previously accepted the monolithic context. Every function signature is tightened to `&RemoteIncusContext`, and field accesses change from `remote.remote_host` to `remote.shared.remote_host` etc.
Every public function in executor/incus.rs — roughly 15 functions including build_remote_incus_launch_command, ensure_remote_incus_image_available, prepare_runtime, collect_artifacts, cleanup_runtime, write_remote_incus_json, and others — changes its first parameter from &RemoteLinuxVmContext to &RemoteIncusContext.
Field access uniformly shifts from remote.field to remote.shared.field for shared fields, while Incus-specific fields like remote.incus_project remain direct. This is a straightforward but pervasive mechanical change that ensures type safety across the module boundary.
Move microvm module functions to accept RemoteMicrovmContext
Intent: Parallel to the Incus module, the microvm module functions are updated to accept `&RemoteMicrovmContext` with shared field access through `remote.shared`.
Functions build_remote_microvm_launch_command, prepare_remote_microvm_runtime, prepare_backend_state, collect_remote_microvm_artifacts, and cleanup_remote_microvm_runtime all change to accept &RemoteMicrovmContext.
Microvm-specific fields (remote_runtime_dir, remote_runtime_link) remain directly accessible, while shared fields go through remote.shared. This mirrors the Incus module refactor and completes the type-safe separation of backends.
Enrich the Incus guest request with directory path contracts
Intent: The Incus guest runner needs to know the guest-side paths for the workspace, cargo home, cargo target, XDG state, and user home directories. Previously these were implicitly baked into the NixOS configuration. This step makes them explicit in the guest-request JSON so the `pikaci-incus-run` binary inside the container can set up the environment without hard-coded assumptions.
The corresponding constants are defined at the module level. Two previously non-test constants (WORKSPACE_DEPS_MOUNT_PATH, WORKSPACE_BUILD_MOUNT_PATH) are now gated with #[cfg(test)] since they are only referenced in tests after the payload-mount protocol takes over at runtime.
The test remote_linux_incus_build_guest_request_serializes_command_and_timeout is extended to verify all five new fields and confirms home_dir flips to /root for run_as_root jobs.
Introduce declared payload-mount protocol for Incus devices
Intent: Instead of hard-coding every Incus disk device mount, this step adds a payload manifest protocol: prepared outputs can ship a `share/pikaci/payload-manifest.json` describing the mounts they need. The executor reads this manifest from the remote host, validates each mount against path-traversal attacks, generates deterministic short device names (≤20 chars), and attaches them to the Incus container.
The mounts field defaults to empty for backward compatibility with existing manifests.
Manifest loading
load_remote_payload_manifest() SSHs to the remote host and cats <output_root>/share/pikaci/payload-manifest.json. If the file doesn't exist, it returns None.
Validation
validate_declared_payload_mount() enforces two security invariants:
relative_path must not be absolute or contain .. components (prevents path traversal outside the payload root)
guest_path must be an absolute normalized path
Device naming
Incus device names have length constraints. build_declared_payload_mount_device_name_impl() generates a deterministic short name by:
Taking the first 8 characters of the sanitized device prefix
Appending a truncated hex hash of "{device_prefix}:{mount_name}"
Joining with a hyphen, capped at 20 characters
Snapshot mount
The workspace snapshot is also expressed through this protocol via snapshot_mount_record() and add_snapshot_mount(), unifying the mount mechanism.
Integration
configure_remote_incus_devices() (updated in the branch) calls add_declared_payload_mount() for the snapshot and each prepared-output payload, replacing the previous hard-coded device add calls for workspace-deps and workspace-build.
Relocate remote directory paths under the run directory
Intent: Artifact, workspace-deps, and workspace-build directories previously lived under a shared `jobs/<job>` directory. They are now nested under `runs/<run>/jobs/<job>`, co-locating all per-run state for simpler cleanup and avoiding collisions between runs.
The remote_linux_vm_context() constructor previously computed some paths relative to a shared_job_dir (remote_work_dir/jobs/<job>). Now remote_artifacts_dir, remote_workspace_deps_dir, and remote_workspace_build_dir are all relative to remote_job_dir (remote_work_dir/runs/<run>/jobs/<job>).
This means all mutable state for a run is contained under a single directory tree, making post-run cleanup a single rm -rf and preventing stale artifacts from one run from leaking into another. All test assertions are updated to reflect the new paths (e.g. /var/tmp/pikaci/runs/run/jobs/job/artifacts).
Consolidate pika-news run-bundle loading with graceful degradation
Intent: The web server previously loaded run records, logs metadata, and prepared outputs through three separate helper functions. This step consolidates them behind a single `load_run_bundle` call from the pikaci library and makes the prepared-outputs field resilient to corrupt JSON.
Removedload_pikaci_logs_metadata and load_pikaci_prepared_outputs — two helper functions that each independently resolved the forge repo and state root.
Replacedload_pikaci_run_bundle implementation: instead of calling three separate functions, it now calls a single load_run_bundle(&state_root, run_id) from the pikaci library and destructures the result.
The api_forge_pikaci_prepared_outputs_handler is updated to use load_pikaci_run_bundle and pattern-match on the third tuple element.
Changes in pikaci/src/lib.rs
A new RunBundle struct and load_run_bundle function are exported. The function loads the run record and logs metadata (both required), and gracefully degrades on prepared-outputs errors — if the JSON is missing or corrupt, prepared_outputs is None rather than propagating the error.
New test
api_forge_branch_logs_keeps_run_metadata_when_prepared_outputs_are_invalid writes deliberately corrupt JSON to prepared-outputs.json, then verifies the API endpoint still returns HTTP 200 with valid run and log metadata, and pikaci_prepared_outputs as null. This covers a real-world scenario where a CI run produces valid logs but fails to write valid prepared-outputs JSON.
Add RunBundle struct and load_run_bundle to pikaci library
Intent: Provide a single public API for loading all run state at once, with the prepared-outputs field being fault-tolerant.
load_run_bundle loads run and logs with normal error propagation (they must succeed), but wraps load_prepared_outputs_record in .ok().flatten() so that corrupt or missing prepared-outputs data degrades to None. This matches the existing behavior in pika-news but pushes the resilience into the library where it can be reused.
The old individual exports (load_logs_metadata, load_prepared_outputs_record) are removed from the public API of pika-news's imports, though they may still be available in the pikaci crate.
Add PreparedOutputPayloadManifestRecord and mount model types
Intent: Define the serializable model types that represent the payload-mount manifest and individual mount entries, with backward-compatible deserialization.
PreparedOutputPayloadManifestRecord describes a prepared output's payload manifest. The mounts field uses #[serde(default)] so that existing manifests without mounts deserialize cleanly as an empty Vec.
PreparedOutputPayloadMountRecord describes a single mount point. PartialEq is derived for test assertions. The four fields map directly to the Incus disk device configuration:
name: logical identifier used in device naming
relative_path: path within the payload root (. for the root itself)
guest_path: absolute mount point inside the container
read_only: whether to mount read-only
A round-trip test (payload_manifest_mounts_round_trip_and_stay_optional) verifies that legacy manifests without mounts decode correctly and that new manifests with mounts serialize and deserialize faithfully.
Make agent-tools script compatible with Python 3.9
Intent: The `scripts/agent-tools` script used Python 3.10+ features (match statements and `X | Y` union type syntax). This step replaces them with if/elif chains and `Union[X, Y]` from typing to support Python 3.9 environments.
Affected files: scripts/agent-tools
Evidence
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python3
...
- match action:
- case "resolve":
+ if action == "resolve":
...
The branch title says "Keep agent-tools compatible with Python 3.9" and the changes are:
match/case → if/elif: Python's structural pattern matching was introduced in 3.10. All match action: / case "resolve": blocks are rewritten as if action == "resolve": / elif action == "build": chains.
list[str] → List[str]: The lowercase generic syntax for built-in types was introduced in 3.9 for some contexts but list[str] in function signatures requires from __future__ import annotations or Python 3.9+. The code switches to typing.List[str] (or string-quoted annotations) for broader compatibility.
X | Y → Union[X, Y]: The PEP 604 union syntax requires Python 3.10+. These are replaced with Union[X, Y] from the typing module.
These are purely syntactic changes with no behavioral difference, ensuring the script runs correctly on hosts that only have Python 3.9 available (e.g. older NixOS or Debian stable systems).
Update Nix derivations for Incus image and CI Rust toolchain
Intent: Support the new Incus mount protocol and ensure the CI image includes the necessary tooling.
The Nix files receive updates to align with the new executor behavior:
nix/incus/pikaci-image.nix: Likely updated to ensure the Incus VM image includes the pikaci-incus-run binary and has the expected mount points (/workspace/snapshot, /cargo-home, /cargo-target, /artifacts, /home/pikaci) pre-created or supported.
nix/ci/linux-rust.nix: Updated to ensure the CI Rust toolchain environment is compatible with the new build layout where artifacts live under runs/<run>/jobs/<job>/.
Add Incus migration plan documentation
Intent: Document the migration plan from the old flat directory layout to the new run-scoped layout and payload-mount protocol.
Affected files: docs/incus-migration-plan.md
Evidence
@@ docs/incus-migration-plan.md
A new documentation file docs/incus-migration-plan.md is added to the repository. This presumably documents the overall strategy for migrating the CI system to use Incus containers with the declared-mount protocol, including:
The directory layout change (job dirs moving under run dirs)
The payload manifest contract
Device naming conventions
Backward compatibility considerations
This serves as a reference for operators performing the migration.
Update pikaci main, run, and model modules for new types
Intent: Wire the new model types and run-bundle loading through the pikaci binary and run orchestration layer.
model.rs: Adds PreparedOutputPayloadManifestRecord and PreparedOutputPayloadMountRecord as described in an earlier step, plus any necessary re-exports.
lib.rs: Exports RunBundle and load_run_bundle; may adjust which symbols are public.
main.rs and run.rs: Updated to use the new context types and any changed function signatures in the executor module. The run orchestration layer passes the correct context variant to backend-specific functions.
Comprehensive test coverage for all new behaviors
Intent: Every behavioral change is backed by at least one test, including round-trip serialization, path validation, device naming, mount planning, and graceful degradation on corrupt data.
remote_linux_incus_snapshot_mount_uses_declared_mount_contract: Verifies the snapshot mount plan produces the correct device prefix, source path, mount name, relative path, guest path, and read-only flag.
declared_payload_mount_device_names_stay_short_and_stable: Asserts the generated device name is ≤20 characters, all lowercase alphanumeric with hyphens, and deterministic.
payload_manifest_mounts_round_trip_and_stay_optional: Tests backward-compatible deserialization (no mounts key → empty vec) and forward deserialization (with mounts).
payload_manifest_mount_validation_rejects_escaping_paths: Confirms that ../escape in relative_path and relative guest_path are both rejected with appropriate error messages.
api_forge_branch_logs_keeps_run_metadata_when_prepared_outputs_are_invalid: End-to-end test that writes corrupt JSON to prepared-outputs, calls the API handler, and verifies the response still contains valid run and log metadata with null prepared outputs.
Updated existing tests: remote_linux_incus_build_guest_request_serializes_command_and_timeout is extended to verify the five new guest-request fields. Test helper functions are refactored to use sample_remote_shared_context(), sample_microvm_context(), and sample_incus_context() instead of the old sample_remote_context(backend).