@things-factory/headless-twin 10.0.19 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +10 -1
  2. package/dist-server/engine/canonical-ingest.js +110 -33
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/event-time.d.ts +25 -0
  5. package/dist-server/engine/event-time.js +98 -0
  6. package/dist-server/engine/event-time.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +23 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/ingest-dedupe.d.ts +18 -6
  11. package/dist-server/engine/ingest-dedupe.js +201 -15
  12. package/dist-server/engine/ingest-dedupe.js.map +1 -1
  13. package/dist-server/engine/ingest-health.d.ts +60 -1
  14. package/dist-server/engine/ingest-health.js +43 -8
  15. package/dist-server/engine/ingest-health.js.map +1 -1
  16. package/dist-server/engine/kpi-fold.d.ts +19 -0
  17. package/dist-server/engine/kpi-fold.js +4 -1
  18. package/dist-server/engine/kpi-fold.js.map +1 -1
  19. package/dist-server/engine/kpi-query.js +23 -3
  20. package/dist-server/engine/kpi-query.js.map +1 -1
  21. package/dist-server/engine/oee-accumulator.d.ts +35 -2
  22. package/dist-server/engine/oee-accumulator.js +56 -4
  23. package/dist-server/engine/oee-accumulator.js.map +1 -1
  24. package/dist-server/engine/twin-engine.d.ts +72 -3
  25. package/dist-server/engine/twin-engine.js +118 -5
  26. package/dist-server/engine/twin-engine.js.map +1 -1
  27. package/dist-server/index.js +21 -0
  28. package/dist-server/index.js.map +1 -1
  29. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.d.ts +5 -0
  30. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js +66 -0
  31. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +1 -0
  32. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.d.ts +5 -0
  33. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js +79 -0
  34. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +1 -0
  35. package/dist-server/migrations/index.js +5 -1
  36. package/dist-server/migrations/index.js.map +1 -1
  37. package/dist-server/routes.js +12 -20
  38. package/dist-server/routes.js.map +1 -1
  39. package/dist-server/service/actuation/actuation-advisor.d.ts +60 -0
  40. package/dist-server/service/actuation/actuation-advisor.js +53 -0
  41. package/dist-server/service/actuation/actuation-advisor.js.map +1 -0
  42. package/dist-server/service/actuation/actuation-outcome.d.ts +52 -0
  43. package/dist-server/service/actuation/actuation-outcome.js +34 -0
  44. package/dist-server/service/actuation/actuation-outcome.js.map +1 -0
  45. package/dist-server/service/actuation/actuation-rule-resolver.d.ts +19 -0
  46. package/dist-server/service/actuation/actuation-rule-resolver.js +161 -0
  47. package/dist-server/service/actuation/actuation-rule-resolver.js.map +1 -0
  48. package/dist-server/service/actuation/actuation-rule.d.ts +55 -0
  49. package/dist-server/service/actuation/actuation-rule.js +130 -0
  50. package/dist-server/service/actuation/actuation-rule.js.map +1 -0
  51. package/dist-server/service/actuation/approval-gateway.d.ts +20 -0
  52. package/dist-server/service/actuation/approval-gateway.js +14 -0
  53. package/dist-server/service/actuation/approval-gateway.js.map +1 -0
  54. package/dist-server/service/actuation/catch-up-effects.d.ts +16 -0
  55. package/dist-server/service/actuation/catch-up-effects.js +120 -0
  56. package/dist-server/service/actuation/catch-up-effects.js.map +1 -0
  57. package/dist-server/service/actuation/command-dispatcher.d.ts +206 -8
  58. package/dist-server/service/actuation/command-dispatcher.js +191 -14
  59. package/dist-server/service/actuation/command-dispatcher.js.map +1 -1
  60. package/dist-server/service/actuation/command-store.d.ts +10 -2
  61. package/dist-server/service/actuation/command-store.js +168 -5
  62. package/dist-server/service/actuation/command-store.js.map +1 -1
  63. package/dist-server/service/actuation/dispatch-after-approval.d.ts +11 -0
  64. package/dist-server/service/actuation/dispatch-after-approval.js +73 -0
  65. package/dist-server/service/actuation/dispatch-after-approval.js.map +1 -0
  66. package/dist-server/service/actuation/effect-match.d.ts +53 -0
  67. package/dist-server/service/actuation/effect-match.js +65 -0
  68. package/dist-server/service/actuation/effect-match.js.map +1 -0
  69. package/dist-server/service/actuation/index.d.ts +21 -2
  70. package/dist-server/service/actuation/index.js +21 -2
  71. package/dist-server/service/actuation/index.js.map +1 -1
  72. package/dist-server/service/actuation/retry-plan.d.ts +63 -0
  73. package/dist-server/service/actuation/retry-plan.js +99 -0
  74. package/dist-server/service/actuation/retry-plan.js.map +1 -0
  75. package/dist-server/service/actuation/rule-evaluate.d.ts +127 -0
  76. package/dist-server/service/actuation/rule-evaluate.js +205 -0
  77. package/dist-server/service/actuation/rule-evaluate.js.map +1 -0
  78. package/dist-server/service/actuation/rule-loop.d.ts +5 -0
  79. package/dist-server/service/actuation/rule-loop.js +156 -0
  80. package/dist-server/service/actuation/rule-loop.js.map +1 -0
  81. package/dist-server/service/actuation/rule-runner.d.ts +52 -0
  82. package/dist-server/service/actuation/rule-runner.js +135 -0
  83. package/dist-server/service/actuation/rule-runner.js.map +1 -0
  84. package/dist-server/service/actuation/rule-wiring.d.ts +24 -0
  85. package/dist-server/service/actuation/rule-wiring.js +80 -0
  86. package/dist-server/service/actuation/rule-wiring.js.map +1 -0
  87. package/dist-server/service/actuation/settle-effects.d.ts +21 -0
  88. package/dist-server/service/actuation/settle-effects.js +101 -0
  89. package/dist-server/service/actuation/settle-effects.js.map +1 -0
  90. package/dist-server/service/actuation/sweep-dispatch.d.ts +37 -0
  91. package/dist-server/service/actuation/sweep-dispatch.js +125 -0
  92. package/dist-server/service/actuation/sweep-dispatch.js.map +1 -0
  93. package/dist-server/service/actuation/twin-command-resolver.d.ts +120 -0
  94. package/dist-server/service/actuation/twin-command-resolver.js +406 -0
  95. package/dist-server/service/actuation/twin-command-resolver.js.map +1 -0
  96. package/dist-server/service/actuation/twin-command.d.ts +81 -0
  97. package/dist-server/service/actuation/twin-command.js +60 -0
  98. package/dist-server/service/actuation/twin-command.js.map +1 -1
  99. package/dist-server/service/actuation/twin-now.d.ts +41 -0
  100. package/dist-server/service/actuation/twin-now.js +65 -0
  101. package/dist-server/service/actuation/twin-now.js.map +1 -0
  102. package/dist-server/service/index.d.ts +5 -2
  103. package/dist-server/service/index.js +8 -0
  104. package/dist-server/service/index.js.map +1 -1
  105. package/dist-server/service/reference/actuation-routing.d.ts +60 -0
  106. package/dist-server/service/reference/actuation-routing.js +126 -0
  107. package/dist-server/service/reference/actuation-routing.js.map +1 -0
  108. package/dist-server/service/reference/actuation-target.d.ts +65 -0
  109. package/dist-server/service/reference/actuation-target.js +94 -0
  110. package/dist-server/service/reference/actuation-target.js.map +1 -0
  111. package/dist-server/service/reference/connection-portability-resolver.d.ts +29 -0
  112. package/dist-server/service/reference/connection-portability-resolver.js +181 -0
  113. package/dist-server/service/reference/connection-portability-resolver.js.map +1 -0
  114. package/dist-server/service/reference/connection-portability.d.ts +138 -0
  115. package/dist-server/service/reference/connection-portability.js +230 -0
  116. package/dist-server/service/reference/connection-portability.js.map +1 -0
  117. package/dist-server/service/reference/fill-cadence.d.ts +17 -0
  118. package/dist-server/service/reference/fill-cadence.js +31 -0
  119. package/dist-server/service/reference/fill-cadence.js.map +1 -0
  120. package/dist-server/service/reference/fill-dedupe.d.ts +16 -0
  121. package/dist-server/service/reference/fill-dedupe.js +63 -0
  122. package/dist-server/service/reference/fill-dedupe.js.map +1 -0
  123. package/dist-server/service/reference/fill-loop.d.ts +35 -0
  124. package/dist-server/service/reference/fill-loop.js +187 -0
  125. package/dist-server/service/reference/fill-loop.js.map +1 -0
  126. package/dist-server/service/reference/fill-span.d.ts +10 -0
  127. package/dist-server/service/reference/fill-span.js +37 -0
  128. package/dist-server/service/reference/fill-span.js.map +1 -0
  129. package/dist-server/service/reference/hook-contract.d.ts +88 -4
  130. package/dist-server/service/reference/hook-contract.js +105 -4
  131. package/dist-server/service/reference/hook-contract.js.map +1 -1
  132. package/dist-server/service/reference/hook-rejected.d.ts +25 -0
  133. package/dist-server/service/reference/hook-rejected.js +48 -0
  134. package/dist-server/service/reference/hook-rejected.js.map +1 -0
  135. package/dist-server/service/reference/index.d.ts +4 -1
  136. package/dist-server/service/reference/index.js +5 -1
  137. package/dist-server/service/reference/index.js.map +1 -1
  138. package/dist-server/service/reference/live-cadence.d.ts +68 -0
  139. package/dist-server/service/reference/live-cadence.js +44 -0
  140. package/dist-server/service/reference/live-cadence.js.map +1 -0
  141. package/dist-server/service/reference/live-feed-lease.d.ts +15 -0
  142. package/dist-server/service/reference/live-feed-lease.js +56 -0
  143. package/dist-server/service/reference/live-feed-lease.js.map +1 -0
  144. package/dist-server/service/reference/live-ingest.d.ts +21 -0
  145. package/dist-server/service/reference/live-ingest.js +66 -0
  146. package/dist-server/service/reference/live-ingest.js.map +1 -0
  147. package/dist-server/service/reference/reference-adapter.d.ts +291 -2
  148. package/dist-server/service/reference/reference-adapter.js +19 -1
  149. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  150. package/dist-server/service/reference/reference-fill-run.d.ts +50 -0
  151. package/dist-server/service/reference/reference-fill-run.js +142 -0
  152. package/dist-server/service/reference/reference-fill-run.js.map +1 -0
  153. package/dist-server/service/reference/reference-fill.d.ts +90 -0
  154. package/dist-server/service/reference/reference-fill.js +141 -0
  155. package/dist-server/service/reference/reference-fill.js.map +1 -0
  156. package/dist-server/service/reference/reference-hook.d.ts +22 -0
  157. package/dist-server/service/reference/reference-hook.js +135 -55
  158. package/dist-server/service/reference/reference-hook.js.map +1 -1
  159. package/dist-server/service/reference/reference-link-groups.d.ts +10 -0
  160. package/dist-server/service/reference/reference-link-groups.js +15 -0
  161. package/dist-server/service/reference/reference-link-groups.js.map +1 -0
  162. package/dist-server/service/reference/reference-links.d.ts +14 -0
  163. package/dist-server/service/reference/reference-links.js +73 -0
  164. package/dist-server/service/reference/reference-links.js.map +1 -0
  165. package/dist-server/service/reference/reference-live.d.ts +1 -0
  166. package/dist-server/service/reference/reference-live.js +32 -6
  167. package/dist-server/service/reference/reference-live.js.map +1 -1
  168. package/dist-server/service/reference/reference-master.d.ts +56 -2
  169. package/dist-server/service/reference/reference-master.js +33 -3
  170. package/dist-server/service/reference/reference-master.js.map +1 -1
  171. package/dist-server/service/reference/reference-resolver.d.ts +27 -0
  172. package/dist-server/service/reference/reference-resolver.js +79 -1
  173. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  174. package/dist-server/service/twin-event/twin-event-keys.d.ts +31 -3
  175. package/dist-server/service/twin-event/twin-event-keys.js +36 -4
  176. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  177. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  178. package/dist-server/service/twin-event/twin-event.js +11 -2
  179. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  180. package/dist-server/service/twin-model/choose-recipe.d.ts +15 -0
  181. package/dist-server/service/twin-model/choose-recipe.js +33 -0
  182. package/dist-server/service/twin-model/choose-recipe.js.map +1 -0
  183. package/dist-server/service/twin-model/external-resolver.d.ts +76 -0
  184. package/dist-server/service/twin-model/external-resolver.js +109 -0
  185. package/dist-server/service/twin-model/external-resolver.js.map +1 -0
  186. package/dist-server/service/twin-model/project-structure.js +16 -3
  187. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  188. package/dist-server/service/twin-model/twin-equipment.d.ts +17 -0
  189. package/dist-server/service/twin-model/twin-equipment.js +10 -0
  190. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  191. package/dist-server/service/twin-model/twin-lineage-query.js +75 -1
  192. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  193. package/dist-server/service/twin-model/twin-model-item-query.js +94 -5
  194. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  195. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  196. package/dist-server/service/twin-space/twin-space.d.ts +29 -0
  197. package/dist-server/service/twin-space/twin-space.js +13 -0
  198. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  199. package/dist-server/service/twin-subject/event-subjects.d.ts +3 -1
  200. package/dist-server/service/twin-subject/event-subjects.js +8 -1
  201. package/dist-server/service/twin-subject/event-subjects.js.map +1 -1
  202. package/dist-shared/entity-delta.js +1 -1
  203. package/dist-shared/entity-delta.js.map +1 -1
  204. package/package.json +7 -7
  205. package/server/engine/canonical-ingest.ts +119 -33
  206. package/server/engine/event-time.ts +96 -0
  207. package/server/engine/index.ts +20 -0
  208. package/server/engine/ingest-dedupe.ts +203 -27
  209. package/server/engine/ingest-health.ts +91 -6
  210. package/server/engine/kpi-fold.ts +24 -1
  211. package/server/engine/kpi-query.ts +23 -4
  212. package/server/engine/oee-accumulator.ts +80 -8
  213. package/server/engine/twin-engine.ts +119 -5
  214. package/server/index.ts +23 -0
  215. package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +66 -0
  216. package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +82 -0
  217. package/server/migrations/index.ts +5 -1
  218. package/server/routes.ts +12 -24
  219. package/server/service/actuation/actuation-advisor.ts +124 -0
  220. package/server/service/actuation/actuation-outcome.ts +97 -0
  221. package/server/service/actuation/actuation-rule-resolver.ts +147 -0
  222. package/server/service/actuation/actuation-rule.ts +139 -0
  223. package/server/service/actuation/approval-gateway.ts +44 -0
  224. package/server/service/actuation/catch-up-effects.ts +152 -0
  225. package/server/service/actuation/command-dispatcher.ts +401 -20
  226. package/server/service/actuation/command-store.ts +182 -8
  227. package/server/service/actuation/dispatch-after-approval.ts +71 -0
  228. package/server/service/actuation/effect-match.ts +124 -0
  229. package/server/service/actuation/index.ts +21 -2
  230. package/server/service/actuation/retry-plan.ts +145 -0
  231. package/server/service/actuation/rule-evaluate.ts +352 -0
  232. package/server/service/actuation/rule-loop.ts +160 -0
  233. package/server/service/actuation/rule-runner.ts +216 -0
  234. package/server/service/actuation/rule-wiring.ts +84 -0
  235. package/server/service/actuation/settle-effects.ts +115 -0
  236. package/server/service/actuation/sweep-dispatch.ts +179 -0
  237. package/server/service/actuation/twin-command-resolver.ts +387 -0
  238. package/server/service/actuation/twin-command.ts +128 -1
  239. package/server/service/actuation/twin-now.ts +97 -0
  240. package/server/service/index.ts +9 -1
  241. package/server/service/reference/actuation-routing.ts +165 -0
  242. package/server/service/reference/actuation-target.ts +163 -0
  243. package/server/service/reference/connection-portability-resolver.ts +171 -0
  244. package/server/service/reference/connection-portability.ts +285 -0
  245. package/server/service/reference/fill-cadence.ts +29 -0
  246. package/server/service/reference/fill-dedupe.ts +66 -0
  247. package/server/service/reference/fill-loop.ts +153 -0
  248. package/server/service/reference/fill-span.ts +31 -0
  249. package/server/service/reference/hook-contract.ts +185 -5
  250. package/server/service/reference/hook-rejected.ts +53 -0
  251. package/server/service/reference/index.ts +5 -1
  252. package/server/service/reference/live-cadence.ts +129 -0
  253. package/server/service/reference/live-feed-lease.ts +56 -0
  254. package/server/service/reference/live-ingest.ts +94 -0
  255. package/server/service/reference/reference-adapter.ts +327 -5
  256. package/server/service/reference/reference-fill-run.ts +165 -0
  257. package/server/service/reference/reference-fill.ts +223 -0
  258. package/server/service/reference/reference-hook.ts +162 -56
  259. package/server/service/reference/reference-link-groups.ts +21 -0
  260. package/server/service/reference/reference-links.ts +77 -0
  261. package/server/service/reference/reference-live.ts +30 -5
  262. package/server/service/reference/reference-master.ts +90 -5
  263. package/server/service/reference/reference-resolver.ts +77 -2
  264. package/server/service/twin-event/twin-event-keys.ts +37 -4
  265. package/server/service/twin-event/twin-event.ts +18 -1
  266. package/server/service/twin-model/choose-recipe.ts +31 -0
  267. package/server/service/twin-model/external-resolver.ts +161 -0
  268. package/server/service/twin-model/project-structure.ts +16 -3
  269. package/server/service/twin-model/twin-equipment.ts +22 -0
  270. package/server/service/twin-model/twin-lineage-query.ts +82 -1
  271. package/server/service/twin-model/twin-model-item-query.ts +95 -4
  272. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  273. package/server/service/twin-space/twin-space.ts +39 -0
  274. package/server/service/twin-subject/event-subjects.ts +8 -1
  275. package/shared/entity-delta.ts +1 -1
  276. package/test/ack-shape-alignment.test.ts +97 -0
  277. package/test/actuation-approval-door.test.ts +421 -0
  278. package/test/actuation-dispatch.test.ts +16 -3
  279. package/test/actuation-effect.test.ts +162 -0
  280. package/test/actuation-outcome.test.ts +152 -0
  281. package/test/actuation-rule-runner.test.ts +281 -0
  282. package/test/actuation-rule.test.ts +266 -0
  283. package/test/actuation-seam.test.ts +249 -0
  284. package/test/actuation-settle.test.ts +164 -0
  285. package/test/actuation-target.test.ts +145 -0
  286. package/test/canonical-ingest-vocabularies.test.ts +49 -2
  287. package/test/capability-mapping.test.ts +1 -1
  288. package/test/choose-recipe.test.ts +117 -0
  289. package/test/command-store-writes-columns.test.ts +126 -0
  290. package/test/connection-portability-doors.test.ts +87 -0
  291. package/test/connector-capability-declaration.test.ts +54 -4
  292. package/test/declaration-reaches-model.test.ts +43 -0
  293. package/test/dedupe-transformation.test.ts +112 -0
  294. package/test/entity-delta.test.ts +1 -1
  295. package/test/event-subjects.test.ts +2 -1
  296. package/test/external-resolver.test.ts +171 -0
  297. package/test/fill-dedupe.test.ts +59 -0
  298. package/test/fill-loop.test.ts +35 -0
  299. package/test/first-envelope.test.ts +153 -0
  300. package/test/fold-shift-dst.test.ts +110 -0
  301. package/test/home-in-area.test.ts +90 -0
  302. package/test/hook-rejected-shape.test.ts +86 -0
  303. package/test/hook-response-sum.test.ts +193 -0
  304. package/test/hook-sequence.test.ts +1 -1
  305. package/test/ingest-dedupe.test.ts +56 -0
  306. package/test/ingest-expected-quiet.test.ts +111 -0
  307. package/test/ingest-idempotent.test.ts +79 -0
  308. package/test/ingest-reconcile-four-ways.test.ts +97 -0
  309. package/test/ingest-rules-header.test.ts +100 -0
  310. package/test/ingest-window-rolls-unobserved.test.ts +72 -0
  311. package/test/intake-mapping-parity.test.ts +175 -0
  312. package/test/live-cadence.test.ts +170 -0
  313. package/test/live-feed-lease.test.ts +63 -0
  314. package/test/live-mirror-parity.test.ts +1 -1
  315. package/test/oee-accumulator.test.ts +3 -3
  316. package/test/oee-site-calendar.test.ts +146 -0
  317. package/test/order-and-transaction-split.test.ts +107 -0
  318. package/test/reference-fill.test.ts +235 -0
  319. package/test/reference-hook.test.ts +1 -1
  320. package/test/reference-links.test.ts +56 -0
  321. package/test/retry-plan.test.ts +139 -0
  322. package/test/sweep-dispatch.test.ts +191 -0
  323. package/test/twin-event-keys.test.ts +20 -6
  324. package/test/twin-model-item-db.test.ts +6 -6
  325. package/test/twin-model-tree-db.test.ts +6 -6
  326. package/tsconfig.shared.tsbuildinfo +1 -1
  327. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,421 @@
1
+ /*
2
+ * **승인의 문은 한 벌이다** — 결재선에 올라간 조치는 여기서 결정되지 않는다.
3
+ *
4
+ * ── 무엇이 있었나 (2026-08-31) ───────────────────────────────────────────
5
+ * 게이트를 세우고 승인 화면을 계획하다가 물었다 — 「무엇을 승인하는 화면인가」. 커맨드 행을 **적는
6
+ * 코드가 하나도 없었다.** 대기 줄은 구조적으로 비어 있었다. 자리는 있고 길이 없는 그 부류다.
7
+ *
8
+ * 그것을 채우면서 문이 둘이 되었다: 이 패키지의 직접 결정과, 업무 목록(worklist)의 결재선. 문이 둘이면
9
+ * 결재선에는 대기로 남아 있는데 조치는 이미 나간 상태가 만들어지고, 사고 뒤에 **어느 기록이 진짜인지**
10
+ * 말할 수 없다.
11
+ *
12
+ * 그래서 규칙 하나: **결재선 자리가 적힌 커맨드는 결재선에서만 결정한다.** 이 시험이 그 규칙을 지킨다.
13
+ */
14
+ import { test } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+
17
+ import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract'
18
+
19
+ import {
20
+ approve,
21
+ dispatch,
22
+ linkApproval,
23
+ propose,
24
+ reject,
25
+ settleByApproval,
26
+ type CommandStore,
27
+ type StoredCommand
28
+ } from '../server/service/actuation/command-dispatcher.js'
29
+
30
+ const DOMAIN = 'd-1'
31
+ const AT = '2026-08-31T02:00:00.000Z'
32
+ const REF = 'activity-instance-1'
33
+
34
+ /* MES 프로필의 선언을 그대로 쓴다 — 시험용 종류를 지어내면 선언과 어긋난 것을 시험이 통과시킨다. */
35
+ const SPEC: readonly CommandTypeSpec[] = MES_COMMANDS
36
+ const ORDER = MES_COMMANDS[0]!
37
+ const PAYLOAD = { materialDefinitionId: 'urn:epc:idpat:sgtin:9521234.00001.*', quantity: 12, uom: 'EA' }
38
+
39
+ const PROPOSER = 'line-lead@plant.example'
40
+ const APPROVER = 'plant-manager@plant.example'
41
+
42
+ const seed = (over: Partial<Parameters<typeof propose>[3]> = {}) => ({
43
+ id: 'cmd-1',
44
+ instanceId: 'twin-a',
45
+ type: ORDER.type,
46
+ origin: 'operator' as const,
47
+ proposedBy: PROPOSER,
48
+ proposedAt: AT,
49
+ payload: PAYLOAD,
50
+ ...over
51
+ })
52
+
53
+ /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */
54
+ function makeStore(initial?: StoredCommand): CommandStore & { rows: Map<string, StoredCommand> } {
55
+ const rows = new Map<string, StoredCommand>(initial ? [[initial.id, initial]] : [])
56
+ const put = (command: StoredCommand) => {
57
+ const copy: StoredCommand = JSON.parse(JSON.stringify(command))
58
+ rows.set(copy.id, copy)
59
+ return copy
60
+ }
61
+ return {
62
+ rows,
63
+ load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null),
64
+ save: async (domainId, command) => {
65
+ assert.equal(domainId, DOMAIN)
66
+ put(command)
67
+ },
68
+ create: async (domainId, command) => {
69
+ assert.equal(domainId, DOMAIN)
70
+ if (rows.has(command.id)) throw new Error(`이미 있는 커맨드다 (${command.id})`)
71
+ return put(command)
72
+ }
73
+ }
74
+ }
75
+
76
+ const ok = async (): Promise<{ ok: boolean; ref?: string }> => ({ ok: true, ref: 'JOB-1' })
77
+
78
+ /* ── 낸다 ─────────────────────────────────────────────────────────────────── */
79
+
80
+ test('★ 낸 조치가 대기 줄에 남는다 — 이 길이 없어 대기 줄이 비어 있었다', async () => {
81
+ const store = makeStore()
82
+ const command = await propose(store, DOMAIN, SPEC, seed())
83
+
84
+ assert.equal(command.state, 'proposed')
85
+ assert.equal(store.rows.size, 1, '행이 적히지 않았다')
86
+ assert.equal(store.rows.get('cmd-1')!.type, ORDER.type)
87
+ assert.equal(store.rows.get('cmd-1')!.payload.quantity, 12, '무엇을 지시하는지가 남지 않으면 승인할 근거가 없다')
88
+ })
89
+
90
+ test('★ 선언되지 않은 종류는 낼 수 없다 — 되돌릴 수 있는지 모르는 채로 게이트를 지나게 하지 않는다', async () => {
91
+ const store = makeStore()
92
+ await assert.rejects(propose(store, DOMAIN, SPEC, seed({ type: 'line.shutdown' })), /선언되지 않은 조치 종류/)
93
+ assert.equal(store.rows.size, 0, '거절하면서 행을 적었다')
94
+ })
95
+
96
+ test('★ 요구하는 칸이 없으면 낼 수 없다 — 빈 지시가 대기 줄에 쌓이지 않게', async () => {
97
+ const store = makeStore()
98
+ await assert.rejects(propose(store, DOMAIN, SPEC, seed({ payload: { quantity: 12 } })), /요구하는 칸이 없다/)
99
+ assert.equal(store.rows.size, 0)
100
+ })
101
+
102
+ test('선언이 「되돌릴 수 없다」이면 그대로 적힌다 — 낸 쪽이 정하지 않는다', async () => {
103
+ const store = makeStore()
104
+ const command = await propose(store, DOMAIN, SPEC, seed())
105
+ assert.equal(command.reversible, ORDER.reversible)
106
+ assert.equal(command.reversible, false, 'MES 생산 지시는 되돌릴 수 없다 — 취소해도 만든 것은 남는다')
107
+ })
108
+
109
+ /* ── 문은 한 벌이다 ───────────────────────────────────────────────────────── */
110
+
111
+ test('★ 결재선에 올라간 조치는 여기서 승인되지 않는다', async () => {
112
+ const store = makeStore()
113
+ await propose(store, DOMAIN, SPEC, seed())
114
+ await linkApproval(store, DOMAIN, 'cmd-1', REF)
115
+
116
+ await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/)
117
+ await assert.rejects(reject(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/)
118
+ assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '막았다면서 상태가 움직였다')
119
+ })
120
+
121
+ test('★ 결재선을 쓰지 않는 배포에서는 직접 결정이 유일한 문이다', async () => {
122
+ const store = makeStore()
123
+ await propose(store, DOMAIN, SPEC, seed())
124
+
125
+ const approved = await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
126
+ assert.equal(approved.state, 'approved')
127
+ assert.equal((await dispatch(store, DOMAIN, 'cmd-1', ok)).state, 'acked')
128
+ })
129
+
130
+ test('★ 올라가지 않은 커맨드에 결재선 판정을 옮기지 않는다 — 어디서 온 판정인지 알 수 없다', async () => {
131
+ const store = makeStore()
132
+ await propose(store, DOMAIN, SPEC, seed())
133
+
134
+ await assert.rejects(
135
+ settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT }),
136
+ /결재선에 올라가지 않은/
137
+ )
138
+ })
139
+
140
+ test('★ 다른 결재 건의 판정은 받지 않는다 — 옆 건의 승인으로 게이트가 열리지 않게', async () => {
141
+ const store = makeStore()
142
+ await propose(store, DOMAIN, SPEC, seed())
143
+ await linkApproval(store, DOMAIN, 'cmd-1', REF)
144
+
145
+ await assert.rejects(
146
+ settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: 'activity-instance-9', approved: true, by: APPROVER, at: AT }),
147
+ /다른 결재 건의 판정/
148
+ )
149
+ assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
150
+ })
151
+
152
+ /* ── 결재선의 판정이 옮겨진다 ────────────────────────────────────────────── */
153
+
154
+ test('★ 결재선이 승인하면 나갈 수 있다 — 누가 승인했는지가 커맨드에도 남는다', async () => {
155
+ const store = makeStore()
156
+ await propose(store, DOMAIN, SPEC, seed())
157
+ await linkApproval(store, DOMAIN, 'cmd-1', REF)
158
+
159
+ const approved = await settleByApproval(store, DOMAIN, 'cmd-1', {
160
+ approvalRef: REF,
161
+ approved: true,
162
+ by: APPROVER,
163
+ at: AT,
164
+ note: '야간 물량 반영'
165
+ })
166
+
167
+ assert.equal(approved.state, 'approved')
168
+ /* 커맨드 목록이 결재선을 조회하지 않고도 「누가 언제」를 보여야 한다. */
169
+ assert.equal(store.rows.get('cmd-1')!.approval?.by, APPROVER)
170
+ assert.equal(store.rows.get('cmd-1')!.approval?.note, '야간 물량 반영')
171
+ assert.equal((await dispatch(store, DOMAIN, 'cmd-1', ok)).state, 'acked')
172
+ })
173
+
174
+ test('★ 결재선이 거절하면 커맨드는 거기서 닫힌다 — 뒤늦은 승인이 게이트를 열지 못한다', async () => {
175
+ /*
176
+ * 업무 목록에서 「거절」은 고쳐 다시 올리라는 뜻이지만, 조치에는 고칠 것이 없다 — 지시 내용은 제안될
177
+ * 때 정해진다. 그래서 커맨드 쪽은 끝으로 다룬다. 그 뒤에 결재선에서 승인이 와도 계약이 막는다.
178
+ */
179
+ const store = makeStore()
180
+ await propose(store, DOMAIN, SPEC, seed())
181
+ await linkApproval(store, DOMAIN, 'cmd-1', REF)
182
+
183
+ const rejected = await settleByApproval(store, DOMAIN, 'cmd-1', {
184
+ approvalRef: REF,
185
+ approved: false,
186
+ by: APPROVER,
187
+ at: AT,
188
+ note: '그 라인은 정지 중이다'
189
+ })
190
+ assert.equal(rejected.state, 'rejected')
191
+
192
+ await assert.rejects(
193
+ settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: 'someone-else@plant.example', at: AT }),
194
+ /승인할 수 없다|승인되지 않은|rejected/
195
+ )
196
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok))
197
+ })
198
+
199
+ /* ── 자극과 조치는 다른 어휘다 ───────────────────────────────────────────── */
200
+
201
+ test('★ 커널 자극을 조치로 낼 수 없다 — 모델 안의 자극이 현장 지시가 되지 않게', async () => {
202
+ /*
203
+ * 계약에 어휘가 두 벌이다.
204
+ *
205
+ * CMD 커널 안에서 도는 자극. `resource.repair` = 모델에서 수리된 것으로 둔다
206
+ * MES_COMMANDS 현장에 나가는 지시. `production.order` = 실제로 만든다
207
+ *
208
+ * 주의 신호가 권하는 것(`Attention.suggestedAction.command`)은 **앞엣것**이다. 주의를 읽어 조치를
209
+ * 내는 코드를 쓰다 보면 그 값을 그대로 넘기게 된다 — 실제로 그러려다 멈췄다.
210
+ */
211
+ const store = makeStore()
212
+ await assert.rejects(
213
+ propose(store, DOMAIN, SPEC, seed({ type: CMD.resourceRepair })),
214
+ /커널 안에서 도는 자극이다/
215
+ )
216
+ assert.equal(store.rows.size, 0)
217
+ })
218
+
219
+ test('★ 두 어휘가 겹치지 않는다 — 겹치면 어느 쪽으로 읽어야 할지 알 수 없다', () => {
220
+ /*
221
+ * 이 시험이 지키는 것은 앞의 거절이 **뜻을 갖는다**는 사실이다. 같은 문자열이 양쪽에 있으면 거절도
222
+ * 통과도 옳지 않게 되고, 그때 위 시험은 초록인데 규칙은 사라진다.
223
+ */
224
+ const stimuli = new Set(Object.values(CMD) as string[])
225
+ const overlap = MES_COMMANDS.map(s => s.type).filter(t => stimuli.has(t))
226
+
227
+ assert.deepEqual(
228
+ overlap,
229
+ [],
230
+ `자극과 조치에 같은 이름이 있다 (${overlap.join(', ')}) — 한 이름이 모델 상태를 바꾸기도 하고 현장에 나가기도 한다`
231
+ )
232
+ })
233
+
234
+ /* ── 낸 사람은 자기 것을 승인하지 못한다 ─────────────────────────────────── */
235
+
236
+ test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가 시늉이 되는 자리', async () => {
237
+ /*
238
+ * 이 규칙이 왜 필요한지는 돌려 보고 알았다. 업무 목록은 결재선이 비어 있으면 스레드를 곧바로
239
+ * 종료하고(§`submit.ts`), 그러면 콜백이 「승인됐다」로 받는다. 승인자를 지정하지 않은 도메인에서는
240
+ * 낸 사람이 그 자리에서 자기 조치를 승인한 것이 된다 — 오류 한 줄 없이.
241
+ */
242
+ const store = makeStore()
243
+ await propose(store, DOMAIN, SPEC, seed())
244
+
245
+ await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), /낸 사람이 자기 조치를 승인할 수 없다/)
246
+ assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
247
+ })
248
+
249
+ test('★ 결재선을 지나온 판정에도 같은 규칙이 걸린다 — Myself 를 승인자로 넣으면 같은 사람이다', async () => {
250
+ const store = makeStore()
251
+ await propose(store, DOMAIN, SPEC, seed())
252
+ await linkApproval(store, DOMAIN, 'cmd-1', REF)
253
+
254
+ await assert.rejects(
255
+ settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT }),
256
+ /낸 사람이 자기 조치를 승인할 수 없다/
257
+ )
258
+ assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
259
+ })
260
+
261
+ test('대소문자가 달라도 같은 사람이다 — 로그인 이름의 표기로 규칙을 비껴가지 못한다', async () => {
262
+ const store = makeStore()
263
+ await propose(store, DOMAIN, SPEC, seed())
264
+ await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER.toUpperCase(), at: AT }), /낸 사람이/)
265
+ })
266
+
267
+ test('★ 낸 사람은 자기 것을 물릴 수 있다 — 거절에는 걸지 않는다', async () => {
268
+ const store = makeStore()
269
+ await propose(store, DOMAIN, SPEC, seed())
270
+ const rejected = await reject(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT, note: '잘못 냈다' })
271
+ assert.equal(rejected.state, 'rejected')
272
+ })
273
+
274
+ test('★ 사람이 내는데 낸 사람이 없으면 받지 않는다 — 그 한 건만 규칙을 비껴간다', async () => {
275
+ const store = makeStore()
276
+ await assert.rejects(propose(store, DOMAIN, SPEC, seed({ proposedBy: undefined })), /낸 사람이 있어야 한다/)
277
+ assert.equal(store.rows.size, 0)
278
+ })
279
+
280
+ test('규칙·AI 가 낸 것은 낸 사람이 없다 — 사람이 아니다', async () => {
281
+ const store = makeStore()
282
+ const command = await propose(store, DOMAIN, SPEC, seed({ origin: 'rule', proposedBy: undefined }))
283
+ assert.equal(command.proposedBy, undefined)
284
+ /* 사람이 없으니 누구든 승인할 수 있다 — 그것이 이 경로의 뜻이다. */
285
+ assert.equal((await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })).state, 'approved')
286
+ })
287
+
288
+ test('★ 낸 사람이 재기동을 넘어 산다 — 사라지면 자기 승인이 열린다', async () => {
289
+ const first = makeStore()
290
+ await propose(first, DOMAIN, SPEC, seed())
291
+ const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
292
+
293
+ const second = makeStore(saved)
294
+ assert.equal(second.rows.get('cmd-1')!.proposedBy, PROPOSER, '재기동에 낸 사람이 사라졌다')
295
+ await assert.rejects(approve(second, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), /낸 사람이/)
296
+ })
297
+
298
+ test('★ 넘긴 뒤에도 낸 사람이 남는다 — 사고 뒤에 누가 냈는지 물을 자리다', async () => {
299
+ const store = makeStore()
300
+ await propose(store, DOMAIN, SPEC, seed())
301
+ await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
302
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', ok)
303
+
304
+ assert.equal(settled.proposedBy, PROPOSER)
305
+ assert.equal(store.rows.get('cmd-1')!.proposedBy, PROPOSER, '넘기는 사이에 낸 사람이 지워졌다')
306
+ })
307
+
308
+ /* ── 재기동 ───────────────────────────────────────────────────────────────── */
309
+
310
+ test('★ 결재선 자리가 재기동을 넘어 산다 — 다시 세운 뒤 직접 결정이 열리면 우회로가 된다', async () => {
311
+ const first = makeStore()
312
+ await propose(first, DOMAIN, SPEC, seed())
313
+ await linkApproval(first, DOMAIN, 'cmd-1', REF)
314
+
315
+ /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
316
+ const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
317
+ const second = makeStore(saved)
318
+
319
+ assert.equal(second.rows.get('cmd-1')!.approvalRef, REF, '재기동에 결재선 자리가 사라졌다')
320
+ await assert.rejects(approve(second, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/)
321
+
322
+ const approved = await settleByApproval(second, DOMAIN, 'cmd-1', {
323
+ approvalRef: REF,
324
+ approved: true,
325
+ by: APPROVER,
326
+ at: AT
327
+ })
328
+ assert.equal(approved.state, 'approved')
329
+ })
330
+
331
+ test('★ 넘긴 뒤에도 결재선 자리가 남는다 — 사고 뒤에 어느 결재로 나갔는지 물을 자리다', async () => {
332
+ const store = makeStore()
333
+ await propose(store, DOMAIN, SPEC, seed())
334
+ await linkApproval(store, DOMAIN, 'cmd-1', REF)
335
+ await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT })
336
+
337
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', ok)
338
+ assert.equal(settled.state, 'acked')
339
+ assert.equal(settled.approvalRef, REF)
340
+ assert.equal(store.rows.get('cmd-1')!.approvalRef, REF, '넘기는 사이에 결재선 자리가 지워졌다')
341
+ })
342
+
343
+ /* ── 받아들였으나 남은 것이 있다 ────────────────────────────────────────────── */
344
+
345
+ test('★ 저쪽이 덧붙인 말은 실패가 아니다 — 두 답이 구별되어야 한다', async () => {
346
+ /*
347
+ * 실측(2026-09-02): MES 에 조치 둘을 넘겼더니 트윈 쪽 두 행이 완전히 같았다. 하나는 지시서가
348
+ * 붙었고 하나는 안 붙었는데 `state`·`dispatch_ref`·`error` 가 셋 다 같았다. 화면은 둘 다
349
+ * 「나갔다」로 읽는다.
350
+ */
351
+ const store = makeStore()
352
+ await propose(store, DOMAIN, SPEC, seed())
353
+ await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
354
+
355
+ const out = await dispatch(store, DOMAIN, 'cmd-1', async () => ({
356
+ ok: true,
357
+ ref: 'WO-005',
358
+ note: '지시서가 붙지 않았습니다 — 사람이 MES 에서 골라야 합니다'
359
+ }))
360
+
361
+ assert.equal(out.state, 'acked', '덧붙인 말이 있는 것을 실패로 앉혔다')
362
+ assert.equal(out.dispatchRef, 'WO-005')
363
+ assert.match(out.dispatchNote!, /지시서가 붙지 않았습니다/)
364
+ assert.equal(out.error, undefined, '덧붙인 말을 오류 칸에 적었다')
365
+ })
366
+
367
+ test('덧붙인 말이 없으면 그 칸은 비어 있다 — 빈 문장을 만들지 않는다', async () => {
368
+ const store = makeStore()
369
+ await propose(store, DOMAIN, SPEC, seed())
370
+ await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
371
+
372
+ const out = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-004' }))
373
+
374
+ assert.equal(out.state, 'acked')
375
+ assert.equal(out.dispatchNote, undefined)
376
+ })
377
+
378
+ /* ── 다시 넘길 때 지난 시도의 자국을 지운다 ────────────────────────────────── */
379
+
380
+ test('★ 다시 넘겨 성공하면 지난 실패의 오류가 남지 않는다 — 한 행이 두 가지를 말하면 안 된다', async () => {
381
+ /*
382
+ * 실측(2026-09-03): `failed → dispatch` 로 다시 넘겨 성공했는데 지난 실패의 `error` 가 그대로
383
+ * 남아, 한 행이 「받았다(acked)」와 「저쪽이 안 받는다」를 동시에 말했다. 화면이 그 행을 그리면
384
+ * 어느 쪽을 믿어야 하는지 알 수 없다.
385
+ */
386
+ const store = makeStore()
387
+ await propose(store, DOMAIN, SPEC, seed())
388
+ await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
389
+
390
+ const failed = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '저쪽에 비밀값이 없습니다' }))
391
+ assert.equal(failed.state, 'failed')
392
+ assert.match(failed.error!, /비밀값/)
393
+
394
+ const retried = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-001' }))
395
+ assert.equal(retried.state, 'acked')
396
+ assert.equal(retried.dispatchRef, 'WO-001')
397
+ assert.equal(retried.error, undefined, '지난 실패의 오류가 성공한 행에 남았다')
398
+ })
399
+
400
+ test('★ 다시 넘겨 덧붙일 말이 없으면 옛 말이 남지 않는다 — 오류와 같은 부류다', async () => {
401
+ const store = makeStore()
402
+ await propose(store, DOMAIN, SPEC, seed())
403
+ await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
404
+
405
+ /* 한 번 실패하면서 덧붙인 말이 남을 수 있는 자리를 만든다. */
406
+ await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-1', note: '지시서를 골라야 합니다' }))
407
+
408
+ /* `acked` 는 종착이라 다시 넘길 수 없다 — 그 사실을 함께 못 박는다. */
409
+ await assert.rejects(() => dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-2' })), /넘길 수 없다/)
410
+ })
411
+
412
+ test('★ 다시 넘기다 또 실패하면 이번 사유만 남는다', async () => {
413
+ const store = makeStore()
414
+ await propose(store, DOMAIN, SPEC, seed())
415
+ await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
416
+
417
+ await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '첫 번째 사유' }))
418
+ const again = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '두 번째 사유' }))
419
+
420
+ assert.equal(again.error, '두 번째 사유', '두 사유가 겹쳐 남았다')
421
+ })
@@ -14,7 +14,14 @@ import assert from 'node:assert/strict'
14
14
 
15
15
  import type { TwinCommand } from '@operato/ops-contract'
16
16
 
17
- import { approve, dispatch, reject, type CommandAck, type CommandStore } from '../server/service/actuation/command-dispatcher.js'
17
+ import {
18
+ approve,
19
+ dispatch,
20
+ reject,
21
+ type CommandAck,
22
+ type CommandStore,
23
+ type StoredCommand
24
+ } from '../server/service/actuation/command-dispatcher.js'
18
25
 
19
26
  const DOMAIN = 'd-1'
20
27
  const AT = '2026-08-31T02:00:00.000Z'
@@ -31,8 +38,8 @@ const irreversible = (over: Partial<TwinCommand> = {}): TwinCommand => ({
31
38
  })
32
39
 
33
40
  /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */
34
- function makeStore(initial: TwinCommand): CommandStore & { rows: Map<string, TwinCommand> } {
35
- const rows = new Map<string, TwinCommand>([[initial.id, initial]])
41
+ function makeStore(initial: StoredCommand): CommandStore & { rows: Map<string, StoredCommand> } {
42
+ const rows = new Map<string, StoredCommand>([[initial.id, initial]])
36
43
  return {
37
44
  rows,
38
45
  load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null),
@@ -40,6 +47,12 @@ function makeStore(initial: TwinCommand): CommandStore & { rows: Map<string, Twi
40
47
  save: async (domainId, command) => {
41
48
  assert.equal(domainId, DOMAIN)
42
49
  rows.set(command.id, JSON.parse(JSON.stringify(command)))
50
+ },
51
+ create: async (domainId, command) => {
52
+ assert.equal(domainId, DOMAIN)
53
+ const copy = JSON.parse(JSON.stringify(command))
54
+ rows.set(command.id, copy)
55
+ return copy
43
56
  }
44
57
  }
45
58
  }
@@ -0,0 +1,162 @@
1
+ /*
2
+ * **나간 지시와 돌아온 관측을 잇는다** — 그 판정.
3
+ *
4
+ * `dispatchRef` 는 쓰는 곳만 있고 읽는 곳이 없었다. 그래서 조치를 넘긴 뒤 저쪽에서 그것이 어떻게
5
+ * 됐는지 트윈이 몰랐고, 화면은 「넘겼다」까지만 보여 주고 끝난 것처럼 읽혔다.
6
+ *
7
+ * 여기서 지키는 것은 셋이다.
8
+ *
9
+ * 이어지나 우리가 낸 지시의 식별자로 돌아온 보고를 찾는가
10
+ * 해석하지 않나 커널이 「released 는 나간 것」 같은 방언을 알지 않는가
11
+ * 버리지 않나 못 이은 보고의 이유가 남는가
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ import {
17
+ matchEffects,
18
+ refsToLookUp,
19
+ type DispatchedCommand,
20
+ type OrderObservation
21
+ } from '../server/service/actuation/effect-match.js'
22
+
23
+ const AT = '2026-09-02T09:00:00.000Z'
24
+ const LATER = '2026-09-02T09:05:00.000Z'
25
+
26
+ const ob = (over: Partial<OrderObservation> = {}): OrderObservation => ({
27
+ orderId: 'WO-20260902-002',
28
+ status: 'released',
29
+ at: AT,
30
+ ...over
31
+ })
32
+
33
+ function byRef(...commands: DispatchedCommand[]) {
34
+ return new Map(commands.map(c => [c.dispatchRef, c]))
35
+ }
36
+
37
+ const cmd = (over: Partial<DispatchedCommand> = {}): DispatchedCommand => ({
38
+ id: 'cmd-1',
39
+ dispatchRef: 'WO-20260902-002',
40
+ ...over
41
+ })
42
+
43
+ /* ── 이어진다 ───────────────────────────────────────────────────────────────── */
44
+
45
+ test('★ 우리가 낸 지시의 식별자로 돌아온 보고를 잇는다 — 이것이 폐루프의 마지막 칸이다', () => {
46
+ const out = matchEffects({ observations: [ob()], byRef: byRef(cmd()) })
47
+
48
+ assert.deepEqual(out.updates, [{ commandId: 'cmd-1', status: 'released', at: AT, seen: 1 }])
49
+ assert.deepEqual(out.skips, [])
50
+ })
51
+
52
+ test('★ 상태를 해석하지 않는다 — 커널은 어느 값이 「현장에 나갔다」인지 모른다', () => {
53
+ /*
54
+ * 오더 상태 어휘는 도메인 소유다(`OrderStatusDelta.status` 는 열린 문자열이고 코어가 강제하지
55
+ * 않는다). 커널이 `released` 를 특별히 알면 그 방언을 쓰지 않는 현장에서 폐루프가 조용히 안 닫힌다.
56
+ *
57
+ * 그래서 무엇이 와도 그대로 적는다 — 뜻은 그 어휘를 아는 쪽이 읽는다.
58
+ */
59
+ for (const status of ['released', 'in-progress', '작업중', 'WIP', 'cancelled']) {
60
+ const out = matchEffects({ observations: [ob({ status })], byRef: byRef(cmd()) })
61
+ assert.equal(out.updates[0]?.status, status, `${status} 를 그대로 적지 않았다`)
62
+ }
63
+ })
64
+
65
+ test('취소 보고도 효과다 — 저쪽에서 무엇이 되었나이지 잘 되었나가 아니다', () => {
66
+ const out = matchEffects({ observations: [ob({ status: 'cancelled' })], byRef: byRef(cmd()) })
67
+
68
+ assert.equal(out.updates.length, 1, '나쁜 소식을 걸러 내면 화면이 여전히 「넘겼다」로 끝난다')
69
+ assert.equal(out.updates[0]!.status, 'cancelled')
70
+ })
71
+
72
+ /* ── 몇 번 바뀌었나를 남긴다 ────────────────────────────────────────────────── */
73
+
74
+ test('★ 상태가 여러 번 바뀌면 마지막이 남고 센 수가 는다', () => {
75
+ const out = matchEffects({
76
+ observations: [ob({ status: 'released', at: AT }), ob({ status: 'in-progress', at: LATER })],
77
+ byRef: byRef(cmd())
78
+ })
79
+
80
+ assert.equal(out.updates.length, 1, '같은 조치에 두 줄을 냈다')
81
+ assert.deepEqual(out.updates[0], { commandId: 'cmd-1', status: 'in-progress', at: LATER, seen: 2 })
82
+ })
83
+
84
+ test('★ 이미 적힌 효과에서 이어 센다 — 다음 묶음이 1로 되돌리지 않는다', () => {
85
+ const out = matchEffects({
86
+ observations: [ob({ status: 'in-progress', at: LATER })],
87
+ byRef: byRef(cmd({ effect: { status: 'released', at: AT, seen: 3 } }))
88
+ })
89
+
90
+ assert.equal(out.updates[0]!.seen, 4)
91
+ })
92
+
93
+ /* ── 못 이은 것은 이유가 남는다 ─────────────────────────────────────────────── */
94
+
95
+ test('★ 우리가 내지 않은 오더는 이유로 남는다 — 대개 이것이고, 결함이 아니다', () => {
96
+ /*
97
+ * 저쪽 시스템은 자기 오더를 다 보고하고 그중 우리가 낸 것은 일부다. 이 줄이 쌓이는 것을 결함으로
98
+ * 읽으면 안 된다 — 그래서 이유 문장이 그렇게 말한다.
99
+ */
100
+ const out = matchEffects({ observations: [ob({ orderId: 'WO-남의것' })], byRef: byRef(cmd()) })
101
+
102
+ assert.deepEqual(out.updates, [])
103
+ assert.equal(out.skips.length, 1)
104
+ assert.match(out.skips[0]!.reason, /우리가 낸 지시가 아니다/)
105
+ assert.equal(out.skips[0]!.orderId, 'WO-남의것', '어느 오더였는지 말하지 않으면 되짚을 수 없다')
106
+ })
107
+
108
+ test('★ 시각이 없는 보고는 「지금」으로 채우지 않는다 — 처리 시각이 관측 시각이 된다', () => {
109
+ const out = matchEffects({ observations: [ob({ at: undefined })], byRef: byRef(cmd()) })
110
+
111
+ assert.deepEqual(out.updates, [])
112
+ assert.match(out.skips[0]!.reason, /지금으로 채우지 않는다/)
113
+ })
114
+
115
+ test('★ 상태가 빈 보고는 효과로 적지 않는다 — 「아무 말도 안 했다」가 「효과 없음」이 된다', () => {
116
+ for (const status of ['', ' ']) {
117
+ const out = matchEffects({ observations: [ob({ status })], byRef: byRef(cmd()) })
118
+ assert.deepEqual(out.updates, [], `${JSON.stringify(status)} 를 효과로 적었다`)
119
+ assert.match(out.skips[0]!.reason, /빈 값을 효과로 적지 않는다/)
120
+ }
121
+ })
122
+
123
+ test('오더 식별자가 없는 보고도 이유로 남는다', () => {
124
+ const out = matchEffects({ observations: [ob({ orderId: ' ' })], byRef: byRef(cmd()) })
125
+
126
+ assert.deepEqual(out.updates, [])
127
+ assert.match(out.skips[0]!.reason, /식별자가 없는/)
128
+ })
129
+
130
+ test('보고가 없으면 아무것도 만들지 않는다 — 빈 묶음에 이유를 만들지 않는다', () => {
131
+ const out = matchEffects({ observations: [], byRef: byRef(cmd()) })
132
+
133
+ assert.deepEqual(out.updates, [])
134
+ assert.deepEqual(out.skips, [])
135
+ })
136
+
137
+ /* ── 여러 조치 ──────────────────────────────────────────────────────────────── */
138
+
139
+ test('★ 한 묶음이 여러 조치에 닿으면 각각 적는다 — 하나만 잇고 끝내지 않는다', () => {
140
+ const out = matchEffects({
141
+ observations: [ob({ orderId: 'WO-1' }), ob({ orderId: 'WO-2', status: 'in-progress' }), ob({ orderId: 'WO-남의것' })],
142
+ byRef: byRef(cmd({ id: 'cmd-1', dispatchRef: 'WO-1' }), cmd({ id: 'cmd-2', dispatchRef: 'WO-2' }))
143
+ })
144
+
145
+ assert.equal(out.updates.length, 2)
146
+ assert.deepEqual(
147
+ out.updates.map(u => [u.commandId, u.status]).sort(),
148
+ [
149
+ ['cmd-1', 'released'],
150
+ ['cmd-2', 'in-progress']
151
+ ]
152
+ )
153
+ assert.equal(out.skips.length, 1)
154
+ })
155
+
156
+ /* ── 저장소에 물을 것을 줄인다 ─────────────────────────────────────────────── */
157
+
158
+ test('★ 찾아볼 식별자만 골라 낸다 — 유입은 초당 수천 건이 지나는 자리다', () => {
159
+ const observations = [ob({ orderId: 'WO-1' }), ob({ orderId: 'WO-1' }), ob({ orderId: 'WO-2' }), ob({ orderId: ' ' })]
160
+
161
+ assert.deepEqual(refsToLookUp(observations).sort(), ['WO-1', 'WO-2'], '중복이나 빈 값을 저장소에 물었다')
162
+ })