@things-factory/headless-twin 10.0.9 → 10.0.11
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/canonical-ingest.d.ts +2 -2
- package/dist-server/engine/canonical-ingest.js +24 -2
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/command-routing.js +1 -1
- package/dist-server/engine/command-routing.js.map +1 -1
- package/dist-server/engine/energy-topology.d.ts +86 -0
- package/dist-server/engine/energy-topology.js +144 -0
- package/dist-server/engine/energy-topology.js.map +1 -0
- package/dist-server/engine/index.d.ts +8 -3
- package/dist-server/engine/index.js +10 -3
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-baseline.d.ts +1 -1
- package/dist-server/engine/kpi-baseline.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +137 -1
- package/dist-server/engine/kpi-fold.js +225 -0
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +1 -1
- package/dist-server/engine/kpi-query.js +212 -18
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/kpi-target.d.ts +17 -0
- package/dist-server/engine/kpi-target.js +24 -2
- package/dist-server/engine/kpi-target.js.map +1 -1
- package/dist-server/engine/live-feed-registry.d.ts +25 -0
- package/dist-server/engine/live-feed-registry.js +51 -0
- package/dist-server/engine/live-feed-registry.js.map +1 -0
- package/dist-server/engine/load-meter.d.ts +181 -0
- package/dist-server/engine/load-meter.js +267 -0
- package/dist-server/engine/load-meter.js.map +1 -0
- package/dist-server/engine/local-declarations.d.ts +262 -0
- package/dist-server/engine/local-declarations.js +528 -0
- package/dist-server/engine/local-declarations.js.map +1 -0
- package/dist-server/engine/model-basis.d.ts +39 -6
- package/dist-server/engine/model-basis.js +69 -9
- package/dist-server/engine/model-basis.js.map +1 -1
- package/dist-server/engine/{board-vocabulary.d.ts → model-vocabulary.d.ts} +4 -4
- package/dist-server/engine/{board-vocabulary.js → model-vocabulary.js} +37 -19
- package/dist-server/engine/model-vocabulary.js.map +1 -0
- package/dist-server/engine/oee-accumulator.d.ts +28 -0
- package/dist-server/engine/oee-accumulator.js +26 -1
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/operation-basis.d.ts +32 -0
- package/dist-server/engine/operation-basis.js +87 -0
- package/dist-server/engine/operation-basis.js.map +1 -0
- package/dist-server/engine/property-effects.d.ts +30 -0
- package/dist-server/engine/property-effects.js +192 -0
- package/dist-server/engine/property-effects.js.map +1 -0
- package/dist-server/engine/runtime-key.d.ts +1 -1
- package/dist-server/engine/runtime-key.js +1 -1
- package/dist-server/engine/runtime-key.js.map +1 -1
- package/dist-server/engine/spec-coverage.js +3 -3
- package/dist-server/engine/spec-coverage.js.map +1 -1
- package/dist-server/engine/state-axes.d.ts +7 -2
- package/dist-server/engine/state-axes.js +8 -3
- package/dist-server/engine/state-axes.js.map +1 -1
- package/dist-server/engine/structure-diff.js +1 -1
- package/dist-server/engine/structure-diff.js.map +1 -1
- package/dist-server/engine/travel-estimator.d.ts +9 -2
- package/dist-server/engine/travel-estimator.js +11 -6
- package/dist-server/engine/travel-estimator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +332 -47
- package/dist-server/engine/twin-engine.js +1314 -279
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +38 -4
- package/dist-server/engine/warm-start.js +38 -9
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +17 -11
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.d.ts +5 -0
- package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js +48 -0
- package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js.map +1 -0
- package/dist-server/migrations/index.d.ts +2 -0
- package/dist-server/migrations/index.js +10 -0
- package/dist-server/migrations/index.js.map +1 -0
- package/dist-server/service/index.d.ts +3 -2
- package/dist-server/service/index.js +25 -17
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +1 -1
- package/dist-server/service/reference/discovery-result.js +2 -2
- package/dist-server/service/reference/discovery-result.js.map +1 -1
- package/dist-server/service/reference/ingest-space.d.ts +24 -1
- package/dist-server/service/reference/ingest-space.js +17 -1
- package/dist-server/service/reference/ingest-space.js.map +1 -1
- package/dist-server/service/reference/reference-live.d.ts +23 -0
- package/dist-server/service/reference/reference-live.js +121 -4
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +194 -13
- package/dist-server/service/reference/reference-master.js +159 -28
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +31 -1
- package/dist-server/service/reference/reference-resolver.js +223 -12
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +19 -3
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-audit/command-audit.d.ts +37 -0
- package/dist-server/service/twin-audit/command-audit.js +53 -0
- package/dist-server/service/twin-audit/command-audit.js.map +1 -0
- package/dist-server/service/twin-audit/index.d.ts +4 -0
- package/dist-server/service/twin-audit/index.js +8 -0
- package/dist-server/service/twin-audit/index.js.map +1 -0
- package/dist-server/service/twin-audit/twin-audit-event.d.ts +24 -0
- package/dist-server/service/twin-audit/twin-audit-event.js +141 -0
- package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -0
- package/dist-server/service/twin-audit/twin-audit-query.d.ts +4 -0
- package/dist-server/service/twin-audit/twin-audit-query.js +76 -0
- package/dist-server/service/twin-audit/twin-audit-query.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +21 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
- package/dist-server/service/twin-event/twin-event-keys.js +1 -1
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +14 -5
- package/dist-server/service/twin-event/twin-event.js +45 -13
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/forecast-metrics.d.ts +13 -0
- package/dist-server/service/twin-forecast/forecast-metrics.js +57 -0
- package/dist-server/service/twin-forecast/forecast-metrics.js.map +1 -0
- package/dist-server/service/twin-forecast/forecast-tuning.d.ts +12 -0
- package/dist-server/service/twin-forecast/forecast-tuning.js +44 -0
- package/dist-server/service/twin-forecast/forecast-tuning.js.map +1 -0
- package/dist-server/service/twin-forecast/gap-analytics.d.ts +28 -2
- package/dist-server/service/twin-forecast/gap-analytics.js +43 -17
- package/dist-server/service/twin-forecast/gap-analytics.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +217 -38
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.d.ts +38 -5
- package/dist-server/service/twin-instance/twin-instance.js +66 -20
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +5 -2
- package/dist-server/service/twin-journal/twin-journal-query.js +71 -9
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +3 -5
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +24 -27
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
- package/dist-server/service/twin-metrics/twin-metrics-query.js +62 -0
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-model/epcis-coverage.d.ts +14 -0
- package/dist-server/service/twin-model/epcis-coverage.js +174 -0
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -0
- package/dist-server/service/twin-model/iec61850-coverage.d.ts +14 -0
- package/dist-server/service/twin-model/iec61850-coverage.js +98 -0
- package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -0
- package/dist-server/service/twin-model/index.d.ts +13 -0
- package/dist-server/service/twin-model/index.js +29 -0
- package/dist-server/service/twin-model/index.js.map +1 -0
- package/dist-server/service/twin-model/isa95-coverage.d.ts +48 -0
- package/dist-server/service/twin-model/isa95-coverage.js +155 -0
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -0
- package/dist-server/service/twin-model/project-structure.d.ts +16 -0
- package/dist-server/service/twin-model/project-structure.js +173 -0
- package/dist-server/service/twin-model/project-structure.js.map +1 -0
- package/dist-server/service/twin-model/standard-coverage.d.ts +9 -0
- package/dist-server/service/twin-model/standard-coverage.js +37 -0
- package/dist-server/service/twin-model/standard-coverage.js.map +1 -0
- package/dist-server/service/twin-model/twin-equipment.d.ts +64 -0
- package/dist-server/service/twin-model/twin-equipment.js +134 -0
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -0
- package/dist-server/service/twin-model/twin-lineage-query.d.ts +3 -0
- package/dist-server/service/twin-model/twin-lineage-query.js +206 -0
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -0
- package/dist-server/service/twin-model/twin-location.d.ts +58 -0
- package/dist-server/service/twin-model/twin-location.js +130 -0
- package/dist-server/service/twin-model/twin-location.js.map +1 -0
- package/dist-server/service/twin-model/twin-model-item-query.d.ts +141 -0
- package/dist-server/service/twin-model/twin-model-item-query.js +852 -0
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -0
- package/dist-server/service/twin-model/twin-model-mutation.d.ts +57 -0
- package/dist-server/service/twin-model/twin-model-mutation.js +490 -0
- package/dist-server/service/twin-model/twin-model-mutation.js.map +1 -0
- package/dist-server/service/twin-model/twin-model-query.d.ts +43 -0
- package/dist-server/service/twin-model/twin-model-query.js +551 -0
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -0
- package/dist-server/service/twin-model/twin-model-tree-query.d.ts +3 -0
- package/dist-server/service/twin-model/twin-model-tree-query.js +158 -0
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -0
- package/dist-server/service/twin-model/twin-operation.d.ts +61 -0
- package/dist-server/service/twin-model/twin-operation.js +137 -0
- package/dist-server/service/twin-model/twin-operation.js.map +1 -0
- package/dist-server/service/twin-space/move-space.d.ts +65 -0
- package/dist-server/service/twin-space/move-space.js +166 -0
- package/dist-server/service/twin-space/move-space.js.map +1 -0
- package/dist-server/service/twin-space/space-integrity.d.ts +97 -0
- package/dist-server/service/twin-space/space-integrity.js +182 -0
- package/dist-server/service/twin-space/space-integrity.js.map +1 -0
- package/dist-server/service/twin-space/twin-space-area.js +4 -4
- package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-representation.js +6 -6
- package/dist-server/service/twin-space/twin-space-representation.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.d.ts +63 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +356 -18
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +17 -1
- package/dist-server/service/twin-space/twin-space.js +15 -5
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-structure/twin-structure.d.ts +1 -1
- package/dist-server/service/twin-structure/twin-structure.js +8 -8
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
- package/dist-shared/axis-read.d.ts +12 -0
- package/dist-shared/axis-read.js +32 -0
- package/dist-shared/axis-read.js.map +1 -0
- package/{dist-server/engine → dist-shared}/entity-delta.d.ts +3 -0
- package/{dist-server/engine → dist-shared}/entity-delta.js +102 -2
- package/dist-shared/entity-delta.js.map +1 -0
- package/dist-shared/kpi-broadcast.js.map +1 -0
- package/dist-shared/twin-level.js.map +1 -0
- package/package.json +14 -12
- package/server/engine/canonical-ingest.ts +36 -4
- package/server/engine/command-routing.ts +1 -1
- package/server/engine/energy-topology.ts +189 -0
- package/server/engine/index.ts +10 -3
- package/server/engine/kpi-baseline.ts +1 -1
- package/server/engine/kpi-fold.ts +389 -1
- package/server/engine/kpi-query.ts +214 -19
- package/server/engine/kpi-target.ts +24 -2
- package/server/engine/live-feed-registry.ts +58 -0
- package/server/engine/load-meter.ts +384 -0
- package/server/engine/local-declarations.ts +700 -0
- package/server/engine/model-basis.ts +78 -10
- package/server/engine/{board-vocabulary.ts → model-vocabulary.ts} +36 -15
- package/server/engine/oee-accumulator.ts +34 -1
- package/server/engine/operation-basis.ts +100 -0
- package/server/engine/property-effects.ts +199 -0
- package/server/engine/runtime-key.ts +1 -1
- package/server/engine/spec-coverage.ts +3 -3
- package/server/engine/state-axes.ts +8 -3
- package/server/engine/structure-diff.ts +1 -1
- package/server/engine/travel-estimator.ts +11 -6
- package/server/engine/twin-engine.ts +1414 -274
- package/server/engine/warm-start.ts +83 -12
- package/server/index.ts +13 -10
- package/server/migrations/1786000000000-RenameTwinInstanceBoardToModel.ts +43 -0
- package/server/migrations/index.ts +7 -0
- package/server/service/index.ts +9 -1
- package/server/service/reference/discovery-result.ts +2 -2
- package/server/service/reference/ingest-space.ts +35 -1
- package/server/service/reference/reference-live.ts +119 -4
- package/server/service/reference/reference-master.ts +276 -35
- package/server/service/reference/reference-resolver.ts +232 -12
- package/server/service/reference/template-registry.ts +20 -5
- package/server/service/twin-audit/command-audit.ts +81 -0
- package/server/service/twin-audit/index.ts +5 -0
- package/server/service/twin-audit/twin-audit-event.ts +138 -0
- package/server/service/twin-audit/twin-audit-query.ts +72 -0
- package/server/service/twin-control/twin-control-mutation.ts +22 -1
- package/server/service/twin-event/twin-event-keys.ts +1 -1
- package/server/service/twin-event/twin-event.ts +59 -14
- package/server/service/twin-forecast/forecast-metrics.ts +60 -0
- package/server/service/twin-forecast/forecast-tuning.ts +40 -0
- package/server/service/twin-forecast/gap-analytics.ts +48 -9
- package/server/service/twin-forecast/twin-forecast-query.ts +208 -36
- package/server/service/twin-instance/twin-instance.ts +126 -25
- package/server/service/twin-journal/twin-journal-query.ts +74 -10
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +23 -22
- package/server/service/twin-metrics/twin-metrics-query.ts +59 -2
- package/server/service/twin-model/epcis-coverage.ts +195 -0
- package/server/service/twin-model/iec61850-coverage.ts +117 -0
- package/server/service/twin-model/index.ts +25 -0
- package/server/service/twin-model/isa95-coverage.ts +190 -0
- package/server/service/twin-model/project-structure.ts +199 -0
- package/server/service/twin-model/standard-coverage.ts +35 -0
- package/server/service/twin-model/twin-equipment.ts +157 -0
- package/server/service/twin-model/twin-lineage-query.ts +193 -0
- package/server/service/twin-model/twin-location.ts +147 -0
- package/server/service/twin-model/twin-model-item-query.ts +848 -0
- package/server/service/twin-model/twin-model-mutation.ts +503 -0
- package/server/service/twin-model/twin-model-query.ts +537 -0
- package/server/service/twin-model/twin-model-tree-query.ts +180 -0
- package/server/service/twin-model/twin-operation.ts +157 -0
- package/server/service/twin-space/move-space.ts +262 -0
- package/server/service/twin-space/space-integrity.ts +293 -0
- package/server/service/twin-space/twin-space-area.ts +4 -4
- package/server/service/twin-space/twin-space-representation.ts +6 -6
- package/server/service/twin-space/twin-space-resolver.ts +355 -21
- package/server/service/twin-space/twin-space.ts +40 -6
- package/server/service/twin-structure/twin-structure.ts +10 -10
- package/shared/axis-read.ts +28 -0
- package/{server/engine → shared}/entity-delta.ts +96 -1
- package/test/adopt-structure-live.test.ts +133 -0
- package/test/attention-digest.test.ts +3 -3
- package/test/axis-read.test.ts +87 -0
- package/test/boot-resume.test.ts +205 -0
- package/test/canonical-ingest-vocabularies.test.ts +118 -0
- package/test/capability-mapping.test.ts +1 -1
- package/test/column-type-portability.test.ts +122 -0
- package/test/command-routing.test.ts +1 -1
- package/test/declaration-reaches-model.test.ts +178 -0
- package/test/discovery-result.test.ts +1 -1
- package/test/energy-topology.test.ts +113 -0
- package/test/entity-delta.test.ts +171 -1
- package/test/event-time-column.test.ts +67 -0
- package/test/forecast-metrics.test.ts +71 -0
- package/test/forecast-tuning.test.ts +42 -0
- package/test/gap-analytics.test.ts +34 -7
- package/test/ingest-bench.test.ts +6 -6
- package/test/ingest-running-guard.test.ts +136 -0
- package/test/ingest-space.test.ts +29 -1
- package/test/instance-cache-lifecycle.test.ts +110 -0
- package/test/kernel-kind-guard.test.ts +96 -0
- package/test/kpi-baseline-db.test.ts +3 -1
- package/test/kpi-baseline.test.ts +3 -3
- package/test/kpi-fold.test.ts +393 -2
- package/test/kpi-query-bench.test.ts +5 -3
- package/test/lineage-survives-restart.test.ts +203 -0
- package/test/live-feed-registry.test.ts +67 -0
- package/test/live-kernel-facts.test.ts +161 -0
- package/test/live-mirror-parity.test.ts +22 -15
- package/test/load-meter.test.ts +314 -0
- package/test/local-declarations.test.ts +554 -0
- package/test/master-to-twin.test.ts +121 -33
- package/test/model-basis.test.ts +56 -1
- package/test/{board-vocabulary.test.ts → model-vocabulary.test.ts} +25 -25
- package/test/move-space.test.ts +149 -0
- package/test/mutation-gate.test.ts +19 -5
- package/test/oee-accumulator.test.ts +26 -7
- package/test/operation-basis.test.ts +100 -0
- package/test/operations-capability-db.test.ts +165 -0
- package/test/project-structure-db.test.ts +226 -0
- package/test/projection-reaches-screen.test.ts +272 -0
- package/test/property-effects.test.ts +96 -0
- package/test/resync-origin-site.test.ts +77 -0
- package/test/scale-twin-bench.test.ts +5 -5
- package/test/snapshot-freshness.test.ts +60 -0
- package/test/space-integrity.test.ts +223 -0
- package/test/standard-coverage.test.ts +94 -0
- package/test/state-axes.test.ts +1 -1
- package/test/streamline-e2e.test.ts +14 -14
- package/test/structure-revision-db.test.ts +11 -11
- package/test/tenant-registry-db.test.ts +5 -5
- package/test/twin-audit.test.ts +83 -0
- package/test/twin-model-item-db.test.ts +275 -0
- package/test/twin-model-tree-db.test.ts +176 -0
- package/test/twin-origin-resync.test.ts +114 -0
- package/test/warm-start-seam.test.ts +6 -6
- package/test/warm-start.test.ts +88 -3
- package/tsconfig.json +6 -1
- package/tsconfig.shared.json +23 -0
- package/tsconfig.shared.tsbuildinfo +1 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/dist-server/engine/board-vocabulary.js.map +0 -1
- package/dist-server/engine/entity-delta.js.map +0 -1
- package/dist-server/engine/kpi-broadcast.js.map +0 -1
- package/dist-server/engine/twin-level.js.map +0 -1
- package/dist-server/service/twin-event/backfill-keys.d.ts +0 -11
- package/dist-server/service/twin-event/backfill-keys.js +0 -63
- package/dist-server/service/twin-event/backfill-keys.js.map +0 -1
- package/dist-server/tsconfig.tsbuildinfo +0 -1
- package/server/service/twin-event/backfill-keys.ts +0 -72
- /package/{dist-server/engine → dist-shared}/kpi-broadcast.d.ts +0 -0
- /package/{dist-server/engine → dist-shared}/kpi-broadcast.js +0 -0
- /package/{dist-server/engine → dist-shared}/twin-level.d.ts +0 -0
- /package/{dist-server/engine → dist-shared}/twin-level.js +0 -0
- /package/{server/engine → shared}/kpi-broadcast.ts +0 -0
- /package/{server/engine → shared}/twin-level.ts +0 -0
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { pubsub, getRepository, Domain } from '@things-factory/shell'
|
|
11
|
+
/* 저널을 자를 조건은 SQL 이 안다 — 관용구만 쓴다(원시 SQL 은 5개 드라이버에서 갈라진다). */
|
|
12
|
+
import { And, IsNull, LessThanOrEqual, MoreThan } from 'typeorm'
|
|
11
13
|
import { cacheService } from '@things-factory/cache-service'
|
|
12
14
|
|
|
15
|
+
import type { ReducerCheckpoint } from '@operato/twin-kernel'
|
|
16
|
+
import type { OeeCheckpoint } from './oee-accumulator.js'
|
|
13
17
|
import { TwinEvent } from '../service/twin-event/twin-event.js'
|
|
14
18
|
import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
|
|
15
|
-
import { planWarmStart, unwrapState } from './warm-start.js'
|
|
19
|
+
import { planLiveContinuity, planWarmStart, unwrapState } from './warm-start.js'
|
|
20
|
+
import { applyDeclarationLayers } from './local-declarations.js'
|
|
16
21
|
import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
|
|
17
22
|
import { routeCommand } from './command-routing.js'
|
|
18
23
|
import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
@@ -23,26 +28,46 @@ import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-repres
|
|
|
23
28
|
import { TwinSpaceArea } from '../service/twin-space/twin-space-area.js'
|
|
24
29
|
import { TwinArea } from '../service/twin-space/twin-area.js'
|
|
25
30
|
import { masterToTwin, type ReferenceMaster } from '../service/reference/reference-master.js'
|
|
26
|
-
import { resolveIngestSpace
|
|
27
|
-
import { type IngestWarning,
|
|
31
|
+
import { resolveIngestSpace } from '../service/reference/ingest-space.js'
|
|
32
|
+
import { type IngestWarning, describeWarnings, unresolvedReference, projectionFailed, structureAdopted, generationWithoutTimeZone } from '../service/reference/reference-master.js'
|
|
33
|
+
import { projectStructure } from '../service/twin-model/project-structure.js'
|
|
28
34
|
import { buildTravelEstimator, chainEstimators } from './travel-estimator.js'
|
|
29
35
|
import { buildMeasuredEstimator } from './measured-estimator.js'
|
|
30
36
|
import { describeModelBasis, type ModelBasis } from './model-basis.js'
|
|
31
37
|
import { computeTwinKpi } from './kpi-query.js'
|
|
32
38
|
import { createHash } from 'node:crypto'
|
|
33
39
|
|
|
34
|
-
import { buildEntityDeltas } from '
|
|
40
|
+
import { buildEntityDeltas } from '@things-factory/headless-twin/dist-shared/entity-delta.js'
|
|
41
|
+
import { fleetLoad, judgeCycle, loadSummary, newLoadMeter, recordFork, recordPhase, slowTickMessage, type LoadMeter, type LoadPhase } from './load-meter.js'
|
|
35
42
|
import { diffStructures, type StructureDiff } from './structure-diff.js'
|
|
36
43
|
import { OeeAccumulator, withLiveOee } from './oee-accumulator.js'
|
|
37
44
|
import { withLiveAttentions } from './live-attentions.js'
|
|
38
45
|
import { digestAttentions, mergeLensAttentions } from './attention-digest.js'
|
|
39
46
|
|
|
40
|
-
import
|
|
47
|
+
import { liveFeedStateOf } from './live-feed-registry.js'
|
|
48
|
+
import { EMS_PROPERTY } from '@operato/twin-kernel'
|
|
49
|
+
import type { TwinKernel, TwinModelDef, StructureShift, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
|
|
41
50
|
|
|
42
51
|
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
|
|
43
|
-
const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
|
|
52
|
+
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
|
|
44
53
|
|
|
45
|
-
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel }
|
|
54
|
+
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 종류 문자열 → 커널. **모르는 값이면 던진다.**
|
|
58
|
+
*
|
|
59
|
+
* 예전에는 표를 찾고 없으면 WmsKernel 로 떨어졌다. `kind` 는 검증 없는 자유 문자열(`@Arg('kind') kind: string`)
|
|
60
|
+
* 이라 오타 하나·대소문자 하나로 야드/생산 트윈이 **조용히 창고 커널로 돌았다.** 오류가 없으니 화면에는
|
|
61
|
+
* 트윈이 정상으로 보이고, 안에서 도는 규칙만 다른 도메인의 것이다. 예측 경로가 가장 나쁘다 —
|
|
62
|
+
* 야드의 미래를 창고 규칙으로 실행해 놓고 숫자만 뜬다.
|
|
63
|
+
*
|
|
64
|
+
* 틀린 공장을 조용히 띄우는 것보다 뜨지 않는 편이 낫다.
|
|
65
|
+
*/
|
|
66
|
+
function kernelFor(kind: string): any {
|
|
67
|
+
const K = KERNELS[kind]
|
|
68
|
+
if (!K) throw new Error(`unknown twin kind "${kind}" — expected one of ${Object.keys(KERNELS).join(' | ')}`)
|
|
69
|
+
return K
|
|
70
|
+
}
|
|
46
71
|
|
|
47
72
|
/* 라이브 처리량 계측(모니터) — 유입/방송/저널률·백로그. 실 동기 인터페이스 부하를 읽는 일급 지표. */
|
|
48
73
|
interface TwinMetrics {
|
|
@@ -70,6 +95,8 @@ export const DEFAULT_REALITY_MODE: RealityMode = 'sim-experiment'
|
|
|
70
95
|
|
|
71
96
|
/* 인메모리 라이브 런타임 홀더(영속 엔티티 TwinInstance 와 구분). */
|
|
72
97
|
interface InstanceRuntime {
|
|
98
|
+
/** 부하 계기판 — 작업별 소요. 시뮬·라이브 모두 붙는다(누가 루프를 점유하는지 보려면 둘 다 필요하다). */
|
|
99
|
+
load?: LoadMeter
|
|
73
100
|
/** 현실 출처 선언(§0 프레임 ①). 부팅 거동(reset/resume/resync)의 근거. */
|
|
74
101
|
realityMode?: RealityMode
|
|
75
102
|
id: string
|
|
@@ -78,6 +105,8 @@ interface InstanceRuntime {
|
|
|
78
105
|
runtime?: TwinRuntimeType // sim: 커널 런타임(tick/subscribe/dispatch). live 는 projector 사용.
|
|
79
106
|
kernel?: any // sim: ForecastTwin(fork/getSnapshot/tick/scenario). live 미사용.
|
|
80
107
|
mode?: 'sim' | 'live' // 상태 드라이버 — sim=커널 tick / live=projector 미러(face2-inbound-live). 미지정=sim.
|
|
108
|
+
/** 이 트윈이 선 현장 — 같은 현장의 트윈끼리 서로의 설비 상태를 읽을 수 있게 하는 열쇠. */
|
|
109
|
+
spaceId?: string
|
|
81
110
|
/**
|
|
82
111
|
* live 의 상태 접근 어댑터 — 이제 **관측 모드 커널**을 가리킨다(`kernel` 과 같은 것).
|
|
83
112
|
* 이름은 소비처 호환으로 남았고, P3 에서 커널 어휘로 정리하면 사라진다.
|
|
@@ -85,6 +114,11 @@ interface InstanceRuntime {
|
|
|
85
114
|
projector?: any
|
|
86
115
|
oee?: OeeAccumulator // live: OEE 계산 층(이벤트 누적 → equipment payload 보강). sim 은 커널이 계산.
|
|
87
116
|
unsub: () => void
|
|
117
|
+
/**
|
|
118
|
+
* 방금 인입한 봉투들 — 커널이 그것을 재방출할 때 **저널에 두 번 적지 않기** 위한 표시.
|
|
119
|
+
* 라이브만 쓴다(sim 은 커널이 스스로 낸 것만 흘린다). 약한 참조라 따로 비울 것이 없다.
|
|
120
|
+
*/
|
|
121
|
+
applying?: WeakSet<object>
|
|
88
122
|
timer?: any
|
|
89
123
|
/** 엔티티별 마지막 발행 시그니처 — 값이 바뀐 엔티티만 재발행(무변화 반복 push 방지). */
|
|
90
124
|
entitySigs?: Map<string, string>
|
|
@@ -98,9 +132,21 @@ interface InstanceRuntime {
|
|
|
98
132
|
metrics?: TwinMetrics
|
|
99
133
|
}
|
|
100
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 트윈이 **스스로 멈춘 이유** — 문장이 아니라 코드다.
|
|
137
|
+
*
|
|
138
|
+
* 서버가 만든 영어 문장을 그대로 실으면 한국어·일본어 화면에 그 영어가 뜬다. 이 시스템은 거절 메시지에서
|
|
139
|
+
* 같은 결론에 이르렀다(코드+파라미터를 내고 화면이 옮긴다). `reason` 만은 원문 그대로다 — 커널·런타임이
|
|
140
|
+
* 낸 오류 문구는 옮길 수 있는 어휘가 아니고, 옮기려 들면 원인을 잃는다.
|
|
141
|
+
*/
|
|
142
|
+
export interface StopNote {
|
|
143
|
+
code: 'starved' | 'tick-failed'
|
|
144
|
+
params: Record<string, string | number>
|
|
145
|
+
}
|
|
146
|
+
|
|
101
147
|
export class TwinEngine {
|
|
102
148
|
/*
|
|
103
|
-
*
|
|
149
|
+
* 기동 중인 런타임 — **키는 `runtimeKey(domainId, instanceId)`** 다(`runtime-key.ts` 에 이유).
|
|
104
150
|
*
|
|
105
151
|
* 예전엔 `instanceId` 하나로 키를 잡았다. 그런데 정체성은 `(domain, instanceId)` 이고 DB 유일성도
|
|
106
152
|
* 그쪽이라, 두 테넌트가 같은 id 를 쓰면(레퍼런스 경로가 소스 이름을 id 로 쓴다) 한 자리를 다퉜다.
|
|
@@ -112,10 +158,49 @@ export class TwinEngine {
|
|
|
112
158
|
static recovered: Record<string, any> = {}
|
|
113
159
|
static TICK_MS = 1000
|
|
114
160
|
|
|
161
|
+
/*
|
|
162
|
+
* ── 굶김 안전망 (2026-08-14) ──────────────────────────────────────────────
|
|
163
|
+
* 시뮬 틱은 **메인 이벤트 루프**에서 돈다. 그래서 한 트윈의 틱이 길어지면 그 시간만큼 호스트 전체가
|
|
164
|
+
* 멈춘다 — HTTP·구독·다른 트윈의 틱까지. 실측으로 `order-check` 의 틱 하나가 34.9초였고, 그 사이
|
|
165
|
+
* 구독자가 아무것도 빼내지 못해 pubsub 이 넘쳐 프로세스가 죽었다.
|
|
166
|
+
*
|
|
167
|
+
* 방송 반복은 걷어냈지만(`flushLiveBroadcasts` 로 병합) **커널 틱 자체는 여전히 메인 루프에 있다.**
|
|
168
|
+
* 근본 해결은 분산이고 그것은 이연됐다 — 그때까지의 안전망이 이 셋이다.
|
|
169
|
+
*
|
|
170
|
+
* 판정을 예산(500ms)이 아니라 **굶김 문턱**으로 따로 둔다: 조금 느린 트윈은 계기판이 말하게 두고
|
|
171
|
+
* (경고), 호스트를 굶기는 트윈만 멈춘다. 한 번으로 멈추지 않는다 — 웜스타트 직후의 첫 틱은 원래
|
|
172
|
+
* 무겁다(복구한 상태를 처음 접는다). **연속**으로 이어질 때가 구조적으로 느린 것이다.
|
|
173
|
+
*/
|
|
174
|
+
/** 굶김 문턱 — 틱 간격의 배수(1초 간격이면 5초). 이 시간만큼 호스트가 멈춘다. */
|
|
175
|
+
static STARVE_FACTOR = 5
|
|
176
|
+
/** 연속 몇 번이면 멈추나 — 3번이면 15초를 굶긴 셈이고, 그건 우연이 아니다. */
|
|
177
|
+
static STARVE_STREAK = 3
|
|
178
|
+
private static starveStreak = new Map<string, number>()
|
|
179
|
+
/**
|
|
180
|
+
* 왜 멈췄나 — **화면이 그대로 말할 수 있게.** 스스로 멈춘 트윈이 이유 없이 「정지」로만 보이면
|
|
181
|
+
* 사람은 자기가 멈춘 줄 안다. 메모리에만 둔다(재기동하면 사라진다 — 그때는 「모른다」가 사실이다).
|
|
182
|
+
*/
|
|
183
|
+
private static stopNotes = new Map<string, StopNote>()
|
|
184
|
+
|
|
115
185
|
/* 최신 스냅샷 영속(warm-start) — 재기동 시 저널 fold-from-0 replay 대신 마지막 라이브 스냅샷으로 복원.
|
|
116
186
|
* 프레임워크 공통 cache-service(@things-factory/cache-service) 재사용(재발명 금지, framework-leverage §4).
|
|
117
187
|
* 저널은 그대로 진실(시간여행/history replay 무변경) — 캐시는 display-only 웜스타트 최적화. */
|
|
118
188
|
static SNAPSHOT_CACHE_ID = 'twin-snapshot'
|
|
189
|
+
/*
|
|
190
|
+
* ── 재개점 **사슬** — 과거를 물었을 때 목표 직전에서 접기 위해 (2026-08-18) ──
|
|
191
|
+
*
|
|
192
|
+
* 최신 재개점 하나로는 시간여행을 도울 수 없다: 그것은 언제나 목표보다 **뒤**에 있다. 그래서 지점을
|
|
193
|
+
* 여러 개 남긴다. 다만 그것들은 각각 상태 전체를 들고 있어 무겁다 — 한 행에 몰아 넣으면 거대한
|
|
194
|
+
* JSON 이 되므로 **지점마다 따로 두고 색인을 둔다**(cache-service 는 해시 키 조회라 열거가 안 된다).
|
|
195
|
+
*
|
|
196
|
+
* 간격은 리비전 눈금으로 잡는다(`CHAIN_STRIDE`): 눈금을 넘을 때만 한 지점을 남기므로, 저널이 빠르게
|
|
197
|
+
* 자라는 트윈에서도 지점 수가 폭발하지 않는다. 오래된 것부터 버리고 최근 `CHAIN_KEEP` 개만 든다 —
|
|
198
|
+
* 과거로 깊이 갈수록 지점이 없어 0부터 접는 것은 **알려진 한계**다(무한 보관보다 정직하다).
|
|
199
|
+
*/
|
|
200
|
+
static CHAIN_CACHE_ID = 'twin-fold-chain'
|
|
201
|
+
static CHAIN_INDEX_CACHE_ID = 'twin-fold-chain-index'
|
|
202
|
+
static CHAIN_STRIDE = 5000 // 리비전 눈금 — 이 간격을 넘을 때만 한 지점을 남긴다
|
|
203
|
+
static CHAIN_KEEP = 5 // 최근 몇 지점을 들고 있나(그보다 과거는 0부터 접는다)
|
|
119
204
|
static SNAPSHOT_TTL_S = 7 * 24 * 3600 // 7일 — 정상 다운타임 생존, 만료 시 저널 replay 폴백
|
|
120
205
|
static CHECKPOINT_MS = 20000 // 체크포인트 주기(핫 브로드캐스트 경로와 분리, O(state) 스로틀)
|
|
121
206
|
private static checkpointTimer?: any
|
|
@@ -132,15 +217,123 @@ export class TwinEngine {
|
|
|
132
217
|
const state = unwrapState(this.snapshot(domainId, instanceId))
|
|
133
218
|
if (!state) return
|
|
134
219
|
const revision = inst.revision ?? state.revision ?? 0
|
|
135
|
-
|
|
220
|
+
/* 구조 리비전도 함께 — 읽는 쪽이 "이 상태가 지금의 공장인가" 를 가릴 수 있어야 한다. */
|
|
221
|
+
const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }) as any)
|
|
222
|
+
await cacheService.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state, structureRev }, this.SNAPSHOT_TTL_S)
|
|
136
223
|
}
|
|
137
224
|
|
|
225
|
+
/**
|
|
226
|
+
* 접기의 **재개점** — 리듀서 내부 상태 전부 + 가동 누적기.
|
|
227
|
+
*
|
|
228
|
+
* 스냅샷(`state`)은 소비처가 보는 값이라 이어 접기의 씨앗이 되지 못한다(보류된 담김·집합·반영 못 한
|
|
229
|
+
* 사건 집계가 없다 — 그 상태로 뒤를 접으면 0부터 접은 결과와 조용히 달라진다). 그래서 씨앗은 따로 든다.
|
|
230
|
+
*/
|
|
231
|
+
private static readonly FOLD_NOTE = 'reducer + oee checkpoint — the seed for folding only the tail'
|
|
232
|
+
|
|
138
233
|
/** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */
|
|
139
|
-
static async loadSnapshot(
|
|
234
|
+
static async loadSnapshot(
|
|
235
|
+
domainId: string,
|
|
236
|
+
instanceId: string
|
|
237
|
+
): Promise<{ revision: number; state: any; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> {
|
|
140
238
|
const entry = await cacheService.getFromCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId })
|
|
141
239
|
return (entry as any)?.value ?? null
|
|
142
240
|
}
|
|
143
241
|
|
|
242
|
+
/**
|
|
243
|
+
* 접은 상태를 스냅샷으로 남긴다 — **라이브가 아니어도.**
|
|
244
|
+
*
|
|
245
|
+
* `persistSnapshot` 은 기동 중인 인스턴스에서만 뜬다(메모리가 진실이므로 옳다). 그런데 **멈춘**
|
|
246
|
+
* 트윈을 조회할 때마다 저널을 전량 다시 접고 있었다(23,731건짜리 트윈에서 4.6초). 그 폴드의 결과를
|
|
247
|
+
* 남겨 두면 **처음 한 번만 느리다.**
|
|
248
|
+
*
|
|
249
|
+
* `structureRev` 를 함께 적는다: 이벤트가 하나도 안 늘어도 구조를 갈아치우면(재프로비저닝) 그
|
|
250
|
+
* 상태는 낡은 것이다. 리비전만 보면 새 설비가 없는 옛 상태를 "지금" 으로 내게 된다.
|
|
251
|
+
*/
|
|
252
|
+
static async saveFoldedSnapshot(
|
|
253
|
+
domainId: string,
|
|
254
|
+
instanceId: string,
|
|
255
|
+
value: { revision: number; state: any; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } }
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
if (!value?.state) return
|
|
258
|
+
await cacheService
|
|
259
|
+
.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, value, this.SNAPSHOT_TTL_S)
|
|
260
|
+
.catch((err: any) => console.error(`[twin-engine] snapshot save fail "${instanceId}"`, err?.message ?? err))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
/** 사슬 색인 — 어떤 리비전 지점을 들고 있나(최신순 아님, 오름차순). */
|
|
265
|
+
private static async chainIndex(domainId: string, instanceId: string): Promise<number[]> {
|
|
266
|
+
const entry = await cacheService.getFromCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }).catch(() => null)
|
|
267
|
+
const revs = (entry as any)?.value?.revisions
|
|
268
|
+
return Array.isArray(revs) ? revs.filter((r: any) => Number.isFinite(r)).sort((a: number, b: number) => a - b) : []
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 목표 이전의 **가장 가까운 지점**을 고른다 — 없으면 `null`(0부터 접는다).
|
|
273
|
+
*
|
|
274
|
+
* 시각으로 물었으면 그 지점의 마지막 사실 시각이 목표 이내여야 한다(리비전만 보면 목표보다 뒤의
|
|
275
|
+
* 사실이 씨앗에 섞인다). 구조가 바뀐 트윈에서는 쓰지 않는다 — 마디를 건너뛴 씨앗은 그 경계의
|
|
276
|
+
* 판정을 잃는다(그 경우는 0부터 접는 것이 옳다).
|
|
277
|
+
*/
|
|
278
|
+
private static async chainSeedFor(
|
|
279
|
+
domainId: string,
|
|
280
|
+
instanceId: string,
|
|
281
|
+
target: { revision?: number; timeMs?: number }
|
|
282
|
+
): Promise<{ revision: number; eventTime?: string; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> {
|
|
283
|
+
const revs = await this.chainIndex(domainId, instanceId)
|
|
284
|
+
if (!revs.length) return null
|
|
285
|
+
for (const revision of [...revs].reverse()) {
|
|
286
|
+
if (target.revision != null && revision > target.revision) continue
|
|
287
|
+
const entry = await cacheService.getFromCache(this.CHAIN_CACHE_ID, { domainId, instanceId, revision }).catch(() => null)
|
|
288
|
+
const value = (entry as any)?.value
|
|
289
|
+
if (!value?.fold?.reducer) continue
|
|
290
|
+
if (target.timeMs != null) {
|
|
291
|
+
const t = value.eventTime ? Date.parse(String(value.eventTime)) : NaN
|
|
292
|
+
/* 시각을 모르는 지점은 쓰지 않는다 — 목표 이내인지 가릴 수 없다(짐작하지 않는다). */
|
|
293
|
+
if (!Number.isFinite(t) || t > target.timeMs) continue
|
|
294
|
+
}
|
|
295
|
+
return value
|
|
296
|
+
}
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 눈금을 넘었으면 한 지점을 남긴다 — 오래된 것은 버린다(색인도 함께 줄인다). */
|
|
301
|
+
private static async keepChainPoint(
|
|
302
|
+
domainId: string,
|
|
303
|
+
instanceId: string,
|
|
304
|
+
/*
|
|
305
|
+
* **보기(state)는 담지 않는다** (2026-08-18 실측으로 고침).
|
|
306
|
+
*
|
|
307
|
+
* 처음에는 상태까지 담았더니 지점 하나가 **11.4 MB** 였다(27만 건 트윈). 사슬이 필요한 것은 씨앗
|
|
308
|
+
* (재개점)뿐이고, 소비처가 보는 값은 그 씨앗에서 다시 만들어진다 — 같은 사실을 두 번 저장하지 않는다.
|
|
309
|
+
*/
|
|
310
|
+
value: { revision: number; eventTime?: string; structureRev?: number | null; fold: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } }
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
const revs = await this.chainIndex(domainId, instanceId)
|
|
313
|
+
const newest = revs.length ? revs[revs.length - 1] : -Infinity
|
|
314
|
+
if (value.revision - newest < this.CHAIN_STRIDE) return // 아직 눈금을 넘지 않았다
|
|
315
|
+
const next = [...revs, value.revision].slice(-this.CHAIN_KEEP)
|
|
316
|
+
const dropped = revs.filter(r => !next.includes(r))
|
|
317
|
+
await cacheService.setInCache(this.CHAIN_CACHE_ID, { domainId, instanceId, revision: value.revision }, value, this.SNAPSHOT_TTL_S)
|
|
318
|
+
await cacheService.setInCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }, { revisions: next }, this.SNAPSHOT_TTL_S)
|
|
319
|
+
/*
|
|
320
|
+
* 버린 지점은 **색인에서만** 빠진다 — 공용 캐시에 삭제 API 가 없다(`ICacheService` 는 get·set·
|
|
321
|
+
* clearStaleCache 뿐이다). 그래서 값은 TTL(7일)까지 남는다: 아무도 닿지 못하지만 자리는 차지한다.
|
|
322
|
+
* 지금 규모에서 감당할 수 있는 낭비이고, 공용 모듈에 삭제를 여는 것은 다른 소비처까지 함께 볼
|
|
323
|
+
* 일이라 여기서 몰래 하지 않는다 — **알려진 한계로 적어 둔다.**
|
|
324
|
+
*/
|
|
325
|
+
if (dropped.length) console.info(`[twin-engine] "${instanceId}" fold chain dropped ${dropped.join(', ')} from the index (values expire with the cache TTL).`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 이 트윈 저널의 끝 리비전 · 최신 구조 리비전 — 스냅샷이 지금의 사실인지 가리는 두 값. */
|
|
329
|
+
private static async tipOf(domainId: string, instanceId: string): Promise<{ revision: number; structureRev: number | null }> {
|
|
330
|
+
const [tip, newest] = await Promise.all([
|
|
331
|
+
getRepository(TwinEvent).findOne({ where: { domain: { id: domainId }, instanceId }, order: { revision: 'DESC' } }),
|
|
332
|
+
getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
333
|
+
])
|
|
334
|
+
return { revision: tip?.revision ?? 0, structureRev: newest?.rev ?? null }
|
|
335
|
+
}
|
|
336
|
+
|
|
144
337
|
/** 체크포인트 루프 기동(1회) — 라이브 인스턴스들의 최신 스냅샷을 주기 영속. */
|
|
145
338
|
static startCheckpointLoop(): void {
|
|
146
339
|
if (this.checkpointTimer) return
|
|
@@ -161,33 +354,7 @@ export class TwinEngine {
|
|
|
161
354
|
*/
|
|
162
355
|
static async bootstrap(): Promise<void> {
|
|
163
356
|
try {
|
|
164
|
-
// 백필(space #3) — spaceId 컬럼 없는 기존 인스턴스를 board.spaceId 로 채움(1회, 마이그레이션 안전).
|
|
165
357
|
const repo = getRepository(TwinInstance)
|
|
166
|
-
const missing = (await repo.find()).filter(r => !r.spaceId && (r.board as any)?.spaceId)
|
|
167
|
-
for (const r of missing) {
|
|
168
|
-
r.spaceId = (r.board as any).spaceId
|
|
169
|
-
r.areaId = (r.board as any).areaId ?? r.areaId
|
|
170
|
-
await repo.save(r)
|
|
171
|
-
}
|
|
172
|
-
if (missing.length) console.log(`[twin-engine] backfilled spaceId for ${missing.length} instance(s).`)
|
|
173
|
-
|
|
174
|
-
// 백필(area 단일화 P3) — deprecated content.areas → TwinArea 이관(폴백 제거 전 완전성 보장, 1회).
|
|
175
|
-
const spaceRepo = getRepository(TwinSpace)
|
|
176
|
-
const twinAreaRepo = getRepository(TwinArea)
|
|
177
|
-
for (const sp of await spaceRepo.find()) {
|
|
178
|
-
const cAreas = ((sp.content as any)?.areas ?? []) as any[]
|
|
179
|
-
for (const a of cAreas) {
|
|
180
|
-
if (!a?.id) continue
|
|
181
|
-
const ex = await twinAreaRepo.findOne({ where: { domain: { id: sp.domainId } as any, space: { id: sp.id } as any, areaId: a.id } })
|
|
182
|
-
if (ex) continue
|
|
183
|
-
await twinAreaRepo.save(twinAreaRepo.create({
|
|
184
|
-
domain: { id: sp.domainId } as any, space: { id: sp.id } as any, areaId: a.id,
|
|
185
|
-
name: a.name ?? a.id, type: a.type, parentId: a.parentId ?? null,
|
|
186
|
-
layout: a.x != null ? { x: a.x, y: a.y, w: a.w, h: a.h } : null
|
|
187
|
-
}))
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
358
|
const rows = await repo.find({ where: { status: 'running' } })
|
|
192
359
|
for (const row of rows) {
|
|
193
360
|
if (!row.domainId || !row.instanceId) continue
|
|
@@ -197,7 +364,15 @@ export class TwinEngine {
|
|
|
197
364
|
if (cached?.state) {
|
|
198
365
|
/* 예전에 겹포장으로 저장된 값이 남아 있을 수 있다 — 읽는 쪽에서도 벗긴다(한 번은 반드시 만난다). */
|
|
199
366
|
this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: cached.revision, state: unwrapState(cached.state) }
|
|
200
|
-
|
|
367
|
+
/*
|
|
368
|
+
* **「웜스타트했다」고 말하지 않는다** — 여기서는 상태를 **찾아 둔 것**뿐이다.
|
|
369
|
+
*
|
|
370
|
+
* 실제 주입은 기동 때 일어나고(`warmStart` 가 그때 무엇을 심었는지 말한다), 미러(live) 트윈은
|
|
371
|
+
* 그 씨앗을 아예 쓰지 않는다(`startLive` 가 버린다). 그런데 이 줄이 「warm-started」라고 말해
|
|
372
|
+
* 로그만 읽으면 심긴 줄 알게 된다 — 실제로 그렇게 읽고 재기동 뒤 지속시간이 사라진 것을
|
|
373
|
+
* 데이터 문제로 오진할 뻔했다.
|
|
374
|
+
*/
|
|
375
|
+
console.log(`[twin-engine] found cached state for "${row.instanceId}" → revision ${cached.revision} (seeded at start).`)
|
|
201
376
|
continue
|
|
202
377
|
}
|
|
203
378
|
const state = await this.recover(row.domainId, row.instanceId).catch(() => null)
|
|
@@ -206,6 +381,8 @@ export class TwinEngine {
|
|
|
206
381
|
console.log(`[twin-engine] recovered "${row.instanceId}" from journal → revision ${state.revision}.`)
|
|
207
382
|
}
|
|
208
383
|
}
|
|
384
|
+
/* 상태만 되찾는 것으로는 **도는 트윈이 되지 않는다** — 런타임까지 되살린다(아래). */
|
|
385
|
+
for (const row of rows) await this.resumeRow(row)
|
|
209
386
|
this.startCheckpointLoop() // 이후 기동되는 라이브 인스턴스의 최신 스냅샷을 주기 영속
|
|
210
387
|
} catch (err) {
|
|
211
388
|
console.error('[twin-engine] recovery scan failed', err)
|
|
@@ -213,10 +390,69 @@ export class TwinEngine {
|
|
|
213
390
|
}
|
|
214
391
|
|
|
215
392
|
/**
|
|
216
|
-
*
|
|
393
|
+
* 부팅 때 **도는 트윈을 실제로 되살린다** — 「도는 중」이 사실이 되게.
|
|
394
|
+
*
|
|
395
|
+
* ── 무엇이 거짓말이었나 (2026-08-14) ────────────────────────────────────────
|
|
396
|
+
* `bootstrap()` 은 상태만 되찾아 `recovered` 에 담았고, 커널을 세우는 것은 **명시 mutation 뿐**이었다
|
|
397
|
+
* (이 파일 위쪽 주석이 「향후」라고 적어 둔 그 자리다). 그래서 서버를 한 번 재기동하면 등록부는
|
|
398
|
+
* `running` 이라 말하는데 **아무 커널도 돌지 않았다** — 화면은 도는 트윈을, 실제로는 멈춘 트윈을.
|
|
399
|
+
* 미러 트윈에서는 더 나쁘다: 계측이 조용히 끊기고, 사람은 「값이 안 변하네」로 알게 된다.
|
|
400
|
+
*
|
|
401
|
+
* ── 모드를 지어내지 않는다 ──────────────────────────────────────────────────
|
|
402
|
+
* 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다).
|
|
403
|
+
* 그래서 선언된 `realityMode` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
|
|
404
|
+
*
|
|
405
|
+
* ── 되살릴 수 없으면 그렇게 적는다 ──────────────────────────────────────────
|
|
406
|
+
* 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한
|
|
407
|
+
* 행은 `stopped` 로 적고 이유를 남긴다. 「멈췄다」는 사실이고, 「도는 중」은 사실이 아니었다.
|
|
408
|
+
*/
|
|
409
|
+
private static async resumeRow(row: TwinInstance): Promise<void> {
|
|
410
|
+
const { domainId, instanceId } = row
|
|
411
|
+
if (!domainId || !instanceId) return
|
|
412
|
+
if (this.instances[runtimeKey(domainId, instanceId)]) return // 이미 세워졌다(데모 시드 등)
|
|
413
|
+
|
|
414
|
+
/*
|
|
415
|
+
* 벤치 사본은 되살리지 않는다 — 그것은 **누군가 지켜보던 실험**이고, 무인으로 되살아나면 호스트를
|
|
416
|
+
* 그대로 두들긴다(격리 사본의 뜻은 「운영과 섞이지 않는다」이지 「영원히 돈다」가 아니다).
|
|
417
|
+
* 실험은 재기동을 넘기지 못했으므로 등록부도 그렇게 적는다.
|
|
418
|
+
*/
|
|
419
|
+
if (row.purpose === 'bench') {
|
|
420
|
+
await this.markStopped(row, 'bench copy — an experiment does not survive a restart unattended')
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
if (row.realityMode === 'mirror') {
|
|
426
|
+
if (!row.model) throw new Error('no model')
|
|
427
|
+
/* 시각 기준은 **공간**이 갖는다 — 교대의 HH:MM 을 어느 기준으로 읽나(라이브 기동과 같은 규칙). */
|
|
428
|
+
this.startLive(instanceId, domainId, row.kind, await this.withSpaceTimeBase(row.model as TwinModelDef, domainId))
|
|
429
|
+
/* 계측을 나르는 피드는 커넥터의 것이다 — 레퍼런스 계층이 부팅 훅에서 다시 붙인다
|
|
430
|
+
(`resumeReferenceLiveFeeds`). 여기서 어댑터를 아는 것은 계층을 거꾸로 잇는 것이다. */
|
|
431
|
+
console.log(`[twin-engine] resumed mirror "${instanceId}" — feed reattach is the reference layer's job.`)
|
|
432
|
+
} else {
|
|
433
|
+
await this.startFromRegistry(domainId, instanceId)
|
|
434
|
+
console.log(`[twin-engine] resumed ${row.realityMode} "${instanceId}".`)
|
|
435
|
+
}
|
|
436
|
+
} catch (err: any) {
|
|
437
|
+
await this.markStopped(row, err?.message ?? 'resume failed')
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 되살리지 못한 행을 정직하게 적는다 — 「도는 중」이라 말하는 채로 두지 않는다. */
|
|
442
|
+
private static async markStopped(row: TwinInstance, why: string): Promise<void> {
|
|
443
|
+
console.warn(`[twin-engine] "${row.instanceId}" not resumed (${why}) — registry says stopped now.`)
|
|
444
|
+
try {
|
|
445
|
+
await getRepository(TwinInstance).update({ id: row.id }, { status: 'stopped' })
|
|
446
|
+
} catch (e: any) {
|
|
447
|
+
console.error(`[twin-engine] could not mark "${row.instanceId}" stopped — registry now lies about it.`, e?.message)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 웜스타트 — 기동하는 커널에 **직전 관측 상태**를 주입한다.
|
|
217
453
|
*
|
|
218
454
|
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
219
|
-
* `
|
|
455
|
+
* `loadTwinModel` 는 **구조만** 싣는다(자리·설비). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
|
|
220
456
|
* 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
|
|
221
457
|
* 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
|
|
222
458
|
* `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
|
|
@@ -233,12 +469,12 @@ export class TwinEngine {
|
|
|
233
469
|
* (이행 완료 오더 제외·고아 작업 제외)은 커널에 있고, 호스트가 미리 골라내지 않는다.
|
|
234
470
|
*
|
|
235
471
|
* ── 남은 한계는 숨기지 않고 센다 ────────────────────────────────────────────
|
|
236
|
-
* · 원값이 없는 오더(progress 만 있는 것)는 남은 수량을 알 수 없어
|
|
472
|
+
* · 원값이 없는 오더(progress 만 있는 것)는 남은 수량을 알 수 없어 주입하지 않는다. **지어내지 않는
|
|
237
473
|
* 대신 몇 건인지 말한다** — 세지 않으면 "주문이 없다" 와 "주문을 못 심었다" 가 화면에서 같아진다.
|
|
238
474
|
* · 진행 중 개별 작업의 내부 상태는 관측만으로 완전히 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
|
|
239
475
|
*
|
|
240
476
|
* ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
|
|
241
|
-
* 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를
|
|
477
|
+
* 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 주입하면 측정이 오염된다.
|
|
242
478
|
*/
|
|
243
479
|
private static warmStart(domainId: string, id: string, kernel: TwinKernel, purpose?: string): void {
|
|
244
480
|
const hydrate = (kernel as any).hydrateObserved
|
|
@@ -264,7 +500,9 @@ export class TwinEngine {
|
|
|
264
500
|
...(plan.personCount ? [`${plan.personCount} person(s)`] : []),
|
|
265
501
|
...(plan.assetCount ? [`${plan.assetCount} asset(s)`] : []),
|
|
266
502
|
/* 확인해 둔 신호를 이어받았다는 사실도 말한다 — 잃으면 확인 처리가 다시 빨개지는 것으로 보인다. */
|
|
267
|
-
...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : [])
|
|
503
|
+
...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : []),
|
|
504
|
+
/* 「언제부터인가」도 말한다 — 잃으면 지속된 조건이 모두 「방금」으로 보인다. */
|
|
505
|
+
...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
|
|
268
506
|
].join(', ')
|
|
269
507
|
console.log(`[twin-engine] warm-started "${id}" — restored ${restored}.`)
|
|
270
508
|
/* 뺀 것은 조용히 넘기지 않는다 — 지어내지 않았다는 사실 자체를 말해야 화면의 빈칸이 읽힌다. */
|
|
@@ -276,21 +514,44 @@ export class TwinEngine {
|
|
|
276
514
|
}
|
|
277
515
|
}
|
|
278
516
|
|
|
279
|
-
/** 트윈 인스턴스 시작 — 커널 생성 +
|
|
517
|
+
/** 트윈 인스턴스 시작 — 커널 생성 + 모델 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
|
|
280
518
|
/**
|
|
281
519
|
* 공정 명세를 커널에 싣는다 — **시뮬레이션의 시간을 데이터가 말하게 하는 마지막 한 칸.**
|
|
282
520
|
*
|
|
283
|
-
* `
|
|
521
|
+
* `model.operations`(마스터 인제스트가 통과시킨 ISA-95 OperationsSegment 명세)를 커널이 소비한다.
|
|
284
522
|
* 없으면 커널 기본 상수로 굴러가고, 커널 `specCoverage()` 가 무엇을 기본값으로 썼는지 보고한다.
|
|
285
523
|
*
|
|
286
524
|
* 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **조용히 넘어가지 않고 경고한다** — 명세를
|
|
287
525
|
* 선언했는데 반영되지 않는 상태를 모르고 지나가면, 예측이 상수로 돌아간 것을 아무도 알 수 없다.
|
|
288
526
|
*/
|
|
289
|
-
private static applyOperations(kernel: any,
|
|
290
|
-
|
|
527
|
+
private static applyOperations(kernel: any, model: any, id: string): void {
|
|
528
|
+
/*
|
|
529
|
+
* 현장이 정한 공정 시간 — 명세 행과 **따로** 싣는다(`declareDurations`).
|
|
530
|
+
*
|
|
531
|
+
* 행이 없는 종류에도 시간을 줄 수 있어야 한다: 실측으로 창고·야드 트윈에는 공정 명세 행이 0개였고,
|
|
532
|
+
* 그 트윈들의 시간은 전부 커널 상수에서 왔다. 행을 지어 만드는 길은 막았다(`label`·`intent` 를
|
|
533
|
+
* 지어내면 능력 계산까지 오염된다) — 그래서 시간만 받는 창구가 커널에 있다.
|
|
534
|
+
*/
|
|
535
|
+
const declared = model?.localDurations as Record<string, string> | undefined
|
|
536
|
+
if (declared && Object.keys(declared).length) {
|
|
537
|
+
if (typeof kernel?.declareDurations !== 'function') {
|
|
538
|
+
console.warn(
|
|
539
|
+
`[twin-engine] "${id}": this facility declared ${Object.keys(declared).length} operation duration(s) but the kernel cannot consume them ` +
|
|
540
|
+
'(declareDurations missing — kernel needs publishing). The simulation keeps running on built-in constants, and specCoverage() will keep reporting "default".'
|
|
541
|
+
)
|
|
542
|
+
} else {
|
|
543
|
+
try {
|
|
544
|
+
kernel.declareDurations(declared)
|
|
545
|
+
} catch (err: any) {
|
|
546
|
+
/* 커널이 거절한 값은 조용히 넘기지 않는다 — 화면은 「넣었습니다」라고 말한 값이다. */
|
|
547
|
+
console.warn(`[twin-engine] "${id}": declared operation duration rejected by the kernel — ${err?.message ?? err}`)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const ops = model?.operations
|
|
291
552
|
if (!ops?.length) return
|
|
292
553
|
if (typeof kernel?.loadOperations !== 'function') {
|
|
293
|
-
console.warn(`[twin-engine] "${id}":
|
|
554
|
+
console.warn(`[twin-engine] "${id}": model declares ${ops.length} operation spec(s) but the kernel cannot consume them (loadOperations missing — kernel needs publishing). Simulation will use built-in default durations.`)
|
|
294
555
|
return
|
|
295
556
|
}
|
|
296
557
|
kernel.loadOperations(ops)
|
|
@@ -301,16 +562,16 @@ export class TwinEngine {
|
|
|
301
562
|
*
|
|
302
563
|
* 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 두 가지를 사슬로 넣는다:
|
|
303
564
|
* ① **실측**(저널의 작업 종류별 작업시간 p50) — 그 현장에서 실제로 얼마 걸렸나. 가장 강한 근거.
|
|
304
|
-
* ② **거리 × 속도**(
|
|
565
|
+
* ② **거리 × 속도**(model.layout + 설비 속도 속성) — 이동은 거리에 비례한다. 커널은 좌표를 모르므로
|
|
305
566
|
* 호스트가 계산해 넣는다.
|
|
306
567
|
* 둘 다 못 만들면 주입하지 않는다 — 커널이 명세·상수로 굴러가고 `specCoverage()` 가 그 사실을 남긴다.
|
|
307
568
|
*
|
|
308
569
|
* 실측은 DB 조회라 비동기다. 그래서 이 함수는 **await 하지 않는 쪽에서도 안전**하도록 실패를 삼키되,
|
|
309
570
|
* 무엇을 왜 못 넣었는지는 로그로 남긴다(조용한 무효화 금지).
|
|
310
571
|
*/
|
|
311
|
-
static async installEstimators(kernel: any, domainId: string, instanceId: string,
|
|
572
|
+
static async installEstimators(kernel: any, domainId: string, instanceId: string, model: any): Promise<void> {
|
|
312
573
|
if (!kernel || typeof kernel !== 'object') return
|
|
313
|
-
const travel = buildTravelEstimator({ layout:
|
|
574
|
+
const travel = buildTravelEstimator({ layout: model?.layout, equipment: model?.equipment, unit: model?.unit })
|
|
314
575
|
const measured = await this.measuredEstimator(domainId, instanceId)
|
|
315
576
|
const chained = chainEstimators([measured?.estimator, travel.estimator])
|
|
316
577
|
if (!chained) {
|
|
@@ -337,9 +598,24 @@ export class TwinEngine {
|
|
|
337
598
|
*/
|
|
338
599
|
private static measuredCache = new Map<string, { at: number; value: ReturnType<typeof buildMeasuredEstimator> | undefined }>()
|
|
339
600
|
private static readonly MEASURED_TTL_MS = 60_000
|
|
601
|
+
/**
|
|
602
|
+
* 캐시 항목 상한 — **라이프사이클이 놓친 것까지 막는 두 번째 방어.**
|
|
603
|
+
*
|
|
604
|
+
* 지움은 `forgetInstance` 가 한다(그것이 첫 번째 방어이고 정확한 쪽이다). 그런데 트윈이 우리 API 를
|
|
605
|
+
* 지나지 않고 사라지는 길이 있다: 도메인(테넌트)째로 지워지거나 DB 를 직접 손대는 경우다. 그때
|
|
606
|
+
* 남은 항목을 지울 주인이 없으므로 상한이 필요하다.
|
|
607
|
+
*
|
|
608
|
+
* 넘치면 **가장 오래 손대지 않은 것**부터 버린다(Map 의 삽입 순서가 곧 그 순서다 — 값을 쓸 때마다
|
|
609
|
+
* 다시 넣으므로). 버리는 것이 손해가 아닌 이유: 이 값은 캐시이고, 없으면 다시 계산한다.
|
|
610
|
+
*
|
|
611
|
+
* 수를 크게 잡는다 — 트윈 규모는 늘 크고, 항목 하나는 작업 종류별 소요 몇 줄이다. 상한이 작으면
|
|
612
|
+
* 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 조용히 느려진다).
|
|
613
|
+
*/
|
|
614
|
+
private static readonly MEASURED_MAX = 5_000
|
|
340
615
|
|
|
341
616
|
private static async measuredEstimator(domainId: string, instanceId: string) {
|
|
342
|
-
|
|
617
|
+
/* 키는 `runtimeKey` 하나로 — 손으로 조립하면 지우는 쪽과 어긋나 못 지우는 항목이 생긴다. */
|
|
618
|
+
const key = runtimeKey(domainId, instanceId)
|
|
343
619
|
const hit = this.measuredCache.get(key)
|
|
344
620
|
if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS) return hit.value
|
|
345
621
|
let value: ReturnType<typeof buildMeasuredEstimator> | undefined
|
|
@@ -350,10 +626,32 @@ export class TwinEngine {
|
|
|
350
626
|
} catch (err) {
|
|
351
627
|
console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, (err as any)?.message)
|
|
352
628
|
}
|
|
629
|
+
/* 다시 넣어 **최근 쓴 것**으로 만든다 — 삽입 순서가 곧 버릴 순서이므로 이 한 줄이 LRU 를 만든다. */
|
|
630
|
+
this.measuredCache.delete(key)
|
|
353
631
|
this.measuredCache.set(key, { at: Date.now(), value })
|
|
632
|
+
if (this.measuredCache.size > this.MEASURED_MAX) {
|
|
633
|
+
const oldest = this.measuredCache.keys().next()
|
|
634
|
+
if (!oldest.done) this.measuredCache.delete(oldest.value)
|
|
635
|
+
}
|
|
354
636
|
return value
|
|
355
637
|
}
|
|
356
638
|
|
|
639
|
+
/**
|
|
640
|
+
* 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거.
|
|
641
|
+
*
|
|
642
|
+
* ── 왜 필요한가 (2026-08-18 실측) ────────────────────────────────────────
|
|
643
|
+
* 커널의 자기보고(`specCoverage()`)는 **작업이 새로 생길 때** 채워진다. 그래서 재기동 직후에는 이력이
|
|
644
|
+
* 풍부한 트윈에서도 목록이 비고, 「이 트윈이 무슨 공정을 돌리나」 화면이 빈칸이 된다 — 값을 채울 곳을
|
|
645
|
+
* 보여야 하는 화면이 정작 그때 아무 말도 못 한다.
|
|
646
|
+
*
|
|
647
|
+
* 그 빈칸을 이력로 메운다: 저널에서 배운 종류는 **추정기가 이미 답할 수 있는 종류**이므로, 지어내는
|
|
648
|
+
* 것이 아니라 있는 사실을 꺼내는 것이다. 같은 캐시(60초)를 쓰므로 조회마다 저널을 다시 접지 않는다.
|
|
649
|
+
*/
|
|
650
|
+
static async measuredOperationKinds(domainId: string, instanceId: string): Promise<string[]> {
|
|
651
|
+
const measured = await this.measuredEstimator(domainId, instanceId)
|
|
652
|
+
return Object.keys((measured as any)?.learned ?? {})
|
|
653
|
+
}
|
|
654
|
+
|
|
357
655
|
/**
|
|
358
656
|
* 지금 이 트윈이 어떤 모델로 굴러가는가 — 예측 출력 보정이 **자기가 배운 모델**에만 적용되도록
|
|
359
657
|
* 비교하는 지문. 재료는 호스트가 아는 것(실측·속도·선언 명세)이라 커널 발행 상태와 무관하다.
|
|
@@ -362,23 +660,23 @@ export class TwinEngine {
|
|
|
362
660
|
static async modelBasis(domainId: string, instanceId: string): Promise<ModelBasis | undefined> {
|
|
363
661
|
try {
|
|
364
662
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
365
|
-
const
|
|
663
|
+
const model: any = reg?.model
|
|
366
664
|
const measured = await this.measuredEstimator(domainId, instanceId)
|
|
367
|
-
const travel = buildTravelEstimator({ layout:
|
|
368
|
-
return { measured: measured?.learned, speeds: travel.speedsByKind, declared:
|
|
665
|
+
const travel = buildTravelEstimator({ layout: model?.layout, equipment: model?.equipment, unit: model?.unit })
|
|
666
|
+
return { measured: measured?.learned, speeds: travel.speedsByKind, declared: model?.operations }
|
|
369
667
|
} catch {
|
|
370
668
|
return undefined
|
|
371
669
|
}
|
|
372
670
|
}
|
|
373
671
|
|
|
374
672
|
/**
|
|
375
|
-
* **이 트윈이 하루 몇 대를 낼 수 있는가** —
|
|
673
|
+
* **이 트윈이 하루 몇 대를 낼 수 있는가** — 실행해 보지 않고 답한다.
|
|
376
674
|
*
|
|
377
675
|
* 계산은 커널이 자기 상태에서 한다(`kernel.capacity`). 여기서 하는 일은 **기준 주를 정해 주는
|
|
378
676
|
* 것**뿐이다: 공휴일이 없는 평상주여야 한다 — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시
|
|
379
|
-
*
|
|
677
|
+
* 상한을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 상한이 조용히 낮아진다.
|
|
380
678
|
*
|
|
381
|
-
* 트윈이
|
|
679
|
+
* 트윈이 기동 중이 아니면 `undefined` 다 — 0 이 아니다. 기동하지 않은 트윈의 상한을 0 이라고 답하면 화면은
|
|
382
680
|
* "이 공장은 아무것도 못 만든다" 고 말한다.
|
|
383
681
|
*/
|
|
384
682
|
static async capacity(
|
|
@@ -387,7 +685,7 @@ export class TwinEngine {
|
|
|
387
685
|
unitsPerDay: number,
|
|
388
686
|
sampleWeekStartMs: number
|
|
389
687
|
): Promise<{ instanceId: string; kind?: string; analysis?: any; reason?: 'not-running' | 'no-operations' }[]> {
|
|
390
|
-
/* 대상은 **등록부**가 정한다 —
|
|
688
|
+
/* 대상은 **등록부**가 정한다 — 기동 중인 런타임만 훑으면 "안 떠 있어서 안 보이는 것" 과 "없는 것" 이
|
|
391
689
|
구별되지 않는다. 화면은 그 둘을 다르게 말해야 한다. */
|
|
392
690
|
const where: any = { domain: { id: domainId } }
|
|
393
691
|
if (target.instanceId) where.instanceId = target.instanceId
|
|
@@ -407,16 +705,80 @@ export class TwinEngine {
|
|
|
407
705
|
})
|
|
408
706
|
}
|
|
409
707
|
|
|
410
|
-
|
|
708
|
+
/**
|
|
709
|
+
* **생산 능력 보고서** — ISA-95 Part 4 `OperationsCapability`. 구간의 양을 종류로 구분한다
|
|
710
|
+
* (약정·가용·달성불가). 용량(`capacity`)은 비율을 내고 이것은 **구간의 양**을 낸다.
|
|
711
|
+
*
|
|
712
|
+
* 대상 선정·거절 사유는 `capacity` 와 **같은 규율**이다: 등록부가 대상을 정하고(안 기동 중인 것과
|
|
713
|
+
* 없는 것을 구별한다), 공정을 선언하지 않은 트윈은 판정 대상이 아니라고 말한다(0 으로 채우면
|
|
714
|
+
* "야드가 병목" 이라는 없는 사실이 생긴다).
|
|
715
|
+
*/
|
|
716
|
+
static async operationsCapability(
|
|
717
|
+
domainId: string,
|
|
718
|
+
target: { instanceId?: string; spaceId?: string },
|
|
719
|
+
unitsPerDay: number,
|
|
720
|
+
sampleWeekStartMs: number,
|
|
721
|
+
window: { fromTime: string; toTime: string }
|
|
722
|
+
): Promise<{ instanceId: string; kind?: string; report?: any; reason?: 'not-running' | 'no-operations' }[]> {
|
|
723
|
+
const where: any = { domain: { id: domainId } }
|
|
724
|
+
if (target.instanceId) where.instanceId = target.instanceId
|
|
725
|
+
else where.spaceId = target.spaceId
|
|
726
|
+
const registered = await getRepository(TwinInstance).find({ where })
|
|
727
|
+
|
|
728
|
+
return registered.map(reg => {
|
|
729
|
+
const rt = this.instances[runtimeKey(domainId, reg.instanceId)]
|
|
730
|
+
const kernel: any = rt?.kernel
|
|
731
|
+
if (typeof kernel?.operationsCapability !== 'function') {
|
|
732
|
+
return { instanceId: reg.instanceId, kind: reg.kind, reason: 'not-running' as const }
|
|
733
|
+
}
|
|
734
|
+
const report = kernel.operationsCapability({ unitsPerDay, sampleWeekStartMs, window })
|
|
735
|
+
if (!report?.perOperation?.length) return { instanceId: reg.instanceId, kind: reg.kind, reason: 'no-operations' as const }
|
|
736
|
+
return { instanceId: reg.instanceId, kind: reg.kind, report }
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* 미러 기동의 연속성 씨앗 — 이어받은 것은 **말한다**(조용히 잇지 않는다).
|
|
742
|
+
*
|
|
743
|
+
* 커널이 그 문을 열어 두지 않았으면 그 사실도 말한다: 그 트윈은 재기동마다 열린 구간을 잃는다.
|
|
744
|
+
*/
|
|
745
|
+
private static seedLiveContinuity(domainId: string, id: string, kernel: any): void {
|
|
746
|
+
const cached = this.recovered[runtimeKey(domainId, id)]?.state
|
|
747
|
+
if (!cached) return
|
|
748
|
+
const plan = planLiveContinuity(cached)
|
|
749
|
+
if (!plan.ackedCount && !plan.attentionSinceCount && !plan.energy) return
|
|
750
|
+
if (typeof kernel.hydrateContinuity !== 'function') {
|
|
751
|
+
console.warn(
|
|
752
|
+
`[twin-engine] kernel for "${id}" cannot carry continuity (no hydrateContinuity) — the open demand window, ` +
|
|
753
|
+
'its peak and the attention start times are lost on every restart.'
|
|
754
|
+
)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
kernel.hydrateContinuity(plan.seed)
|
|
759
|
+
} catch (err: any) {
|
|
760
|
+
/* 이어받기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */
|
|
761
|
+
console.warn(`[twin-engine] continuity seed failed for "${id}" — starting without it: ${err?.message ?? err}`)
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
const carried = [
|
|
765
|
+
...(plan.energy ? ['energy accumulation (open window, totalizer baselines, peak)'] : []),
|
|
766
|
+
...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : []),
|
|
767
|
+
...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
|
|
768
|
+
].join(', ')
|
|
769
|
+
console.log(`[twin-engine] mirror "${id}" carried over ${carried} — observation axes come from the source.`)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
static start(id: string, domainId: string, kind: string, model: TwinModelDef, realityMode?: RealityMode, purpose?: string, resumeFrom?: number): InstanceRuntime {
|
|
411
773
|
const key = runtimeKey(domainId, id)
|
|
412
774
|
if (this.instances[key]) return this.instances[key]
|
|
413
775
|
|
|
414
|
-
const Kernel =
|
|
415
|
-
const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(
|
|
416
|
-
kernel.
|
|
417
|
-
this.applyOperations(kernel,
|
|
776
|
+
const Kernel = kernelFor(kind)
|
|
777
|
+
const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(model))
|
|
778
|
+
kernel.loadTwinModel(model) // 구조만. 상태는 아래 웜스타트가 주입한다.
|
|
779
|
+
this.applyOperations(kernel, model, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값
|
|
418
780
|
/* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
|
|
419
|
-
this.installEstimators(kernel, domainId, id,
|
|
781
|
+
this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
|
|
420
782
|
this.warmStart(domainId, id, kernel, purpose)
|
|
421
783
|
/*
|
|
422
784
|
* **번호를 이어 센다** — 저널에 이미 있는 번호와 겹치지 않게.
|
|
@@ -425,11 +787,31 @@ export class TwinEngine {
|
|
|
425
787
|
* 리비전이 둘 생기고, 재생 순서가 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다 — 오류는 안 난다.
|
|
426
788
|
*/
|
|
427
789
|
if (resumeFrom && typeof (kernel as any).resumeRevision === 'function') (kernel as any).resumeRevision(resumeFrom)
|
|
790
|
+
/*
|
|
791
|
+
* ── 이 트윈의 시계를 **지금**에 맞춘다 (2026-08-15) ────────────────────────
|
|
792
|
+
* 커널의 기본 기준점은 상수(2026-01-01)다. 그대로 두면 시뮬 트윈의 저널 시각이 **재기동마다
|
|
793
|
+
* 되감겨** 며칠 치 사건이 전부 같은 분에 몰린다 — 「언제 있었던 일인가」를 되짚을 수 없고,
|
|
794
|
+
* 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
|
|
795
|
+
*
|
|
796
|
+
* 재기동하면 그만큼 시계가 앞으로 뛰는데, 그것이 사실이다: 그 사이 이 트윈은 돌지 않았고
|
|
797
|
+
* 저널의 빈 구간이 그 사실을 말한다.
|
|
798
|
+
*/
|
|
799
|
+
kernel.setClockOrigin?.(Date.now())
|
|
428
800
|
const runtime: TwinRuntimeType = new TwinRuntime(kernel)
|
|
429
801
|
|
|
430
802
|
/* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
|
|
431
|
-
|
|
803
|
+
/*
|
|
804
|
+
* **모드를 적어 둔다** (2026-08-18 실측으로 고침).
|
|
805
|
+
*
|
|
806
|
+
* 시뮬 경로는 `mode` 를 비워 두었고(「미지정=sim」이라는 규약에 기대), 라이브만 적었다. 그래서
|
|
807
|
+
* `modeOf()` 가 **도는 시뮬레이션에 undefined** 를 답했고, 선언·철회의 답이 「저장했다」로 나갔다 —
|
|
808
|
+
* 실제로는 도는 커널이 옛 수를 그대로 쓰고 재기동 때 받는데, 그 사실이 답에서 사라진 것이다.
|
|
809
|
+
* 규약에 기대는 대신 사실을 적는다: 「돌고 있나」를 묻는 쪽이 그 답을 받아야 한다.
|
|
810
|
+
*/
|
|
811
|
+
const inst: InstanceRuntime = { id, domainId, mode: 'sim', runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, spaceId: (model as any)?.spaceId, unsub: () => {} }
|
|
432
812
|
this.instances[key] = inst
|
|
813
|
+
/* 다시 세웠으므로 지난 정지 이유는 사실이 아니다 — 남겨 두면 도는 트윈이 「굶겨서 멈췄다」고 말한다. */
|
|
814
|
+
this.stopNotes.delete(key)
|
|
433
815
|
delete this.recovered[key] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
|
|
434
816
|
|
|
435
817
|
/* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
|
|
@@ -437,19 +819,34 @@ export class TwinEngine {
|
|
|
437
819
|
|
|
438
820
|
/* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 방송(구독 리졸버가 instanceId 필터). */
|
|
439
821
|
const sub = runtime.subscribe((msg: SubscriptionMessage) => {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
822
|
+
this.publishGuarded(
|
|
823
|
+
'twin-state',
|
|
824
|
+
{ twinState: { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg } },
|
|
825
|
+
`twin-state:${id}`
|
|
826
|
+
)
|
|
443
827
|
/* 영속 + 라이브 바인딩 브리지: delta 마다 저널 저장 + 엔티티별 data(tag:) publish → 보드 컴포넌트 라이브. */
|
|
444
828
|
if (msg.kind === 'delta') {
|
|
445
829
|
this.persist(domainId, id, msg).catch(err => console.error('twin persist fail', err))
|
|
446
|
-
|
|
830
|
+
/*
|
|
831
|
+
* ── 방송은 **모아서** 한 번 (2026-08-14 실측으로 잡음) ────────────────────
|
|
832
|
+
* 여기서 곧바로 방송하고 있었다. 그런데 커널은 한 번의 tick 에서 사실을 **여러 개** 낸다
|
|
833
|
+
* (예약·배치·완료…). 그래서 tick 하나가 전 상태 투영을 수백 번 반복했다 — `order-check`
|
|
834
|
+
* 트윈에서 **틱 하나가 35초**를 먹고(단계 합은 32ms 였다: 시간은 반복 횟수에 있었다) 그
|
|
835
|
+
* 35초 동안 이벤트 루프가 막혀 구독자가 아무것도 빼내지 못했다. 밀린 push 가 1024를 넘는
|
|
836
|
+
* 순간 pubsub 이 던지고, 그 예외가 타이머 콜백을 타고 올라와 **호스트가 죽었다.**
|
|
837
|
+
*
|
|
838
|
+
* 라이브는 이미 dirty 표시 + 주기 flush 로 이 문제를 풀어 두었다(BROADCAST_COALESCE_MS).
|
|
839
|
+
* 시뮬만 그 규율 밖에 있었다 — 같은 규율로 들인다(최신-상태 채널이라 중간 상태를 모두
|
|
840
|
+
* 보낼 이유가 없다: 200ms 마다 마지막 것 하나면 화면은 같다).
|
|
841
|
+
*/
|
|
842
|
+
inst.dirty = true
|
|
843
|
+
this.ensureBroadcastCoalescer()
|
|
447
844
|
}
|
|
448
845
|
})
|
|
449
846
|
inst.unsub = () => sub.unsubscribe()
|
|
450
847
|
|
|
451
|
-
/* 복구 앵커: 레지스트리에
|
|
452
|
-
this.register(domainId, id, kind,
|
|
848
|
+
/* 복구 앵커: 레지스트리에 model/kind/status/realityMode 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
|
|
849
|
+
this.register(domainId, id, kind, model, 'running', inst.realityMode).catch(err => console.error('twin register fail', err))
|
|
453
850
|
|
|
454
851
|
/* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
|
|
455
852
|
inst.timer = setInterval(() => this.tickGuarded(domainId, id, runtime), this.TICK_MS)
|
|
@@ -469,14 +866,14 @@ export class TwinEngine {
|
|
|
469
866
|
* 실 이벤트원 = reference 어댑터 openLiveFeed → face2-adapter.ingest → CanonicalEnvelope → ingestLive().
|
|
470
867
|
*/
|
|
471
868
|
/**
|
|
472
|
-
*
|
|
869
|
+
* 모델이 실은 **생산 정의**(레시피·라우트·바인딩)를 꺼낸다 — 커널의 정의-구동 모드 입구.
|
|
473
870
|
*
|
|
474
871
|
* ── 없을 때 무엇이 일어났나 ──────────────────────────────────────────────
|
|
475
872
|
* 커널에는 정의-구동 MES 경로가 있는데 **호스트가 그것을 한 번도 넘기지 않았다.** 그래서 모든 MES
|
|
476
873
|
* 트윈이 **하드코딩된 레거시 흐름**으로 돌았다 — 현장 레시피를 아무리 정성껏 적어도 커널은 그것을
|
|
477
874
|
* 보지 못하고 토이 부품으로 토이 제품을 만들었다. 선언과 실행이 갈라져 있던 자리다.
|
|
478
875
|
*
|
|
479
|
-
*
|
|
876
|
+
* 모델에 없으면 `undefined` — 레거시 경로 그대로다(기존 트윈의 거동을 바꾸지 않는다).
|
|
480
877
|
*/
|
|
481
878
|
/**
|
|
482
879
|
* 생산 선언을 꺼낸다 — 커널 생성자에 넘긴다.
|
|
@@ -485,58 +882,96 @@ export class TwinEngine {
|
|
|
485
882
|
* 이름이 그대로 굳은 것), 일반 기제에 한 시스템 이름이 붙어 있었기 때문에 창고 트윈이 이 자리를
|
|
486
883
|
* 쓰지 못했다. 별명으로 남겨 두면 그 혼동이 계속되므로 하나로 통일했다.
|
|
487
884
|
*
|
|
488
|
-
* 옛 이름만 가진
|
|
885
|
+
* 옛 이름만 가진 모델이 있으면 **조용히 생산 선언을 잃는 대신 분명히 멈춘다** — 그 트윈은 공정이
|
|
489
886
|
* 없는 채로 돌게 되고(라인이 서 있는 창고), 원인을 찾기 어렵다.
|
|
490
887
|
*/
|
|
491
|
-
private static productionSpecOf(
|
|
492
|
-
const legacy = (
|
|
493
|
-
if (legacy && !(
|
|
888
|
+
private static productionSpecOf(model: TwinModelDef | undefined): any {
|
|
889
|
+
const legacy = (model as any)?.mesSpec
|
|
890
|
+
if (legacy && !(model as any)?.productionSpec) {
|
|
494
891
|
throw new Error(
|
|
495
|
-
'
|
|
892
|
+
'the twin model carries the retired `mesSpec` field — rename it to `productionSpec` ' +
|
|
496
893
|
'(same shape; the name was tied to one kernel while the declaration is ISA-95 operations + BOM)'
|
|
497
894
|
)
|
|
498
895
|
}
|
|
499
|
-
return (
|
|
896
|
+
return (model as any)?.productionSpec
|
|
500
897
|
}
|
|
501
898
|
|
|
502
899
|
/**
|
|
503
|
-
* 현장(공간)의 **시각 기준**을
|
|
900
|
+
* 현장(공간)의 **시각 기준**을 모델에 얹는다 — 커널이 교대의 `HH:MM` 을 읽을 기준.
|
|
504
901
|
*
|
|
505
902
|
* **테넌트가 아니라 공간이 권위다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고,
|
|
506
|
-
* 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 그래서 인스턴스가
|
|
903
|
+
* 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 그래서 인스턴스가 연결된 공간의
|
|
507
904
|
* `timezone` 을 읽는다. 공간이 말하지 않으면 **테넌트로 내려가지 않는다** — 잘못된 입자로 답하는 것이
|
|
508
905
|
* 모르는 것보다 나쁘다(그때는 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다).
|
|
509
906
|
*
|
|
510
907
|
* 커널은 zero-dep 이라 시간대 데이터베이스를 갖지 않으므로 **분 오프셋**으로 풀어 넘긴다. 그 값은
|
|
511
908
|
* 지금 계절의 것이다(일광절약시간) — 계절을 넘는 긴 예측은 한 시간 어긋난다(계약에 명시).
|
|
512
909
|
*/
|
|
513
|
-
static async withSpaceTimeBase(
|
|
514
|
-
const sid = spaceId ?? (
|
|
515
|
-
if (!sid) return
|
|
910
|
+
static async withSpaceTimeBase(model: TwinModelDef, domainId: string, spaceId?: string): Promise<TwinModelDef> {
|
|
911
|
+
const sid = spaceId ?? (model as any)?.spaceId
|
|
912
|
+
if (!sid) return model
|
|
516
913
|
const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } }).catch(() => null)
|
|
517
914
|
const offset = utcOffsetOf(space?.timezone)
|
|
518
915
|
if (offset === undefined) {
|
|
519
916
|
if (space?.timezone) console.warn(`[twin-engine] space "${sid}" declares time zone "${space.timezone}" but it is not a known IANA zone — times will be read as UTC.`)
|
|
520
|
-
return
|
|
917
|
+
return model
|
|
521
918
|
}
|
|
522
|
-
return { ...
|
|
919
|
+
return { ...model, utcOffsetMinutes: offset }
|
|
523
920
|
}
|
|
524
921
|
|
|
525
|
-
static startLive(id: string, domainId: string, kind: string,
|
|
922
|
+
static startLive(id: string, domainId: string, kind: string, model: TwinModelDef): InstanceRuntime {
|
|
526
923
|
const key = runtimeKey(domainId, id)
|
|
527
924
|
if (this.instances[key]) return this.instances[key]
|
|
528
|
-
const Kernel =
|
|
529
|
-
const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(
|
|
530
|
-
kernel.
|
|
531
|
-
|
|
925
|
+
const Kernel = kernelFor(kind)
|
|
926
|
+
const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(model))
|
|
927
|
+
kernel.loadTwinModel(model)
|
|
928
|
+
/* **세우는 쪽이 아는 사실은 세울 때 말한다.** 예전에는 첫 이벤트가 도착해야 커널이 스스로를
|
|
929
|
+
관측 구동으로 여겼고, 그래서 아직 아무것도 못 받은 미러는 시뮬레이션 취급을 받았다. */
|
|
930
|
+
kernel.observe?.()
|
|
931
|
+
this.applyOperations(kernel, model, id) // 명세는 라이브에도 실린다(예측 자격이 sim 과 같아진다)
|
|
532
932
|
/* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
|
|
533
933
|
* (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
|
|
534
934
|
const projector = { apply: (e: CanonicalEnvelope) => kernel.apply(e), snapshot: () => kernel.getSnapshot() }
|
|
535
|
-
const inst: InstanceRuntime = { id, domainId, mode: 'live', realityMode: 'mirror', kernel, projector, oee: new OeeAccumulator(), unsub: () => {} }
|
|
536
|
-
/*
|
|
537
|
-
|
|
935
|
+
const inst: InstanceRuntime = { id, domainId, mode: 'live', realityMode: 'mirror', kernel, projector, oee: new OeeAccumulator(), spaceId: (model as any)?.spaceId, unsub: () => {} }
|
|
936
|
+
/*
|
|
937
|
+
* ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ────
|
|
938
|
+
*
|
|
939
|
+
* 라이브는 인입 봉투만 저널에 적고 있었다(`ingestLive`). 그런데 커널은 관측을 접다가 **자기 사실**을
|
|
940
|
+
* 낸다 — 에너지의 수요 구간 마감·피크 경신·감축 제안이 그렇다(`emitOp`). 그것을 구독하는 곳이
|
|
941
|
+
* 없어서 그 사실들이 **커널 안에서 사라졌다**: 표본 48건이 저널에 쌓였는데 구간 마감은 0건이었고,
|
|
942
|
+
* 저널을 읽는 성과 화면의 전력 타일은 영원히 나오지 않았다.
|
|
943
|
+
*
|
|
944
|
+
* sim 은 `runtime.subscribe` 로 같은 일을 한다 — 라이브에만 그 배선이 없었다.
|
|
945
|
+
*
|
|
946
|
+
* **인입의 재방출은 걸러낸다**: `apply()` 는 받은 봉투를 구독자에게 그대로 흘린다(호스트가 두 모드에서
|
|
947
|
+
* 같은 배선을 쓰게 하려고). 그것까지 적으면 인입이 저널에 두 번 들어간다 — 그래서 방금 넣은 것과
|
|
948
|
+
* **같은 객체**인지 보고 건너뛴다(id 비교는 어댑터가 id 를 어떻게 만드는지에 기대게 되어 약하다).
|
|
949
|
+
*/
|
|
950
|
+
const applying = new WeakSet<object>()
|
|
951
|
+
inst.applying = applying
|
|
952
|
+
inst.unsub = kernel.onEvent?.((e: CanonicalEnvelope) => {
|
|
953
|
+
if (e && typeof e === 'object' && applying.has(e as object)) return // 인입의 재방출 — 저널은 인입에서 한 번만
|
|
954
|
+
;(inst.pendingJournal ?? (inst.pendingJournal = [])).push(e)
|
|
955
|
+
/* 커널이 낸 사실도 상태를 바꾼다(피크·마감) — 다음 방송 주기에 실린다. */
|
|
956
|
+
inst.dirty = true
|
|
957
|
+
this.ensureBroadcastCoalescer()
|
|
958
|
+
}) ?? (() => {})
|
|
959
|
+
/* 추정기(실측·거리)도 라이브에 붙인다 — 예측이 상수로 계산되지 않게. 기동을 막지 않는다. */
|
|
960
|
+
this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
|
|
538
961
|
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() }
|
|
539
962
|
this.instances[key] = inst
|
|
963
|
+
/*
|
|
964
|
+
* **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임).
|
|
965
|
+
*
|
|
966
|
+
* 미러는 오랫동안 아무것도 이어받지 않았다 — 진실이 원천에 있으니 옳은 판단이었지만, 원천이 **애초에
|
|
967
|
+
* 다시 말해 주지 않는 축**까지 함께 버렸다: 열린 15분 구간의 누적·적산 기준점·관측 이후 최대, 그리고
|
|
968
|
+
* 확인해 둔 신호와 조건이 언제부터인지. 그래서 재기동하면 오류 없이 값이 작아졌다(그 구간의 전력량과
|
|
969
|
+
* 피크가 부팅 이후로만 잡히고, 세 시간째 지속된 경보가 「0초째」가 됐다 — 요금이 걸린 수다).
|
|
970
|
+
*
|
|
971
|
+
* 관측 축(재고·위치·설비)은 **여전히 심지 않는다** — 다음 계측이 정정하고, 심으면 떠난 물건이
|
|
972
|
+
* 되살아난다. 무엇을 넘길지는 `planLiveContinuity` 가 고르고, 어떻게 흡수할지는 커널이 정한다.
|
|
973
|
+
*/
|
|
974
|
+
this.seedLiveContinuity(domainId, id, kernel)
|
|
540
975
|
delete this.recovered[key]
|
|
541
976
|
/* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */
|
|
542
977
|
getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
|
|
@@ -545,7 +980,7 @@ export class TwinEngine {
|
|
|
545
980
|
.findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
|
|
546
981
|
.then(top => (inst.revision = top?.revision ?? 0))
|
|
547
982
|
.catch(() => (inst.revision = 0))
|
|
548
|
-
this.register(domainId, id, kind,
|
|
983
|
+
this.register(domainId, id, kind, model, 'running', 'mirror').catch(err => console.error('twin register fail', err))
|
|
549
984
|
return inst
|
|
550
985
|
}
|
|
551
986
|
|
|
@@ -557,7 +992,14 @@ export class TwinEngine {
|
|
|
557
992
|
static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[]): void {
|
|
558
993
|
const inst = this.instances[runtimeKey(domainId, id)]
|
|
559
994
|
if (inst?.mode !== 'live' || !inst.projector) return
|
|
560
|
-
|
|
995
|
+
const tIngest = performance.now()
|
|
996
|
+
/* 인입 봉투를 표시해 두고 넣는다 — 커널이 그것을 재방출해도 저널에 두 번 적히지 않게(위 구독 주석). */
|
|
997
|
+
for (const e of envelopes) {
|
|
998
|
+
if (e && typeof e === 'object') inst.applying?.add(e as object)
|
|
999
|
+
inst.projector.apply(e)
|
|
1000
|
+
inst.oee?.apply(e) // 관측(projector) + 계산(OEE 누적)
|
|
1001
|
+
}
|
|
1002
|
+
recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'ingest', performance.now() - tIngest)
|
|
561
1003
|
// 저널 결선(라이브도 sim 처럼 이벤트 영속) — 단 이벤트마다 DB write 하면 부하폭발이라 모아뒀다가
|
|
562
1004
|
// coalescer tick 에서 배치 기록(방송과 동일 주기). 히스토리 노브·타임라인·비즈니스 원장이 이 저널을 읽는다.
|
|
563
1005
|
;(inst.pendingJournal ?? (inst.pendingJournal = [])).push(...envelopes)
|
|
@@ -572,7 +1014,7 @@ export class TwinEngine {
|
|
|
572
1014
|
* 구간 성과 방송은 **없앴다**(2026-08-06). 카드가 `twinKpi` 를 직접 묻는다.
|
|
573
1015
|
*
|
|
574
1016
|
* 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면 방송으로는 태그가 트윈당 1,200개가 되고,
|
|
575
|
-
*
|
|
1017
|
+
* **모델에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 접었다. 질의로 바꾸니 보고 있는 카드
|
|
576
1018
|
* 수만큼만 들고, 같은 (대상·창·축) 은 클라이언트가 하나로 합친다.
|
|
577
1019
|
*
|
|
578
1020
|
* 덤으로 질의만 할 수 있는 것이 둘 생겼다 — **과거 시각**(`toTime`)과 **공간 단위 합산**(여러 트윈을
|
|
@@ -591,15 +1033,19 @@ export class TwinEngine {
|
|
|
591
1033
|
if (typeof this.broadcastTimer.unref === 'function') this.broadcastTimer.unref() // 종료 비차단
|
|
592
1034
|
}
|
|
593
1035
|
|
|
594
|
-
/**
|
|
1036
|
+
/**
|
|
1037
|
+
* dirty 인스턴스 방송 flush(주기 tick 또는 명시 호출) — **시뮬과 라이브 둘 다.**
|
|
1038
|
+
*
|
|
1039
|
+
* 예전에는 라이브만 봤다(`mode !== 'live'` 면 건너뜀). 시뮬은 delta 마다 곧바로 방송했고, 그것이
|
|
1040
|
+
* 한 tick 에서 수백 번 반복되며 이벤트 루프를 막았다(위 `start()` 주석의 35초 틱). 방송을 모으는
|
|
1041
|
+
* 규율은 모드의 성질이 아니라 **채널의 성질**이다 — 최신-상태 채널이면 중간 상태는 보낼 값이 없다.
|
|
1042
|
+
*/
|
|
595
1043
|
static flushLiveBroadcasts(): void {
|
|
596
|
-
let anyLive = false
|
|
597
1044
|
const now = Date.now()
|
|
598
1045
|
for (const inst of Object.values(this.instances)) {
|
|
599
|
-
|
|
600
|
-
anyLive = true
|
|
1046
|
+
const isLive = inst.mode === 'live'
|
|
601
1047
|
// 처리량 계측(④-1) — 창(≥1s)마다 유입/방송/저널률 갱신. dirty 무관(유휴면 0으로 수렴). 부하를 읽는 신호.
|
|
602
|
-
const m = inst.metrics
|
|
1048
|
+
const m = isLive ? inst.metrics : undefined
|
|
603
1049
|
if (m) {
|
|
604
1050
|
const dt = (now - m._windowStartMs) / 1000
|
|
605
1051
|
if (dt >= 1) {
|
|
@@ -614,6 +1060,9 @@ export class TwinEngine {
|
|
|
614
1060
|
// ① 엔티티 data(tag) 방송 — 보드 컴포넌트 라이브 렌더.
|
|
615
1061
|
this.publishEntityData(inst)
|
|
616
1062
|
if (m) { m.broadcastTotal++; m._accBroadcast++ }
|
|
1063
|
+
/* 시뮬의 저널·state 채널은 자기 콜백이 delta 마다 처리한다(사실은 하나도 빠뜨리지 않는다).
|
|
1064
|
+
여기서 모으는 것은 **엔티티 방송**뿐이다 — 화면이 읽는 최신-상태 채널. */
|
|
1065
|
+
if (!isLive) continue
|
|
617
1066
|
// ③ 저널 배치 기록 — 모아둔 이벤트에 revision 부여해 벌크 저장(이벤트마다 write 아님).
|
|
618
1067
|
// revision 카운터는 인메모리(startLive 에서 저널 high-water 로 1회 시드) → tick 마다 DB 질의 없음.
|
|
619
1068
|
const batch = inst.pendingJournal
|
|
@@ -622,16 +1071,26 @@ export class TwinEngine {
|
|
|
622
1071
|
const start = inst.revision
|
|
623
1072
|
inst.revision = start + batch.length
|
|
624
1073
|
if (m) { m.journaledTotal += batch.length; m._accJournal += batch.length; m.backlog = batch.length }
|
|
625
|
-
|
|
1074
|
+
const tJournal = performance.now()
|
|
1075
|
+
this.persistBatch(inst.domainId, inst.id, batch, start)
|
|
1076
|
+
.then(() => recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'journal', performance.now() - tJournal))
|
|
1077
|
+
.catch(err => console.error('twin live journal fail', err))
|
|
626
1078
|
}
|
|
627
1079
|
// ② State 채널 방송 — "바뀌었다"는 가벼운 신호만(kind+revision). 맵 구독(subscribeTwinState)은 이 신호에
|
|
628
1080
|
// scheduleRefresh(250ms 디바운스)→pollLive 로 되물어봄. 스냅샷(O(state))은 보는 사람이 물을 때만 1회 계산.
|
|
629
1081
|
// (여기서 payload 로 스냅샷을 실으면 아무도 안 읽는데 tick 마다 통째로 떠서 순수 낭비 — 신호만 보낸다.)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1082
|
+
this.publishGuarded(
|
|
1083
|
+
'twin-state',
|
|
1084
|
+
{ twinState: { instanceId: inst.id, kind: 'delta', revision: inst.revision ?? 0 } },
|
|
1085
|
+
`twin-state:${inst.id}`
|
|
1086
|
+
)
|
|
1087
|
+
}
|
|
1088
|
+
/* 돌고 있는 인스턴스가 하나도 없으면 tick 을 멈춘다(예전엔 「라이브가 없으면」이었는데, 이제
|
|
1089
|
+
시뮬도 이 flush 에 기대므로 그 조건이면 시뮬 방송이 멈춘 채 남는다). */
|
|
1090
|
+
if (!Object.keys(this.instances).length && this.broadcastTimer) {
|
|
1091
|
+
clearInterval(this.broadcastTimer)
|
|
1092
|
+
this.broadcastTimer = undefined
|
|
633
1093
|
}
|
|
634
|
-
if (!anyLive && this.broadcastTimer) { clearInterval(this.broadcastTimer); this.broadcastTimer = undefined } // live 없으면 tick 정지
|
|
635
1094
|
}
|
|
636
1095
|
|
|
637
1096
|
/**
|
|
@@ -639,17 +1098,27 @@ export class TwinEngine {
|
|
|
639
1098
|
* 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
|
|
640
1099
|
* 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
|
|
641
1100
|
*/
|
|
1101
|
+
/** 저널의 시각 축은 날짜다 — 못 읽는 값은 비운다(0 이나 지금으로 위장하지 않는다). */
|
|
1102
|
+
private static toEventTimeValue(v: any): Date | undefined {
|
|
1103
|
+
if (v instanceof Date) return v
|
|
1104
|
+
const t = typeof v === 'string' ? Date.parse(v) : NaN
|
|
1105
|
+
return Number.isNaN(t) ? undefined : new Date(t)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
642
1108
|
private static journalRow(repo: any, domainId: string, instanceId: string, e: any, revision: number, structureRev?: number) {
|
|
643
1109
|
return repo.create({
|
|
644
1110
|
domain: { id: domainId } as any,
|
|
645
1111
|
instanceId,
|
|
646
|
-
tenantId: e?.tenantId,
|
|
647
1112
|
eventType: e?.eventType,
|
|
1113
|
+
/* 커널이 커맨드에서 이어 준 값 — 이것이 감사 기록과 저널을 잇는 다리다. */
|
|
1114
|
+
correlationId: e?.correlationId,
|
|
648
1115
|
revision,
|
|
649
1116
|
/* **이 사실이 일어난 공장**을 함께 찍는다 — 이것이 없으면 나중에 구조가 바뀌었을 때 이 행을
|
|
650
1117
|
새 공장에 대고 접게 되고, 그때 없던 설비에서 일이 있었던 것처럼 보인다. */
|
|
651
1118
|
...(structureRev === undefined ? {} : { structureRev }),
|
|
652
|
-
|
|
1119
|
+
/* 커널은 ISO 문자열을 준다. 컬럼은 날짜다 — **여기서 옮긴다.** 문자열을 그대로 넣으면
|
|
1120
|
+
드라이버가 제 형식으로 정규화하지 않아, 나중에 날짜로 거는 질의에 안 걸린다. */
|
|
1121
|
+
eventTime: this.toEventTimeValue(e?.eventTime),
|
|
653
1122
|
...twinEventKeys(e),
|
|
654
1123
|
payload: e
|
|
655
1124
|
})
|
|
@@ -663,7 +1132,7 @@ export class TwinEngine {
|
|
|
663
1132
|
*/
|
|
664
1133
|
private static structureRevCache: Record<string, number> = {}
|
|
665
1134
|
static async structureRevOf(domainId: string, instanceId: string): Promise<number | undefined> {
|
|
666
|
-
const key =
|
|
1135
|
+
const key = runtimeKey(domainId, instanceId)
|
|
667
1136
|
const cached = this.structureRevCache[key]
|
|
668
1137
|
if (cached !== undefined) return cached
|
|
669
1138
|
const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
@@ -684,9 +1153,16 @@ export class TwinEngine {
|
|
|
684
1153
|
domainId: string,
|
|
685
1154
|
instanceId: string,
|
|
686
1155
|
kind: string,
|
|
687
|
-
|
|
1156
|
+
model: TwinModelDef,
|
|
688
1157
|
status: 'running' | 'stopped' = 'running',
|
|
689
|
-
realityMode?: RealityMode
|
|
1158
|
+
realityMode?: RealityMode,
|
|
1159
|
+
origin?: any,
|
|
1160
|
+
/*
|
|
1161
|
+
* 사람이 부르는 이름과 **누가 했나** — 업무키를 나눠 둔 대가로 이름을 함께 실어야 한다.
|
|
1162
|
+
* 사람 없는 경로(부팅 자동 프로비저닝·복구)는 `actor` 를 주지 않는다 — 아무 사용자를 적으면
|
|
1163
|
+
* 감사 기록이 거짓이 된다.
|
|
1164
|
+
*/
|
|
1165
|
+
meta?: { name?: string; description?: string; actor?: { id: string } }
|
|
690
1166
|
): Promise<void> {
|
|
691
1167
|
const repo = getRepository(TwinInstance)
|
|
692
1168
|
const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
@@ -696,21 +1172,35 @@ export class TwinEngine {
|
|
|
696
1172
|
domain: { id: domainId } as any,
|
|
697
1173
|
instanceId,
|
|
698
1174
|
kind,
|
|
699
|
-
|
|
700
|
-
// 인스턴스↔공간 1급 링크(space #3) —
|
|
701
|
-
spaceId: (
|
|
702
|
-
areaId: (
|
|
703
|
-
|
|
704
|
-
|
|
1175
|
+
model,
|
|
1176
|
+
// 인스턴스↔공간 1급 링크(space #3) — model.spaceId/areaId 를 컬럼으로 승격(dual-write). model JSON 도 유지(커널·복구용).
|
|
1177
|
+
spaceId: (model as any)?.spaceId ?? existing?.spaceId,
|
|
1178
|
+
areaId: (model as any)?.areaId ?? existing?.areaId,
|
|
1179
|
+
/* 현실 출처 선언(§0 ①) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
|
|
1180
|
+
둘 다 없으면 여기서 **각인한다**. 컬럼을 비워 두면 읽는 자리마다 기본값을 고르게 되고,
|
|
1181
|
+
그러면 같은 트윈이 부르는 곳에 따라 다르게 재기동한다. 선언은 저장소에서 항상 명시적이다. */
|
|
1182
|
+
realityMode: realityMode ?? existing?.realityMode ?? DEFAULT_REALITY_MODE,
|
|
1183
|
+
/* 용도도 각인한다. 벤치로 뒤집는 것은 `setPurpose` 하나뿐이므로 기존값을 반드시 보존한다
|
|
1184
|
+
— 여기서 덮으면 재프로비전이 벤치 사본을 운영 트윈으로 되돌린다. */
|
|
1185
|
+
purpose: existing?.purpose ?? 'operational',
|
|
1186
|
+
/* **다시 읽는 방법**을 각인한다. 명시값 우선, 없으면 기존값 보존 — 원천을 모르는 호출이
|
|
1187
|
+
한 번 지나가면서 이미 알던 것을 지워 버리면, 그 트윈은 다시 읽을 수 없는 트윈이 된다. */
|
|
1188
|
+
origin: origin ?? existing?.origin ?? null,
|
|
1189
|
+
/* 이름은 **덮지 않고 채운다** — 사용자가 고친 이름을 재프로비전이 되돌리면 안 된다. */
|
|
1190
|
+
name: existing?.name ?? meta?.name ?? null,
|
|
1191
|
+
description: existing?.description ?? meta?.description ?? null,
|
|
1192
|
+
/* 만든 사람은 처음 한 번만. 고친 사람은 사람이 한 경우에만 갱신한다. */
|
|
1193
|
+
...(existing ? {} : meta?.actor ? { creator: { id: meta.actor.id } as any } : {}),
|
|
1194
|
+
...(meta?.actor ? { updater: { id: meta.actor.id } as any } : {}),
|
|
705
1195
|
status
|
|
706
1196
|
})
|
|
707
1197
|
)
|
|
708
1198
|
}
|
|
709
1199
|
|
|
710
1200
|
/**
|
|
711
|
-
* 라이브 구조 변이(resource.add 등)를 저장된
|
|
712
|
-
* 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장
|
|
713
|
-
* 추가분을 잃는다. 기존
|
|
1201
|
+
* 라이브 구조 변이(resource.add 등)를 저장된 model 에 반영 — 런타임 커널의 무버를 registry model.equipment 에 동기.
|
|
1202
|
+
* 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 model 는 원본이라 프로비저닝 편집기·재기동(loadTwinModel)이
|
|
1203
|
+
* 추가분을 잃는다. 기존 model.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation).
|
|
714
1204
|
* 좌표(layout)만 다루는 register 와 달리 equipment 집합을 갱신하나, 재프로비전(purge)이 아니라 in-place 갱신이라
|
|
715
1205
|
* 저널은 보존된다(추가는 이미 equipment 델타로 저널됨 → replay 는 id-keyed upsert 라 이중계산 없음).
|
|
716
1206
|
*/
|
|
@@ -720,25 +1210,132 @@ export class TwinEngine {
|
|
|
720
1210
|
if (!snap?.equipment) return
|
|
721
1211
|
const repo = getRepository(TwinInstance)
|
|
722
1212
|
const reg = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
723
|
-
if (!reg?.
|
|
724
|
-
const
|
|
725
|
-
const known = new Set((
|
|
1213
|
+
if (!reg?.model) return
|
|
1214
|
+
const model = reg.model as TwinModelDef
|
|
1215
|
+
const known = new Set((model.equipment ?? []).map(m => m.id))
|
|
726
1216
|
const added = snap.equipment
|
|
727
1217
|
.filter((m: any) => !known.has(m.id))
|
|
728
1218
|
.map((m: any) => ({ id: m.id, kind: m.kind, homeLocation: m.location }))
|
|
729
1219
|
if (!added.length) return
|
|
730
|
-
|
|
731
|
-
reg.
|
|
1220
|
+
model.equipment = [...(model.equipment ?? []), ...added]
|
|
1221
|
+
reg.model = model
|
|
732
1222
|
await repo.save(reg)
|
|
733
1223
|
}
|
|
734
1224
|
|
|
735
1225
|
/**
|
|
736
|
-
* 프로비저닝 — 레지스트리에 구조(
|
|
1226
|
+
* 프로비저닝 — 레지스트리에 구조(model)를 등록만 하고 기동하지 않음(status='stopped').
|
|
737
1227
|
* 재프로비전 시 구조 시그니처가 바뀌면(노드·무버 집합/속성 변경) 기존 저널을 purge 한다(ADR-0015):
|
|
738
|
-
*
|
|
1228
|
+
* model 는 replay 의 마스터라 구조가 바뀌면 과거 이벤트의 전제가 깨진다. 좌표(layout)만 바뀌면 저널 보존.
|
|
1229
|
+
*/
|
|
1230
|
+
/**
|
|
1231
|
+
* 실행 중이면 구조를 교체하지 않는다 — **판정 문장을 한 곳에 둔다.**
|
|
1232
|
+
*
|
|
1233
|
+
* 인제스트는 전량 교체라, 이벤트를 접고 있는 커널 밑에서 바닥을 바꾸는 셈이 된다. 지금은 거절이
|
|
1234
|
+
* 유일한 답이지만 최종형은 아니다(`adoptStructure` 배선). 두 곳에서 같은 문장으로 거절해야
|
|
1235
|
+
* 나중에 이 규칙을 걷어낼 때 걷어낼 것이 하나로 보인다.
|
|
1236
|
+
*/
|
|
1237
|
+
private static assertNotRunning(domainId: string, instanceId: string): void {
|
|
1238
|
+
if (!this.instances[runtimeKey(domainId, instanceId)]) return
|
|
1239
|
+
/*
|
|
1240
|
+
* **거절도 번역돼야 한다.** 이 문장은 던져져서 리졸버의 `catch` 를 지나 화면 토스트에 그대로
|
|
1241
|
+
* 떴다 — 다섯 언어 제품에서 영어 한 줄이 사용자에게 보였다(2026-08-14 실측).
|
|
1242
|
+
*
|
|
1243
|
+
* 그래서 코드와 파라미터를 예외에 실어 보낸다(`ImportSpaceRefusal` 과 같은 규약: 영어 문장은
|
|
1244
|
+
* canonical 이고 로그·폴백으로만 쓰인다). 이유가 코드로 오면 화면이 옮길 수 있다.
|
|
1245
|
+
*/
|
|
1246
|
+
const err: any = new Error(`instance "${instanceId}" is running — stop before re-provisioning`)
|
|
1247
|
+
err.code = 'twin-running'
|
|
1248
|
+
err.params = { instanceId }
|
|
1249
|
+
throw err
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* 돌면서 공장을 전환한다 — **미러 전용.**
|
|
1254
|
+
*
|
|
1255
|
+
* 미러가 비추는 현실은 안 멈춘다. 설비 한 대가 늘었다고 트윈을 멈췄다 세우면 그 사이의 사실을
|
|
1256
|
+
* 잃고, 라이브에서 유실은 곧 거짓이다. 그래서 미러는 **동작 중에** 새 구조를 받는다.
|
|
1257
|
+
*
|
|
1258
|
+
* 시뮬레이션은 여기 오지 않는다. 조건이 바뀐 실험은 다른 실험이므로 멈추고 다시 세우는 것이
|
|
1259
|
+
* 옳다 — 커널이 그렇게 거절한다(`FlowEngine.adoptStructure`).
|
|
1260
|
+
*
|
|
1261
|
+
* 세 가지가 **함께** 일어나야 한다. 하나라도 빠지면 층이 어긋난다:
|
|
1262
|
+
* ① 리비전 — 이 시점 이후의 이벤트가 새 번호를 달고 다닌다(재생이 마디를 나눌 수 있게)
|
|
1263
|
+
* ② 저장 model·구조 행 — 조회가 새 구조를 본다
|
|
1264
|
+
* ③ 커널 — 지금 접히는 이벤트가 새 구조 위에서 접힌다
|
|
739
1265
|
*/
|
|
740
|
-
static async
|
|
741
|
-
|
|
1266
|
+
static async adoptStructure(
|
|
1267
|
+
domainId: string,
|
|
1268
|
+
instanceId: string,
|
|
1269
|
+
model: TwinModelDef,
|
|
1270
|
+
comment?: string,
|
|
1271
|
+
origin?: any
|
|
1272
|
+
): Promise<{ rev: number } & StructureShift> {
|
|
1273
|
+
const inst = this.instances[runtimeKey(domainId, instanceId)]
|
|
1274
|
+
if (!inst) throw new Error(`instance "${instanceId}" is not running — provision it instead of adopting`)
|
|
1275
|
+
if (inst.mode !== 'live')
|
|
1276
|
+
throw new Error(`instance "${instanceId}" is a simulation — stop it and re-provision (a changed experiment is a different experiment)`)
|
|
1277
|
+
|
|
1278
|
+
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
1279
|
+
if (!reg) throw new Error(`instance "${instanceId}" is running but not registered — refusing to adopt into an unknown twin`)
|
|
1280
|
+
|
|
1281
|
+
const rev = await this.recordStructure(domainId, instanceId, model, comment)
|
|
1282
|
+
await this.register(domainId, instanceId, reg.kind, model, 'running', undefined, origin)
|
|
1283
|
+
/* 커널을 **마지막에** 전환한다 — 저장이 실패하면 메모리만 새 구조가 되어, 재기동하면 조용히
|
|
1284
|
+
옛 구조로 돌아간다(고치기 어려운 어긋남이다). */
|
|
1285
|
+
const shift: StructureShift = inst.kernel.adoptStructure(model)
|
|
1286
|
+
/*
|
|
1287
|
+
* **공정 명세·현장이 정한 시간도 함께 싣는다** — 커널의 구조 전환은 자리·설비를 갈지만, 공정 시간은
|
|
1288
|
+
* 호스트가 실어 주는 것이므로(`model.localDurations`) 여기서 다시 부르지 않으면 돌고 있는 미러는
|
|
1289
|
+
* 재기동할 때까지 옛 시간으로 돈다(화면은 「넣었습니다」라고 말한 값이다).
|
|
1290
|
+
*/
|
|
1291
|
+
this.applyOperations(inst.kernel, model, instanceId)
|
|
1292
|
+
|
|
1293
|
+
/*
|
|
1294
|
+
* **투영 행도 새 구조를 따른다** (2026-08-18 실측으로 붙임).
|
|
1295
|
+
*
|
|
1296
|
+
* 행(`twin_locations`·`twin_equipment`)은 구조의 캐시다. 그런데 이 경로는 커널만 갈고 행은 그대로
|
|
1297
|
+
* 두었다 — 돌고 있는 미러에 값을 선언하면 커널은 새 값으로 계산하는데 **항목 패널은 옛 행을 보여
|
|
1298
|
+
* 준다**(넣은 값이 화면에서 보이지 않는다). 구조가 바뀐 순간이 곧 캐시를 다시 그릴 순간이다.
|
|
1299
|
+
*
|
|
1300
|
+
* 실패는 흡수한다: 투영이 막혀도(예: 모델에 중복 id) 커널은 이미 새 구조로 돌고 있으므로 그 사실을
|
|
1301
|
+
* 되돌리지 않는다 — 다만 조용히 넘기지 않고 말한다.
|
|
1302
|
+
*/
|
|
1303
|
+
await projectStructure(domainId, instanceId, model, instanceId).catch((err: any) =>
|
|
1304
|
+
console.warn(`[twin-engine] "${instanceId}" adopted a new structure but its projected rows were not refreshed — ${err?.message ?? err}`)
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
/*
|
|
1308
|
+
* **구조가 바뀐 순간이 상태가 바뀐 순간이다** — 그러니 방송한다.
|
|
1309
|
+
*
|
|
1310
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────
|
|
1311
|
+
* 구조 전환은 커널만 갈고 조용히 끝났다. 그런데 화면이 보는 것 상당수가 구조에서 파생된다 —
|
|
1312
|
+
* 계약 대비 판정, 주목 신호, 자리 색. 현장이 계약을 고쳐 선언한 순간 조건이 성립하는데도, 상태
|
|
1313
|
+
* 방송이 없어서 지도 레일은 **다음 계측 표본이 올 때까지** 옛 화면을 들고 있었다(그 사이 헤더
|
|
1314
|
+
* 배지는 4초 폴링으로 먼저 알아, 「배지엔 있고 목록엔 없는」 어긋난 화면이 실제로 보였다).
|
|
1315
|
+
*
|
|
1316
|
+
* 새 방송 경로를 만들지 않는다: dirty 를 세워 **이미 있는 병합 규율**(200ms)에 얹는다. 구조 전환은
|
|
1317
|
+
* 드물지만, 여러 트윈에 잇달아 들어올 수 있고(현장 일괄 선언) 그때도 방송률 상한은 지켜야 한다.
|
|
1318
|
+
*/
|
|
1319
|
+
inst.dirty = true
|
|
1320
|
+
this.ensureBroadcastCoalescer()
|
|
1321
|
+
|
|
1322
|
+
return { rev, ...shift }
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
static async provision(
|
|
1326
|
+
domainId: string,
|
|
1327
|
+
instanceId: string,
|
|
1328
|
+
kind: string,
|
|
1329
|
+
model: TwinModelDef,
|
|
1330
|
+
comment?: string,
|
|
1331
|
+
origin?: any,
|
|
1332
|
+
/** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */
|
|
1333
|
+
meta?: { name?: string; description?: string; actor?: { id: string } }
|
|
1334
|
+
): Promise<void> {
|
|
1335
|
+
this.assertNotRunning(domainId, instanceId)
|
|
1336
|
+
/* 종류를 **저장하기 전에** 검증한다. 나중에 기동할 때 걸리면 원인이 프로비저닝에서 멀어지고,
|
|
1337
|
+
그 사이 레지스트리에는 돌릴 수 없는 트윈이 앉아 있다. */
|
|
1338
|
+
kernelFor(kind)
|
|
742
1339
|
const repo = getRepository(TwinInstance)
|
|
743
1340
|
const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
744
1341
|
|
|
@@ -751,10 +1348,10 @@ export class TwinEngine {
|
|
|
751
1348
|
* 하거나** 둘뿐이었고, 그래서 공장을 고칠 때마다 이력을 버려야 했다.
|
|
752
1349
|
*
|
|
753
1350
|
* 이제 셋째 길로 간다: 바뀐 구조를 **새 리비전**으로 남기고, 앞으로 쓰이는 이벤트가 그 번호를
|
|
754
|
-
* 달고 다닌다. 재생은 구조가 바뀌는 지점에서
|
|
1351
|
+
* 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어 접는다(`replaySegments`).
|
|
755
1352
|
*/
|
|
756
|
-
await this.recordStructure(domainId, instanceId,
|
|
757
|
-
await this.register(domainId, instanceId, kind,
|
|
1353
|
+
await this.recordStructure(domainId, instanceId, model, comment)
|
|
1354
|
+
await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped', undefined, origin, meta)
|
|
758
1355
|
}
|
|
759
1356
|
|
|
760
1357
|
/**
|
|
@@ -763,9 +1360,9 @@ export class TwinEngine {
|
|
|
763
1360
|
* 프로비저닝은 부팅마다 다시 도는데, 그때마다 리비전이 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다.
|
|
764
1361
|
* 그래서 **구조 서명이 같으면 있던 리비전을 그대로 쓴다.**
|
|
765
1362
|
*/
|
|
766
|
-
static async recordStructure(domainId: string, instanceId: string,
|
|
1363
|
+
static async recordStructure(domainId: string, instanceId: string, model: TwinModelDef, comment?: string): Promise<number> {
|
|
767
1364
|
const repo = getRepository(TwinStructure)
|
|
768
|
-
const signature = this.structureFingerprint(
|
|
1365
|
+
const signature = this.structureFingerprint(model)
|
|
769
1366
|
const latest = await repo.findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
770
1367
|
|
|
771
1368
|
/*
|
|
@@ -775,22 +1372,22 @@ export class TwinEngine {
|
|
|
775
1372
|
* 것이라 무엇과도 안 맞는다. 그러면 **아무것도 안 바뀐 트윈들이 전부 "구조가 바뀌었다"** 로
|
|
776
1373
|
* 기록된다 — 실제로 그렇게 됐고, 재기동 한 번에 리비전이 통째로 하나씩 늘었다.
|
|
777
1374
|
*
|
|
778
|
-
* 그래서 안 맞을 때 한 번 더 묻는다: 저장된
|
|
1375
|
+
* 그래서 안 맞을 때 한 번 더 묻는다: 저장된 **모델**로 지문을 다시 계산하면 같은가? 같다면
|
|
779
1376
|
* 공장은 그대로이고 지문 표기만 낡은 것이니, 리비전을 만들지 않고 표기만 고친다.
|
|
780
1377
|
*/
|
|
781
|
-
if (latest && latest.signature !== signature && this.structureFingerprint(latest.
|
|
1378
|
+
if (latest && latest.signature !== signature && this.structureFingerprint(latest.model as TwinModelDef) === signature) {
|
|
782
1379
|
latest.signature = signature
|
|
783
1380
|
await repo.save(latest)
|
|
784
1381
|
}
|
|
785
1382
|
|
|
786
1383
|
if (latest?.signature === signature) {
|
|
787
|
-
this.structureRevCache[
|
|
1384
|
+
this.structureRevCache[runtimeKey(domainId, instanceId)] = latest.rev
|
|
788
1385
|
return latest.rev
|
|
789
1386
|
}
|
|
790
1387
|
|
|
791
1388
|
const rev = (latest?.rev ?? 0) + 1
|
|
792
|
-
await repo.save(repo.create({ domain: { id: domainId } as any, instanceId, rev, signature,
|
|
793
|
-
this.structureRevCache[
|
|
1389
|
+
await repo.save(repo.create({ domain: { id: domainId } as any, instanceId, rev, signature, model: model as any, ...(comment ? { comment } : {}) }))
|
|
1390
|
+
this.structureRevCache[runtimeKey(domainId, instanceId)] = rev
|
|
794
1391
|
/* 구조가 바뀌면 재구성 캐시는 옛 공장의 것이다 — 버린다(지우는 건 캐시뿐, 사실은 남는다). */
|
|
795
1392
|
delete this.recovered[runtimeKey(domainId, instanceId)]
|
|
796
1393
|
if (latest) console.info(`[twin-engine] "${instanceId}" structure changed → revision ${rev} (history kept; older events stay under revision ${latest.rev}).`)
|
|
@@ -801,7 +1398,7 @@ export class TwinEngine {
|
|
|
801
1398
|
* 이 트윈이 거쳐 온 구조들 — **무엇이 언제 달라졌나.**
|
|
802
1399
|
*
|
|
803
1400
|
* 리비전은 그 시절 공장을 통째로 담지만(재생에 필요하다), 사람이 보고 싶은 것은 통째가 아니라
|
|
804
|
-
* 차이다.
|
|
1401
|
+
* 차이다. 모델은 빼고 **차이 요약만** 내보낸다 — 62KB 짜리 보드를 화면에 실어 보낼 이유가 없다.
|
|
805
1402
|
*/
|
|
806
1403
|
static async structureHistory(
|
|
807
1404
|
domainId: string,
|
|
@@ -831,15 +1428,15 @@ export class TwinEngine {
|
|
|
831
1428
|
createdAt: r.createdAt,
|
|
832
1429
|
...(r.comment ? { comment: r.comment } : {}),
|
|
833
1430
|
events: (counts.get(r.rev) ?? 0) + (r.rev === oldest ? legacy : 0),
|
|
834
|
-
diff: diffStructures(i === 0 ? undefined : (revs[i - 1].
|
|
1431
|
+
diff: diffStructures(i === 0 ? undefined : (revs[i - 1].model as any), r.model as any)
|
|
835
1432
|
}))
|
|
836
1433
|
}
|
|
837
1434
|
|
|
838
1435
|
/** 지금 쓰이는 구조 리비전 — 이벤트에 찍을 번호. 아직 없으면 기록하며 만든다. */
|
|
839
|
-
static async currentStructureRev(domainId: string, instanceId: string,
|
|
1436
|
+
static async currentStructureRev(domainId: string, instanceId: string, model?: TwinModelDef): Promise<number | undefined> {
|
|
840
1437
|
const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
841
1438
|
if (latest) return latest.rev
|
|
842
|
-
return
|
|
1439
|
+
return model ? this.recordStructure(domainId, instanceId, model) : undefined
|
|
843
1440
|
}
|
|
844
1441
|
|
|
845
1442
|
/** 구조 동일성 지문 — 좌표(layout) 등 뷰 관심사는 제외하고 커널이 보는 위상·용량·무버만. */
|
|
@@ -850,28 +1447,28 @@ export class TwinEngine {
|
|
|
850
1447
|
* 길이를 강제하지 않아 그냥 들어가지만 **Postgres 는 거기서 터진다** — 개발에서는 멀쩡하고
|
|
851
1448
|
* 운영에서만 죽는 종류의 실패다. 비교에만 쓰는 값이므로 해시로 충분하다.
|
|
852
1449
|
*/
|
|
853
|
-
private static structureFingerprint(
|
|
854
|
-
return createHash('sha256').update(this.structureSignature(
|
|
1450
|
+
private static structureFingerprint(model: TwinModelDef): string {
|
|
1451
|
+
return createHash('sha256').update(this.structureSignature(model)).digest('hex')
|
|
855
1452
|
}
|
|
856
1453
|
|
|
857
|
-
private static structureSignature(
|
|
858
|
-
const locations = [...(
|
|
1454
|
+
private static structureSignature(model: TwinModelDef): string {
|
|
1455
|
+
const locations = [...(model.locations ?? [])]
|
|
859
1456
|
.map(n => `${n.id}:${n.type}:${n.capacity}`)
|
|
860
1457
|
.sort()
|
|
861
1458
|
.join('|')
|
|
862
|
-
const equipment = [...(
|
|
1459
|
+
const equipment = [...(model.equipment ?? [])]
|
|
863
1460
|
.map(m => `${m.id}:${m.kind}:${m.homeLocation}`)
|
|
864
1461
|
.sort()
|
|
865
1462
|
.join('|')
|
|
866
1463
|
return `N[${locations}]M[${equipment}]`
|
|
867
1464
|
}
|
|
868
1465
|
|
|
869
|
-
/** 레지스트리
|
|
1466
|
+
/** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */
|
|
870
1467
|
static async startFromRegistry(domainId: string, instanceId: string, realityMode?: RealityMode): Promise<InstanceRuntime> {
|
|
871
1468
|
const key = runtimeKey(domainId, instanceId)
|
|
872
1469
|
if (this.instances[key]) return this.instances[key]
|
|
873
1470
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
874
|
-
if (!reg?.
|
|
1471
|
+
if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
|
|
875
1472
|
|
|
876
1473
|
/*
|
|
877
1474
|
* 웜스타트 재료를 **여기서 확실히 확보한다.**
|
|
@@ -894,7 +1491,7 @@ export class TwinEngine {
|
|
|
894
1491
|
* 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
|
|
895
1492
|
*
|
|
896
1493
|
* 저널을 초기화하고 기동하는 모드(sim-experiment)라면 이 값이 0이라 아무 영향이 없다. 규칙을
|
|
897
|
-
* 모드별로
|
|
1494
|
+
* 모드별로 구분하지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
|
|
898
1495
|
* 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
|
|
899
1496
|
*/
|
|
900
1497
|
const last = await getRepository(TwinEvent)
|
|
@@ -907,16 +1504,18 @@ export class TwinEngine {
|
|
|
907
1504
|
return this.start(
|
|
908
1505
|
instanceId,
|
|
909
1506
|
domainId,
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
reg.
|
|
1507
|
+
/* 커널 종류·현실 선언은 레지스트리에 반드시 있다(둘 다 NOT NULL). 예전에는 `?? 'wms'` 로
|
|
1508
|
+
메웠는데, 그건 YMS/MES 트윈을 **조용히 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
|
|
1509
|
+
reg.kind,
|
|
1510
|
+
reg.model as TwinModelDef,
|
|
1511
|
+
realityMode ?? (reg.realityMode as RealityMode),
|
|
1512
|
+
reg.purpose,
|
|
914
1513
|
Number(last?.max ?? 0) || 0
|
|
915
1514
|
)
|
|
916
1515
|
}
|
|
917
1516
|
|
|
918
1517
|
/**
|
|
919
|
-
* 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 realityMode 에 따라 재기동 방식을
|
|
1518
|
+
* 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 realityMode 에 따라 재기동 방식을 구분한다.
|
|
920
1519
|
* 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다.
|
|
921
1520
|
* - 'mirror' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
|
|
922
1521
|
* - 'sim-world' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
|
|
@@ -928,8 +1527,8 @@ export class TwinEngine {
|
|
|
928
1527
|
static async bootDeclared(domainId: string, instanceId: string, mode: RealityMode = DEFAULT_REALITY_MODE): Promise<InstanceRuntime> {
|
|
929
1528
|
if (mode === 'mirror') {
|
|
930
1529
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
931
|
-
if (!reg?.
|
|
932
|
-
return this.startLive(instanceId, domainId, reg.kind
|
|
1530
|
+
if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
|
|
1531
|
+
return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef)
|
|
933
1532
|
}
|
|
934
1533
|
if (mode === 'sim-world') {
|
|
935
1534
|
/*
|
|
@@ -964,63 +1563,128 @@ export class TwinEngine {
|
|
|
964
1563
|
/** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */
|
|
965
1564
|
static async remove(domainId: string, instanceId: string): Promise<void> {
|
|
966
1565
|
await this.stop(domainId, instanceId)
|
|
967
|
-
|
|
1566
|
+
this.forgetInstance(domainId, instanceId)
|
|
968
1567
|
await getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId })
|
|
969
1568
|
await getRepository(TwinInstance).delete({ domain: { id: domainId } as any, instanceId })
|
|
970
1569
|
}
|
|
971
1570
|
|
|
1571
|
+
/**
|
|
1572
|
+
* 이 트윈에 대해 **메모리에 남은 것을 전부 잊는다** — 지움의 한 자리.
|
|
1573
|
+
*
|
|
1574
|
+
* 캐시가 라이프사이클과 어긋나 있었다: 저널·등록부는 지우면서 인스턴스별 캐시는 남겨, 삭제된 트윈의
|
|
1575
|
+
* 항목이 **서버 재기동까지 살아 있었다.** 항목 하나가 하루치 KPI 그룹을 물고 있으므로 잊지 않으면
|
|
1576
|
+
* 지운 트윈이 계속 메모리를 차지한다. TTL 은 값이 낡는 것을 막을 뿐 **없어진 주인을 지우지 않는다.**
|
|
1577
|
+
*
|
|
1578
|
+
* 잊을 것을 여기 모아 두는 이유: 캐시를 새로 만드는 사람이 지우는 자리를 찾아 헤매지 않게 한다.
|
|
1579
|
+
* 새 인스턴스별 캐시를 만들면 **이 함수에 한 줄을 더한다** — 그러지 않으면 같은 누수가 다시 생긴다.
|
|
1580
|
+
*/
|
|
1581
|
+
private static forgetInstance(domainId: string, instanceId: string): void {
|
|
1582
|
+
const key = runtimeKey(domainId, instanceId)
|
|
1583
|
+
delete this.recovered[key]
|
|
1584
|
+
this.measuredCache.delete(key)
|
|
1585
|
+
delete this.structureRevCache[key]
|
|
1586
|
+
}
|
|
1587
|
+
|
|
972
1588
|
/** 관리 목록 — domain 의 등록 인스턴스 전체(라이브 여부·최신 저널 revision·노드/무버 수 포함). */
|
|
973
1589
|
static async list(domainId: string): Promise<any[]> {
|
|
974
1590
|
const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
|
|
1591
|
+
/*
|
|
1592
|
+
* **현장 이름을 함께 낸다** — 목록이 `spaceId` 만 내보내서 화면이 이름을 따로 조회하고 있었다.
|
|
1593
|
+
* 그 조회(`twinSpaces`)는 공간마다 카운트 쿼리를 도는 N+1 이라 느리고, 그 사이 화면은 **id 를
|
|
1594
|
+
* 이름처럼** 보여 준다(2026-08-13 관측: 「현장 crew-probe-site」). 한 번에 읽어 map 으로 붙이면
|
|
1595
|
+
* 왕복도 줄고 그 깜빡임도 사라진다. 이름이 없으면 **비운다** — id 를 이름으로 베끼지 않는다.
|
|
1596
|
+
*/
|
|
1597
|
+
const spaceNameOf = new Map(
|
|
1598
|
+
(await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name])
|
|
1599
|
+
)
|
|
1600
|
+
/*
|
|
1601
|
+
* **최신 리비전은 한 번에 묻는다.**
|
|
1602
|
+
*
|
|
1603
|
+
* 트윈마다 `findOne(order revision DESC)` 을 돌고 있었다 — 13개면 질의 13번이고, 트윈이 늘면
|
|
1604
|
+
* 그대로 자란다. 이 목록은 트윈 관리·현장 구성·엔티티 패널이 모두 읽는 자리다.
|
|
1605
|
+
* 한 번의 그룹 질의로 바꾼다(같은 모양의 선례가 이 파일에 이미 있다: structureRev 집계).
|
|
1606
|
+
*/
|
|
1607
|
+
const tipOfInstance = new Map<string, number>()
|
|
1608
|
+
try {
|
|
1609
|
+
const tips = await getRepository(TwinEvent)
|
|
1610
|
+
.createQueryBuilder('e')
|
|
1611
|
+
.select('e.instanceId', 'instanceId')
|
|
1612
|
+
.addSelect('MAX(e.revision)', 'revision')
|
|
1613
|
+
.where('e.domain = :domainId', { domainId })
|
|
1614
|
+
.groupBy('e.instanceId')
|
|
1615
|
+
.getRawMany()
|
|
1616
|
+
for (const t of tips) if (t?.instanceId != null) tipOfInstance.set(String(t.instanceId), Number(t.revision) || 0)
|
|
1617
|
+
} catch (err: any) {
|
|
1618
|
+
/* 집계가 실패하면 **0 으로 메우지 않는다** — 리비전 0 은 "아직 아무 일도 없었다" 는 사실 주장이다.
|
|
1619
|
+
비워 두면 아래에서 `?? 0` 이 아니라 undefined 로 남고, 화면은 그것을 "모름" 으로 그린다. */
|
|
1620
|
+
console.error('[twin-engine] latest revision aggregate failed', err?.message ?? err)
|
|
1621
|
+
}
|
|
1622
|
+
|
|
975
1623
|
const out: any[] = []
|
|
976
1624
|
for (const r of rows) {
|
|
977
|
-
const
|
|
978
|
-
const last = await getRepository(TwinEvent).findOne({
|
|
979
|
-
where: { domain: { id: domainId } as any, instanceId: r.instanceId },
|
|
980
|
-
order: { revision: 'DESC' }
|
|
981
|
-
})
|
|
1625
|
+
const model = (r.model as TwinModelDef) ?? { locations: [], equipment: [] }
|
|
982
1626
|
out.push({
|
|
983
1627
|
instanceId: r.instanceId,
|
|
1628
|
+
/*
|
|
1629
|
+
* **이름을 함께 낸다.** 업무키(`instanceId`)만 내보내고 있어서 목록이 `crew-two` 만 보였다 —
|
|
1630
|
+
* 업무키를 나눠 둔 설계에서는 이름을 같이 실어야 그 나눔이 값을 한다.
|
|
1631
|
+
* 없으면 `undefined` 다(id 를 베끼지 않는다 — 화면이 이름 없음을 알아볼 수 있어야 한다).
|
|
1632
|
+
*/
|
|
1633
|
+
name: r.name ?? undefined,
|
|
1634
|
+
description: r.description ?? undefined,
|
|
984
1635
|
kind: r.kind,
|
|
985
1636
|
status: r.status,
|
|
986
|
-
spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한
|
|
987
|
-
|
|
988
|
-
|
|
1637
|
+
spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한 현장 공유 — 현장뷰 집약 키(G7)
|
|
1638
|
+
/* 사람이 읽는 현장 이름 — 없으면 `undefined`(화면이 "이름 없음" 을 알아볼 수 있어야 한다). */
|
|
1639
|
+
spaceName: (r.spaceId ? spaceNameOf.get(r.spaceId) : undefined) || undefined,
|
|
1640
|
+
realityMode: r.realityMode, // 현실 출처 선언(§0 ①) — mirror/sim-world/sim-experiment. 저장 시 각인되므로 비지 않는다.
|
|
1641
|
+
purpose: r.purpose, // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님). 저장 시 각인되므로 비지 않는다.
|
|
989
1642
|
copyOf: r.copyOf ?? undefined,
|
|
990
1643
|
running: !!this.instances[runtimeKey(domainId, r.instanceId)],
|
|
991
|
-
revision: last?.revision ?? 0,
|
|
992
1644
|
/*
|
|
993
|
-
*
|
|
1645
|
+
* **계측이 들어오고 있나** — 「도는 중」과 다른 사실이다.
|
|
1646
|
+
*
|
|
1647
|
+
* 미러 트윈은 피드가 떨어져도 커널이 돌기만 하면 「도는 중」이었다. 그래서 아무것도 받지 않는
|
|
1648
|
+
* 트윈과 흐르는 트윈이 화면에서 똑같이 보였다(재기동 뒤 실제로 그랬다). 시뮬·정지 트윈에는
|
|
1649
|
+
* 「해당 없음」이다 — 없어야 하는 것을 끊겼다고 부르지 않는다.
|
|
1650
|
+
*/
|
|
1651
|
+
/* 스스로 멈춘 이유 — 있으면 낸다. 재기동 뒤에는 없다(그때는 「모른다」가 사실이다). */
|
|
1652
|
+
stopNote: this.stopNotes.get(runtimeKey(domainId, r.instanceId)) || undefined,
|
|
1653
|
+
liveFeed: liveFeedStateOf({
|
|
1654
|
+
realityMode: r.realityMode,
|
|
1655
|
+
running: !!this.instances[runtimeKey(domainId, r.instanceId)],
|
|
1656
|
+
instanceId: r.instanceId
|
|
1657
|
+
}),
|
|
1658
|
+
revision: tipOfInstance.get(r.instanceId) ?? 0,
|
|
1659
|
+
/*
|
|
1660
|
+
* **리더를 거쳐 센다** — 보드 키를 직접 읽으면 옛 세대 모델이 0 으로 보인다.
|
|
994
1661
|
*
|
|
995
1662
|
* 커널은 자리·설비 배열의 **옛 세대 키까지 흡수해** 읽어 주는데, 이 목록은 새 이름만 직접
|
|
996
|
-
* 세고 있었다. 그래서 옛
|
|
997
|
-
* 으로 떴다 — 오류 없이, 그냥 빈 공장처럼. 세는 규칙이 두 벌이면 이런 식으로
|
|
1663
|
+
* 세고 있었다. 그래서 옛 모델 12개가 화면에 **자리 0 · 설비 0**
|
|
1664
|
+
* 으로 떴다 — 오류 없이, 그냥 빈 공장처럼. 세는 규칙이 두 벌이면 이런 식으로 어긋난다.
|
|
998
1665
|
*/
|
|
999
|
-
locationCount: readBoardLocations(
|
|
1000
|
-
equipmentCount: readBoardEquipment(
|
|
1666
|
+
locationCount: readBoardLocations(model).length,
|
|
1667
|
+
equipmentCount: readBoardEquipment(model).length
|
|
1001
1668
|
})
|
|
1002
1669
|
}
|
|
1003
1670
|
return out
|
|
1004
1671
|
}
|
|
1005
1672
|
|
|
1006
|
-
/**
|
|
1007
|
-
static isBenchSpace(spaceId?: string): boolean {
|
|
1008
|
-
return !!spaceId && (/^bench-/.test(spaceId) || spaceId === 'rosarito-load' || /^loadtest/.test(spaceId))
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
/** 운영 공간 목록(벤치 소스 선택기) — 이름·규모 포함, 벤치 사본·잔재 제외. */
|
|
1673
|
+
/** 운영 공간 목록(벤치 소스 선택기) — 이름·규모 포함, 벤치 사본 제외. */
|
|
1012
1674
|
static async listSpaces(domainId: string): Promise<{ spaceId: string; name: string; instances: number; locations: number; equipment: number }[]> {
|
|
1013
1675
|
const nameOf = new Map((await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name]))
|
|
1014
1676
|
const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
|
|
1015
1677
|
const agg = new Map<string, { spaceId: string; name: string; instances: number; locations: number; equipment: number }>()
|
|
1016
1678
|
for (const r of insts) {
|
|
1017
|
-
|
|
1018
|
-
|
|
1679
|
+
/* 벤치 제외는 **선언으로만** 판단한다. 예전에는 spaceId 이름(`bench-`·`loadtest*`)으로도 추측했는데,
|
|
1680
|
+
그러면 그렇게 이름 지은 진짜 운영 공간이 목록에서 조용히 사라진다. purpose 가 정본이다. */
|
|
1681
|
+
if (!r.spaceId || r.purpose === 'bench') continue
|
|
1682
|
+
const model: any = r.model ?? {}
|
|
1019
1683
|
const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, locations: 0, equipment: 0 }
|
|
1020
1684
|
a.instances++
|
|
1021
1685
|
/* 목록과 **같은 규칙**으로 센다 — 여기만 직접 세면 공간 요약과 인스턴스 목록이 다른 수를 말한다. */
|
|
1022
|
-
a.locations += readBoardLocations(
|
|
1023
|
-
a.equipment += readBoardEquipment(
|
|
1686
|
+
a.locations += readBoardLocations(model).length
|
|
1687
|
+
a.equipment += readBoardEquipment(model).length
|
|
1024
1688
|
agg.set(r.spaceId, a)
|
|
1025
1689
|
}
|
|
1026
1690
|
return [...agg.values()]
|
|
@@ -1032,17 +1696,28 @@ export class TwinEngine {
|
|
|
1032
1696
|
}
|
|
1033
1697
|
|
|
1034
1698
|
/**
|
|
1035
|
-
* 공간 구조 판독 — 완전복제(벤치 사본)의 소스. 저장물이
|
|
1699
|
+
* 공간 구조 판독 — 완전복제(벤치 사본)의 소스. 저장물이 model(위상)·TwinSpace.content(치수·geo·표현)·TwinArea(영역)로
|
|
1036
1700
|
* 분산돼 있으므로 셋을 모아 돌려준다. 호출측이 ReferenceMaster 로 재구성 → 재식별 → ingestMaster.
|
|
1037
1701
|
*/
|
|
1038
|
-
static async spaceStructure(domainId: string, spaceId: string): Promise<{
|
|
1702
|
+
static async spaceStructure(domainId: string, spaceId: string): Promise<{
|
|
1703
|
+
name?: string
|
|
1704
|
+
/** 설계 좌표계 범위(미터)와 지오레퍼런스 — 사본이 원본과 같은 자리에 서려면 함께 가야 한다. */
|
|
1705
|
+
frame: { extentX?: number; extentY?: number; geo?: any; primaryRepresentationId?: string }
|
|
1706
|
+
areas: any[]
|
|
1707
|
+
instances: { instanceId: string; kind?: string; model: any }[]
|
|
1708
|
+
} | null> {
|
|
1039
1709
|
const sp = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } })
|
|
1040
1710
|
const areas = sp
|
|
1041
1711
|
? (await getRepository(TwinArea).find({ where: { domain: { id: domainId } as any, space: { id: sp.id } as any } })).map(a => ({ id: a.areaId, type: a.type, name: a.name, parentId: a.parentId, layout: a.layout }))
|
|
1042
1712
|
: []
|
|
1043
1713
|
const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } })
|
|
1044
1714
|
if (!insts.length) return null
|
|
1045
|
-
return {
|
|
1715
|
+
return {
|
|
1716
|
+
name: sp?.name,
|
|
1717
|
+
frame: { extentX: sp?.extentX, extentY: sp?.extentY, geo: sp?.geo, primaryRepresentationId: sp?.primaryRepresentationId },
|
|
1718
|
+
areas,
|
|
1719
|
+
instances: insts.map(i => ({ instanceId: i.instanceId, kind: i.kind, model: i.model }))
|
|
1720
|
+
}
|
|
1046
1721
|
}
|
|
1047
1722
|
|
|
1048
1723
|
/** 공간(+표현·영역) 삭제 — 벤치 정리 등. 호출측이 바인딩 인스턴스를 먼저 제거하는 전제(가드 없음). deleteTwinSpace 리졸버와 동형. */
|
|
@@ -1069,9 +1744,70 @@ export class TwinEngine {
|
|
|
1069
1744
|
static async ingestMaster(
|
|
1070
1745
|
domainId: string,
|
|
1071
1746
|
master: ReferenceMaster,
|
|
1072
|
-
into?: { spaceId?: string }
|
|
1747
|
+
into?: { spaceId?: string },
|
|
1748
|
+
/** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */
|
|
1749
|
+
actor?: { id: string }
|
|
1073
1750
|
): Promise<{ instanceId: string; spaceId: string; warnings: IngestWarning[] }> {
|
|
1074
|
-
|
|
1751
|
+
/*
|
|
1752
|
+
* **거절할 것이면 아무것도 쓰기 전에 거절한다.**
|
|
1753
|
+
*
|
|
1754
|
+
* 가동 중 재프로비저닝은 `provision` 이 막는다. 그런데 그 호출은 이 함수의 **맨 끝**이라,
|
|
1755
|
+
* 그때까지 공간·표현·구역(TwinArea)은 이미 저장된 뒤였다. 사용자에게는 "인제스트 실패" 로
|
|
1756
|
+
* 보이는데 실제로는 절반이 적용된 상태 — 실패라고 말하면서 데이터를 바꾸는 것이 가장 나쁘다.
|
|
1757
|
+
*
|
|
1758
|
+
* 그래서 판정을 앞으로 옮긴다. 아래 `provision` 의 같은 판정은 그대로 둔다 — 이건 편의를 위한
|
|
1759
|
+
* 앞선 거절이고, 저기가 진짜 방벽이다(다른 경로가 생겨도 지켜져야 한다).
|
|
1760
|
+
*
|
|
1761
|
+
* **미러는 여기서 거절하지 않는다.** 미러가 비추는 현실은 안 멈추므로 동작 중에 전환한다
|
|
1762
|
+
* (아래 `adoptStructure`). 거절은 시뮬레이션에만 남는다 — 조건이 바뀐 실험은 다른 실험이다.
|
|
1763
|
+
*/
|
|
1764
|
+
const running = this.instances[runtimeKey(domainId, master.source)]
|
|
1765
|
+
if (running && running.mode !== 'live') this.assertNotRunning(domainId, master.source)
|
|
1766
|
+
|
|
1767
|
+
const { model, spaceContent, warnings } = masterToTwin(master, DOMAIN_CATALOG)
|
|
1768
|
+
/*
|
|
1769
|
+
* ── 현장 선언을 다시 얹는다 (2026-08-18) ──────────────────────────────────
|
|
1770
|
+
*
|
|
1771
|
+
* 여기가 「원천에서 다시 그리는」 자리다. 요금 단가·계약 정보처럼 **현장이 정한 수**는 원천이 모르므로
|
|
1772
|
+
* 다시 그리면 그대로 사라진다 — 금액이 사라지고, 사용자는 자기가 넣은 값이 어디로 갔는지 알 수 없다.
|
|
1773
|
+
* 그래서 겹으로 보관한 선언을 매번 다시 얹는다(`engine/local-declarations`).
|
|
1774
|
+
*
|
|
1775
|
+
* 대상이 사라졌으면(구조가 바뀌었다) 그 사실을 경고로 낸다 — 갈 곳 없는 선언을 조용히 버리지 않는다.
|
|
1776
|
+
*/
|
|
1777
|
+
const prior = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId: master.source } })
|
|
1778
|
+
/*
|
|
1779
|
+
* 겹이 **둘**이다 — 현장(공간)의 것과 이 트윈의 것. 순서가 규율이다: 현장 먼저, 트윈이 나중.
|
|
1780
|
+
* 요금처럼 현장이 정한 수는 그 현장의 트윈 전부가 같이 읽어야 하고, 한 트윈만 다르게 두는 실험은
|
|
1781
|
+
* 좁은 겹이 이겨야 한다(넓은 값을 고치면 나머지 트윈이 함께 흔들린다).
|
|
1782
|
+
*/
|
|
1783
|
+
const priorSpaceId = prior?.spaceId ?? (model as any)?.spaceId
|
|
1784
|
+
const spaceRow = priorSpaceId
|
|
1785
|
+
? await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: priorSpaceId } })
|
|
1786
|
+
: null
|
|
1787
|
+
if (prior?.localDeclarations || spaceRow?.localDeclarations) {
|
|
1788
|
+
const layered = applyDeclarationLayers(model as any, spaceRow?.localDeclarations as any, prior?.localDeclarations as any)
|
|
1789
|
+
Object.assign(model as any, layered.model)
|
|
1790
|
+
/*
|
|
1791
|
+
* 겹을 **다시 저장한다** — 여기서 얹은 것은 원천에서 새로 그린 모델이므로, 그때 우리가 덮은 값이
|
|
1792
|
+
* 곧 지금의 원천 값이다(`replaced`). 저장하지 않으면 그 기억이 낡아, 철회가 옛 원천 값으로
|
|
1793
|
+
* 되돌린다 — 원천이 그동안 값을 고쳤어도 알 수 없다.
|
|
1794
|
+
*/
|
|
1795
|
+
if (prior) {
|
|
1796
|
+
prior.localDeclarations = layered.instanceLayerApplied as any
|
|
1797
|
+
await getRepository(TwinInstance).save(prior)
|
|
1798
|
+
}
|
|
1799
|
+
if (spaceRow) {
|
|
1800
|
+
spaceRow.localDeclarations = layered.spaceLayerApplied as any
|
|
1801
|
+
await getRepository(TwinSpace).save(spaceRow)
|
|
1802
|
+
}
|
|
1803
|
+
/*
|
|
1804
|
+
* 갈 곳 없는 선언은 경고로 낸다 — 다만 **현장 겹의 것은 뺀다**: 한 현장에는 여러 종류의 트윈이
|
|
1805
|
+
* 살고(창고·야드·에너지), 요금 자리가 없는 트윈에 요금이 얹히지 않는 것은 정상이다.
|
|
1806
|
+
*/
|
|
1807
|
+
for (const t of layered.missingTargets.filter(x => x.scope === 'instance')) {
|
|
1808
|
+
warnings.push(unresolvedReference('localDeclaration.target', `${t.target}:${t.id}`))
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1075
1811
|
const repo = getRepository(TwinSpace)
|
|
1076
1812
|
/* 합칠 공간을 골랐으면 **있는지 먼저 확인한다** — 없는 곳에 조용히 새 공간을 만들면 사용자는
|
|
1077
1813
|
합쳤다고 믿고 화면은 따로 논다. 판정 함수가 그 경우 거절한다. */
|
|
@@ -1079,49 +1815,42 @@ export class TwinEngine {
|
|
|
1079
1815
|
const wantedExists = wanted
|
|
1080
1816
|
? (await repo.count({ where: { domain: { id: domainId }, spaceId: wanted } })) > 0
|
|
1081
1817
|
: false
|
|
1082
|
-
const choice = resolveIngestSpace(
|
|
1818
|
+
const choice = resolveIngestSpace(model.spaceId, wanted, wantedExists)
|
|
1083
1819
|
const spaceId: string = choice.spaceId
|
|
1084
|
-
|
|
1820
|
+
model.spaceId = spaceId // 보드(커널 소비)도 같은 공간을 가리켜야 한다
|
|
1085
1821
|
const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId } })
|
|
1086
1822
|
if (choice.joined) {
|
|
1087
|
-
/* 같은 id 의 구역·랜드마크는 합집합으로 접힌다 —
|
|
1823
|
+
/* 같은 id 의 구역·랜드마크는 합집합으로 접힌다 — 구분할 수 없는 것을 고르지 않고 무엇이
|
|
1088
1824
|
합쳐졌는지 말한다(정당한 경우가 많으므로 막지 않는다). */
|
|
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
|
-
}
|
|
1093
|
-
/*
|
|
1094
|
-
* 공유 공간(여러 트윈이 한 spaceId, N:1) content 병합 — 마지막 인제스트가 통째로 덮어써 area(그룹)·표현이
|
|
1095
|
-
* 유실되던 문제 보정. area·landmark 는 id 합집합, representations 는 비어있지 않은 쪽 보존, 나머지는 first-wins.
|
|
1096
|
-
*/
|
|
1097
|
-
const prev: any = existing?.content ?? {}
|
|
1098
|
-
const cur: any = spaceContent
|
|
1099
|
-
const unionById = (a: any[] = [], b: any[] = []) => {
|
|
1100
|
-
const m = new Map<string, any>()
|
|
1101
|
-
for (const x of [...a, ...b]) if (x?.id) m.set(x.id, x)
|
|
1102
|
-
return [...m.values()]
|
|
1103
|
-
}
|
|
1104
|
-
const mergedContent = {
|
|
1105
|
-
width: prev.width ?? cur.width,
|
|
1106
|
-
depth: prev.depth ?? cur.depth,
|
|
1107
|
-
unit: prev.unit ?? cur.unit,
|
|
1108
|
-
geo: prev.geo ?? cur.geo,
|
|
1109
|
-
primaryId: prev.primaryId ?? cur.primaryId,
|
|
1110
|
-
representations: (cur.representations?.length ? cur.representations : prev.representations) ?? [],
|
|
1111
|
-
// areas 는 content 에 더 이상 쓰지 않는다(area 단일화 P3) — 권위=TwinArea. 아래 TwinArea 로만 기록.
|
|
1112
|
-
landmarks: unionById(prev.landmarks, cur.landmarks)
|
|
1113
1825
|
}
|
|
1114
1826
|
/*
|
|
1115
|
-
*
|
|
1827
|
+
* 공유 공간(여러 트윈이 한 spaceId, N:1) — **먼저 쓴 값이 이긴다.**
|
|
1116
1828
|
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
* **사용자가 고친 값을 마스터가 덮지 않는다** — 이미 있으면 그대로 둔다(현장이 정본을 이긴다).
|
|
1829
|
+
* 마지막 인제스트가 통째로 덮어써 표현·구역이 유실되던 문제 보정. 설계 좌표계 범위와
|
|
1830
|
+
* 지오레퍼런스는 공간의 성질이라 트윈마다 다를 수 없다 — 두 번째 트윈이 들어오면서 바꾸면
|
|
1831
|
+
* 먼저 놓인 구역들이 통째로 어긋난다. 그래서 이미 있으면 그대로 둔다.
|
|
1122
1832
|
*/
|
|
1833
|
+
const cur: any = spaceContent
|
|
1123
1834
|
const timezone = existing?.timezone || master.space.timezone
|
|
1124
|
-
const savedSpace = await repo.save(
|
|
1835
|
+
const savedSpace = await repo.save(
|
|
1836
|
+
repo.create({
|
|
1837
|
+
...(existing ?? {}),
|
|
1838
|
+
domain: { id: domainId } as any,
|
|
1839
|
+
spaceId,
|
|
1840
|
+
name: existing?.name || master.siteName,
|
|
1841
|
+
...(timezone ? { timezone } : {}),
|
|
1842
|
+
extentX: existing?.extentX ?? cur.width,
|
|
1843
|
+
extentY: existing?.extentY ?? cur.depth,
|
|
1844
|
+
geo: existing?.geo ?? cur.geo,
|
|
1845
|
+
/*
|
|
1846
|
+
* 대표 표현 포인터는 **표현을 저장한 뒤** 실제 행 id 로 채운다(아래). 여기서 마스터의 논리
|
|
1847
|
+
* id(`r-map` 따위)를 넣으면 표현 행은 생성 uuid 를 받으므로 포인터가 **처음부터 허공을
|
|
1848
|
+
* 가리킨다** — 실제로 13개 현장이 그 상태였고 정합성 점검이 잡았다(2026-08-13). 화면은
|
|
1849
|
+
* 행의 `isPrimary` 로 ★를 그려서 증상이 보이지 않았다(사실이 두 벌이면 이렇게 조용하다).
|
|
1850
|
+
*/
|
|
1851
|
+
primaryRepresentationId: existing?.primaryRepresentationId ?? null
|
|
1852
|
+
})
|
|
1853
|
+
)
|
|
1125
1854
|
/*
|
|
1126
1855
|
* 표현을 twin_space_representations 테이블에 저장한다 — 뷰(twin-map-page 등)는 content 가 아니라 테이블 표현을 소싱하므로,
|
|
1127
1856
|
* 저장하지 않으면 인제스트 사이트에 "대표 표현" 이 없어 지도로 시작하지 못한다. rep.areas 가 있으면 TwinSpaceArea 도 함께 저장.
|
|
@@ -1143,6 +1872,17 @@ export class TwinEngine {
|
|
|
1143
1872
|
await areaRepo.save(areaRepo.create({ domain: { id: domainId } as any, representation: { id: savedRep.id } as any, name: area.name, type: area.type, geometry: area.geometry, drillTo: area.drillTo ?? null, binding: area.binding ?? null, style: area.style ?? null }))
|
|
1144
1873
|
}
|
|
1145
1874
|
}
|
|
1875
|
+
/*
|
|
1876
|
+
* 대표를 **저장된 행 id** 로 가리킨다 — 마스터의 논리 id 는 저장 키가 아니다. 고른 순서:
|
|
1877
|
+
* ① 마스터가 primary 로 표시한 표현 ② 없으면 첫 표현 ③ 표현이 없으면 비운다(지어내지 않는다).
|
|
1878
|
+
*/
|
|
1879
|
+
const saved = await repRepo.find({ where: { domain: { id: domainId } as any, space: { id: savedSpace.id } as any }, order: { seq: 'ASC' } })
|
|
1880
|
+
const primary = saved.find(r => r.isPrimary) ?? saved[0]
|
|
1881
|
+
if (primary && savedSpace.primaryRepresentationId !== primary.id) {
|
|
1882
|
+
await repo.save(repo.create({ ...savedSpace, primaryRepresentationId: primary.id }))
|
|
1883
|
+
/* 하나만 대표다 — 행 플래그도 그 하나로 맞춘다(둘이 켜져 있으면 화면이 ★를 두 번 그린다). */
|
|
1884
|
+
for (const r of saved) if (!!r.isPrimary !== (r.id === primary.id)) await repRepo.save(repRepo.create({ ...r, isPrimary: r.id === primary.id }))
|
|
1885
|
+
}
|
|
1146
1886
|
}
|
|
1147
1887
|
/*
|
|
1148
1888
|
* area 단일화(space #2, P1) — 논리 구역을 TwinArea(정규·표현무관·공유)에 dual-write(upsert by areaId).
|
|
@@ -1167,12 +1907,51 @@ export class TwinEngine {
|
|
|
1167
1907
|
)
|
|
1168
1908
|
}
|
|
1169
1909
|
|
|
1170
|
-
|
|
1910
|
+
if (running) {
|
|
1911
|
+
/* 실행 중인 미러 — 멈추지 않고 전환한다. 무엇이 사라졌는지는 **경고로 말한다**
|
|
1912
|
+
(멈췄다 세우는 마디가 없으므로, 말하지 않으면 자리 하나가 조용히 없어진다). */
|
|
1913
|
+
const shift = await this.adoptStructure(domainId, master.source, model, undefined, master.origin)
|
|
1914
|
+
warnings.push(structureAdopted(shift.rev, shift))
|
|
1915
|
+
} else {
|
|
1916
|
+
/* 사이트 이름이 곧 트윈의 이름이다 — 마스터가 이미 말했으므로 지어내지 않고 그대로 싣는다. */
|
|
1917
|
+
await this.provision(domainId, master.source, master.system, model, undefined, master.origin, {
|
|
1918
|
+
name: master.siteName,
|
|
1919
|
+
description: (master as any).description,
|
|
1920
|
+
actor
|
|
1921
|
+
})
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/*
|
|
1925
|
+
* 구조 투영 — model(커널 입력 문서)를 **표준 엔티티 행**으로 푼다(ADR-0032).
|
|
1926
|
+
*
|
|
1927
|
+
* 인제스트가 유일한 쓰기 경로다. 여기서 실패해도 인제스트 자체는 성공으로 둔다 —
|
|
1928
|
+
* 행은 **원본에서 언제든 다시 그릴 수 있는 캐시**이고, 트윈 자체는 model 로 이미 동작한다.
|
|
1929
|
+
* 다만 **조용히 넘기지 않는다**: 못 이은 참조는 인제스트 경고로 올라간다.
|
|
1930
|
+
*/
|
|
1931
|
+
try {
|
|
1932
|
+
const projected = await projectStructure(domainId, master.source, model, master.source)
|
|
1933
|
+
for (const ref of projected.unresolvedHome) warnings.push(unresolvedReference('equipment.homeLocation', ref))
|
|
1934
|
+
for (const ref of projected.unresolvedArea) warnings.push(unresolvedReference('location.area', ref))
|
|
1935
|
+
/*
|
|
1936
|
+
* 발전 형상은 **시각**을 읽는다 — 시간대를 모르면 UTC 로 읽히고, 현장 정오가 아닌 시각에 피크가 선다.
|
|
1937
|
+
* 시간대를 지어내지 않고 그 조합을 말한다(공간에 선언하면 사라진다).
|
|
1938
|
+
*/
|
|
1939
|
+
if ((model as any)?.utcOffsetMinutes === undefined) {
|
|
1940
|
+
for (const e of ((model as any)?.equipment ?? []) as any[]) {
|
|
1941
|
+
const hasProfile = ((e?.properties ?? []) as any[]).some(p => String(p?.id) === EMS_PROPERTY.genDailyProfile && String(p?.value ?? '').trim())
|
|
1942
|
+
if (hasProfile) warnings.push(generationWithoutTimeZone(String((model as any)?.spaceId ?? ''), String(e.id)))
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
} catch (err: any) {
|
|
1946
|
+
console.error(`[twin-engine] structure projection failed for "${master.source}":`, err?.message)
|
|
1947
|
+
warnings.push(projectionFailed(err?.message ?? 'unknown'))
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1171
1950
|
if (warnings.length) console.warn(`[twin-engine] ingest "${master.source}" warnings: ${describeWarnings(warnings)}`)
|
|
1172
1951
|
return { instanceId: master.source, spaceId, warnings }
|
|
1173
1952
|
}
|
|
1174
1953
|
|
|
1175
|
-
/** 단건 상세 — 프로비저닝 에디터가 편집할
|
|
1954
|
+
/** 단건 상세 — 프로비저닝 에디터가 편집할 model(구조+layout) 포함. */
|
|
1176
1955
|
static async detail(domainId: string, instanceId: string): Promise<any | null> {
|
|
1177
1956
|
const r = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
1178
1957
|
if (!r) return null
|
|
@@ -1182,7 +1961,7 @@ export class TwinEngine {
|
|
|
1182
1961
|
status: r.status,
|
|
1183
1962
|
running: !!this.instances[runtimeKey(domainId, r.instanceId)],
|
|
1184
1963
|
realityMode: r.realityMode, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
|
|
1185
|
-
|
|
1964
|
+
model: r.model
|
|
1186
1965
|
}
|
|
1187
1966
|
}
|
|
1188
1967
|
|
|
@@ -1191,16 +1970,53 @@ export class TwinEngine {
|
|
|
1191
1970
|
* 보드 컴포넌트가 tag 로 구독(board-ui provider)해 `component.data` 로 라이브 갱신. delta 시에만(희소).
|
|
1192
1971
|
* tag=엔티티 id(데모=단일 인스턴스). 멀티 인스턴스/보드 재사용 시 tag 네임스페이스는 후속.
|
|
1193
1972
|
*/
|
|
1973
|
+
/** 방송 실패 로그 조절 — 창마다 한 줄(막힌 구독자는 초당 수십 번 실패한다). */
|
|
1974
|
+
private static publishDrops = new Map<string, { count: number; lastLogMs: number }>()
|
|
1975
|
+
private static readonly PUBLISH_DROP_LOG_MS = 10_000
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* 한 번의 방송 — **구독자 하나가 호스트를 죽이지 못하게.**
|
|
1979
|
+
*
|
|
1980
|
+
* ── 무엇이 죽였나 (2026-08-14) ─────────────────────────────────────────────
|
|
1981
|
+
* 밀린 push 가 1024를 넘으면 pubsub 이 던진다(`RepeaterOverflowError`). 그 방송은 타이머 콜백
|
|
1982
|
+
* 안에서 일어나므로 예외가 잡히는 곳 없이 올라가 **프로세스가 끝났다** — 트윈 14개가 도는 호스트가
|
|
1983
|
+
* 소비를 멈춘 구독자 하나 때문에 통째로.
|
|
1984
|
+
*
|
|
1985
|
+
* 못 보낸 것은 **사실로 남긴다**(횟수를 세고 창마다 한 줄 남긴다). 삼키면 「보냈는데 화면이 낡았다」가
|
|
1986
|
+
* 되고, 그건 가장 찾기 어려운 부류다.
|
|
1987
|
+
*/
|
|
1988
|
+
private static publishGuarded(channel: 'data' | 'twin-state', payload: any, what: string): boolean {
|
|
1989
|
+
try {
|
|
1990
|
+
pubsub.publish(channel as any, payload)
|
|
1991
|
+
return true
|
|
1992
|
+
} catch (err: any) {
|
|
1993
|
+
const d = this.publishDrops.get(what) ?? { count: 0, lastLogMs: 0 }
|
|
1994
|
+
d.count++
|
|
1995
|
+
const now = Date.now()
|
|
1996
|
+
if (now - d.lastLogMs >= this.PUBLISH_DROP_LOG_MS) {
|
|
1997
|
+
d.lastLogMs = now
|
|
1998
|
+
console.warn(`[twin-engine] broadcast dropped on "${what}" (${d.count} so far) — ${err?.message ?? err}`)
|
|
1999
|
+
}
|
|
2000
|
+
this.publishDrops.set(what, d)
|
|
2001
|
+
return false
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1194
2005
|
static publishEntityData(inst: InstanceRuntime): void {
|
|
1195
2006
|
const domain = inst.domain
|
|
1196
2007
|
if (!domain) return
|
|
1197
2008
|
/* 상태 출처 스왑 — sim: 커널 runtime, live: projector 미러(+OEE 계산 층 보강). 계약·payload 동일, 드라이버만 다름. */
|
|
2009
|
+
const load = inst.load ?? (inst.load = newLoadMeter())
|
|
2010
|
+
|
|
2011
|
+
/* ① 상태 투영 — 상태 크기에 비례한다(대규모에서 가장 무거운 축). */
|
|
2012
|
+
const tSnap = performance.now()
|
|
1198
2013
|
const st: any =
|
|
1199
2014
|
inst.mode === 'live'
|
|
1200
2015
|
? inst.oee
|
|
1201
2016
|
? withLiveOee(inst.projector?.snapshot?.(), inst.oee) // 관측 커널 + 계산(OEE) → equipment payload 에 oee 포함(sim 동형)
|
|
1202
2017
|
: inst.projector?.snapshot?.()
|
|
1203
2018
|
: inst.runtime?.resync?.()?.state
|
|
2019
|
+
recordPhase(load, 'snapshot', performance.now() - tSnap)
|
|
1204
2020
|
if (!st) return
|
|
1205
2021
|
/*
|
|
1206
2022
|
* 변화한 엔티티만 발행 — 이전엔 delta 마다 전 엔티티(노드+무버+오더)를 값 변화와 무관하게 전량 재발행해
|
|
@@ -1211,13 +2027,24 @@ export class TwinEngine {
|
|
|
1211
2027
|
const seen = new Set<string>()
|
|
1212
2028
|
/* payload 매핑은 순수 함수(buildEntityDeltas)로 분리 — 여기선 시그니처 dedup + 발행만.
|
|
1213
2029
|
* 변화한 엔티티만 발행(최신-상태 채널이라 무변화 재방송 무의미). */
|
|
1214
|
-
|
|
2030
|
+
/* ② payload 만들기 — 엔티티 수에 비례. ③ 시그니처 비교 + 발행 — 바뀐 것 수에 비례. */
|
|
2031
|
+
const tDelta = performance.now()
|
|
2032
|
+
const deltas = buildEntityDeltas(st, inst.id)
|
|
2033
|
+
recordPhase(load, 'deltas', performance.now() - tDelta)
|
|
2034
|
+
|
|
2035
|
+
const tPub = performance.now()
|
|
2036
|
+
for (const { tag, data } of deltas) {
|
|
1215
2037
|
seen.add(tag)
|
|
1216
2038
|
const sig = JSON.stringify(data)
|
|
1217
2039
|
if (sigs.get(tag) === sig) continue // 무변화 → 발행 생략
|
|
1218
2040
|
sigs.set(tag, sig)
|
|
1219
|
-
|
|
2041
|
+
/*
|
|
2042
|
+
* **못 보냈으면 보낸 것으로 적지 않는다.** 시그니처를 남겨 두면 다음 주기에 「무변화」로 건너뛰고,
|
|
2043
|
+
* 그 태그는 영원히 낡은 값을 보여 준다(오류 없이). 되돌려 두면 다음 주기가 다시 시도한다.
|
|
2044
|
+
*/
|
|
2045
|
+
if (!this.publishGuarded('data', { data: { domain, tag, data } }, `data:${inst.id}`)) sigs.delete(tag)
|
|
1220
2046
|
}
|
|
2047
|
+
recordPhase(load, 'publish', performance.now() - tPub)
|
|
1221
2048
|
/* 사라진 엔티티의 시그니처 정리(맵 무한 성장 방지). */
|
|
1222
2049
|
if (sigs.size > seen.size) for (const tag of sigs.keys()) if (!seen.has(tag)) sigs.delete(tag)
|
|
1223
2050
|
}
|
|
@@ -1229,7 +2056,7 @@ export class TwinEngine {
|
|
|
1229
2056
|
|
|
1230
2057
|
/**
|
|
1231
2058
|
* 재부팅 복구 / 시간여행 — DB 저널을 replay 해 상태 재구성.
|
|
1232
|
-
*
|
|
2059
|
+
* model 는 레지스트리(TwinInstance)에서, 이벤트는 TwinEvent(revision ASC)에서.
|
|
1233
2060
|
* 커서 두 축(runtime-state-model §4): **시각(untilTime, 사용자 모국어·공간 공통축)** 우선, 없으면 리비전(untilRevision, 렌즈 내부).
|
|
1234
2061
|
* - untilTime 주면 `eventTime ≤ T` 인 이벤트만(그 시점 watermark) — 공동배치 여러 트윈을 하나의 시각 T로 통일 해소.
|
|
1235
2062
|
* - 둘 다 없으면 최신(라이브 인메모리 진실).
|
|
@@ -1245,21 +2072,100 @@ export class TwinEngine {
|
|
|
1245
2072
|
// (저널이 있어도 최신은 인메모리가 진실 — replay 는 시간여행/복구 전용.)
|
|
1246
2073
|
if (live?.mode === 'live' && live.projector) return this.snapshot(domainId, instanceId)
|
|
1247
2074
|
}
|
|
2075
|
+
/*
|
|
2076
|
+
* **끝에 있는 스냅샷이면 접지 않는다.**
|
|
2077
|
+
*
|
|
2078
|
+
* 멈춘 트윈을 조회할 때마다 저널을 전량 접고 있었다 — 트윈을 하나도 안 돌려도 모델 조회가
|
|
2079
|
+
* 4.6~7.7초였던 이유다(crew-probe 23,731건). 스냅샷은 같은 폴드의 결과이므로, 그 뒤로 이벤트도
|
|
2080
|
+
* 구조 변경도 없다면 **다시 접어 봐야 같은 값**이다.
|
|
2081
|
+
*
|
|
2082
|
+
* 쓰지 않는 조건을 좁게 잡는다: 지금을 물었을 때만(시간여행은 그 시점까지 접어야 한다), 리비전이
|
|
2083
|
+
* 끝과 같을 때만, 구조 리비전까지 같을 때만. 하나라도 어긋나면 접는다 — 캐시가 사실을 이기지 않는다.
|
|
2084
|
+
*/
|
|
2085
|
+
const asOfNowRead = untilRevision == null && untilTime == null
|
|
2086
|
+
const tip = asOfNowRead ? await this.tipOf(domainId, instanceId).catch(() => null) : null
|
|
2087
|
+
/* 한 번만 읽는다 — 끝에 있으면 그대로 쓰고, 아니면 아래에서 **이어 접는 씨앗**으로 쓴다. */
|
|
2088
|
+
const cachedForResume = asOfNowRead && tip ? await this.loadSnapshot(domainId, instanceId).catch(() => null) : null
|
|
2089
|
+
if (cachedForResume) {
|
|
2090
|
+
const state = unwrapState(cachedForResume.state)
|
|
2091
|
+
if (state && (cachedForResume.revision ?? 0) === tip!.revision && (cachedForResume.structureRev ?? null) === tip!.structureRev)
|
|
2092
|
+
return state
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1248
2095
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
1249
|
-
if (!reg?.
|
|
2096
|
+
if (!reg?.model) throw new Error(`twin instance "${instanceId}" not registered (no model to replay)`)
|
|
1250
2097
|
|
|
2098
|
+
/*
|
|
2099
|
+
* ── 재개점에서 **이어 접는다** (2026-08-18) ────────────────────────────
|
|
2100
|
+
*
|
|
2101
|
+
* 우리는 이미 접은 결과를 남기고 있다(`saveFoldedSnapshot`). 그 지점의 **재개점**(리듀서 내부 상태
|
|
2102
|
+
* 전부 + 가동 누적기)이 함께 있으면, 그 뒤에 일어난 것만 접어도 같은 답이 나온다 — 그 동치는
|
|
2103
|
+
* 커널 시험이 증명한다(0부터 접기 == 재개점 + 꼬리).
|
|
2104
|
+
*
|
|
2105
|
+
* 쓰는 조건을 좁게 잡는다: **지금을 물었을 때만**(시간여행은 목표 이전 재개점이 필요한데 지금은
|
|
2106
|
+
* 최신 하나만 남긴다 — 사슬은 다음 단계다), 구조가 그대로일 때만(구조가 바뀌면 그 경계에서 갈라
|
|
2107
|
+
* 접어야 한다), 재개점이 저널 끝보다 앞설 때만. 하나라도 어긋나면 0부터 접는다 — **캐시가 사실을
|
|
2108
|
+
* 이기지 않는다.**
|
|
2109
|
+
*/
|
|
2110
|
+
/*
|
|
2111
|
+
* 씨앗은 둘 중 하나다: **지금**을 물으면 최신 재개점, **과거**를 물으면 사슬에서 목표 직전 지점.
|
|
2112
|
+
*
|
|
2113
|
+
* 과거 씨앗은 구조가 한 번도 바뀌지 않은 트윈에서만 쓴다 — 구조가 갈린 저널은 마디마다 갈아 접어야
|
|
2114
|
+
* 하고, 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다(그때는 0부터 접는 것이 옳다).
|
|
2115
|
+
*/
|
|
2116
|
+
const structureCount = asOfNowRead
|
|
2117
|
+
? 0
|
|
2118
|
+
: await getRepository(TwinStructure).count({ where: { domain: { id: domainId }, instanceId } }).catch(() => 99)
|
|
2119
|
+
const pastSeed =
|
|
2120
|
+
!asOfNowRead && structureCount <= 1
|
|
2121
|
+
? await this.chainSeedFor(domainId, instanceId, {
|
|
2122
|
+
...(untilRevision != null ? { revision: untilRevision } : {}),
|
|
2123
|
+
...(untilTime != null && !Number.isNaN(Date.parse(untilTime)) ? { timeMs: Date.parse(untilTime) } : {})
|
|
2124
|
+
}).catch(() => null)
|
|
2125
|
+
: null
|
|
2126
|
+
const resume =
|
|
2127
|
+
asOfNowRead && tip && cachedForResume && cachedForResume.fold && (cachedForResume.structureRev ?? null) === tip.structureRev
|
|
2128
|
+
? cachedForResume
|
|
2129
|
+
: pastSeed
|
|
2130
|
+
|
|
2131
|
+
/*
|
|
2132
|
+
* **자를 것을 알면서 다 읽어 오지 않는다** (2026-08-18 실측).
|
|
2133
|
+
*
|
|
2134
|
+
* 저널을 전량 읽어 온 뒤 JS 에서 걸렀다. 27만 건짜리 트윈(order-check)에서 한 시간 전 상태를 물으면
|
|
2135
|
+
* **7.4초**가 걸렸고, 그 대부분이 「필요 없는 행을 읽어 오는 시간」이었다. 시각·리비전 상한은 SQL 이
|
|
2136
|
+
* 아는 조건이므로 거기서 자른다(TypeORM 관용구만 — 원시 SQL 은 5개 드라이버에서 갈라진다).
|
|
2137
|
+
*
|
|
2138
|
+
* 시각 상한에는 **경계를 그대로** 쓴다(`<=`): 그 시각에 일어난 사실은 그 시각의 화면에 있어야 한다.
|
|
2139
|
+
* 시각이 없는 레거시 행은 보수적으로 포함해 왔는데, SQL 로 자르면 그 행들이 빠진다 — 그래서 시각
|
|
2140
|
+
* 조건일 때는 **시각이 비어 있는 행도 함께** 가져와 예전과 같은 결과를 낸다.
|
|
2141
|
+
*/
|
|
2142
|
+
const cutoffMs = untilTime != null ? Date.parse(untilTime) : NaN
|
|
2143
|
+
const useTime = untilTime != null && !Number.isNaN(cutoffMs)
|
|
2144
|
+
const baseWhere = { domain: { id: domainId }, instanceId }
|
|
2145
|
+
/* 이어 접을 때는 **그 뒤만** 읽는다 — 재개점까지의 사실은 이미 씨앗 안에 있다. */
|
|
2146
|
+
const from = resume ? (resume.revision ?? 0) : undefined
|
|
1251
2147
|
const rows = await getRepository(TwinEvent).find({
|
|
1252
|
-
where:
|
|
2148
|
+
where: useTime
|
|
2149
|
+
? from
|
|
2150
|
+
? /* 씨앗 뒤 · 목표 이내 — 시각이 빈 옛 행은 씨앗 뒤에 있는 것만 포함한다(보수적 포함을 유지). */
|
|
2151
|
+
[
|
|
2152
|
+
{ ...baseWhere, revision: MoreThan(from), eventTime: LessThanOrEqual(new Date(cutoffMs)) },
|
|
2153
|
+
{ ...baseWhere, revision: MoreThan(from), eventTime: IsNull() }
|
|
2154
|
+
]
|
|
2155
|
+
: [
|
|
2156
|
+
{ ...baseWhere, eventTime: LessThanOrEqual(new Date(cutoffMs)) },
|
|
2157
|
+
{ ...baseWhere, eventTime: IsNull() }
|
|
2158
|
+
]
|
|
2159
|
+
: untilRevision != null
|
|
2160
|
+
? from
|
|
2161
|
+
? { ...baseWhere, revision: And(MoreThan(from), LessThanOrEqual(untilRevision)) }
|
|
2162
|
+
: { ...baseWhere, revision: LessThanOrEqual(untilRevision) }
|
|
2163
|
+
: from
|
|
2164
|
+
? { ...baseWhere, revision: MoreThan(from) }
|
|
2165
|
+
: baseWhere,
|
|
1253
2166
|
order: { revision: 'ASC' }
|
|
1254
2167
|
})
|
|
1255
|
-
const
|
|
1256
|
-
const wanted = rows.filter(r => {
|
|
1257
|
-
if (untilTime != null && !Number.isNaN(cutoffMs)) {
|
|
1258
|
-
const t = r.eventTime != null ? Date.parse(String(r.eventTime)) : NaN
|
|
1259
|
-
return Number.isNaN(t) ? true : t <= cutoffMs // eventTime 없는 레거시 행은 포함(보수적)
|
|
1260
|
-
}
|
|
1261
|
-
return untilRevision == null || (r.revision ?? 0) <= untilRevision
|
|
1262
|
-
})
|
|
2168
|
+
const wanted = rows
|
|
1263
2169
|
|
|
1264
2170
|
/*
|
|
1265
2171
|
* **그때의 공장으로 접는다.**
|
|
@@ -1275,19 +2181,19 @@ export class TwinEngine {
|
|
|
1275
2181
|
where: { domain: { id: domainId }, instanceId },
|
|
1276
2182
|
order: { rev: 'ASC' }
|
|
1277
2183
|
})
|
|
1278
|
-
const boardOf = new Map<number,
|
|
2184
|
+
const boardOf = new Map<number, TwinModelDef>(structures.map(x => [x.rev, x.model as TwinModelDef]))
|
|
1279
2185
|
/* 컬럼이 생기기 전 행은 리비전이 비어 있다 — **가장 오래된 구조**에 속한다(0 으로 채우지 않는다). */
|
|
1280
2186
|
const oldest = structures[0]?.rev
|
|
1281
2187
|
const revOf = (r: { structureRev?: number }): number | undefined => r.structureRev ?? oldest
|
|
1282
2188
|
|
|
1283
|
-
const segments: {
|
|
2189
|
+
const segments: { model: TwinModelDef; events: any[] }[] = []
|
|
1284
2190
|
for (const r of wanted) {
|
|
1285
2191
|
if (!r.payload) continue
|
|
1286
2192
|
const rev = revOf(r)
|
|
1287
|
-
const
|
|
2193
|
+
const model = (rev !== undefined && boardOf.get(rev)) || (reg.model as TwinModelDef)
|
|
1288
2194
|
const last = segments[segments.length - 1]
|
|
1289
|
-
if (last && last.
|
|
1290
|
-
else segments.push({
|
|
2195
|
+
if (last && last.model === model) last.events.push(r.payload)
|
|
2196
|
+
else segments.push({ model, events: [r.payload] })
|
|
1291
2197
|
}
|
|
1292
2198
|
/*
|
|
1293
2199
|
* **가장 새 구조가 지금의 공장이다** — 그 아래에서 아직 아무 일도 없었더라도.
|
|
@@ -1300,29 +2206,29 @@ export class TwinEngine {
|
|
|
1300
2206
|
* 설비가 화면에 서고 — 이 작업이 막으려던 바로 그 거짓말이 된다.
|
|
1301
2207
|
*/
|
|
1302
2208
|
const asOfNow = untilRevision == null && untilTime == null
|
|
1303
|
-
const newest = structures[structures.length - 1]?.
|
|
1304
|
-
if (asOfNow && newest && segments[segments.length - 1]?.
|
|
2209
|
+
const newest = structures[structures.length - 1]?.model as TwinModelDef | undefined
|
|
2210
|
+
if (asOfNow && newest && segments[segments.length - 1]?.model !== newest) segments.push({ model: newest, events: [] })
|
|
1305
2211
|
|
|
1306
2212
|
/*
|
|
1307
2213
|
* 접은 상태에 **주의 신호와 시각을 채운다.**
|
|
1308
2214
|
*
|
|
1309
2215
|
* 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 접는 이 경로는 프로젝터 상태만 낸다 — 신호도
|
|
1310
|
-
* `nowTime` 도 없다. 그래서 **과거를
|
|
2216
|
+
* `nowTime` 도 없다. 그래서 **과거를 다시 계산하면 주의 레일이 텅 비었고**(지도는 `snap.attentions` 를
|
|
1311
2217
|
* 읽는다), 기동돼 있지 않은 트윈을 보는 화면도 같았다. 신호는 상태에서 계산되는 것이므로
|
|
1312
2218
|
* 여기서 같은 공식(`deriveAttentions`)으로 채우면 된다 — 두 벌을 두지 않는다.
|
|
1313
2219
|
*
|
|
1314
|
-
* `nowTime` 이 먼저다: "늦었나" 판정이 그 값을 본다.
|
|
2220
|
+
* `nowTime` 이 먼저다: "늦었나" 판정이 그 값을 본다. 다시 계산한 시점의 정직한 "지금" 은 물어본 시각
|
|
1315
2221
|
* (`untilTime`)이고, 없으면 마지막으로 적용한 이벤트의 시각이다. 둘 다 없으면 채우지 않는다 —
|
|
1316
2222
|
* 벽시계를 끼워 넣으면 과거 화면이 "지금 기준으로 늦었다" 고 말하게 된다.
|
|
1317
2223
|
*/
|
|
1318
|
-
const lastEventTime = rows.length ?
|
|
2224
|
+
const lastEventTime = rows.length ? (rows[rows.length - 1]?.eventTime?.toISOString() ?? '') : ''
|
|
1319
2225
|
|
|
1320
2226
|
/*
|
|
1321
|
-
* **가동 이력도 되살린다** —
|
|
2227
|
+
* **가동 이력도 되살린다** — 다시 계산한 화면에 설비 계측이 비어 있던 것.
|
|
1322
2228
|
*
|
|
1323
2229
|
* OEE 는 원 시스템이 누적을 보내 주지 않아 호스트가 상태 전이를 적분해 만든다(그래서 커널이 아니라
|
|
1324
2230
|
* 여기 있다). 그런데 그 누적기는 **라이브에서만** 돌았고, 저널을 접는 경로는 그 계산을 하지 않았다 —
|
|
1325
|
-
* 과거를
|
|
2231
|
+
* 과거를 다시 계산하면 모든 설비가 "가동 이력이 전혀 없음" 으로 보였다.
|
|
1326
2232
|
*
|
|
1327
2233
|
* 없는 것은 데이터가 아니라 계산이다: 입력(`equipment.status` 전이·`quality.output`)은 저널에 다
|
|
1328
2234
|
* 있다. 그래서 **같은 누적기에 같은 이벤트를 태운다** — 규칙을 두 벌 만들지 않는다.
|
|
@@ -1331,6 +2237,8 @@ export class TwinEngine {
|
|
|
1331
2237
|
* 가동 이력이 나온다(그 뒤에 일어난 고장이 과거 화면에 섞이지 않는다).
|
|
1332
2238
|
*/
|
|
1333
2239
|
const oee = new OeeAccumulator()
|
|
2240
|
+
/* 재개점이 있으면 그 위에 꼬리만 얹는다 — 없으면 꼬리분만 세어 가용률이 조용히 작아진다. */
|
|
2241
|
+
if (resume?.fold?.oee) oee.restore(resume.fold.oee)
|
|
1334
2242
|
for (const r of wanted) {
|
|
1335
2243
|
try {
|
|
1336
2244
|
oee.apply(r.payload as any)
|
|
@@ -1349,15 +2257,68 @@ export class TwinEngine {
|
|
|
1349
2257
|
return withLiveAttentions(withMetrics)
|
|
1350
2258
|
}
|
|
1351
2259
|
|
|
1352
|
-
|
|
1353
|
-
|
|
2260
|
+
/* 접은 결과를 남긴다 — **지금을 물었을 때만**(시간여행 결과를 "지금" 으로 저장하면 거짓이 된다). */
|
|
2261
|
+
/*
|
|
2262
|
+
* 접은 지점의 리비전 — 사슬에 적을 이름이다. 꼬리를 접었으면 그 꼬리의 끝, 아무것도 안 읽었으면
|
|
2263
|
+
* 씨앗의 자리 그대로다(모르면 0).
|
|
2264
|
+
*/
|
|
2265
|
+
const foldedTo = rows.length ? (rows[rows.length - 1]?.revision ?? 0) : (from ?? 0)
|
|
2266
|
+
|
|
2267
|
+
const keep = (st: any, fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint }): any => {
|
|
2268
|
+
/* 재개점을 함께 남긴다 — 상태만 남기면 다음 번에 또 0부터 접어야 한다(그것이 이 작업의 요점이다).
|
|
2269
|
+
**구조가 갈린 폴드에는 재개점을 붙이지 않는다**: 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다. */
|
|
2270
|
+
if (asOfNowRead && tip && st) {
|
|
2271
|
+
void this.saveFoldedSnapshot(domainId, instanceId, { revision: tip.revision, state: st, structureRev: tip.structureRev, fold })
|
|
2272
|
+
}
|
|
2273
|
+
/*
|
|
2274
|
+
* 사슬에는 **과거를 접었을 때도** 한 지점을 남긴다 (2026-08-18 실측으로 고침).
|
|
2275
|
+
*
|
|
2276
|
+
* 처음에는 「지금 읽기」에서만 남겼다. 그런데 도는 트윈의 지금 읽기는 커널 스냅샷으로 즉시 답하고
|
|
2277
|
+
* 폴드에 닿지 않는다 — 그래서 사슬이 **영원히 비어 있었다**(실측: 지점 0개, 시간여행 3.7s 그대로).
|
|
2278
|
+
*
|
|
2279
|
+
* 지점은 「지금」을 주장하지 않는다: 자기 리비전과 시각을 달고 있으므로 과거 폴드의 결과를 남겨도
|
|
2280
|
+
* 거짓이 아니다(그것이 스냅샷과 다른 점이다). 그래서 첫 과거 조회가 다음 과거 조회를 빠르게 한다.
|
|
2281
|
+
*/
|
|
2282
|
+
if (fold && st) {
|
|
2283
|
+
const at = asOfNowRead && tip ? tip.revision : foldedTo
|
|
2284
|
+
if (at > 0)
|
|
2285
|
+
void this.keepChainPoint(domainId, instanceId, {
|
|
2286
|
+
revision: at,
|
|
2287
|
+
...(lastEventTime ? { eventTime: lastEventTime } : {}),
|
|
2288
|
+
structureRev: (asOfNowRead && tip ? tip.structureRev : null) ?? null,
|
|
2289
|
+
fold
|
|
2290
|
+
}).catch(() => {})
|
|
2291
|
+
}
|
|
2292
|
+
return st
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/*
|
|
2296
|
+
* 씨앗이 있으면 **이어 접는다** — 한 구조 안에서만(구조가 갈리면 아래 마디 경로가 맡는다).
|
|
2297
|
+
* 새 재개점도 함께 남긴다: 다음 번에 또 꼬리만 접을 수 있어야 이 지름길이 계속 산다.
|
|
2298
|
+
*/
|
|
2299
|
+
if (resume?.fold?.reducer && segments.length <= 1) {
|
|
2300
|
+
const model = (segments[0]?.model ?? (asOfNow && newest) ?? reg.model) as TwinModelDef
|
|
2301
|
+
const out = replayFrom(model, resume.fold.reducer, segments[0]?.events ?? [])
|
|
2302
|
+
return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() })
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
if (!segments.length) {
|
|
2306
|
+
const model = ((asOfNow && newest) || reg.model) as TwinModelDef
|
|
2307
|
+
const out = replayWithCheckpoint(model, [])
|
|
2308
|
+
return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() })
|
|
2309
|
+
}
|
|
2310
|
+
if (segments.length === 1) {
|
|
2311
|
+
const out = replayWithCheckpoint(segments[0].model, segments[0].events)
|
|
2312
|
+
return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() })
|
|
2313
|
+
}
|
|
1354
2314
|
|
|
2315
|
+
/* 커널 계약도 `model` 이다(0.6.14) — 경계에서 어휘를 되돌려 담던 브릿지가 사라졌다. */
|
|
1355
2316
|
const { state, shifts } = replaySegments(segments)
|
|
1356
2317
|
/* 경계에서 사라진 것을 조용히 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */
|
|
1357
2318
|
for (const sh of shifts)
|
|
1358
2319
|
if (sh.equipmentDropped || sh.locationsDropped || sh.personsDropped || sh.assetsDropped)
|
|
1359
2320
|
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.`)
|
|
1360
|
-
return withNow(state)
|
|
2321
|
+
return keep(withNow(state))
|
|
1361
2322
|
}
|
|
1362
2323
|
|
|
1363
2324
|
/**
|
|
@@ -1392,6 +2353,15 @@ export class TwinEngine {
|
|
|
1392
2353
|
static async stop(domainId: string, id: string): Promise<void> {
|
|
1393
2354
|
const i = this.instances[runtimeKey(domainId, id)]
|
|
1394
2355
|
if (i) {
|
|
2356
|
+
/*
|
|
2357
|
+
* **끄기 전에 남긴다** — 지금이 메모리가 진실인 마지막 순간이다.
|
|
2358
|
+
*
|
|
2359
|
+
* 남기지 않으면 다음 조회가 저널을 전량 다시 접는다(그 값은 어차피 방금 메모리에 있던 것이다).
|
|
2360
|
+
* 체크포인트 루프가 20초마다 뜨지만 그 사이에 멈추면 그 구간이 통째로 다시 접힌다.
|
|
2361
|
+
*/
|
|
2362
|
+
await this.persistSnapshot(domainId, id).catch(err =>
|
|
2363
|
+
console.error(`[twin-engine] snapshot on stop fail "${id}"`, err?.message ?? err)
|
|
2364
|
+
)
|
|
1395
2365
|
clearInterval(i.timer)
|
|
1396
2366
|
i.unsub()
|
|
1397
2367
|
delete this.instances[runtimeKey(domainId, id)]
|
|
@@ -1410,25 +2380,125 @@ export class TwinEngine {
|
|
|
1410
2380
|
* 선언이 모든 테넌트를 멈춘 셈이다. 문 앞에서 막는 것이 1차 방벽이고(`validateScenario`),
|
|
1411
2381
|
* 이것이 2차 방벽이다.
|
|
1412
2382
|
*
|
|
1413
|
-
* **조용히 삼키지 않는다.**
|
|
2383
|
+
* **조용히 삼키지 않는다.** 실행을 멈추고 그 사실을 남긴다 — 예외를 무시하고 계속 tick 하면
|
|
1414
2384
|
* 같은 오류가 매 주기 쏟아지고, 그 트윈은 "도는 것처럼 보이면서" 아무것도 진행하지 않는다.
|
|
1415
2385
|
*/
|
|
2386
|
+
/**
|
|
2387
|
+
* 같은 현장 트윈들의 **설비 상태**를 커널에 넘긴다 — 커널이 요청할 때만.
|
|
2388
|
+
*
|
|
2389
|
+
* ── 경계 (2026-08-14) ──────────────────────────────────────────────────────
|
|
2390
|
+
* 호스트가 아는 것은 **누가 같은 현장에 있는가** 하나다. 무엇을 읽고 그것을 어떻게 부하로 바꿀지는
|
|
2391
|
+
* 커널이 정한다(계수는 트윈의 모델이 선언한다). 호스트가 kW 를 계산해 주면 트윈마다 다른 규칙이
|
|
2392
|
+
* 생기고, 그 규칙은 어디에도 적혀 있지 않게 된다.
|
|
2393
|
+
*
|
|
2394
|
+
* 상태만 넘긴다. 스냅샷을 통째로 넘기면 상태 크기에 비례한 비용을 매 tick 치르게 된다 —
|
|
2395
|
+
* 설비 목록은 그 트윈의 커널이 이미 들고 있는 것이라 훑는 값이 싸다.
|
|
2396
|
+
*/
|
|
2397
|
+
private static feedPeerEquipment(inst: InstanceRuntime): void {
|
|
2398
|
+
const kernel: any = inst.kernel
|
|
2399
|
+
if (typeof kernel?.observePeerEquipment !== 'function') return // 이웃을 읽지 않는 커널
|
|
2400
|
+
if (!inst.spaceId) {
|
|
2401
|
+
kernel.observePeerEquipment([]) // 현장이 없으면 이웃도 없다(옛 상태가 남지 않게 비운다)
|
|
2402
|
+
return
|
|
2403
|
+
}
|
|
2404
|
+
const peers: { id: string; status?: string }[] = []
|
|
2405
|
+
for (const other of Object.values(this.instances)) {
|
|
2406
|
+
if (other === inst || other.domainId !== inst.domainId || other.spaceId !== inst.spaceId) continue
|
|
2407
|
+
const eq: Map<string, any> | undefined = (other.kernel as any)?.equipment
|
|
2408
|
+
if (!eq || typeof eq.forEach !== 'function') continue
|
|
2409
|
+
eq.forEach(e => peers.push({ id: String(e?.id ?? ''), status: String(e?.status ?? '') }))
|
|
2410
|
+
}
|
|
2411
|
+
kernel.observePeerEquipment(peers)
|
|
2412
|
+
}
|
|
2413
|
+
|
|
1416
2414
|
private static tickGuarded(domainId: string, id: string, runtime: TwinRuntimeType): void {
|
|
2415
|
+
const inst = this.instances[runtimeKey(domainId, id)]
|
|
2416
|
+
const load = inst && (inst.load ?? (inst.load = newLoadMeter()))
|
|
2417
|
+
const t0 = performance.now()
|
|
1417
2418
|
try {
|
|
2419
|
+
/* 이웃의 설비 상태를 먼저 싣는다 — 커널이 이번 tick 에서 부하를 만들 때 읽는다. */
|
|
2420
|
+
if (inst) this.feedPeerEquipment(inst)
|
|
1418
2421
|
runtime.tick(this.TICK_MS)
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
2422
|
+
|
|
2423
|
+
/* 재고 말한다 — 시뮬 틱은 메인 이벤트 루프에서 수행된다. 예산(간격의 절반)을 넘기면 HTTP·구독이
|
|
2424
|
+
함께 느려지는데, 예전에는 그것을 볼 계기판이 없어 "터지면 알고 느려지면 몰랐다". */
|
|
2425
|
+
if (load) {
|
|
2426
|
+
const took = performance.now() - t0
|
|
2427
|
+
recordPhase(load, 'tick', took)
|
|
2428
|
+
const j = judgeCycle(load, took, this.TICK_MS, Date.now())
|
|
2429
|
+
if (j.warn) console.warn(slowTickMessage(id, took, j.budgetMs, load))
|
|
2430
|
+
this.guardStarvation(domainId, id, took)
|
|
1424
2431
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
2432
|
+
} catch (err: any) {
|
|
2433
|
+
/*
|
|
2434
|
+
* 멈추는 길은 **하나**다(`stop`) — 예전에는 타이머만 껐다. 그러면 등록부는 계속 `running` 이라
|
|
2435
|
+
* 말하고(아무 틱도 없는 채로), 부팅 재개는 그 거짓을 근거로 다시 세운다. 스냅샷 보존·피드 정리·
|
|
2436
|
+
* 등록부 갱신이 모두 `stop` 안에 있다.
|
|
2437
|
+
*/
|
|
2438
|
+
this.stopWithNote(
|
|
2439
|
+
domainId,
|
|
2440
|
+
id,
|
|
2441
|
+
{ code: 'tick-failed', params: { reason: String(err?.message ?? err) } },
|
|
2442
|
+
`[twin-engine] "${id}" tick failed — this twin is stopped (other twins keep running). ` +
|
|
1427
2443
|
`Fix the declaration and start it again. Reason: ${err?.message ?? err}`
|
|
1428
2444
|
)
|
|
1429
2445
|
}
|
|
1430
2446
|
}
|
|
1431
2447
|
|
|
2448
|
+
/**
|
|
2449
|
+
* 호스트를 굶기는 트윈을 멈춘다 — **도는 척하는 것보다 멈춘 것이 낫다.**
|
|
2450
|
+
*
|
|
2451
|
+
* 굶김 문턱을 넘는 틱이 연속 `STARVE_STREAK` 번이면 그 트윈은 구조적으로 무겁다(우연이 아니다).
|
|
2452
|
+
* 문턱 아래로 한 번만 내려와도 연속을 끊는다 — 무거운 순간 하나로 트윈을 내리지 않는다.
|
|
2453
|
+
*/
|
|
2454
|
+
private static guardStarvation(domainId: string, id: string, tookMs: number): void {
|
|
2455
|
+
const key = runtimeKey(domainId, id)
|
|
2456
|
+
const starveMs = this.TICK_MS * this.STARVE_FACTOR
|
|
2457
|
+
if (!(tookMs >= starveMs)) {
|
|
2458
|
+
this.starveStreak.delete(key)
|
|
2459
|
+
return
|
|
2460
|
+
}
|
|
2461
|
+
const streak = (this.starveStreak.get(key) ?? 0) + 1
|
|
2462
|
+
this.starveStreak.set(key, streak)
|
|
2463
|
+
if (streak < this.STARVE_STREAK) return
|
|
2464
|
+
|
|
2465
|
+
this.starveStreak.delete(key)
|
|
2466
|
+
const secs = Math.round(tookMs) / 1000
|
|
2467
|
+
this.stopWithNote(
|
|
2468
|
+
domainId,
|
|
2469
|
+
id,
|
|
2470
|
+
{ code: 'starved', params: { streak, thresholdSec: starveMs / 1000, lastSec: secs } },
|
|
2471
|
+
`[twin-engine] "${id}" stopped — ${streak} consecutive ticks over ${starveMs / 1000}s (last ${secs}s). ` +
|
|
2472
|
+
`A simulation tick runs on the main loop, so this twin was stalling HTTP, subscriptions and every other twin. ` +
|
|
2473
|
+
`Its state is checkpointed; start it again after making it lighter (fewer orders/resources) or wait for off-loop ticking.`
|
|
2474
|
+
)
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** 멈추고 **이유를 남긴다** — 이유 없는 「정지」는 사람이 자기가 멈춘 것으로 읽는다. */
|
|
2478
|
+
private static stopWithNote(domainId: string, id: string, note: StopNote, log: string): void {
|
|
2479
|
+
console.error(log)
|
|
2480
|
+
this.stopNotes.set(runtimeKey(domainId, id), note)
|
|
2481
|
+
this.stop(domainId, id).catch(err => console.error(`[twin-engine] stop after guard failed "${id}"`, err?.message ?? err))
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/** 이 트윈이 스스로 멈춘 이유(있으면) — 화면이 옮겨 말한다. */
|
|
2485
|
+
static stopNoteOf(domainId: string, id: string): StopNote | undefined {
|
|
2486
|
+
return this.stopNotes.get(runtimeKey(domainId, id))
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* 이 트윈이 **지금 실행 중인가** — 판정의 집 하나.
|
|
2491
|
+
*
|
|
2492
|
+
* "스냅샷이 있나" 로는 답이 되지 않는다: 웜스타트가 되살린 상태가 캐시에 남아 있으면 멈춘 트윈도
|
|
2493
|
+
* 스냅샷을 낸다. 그것을 관측 중으로 읽으면 화면이 **멈춘 트윈의 작업을 0 건이라고 단언**한다
|
|
2494
|
+
* (실제로 그렇게 나왔다 — 관측이 없는 것과 0 건은 다르다는 이 프로젝트의 규율을 화면이 어겼다).
|
|
2495
|
+
*
|
|
2496
|
+
* 목록이 쓰는 것과 **같은 술어**다(`list()` 의 `running`) — 두 벌이면 어긋난다.
|
|
2497
|
+
*/
|
|
2498
|
+
static isRunning(domainId: string, id: string): boolean {
|
|
2499
|
+
return !!this.instances[runtimeKey(domainId, id)]
|
|
2500
|
+
}
|
|
2501
|
+
|
|
1432
2502
|
static runtime(domainId: string, id: string): TwinRuntimeType | undefined {
|
|
1433
2503
|
return this.instances[runtimeKey(domainId, id)]?.runtime
|
|
1434
2504
|
}
|
|
@@ -1482,7 +2552,7 @@ export class TwinEngine {
|
|
|
1482
2552
|
* 검증 없이 접근하면 크로스테넌트 읽기/정지가 가능해진다.
|
|
1483
2553
|
*/
|
|
1484
2554
|
/**
|
|
1485
|
-
* 이 런타임의 **관측 모드** — `'live'`(외부 실물을 미러) 또는 `'sim'`(커널이
|
|
2555
|
+
* 이 런타임의 **관측 모드** — `'live'`(외부 실물을 미러) 또는 `'sim'`(커널이 실행한다). 기동 중이 아니면 없다.
|
|
1486
2556
|
*
|
|
1487
2557
|
* 소비처가 `instances[id].mode` 를 직접 읽던 자리를 대신한다. 레지스트리를 밖에 열면 도메인 확인이
|
|
1488
2558
|
* 자리마다 제각각이 되고, 실제로 그렇게 됐다(테넌트 격리 전수 확인, 2026-08-06).
|
|
@@ -1498,10 +2568,10 @@ export class TwinEngine {
|
|
|
1498
2568
|
/**
|
|
1499
2569
|
* 이 트윈이 이 테넌트 것인가 — **떠 있든 아니든.**
|
|
1500
2570
|
*
|
|
1501
|
-
* `owns()` 는
|
|
2571
|
+
* `owns()` 는 **기동 중인** 런타임만 안다. 그것을 이력 조회의 관문으로 쓰면, 꺼진 트윈의 저널을
|
|
1502
2572
|
* 읽으려 할 때 "이 테넌트에 없다" 는 답이 돌아온다 — 두 가지가 틀렸다. 첫째, 저널은 트윈이 꺼져
|
|
1503
2573
|
* 있을 때 **가장 필요한 것**이다(그게 이력의 존재 이유다). 둘째, 그 문장은 남의 것이라는 뜻이라
|
|
1504
|
-
* 사용자가 권한 문제로 오해한다. 실제로는 그냥
|
|
2574
|
+
* 사용자가 권한 문제로 오해한다. 실제로는 그냥 실행 중이 아닐 뿐이다.
|
|
1505
2575
|
*
|
|
1506
2576
|
* 소유는 **등록부**가 안다. 이력·집계처럼 런타임과 무관한 질문은 이쪽에 묻는다.
|
|
1507
2577
|
*/
|
|
@@ -1561,10 +2631,76 @@ export class TwinEngine {
|
|
|
1561
2631
|
instanceId: id,
|
|
1562
2632
|
ingestedTotal: m.ingestedTotal, broadcastTotal: m.broadcastTotal, journaledTotal: m.journaledTotal,
|
|
1563
2633
|
ingestRate: m.ingestRate, broadcastRate: m.broadcastRate, journalRate: m.journalRate,
|
|
1564
|
-
backlog: m.backlog, broadcastCoalesceMs: this.BROADCAST_COALESCE_MS
|
|
2634
|
+
backlog: m.backlog, broadcastCoalesceMs: this.BROADCAST_COALESCE_MS,
|
|
2635
|
+
/* 작업별 부하 — 무거운 것부터. 잰 적이 없으면 null(0 으로 채우면 "빠르다" 로 읽힌다). */
|
|
2636
|
+
load: loadSummary(this.instances[runtimeKey(domainId, id)]?.load, this.TICK_MS)
|
|
1565
2637
|
}
|
|
1566
2638
|
}
|
|
1567
2639
|
|
|
2640
|
+
/**
|
|
2641
|
+
* 부하 계기판만 따로 — **시뮬 트윈도 포함한다.**
|
|
2642
|
+
*
|
|
2643
|
+
* `metrics()` 는 라이브 지표(`inst.metrics`)가 있어야 무언가를 돌려준다. 그런데 이벤트 루프를 점유하는
|
|
2644
|
+
* 것은 주로 **시뮬 틱**이고, 시뮬 인스턴스에는 그 지표가 없어 통째로 안 보였다.
|
|
2645
|
+
*/
|
|
2646
|
+
static load(domainId: string, id: string): any {
|
|
2647
|
+
const inst = this.instances[runtimeKey(domainId, id)]
|
|
2648
|
+
if (!inst) return null
|
|
2649
|
+
|
|
2650
|
+
return {
|
|
2651
|
+
instanceId: id,
|
|
2652
|
+
mode: inst.mode ?? 'sim',
|
|
2653
|
+
domainId: inst.domainId,
|
|
2654
|
+
domainLabel: inst.domain?.subdomain,
|
|
2655
|
+
realityMode: inst.realityMode,
|
|
2656
|
+
tickMs: this.TICK_MS,
|
|
2657
|
+
running: !!inst.timer || inst.mode === 'live',
|
|
2658
|
+
...(loadSummary(inst.load, this.TICK_MS) ?? { recent: null, total: null, forksCreated: 0, overBudget: 0, budgetMs: this.TICK_MS * 0.5, loadRatio: null })
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/**
|
|
2663
|
+
* **한 곳에서 보는 전체 부하** — 몇 개가 실행 중이고, 누가 루프를 점유하고 있나.
|
|
2664
|
+
*
|
|
2665
|
+
* 시뮬 트윈도 포함한다(`metrics()` 는 라이브 지표가 있어야 답해서 시뮬이 통째로 안 보였다).
|
|
2666
|
+
*/
|
|
2667
|
+
static fleet(domainId?: string): any {
|
|
2668
|
+
const keys = Object.keys(this.instances).filter(key => !domainId || isOfDomain(key, domainId))
|
|
2669
|
+
const rows = keys.map(key => {
|
|
2670
|
+
const at = parseRuntimeKey(key)
|
|
2671
|
+
const inst = this.instances[key]
|
|
2672
|
+
/* 도메인을 함께 넘긴다 — 운영자가 먼저 묻는 것은 "어느 테넌트가 루프를 먹나" 다.
|
|
2673
|
+
읽을 수 있는 이름(subdomain)은 라이브 인스턴스에만 붙어 있으므로, 없으면 리졸버가 채운다. */
|
|
2674
|
+
return {
|
|
2675
|
+
instanceId: at.instanceId,
|
|
2676
|
+
mode: inst.mode ?? 'sim',
|
|
2677
|
+
domainId: at.domainId,
|
|
2678
|
+
domainLabel: inst.domain?.subdomain,
|
|
2679
|
+
meter: inst.load
|
|
2680
|
+
}
|
|
2681
|
+
})
|
|
2682
|
+
|
|
2683
|
+
return {
|
|
2684
|
+
tickMs: this.TICK_MS,
|
|
2685
|
+
broadcastCoalesceMs: this.BROADCAST_COALESCE_MS,
|
|
2686
|
+
...fleetLoad(rows, this.TICK_MS),
|
|
2687
|
+
/* 줄마다 상세 — 화면이 펼쳐 볼 수 있게. 최근 부하 순서는 fleetLoad 가 정한다. */
|
|
2688
|
+
details: keys.map(key => { const at = parseRuntimeKey(key); return this.load(at.domainId, at.instanceId) }).filter(Boolean)
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/** 이 트윈에서 fork 가 만들어졌다 — 예측 한 번이 수십 개를 만든다. */
|
|
2693
|
+
static countForks(domainId: string, id: string, count: number): void {
|
|
2694
|
+
const inst = this.instances[runtimeKey(domainId, id)]
|
|
2695
|
+
if (inst) recordFork(inst.load ?? (inst.load = newLoadMeter()), count)
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/** 엔진 밖(예측 질의 등)에서 무거운 작업을 잰다 — 같은 계기판에 모인다. */
|
|
2699
|
+
static recordLoad(domainId: string, id: string, phase: LoadPhase, tookMs: number): void {
|
|
2700
|
+
const inst = this.instances[runtimeKey(domainId, id)]
|
|
2701
|
+
if (inst) recordPhase(inst.load ?? (inst.load = newLoadMeter()), phase, tookMs)
|
|
2702
|
+
}
|
|
2703
|
+
|
|
1568
2704
|
/** 전체 라이브 인스턴스 계측(모니터 대시보드용). */
|
|
1569
2705
|
static async allMetrics(domainId?: string): Promise<any[]> {
|
|
1570
2706
|
/* 도메인 없이 부르면 전 테넌트를 훑는다(내부 모니터용) — 키에서 도메인을 되돌려 각자에게 묻는다. */
|
|
@@ -1579,10 +2715,14 @@ export class TwinEngine {
|
|
|
1579
2715
|
// 표시 이름 부여 — 실행중 카드가 id 만 보이지 않도록 공간명을 실어준다(공간 카드와 동일). name=공간명 폴백 spaceId 폴백 instanceId.
|
|
1580
2716
|
const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
|
|
1581
2717
|
const spaceOf = new Map(insts.map(i => [i.instanceId, i.spaceId]))
|
|
2718
|
+
/* 용도 선언도 함께 실어 보낸다 — 화면이 공간 이름으로 벤치를 **추측하지 않게** 한다.
|
|
2719
|
+
추측하면 `loadtest-` 로 이름 지은 운영 트윈에 벤치 딱지가 붙는다. */
|
|
2720
|
+
const purposeOf = new Map(insts.map(i => [i.instanceId, i.purpose]))
|
|
1582
2721
|
const nameOf = new Map((await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name]))
|
|
1583
2722
|
for (const r of rows) {
|
|
1584
2723
|
const sp = spaceOf.get(r.instanceId)
|
|
1585
2724
|
r.spaceId = sp
|
|
2725
|
+
r.purpose = purposeOf.get(r.instanceId)
|
|
1586
2726
|
r.name = (sp && nameOf.get(sp)) || sp || r.instanceId
|
|
1587
2727
|
}
|
|
1588
2728
|
return rows
|
|
@@ -1600,24 +2740,24 @@ export class TwinEngine {
|
|
|
1600
2740
|
|
|
1601
2741
|
/**
|
|
1602
2742
|
* 예측용 커널을 **임의 시각 T 기준**으로 재구성 — 과거-vantage 예측(백테스트)·"그때 서서 본 미래".
|
|
1603
|
-
* recover(untilTime)로 T 시점 상태를, T 이하 오더 관측을 모아 hydrate → monteCarloForecast 가 T 에서 앞으로
|
|
2743
|
+
* recover(untilTime)로 T 시점 상태를, T 이하 오더 관측을 모아 hydrate → monteCarloForecast 가 T 에서 앞으로 실행한다.
|
|
1604
2744
|
* sim/live 무관(저널 기반 재구성). untilTime 생략 시 최신. 도메인 스코프(reg·journal 조회가 domainId).
|
|
1605
2745
|
*/
|
|
1606
2746
|
static async buildForecastKernelAt(domainId: string, instanceId: string, untilTime?: string): Promise<any | null> {
|
|
1607
2747
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
1608
|
-
if (!reg?.
|
|
1609
|
-
const Kernel =
|
|
1610
|
-
const k = new Kernel(domainId, undefined, this.productionSpecOf(reg.
|
|
1611
|
-
k.
|
|
1612
|
-
this.applyOperations(k, reg.
|
|
1613
|
-
await this.installEstimators(k, domainId, instanceId, reg.
|
|
2748
|
+
if (!reg?.model) return null
|
|
2749
|
+
const Kernel = kernelFor(reg.kind)
|
|
2750
|
+
const k = new Kernel(domainId, undefined, this.productionSpecOf(reg.model as TwinModelDef))
|
|
2751
|
+
k.loadTwinModel(reg.model as TwinModelDef)
|
|
2752
|
+
this.applyOperations(k, reg.model, instanceId) // 예측도 같은 명세로 굴러야 한다(화면과 다른 숫자 금지)
|
|
2753
|
+
await this.installEstimators(k, domainId, instanceId, reg.model) // 예측은 기다린다 — 실측을 놓치면 예측이 상수로 계산된다
|
|
1614
2754
|
const state = await this.recover(domainId, instanceId, undefined, untilTime).catch(() => null)
|
|
1615
2755
|
if (!state) return null
|
|
1616
2756
|
const cutoff = untilTime != null ? Date.parse(untilTime) : Infinity
|
|
1617
2757
|
const rows = await getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order }, order: { revision: 'ASC' } })
|
|
1618
2758
|
const latest = new Map<string, any>()
|
|
1619
2759
|
for (const r of rows) {
|
|
1620
|
-
if (Number.isFinite(cutoff) &&
|
|
2760
|
+
if (Number.isFinite(cutoff) && (r.eventTime?.getTime() ?? NaN) > cutoff) continue
|
|
1621
2761
|
const d = (r.payload as any)?.data
|
|
1622
2762
|
if (d?.orderId) latest.set(d.orderId, d)
|
|
1623
2763
|
}
|