fix(shadow): respect queue phases and compress input evidence

This commit is contained in:
zgw
2026-09-08 11:23:50 +08:00
parent 414b3bf4a9
commit af8b5f1830
40 changed files with 1035 additions and 46 deletions

View File

@@ -0,0 +1,58 @@
# 2026-09-08 影子阶段与存储修复
适用:现场源码基线 `414b3bf`,分支 `optimize/wcs`,目录 `C:\project\FT546-WCS-SHANDOW`,Windows CMD + IDEA + Java 8 + Maven 3.6.0。
本次更新仅用于继续影子测试。正式调度、PLC 通讯和生产数据库写入路径不变。本次没有现场 SQL 迁移、权限调整、清理历史数据、扩大容量上限或远程进程操作。
## 修复后的行为
1. 从现有只读视图增加采集 `issued`、`execution_status`。只有明确的 `0 / 1` 待下发阶段可产生新预测。已下发、已完成、生命周期不明、已到终点及无移动阶段记录为 `OBSERVATION_ONLY`,不产生虚拟寄存器写入,也不计入算法不一致。
2. 保留队列已绑定的车辆;忙碌、禁止发送、低电量和故障门槛不放宽。绑定阶段按正式程序的发送规则,从车辆当前位置到当前终点预测,不再回到旧队列起点折返。跨楼层及两阶段交接当前只观察,仍需专门的状态机证据才能评价其新调度行为。
3. 旧结果还原遵循 PLC 发送规则:`tsj` 的 `default02=1/2` 终点为 `09050<楼层>`,`8` 为 `07050<楼层>`。路径可在提升机入口结束,而实际发送端点会被改写,不能直接用路径末点当作发送末点。与本阶段终点不符的旧路径,以及待下发阶段起点已漂移的路径,记录为旧结果不可用。
4. 新输入采用无损 gzip;同一次证据保存中的相同输入只存一份压缩正文,其余记录引用本批首条输入。原始 JSON 的哈希保持不变。使用现有表、同一事务,不增加生产访问权限。
5. 安全等待/避让点只能通过 `shadow.traffic.safe-wait-sites`、`shadow.traffic.safe-yield-sites` 显式配置。默认空列表,状态显示 `UNCONFIGURED`。配置点必须是可通行数字站点且至少有两个相邻出口;这个校验不能代替现场人员确认物理安全。未取得现场确认前保持空列表。
6. `/shadow/status` 和运行心跳新增 `runtimeIdentity`。本次 `buildId` 为 `ft546-shadow-20260908-phase-storage-v2`。`effectiveSettingsSha256` 自动计算,覆盖返回的非秘密调度、存储、端口及安全点配置;不包含数据库连接凭据、外部配置文件或已编译类的完整指纹。
## 现场更新顺序
与操作人员逐步进行,每一步确认结果后再进入下一步。只停止 `Ft546ShadowApplication`;不要停止 `RuoYiApplication`,不要使用 Stop All,也不要批量结束 Java 进程。
1. 在 IDEA 中选中影子程序 `Ft546ShadowApplication` 的运行窗口,点红色停止按钮,确认这个影子进程退出。
2. CMD 进入 `C:\project\FT546-WCS-SHANDOW`,先执行 `git status --short` 和 `git fetch origin optimize/wcs`。保留现场配置、忽略文件和 `shadow-evidence`。若有与本次源码重叠的本地修改,先备份并核对;不要 reset、clean 或强制覆盖。
3. 使用交付消息指定的准确提交执行 `git merge --ff-only <提交号>`,再执行 `git log -1 --oneline` 核对。不要无条件更新到将来的其他提交。
4. IDEA 刷新 Maven,保持原有 Java 8 和 Maven 3.6.0,编译 `ruoyi-wcs-shadow` 及其依赖。沿用此前已验证的离线依赖,不需要部署脚本或 JAR 包。
5. 核对影子运行配置:主类仍为 `com.ruoyi.shadow.Ft546ShadowApplication`;数据库账号、生产只读库、证据库、18091 本机端口和存储上限沿用现场设置。新增安全点列表保持空,直到现场确认。
6. 修正运行元数据后,仅启动这个影子配置。浏览器访问 `http://127.0.0.1:18091/shadow/status`。核对 `READY`、全部启动检查 PASS、上述 buildId,以及 `cycleDiagnostics.dispatch` 内各项计数。只观察时 `eligiblePhases=0` 可以是正常状态,不代表算法完成验证。
## IDEA 中两个人工哈希字段
历史运行的元数据不修改。审计发现此前配置哈希填入了源码归档哈希;新运行不要照抄或互换这两个值。
- `shadow.evidence.run.artifact-sha256`:记录本次交付物的 SHA-256。源码运行时,可以使用当前准确提交的源码归档哈希,并在现场记录中注明 `source archive`,不能称为运行中 JAR 或编译类的校验值。
- `shadow.evidence.run.configuration-sha256`:记录本次现场运行配置快照文件的 SHA-256。配置快照保留在现场受控目录,不提交到 Git,不发送含密码的文件。
- 在 CMD 中可以使用 `certutil -hashfile "文件完整路径" SHA256` 计算实际文件的哈希。若用源码归档,可先运行 `git archive --format=tar -o "%TEMP%\ft546-shadow-source.tar" HEAD`,再对这个文件运行 certutil。归档仅用于现场计算元数据,不是额外的部署包。
- 状态中的 `operatorArtifactSha256` / `operatorConfigurationSha256` 是上述人工输入,程序没有将其认证为实际运行产物。`buildId` 是代码内版本标签;`effectiveSettingsSha256` 是程序自动计算的配置摘要,两者与人工哈希分开显示。
## 证据导出兼容性
`shadow_input_batch.captured_json` 现在允许三种形式:旧版原始 JSON、`ft546-input-gzip-v1` 压缩信封、`ft546-input-ref-v1` 同批引用。`content_hash` 始终校验原始 JSON,不能对压缩信封直接算哈希后判定损坏。
推荐从生产服务器本机浏览器或可访问本机 HTTP 的 AI 工具读取:
```text
http://127.0.0.1:18091/shadow/inputs/<cycle_id>
```
每次返回一条输入:`cycleId`、`runId`、`contentHash`、`hashVerified=true`、`capturedJson`(原始 JSON 字符串)。接口只查询证据库;拒绝非本机连接、缺失/跨运行/循环/多层引用、损坏哈希和超限解压。单份原始输入最多 8 MiB。
如果 AI 只能查询 SQL,应同时导出引用记录和所指向的根记录,保留原样交给分析程序解码。仅按尾部时间窗口导出可能遗漏根记录。不要再假定 `captured_json` 始终直接包含 `maps/queues/vehicles`。
同批根输入与引用在同一事务中写入,任一子表失败整批回滚。压缩格式不影响原始输入哈希、决定哈希和逻辑证据哈希。旧程序回退运行仍可新增旧格式输入;分析工具必须继续理解新旧两种物理存储形式。不要逐条删除某个根输入而保留引用。
## 验证与剩余边界
- 最终本地验证命令:`mvn -o -pl ruoyi-wcs-shadow -am test -Dshadow.replay.audit-directory=<本地审计导出目录>`。覆盖 WCS 核心、FT546 协议和影子模块;现场样本留在 Git 之外。旧 300 条归档已不在原附件路径,本次使用仍可读取的 2026-09-08 审计导出回放,未编造或补填其生命周期。
- 新增回归覆盖生命周期隔离、已到终点、绑定车辆不可重分配、忙碌车辆保护、提升机端点改写、旧阶段路径、显式安全点、压缩还原、篡改/解压上限/引用校验和事务回滚。
- 2026-09-08 审计导出的 25 条样本包含 17 份不同输入,离线回放保持 JSON 与哈希原样。输入字段按 SQL Server nvarchar 字节估算,从 3,294,254 字节降至 604,048 字节(18.34%);此数字不包括其他表、索引、页开销、事务日志,也不代表数据库文件自动缩小。
- 当日最后一次容量检查为 14.39 GiB / 16 GiB。压缩只降低后续增长;旧空间不释放,容量上限不变。再次达到保护上限仍会停止影子采集,需要另行评估归档方案,不能只调大阈值。
- `READY` 只说明影子采集链路健康。正式版接管前还需本次新运行的有效待下发样本、路线与协议差异、拥堵/避让及特殊交接阶段证据;本修复不构成正式版上线批准。

View File

@@ -0,0 +1,27 @@
# FT546 shadow phase and evidence repair
The user approved repairing the five findings in the 2026-09-08 field assessment. Execute in the existing `optimize/wcs` worktree. Subagents inspect/review only; the main agent implements and verifies.
## Constraints and design
- Preserve Java 8, Maven 3.6.0, offline dependencies, the formal Admin/PLC/WMS runtime, existing evidence and SQL permissions. No live migration, deletion, threshold increase or process operation.
- Read queue `issued` and `execution_status` from the existing source views. Only explicit pending `0/1` rows may produce new predictions; executing, completed, unknown and already-arrived phases produce observation evidence. Preserve an existing vehicle binding with a shadow-only assignment adapter. Do not relax busy-vehicle checks.
- Reconstruct legacy PLC endpoints using the actual sending rules, including lift endpoint rewriting and stale path/phase rejection. Observation rows are separate from algorithm differences.
- Store new input JSON losslessly as a versioned gzip envelope; share the first stored input within a save batch through a bounded cycle reference. Preserve original logical hashes. Keep old raw JSON readable. All input rows/references remain in existing tables, in the existing transaction. A loopback input export endpoint returns original JSON with its verified hash, so readers do not need to understand physical storage.
- Configure explicitly approved safe wait/yield site IDs; validate topology membership and safe-exit constraints. Empty configuration remains protected and produces an explicit diagnostic. Never infer physical safety from ordinary passability.
- Publish an embedded repair build ID and a computed digest of nonsecret effective runtime settings independently of operator-supplied artifact/config hashes. Record the required IDEA field correction in the upgrade notes; do not rewrite historical runs.
## Execution checklist
- [x] Baseline `mvn -o -pl ruoyi-wcs-shadow -am test` passes; keep the baseline log outside the worktree.
- [x] Add failing lifecycle and legacy projection tests, including already-issued, completed-at-destination, pending bound vehicle and lift `tsj` cases. Add fields to `ShadowQueueFact`/`SqlServerShadowFactReader`, a shadow eligibility classifier/assignment adapter, and observation support in evidence collection/comparison. Preserve old fixture hashes when new fields are absent; absent lifecycle is observation-only for dispatch.
- [x] Add failing storage round-trip, tamper, inflation-limit, reference, rollback and old-format tests. Implement `ShadowInputPayloadCodec`, repository input resolution and a bounded loopback input endpoint. Budget physical inserted payload while enforcing logical input bounds.
- [x] Add configured-safe-point and invalid-point tests. Wire the settings into `ShadowDispatchSnapshotAssembler` and expose blocked-resource/safe-point diagnostics without enabling unconfigured avoidance.
- [x] Add independent runtime build/config identity tests and wire identity into the status/heartbeat. Document source-only deployment fields and physical input export format.
- [x] Run focused tests after each change, then the full reactor and historical/new captured-input replay. Validate real field inputs remain immutable and incomplete old lifecycle cannot authorize dispatch.
- [x] Run independent read-only review of phase safety and evidence compatibility. Resolve findings, update the runbook and confirm no formal runtime source changes.
- Delivery after this plan snapshot: commit/push the scoped shadow repair under the existing authorization for `optimize/wcs`; record the exact revision in the handoff. Do not deploy remotely.
Implementation decisions: `BaseSRgvThread` passes the live vehicle site to the sender, so bound phase predictions now start there directly. Cross-floor and two-stage handoffs remain observation-only pending state-machine evidence. Reviewer suggestion to reject all lift-entry paths was not adopted: the formal sender explicitly rewrites the endpoint after path construction. Final review identified IPv4-mapped loopback compatibility; a failing regression reproduced it and numeric `InetAddress.isLoopbackAddress` parsing fixes it without DNS or forwarded-header trust.
Private field replay: 25 rows / 17 unique raw inputs preserve their exact JSON and input hashes. New gzip root payloads total 604,048 nvarchar bytes versus 3,294,254 raw bytes (18.34%). This is input payload only, not total SQL allocation. The earlier 300-row archive is no longer at its attachment path; current verification uses the available 2026-09-08 audit exports plus explicit pending lifecycle fixtures.

View File

@@ -59,8 +59,8 @@ public class ShadowKernelConfiguration {
@Bean @Bean
public VehicleAssignmentPolicy shadowVehicleAssignmentPolicy( public VehicleAssignmentPolicy shadowVehicleAssignmentPolicy(
RoutePlanner routePlanner) { RoutePlanner routePlanner) {
return new DefaultVehicleAssignmentPolicy(routePlanner, return new com.ruoyi.shadow.kernel.ShadowVehicleAssignmentPolicy(
DispatchPolicy.NONE); new DefaultVehicleAssignmentPolicy(routePlanner, DispatchPolicy.NONE));
} }
@Bean @Bean
@@ -112,10 +112,11 @@ public class ShadowKernelConfiguration {
@Bean @Bean
public ShadowDispatchSnapshotAssembler shadowDispatchSnapshotAssembler( public ShadowDispatchSnapshotAssembler shadowDispatchSnapshotAssembler(
JdbcShadowTrafficStateRepository trafficStateRepository, JdbcShadowTrafficStateRepository trafficStateRepository,
@Qualifier("shadowClock") Clock clock) { @Qualifier("shadowClock") Clock clock, ShadowProperties properties) {
return new ShadowDispatchSnapshotAssembler("FT546", return new ShadowDispatchSnapshotAssembler("FT546",
SHADOW_ROUTE_VERSION, SHADOW_ROUTE_VERSION,
trafficStateRepository::load, clock); trafficStateRepository::load, clock, properties.getTraffic().getSafeWaitSites(),
properties.getTraffic().getSafeYieldSites());
} }
@Bean @Bean

View File

@@ -7,11 +7,22 @@ public class ShadowProperties {
private boolean enabled; private boolean enabled;
private final JdbcEndpoint source = new JdbcEndpoint(); private final JdbcEndpoint source = new JdbcEndpoint();
private final EvidenceEndpoint evidence = new EvidenceEndpoint(); private final EvidenceEndpoint evidence = new EvidenceEndpoint();
private final Traffic traffic = new Traffic();
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; }
public JdbcEndpoint getSource() { return source; } public JdbcEndpoint getSource() { return source; }
public EvidenceEndpoint getEvidence() { return evidence; } public EvidenceEndpoint getEvidence() { return evidence; }
public Traffic getTraffic() { return traffic; }
public static final class Traffic {
private java.util.Set<String> safeWaitSites = new java.util.LinkedHashSet<String>();
private java.util.Set<String> safeYieldSites = new java.util.LinkedHashSet<String>();
public java.util.Set<String> getSafeWaitSites() { return safeWaitSites; }
public void setSafeWaitSites(java.util.Set<String> values) { safeWaitSites = values; }
public java.util.Set<String> getSafeYieldSites() { return safeYieldSites; }
public void setSafeYieldSites(java.util.Set<String> values) { safeYieldSites = values; }
}
public static class JdbcEndpoint { public static class JdbcEndpoint {
private String jdbcUrl; private String jdbcUrl;

View File

@@ -49,7 +49,15 @@ public class ShadowRuntimeConfiguration {
ShadowStartupGate gate, ApplicationContext context, ShadowStartupGate gate, ApplicationContext context,
Environment environment, ShadowProperties properties, Environment environment, ShadowProperties properties,
ShadowDatabaseGate databaseGate) { ShadowDatabaseGate databaseGate) {
return gate.verify(context, environment, properties, databaseGate); ShadowStartupReport report = gate.verify(context, environment, properties, databaseGate);
report.recordRuntimeIdentity(com.ruoyi.shadow.startup.ShadowRuntimeIdentity.describe(properties, environment));
return report;
}
@Bean
public com.ruoyi.shadow.web.ShadowInputController shadowInputController(
com.ruoyi.shadow.evidence.JdbcShadowEvidenceRepository repository) {
return new com.ruoyi.shadow.web.ShadowInputController(repository);
} }
@Bean @Bean

View File

@@ -30,6 +30,7 @@ public final class JdbcShadowEvidenceRepository
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ShadowRunEvidence run; private final ShadowRunEvidence run;
private final long maximumBatchPayloadBytes; private final long maximumBatchPayloadBytes;
private final ShadowInputPayloadCodec inputCodec;
public JdbcShadowEvidenceRepository(JdbcTemplate jdbc, public JdbcShadowEvidenceRepository(JdbcTemplate jdbc,
TransactionTemplate transaction, TransactionTemplate transaction,
@@ -50,6 +51,7 @@ public final class JdbcShadowEvidenceRepository
throw new IllegalArgumentException("maximumBatchPayloadBytes must be positive"); throw new IllegalArgumentException("maximumBatchPayloadBytes must be positive");
} }
this.maximumBatchPayloadBytes = maximumBatchPayloadBytes; this.maximumBatchPayloadBytes = maximumBatchPayloadBytes;
this.inputCodec = new ShadowInputPayloadCodec(objectMapper);
} }
@Override @Override
@@ -113,6 +115,55 @@ public final class JdbcShadowEvidenceRepository
return false; return false;
} }
/** One bounded, verified input export; old raw JSON and the new physical format are supported. */
public Map<String, Object> loadInput(UUID cycleId) {
Objects.requireNonNull(cycleId, "cycleId");
InputRow row = inputRow(cycleId);
UUID rootId = inputCodec.referencedCycle(row.payload, row.hash);
InputRow root = row;
if (rootId != null) {
if (cycleId.equals(rootId)) throw new ShadowEvidenceException("INPUT_REFERENCE_CYCLE");
root = inputRow(rootId);
if (!row.runId.equals(root.runId) || !row.hash.equals(root.hash))
throw new ShadowEvidenceException("INPUT_REFERENCE_SCOPE_MISMATCH");
if (inputCodec.referencedCycle(root.payload, root.hash) != null)
throw new ShadowEvidenceException("INPUT_REFERENCE_DEPTH_LIMIT");
}
String raw = inputCodec.decode(root.payload, row.hash);
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("cycleId", cycleId.toString());
result.put("runId", row.runId.toString());
result.put("contentHash", row.hash);
result.put("hashVerified", true);
result.put("capturedJson", raw);
return Collections.unmodifiableMap(result);
}
private InputRow inputRow(UUID cycleId) {
List<InputRow> rows = jdbc.query("SELECT c.run_id,c.input_batch_hash,i.content_hash,"
+ "CASE WHEN LEN(i.captured_json)<=? THEN i.captured_json ELSE NULL END AS payload "
+ "FROM shadow_input_batch i JOIN shadow_cycle c ON c.cycle_id=i.cycle_id WHERE i.cycle_id=?",
(rs, n) -> {
String hash = rs.getString("content_hash").trim();
if (!hash.equals(rs.getString("input_batch_hash").trim()))
throw new ShadowEvidenceException("INPUT_HASH_MISMATCH");
String payload = rs.getString("payload");
if (payload == null) throw new ShadowEvidenceException("INPUT_STORAGE_SIZE_LIMIT");
return new InputRow(uuid(rs.getObject("run_id")), hash, payload);
}, ShadowInputPayloadCodec.MAX_STORED_CHARS, cycleId);
if (rows.size() != 1) throw new ShadowEvidenceException("INPUT_NOT_FOUND");
return rows.get(0);
}
private static final class InputRow {
final UUID runId;
final String hash;
final String payload;
InputRow(UUID runId, String hash, String payload) {
this.runId = runId; this.hash = hash; this.payload = payload;
}
}
@Override @Override
public <T> T inTransaction(Supplier<T> work) { public <T> T inTransaction(Supplier<T> work) {
Objects.requireNonNull(work, "work"); Objects.requireNonNull(work, "work");
@@ -250,9 +301,10 @@ public final class JdbcShadowEvidenceRepository
result.add(verifyReplay(existing, item)); result.add(verifyReplay(existing, item));
} }
} }
verifyPayloadBudget(inserts); Map<UUID, String> inputPayloads = prepareInputPayloads(inserts);
verifyPayloadBudget(inserts, inputPayloads);
for (ShadowCycleEvidence item : inserts) { for (ShadowCycleEvidence item : inserts) {
insert(item); insert(item, inputPayloads.get(item.getCycleId()));
} }
for (int index = 0; index < evidence.size(); index++) { for (int index = 0; index < evidence.size(); index++) {
if (result.get(index) == null) { if (result.get(index) == null) {
@@ -262,10 +314,30 @@ public final class JdbcShadowEvidenceRepository
return Collections.unmodifiableList(result); return Collections.unmodifiableList(result);
} }
private void verifyPayloadBudget(List<ShadowCycleEvidence> inserts) { private Map<UUID, String> prepareInputPayloads(List<ShadowCycleEvidence> inserts) {
Map<String, ShadowCycleEvidence> firstInputs = new LinkedHashMap<String, ShadowCycleEvidence>();
Map<UUID, String> result = new LinkedHashMap<UUID, String>();
long logicalRemaining = 128L * 1024L * 1024L;
for (ShadowCycleEvidence item : inserts) {
logicalRemaining -= 2L * item.getInputJson().length();
if (logicalRemaining < 0L) throw new ShadowEvidenceException("EVIDENCE_LOGICAL_BATCH_LIMIT");
ShadowCycleEvidence first = firstInputs.get(item.getInputBatchHash());
if (first == null) {
result.put(item.getCycleId(), inputCodec.encode(item.getInputJson(), item.getInputBatchHash()));
firstInputs.put(item.getInputBatchHash(), item);
} else {
if (!first.getInputJson().equals(item.getInputJson()))
throw new ShadowEvidenceException("INPUT_HASH_COLLISION");
result.put(item.getCycleId(), inputCodec.reference(first.getCycleId(), item.getInputBatchHash()));
}
}
return result;
}
private void verifyPayloadBudget(List<ShadowCycleEvidence> inserts, Map<UUID, String> inputPayloads) {
long remaining = maximumBatchPayloadBytes; long remaining = maximumBatchPayloadBytes;
for (ShadowCycleEvidence item : inserts) { for (ShadowCycleEvidence item : inserts) {
remaining = consumePayload(remaining, item.getInputJson()); remaining = consumePayload(remaining, inputPayloads.get(item.getCycleId()));
remaining = consumePayload(remaining, item.getLegacyJson()); remaining = consumePayload(remaining, item.getLegacyJson());
remaining = consumePayload(remaining, item.getLegacyPathInfo()); remaining = consumePayload(remaining, item.getLegacyPathInfo());
remaining = consumePayload(remaining, item.getDecisionJson()); remaining = consumePayload(remaining, item.getDecisionJson());
@@ -289,7 +361,7 @@ public final class JdbcShadowEvidenceRepository
return remaining - bytes; return remaining - bytes;
} }
private void insert(ShadowCycleEvidence evidence) { private void insert(ShadowCycleEvidence evidence, String inputPayload) {
jdbc.update("INSERT INTO shadow_cycle " jdbc.update("INSERT INTO shadow_cycle "
+ "(cycle_id,run_id,input_batch_hash,parent_task_id," + "(cycle_id,run_id,input_batch_hash,parent_task_id,"
+ "phase,route_version,status,reason_code," + "phase,route_version,status,reason_code,"
@@ -305,7 +377,7 @@ public final class JdbcShadowEvidenceRepository
jdbc.update("INSERT INTO shadow_input_batch " jdbc.update("INSERT INTO shadow_input_batch "
+ "(cycle_id,content_hash,captured_json) VALUES (?,?,?)", + "(cycle_id,content_hash,captured_json) VALUES (?,?,?)",
evidence.getCycleId(), evidence.getInputBatchHash(), evidence.getCycleId(), evidence.getInputBatchHash(),
evidence.getInputJson()); inputPayload);
jdbc.update("INSERT INTO shadow_legacy_queue " jdbc.update("INSERT INTO shadow_legacy_queue "
+ "(cycle_id,vehicle_id,path_info,result_status," + "(cycle_id,vehicle_id,path_info,result_status,"
+ "result_json,content_hash) VALUES (?,?,?,?,?,?)", + "result_json,content_hash) VALUES (?,?,?,?,?,?)",

View File

@@ -23,6 +23,11 @@ public final class ShadowComparisonService {
List<ShadowDifference> differences = List<ShadowDifference> differences =
new ArrayList<ShadowDifference>(); new ArrayList<ShadowDifference>();
if (decision.getType() == ShadowDecision.Type.OBSERVATION_ONLY) {
differences.add(difference(ShadowDifferenceCategory.OBSERVATION_ONLY,
decision.getReasonCode(), null, null));
return immutableSorted(differences);
}
if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) { if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) {
differences.add(difference( differences.add(difference(
ShadowDifferenceCategory.INSUFFICIENT_FACTS, ShadowDifferenceCategory.INSUFFICIENT_FACTS,

View File

@@ -189,6 +189,11 @@ public final class ShadowCycleEvidence {
row.put("default08", value.getDefault08()); row.put("default08", value.getDefault08());
row.put("endSite", value.getEndSite()); row.put("endSite", value.getEndSite());
row.put("guid", value.getGuid()); row.put("guid", value.getGuid());
// Keep the canonical payload of older captures unchanged.
if (value.getIssued() != null || value.getExecutionStatus() != null) {
row.put("issued", value.getIssued());
row.put("executionStatus", value.getExecutionStatus());
}
row.put("pathInfo", value.getPathInfo()); row.put("pathInfo", value.getPathInfo());
row.put("priority", value.getPriority()); row.put("priority", value.getPriority());
row.put("startSite", value.getStartSite()); row.put("startSite", value.getStartSite());
@@ -341,6 +346,7 @@ public final class ShadowCycleEvidence {
} }
private static String status(ShadowDecision decision) { private static String status(ShadowDecision decision) {
if (decision.getType() == ShadowDecision.Type.OBSERVATION_ONLY) return "OBSERVATION_ONLY";
if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) { if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) {
return "INSUFFICIENT_FACTS"; return "INSUFFICIENT_FACTS";
} }

View File

@@ -67,7 +67,7 @@ public final class ShadowCycleEvidenceCollector {
GrantedDispatch grant = decisions.getGranted().get(index); GrantedDispatch grant = decisions.getGranted().get(index);
Ft546EncodedCommand encoded = encodedCommands.get(index); Ft546EncodedCommand encoded = encodedCommands.get(index);
ShadowQueueFact queue = batch.queue(grant.getStepId()); ShadowQueueFact queue = batch.queue(grant.getStepId());
ShadowLegacyQueueResult legacy = legacy(queue); ShadowLegacyQueueResult legacy = legacy(queue, batch);
ShadowDecision decision = ShadowDecision.granted( ShadowDecision decision = ShadowDecision.granted(
encoded.getParentTaskId(), encoded.getPhase(), encoded.getParentTaskId(), encoded.getPhase(),
encoded.getRouteVersion(), grant.getVehicleId(), encoded.getRouteVersion(), grant.getVehicleId(),
@@ -77,6 +77,14 @@ public final class ShadowCycleEvidenceCollector {
} }
addHeld(evidence, batch, decisions.getHeld(), false, completedAt); addHeld(evidence, batch, decisions.getHeld(), false, completedAt);
addHeld(evidence, batch, decisions.getYielded(), true, completedAt); addHeld(evidence, batch, decisions.getYielded(), true, completedAt);
for (ShadowQueueFact queue : batch.getQueues()) {
if (!queue.isActive()) continue;
String reason = com.ruoyi.shadow.facts.ShadowQueueDispatchEligibility.observationReason(queue, batch);
if (reason != null) {
evidence.add(complete(batch, legacy(queue, batch), ShadowDecision.observation(
parent(queue), phase(queue), routeVersion, queue.getAgvId(), reason), null, completedAt));
}
}
return Collections.unmodifiableList(evidence); return Collections.unmodifiableList(evidence);
} }
@@ -91,7 +99,7 @@ public final class ShadowCycleEvidenceCollector {
} }
ShadowDecision decision = ShadowDecision.insufficient( ShadowDecision decision = ShadowDecision.insufficient(
parent(queue), phase(queue), routeVersion, reasonCode); parent(queue), phase(queue), routeVersion, reasonCode);
result.add(complete(batch, legacy(queue), decision, null, result.add(complete(batch, legacy(queue, batch), decision, null,
completedAt)); completedAt));
} }
if (result.isEmpty()) { if (result.isEmpty()) {
@@ -120,7 +128,7 @@ public final class ShadowCycleEvidenceCollector {
decision = ShadowDecision.held(parent, phase, routeVersion, decision = ShadowDecision.held(parent, phase, routeVersion,
held.getVehicleId(), held.getReasonCode()); held.getVehicleId(), held.getReasonCode());
} }
evidence.add(complete(batch, legacy(queue), decision, null, evidence.add(complete(batch, legacy(queue, batch), decision, null,
completedAt)); completedAt));
} }
} }
@@ -135,10 +143,10 @@ public final class ShadowCycleEvidenceCollector {
encoded, differences, completedAt, objectMapper); encoded, differences, completedAt, objectMapper);
} }
private ShadowLegacyQueueResult legacy(ShadowQueueFact queue) { private ShadowLegacyQueueResult legacy(ShadowQueueFact queue, ShadowFactBatch batch) {
return queue == null return queue == null
? ShadowLegacyQueueResult.unavailable("MISSING_QUEUE_FOR_DECISION") ? ShadowLegacyQueueResult.unavailable("MISSING_QUEUE_FOR_DECISION")
: legacyProjector.project(queue); : legacyProjector.project(queue, batch);
} }
private static List<Integer> route(HeldDispatch held) { private static List<Integer> route(HeldDispatch held) {
@@ -147,6 +155,7 @@ public final class ShadowCycleEvidenceCollector {
} }
List<Integer> result = new ArrayList<Integer>(); List<Integer> result = new ArrayList<Integer>();
for (String node : held.getYieldRoute().getNodeIds()) { for (String node : held.getYieldRoute().getNodeIds()) {
if (node.matches("(?:TD[0-9]{3}|WY[0-9]{4})")) continue;
try { try {
result.add(Integer.valueOf(node)); result.add(Integer.valueOf(node));
} catch (NumberFormatException invalid) { } catch (NumberFormatException invalid) {

View File

@@ -5,7 +5,7 @@ import java.util.Collections;
import java.util.List; import java.util.List;
public final class ShadowDecision { public final class ShadowDecision {
public enum Type { GRANTED, HELD, YIELDED, INSUFFICIENT_FACTS, SOURCE_GAP } public enum Type { GRANTED, HELD, YIELDED, INSUFFICIENT_FACTS, SOURCE_GAP, OBSERVATION_ONLY }
private final Type type; private final Type type;
private final String parentTaskId; private final String parentTaskId;
@@ -74,6 +74,13 @@ public final class ShadowDecision {
required(reasonCode, "reasonCode")); required(reasonCode, "reasonCode"));
} }
public static ShadowDecision observation(String parentTaskId, String phase,
long routeVersion, String vehicleId, String reasonCode) {
return new ShadowDecision(Type.OBSERVATION_ONLY, parentTaskId, phase,
routeVersion, vehicleId, Collections.<Integer>emptyList(),
required(reasonCode, "reasonCode"));
}
public Type getType() { return type; } public Type getType() { return type; }
public String getParentTaskId() { return parentTaskId; } public String getParentTaskId() { return parentTaskId; }
public String getPhase() { return phase; } public String getPhase() { return phase; }

View File

@@ -11,5 +11,6 @@ public enum ShadowDifferenceCategory {
ALGORITHM_ROUTE, ALGORITHM_ROUTE,
ALGORITHM_HOLD, ALGORITHM_HOLD,
INSUFFICIENT_FACTS, INSUFFICIENT_FACTS,
SOURCE_GAP SOURCE_GAP,
OBSERVATION_ONLY
} }

View File

@@ -0,0 +1,117 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
/** Physical storage envelope; logical evidence is always hashed over the original UTF-8 JSON. */
public final class ShadowInputPayloadCodec {
public static final String GZIP = "ft546-input-gzip-v1";
public static final String REFERENCE = "ft546-input-ref-v1";
public static final int MAX_INPUT_BYTES = 8 * 1024 * 1024;
public static final int MAX_STORED_CHARS = 12 * 1024 * 1024;
private final ObjectMapper mapper;
public ShadowInputPayloadCodec(ObjectMapper mapper) { this.mapper = mapper; }
public String encode(String raw, String hash) {
if (raw == null || raw.length() > MAX_INPUT_BYTES) throw failure("INPUT_LOGICAL_SIZE_LIMIT");
byte[] utf8 = raw.getBytes(StandardCharsets.UTF_8);
if (utf8.length > MAX_INPUT_BYTES) throw failure("INPUT_LOGICAL_SIZE_LIMIT");
verify(raw, hash);
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) { gzip.write(utf8); }
Map<String, Object> envelope = envelope(GZIP, hash);
envelope.put("utf8Bytes", utf8.length);
envelope.put("data", Base64.getEncoder().encodeToString(bytes.toByteArray()));
String stored = ShadowEvidenceDigest.canonicalJson(mapper, envelope);
if (stored.length() > MAX_STORED_CHARS) throw failure("INPUT_STORAGE_SIZE_LIMIT");
return stored;
} catch (IOException impossible) { throw new ShadowEvidenceException("INPUT_COMPRESSION_FAILED", impossible); }
}
public String reference(UUID firstCycle, String hash) {
Map<String, Object> envelope = envelope(REFERENCE, hash);
envelope.put("cycleId", firstCycle.toString());
return ShadowEvidenceDigest.canonicalJson(mapper, envelope);
}
public UUID referencedCycle(String stored, String hash) {
JsonNode node = parse(stored);
if (!REFERENCE.equals(node.path("storageFormat").asText())) return null;
verifyEnvelopeHash(node, hash);
try { return UUID.fromString(node.path("cycleId").asText()); }
catch (IllegalArgumentException invalid) { throw failure("INPUT_REFERENCE_INVALID"); }
}
/** Decodes a root payload only. References must be resolved and scoped by the repository. */
public String decode(String stored, String hash) {
JsonNode node = parse(stored);
if (!node.has("storageFormat")) {
if (stored.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_BYTES)
throw failure("INPUT_LOGICAL_SIZE_LIMIT");
verify(stored, hash);
return stored;
}
if (!GZIP.equals(node.path("storageFormat").asText())) throw failure("INPUT_STORAGE_FORMAT_UNSUPPORTED");
verifyEnvelopeHash(node, hash);
JsonNode length = node.path("utf8Bytes");
if (!length.isIntegralNumber() || !length.canConvertToInt()
|| length.intValue() < 0 || length.intValue() > MAX_INPUT_BYTES)
throw failure("INPUT_LOGICAL_SIZE_LIMIT");
int expected = length.intValue();
try {
byte[] compressed = Base64.getDecoder().decode(node.path("data").asText());
ByteArrayOutputStream raw = new ByteArrayOutputStream(Math.min(expected, 65536));
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
byte[] buffer = new byte[8192];
int count;
while ((count = gzip.read(buffer)) != -1) {
if (count > expected - raw.size()) throw failure("INPUT_INFLATION_LIMIT");
raw.write(buffer, 0, count);
}
}
if (raw.size() != expected) throw failure("INPUT_LENGTH_MISMATCH");
String json = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(raw.toByteArray())).toString();
verify(json, hash);
return json;
} catch (IOException | IllegalArgumentException invalid) {
throw new ShadowEvidenceException("INPUT_DECOMPRESSION_FAILED", invalid);
}
}
private JsonNode parse(String stored) {
if (stored == null || stored.length() > MAX_STORED_CHARS) throw failure("INPUT_STORAGE_SIZE_LIMIT");
try {
JsonNode node = mapper.readTree(stored);
if (node == null || !node.isObject()) throw failure("INPUT_STORAGE_FORMAT_INVALID");
return node;
} catch (IOException invalid) { throw new ShadowEvidenceException("INPUT_STORAGE_FORMAT_INVALID", invalid); }
}
private static Map<String, Object> envelope(String format, String hash) {
Map<String, Object> values = new LinkedHashMap<String, Object>();
values.put("storageFormat", format); values.put("contentHash", hash); return values;
}
private static void verifyEnvelopeHash(JsonNode node, String hash) {
if (hash == null || !hash.equals(node.path("contentHash").asText())) throw failure("INPUT_HASH_MISMATCH");
}
private static void verify(String raw, String hash) {
if (!ShadowEvidenceDigest.sha256(raw).equals(hash)) throw failure("INPUT_HASH_MISMATCH");
}
private static ShadowEvidenceException failure(String code) { return new ShadowEvidenceException(code); }
}

View File

@@ -8,6 +8,17 @@ import java.util.List;
import java.util.Map; import java.util.Map;
public final class LegacyQueueProtocolProjector { public final class LegacyQueueProtocolProjector {
public ShadowLegacyQueueResult project(ShadowQueueFact queue, ShadowFactBatch batch) {
ShadowLegacyQueueResult result = project(queue);
if (queue != null && batch != null && result.getStatus() == ShadowLegacyQueueResult.Status.AVAILABLE
&& ShadowQueueDispatchEligibility.lifecycleReason(queue) == null) {
Integer current = site(ShadowQueueEndpoints.dispatchStart(queue, batch));
if (current == null || !current.equals(result.getPathPoints().get(0)))
return unavailable("PATH_START_DIFFERS_FROM_CURRENT_PHASE");
}
return result;
}
public ShadowLegacyQueueResult project(ShadowQueueFact queue) { public ShadowLegacyQueueResult project(ShadowQueueFact queue) {
if (queue == null) { if (queue == null) {
return unavailable("MISSING_QUEUE"); return unavailable("MISSING_QUEUE");
@@ -125,6 +136,22 @@ public final class LegacyQueueProtocolProjector {
if (executedPath == null || executedPath.isEmpty()) { if (executedPath == null || executedPath.isEmpty()) {
return null; return null;
} }
Integer pathStart = site(executedPath.get(0));
Integer pathEnd = site(executedPath.get(executedPath.size() - 1));
// Formal sendPointToPointTask receives currSite, even when queue.start_site is older.
Integer source = pathStart;
Integer destination = site(queue.getEndSite());
if (source == null) return null;
if ("tsj".equals(trim(queue.getEndSite()))) {
if (!trim(queue.getTheFloor()).matches("[1-9]")) return null;
String type = trim(queue.getDefault02());
if (!"1".equals(type) && !"2".equals(type) && !"8".equals(type)) return null;
Integer liftEntry = site("07050" + trim(queue.getTheFloor()));
destination = site(("8".equals(type) ? "07050" : "09050") + trim(queue.getTheFloor()));
if (!destination.equals(pathEnd) && !liftEntry.equals(pathEnd)) return null;
} else if (destination == null || !destination.equals(pathEnd)) {
return null;
}
if (executedPath.size() == 1 if (executedPath.size() == 1
&& !("3".equals(trim(queue.getTaskType())) && !("3".equals(trim(queue.getTaskType()))
&& "3".equals(trim(queue.getDefault02())) && "3".equals(trim(queue.getDefault02()))
@@ -134,8 +161,8 @@ public final class LegacyQueueProtocolProjector {
return null; return null;
} }
List<Integer> result = new ArrayList<Integer>(); List<Integer> result = new ArrayList<Integer>();
result.add(site(executedPath.get(0))); result.add(source);
result.add(site(executedPath.get(executedPath.size() - 1))); result.add(destination);
if (notBlank(queue.getTurnPath())) { if (notBlank(queue.getTurnPath())) {
for (String token : queue.getTurnPath().split(",")) { for (String token : queue.getTurnPath().split(",")) {
if (!notBlank(token)) { if (!notBlank(token)) {

View File

@@ -111,7 +111,8 @@ public final class ShadowFactBatchValidator {
} }
for (ShadowQueueFact queue : batch.getQueues()) { for (ShadowQueueFact queue : batch.getQueues()) {
// Finished rows are historical versions, not current dispatch inputs. // Finished rows are historical versions, not current dispatch inputs.
if (!queue.isActive()) { if (!queue.isActive() || !Integer.valueOf(0).equals(queue.getIssued())
|| !Integer.valueOf(1).equals(queue.getExecutionStatus())) {
continue; continue;
} }
if (queue.getCreatedAt() == null) { if (queue.getCreatedAt() == null) {
@@ -130,7 +131,7 @@ public final class ShadowFactBatchValidator {
if (missing != null) { if (missing != null) {
return ShadowFactValidation.insufficient("MISSING_MAP_SITE:" + missing); return ShadowFactValidation.insufficient("MISSING_MAP_SITE:" + missing);
} }
if (notBlank(queue.getAgvId()) if (notBlank(queue.getAgvId()) && !"0".equals(queue.getAgvId())
&& !vehicleSites.containsKey(queue.getAgvId())) { && !vehicleSites.containsKey(queue.getAgvId())) {
return ShadowFactValidation.insufficient( return ShadowFactValidation.insufficient(
"MISSING_LEGACY_ASSIGNED_VEHICLE:" + queue.getAgvId()); "MISSING_LEGACY_ASSIGNED_VEHICLE:" + queue.getAgvId());

View File

@@ -0,0 +1,40 @@
package com.ruoyi.shadow.facts;
/** Lifecycle gates for predictions only. Never acknowledges or changes a source task. */
public final class ShadowQueueDispatchEligibility {
private ShadowQueueDispatchEligibility() { }
/** null means an explicitly pending phase; all other values are observation reasons. */
public static String observationReason(ShadowQueueFact queue, ShadowFactBatch batch) {
String lifecycle = lifecycleReason(queue);
if (lifecycle != null) return lifecycle;
if ("3".equals(queue.getTaskType()) && "3".equals(queue.getDefault02())) return null;
String destination = ShadowQueueEndpoints.end(queue, batch);
for (ShadowVehicleFact vehicle : batch.getVehicles()) {
if (vehicle.getDeviceId().equals(queue.getAgvId())
&& destination != null && destination.equals(vehicle.getCurrentSite()))
return "BOUND_VEHICLE_AT_PHASE_DESTINATION";
}
if (destination != null && destination.equals(ShadowQueueEndpoints.dispatchStart(queue, batch)))
return "NO_TRANSPORT_MOVEMENT";
return null;
}
public static String lifecycleReason(ShadowQueueFact queue) {
if (!queue.isActive()) return "HISTORICAL_QUEUE";
if (queue.getIssued() == null || queue.getExecutionStatus() == null)
return "UNKNOWN_QUEUE_LIFECYCLE";
if (!Integer.valueOf(0).equals(queue.getIssued())
|| !Integer.valueOf(1).equals(queue.getExecutionStatus()))
return "QUEUE_PHASE_NOT_PENDING";
// Two-stage and cross-floor handoffs need their own state-machine evidence.
// They must not be predicted as ordinary approach + execution routes.
if (nonblank(queue.getDefault08()) || "8".equals(queue.getDefault02()))
return "SPECIAL_PHASE_REQUIRES_HANDOFF_EVIDENCE";
return null;
}
private static boolean nonblank(String value) {
return value != null && !value.trim().isEmpty();
}
}

View File

@@ -4,6 +4,14 @@ package com.ruoyi.shadow.facts;
public final class ShadowQueueEndpoints { public final class ShadowQueueEndpoints {
private ShadowQueueEndpoints() { } private ShadowQueueEndpoints() { }
/** The formal sender routes a bound phase from the vehicle's live site directly to its destination. */
public static String dispatchStart(ShadowQueueFact queue, ShadowFactBatch batch) {
for (ShadowVehicleFact vehicle : batch.getVehicles()) {
if (vehicle.getDeviceId().equals(queue.getAgvId())) return vehicle.getCurrentSite();
}
return start(queue, batch);
}
public static String start(ShadowQueueFact queue, ShadowFactBatch batch) { public static String start(ShadowQueueFact queue, ShadowFactBatch batch) {
String site = trimmed(queue.getStartSite()); String site = trimmed(queue.getStartSite());
if (!"tsj".equals(site)) return site; if (!"tsj".equals(site)) return site;

View File

@@ -25,6 +25,8 @@ public final class ShadowQueueFact {
private final String default07; private final String default07;
private final String default08; private final String default08;
private final String contentHash; private final String contentHash;
private final Integer issued;
private final Integer executionStatus;
private ShadowQueueFact(Builder builder) { private ShadowQueueFact(Builder builder) {
this.tableName = required(builder.tableName, "tableName"); this.tableName = required(builder.tableName, "tableName");
@@ -48,10 +50,14 @@ public final class ShadowQueueFact {
this.default06 = builder.default06; this.default06 = builder.default06;
this.default07 = builder.default07; this.default07 = builder.default07;
this.default08 = builder.default08; this.default08 = builder.default08;
this.contentHash = FactHash.sha256(tableName, guid, taskNo, taskType, this.issued = builder.issued;
this.executionStatus = builder.executionStatus;
String legacyHash = FactHash.sha256(tableName, guid, taskNo, taskType,
priority, cargoPickSite, cargoPlaceSite, startSite, endSite, priority, cargoPickSite, cargoPlaceSite, startSite, endSite,
createdAt, agvId, pathInfo, turnPath, default02, default03, createdAt, agvId, pathInfo, turnPath, default02, default03,
default04, default05, default06, default07, default08, theFloor); default04, default05, default06, default07, default08, theFloor);
this.contentHash = issued == null && executionStatus == null ? legacyHash
: FactHash.sha256(legacyHash, "queue-lifecycle-v1", issued, executionStatus);
} }
public static Builder builder(String tableName, String guid) { public static Builder builder(String tableName, String guid) {
@@ -81,6 +87,8 @@ public final class ShadowQueueFact {
public String getDefault07() { return default07; } public String getDefault07() { return default07; }
public String getDefault08() { return default08; } public String getDefault08() { return default08; }
public String getContentHash() { return contentHash; } public String getContentHash() { return contentHash; }
public Integer getIssued() { return issued; }
public Integer getExecutionStatus() { return executionStatus; }
private static String required(String value, String name) { private static String required(String value, String name) {
if (value == null || value.trim().isEmpty()) { if (value == null || value.trim().isEmpty()) {
@@ -111,6 +119,8 @@ public final class ShadowQueueFact {
private String default06; private String default06;
private String default07; private String default07;
private String default08; private String default08;
private Integer issued;
private Integer executionStatus;
private Builder(String tableName, String guid) { private Builder(String tableName, String guid) {
this.tableName = tableName; this.tableName = tableName;
@@ -136,6 +146,8 @@ public final class ShadowQueueFact {
public Builder default06(String value) { default06 = value; return this; } public Builder default06(String value) { default06 = value; return this; }
public Builder default07(String value) { default07 = value; return this; } public Builder default07(String value) { default07 = value; return this; }
public Builder default08(String value) { default08 = value; return this; } public Builder default08(String value) { default08 = value; return this; }
public Builder issued(Integer value) { issued = value; return this; }
public Builder executionStatus(Integer value) { executionStatus = value; return this; }
public ShadowQueueFact build() { return new ShadowQueueFact(this); } public ShadowQueueFact build() { return new ShadowQueueFact(this); }
} }
} }

View File

@@ -182,7 +182,7 @@ public final class SqlServerShadowFactReader {
static final String QUEUE_SQL_PREFIX = static final String QUEUE_SQL_PREFIX =
"SELECT TOP (?) guid, task_no, task_type, priority, " "SELECT TOP (?) guid, task_no, task_type, priority, "
+ "cargo_pick_site, cargo_place_site, start_site, end_site, the_floor, " + "cargo_pick_site, cargo_place_site, start_site, end_site, the_floor, "
+ "create_date_utc, agv_id, path_info, trun_path, default02, " + "create_date_utc, agv_id, issued, execution_status, path_info, trun_path, default02, "
+ "default03, default04, default05, default06, default07, default08 "; + "default03, default04, default05, default06, default07, default08 ";
static final String QUEUE_SQL_SUFFIX = static final String QUEUE_SQL_SUFFIX =
" WHERE create_date_utc > ? " " WHERE create_date_utc > ? "
@@ -348,6 +348,8 @@ public final class SqlServerShadowFactReader {
.theFloor(result.getString("the_floor")) .theFloor(result.getString("the_floor"))
.createdAt(instant(result, "create_date_utc")) .createdAt(instant(result, "create_date_utc"))
.agvId(result.getString("agv_id")) .agvId(result.getString("agv_id"))
.issued(intValue(result, "issued"))
.executionStatus(intValue(result, "execution_status"))
.pathInfo(result.getString("path_info")) .pathInfo(result.getString("path_info"))
.turnPath(result.getString("trun_path")) .turnPath(result.getString("trun_path"))
.default02(result.getString("default02")) .default02(result.getString("default02"))

View File

@@ -168,6 +168,7 @@ public final class Ft546ShadowCycle {
"INVALID_COMMAND_FACTS:" + message(failure), progress); "INVALID_COMMAND_FACTS:" + message(failure), progress);
} }
progress.enter("EVIDENCE_BUILD"); progress.enter("EVIDENCE_BUILD");
progress.dispatch(batch, snapshot, result.getDecisions());
List<ShadowCycleEvidence> evidence = evidenceCollector.collect(batch, List<ShadowCycleEvidence> evidence = evidenceCollector.collect(batch,
result.getDecisions(), encoded, clock.instant()); result.getDecisions(), encoded, clock.instant());
progress.enter("EVIDENCE_SAVE"); progress.enter("EVIDENCE_SAVE");
@@ -199,7 +200,9 @@ public final class Ft546ShadowCycle {
.getReasonCode(); .getReasonCode();
if ("EVIDENCE_KEY_COLLISION".equals(reasonCode) if ("EVIDENCE_KEY_COLLISION".equals(reasonCode)
|| "EVIDENCE_RUN_COLLISION".equals(reasonCode) || "EVIDENCE_RUN_COLLISION".equals(reasonCode)
|| "EVIDENCE_BATCH_PAYLOAD_LIMIT".equals(reasonCode)) { || "EVIDENCE_BATCH_PAYLOAD_LIMIT".equals(reasonCode)
|| "EVIDENCE_LOGICAL_BATCH_LIMIT".equals(reasonCode)
|| reasonCode.startsWith("INPUT_")) {
return reasonCode; return reasonCode;
} }
} }

View File

@@ -18,6 +18,7 @@ final class ShadowCycleDiagnostics {
private final Map<String, Long> phaseNanos = new LinkedHashMap<String, Long>(); private final Map<String, Long> phaseNanos = new LinkedHashMap<String, Long>();
private Map<String, Object> completed; private Map<String, Object> completed;
private boolean cancelled; private boolean cancelled;
private Map<String, Object> dispatch = Collections.emptyMap();
ShadowCycleDiagnostics() { this(System::nanoTime); } ShadowCycleDiagnostics() { this(System::nanoTime); }
@@ -27,6 +28,43 @@ final class ShadowCycleDiagnostics {
this.phaseStarted = started; this.phaseStarted = started;
} }
synchronized void dispatch(com.ruoyi.shadow.facts.ShadowFactBatch batch,
com.ruoyi.wcs.application.dispatch.DispatchSnapshot snapshot,
com.ruoyi.wcs.application.dispatch.DispatchCycleResult result) {
if (completed != null || cancelled) return;
Map<String, Object> values = new LinkedHashMap<String, Object>();
values.put("eligiblePhases", snapshot.getReadySteps().size());
values.put("granted", result.getGranted().size());
values.put("held", result.getHeld().size());
values.put("yielded", result.getYielded().size());
Map<String, Integer> observations = new LinkedHashMap<String, Integer>();
for (com.ruoyi.shadow.facts.ShadowQueueFact queue : batch.getQueues()) {
if (!queue.isActive()) continue;
String reason = com.ruoyi.shadow.facts.ShadowQueueDispatchEligibility.observationReason(queue, batch);
if (reason != null) observations.merge(reason, 1, Integer::sum);
}
values.put("observations", Collections.unmodifiableMap(observations));
int safe = 0;
for (String id : snapshot.getTopology().getNodeIds()) {
com.ruoyi.wcs.domain.topology.TopologyNode node = snapshot.getTopology().node(id);
if (node.isSafeWait() || node.isSafeYield()) safe++;
}
values.put("validatedSafePointCount", safe);
values.put("safePointStatus", safe == 0 ? "UNCONFIGURED" : "TOPOLOGY_VALIDATED");
List<Map<String, Object>> holds = new ArrayList<Map<String, Object>>();
for (com.ruoyi.wcs.application.dispatch.HeldDispatch hold : result.getHeld()) {
if (holds.size() == 20) break;
Map<String, Object> row = new LinkedHashMap<String, Object>();
row.put("taskId", hold.getOrderId()); row.put("vehicleId", hold.getVehicleId());
row.put("reasonCode", hold.getReasonCode());
List<String> blockers = new ArrayList<String>(hold.getBlockingVehicleIds());
row.put("blockingVehicleIds", Collections.unmodifiableList(blockers.subList(0, Math.min(20, blockers.size()))));
holds.add(Collections.unmodifiableMap(row));
}
values.put("holdSamples", Collections.unmodifiableList(holds));
dispatch = Collections.unmodifiableMap(values);
}
synchronized void enter(String next) { synchronized void enter(String next) {
if (cancelled || Thread.currentThread().isInterrupted()) { if (cancelled || Thread.currentThread().isInterrupted()) {
throw new CancellationException("SHADOW_CYCLE_CANCELLED"); throw new CancellationException("SHADOW_CYCLE_CANCELLED");
@@ -49,6 +87,7 @@ final class ShadowCycleDiagnostics {
Map<String, Long> timings = new LinkedHashMap<String, Long>(); Map<String, Long> timings = new LinkedHashMap<String, Long>();
phaseNanos.forEach((key, value) -> timings.put(key, TimeUnit.NANOSECONDS.toMillis(value))); phaseNanos.forEach((key, value) -> timings.put(key, TimeUnit.NANOSECONDS.toMillis(value)));
snapshot.put("phaseMillis", Collections.unmodifiableMap(timings)); snapshot.put("phaseMillis", Collections.unmodifiableMap(timings));
snapshot.put("dispatch", dispatch);
if (worker != null) { if (worker != null) {
snapshot.put("workerState", worker.getState().name()); snapshot.put("workerState", worker.getState().name());
List<String> stack = new ArrayList<String>(); List<String> stack = new ArrayList<String>();

View File

@@ -39,12 +39,20 @@ public final class ShadowDispatchSnapshotAssembler {
private final long topologyVersion; private final long topologyVersion;
private final Function<EffectiveTopology, ShadowTrafficState> stateLoader; private final Function<EffectiveTopology, ShadowTrafficState> stateLoader;
private final Clock clock; private final Clock clock;
private final java.util.Set<String> safeWaitSites;
private final java.util.Set<String> safeYieldSites;
public ShadowDispatchSnapshotAssembler( public ShadowDispatchSnapshotAssembler(
String warehouseCode, String warehouseCode,
long topologyVersion, long topologyVersion,
Function<EffectiveTopology, ShadowTrafficState> stateLoader, Function<EffectiveTopology, ShadowTrafficState> stateLoader,
Clock clock) { Clock clock) {
this(warehouseCode, topologyVersion, stateLoader, clock, Collections.emptySet(), Collections.emptySet());
}
public ShadowDispatchSnapshotAssembler(String warehouseCode, long topologyVersion,
Function<EffectiveTopology, ShadowTrafficState> stateLoader, Clock clock,
java.util.Set<String> safeWaitSites, java.util.Set<String> safeYieldSites) {
this.warehouseCode = required(warehouseCode, "warehouseCode"); this.warehouseCode = required(warehouseCode, "warehouseCode");
if (topologyVersion <= 0L) { if (topologyVersion <= 0L) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
@@ -53,6 +61,8 @@ public final class ShadowDispatchSnapshotAssembler {
this.topologyVersion = topologyVersion; this.topologyVersion = topologyVersion;
this.stateLoader = Objects.requireNonNull(stateLoader, "stateLoader"); this.stateLoader = Objects.requireNonNull(stateLoader, "stateLoader");
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
this.safeWaitSites = safeSites(safeWaitSites);
this.safeYieldSites = safeSites(safeYieldSites);
} }
public DispatchSnapshot assemble(ShadowFactBatch batch) { public DispatchSnapshot assemble(ShadowFactBatch batch) {
@@ -74,7 +84,8 @@ public final class ShadowDispatchSnapshotAssembler {
continue; continue;
} }
nodes.add(new TopologyNode(fact.getSiteId(), floor(fact.getZ()), nodes.add(new TopologyNode(fact.getSiteId(), floor(fact.getZ()),
fact.getX(), fact.getY(), 1, false, false)); fact.getX(), fact.getY(), 1, safeWaitSites.contains(fact.getSiteId()),
safeYieldSites.contains(fact.getSiteId())));
String coordinate = coordinate(fact.getX(), fact.getY(), fact.getZ()); String coordinate = coordinate(fact.getX(), fact.getY(), fact.getZ());
if (coordinates.put(coordinate, fact) != null) { if (coordinates.put(coordinate, fact) != null) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
@@ -96,7 +107,26 @@ public final class ShadowDispatchSnapshotAssembler {
Collections.<ResourceDefinition>emptyList(), Collections.<ResourceDefinition>emptyList(),
Collections.<String>emptyList(), Collections.<String>emptyList(),
Collections.<String>emptyList()); Collections.<String>emptyList());
return new TopologyCompiler().compile(definition); EffectiveTopology topology = new TopologyCompiler().compile(definition);
java.util.Set<String> configured = new java.util.LinkedHashSet<String>(safeWaitSites);
configured.addAll(safeYieldSites);
for (String id : configured) {
if (!topology.containsNode(id) || topology.neighbors(id).size() < 2) {
throw new IllegalArgumentException("INVALID_CONFIGURED_SAFE_SITE:" + id);
}
}
return topology;
}
public static java.util.Set<String> safeSites(java.util.Set<String> values) {
java.util.Set<String> normalized = new java.util.TreeSet<String>();
for (String value : Objects.requireNonNull(values, "safeSites")) {
String id = required(value, "safeSite");
if (!id.matches("[0-9]{1,6}") || Integer.parseInt(id) == 0)
throw new IllegalArgumentException("INVALID_CONFIGURED_SAFE_SITE:" + id);
normalized.add(id);
}
return Collections.unmodifiableSet(normalized);
} }
private List<DispatchTask> tasks(ShadowFactBatch batch, private List<DispatchTask> tasks(ShadowFactBatch batch,
@@ -104,10 +134,10 @@ public final class ShadowDispatchSnapshotAssembler {
List<DispatchTask> tasks = new ArrayList<DispatchTask>(); List<DispatchTask> tasks = new ArrayList<DispatchTask>();
Instant now = clock.instant(); Instant now = clock.instant();
for (ShadowQueueFact fact : batch.getQueues()) { for (ShadowQueueFact fact : batch.getQueues()) {
if (!ACTIVE_QUEUE.equals(fact.getTableName())) { if (com.ruoyi.shadow.facts.ShadowQueueDispatchEligibility.observationReason(fact, batch) != null) {
continue; continue;
} }
String source = required(ShadowQueueEndpoints.start(fact, batch), "startSite"); String source = required(ShadowQueueEndpoints.dispatchStart(fact, batch), "startSite");
String destination = required(ShadowQueueEndpoints.end(fact, batch), "endSite"); String destination = required(ShadowQueueEndpoints.end(fact, batch), "endSite");
if (!topology.containsNode(source) if (!topology.containsNode(source)
|| !topology.containsNode(destination)) { || !topology.containsNode(destination)) {
@@ -130,6 +160,10 @@ public final class ShadowDispatchSnapshotAssembler {
values.put("parentTaskId", parentTaskId); values.put("parentTaskId", parentTaskId);
values.put("phase", phase(required(fact.getTaskType(), "taskType"))); values.put("phase", phase(required(fact.getTaskType(), "taskType")));
values.put("routeVersion", Long.toString(topologyVersion)); values.put("routeVersion", Long.toString(topologyVersion));
String boundVehicle = optional(fact.getAgvId());
if (boundVehicle != null && !"0".equals(boundVehicle)) {
values.put("shadowBoundVehicleId", boundVehicle);
}
values.put("taskNo", positiveNumber(parentTaskId, "taskNo")); values.put("taskNo", positiveNumber(parentTaskId, "taskNo"));
short taskType = parsedShort(required(fact.getDefault02(), "default02"), short taskType = parsedShort(required(fact.getDefault02(), "default02"),
"default02"); "default02");

View File

@@ -0,0 +1,31 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentDecision;
import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Keeps the source phase's vehicle binding without relaxing the normal safety gates. */
public final class ShadowVehicleAssignmentPolicy implements VehicleAssignmentPolicy {
private final VehicleAssignmentPolicy delegate;
public ShadowVehicleAssignmentPolicy(VehicleAssignmentPolicy delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
@Override
public VehicleAssignmentDecision assign(DispatchTask task, List<VehicleSnapshot> vehicles,
EffectiveTopology topology) {
String bound = task.getCommandParameters().get("shadowBoundVehicleId");
if (bound == null) return delegate.assign(task, vehicles, topology);
List<VehicleSnapshot> candidates = new ArrayList<VehicleSnapshot>();
for (VehicleSnapshot vehicle : vehicles) {
if (bound.equals(vehicle.getVehicleId())) candidates.add(vehicle);
}
return delegate.assign(task, candidates, topology);
}
}

View File

@@ -0,0 +1,55 @@
package com.ruoyi.shadow.startup;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.shadow.config.ShadowProperties;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.shadow.evidence.ShadowInputPayloadCodec;
import org.springframework.core.env.Environment;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
/** Identifies this source revision and the nonsecret dispatch/storage settings, not a built artifact. */
public final class ShadowRuntimeIdentity {
public static final String BUILD_ID = "ft546-shadow-20260908-phase-storage-v2";
private ShadowRuntimeIdentity() { }
public static Map<String, Object> describe(ShadowProperties properties, Environment env) {
Map<String, Object> settings = new TreeMap<String, Object>();
settings.put("enabled", properties.isEnabled());
settings.put("cycleDeadlineMillis", env.getProperty("shadow.cycle.deadline-ms", Long.class, 10000L));
settings.put("pollDelayMillis", env.getProperty("shadow.poll.fixed-delay-ms", Long.class, 5000L));
settings.put("initialDelayMillis", env.getProperty("shadow.poll.initial-delay-ms", Long.class, 5000L));
settings.put("maximumDatabaseBytes", properties.getEvidence().getMaximumDatabaseBytes());
settings.put("maximumBatchPayloadBytes", properties.getEvidence().getMaximumBatchPayloadBytes());
settings.put("minimumFreeBytes", properties.getEvidence().getMinimumFreeBytes());
settings.put("statusAddress", env.getProperty("server.address", "127.0.0.1"));
settings.put("statusPort", env.getProperty("server.port", Integer.class, 18091));
settings.put("safeWaitSites", ShadowDispatchSnapshotAssembler.safeSites(properties.getTraffic().getSafeWaitSites()));
settings.put("safeYieldSites", ShadowDispatchSnapshotAssembler.safeSites(properties.getTraffic().getSafeYieldSites()));
settings.put("inputStorageFormat", ShadowInputPayloadCodec.GZIP);
Map<String, Object> result = new TreeMap<String, Object>();
result.put("buildId", BUILD_ID);
result.put("effectiveSettings", Collections.unmodifiableMap(settings));
result.put("effectiveSettingsSha256", digest(settings));
result.put("identityScope", "SOURCE_BUILD_LABEL_AND_NONSECRET_DISPATCH_STORAGE_SETTINGS");
result.put("operatorArtifactSha256", properties.getEvidence().getRun().getArtifactSha256());
result.put("operatorConfigurationSha256", properties.getEvidence().getRun().getConfigurationSha256());
result.put("safePointConfiguration", properties.getTraffic().getSafeWaitSites().isEmpty()
&& properties.getTraffic().getSafeYieldSites().isEmpty() ? "UNCONFIGURED" : "CONFIGURED_REQUIRES_TOPOLOGY_VALIDATION");
return Collections.unmodifiableMap(result);
}
private static String digest(Map<String, Object> settings) {
try {
byte[] bytes = new ObjectMapper().writeValueAsString(settings).getBytes(StandardCharsets.UTF_8);
StringBuilder result = new StringBuilder();
for (byte b : MessageDigest.getInstance("SHA-256").digest(bytes))
result.append(String.format(Locale.ROOT, "%02x", b & 255));
return result.toString();
} catch (Exception impossible) { throw new IllegalStateException("RUNTIME_IDENTITY_FAILED", impossible); }
}
}

View File

@@ -17,6 +17,7 @@ public final class ShadowStartupReport {
private volatile String permanentDegradedReason; private volatile String permanentDegradedReason;
private final Map<String, String> startupChecks; private final Map<String, String> startupChecks;
private volatile Map<String, Object> cycleDiagnostics = Collections.emptyMap(); private volatile Map<String, Object> cycleDiagnostics = Collections.emptyMap();
private volatile Map<String, Object> runtimeIdentity = Collections.emptyMap();
private ShadowStartupReport(Status status, Instant checkedAt, private ShadowStartupReport(Status status, Instant checkedAt,
Instant lastCompletedCycle, Instant lastCompletedCycle,
@@ -46,6 +47,10 @@ public final class ShadowStartupReport {
public String getReasonCode() { return reasonCode; } public String getReasonCode() { return reasonCode; }
public Map<String, String> getStartupChecks() { return startupChecks; } public Map<String, String> getStartupChecks() { return startupChecks; }
public Map<String, Object> getCycleDiagnostics() { return cycleDiagnostics; } public Map<String, Object> getCycleDiagnostics() { return cycleDiagnostics; }
public Map<String, Object> getRuntimeIdentity() { return runtimeIdentity; }
public void recordRuntimeIdentity(Map<String, Object> identity) {
runtimeIdentity = Collections.unmodifiableMap(new LinkedHashMap<String, Object>(identity));
}
public void recordCycleDiagnostics(Map<String, Object> diagnostics) { public void recordCycleDiagnostics(Map<String, Object> diagnostics) {
cycleDiagnostics = Collections.unmodifiableMap(new LinkedHashMap<String, Object>(diagnostics)); cycleDiagnostics = Collections.unmodifiableMap(new LinkedHashMap<String, Object>(diagnostics));
@@ -107,6 +112,7 @@ public final class ShadowStartupReport {
|| permanentDegradedReason != null); || permanentDegradedReason != null);
state.put("sourceLagMillis", sourceLagMillis); state.put("sourceLagMillis", sourceLagMillis);
state.put("cycleDiagnostics", cycleDiagnostics); state.put("cycleDiagnostics", cycleDiagnostics);
state.put("runtimeIdentity", runtimeIdentity);
return Collections.unmodifiableMap(state); return Collections.unmodifiableMap(state);
} }

View File

@@ -0,0 +1,43 @@
package com.ruoyi.shadow.web;
import com.ruoyi.shadow.evidence.JdbcShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
@RestController
public final class ShadowInputController {
private final JdbcShadowEvidenceRepository repository;
public ShadowInputController(JdbcShadowEvidenceRepository repository) { this.repository = repository; }
@GetMapping("/shadow/inputs/{cycleId}")
public ResponseEntity<Map<String, Object>> input(@PathVariable("cycleId") UUID cycleId,
HttpServletRequest request) {
String remote = request.getRemoteAddr();
if (!isLoopback(remote))
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok().header("Cache-Control", "no-store").body(repository.loadInput(cycleId));
} catch (ShadowEvidenceException failure) {
HttpStatus status = "INPUT_NOT_FOUND".equals(failure.getReasonCode())
? HttpStatus.NOT_FOUND : HttpStatus.CONFLICT;
return ResponseEntity.status(status).body(Collections.<String, Object>singletonMap("reasonCode", failure.getReasonCode()));
}
}
private static boolean isLoopback(String remote) {
// Only parse numeric servlet peer addresses; never resolve hostnames or trust forwarded headers.
if (remote == null || !remote.matches("[0-9a-fA-F:.]+")) return false;
try { return InetAddress.getByName(remote).isLoopbackAddress(); }
catch (UnknownHostException invalid) { return false; }
}
}

View File

@@ -6,6 +6,9 @@ spring:
banner-mode: "off" banner-mode: "off"
shadow: shadow:
enabled: false enabled: false
traffic:
safe-wait-sites: []
safe-yield-sites: []
cycle: cycle:
deadline-ms: 10000 deadline-ms: 10000
poll: poll:

View File

@@ -116,6 +116,7 @@ class Ft546ShadowApplicationTest {
context.getBean(com.ruoyi.wcs.port.out context.getBean(com.ruoyi.wcs.port.out
.TrafficStateRepository.class)); .TrafficStateRepository.class));
assertNotNull(context.getBean(ShadowStatusController.class)); assertNotNull(context.getBean(ShadowStatusController.class));
assertNotNull(context.getBean(com.ruoyi.shadow.web.ShadowInputController.class));
Map<String, String> persisted = context.getBean( Map<String, String> persisted = context.getBean(
RecordingShadowDatabaseGate.class).getPersistedChecks(); RecordingShadowDatabaseGate.class).getPersistedChecks();
assertNotNull(persisted); assertNotNull(persisted);
@@ -138,11 +139,19 @@ class Ft546ShadowApplicationTest {
.andExpect(jsonPath("$.status").value("STARTING")) .andExpect(jsonPath("$.status").value("STARTING"))
.andExpect(jsonPath("$.checkedAt").exists()) .andExpect(jsonPath("$.checkedAt").exists())
.andExpect(jsonPath("$.evidenceBacklog").value(0)) .andExpect(jsonPath("$.evidenceBacklog").value(0))
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260908-phase-storage-v2"))
.andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists())
.andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED"))
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED") .andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
.value("PASS")) .value("PASS"))
.andExpect(jsonPath( .andExpect(jsonPath(
"$.startupChecks.SIDE_EFFECT_BEANS_ABSENT") "$.startupChecks.SIDE_EFFECT_BEANS_ABSENT")
.value("PASS")); .value("PASS"));
mvc.perform(get("/shadow/inputs/00000000-0000-0000-0000-000000000001")
.with(request -> { request.setRemoteAddr("10.6.22.6"); return request; }))
.andExpect(status().isForbidden());
mvc.perform(get("/shadow/inputs/not-a-uuid"))
.andExpect(status().isBadRequest());
}); });
} }

View File

@@ -64,17 +64,18 @@ class Ft546ShadowEndToEndTest {
private static final String FIXTURES = "/fixtures/"; private static final String FIXTURES = "/fixtures/";
@Test @Test
void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence() void ordinaryPredictsWhileHandoffPhasesStayObservationOnly()
throws Exception { throws Exception {
ScenarioResult ordinary = run("ordinary-cycle.json"); ScenarioResult ordinary = run("ordinary-cycle.json");
ScenarioResult crossFloor = run("cross-floor-cycle.json"); ScenarioResult crossFloor = run("cross-floor-cycle.json");
ScenarioResult twoStage = run("two-stage-cycle.json"); ScenarioResult twoStage = run("two-stage-cycle.json");
assertComparable(ordinary, "4004", "4000", "4002", "4021"); assertComparable(ordinary, "4004", "4000", "4002", "4021");
assertComparable(crossFloor, "4004", "4000", "4002", "4021", for (ScenarioResult special : Arrays.asList(crossFloor, twoStage)) {
"4020", "4022"); assertTrue(special.cycle.getDecisions().getGranted().isEmpty());
assertComparable(twoStage, "4404", "4422", "4423", "4402", assertTrue(special.cycle.getEncodedCommands().isEmpty());
"4400"); assertEquals(1, special.persistedCycles);
}
assertEquals(3, ordinary.persistedCycles + crossFloor.persistedCycles assertEquals(3, ordinary.persistedCycles + crossFloor.persistedCycles
+ twoStage.persistedCycles); + twoStage.persistedCycles);
} }
@@ -187,6 +188,7 @@ class Ft546ShadowEndToEndTest {
ShadowQueueFact.Builder queue = ShadowQueueFact.builder( ShadowQueueFact.Builder queue = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", fixture.queueGuid) "shadow_ro.v_wcs_srgv_queue", fixture.queueGuid)
.taskNo(fixture.taskNo) .taskNo(fixture.taskNo)
.issued(0).executionStatus(1)
.taskType(fixture.taskType) .taskType(fixture.taskType)
.priority(Long.valueOf(fixture.priority)) .priority(Long.valueOf(fixture.priority))
.cargoPickSite(fixture.startSite) .cargoPickSite(fixture.startSite)

View File

@@ -661,6 +661,7 @@ class Ft546ShadowFaultIsolationTest {
private static ShadowQueueFact queue(String table, String guid, private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) { Instant createdAt) {
return ShadowQueueFact.builder(table, guid) return ShadowQueueFact.builder(table, guid)
.issued(0).executionStatus(1)
.taskNo("30001") .taskNo("30001")
.taskType("2") .taskType("2")
.priority(1L) .priority(1L)

View File

@@ -80,6 +80,90 @@ class JdbcShadowEvidenceRepositoryTest {
objectMapper, run); objectMapper, run);
} }
@Test
void newInputUsesAVersionedCompressedPayload() {
ShadowCycleEvidence evidence = matchingCycle();
repository.save(evidence);
String stored = jdbc.queryForObject("SELECT captured_json FROM shadow_input_batch", String.class);
assertNotEquals(evidence.getInputJson(), stored);
assertTrue(stored.contains("ft546-input-gzip-v1"));
assertEquals(evidence.getInputJson(), repository.loadInput(evidence.getCycleId()).get("capturedJson"));
org.springframework.mock.web.MockHttpServletRequest request = new org.springframework.mock.web.MockHttpServletRequest();
request.setRemoteAddr("127.0.0.1");
org.springframework.http.ResponseEntity<Map<String, Object>> response =
new com.ruoyi.shadow.web.ShadowInputController(repository).input(evidence.getCycleId(), request);
assertEquals(200, response.getStatusCodeValue());
assertEquals(evidence.getInputJson(), response.getBody().get("capturedJson"));
assertEquals(Boolean.TRUE, response.getBody().get("hashVerified"));
assertEquals("no-store", response.getHeaders().getFirst("Cache-Control"));
assertEquals(404, new com.ruoyi.shadow.web.ShadowInputController(repository)
.input(UUID.randomUUID(), request).getStatusCodeValue());
for (String loopback : Arrays.asList("::1", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1")) {
request.setRemoteAddr(loopback);
assertEquals(200, new com.ruoyi.shadow.web.ShadowInputController(repository)
.input(evidence.getCycleId(), request).getStatusCodeValue(), loopback);
}
}
@Test
void sameBatchSharesOneRootAndRestartExportsBothInputs() {
ShadowCycleEvidence first = observation("ONE"), second = observation("TWO");
repository.saveAll(Arrays.asList(first, second));
String ref = jdbc.queryForObject("SELECT captured_json FROM shadow_input_batch WHERE cycle_id=?",
String.class, second.getCycleId());
assertTrue(ref.contains("ft546-input-ref-v1"));
assertTrue(ref.contains(first.getCycleId().toString()));
JdbcShadowEvidenceRepository restarted = new JdbcShadowEvidenceRepository(jdbc, transaction, objectMapper, run);
assertEquals(first.getInputJson(), restarted.loadInput(first.getCycleId()).get("capturedJson"));
assertEquals(second.getInputJson(), restarted.loadInput(second.getCycleId()).get("capturedJson"));
assertEquals(second.getInputBatchHash(), restarted.loadInput(second.getCycleId()).get("contentHash"));
// Legacy rows remain readable; no migration is necessary.
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?", first.getInputJson(), first.getCycleId());
assertEquals(second.getInputJson(), restarted.loadInput(second.getCycleId()).get("capturedJson"));
}
@Test
void missingCyclicCrossRunAndTamperedReferencesFailClosed() {
ShadowCycleEvidence first = observation("ONE"), second = observation("TWO");
repository.saveAll(Arrays.asList(first, second));
ShadowInputPayloadCodec codec = new ShadowInputPayloadCodec(objectMapper);
String hash = first.getInputBatchHash();
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?",
codec.reference(second.getCycleId(), hash), first.getCycleId());
assertThrows(ShadowEvidenceException.class, () -> repository.loadInput(second.getCycleId()));
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?",
codec.reference(UUID.randomUUID(), hash), second.getCycleId());
assertThrows(ShadowEvidenceException.class, () -> repository.loadInput(second.getCycleId()));
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?", "{}", first.getCycleId());
assertThrows(ShadowEvidenceException.class, () -> repository.loadInput(first.getCycleId()));
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?",
codec.reference(first.getCycleId(), hash), second.getCycleId());
UUID otherRun = UUID.randomUUID();
jdbc.update("INSERT INTO shadow_run SELECT ?,started_at,artifact_sha256,configuration_sha256,"
+ "source_server,source_database,source_login,status FROM shadow_run WHERE run_id=?", otherRun, run.getRunId());
jdbc.update("UPDATE shadow_cycle SET run_id=? WHERE cycle_id=?", otherRun, first.getCycleId());
assertEquals("INPUT_REFERENCE_SCOPE_MISMATCH", assertThrows(ShadowEvidenceException.class,
() -> repository.loadInput(second.getCycleId())).getReasonCode());
}
@Test
void failedChildOfSharedInputRollsBackRootAndReferenceTogether() {
jdbc.execute("ALTER TABLE shadow_decision ADD CONSTRAINT ck_second CHECK (reason_code <> 'TWO')");
assertThrows(RuntimeException.class, () -> repository.saveAll(Arrays.asList(observation("ONE"), observation("TWO"))));
assertEquals(0, count("shadow_input_batch"));
assertEquals(0, count("shadow_cycle"));
assertEquals(0, count("shadow_run"));
}
private ShadowCycleEvidence observation(String parent) {
ShadowFactBatch batch = ShadowFactBatch.create(NOW, NOW, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList());
return ShadowCycleEvidence.complete(run.getRunId(), batch, ShadowLegacyQueueResult.unavailable("NO_QUEUE"),
ShadowDecision.observation(parent, "OBSERVED", 1L, null, parent), null,
Collections.singletonList(new ShadowDifference(ShadowDifferenceCategory.OBSERVATION_ONLY,
parent, null, null)), NOW, objectMapper);
}
@Test @Test
void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception { void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception {
createTrafficSchema(); createTrafficSchema();
@@ -147,8 +231,7 @@ class JdbcShadowEvidenceRepositoryTest {
objectMapper, run, 1L); objectMapper, run, 1L);
assertEquals(evidence.getCycleId(), bounded.save(evidence).getCycleId()); assertEquals(evidence.getCycleId(), bounded.save(evidence).getCycleId());
assertEquals(1, count("shadow_cycle")); assertEquals(1, count("shadow_cycle"));
assertEquals(evidence.getInputJson(), jdbc.queryForObject( assertEquals(evidence.getInputJson(), repository.loadInput(evidence.getCycleId()).get("capturedJson"));
"SELECT captured_json FROM shadow_input_batch", String.class));
} }
@Test @Test

View File

@@ -54,6 +54,7 @@ class ShadowComparisonServiceTest {
.taskNo("30001") .taskNo("30001")
.taskType("2") .taskType("2")
.agvId("VEH-1") .agvId("VEH-1")
.startSite("070501").endSite("070502")
.pathInfo("70501,70502") .pathInfo("70501,70502")
.default02("1") .default02("1")
.build(); .build();

View File

@@ -0,0 +1,51 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class ShadowInputPayloadCodecTest {
private final ObjectMapper mapper = new ObjectMapper();
private final ShadowInputPayloadCodec codec = new ShadowInputPayloadCodec(mapper);
@Test
void losslessUnicodeRoundTripAndLegacyRawJsonKeepTheirOriginalHash() {
String raw = "{\"maps\":\"" + String.join("", Collections.nCopies(4000, "现场点位😀")) + "\"}";
String hash = ShadowEvidenceDigest.sha256(raw);
String stored = codec.encode(raw, hash);
assertTrue(stored.length() < raw.length() / 10);
assertEquals(raw, codec.decode(stored, hash));
assertEquals(raw, codec.decode(raw, hash));
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored, ShadowEvidenceDigest.sha256("bad")));
}
@Test
void alteredDataLengthsAndFormatsFailClosed() throws Exception {
String raw = "{\"data\":\"" + String.join("", Collections.nCopies(10000, "a")) + "\"}";
String hash = ShadowEvidenceDigest.sha256(raw);
ObjectNode stored = (ObjectNode) mapper.readTree(codec.encode(raw, hash));
stored.put("utf8Bytes", 10);
assertEquals("INPUT_INFLATION_LIMIT", assertThrows(ShadowEvidenceException.class,
() -> codec.decode(stored.toString(), hash)).getReasonCode());
stored.put("utf8Bytes", ShadowInputPayloadCodec.MAX_INPUT_BYTES + 1);
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored.toString(), hash));
stored.put("utf8Bytes", raw.length()).put("data", "garbage");
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored.toString(), hash));
stored.put("storageFormat", "ft546-input-gzip-v99");
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored.toString(), hash));
assertThrows(ShadowEvidenceException.class, () -> codec.decode(raw + " ", hash));
}
@Test
void referencesRequireRepositoryResolutionAndCannotBypassHashChecks() {
UUID id = UUID.randomUUID();
String hash = ShadowEvidenceDigest.sha256("{}");
String ref = codec.reference(id, hash);
assertEquals(id, codec.referencedCycle(ref, hash));
assertThrows(ShadowEvidenceException.class, () -> codec.decode(ref, hash));
assertThrows(ShadowEvidenceException.class, () -> codec.referencedCycle(ref, "wrong"));
}
}

View File

@@ -211,6 +211,7 @@ class ShadowFactBatchValidatorTest {
.startSite("070501").endSite("070502") .startSite("070501").endSite("070502")
.cargoPickSite("070501").cargoPlaceSite("070502") .cargoPickSite("070501").cargoPlaceSite("070502")
.createdAt(null).agvId("1") .createdAt(null).agvId("1")
.issued(0).executionStatus(1)
.pathInfo("70501,70502,").turnPath("") .pathInfo("70501,70502,").turnPath("")
.default02("1").build(); .default02("1").build();
@@ -232,6 +233,7 @@ class ShadowFactBatchValidatorTest {
.startSite("070501").endSite("070502") .startSite("070501").endSite("070502")
.cargoPickSite("070501").cargoPlaceSite("070502") .cargoPickSite("070501").cargoPlaceSite("070502")
.createdAt(NOW.minusSeconds(2)).agvId("1") .createdAt(NOW.minusSeconds(2)).agvId("1")
.issued(0).executionStatus(1)
.pathInfo("70501,70502,").turnPath("") .pathInfo("70501,70502,").turnPath("")
.default02("1").default06("070503").default08("2") .default02("1").default06("070503").default08("2")
.build(); .build();
@@ -278,6 +280,7 @@ class ShadowFactBatchValidatorTest {
.startSite(start).endSite(end) .startSite(start).endSite(end)
.cargoPickSite(start).cargoPlaceSite(end) .cargoPickSite(start).cargoPlaceSite(end)
.createdAt(NOW.minusSeconds(2)).agvId(agvId) .createdAt(NOW.minusSeconds(2)).agvId(agvId)
.issued(0).executionStatus(1)
.pathInfo("70501,70502,").turnPath("") .pathInfo("70501,70502,").turnPath("")
.default02("1").build(); .default02("1").build();
} }

View File

@@ -411,6 +411,7 @@ class SqlServerShadowFactReaderTest {
ShadowLegacyQueueResult crossFloor = projector.project( ShadowLegacyQueueResult crossFloor = projector.project(
queueBuilder("CROSS", NOW).taskNo("30002") queueBuilder("CROSS", NOW).taskNo("30002")
.endSite("080501")
.default02("8").default07("8") .default02("8").default07("8")
.pathInfo("70501,70507,80507,80501,") .pathInfo("70501,70507,80507,80501,")
.turnPath("70507,80507").build()); .turnPath("70507,80507").build());
@@ -421,6 +422,7 @@ class SqlServerShadowFactReaderTest {
ShadowLegacyQueueResult stageOne = projector.project( ShadowLegacyQueueResult stageOne = projector.project(
queueBuilder("STAGE-1", NOW).taskNo("30003") queueBuilder("STAGE-1", NOW).taskNo("30003")
.endSite("070503")
.default02("1").default08("1") .default02("1").default08("1")
.pathInfo("70501,70503,").turnPath("").build()); .pathInfo("70501,70503,").turnPath("").build());
assertEquals("4404,4422,4402,4400", stageOne.getWriteAddresses()); assertEquals("4404,4422,4402,4400", stageOne.getWriteAddresses());
@@ -455,7 +457,7 @@ class SqlServerShadowFactReaderTest {
} }
@Test @Test
void legacyProjectionUsesTheExecutedPathEndpointsInsteadOfQueueOrigins() { void legacyProjectionUsesTheActualSendOriginWhenTheVehicleWasRepositioned() {
ShadowQueueFact queue = queueBuilder("REPOSITIONED", NOW) ShadowQueueFact queue = queueBuilder("REPOSITIONED", NOW)
.startSite("070501").endSite("070504") .startSite("070501").endSite("070504")
.pathInfo("070502,070503,070504,") .pathInfo("070502,070503,070504,")
@@ -464,13 +466,13 @@ class SqlServerShadowFactReaderTest {
ShadowLegacyQueueResult result = ShadowLegacyQueueResult result =
new LegacyQueueProtocolProjector().project(queue); new LegacyQueueProtocolProjector().project(queue);
assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0), assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0), result.write("4004").getValues());
result.write("4004").getValues());
} }
@Test @Test
void legacyProjectionAcceptsPersistedZeroPaddedTurnPoints() { void legacyProjectionAcceptsPersistedZeroPaddedTurnPoints() {
ShadowQueueFact queue = queueBuilder("PADDED-TURNS", NOW) ShadowQueueFact queue = queueBuilder("PADDED-TURNS", NOW)
.endSite("070504")
.pathInfo("070501,070502,070504,") .pathInfo("070501,070502,070504,")
.turnPath("070503,0,0,0,0,0").build(); .turnPath("070503,0,0,0,0,0").build();

View File

@@ -526,6 +526,7 @@ class Ft546ShadowCycleTest {
private static ShadowQueueFact queue(String table, String guid, private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) { Instant createdAt) {
return ShadowQueueFact.builder(table, guid) return ShadowQueueFact.builder(table, guid)
.issued(0).executionStatus(1)
.taskNo("30001") .taskNo("30001")
.taskType("2") .taskType("2")
.priority(2L) .priority(2L)
@@ -544,6 +545,7 @@ class Ft546ShadowCycleTest {
private static ShadowQueueFact queueWithoutLegacyAssignment( private static ShadowQueueFact queueWithoutLegacyAssignment(
Instant createdAt) { Instant createdAt) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE") return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE")
.issued(0).executionStatus(1)
.taskNo("30001") .taskNo("30001")
.taskType("2") .taskType("2")
.priority(2L) .priority(2L)

View File

@@ -46,6 +46,11 @@ class ShadowCapturedInputReplayTest {
String raw = row.get("captured_json").asText(); String raw = row.get("captured_json").asText();
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(raw)); assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(raw));
ShadowFactBatch batch = batch(mapper.readTree(raw)); ShadowFactBatch batch = batch(mapper.readTree(raw));
com.ruoyi.shadow.evidence.ShadowCycleEvidence reconstructed = new com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector(
UUID.randomUUID(), 1L, new ObjectMapper().findAndRegisterModules())
.insufficient(batch, "HASH_REPLAY_ONLY", batch.getDatabaseCapturedAt()).get(0);
assertEquals(raw, reconstructed.getInputJson(), "captured input must remain byte-for-byte unchanged");
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), reconstructed.getInputBatchHash());
Instant now = batch.getDatabaseCapturedAt(); Instant now = batch.getDatabaseCapturedAt();
ShadowFactValidation validation = new ShadowFactBatchValidator( ShadowFactValidation validation = new ShadowFactBatchValidator(
Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now); Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now);
@@ -60,6 +65,7 @@ class ShadowCapturedInputReplayTest {
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology), "FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC)); new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC));
DispatchSnapshot snapshot = assembler.assemble(batch); DispatchSnapshot snapshot = assembler.assemble(batch);
assertTrue(snapshot.getReadySteps().isEmpty(), "old capture has no queue lifecycle");
RoutePlanner planner = new AStarRoutePlanner(); RoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator( DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE), new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
@@ -81,6 +87,8 @@ class ShadowCapturedInputReplayTest {
} }
assertEquals(300, inputs); assertEquals(300, inputs);
assertEquals(600, legacyAvailable); assertEquals(600, legacyAvailable);
assertEquals(0, grants);
assertEquals(0, writes);
System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs
+ " legacyAvailable=" + legacyAvailable + " grants=" + grants + " legacyAvailable=" + legacyAvailable + " grants=" + grants
+ " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons); + " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons);
@@ -95,6 +103,7 @@ class ShadowCapturedInputReplayTest {
.cargoPickSite(text(q, "cargoPickSite")).cargoPlaceSite(text(q, "cargoPlaceSite")) .cargoPickSite(text(q, "cargoPickSite")).cargoPlaceSite(text(q, "cargoPlaceSite"))
.startSite(text(q, "startSite")).endSite(text(q, "endSite")).theFloor(text(q, "theFloor")) .startSite(text(q, "startSite")).endSite(text(q, "endSite")).theFloor(text(q, "theFloor"))
.createdAt(instant(q, "createdAt")).agvId(text(q, "agvId")) .createdAt(instant(q, "createdAt")).agvId(text(q, "agvId"))
.issued(number(q, "issued")).executionStatus(number(q, "executionStatus"))
.pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath")) .pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath"))
.default02(text(q, "default02")).default03(text(q, "default03")) .default02(text(q, "default02")).default03(text(q, "default03"))
.default04(text(q, "default04")).default05(text(q, "default05")) .default04(text(q, "default04")).default05(text(q, "default05"))

View File

@@ -0,0 +1,58 @@
package com.ruoyi.shadow.kernel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.shadow.evidence.ShadowCycleEvidence;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowInputPayloadCodec;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Paths;
import java.time.Clock;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** Private field exports stay outside source control. No network or database is contacted. */
@EnabledIfSystemProperty(named = "shadow.replay.audit-directory", matches = ".+")
class ShadowFieldEvidenceReplayTest {
@Test
void auditedInputsKeepOriginalHashesAndRoundTripWithBoundedCompressedStorage() throws Exception {
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
ShadowInputPayloadCodec codec = new ShadowInputPayloadCodec(mapper);
Set<String> hashes = new HashSet<String>();
int captures = 0;
long rawBytes = 0, storedBytes = 0;
for (String filename : new String[] {"03-samples.json", "04-detail-statistics.json"}) {
JsonNode export = mapper.readTree(Paths.get(System.getProperty("shadow.replay.audit-directory"), filename).toFile());
for (JsonNode result : export.path("resultSets")) for (JsonNode row : result.path("rows")) {
if (!row.hasNonNull("captured_json")) continue;
String raw = row.get("captured_json").asText();
ShadowFactBatch batch = ShadowCapturedInputReplayTest.batch(mapper.readTree(raw));
ShadowCycleEvidence evidence = new ShadowCycleEvidenceCollector(UUID.randomUUID(), 1L, mapper)
.insufficient(batch, "RAW_INPUT_REPLAY", batch.getDatabaseCapturedAt()).get(0);
assertEquals(raw, evidence.getInputJson());
if (row.hasNonNull("content_hash")) assertEquals(row.path("content_hash").asText(), evidence.getInputBatchHash());
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler("FT546", 1L,
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), 0L, Collections.emptyMap()),
Clock.fixed(batch.getDatabaseCapturedAt(), ZoneOffset.UTC));
assertTrue(assembler.assemble(batch).getReadySteps().isEmpty(), "Old capture lacks lifecycle; do not invent pending status");
String stored = codec.encode(raw, evidence.getInputBatchHash());
assertEquals(raw, codec.decode(stored, evidence.getInputBatchHash()));
if (hashes.add(evidence.getInputBatchHash())) { rawBytes += 2L * raw.length(); storedBytes += 2L * stored.length(); }
captures++;
}
}
assertTrue(captures >= 17, "field export is incomplete");
assertTrue(storedBytes < rawBytes / 2, "field maps should compress materially");
System.out.println("FIELD_INPUT_REPLAY captures=" + captures + " uniqueInputs=" + hashes.size()
+ " originalNvarcharBytes=" + rawBytes + " compressedNvarcharBytes=" + storedBytes
+ " storedPercent=" + (100.0 * storedBytes / rawBytes));
}
}

View File

@@ -44,6 +44,115 @@ class ShadowFieldRegressionTest {
private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue"; private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue";
private static final String FINISHED = "shadow_ro.v_wcs_srgv_queue_finish"; private static final String FINISHED = "shadow_ro.v_wcs_srgv_queue_finish";
@Test
void missingLifecycleCannotAuthorizeANewDispatch() {
ShadowQueueFact queue = queue(ACTIVE, "UNKNOWN", "080202", "080203")
.issued(null).executionStatus(null).build();
assertTrue(assembler().assemble(batch(Collections.singletonList(queue), vehicle().build()))
.getReadySteps().isEmpty());
}
@Test
void issuedCompletedUnknownAndArrivedPhasesProduceObservationEvidenceWithoutWrites() throws Exception {
for (ShadowQueueFact queue : Arrays.asList(
queue(ACTIVE, "ISSUED", "080202", "080203").issued(1).executionStatus(2).build(),
queue(ACTIVE, "DONE", "080202", "080203").issued(1).executionStatus(3).build(),
queue(ACTIVE, "UNKNOWN", "080202", "080203").issued(null).executionStatus(null).build(),
queue(ACTIVE, "ARRIVED", "080203", "080202").build(),
queue(ACTIVE, "SINGLE", "080202", "080202").build())) {
ShadowFactBatch batch = batch(Collections.singletonList(queue), vehicle().finish(1).build());
com.ruoyi.wcs.application.dispatch.DispatchSnapshot snapshot = assembler().assemble(batch);
assertTrue(snapshot.getReadySteps().isEmpty(), queue.getGuid());
com.ruoyi.wcs.application.dispatch.DispatchCycleResult decisions = coordinator().dispatch(snapshot);
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
com.ruoyi.shadow.evidence.ShadowCycleEvidence evidence = new ShadowCycleEvidenceCollector(
java.util.UUID.randomUUID(), 1L, mapper).collect(batch, decisions, Collections.emptyList(), NOW).get(0);
assertEquals("OBSERVATION_ONLY", evidence.getDecision());
assertEquals("OBSERVATION_ONLY", evidence.getStatus());
assertTrue(evidence.getEncodedWrites().isEmpty());
assertEquals(com.ruoyi.shadow.evidence.ShadowDifferenceCategory.OBSERVATION_ONLY,
evidence.getDifferences().get(0).getCategory());
if (queue.getIssued() != null) assertEquals(queue.getIssued().intValue(),
mapper.readTree(evidence.getInputJson()).path("queues").get(0).path("issued").intValue());
}
}
@Test
void pendingBindingCannotBeReassignedAndBusyBindingStaysBlocked() {
ShadowQueueFact queue = queue(ACTIVE, "PENDING", "080202", "080203").build();
ShadowFactBatch free = batch(Collections.singletonList(queue), vehicle().build());
assertEquals("2", coordinator().dispatch(assembler().assemble(free)).getGranted().get(0).getVehicleId());
ShadowVehicleFact competitor = ShadowVehicleFact.builder("OTHER", "3").currentSite("080202")
.workMode(5).status(1).allowSend(1).battery(100).currentFloor(2).capturedAt(NOW).build();
ShadowFactBatch busy = ShadowFactBatch.create(NOW, NOW, free.getQueues(),
Arrays.asList(vehicle().allowSend(0).status(2).build(), competitor), free.getMaps());
assertTrue(coordinator().dispatch(assembler().assemble(busy)).getGranted().isEmpty());
assertEquals("NO_ELIGIBLE_VEHICLE", coordinator().dispatch(assembler().assemble(busy)).getHeld().get(0).getReasonCode());
}
@Test
void onlyConfiguredPassableSitesWithAnExitCanBeUsedForYielding() {
ShadowFactBatch batch = liftBatch(queue(ACTIVE, "LIFT", "080202", "tsj")
.issued(1).executionStatus(2).theFloor("2").build());
ShadowDispatchSnapshotAssembler configured = assembler(Collections.singleton("080203"));
assertTrue(configured.assemble(batch).getTopology().node("080203").isSafeYield());
assertFalse(configured.assemble(batch).getTopology().node("070502").isSafeYield());
assertFalse(assembler().assemble(batch).getTopology().node("080203").isSafeYield());
assertThrows(RuntimeException.class, () -> assembler(Collections.singleton("080202")).assemble(batch));
assertThrows(RuntimeException.class, () -> assembler(Collections.singleton("999999")).assemble(batch));
assertThrows(RuntimeException.class, () -> assembler(Collections.singleton("TD002")));
}
@Test
void lifecycleChangesParticipateInTheQueueAndRawInputHashes() {
ShadowQueueFact pending = queue(ACTIVE, "Q", "080202", "080203").build();
ShadowQueueFact sent = queue(ACTIVE, "Q", "080202", "080203").issued(1).build();
assertNotEquals(pending.getContentHash(), sent.getContentHash());
ShadowCycleEvidenceCollector collector = new ShadowCycleEvidenceCollector(java.util.UUID.randomUUID(),
1L, new ObjectMapper().findAndRegisterModules());
assertNotEquals(collector.insufficient(batch(Collections.singletonList(pending), vehicle().build()), "TEST", NOW).get(0).getInputBatchHash(),
collector.insufficient(batch(Collections.singletonList(sent), vehicle().build()), "TEST", NOW).get(0).getInputBatchHash());
}
@Test
void observationalRowsWithIncompletePhaseFieldsDoNotInvalidatePendingWork() {
ShadowQueueFact pending = queue(ACTIVE, "LIVE", "080202", "080203").build();
ShadowQueueFact observed = queue(ACTIVE, "OLD", "tsj", "tsj")
.taskNo("503").issued(1).executionStatus(3).createdAt(null).pathInfo(null).build();
ShadowFactBatch batch = batch(Arrays.asList(pending, observed), vehicle().build());
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
assertEquals(1, assembler().assemble(batch).getReadySteps().size());
}
private static DispatchCoordinator coordinator() {
AStarRoutePlanner planner = new AStarRoutePlanner();
return new DispatchCoordinator(new ShadowVehicleAssignmentPolicy(new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE)),
new DeadlockGuard(new WaitForGraph(), planner), commit -> true, () -> java.util.UUID.randomUUID().toString());
}
private static ShadowDispatchSnapshotAssembler assembler(java.util.Set<String> yieldSites) {
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), 0L, Collections.emptyMap()),
Clock.fixed(NOW, ZoneOffset.UTC), Collections.emptySet(), yieldSites);
}
@Test
void legacyLiftEndpointFollowsTheActualPlcRewrite() {
ShadowQueueFact queue = queue(ACTIVE, "LIFT", "050101", "tsj")
.theFloor("1").default02("1").pathInfo("050101,060101,060501,070501,")
.turnPath("060101,060501,").build();
assertEquals(Arrays.asList(50101, 90501, 60101, 60501),
new LegacyQueueProtocolProjector().project(queue).getPathPoints());
}
@Test
void previousPhasePathIsNotAComparableCurrentCommand() {
ShadowQueueFact queue = queue(ACTIVE, "PHASE", "030602", "tsj")
.theFloor("2").default02("2").pathInfo("090502,030602,").build();
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
new LegacyQueueProtocolProjector().project(queue).getStatus());
}
@Test @Test
void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() { void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() {
VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build()); VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build());
@@ -122,8 +231,10 @@ class ShadowFieldRegressionTest {
.default02(type).default07("1").build(); .default02(type).default07("1").build();
ShadowFactBatch batch = liftBatch(queue); ShadowFactBatch batch = liftBatch(queue);
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus()); assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
DispatchTask task = assembler().assemble(batch).getReadySteps().get(0); assertEquals("8".equals(type) ? "070502" : "090502",
assertEquals("8".equals(type) ? "070502" : "090502", task.getDestinationNodeId()); com.ruoyi.shadow.facts.ShadowQueueEndpoints.end(queue, batch));
if ("8".equals(type)) assertTrue(assembler().assemble(batch).getReadySteps().isEmpty());
else assertEquals("090502", assembler().assemble(batch).getReadySteps().get(0).getDestinationNodeId());
assertEquals("tsj", batch.queue("LIFT").getEndSite()); assertEquals("tsj", batch.queue("LIFT").getEndSite());
} }
} }
@@ -248,7 +359,7 @@ class ShadowFieldRegressionTest {
return ShadowQueueFact.builder(table, guid).taskNo("502").taskType("2").priority(2L) return ShadowQueueFact.builder(table, guid).taskNo("502").taskType("2").priority(2L)
.startSite(start).endSite(end).cargoPickSite(start).cargoPlaceSite(end) .startSite(start).endSite(end).cargoPickSite(start).cargoPlaceSite(end)
.createdAt(NOW.minusSeconds(10)).agvId("2").pathInfo(start + "," + end + ",") .createdAt(NOW.minusSeconds(10)).agvId("2").pathInfo(start + "," + end + ",")
.turnPath("").default02("1"); .turnPath("").default02("1").issued(0).executionStatus(1);
} }
private static ShadowVehicleFact.Builder vehicle() { private static ShadowVehicleFact.Builder vehicle() {

View File

@@ -58,7 +58,7 @@ class ShadowLivePathRegressionTest {
} }
@Test @Test
void overlappingApproachAndExecutionReserveEachResourceOnceWithoutLosingRouteOrder() { void boundPhaseMovesFromLivePositionWithoutReturningToAnOldQueueOrigin() {
DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201"); DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201");
AStarRoutePlanner planner = new AStarRoutePlanner(); AStarRoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator( DispatchCoordinator coordinator = new DispatchCoordinator(
@@ -68,10 +68,9 @@ class ShadowLivePathRegressionTest {
DispatchCycleResult result = coordinator.dispatch(snapshot); DispatchCycleResult result = coordinator.dispatch(snapshot);
assertEquals(1, result.getGranted().size()); assertEquals(1, result.getGranted().size());
RoutePlan route = result.getGranted().get(0).getProtectedRoute(); RoutePlan route = result.getGranted().get(0).getProtectedRoute();
assertEquals(Arrays.asList("060301", "WY0002", "060201", "WY0001", "060101", assertEquals(Arrays.asList("060301", "WY0002", "060201"), route.getNodeIds());
"070101", "060101", "WY0001", "060201"), route.getNodeIds());
assertEquals(new HashSet<>(route.getResourceKeys()).size(), route.getResourceKeys().size()); assertEquals(new HashSet<>(route.getResourceKeys()).size(), route.getResourceKeys().size());
assertEquals(Arrays.asList(60301, 60201, 60101, 70101, 60101), assertEquals(Arrays.asList(60301, 60201),
new ShadowCommandIntentFactory().create(result.getGranted().get(0)).getPathPoints()); new ShadowCommandIntentFactory().create(result.getGranted().get(0)).getPathPoints());
} }
@@ -120,6 +119,7 @@ class ShadowLivePathRegressionTest {
private static ShadowQueueFact.Builder queue(String start, String end) { private static ShadowQueueFact.Builder queue(String start, String end) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD") return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD")
.issued(0).executionStatus(1)
.taskNo("31312").taskType("1").priority(2L).startSite(start).endSite(end) .taskNo("31312").taskType("1").priority(2L).startSite(start).endSite(end)
.cargoPickSite(start).cargoPlaceSite(end).theFloor("1").agvId("3") .cargoPickSite(start).cargoPlaceSite(end).theFloor("1").agvId("3")
.createdAt(NOW.minusSeconds(10)).default02("4").default08("").turnPath(""); .createdAt(NOW.minusSeconds(10)).default02("4").default08("").turnPath("");

View File

@@ -0,0 +1,31 @@
package com.ruoyi.shadow.startup;
import com.ruoyi.shadow.config.ShadowProperties;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShadowRuntimeIdentityTest {
@Test
void settingsDigestIsIndependentOfOperatorLabelsAndSecretsButTracksActualBehaviorSettings() {
ShadowProperties p = new ShadowProperties();
MockEnvironment env = new MockEnvironment();
Map<String, Object> first = ShadowRuntimeIdentity.describe(p, env);
p.getSource().setPassword("secret-source"); p.getEvidence().setPassword("secret-evidence");
p.getEvidence().getRun().setArtifactSha256("operator-label");
Map<String, Object> second = ShadowRuntimeIdentity.describe(p, env);
assertEquals(first.get("effectiveSettingsSha256"), second.get("effectiveSettingsSha256"));
assertFalse(second.toString().contains("secret-"));
env.setProperty("shadow.poll.fixed-delay-ms", "6000");
assertNotEquals(first.get("effectiveSettingsSha256"), ShadowRuntimeIdentity.describe(p, env).get("effectiveSettingsSha256"));
env.setProperty("shadow.poll.fixed-delay-ms", "5000");
p.getTraffic().setSafeYieldSites(Collections.singleton("080203"));
assertNotEquals(first.get("effectiveSettingsSha256"), ShadowRuntimeIdentity.describe(p, env).get("effectiveSettingsSha256"));
ShadowStartupReport report = ShadowStartupReport.starting(Instant.EPOCH, Collections.emptyMap());
report.recordRuntimeIdentity(second);
assertEquals(second, report.snapshotRuntimeState(Instant.EPOCH, null, false).get("runtimeIdentity"));
}
}