plc_control/docs/superpowers/plans/2026-04-14-dual-app-shared-...

22 KiB

Dual App Shared Core Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restructure the repository into a Cargo workspace with one shared Rust platform library and two app binaries, while preserving the current feeder/distributor behavior and preparing a second operation-system app.

Architecture: Keep the current feeder/distributor business logic as the first concrete app, extract stable platform modules into plc_platform_core, and add app_operation_system as a second binary with its own routes, state, and static assets. Store platform and business events in the same event table using namespaced event_type values.

Tech Stack: Rust 2021, Cargo workspace, Tokio, Axum, SQLx, async-opcua, WebSocket, existing web static assets


File Map

New workspace-level files

  • Create: crates/plc_platform_core/Cargo.toml
  • Create: crates/plc_platform_core/src/lib.rs
  • Create: crates/plc_platform_core/src/bootstrap.rs
  • Create: crates/plc_platform_core/src/platform_context.rs
  • Create: crates/app_feeder_distributor/Cargo.toml
  • Create: crates/app_feeder_distributor/src/main.rs
  • Create: crates/app_feeder_distributor/src/app.rs
  • Create: crates/app_feeder_distributor/src/router.rs
  • Create: crates/app_operation_system/Cargo.toml
  • Create: crates/app_operation_system/src/main.rs
  • Create: crates/app_operation_system/src/app.rs
  • Create: crates/app_operation_system/src/router.rs

Existing files that move into the shared crate

  • Modify then move: src/config.rs
  • Modify then move: src/db.rs
  • Modify then move: src/model.rs
  • Modify then move: src/connection.rs
  • Modify then move: src/telemetry.rs
  • Modify then move: src/event.rs
  • Modify then move: src/websocket.rs
  • Modify then move: src/service.rs
  • Modify then move: src/service/*.rs
  • Modify then move: src/util.rs
  • Modify then move: src/util/*.rs
  • Modify then move: src/control/command.rs
  • Modify then move: src/control/runtime.rs

Existing files that stay in the feeder/distributor app

  • Modify and keep: src/control/engine.rs
  • Modify and keep: src/control/simulate.rs
  • Modify and keep: src/handler/control.rs
  • Modify and keep: web/**

New or modified tests

  • Create: crates/plc_platform_core/tests/bootstrap_smoke.rs
  • Create: crates/plc_platform_core/tests/event_namespace.rs
  • Create: crates/app_feeder_distributor/tests/router_smoke.rs
  • Create: crates/app_operation_system/tests/router_smoke.rs

Task 1: Convert The Repository Into A Workspace

Files:

  • Modify: Cargo.toml

  • Create: crates/plc_platform_core/Cargo.toml

  • Create: crates/app_feeder_distributor/Cargo.toml

  • Create: crates/app_operation_system/Cargo.toml

  • Step 1: Write the failing workspace metadata test via cargo metadata

Run:

cargo metadata --no-deps

Expected:

  • FAIL or output only one package instead of the three planned workspace members

  • Step 2: Replace the root Cargo.toml with workspace metadata

[workspace]
members = [
  "crates/plc_platform_core",
  "crates/app_feeder_distributor",
  "crates/app_operation_system",
]
resolver = "2"
  • Step 3: Add the shared crate manifest
[package]
name = "plc_platform_core"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1.49", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_with = "3.0"
async-stream = "0.3"
chrono = "0.4"
time = "0.3"
uuid = { version = "1.21", features = ["serde", "v4"] }
async-opcua = { version = "0.18", features = ["client"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "time", "json"] }
tracing-appender = "0.2"
dotenv = "0.15"
validator = { version = "0.20", features = ["derive"] }
anyhow = "1.0"
fs2 = "0.4"
  • Step 4: Add the feeder/distributor app manifest
[package]
name = "app_feeder_distributor"
version = "0.1.0"
edition = "2021"

[dependencies]
plc_platform_core = { path = "../plc_platform_core" }
tokio = { version = "1.49", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
tracing = "0.1"
dotenv = "0.15"

[target.'cfg(windows)'.dependencies]
tray-icon = "0.15"
winit = "0.30"
webbrowser = "0.8"
  • Step 5: Add the operation-system app manifest
[package]
name = "app_operation_system"
version = "0.1.0"
edition = "2021"

[dependencies]
plc_platform_core = { path = "../plc_platform_core" }
tokio = { version = "1.49", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
tracing = "0.1"
dotenv = "0.15"
  • Step 6: Run metadata again to verify the workspace shape

Run:

cargo metadata --no-deps

Expected:

  • PASS

  • Output includes plc_platform_core, app_feeder_distributor, and app_operation_system

  • Step 7: Commit

git add Cargo.toml crates/plc_platform_core/Cargo.toml crates/app_feeder_distributor/Cargo.toml crates/app_operation_system/Cargo.toml
git commit -m "build(workspace): add dual-app workspace manifests"

Task 2: Introduce The Shared Core Skeleton

Files:

  • Create: crates/plc_platform_core/src/lib.rs

  • Create: crates/plc_platform_core/src/platform_context.rs

  • Create: crates/plc_platform_core/src/bootstrap.rs

  • Test: crates/plc_platform_core/tests/bootstrap_smoke.rs

  • Step 1: Write the failing shared-core smoke test

use plc_platform_core::platform_context::PlatformContext;

#[test]
fn platform_context_type_is_public() {
    fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
    assert_send_sync_clone::<PlatformContext>();
}
  • Step 2: Run the test to verify it fails

Run:

cargo test -p plc_platform_core platform_context_type_is_public -- --exact

Expected:

  • FAIL with unresolved import or missing type errors

  • Step 3: Add the shared-core public module surface

pub mod bootstrap;
pub mod platform_context;
  • Step 4: Add the initial PlatformContext type
use std::sync::Arc;

#[derive(Clone)]
pub struct PlatformContext {
    pub config_name: Arc<str>,
}

impl PlatformContext {
    pub fn new(config_name: impl Into<Arc<str>>) -> Self {
        Self {
            config_name: config_name.into(),
        }
    }
}
  • Step 5: Add a minimal bootstrap module
use crate::platform_context::PlatformContext;

pub fn bootstrap_platform_for_tests() -> PlatformContext {
    PlatformContext::new("test")
}
  • Step 6: Run the shared-core test to verify it passes

Run:

cargo test -p plc_platform_core platform_context_type_is_public -- --exact

Expected:

  • PASS

  • Step 7: Commit

git add crates/plc_platform_core/src/lib.rs crates/plc_platform_core/src/platform_context.rs crates/plc_platform_core/src/bootstrap.rs crates/plc_platform_core/tests/bootstrap_smoke.rs
git commit -m "feat(core): add shared platform skeleton"

Task 3: Move Stable Utility And Model Modules Into The Shared Core

Files:

  • Create or move: crates/plc_platform_core/src/model.rs

  • Create or move: crates/plc_platform_core/src/util.rs

  • Create or move: crates/plc_platform_core/src/util/*.rs

  • Modify: crates/plc_platform_core/src/lib.rs

  • Modify: imports in the current business code that referenced crate::model or crate::util

  • Step 1: Write a failing model import test

use plc_platform_core::model::Equipment;

#[test]
fn equipment_model_is_exposed_from_shared_core() {
    let _ = std::mem::size_of::<Equipment>();
}
  • Step 2: Run the test to verify it fails

Run:

cargo test -p plc_platform_core equipment_model_is_exposed_from_shared_core -- --exact

Expected:

  • FAIL with missing model module or Equipment type

  • Step 3: Move src/model.rs and src/util* into plc_platform_core and export them

pub mod bootstrap;
pub mod model;
pub mod platform_context;
pub mod util;
  • Step 4: Update downstream imports to the shared path
use plc_platform_core::model::Equipment;
use plc_platform_core::util::response::ApiResponse;
  • Step 5: Run focused tests and a type check

Run:

cargo test -p plc_platform_core equipment_model_is_exposed_from_shared_core -- --exact
cargo check -p app_feeder_distributor

Expected:

  • PASS for the test

  • cargo check succeeds or reports only the next modules that still need moving

  • Step 6: Commit

git add crates/plc_platform_core/src/lib.rs crates/plc_platform_core/src/model.rs crates/plc_platform_core/src/util.rs crates/plc_platform_core/src/util app_feeder_distributor
git commit -m "refactor(core): move model and util modules into shared crate"

Task 4: Move Database, Service, Telemetry, And Connection Modules Into The Shared Core

Files:

  • Move: src/db.rs

  • Move: src/service.rs

  • Move: src/service/*.rs

  • Move: src/telemetry.rs

  • Move: src/connection.rs

  • Modify: crates/plc_platform_core/src/lib.rs

  • Modify: downstream imports in both apps

  • Step 1: Write a failing service API exposure test

use plc_platform_core::service::EquipmentRolePoint;

#[test]
fn service_types_are_public_from_shared_core() {
    let _ = std::mem::size_of::<EquipmentRolePoint>();
}
  • Step 2: Run the test to verify it fails

Run:

cargo test -p plc_platform_core service_types_are_public_from_shared_core -- --exact

Expected:

  • FAIL with unresolved module or type

  • Step 3: Move and export the stable platform modules

pub mod bootstrap;
pub mod connection;
pub mod db;
pub mod model;
pub mod platform_context;
pub mod service;
pub mod telemetry;
pub mod util;
  • Step 4: Update feeder imports to use the shared crate
use plc_platform_core::connection::ConnectionManager;
use plc_platform_core::service::get_all_enabled_sources;
use plc_platform_core::telemetry::PointMonitorInfo;
  • Step 5: Run focused verification

Run:

cargo test -p plc_platform_core service_types_are_public_from_shared_core -- --exact
cargo check -p app_feeder_distributor

Expected:

  • PASS for the test

  • Feeder app type-checks after import updates

  • Step 6: Commit

git add crates/plc_platform_core/src/db.rs crates/plc_platform_core/src/service.rs crates/plc_platform_core/src/service crates/plc_platform_core/src/telemetry.rs crates/plc_platform_core/src/connection.rs crates/plc_platform_core/src/lib.rs crates/app_feeder_distributor
git commit -m "refactor(core): move platform data and connection modules"

Task 5: Split The Event Layer Into Platform Infrastructure Plus Namespaced Business Events

Files:

  • Move and modify: src/event.rs

  • Create: crates/plc_platform_core/tests/event_namespace.rs

  • Modify: feeder business event call sites

  • Step 1: Write a failing event namespace test

use serde_json::json;

#[test]
fn namespaced_event_types_keep_their_prefix() {
    let event_type = "feeder.auto_control_started";
    let payload = json!({"unit_id": "00000000-0000-0000-0000-000000000000"});
    assert!(event_type.starts_with("feeder."));
    assert_eq!(payload["unit_id"], "00000000-0000-0000-0000-000000000000");
}
  • Step 2: Add a shared platform event record type and publisher surface
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EventEnvelope {
    pub event_type: String,
    pub payload: serde_json::Value,
}

impl EventEnvelope {
    pub fn new(event_type: impl Into<String>, payload: serde_json::Value) -> Self {
        Self {
            event_type: event_type.into(),
            payload,
        }
    }
}
  • Step 3: Convert feeder-specific events into explicit envelopes at the call site
use plc_platform_core::event::EventEnvelope;
use serde_json::json;

let event = EventEnvelope::new(
    "feeder.auto_control_started",
    json!({ "unit_id": unit_id }),
);
state.platform.event_manager.publish(event)?;
  • Step 4: Run the shared-core tests and feeder checks

Run:

cargo test -p plc_platform_core namespaced_event_types_keep_their_prefix -- --exact
cargo check -p app_feeder_distributor

Expected:

  • PASS

  • Feeder app compiles with event namespace updates

  • Step 5: Commit

git add crates/plc_platform_core/src/event.rs crates/plc_platform_core/tests/event_namespace.rs crates/app_feeder_distributor
git commit -m "refactor(events): add shared event envelopes with namespaces"

Task 6: Move WebSocket, Runtime, And Command Infrastructure Into The Shared Core

Files:

  • Move: src/websocket.rs

  • Move: src/control/runtime.rs

  • Move: src/control/command.rs

  • Modify: crates/plc_platform_core/src/lib.rs

  • Modify: feeder engine imports

  • Step 1: Write a failing runtime exposure test

use plc_platform_core::control::runtime::UnitRuntimeState;

#[test]
fn runtime_state_is_exposed_from_shared_core() {
    assert_eq!(serde_json::to_string(&UnitRuntimeState::Stopped).unwrap(), "\"stopped\"");
}
  • Step 2: Run the test to verify it fails

Run:

cargo test -p plc_platform_core runtime_state_is_exposed_from_shared_core -- --exact

Expected:

  • FAIL with unresolved module path

  • Step 3: Add the shared control module surface

pub mod command;
pub mod runtime;
  • Step 4: Export control from the shared crate root
pub mod bootstrap;
pub mod connection;
pub mod control;
pub mod db;
pub mod event;
pub mod model;
pub mod platform_context;
pub mod service;
pub mod telemetry;
pub mod util;
pub mod websocket;
  • Step 5: Update feeder imports
use plc_platform_core::control::command::send_pulse_command;
use plc_platform_core::control::runtime::{ControlRuntimeStore, UnitRuntime, UnitRuntimeState};
use plc_platform_core::websocket::WsMessage;
  • Step 6: Run verification

Run:

cargo test -p plc_platform_core runtime_state_is_exposed_from_shared_core -- --exact
cargo check -p app_feeder_distributor

Expected:

  • PASS

  • Feeder app compiles with shared runtime and command imports

  • Step 7: Commit

git add crates/plc_platform_core/src/control crates/plc_platform_core/src/websocket.rs crates/plc_platform_core/src/lib.rs crates/app_feeder_distributor
git commit -m "refactor(core): move websocket runtime and command infrastructure"

Task 7: Build The Feeder/Distributor App Crate Around The Current Business Logic

Files:

  • Create: crates/app_feeder_distributor/src/main.rs

  • Create: crates/app_feeder_distributor/src/app.rs

  • Create: crates/app_feeder_distributor/src/router.rs

  • Move or copy business modules: feeder handler, feeder control, feeder web

  • Test: crates/app_feeder_distributor/tests/router_smoke.rs

  • Step 1: Write the failing feeder router smoke test

use axum::Router;

#[test]
fn feeder_router_builds() {
    fn assert_router(_: Router) {}
    assert_router(app_feeder_distributor::router::build_router_for_tests());
}
  • Step 2: Run the test to verify it fails

Run:

cargo test -p app_feeder_distributor feeder_router_builds -- --exact

Expected:

  • FAIL with missing library module or router function

  • Step 3: Add the feeder app state and router shell

use std::sync::Arc;
use plc_platform_core::platform_context::PlatformContext;
use plc_platform_core::control::runtime::ControlRuntimeStore;

#[derive(Clone)]
pub struct FeederAppState {
    pub platform: Arc<PlatformContext>,
    pub runtime: Arc<ControlRuntimeStore>,
}
use axum::Router;

pub fn build_router_for_tests() -> Router {
    Router::new()
}
  • Step 4: Move the current feeder/distributor business modules into the new crate and wire them to FeederAppState
pub mod control;
pub mod handler;
pub mod router;
use crate::app::FeederAppState;
use axum::Router;

pub fn build_router(state: FeederAppState) -> Router {
    Router::new().with_state(state)
}
  • Step 5: Add the feeder binary entrypoint
#[tokio::main]
async fn main() {
    dotenv::dotenv().ok();
    let _ = app_feeder_distributor::app::run().await;
}
  • Step 6: Run the feeder test and type-check

Run:

cargo test -p app_feeder_distributor feeder_router_builds -- --exact
cargo check -p app_feeder_distributor

Expected:

  • PASS

  • The feeder app builds as a standalone binary crate

  • Step 7: Commit

git add crates/app_feeder_distributor
git commit -m "feat(feeder): create dedicated feeder distributor app crate"

Task 8: Add The Operation-System App Skeleton With Its Own State, Router, And Web Root

Files:

  • Create: crates/app_operation_system/src/main.rs

  • Create: crates/app_operation_system/src/app.rs

  • Create: crates/app_operation_system/src/router.rs

  • Create: crates/app_operation_system/web/index.html

  • Test: crates/app_operation_system/tests/router_smoke.rs

  • Step 1: Write the failing operation-system router smoke test

use axum::Router;

#[test]
fn operation_router_builds() {
    fn assert_router(_: Router) {}
    assert_router(app_operation_system::router::build_router_for_tests());
}
  • Step 2: Run the test to verify it fails

Run:

cargo test -p app_operation_system operation_router_builds -- --exact

Expected:

  • FAIL with missing module or function

  • Step 3: Add the operation-system app state and router shell

use std::sync::Arc;
use plc_platform_core::platform_context::PlatformContext;

#[derive(Clone)]
pub struct OperationAppState {
    pub platform: Arc<PlatformContext>,
}
use axum::Router;

pub fn build_router_for_tests() -> Router {
    Router::new()
}
  • Step 4: Add a distinct operation-system binary
#[tokio::main]
async fn main() {
    dotenv::dotenv().ok();
    let _ = app_operation_system::app::run().await;
}
  • Step 5: Add a dedicated web placeholder
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>运转系统专用版</title>
  </head>
  <body>
    <main>Operation system app scaffold</main>
  </body>
</html>
  • Step 6: Run the operation-system test and type-check

Run:

cargo test -p app_operation_system operation_router_builds -- --exact
cargo check -p app_operation_system

Expected:

  • PASS

  • The operation-system app builds as a separate binary crate

  • Step 7: Commit

git add crates/app_operation_system
git commit -m "feat(ops): add operation-system app skeleton"

Task 9: Remove The Old Root Binary And Keep Compatibility Wrappers Only Where Needed

Files:

  • Modify or delete: root src/main.rs

  • Modify: any root module declarations that are now redundant

  • Optionally create: compatibility README notes for new build commands

  • Step 1: Write the failing package selection check

Run:

cargo check

Expected:

  • FAIL because the old root package layout no longer matches the workspace layout

  • Step 2: Remove the obsolete root binary packaging and leave only workspace members

Delete the old root package entrypoint after both app crates compile.
Do not leave a third unnamed binary package at repository root.
  • Step 3: Add explicit build instructions to the README
## Build

```powershell
cargo build -p app_feeder_distributor --release
cargo build -p app_operation_system --release

- [ ] **Step 4: Run workspace verification**

Run:

```powershell
cargo check --workspace

Expected:

  • PASS

  • Both app crates and the shared core compile from the workspace root

  • Step 5: Commit

git add README.md
git rm src/main.rs
git commit -m "build(workspace): remove obsolete root binary entrypoint"

Task 10: Verify Namespaced Event Storage And Both App Builds

Files:

  • Test: crates/plc_platform_core/tests/event_namespace.rs

  • Test: crates/app_feeder_distributor/tests/router_smoke.rs

  • Test: crates/app_operation_system/tests/router_smoke.rs

  • Modify: docs if build commands changed

  • Step 1: Add a final event naming regression test

#[test]
fn event_namespaces_match_the_supported_apps() {
    let supported = ["platform.source_connected", "feeder.auto_control_started", "ops.unit_started"];
    for name in supported {
        assert!(name.contains('.'));
    }
}
  • Step 2: Run all focused tests

Run:

cargo test -p plc_platform_core
cargo test -p app_feeder_distributor
cargo test -p app_operation_system

Expected:

  • PASS for all three packages

  • Step 3: Run final workspace builds

Run:

cargo build -p app_feeder_distributor --release
cargo build -p app_operation_system --release

Expected:

  • PASS

  • Two release binaries are produced successfully

  • Step 4: Commit

git add docs/superpowers/plans/2026-04-14-dual-app-shared-core-implementation.md crates README.md
git commit -m "test(workspace): verify dual-app shared-core builds"

Self-Review

Spec coverage

  • Workspace + shared core + two apps: covered by Tasks 1, 2, 7, and 8
  • Shared module extraction: covered by Tasks 3, 4, and 6
  • Namespaced events in one table: covered by Tasks 5 and 10
  • Two exe outputs: covered by Tasks 9 and 10
  • Future single-app expansion path: preserved by the app-crate composition approach in Tasks 7 and 8

Placeholder scan

  • No placeholder markers remain in the task instructions
  • Each code-changing step includes concrete code or concrete commands

Type consistency

  • Shared state type is PlatformContext
  • Feeder business state is FeederAppState
  • Operation business state is OperationAppState
  • Shared event wrapper is EventEnvelope