@things-factory/headless-twin 10.0.8 → 10.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/attention-digest.d.ts +52 -0
- package/dist-server/engine/attention-digest.js +76 -0
- package/dist-server/engine/attention-digest.js.map +1 -0
- package/dist-server/engine/board-vocabulary.d.ts +17 -0
- package/dist-server/engine/board-vocabulary.js +63 -0
- package/dist-server/engine/board-vocabulary.js.map +1 -0
- package/dist-server/engine/command-routing.d.ts +33 -0
- package/dist-server/engine/command-routing.js +53 -0
- package/dist-server/engine/command-routing.js.map +1 -0
- package/dist-server/engine/entity-delta.d.ts +13 -6
- package/dist-server/engine/entity-delta.js +38 -12
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +6 -0
- package/dist-server/engine/index.js +10 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-baseline.d.ts +78 -0
- package/dist-server/engine/kpi-baseline.js +123 -0
- package/dist-server/engine/kpi-baseline.js.map +1 -0
- package/dist-server/engine/kpi-broadcast.d.ts +4 -0
- package/dist-server/engine/kpi-broadcast.js +16 -0
- package/dist-server/engine/kpi-broadcast.js.map +1 -0
- package/dist-server/engine/kpi-query.d.ts +31 -0
- package/dist-server/engine/kpi-query.js +50 -2
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-attentions.d.ts +1 -0
- package/dist-server/engine/live-attentions.js +7 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/runtime-key.d.ts +15 -0
- package/dist-server/engine/runtime-key.js +64 -0
- package/dist-server/engine/runtime-key.js.map +1 -0
- package/dist-server/engine/state-axes.d.ts +16 -0
- package/dist-server/engine/state-axes.js +54 -0
- package/dist-server/engine/state-axes.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +92 -16
- package/dist-server/engine/twin-engine.js +335 -77
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/twin-level.d.ts +23 -0
- package/dist-server/engine/twin-level.js +52 -0
- package/dist-server/engine/twin-level.js.map +1 -0
- package/dist-server/engine/warm-start.d.ts +58 -12
- package/dist-server/engine/warm-start.js +80 -9
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +34 -0
- package/dist-server/service/reference/discovery-result.js +84 -0
- package/dist-server/service/reference/discovery-result.js.map +1 -0
- package/dist-server/service/reference/ingest-space.d.ts +30 -0
- package/dist-server/service/reference/ingest-space.js +63 -0
- package/dist-server/service/reference/ingest-space.js.map +1 -0
- package/dist-server/service/reference/knob-defaults.d.ts +20 -0
- package/dist-server/service/reference/knob-defaults.js +59 -0
- package/dist-server/service/reference/knob-defaults.js.map +1 -0
- package/dist-server/service/reference/reference-live.js +2 -2
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +37 -2
- package/dist-server/service/reference/reference-master.js +55 -5
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +2 -2
- package/dist-server/service/reference/reference-resolver.js +71 -15
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +4 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
- package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
- package/dist-server/service/twin-structure/twin-structure.js +2 -1
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
- package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/engine/attention-digest.ts +102 -0
- package/server/engine/board-vocabulary.ts +61 -0
- package/server/engine/command-routing.ts +67 -0
- package/server/engine/entity-delta.ts +33 -11
- package/server/engine/index.ts +10 -0
- package/server/engine/kpi-baseline.ts +202 -0
- package/server/engine/kpi-broadcast.ts +13 -0
- package/server/engine/kpi-query.ts +81 -3
- package/server/engine/live-attentions.ts +7 -2
- package/server/engine/runtime-key.ts +58 -0
- package/server/engine/state-axes.ts +55 -0
- package/server/engine/twin-engine.ts +350 -77
- package/server/engine/twin-level.ts +48 -0
- package/server/engine/warm-start.ts +130 -16
- package/server/service/reference/discovery-result.ts +95 -0
- package/server/service/reference/ingest-space.ts +70 -0
- package/server/service/reference/knob-defaults.ts +59 -0
- package/server/service/reference/reference-live.ts +2 -2
- package/server/service/reference/reference-master.ts +94 -8
- package/server/service/reference/reference-resolver.ts +80 -16
- package/server/service/twin-attention/twin-attention-query.ts +43 -6
- package/server/service/twin-control/twin-control-mutation.ts +31 -12
- package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
- package/server/service/twin-instance/twin-instance.ts +6 -2
- package/server/service/twin-journal/twin-journal-query.ts +35 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
- package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +10 -1
- package/server/service/twin-state/twin-state-subscription.ts +1 -1
- package/server/service/twin-structure/twin-structure.ts +4 -1
- package/server/service/twin-target/twin-target-resolver.ts +22 -4
- package/test/attention-digest.test.ts +135 -0
- package/test/board-vocabulary.test.ts +114 -0
- package/test/capability-mapping.test.ts +4 -4
- package/test/command-routing.test.ts +61 -0
- package/test/discovery-result.test.ts +75 -0
- package/test/entity-delta.test.ts +25 -25
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-space.test.ts +50 -0
- package/test/knob-defaults.test.ts +72 -0
- package/test/kpi-baseline-db.test.ts +214 -0
- package/test/kpi-baseline.test.ts +196 -0
- package/test/kpi-query-bench.test.ts +128 -0
- package/test/live-mirror-parity.test.ts +35 -2
- package/test/master-to-twin.test.ts +7 -3
- package/test/mutation-gate.test.ts +108 -0
- package/test/oee-accumulator.test.ts +65 -1
- package/test/registry-key-guard.test.ts +80 -0
- package/test/runtime-key.test.ts +66 -0
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/state-axes.test.ts +74 -0
- package/test/streamline-e2e.test.ts +2 -2
- package/test/structure-revision-db.test.ts +5 -4
- package/test/tenant-registry-db.test.ts +149 -0
- package/test/warm-start-seam.test.ts +140 -0
- package/test/warm-start.test.ts +138 -4
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'
|
|
2
2
|
|
|
3
3
|
import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
4
4
|
|
|
5
5
|
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
import { applyKnobDefaults, filledKnobKeys } from './knob-defaults.js'
|
|
7
|
+
import { knobDefaultsFilled } from './reference-master.js'
|
|
6
8
|
import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
7
9
|
import { TwinReference } from './twin-reference.js'
|
|
8
10
|
import { listReferences, getReference } from './reference-registry.js'
|
|
9
11
|
import { getAdapter, listAdapterTypes, listAdapters } from './reference-adapter.js'
|
|
12
|
+
import {
|
|
13
|
+
discovered, failed, detailOf,
|
|
14
|
+
referenceNotFound, adapterUnknown, discoveryFailed, discoveryTimedOut
|
|
15
|
+
} from './discovery-result.js'
|
|
10
16
|
import { startReferenceLiveFeed } from './reference-live.js'
|
|
11
17
|
import { listTemplates, getTemplate } from './template-registry.js'
|
|
12
18
|
|
|
@@ -14,6 +20,27 @@ import { listTemplates, getTemplate } from './template-registry.js'
|
|
|
14
20
|
* Face2 레퍼런스 인제스트 채널(ADR-0018) — 등록된 레퍼런스 소스를 열거하고, 골라서 트윈으로 인제스트.
|
|
15
21
|
* "연결하고 내 것을 불러온다" — 엔티티는 레퍼런스에서 오지 손배선/발명이 아니다.
|
|
16
22
|
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 발견 조회의 시간 상한 — 15초.
|
|
26
|
+
*
|
|
27
|
+
* 어댑터 계약에 시간 제한이 없다. 주소가 틀린 엔드포인트는 답하지 않고, 그러면 화면이 무한히 돈다 —
|
|
28
|
+
* 사용자에게는 "실패" 보다 못한 상태다(무엇을 기다리는지 모른 채 기다린다). 실 시스템 조회가 몇 초
|
|
29
|
+
* 걸리는 것은 정상이므로 넉넉히 두되, 끝은 있어야 한다.
|
|
30
|
+
*/
|
|
31
|
+
const DISCOVER_TIMEOUT_MS = 15_000
|
|
32
|
+
const TIMEOUT = 'discover-timeout'
|
|
33
|
+
|
|
34
|
+
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
|
|
35
|
+
return new Promise<T>((resolve, reject) => {
|
|
36
|
+
const timer = setTimeout(() => reject(Object.assign(new Error('timeout'), { code: TIMEOUT })), ms)
|
|
37
|
+
work.then(
|
|
38
|
+
v => { clearTimeout(timer); resolve(v) },
|
|
39
|
+
e => { clearTimeout(timer); reject(e) }
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
@Resolver()
|
|
18
45
|
export class TwinReferenceResolver {
|
|
19
46
|
/** 등록된 레퍼런스 소스 목록(요약 + 이미 인제스트됐는지). */
|
|
@@ -31,13 +58,14 @@ export class TwinReferenceResolver {
|
|
|
31
58
|
areaCount: m.locations.filter(l => l.role === 'area' || l.role === 'both').length,
|
|
32
59
|
equipmentCount: (m.equipment ?? []).length,
|
|
33
60
|
imported: !!reg,
|
|
34
|
-
running:
|
|
61
|
+
running: TwinEngine.owns(domainId, m.source)
|
|
35
62
|
})
|
|
36
63
|
}
|
|
37
64
|
return out
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
/** 소스를 인제스트 → 공간 + 인스턴스 생성(provision). start 면 기동까지. */
|
|
68
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
41
69
|
@Mutation(returns => ScalarObject, { description: 'Ingest a registered reference source into a twin (space + instance). Optionally start it.' })
|
|
42
70
|
async importFromReference(
|
|
43
71
|
@Arg('source') source: string,
|
|
@@ -63,30 +91,43 @@ export class TwinReferenceResolver {
|
|
|
63
91
|
}
|
|
64
92
|
|
|
65
93
|
/** 템플릿 + 노브 → 합성 마스터 → 인제스트(+기동+시나리오). 레퍼런스 import 와 같은 경로, 원천만 합성. */
|
|
94
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
66
95
|
@Mutation(returns => ScalarObject, { description: 'Instantiate a twin template with params → synthetic master → ingest (+start+scenario). Returns {ok, instanceId, spaceId}.' })
|
|
67
96
|
async createFromTemplate(
|
|
68
97
|
@Arg('templateId') templateId: string,
|
|
69
98
|
@Arg('params', type => ScalarObject) params: any,
|
|
70
99
|
@Ctx() context: ResolverContext,
|
|
71
|
-
@Arg('start', { nullable: true }) start?: boolean
|
|
100
|
+
@Arg('start', { nullable: true }) start?: boolean,
|
|
101
|
+
@Arg('spaceId', { nullable: true, description: 'Existing space to co-locate this twin into (add a lens to a site). Omit to create its own space.' }) spaceId?: string
|
|
72
102
|
): Promise<any> {
|
|
73
103
|
const tpl = getTemplate(templateId)
|
|
74
104
|
if (!tpl) return { ok: false, error: `unknown template: ${templateId}` }
|
|
75
105
|
const domainId = context.state.domain.id
|
|
76
106
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
/*
|
|
108
|
+
* **빠진 노브를 서버가 채운다.** 기본값을 아는 곳은 노브 스키마(계약)인데 그것을 적용하는 곳이
|
|
109
|
+
* 위자드뿐이었다 — 그래서 화면을 거치지 않는 호출자(AI·스크립트·API)가 파라미터를 일부만 주면
|
|
110
|
+
* 템플릿 안에서 발생률이 0 이 되고 **아무것도 하지 않는 트윈**이 조용히 만들어졌다.
|
|
111
|
+
* 채운 노브 이름은 경고로 함께 돌려준다 — 조용히 채우는 것도 조용히 비우는 것만큼 위험하다.
|
|
112
|
+
*/
|
|
113
|
+
const filled = filledKnobKeys(tpl.knobs, params)
|
|
114
|
+
const effective = applyKnobDefaults(tpl.knobs, params)
|
|
115
|
+
const { master, scenario } = tpl.build(effective)
|
|
116
|
+
/* 공간 선택은 템플릿을 거치지 않는다 — 템플릿마다 이 노브를 알고 있어야 한다면 새 템플릿이
|
|
117
|
+
조용히 무시하게 된다. 합성된 마스터에 한 곳에서 얹는다(레퍼런스 인제스트와 같은 경로). */
|
|
118
|
+
const res = await TwinEngine.ingestMaster(domainId, master, { spaceId })
|
|
79
119
|
if (start !== false) {
|
|
80
120
|
await TwinEngine.startFromRegistry(domainId, res.instanceId)
|
|
81
121
|
if (scenario) {
|
|
82
|
-
const runtime = TwinEngine.runtime(res.instanceId)
|
|
122
|
+
const runtime = TwinEngine.runtime(domainId, res.instanceId)
|
|
83
123
|
if (runtime) {
|
|
84
124
|
runtime.scenario.load(scenario)
|
|
85
125
|
runtime.scenario.start()
|
|
86
126
|
}
|
|
87
127
|
}
|
|
88
128
|
}
|
|
89
|
-
|
|
129
|
+
const warnings = filled.length ? [knobDefaultsFilled(filled), ...res.warnings] : res.warnings
|
|
130
|
+
return { ok: true, instanceId: res.instanceId, spaceId: res.spaceId, warnings }
|
|
90
131
|
} catch (e: any) {
|
|
91
132
|
return { ok: false, error: e?.message || 'template instantiation failed' }
|
|
92
133
|
}
|
|
@@ -155,7 +196,7 @@ export class TwinReferenceResolver {
|
|
|
155
196
|
.filter(p => instMap.has(p.instanceId))
|
|
156
197
|
.map(p => {
|
|
157
198
|
const inst = instMap.get(p.instanceId) as any
|
|
158
|
-
return { instanceId: p.instanceId, spaceId: p.spaceId, siteId: p.siteId, kind: inst.kind, status: inst.status, running:
|
|
199
|
+
return { instanceId: p.instanceId, spaceId: p.spaceId, siteId: p.siteId, kind: inst.kind, status: inst.status, running: TwinEngine.owns(domainId, p.instanceId) }
|
|
159
200
|
})
|
|
160
201
|
return {
|
|
161
202
|
source: r.source,
|
|
@@ -188,6 +229,7 @@ export class TwinReferenceResolver {
|
|
|
188
229
|
return listAdapters().map(a => ({ type: a.type, ...(a.meta ?? {}) }))
|
|
189
230
|
}
|
|
190
231
|
|
|
232
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
191
233
|
@Mutation(returns => ScalarObject, { description: 'Create or update a tenant-scoped twin reference (persisted).' })
|
|
192
234
|
async saveTwinReference(
|
|
193
235
|
@Arg('source') source: string,
|
|
@@ -219,6 +261,7 @@ export class TwinReferenceResolver {
|
|
|
219
261
|
}
|
|
220
262
|
|
|
221
263
|
/** 연결 해제 — removeTwins=true 면 이 레퍼런스가 만든 트윈(provenance)도 함께 제거(라이프사이클 연쇄). 기본은 트윈 보존(분리). */
|
|
264
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
222
265
|
@Mutation(returns => Boolean, { description: 'Delete a tenant-scoped reference. If removeTwins, also remove the twin instances it produced (provenance cascade); otherwise twins are kept (detached).' })
|
|
223
266
|
async deleteTwinReference(
|
|
224
267
|
@Arg('source') source: string,
|
|
@@ -241,6 +284,7 @@ export class TwinReferenceResolver {
|
|
|
241
284
|
return true
|
|
242
285
|
}
|
|
243
286
|
|
|
287
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
244
288
|
@Mutation(returns => ScalarObject, { description: 'Test a reference connection via its adapter; updates status.' })
|
|
245
289
|
async testReferenceConnection(@Arg('source') source: string, @Ctx() context: ResolverContext): Promise<any> {
|
|
246
290
|
const repo = getRepository(TwinReference)
|
|
@@ -262,16 +306,29 @@ export class TwinReferenceResolver {
|
|
|
262
306
|
* P1 은 대개 N=1(virtual/단일). start 면 각 인스턴스 기동. 라이브 피드·mode 는 face2-inbound-live 소유.
|
|
263
307
|
*/
|
|
264
308
|
/** 레퍼런스가 노출하는 사이트/공장 발견 — 트윈 생성 시 "어느 공장" 선택용(§2.2). */
|
|
265
|
-
@Query(returns =>
|
|
266
|
-
async discoverReferenceSites(@Arg('source') source: string, @Ctx() context: ResolverContext): Promise<any
|
|
309
|
+
@Query(returns => ScalarObject, { description: 'Discover sites/factories exposed by a reference. Returns { sites, error? } — an empty list with no error means the system genuinely has none, which is different from a failure.' })
|
|
310
|
+
async discoverReferenceSites(@Arg('source') source: string, @Ctx() context: ResolverContext): Promise<any> {
|
|
311
|
+
/*
|
|
312
|
+
* **"못 찾았다" 와 "없다" 를 가른다.** 예전에는 넷을 빈 배열 하나로 접었고(레퍼런스 없음·어댑터
|
|
313
|
+
* 미상·조회 실패·정말 0건), 화면은 그 모두를 "사이트를 찾지 못했습니다" 라고만 말했다. 실 시스템
|
|
314
|
+
* 연결에서 가장 자주 막히는 자리인데 자격이 틀렸는지 주소가 틀렸는지 알 수 없었다.
|
|
315
|
+
* 판정과 문장 형태는 `discovery-result`(순수)가 갖는다.
|
|
316
|
+
*/
|
|
267
317
|
const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: context.state.domain.id }, source } })
|
|
268
|
-
if (!ref) return
|
|
318
|
+
if (!ref) return failed(referenceNotFound(source))
|
|
269
319
|
const adapter = getAdapter(ref.adapterType)
|
|
270
|
-
if (!adapter) return
|
|
320
|
+
if (!adapter) return failed(adapterUnknown(ref.adapterType, source))
|
|
271
321
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
322
|
+
/*
|
|
323
|
+
* **기다려도 답이 없으면 사유를 준다.** 어댑터 계약에 시간 제한이 없어서, 주소가 틀린 엔드포인트는
|
|
324
|
+
* 화면을 무한히 돌게 만들었다 — 무한히 도는 화면은 조용한 실패의 다른 얼굴이다.
|
|
325
|
+
* 상한은 여기서 정한다(어댑터마다 다르게 두면 사용자가 어느 것을 기다리는지 알 수 없다).
|
|
326
|
+
*/
|
|
327
|
+
const sites = await withTimeout(adapter.discoverSites(ref.connectionConfig ?? {}), DISCOVER_TIMEOUT_MS)
|
|
328
|
+
return discovered(sites)
|
|
329
|
+
} catch (e: any) {
|
|
330
|
+
if (e?.code === TIMEOUT) return failed(discoveryTimedOut(source, DISCOVER_TIMEOUT_MS / 1000))
|
|
331
|
+
return failed(discoveryFailed(source, detailOf(e)))
|
|
275
332
|
}
|
|
276
333
|
}
|
|
277
334
|
|
|
@@ -279,6 +336,7 @@ export class TwinReferenceResolver {
|
|
|
279
336
|
* 트윈 생성 — 레퍼런스에서 사이트 발견 → (siteIds 있으면 그 공장만) → fetchMaster → provision. 사이트별 resilient.
|
|
280
337
|
* 여러 공장을 관할하는 소스는 siteIds 로 "어느 공장"을 골라 생성(1:N, §2.1·§2.2).
|
|
281
338
|
*/
|
|
339
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
282
340
|
@Mutation(returns => ScalarObject, { description: 'Create twins from a reference: discover sites → filter by siteIds (if given) → fetch master(s) → provision. start=false to only provision; live=true to run as a mirror (adapter openLiveFeed → live projector) instead of sim.' })
|
|
283
341
|
async importTwinReference(
|
|
284
342
|
@Arg('source') source: string,
|
|
@@ -323,7 +381,13 @@ export class TwinReferenceResolver {
|
|
|
323
381
|
{
|
|
324
382
|
status: 'connected',
|
|
325
383
|
lastSyncedAt: new Date().toISOString(),
|
|
326
|
-
|
|
384
|
+
/*
|
|
385
|
+
* **영어 canonical 로 남긴다.** 이 값은 DB 에 저장되고 연결 화면이 그대로 뿌린다 — 예전에는
|
|
386
|
+
* 한글 산문(`N개 사이트 실패`)이라 다섯 언어 화면에 한글이 새어 나갔다. 어댑터가 주는 사유도
|
|
387
|
+
* 같은 필드에 들어오므로(그것은 원 시스템의 말이다) 키가 아니라 영어 문장으로 통일한다.
|
|
388
|
+
* 어느 사이트가 왜 실패했는지는 `failed[]` 가 답한다 — 화면이 그것을 보여 준다.
|
|
389
|
+
*/
|
|
390
|
+
lastError: failed.length ? `${failed.length} of ${sites.length} site(s) failed: ${failed.map((f: any) => f.siteId).join(', ')}` : null,
|
|
327
391
|
scopeSpec: { ...((ref.scopeSpec as any) ?? {}), produced: [...prev, ...producedNew] }
|
|
328
392
|
} as any
|
|
329
393
|
)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Arg, Ctx, Query, Resolver } from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
2
2
|
|
|
3
3
|
import { ScalarObject } from '@things-factory/shell'
|
|
4
4
|
|
|
5
5
|
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
import { digestAttentions } from '../../engine/attention-digest.js'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
* 주목 신호(Attention) 질의 — 현재 스냅샷의 요약된 "봐야 할 것".
|
|
@@ -16,11 +17,47 @@ export class TwinAttentionQuery {
|
|
|
16
17
|
@Query(returns => [ScalarObject], {
|
|
17
18
|
description: 'Active attention signals (things to look at) derived from the current twin snapshot — severity (ISA-18.2), lifecycle state (OPC UA A&C), space anchor, and suggested action.'
|
|
18
19
|
})
|
|
19
|
-
async twinAttentions(
|
|
20
|
-
|
|
20
|
+
async twinAttentions(
|
|
21
|
+
@Ctx() context: ResolverContext,
|
|
22
|
+
@Arg('instanceId', { nullable: true }) instanceId?: string,
|
|
23
|
+
/*
|
|
24
|
+
* **공간으로 물으면 그 현장의 모든 렌즈에서 모아 답한다** — 한 현실을 WMS·MES·YMS 가 함께 비추므로
|
|
25
|
+
* "이 현장에서 봐야 할 것" 은 렌즈 하나로 답할 수 없다. 예전에는 화면이 인스턴스마다 물어 손으로
|
|
26
|
+
* 합쳤고, 그러면 정렬 규칙이 화면마다 갈린다.
|
|
27
|
+
*
|
|
28
|
+
* 경고는 **합집합이 그대로 뜻이 있는 유일한 렌즈**다. 예측은 합칠 수 없다(P50 두 개를 더할 수 없다) —
|
|
29
|
+
* 그래서 예측은 여전히 인스턴스별로 답하고 화면이 나란히 놓는다.
|
|
30
|
+
*/
|
|
31
|
+
@Arg('spaceId', { nullable: true }) spaceId?: string,
|
|
32
|
+
/** 상위 몇 건만 — 말하지 않으면 자르지 않는다. 전체 개수는 `twinAttentionDigest` 가 함께 답한다. */
|
|
33
|
+
@Arg('limit', type => Int, { nullable: true }) limit?: number
|
|
34
|
+
): Promise<any[]> {
|
|
35
|
+
if (spaceId) return (await TwinEngine.attentionsOfSpace(context.state.domain.id, spaceId, limit ?? undefined)).attentions
|
|
36
|
+
if (!instanceId || !TwinEngine.owns(context.state.domain.id, instanceId)) return [] // 테넌트 격리
|
|
21
37
|
// 라이브 커널 스냅샷(fresh) 우선 — snapshot()=resync() 는 캐시라 커맨드 델타 지연(recover 와 동일 원천 사용).
|
|
22
|
-
const kernel = TwinEngine.kernel(instanceId)
|
|
23
|
-
const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(instanceId)
|
|
24
|
-
|
|
38
|
+
const kernel = TwinEngine.kernel(context.state.domain.id, instanceId)
|
|
39
|
+
const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(context.state.domain.id, instanceId)
|
|
40
|
+
const list = snap?.attentions ?? []
|
|
41
|
+
return limit && limit > 0 ? digestAttentions(list, limit).attentions : list
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 잘랐다면 **몇 건을 잘랐는지**와 자리 색 요약까지 — 목록만 주면 화면이 "N건 더" 를 말할 수 없다.
|
|
46
|
+
* 자리별 최고 심각도는 **전량을 근거로** 접는다(상위 N 으로 접으면 20위 밖 신호를 가진 자리가 색을 잃는다).
|
|
47
|
+
*/
|
|
48
|
+
@Query(returns => ScalarObject, {
|
|
49
|
+
description: 'Attention signals for a space (all its lenses) or one twin, with the total count and the per-location highest severity folded from all of them.'
|
|
50
|
+
})
|
|
51
|
+
async twinAttentionDigest(
|
|
52
|
+
@Ctx() context: ResolverContext,
|
|
53
|
+
@Arg('spaceId', { nullable: true }) spaceId?: string,
|
|
54
|
+
@Arg('instanceId', { nullable: true }) instanceId?: string,
|
|
55
|
+
@Arg('limit', type => Int, { nullable: true }) limit?: number
|
|
56
|
+
): Promise<any> {
|
|
57
|
+
if (spaceId) return TwinEngine.attentionsOfSpace(context.state.domain.id, spaceId, limit ?? undefined)
|
|
58
|
+
if (!instanceId || !TwinEngine.owns(context.state.domain.id, instanceId)) return { attentions: [], attentionTotal: 0, severityByLocation: {} }
|
|
59
|
+
const kernel = TwinEngine.kernel(context.state.domain.id, instanceId)
|
|
60
|
+
const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(context.state.domain.id, instanceId)
|
|
61
|
+
return digestAttentions((snap?.attentions ?? []).map((a: any) => ({ ...a, instanceId })), limit ?? undefined)
|
|
25
62
|
}
|
|
26
63
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { Arg, Ctx, Mutation, Resolver } from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Mutation, Resolver, Directive} from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { validateScenario } from '@operato/twin-kernel'
|
|
2
4
|
|
|
3
5
|
import { ScalarObject } from '@things-factory/shell'
|
|
4
6
|
|
|
@@ -15,27 +17,31 @@ import { TwinEngine } from '../../engine/index.js'
|
|
|
15
17
|
@Resolver()
|
|
16
18
|
export class TwinControlMutation {
|
|
17
19
|
/** Command 채널 — 오더 hold/resume/release 등. 커널 dispatch 로 위임, ack{accepted,error} 반환. */
|
|
20
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
18
21
|
@Mutation(returns => ScalarObject, { description: 'dispatch a twin command (order.hold/resume/release …). Returns {accepted, error?}.' })
|
|
19
22
|
async dispatchTwinCommand(
|
|
20
23
|
@Arg('instanceId') instanceId: string,
|
|
21
24
|
@Arg('command', type => ScalarObject) command: any,
|
|
22
25
|
@Ctx() context: ResolverContext
|
|
23
|
-
): Promise<{ accepted: boolean; error?: string }> {
|
|
26
|
+
): Promise<{ accepted: boolean; error?: string; errorCode?: string; errorParams?: Record<string, string | number> }> {
|
|
24
27
|
const domainId = context.state.domain?.id
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
if (!domainId) return { accepted: false, error: 'no-domain', errorCode: 'no-domain' }
|
|
29
|
+
/*
|
|
30
|
+
* 라우팅은 엔진이 한다 — 예전에는 여기서 `inst.runtime.dispatch` 를 곧바로 불렀고, `runtime` 이
|
|
31
|
+
* 시뮬에만 있어서 미러 트윈에서는 이 한 줄이 TypeError 로 터졌다(깔끔한 거절이 아니라 실패).
|
|
32
|
+
* 권위(모드) × 성격(주석/액추에이션) 판정은 한 곳에만 둔다.
|
|
33
|
+
*/
|
|
34
|
+
const ack = await TwinEngine.dispatchCommand(domainId, instanceId, command)
|
|
31
35
|
/* 라이브 구조 변이(resource.add)는 저장 board 에도 반영 — 프로비저닝 편집기·재기동이 추가분을 잃지 않게. */
|
|
32
36
|
if (ack?.accepted && command?.type === 'resource.add') {
|
|
33
37
|
await TwinEngine.syncBoardEquipment(domainId, instanceId).catch(err => console.error('twin syncBoardEquipment fail', err))
|
|
34
38
|
}
|
|
35
|
-
|
|
39
|
+
/* 거절 사유는 **언어 중립 코드**로 함께 돌려준다 — 화면이 `twin.cmderr.<code>` 로 사람 말을 만든다. */
|
|
40
|
+
return { accepted: !!ack?.accepted, error: ack?.error, errorCode: ack?.errorCode, errorParams: ack?.errorParams }
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** Scenario 채널 — 생성기 시나리오 제어(load/start/pause/setSpeed). */
|
|
44
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
39
45
|
@Mutation(returns => Boolean, { description: 'control twin scenario (load|start|pause|setSpeed)' })
|
|
40
46
|
async controlTwinScenario(
|
|
41
47
|
@Arg('instanceId') instanceId: string,
|
|
@@ -44,14 +50,27 @@ export class TwinControlMutation {
|
|
|
44
50
|
@Arg('scenario', type => ScalarObject, { nullable: true }) scenario?: any
|
|
45
51
|
): Promise<boolean> {
|
|
46
52
|
const domainId = context.state.domain?.id
|
|
47
|
-
|
|
48
|
-
if (!
|
|
49
|
-
const runtime =
|
|
53
|
+
/* 테넌트 격리 — 레지스트리를 직접 색인하지 않는다(키에 도메인이 들어 있다). */
|
|
54
|
+
if (!TwinEngine.owns(domainId, instanceId)) return false
|
|
55
|
+
const runtime = TwinEngine.runtime(domainId, instanceId)
|
|
50
56
|
/*
|
|
51
57
|
* 자극 주입 가드 — mirror 트윈(외부 실물이 진실)은 커널 시나리오 엔진이 없다(startLive=projector, runtime 없음).
|
|
52
58
|
* what-if 를 여기에 주입하면 현실을 오염시키므로 거부한다(sim-experiment/sim-world 만 자극 가능). silent throw 방지.
|
|
53
59
|
*/
|
|
54
60
|
if (!runtime?.scenario) return false
|
|
61
|
+
/*
|
|
62
|
+
* **잘못된 선언은 문 앞에서 막는다.** 예전에는 검증 없이 실었고, 다음 tick 에서 `rate.meanPerHour`
|
|
63
|
+
* 를 읽다 터져 **서버 프로세스가 내려갔다**(클라이언트가 보낸 값 하나로 앱 전체가 죽었다).
|
|
64
|
+
* 규칙은 커널이 들고 있다(계약의 주인) — 호스트가 따로 만들면 방언이 생긴다.
|
|
65
|
+
*/
|
|
66
|
+
if (action === 'load') {
|
|
67
|
+
const v = validateScenario(scenario)
|
|
68
|
+
if (v.ok === false) {
|
|
69
|
+
/* 사유를 로그로 남긴다 — 이 뮤테이션의 반환은 Boolean 계약이라 사유를 실어 보낼 자리가 없다. */
|
|
70
|
+
console.warn(`[twin] scenario rejected for "${instanceId}": ${v.errorCode} ${JSON.stringify(v.errorParams ?? {})}`)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
55
74
|
if (action === 'load' && scenario) runtime.scenario.load(scenario)
|
|
56
75
|
else if (action === 'start') runtime.scenario.start()
|
|
57
76
|
else if (action === 'pause') runtime.scenario.pause()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Arg, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Int, Mutation, Query, Resolver, Directive} from 'type-graphql'
|
|
2
2
|
|
|
3
3
|
import { getRepository, ScalarObject } from '@things-factory/shell'
|
|
4
4
|
|
|
@@ -90,7 +90,7 @@ export class TwinForecastQuery {
|
|
|
90
90
|
const k = at
|
|
91
91
|
? await TwinEngine.buildForecastKernelAt(domainId, instanceId, at)
|
|
92
92
|
: TwinEngine.owns(domainId, instanceId)
|
|
93
|
-
? TwinEngine.kernel(instanceId)
|
|
93
|
+
? TwinEngine.kernel(domainId, instanceId)
|
|
94
94
|
: undefined
|
|
95
95
|
if (!k) return null
|
|
96
96
|
|
|
@@ -228,6 +228,7 @@ export class TwinForecastQuery {
|
|
|
228
228
|
* assimilation-lite: 잔차(innovation)를 되먹여 추정치를 현실로. per-instance(클라가 co-located 합산).
|
|
229
229
|
* 반환: 학습된 보정 + before/after 정확도(효과 정량화). 저장은 인메모리(세션) — DB 영속은 후속.
|
|
230
230
|
*/
|
|
231
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
231
232
|
@Mutation(returns => ScalarObject, { description: 'Learn a forecast calibration (bias + band) from the gap trend and persist it so future forecasts apply it. Returns the calibration + before/after accuracy.' })
|
|
232
233
|
async twinLearnCalibration(
|
|
233
234
|
@Arg('instanceId') instanceId: string,
|
|
@@ -2,6 +2,7 @@ import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, Primary
|
|
|
2
2
|
import { ObjectType, Field, ID, Int } from 'type-graphql'
|
|
3
3
|
|
|
4
4
|
import { Domain, ScalarObject } from '@things-factory/shell'
|
|
5
|
+
import { boardColumnTransformer } from '../../engine/board-vocabulary.js'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
* TwinInstance — 트윈 인스턴스 레지스트리(재부팅 복구의 앵커).
|
|
@@ -45,8 +46,11 @@ export class TwinInstance {
|
|
|
45
46
|
@Field({ nullable: true, description: "Reality source declaration: 'mirror' (external system is truth, resync on reboot) | 'sim-world' (generated timeline is truth and persists, resume on reboot) | 'sim-experiment' (seed-reproducible, resets on reboot). Absent ⇒ treated as sim-experiment." })
|
|
46
47
|
realityMode?: string
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
/* 읽을 때 어휘 세대를 흡수한다(`nodes`/`movers`/`equipmentList` → `locations`/`equipment`). (vocabulary-guard: allow — 세대 이름 없이는 설명할 수 없다.)
|
|
50
|
+
자리마다 리더를 쓰게 하면 다음에 추가되는 자리가 또 빠지고, 빠진 것은 오류가 아니라 **빈 공장**이라
|
|
51
|
+
조용하다(2026-08-06: 모델러 엔티티 목록이 통째로 비었다). 상세 `engine/board-vocabulary.ts`. */
|
|
52
|
+
@Column({ type: 'simple-json', nullable: true, transformer: boardColumnTransformer })
|
|
53
|
+
@Field(type => ScalarObject, { nullable: true, description: 'BoardDef blueprint (locations/equipment) required to replay events into state. Older stored boards used nodes/movers/equipmentList; they are normalised on read so consumers see one vocabulary.' }) // vocabulary-guard: allow — 옛 어휘를 흡수하는 것이 이 코드의 일이다(세대 이름을 적어야 흡수할 수 있다)
|
|
50
54
|
board?: any
|
|
51
55
|
|
|
52
56
|
@Column({ nullable: true })
|
|
@@ -6,6 +6,8 @@ import { buildNextCursor, cursorSortings, getQueryBuilderFromListParams, getRepo
|
|
|
6
6
|
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
7
7
|
import { TwinEventList } from '../twin-event/twin-event-type.js'
|
|
8
8
|
import { TwinEngine } from '../../engine/index.js'
|
|
9
|
+
import { pickAxes } from '../../engine/state-axes.js'
|
|
10
|
+
import { digestAttentions } from '../../engine/attention-digest.js'
|
|
9
11
|
import { computeTwinKpi, resolveTwinTargets } from '../../engine/kpi-query.js'
|
|
10
12
|
|
|
11
13
|
/*
|
|
@@ -126,7 +128,7 @@ export class TwinJournalQuery {
|
|
|
126
128
|
*/
|
|
127
129
|
@Query(returns => ScalarObject, {
|
|
128
130
|
description:
|
|
129
|
-
'Windowed business KPI folded from the event journal, for one space (every running twin in it, folded together — averaging per-twin percentiles would be wrong) or one twin: throughput (completed tasks / orders), lead / work / wait time distributions (p50·p90), and per-resource busy ratio. Distinct from twinMetrics, which reports infrastructure throughput (events per second). When toTime is omitted the window ends at the twin\'s own last recorded event time, not wall clock — a simulated twin lives on its own clock, so asking for \'the last hour\' in real time would always find nothing. The basis used is reported in window.basis. Pass groupBy (resource | taskKind | location | order | area | shift) to break the same window down by that perspective. The shift axis is **computed, not stored**: the journal records only times, and the shift is derived from each completion\'s time plus the declared site shifts and time zone — so correcting a shift definition re-counts history correctly instead of leaving it frozen under the old boundaries. Sites that declare no shifts report every group as unknown rather than inventing shift names — per-group numbers use the same rules as the totals, so group counts sum to the total, and groups beyond groupLimit are reported as groups.truncated (how many groups) and groups.truncatedTasks (how much work those groups held) rather than silently dropped. Pass compareTo (previous | yesterday) to fold the same-length window shifted back and get comparison.delta (current minus that window); when the comparison window has no records, comparison.measured is false and no delta is produced rather than reporting zero change. Passing groupBy together with compareTo crosses the two: each group carries prevTasks / deltaTasks / deltaWorkP50Ms, groups absent from the comparison window are flagged isNew (no delta, so growth is not implied), and groups that had work before but none now are returned in groups.disappeared so a stopped area is never silently missing from the table. Area breakdown needs board locations to carry a parent area; when they do not, groups.note says so instead of implying there was no work. When the journal read hits its row cap, eventsCapped is true and every number is a lower bound — narrow the window and ask again rather than reading the smaller figures as a drop in performance. When operational targets are set for the space or instance, `targets` carries the verdicts [{metric, actual, target, direction, met, ratio}] — ratio is normalised so that 1 or more is always good, letting throughput and duration metrics be compared on one scale. A metric with no target, or with no measurement in the window, is absent from `targets` rather than reported as a miss, because a period with no work would otherwise read as a perfect score on duration metrics. Completions are counted in the window they complete; starts are looked up before the window, and any completion whose start was not found is reported in `unpaired` rather than silently dropped.'
|
|
131
|
+
'Windowed business KPI folded from the event journal, for one space (every running twin in it, folded together — averaging per-twin percentiles would be wrong) or one twin: throughput (completed tasks / orders), lead / work / wait time distributions (p50·p90), and per-resource busy ratio. Distinct from twinMetrics, which reports infrastructure throughput (events per second). When toTime is omitted the window ends at the twin\'s own last recorded event time, not wall clock — a simulated twin lives on its own clock, so asking for \'the last hour\' in real time would always find nothing. The basis used is reported in window.basis. Pass groupBy (resource | taskKind | location | order | area | shift) to break the same window down by that perspective. The shift axis is **computed, not stored**: the journal records only times, and the shift is derived from each completion\'s time plus the declared site shifts and time zone — so correcting a shift definition re-counts history correctly instead of leaving it frozen under the old boundaries. Sites that declare no shifts report every group as unknown rather than inventing shift names — per-group numbers use the same rules as the totals, so group counts sum to the total, and groups beyond groupLimit are reported as groups.truncated (how many groups) and groups.truncatedTasks (how much work those groups held) rather than silently dropped. Pass compareTo (previous | yesterday) to fold the same-length window shifted back and get comparison.delta (current minus that window); when the comparison window has no records, comparison.measured is false and no delta is produced rather than reporting zero change. Passing groupBy together with compareTo crosses the two: each group carries prevTasks / deltaTasks / deltaWorkP50Ms, groups absent from the comparison window are flagged isNew (no delta, so growth is not implied), and groups that had work before but none now are returned in groups.disappeared so a stopped area is never silently missing from the table. Area breakdown needs board locations to carry a parent area; when they do not, groups.note says so instead of implying there was no work. When the journal read hits its row cap, eventsCapped is true and every number is a lower bound — narrow the window and ask again rather than reading the smaller figures as a drop in performance. When operational targets are set for the space or instance, `targets` carries the verdicts [{metric, actual, target, direction, met, ratio}] — ratio is normalised so that 1 or more is always good, letting throughput and duration metrics be compared on one scale. A metric with no target, or with no measurement in the window, is absent from `targets` rather than reported as a miss, because a period with no work would otherwise read as a perfect score on duration metrics. Completions are counted in the window they complete; starts are looked up before the window, and any completion whose start was not found is reported in `unpaired` rather than silently dropped. Pass baselinePeriods (1-14, with baselineStride day | week) to fold the same time-of-day window on each of the last N days (or the same weekday on each of the last N weeks) and get `baseline.metrics` — per metric the distribution of those windows (min / median / max) plus where the current window sits: `rank` (1 = smallest, direction-free) and `standing` (best | better-than-typical | typical | worse-than-typical | worst, which does apply the metric direction). This answers what a single comparison cannot: comparing to yesterday alone is useless if yesterday was also bad. Every requested window is returned in `samples` with `measured` true or false — windows without records are kept and counted (measuredCount vs requested) rather than dropped, so "the last 7 days" never silently means 3. No position is reported when the current window has no value or fewer than two baseline windows were measured; `noPosition` says which, because a missing distribution drawn as neutral would read as "typical". The baseline is produced even when the current window itself has no records, since that is exactly when knowing the usual level matters (a stopped line).'
|
|
130
132
|
})
|
|
131
133
|
async twinKpi(
|
|
132
134
|
@Ctx() context: ResolverContext,
|
|
@@ -147,7 +149,11 @@ export class TwinJournalQuery {
|
|
|
147
149
|
/** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 알린다. */
|
|
148
150
|
@Arg('groupLimit', type => Int, { nullable: true }) groupLimit?: number,
|
|
149
151
|
/** 비교 기준 — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday). */
|
|
150
|
-
@Arg('compareTo', { nullable: true }) compareTo?: string
|
|
152
|
+
@Arg('compareTo', { nullable: true }) compareTo?: string,
|
|
153
|
+
/** 여러 구간 동시 비교 — 최근 N 구간의 같은 시간대 분포(1~14). */
|
|
154
|
+
@Arg('baselinePeriods', type => Int, { nullable: true }) baselinePeriods?: number,
|
|
155
|
+
/** 분포의 간격 — day(같은 시간대) | week(같은 요일·시간대). 기본 day. */
|
|
156
|
+
@Arg('baselineStride', { nullable: true }) baselineStride?: string
|
|
151
157
|
): Promise<any> {
|
|
152
158
|
/* 테넌트 격리 — 트윈 지정이면 소속을 확인한다. 공간 지정이면 대상 해소가 도메인 목록에서
|
|
153
159
|
* 이뤄지므로(computeTwinKpi) 남의 트윈이 섞이지 않는다. 조용한 빈 결과가 아니라 명시 실패. */
|
|
@@ -168,7 +174,10 @@ export class TwinJournalQuery {
|
|
|
168
174
|
lookbackMinutes,
|
|
169
175
|
groupBy: groupBy as any,
|
|
170
176
|
groupLimit,
|
|
171
|
-
compareTo: compareTo as any
|
|
177
|
+
compareTo: compareTo as any,
|
|
178
|
+
...(baselinePeriods && baselinePeriods > 0
|
|
179
|
+
? { baseline: { periods: baselinePeriods, stride: (baselineStride as any) === 'week' ? 'week' : 'day' } }
|
|
180
|
+
: {})
|
|
172
181
|
})
|
|
173
182
|
}
|
|
174
183
|
|
|
@@ -180,9 +189,30 @@ export class TwinJournalQuery {
|
|
|
180
189
|
@Arg('instanceId') instanceId: string,
|
|
181
190
|
@Arg('untilRevision', type => Int, { nullable: true }) untilRevision: number,
|
|
182
191
|
@Arg('untilTime', { nullable: true }) untilTime: string,
|
|
183
|
-
@Ctx() context: ResolverContext
|
|
192
|
+
@Ctx() context: ResolverContext,
|
|
193
|
+
/*
|
|
194
|
+
* 필요한 축만 — 지도는 `locations`·`attentions` 만 쓰는데 상태를 통째로 받고 있었다(실 저널로
|
|
195
|
+
* 재 보니 트윈 하나가 최대 710KB, 같은 공간에 셋이면 약 2MB). 화면 전용 질의를 만들지 않고
|
|
196
|
+
* 호출부가 축을 말한다(무방언 — 어휘가 화면 이름이 아니라 상태의 축이다).
|
|
197
|
+
* 말하지 않으면 자르지 않는다(기존 호출부 무영향).
|
|
198
|
+
*/
|
|
199
|
+
@Arg('axes', type => [String], { nullable: true }) axes?: string[],
|
|
200
|
+
/*
|
|
201
|
+
* 신호는 몇 건만 필요한가 — 레일은 20건을 그리고 나머지는 "N건 더" 라고만 말한다. 그런데 신호가
|
|
202
|
+
* 많은 트윈은 그 전량이 108KB 다(`hatio-yard` 397건). 상위 N 만 보내고 **전체 개수와 자리별
|
|
203
|
+
* 최고 심각도는 전량을 근거로** 함께 보낸다 — 레일은 잘라도 되지만 지도 색은 잘라선 안 된다.
|
|
204
|
+
* 말하지 않으면 자르지 않는다.
|
|
205
|
+
*/
|
|
206
|
+
@Arg('attentionLimit', type => Int, { nullable: true }) attentionLimit?: number
|
|
184
207
|
): Promise<any> {
|
|
185
|
-
|
|
208
|
+
const state = await TwinEngine.recover(context.state.domain.id, instanceId, untilRevision ?? undefined, untilTime ?? undefined)
|
|
209
|
+
const picked = pickAxes(state, axes)
|
|
210
|
+
/* 오타를 조용히 넘기지 않는다 — 화면이 빈 채로 뜨는 이유를 아무도 모르게 되는 것이 가장 나쁘다. */
|
|
211
|
+
if (picked.unknown.length) console.warn(`[twin] twinReplay: unknown axes ignored for "${instanceId}": ${picked.unknown.join(', ')}`)
|
|
212
|
+
if (!attentionLimit || !picked.state?.attentions) return picked.state
|
|
213
|
+
const d = digestAttentions(picked.state.attentions, attentionLimit)
|
|
214
|
+
/* 잘랐다는 사실을 값으로 남긴다 — 개수만 줄어들면 화면은 "괜찮아졌다" 로 읽는다. */
|
|
215
|
+
return { ...picked.state, attentions: d.attentions, attentionTotal: d.attentionTotal, severityByLocation: d.severityByLocation }
|
|
186
216
|
}
|
|
187
217
|
|
|
188
218
|
@Query(returns => ScalarObject, {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'
|
|
2
2
|
|
|
3
3
|
import { ScalarObject } from '@things-factory/shell'
|
|
4
4
|
|
|
@@ -19,6 +19,7 @@ import { domainCatalog } from './domain-catalog.js'
|
|
|
19
19
|
@Resolver()
|
|
20
20
|
export class TwinLifecycleMutation {
|
|
21
21
|
/** 프로비저닝(저장, 미기동) — 구조를 레지스트리에 등록만. 재프로비전 시 구조 변경이면 저널 purge(엔진). */
|
|
22
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
22
23
|
@Mutation(returns => Boolean, {
|
|
23
24
|
description: 'Provision (save) a twin instance structure (kind: wms|yms|mes) from a BoardDef without starting it.'
|
|
24
25
|
})
|
|
@@ -33,6 +34,7 @@ export class TwinLifecycleMutation {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** 기동 — board 를 주면 provision+start, 없으면 레지스트리에 저장된 구조로 start. */
|
|
37
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
36
38
|
@Mutation(returns => Boolean, {
|
|
37
39
|
description: 'Start a twin runtime instance and begin the worker tick. If board is given it provisions first; otherwise it starts from the saved registry board.'
|
|
38
40
|
})
|
|
@@ -49,14 +51,16 @@ export class TwinLifecycleMutation {
|
|
|
49
51
|
return true
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
52
55
|
@Mutation(returns => Boolean, { description: 'Stop a running twin runtime instance by its id (only if it belongs to the current domain).' })
|
|
53
56
|
async stopTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
54
57
|
if (!TwinEngine.owns(context.state.domain.id, instanceId)) return false // 테넌트 격리 — 남의 도메인 트윈 정지 차단
|
|
55
|
-
await TwinEngine.stop(instanceId)
|
|
58
|
+
await TwinEngine.stop(context.state.domain.id, instanceId)
|
|
56
59
|
return true
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */
|
|
63
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
60
64
|
@Mutation(returns => Boolean, { description: 'Delete a twin instance: stop it, remove the registry entry and purge its event journal.' })
|
|
61
65
|
async deleteTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
62
66
|
await TwinEngine.remove(context.state.domain.id, instanceId)
|
|
@@ -64,6 +68,18 @@ export class TwinLifecycleMutation {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/** 관리 목록 — 등록된 인스턴스 전체(kind/status/running/revision/노드·무버 수). 데이터 없으면 빈 배열. */
|
|
71
|
+
/**
|
|
72
|
+
* **이 공간의 트윈 id 들** — 한 현실을 여러 렌즈가 비추므로 공간을 물으면 그 렌즈 전부를 답한다.
|
|
73
|
+
*
|
|
74
|
+
* 화면이 전체 목록을 받아 걸러 쓰던 규칙을 서버로 모은다. 예측 화면은 같은 확장을 한 파일에서
|
|
75
|
+
* **두 번** 복제하고 있었고, 규칙이 흩어지면 한쪽만 고쳐진다. 떠 있지 않은 것도 포함한다 —
|
|
76
|
+
* 공간에 무엇이 있는지는 기동 여부와 다른 사실이다.
|
|
77
|
+
*/
|
|
78
|
+
@Query(returns => [String], { description: 'Ids of the twins that live in a space (all its lenses), running or not.' })
|
|
79
|
+
async twinSpaceInstanceIds(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise<string[]> {
|
|
80
|
+
return TwinEngine.instanceIdsOfSpace(context.state.domain.id, spaceId)
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
@Query(returns => [ScalarObject], { description: 'List provisioned twin instances in the current domain with management metadata.' })
|
|
68
84
|
async twinInstanceList(@Ctx() context: ResolverContext): Promise<any[]> {
|
|
69
85
|
return TwinEngine.list(context.state.domain.id)
|
|
@@ -14,7 +14,7 @@ export class TwinMetricsQuery {
|
|
|
14
14
|
@Query(returns => ScalarObject, { nullable: true, description: 'Live throughput metrics of one twin instance (ingest/broadcast/journal rate, backlog). Null if not a live instance or not in the current domain.' })
|
|
15
15
|
twinMetrics(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): any {
|
|
16
16
|
if (!TwinEngine.owns(context.state.domain.id, instanceId)) return null // 테넌트 격리
|
|
17
|
-
return TwinEngine.metrics(instanceId)
|
|
17
|
+
return TwinEngine.metrics(context.state.domain.id, instanceId)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
@Query(returns => [ScalarObject], { description: 'Live throughput metrics of all running live twin instances in the current domain (monitor dashboard).' })
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'
|
|
2
2
|
|
|
3
3
|
import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
4
4
|
|
|
@@ -47,6 +47,7 @@ export class TwinSpaceResolver {
|
|
|
47
47
|
return { spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, timezone: r.timezone, primaryRepresentationId: r.primaryRepresentationId, content: r.content, representations }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
50
51
|
@Mutation(returns => Boolean, { description: 'Create or update a Space (name/description/geo/timezone/primaryRepresentationId).' })
|
|
51
52
|
async saveTwinSpace(
|
|
52
53
|
@Arg('spaceId') spaceId: string,
|
|
@@ -62,6 +63,7 @@ export class TwinSpaceResolver {
|
|
|
62
63
|
return true
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
65
67
|
@Mutation(returns => Boolean, { description: 'Delete a Space and its representations/areas.' })
|
|
66
68
|
async deleteTwinSpace(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
67
69
|
const domainId = context.state.domain.id
|
|
@@ -92,6 +94,7 @@ export class TwinSpaceResolver {
|
|
|
92
94
|
return rows.map(a => ({ areaId: a.areaId, name: a.name, type: a.type, parentId: a.parentId, layout: a.layout }))
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
95
98
|
@Mutation(returns => Boolean, { description: 'Create/update a logical area (shared across representations). (area unification)' })
|
|
96
99
|
async saveTwinArea(
|
|
97
100
|
@Arg('spaceId') spaceId: string,
|
|
@@ -124,6 +127,7 @@ export class TwinSpaceResolver {
|
|
|
124
127
|
* 참조: 자식 area(parentId) · 표현별 폴리곤(TwinSpaceArea binding.groupId/areaId).
|
|
125
128
|
* (노드/엔티티 담김·drillTo 참조는 P2 areaId 배선 후 확장.)
|
|
126
129
|
*/
|
|
130
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
127
131
|
@Mutation(returns => Boolean, { description: 'Delete a logical area only if unreferenced (reference-counted). Throws if referenced. (area unification)' })
|
|
128
132
|
async deleteTwinArea(@Arg('spaceId') spaceId: string, @Arg('areaId') areaId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
129
133
|
const domainId = context.state.domain.id
|
|
@@ -146,6 +150,7 @@ export class TwinSpaceResolver {
|
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
/* ── Representation ─────────────────────────────────────────────── */
|
|
153
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
149
154
|
@Mutation(returns => ScalarObject, { description: 'Create/update a representation (map|image|board). Returns {id}.' })
|
|
150
155
|
async saveSpaceRepresentation(
|
|
151
156
|
@Arg('spaceId') spaceId: string,
|
|
@@ -161,6 +166,7 @@ export class TwinSpaceResolver {
|
|
|
161
166
|
return { ok: true, id: saved.id }
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
164
170
|
@Mutation(returns => Boolean, { description: 'Delete a representation and its areas.' })
|
|
165
171
|
async deleteSpaceRepresentation(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
166
172
|
const domainId = context.state.domain.id
|
|
@@ -169,6 +175,7 @@ export class TwinSpaceResolver {
|
|
|
169
175
|
return true
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
172
179
|
@Mutation(returns => Boolean, { description: 'Set the primary representation of a space.' })
|
|
173
180
|
async setPrimaryRepresentation(@Arg('spaceId') spaceId: string, @Arg('repId') repId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
174
181
|
const domainId = context.state.domain.id
|
|
@@ -183,6 +190,7 @@ export class TwinSpaceResolver {
|
|
|
183
190
|
}
|
|
184
191
|
|
|
185
192
|
/* ── Area (map/image 폴리곤) ────────────────────────────────────── */
|
|
193
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
186
194
|
@Mutation(returns => ScalarObject, { description: 'Create/update a polygon area (geometry + drillTo board + binding). Returns {id}.' })
|
|
187
195
|
async saveSpaceArea(
|
|
188
196
|
@Arg('representationId') representationId: string,
|
|
@@ -198,6 +206,7 @@ export class TwinSpaceResolver {
|
|
|
198
206
|
return { ok: true, id: saved.id }
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
201
210
|
@Mutation(returns => Boolean, { description: 'Delete a polygon area.' })
|
|
202
211
|
async deleteSpaceArea(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
203
212
|
await getRepository(TwinSpaceArea).delete({ domain: { id: context.state.domain.id } as any, id } as any)
|