@things-factory/headless-twin 10.0.7 → 10.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist-server/engine/attention-digest.d.ts +52 -0
  2. package/dist-server/engine/attention-digest.js +76 -0
  3. package/dist-server/engine/attention-digest.js.map +1 -0
  4. package/dist-server/engine/board-vocabulary.d.ts +17 -0
  5. package/dist-server/engine/board-vocabulary.js +63 -0
  6. package/dist-server/engine/board-vocabulary.js.map +1 -0
  7. package/dist-server/engine/command-routing.d.ts +33 -0
  8. package/dist-server/engine/command-routing.js +53 -0
  9. package/dist-server/engine/command-routing.js.map +1 -0
  10. package/dist-server/engine/entity-delta.d.ts +13 -6
  11. package/dist-server/engine/entity-delta.js +38 -12
  12. package/dist-server/engine/entity-delta.js.map +1 -1
  13. package/dist-server/engine/index.d.ts +6 -0
  14. package/dist-server/engine/index.js +10 -0
  15. package/dist-server/engine/index.js.map +1 -1
  16. package/dist-server/engine/kpi-baseline.d.ts +78 -0
  17. package/dist-server/engine/kpi-baseline.js +123 -0
  18. package/dist-server/engine/kpi-baseline.js.map +1 -0
  19. package/dist-server/engine/kpi-broadcast.d.ts +4 -0
  20. package/dist-server/engine/kpi-broadcast.js +16 -0
  21. package/dist-server/engine/kpi-broadcast.js.map +1 -0
  22. package/dist-server/engine/kpi-query.d.ts +31 -0
  23. package/dist-server/engine/kpi-query.js +50 -2
  24. package/dist-server/engine/kpi-query.js.map +1 -1
  25. package/dist-server/engine/live-attentions.d.ts +1 -0
  26. package/dist-server/engine/live-attentions.js +7 -1
  27. package/dist-server/engine/live-attentions.js.map +1 -1
  28. package/dist-server/engine/runtime-key.d.ts +15 -0
  29. package/dist-server/engine/runtime-key.js +64 -0
  30. package/dist-server/engine/runtime-key.js.map +1 -0
  31. package/dist-server/engine/state-axes.d.ts +16 -0
  32. package/dist-server/engine/state-axes.js +54 -0
  33. package/dist-server/engine/state-axes.js.map +1 -0
  34. package/dist-server/engine/twin-engine.d.ts +92 -16
  35. package/dist-server/engine/twin-engine.js +335 -77
  36. package/dist-server/engine/twin-engine.js.map +1 -1
  37. package/dist-server/engine/twin-level.d.ts +23 -0
  38. package/dist-server/engine/twin-level.js +52 -0
  39. package/dist-server/engine/twin-level.js.map +1 -0
  40. package/dist-server/engine/warm-start.d.ts +58 -12
  41. package/dist-server/engine/warm-start.js +80 -9
  42. package/dist-server/engine/warm-start.js.map +1 -1
  43. package/dist-server/service/reference/discovery-result.d.ts +34 -0
  44. package/dist-server/service/reference/discovery-result.js +84 -0
  45. package/dist-server/service/reference/discovery-result.js.map +1 -0
  46. package/dist-server/service/reference/ingest-space.d.ts +30 -0
  47. package/dist-server/service/reference/ingest-space.js +63 -0
  48. package/dist-server/service/reference/ingest-space.js.map +1 -0
  49. package/dist-server/service/reference/knob-defaults.d.ts +20 -0
  50. package/dist-server/service/reference/knob-defaults.js +59 -0
  51. package/dist-server/service/reference/knob-defaults.js.map +1 -0
  52. package/dist-server/service/reference/reference-live.js +2 -2
  53. package/dist-server/service/reference/reference-live.js.map +1 -1
  54. package/dist-server/service/reference/reference-master.d.ts +37 -2
  55. package/dist-server/service/reference/reference-master.js +55 -5
  56. package/dist-server/service/reference/reference-master.js.map +1 -1
  57. package/dist-server/service/reference/reference-resolver.d.ts +2 -2
  58. package/dist-server/service/reference/reference-resolver.js +71 -15
  59. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  60. package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
  61. package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
  62. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  63. package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
  64. package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
  65. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +4 -2
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
  71. package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
  77. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
  81. package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
  82. package/dist-server/service/twin-structure/twin-structure.js +2 -1
  83. package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
  84. package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
  85. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
  86. package/dist-server/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +6 -6
  88. package/server/engine/attention-digest.ts +102 -0
  89. package/server/engine/board-vocabulary.ts +61 -0
  90. package/server/engine/command-routing.ts +67 -0
  91. package/server/engine/entity-delta.ts +33 -11
  92. package/server/engine/index.ts +10 -0
  93. package/server/engine/kpi-baseline.ts +202 -0
  94. package/server/engine/kpi-broadcast.ts +13 -0
  95. package/server/engine/kpi-query.ts +81 -3
  96. package/server/engine/live-attentions.ts +7 -2
  97. package/server/engine/runtime-key.ts +58 -0
  98. package/server/engine/state-axes.ts +55 -0
  99. package/server/engine/twin-engine.ts +350 -77
  100. package/server/engine/twin-level.ts +48 -0
  101. package/server/engine/warm-start.ts +130 -16
  102. package/server/service/reference/discovery-result.ts +95 -0
  103. package/server/service/reference/ingest-space.ts +70 -0
  104. package/server/service/reference/knob-defaults.ts +59 -0
  105. package/server/service/reference/reference-live.ts +2 -2
  106. package/server/service/reference/reference-master.ts +94 -8
  107. package/server/service/reference/reference-resolver.ts +80 -16
  108. package/server/service/twin-attention/twin-attention-query.ts +43 -6
  109. package/server/service/twin-control/twin-control-mutation.ts +31 -12
  110. package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
  111. package/server/service/twin-instance/twin-instance.ts +6 -2
  112. package/server/service/twin-journal/twin-journal-query.ts +35 -5
  113. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
  114. package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
  115. package/server/service/twin-space/twin-space-resolver.ts +10 -1
  116. package/server/service/twin-state/twin-state-subscription.ts +1 -1
  117. package/server/service/twin-structure/twin-structure.ts +4 -1
  118. package/server/service/twin-target/twin-target-resolver.ts +22 -4
  119. package/test/attention-digest.test.ts +135 -0
  120. package/test/board-vocabulary.test.ts +114 -0
  121. package/test/capability-mapping.test.ts +4 -4
  122. package/test/command-routing.test.ts +61 -0
  123. package/test/discovery-result.test.ts +75 -0
  124. package/test/entity-delta.test.ts +25 -25
  125. package/test/ingest-bench.test.ts +3 -3
  126. package/test/ingest-space.test.ts +50 -0
  127. package/test/knob-defaults.test.ts +72 -0
  128. package/test/kpi-baseline-db.test.ts +214 -0
  129. package/test/kpi-baseline.test.ts +196 -0
  130. package/test/kpi-query-bench.test.ts +128 -0
  131. package/test/live-mirror-parity.test.ts +35 -2
  132. package/test/master-to-twin.test.ts +7 -3
  133. package/test/mutation-gate.test.ts +108 -0
  134. package/test/oee-accumulator.test.ts +65 -1
  135. package/test/registry-key-guard.test.ts +80 -0
  136. package/test/runtime-key.test.ts +66 -0
  137. package/test/scale-twin-bench.test.ts +2 -2
  138. package/test/state-axes.test.ts +74 -0
  139. package/test/streamline-e2e.test.ts +2 -2
  140. package/test/structure-revision-db.test.ts +5 -4
  141. package/test/tenant-registry-db.test.ts +149 -0
  142. package/test/warm-start-seam.test.ts +140 -0
  143. package/test/warm-start.test.ts +138 -4
@@ -39,7 +39,7 @@ export class TwinStateSubscription {
39
39
 
40
40
  /* 구독 순간 현재 상태를 먼저 발행(late 구독자도 snapshot→delta 순서 보장). */
41
41
  process.nextTick(() => {
42
- const snap = TwinEngine.snapshot(instanceId)
42
+ const snap = TwinEngine.snapshot(domainId, instanceId)
43
43
  if (snap) {
44
44
  pubsub.publish('twin-state', {
45
45
  twinState: { instanceId, kind: 'snapshot', revision: snap.revision, payload: snap }
@@ -3,6 +3,7 @@ import { ObjectType, Field, ID, Int } from 'type-graphql'
3
3
 
4
4
  import { Domain, ScalarObject } from '@things-factory/shell'
5
5
  import { User } from '@things-factory/auth-base'
6
+ import { boardColumnTransformer } from '../../engine/board-vocabulary.js'
6
7
 
7
8
  /*
8
9
  * TwinStructure — **한 트윈이 거쳐 온 공장 구조들.** 리비전 하나가 그 시절의 공장이다.
@@ -71,7 +72,9 @@ export class TwinStructure {
71
72
  @Field({ description: 'Short hash of the structural signature (locations + equipment). A re-provision with the same signature does not create a new revision — otherwise every reboot would slice the history into meaningless segments.' })
72
73
  signature: string
73
74
 
74
- @Column({ type: 'simple-json' })
75
+ /* 읽을 어휘 세대를 흡수한다 — 옛 리비전의 보드로 지문을 다시 계산할 때 빈 공장으로 읽히면
76
+ 구조 변경 감지가 죽는다(`engine/board-vocabulary.ts`). */
77
+ @Column({ type: 'simple-json', transformer: boardColumnTransformer })
75
78
  @Field(type => ScalarObject, { description: 'The full BoardDef as it stood for this revision. Stored whole rather than as a diff: replay needs the entire topology, and rebuilding it from diffs would be a second rule that can drift.' })
76
79
  board: any
77
80
 
@@ -11,9 +11,27 @@ import { TWIN_METRIC, isTwinMetric } from '../../engine/kpi-target.js'
11
11
  * 경영 KPI 관리메뉴가 아니라 여기인 이유: 목표가 숫자 옆에 있어야 맥락이 살고, 운영 임계는 자주
12
12
  * 조정된다. 조직 단위 기간 평가는 `@things-factory/kpi` 의 일이고 이쪽으로 베껴 오지 않는다.
13
13
  */
14
+ /*
15
+ * **쓰기는 막고 읽기는 연다** (2026-08-06 · ADR-0027).
16
+ *
17
+ * 이 리졸버는 트윈 리졸버 중 **유일하게** 권한 게이트를 갖는데, 처음에는 우회 표시가 없었다
18
+ * (`@privilege(category:"twin", privilege:"query")`). 그러면 `twin/query`·`twin/mutation` 권한을
19
+ * 실제로 부여받은 사용자만 쓸 수 있는데 — **저장소 어디에도 그 권한을 만드는 곳이 없다.** 결과는
20
+ * 도메인 소유자도 슈퍼유저도 목표를 걸 수 없는 상태였다: 성과 화면의 목표 버튼이 "Unauthorized" 로
21
+ * 떨어지고, 이 관점의 최상위 기능(§5 목표 프레임)이 **아무도 쓸 수 없는 채로** 놓여 있었다.
22
+ *
23
+ * 그래서 프레임워크의 통용 관용구를 따른다 — 가장 가까운 형제인 `@things-factory/kpi`(목표·지표 관리)가
24
+ * 74곳 전부 `domainOwnerGranted: true, superUserGranted: true` 를 쓴다. 같은 성격의 조작(무엇을 잘한
25
+ * 것으로 볼지 정하는 일)이므로 같은 자세를 쓴다: **현장을 소유한 사람은 바로 쓰고**, 다른 사람에게
26
+ * 맡기려면 `twin` 권한을 부여한다.
27
+ *
28
+ * 이제 트윈의 **모든 변경**에 같은 게이트가 있다(ADR-0027) — 처음에는 이 리졸버 하나만 게이트를 갖는
29
+ * 반쪽이었다. 조회 게이트는 걷었다: 같은 판정이 `twinKpi` 로 이미 열려 있어 지키는 것이 없었다.
30
+ */
14
31
  @Resolver()
15
32
  export class TwinTargetResolver {
16
- @Directive('@privilege(category: "twin", privilege: "query")')
33
+ /* 조회에는 게이트를 두지 않는다 — 같은 판정이 `twinKpi.targets` 로 이미 열려 있어(그쪽은 게이트가
34
+ 없다) 여기만 막으면 지키는 것 없이 화면만 깨진다. 읽기의 경계는 **테넌트 소속**이다. */
17
35
  @Query(returns => [String], {
18
36
  description:
19
37
  'The metric ids a twin target may refer to. Fixed in one place so a target and the fact it judges can never drift into two names.'
@@ -22,7 +40,7 @@ export class TwinTargetResolver {
22
40
  return Object.keys(TWIN_METRIC)
23
41
  }
24
42
 
25
- @Directive('@privilege(category: "twin", privilege: "query")')
43
+
26
44
  @Query(returns => [TwinTarget], {
27
45
  description:
28
46
  'Operational targets in scope — site-wide targets for the space plus targets bound to the given instance. Callers judge with the narrower one when both exist.'
@@ -37,7 +55,7 @@ export class TwinTargetResolver {
37
55
  return rows.filter(r => (spaceId && r.spaceId === spaceId) || (instanceId && r.instanceId === instanceId))
38
56
  }
39
57
 
40
- @Directive('@privilege(category: "twin", privilege: "mutation")')
58
+ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
41
59
  @Mutation(returns => Boolean, {
42
60
  description:
43
61
  'Create or update one operational target. The metric must be one of twinTargetMetrics — an unknown name is rejected rather than stored, because a target nobody can evaluate reads as "no target" and hides the gap.'
@@ -79,7 +97,7 @@ export class TwinTargetResolver {
79
97
  return true
80
98
  }
81
99
 
82
- @Directive('@privilege(category: "twin", privilege: "mutation")')
100
+ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
83
101
  @Mutation(returns => Boolean, { description: 'Remove an operational target. Removing a target removes the judgement, not the fact — the metric keeps being reported without a verdict.' })
84
102
  async deleteTwinTarget(
85
103
  @Arg('metric') metric: string,
@@ -0,0 +1,135 @@
1
+ /*
2
+ * ★ 주의 신호 요약 — 신호가 많은 트윈의 전송량.
3
+ *
4
+ * ── 계기 ────────────────────────────────────────────────────────────────────
5
+ * 축 선택으로 리프레시를 80KB → 2KB 로 줄였는데 **신호가 많은 트윈은 거기서 막혔다**:
6
+ * `hatio-yard` 는 신호 397건이 108KB 라 5배 감소에 그쳤다. 그런데 화면이 그 397건으로 하는 일은
7
+ * 둘뿐이다 — 레일에 20건만 그리고, 자리 7곳의 색을 뽑는다.
8
+ *
9
+ * ── 이 테스트가 지키는 것 ───────────────────────────────────────────────────
10
+ * ① **자리 색은 전량을 근거로** 접힌다. 상위 N 으로 접으면 20위 밖의 신호를 가진 자리가 색을 잃는다.
11
+ * ② **조용히 자르지 않는다.** 전체 개수를 함께 보내야 레일이 "N건 더" 를 말할 수 있다.
12
+ * ③ **순서가 화면과 같다.** 서버가 고른 20건이 화면이 고를 20건과 달라지면 아무 데서도 안 드러난다.
13
+ */
14
+ import { test } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+
17
+ import { digestAttentions, compareAttentions, mergeLensAttentions } from '../server/engine/attention-digest.ts'
18
+
19
+ const attn = (id: string, severity: string, locationId?: string, state?: string) => ({ id, severity, state, anchor: locationId ? { locationId } : undefined })
20
+
21
+ test('상위 N 만 남기고 전체 개수를 함께 낸다 — 조용히 자르지 않는다', () => {
22
+ const all = Array.from({ length: 50 }, (_, i) => attn(`a${i}`, 'low', `loc-${i % 3}`))
23
+ const d = digestAttentions(all, 20)
24
+ assert.equal(d.attentions.length, 20)
25
+ assert.equal(d.attentionTotal, 50, '전체 개수가 없으면 화면이 "괜찮아졌다" 로 읽는다')
26
+ })
27
+
28
+ test('자리 색은 전량을 근거로 접는다 — 상위 N 밖의 자리도 색을 갖는다', () => {
29
+ /* 앞 20건은 loc-hot 에, 21번째 한 건만 loc-cold 에 — 잘린 뒤에 접으면 loc-cold 가 색을 잃는다. */
30
+ const all = [...Array.from({ length: 20 }, (_, i) => attn(`hot${i}`, 'high', 'loc-hot')), attn('cold', 'critical', 'loc-cold')]
31
+ const d = digestAttentions(all, 20)
32
+ assert.equal(d.attentions.length, 20)
33
+ assert.equal(d.severityByLocation['loc-cold'], 'critical', '상위 N 밖의 신호도 자리 색에는 반영돼야 한다')
34
+ assert.equal(d.severityByLocation['loc-hot'], 'high')
35
+ })
36
+
37
+ test('자리마다 가장 급한 심각도가 남는다', () => {
38
+ const d = digestAttentions([attn('a', 'low', 'n1'), attn('b', 'critical', 'n1'), attn('c', 'medium', 'n1')])
39
+ assert.equal(d.severityByLocation['n1'], 'critical')
40
+ })
41
+
42
+ test('순서는 화면과 같다 — 확인된 것은 아래로, 그다음 심각도순', () => {
43
+ const all = [attn('acked-crit', 'critical', 'n1', 'acknowledged'), attn('low', 'low', 'n2'), attn('high', 'high', 'n3')]
44
+ const d = digestAttentions(all, 2)
45
+ assert.deepEqual(d.attentions.map(a => a.id), ['high', 'low'], '확인된 critical 보다 확인 안 된 것이 먼저다')
46
+ assert.equal(d.attentionTotal, 3)
47
+ })
48
+
49
+ test('한도를 말하지 않으면 자르지 않는다 — 이 값을 모르는 소비처가 일부만 받으면 안 된다', () => {
50
+ const all = Array.from({ length: 30 }, (_, i) => attn(`a${i}`, 'low'))
51
+ assert.equal(digestAttentions(all).attentions.length, 30)
52
+ assert.equal(digestAttentions(all, 0).attentions.length, 30)
53
+ assert.equal(digestAttentions(all, -5).attentions.length, 30)
54
+ })
55
+
56
+ test('자리에 붙지 않은 신호는 색 요약에 들어가지 않는다 — 없는 자리를 만들지 않는다', () => {
57
+ const d = digestAttentions([attn('orphan', 'critical'), attn('anchored', 'low', 'n1')])
58
+ assert.deepEqual(Object.keys(d.severityByLocation), ['n1'])
59
+ assert.equal(d.attentionTotal, 2, '색에 안 들어가도 개수에는 든다')
60
+ })
61
+
62
+ test('신호가 없으면 빈 요약 — 없는 것을 만들지 않는다', () => {
63
+ for (const v of [undefined, null, []]) {
64
+ const d = digestAttentions(v as any, 20)
65
+ assert.deepEqual(d.attentions, [])
66
+ assert.equal(d.attentionTotal, 0)
67
+ assert.deepEqual(d.severityByLocation, {})
68
+ }
69
+ })
70
+
71
+ test('정렬 함수는 원본을 바꾸지 않는다 — 스냅샷을 제자리에서 흔들면 다른 소비처가 영향받는다', () => {
72
+ const all = [attn('low', 'low'), attn('crit', 'critical')]
73
+ const before = all.map(a => a.id)
74
+ digestAttentions(all, 1)
75
+ assert.deepEqual(all.map(a => a.id), before)
76
+ assert.equal(compareAttentions(all[1], all[0]) < 0, true, 'critical 이 앞이다')
77
+ })
78
+
79
+ /*
80
+ * ── 여러 렌즈를 한 현장으로 모으기 (2026-08-05) ────────────────────────────
81
+ * 한 공장에 WMS·MES·YMS 가 함께 있으므로 "이 현장에서 봐야 할 것" 은 렌즈 하나로 답할 수 없다.
82
+ * 예전에는 화면이 인스턴스마다 물어 손으로 합쳤고, 그러면 정렬 규칙이 화면마다 갈린다.
83
+ *
84
+ * 모으면서 조용히 틀릴 수 있는 자리 셋을 여기서 못 박는다.
85
+ */
86
+ test('★ 어느 트윈에서 온 신호인지 붙는다 — 조치는 결국 그 트윈에 보내야 한다', () => {
87
+ const d = mergeLensAttentions([
88
+ { instanceId: 'wms', attentions: [attn('a', 'high', 'dock-1')] },
89
+ { instanceId: 'yms', attentions: [attn('b', 'low', 'gate-1')] }
90
+ ])
91
+ assert.deepEqual(d.attentions.map(a => [a.id, (a as any).instanceId]), [['a', 'wms'], ['b', 'yms']])
92
+ })
93
+
94
+ test('★ 순서는 렌즈 순서가 아니라 급한 순서다 — 뒤 렌즈의 심각한 신호가 밀려나면 안 된다', () => {
95
+ const d = mergeLensAttentions(
96
+ [
97
+ { instanceId: 'wms', attentions: [attn('low-1', 'low'), attn('low-2', 'low')] },
98
+ { instanceId: 'mes', attentions: [attn('crit', 'critical')] }
99
+ ],
100
+ 1
101
+ )
102
+ assert.deepEqual(d.attentions.map(a => a.id), ['crit'], '상위 1건은 뒤 렌즈의 critical 이어야 한다')
103
+ assert.equal(d.attentionTotal, 3)
104
+ })
105
+
106
+ test('★ 두 렌즈가 같은 자리를 가리키면 더 급한 쪽이 색을 정한다 — 하나의 현실이므로', () => {
107
+ const d = mergeLensAttentions([
108
+ { instanceId: 'wms', attentions: [attn('a', 'low', 'dock-1')] },
109
+ { instanceId: 'yms', attentions: [attn('b', 'critical', 'dock-1')] }
110
+ ])
111
+ assert.equal(d.severityByLocation['dock-1'], 'critical')
112
+ /* 렌즈 순서를 뒤집어도 같아야 한다 — 색이 순서에 따라 달라지면 화면이 새로고침마다 바뀐다. */
113
+ const flipped = mergeLensAttentions([
114
+ { instanceId: 'yms', attentions: [attn('b', 'critical', 'dock-1')] },
115
+ { instanceId: 'wms', attentions: [attn('a', 'low', 'dock-1')] }
116
+ ])
117
+ assert.equal(flipped.severityByLocation['dock-1'], 'critical')
118
+ })
119
+
120
+ test('★ 신호가 없는 렌즈는 아무것도 보태지 않는다 — 빈 렌즈가 개수를 늘리지 않게', () => {
121
+ const d = mergeLensAttentions([
122
+ { instanceId: 'wms', attentions: [attn('a', 'low')] },
123
+ { instanceId: 'mes', attentions: [] },
124
+ { instanceId: 'yms', attentions: undefined }
125
+ ])
126
+ assert.equal(d.attentionTotal, 1)
127
+ assert.deepEqual(d.attentions.map(a => a.id), ['a'])
128
+ })
129
+
130
+ test('★ 렌즈가 하나도 없으면 빈 요약 — 공간에 떠 있는 트윈이 없을 때', () => {
131
+ const d = mergeLensAttentions([], 20)
132
+ assert.deepEqual(d.attentions, [])
133
+ assert.equal(d.attentionTotal, 0)
134
+ assert.deepEqual(d.severityByLocation, {})
135
+ })
@@ -0,0 +1,114 @@
1
+ /*
2
+ * 저장된 보드의 어휘 세대 흡수 — **빈 공장으로 읽히지 않게.**
3
+ *
4
+ * ── 계기 (2026-08-06, 사용자 신고) ───────────────────────────────────────────
5
+ * 모델러의 엔티티 목록이 통째로 비어 있었다. **오류는 없었다.** 저장된 보드가 옛 어휘(`nodes`/`movers`)
6
+ * 인데 화면과 호스트가 새 어휘를 읽었기 때문이다. 같은 원인이 구조 서명(`N[]M[]` — 서로 다른 두 공장이
7
+ * 같아 보인다)·주행 추정기·설비 동기까지 조용히 망가뜨리고 있었다.
8
+ *
9
+ * 그래서 **DB에서 읽는 순간** 한 번 정규화한다. 이 테스트가 지키는 것은 그 정규화가 ① 세 세대를 다
10
+ * 흡수하고 ② 나머지 속성을 하나도 잃지 않고 ③ 새 보드에는 아무 일도 하지 않는 것이다.
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+
15
+ import { readFileSync } from 'node:fs'
16
+
17
+ import { boardColumnTransformer, canonicalBoard } from '../server/engine/board-vocabulary.ts'
18
+
19
+ const LOC = [{ id: 'dock', type: 'dock', capacity: 10 }]
20
+ const EQ = [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock' }]
21
+
22
+ test('가장 오래된 세대(nodes·movers)를 흡수한다 — 사용자가 본 증상의 원인', () => {
23
+ const out = canonicalBoard({ nodes: LOC, movers: EQ, spaceId: 'site-1' })
24
+
25
+ assert.deepEqual(out.locations, LOC)
26
+ assert.deepEqual(out.equipment, EQ)
27
+ })
28
+
29
+ test('중간 세대(equipmentList)도 흡수한다 — 세 세대가 저장돼 있다', () => {
30
+ const out = canonicalBoard({ nodes: LOC, equipmentList: EQ })
31
+
32
+ assert.deepEqual(out.equipment, EQ)
33
+ })
34
+
35
+ test('옛 키를 **내보내지 않는다** — 소비처가 두 어휘를 다시 보지 않게', () => {
36
+ const out: any = canonicalBoard({ nodes: LOC, movers: EQ, equipmentList: [] })
37
+
38
+ for (const k of ['nodes', 'movers', 'equipmentList']) assert.equal(k in out, false, `${k} 가 남았다`)
39
+ })
40
+
41
+ test('나머지 속성을 하나도 잃지 않는다 — 곁가지로 보이는 값이 전제다', () => {
42
+ /* 이 도메인에서 사소한 속성은 없다: 좌표·시각기준·명세는 소요시간·교대·생산의 전제다. */
43
+ const board = {
44
+ nodes: LOC,
45
+ movers: EQ,
46
+ spaceId: 'site-1',
47
+ areaId: 'zone-a',
48
+ layout: { dock: { x: 1, y: 2 } },
49
+ utcOffsetMinutes: 540,
50
+ unit: 'm',
51
+ productionSpec: { definition: { operations: [] } },
52
+ operations: [{ id: 'op-1' }]
53
+ }
54
+ const out: any = canonicalBoard(board)
55
+
56
+ for (const k of ['spaceId', 'areaId', 'layout', 'utcOffsetMinutes', 'unit', 'productionSpec', 'operations']) {
57
+ assert.deepEqual(out[k], (board as any)[k], `${k} 가 사라졌다`)
58
+ }
59
+ })
60
+
61
+ test('새 어휘 보드에는 아무 일도 하지 않는다 — 멱등', () => {
62
+ const board = { locations: LOC, equipment: EQ, spaceId: 'site-1' }
63
+
64
+ const once: any = canonicalBoard(board)
65
+ const twice: any = canonicalBoard(once)
66
+
67
+ assert.deepEqual(once, board)
68
+ assert.deepEqual(twice, once)
69
+ })
70
+
71
+ test('컬렉션이 없으면 빈 배열이 된다 — 소비처가 `?? []` 를 다시 적지 않게', () => {
72
+ const out = canonicalBoard({ spaceId: 'site-1' })
73
+
74
+ assert.deepEqual(out.locations, [])
75
+ assert.deepEqual(out.equipment, [])
76
+ })
77
+
78
+ test('보드가 아닌 것은 그대로 통과시킨다 — 모양을 짐작해 고치지 않는다', () => {
79
+ for (const v of [null, undefined, 0, '', 'board', [1, 2]]) {
80
+ assert.deepEqual(canonicalBoard(v as any), v as any, JSON.stringify(v))
81
+ }
82
+ })
83
+
84
+ test('변환기는 **읽을 때만** 정규화한다 — 쓰기는 준 그대로', () => {
85
+ const old = { nodes: LOC, movers: EQ }
86
+
87
+ assert.deepEqual(boardColumnTransformer.from(old).equipment, EQ)
88
+ /* 쓰기를 손대면 "저장된 것" 과 "준 것" 이 달라져 추적이 어려워진다. 읽기가 정규화되므로 다음 저장에서
89
+ 자연히 새 어휘가 된다. */
90
+ assert.equal(boardColumnTransformer.to(old), old)
91
+ })
92
+
93
+ test('두 옛 보드가 구조적으로 구별된다 — 서명이 빈 문자열로 뭉개지지 않는다', () => {
94
+ /* 정규화 전에는 둘 다 `locations: undefined` 라 서명이 `N[]M[]` 로 같았다(변경 감지가 죽는다). */
95
+ const a = canonicalBoard({ nodes: [{ id: 'a', type: 'dock', capacity: 1 }], movers: [] })
96
+ const b = canonicalBoard({ nodes: [{ id: 'b', type: 'storage', capacity: 9 }], movers: [] })
97
+
98
+ assert.notDeepEqual(a.locations, b.locations)
99
+ assert.equal(a.locations.length, 1)
100
+ assert.equal(b.locations.length, 1)
101
+ })
102
+
103
+ test('보드를 저장하는 엔티티가 **변환기를 달고 있다** — 결선이 빠지면 순수 모듈은 그린인데 화면은 빈다', () => {
104
+ /* 이 결함이 정확히 그랬다: 리더는 커널에 있었고 목록만 그것을 거쳤다. 결선은 조용히 빠질 수 있으므로
105
+ 기계가 지킨다. 새 엔티티가 보드를 담게 되면 여기 함께 올린다. */
106
+ const owners = ['server/service/twin-instance/twin-instance.ts', 'server/service/twin-structure/twin-structure.ts']
107
+
108
+ for (const rel of owners) {
109
+ const src = readFileSync(new URL('../' + rel, import.meta.url).pathname, 'utf-8')
110
+ const boardColumn = src.split('\n').find(l => l.includes('@Column') && l.includes('transformer'))
111
+ assert.ok(boardColumn, `${rel}: board 컬럼에 transformer 가 없다`)
112
+ assert.match(boardColumn as string, /boardColumnTransformer/, `${rel}: 다른 변환기가 달려 있다`)
113
+ }
114
+ })
@@ -63,17 +63,17 @@ test('능력 소싱 구조: 4 자원/위치 능력은 카탈로그 타입에서,
63
63
 
64
64
  test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload 에 실림 (커널→보드 도달)', () => {
65
65
  // storable 노드 → occupancy/capacity (+ operable status)
66
- const nodeD: any = buildEntityDeltas({ locations: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] })[0].data
66
+ const nodeD: any = buildEntityDeltas({ locations: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] }, 't1')[0].data
67
67
  for (const f of stateFieldsOf(['storable'])) assert.ok(f in nodeD, `storable payload.${f}`)
68
68
  assert.ok('status' in nodeD, 'operable(노드 status) payload')
69
69
 
70
70
  // mobile+operable 무버 → location/motion/status
71
- const equipmentD: any = buildEntityDeltas({ equipment: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }).find(x => x.tag === 'm')!.data
71
+ const equipmentD: any = buildEntityDeltas({ equipment: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }, 't1').find(x => x.tag === 'm')!.data
72
72
  for (const f of stateFieldsOf(['mobile'])) assert.ok(f in equipmentD, `mobile payload.${f}`)
73
73
  for (const f of stateFieldsOf(['operable'])) assert.ok(f in equipmentD, `operable payload.${f}`)
74
74
 
75
75
  // trackable 오더 → progress/held (lifecycle 은 payload 에서 'status' 로 표면화 — 이름-매핑 시임)
76
- const orderD: any = buildEntityDeltas({ orders: [{ id: 'o', status: 'picking', progress: 0.5, held: false }] }).find(x => x.tag === 'o')!.data
76
+ const orderD: any = buildEntityDeltas({ orders: [{ id: 'o', status: 'picking', progress: 0.5, held: false }] }, 't1').find(x => x.tag === 'o')!.data
77
77
  assert.ok('progress' in orderD && 'held' in orderD, 'trackable progress/held payload')
78
78
  assert.ok('status' in orderD, 'trackable lifecycle → payload.status (이름-매핑)')
79
79
  })
@@ -82,7 +82,7 @@ test('시임 문서화 · processable output(양품/불량)은 payload oee.good/
82
82
  // 계약 stateField = 'output' 이나, 라이브 payload 는 이를 oee(정수%+good/scrap)로 집약해 표면화.
83
83
  const d: any = buildEntityDeltas({
84
84
  equipment: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
85
- }).find(x => x.tag === 'c')!.data
85
+ }, 't1').find(x => x.tag === 'c')!.data
86
86
  assert.ok(d.oee, 'processable → payload.oee 존재')
87
87
  assert.equal(d.oee.good, 10, 'output.good = oee.good')
88
88
  assert.equal(d.oee.scrap, 1, 'output.scrap = oee.scrap')
@@ -0,0 +1,61 @@
1
+ /*
2
+ * ★ 커맨드 라우팅 판정 — 권위(모드) × 성격(주석/액추에이션).
3
+ *
4
+ * ── 무엇이 문제였나 ─────────────────────────────────────────────────────────
5
+ * 리졸버가 `inst.runtime.dispatch(...)` 를 곧바로 불렀다. 그런데 `runtime` 은 **시뮬 경로에서만**
6
+ * 만들어진다. 실물을 비추는 트윈(mirror)에서는 그 한 줄이 `TypeError` 로 터져 조치 채널이 통째로
7
+ * 닿지 않았다 — 사용자에게는 이유 있는 거절이 아니라 그냥 실패였다.
8
+ *
9
+ * ── 그냥 열면 안 되는 이유 ──────────────────────────────────────────────────
10
+ * 미러에서 액추에이션을 허용하면 **새 거짓말이 생긴다**: 트윈은 "보류됨" 이라 말하고 실 시스템은
11
+ * 모른다. 그래서 실 프로세스를 바꾸는 커맨드는 아웃바운드 어댑터가 붙기 전까지 이유를 밝혀 거절한다
12
+ * (`design/plans/command-routing.md` P0). 확인(ack)은 트윈 쪽 주석이라 미러에서도 통과한다.
13
+ */
14
+ import { test } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+
17
+ import { routeCommand, isActuation } from '../server/engine/command-routing.ts'
18
+
19
+ const sim = { hasRuntime: true, hasKernelDispatch: true }
20
+ const mirror = { mode: 'live', hasRuntime: false, hasKernelDispatch: true }
21
+
22
+ test('시뮬 — 커맨드는 커널 런타임으로 간다(현행 경로 그대로)', () => {
23
+ assert.deepEqual(routeCommand(sim, true, 'order.hold'), { target: 'runtime' })
24
+ assert.deepEqual(routeCommand(sim, true, 'attention.ack'), { target: 'runtime' })
25
+ })
26
+
27
+ test('미러 — 확인(ack)은 관측 커널로 간다. 현실을 건드리지 않는 주석이다', () => {
28
+ assert.deepEqual(routeCommand(mirror, true, 'attention.ack'), { target: 'kernel' })
29
+ })
30
+
31
+ test('미러 — 실 프로세스를 바꾸는 커맨드는 이유를 밝히고 거절한다', () => {
32
+ for (const type of ['order.hold', 'order.resume', 'order.release', 'resource.down', 'resource.repair', 'resource.add']) {
33
+ const r = routeCommand(mirror, true, type)
34
+ assert.equal(r.target, 'reject', `${type} 은 미러에서 통과하면 안 된다`)
35
+ if (r.target !== 'reject') return
36
+ assert.equal(r.errorCode, 'actuation-not-wired')
37
+ assert.equal(r.errorParams?.type, type, '어느 커맨드였는지 함께 알려야 한다')
38
+ }
39
+ })
40
+
41
+ test('없는 인스턴스와 다른 도메인은 같은 사유로 거절한다 — 있음/없음이 구별되면 그것이 정보다', () => {
42
+ assert.deepEqual(routeCommand(undefined, false, 'attention.ack'), { target: 'reject', errorCode: 'no-instance' })
43
+ assert.deepEqual(routeCommand(sim, false, 'attention.ack'), { target: 'reject', errorCode: 'no-instance' })
44
+ })
45
+
46
+ test('런타임이 없는 시뮬 — 터뜨리지 않고 이유를 말한다', () => {
47
+ assert.deepEqual(routeCommand({ hasRuntime: false, hasKernelDispatch: true }, true, 'order.hold'), { target: 'reject', errorCode: 'no-runtime' })
48
+ })
49
+
50
+ test('커널이 커맨드를 못 받는 미러 — 확인도 이유를 말하고 멈춘다', () => {
51
+ assert.deepEqual(routeCommand({ mode: 'live', hasRuntime: false, hasKernelDispatch: false }, true, 'attention.ack'), { target: 'reject', errorCode: 'no-runtime' })
52
+ })
53
+
54
+ test('빈 커맨드 종류는 거절한다 — 무엇을 하라는지 모르는 것을 보내지 않는다', () => {
55
+ assert.deepEqual(routeCommand(sim, true, ''), { target: 'reject', errorCode: 'unknown-command' })
56
+ })
57
+
58
+ test('성격 판정 — 확인만 주석이고 나머지는 액추에이션이다', () => {
59
+ assert.equal(isActuation('attention.ack'), false)
60
+ for (const t of ['order.hold', 'resource.add', 'order.release']) assert.equal(isActuation(t), true, t)
61
+ })
@@ -0,0 +1,75 @@
1
+ /*
2
+ * 사이트 발견 결과 — **"못 찾았다" 를 "없다" 로 말하지 않게.**
3
+ *
4
+ * 실 시스템을 연결해 트윈을 만드는 경로에서, 발견이 실패하면 화면이 "사이트를 찾지 못했습니다" 라고만
5
+ * 말했다. 레퍼런스 미등록·어댑터 미상·조회 실패·정말 0건이 그 한 문장으로 접혀 있었다 — 자격이
6
+ * 틀렸는지 주소가 틀렸는지 알 수 없었다.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import {
12
+ discovered, failed, detailOf,
13
+ referenceNotFound, adapterUnknown, discoveryFailed, discoveryTimedOut
14
+ } from '../server/service/reference/discovery-result.ts'
15
+
16
+ test('사이트가 없는 것은 실패가 아니다 — 사유 없는 빈 목록이 "없다" 를 뜻한다', () => {
17
+ /* 권한이 좁거나 아직 등록되지 않아 공장이 하나도 없는 시스템은 있을 수 있다. 그것은 사실이므로
18
+ 실패로 뭉개면 사용자가 있지도 않은 문제를 찾는다. */
19
+ const r = discovered([])
20
+ assert.deepEqual(r, { sites: [] })
21
+ assert.equal(r.error, undefined, '사유가 없다 = 성공')
22
+ })
23
+
24
+ test('찾은 것을 그대로 돌려주되 입력을 붙들지 않는다', () => {
25
+ const src = [{ siteId: 'PL1' }, { siteId: 'PL2' }]
26
+ const r = discovered(src)
27
+ assert.deepEqual(r.sites, src)
28
+ src.push({ siteId: 'PL3' })
29
+ assert.equal(r.sites.length, 2, '나중에 바뀐 원본이 결과를 바꾸지 않는다')
30
+ })
31
+
32
+ test('없는 목록·null 도 견딘다', () => {
33
+ assert.deepEqual(discovered(null), { sites: [] })
34
+ assert.deepEqual(discovered(undefined), { sites: [] })
35
+ })
36
+
37
+ test('실패는 사유와 함께, 사이트는 비워서 — 반쪽 목록을 성공처럼 보이지 않게', () => {
38
+ const r = failed(referenceNotFound('sap-ewm-1710'))
39
+ assert.deepEqual(r.sites, [])
40
+ assert.equal(r.error?.code, 'reference-not-found')
41
+ assert.equal(r.error?.params?.source, 'sap-ewm-1710')
42
+ assert.match(r.error!.message, /not registered/, '영어 canonical 문장이 로그·폴백으로 남는다')
43
+ })
44
+
45
+ test('네 가지 사유가 서로 구별된다 — 이것이 이 작업의 요점이다', () => {
46
+ const codes = [
47
+ referenceNotFound('s').code,
48
+ adapterUnknown('sap-ewm', 's').code,
49
+ discoveryFailed('s', '401 Unauthorized').code,
50
+ discoveryTimedOut('s', 15).code
51
+ ]
52
+ assert.deepEqual(codes, ['reference-not-found', 'adapter-unknown', 'discovery-failed', 'discovery-timeout'])
53
+ assert.equal(new Set(codes).size, 4, '넷이 한 코드로 접히지 않는다')
54
+ })
55
+
56
+ test('원 시스템이 준 사유를 감추지 않는다 — 사용자가 관리자에게 물어볼 수 있게', () => {
57
+ const e = discoveryFailed('sap-ewm-1710', '401 Unauthorized')
58
+ assert.equal(e.params?.detail, '401 Unauthorized')
59
+ assert.match(e.message, /401 Unauthorized/)
60
+ })
61
+
62
+ test('타임아웃은 초 단위로 말한다 — 얼마를 기다렸는지 알 수 있게', () => {
63
+ const e = discoveryTimedOut('s', 15)
64
+ assert.equal(e.params?.seconds, 15)
65
+ assert.match(e.message, /15s/)
66
+ })
67
+
68
+ test('어댑터가 던진 것에서 사유를 뽑는다 — 형태를 가정하지 않는다', () => {
69
+ assert.equal(detailOf(new Error('ECONNREFUSED')), 'ECONNREFUSED')
70
+ assert.equal(detailOf('404 Not Found'), '404 Not Found')
71
+ /* 무엇이 왔는지 모르면 그렇게 말한다 — 빈 문장으로 "이유 없음" 처럼 보이게 하지 않는다. */
72
+ for (const weird of [undefined, null, {}, 0, new Error('')]) {
73
+ assert.match(detailOf(weird), /without a message/, `이상한 입력: ${JSON.stringify(weird)}`)
74
+ }
75
+ })