@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
@@ -5,6 +5,8 @@
5
5
  * 서지 않는다. 그러면 **게이트의 배선을 아무 시험도 걷지 않게 된다** — 훅에서 이미 겪은 부류다
6
6
  * (§`hook-store.ts`).
7
7
  */
8
+ import { In, IsNull, Not } from 'typeorm'
9
+
8
10
  import { getRepository } from '@things-factory/shell'
9
11
 
10
12
  import type { TwinCommand } from '@operato/ops-contract'
@@ -12,7 +14,7 @@ import type { TwinCommand } from '@operato/ops-contract'
12
14
  import { hydratedDate } from '../../engine/read-time.js'
13
15
 
14
16
  import { TwinCommandRecord } from './twin-command.js'
15
- import type { CommandStore } from './command-dispatcher.js'
17
+ import type { CommandStore, StoredCommand } from './command-dispatcher.js'
16
18
 
17
19
  /**
18
20
  * 행 → 커맨드. 상태 어휘는 계약이 정한 문자열 그대로다.
@@ -20,8 +22,12 @@ import type { CommandStore } from './command-dispatcher.js'
20
22
  * `createdAt` 은 드라이버가 준 값이라 **직접 날짜로 만들지 않는다** — 드라이버마다 모양이 다르고,
21
23
  * 문자열로 오는 드라이버에서는 시간대가 없는 값이 된다. `hydratedDate` 가 그 컬럼의 정의를 보고 푼다.
22
24
  */
23
- function toCommand(repo: any, row: TwinCommandRecord): TwinCommand {
25
+ function toCommand(repo: any, row: TwinCommandRecord): StoredCommand {
24
26
  return {
27
+ ...(row.approvalRef ? { approvalRef: row.approvalRef } : {}),
28
+ ...(row.proposedBy ? { proposedBy: row.proposedBy } : {}),
29
+ ...(row.causeRef ? { causeRef: row.causeRef } : {}),
30
+ ...(row.ruleRef ? { ruleRef: row.ruleRef } : {}),
25
31
  id: row.id,
26
32
  instanceId: row.instanceId,
27
33
  type: row.type,
@@ -33,30 +39,198 @@ function toCommand(repo: any, row: TwinCommandRecord): TwinCommand {
33
39
  state: row.state as TwinCommand['state'],
34
40
  ...(row.decidedBy ? { approval: { by: row.decidedBy, at: row.decidedAt ?? '', ...(row.decisionNote ? { note: row.decisionNote } : {}) } } : {}),
35
41
  ...(row.dispatchRef ? { dispatchRef: row.dispatchRef } : {}),
42
+ ...(row.dispatchNote ? { dispatchNote: row.dispatchNote } : {}),
43
+ /* 셋이 다 있어야 효과다 — 하나라도 비면 적지 않는다(빈 값을 사실로 만들지 않는다). */
44
+ ...(row.effectStatus && row.effectAt
45
+ ? { effect: { status: row.effectStatus, at: row.effectAt, seen: row.effectSeen ?? 1 } }
46
+ : {}),
36
47
  ...(row.error ? { error: row.error } : {})
37
48
  }
38
49
  }
39
50
 
40
- /** 실제 저장소를 쓰는 구현 — 리졸버가 이것을 넣는다. */
41
- export function databaseCommandStore(): CommandStore {
51
+ /**
52
+ * 실제 저장소를 쓰는 구현 — 리졸버가 이것을 넣는다.
53
+ *
54
+ * **트랜잭션을 받는다.** `getRepository(target)` 를 인자 없이 부르면 요청의 트랜잭션에 붙지 않고
55
+ * 별도로 커밋된다(`shell/initializers/database.ts:47`). 그러면 「올리고 적기」가 한 덩어리가 아니게 되고,
56
+ * 뒤가 실패했을 때 앞이 남는다 — 결재선에는 없는데 커맨드는 적혀 직접 결정이 열린 상태다.
57
+ *
58
+ * 콜백에서 부를 때도 넘긴다 — 업무 목록의 판정과 우리 커맨드가 같이 커밋되거나 같이 물러나야 한다.
59
+ */
60
+ export function databaseCommandStore(tx?: any): CommandStore {
42
61
  return {
43
62
  load: async (domainId: string, id: string) => {
44
- const repo = await getRepository(TwinCommandRecord)
63
+ const repo = await getRepository(TwinCommandRecord, tx)
45
64
  const row = await repo.findOne({ where: { domain: { id: domainId }, id } }).catch(() => null)
46
65
  return row ? toCommand(repo, row) : null
47
66
  },
48
- save: async (domainId: string, command: TwinCommand) => {
49
- await getRepository(TwinCommandRecord).update(
67
+
68
+ /**
69
+ * 결정을 기다리는 것들의 **근거** — 규칙이 같은 조건을 두 번 올리지 않게.
70
+ *
71
+ * 저장소에서 읽는다. 프로세스 기억에 두면 재기동이 곧 중복이 된다 — 다시 선 트윈이 같은 조건을 처음
72
+ * 보는 것으로 읽고 대기 줄에 같은 지시를 또 올린다.
73
+ *
74
+ * `proposed` 만 본다 — 결정을 기다리는 것은 시간과 무관하게 막는다. 결정이 끝난 것을 언제 다시 낼지는
75
+ * 이것이 아니라 **기다리는 시간**이 정한다(§`lastProposedAt`).
76
+ */
77
+ openCauses: async (domainId: string, instanceId: string) => {
78
+ const repo = await getRepository(TwinCommandRecord, tx)
79
+ const rows = await repo
80
+ .find({ where: { domain: { id: domainId }, instanceId, state: 'proposed' }, select: ['causeRef'] as any })
81
+ .catch(() => [] as TwinCommandRecord[])
82
+
83
+ return new Set(rows.map(r => r.causeRef).filter((v): v is string => !!v))
84
+ },
85
+
86
+ /**
87
+ * 근거마다 **마지막으로 조치를 낸 시각** — 결정이 끝난 것까지 본다.
88
+ *
89
+ * `openCauses` 만으로는 부족하다. 승인하는 순간 그 커맨드가 대기에서 빠지고 조건은 그대로 서 있어서
90
+ * 다음 바퀴가 같은 것을 또 낸다. 실제로 그렇게 됐다 — 승인 한 번에 대기 줄이 다시 찼다.
91
+ */
92
+ lastProposedAt: async (domainId: string, instanceId: string) => {
93
+ const repo = await getRepository(TwinCommandRecord, tx)
94
+ const rows = await repo
95
+ .find({ where: { domain: { id: domainId }, instanceId }, select: ['causeRef', 'createdAt'] as any })
96
+ .catch(() => [] as TwinCommandRecord[])
97
+
98
+ const out = new Map<string, number>()
99
+ for (const row of rows) {
100
+ if (!row.causeRef) continue
101
+ /* 드라이버가 준 값을 직접 날짜로 만들지 않는다 — 컬럼 정의를 보고 푼다. */
102
+ const at = hydratedDate(repo, 'createdAt', row.createdAt)?.getTime()
103
+ if (at === undefined || !Number.isFinite(at)) continue
104
+ const prev = out.get(row.causeRef)
105
+ if (prev === undefined || at > prev) out.set(row.causeRef, at)
106
+ }
107
+ return out
108
+ },
109
+
110
+ /**
111
+ * 넘긴 지시의 식별자로 찾는다 — 한 번에 묶어서 묻는다.
112
+ *
113
+ * 유입은 초당 수천 건이 지나는 자리다. 관측 하나마다 질의를 붙이면 그 경로가 느려지고, 느려지면
114
+ * 훅이 밀린다. 그래서 묶음 하나에 질의 하나다.
115
+ */
116
+ byDispatchRef: async (domainId: string, refs: readonly string[]) => {
117
+ const out = new Map<string, StoredCommand>()
118
+ if (!refs.length) return out
119
+
120
+ const repo = await getRepository(TwinCommandRecord, tx)
121
+ const rows = await repo
122
+ .find({ where: { domain: { id: domainId }, dispatchRef: In([...new Set(refs)]) } })
123
+ .catch(() => [] as TwinCommandRecord[])
124
+
125
+ for (const row of rows) {
126
+ if (row.dispatchRef) out.set(row.dispatchRef, toCommand(repo, row))
127
+ }
128
+ return out
129
+ },
130
+
131
+ /**
132
+ * 효과를 기다리는 조치들 — 넘긴 식별자가 있는 것만.
133
+ *
134
+ * 효과가 이미 적힌 것도 낸다. 상태는 여러 번 바뀌고(초안 → 나감 → 진행 중), 마지막 것이 남아야
135
+ * 화면이 「지금 저쪽에서 무엇인가」에 답할 수 있다.
136
+ *
137
+ * 결정이 안 끝난 것과 실패한 것은 빼지 않는다 — 실패로 적혔는데 저쪽은 받았을 수 있고, 그때
138
+ * 식별자가 있으면 그 사실이 이 길로 드러난다.
139
+ */
140
+ awaitingEffect: async (domainId: string) => {
141
+ const repo = await getRepository(TwinCommandRecord, tx)
142
+ const rows = await repo
143
+ .find({ where: { domain: { id: domainId }, dispatchRef: Not(IsNull()) } })
144
+ .catch(() => [] as TwinCommandRecord[])
145
+ return rows.map(row => toCommand(repo, row))
146
+ },
147
+
148
+ /*
149
+ * 넘길 차례를 기다리는 것들 — **두 상태만** 본다.
150
+ *
151
+ * `approved` 는 승인 직후 발신이 놓친 것(프로세스가 그 사이에 죽었다), `failed` 는 다시 해
152
+ * 볼 것이다. 그 밖의 상태는 일꾼의 일이 아니다 — `proposed` 는 승인을 기다리고 `acked` 는
153
+ * 종착이다.
154
+ *
155
+ * 물러섬은 여기서 걸러내지 않는다. 걸러내려면 시각 비교를 SQL 방언으로 써야 하고(이 저장소는
156
+ * 다섯 드라이버를 지난다), 판정은 이미 순수 함수가 한다(§`retryDecisionOf`). 대기 줄이 큰
157
+ * 현장이 오면 그때 인덱스와 함께 다시 본다.
158
+ */
159
+ pendingDispatch: async (domainId: string) => {
160
+ const repo = await getRepository(TwinCommandRecord, tx)
161
+ const rows = await repo
162
+ .find({ where: { domain: { id: domainId }, state: In(['approved', 'failed']) } })
163
+ .catch(() => [] as TwinCommandRecord[])
164
+ return rows.map(row => toCommand(repo, row))
165
+ },
166
+
167
+ /*
168
+ * 효과만 적는다 — 상태·승인·오류는 건드리지 않는다. 관측이 조치의 수명에 손을 대는 길은 두지
169
+ * 않는다. 그리고 도메인으로 좁힌다 — 식별자는 시스템마다 겹칠 수 있다.
170
+ */
171
+ recordEffects: async (domainId: string, updates) => {
172
+ const repo = await getRepository(TwinCommandRecord, tx)
173
+ for (const u of updates) {
174
+ await repo.update(
175
+ { id: u.commandId, domain: { id: domainId } },
176
+ { effectStatus: u.status, effectAt: u.at, effectSeen: u.seen } as any
177
+ )
178
+ }
179
+ },
180
+
181
+ /*
182
+ * **근거는 여기서 안 적는다.** `causeRef`·`ruleRef` 는 낼 때 정해지고 바뀌지 않는다. 이 목록에 넣으면
183
+ * 상태가 바뀔 때마다 다시 쓰이고, 부르는 쪽이 그 칸을 빼먹은 날 **근거가 지워진다** — 그러면 중복
184
+ * 판정이 그 커맨드를 못 보고 같은 지시를 또 올린다.
185
+ */
186
+ save: async (domainId: string, command: StoredCommand) => {
187
+ await getRepository(TwinCommandRecord, tx).update(
50
188
  { id: command.id, domain: { id: domainId } },
51
189
  {
52
190
  state: command.state,
191
+ approvalRef: command.approvalRef ?? null,
53
192
  decidedBy: command.approval?.by ?? null,
54
193
  decidedAt: command.approval?.at ?? null,
55
194
  decisionNote: command.approval?.note ?? null,
56
195
  dispatchRef: command.dispatchRef ?? null,
57
- error: command.error ?? null
196
+ dispatchNote: command.dispatchNote ?? null,
197
+ dispatchNoteNext: command.dispatchNoteNext ?? null,
198
+ error: command.error ?? null,
199
+ /* 일꾼이 쓰는 셋 — 없으면 물러섬이 표에 안 남고 재기동마다 즉시 재시도가 된다. */
200
+ retry: command.retry ?? null,
201
+ attempts: command.attempts ?? null,
202
+ nextAttemptAt: command.nextAttemptAt ?? null
58
203
  } as any
59
204
  )
205
+ },
206
+
207
+ /*
208
+ * 낼 때는 커맨드가 든 것을 그대로 적는다.
209
+ *
210
+ * `proposedAt` 은 옮기지 않는다 — 그 값은 `createdAt` 에서 읽으므로(§`toCommand`), 여기서 또 적으면
211
+ * 같은 사실이 두 칸에 살고 하나만 고쳐지는 날이 온다. 그리고 되돌려 줄 때는 **적힌 행을 다시 읽는다**
212
+ * — 낸 값을 그대로 답하면 드라이버가 실제로 무엇을 적었는지 모른 채로 사람에게 보여 준다.
213
+ */
214
+ create: async (domainId: string, command: StoredCommand) => {
215
+ const repo = await getRepository(TwinCommandRecord, tx)
216
+ const saved = await repo.save({
217
+ id: command.id,
218
+ domain: { id: domainId },
219
+ instanceId: command.instanceId,
220
+ type: command.type,
221
+ payload: command.payload ?? null,
222
+ origin: command.origin,
223
+ proposedBy: command.proposedBy ?? null,
224
+ causeRef: command.causeRef ?? null,
225
+ ruleRef: command.ruleRef ?? null,
226
+ reversible: command.reversible ?? null,
227
+ state: command.state,
228
+ approvalRef: command.approvalRef ?? null
229
+ } as any)
230
+
231
+ const row = await repo.findOne({ where: { domain: { id: domainId }, id: saved.id } })
232
+ if (!row) throw new Error(`커맨드를 적었는데 다시 읽을 수 없다 (${saved.id})`)
233
+ return toCommand(repo, row)
60
234
  }
61
235
  }
62
236
  }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * **승인 다음에 넘긴다** — 승인 도장과 실제 발신 사이의 한 걸음.
3
+ *
4
+ * ── 무엇이 없었나 (2026-09-04) ──────────────────────────────────────────────
5
+ * 사람이 결재함에서 승인하면 커맨드가 `approved` 로 앉고 **거기서 멈췄다.** 그것을 집어 현장에
6
+ * 보내는 것이 아무것도 없었다 — 규칙 타이머는 조치를 *내는* 일만 하고, 화면에는 넘기기 단추가
7
+ * 없다. 폐루프를 라이브로 확인한 것도 사람이 뮤테이션을 손으로 불러서였다.
8
+ *
9
+ * 그래서 승인은 아무 데도 닿지 않았고, 현장은 오지 않는 지시를 기다렸다. 오류는 나지 않는다.
10
+ *
11
+ * ── 왜 승인 트랜잭션 안에서 보내지 않나 ─────────────────────────────────────
12
+ * 승인은 업무 목록의 트랜잭션 안에서 일어난다. 그 안에서 현장에 요청을 쏘면 두 가지가 깨진다.
13
+ *
14
+ * ① 롤백이 되돌리지 못한다. 공장에는 작업지시가 생겼는데 우리 장부는 승인을 취소한 상태가 된다.
15
+ * **네트워크로 나간 것은 트랜잭션이 못 되돌린다.**
16
+ * ② 트랜잭션이 원격 응답을 기다리는 동안 열려 있다. 저쪽이 느리면 결재 화면이 함께 멈춘다.
17
+ *
18
+ * 그래서 **커밋 다음으로 미룬다.** 미루면 「미룬 사이에 롤백됐으면?」이 남는데, 그것은
19
+ * `dispatch` 가 스스로 막는다 — 새 연결로 커맨드를 **다시 읽고** 상태를 확인한다. 롤백됐으면
20
+ * 승인 기록이 없어 그 자리에서 던진다(§`asApproved`). 우리가 따로 검사할 것이 없다.
21
+ *
22
+ * ── 정직한 한계 ─────────────────────────────────────────────────────────────
23
+ * 커밋과 발신 사이에 프로세스가 죽으면 그 커맨드는 `approved` 로 남는다. **잃지는 않지만 스스로
24
+ * 나가지도 않는다.** 그것을 사람이 볼 자리(「넘기지 못한 조치」 목록)와 다시 넘기는 길이 아직
25
+ * 없다 — 이 파일이 그 빈 자리를 메우지 않는다는 것을 여기 적어 둔다.
26
+ *
27
+ * 그리고 이 미룸은 **한 프로세스 안의 약속**이다. 여러 대로 늘리면 승인을 받은 프로세스가
28
+ * 보내는 것이 되고, 그 프로세스가 죽으면 위와 같다. 그때는 표를 보고 집어 가는 일꾼이 필요하다.
29
+ */
30
+ import { twinWarn } from '../../engine/log.js'
31
+ import { databaseCommandStore } from './command-store.js'
32
+ import { dispatch } from './command-dispatcher.js'
33
+ import { routeActuation } from '../reference/actuation-routing.js'
34
+
35
+ /**
36
+ * 승인이 확정된 조치를 **커밋 뒤에** 현장으로 넘긴다.
37
+ *
38
+ * 부르는 쪽은 기다리지 않는다(`void`). 결재의 성공은 넘김의 성공에 달려 있지 않다 — 어댑터가
39
+ * 죽어 있어도 승인은 남아 있어야 하고, 그것이 승인과 넘김을 가른 이유다.
40
+ *
41
+ * **던지지 않는다.** 이 함수는 요청 밖에서 돌기 때문에, 던지면 처리되지 않은 거절이 되어
42
+ * 프로세스를 내린다. 넘기다 생긴 실패는 `dispatch` 가 `failed` 로 앉히고, 그 위의 예외(상태가
43
+ * 맞지 않음·커맨드가 사라짐)만 여기서 받아 적는다.
44
+ */
45
+ export function dispatchAfterApproval(domainId: string, commandId: string): void {
46
+ /*
47
+ * `setImmediate` 로 미룬다 — 지금 열린 트랜잭션이 커밋될 기회를 준다.
48
+ *
49
+ * 이것이 커밋을 **보장하지는 않는다.** 보장은 `dispatch` 의 다시 읽기가 한다(머리말). 여기서
50
+ * 미루는 것은 「대개 커밋된 뒤에 읽게 하려는」 것이고, 아니면 다시 읽기가 거절한다.
51
+ */
52
+ setImmediate(() => {
53
+ void (async () => {
54
+ try {
55
+ const settled = await dispatch(databaseCommandStore(), domainId, commandId, command =>
56
+ routeActuation(domainId, command.instanceId, command)
57
+ )
58
+ if (settled.state === 'failed') {
59
+ /* 실패는 결함이 아니다 — 저쪽이 안 받았거나 설정이 비었다는 사실이고, 행에 남는다. */
60
+ twinWarn(`[twin-actuation] 조치 ${commandId} 를 넘기지 못했다: ${settled.error ?? '(사유 없음)'}`)
61
+ }
62
+ } catch (e: any) {
63
+ /*
64
+ * 여기 오는 것은 「넘길 수 없는 상태」다. 가장 흔한 원인은 **승인 트랜잭션이 롤백된 것**이고,
65
+ * 그때는 넘기지 않는 것이 맞다. 조용히 지나가지 않고 적는다 — 다른 원인이면 그것이 결함이다.
66
+ */
67
+ twinWarn(`[twin-actuation] 조치 ${commandId} 를 넘기지 않았다: ${String(e?.message ?? e)}`)
68
+ }
69
+ })()
70
+ })
71
+ }
@@ -0,0 +1,124 @@
1
+ /*
2
+ * **나간 지시와 돌아온 관측을 잇는다** — 폐루프의 마지막 칸. 판단만, 저장소는 여기 없다.
3
+ *
4
+ * ── 왜 이 칸이 비어 있었나 (2026-09-02) ───────────────────────────────────────
5
+ * `dispatchRef` 를 **쓰는 곳만 있고 읽는 곳이 없었다.** 어댑터가 답한 작업지시 이름을 적어 두는데,
6
+ * 그 이름으로 무엇도 찾지 않았다. 그래서 조치를 넘긴 뒤 저쪽에서 그것이 어떻게 됐는지 트윈은 몰랐다.
7
+ *
8
+ * 자리는 있고 길이 없는 상태다. 조치 경로에서 이 부류가 나면 결과가 하나다 — **넘긴 것이 현장에서
9
+ * 무엇이 되었는지 아무도 모르는데, 화면은 「넘겼다」까지만 보여 주고 끝난 것처럼 읽힌다.**
10
+ *
11
+ * ── 커널은 무엇이 효과인지 정하지 않는다 ─────────────────────────────────────
12
+ * 오더 상태 어휘는 도메인 소유다(§`OrderStatusDelta.status` — `status: string`, 코어는 강제하지
13
+ * 않는다). 어느 값이 「현장에 나갔다」인지는 현장마다 다르고, 커널이 그 방언을 알면 보편 계약이
14
+ * 깨진다 — 흐름 판정이 공정 이름을 설비 종류로 환산하지 않는 것과 같은 이유다.
15
+ *
16
+ * 그래서 여기서 판단하는 것은 **이어지나** 하나다.
17
+ *
18
+ * 이 관측의 오더 식별자가 우리가 낸 지시의 식별자와 같은가
19
+ *
20
+ * 같으면 그 상태를 **해석하지 않고 그대로** 적는다. 무엇을 뜻하는지는 그 어휘를 아는 쪽이 읽는다.
21
+ */
22
+ import type { ISOTime } from '@operato/ops-contract'
23
+
24
+ /** 관측 하나 — 오더 상태 보고에서 필요한 것만. */
25
+ export interface OrderObservation {
26
+ orderId: string
27
+ status: string
28
+ /** 이 보고를 관측한 시각. 없으면 이을 수 없다 — 언제인지 모르는 효과는 적지 않는다. */
29
+ at?: ISOTime
30
+ }
31
+
32
+ /** 이을 대상 — 저장소가 `dispatchRef` 로 찾아 준 조치. */
33
+ export interface DispatchedCommand {
34
+ id: string
35
+ dispatchRef: string
36
+ /** 이미 적힌 효과가 있나 — 몇 번 보고됐는지를 이어서 센다. */
37
+ effect?: { status: string; at: ISOTime; seen: number }
38
+ }
39
+
40
+ /** 적을 것 하나. */
41
+ export interface EffectUpdate {
42
+ commandId: string
43
+ status: string
44
+ at: ISOTime
45
+ seen: number
46
+ }
47
+
48
+ export interface EffectMatch {
49
+ updates: EffectUpdate[]
50
+ /** 이을 수 없던 관측과 그 이유 — 조용히 버리지 않는다. */
51
+ skips: { orderId: string; reason: string }[]
52
+ }
53
+
54
+ /**
55
+ * 한 번에 여러 관측을 잇는다.
56
+ *
57
+ * **같은 오더가 한 묶음에 여러 번 올 수 있다.** 그때는 마지막 것이 남고 `seen` 이 그만큼 는다 —
58
+ * 중간 상태를 덮어쓰는 것이 맞다(화면이 묻는 것은 「지금 저쪽에서 무엇인가」다). 다만 몇 번 바뀌었는지는
59
+ * 남긴다. 한 번도 안 바뀐 것과 다섯 번 바뀐 것은 다른 일이다.
60
+ */
61
+ export function matchEffects(args: {
62
+ observations: readonly OrderObservation[]
63
+ /** `dispatchRef` → 그 지시를 낸 조치. 저장소가 채운다. */
64
+ byRef: ReadonlyMap<string, DispatchedCommand>
65
+ }): EffectMatch {
66
+ const { observations, byRef } = args
67
+
68
+ const updates = new Map<string, EffectUpdate>()
69
+ const skips: { orderId: string; reason: string }[] = []
70
+
71
+ for (const ob of observations) {
72
+ const orderId = String(ob?.orderId ?? '').trim()
73
+ if (!orderId) {
74
+ skips.push({ orderId: '', reason: '오더 식별자가 없는 보고 — 이을 수 없다' })
75
+ continue
76
+ }
77
+
78
+ const command = byRef.get(orderId)
79
+ if (!command) {
80
+ /*
81
+ * 대개 이것이다 — 우리가 내지 않은 오더의 상태 보고. 저쪽 시스템은 자기 오더를 다 보고하고,
82
+ * 그중 우리가 낸 것은 일부다. **이유로 남기되 흔한 것임을 적어 둔다** — 이 줄이 쌓이는 것을
83
+ * 결함으로 읽으면 안 된다.
84
+ */
85
+ skips.push({ orderId, reason: '우리가 낸 지시가 아니다' })
86
+ continue
87
+ }
88
+
89
+ const status = String(ob?.status ?? '').trim()
90
+ if (!status) {
91
+ /* 빈 상태를 적으면 「보고는 왔는데 아무 말도 안 했다」가 「효과 없음」과 같아 보인다. */
92
+ skips.push({ orderId, reason: '상태를 말하지 않은 보고 — 빈 값을 효과로 적지 않는다' })
93
+ continue
94
+ }
95
+
96
+ const at = String(ob?.at ?? '').trim()
97
+ if (!at) {
98
+ /* 언제인지 모르는 효과는 적지 않는다 — 「지금」으로 채우면 관측 시각이 아니라 처리 시각이 된다. */
99
+ skips.push({ orderId, reason: '시각이 없는 보고 — 지금으로 채우지 않는다' })
100
+ continue
101
+ }
102
+
103
+ /* 같은 묶음 안에서 앞서 센 것이 있으면 그것을 잇는다 — 없으면 적힌 효과에서 잇는다. */
104
+ const prior = updates.get(command.id)?.seen ?? command.effect?.seen ?? 0
105
+ updates.set(command.id, { commandId: command.id, status, at, seen: prior + 1 })
106
+ }
107
+
108
+ return { updates: [...updates.values()], skips }
109
+ }
110
+
111
+ /**
112
+ * 이 묶음에서 **찾아봐야 할 오더 식별자**만 골라낸다 — 저장소에 물을 것을 줄인다.
113
+ *
114
+ * 관측 한 묶음이 수백 건일 수 있고 그중 우리가 낸 것은 몇 건이다. 전부를 하나씩 물으면 유입 경로에
115
+ * 질의가 그만큼 붙는다 — 유입은 초당 수천 건이 지나는 자리다.
116
+ */
117
+ export function refsToLookUp(observations: readonly OrderObservation[]): string[] {
118
+ const out = new Set<string>()
119
+ for (const ob of observations) {
120
+ const orderId = String(ob?.orderId ?? '').trim()
121
+ if (orderId) out.add(orderId)
122
+ }
123
+ return [...out]
124
+ }
@@ -1,8 +1,27 @@
1
+ import { TwinActuationRule } from './actuation-rule.js'
2
+ import { TwinActuationRuleResolver } from './actuation-rule-resolver.js'
1
3
  import { TwinCommandRecord } from './twin-command.js'
4
+ import { TwinCommandResolver } from './twin-command-resolver.js'
2
5
 
3
- export const entities = [TwinCommandRecord]
4
- export const resolvers = []
6
+ export const entities = [TwinCommandRecord, TwinActuationRule]
7
+ export const resolvers = [TwinCommandResolver, TwinActuationRuleResolver]
5
8
 
6
9
  export * from './twin-command.js'
10
+ export * from './actuation-rule.js'
11
+ export * from './approval-gateway.js'
12
+ export * from './dispatch-after-approval.js'
13
+ export * from './actuation-outcome.js'
14
+ export * from './retry-plan.js'
15
+ export * from './sweep-dispatch.js'
16
+ export * from './actuation-advisor.js'
7
17
  export * from './command-dispatcher.js'
8
18
  export * from './command-store.js'
19
+ export * from './twin-now.js'
20
+ export * from './effect-match.js'
21
+ export * from './settle-effects.js'
22
+ export * from './catch-up-effects.js'
23
+ export * from './rule-evaluate.js'
24
+ export * from './rule-runner.js'
25
+ export * from './rule-loop.js'
26
+ export * from './twin-command-resolver.js'
27
+ export * from './actuation-rule-resolver.js'
@@ -0,0 +1,145 @@
1
+ /*
2
+ * **다시 넘길 것인가, 언제인가** — 일꾼이 묻는 것 하나.
3
+ *
4
+ * ── 왜 순수한가 ─────────────────────────────────────────────────────────────
5
+ * 일꾼의 판단이 전부 여기 있다. DB 도 어댑터도 시계도 보지 않는다 — 시각은 받는다. 그래서 「10분
6
+ * 뒤에 다시」 같은 규칙을 기다리지 않고 값만 바꿔 가며 잴 수 있다.
7
+ *
8
+ * ── 세 갈래는 어댑터가 답한다 ───────────────────────────────────────────────
9
+ * 실패가 다 같지 않다. 2026-09-02 에 둘을 겪었다 — 저쪽 인증 정보가 없어 503(고치면 됨)이었고,
10
+ * `ITEM-1` 은 400(고쳐도 이 조치로는 안 됨)이었다. 답만 보면 구별되지 않았다.
11
+ *
12
+ * again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다
13
+ * after-fix 사람이 고친 뒤 그대로 나간다 설정 문제
14
+ * never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다
15
+ *
16
+ * 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다(인티그레이션 레인 판단).
17
+ *
18
+ * ── `after-fix` 를 횟수로 접지 않는다 ───────────────────────────────────────
19
+ * 이것이 이 파일에서 가장 중요한 결정이다. `after-fix` 는 **재시도가 아니라 기다리는 것**이다.
20
+ * 두드려서 풀릴 일이 아니고, 사람이 설정을 고치는 순간 다음 주기에 나가면 된다.
21
+ *
22
+ * 횟수로 접으면 **사람이 고쳐 놓고도 다시 손으로 눌러야** 한다. 2026-09-02 사고가 정확히 그
23
+ * 모양이었다 — 저쪽이 고쳐진 뒤 사람이 손으로 다시 넘겨서 나갔다.
24
+ *
25
+ * `again` 만 접는다. 끝없이 두드리는 것은 소음이고, 짧게 세 번 해서 안 되면 망이 흔들린 것이
26
+ * 아니라 다른 것이다 — 그때는 사람이 봐야 한다.
27
+ *
28
+ * ── 멱등이 첫 시도와 재시도를 가른다 ────────────────────────────────────────
29
+ * approved → 넘김 **첫 시도**다. 멱등 선언이 없어도 한다 — 한 번뿐이라 위험이 없다
30
+ * failed → 넘김 **재시도**다. 두 번 보내도 지시가 하나라고 선언한 커넥터만 한다
31
+ *
32
+ * 선언하지 않은 커넥터의 실패는 목록에 남는다. 사람이 저쪽 상태를 보고 결정해야 한다 — 우리가
33
+ * 「아마 괜찮겠지」로 두 번 보내면 현장에 지시가 두 건 선다.
34
+ */
35
+
36
+ /** 어댑터가 답하는 세 갈래. 비어 있으면 **말하지 않은 것**이고, `never` 가 아니다. */
37
+ export type RetryOutlook = 'again' | 'after-fix' | 'never'
38
+
39
+ export function isRetryOutlook(v: unknown): v is RetryOutlook {
40
+ return v === 'again' || v === 'after-fix' || v === 'never'
41
+ }
42
+
43
+ /**
44
+ * `again` 의 물러섬 — 30초 · 2분 · 10분, 그다음 접는다.
45
+ *
46
+ * 첫 값이 일꾼의 주기와 같다(§`RULE_PERIOD_MS`). 새 박자를 만들지 않는다.
47
+ */
48
+ export const AGAIN_BACKOFF_MS = [30_000, 120_000, 600_000] as const
49
+
50
+ /**
51
+ * `after-fix` 는 **10분마다 계속** 본다 — 접지 않는다(머리말).
52
+ *
53
+ * 사람이 설정을 고치는 것을 기다리는 간격이다. 짧게 두면 헛되고, 길게 두면 고쳐 놓고 기다린다.
54
+ */
55
+ export const AFTER_FIX_PERIOD_MS = 600_000
56
+
57
+ /** 왜 집지 않는가 — 목록이 「고칠 것」과 「우리 쪽을 보지 말 것」을 갈라 보이게 한다. */
58
+ export type SkipReason =
59
+ /** 종착이거나 넘길 수 없는 상태다. */
60
+ | 'not-dispatchable'
61
+ /** 아직 물러섬이 끝나지 않았다 — 다음 기회를 기다린다. */
62
+ | 'backing-off'
63
+ /** 어댑터가 「영원히 안 된다」고 답했다 — 사람이 **새 조치**를 내야 한다. 우리 쪽에 고칠 것이 없다. */
64
+ | 'never'
65
+ /** `again` 을 세 번 다 썼다 — 사람이 봐야 한다. */
66
+ | 'attempts-spent'
67
+ /** 어댑터가 다시 될 일인지 말하지 않았다 — 모르는 것을 재시도로 밀지 않는다. */
68
+ | 'outlook-unknown'
69
+ /** 재시도인데 커넥터가 멱등을 선언하지 않았다 — 두 건이 설 위험을 우리가 지지 않는다. */
70
+ | 'not-idempotent'
71
+
72
+ export interface RetryDecision {
73
+ /** 지금 넘길 것인가. */
74
+ take: boolean
75
+ /** 안 넘기면 왜 — 목록이 그대로 보인다. */
76
+ skip?: SkipReason
77
+ /** 넘길 것이면 이번이 몇 번째 시도인가(첫 시도가 1). */
78
+ attempt?: number
79
+ /** 이번 시도가 실패하면 다음은 언제인가(ISO). 없으면 더 안 본다. */
80
+ nextAttemptAt?: string
81
+ }
82
+
83
+ export interface RetryInput {
84
+ /** 지금 상태 — `approved` 는 첫 시도, `failed` 는 재시도. */
85
+ state: string
86
+ /** 넘길 수 있는 상태인가 — 상태 기계가 답한다(`nextCommandState(state,'dispatch')`). */
87
+ dispatchable: boolean
88
+ /** 어댑터가 지난 실패에 답한 갈래. 첫 시도에는 없다. */
89
+ retry?: string
90
+ /** 지금까지 몇 번 넘겨 봤나. 없으면 0. */
91
+ attempts?: number
92
+ /** 다음에 볼 시각(ISO). 없으면 지금 봐도 된다. */
93
+ nextAttemptAt?: string
94
+ /** 이 커넥터가 두 번 보내도 지시가 하나인가 — **선언만 참으로 읽는다.** */
95
+ idempotent?: boolean
96
+ /** 지금(ms). */
97
+ nowMs: number
98
+ }
99
+
100
+ const at = (ms: number): string => new Date(ms).toISOString()
101
+
102
+ /**
103
+ * 이 조치를 지금 넘길 것인가.
104
+ *
105
+ * **모르면 넘기지 않는다.** 어댑터가 갈래를 말하지 않았거나 멱등을 선언하지 않았으면 재시도하지
106
+ * 않고 목록에 남긴다 — 오늘 커널의 다른 축에서 지킨 것과 같은 규율이다(정격 사용량·품질 계수기·
107
+ * 조용한 시각: 모르면 단언하지 않는다).
108
+ */
109
+ export function retryDecisionOf(input: RetryInput): RetryDecision {
110
+ if (!input.dispatchable) return { take: false, skip: 'not-dispatchable' }
111
+
112
+ const attempts = typeof input.attempts === 'number' && input.attempts > 0 ? input.attempts : 0
113
+
114
+ /*
115
+ * **첫 시도** — 승인됐는데 한 번도 안 나간 것. 물러섬도 멱등도 묻지 않는다: 한 번뿐이라 두 건이
116
+ * 설 수 없고, 어댑터는 아직 아무 답도 하지 않았다.
117
+ *
118
+ * 이 갈래가 승인 직후 발신이 놓친 것을 줍는다 — 커밋과 발신 사이에 프로세스가 죽은 경우다.
119
+ */
120
+ if (attempts === 0) {
121
+ return { take: true, attempt: 1, nextAttemptAt: at(input.nowMs + AGAIN_BACKOFF_MS[0]) }
122
+ }
123
+
124
+ /* 재시도다. 여기서부터 어댑터의 답과 커넥터의 성질을 묻는다. */
125
+ if (!isRetryOutlook(input.retry)) return { take: false, skip: 'outlook-unknown' }
126
+ if (input.retry === 'never') return { take: false, skip: 'never' }
127
+ if (input.idempotent !== true) return { take: false, skip: 'not-idempotent' }
128
+
129
+ if (input.nextAttemptAt) {
130
+ const dueMs = Date.parse(input.nextAttemptAt)
131
+ /* 읽을 수 없는 값으로 막지 않는다 — 막으면 그 조치가 표에 갇힌다. */
132
+ if (Number.isFinite(dueMs) && input.nowMs < dueMs) return { take: false, skip: 'backing-off' }
133
+ }
134
+
135
+ if (input.retry === 'after-fix') {
136
+ /* 접지 않는다(머리말) — 같은 간격으로 계속 본다. */
137
+ return { take: true, attempt: attempts + 1, nextAttemptAt: at(input.nowMs + AFTER_FIX_PERIOD_MS) }
138
+ }
139
+
140
+ /* `again` — 물러섬 표를 다 쓰면 접는다. */
141
+ if (attempts >= AGAIN_BACKOFF_MS.length) return { take: false, skip: 'attempts-spent' }
142
+
143
+ const next = AGAIN_BACKOFF_MS[attempts]
144
+ return { take: true, attempt: attempts + 1, nextAttemptAt: at(input.nowMs + next) }
145
+ }