diff --git a/docs/runbooks/ft546-shadow-20260908-phase-storage-repair.md b/docs/runbooks/ft546-shadow-20260908-phase-storage-repair.md new file mode 100644 index 0000000..34b755c --- /dev/null +++ b/docs/runbooks/ft546-shadow-20260908-phase-storage-repair.md @@ -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/ +``` + +每次返回一条输入:`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` 只说明影子采集链路健康。正式版接管前还需本次新运行的有效待下发样本、路线与协议差异、拥堵/避让及特殊交接阶段证据;本修复不构成正式版上线批准。 diff --git a/docs/superpowers/plans/2026-09-08-shadow-phase-storage-repair.md b/docs/superpowers/plans/2026-09-08-shadow-phase-storage-repair.md new file mode 100644 index 0000000..1b8c5b5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-shadow-phase-storage-repair.md @@ -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. diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java index 63a5a5b..b0fa9e1 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java @@ -59,8 +59,8 @@ public class ShadowKernelConfiguration { @Bean public VehicleAssignmentPolicy shadowVehicleAssignmentPolicy( RoutePlanner routePlanner) { - return new DefaultVehicleAssignmentPolicy(routePlanner, - DispatchPolicy.NONE); + return new com.ruoyi.shadow.kernel.ShadowVehicleAssignmentPolicy( + new DefaultVehicleAssignmentPolicy(routePlanner, DispatchPolicy.NONE)); } @Bean @@ -112,10 +112,11 @@ public class ShadowKernelConfiguration { @Bean public ShadowDispatchSnapshotAssembler shadowDispatchSnapshotAssembler( JdbcShadowTrafficStateRepository trafficStateRepository, - @Qualifier("shadowClock") Clock clock) { + @Qualifier("shadowClock") Clock clock, ShadowProperties properties) { return new ShadowDispatchSnapshotAssembler("FT546", SHADOW_ROUTE_VERSION, - trafficStateRepository::load, clock); + trafficStateRepository::load, clock, properties.getTraffic().getSafeWaitSites(), + properties.getTraffic().getSafeYieldSites()); } @Bean diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java index 546aaa8..c36c283 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java @@ -7,11 +7,22 @@ public class ShadowProperties { private boolean enabled; private final JdbcEndpoint source = new JdbcEndpoint(); private final EvidenceEndpoint evidence = new EvidenceEndpoint(); + private final Traffic traffic = new Traffic(); public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public JdbcEndpoint getSource() { return source; } public EvidenceEndpoint getEvidence() { return evidence; } + public Traffic getTraffic() { return traffic; } + + public static final class Traffic { + private java.util.Set safeWaitSites = new java.util.LinkedHashSet(); + private java.util.Set safeYieldSites = new java.util.LinkedHashSet(); + public java.util.Set getSafeWaitSites() { return safeWaitSites; } + public void setSafeWaitSites(java.util.Set values) { safeWaitSites = values; } + public java.util.Set getSafeYieldSites() { return safeYieldSites; } + public void setSafeYieldSites(java.util.Set values) { safeYieldSites = values; } + } public static class JdbcEndpoint { private String jdbcUrl; diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java index 981c574..e03dcfe 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java @@ -49,7 +49,15 @@ public class ShadowRuntimeConfiguration { ShadowStartupGate gate, ApplicationContext context, Environment environment, ShadowProperties properties, 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 diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java index f6da36a..b23451f 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java @@ -30,6 +30,7 @@ public final class JdbcShadowEvidenceRepository private final ObjectMapper objectMapper; private final ShadowRunEvidence run; private final long maximumBatchPayloadBytes; + private final ShadowInputPayloadCodec inputCodec; public JdbcShadowEvidenceRepository(JdbcTemplate jdbc, TransactionTemplate transaction, @@ -50,6 +51,7 @@ public final class JdbcShadowEvidenceRepository throw new IllegalArgumentException("maximumBatchPayloadBytes must be positive"); } this.maximumBatchPayloadBytes = maximumBatchPayloadBytes; + this.inputCodec = new ShadowInputPayloadCodec(objectMapper); } @Override @@ -113,6 +115,55 @@ public final class JdbcShadowEvidenceRepository return false; } + /** One bounded, verified input export; old raw JSON and the new physical format are supported. */ + public Map 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 result = new LinkedHashMap(); + 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 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 public T inTransaction(Supplier work) { Objects.requireNonNull(work, "work"); @@ -250,9 +301,10 @@ public final class JdbcShadowEvidenceRepository result.add(verifyReplay(existing, item)); } } - verifyPayloadBudget(inserts); + Map inputPayloads = prepareInputPayloads(inserts); + verifyPayloadBudget(inserts, inputPayloads); for (ShadowCycleEvidence item : inserts) { - insert(item); + insert(item, inputPayloads.get(item.getCycleId())); } for (int index = 0; index < evidence.size(); index++) { if (result.get(index) == null) { @@ -262,10 +314,30 @@ public final class JdbcShadowEvidenceRepository return Collections.unmodifiableList(result); } - private void verifyPayloadBudget(List inserts) { + private Map prepareInputPayloads(List inserts) { + Map firstInputs = new LinkedHashMap(); + Map result = new LinkedHashMap(); + 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 inserts, Map inputPayloads) { long remaining = maximumBatchPayloadBytes; 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.getLegacyPathInfo()); remaining = consumePayload(remaining, item.getDecisionJson()); @@ -289,7 +361,7 @@ public final class JdbcShadowEvidenceRepository return remaining - bytes; } - private void insert(ShadowCycleEvidence evidence) { + private void insert(ShadowCycleEvidence evidence, String inputPayload) { jdbc.update("INSERT INTO shadow_cycle " + "(cycle_id,run_id,input_batch_hash,parent_task_id," + "phase,route_version,status,reason_code," @@ -305,7 +377,7 @@ public final class JdbcShadowEvidenceRepository jdbc.update("INSERT INTO shadow_input_batch " + "(cycle_id,content_hash,captured_json) VALUES (?,?,?)", evidence.getCycleId(), evidence.getInputBatchHash(), - evidence.getInputJson()); + inputPayload); jdbc.update("INSERT INTO shadow_legacy_queue " + "(cycle_id,vehicle_id,path_info,result_status," + "result_json,content_hash) VALUES (?,?,?,?,?,?)", diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java index 85b1b32..a70a82a 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java @@ -23,6 +23,11 @@ public final class ShadowComparisonService { List differences = new ArrayList(); + 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) { differences.add(difference( ShadowDifferenceCategory.INSUFFICIENT_FACTS, diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java index af931a3..e9cbcc0 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java @@ -189,6 +189,11 @@ public final class ShadowCycleEvidence { row.put("default08", value.getDefault08()); row.put("endSite", value.getEndSite()); 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("priority", value.getPriority()); row.put("startSite", value.getStartSite()); @@ -341,6 +346,7 @@ public final class ShadowCycleEvidence { } private static String status(ShadowDecision decision) { + if (decision.getType() == ShadowDecision.Type.OBSERVATION_ONLY) return "OBSERVATION_ONLY"; if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) { return "INSUFFICIENT_FACTS"; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java index b521ec2..02380b2 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java @@ -67,7 +67,7 @@ public final class ShadowCycleEvidenceCollector { GrantedDispatch grant = decisions.getGranted().get(index); Ft546EncodedCommand encoded = encodedCommands.get(index); ShadowQueueFact queue = batch.queue(grant.getStepId()); - ShadowLegacyQueueResult legacy = legacy(queue); + ShadowLegacyQueueResult legacy = legacy(queue, batch); ShadowDecision decision = ShadowDecision.granted( encoded.getParentTaskId(), encoded.getPhase(), encoded.getRouteVersion(), grant.getVehicleId(), @@ -77,6 +77,14 @@ public final class ShadowCycleEvidenceCollector { } addHeld(evidence, batch, decisions.getHeld(), false, 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); } @@ -91,7 +99,7 @@ public final class ShadowCycleEvidenceCollector { } ShadowDecision decision = ShadowDecision.insufficient( parent(queue), phase(queue), routeVersion, reasonCode); - result.add(complete(batch, legacy(queue), decision, null, + result.add(complete(batch, legacy(queue, batch), decision, null, completedAt)); } if (result.isEmpty()) { @@ -120,7 +128,7 @@ public final class ShadowCycleEvidenceCollector { decision = ShadowDecision.held(parent, phase, routeVersion, held.getVehicleId(), held.getReasonCode()); } - evidence.add(complete(batch, legacy(queue), decision, null, + evidence.add(complete(batch, legacy(queue, batch), decision, null, completedAt)); } } @@ -135,10 +143,10 @@ public final class ShadowCycleEvidenceCollector { encoded, differences, completedAt, objectMapper); } - private ShadowLegacyQueueResult legacy(ShadowQueueFact queue) { + private ShadowLegacyQueueResult legacy(ShadowQueueFact queue, ShadowFactBatch batch) { return queue == null ? ShadowLegacyQueueResult.unavailable("MISSING_QUEUE_FOR_DECISION") - : legacyProjector.project(queue); + : legacyProjector.project(queue, batch); } private static List route(HeldDispatch held) { @@ -147,6 +155,7 @@ public final class ShadowCycleEvidenceCollector { } List result = new ArrayList(); for (String node : held.getYieldRoute().getNodeIds()) { + if (node.matches("(?:TD[0-9]{3}|WY[0-9]{4})")) continue; try { result.add(Integer.valueOf(node)); } catch (NumberFormatException invalid) { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java index 0520811..3b010f8 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java @@ -5,7 +5,7 @@ import java.util.Collections; import java.util.List; 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 String parentTaskId; @@ -74,6 +74,13 @@ public final class ShadowDecision { 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.emptyList(), + required(reasonCode, "reasonCode")); + } + public Type getType() { return type; } public String getParentTaskId() { return parentTaskId; } public String getPhase() { return phase; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java index 62f5869..d0b4762 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java @@ -11,5 +11,6 @@ public enum ShadowDifferenceCategory { ALGORITHM_ROUTE, ALGORITHM_HOLD, INSUFFICIENT_FACTS, - SOURCE_GAP + SOURCE_GAP, + OBSERVATION_ONLY } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowInputPayloadCodec.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowInputPayloadCodec.java new file mode 100644 index 0000000..614d6fc --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowInputPayloadCodec.java @@ -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 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 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 envelope(String format, String hash) { + Map values = new LinkedHashMap(); + 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); } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java index fe71803..3f6751a 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java @@ -8,6 +8,17 @@ import java.util.List; import java.util.Map; 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) { if (queue == null) { return unavailable("MISSING_QUEUE"); @@ -125,6 +136,22 @@ public final class LegacyQueueProtocolProjector { if (executedPath == null || executedPath.isEmpty()) { 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 && !("3".equals(trim(queue.getTaskType())) && "3".equals(trim(queue.getDefault02())) @@ -134,8 +161,8 @@ public final class LegacyQueueProtocolProjector { return null; } List result = new ArrayList(); - result.add(site(executedPath.get(0))); - result.add(site(executedPath.get(executedPath.size() - 1))); + result.add(source); + result.add(destination); if (notBlank(queue.getTurnPath())) { for (String token : queue.getTurnPath().split(",")) { if (!notBlank(token)) { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java index 536eddb..0cf07c0 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java @@ -111,7 +111,8 @@ public final class ShadowFactBatchValidator { } for (ShadowQueueFact queue : batch.getQueues()) { // 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; } if (queue.getCreatedAt() == null) { @@ -130,7 +131,7 @@ public final class ShadowFactBatchValidator { if (missing != null) { return ShadowFactValidation.insufficient("MISSING_MAP_SITE:" + missing); } - if (notBlank(queue.getAgvId()) + if (notBlank(queue.getAgvId()) && !"0".equals(queue.getAgvId()) && !vehicleSites.containsKey(queue.getAgvId())) { return ShadowFactValidation.insufficient( "MISSING_LEGACY_ASSIGNED_VEHICLE:" + queue.getAgvId()); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueDispatchEligibility.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueDispatchEligibility.java new file mode 100644 index 0000000..5886327 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueDispatchEligibility.java @@ -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(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java index 6a2d5e8..43ffe26 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java @@ -4,6 +4,14 @@ package com.ruoyi.shadow.facts; public final class 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) { String site = trimmed(queue.getStartSite()); if (!"tsj".equals(site)) return site; diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java index aee7099..596b0cc 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java @@ -25,6 +25,8 @@ public final class ShadowQueueFact { private final String default07; private final String default08; private final String contentHash; + private final Integer issued; + private final Integer executionStatus; private ShadowQueueFact(Builder builder) { this.tableName = required(builder.tableName, "tableName"); @@ -48,10 +50,14 @@ public final class ShadowQueueFact { this.default06 = builder.default06; this.default07 = builder.default07; 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, createdAt, agvId, pathInfo, turnPath, default02, default03, 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) { @@ -81,6 +87,8 @@ public final class ShadowQueueFact { public String getDefault07() { return default07; } public String getDefault08() { return default08; } public String getContentHash() { return contentHash; } + public Integer getIssued() { return issued; } + public Integer getExecutionStatus() { return executionStatus; } private static String required(String value, String name) { if (value == null || value.trim().isEmpty()) { @@ -111,6 +119,8 @@ public final class ShadowQueueFact { private String default06; private String default07; private String default08; + private Integer issued; + private Integer executionStatus; private Builder(String tableName, String guid) { this.tableName = tableName; @@ -136,6 +146,8 @@ public final class ShadowQueueFact { public Builder default06(String value) { default06 = value; return this; } public Builder default07(String value) { default07 = 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); } } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java index 9e8e33f..26207f5 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java @@ -182,7 +182,7 @@ public final class SqlServerShadowFactReader { static final String QUEUE_SQL_PREFIX = "SELECT TOP (?) guid, task_no, task_type, priority, " + "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 "; static final String QUEUE_SQL_SUFFIX = " WHERE create_date_utc > ? " @@ -348,6 +348,8 @@ public final class SqlServerShadowFactReader { .theFloor(result.getString("the_floor")) .createdAt(instant(result, "create_date_utc")) .agvId(result.getString("agv_id")) + .issued(intValue(result, "issued")) + .executionStatus(intValue(result, "execution_status")) .pathInfo(result.getString("path_info")) .turnPath(result.getString("trun_path")) .default02(result.getString("default02")) diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java index 8a5894b..de9542a 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java @@ -168,6 +168,7 @@ public final class Ft546ShadowCycle { "INVALID_COMMAND_FACTS:" + message(failure), progress); } progress.enter("EVIDENCE_BUILD"); + progress.dispatch(batch, snapshot, result.getDecisions()); List evidence = evidenceCollector.collect(batch, result.getDecisions(), encoded, clock.instant()); progress.enter("EVIDENCE_SAVE"); @@ -199,7 +200,9 @@ public final class Ft546ShadowCycle { .getReasonCode(); if ("EVIDENCE_KEY_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; } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java index dfbd585..5c86557 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java @@ -18,6 +18,7 @@ final class ShadowCycleDiagnostics { private final Map phaseNanos = new LinkedHashMap(); private Map completed; private boolean cancelled; + private Map dispatch = Collections.emptyMap(); ShadowCycleDiagnostics() { this(System::nanoTime); } @@ -27,6 +28,43 @@ final class ShadowCycleDiagnostics { 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 values = new LinkedHashMap(); + 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 observations = new LinkedHashMap(); + 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> holds = new ArrayList>(); + for (com.ruoyi.wcs.application.dispatch.HeldDispatch hold : result.getHeld()) { + if (holds.size() == 20) break; + Map row = new LinkedHashMap(); + row.put("taskId", hold.getOrderId()); row.put("vehicleId", hold.getVehicleId()); + row.put("reasonCode", hold.getReasonCode()); + List blockers = new ArrayList(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) { if (cancelled || Thread.currentThread().isInterrupted()) { throw new CancellationException("SHADOW_CYCLE_CANCELLED"); @@ -49,6 +87,7 @@ final class ShadowCycleDiagnostics { Map timings = new LinkedHashMap(); phaseNanos.forEach((key, value) -> timings.put(key, TimeUnit.NANOSECONDS.toMillis(value))); snapshot.put("phaseMillis", Collections.unmodifiableMap(timings)); + snapshot.put("dispatch", dispatch); if (worker != null) { snapshot.put("workerState", worker.getState().name()); List stack = new ArrayList(); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java index 9be97eb..5eff6ec 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java @@ -39,12 +39,20 @@ public final class ShadowDispatchSnapshotAssembler { private final long topologyVersion; private final Function stateLoader; private final Clock clock; + private final java.util.Set safeWaitSites; + private final java.util.Set safeYieldSites; public ShadowDispatchSnapshotAssembler( String warehouseCode, long topologyVersion, Function stateLoader, Clock clock) { + this(warehouseCode, topologyVersion, stateLoader, clock, Collections.emptySet(), Collections.emptySet()); + } + + public ShadowDispatchSnapshotAssembler(String warehouseCode, long topologyVersion, + Function stateLoader, Clock clock, + java.util.Set safeWaitSites, java.util.Set safeYieldSites) { this.warehouseCode = required(warehouseCode, "warehouseCode"); if (topologyVersion <= 0L) { throw new IllegalArgumentException( @@ -53,6 +61,8 @@ public final class ShadowDispatchSnapshotAssembler { this.topologyVersion = topologyVersion; this.stateLoader = Objects.requireNonNull(stateLoader, "stateLoader"); this.clock = Objects.requireNonNull(clock, "clock"); + this.safeWaitSites = safeSites(safeWaitSites); + this.safeYieldSites = safeSites(safeYieldSites); } public DispatchSnapshot assemble(ShadowFactBatch batch) { @@ -74,7 +84,8 @@ public final class ShadowDispatchSnapshotAssembler { continue; } 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()); if (coordinates.put(coordinate, fact) != null) { throw new IllegalArgumentException( @@ -96,7 +107,26 @@ public final class ShadowDispatchSnapshotAssembler { Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); - return new TopologyCompiler().compile(definition); + EffectiveTopology topology = new TopologyCompiler().compile(definition); + java.util.Set configured = new java.util.LinkedHashSet(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 safeSites(java.util.Set values) { + java.util.Set normalized = new java.util.TreeSet(); + 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 tasks(ShadowFactBatch batch, @@ -104,10 +134,10 @@ public final class ShadowDispatchSnapshotAssembler { List tasks = new ArrayList(); Instant now = clock.instant(); for (ShadowQueueFact fact : batch.getQueues()) { - if (!ACTIVE_QUEUE.equals(fact.getTableName())) { + if (com.ruoyi.shadow.facts.ShadowQueueDispatchEligibility.observationReason(fact, batch) != null) { 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"); if (!topology.containsNode(source) || !topology.containsNode(destination)) { @@ -130,6 +160,10 @@ public final class ShadowDispatchSnapshotAssembler { values.put("parentTaskId", parentTaskId); values.put("phase", phase(required(fact.getTaskType(), "taskType"))); 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")); short taskType = parsedShort(required(fact.getDefault02(), "default02"), "default02"); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowVehicleAssignmentPolicy.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowVehicleAssignmentPolicy.java new file mode 100644 index 0000000..df02f96 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowVehicleAssignmentPolicy.java @@ -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 vehicles, + EffectiveTopology topology) { + String bound = task.getCommandParameters().get("shadowBoundVehicleId"); + if (bound == null) return delegate.assign(task, vehicles, topology); + List candidates = new ArrayList(); + for (VehicleSnapshot vehicle : vehicles) { + if (bound.equals(vehicle.getVehicleId())) candidates.add(vehicle); + } + return delegate.assign(task, candidates, topology); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java new file mode 100644 index 0000000..39966c5 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java @@ -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 describe(ShadowProperties properties, Environment env) { + Map settings = new TreeMap(); + 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 result = new TreeMap(); + 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 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); } + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java index 6da2fe7..7cdb8d0 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java @@ -17,6 +17,7 @@ public final class ShadowStartupReport { private volatile String permanentDegradedReason; private final Map startupChecks; private volatile Map cycleDiagnostics = Collections.emptyMap(); + private volatile Map runtimeIdentity = Collections.emptyMap(); private ShadowStartupReport(Status status, Instant checkedAt, Instant lastCompletedCycle, @@ -46,6 +47,10 @@ public final class ShadowStartupReport { public String getReasonCode() { return reasonCode; } public Map getStartupChecks() { return startupChecks; } public Map getCycleDiagnostics() { return cycleDiagnostics; } + public Map getRuntimeIdentity() { return runtimeIdentity; } + public void recordRuntimeIdentity(Map identity) { + runtimeIdentity = Collections.unmodifiableMap(new LinkedHashMap(identity)); + } public void recordCycleDiagnostics(Map diagnostics) { cycleDiagnostics = Collections.unmodifiableMap(new LinkedHashMap(diagnostics)); @@ -107,6 +112,7 @@ public final class ShadowStartupReport { || permanentDegradedReason != null); state.put("sourceLagMillis", sourceLagMillis); state.put("cycleDiagnostics", cycleDiagnostics); + state.put("runtimeIdentity", runtimeIdentity); return Collections.unmodifiableMap(state); } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowInputController.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowInputController.java new file mode 100644 index 0000000..d0a52d5 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowInputController.java @@ -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> 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.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; } + } +} diff --git a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml index a84882f..c06690e 100644 --- a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml +++ b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml @@ -6,6 +6,9 @@ spring: banner-mode: "off" shadow: enabled: false + traffic: + safe-wait-sites: [] + safe-yield-sites: [] cycle: deadline-ms: 10000 poll: diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java index d033c7c..cd519df 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java @@ -116,6 +116,7 @@ class Ft546ShadowApplicationTest { context.getBean(com.ruoyi.wcs.port.out .TrafficStateRepository.class)); assertNotNull(context.getBean(ShadowStatusController.class)); + assertNotNull(context.getBean(com.ruoyi.shadow.web.ShadowInputController.class)); Map persisted = context.getBean( RecordingShadowDatabaseGate.class).getPersistedChecks(); assertNotNull(persisted); @@ -138,11 +139,19 @@ class Ft546ShadowApplicationTest { .andExpect(jsonPath("$.status").value("STARTING")) .andExpect(jsonPath("$.checkedAt").exists()) .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") .value("PASS")) .andExpect(jsonPath( "$.startupChecks.SIDE_EFFECT_BEANS_ABSENT") .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()); }); } diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java index c7a9c10..7bffefb 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java @@ -64,17 +64,18 @@ class Ft546ShadowEndToEndTest { private static final String FIXTURES = "/fixtures/"; @Test - void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence() + void ordinaryPredictsWhileHandoffPhasesStayObservationOnly() throws Exception { ScenarioResult ordinary = run("ordinary-cycle.json"); ScenarioResult crossFloor = run("cross-floor-cycle.json"); ScenarioResult twoStage = run("two-stage-cycle.json"); assertComparable(ordinary, "4004", "4000", "4002", "4021"); - assertComparable(crossFloor, "4004", "4000", "4002", "4021", - "4020", "4022"); - assertComparable(twoStage, "4404", "4422", "4423", "4402", - "4400"); + for (ScenarioResult special : Arrays.asList(crossFloor, twoStage)) { + assertTrue(special.cycle.getDecisions().getGranted().isEmpty()); + assertTrue(special.cycle.getEncodedCommands().isEmpty()); + assertEquals(1, special.persistedCycles); + } assertEquals(3, ordinary.persistedCycles + crossFloor.persistedCycles + twoStage.persistedCycles); } @@ -187,6 +188,7 @@ class Ft546ShadowEndToEndTest { ShadowQueueFact.Builder queue = ShadowQueueFact.builder( "shadow_ro.v_wcs_srgv_queue", fixture.queueGuid) .taskNo(fixture.taskNo) + .issued(0).executionStatus(1) .taskType(fixture.taskType) .priority(Long.valueOf(fixture.priority)) .cargoPickSite(fixture.startSite) diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java index 6886077..b07cc09 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java @@ -661,6 +661,7 @@ class Ft546ShadowFaultIsolationTest { private static ShadowQueueFact queue(String table, String guid, Instant createdAt) { return ShadowQueueFact.builder(table, guid) + .issued(0).executionStatus(1) .taskNo("30001") .taskType("2") .priority(1L) diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java index dbde6fc..d72f089 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java @@ -80,6 +80,90 @@ class JdbcShadowEvidenceRepositoryTest { 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> 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 void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception { createTrafficSchema(); @@ -147,8 +231,7 @@ class JdbcShadowEvidenceRepositoryTest { objectMapper, run, 1L); assertEquals(evidence.getCycleId(), bounded.save(evidence).getCycleId()); assertEquals(1, count("shadow_cycle")); - assertEquals(evidence.getInputJson(), jdbc.queryForObject( - "SELECT captured_json FROM shadow_input_batch", String.class)); + assertEquals(evidence.getInputJson(), repository.loadInput(evidence.getCycleId()).get("capturedJson")); } @Test diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java index 220305d..2d9d9c1 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java @@ -54,6 +54,7 @@ class ShadowComparisonServiceTest { .taskNo("30001") .taskType("2") .agvId("VEH-1") + .startSite("070501").endSite("070502") .pathInfo("70501,70502") .default02("1") .build(); diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowInputPayloadCodecTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowInputPayloadCodecTest.java new file mode 100644 index 0000000..d5d8f9c --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowInputPayloadCodecTest.java @@ -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")); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java index e14bd42..b5bdb77 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java @@ -211,6 +211,7 @@ class ShadowFactBatchValidatorTest { .startSite("070501").endSite("070502") .cargoPickSite("070501").cargoPlaceSite("070502") .createdAt(null).agvId("1") + .issued(0).executionStatus(1) .pathInfo("70501,70502,").turnPath("") .default02("1").build(); @@ -232,6 +233,7 @@ class ShadowFactBatchValidatorTest { .startSite("070501").endSite("070502") .cargoPickSite("070501").cargoPlaceSite("070502") .createdAt(NOW.minusSeconds(2)).agvId("1") + .issued(0).executionStatus(1) .pathInfo("70501,70502,").turnPath("") .default02("1").default06("070503").default08("2") .build(); @@ -278,6 +280,7 @@ class ShadowFactBatchValidatorTest { .startSite(start).endSite(end) .cargoPickSite(start).cargoPlaceSite(end) .createdAt(NOW.minusSeconds(2)).agvId(agvId) + .issued(0).executionStatus(1) .pathInfo("70501,70502,").turnPath("") .default02("1").build(); } diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java index ae4c226..b246c2e 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java @@ -411,6 +411,7 @@ class SqlServerShadowFactReaderTest { ShadowLegacyQueueResult crossFloor = projector.project( queueBuilder("CROSS", NOW).taskNo("30002") + .endSite("080501") .default02("8").default07("8") .pathInfo("70501,70507,80507,80501,") .turnPath("70507,80507").build()); @@ -421,6 +422,7 @@ class SqlServerShadowFactReaderTest { ShadowLegacyQueueResult stageOne = projector.project( queueBuilder("STAGE-1", NOW).taskNo("30003") + .endSite("070503") .default02("1").default08("1") .pathInfo("70501,70503,").turnPath("").build()); assertEquals("4404,4422,4402,4400", stageOne.getWriteAddresses()); @@ -455,7 +457,7 @@ class SqlServerShadowFactReaderTest { } @Test - void legacyProjectionUsesTheExecutedPathEndpointsInsteadOfQueueOrigins() { + void legacyProjectionUsesTheActualSendOriginWhenTheVehicleWasRepositioned() { ShadowQueueFact queue = queueBuilder("REPOSITIONED", NOW) .startSite("070501").endSite("070504") .pathInfo("070502,070503,070504,") @@ -464,13 +466,13 @@ class SqlServerShadowFactReaderTest { ShadowLegacyQueueResult result = new LegacyQueueProtocolProjector().project(queue); - assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0), - result.write("4004").getValues()); + assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0), result.write("4004").getValues()); } @Test void legacyProjectionAcceptsPersistedZeroPaddedTurnPoints() { ShadowQueueFact queue = queueBuilder("PADDED-TURNS", NOW) + .endSite("070504") .pathInfo("070501,070502,070504,") .turnPath("070503,0,0,0,0,0").build(); diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java index fee2458..d0a6564 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java @@ -526,6 +526,7 @@ class Ft546ShadowCycleTest { private static ShadowQueueFact queue(String table, String guid, Instant createdAt) { return ShadowQueueFact.builder(table, guid) + .issued(0).executionStatus(1) .taskNo("30001") .taskType("2") .priority(2L) @@ -544,6 +545,7 @@ class Ft546ShadowCycleTest { private static ShadowQueueFact queueWithoutLegacyAssignment( Instant createdAt) { return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE") + .issued(0).executionStatus(1) .taskNo("30001") .taskType("2") .priority(2L) diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java index c360c17..2309291 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java @@ -46,6 +46,11 @@ class ShadowCapturedInputReplayTest { String raw = row.get("captured_json").asText(); assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(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(); ShadowFactValidation validation = new ShadowFactBatchValidator( Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now); @@ -60,6 +65,7 @@ class ShadowCapturedInputReplayTest { "FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC)); DispatchSnapshot snapshot = assembler.assemble(batch); + assertTrue(snapshot.getReadySteps().isEmpty(), "old capture has no queue lifecycle"); RoutePlanner planner = new AStarRoutePlanner(); DispatchCoordinator coordinator = new DispatchCoordinator( new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE), @@ -81,6 +87,8 @@ class ShadowCapturedInputReplayTest { } assertEquals(300, inputs); assertEquals(600, legacyAvailable); + assertEquals(0, grants); + assertEquals(0, writes); System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs + " legacyAvailable=" + legacyAvailable + " grants=" + grants + " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons); @@ -95,6 +103,7 @@ class ShadowCapturedInputReplayTest { .cargoPickSite(text(q, "cargoPickSite")).cargoPlaceSite(text(q, "cargoPlaceSite")) .startSite(text(q, "startSite")).endSite(text(q, "endSite")).theFloor(text(q, "theFloor")) .createdAt(instant(q, "createdAt")).agvId(text(q, "agvId")) + .issued(number(q, "issued")).executionStatus(number(q, "executionStatus")) .pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath")) .default02(text(q, "default02")).default03(text(q, "default03")) .default04(text(q, "default04")).default05(text(q, "default05")) diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldEvidenceReplayTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldEvidenceReplayTest.java new file mode 100644 index 0000000..ee2129c --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldEvidenceReplayTest.java @@ -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 hashes = new HashSet(); + 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)); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java index c3af139..b3993fa 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java @@ -44,6 +44,115 @@ class ShadowFieldRegressionTest { private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue"; 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 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 void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() { VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build()); @@ -122,8 +231,10 @@ class ShadowFieldRegressionTest { .default02(type).default07("1").build(); ShadowFactBatch batch = liftBatch(queue); assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus()); - DispatchTask task = assembler().assemble(batch).getReadySteps().get(0); - assertEquals("8".equals(type) ? "070502" : "090502", task.getDestinationNodeId()); + assertEquals("8".equals(type) ? "070502" : "090502", + 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()); } } @@ -248,7 +359,7 @@ class ShadowFieldRegressionTest { return ShadowQueueFact.builder(table, guid).taskNo("502").taskType("2").priority(2L) .startSite(start).endSite(end).cargoPickSite(start).cargoPlaceSite(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() { diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java index e9c42b8..0bdb640 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java @@ -58,7 +58,7 @@ class ShadowLivePathRegressionTest { } @Test - void overlappingApproachAndExecutionReserveEachResourceOnceWithoutLosingRouteOrder() { + void boundPhaseMovesFromLivePositionWithoutReturningToAnOldQueueOrigin() { DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201"); AStarRoutePlanner planner = new AStarRoutePlanner(); DispatchCoordinator coordinator = new DispatchCoordinator( @@ -68,10 +68,9 @@ class ShadowLivePathRegressionTest { DispatchCycleResult result = coordinator.dispatch(snapshot); assertEquals(1, result.getGranted().size()); RoutePlan route = result.getGranted().get(0).getProtectedRoute(); - assertEquals(Arrays.asList("060301", "WY0002", "060201", "WY0001", "060101", - "070101", "060101", "WY0001", "060201"), route.getNodeIds()); + assertEquals(Arrays.asList("060301", "WY0002", "060201"), route.getNodeIds()); 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()); } @@ -120,6 +119,7 @@ class ShadowLivePathRegressionTest { private static ShadowQueueFact.Builder queue(String start, String end) { return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD") + .issued(0).executionStatus(1) .taskNo("31312").taskType("1").priority(2L).startSite(start).endSite(end) .cargoPickSite(start).cargoPlaceSite(end).theFloor("1").agvId("3") .createdAt(NOW.minusSeconds(10)).default02("4").default08("").turnPath(""); diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentityTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentityTest.java new file mode 100644 index 0000000..3bb92aa --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentityTest.java @@ -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 first = ShadowRuntimeIdentity.describe(p, env); + p.getSource().setPassword("secret-source"); p.getEvidence().setPassword("secret-evidence"); + p.getEvidence().getRun().setArtifactSha256("operator-label"); + Map 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")); + } +}