@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.
- package/dist-server/engine/attention-digest.d.ts +52 -0
- package/dist-server/engine/attention-digest.js +76 -0
- package/dist-server/engine/attention-digest.js.map +1 -0
- package/dist-server/engine/board-vocabulary.d.ts +17 -0
- package/dist-server/engine/board-vocabulary.js +63 -0
- package/dist-server/engine/board-vocabulary.js.map +1 -0
- package/dist-server/engine/command-routing.d.ts +33 -0
- package/dist-server/engine/command-routing.js +53 -0
- package/dist-server/engine/command-routing.js.map +1 -0
- package/dist-server/engine/entity-delta.d.ts +13 -6
- package/dist-server/engine/entity-delta.js +38 -12
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +6 -0
- package/dist-server/engine/index.js +10 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-baseline.d.ts +78 -0
- package/dist-server/engine/kpi-baseline.js +123 -0
- package/dist-server/engine/kpi-baseline.js.map +1 -0
- package/dist-server/engine/kpi-broadcast.d.ts +4 -0
- package/dist-server/engine/kpi-broadcast.js +16 -0
- package/dist-server/engine/kpi-broadcast.js.map +1 -0
- package/dist-server/engine/kpi-query.d.ts +31 -0
- package/dist-server/engine/kpi-query.js +50 -2
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-attentions.d.ts +1 -0
- package/dist-server/engine/live-attentions.js +7 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/runtime-key.d.ts +15 -0
- package/dist-server/engine/runtime-key.js +64 -0
- package/dist-server/engine/runtime-key.js.map +1 -0
- package/dist-server/engine/state-axes.d.ts +16 -0
- package/dist-server/engine/state-axes.js +54 -0
- package/dist-server/engine/state-axes.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +92 -16
- package/dist-server/engine/twin-engine.js +335 -77
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/twin-level.d.ts +23 -0
- package/dist-server/engine/twin-level.js +52 -0
- package/dist-server/engine/twin-level.js.map +1 -0
- package/dist-server/engine/warm-start.d.ts +58 -12
- package/dist-server/engine/warm-start.js +80 -9
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +34 -0
- package/dist-server/service/reference/discovery-result.js +84 -0
- package/dist-server/service/reference/discovery-result.js.map +1 -0
- package/dist-server/service/reference/ingest-space.d.ts +30 -0
- package/dist-server/service/reference/ingest-space.js +63 -0
- package/dist-server/service/reference/ingest-space.js.map +1 -0
- package/dist-server/service/reference/knob-defaults.d.ts +20 -0
- package/dist-server/service/reference/knob-defaults.js +59 -0
- package/dist-server/service/reference/knob-defaults.js.map +1 -0
- package/dist-server/service/reference/reference-live.js +2 -2
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +37 -2
- package/dist-server/service/reference/reference-master.js +55 -5
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +2 -2
- package/dist-server/service/reference/reference-resolver.js +71 -15
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +4 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
- package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
- package/dist-server/service/twin-structure/twin-structure.js +2 -1
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
- package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/attention-digest.ts +102 -0
- package/server/engine/board-vocabulary.ts +61 -0
- package/server/engine/command-routing.ts +67 -0
- package/server/engine/entity-delta.ts +33 -11
- package/server/engine/index.ts +10 -0
- package/server/engine/kpi-baseline.ts +202 -0
- package/server/engine/kpi-broadcast.ts +13 -0
- package/server/engine/kpi-query.ts +81 -3
- package/server/engine/live-attentions.ts +7 -2
- package/server/engine/runtime-key.ts +58 -0
- package/server/engine/state-axes.ts +55 -0
- package/server/engine/twin-engine.ts +350 -77
- package/server/engine/twin-level.ts +48 -0
- package/server/engine/warm-start.ts +130 -16
- package/server/service/reference/discovery-result.ts +95 -0
- package/server/service/reference/ingest-space.ts +70 -0
- package/server/service/reference/knob-defaults.ts +59 -0
- package/server/service/reference/reference-live.ts +2 -2
- package/server/service/reference/reference-master.ts +94 -8
- package/server/service/reference/reference-resolver.ts +80 -16
- package/server/service/twin-attention/twin-attention-query.ts +43 -6
- package/server/service/twin-control/twin-control-mutation.ts +31 -12
- package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
- package/server/service/twin-instance/twin-instance.ts +6 -2
- package/server/service/twin-journal/twin-journal-query.ts +35 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
- package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +10 -1
- package/server/service/twin-state/twin-state-subscription.ts +1 -1
- package/server/service/twin-structure/twin-structure.ts +4 -1
- package/server/service/twin-target/twin-target-resolver.ts +22 -4
- package/test/attention-digest.test.ts +135 -0
- package/test/board-vocabulary.test.ts +114 -0
- package/test/capability-mapping.test.ts +4 -4
- package/test/command-routing.test.ts +61 -0
- package/test/discovery-result.test.ts +75 -0
- package/test/entity-delta.test.ts +25 -25
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-space.test.ts +50 -0
- package/test/knob-defaults.test.ts +72 -0
- package/test/kpi-baseline-db.test.ts +214 -0
- package/test/kpi-baseline.test.ts +196 -0
- package/test/kpi-query-bench.test.ts +128 -0
- package/test/live-mirror-parity.test.ts +35 -2
- package/test/master-to-twin.test.ts +7 -3
- package/test/mutation-gate.test.ts +108 -0
- package/test/oee-accumulator.test.ts +65 -1
- package/test/registry-key-guard.test.ts +80 -0
- package/test/runtime-key.test.ts +66 -0
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/state-axes.test.ts +74 -0
- package/test/streamline-e2e.test.ts +2 -2
- package/test/structure-revision-db.test.ts +5 -4
- package/test/tenant-registry-db.test.ts +149 -0
- package/test/warm-start-seam.test.ts +140 -0
- 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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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?.
|
|
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
|
-
|
|
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.
|
|
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[
|
|
389
|
-
delete this.recovered[
|
|
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(() =>
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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.
|
|
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[
|
|
478
|
-
delete this.recovered[
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
-
|
|
918
|
-
|
|
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
|
-
|
|
940
|
-
a.
|
|
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(
|
|
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
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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]
|
|
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(
|
|
1303
|
-
.map(
|
|
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.
|
|
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
|
}
|