Back to feed

sledtools/pika branch #83

pika-git-forge-model-1

Share forge API models across server and ph

Target branch: master

Merge Commit: 3e235dd117d81db84b724f81fab4c21161bd74f2

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

9 passed

head 9e67c0631c50a769ab69f9aa6879b8f8c57b567c · queued 2026-03-26 00:51:45 · 9 lane(s)

queued 13s · ran 2m 04s

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

Summary

This branch introduces a new shared crate pika-forge-model that extracts forge API model types (request/response structs and status enums) previously duplicated between the ph CLI client and the pika-news server into a single source of truth. The new crate uses a string_enum! macro to define forward-compatible enums (ForgeCiStatus, CiLaneStatus, BranchState, etc.) that serialize as strings and gracefully handle unknown wire values via an Unknown(String) variant. Both ph and pika-news now depend on pika-forge-model, eliminating ~190 lines of duplicated struct definitions from ph/src/api.rs and ~60 lines of ad-hoc response structs from pika-news/src/web.rs. The server-side handlers are updated to construct typed shared structs instead of inline serde_json::json!() calls, and the client-side code replaces raw string comparisons with semantic enum methods like is_active() and is_success().

Tutorial Steps

Create the pika-forge-model crate and workspace registration

Intent: Establish a new shared library crate that will house all forge API model types, and register it in the workspace so both consumers can depend on it.

Affected files: crates/pika-forge-model/Cargo.toml, Cargo.toml, Cargo.lock

Evidence
@@ -0,0 +1,8 @@
+[package]
+name = "pika-forge-model"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
@@ -4,6 +4,7 @@ members = [
   "rust",
   "uniffi-bindgen",
   "cli",
+  "crates/pika-forge-model",
@@ -32,6 +33,7 @@ mdk-storage-traits = ...
+pika-forge-model = { path = "crates/pika-forge-model" }

A new crate crates/pika-forge-model is created with minimal dependencies (serde and serde_json). It is added to the workspace members list in the root Cargo.toml and given a workspace-level path alias so downstream crates can reference it as pika-forge-model = { path = "../pika-forge-model" } or via workspace inheritance.

The crate intentionally has no runtime dependencies beyond serialization, keeping it lightweight for both server and CLI consumers.

Define the string_enum! macro for forward-compatible status enums

Intent: Provide a single macro that generates enums which serialize/deserialize as plain strings, support an Unknown(String) catch-all variant for forward compatibility, and implement Display, From<&str>, and From<String>.

Affected files: crates/pika-forge-model/src/lib.rs

Evidence
@@ -0,0 +1,394 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+macro_rules! string_enum {
+    (
+        $(#[$meta:meta])*
+        pub enum $name:ident {
+            $($variant:ident => $wire:literal),+ $(,)?
+        }
+    ) => {
+        $(#[$meta])*
+        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
+        pub enum $name {
+            $($variant,)+
+            Unknown(String),
+        }

The string_enum! macro is the core abstraction of the new crate. For each invocation it generates:

  1. An enum with all named variants plus Unknown(String) for unrecognized wire values.
  2. as_str(&self) -> &str returning the canonical wire representation.
  3. From<&str> and From<String> conversions that map known strings to variants and everything else to Unknown.
  4. Custom Serialize / Deserialize implementations that go through the string representation.
  5. A Display implementation delegating to as_str().

This means the server can add new status values without breaking older CLI clients — they simply deserialize into Unknown("new_value") and round-trip cleanly.

Define all shared enums using the macro

Intent: Enumerate every status domain (branch state, CI status, lane status, execution reason, failure kind, target health) as typed enums with known wire values, plus semantic helper methods.

Affected files: crates/pika-forge-model/src/lib.rs

Evidence
@@ +76,0 +76,48 @@
+string_enum! {
+    pub enum BranchState {
+        Open => "open",
+        Merged => "merged",
+        Closed => "closed",
+    }
+}
+
+string_enum! {
+    pub enum ForgeCiStatus {
+        Queued => "queued",
+        Running => "running",
+        Success => "success",
+        Failed => "failed",
+        ...
+    }
+}
+
+impl ForgeCiStatus {
+    pub fn is_active(&self) -> bool {
+        matches!(self, Self::Queued | Self::Running)
+    }
+
+    pub fn is_success(&self) -> bool {
+        matches!(self, Self::Success)
+    }
+}

Six enums are defined via the macro:

  • BranchStateOpen, Merged, Closed
  • TutorialStatusPending, Ready, Failed
  • ForgeCiStatus — 20+ variants covering every known CI status, with is_active() and is_success() helpers
  • CiLaneStatus — lane-specific subset (Queued, Running, Success, Failed, Skipped, Lost, TimedOut, Cancelled) with its own is_active()
  • CiLaneExecutionReason — with label() returning human-readable text and a Default impl yielding Queued
  • CiLaneFailureKindTestFailure, Timeout, Infrastructure with label()
  • CiTargetHealthStateHealthy, Unhealthy

Having distinct ForgeCiStatus vs CiLaneStatus types prevents accidental mixing of run-level and lane-level statuses.

Move shared response structs into pika-forge-model

Intent: Extract all API response/request structs that were duplicated between the server and CLI into the shared crate, making fields public and using the new typed enums instead of raw strings.

Affected files: crates/pika-forge-model/src/lib.rs

Evidence
@@ +220,0 +220,60 @@
+pub struct BranchSummary {
+    pub branch_id: i64,
+    pub repo: String,
+    ...
+    pub tutorial_status: TutorialStatus,
+    pub ci_status: ForgeCiStatus,
+    pub error_message: Option<String>,
+}
+
+pub struct CiRun {
+    pub id: i64,
+    ...
+    pub status: ForgeCiStatus,
+    #[serde(default)]
+    pub status_tone: Option<String>,
+    ...
+}
+
+pub struct CiLane {
+    ...
+    pub status: CiLaneStatus,
+    ...
+    pub execution_reason: CiLaneExecutionReason,
+    pub failure_kind: Option<CiLaneFailureKind>,
+    ...
+}

All response structs (BranchResolveResponse, BranchSummary, BranchDetailResponse, CiRun, CiLane, BranchLogsResponse, NightlyDetailResponse, LaneMutationResponse, RecoverRunResponse, WakeCiResponse, BranchActionResponse) are moved here with pub visibility and both Serialize + Deserialize.

Key changes compared to the old per-crate definitions:

  • Status fields that were String are now their respective enum types.
  • New optional fields are added with #[serde(default)]: status_tone, status_badge_class, is_failed, execution_reason_label, failure_kind_label, timing_summary, last_heartbeat_at, lease_expires_at, operator_hint.
  • BranchLogsResponse gains generic type parameters for pikaci-specific payload types, defaulting to serde_json::Value so the CLI can deserialize without knowing server-internal types.

Unit tests verify unknown status round-tripping and default deserialization of execution_reason.

Wire pika-forge-model into the ph CLI crate

Intent: Replace the ~190 lines of locally defined API structs in ph with re-exports from the shared crate, and update all call sites to use typed enum methods.

Affected files: crates/ph/Cargo.toml, crates/ph/src/api.rs, crates/ph/src/commands.rs, crates/ph/src/tests.rs

Evidence
@@ -12,6 +12,7 @@ anyhow = { workspace = true }
+pika-forge-model = { path = "../pika-forge-model" }
@@ -1,6 +1,13 @@
+#[allow(unused_imports)]
+pub(crate) use pika_forge_model::{
+    BranchActionResponse, BranchDetailResponse, ...
+};
@@ -30,194 +37,6 @@ pub(crate) struct MeResponse {
-#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
-pub(crate) struct BranchResolveResponse {
-    ...
@@ -83,7 +83,7 @@
-            return if branch.branch.ci_status == "success" {
+            return if branch.branch.ci_status.is_success() {
@@ -276,13 +276,9 @@
-    matches!(branch.branch.ci_status.as_str(), "queued" | "running")
+    branch.branch.ci_status.is_active()

ph/Cargo.toml adds the pika-forge-model dependency. In api.rs, the large block of struct and enum definitions (lines 30–224 in the old file) is deleted and replaced with a single pub(crate) use pika_forge_model::{...} re-export.

In commands.rs, every raw string comparison is replaced with the typed method:

  • branch.branch.ci_status == "success"branch.branch.ci_status.is_success()
  • matches!(status.as_str(), "queued" | "running")status.is_active()
  • lane.failure_kind changes from Copy enum to Option<CiLaneFailureKind> reference (.as_ref() added)
  • CiTargetHealthState comparison now uses matches!(...as_ref(), Some(...))
  • CiLaneExecutionReason comparison uses .as_str() on both sides since the type is no longer Copy

In tests.rs, test fixtures are updated to construct enum variants directly (BranchState::Open, ForgeCiStatus::Running, CiLaneStatus::Queued) and populate the new optional fields (status_tone, timing_summary, operator_hint, etc.).

Wire pika-forge-model into the pika-news server crate

Intent: Replace ad-hoc response structs and serde_json::json!() calls in the server with the shared typed structs, adding mapping functions to convert internal DB records into the shared API types.

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

Evidence
@@ -22,6 +22,7 @@ hmac = { workspace = true }
+pika-forge-model = { path = "../pika-forge-model" }
@@ -383,48 +397,6 @@
-struct ForgeBranchResolveResponse {
-    branch_id: i64,
-    ...
-struct ForgeBranchLogsResponse {
-    ...
-struct ForgeNightlyDetailResponse {
-    ...
@@ -2133,18 +2089,112 @@
+fn map_api_ci_run(run: BranchCiRunRecord, now: DateTime<Utc>) -> CiRun {
+    let view = map_ci_run_view(run, now);
+    CiRun {
+        id: view.id,
+        ...
+    }
+}
+
+fn map_api_ci_lane_from_view(view: CiLaneView) -> CiLane {
+    CiLane {
+        ...
+        status: ForgeCiLaneStatus::from(view.status),
+        ...
+    }
+}
@@ -2210,11 +2260,12 @@
-        }) => Json(serde_json::json!({
-            "status": "ok",
-            "branch_id": branch_id,
+        }) => Json(BranchActionResponse {
+            status: "ok".to_string(),
+            branch_id,

pika-news/Cargo.toml adds the shared dependency. In web.rs:

  1. Deleted structs: ForgeBranchResolveResponse, ForgeBranchSummaryResponse, ForgeBranchDetailResponse, ForgeBranchLogsResponse, and ForgeNightlyDetailResponse are removed (~60 lines). They are replaced by imports from pika_forge_model with type aliases where names differ.

  2. Type alias for logs: ForgeBranchLogsResponse is defined as SharedForgeBranchLogsResponse<CiLane, RunRecord, RunLogsMetadata, PreparedOutputsRecord>, specializing the generic parameters with server-specific pikaci types.

  3. New mapping functions: Four functions are added to convert internal DB/view records into the shared API types:

    • map_api_ci_run — wraps existing map_ci_run_view and converts to CiRun
    • map_api_ci_lane — wraps map_ci_lane_view for branch lanes
    • map_api_ci_lane_from_view — direct CiLaneViewCiLane conversion
    • map_api_nightly_lane — wraps map_nightly_lane_view for nightly lanes
  4. Handler updates: All forge API handlers (merge_handler, close_handler, fail_branch_ci_lane_handler, requeue_branch_ci_lane_handler, recover_branch_ci_run_handler, fail_nightly_lane_handler, requeue_nightly_lane_handler, recover_nightly_run_handler, wake_ci_handler, api_forge_branch_resolve_handler, api_forge_branch_detail_handler, api_forge_branch_logs_handler, api_forge_nightly_detail_handler) are updated from Json(serde_json::json!({...})) to Json(TypedStruct { ... }). String fields like branch_state and ci_status are now constructed via BranchState::from(...) and ForgeCiStatus::from(...) respectively.

This ensures the server's JSON responses exactly match the struct shapes the CLI deserializes, catching any schema drift at compile time.

Diff