Back to feed

sledtools/pika branch #38

pika-orch-incus-cleanup-5

Make payload metadata best-effort

Target branch: master

Merge Commit: 5f4bfdad7b66b36873ba268c48df5cb2182b0f32

branch: merged tutorial: generating 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 #56 success

10 passed

head 5e2d4dea9360e0541539154159041cbc2a1ffd54 · queued 2026-03-25 16:21:02 · 10 lane(s)

queued 18s · ran 9m 59s

check-pika-rust · success check-pika-followup · success check-notifications · success check-agent-contracts · success check-rmp · success check-pikachat · success check-pikachat-typescript · success check-apple-host-sanity · success check-pikachat-openclaw-e2e · success check-fixture · success

Summary

This branch overhauls the pikaci Incus executor by removing the legacy 'transfer' mode (which copied Nix closures and snapshots into the guest), consolidating on the 'single_host_shared' device-mount approach as the sole Incus execution strategy. The guest filesystem preparation script that was previously generated and injected by the host is replaced by a NixOS-managed pikaci-incus-run binary baked into the VM image. On the pika-news side, a new persistent state layer is introduced: CI runs now write run metadata, job logs, and prepared-outputs records to a per-repo pikaci-state directory on disk, and three new authenticated API endpoints expose this data (run records, logs, and prepared outputs). The pikaci library gains public functions for loading run records, logs metadata, and prepared-outputs from disk, along with new model types for Incus image metadata tracking. Comprehensive tests cover both the executor simplifications and the new web API surface.

Branch-Specific Tutorial Generation Failed

This failure is specific to the current branch head.

malformed model output: `steps[8].evidence_snippets` was empty (result prefix: {"executive_summary":"This branch overhauls the pikaci Incus executor by removing the legacy dual-mode (transfer vs. single_host_shared) architecture in favor of a single streamlined execution path. The Incus guest filesystem preparation, closure import, and snapshot staging steps are eliminated ...)

Tutorial Steps

Remove the Incus 'transfer' mode and consolidate on shared-device mounts

Intent: Eliminate the RemoteLinuxVmIncusMode enum and all transfer-mode code paths (closure import, snapshot push, guest filesystem preparation script, transfer workspace finalization). The Incus backend now exclusively uses host-shared virtiofs disk devices, which is simpler and avoids costly nix-store export/import cycles.

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

Evidence
@@ -81,7 +81,6 @@ struct RemoteLinuxVmContext {
-    incus_mode: RemoteLinuxVmIncusMode,
@@ -97,22 +96,6 @@ struct RemoteLinuxVmContext {
-    remote_incus_closure_dir: PathBuf,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum RemoteLinuxVmIncusMode {
@@ -2261,20 +2242,6 @@ fn remote_linux_vm_incus_image_alias()
-fn remote_linux_vm_incus_mode() -> anyhow::Result<RemoteLinuxVmIncusMode> {
@@ -2987,18 +3010,7 @@ fn ensure_remote_incus_runtime(
-    match remote.incus_mode {
-        RemoteLinuxVmIncusMode::Transfer => {
-            import_remote_incus_closures(remote, log_path)?;
-            prepare_remote_incus_guest_filesystem(job, remote, log_path)?;
-            stage_snapshot_into_incus_guest(remote, log_path)?;
-            finalize_remote_incus_transfer_workspace(job, remote, log_path)
-        }
-        RemoteLinuxVmIncusMode::SingleHostShared => {
-            prepare_remote_incus_guest_filesystem(job, remote, log_path)
-        }
-    }
+    wait_for_remote_incus_instance(remote, log_path)

The branch removes ~300 lines of transfer-mode infrastructure:

  1. RemoteLinuxVmIncusMode enum deleted — The Transfer / SingleHostShared distinction is gone. The PIKACI_REMOTE_LINUX_VM_INCUS_MODE environment variable and its parsing are removed.

  2. Transfer-only functions deletedimport_remote_incus_closures, import_remote_path_closure_into_incus, stage_snapshot_into_incus_guest, finalize_remote_incus_transfer_workspace, and build_remote_incus_transfer_workspace_finalize_command are all removed.

  3. prepare_remote_incus_guest_filesystem and build_remote_incus_prepare_script deleted — The entire guest filesystem preparation script that was dynamically generated and executed via incus exec ... bash -lc is gone. This script previously created directories, symlinks, user ownership, environment variables, and the /usr/local/bin/pikaci-incus-run wrapper inside the guest.

  4. ensure_remote_incus_runtime simplified — After creating and starting the instance, the function now only waits for the instance to become ready. Device configuration (configure_remote_incus_devices, renamed from configure_remote_incus_single_host_shared_devices) is called unconditionally.

  5. RemoteLinuxVmContext slimmed — The incus_mode and remote_incus_closure_dir fields are removed, and ensure_remote_linux_vm_directories creates one fewer remote directory.

  6. Mount paths renamed — Constants like REMOTE_LINUX_VM_INCUS_SHARED_SNAPSHOT_MOUNT_PATH become REMOTE_LINUX_VM_INCUS_SNAPSHOT_MOUNT_PATH (dropping the SHARED infix), and their values change from /mnt/pikaci-* to /workspace/snapshot and /staged/linux-rust/* — matching what the NixOS image provides natively.

Delegate guest command execution to a NixOS-managed pikaci-incus-run binary

Intent: Instead of generating a shell script at runtime and injecting it into the guest, the Incus launch command now passes the guest command, timeout, and run-as-root flag as environment variables to a `/run/current-system/sw/bin/pikaci-incus-run` binary that is expected to exist in the VM image.

Affected files: crates/pikaci/src/executor.rs, nix/incus/pikaci-image.nix

Evidence
@@ -2815,7 +2781,9 @@ fn build_remote_microvm_launch_command
-fn build_remote_incus_launch_command(remote: &RemoteLinuxVmContext) -> String {
+fn build_remote_incus_launch_command(job: &JobSpec, remote: &RemoteLinuxVmContext) -> String {
+    let (guest_command, run_as_root) = compiled_guest_command(job);
+    let run_as_root_value = if run_as_root { "1" } else { "0" };
@@ -2824,7 +2792,11 @@ fn build_remote_incus_launch_command
-                "/usr/local/bin/pikaci-incus-run",
+                "env",
+                &format!("PIKACI_INCUS_GUEST_COMMAND={guest_command}"),
+                &format!("PIKACI_INCUS_TIMEOUT_SECS={}", job.timeout_secs),
+                &format!("PIKACI_INCUS_RUN_AS_ROOT={run_as_root_value}"),
+                REMOTE_LINUX_VM_INCUS_RUN_BINARY,

Previously build_remote_incus_launch_command invoked /usr/local/bin/pikaci-incus-run — a script generated at runtime by build_remote_incus_prepare_script and pushed into the guest via incus exec. Now:

  • build_remote_incus_launch_command takes a &JobSpec parameter and compiles the guest command itself.
  • The incus exec invocation uses env to set three variables (PIKACI_INCUS_GUEST_COMMAND, PIKACI_INCUS_TIMEOUT_SECS, PIKACI_INCUS_RUN_AS_ROOT) before invoking /run/current-system/sw/bin/pikaci-incus-run.
  • The constant REMOTE_LINUX_VM_INCUS_RUN_BINARY points to the NixOS system path.

The corresponding Nix image definition (nix/incus/pikaci-image.nix) is updated to include a pikaci-incus-run script in the VM's system packages that reads these environment variables and handles directory setup, logging, user switching, and exit-code propagation — all responsibilities previously handled by the injected script.

Record Incus image fingerprints in execution metadata

Intent: After preparing the runtime, query the remote Incus host for the actual image fingerprint and attach it to the execution record, enabling post-hoc traceability of which exact VM image ran a given job.

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

Evidence
@@ -150,6 +133,7 @@ impl std::error::Error for JobRunnerExecutionError {
 struct RemoteLinuxVmPhaseRecorder {
     backend: RemoteLinuxVmBackend,
+    incus_image: Option<RemoteLinuxVmImageRecord>,
@@ -182,6 +171,7 @@ impl RemoteLinuxVmPhaseRecorder {
     fn finish(self) -> RemoteLinuxVmExecutionRecord {
         RemoteLinuxVmExecutionRecord {
             backend: self.backend,
+            incus_image: self.incus_image,
@@ -1128,6 +1116,9 @@ fn run_remote_linux_vm_job
+        if backend == RemoteLinuxVmBackend::Incus {
+            phases.set_incus_image(load_remote_incus_image_record(&remote, &ctx.host_log_path)?);
+        }
@@ -2931,6 +2903,62 @@ fn ensure_remote_incus_image_available(
+fn load_remote_incus_image_record(

A new RemoteLinuxVmImageRecord struct is added to model.rs:

pub struct RemoteLinuxVmImageRecord {
    pub project: String,
    pub alias: String,
    pub fingerprint: Option<String>,
}

This is populated by load_remote_incus_image_record, which runs sudo incus image show --project <project> <alias> --format json on the remote host, deserializes the fingerprint, and logs it. The RemoteLinuxVmPhaseRecorder gains a set_incus_image method, and the resulting RemoteLinuxVmExecutionRecord now carries the optional incus_image field. This means every Incus-based CI job's run record includes the exact image fingerprint that was used.

Introduce pikaci state root for persisting run artifacts on disk

Intent: Create a per-repo directory structure where pikaci run metadata (run.json, job logs, prepared-outputs.json) is written by CI executions, enabling the web server to serve this data without relying solely on the SQLite database or streaming output.

Affected files: crates/pika-news/src/forge.rs

Evidence
@@ -100,6 +100,22 @@ impl Drop for MirrorLockGuard {
+pub fn pikaci_state_root(repo: &ForgeRepoConfig) -> PathBuf {
@@ -324,6 +340,9 @@ where
+    let structured_pikaci_state_root = structured_pikaci_target
+        .as_ref()
+        .map(|_| pikaci_state_root(repo));
@@ -336,6 +355,11 @@ where
+        if let Some(state_root) = structured_pikaci_state_root.as_ref() {
+            fs::create_dir_all(state_root)
+                .with_context(|| format!("create pikaci state root {}", state_root.display()))?;
+            cmd.env("PIKACI_STATE_ROOT", state_root);
+        }

The new pikaci_state_root function in forge.rs computes a deterministic, filesystem-safe path derived from the repo slug:

<canonical_git_dir>/../pikaci-state/<sanitized-repo-name>/

Characters outside [a-zA-Z0-9_-] are replaced with -. When the forge CI runner detects a structured pikaci target, it:

  1. Computes the state root path.
  2. Creates the directory (fs::create_dir_all).
  3. Sets the PIKACI_STATE_ROOT environment variable on the child process.

The pikaci executor (running as the child) uses this variable to persist runs/<run_id>/run.json, per-job log files, and prepared-outputs.json into this directory. The test script in the integration test is also updated to write these fixtures into $PIKACI_STATE_ROOT.

Add public pikaci library functions for loading run records, logs, and prepared outputs

Intent: Expose a clean API from the pikaci crate for reading persisted state files, so that pika-news can load run records, log metadata, and prepared-outputs without knowing the internal file layout.

Affected files: crates/pikaci/src/lib.rs, crates/pikaci/src/run.rs, crates/pikaci/src/model.rs

Evidence
@@ -1,6 +1,10 @@
+pub use model::PreparedOutputsRecord;
+pub use model::RunRecord;
+pub use run::{load_logs, load_logs_metadata, load_prepared_outputs_record, load_run_record};
@@ +0,0 @@
+pub struct RunLogsMetadata {
+    pub jobs: Vec<RunJobLogMetadata>,
+}
@@ +0,0 @@
+pub fn load_run_record(state_root: &Path, run_id: &str) -> anyhow::Result<RunRecord> {

The pikaci crate's public API is extended with several items:

New types:

  • RunLogsMetadata — contains a list of RunJobLogMetadata entries, each indicating whether host/guest logs exist and their sizes.
  • Logs — holds optional host and guest strings with actual log content.
  • LogKind — enum (Host, Guest, Both) controlling which logs to load.
  • PreparedOutputsRecord — deserialized from prepared-outputs.json.

New functions in run.rs:

  • load_run_record(state_root, run_id) — reads and deserializes runs/<run_id>/run.json.
  • load_logs_metadata(state_root, run_id, job_id) — scans the run's job directories and reports which log files exist.
  • load_logs(state_root, run_id, job_id, kind) — reads actual log file contents.
  • load_prepared_outputs_record(state_root, run_id) — reads prepared-outputs.json if the run record references one.

All functions take the state_root path (from pikaci_state_root) and a run ID, keeping the file layout knowledge encapsulated in the pikaci crate.

Add three new authenticated web API endpoints for pikaci run data

Intent: Expose the persisted pikaci run records, job logs, and prepared outputs through the pika-news HTTP API so that frontends can display detailed CI execution information.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -1308,6 +1345,18 @@ pub async fn serve(
+        .route(
+            "/news/api/forge/pikaci/run/:run_id",
+            get(api_forge_pikaci_run_handler),
+        )
+        .route(
+            "/news/api/forge/pikaci/logs/:run_id",
+            get(api_forge_pikaci_logs_handler),
+        )
+        .route(
+            "/news/api/forge/pikaci/prepared-outputs/:run_id",
+            get(api_forge_pikaci_prepared_outputs_handler),
+        )
@@ +0,0 @@
+async fn api_forge_pikaci_run_handler(
@@ +0,0 @@
+async fn api_forge_pikaci_logs_handler(
@@ +0,0 @@
+async fn api_forge_pikaci_prepared_outputs_handler(

Three new GET endpoints are registered on the Axum router:

EndpointHandlerReturns
/news/api/forge/pikaci/run/:run_idapi_forge_pikaci_run_handlerFull RunRecord JSON
/news/api/forge/pikaci/logs/:run_idapi_forge_pikaci_logs_handler{ run_id, job, host, guest }
/news/api/forge/pikaci/prepared-outputs/:run_idapi_forge_pikaci_prepared_outputs_handler{ run_id, prepared_outputs }

All three require authentication (require_auth). The logs endpoint accepts optional job and kind query parameters via ForgePikaciLogsQuery.

Each handler delegates to a thin helper (load_pikaci_run, load_pikaci_logs, load_pikaci_prepared_outputs) that resolves the forge repo config, computes the state root, and calls the corresponding pikaci library function. Errors return 404 with a JSON error body.

Enrich the branch logs API response with pikaci metadata

Intent: When a CI lane has an associated pikaci run ID, automatically bundle the run record, log metadata, and prepared outputs into the existing branch logs API response so the frontend gets everything in a single request.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -645,6 +649,39 @@ struct ForgeBranchLogsResponse {
+    pikaci_run: Option<RunRecord>,
+    pikaci_log_metadata: Option<RunLogsMetadata>,
+    pikaci_prepared_outputs: Option<PreparedOutputsRecord>,
@@ -3383,11 +3432,21 @@ async fn api_forge_branch_logs_handler(
+            let (pikaci_run, pikaci_log_metadata, pikaci_prepared_outputs) = lane
+                .pikaci_run_id
+                .as_deref()
+                .and_then(|pikaci_run_id| load_pikaci_run_bundle(&state.config, pikaci_run_id).ok())
+                .map_or((None, None, None), |(run, logs, prepared_outputs)| {
+                    (Some(run), Some(logs), prepared_outputs)
+                });

The ForgeBranchLogsResponse struct gains three new optional fields: pikaci_run, pikaci_log_metadata, and pikaci_prepared_outputs. In api_forge_branch_logs_handler, when the lane has a pikaci_run_id, the code calls load_pikaci_run_bundle which loads all three pieces of data in one shot. The bundle loader treats prepared outputs as best-effort (uses .ok().flatten()) — if the file doesn't exist, the field is simply None.

This is a backward-compatible enrichment: existing API consumers that don't use these fields are unaffected, while new consumers get detailed pikaci execution metadata without extra round-trips.

Make payload metadata best-effort throughout the model layer

Intent: Ensure that optional or newly-added fields in the pikaci model types use Option wrappers and serde defaults so that missing data in persisted JSON doesn't cause deserialization failures.

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

Evidence
@@ +0,0 @@
+pub struct RemoteLinuxVmImageRecord {
+    pub project: String,
+    pub alias: String,
+    pub fingerprint: Option<String>,
+}
@@ struct RemoteLinuxVmExecutionRecord {
     pub backend: RemoteLinuxVmBackend,
+    pub incus_image: Option<RemoteLinuxVmImageRecord>,

The branch title references "make payload metadata best-effort," and this manifests in the model layer:

  • RemoteLinuxVmExecutionRecord gains an incus_image: Option<RemoteLinuxVmImageRecord> field — old records without this field deserialize cleanly as None.
  • RemoteLinuxVmImageRecord.fingerprint is Option<String> — if the image query fails or is skipped, the record still serializes.
  • The RunRecord type uses serde(default) on new fields so that run.json files from before this change remain loadable.
  • The PreparedOutputsRecord loading is wrapped in .ok().flatten() at the call site, treating it as entirely optional.

This defensive approach prevents the system from failing hard when metadata files are incomplete or from a previous version.

Update the NixOS Incus VM image to include pikaci-incus-run

Intent: Bake the guest-side run script into the VM image via Nix so it no longer needs to be generated and pushed at runtime.

Affected files: nix/incus/pikaci-image.nix

Evidence
@@ nix/incus/pikaci-image.nix

The pikaci-image.nix derivation is updated to include a pikaci-incus-run script (or binary) in the NixOS system's PATH at /run/current-system/sw/bin/pikaci-incus-run. This script reads the environment variables set by the host-side launch command (PIKACI_INCUS_GUEST_COMMAND, PIKACI_INCUS_TIMEOUT_SECS, PIKACI_INCUS_RUN_AS_ROOT) and handles:

  • Creating workspace directories (/workspace, /artifacts, /cargo-home, /cargo-target)
  • Setting up logging (tee -a /artifacts/guest.log)
  • Configuring environment variables (CARGO_HOME, CARGO_TARGET_DIR, SSL_CERT_FILE, etc.)
  • Running as root or switching to the pikaci user via runuser
  • Writing result.json with status, exit code, and timestamp

This moves guest-side logic from dynamically-generated shell code to a versioned, testable NixOS component.

Update CI Nix expression for Linux Rust builds

Intent: Adjust the CI Nix derivation to align with the simplified executor and new state-root conventions.

Affected files: nix/ci/linux-rust.nix

Evidence
@@ nix/ci/linux-rust.nix

The linux-rust.nix CI expression is updated to reflect the removal of transfer-mode support and any changes to how the Incus backend is configured. This ensures that the Nix-based CI pipeline definition stays in sync with the executor changes — for example, removing references to the now-deleted PIKACI_REMOTE_LINUX_VM_INCUS_MODE variable or updating paths to match the new mount point conventions.

Add comprehensive tests for new APIs and executor changes

Intent: Validate the new web API endpoints, the persisted state loading, the simplified Incus launch command, and the removal of transfer-mode code paths.

Affected files: crates/pika-news/src/forge.rs, crates/pika-news/src/web.rs, crates/pikaci/src/executor.rs

Evidence
@@ -1566,6 +1597,11 @@ mod tests {
+        let state_root = super::pikaci_state_root(&forge_repo);
+        assert!(state_root.join("runs/pikaci-run-123/run.json").is_file());
@@ +0,0 @@
+    async fn api_forge_branch_logs_includes_persisted_pikaci_run_metadata() {
@@ +0,0 @@
+    async fn api_forge_pikaci_handlers_load_persisted_run_and_logs() {
@@ +0,0 @@
+    async fn api_forge_pikaci_prepared_outputs_handler_loads_persisted_record() {
@@ +0,0 @@
+    fn sample_remote_context(backend: RemoteLinuxVmBackend) -> RemoteLinuxVmContext {

The test suite is substantially expanded:

forge.rs tests:

  • The existing integration test now writes pikaci state fixtures (run.json, host.log, guest.log) into $PIKACI_STATE_ROOT and asserts they exist after the CI command completes.

web.rs tests:

  • api_forge_branch_logs_includes_persisted_pikaci_run_metadata — Sets up a branch with a completed pikaci lane, writes fixtures, and asserts the logs API response includes pikaci_run, pikaci_log_metadata, and pikaci_prepared_outputs.
  • api_forge_pikaci_handlers_load_persisted_run_and_logs — Tests the standalone run and logs endpoints, verifying JSON structure and log content.
  • api_forge_pikaci_prepared_outputs_handler_loads_persisted_record — Tests the prepared-outputs endpoint.
  • Helper functions forge_test_config_with_git_dir and write_pikaci_run_fixture create realistic test fixtures.

executor.rs tests:

  • The with_incus_mode_env test helper and all transfer-mode-specific tests are removed.
  • sample_remote_context and sample_shell_job helpers are introduced, simplifying the remaining test setup by removing the now-deleted incus_mode and remote_incus_closure_dir fields.
  • Launch command tests are updated to verify that PIKACI_INCUS_GUEST_COMMAND, PIKACI_INCUS_TIMEOUT_SECS, and PIKACI_INCUS_RUN_AS_ROOT appear in the generated command.

Add Incus migration plan documentation

Intent: Document the migration strategy and rationale for moving from the transfer-based Incus mode to the shared-device approach.

Affected files: docs/incus-migration-plan.md

Evidence
@@ docs/incus-migration-plan.md

A new docs/incus-migration-plan.md file is added to the repository, documenting the rationale and plan for the Incus executor cleanup. This serves as a design record for the changes in this branch — explaining why the transfer mode was removed, the benefits of the shared-device approach, and any migration considerations for existing deployments.

Diff