@things-factory/headless-twin 10.0.7 → 10.0.9

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 (143) hide show
  1. package/dist-server/engine/attention-digest.d.ts +52 -0
  2. package/dist-server/engine/attention-digest.js +76 -0
  3. package/dist-server/engine/attention-digest.js.map +1 -0
  4. package/dist-server/engine/board-vocabulary.d.ts +17 -0
  5. package/dist-server/engine/board-vocabulary.js +63 -0
  6. package/dist-server/engine/board-vocabulary.js.map +1 -0
  7. package/dist-server/engine/command-routing.d.ts +33 -0
  8. package/dist-server/engine/command-routing.js +53 -0
  9. package/dist-server/engine/command-routing.js.map +1 -0
  10. package/dist-server/engine/entity-delta.d.ts +13 -6
  11. package/dist-server/engine/entity-delta.js +38 -12
  12. package/dist-server/engine/entity-delta.js.map +1 -1
  13. package/dist-server/engine/index.d.ts +6 -0
  14. package/dist-server/engine/index.js +10 -0
  15. package/dist-server/engine/index.js.map +1 -1
  16. package/dist-server/engine/kpi-baseline.d.ts +78 -0
  17. package/dist-server/engine/kpi-baseline.js +123 -0
  18. package/dist-server/engine/kpi-baseline.js.map +1 -0
  19. package/dist-server/engine/kpi-broadcast.d.ts +4 -0
  20. package/dist-server/engine/kpi-broadcast.js +16 -0
  21. package/dist-server/engine/kpi-broadcast.js.map +1 -0
  22. package/dist-server/engine/kpi-query.d.ts +31 -0
  23. package/dist-server/engine/kpi-query.js +50 -2
  24. package/dist-server/engine/kpi-query.js.map +1 -1
  25. package/dist-server/engine/live-attentions.d.ts +1 -0
  26. package/dist-server/engine/live-attentions.js +7 -1
  27. package/dist-server/engine/live-attentions.js.map +1 -1
  28. package/dist-server/engine/runtime-key.d.ts +15 -0
  29. package/dist-server/engine/runtime-key.js +64 -0
  30. package/dist-server/engine/runtime-key.js.map +1 -0
  31. package/dist-server/engine/state-axes.d.ts +16 -0
  32. package/dist-server/engine/state-axes.js +54 -0
  33. package/dist-server/engine/state-axes.js.map +1 -0
  34. package/dist-server/engine/twin-engine.d.ts +92 -16
  35. package/dist-server/engine/twin-engine.js +335 -77
  36. package/dist-server/engine/twin-engine.js.map +1 -1
  37. package/dist-server/engine/twin-level.d.ts +23 -0
  38. package/dist-server/engine/twin-level.js +52 -0
  39. package/dist-server/engine/twin-level.js.map +1 -0
  40. package/dist-server/engine/warm-start.d.ts +58 -12
  41. package/dist-server/engine/warm-start.js +80 -9
  42. package/dist-server/engine/warm-start.js.map +1 -1
  43. package/dist-server/service/reference/discovery-result.d.ts +34 -0
  44. package/dist-server/service/reference/discovery-result.js +84 -0
  45. package/dist-server/service/reference/discovery-result.js.map +1 -0
  46. package/dist-server/service/reference/ingest-space.d.ts +30 -0
  47. package/dist-server/service/reference/ingest-space.js +63 -0
  48. package/dist-server/service/reference/ingest-space.js.map +1 -0
  49. package/dist-server/service/reference/knob-defaults.d.ts +20 -0
  50. package/dist-server/service/reference/knob-defaults.js +59 -0
  51. package/dist-server/service/reference/knob-defaults.js.map +1 -0
  52. package/dist-server/service/reference/reference-live.js +2 -2
  53. package/dist-server/service/reference/reference-live.js.map +1 -1
  54. package/dist-server/service/reference/reference-master.d.ts +37 -2
  55. package/dist-server/service/reference/reference-master.js +55 -5
  56. package/dist-server/service/reference/reference-master.js.map +1 -1
  57. package/dist-server/service/reference/reference-resolver.d.ts +2 -2
  58. package/dist-server/service/reference/reference-resolver.js +71 -15
  59. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  60. package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
  61. package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
  62. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  63. package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
  64. package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
  65. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +4 -2
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
  71. package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
  77. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
  81. package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
  82. package/dist-server/service/twin-structure/twin-structure.js +2 -1
  83. package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
  84. package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
  85. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
  86. package/dist-server/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +6 -6
  88. package/server/engine/attention-digest.ts +102 -0
  89. package/server/engine/board-vocabulary.ts +61 -0
  90. package/server/engine/command-routing.ts +67 -0
  91. package/server/engine/entity-delta.ts +33 -11
  92. package/server/engine/index.ts +10 -0
  93. package/server/engine/kpi-baseline.ts +202 -0
  94. package/server/engine/kpi-broadcast.ts +13 -0
  95. package/server/engine/kpi-query.ts +81 -3
  96. package/server/engine/live-attentions.ts +7 -2
  97. package/server/engine/runtime-key.ts +58 -0
  98. package/server/engine/state-axes.ts +55 -0
  99. package/server/engine/twin-engine.ts +350 -77
  100. package/server/engine/twin-level.ts +48 -0
  101. package/server/engine/warm-start.ts +130 -16
  102. package/server/service/reference/discovery-result.ts +95 -0
  103. package/server/service/reference/ingest-space.ts +70 -0
  104. package/server/service/reference/knob-defaults.ts +59 -0
  105. package/server/service/reference/reference-live.ts +2 -2
  106. package/server/service/reference/reference-master.ts +94 -8
  107. package/server/service/reference/reference-resolver.ts +80 -16
  108. package/server/service/twin-attention/twin-attention-query.ts +43 -6
  109. package/server/service/twin-control/twin-control-mutation.ts +31 -12
  110. package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
  111. package/server/service/twin-instance/twin-instance.ts +6 -2
  112. package/server/service/twin-journal/twin-journal-query.ts +35 -5
  113. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
  114. package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
  115. package/server/service/twin-space/twin-space-resolver.ts +10 -1
  116. package/server/service/twin-state/twin-state-subscription.ts +1 -1
  117. package/server/service/twin-structure/twin-structure.ts +4 -1
  118. package/server/service/twin-target/twin-target-resolver.ts +22 -4
  119. package/test/attention-digest.test.ts +135 -0
  120. package/test/board-vocabulary.test.ts +114 -0
  121. package/test/capability-mapping.test.ts +4 -4
  122. package/test/command-routing.test.ts +61 -0
  123. package/test/discovery-result.test.ts +75 -0
  124. package/test/entity-delta.test.ts +25 -25
  125. package/test/ingest-bench.test.ts +3 -3
  126. package/test/ingest-space.test.ts +50 -0
  127. package/test/knob-defaults.test.ts +72 -0
  128. package/test/kpi-baseline-db.test.ts +214 -0
  129. package/test/kpi-baseline.test.ts +196 -0
  130. package/test/kpi-query-bench.test.ts +128 -0
  131. package/test/live-mirror-parity.test.ts +35 -2
  132. package/test/master-to-twin.test.ts +7 -3
  133. package/test/mutation-gate.test.ts +108 -0
  134. package/test/oee-accumulator.test.ts +65 -1
  135. package/test/registry-key-guard.test.ts +80 -0
  136. package/test/runtime-key.test.ts +66 -0
  137. package/test/scale-twin-bench.test.ts +2 -2
  138. package/test/state-axes.test.ts +74 -0
  139. package/test/streamline-e2e.test.ts +2 -2
  140. package/test/structure-revision-db.test.ts +5 -4
  141. package/test/tenant-registry-db.test.ts +149 -0
  142. package/test/warm-start-seam.test.ts +140 -0
  143. package/test/warm-start.test.ts +138 -4
@@ -12,7 +12,9 @@ import { cacheService } from '@things-factory/cache-service'
12
12
 
13
13
  import { TwinEvent } from '../service/twin-event/twin-event.js'
14
14
  import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
15
- import { planWarmStart } from './warm-start.js'
15
+ import { planWarmStart, unwrapState } from './warm-start.js'
16
+ import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
17
+ import { routeCommand } from './command-routing.js'
16
18
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
17
19
  import { TwinStructure } from '../service/twin-structure/twin-structure.js'
18
20
  import { TwinSpace } from '../service/twin-space/twin-space.js'
@@ -21,6 +23,8 @@ import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-repres
21
23
  import { TwinSpaceArea } from '../service/twin-space/twin-space-area.js'
22
24
  import { TwinArea } from '../service/twin-space/twin-area.js'
23
25
  import { masterToTwin, type ReferenceMaster } from '../service/reference/reference-master.js'
26
+ import { resolveIngestSpace, collidingIds } from '../service/reference/ingest-space.js'
27
+ import { type IngestWarning, landmarkIdsMerged, describeWarnings } from '../service/reference/reference-master.js'
24
28
  import { buildTravelEstimator, chainEstimators } from './travel-estimator.js'
25
29
  import { buildMeasuredEstimator } from './measured-estimator.js'
26
30
  import { describeModelBasis, type ModelBasis } from './model-basis.js'
@@ -30,11 +34,13 @@ import { createHash } from 'node:crypto'
30
34
  import { buildEntityDeltas } from './entity-delta.js'
31
35
  import { diffStructures, type StructureDiff } from './structure-diff.js'
32
36
  import { OeeAccumulator, withLiveOee } from './oee-accumulator.js'
37
+ import { withLiveAttentions } from './live-attentions.js'
38
+ import { digestAttentions, mergeLensAttentions } from './attention-digest.js'
33
39
 
34
40
  import type { TwinKernel, BoardDef, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
35
41
 
36
42
  /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
37
- const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, replaySegments, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
43
+ const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
38
44
 
39
45
  const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel }
40
46
 
@@ -93,8 +99,16 @@ interface InstanceRuntime {
93
99
  }
94
100
 
95
101
  export class TwinEngine {
102
+ /*
103
+ * 떠 있는 런타임 — **키는 `runtimeKey(domainId, instanceId)`** 다(`runtime-key.ts` 에 이유).
104
+ *
105
+ * 예전엔 `instanceId` 하나로 키를 잡았다. 그런데 정체성은 `(domain, instanceId)` 이고 DB 유일성도
106
+ * 그쪽이라, 두 테넌트가 같은 id 를 쓰면(레퍼런스 경로가 소스 이름을 id 로 쓴다) 한 자리를 다퉜다.
107
+ * **엔진 밖에서 이 맵을 직접 색인하지 않는다** — `owns`·`runtime`·`kernel` 같은 접근자를 쓴다.
108
+ */
96
109
  static instances: Record<string, InstanceRuntime> = {}
97
110
  /** 라이브 런타임이 없을 때(복구 후 미기동) 저널에서 재구성한 상태 캐시. */
111
+ /** 웜스타트 씨앗 — `instances` 와 **같은 키**(겹치면 남의 스냅샷으로 재고가 섞인다). */
98
112
  static recovered: Record<string, any> = {}
99
113
  static TICK_MS = 1000
100
114
 
@@ -108,9 +122,14 @@ export class TwinEngine {
108
122
 
109
123
  /** 최신 스냅샷을 cache-service 에 체크포인트(도메인+instanceId 키). display-only·비차단·오류흡수. */
110
124
  static async persistSnapshot(domainId: string, instanceId: string): Promise<void> {
111
- const inst = this.instances[instanceId]
125
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
112
126
  if (!inst) return
113
- const state = this.snapshot(instanceId)
127
+ /*
128
+ * **봉투가 아니라 상태를 저장한다.** `snapshot()` 은 시뮬에서 `runtime.resync()` 봉투를 주는데,
129
+ * 그것을 다시 `{revision, state}` 로 감싸 넣어 왔다 → 꺼낸 값에 축이 하나도 없어 웜스타트가
130
+ * 조용히 넘어갔다(저널에 수천 건이 있어도 트윈이 빈 채로 떴다).
131
+ */
132
+ const state = unwrapState(this.snapshot(domainId, instanceId))
114
133
  if (!state) return
115
134
  const revision = inst.revision ?? state.revision ?? 0
116
135
  await cacheService.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state }, this.SNAPSHOT_TTL_S)
@@ -126,7 +145,8 @@ export class TwinEngine {
126
145
  static startCheckpointLoop(): void {
127
146
  if (this.checkpointTimer) return
128
147
  this.checkpointTimer = setInterval(() => {
129
- for (const [instanceId, inst] of Object.entries(this.instances)) {
148
+ for (const [key, inst] of Object.entries(this.instances)) {
149
+ const { instanceId } = parseRuntimeKey(key)
130
150
  this.persistSnapshot(inst.domainId, instanceId).catch(err =>
131
151
  console.error(`[twin-engine] snapshot checkpoint fail "${instanceId}"`, err?.message ?? err)
132
152
  )
@@ -175,13 +195,14 @@ export class TwinEngine {
175
195
  // 없으면 저널 fold-from-0 replay(진실 폴백 — replay 는 라이브 파생상태를 못 담으므로 캐시가 더 충실).
176
196
  const cached = await this.loadSnapshot(row.domainId, row.instanceId).catch(() => null)
177
197
  if (cached?.state) {
178
- this.recovered[row.instanceId] = { revision: cached.revision, state: cached.state }
198
+ /* 예전에 겹포장으로 저장된 값이 남아 있을 수 있다 — 읽는 쪽에서도 벗긴다(한 번은 반드시 만난다). */
199
+ this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: cached.revision, state: unwrapState(cached.state) }
179
200
  console.log(`[twin-engine] warm-started "${row.instanceId}" from snapshot cache → revision ${cached.revision}.`)
180
201
  continue
181
202
  }
182
203
  const state = await this.recover(row.domainId, row.instanceId).catch(() => null)
183
204
  if (state) {
184
- this.recovered[row.instanceId] = { revision: state.revision, state }
205
+ this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: state.revision, state }
185
206
  console.log(`[twin-engine] recovered "${row.instanceId}" from journal → revision ${state.revision}.`)
186
207
  }
187
208
  }
@@ -195,25 +216,33 @@ export class TwinEngine {
195
216
  * 웜스타트 — 기동하는 커널에 **직전 관측 상태**를 심는다.
196
217
  *
197
218
  * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
198
- * `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
219
+ * `loadBoard` 는 **구조만** 싣는다(자리·설비). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
199
220
  * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
200
221
  * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
201
222
  * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
202
223
  * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
203
224
  *
204
- * ── 정직한 한계 ─────────────────────────────────────────────────────────────
205
- * · **오더는 복원하지 않는다.** `hydrateObserved` 오더 인자는 requested/fulfilled/lines 요구하는데
206
- * 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
207
- * 넘기지 않는다 재고·노드·무버만 복원되고 진행 오더는 비어서 시작한다.
208
- * · 진행 개별 task 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
209
- * · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
225
+ * ── 반쪽이던 복구를 마무리한다 (2026-08-05) ─────────────────────────────────
226
+ * 재고는 살아났는데 **진행 주문이 통째로 사라진** 화면이 남아 있었다. 씨앗이 자리·물품·설비
227
+ * 셋만 넘겼기 때문이다. 근거는 "스냅샷 오더에 progress 밖에 없다" 였고, 그때는 맞았다
228
+ * 그러나 커널 `OrderState` 원값(`requested`·`fulfilled`·`lines`)을 되찾은 뒤에도 자리만
229
+ * 그대로 남았다. 오류를 내지 않는 종류라 오래 버텼다.
230
+ *
231
+ * 지금은 **관측 스냅샷 전체**를 넘긴다(오더·작업·사람·자산 포함). 시뮬은 커널 `snapshot()`,
232
+ * 라이브는 `StateProjector` 가 그 일곱 축을 모두 담는다. 무엇을 심을 수 있는지 판정하는 규칙
233
+ * (이행 완료 오더 제외·고아 작업 제외)은 커널에 있고, 호스트가 미리 골라내지 않는다.
234
+ *
235
+ * ── 남은 한계는 숨기지 않고 센다 ────────────────────────────────────────────
236
+ * · 원값이 없는 오더(progress 만 있는 것)는 남은 수량을 알 수 없어 심지 않는다. **지어내지 않는
237
+ * 대신 몇 건인지 말한다** — 세지 않으면 "주문이 없다" 와 "주문을 못 심었다" 가 화면에서 같아진다.
238
+ * · 진행 중 개별 작업의 내부 상태는 관측만으로 완전히 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
210
239
  *
211
240
  * ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
212
241
  * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
213
242
  */
214
- private static warmStart(id: string, kernel: TwinKernel, purpose?: string): void {
243
+ private static warmStart(domainId: string, id: string, kernel: TwinKernel, purpose?: string): void {
215
244
  const hydrate = (kernel as any).hydrateObserved
216
- const plan = planWarmStart(this.recovered[id]?.state, purpose, typeof hydrate === 'function')
245
+ const plan = planWarmStart(this.recovered[runtimeKey(domainId, id)]?.state, purpose, typeof hydrate === 'function')
217
246
 
218
247
  if (plan.action === 'skip') {
219
248
  if (plan.reason === 'bench') {
@@ -227,10 +256,24 @@ export class TwinEngine {
227
256
  }
228
257
 
229
258
  hydrate.call(kernel, plan.seed)
230
- console.log(
231
- `[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.equipmentCount} equipment restored. ` +
232
- 'Open orders are not restored (the snapshot carries no requested/fulfilled counts).'
233
- )
259
+ const restored = [
260
+ `${plan.itemCount} item(s)`,
261
+ `${plan.equipmentCount} equipment`,
262
+ `${plan.orderCount} open order(s)`,
263
+ `${plan.taskCount} task(s)`,
264
+ ...(plan.personCount ? [`${plan.personCount} person(s)`] : []),
265
+ ...(plan.assetCount ? [`${plan.assetCount} asset(s)`] : []),
266
+ /* 확인해 둔 신호를 이어받았다는 사실도 말한다 — 잃으면 확인 처리가 다시 빨개지는 것으로 보인다. */
267
+ ...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : [])
268
+ ].join(', ')
269
+ console.log(`[twin-engine] warm-started "${id}" — restored ${restored}.`)
270
+ /* 뺀 것은 조용히 넘기지 않는다 — 지어내지 않았다는 사실 자체를 말해야 화면의 빈칸이 읽힌다. */
271
+ if (plan.ordersWithoutDemand > 0) {
272
+ console.warn(
273
+ `[twin-engine] "${id}": ${plan.ordersWithoutDemand} order(s) could not be restored — they carry progress only, ` +
274
+ 'with no requested/fulfilled counts, so the remaining demand is unknown. They are left out rather than guessed.'
275
+ )
276
+ }
234
277
  }
235
278
 
236
279
  /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
@@ -352,8 +395,8 @@ export class TwinEngine {
352
395
  const registered = await getRepository(TwinInstance).find({ where })
353
396
 
354
397
  return registered.map(reg => {
355
- const rt = this.instances[reg.instanceId]
356
- const kernel: any = rt?.domainId === domainId ? rt.kernel : undefined
398
+ const rt = this.instances[runtimeKey(domainId, reg.instanceId)]
399
+ const kernel: any = rt?.kernel // 키에 도메인이 있으므로 남의 런타임을 집을 수 없다
357
400
  if (typeof kernel?.capacity !== 'function') return { instanceId: reg.instanceId, kind: reg.kind, reason: 'not-running' as const }
358
401
 
359
402
  const analysis = kernel.capacity({ unitsPerDay, sampleWeekStartMs })
@@ -365,15 +408,16 @@ export class TwinEngine {
365
408
  }
366
409
 
367
410
  static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string, resumeFrom?: number): InstanceRuntime {
368
- if (this.instances[id]) return this.instances[id]
411
+ const key = runtimeKey(domainId, id)
412
+ if (this.instances[key]) return this.instances[key]
369
413
 
370
414
  const Kernel = KERNELS[kind] ?? WmsKernel
371
- const kernel: TwinKernel = new Kernel(domainId, undefined, this.mesSpecOf(board))
415
+ const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(board))
372
416
  kernel.loadBoard(board) // 구조만. 상태는 아래 웜스타트가 심는다.
373
417
  this.applyOperations(kernel, board, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값
374
418
  /* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
375
419
  this.installEstimators(kernel, domainId, id, board).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
376
- this.warmStart(id, kernel, purpose)
420
+ this.warmStart(domainId, id, kernel, purpose)
377
421
  /*
378
422
  * **번호를 이어 센다** — 저널에 이미 있는 번호와 겹치지 않게.
379
423
  *
@@ -385,8 +429,8 @@ export class TwinEngine {
385
429
 
386
430
  /* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
387
431
  const inst: InstanceRuntime = { id, domainId, runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, unsub: () => {} }
388
- this.instances[id] = inst
389
- delete this.recovered[id] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
432
+ this.instances[key] = inst
433
+ delete this.recovered[key] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
390
434
 
391
435
  /* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
392
436
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
@@ -408,7 +452,7 @@ export class TwinEngine {
408
452
  this.register(domainId, id, kind, board, 'running', inst.realityMode).catch(err => console.error('twin register fail', err))
409
453
 
410
454
  /* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
411
- inst.timer = setInterval(() => runtime.tick(this.TICK_MS), this.TICK_MS)
455
+ inst.timer = setInterval(() => this.tickGuarded(domainId, id, runtime), this.TICK_MS)
412
456
  return inst
413
457
  }
414
458
 
@@ -434,8 +478,25 @@ export class TwinEngine {
434
478
  *
435
479
  * 보드에 없으면 `undefined` — 레거시 경로 그대로다(기존 트윈의 거동을 바꾸지 않는다).
436
480
  */
437
- private static mesSpecOf(board: BoardDef | undefined): any {
438
- return (board as any)?.mesSpec
481
+ /**
482
+ * 생산 선언을 꺼낸다 — 커널 생성자에 넘긴다.
483
+ *
484
+ * **옛 이름(`mesSpec`)은 읽지 않는다.** 그것은 계약에 선언조차 없던 필드였고(MES 커널 생성자 인자
485
+ * 이름이 그대로 굳은 것), 일반 기제에 한 시스템 이름이 붙어 있었기 때문에 창고 트윈이 이 자리를
486
+ * 쓰지 못했다. 별명으로 남겨 두면 그 혼동이 계속되므로 하나로 통일했다.
487
+ *
488
+ * 옛 이름만 가진 보드가 있으면 **조용히 생산 선언을 잃는 대신 분명히 멈춘다** — 그 트윈은 공정이
489
+ * 없는 채로 돌게 되고(라인이 서 있는 창고), 원인을 찾기 어렵다.
490
+ */
491
+ private static productionSpecOf(board: BoardDef | undefined): any {
492
+ const legacy = (board as any)?.mesSpec
493
+ if (legacy && !(board as any)?.productionSpec) {
494
+ throw new Error(
495
+ 'board carries the retired `mesSpec` field — rename it to `productionSpec` ' +
496
+ '(same shape; the name was tied to one kernel while the declaration is ISA-95 operations + BOM)'
497
+ )
498
+ }
499
+ return (board as any)?.productionSpec
439
500
  }
440
501
 
441
502
  /**
@@ -462,9 +523,10 @@ export class TwinEngine {
462
523
  }
463
524
 
464
525
  static startLive(id: string, domainId: string, kind: string, board: BoardDef): InstanceRuntime {
465
- if (this.instances[id]) return this.instances[id]
526
+ const key = runtimeKey(domainId, id)
527
+ if (this.instances[key]) return this.instances[key]
466
528
  const Kernel = KERNELS[kind] ?? WmsKernel
467
- const kernel: any = new Kernel(domainId, undefined, this.mesSpecOf(board))
529
+ const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(board))
468
530
  kernel.loadBoard(board)
469
531
  this.applyOperations(kernel, board, id) // 명세는 라이브에도 실린다(예측 자격이 sim 과 같아진다)
470
532
  /* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
@@ -474,8 +536,8 @@ export class TwinEngine {
474
536
  /* 추정기(실측·거리)도 라이브에 붙인다 — 예측이 상수로 돌지 않게. 기동을 막지 않는다. */
475
537
  this.installEstimators(kernel, domainId, id, board).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
476
538
  inst.metrics = { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() }
477
- this.instances[id] = inst
478
- delete this.recovered[id]
539
+ this.instances[key] = inst
540
+ delete this.recovered[key]
479
541
  /* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */
480
542
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
481
543
  /* 저널 revision 카운터 시드 — 기존 저널 최대치에서 이어붙임(재기동 시 revision 충돌 방지). 이후 인메모리 증가. */
@@ -492,8 +554,8 @@ export class TwinEngine {
492
554
  * reference 어댑터가 낸 records → 커널 face2-adapter.ingest → CanonicalEnvelope 를 여기로 밀어넣는다.
493
555
  * (State 채널 델타/저널 결선은 후속 — 스켈레톤은 data(tag) 미러 중심.)
494
556
  */
495
- static ingestLive(id: string, envelopes: CanonicalEnvelope[]): void {
496
- const inst = this.instances[id]
557
+ static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[]): void {
558
+ const inst = this.instances[runtimeKey(domainId, id)]
497
559
  if (inst?.mode !== 'live' || !inst.projector) return
498
560
  for (const e of envelopes) { inst.projector.apply(e); inst.oee?.apply(e) } // 관측(projector) + 계산(OEE 누적)
499
561
  // 저널 결선(라이브도 sim 처럼 이벤트 영속) — 단 이벤트마다 DB write 하면 부하폭발이라 모아뒀다가
@@ -506,6 +568,18 @@ export class TwinEngine {
506
568
  this.ensureBroadcastCoalescer()
507
569
  }
508
570
 
571
+ /**
572
+ * 구간 성과 방송은 **없앴다**(2026-08-06). 카드가 `twinKpi` 를 직접 묻는다.
573
+ *
574
+ * 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면 방송으로는 태그가 트윈당 1,200개가 되고,
575
+ * **보드에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 접었다. 질의로 바꾸니 보고 있는 카드
576
+ * 수만큼만 들고, 같은 (대상·창·축) 은 클라이언트가 하나로 합친다.
577
+ *
578
+ * 덤으로 질의만 할 수 있는 것이 둘 생겼다 — **과거 시각**(`toTime`)과 **공간 단위 합산**(여러 트윈을
579
+ * 한 번에 접기). 방송 루프는 트윈별이라 둘 다 못 했다.
580
+ *
581
+ * 축을 나눠도 폴드 비용이 같다는 실측이 근거다(`test/kpi-query-bench.test.ts`).
582
+ */
509
583
  /** 방송 병합 주기(ms) — 방송률 상한. 인제스트가 아무리 빨라도 이 주기로만 방송. */
510
584
  static BROADCAST_COALESCE_MS = 200
511
585
  private static broadcastTimer?: any
@@ -641,7 +715,7 @@ export class TwinEngine {
641
715
  * 저널은 보존된다(추가는 이미 equipment 델타로 저널됨 → replay 는 id-keyed upsert 라 이중계산 없음).
642
716
  */
643
717
  static async syncBoardEquipment(domainId: string, instanceId: string): Promise<void> {
644
- const inst = this.instances[instanceId]
718
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
645
719
  const snap = inst?.kernel?.getSnapshot?.()
646
720
  if (!snap?.equipment) return
647
721
  const repo = getRepository(TwinInstance)
@@ -664,7 +738,7 @@ export class TwinEngine {
664
738
  * board 는 replay 의 마스터라 구조가 바뀌면 과거 이벤트의 전제가 깨진다. 좌표(layout)만 바뀌면 저널 보존.
665
739
  */
666
740
  static async provision(domainId: string, instanceId: string, kind: string, board: BoardDef, comment?: string): Promise<void> {
667
- if (this.instances[instanceId]) throw new Error(`instance "${instanceId}" is running — stop before re-provisioning`)
741
+ if (this.instances[runtimeKey(domainId, instanceId)]) throw new Error(`instance "${instanceId}" is running — stop before re-provisioning`)
668
742
  const repo = getRepository(TwinInstance)
669
743
  const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
670
744
 
@@ -718,7 +792,7 @@ export class TwinEngine {
718
792
  await repo.save(repo.create({ domain: { id: domainId } as any, instanceId, rev, signature, board: board as any, ...(comment ? { comment } : {}) }))
719
793
  this.structureRevCache[`${domainId}:${instanceId}`] = rev
720
794
  /* 구조가 바뀌면 재구성 캐시는 옛 공장의 것이다 — 버린다(지우는 건 캐시뿐, 사실은 남는다). */
721
- delete this.recovered[instanceId]
795
+ delete this.recovered[runtimeKey(domainId, instanceId)]
722
796
  if (latest) console.info(`[twin-engine] "${instanceId}" structure changed → revision ${rev} (history kept; older events stay under revision ${latest.rev}).`)
723
797
  return rev
724
798
  }
@@ -794,7 +868,8 @@ export class TwinEngine {
794
868
 
795
869
  /** 레지스트리 board 로 기동(프로비전된 인스턴스 start). board 인자 없이 저장된 구조로 재기동. */
796
870
  static async startFromRegistry(domainId: string, instanceId: string, realityMode?: RealityMode): Promise<InstanceRuntime> {
797
- if (this.instances[instanceId]) return this.instances[instanceId]
871
+ const key = runtimeKey(domainId, instanceId)
872
+ if (this.instances[key]) return this.instances[key]
798
873
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
799
874
  if (!reg?.board) throw new Error(`instance "${instanceId}" not provisioned (no board)`)
800
875
 
@@ -805,13 +880,13 @@ export class TwinEngine {
805
880
  * 어떤 날은 조용히 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
806
881
  * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
807
882
  */
808
- if (!this.recovered[instanceId] && reg.purpose !== 'bench') {
883
+ if (!this.recovered[key] && reg.purpose !== 'bench') {
809
884
  const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
810
885
  if (cached?.state) {
811
- this.recovered[instanceId] = { revision: cached.revision, state: cached.state }
886
+ this.recovered[key] = { revision: cached.revision, state: cached.state }
812
887
  } else {
813
888
  const state = await this.recover(domainId, instanceId).catch(() => null)
814
- if (state) this.recovered[instanceId] = { revision: state.revision, state }
889
+ if (state) this.recovered[key] = { revision: state.revision, state }
815
890
  }
816
891
  }
817
892
 
@@ -883,13 +958,13 @@ export class TwinEngine {
883
958
  */
884
959
  static async resetJournal(domainId: string, instanceId: string): Promise<void> {
885
960
  await getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId })
886
- delete this.recovered[instanceId]
961
+ delete this.recovered[runtimeKey(domainId, instanceId)]
887
962
  }
888
963
 
889
964
  /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */
890
965
  static async remove(domainId: string, instanceId: string): Promise<void> {
891
- await this.stop(instanceId)
892
- delete this.recovered[instanceId]
966
+ await this.stop(domainId, instanceId)
967
+ delete this.recovered[runtimeKey(domainId, instanceId)]
893
968
  await getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId })
894
969
  await getRepository(TwinInstance).delete({ domain: { id: domainId } as any, instanceId })
895
970
  }
@@ -912,10 +987,17 @@ export class TwinEngine {
912
987
  realityMode: r.realityMode ?? DEFAULT_REALITY_MODE, // 현실 출처 선언(§0 ①) — mirror/sim-world/sim-experiment
913
988
  purpose: r.purpose ?? 'operational', // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님)
914
989
  copyOf: r.copyOf ?? undefined,
915
- running: !!this.instances[r.instanceId],
990
+ running: !!this.instances[runtimeKey(domainId, r.instanceId)],
916
991
  revision: last?.revision ?? 0,
917
- locationCount: board.locations?.length ?? 0,
918
- equipmentCount: board.equipment?.length ?? 0
992
+ /*
993
+ * **리더를 거쳐 센다** — 보드 키를 직접 읽으면 옛 세대 보드가 0 으로 보인다.
994
+ *
995
+ * 커널은 자리·설비 배열의 **옛 세대 키까지 흡수해** 읽어 주는데, 이 목록은 새 이름만 직접
996
+ * 세고 있었다. 그래서 옛 보드 12개가 화면에 **자리 0 · 설비 0**
997
+ * 으로 떴다 — 오류 없이, 그냥 빈 공장처럼. 세는 규칙이 두 벌이면 이런 식으로 갈라진다.
998
+ */
999
+ locationCount: readBoardLocations(board).length,
1000
+ equipmentCount: readBoardEquipment(board).length
919
1001
  })
920
1002
  }
921
1003
  return out
@@ -936,8 +1018,9 @@ export class TwinEngine {
936
1018
  const board: any = r.board ?? {}
937
1019
  const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, locations: 0, equipment: 0 }
938
1020
  a.instances++
939
- a.locations += board.locations?.length ?? 0
940
- a.equipment += board.equipment?.length ?? 0
1021
+ /* 목록과 **같은 규칙**으로 센다 — 여기만 직접 세면 공간 요약과 인스턴스 목록이 다른 수를 말한다. */
1022
+ a.locations += readBoardLocations(board).length
1023
+ a.equipment += readBoardEquipment(board).length
941
1024
  agg.set(r.spaceId, a)
942
1025
  }
943
1026
  return [...agg.values()]
@@ -978,12 +1061,35 @@ export class TwinEngine {
978
1061
  * Face2 마스터 인제스트(ADR-0018) — 레퍼런스 시스템(실 또는 가상)의 마스터를 읽어 트윈을 생성.
979
1062
  * 엔티티를 손배선/발명하지 않고 마스터에서 반영: 공간(Space) upsert + 인스턴스 provision(미기동).
980
1063
  * 노드타입은 커널 카탈로그로 검증(warning). start 는 호출측(bootstrap/mutation)이 결정.
1064
+ *
1065
+ * `into.spaceId` — **이 현장에 더한다.** 한 현실을 여러 렌즈(WMS·MES·YMS)가 비추므로 새 트윈이
1066
+ * 기존 공간으로 들어갈 수 있다. 아래 병합은 원래 그 경우를 위해 있었는데(N:1) 만드는 흐름에서
1067
+ * 공간을 고를 방법이 없었다 — 마스터가 파생한 id 만 쓰였다. 판정은 `resolveIngestSpace`(순수).
981
1068
  */
982
- static async ingestMaster(domainId: string, master: ReferenceMaster): Promise<{ instanceId: string; spaceId: string; warnings: string[] }> {
1069
+ static async ingestMaster(
1070
+ domainId: string,
1071
+ master: ReferenceMaster,
1072
+ into?: { spaceId?: string }
1073
+ ): Promise<{ instanceId: string; spaceId: string; warnings: IngestWarning[] }> {
983
1074
  const { board, spaceContent, warnings } = masterToTwin(master, DOMAIN_CATALOG)
984
- const spaceId: string = board.spaceId
985
1075
  const repo = getRepository(TwinSpace)
1076
+ /* 합칠 공간을 골랐으면 **있는지 먼저 확인한다** — 없는 곳에 조용히 새 공간을 만들면 사용자는
1077
+ 합쳤다고 믿고 화면은 따로 논다. 판정 함수가 그 경우 거절한다. */
1078
+ const wanted = (into?.spaceId ?? '').trim()
1079
+ const wantedExists = wanted
1080
+ ? (await repo.count({ where: { domain: { id: domainId }, spaceId: wanted } })) > 0
1081
+ : false
1082
+ const choice = resolveIngestSpace(board.spaceId, wanted, wantedExists)
1083
+ const spaceId: string = choice.spaceId
1084
+ board.spaceId = spaceId // 보드(커널 소비)도 같은 공간을 가리켜야 한다
986
1085
  const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId } })
1086
+ if (choice.joined) {
1087
+ /* 같은 id 의 구역·랜드마크는 합집합으로 접힌다 — 가릴 수 없는 것을 고르지 않고 무엇이
1088
+ 합쳐졌는지 말한다(정당한 경우가 많으므로 막지 않는다). */
1089
+ const prevContent: any = existing?.content ?? {}
1090
+ const lm = collidingIds(prevContent.landmarks, (spaceContent as any).landmarks)
1091
+ if (lm.length) warnings.push(landmarkIdsMerged(spaceId, lm))
1092
+ }
987
1093
  /*
988
1094
  * 공유 공간(여러 트윈이 한 spaceId, N:1) content 병합 — 마지막 인제스트가 통째로 덮어써 area(그룹)·표현이
989
1095
  * 유실되던 문제 보정. area·landmark 는 id 합집합, representations 는 비어있지 않은 쪽 보존, 나머지는 first-wins.
@@ -1062,7 +1168,7 @@ export class TwinEngine {
1062
1168
  }
1063
1169
 
1064
1170
  await this.provision(domainId, master.source, master.system, board)
1065
- if (warnings.length) console.warn(`[twin-engine] ingest "${master.source}" warnings:`, warnings)
1171
+ if (warnings.length) console.warn(`[twin-engine] ingest "${master.source}" warnings: ${describeWarnings(warnings)}`)
1066
1172
  return { instanceId: master.source, spaceId, warnings }
1067
1173
  }
1068
1174
 
@@ -1074,7 +1180,7 @@ export class TwinEngine {
1074
1180
  instanceId: r.instanceId,
1075
1181
  kind: r.kind,
1076
1182
  status: r.status,
1077
- running: !!this.instances[r.instanceId],
1183
+ running: !!this.instances[runtimeKey(domainId, r.instanceId)],
1078
1184
  realityMode: r.realityMode, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
1079
1185
  board: r.board
1080
1186
  }
@@ -1105,7 +1211,7 @@ export class TwinEngine {
1105
1211
  const seen = new Set<string>()
1106
1212
  /* payload 매핑은 순수 함수(buildEntityDeltas)로 분리 — 여기선 시그니처 dedup + 발행만.
1107
1213
  * 변화한 엔티티만 발행(최신-상태 채널이라 무변화 재방송 무의미). */
1108
- for (const { tag, data } of buildEntityDeltas(st)) {
1214
+ for (const { tag, data } of buildEntityDeltas(st, inst.id)) {
1109
1215
  seen.add(tag)
1110
1216
  const sig = JSON.stringify(data)
1111
1217
  if (sigs.get(tag) === sig) continue // 무변화 → 발행 생략
@@ -1133,11 +1239,11 @@ export class TwinEngine {
1133
1239
  // 라이브 최신(시간여행 아님) + 인메모리 인스턴스 → 라이브 커널 스냅샷을 직접 사용.
1134
1240
  // replay(StateProjector)는 attentions·ack 등 라이브 전용 파생 상태를 담지 못하므로, 최신은 커널 진실을 쓴다.
1135
1241
  if (untilRevision == null && untilTime == null) {
1136
- const live = this.instances[instanceId]
1242
+ const live = this.instances[runtimeKey(domainId, instanceId)]
1137
1243
  if (live?.kernel?.getSnapshot) return live.kernel.getSnapshot()
1138
1244
  // live 모드는 kernel 이 없고 projector 미러 → 인메모리 최신 스냅샷(projector+OEE+attentions) 직접 사용.
1139
1245
  // (저널이 있어도 최신은 인메모리가 진실 — replay 는 시간여행/복구 전용.)
1140
- if (live?.mode === 'live' && live.projector) return this.snapshot(instanceId)
1246
+ if (live?.mode === 'live' && live.projector) return this.snapshot(domainId, instanceId)
1141
1247
  }
1142
1248
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
1143
1249
  if (!reg?.board) throw new Error(`twin instance "${instanceId}" not registered (no board to replay)`)
@@ -1197,15 +1303,61 @@ export class TwinEngine {
1197
1303
  const newest = structures[structures.length - 1]?.board as BoardDef | undefined
1198
1304
  if (asOfNow && newest && segments[segments.length - 1]?.board !== newest) segments.push({ board: newest, events: [] })
1199
1305
 
1200
- if (!segments.length) return replay(((asOfNow && newest) || reg.board) as BoardDef, [])
1201
- if (segments.length === 1) return replay(segments[0].board, segments[0].events)
1306
+ /*
1307
+ * 접은 상태에 **주의 신호와 시각을 채운다.**
1308
+ *
1309
+ * 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 접는 이 경로는 프로젝터 상태만 낸다 — 신호도
1310
+ * `nowTime` 도 없다. 그래서 **과거를 되짚으면 주의 레일이 텅 비었고**(지도는 `snap.attentions` 를
1311
+ * 읽는다), 기동돼 있지 않은 트윈을 보는 화면도 같았다. 신호는 상태에서 계산되는 것이므로
1312
+ * 여기서 같은 공식(`deriveAttentions`)으로 채우면 된다 — 두 벌을 두지 않는다.
1313
+ *
1314
+ * `nowTime` 이 먼저다: "늦었나" 판정이 그 값을 본다. 되짚은 시점의 정직한 "지금" 은 물어본 시각
1315
+ * (`untilTime`)이고, 없으면 마지막으로 적용한 이벤트의 시각이다. 둘 다 없으면 채우지 않는다 —
1316
+ * 벽시계를 끼워 넣으면 과거 화면이 "지금 기준으로 늦었다" 고 말하게 된다.
1317
+ */
1318
+ const lastEventTime = rows.length ? String(rows[rows.length - 1]?.eventTime ?? '') : ''
1319
+
1320
+ /*
1321
+ * **가동 이력도 되살린다** — 되짚은 화면에 설비 계측이 비어 있던 것.
1322
+ *
1323
+ * OEE 는 원 시스템이 누적을 보내 주지 않아 호스트가 상태 전이를 적분해 만든다(그래서 커널이 아니라
1324
+ * 여기 있다). 그런데 그 누적기는 **라이브에서만** 돌았고, 저널을 접는 경로는 그 계산을 하지 않았다 —
1325
+ * 과거를 되짚으면 모든 설비가 "가동 이력이 전혀 없음" 으로 보였다.
1326
+ *
1327
+ * 없는 것은 데이터가 아니라 계산이다: 입력(`equipment.status` 전이·`quality.output`)은 저널에 다
1328
+ * 있다. 그래서 **같은 누적기에 같은 이벤트를 태운다** — 규칙을 두 벌 만들지 않는다.
1329
+ *
1330
+ * 시간여행에서도 맞다: 여기 태우는 것은 이미 잘라 낸(`wanted`) 이벤트뿐이므로, 그 시점까지의
1331
+ * 가동 이력이 나온다(그 뒤에 일어난 고장이 과거 화면에 섞이지 않는다).
1332
+ */
1333
+ const oee = new OeeAccumulator()
1334
+ for (const r of wanted) {
1335
+ try {
1336
+ oee.apply(r.payload as any)
1337
+ } catch {
1338
+ /* 한 건이 이상해도 나머지 계측을 버리지 않는다 — 누적기는 모르는 이벤트를 무시하는 계약이다. */
1339
+ }
1340
+ }
1341
+
1342
+ const withNow = (st: any): any => {
1343
+ if (!st || typeof st !== 'object') return st
1344
+ const nowTime = st.nowTime || untilTime || lastEventTime || undefined
1345
+ const based = nowTime ? { ...st, nowTime } : st
1346
+ /* 계측 시점은 그 화면의 "지금" 이다 — 벽시계로 재면 과거 화면의 가용률이 시간이 갈수록 떨어진다. */
1347
+ const nowMs = nowTime ? Date.parse(String(nowTime)) : Number.NaN
1348
+ const withMetrics = withLiveOee(based, oee, Number.isFinite(nowMs) ? nowMs : undefined)
1349
+ return withLiveAttentions(withMetrics)
1350
+ }
1351
+
1352
+ if (!segments.length) return withNow(replay(((asOfNow && newest) || reg.board) as BoardDef, []))
1353
+ if (segments.length === 1) return withNow(replay(segments[0].board, segments[0].events))
1202
1354
 
1203
1355
  const { state, shifts } = replaySegments(segments)
1204
1356
  /* 경계에서 사라진 것을 조용히 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */
1205
1357
  for (const sh of shifts)
1206
1358
  if (sh.equipmentDropped || sh.locationsDropped || sh.personsDropped || sh.assetsDropped)
1207
1359
  console.info(`[twin-engine] "${instanceId}" replay crossed a structure change — dropped ${sh.equipmentDropped} equipment, ${sh.locationsDropped} locations, ${sh.personsDropped} persons, ${sh.assetsDropped} assets that no longer exist.`)
1208
- return state
1360
+ return withNow(state)
1209
1361
  }
1210
1362
 
1211
1363
  /**
@@ -1237,12 +1389,12 @@ export class TwinEngine {
1237
1389
  this.stopHooks.push(fn)
1238
1390
  }
1239
1391
 
1240
- static async stop(id: string): Promise<void> {
1241
- const i = this.instances[id]
1392
+ static async stop(domainId: string, id: string): Promise<void> {
1393
+ const i = this.instances[runtimeKey(domainId, id)]
1242
1394
  if (i) {
1243
1395
  clearInterval(i.timer)
1244
1396
  i.unsub()
1245
- delete this.instances[id]
1397
+ delete this.instances[runtimeKey(domainId, id)]
1246
1398
  await getRepository(TwinInstance)
1247
1399
  .update({ domain: { id: i.domainId }, instanceId: id }, { status: 'stopped' })
1248
1400
  .catch(err => console.error('twin deregister fail', err))
@@ -1250,8 +1402,77 @@ export class TwinEngine {
1250
1402
  for (const hook of this.stopHooks) { try { hook(id) } catch { /* 훅 격리 */ } }
1251
1403
  }
1252
1404
 
1253
- static runtime(id: string): TwinRuntimeType | undefined {
1254
- return this.instances[id]?.runtime
1405
+ /*
1406
+ * tick 한 번 — **한 트윈의 예외가 서버를 내리지 못하게** 감싼다.
1407
+ *
1408
+ * 잘못된 시나리오가 실렸을 때 다음 tick 에서 `rate.meanPerHour` 를 읽다 터졌고, 타이머 콜백의
1409
+ * 예외는 아무도 받지 않아 **uncaught exception 으로 프로세스가 내려갔다.** 한 테넌트의 잘못된
1410
+ * 선언이 모든 테넌트를 멈춘 셈이다. 문 앞에서 막는 것이 1차 방벽이고(`validateScenario`),
1411
+ * 이것이 2차 방벽이다.
1412
+ *
1413
+ * **조용히 삼키지 않는다.** 굴리기를 멈추고 그 사실을 남긴다 — 예외를 무시하고 계속 tick 하면
1414
+ * 같은 오류가 매 주기 쏟아지고, 그 트윈은 "도는 것처럼 보이면서" 아무것도 진행하지 않는다.
1415
+ */
1416
+ private static tickGuarded(domainId: string, id: string, runtime: TwinRuntimeType): void {
1417
+ try {
1418
+ runtime.tick(this.TICK_MS)
1419
+ } catch (err: any) {
1420
+ const inst = this.instances[runtimeKey(domainId, id)]
1421
+ if (inst?.timer) {
1422
+ clearInterval(inst.timer)
1423
+ inst.timer = undefined
1424
+ }
1425
+ console.error(
1426
+ `[twin-engine] "${id}" tick failed — simulation stopped for this twin (other twins keep running). ` +
1427
+ `Fix the declaration and start it again. Reason: ${err?.message ?? err}`
1428
+ )
1429
+ }
1430
+ }
1431
+
1432
+ static runtime(domainId: string, id: string): TwinRuntimeType | undefined {
1433
+ return this.instances[runtimeKey(domainId, id)]?.runtime
1434
+ }
1435
+
1436
+ /*
1437
+ * 커맨드 실행 — **판정은 `routeCommand`(순수)가 하고 여기서는 실행만** 한다.
1438
+ * 판정을 코드 한가운데 두면 이 부류를 테스트로 못 잡는다(엔진은 DB 를 물고 있어 단위 테스트가
1439
+ * 불러올 수 없다). 예전에는 리졸버가 `inst.runtime.dispatch` 를 곧바로 불러 미러에서 터졌다.
1440
+ */
1441
+ static async dispatchCommand(domainId: string, instanceId: string, command: any): Promise<{ accepted: boolean; error?: string; errorCode?: string; errorParams?: Record<string, string | number> }> {
1442
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
1443
+ const type = String(command?.type ?? '')
1444
+ const route = routeCommand(
1445
+ inst && { mode: inst.mode, hasRuntime: !!inst.runtime, hasKernelDispatch: typeof (inst.kernel as any)?.dispatch === 'function' },
1446
+ !!inst && inst.domainId === domainId,
1447
+ type
1448
+ )
1449
+ if (route.target === 'reject') {
1450
+ return { accepted: false, errorCode: route.errorCode, errorParams: route.errorParams, error: route.errorCode }
1451
+ }
1452
+
1453
+ const cmd = { ...command, tenantId: domainId } // tenantId 는 호출자 도메인으로 각인(감사·무결성)
1454
+ if (route.target === 'runtime') return inst!.runtime!.dispatch(cmd)
1455
+
1456
+ /*
1457
+ * 미러 — 관측 커널로 보내고, **커맨드가 낸 사실을 저널 큐에 실어** 코얼레서가 번호를 부여하게 한다.
1458
+ * 미러에는 State 구독 배관이 없어(시뮬은 그 경로로 저널링) 커널 방출이 아무 데도 닿지 않는다.
1459
+ * 여기서 DB 를 따로 읽어 번호를 매기면 인메모리 카운터와 어긋나 리비전이 겹친다(겹침은 오류를
1460
+ * 내지 않고 재생 순서만 조용히 뒤섞는다).
1461
+ */
1462
+ const kernel: any = inst!.kernel
1463
+ const emitted: any[] = []
1464
+ const off = kernel.onEvent?.((e: any) => emitted.push(e))
1465
+ let ack: any
1466
+ try {
1467
+ ack = kernel.dispatch(cmd)
1468
+ } finally {
1469
+ off?.()
1470
+ }
1471
+ if (ack?.accepted && emitted.length) {
1472
+ inst!.pendingJournal = [...(inst!.pendingJournal ?? []), ...emitted]
1473
+ inst!.dirty = true // 코얼레서가 이번 주기에 비우고 방송까지 하게 한다
1474
+ }
1475
+ return ack ?? { accepted: false, errorCode: 'unknown-command', error: 'unknown-command' }
1255
1476
  }
1256
1477
 
1257
1478
  /**
@@ -1260,8 +1481,18 @@ export class TwinEngine {
1260
1481
  * this.instances[id] 를 만지기 전에 반드시 이걸로 확인해야 한다. 인스턴스 id 는 추측 가능하므로
1261
1482
  * 검증 없이 접근하면 크로스테넌트 읽기/정지가 가능해진다.
1262
1483
  */
1484
+ /**
1485
+ * 이 런타임의 **관측 모드** — `'live'`(외부 실물을 미러) 또는 `'sim'`(커널이 굴린다). 떠 있지 않으면 없다.
1486
+ *
1487
+ * 소비처가 `instances[id].mode` 를 직접 읽던 자리를 대신한다. 레지스트리를 밖에 열면 도메인 확인이
1488
+ * 자리마다 제각각이 되고, 실제로 그렇게 됐다(테넌트 격리 전수 확인, 2026-08-06).
1489
+ */
1490
+ static modeOf(domainId: string, id: string): 'live' | 'sim' | undefined {
1491
+ return this.instances[runtimeKey(domainId, id)]?.mode
1492
+ }
1493
+
1263
1494
  static owns(domainId: string, id: string): boolean {
1264
- return this.instances[id]?.domainId === domainId
1495
+ return !!this.instances[runtimeKey(domainId, id)]
1265
1496
  }
1266
1497
 
1267
1498
  /**
@@ -1279,14 +1510,52 @@ export class TwinEngine {
1279
1510
  return !!(await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }))
1280
1511
  }
1281
1512
 
1513
+ /**
1514
+ * **이 공간의 인스턴스들** — 한 현실을 여러 렌즈가 비추므로, 공간을 물으면 그 렌즈 전부를 답한다.
1515
+ *
1516
+ * 화면이 이 규칙을 손으로 짜지 않게 서버가 답한다. 예측 화면은 같은 확장을 **한 파일에서 두 번**
1517
+ * 복제하고 있었다(전체 목록을 받아 클라이언트에서 걸렀다) — 규칙이 흩어지면 한쪽만 고쳐진다.
1518
+ *
1519
+ * 떠 있지 않은 것도 포함한다: 공간에 무엇이 있는지는 기동 여부와 다른 사실이다.
1520
+ */
1521
+ static async instanceIdsOfSpace(domainId: string, spaceId: string): Promise<string[]> {
1522
+ if (!spaceId) return []
1523
+ const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } })
1524
+ return rows.map(r => r.instanceId).filter((x): x is string => !!x)
1525
+ }
1526
+
1527
+ /**
1528
+ * **이 공간에서 봐야 할 것** — 공간의 모든 렌즈에서 신호를 모은다.
1529
+ *
1530
+ * 합집합이 그대로 뜻이 있는 유일한 렌즈다(예측은 합칠 수 없다 — P50 두 개를 더할 수 없다).
1531
+ * 정렬·잘라내기는 화면과 **같은 규칙**을 쓴다(`digestAttentions`) — 서버가 고른 상위 N 이 화면이
1532
+ * 고를 N 과 달라지면 그 어긋남은 아무 데서도 오류로 드러나지 않는다.
1533
+ *
1534
+ * 신호마다 어느 트윈에서 왔는지(`instanceId`)를 붙인다. 공간에서 보면 같은 자리 id 가 렌즈마다
1535
+ * 다른 것을 가리킬 수 있고, 조치는 결국 그 트윈에 보내야 한다.
1536
+ */
1537
+ static async attentionsOfSpace(domainId: string, spaceId: string, limit?: number): Promise<{ attentions: any[]; attentionTotal: number; severityByLocation: Record<string, string> }> {
1538
+ const ids = await this.instanceIdsOfSpace(domainId, spaceId)
1539
+ const lenses = ids
1540
+ /* 떠 있지 않은 트윈은 **지금** 신호가 없다(그 시절 신호는 시간여행이 답한다). */
1541
+ .filter(id => this.owns(domainId, id))
1542
+ .map(id => {
1543
+ const kernel: any = this.kernel(domainId, id)
1544
+ const snap = kernel?.getSnapshot?.() ?? this.snapshot(domainId, id)
1545
+ return { instanceId: id, attentions: snap?.attentions ?? [] }
1546
+ })
1547
+ /* 모으는 규칙(태깅·급한 순서·자리 색)은 순수 함수가 들고 있다 — 여기서 손으로 접지 않는다. */
1548
+ return mergeLensAttentions(lenses, limit)
1549
+ }
1550
+
1282
1551
  /** 라이브 커널(ForecastTwin) — forecast/divergence 예측 연산용. */
1283
- static kernel(id: string): any {
1284
- return this.instances[id]?.kernel
1552
+ static kernel(domainId: string, id: string): any {
1553
+ return this.instances[runtimeKey(domainId, id)]?.kernel
1285
1554
  }
1286
1555
 
1287
1556
  /** 라이브 처리량 계측 스냅샷(모니터, ④-1) — 내부 누적(_acc*) 제외한 공개 지표. live 아니면 null. */
1288
- static metrics(id: string): any {
1289
- const m = this.instances[id]?.metrics
1557
+ static metrics(domainId: string, id: string): any {
1558
+ const m = this.instances[runtimeKey(domainId, id)]?.metrics
1290
1559
  if (!m) return null
1291
1560
  return {
1292
1561
  instanceId: id,
@@ -1298,9 +1567,13 @@ export class TwinEngine {
1298
1567
 
1299
1568
  /** 전체 라이브 인스턴스 계측(모니터 대시보드용). */
1300
1569
  static async allMetrics(domainId?: string): Promise<any[]> {
1570
+ /* 도메인 없이 부르면 전 테넌트를 훑는다(내부 모니터용) — 키에서 도메인을 되돌려 각자에게 묻는다. */
1301
1571
  const rows = Object.keys(this.instances)
1302
- .filter(id => !domainId || this.instances[id].domainId === domainId)
1303
- .map(id => this.metrics(id))
1572
+ .filter(key => !domainId || isOfDomain(key, domainId))
1573
+ .map(key => {
1574
+ const at = parseRuntimeKey(key)
1575
+ return this.metrics(at.domainId, at.instanceId)
1576
+ })
1304
1577
  .filter(Boolean)
1305
1578
  if (!rows.length || !domainId) return rows
1306
1579
  // 표시 이름 부여 — 실행중 카드가 id 만 보이지 않도록 공간명을 실어준다(공간 카드와 동일). name=공간명 폴백 spaceId 폴백 instanceId.
@@ -1334,7 +1607,7 @@ export class TwinEngine {
1334
1607
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
1335
1608
  if (!reg?.board) return null
1336
1609
  const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel
1337
- const k = new Kernel(domainId, undefined, this.mesSpecOf(reg.board as BoardDef))
1610
+ const k = new Kernel(domainId, undefined, this.productionSpecOf(reg.board as BoardDef))
1338
1611
  k.loadBoard(reg.board as BoardDef)
1339
1612
  this.applyOperations(k, reg.board, instanceId) // 예측도 같은 명세로 굴러야 한다(화면과 다른 숫자 금지)
1340
1613
  await this.installEstimators(k, domainId, instanceId, reg.board) // 예측은 기다린다 — 실측을 놓치면 예측이 상수로 돈다
@@ -1353,8 +1626,8 @@ export class TwinEngine {
1353
1626
  }
1354
1627
 
1355
1628
  /** 현재 전체 스냅샷 — 라이브 우선, 없으면 저널 복구 캐시. */
1356
- static snapshot(id: string): any {
1357
- const inst = this.instances[id]
1629
+ static snapshot(domainId: string, id: string): any {
1630
+ const inst = this.instances[runtimeKey(domainId, id)]
1358
1631
  if (inst?.mode === 'live' && inst.projector) {
1359
1632
  /* live: **커널이 주목 신호를 스스로 낸다**(관측 모드) — 호스트가 덧붙이던 withLiveAttentions 는
1360
1633
  * 필요 없다. OEE 만 호스트가 채운다: 원 시스템이 시간 누적을 보내 주지 않아 상태 전이를 적분해
@@ -1362,6 +1635,6 @@ export class TwinEngine {
1362
1635
  const st = inst.projector.snapshot()
1363
1636
  return inst.oee ? withLiveOee(st, inst.oee) : st
1364
1637
  }
1365
- return inst?.runtime?.resync() ?? this.recovered[id]
1638
+ return inst?.runtime?.resync() ?? this.recovered[runtimeKey(domainId, id)]
1366
1639
  }
1367
1640
  }