938 lines
51 KiB
Markdown
938 lines
51 KiB
Markdown
# FT546 Real-Time Read-Only Shadow 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:** Build an independently deployable FT546 real-time shadow program that reads approved production facts, runs the same dispatch and FT546 PLC-intent code intended for `STANDARD`, and persists comparison evidence without starting legacy threads, connecting to PLC/WMS, or writing any production table.
|
|
|
|
**Architecture:** Keep the deployed stable WCS as the only production scheduler and PLC/WMS writer. Add a separate `ruoyi-wcs-shadow` Spring Boot process with two explicit data sources: a production account restricted to approved read-only views and a physically separate evidence database. Extract FT546 register encoding into a pure protocol component shared by the real adapter and shadow recorder; run the shared dispatch kernel through a shadow-specific cycle that persists virtual commands and comparison evidence instead of dispatching Outbox entries.
|
|
|
|
**Tech Stack:** Java 8, Maven multi-module, Spring Boot 2.5.15, Spring JDBC transactions, SQL Server JDBC, H2 file database for local evidence tests, JUnit 5, ArchUnit, PowerShell deployment scripts.
|
|
|
|
## Global Constraints
|
|
|
|
- Work only in the `optimize/wcs` worktree; do not merge, push, or modify the deployed stable branch.
|
|
- The deployed stable artifact remains the only scheduler and only PLC/WMS writer throughout shadow collection.
|
|
- `LEGACY` remains the default `ft546.wcs.runtime-mode`; no task changes that default.
|
|
- `SHADOW` must have zero PLC connections, zero PLC writes, zero WMS callbacks, zero writes to production business/core tables, and zero legacy business or PLC-reader threads.
|
|
- The real-time shadow is a separate process, service port, log directory, deployment directory, configuration file, and preferably a separate host.
|
|
- The shadow production account may read only `shadow_ro.*` views; it must not receive `INSERT`, `UPDATE`, `DELETE`, `EXECUTE`, `ALTER`, `CONTROL`, or ownership permissions in the production database.
|
|
- Shadow evidence writes use a physically separate database or server. A different schema in the production database is insufficient for the first site run.
|
|
- Do not use `DataSourceType`, `DynamicDataSourceContextHolder`, or the shared RuoYi `SqlSessionFactory` for the two shadow data sources. Use explicit bean qualifiers and explicit transaction managers so a production connection can never be reused for evidence writes.
|
|
- Production reads use a maximum pool size of 2, zero minimum idle connections, a 2-second connect timeout, a 3-second query timeout, fixed-delay polling, exponential backoff, and a one-cycle in-flight guard.
|
|
- Missing, stale, partial, or contradictory facts produce `INSUFFICIENT_FACTS`; they do not generate a comparable virtual command.
|
|
- The FT546 protocol contract remains exactly: main `4004 -> 4000 -> 4002 -> 4021`; cross-floor adds `4020=1 -> 4022=<target>`; two-stage one `4404 -> 4422=1 -> 4402=<taskType> -> 4400`; two-stage two `4404 -> 4422=2 -> 4423=99 -> 4402=9 -> 4400`.
|
|
- A shadow protocol difference and an algorithm decision difference are separate categories. Never call a different selected vehicle/path a protocol failure when both encodings are internally correct.
|
|
- No history field (`agv_id`, `path_info`, completion status, or `finish_date`) may force the new kernel to repeat the legacy choice.
|
|
- This plan delivers the real-time read-only shadow and its deployment/rollback evidence. It does not authorize `STANDARD` PLC control or a production cutover.
|
|
- All feature changes follow red-green-refactor. Each task runs its named test while red and again while green.
|
|
- Preserve the four pre-existing untracked documentation HTML files and the generated `javaTest/outputs/wcs-history-business-shadow-20260812-f66585f/` evidence directory.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
New shared protocol module:
|
|
|
|
- `ruoyi-ft546-protocol/`: pure FT546 profile, capability, command-intent, register-write, and encoder types. It has no Spring, JDBC, PLC, RuoYi Admin, or network dependency.
|
|
- `Ft546SrgvCommandIntent`: strong input containing command identity, phase, route version, task number/type, route points, optional cross-floor target, and optional two-stage number.
|
|
- `Ft546RegisterWrite`: immutable `address/dataType/values/writeSequence/trigger` value.
|
|
- `Ft546EncodedCommand`: immutable ordered write list and command evidence.
|
|
- `Ft546SrgvCommandEncoder`: the only FT546 shuttle register encoder used by both real and shadow paths.
|
|
|
|
New independent shadow process:
|
|
|
|
- `ruoyi-wcs-shadow/`: Spring Boot command-line service with no dependency on `ruoyi-admin` or `ruoyi-framework`.
|
|
- `Ft546ShadowApplication`: imports only explicit shadow configuration and pure core/protocol beans; it does not component-scan `com.ruoyi.wcs` or `com.ruoyi.project`, so it cannot discover `WcsCoreConfiguration`, `ScheduleThread`, `ThreadOperationController`, PLC readers, WMS controllers, or production adapters.
|
|
- `ShadowSourceConfiguration`: explicit read-only SQL Server `DataSource`, `JdbcTemplate`, and transaction manager.
|
|
- `ShadowEvidenceConfiguration`: explicit evidence `DataSource`, `JdbcTemplate`, and transaction manager.
|
|
- `ShadowStartupGate`: fail-closed verification of process mode, JDBC users/databases, source permissions, evidence location, dependency graph, and local output writability.
|
|
- `SqlServerShadowFactReader`: bounded reads from `shadow_ro.v_*` views and assembly of one immutable `ShadowFactBatch`.
|
|
- `Ft546ShadowCycle`: source fact collection -> fact validation -> same-kernel dispatch -> virtual command intent -> protocol encoding -> comparison -> evidence commit. It never invokes `CommandOutboxDispatcher`.
|
|
- `RecordingFt546CommandGateway`: records encoded commands to the evidence repository; it has no `send` or PLC runtime reference.
|
|
- `ShadowEvidenceRepository`: stores run, input batch, legacy result, new decision, encoded writes, differences, and cycle health in the evidence database.
|
|
|
|
Existing production files remain owners of production behavior:
|
|
|
|
- `ScheduleThread` continues to own legacy readers/business threads in `LEGACY` and standard PLC fact readers in `STANDARD`; `SHADOW` constructs neither.
|
|
- `Ft546LegacySrgvAdapter` remains the real PLC gateway in `STANDARD`, but delegates encoding to the shared pure protocol module before iterating the encoded writes.
|
|
- `Ft546AdapterConfiguration` remains `STANDARD`-only. The shadow process does not import it.
|
|
|
|
---
|
|
|
|
### Task 1: Make Runtime Modes Fail-Closed Before Any Legacy or PLC Object Exists
|
|
|
|
**Files:**
|
|
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java`
|
|
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java`
|
|
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java`
|
|
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java`
|
|
- Modify: `ruoyi-admin/src/main/resources/META-INF/spring.factories`
|
|
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java`
|
|
- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java`
|
|
|
|
**Interfaces:**
|
|
- Produces: `Ft546WcsProperties.RuntimeMode.isLegacy/isShadow/isStandard`.
|
|
- Produces: `ScheduleThread.RuntimeCapabilities` with `plcFactReadersAllowed`, `legacyBusinessThreadsAllowed`, and `automaticFactReaderStartAllowed`.
|
|
- Preserves: `LEGACY` initialization/start/stop behavior and `STANDARD` application-ready PLC fact-reader start.
|
|
- Enforces: every thread operation method rejects `SHADOW` before dereferencing a thread field.
|
|
- Enforces: the `ruoyi-admin` artifact refuses to start in `SHADOW` during environment preparation, before Spring constructs `ScheduleThread`; operators must use the dedicated `ruoyi-wcs-shadow` artifact.
|
|
|
|
- [ ] **Step 1: Write failing mode-isolation tests**
|
|
|
|
```java
|
|
@Test
|
|
void shadowPostConstructDoesNotQueryDevicesOrConstructAnyThread() {
|
|
ScheduleThread runtime = runtime(SHADOW);
|
|
ReflectionTestUtils.setField(runtime, "deviceSrgvService",
|
|
failOnAnyInvocation(IDeviceSrgvService.class));
|
|
|
|
runtime.init();
|
|
|
|
assertTrue(runtime.plcSrgvReadCustomThreads.isEmpty());
|
|
assertNull(runtime.plcSsjReadCustomThread);
|
|
assertNull(runtime.plcTsjReadCustomThread);
|
|
assertNull(runtime.plcCdzReadCustomThread);
|
|
assertNull(runtime.sRgvThreadCustomThread);
|
|
}
|
|
|
|
@Test
|
|
void everyManualThreadOperationFailsClosedInShadow() {
|
|
ScheduleThread runtime = runtime(SHADOW);
|
|
|
|
assertThrows(IllegalStateException.class, runtime::startAllThread);
|
|
assertThrows(IllegalStateException.class, runtime::startAllThread2);
|
|
assertThrows(IllegalStateException.class, runtime::stopAllThread);
|
|
assertThrows(IllegalStateException.class, runtime::stopAllThread2);
|
|
}
|
|
```
|
|
|
|
Add a MockMvc test proving all four mutation endpoints return an error in `SHADOW`. Preserve the deployed stable authorization surface exactly: the stable controller currently leaves its `@PreAuthorize` lines commented, so this task must not enable them and risk blocking existing operator accounts. Add a `SpringApplication` test proving `Ft546AdminRuntimeModeEnvironmentPostProcessor` throws `ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW` during environment preparation, after Config Data is loaded but before any test bean's constructor or `@PostConstruct`, when the admin artifact is configured with `runtime-mode=SHADOW`.
|
|
|
|
- [ ] **Step 2: Run the isolation test and verify current `@PostConstruct` behavior fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-admin -am -Dtest=Ft546ShadowIsolationTest,Ft546StandardRuntimeTest test
|
|
```
|
|
|
|
Expected: `shadowPostConstructDoesNotQueryDevicesOrConstructAnyThread` fails because current `init()` unconditionally calls `plcReadInit()` and `scheduleInit()`; manual PLC-reader start also does not reject `SHADOW`.
|
|
|
|
- [ ] **Step 3: Centralize mode capabilities and guard before construction**
|
|
|
|
```java
|
|
public void init() {
|
|
RuntimeCapabilities capabilities = capabilities();
|
|
if (capabilities.plcFactReadersAllowed()) {
|
|
plcReadInit();
|
|
}
|
|
if (capabilities.legacyBusinessThreadsAllowed()) {
|
|
scheduleInit();
|
|
}
|
|
}
|
|
|
|
private RuntimeCapabilities capabilities() {
|
|
RuntimeMode mode = ft546WcsProperties.getRuntimeMode();
|
|
return new RuntimeCapabilities(
|
|
mode == RuntimeMode.LEGACY || mode == RuntimeMode.STANDARD,
|
|
mode == RuntimeMode.LEGACY,
|
|
mode == RuntimeMode.STANDARD);
|
|
}
|
|
```
|
|
|
|
Call `requirePlcFactReadersAllowed()` at the first line of `startAllThread/stopAllThread`, and `requireLegacyBusinessThreadsAllowed()` at the first line of `startAllThread2/stopAllThread2`. Keep `BaseSRgvThread.run()` fail-closed for every non-`LEGACY` mode. Preserve the four endpoints' stable authorization behavior. Preserve the existing `ScheduleThread.getScheduleStatus()` Java signature and add a detailed status view for the controller so operators can see the current mode and allowed operations without breaking existing callers. Register `Ft546AdminRuntimeModeEnvironmentPostProcessor` in `META-INF/spring.factories`; it reads the raw environment property, accepts missing/`LEGACY`/`STANDARD`, and throws after Config Data loading but before bean creation for `SHADOW` or an unknown value. The exception names the dedicated shadow artifact and does not silently fall back to `LEGACY`. Lower-level thread guards remain as defense in depth and are unit-tested directly.
|
|
|
|
- [ ] **Step 4: Run focused and regression tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-admin -am -Dtest=Ft546ShadowIsolationTest,Ft546StandardRuntimeTest,Ft546DeviceRuntimeRegistryTest test
|
|
```
|
|
|
|
Expected: `SHADOW` constructs no thread/runtime object; `LEGACY` existing initialization remains intact; `STANDARD` starts readers only after `ApplicationReadyEvent`; endpoint authorization and mode rejection pass.
|
|
|
|
- [ ] **Step 5: Commit the hard runtime boundary**
|
|
|
|
```bash
|
|
git add ruoyi-admin/src/main/java/com/ruoyi/schedule \
|
|
ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java \
|
|
ruoyi-admin/src/main/resources/META-INF/spring.factories \
|
|
ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime
|
|
git commit -m "fix(ft546): isolate shadow from legacy and PLC threads"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Extract One Pure FT546 Protocol Encoder For Real And Shadow Paths
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-ft546-protocol/pom.xml`
|
|
- Modify: `pom.xml`
|
|
- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546DeviceProfile.java`
|
|
- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java`
|
|
- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546RegisterWrite.java`
|
|
- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546EncodedCommand.java`
|
|
- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoder.java`
|
|
- Delete after import migration: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java`
|
|
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java`
|
|
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java`
|
|
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java`
|
|
- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapper.java`
|
|
- Delete after callers migrate: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java`
|
|
- Create: `ruoyi-ft546-protocol/src/test/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoderTest.java`
|
|
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java`
|
|
- Move/copy fixtures: `ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-point-to-point.json` and `srgv-two-stage-gate.json`
|
|
|
|
**Interfaces:**
|
|
- Produces: `Ft546SrgvCommandEncoder.encode(Ft546SrgvCommandIntent, Ft546DeviceProfile, Set<String>) -> Ft546EncodedCommand`.
|
|
- Produces: `Ft546CommandIntentMapper.from(DeviceCommandEnvelope) -> Ft546SrgvCommandIntent` in `ruoyi-admin`; the pure module never imports WCS envelope types.
|
|
- Produces: ordered `Ft546RegisterWrite` values with public getters for `address`, `dataType`, `values`, `writeSequence`, and `trigger`.
|
|
- Preserves: current `SendReceipt` and `UNKNOWN` behavior in the real adapter.
|
|
|
|
- [ ] **Step 1: Write protocol golden-master tests independent of PLC classes**
|
|
|
|
```java
|
|
@Test
|
|
void crossFloorIntentEncodesTheExactLegacySequence() {
|
|
Ft546EncodedCommand encoded = encoder.encode(
|
|
intent("PARENT-1", "LOADED_TO_PLACE", 30001, (short) 8,
|
|
points(70501, 70502), (short) 8, null),
|
|
fullProfile(), set("POINT_TO_POINT_COMMAND"));
|
|
|
|
assertWrites(encoded,
|
|
write(1, "4004", INT32, 70501, 70502, 0, 0, 0, 0, 0, 0),
|
|
write(2, "4000", INT32, 30001),
|
|
write(3, "4002", INT16, 8),
|
|
trigger(4, "4021", INT16, 1),
|
|
write(5, "4020", INT16, 1),
|
|
write(6, "4022", INT16, 8));
|
|
}
|
|
```
|
|
|
|
Add exact tests for ordinary main task, two-stage stage one, two-stage stage two, missing registers, route length 2/8 boundaries, route length 1/9 rejection, six-digit point overflow, capability gates, and immutable profile/write lists.
|
|
|
|
- [ ] **Step 2: Run the new module test and verify it fails to compile**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-ft546-protocol -am -Dtest=Ft546SrgvCommandEncoderTest test
|
|
```
|
|
|
|
Expected: Maven initially cannot find the module/types.
|
|
|
|
- [ ] **Step 3: Implement the pure encoder and make the real adapter consume it**
|
|
|
|
```java
|
|
Ft546EncodedCommand encoded = encoder.encode(
|
|
intentMapper.from(envelope), profile,
|
|
capabilities.getEnabledCapabilities());
|
|
boolean wroteAny = false;
|
|
for (Ft546RegisterWrite write : encoded.getWrites()) {
|
|
if (!plcRegisterWriter.write(runtime, write)) {
|
|
return markUnknown(envelope.getDeviceId(), receiptId,
|
|
wroteAny
|
|
? "PLC rejected a partial register sequence"
|
|
: "first register write result is ambiguous");
|
|
}
|
|
wroteAny = true;
|
|
}
|
|
```
|
|
|
|
Keep `PlcRegisterWriter` inside `ruoyi-admin`; the pure module never imports `PlcSrgvRead`. The encoder includes evidence fields `deviceId`, `parentTaskId`, `phase`, `commandKey`, `routeVersion`, `taskNo`, `taskType`, `pathPoints`, `writeSequence`, and `triggerValue`.
|
|
|
|
- [ ] **Step 4: Run protocol and adapter regression tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-ft546-protocol,ruoyi-admin -am \
|
|
-Dtest=Ft546SrgvCommandEncoderTest,Ft546LegacySrgvAdapterTest,Ft546StandardWcsEndToEndTest test
|
|
```
|
|
|
|
Expected: golden protocol fixtures and all current real-adapter behaviors pass; partial writes still become `ACCEPTED_UNKNOWN`; no address/type/value/order changes occur.
|
|
|
|
- [ ] **Step 5: Commit the shared protocol seam**
|
|
|
|
```bash
|
|
git add pom.xml ruoyi-ft546-protocol ruoyi-admin/pom.xml \
|
|
ruoyi-admin/src/main/java/com/ruoyi/project/ft546 \
|
|
ruoyi-admin/src/test/java/com/ruoyi/project/ft546
|
|
git commit -m "refactor(ft546): share pure PLC command encoding"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add An Independent Shadow Launcher With A Dependency Deny-List
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-wcs-shadow/pom.xml`
|
|
- Modify: `pom.xml`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/resources/application-shadow.yml`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/ShadowDependencyBoundaryTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml`
|
|
|
|
**Interfaces:**
|
|
- Launch: `java -jar ruoyi-wcs-shadow.jar --spring.profiles.active=shadow --shadow.enabled=true`.
|
|
- Status: `GET /shadow/status` returns `STARTING`, `READY`, `DEGRADED`, or `STOPPED`, last completed cycle, source lag, evidence backlog, and startup checks.
|
|
- Produces: `ShadowStartupGate.verify(ApplicationContext, ShadowProperties)`.
|
|
- Enforces: `server.address=127.0.0.1` by default and `server.port=18091`; no actuator mutation endpoint.
|
|
|
|
- [ ] **Step 1: Write application and architecture tests**
|
|
|
|
```java
|
|
@Test
|
|
void shadowContextContainsNoProductionSideEffectType() {
|
|
contextRunner.run(context -> {
|
|
assertThat(context).doesNotHaveBean(DeviceCommandGateway.class);
|
|
assertThat(context).doesNotHaveBean(WmsNotificationGateway.class);
|
|
assertFalse(ClassUtils.isPresent(
|
|
"com.ruoyi.schedule.ScheduleThread", context.getClassLoader()));
|
|
assertThat(context.getBeanDefinitionNames())
|
|
.noneMatch(name -> name.toLowerCase().contains("plc"));
|
|
});
|
|
}
|
|
|
|
@ArchTest
|
|
static final ArchRule shadowMustNotDependOnAdminOrPlc = noClasses()
|
|
.that().resideInAPackage("com.ruoyi.shadow..")
|
|
.should().dependOnClassesThat()
|
|
.resideInAnyPackage("com.ruoyi.schedule..",
|
|
"com.ruoyi.api..", "HslCommunication..");
|
|
```
|
|
|
|
Also assert startup fails unless `shadow.enabled=true`, `spring.profiles.active=shadow`, and both source/evidence URLs are explicitly supplied outside tests.
|
|
|
|
- [ ] **Step 2: Run the new module test and verify the launcher is missing**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am -Dtest=Ft546ShadowApplicationTest,ShadowDependencyBoundaryTest test
|
|
```
|
|
|
|
Expected: module/application classes are missing.
|
|
|
|
- [ ] **Step 3: Implement a narrow component graph**
|
|
|
|
```java
|
|
@SpringBootConfiguration
|
|
@EnableAutoConfiguration
|
|
@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class})
|
|
@EnableScheduling
|
|
public class Ft546ShadowApplication {
|
|
public static void main(String[] args) {
|
|
SpringApplication.run(Ft546ShadowApplication.class, args);
|
|
}
|
|
}
|
|
```
|
|
|
|
Exclude RuoYi security, Redis, Quartz, Swagger, TCP servers, MyBatis auto-scans, `WcsCoreConfiguration`, and all `ruoyi-admin` dependencies. `ShadowKernelConfiguration` constructs `DefaultVehicleAssignmentPolicy`, `AStarRoutePlanner`, `DeadlockGuard`, and `DispatchCoordinator` explicitly. The module depends only on `ruoyi-wcs`, `ruoyi-ft546-protocol`, Spring Web/JDBC, SQL Server driver, Jackson, and tests. `ShadowStartupGate` inspects bean types and package names at runtime and stops scheduling if a forbidden side-effect type appears.
|
|
|
|
- [ ] **Step 4: Run context and package-boundary tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am -Dtest=Ft546ShadowApplicationTest,ShadowDependencyBoundaryTest test
|
|
```
|
|
|
|
Expected: the context starts with in-memory test adapters only, status binds to loopback, and no forbidden type is reachable.
|
|
|
|
- [ ] **Step 5: Commit the isolated process**
|
|
|
|
```bash
|
|
git add pom.xml ruoyi-wcs-shadow
|
|
git commit -m "feat(ft546): add isolated shadow process"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Enforce Two Physical Data Sources And Read-Only Production Permissions
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowSourceConfiguration.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerPermissionProbe.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/PermissionCheckResult.java`
|
|
- Modify: `ruoyi-wcs-shadow/src/main/resources/application-shadow.yml`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowDataSourceIsolationTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerPermissionProbeTest.java`
|
|
- Create: `sql/shadow/V001__ft546_shadow_read_views.sql`
|
|
- Create: `sql/shadow/V002__ft546_shadow_read_login.sql`
|
|
- Create: `sql/shadow/V003__ft546_shadow_evidence.sql`
|
|
- Create: `sql/shadow/verify_ft546_shadow_permissions.sql`
|
|
|
|
**Interfaces:**
|
|
- Beans: `@Qualifier("shadowSourceDataSource")`, `shadowSourceJdbc`, `shadowSourceTx`; `@Qualifier("shadowEvidenceDataSource")`, `shadowEvidenceJdbc`, `shadowEvidenceTx`.
|
|
- Produces: `SqlServerPermissionProbe.verifySource(Connection)` and `verifyEvidence(Connection)`.
|
|
- Production views: `shadow_ro.v_wcs_srgv_queue`, `v_wcs_srgv_queue_finish`, `v_device_srgv`, `v_wcs_srgv_map`, `v_bas_cargo_space`, `v_tsj_execution`, and `v_current_database_clock`.
|
|
- Evidence tables: `shadow_run`, `shadow_cycle`, `shadow_input_batch`, `shadow_legacy_queue`, `shadow_decision`, `shadow_encoded_write`, `shadow_difference`, `shadow_traffic_state`, `shadow_hold_history`, and `shadow_startup_check`.
|
|
|
|
- [ ] **Step 1: Write dual-source and permission-probe tests**
|
|
|
|
```java
|
|
@Test
|
|
void sourceAndEvidenceConnectionsCannotResolveToTheSameDatabase() {
|
|
StartupFailure failure = assertThrows(StartupFailure.class,
|
|
() -> gate.verify(sourceMeta("prod-host", "FT546-wcs", "shadow_ro"),
|
|
evidenceMeta("prod-host", "FT546-wcs", "shadow_evidence")));
|
|
assertEquals("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER",
|
|
failure.getReasonCode());
|
|
}
|
|
|
|
@Test
|
|
void sourceProbeRejectsAnyProductionWritePermission() {
|
|
PermissionCheckResult result = probe.verifySource(connectionWith(
|
|
"SELECT=1;INSERT=0;UPDATE=1;DELETE=0;CONTROL=0"));
|
|
|
|
assertFalse(result.isAccepted());
|
|
assertEquals("SOURCE_HAS_UPDATE_PERMISSION", result.getReasonCode());
|
|
}
|
|
```
|
|
|
|
The source connection test executes `SET TRANSACTION ISOLATION LEVEL READ COMMITTED`, calls `Connection.setReadOnly(true)`, and verifies permissions only through `HAS_PERMS_BY_NAME`, `fn_my_permissions`, database role membership, and JDBC metadata. The startup probe performs no DML of any kind, including temporary or probe-table writes.
|
|
|
|
- [ ] **Step 2: Run tests and verify current dynamic data-source path cannot satisfy them**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am -Dtest=ShadowDataSourceIsolationTest,SqlServerPermissionProbeTest test
|
|
```
|
|
|
|
Expected: missing explicit data-source beans/probe classes.
|
|
|
|
- [ ] **Step 3: Implement explicit pools and fail-closed startup checks**
|
|
|
|
```java
|
|
@Bean("shadowSourceDataSource")
|
|
DataSource source(ShadowProperties properties) {
|
|
HikariConfig config = base(properties.getSource());
|
|
config.setReadOnly(true);
|
|
config.setMaximumPoolSize(2);
|
|
config.setMinimumIdle(0);
|
|
config.setConnectionTimeout(2000L);
|
|
return new HikariDataSource(config);
|
|
}
|
|
|
|
@Bean("shadowSourceTx")
|
|
DataSourceTransactionManager sourceTx(
|
|
@Qualifier("shadowSourceDataSource") DataSource source) {
|
|
DataSourceTransactionManager manager = new DataSourceTransactionManager(source);
|
|
manager.setEnforceReadOnly(true);
|
|
return manager;
|
|
}
|
|
```
|
|
|
|
Do not modify `DataSourceType`, `DruidConfig`, or `DynamicDataSourceContextHolder`; the shadow process does not load them. `ShadowStartupGate` compares JDBC URL host/database and login names, persists every check to the evidence database, and keeps the scheduler disabled unless all checks pass.
|
|
|
|
- [ ] **Step 4: Add additive SQL objects and least-privilege verification**
|
|
|
|
`V001` creates only `shadow_ro` views selecting the exact existing columns needed from `wcs_srgv_queue`, `wcs_srgv_queue_finish`, `device_srgv`, `wcs_srgv_map`, `bas_cargo_space`, and `tsj_device_execution_queue`. `V002` creates a login/user template, denies write/control permissions, and grants `SELECT` only on `shadow_ro`; site credentials remain deployment secrets and are not committed. `V003` creates evidence tables in a separate database. The verify script returns one row per expected grant/deny and fails if the source user can write any approved base table.
|
|
|
|
- [ ] **Step 5: Run automated tests; run SQL verification only against an approved isolated SQL Server**
|
|
|
|
Run locally:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am -Dtest=ShadowDataSourceIsolationTest,SqlServerPermissionProbeTest test
|
|
```
|
|
|
|
Run in the approved SQL Server integration environment:
|
|
|
|
```powershell
|
|
sqlcmd -S $env:SHADOW_SQLSERVER -d FT546-wcs -E -b -i sql/shadow/verify_ft546_shadow_permissions.sql
|
|
```
|
|
|
|
Expected: local tests pass. SQL verification reports source `SELECT` on views, zero source business-table write permissions, and evidence write permission only in the distinct evidence database. If no isolated SQL Server is available, record this gate as `NOT_RUN`; do not claim site readiness.
|
|
|
|
- [ ] **Step 6: Commit data isolation and additive SQL**
|
|
|
|
```bash
|
|
git add ruoyi-wcs-shadow sql/shadow
|
|
git commit -m "feat(ft546): enforce shadow database isolation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Collect One Immutable, Bounded Production Fact Batch
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowVehicleFact.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowMapFact.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactValidation.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java`
|
|
|
|
**Interfaces:**
|
|
- Produces: `ShadowFactBatch SqlServerShadowFactReader.read(ShadowReadCursor cursor, Instant deadline)`.
|
|
- Produces: `ShadowFactValidation validate(ShadowFactBatch, Instant now)`.
|
|
- Produces: `LegacyQueueProtocolProjector.project(ShadowQueueFact) -> ShadowLegacyQueueResult` using captured legacy queue fields only; it does not call the shared new encoder.
|
|
- Cursor: `(lastCreateDate, lastGuid)` with a 5-second overlap; duplicate rows are retained as source evidence but deduplicated by `(tableName, guid, contentHash)` for kernel input.
|
|
- Hard limits: 500 queue rows, 100 vehicle rows, 10,000 map rows on startup refresh, 3-second JDBC query timeout, and 4-second whole-batch deadline.
|
|
|
|
- [ ] **Step 1: Write immutable batch, ordering, and stale-fact tests**
|
|
|
|
```java
|
|
@Test
|
|
void readsQueueVehicleLegacyResultAndDatabaseClockInOneReadOnlyTransaction() {
|
|
ShadowFactBatch batch = reader.read(cursor, deadline);
|
|
|
|
assertEquals(Arrays.asList("Q-1", "Q-2"), guids(batch.getQueues()));
|
|
assertEquals("070502", batch.vehicle("1").getCurrentSite());
|
|
assertEquals("4004,4000,4002,4021",
|
|
projector.project(batch.queue("Q-1")).getWriteAddresses());
|
|
assertTrue(batch.getDatabaseCapturedAt().isBefore(deadline));
|
|
}
|
|
|
|
@Test
|
|
void staleOrContradictoryFactsAreNotComparable() {
|
|
ShadowFactValidation validation = validator.validate(
|
|
batchWithVehicleAtTwoSites(), NOW);
|
|
|
|
assertEquals(INSUFFICIENT_FACTS, validation.getStatus());
|
|
assertEquals("CONTRADICTORY_VEHICLE_POSITION:1",
|
|
validation.getReasonCode());
|
|
}
|
|
```
|
|
|
|
Add tests for query timeout, row-limit overflow, non-monotonic database clock, missing map site, missing vehicle for legacy assignment, queue mutation during the overlap window, source outage backoff, ordinary/cross-floor/two-stage legacy projection, and `LEGACY_RESULT_UNAVAILABLE` when the captured queue lacks the fields needed to reconstruct old register values.
|
|
|
|
- [ ] **Step 2: Run fact tests and verify the reader/validator are missing**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am -Dtest=SqlServerShadowFactReaderTest,ShadowFactBatchValidatorTest test
|
|
```
|
|
|
|
Expected: missing fact types/readers.
|
|
|
|
- [ ] **Step 3: Implement bounded read-only SQL with deterministic ordering**
|
|
|
|
```sql
|
|
SELECT TOP (?) guid, task_no, task_type, priority, cargo_pick_site,
|
|
cargo_place_site, start_site, end_site, create_date, agv_id,
|
|
path_info, default02, default03, default04, default05, default06,
|
|
default07, default08
|
|
FROM shadow_ro.v_wcs_srgv_queue
|
|
WHERE create_date > ? OR (create_date = ? AND guid > ?)
|
|
ORDER BY create_date, guid;
|
|
```
|
|
|
|
Read all component queries inside `shadowSourceTx` with `readOnly=true`. Capture the database clock before and after; accept a batch only when the span is within the configured freshness window and every required object is present. `LegacyQueueProtocolProjector` independently reconstructs the old expected queue/PLC result from captured `agv_id/path_info/task_type/default*` fields and the frozen legacy contract; it must not invoke `Ft546SrgvCommandEncoder`. Keep all legacy result fields out of new-kernel candidate selection inputs. When legacy values are not reconstructable, persist `LEGACY_RESULT_UNAVAILABLE` and skip protocol equality rather than declaring a match.
|
|
|
|
- [ ] **Step 4: Add load-shedding and outage behavior**
|
|
|
|
One `AtomicBoolean` prevents overlapping reads. On timeout/row overflow, persist a failed cycle, discard the partial batch, and back off `1s -> 2s -> 4s -> 8s -> 30s`. A successful complete batch resets the backoff. The reader never retries inside the same cycle.
|
|
|
|
- [ ] **Step 5: Run focused tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am -Dtest=SqlServerShadowFactReaderTest,ShadowFactBatchValidatorTest test
|
|
```
|
|
|
|
Expected: deterministic ordering, immutable data, stale/contradictory rejection, query limits, and backoff pass.
|
|
|
|
- [ ] **Step 6: Commit the fact boundary**
|
|
|
|
```bash
|
|
git add ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts \
|
|
ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts
|
|
git commit -m "feat(ft546): collect bounded read-only shadow facts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Run The Shared Dispatch Kernel Without Command Outbox Or Side Effects
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java`
|
|
- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java`
|
|
- Create: `ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java`
|
|
- Modify: `ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java`
|
|
|
|
**Interfaces:**
|
|
- Produces: `ReadOnlyDispatchCycle.run(DispatchSnapshot) -> ShadowDispatchResult`.
|
|
- Produces: `ShadowDispatchSnapshotAssembler.assemble(ShadowFactBatch) -> DispatchSnapshot`.
|
|
- Produces: `ShadowCommandIntentFactory.create(GrantedDispatch) -> Ft546SrgvCommandIntent`.
|
|
- Produces: `RecordingFt546CommandGateway.record(Ft546SrgvCommandIntent) -> Ft546EncodedCommand`.
|
|
- Produces: `JdbcShadowTrafficStateRepository.commit(DispatchCommit)` in the evidence database with optimistic `snapshotVersion`; reservations, wait-for edges, and HOLD history survive process restart.
|
|
- Preserves: `WcsRuntimeCoordinator.tick()` production order and Outbox behavior unchanged.
|
|
|
|
- [ ] **Step 1: Write a same-kernel/no-Outbox test**
|
|
|
|
```java
|
|
@Test
|
|
void readOnlyCycleUsesDispatchCoordinatorButNeverCreatesOrSendsACommand() {
|
|
List<String> calls = new ArrayList<String>();
|
|
ReadOnlyDispatchCycle cycle = new ReadOnlyDispatchCycle(
|
|
snapshot -> {
|
|
calls.add("dispatch");
|
|
return committedGrant();
|
|
},
|
|
grant -> calls.add("intent:" + grant.getVehicleId()));
|
|
|
|
ShadowDispatchResult result = cycle.run(snapshot());
|
|
|
|
assertEquals(Arrays.asList("dispatch", "intent:1"), calls);
|
|
assertEquals(1, result.getIntents().size());
|
|
}
|
|
```
|
|
|
|
The integration test injects throwing fakes for every `DeviceCommandRepository`, `CommandOutboxRepository`, `DeviceCommandGateway`, and `WmsNotificationGateway`; a successful shadow cycle proves none is called.
|
|
|
|
- [ ] **Step 2: Run the tests and verify the read-only cycle does not exist**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs,ruoyi-wcs-shadow -am \
|
|
-Dtest=ReadOnlyDispatchCycleTest,Ft546ShadowCycleTest,WcsRuntimeCoordinatorTest test
|
|
```
|
|
|
|
Expected: missing cycle/assembler/recorder types.
|
|
|
|
- [ ] **Step 3: Extract the pure dispatch use without changing production tick**
|
|
|
|
```java
|
|
public ShadowDispatchResult run(DispatchSnapshot snapshot) {
|
|
DispatchCycleResult decisions = dispatcher.apply(snapshot);
|
|
List<Ft546SrgvCommandIntent> intents = new ArrayList<>();
|
|
for (GrantedDispatch grant : decisions.getGranted()) {
|
|
intents.add(intentFactory.apply(grant));
|
|
}
|
|
return new ShadowDispatchResult(decisions, intents);
|
|
}
|
|
```
|
|
|
|
`Ft546ShadowCycle` validates facts first. For valid facts it assembles the same `DispatchSnapshot`/`DispatchTask`/`VehicleSnapshot`/`EffectiveTopology` values used by `DispatchCoordinator`, invokes `ReadOnlyDispatchCycle`, encodes every intent through `Ft546SrgvCommandEncoder`, and sends all evidence to one evidence-database transaction. It does not instantiate `DeviceExecutionService`, `CommandOutboxDispatcher`, `WmsNotificationOutboxDispatcher`, or a production lease/fencing writer. `JdbcShadowTrafficStateRepository` stores the resulting ledger, wait-for graph, consecutive HOLD counters, and snapshot version only in the evidence database; restart reload tests must prove avoidance/HOLD state is not reset.
|
|
|
|
- [ ] **Step 4: Prove phase parameters include ordinary, cross-floor, and two-stage semantics**
|
|
|
|
Add fixture cases whose `GrantedDispatch.commandParameters` include:
|
|
|
|
```text
|
|
parentTaskId=PARENT-1
|
|
phase=LOADED_TO_PLACE
|
|
routeVersion=3
|
|
taskNo=30001
|
|
taskType=1|8
|
|
crossFloorTarget=8 (only when taskType=8)
|
|
stage=1|2 (only for the matching two-stage phase)
|
|
```
|
|
|
|
Assert the encoder output equals the legacy sequence fixture and the vehicle/path may differ without changing protocol classification.
|
|
|
|
- [ ] **Step 5: Run kernel, protocol, and shadow cycle tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs,ruoyi-ft546-protocol,ruoyi-wcs-shadow -am \
|
|
-Dtest=ReadOnlyDispatchCycleTest,WcsRuntimeCoordinatorTest,Ft546SrgvCommandEncoderTest,Ft546ShadowCycleTest test
|
|
```
|
|
|
|
Expected: shared dispatch policy and encoder run; all side-effect fakes remain uncalled; invalid facts produce no intent.
|
|
|
|
- [ ] **Step 6: Commit the same-kernel shadow cycle**
|
|
|
|
```bash
|
|
git add ruoyi-wcs ruoyi-wcs-shadow
|
|
git commit -m "feat(ft546): run shared dispatch kernel in shadow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Persist Auditable Differences And Prove Idempotent Collection
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java`
|
|
- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java`
|
|
- Modify: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java`
|
|
- Modify: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java`
|
|
|
|
**Interfaces:**
|
|
- Categories: `MATCH`, `PROTOCOL_ADDRESS`, `PROTOCOL_DATA_TYPE`, `PROTOCOL_VALUE`, `PROTOCOL_ORDER`, `QUEUE_FIELD`, `ALGORITHM_VEHICLE`, `ALGORITHM_ROUTE`, `ALGORITHM_HOLD`, `INSUFFICIENT_FACTS`, and `SOURCE_GAP`.
|
|
- Idempotency: unique `(run_id, input_batch_hash, parent_task_id, phase, route_version)`.
|
|
- Produces: `ShadowComparisonService.compare(ShadowLegacyQueueResult, ShadowDecision, Ft546EncodedCommand)`.
|
|
- Produces: daily digest counts and reproducible evidence hash.
|
|
|
|
- [ ] **Step 1: Write classification and idempotency tests**
|
|
|
|
```java
|
|
@Test
|
|
void differentVehicleWithMatchingWritesIsAlgorithmOnly() {
|
|
List<ShadowDifference> differences = comparison.compare(
|
|
legacy("1", path(70501, 70502), mainWrites()),
|
|
decision("2", path(70501, 70502)), encoded(mainWrites()));
|
|
|
|
assertEquals(singletonList(ALGORITHM_VEHICLE), categories(differences));
|
|
}
|
|
|
|
@Test
|
|
void changedWriteOrderIsProtocolOrderEvenWhenValuesMatch() {
|
|
assertEquals(PROTOCOL_ORDER, singleDifference(
|
|
legacy(mainWrites()), encoded(swappedTaskNoAndType())).getCategory());
|
|
}
|
|
|
|
@Test
|
|
void replayingTheSameBatchDoesNotDuplicateEvidence() {
|
|
repository.save(cycle);
|
|
repository.save(cycle);
|
|
|
|
assertEquals(1, repository.countCycles());
|
|
assertEquals(cycle.getEvidenceHash(), repository.findCycle(
|
|
cycle.getRunId(), cycle.getInputBatchHash(),
|
|
cycle.getParentTaskId(), cycle.getPhase(),
|
|
cycle.getRouteVersion()).getEvidenceHash());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run evidence tests and verify repository/comparison types are missing**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am \
|
|
-Dtest=ShadowComparisonServiceTest,JdbcShadowEvidenceRepositoryTest test
|
|
```
|
|
|
|
Expected: missing evidence types.
|
|
|
|
- [ ] **Step 3: Implement one evidence transaction per complete cycle**
|
|
|
|
Persist immutable JSON plus indexed columns. Hash canonical UTF-8 JSON for the input batch, legacy result, decision, encoded writes, and differences. If the unique key already exists, verify the stored hash matches and return the existing cycle; a hash mismatch is `EVIDENCE_KEY_COLLISION` and stops collection. Never partially commit a cycle.
|
|
|
|
- [ ] **Step 4: Add storage-pressure fail-safe**
|
|
|
|
Before every cycle check configured minimum free bytes and evidence database health. When the threshold is crossed, change status to `DEGRADED:EVIDENCE_STORAGE_UNAVAILABLE`, stop new reads/calculations, retain the last complete evidence, and keep the stable WCS unaffected. Do not discard differences and continue reporting healthy.
|
|
|
|
- [ ] **Step 5: Run comparison, repository, and cycle tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-wcs-shadow -am \
|
|
-Dtest=ShadowComparisonServiceTest,JdbcShadowEvidenceRepositoryTest,Ft546ShadowCycleTest test
|
|
```
|
|
|
|
Expected: exact classification, idempotent replay, hash verification, atomic commit, and disk/db fail-safe pass.
|
|
|
|
- [ ] **Step 6: Commit the evidence chain**
|
|
|
|
```bash
|
|
git add ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence \
|
|
ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence \
|
|
ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel \
|
|
ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web
|
|
git commit -m "feat(ft546): persist shadow comparison evidence"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Verify End-To-End Shadow Operation And Prepare Immediate Stable Rollback
|
|
|
|
**Files:**
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java`
|
|
- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/ordinary-cycle.json`
|
|
- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/cross-floor-cycle.json`
|
|
- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/two-stage-cycle.json`
|
|
- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/hold-avoidance-cycle.json`
|
|
- Create: `ruoyi-wcs-shadow/src/test/resources/application-shadow-local.yml`
|
|
- Create: `tools/shadow/Start-Ft546Shadow.ps1`
|
|
- Create: `tools/shadow/Stop-Ft546Shadow.ps1`
|
|
- Create: `tools/shadow/Test-Ft546ShadowPreflight.ps1`
|
|
- Create: `tools/shadow/Export-Ft546ShadowEvidence.ps1`
|
|
- Create: `tools/shadow/Capture-StableWcsManifest.ps1`
|
|
- Create: `docs/runbooks/ft546-realtime-shadow-runbook.md`
|
|
- Modify: `README.md`
|
|
|
|
**Interfaces:**
|
|
- Preflight output: stable PID/command line/artifact SHA-256/config SHA-256/listening ports; shadow artifact/config SHA-256; source/evidence JDBC identities; permission checks; PLC/WMS network-deny checks; output free space.
|
|
- Start command refuses to run without `-ConfirmReadOnlyShadow`, a successful preflight JSON, and a distinct shadow deployment directory.
|
|
- Stop command stops only the recorded shadow PID and never finds/kills a process by broad Java name.
|
|
- Evidence export emits `manifest.json`, `manifest.sha256`, cycle CSV/JSON, protocol-difference CSV, algorithm-difference CSV, insufficient-facts CSV, and startup checks.
|
|
|
|
- [ ] **Step 1: Write end-to-end and fault-isolation tests first**
|
|
|
|
```java
|
|
@Test
|
|
void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence() {
|
|
ShadowRunResult result = harness.run(fixtures());
|
|
|
|
assertEquals(3, result.getComparableCycles());
|
|
assertWrites(result.forTask("ORDINARY"), "4004", "4000",
|
|
"4002", "4021");
|
|
assertWrites(result.forTask("CROSS_FLOOR"), "4004", "4000",
|
|
"4002", "4021", "4020", "4022");
|
|
assertWrites(result.forTask("STAGE_2"), "4404", "4422",
|
|
"4423", "4402", "4400");
|
|
assertEquals("HOLD", result.forTask("AVOIDANCE").getDecision());
|
|
assertTrue(result.forTask("AVOIDANCE").getEncodedWrites().isEmpty());
|
|
assertEquals(0, result.getSideEffectCalls());
|
|
}
|
|
|
|
@Test
|
|
void sourceOutageEvidenceFailureAndShadowCrashNeverAffectStableProbe() {
|
|
faultHarness.inject(SOURCE_TIMEOUT, EVIDENCE_DISK_FULL, SHADOW_CRASH);
|
|
|
|
assertTrue(stableProbe.isContinuouslyHealthy());
|
|
assertEquals(0, sideEffectProbe.totalCalls());
|
|
}
|
|
```
|
|
|
|
Add a package/socket test proving the shadow process has no configured PLC IP/port or WMS URL, a process test proving stop/restart changes only the shadow PID, and a restart test proving persisted wait-for edges/consecutive HOLD counters are reloaded so the third identical HOLD follows the configured reroute rule instead of resetting to the first HOLD.
|
|
|
|
- [ ] **Step 2: Run end-to-end tests and verify scripts/runbook are missing**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl ruoyi-ft546-protocol,ruoyi-wcs,ruoyi-wcs-shadow,ruoyi-admin -am \
|
|
-Dtest=Ft546ShadowEndToEndTest,Ft546ShadowFaultIsolationTest,Ft546StandardRuntimeTest,Ft546LegacySrgvAdapterTest test
|
|
```
|
|
|
|
Expected: missing fixtures/harness or end-to-end program.
|
|
|
|
- [ ] **Step 3: Implement safe operational scripts**
|
|
|
|
`Capture-StableWcsManifest.ps1` records the actual deployed stable artifact path, SHA-256, command line, configuration paths/hashes, service name/PID, and listening ports; it does not assume `origin/main` is the deployed version. `Test-Ft546ShadowPreflight.ps1` verifies the shadow source login, separate evidence database, permissions, distinct PID/ports/directories, loopback status endpoint, PLC/WMS network denial, disk threshold, and stable health before start. `Start-Ft546Shadow.ps1` starts only the shadow jar with a PID file in its own directory. `Stop-Ft546Shadow.ps1` resolves and validates that exact PID and executable path before stopping it.
|
|
|
|
- [ ] **Step 4: Write the site runbook with explicit stop conditions**
|
|
|
|
The runbook sequence is:
|
|
|
|
```text
|
|
capture stable manifest and verify stable health
|
|
-> create/read-only source login and separate evidence database
|
|
-> deny shadow host PLC/WMS egress at network layer
|
|
-> run preflight and archive JSON
|
|
-> start shadow only
|
|
-> verify stable PID/health/PLC writer unchanged
|
|
-> collect 15 minutes at low polling rate
|
|
-> inspect source query latency and evidence growth
|
|
-> extend collection window only when stable metrics are unchanged
|
|
-> stop shadow immediately on any stable degradation
|
|
-> export evidence and verify manifest hashes
|
|
```
|
|
|
|
Immediate stop conditions include production DB latency increase above the agreed threshold, permission-probe change, evidence disk threshold, source lag beyond freshness, any PLC/WMS connection attempt, any production-table write audit, shadow CPU/memory limit breach, or stable WCS health change. Stopping/removing the shadow service is the rollback for this phase; the stable WCS is never stopped or switched.
|
|
|
|
- [ ] **Step 5: Run all automated verification from a clean build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn clean test
|
|
```
|
|
|
|
Expected: all modules pass with zero failures/errors; record exact test count and duration.
|
|
|
|
- [ ] **Step 6: Run local process smoke without production endpoints**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
.\tools\shadow\Test-Ft546ShadowPreflight.ps1 `
|
|
-Config .\ruoyi-wcs-shadow\src\test\resources\application-shadow-local.yml
|
|
.\tools\shadow\Start-Ft546Shadow.ps1 `
|
|
-ConfirmReadOnlyShadow `
|
|
-PreflightJson .\outputs\shadow-preflight-local.json
|
|
Invoke-RestMethod http://127.0.0.1:18091/shadow/status
|
|
.\tools\shadow\Stop-Ft546Shadow.ps1
|
|
```
|
|
|
|
Expected: local H2/test-view mode reaches `READY`, generates evidence, makes zero external calls, and stops only its PID.
|
|
|
|
- [ ] **Step 7: Run SQL Server/HIL/site gates only in approved environments**
|
|
|
|
Required evidence before site collection:
|
|
|
|
- SQL Server integration: exact transaction/permission/query-timeout behavior passes against an isolated SQL Server.
|
|
- Network: shadow host cannot reach PLC write endpoints or production WMS callback endpoints.
|
|
- Site preflight: stable manifest captured, stable process healthy, source login read-only, evidence database distinct, and shadow directories/ports distinct.
|
|
- Site observation: stable process metrics and PLC ownership stay unchanged while shadow is started/stopped repeatedly.
|
|
|
|
HIL remains a later gate for real PLC timing and partial-write behavior; this shadow program does not connect to HIL or production PLC. If any gate is missing, the conclusion is limited to local or SQL integration readiness.
|
|
|
|
- [ ] **Step 8: Run repository checks and request review**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git diff --check
|
|
git status --short
|
|
git log --oneline -15
|
|
```
|
|
|
|
Reviewers receive the confirmed design, this plan, base `48b0b154b6319de05df3b71b4457d12110bdb095`, stable tracking reference `652607ce15653f12a682248ad3f335861ebd9e3f`, final HEAD, Maven evidence, preflight output, SQL permission output, process start/stop evidence, and exported manifest. Resolve all Critical/Important findings and rerun focused plus full tests.
|
|
|
|
- [ ] **Step 9: Commit the runbook and acceptance harness**
|
|
|
|
```bash
|
|
git add ruoyi-wcs-shadow/src/test tools/shadow \
|
|
docs/runbooks/ft546-realtime-shadow-runbook.md README.md
|
|
git commit -m "test(ft546): verify read-only shadow operation"
|
|
```
|
|
|
|
---
|
|
|
|
## End-To-End Acceptance Matrix
|
|
|
|
| Layer | Required proof | Permitted conclusion |
|
|
| --- | --- | --- |
|
|
| Unit/domain | Runtime guards, fact validation, same-kernel decision, pure PLC encoding, difference classification | Local implementation behavior only |
|
|
| Spring context | No side-effect beans/types, separate data sources, fail-closed startup | Process wiring is isolated |
|
|
| Local E2E | Ordinary/cross-floor/two-stage reach virtual PLC evidence; zero side-effect calls | Local shadow flow works |
|
|
| SQL Server integration | Read-only permissions, transactions, timeouts, separate evidence writes, restart/idempotency | Database integration works |
|
|
| Site preflight | Stable artifact/PID/config captured; network deny; accounts/ports/directories distinct | Safe to start collection window |
|
|
| Live shadow | Stable health unchanged, source query load bounded, evidence continuous, zero production side effects | Shadow observations are valid |
|
|
| HIL | Real isolated PLC register sequence, disconnect, partial write, restart | Protocol adapter hardware gate only |
|
|
| Cutover drill | Single-writer lease/fencing, recovery, reconciliation, rollback | Future `STANDARD` cutover gate; outside this plan |
|
|
|
|
## Plan Self-Review Checklist
|
|
|
|
- Design coverage: mode isolation (Task 1), pure PLC intent (Task 2), independent launcher (Task 3), two-data-source permission gate (Task 4), read-only facts (Task 5), same-kernel cycle (Task 6), differences/evidence (Task 7), E2E/site protection (Task 8).
|
|
- Stable WCS protection is independent at code, process, database, network, operations, and test layers; one configuration mistake is not the sole barrier.
|
|
- `LEGACY` remains default and unchanged; no task authorizes production `STANDARD`.
|
|
- `SHADOW` never uses current PLC readers as its source because those readers can connect/write and mutate production device tables.
|
|
- The real adapter and shadow recorder share one pure encoder, so address/type/value/order comparisons do not rely on duplicated protocol code.
|
|
- Production source and evidence data sources are explicit and physically distinct; no ThreadLocal routing or transaction-time switching is used.
|
|
- Every partial/invalid fact path closes as `INSUFFICIENT_FACTS`; no fabricated comparison is emitted.
|
|
- Protocol and algorithm differences have distinct enums and tests.
|
|
- Ordinary, cross-floor, two-stage, HOLD/avoidance inputs, source outage, evidence failure, restart, and stable-process isolation all have named automated tests.
|
|
- SQL Server, site, network, and HIL evidence boundaries are explicit; local tests cannot be reported as production acceptance.
|
|
- Every task contains concrete files, interfaces, red/green commands, expected results, and explicit failure behavior; no deferred implementation placeholder remains.
|