@things-factory/headless-twin 10.0.11 → 10.0.12
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 +25 -2
- package/dist-server/engine/canonical-ingest.js +48 -10
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/declared-stimulus.d.ts +43 -0
- package/dist-server/engine/declared-stimulus.js +57 -0
- package/dist-server/engine/declared-stimulus.js.map +1 -0
- package/dist-server/engine/index.d.ts +2 -0
- package/dist-server/engine/index.js +2 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +12 -0
- package/dist-server/engine/kpi-fold.js +21 -1
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +3 -3
- package/dist-server/engine/kpi-query.js +4 -4
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-feed-registry.d.ts +1 -1
- package/dist-server/engine/live-feed-registry.js +1 -1
- package/dist-server/engine/live-feed-registry.js.map +1 -1
- package/dist-server/engine/local-declarations.d.ts +3 -6
- package/dist-server/engine/local-declarations.js +97 -16
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/measured-yield.d.ts +42 -0
- package/dist-server/engine/measured-yield.js +75 -0
- package/dist-server/engine/measured-yield.js.map +1 -0
- package/dist-server/engine/operation-basis.d.ts +16 -0
- package/dist-server/engine/operation-basis.js +20 -2
- package/dist-server/engine/operation-basis.js.map +1 -1
- package/dist-server/engine/property-effects.js +17 -0
- package/dist-server/engine/property-effects.js.map +1 -1
- package/dist-server/engine/restart-policy.d.ts +13 -0
- package/dist-server/engine/restart-policy.js +52 -0
- package/dist-server/engine/restart-policy.js.map +1 -0
- package/dist-server/engine/spec-coverage.d.ts +8 -0
- package/dist-server/engine/spec-coverage.js +3 -1
- package/dist-server/engine/spec-coverage.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +48 -15
- package/dist-server/engine/twin-engine.js +179 -39
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/reference/control-routing.d.ts +14 -0
- package/dist-server/service/reference/control-routing.js +64 -0
- package/dist-server/service/reference/control-routing.js.map +1 -0
- package/dist-server/service/reference/index.d.ts +1 -0
- package/dist-server/service/reference/index.js +2 -0
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +67 -0
- package/dist-server/service/reference/reference-adapter.js +18 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +3 -3
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +3 -3
- package/dist-server/service/reference/reference-resolver.js +35 -18
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
- package/dist-server/service/twin-audit/command-audit.js +15 -1
- package/dist-server/service/twin-audit/command-audit.js.map +1 -1
- package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
- package/dist-server/service/twin-audit/twin-audit-event.js +10 -0
- package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
- package/dist-server/service/twin-control/twin-control-mutation.js +85 -34
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +2 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/package.json +3 -3
- package/server/engine/canonical-ingest.ts +78 -14
- package/server/engine/declared-stimulus.ts +66 -0
- package/server/engine/index.ts +2 -0
- package/server/engine/kpi-fold.ts +29 -1
- package/server/engine/kpi-query.ts +8 -8
- package/server/engine/live-feed-registry.ts +2 -2
- package/server/engine/local-declarations.ts +97 -19
- package/server/engine/measured-yield.ts +89 -0
- package/server/engine/operation-basis.ts +33 -2
- package/server/engine/property-effects.ts +17 -0
- package/server/engine/restart-policy.ts +55 -0
- package/server/engine/spec-coverage.ts +23 -3
- package/server/engine/twin-engine.ts +201 -42
- package/server/index.ts +1 -1
- package/server/service/reference/control-routing.ts +62 -0
- package/server/service/reference/index.ts +1 -0
- package/server/service/reference/reference-adapter.ts +78 -0
- package/server/service/reference/reference-live.ts +3 -3
- package/server/service/reference/reference-resolver.ts +46 -7
- package/server/service/twin-audit/command-audit.ts +34 -1
- package/server/service/twin-audit/twin-audit-event.ts +20 -0
- package/server/service/twin-control/twin-control-mutation.ts +75 -28
- package/server/service/twin-instance/twin-instance.ts +16 -10
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
- package/server/service/twin-model/twin-model-query.ts +1 -1
- package/test/adopt-structure-live.test.ts +7 -7
- package/test/boot-resume.test.ts +3 -3
- package/test/control-capability.test.ts +103 -0
- package/test/declared-stimulus.test.ts +88 -0
- package/test/ingest-running-guard.test.ts +5 -5
- package/test/instance-cache-lifecycle.test.ts +1 -1
- package/test/kpi-baseline-db.test.ts +1 -1
- package/test/kpi-query-bench.test.ts +1 -1
- package/test/lineage-survives-restart.test.ts +3 -3
- package/test/live-feed-registry.test.ts +6 -6
- package/test/local-declarations.test.ts +108 -1
- package/test/measured-yield.test.ts +90 -0
- package/test/operation-basis.test.ts +28 -1
- package/test/operational-vocabulary.test.ts +108 -0
- package/test/operations-capability-db.test.ts +4 -4
- package/test/project-structure-db.test.ts +1 -1
- package/test/projection-reaches-screen.test.ts +1 -1
- package/test/property-effects.test.ts +28 -0
- package/test/restart-policy.test.ts +111 -0
- package/test/resync-origin-site.test.ts +7 -1
- package/test/source-outcome-audit.test.ts +104 -0
- package/test/structure-revision-db.test.ts +27 -27
- package/test/tenant-registry-db.test.ts +2 -2
- package/test/twin-model-item-db.test.ts +3 -3
- package/test/twin-model-tree-db.test.ts +7 -7
- package/test/twin-origin-resync.test.ts +4 -4
- package/test/yield-loop.test.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -21,6 +21,8 @@ import { applyDeclarationLayers } from './local-declarations.js'
|
|
|
21
21
|
import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
|
|
22
22
|
import { routeCommand } from './command-routing.js'
|
|
23
23
|
import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
24
|
+
/* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
|
|
25
|
+
import { TwinReference } from '../service/reference/twin-reference.js'
|
|
24
26
|
import { TwinStructure } from '../service/twin-structure/twin-structure.js'
|
|
25
27
|
import { TwinSpace } from '../service/twin-space/twin-space.js'
|
|
26
28
|
import { utcOffsetOf } from '../service/reference/reference-master.js'
|
|
@@ -33,6 +35,9 @@ import { type IngestWarning, describeWarnings, unresolvedReference, projectionFa
|
|
|
33
35
|
import { projectStructure } from '../service/twin-model/project-structure.js'
|
|
34
36
|
import { buildTravelEstimator, chainEstimators } from './travel-estimator.js'
|
|
35
37
|
import { buildMeasuredEstimator } from './measured-estimator.js'
|
|
38
|
+
import { buildYieldEstimator } from './measured-yield.js'
|
|
39
|
+
import { planStimulus, withStimulus } from './declared-stimulus.js'
|
|
40
|
+
import { readRestartPolicy, type RestartPolicy } from './restart-policy.js'
|
|
36
41
|
import { describeModelBasis, type ModelBasis } from './model-basis.js'
|
|
37
42
|
import { computeTwinKpi } from './kpi-query.js'
|
|
38
43
|
import { createHash } from 'node:crypto'
|
|
@@ -49,7 +54,7 @@ import { EMS_PROPERTY } from '@operato/twin-kernel'
|
|
|
49
54
|
import type { TwinKernel, TwinModelDef, StructureShift, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
|
|
50
55
|
|
|
51
56
|
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
|
|
52
|
-
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
|
|
57
|
+
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/twin-kernel')
|
|
53
58
|
|
|
54
59
|
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
|
|
55
60
|
|
|
@@ -86,19 +91,19 @@ interface TwinMetrics {
|
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
94
|
+
* 재기동 정책은 **선언이고 기본값이 없다** — 정의·판정은 `engine/restart-policy.ts` 가 든다(ADR-0029 §2).
|
|
95
|
+
*
|
|
96
|
+
* 예전에는 여기에 `DEFAULT_REALITY_MODE = 'sim-experiment'` 가 있었고, 미선언이 조용히 **저널 초기화**로
|
|
97
|
+
* 떨어졌다. 그 관용은 값이 하나 어긋나는 순간 이력을 지우는 길이 된다 — 그래서 없앴다.
|
|
92
98
|
*/
|
|
93
|
-
export type
|
|
94
|
-
export const DEFAULT_REALITY_MODE: RealityMode = 'sim-experiment'
|
|
99
|
+
export type { RestartPolicy } from './restart-policy.js'
|
|
95
100
|
|
|
96
101
|
/* 인메모리 라이브 런타임 홀더(영속 엔티티 TwinInstance 와 구분). */
|
|
97
102
|
interface InstanceRuntime {
|
|
98
103
|
/** 부하 계기판 — 작업별 소요. 시뮬·라이브 모두 붙는다(누가 루프를 점유하는지 보려면 둘 다 필요하다). */
|
|
99
104
|
load?: LoadMeter
|
|
100
105
|
/** 현실 출처 선언(§0 프레임 ①). 부팅 거동(reset/resume/resync)의 근거. */
|
|
101
|
-
|
|
106
|
+
restartPolicy?: RestartPolicy
|
|
102
107
|
id: string
|
|
103
108
|
domainId: string
|
|
104
109
|
domain?: any // 라이브 바인딩(data 채널)용 Domain 객체(subdomain 필터). start 시 비동기 해석.
|
|
@@ -400,7 +405,7 @@ export class TwinEngine {
|
|
|
400
405
|
*
|
|
401
406
|
* ── 모드를 지어내지 않는다 ──────────────────────────────────────────────────
|
|
402
407
|
* 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다).
|
|
403
|
-
* 그래서 선언된 `
|
|
408
|
+
* 그래서 선언된 `restartPolicy` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
|
|
404
409
|
*
|
|
405
410
|
* ── 되살릴 수 없으면 그렇게 적는다 ──────────────────────────────────────────
|
|
406
411
|
* 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한
|
|
@@ -422,7 +427,7 @@ export class TwinEngine {
|
|
|
422
427
|
}
|
|
423
428
|
|
|
424
429
|
try {
|
|
425
|
-
if (row.
|
|
430
|
+
if (row.restartPolicy === 'resync') {
|
|
426
431
|
if (!row.model) throw new Error('no model')
|
|
427
432
|
/* 시각 기준은 **공간**이 갖는다 — 교대의 HH:MM 을 어느 기준으로 읽나(라이브 기동과 같은 규칙). */
|
|
428
433
|
this.startLive(instanceId, domainId, row.kind, await this.withSpaceTimeBase(row.model as TwinModelDef, domainId))
|
|
@@ -431,7 +436,7 @@ export class TwinEngine {
|
|
|
431
436
|
console.log(`[twin-engine] resumed mirror "${instanceId}" — feed reattach is the reference layer's job.`)
|
|
432
437
|
} else {
|
|
433
438
|
await this.startFromRegistry(domainId, instanceId)
|
|
434
|
-
console.log(`[twin-engine] resumed ${row.
|
|
439
|
+
console.log(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`)
|
|
435
440
|
}
|
|
436
441
|
} catch (err: any) {
|
|
437
442
|
await this.markStopped(row, err?.message ?? 'resume failed')
|
|
@@ -548,6 +553,22 @@ export class TwinEngine {
|
|
|
548
553
|
}
|
|
549
554
|
}
|
|
550
555
|
}
|
|
556
|
+
/* 공정 모수(수율·셋업)도 같은 규율로 싣는다 — 커널이 그 창구를 갖지 않으면 그 사실을 말한다. */
|
|
557
|
+
const params = model?.localParams as Record<string, Record<string, string>> | undefined
|
|
558
|
+
if (params && Object.keys(params).length) {
|
|
559
|
+
if (typeof kernel?.declareParameters !== 'function') {
|
|
560
|
+
console.warn(
|
|
561
|
+
`[twin-engine] "${id}": this facility declared operation parameters for ${Object.keys(params).length} operation(s) but the kernel cannot consume them ` +
|
|
562
|
+
'(declareParameters missing — kernel needs publishing). Yield and setup keep running on built-in constants.'
|
|
563
|
+
)
|
|
564
|
+
} else {
|
|
565
|
+
try {
|
|
566
|
+
kernel.declareParameters(params)
|
|
567
|
+
} catch (err: any) {
|
|
568
|
+
console.warn(`[twin-engine] "${id}": declared operation parameter rejected by the kernel — ${err?.message ?? err}`)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
551
572
|
const ops = model?.operations
|
|
552
573
|
if (!ops?.length) return
|
|
553
574
|
if (typeof kernel?.loadOperations !== 'function') {
|
|
@@ -580,6 +601,35 @@ export class TwinEngine {
|
|
|
580
601
|
return
|
|
581
602
|
}
|
|
582
603
|
kernel.durationEstimator = chained
|
|
604
|
+
/*
|
|
605
|
+
* **양품률도 이력에서 배운다** (2026-08-19) — 소요와 같은 자리에서 붙인다.
|
|
606
|
+
*
|
|
607
|
+
* 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 조용히 넘어가지 않고 말한다: 수율이 상수로 남은
|
|
608
|
+
* 이유를 모르고 지나가면, 화면의 불량 판정이 그 현장의 사실이 아니라 우리 상수의 결과다.
|
|
609
|
+
*/
|
|
610
|
+
const yields = await this.measuredYield(domainId, instanceId)
|
|
611
|
+
if (yields?.estimator) {
|
|
612
|
+
if (typeof kernel.yieldOf !== 'function') {
|
|
613
|
+
console.warn(
|
|
614
|
+
`[twin-engine] "${instanceId}": learned yield for ${Object.keys(yields.learned).length} operation kind(s) but the kernel cannot consume it ` +
|
|
615
|
+
'(yieldOf missing — kernel needs publishing). Yield keeps running on the declared value or the built-in constant.'
|
|
616
|
+
)
|
|
617
|
+
} else {
|
|
618
|
+
kernel.yieldEstimator = yields.estimator
|
|
619
|
+
console.log(
|
|
620
|
+
`[twin-engine] "${instanceId}": measured yield installed — ${Object.entries(yields.learned)
|
|
621
|
+
.map(([k, v]) => `${k}=${Math.round(v * 1000) / 10}%(${yields.samples[k].good + yields.samples[k].scrap}건)`)
|
|
622
|
+
.join(', ')}${yields.skipped.length ? ` · 표본 부족으로 뺀 종류: ${yields.skipped.map(s => `${s.kind}(${s.judged})`).join(', ')}` : ''}`
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
} else if (yields?.skipped.length) {
|
|
626
|
+
/* 배운 것이 없고 버린 것만 있으면 그 사실도 말한다 — 「이력이 없다」와 「표본이 모자라다」는 다르다. */
|
|
627
|
+
console.log(
|
|
628
|
+
`[twin-engine] "${instanceId}": no measured yield yet — samples below ${yields.minSamples}: ${yields.skipped
|
|
629
|
+
.map(s => `${s.kind}(${s.judged})`)
|
|
630
|
+
.join(', ')}`
|
|
631
|
+
)
|
|
632
|
+
}
|
|
583
633
|
const learned = Object.keys(measured?.learned ?? {})
|
|
584
634
|
const spread = Object.keys(measured?.spreads ?? {})
|
|
585
635
|
console.log(
|
|
@@ -596,7 +646,15 @@ export class TwinEngine {
|
|
|
596
646
|
* 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로
|
|
597
647
|
* 짧은 TTL 로 재사용한다. 캐시는 인스턴스별이고, 만료 전에는 같은 값을 쓴다(예측 간 일관성도 얻는다).
|
|
598
648
|
*/
|
|
599
|
-
private static measuredCache = new Map<
|
|
649
|
+
private static measuredCache = new Map<
|
|
650
|
+
string,
|
|
651
|
+
{
|
|
652
|
+
at: number
|
|
653
|
+
value: ReturnType<typeof buildMeasuredEstimator> | undefined
|
|
654
|
+
/** 같은 폴드에서 나온 양품률 — **저널을 두 번 접지 않는다**(소요와 수율은 같은 창의 같은 사실이다). */
|
|
655
|
+
yields?: ReturnType<typeof buildYieldEstimator>
|
|
656
|
+
}
|
|
657
|
+
>()
|
|
600
658
|
private static readonly MEASURED_TTL_MS = 60_000
|
|
601
659
|
/**
|
|
602
660
|
* 캐시 항목 상한 — **라이프사이클이 놓친 것까지 막는 두 번째 방어.**
|
|
@@ -613,22 +671,31 @@ export class TwinEngine {
|
|
|
613
671
|
*/
|
|
614
672
|
private static readonly MEASURED_MAX = 5_000
|
|
615
673
|
|
|
674
|
+
/** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 접지 않는다). */
|
|
675
|
+
private static async measuredYield(domainId: string, instanceId: string) {
|
|
676
|
+
await this.measuredEstimator(domainId, instanceId)
|
|
677
|
+
return this.measuredCache.get(runtimeKey(domainId, instanceId))?.yields
|
|
678
|
+
}
|
|
679
|
+
|
|
616
680
|
private static async measuredEstimator(domainId: string, instanceId: string) {
|
|
617
681
|
/* 키는 `runtimeKey` 하나로 — 손으로 조립하면 지우는 쪽과 어긋나 못 지우는 항목이 생긴다. */
|
|
618
682
|
const key = runtimeKey(domainId, instanceId)
|
|
619
683
|
const hit = this.measuredCache.get(key)
|
|
620
684
|
if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS) return hit.value
|
|
621
685
|
let value: ReturnType<typeof buildMeasuredEstimator> | undefined
|
|
686
|
+
let yields: ReturnType<typeof buildYieldEstimator> | undefined
|
|
622
687
|
try {
|
|
623
688
|
/* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
|
|
624
689
|
const kpi: any = await computeTwinKpi({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' })
|
|
625
690
|
value = buildMeasuredEstimator(kpi?.groups?.items, {})
|
|
691
|
+
/* 같은 그룹에서 양품률도 배운다 — 한 번 접은 저널을 둘이 나눠 쓴다. */
|
|
692
|
+
yields = buildYieldEstimator(kpi?.groups?.items, {})
|
|
626
693
|
} catch (err) {
|
|
627
694
|
console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, (err as any)?.message)
|
|
628
695
|
}
|
|
629
696
|
/* 다시 넣어 **최근 쓴 것**으로 만든다 — 삽입 순서가 곧 버릴 순서이므로 이 한 줄이 LRU 를 만든다. */
|
|
630
697
|
this.measuredCache.delete(key)
|
|
631
|
-
this.measuredCache.set(key, { at: Date.now(), value })
|
|
698
|
+
this.measuredCache.set(key, { at: Date.now(), value, yields })
|
|
632
699
|
if (this.measuredCache.size > this.MEASURED_MAX) {
|
|
633
700
|
const oldest = this.measuredCache.keys().next()
|
|
634
701
|
if (!oldest.done) this.measuredCache.delete(oldest.value)
|
|
@@ -636,6 +703,68 @@ export class TwinEngine {
|
|
|
636
703
|
return value
|
|
637
704
|
}
|
|
638
705
|
|
|
706
|
+
/**
|
|
707
|
+
* **원본이 선언한 자극을 싣는다** — 재기동해도 살아 있게 (2026-08-19).
|
|
708
|
+
*
|
|
709
|
+
* ── 무엇이 났나 ────────────────────────────────────────────────────────────
|
|
710
|
+
* 데모의 시나리오는 시드 코드가 메모리에만 실었다. 그래서 트윈을 재기동하면 자극이 사라지고 구조만
|
|
711
|
+
* 서 있는 트윈이 남았다(작업 0·오더 0) — 「살아 있는 데모」가 **첫 재기동까지만** 살았다.
|
|
712
|
+
*
|
|
713
|
+
* 자극의 집은 **원본**이다(`TwinReference.connectionConfig.scenario`, ADR-0029 ·
|
|
714
|
+
* `plans/simulator-as-source.md` §4): 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터가 정하는 사실이고
|
|
715
|
+
* 트윈은 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 걷어내야 할 표면이 하나 늘어난다.
|
|
716
|
+
*
|
|
717
|
+
* 판정은 순수 함수가 한다(`planStimulus`) — 미러에 싣지 않고, 검사를 통과하지 못한 선언은 태우지 않고
|
|
718
|
+
* (그 이력이 있다: 잘못된 선언 하나가 다음 틱에서 서버를 내렸다), 못 실은 이유는 **말한다.**
|
|
719
|
+
*/
|
|
720
|
+
private static async installStimulus(domainId: string, instanceId: string, inst: InstanceRuntime): Promise<void> {
|
|
721
|
+
let config: any
|
|
722
|
+
try {
|
|
723
|
+
const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
|
|
724
|
+
config = ref?.connectionConfig
|
|
725
|
+
} catch (err: any) {
|
|
726
|
+
console.warn(`[twin-engine] "${instanceId}": could not read the declared stimulus — ${err?.message ?? err}`)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
const plan = planStimulus(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario as any)
|
|
730
|
+
if (plan.action === 'skip') {
|
|
731
|
+
/* 선언이 없는 것은 정상이므로 조용히 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */
|
|
732
|
+
if (plan.reason !== 'none') {
|
|
733
|
+
console.warn(
|
|
734
|
+
`[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` +
|
|
735
|
+
(plan.reason === 'observed' ? ' This twin runs on observation — we do not manufacture arrivals for it.' : '')
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
inst.runtime!.scenario.load(plan.scenario)
|
|
742
|
+
inst.runtime!.scenario.start()
|
|
743
|
+
const kinds = (plan.scenario.generators ?? []).map((g: any) => g?.kind).filter(Boolean)
|
|
744
|
+
console.log(`[twin-engine] "${instanceId}": stimulus from its source started — ${kinds.length ? kinds.join(', ') : 'no generators'}.`)
|
|
745
|
+
} catch (err: any) {
|
|
746
|
+
console.warn(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load — ${err?.message ?? err}`)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 자극을 **원본에 선언한다** — 프로비저닝·데모 시드가 부르는 문.
|
|
752
|
+
*
|
|
753
|
+
* 트윈이 아니라 원본에 적는 이유는 위와 같다(ADR-0029). 원본 행이 없으면 만들지 않는다 — 어떤 원본에서
|
|
754
|
+
* 온 트윈인지 모르는 채 자극을 지어 붙이면, 그 자극이 어디서 왔는지 아무도 되짚을 수 없다.
|
|
755
|
+
*/
|
|
756
|
+
static async declareStimulus(domainId: string, instanceId: string, scenario: any): Promise<boolean> {
|
|
757
|
+
const repo = getRepository(TwinReference)
|
|
758
|
+
const ref = await repo.findOne({ where: { domain: { id: domainId }, source: instanceId } })
|
|
759
|
+
if (!ref) {
|
|
760
|
+
console.warn(`[twin-engine] "${instanceId}": no source reference — a stimulus has nowhere to be declared.`)
|
|
761
|
+
return false
|
|
762
|
+
}
|
|
763
|
+
ref.connectionConfig = withStimulus(ref.connectionConfig, scenario)
|
|
764
|
+
await repo.save(ref)
|
|
765
|
+
return true
|
|
766
|
+
}
|
|
767
|
+
|
|
639
768
|
/**
|
|
640
769
|
* 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거.
|
|
641
770
|
*
|
|
@@ -769,7 +898,7 @@ export class TwinEngine {
|
|
|
769
898
|
console.log(`[twin-engine] mirror "${id}" carried over ${carried} — observation axes come from the source.`)
|
|
770
899
|
}
|
|
771
900
|
|
|
772
|
-
static start(id: string, domainId: string, kind: string, model: TwinModelDef,
|
|
901
|
+
static start(id: string, domainId: string, kind: string, model: TwinModelDef, restartPolicy?: RestartPolicy, purpose?: string, resumeFrom?: number): InstanceRuntime {
|
|
773
902
|
const key = runtimeKey(domainId, id)
|
|
774
903
|
if (this.instances[key]) return this.instances[key]
|
|
775
904
|
|
|
@@ -808,11 +937,23 @@ export class TwinEngine {
|
|
|
808
937
|
* 실제로는 도는 커널이 옛 수를 그대로 쓰고 재기동 때 받는데, 그 사실이 답에서 사라진 것이다.
|
|
809
938
|
* 규약에 기대는 대신 사실을 적는다: 「돌고 있나」를 묻는 쪽이 그 답을 받아야 한다.
|
|
810
939
|
*/
|
|
811
|
-
const inst: InstanceRuntime = {
|
|
940
|
+
const inst: InstanceRuntime = {
|
|
941
|
+
id,
|
|
942
|
+
domainId,
|
|
943
|
+
mode: 'sim',
|
|
944
|
+
runtime,
|
|
945
|
+
kernel,
|
|
946
|
+
/* 정책은 부르는 쪽이 선언한다 — 여기서 고르면 같은 트윈이 부르는 자리에 따라 다르게 재기동한다. */
|
|
947
|
+
restartPolicy: readRestartPolicy(restartPolicy, `start("${id}")`),
|
|
948
|
+
spaceId: (model as any)?.spaceId,
|
|
949
|
+
unsub: () => {}
|
|
950
|
+
}
|
|
812
951
|
this.instances[key] = inst
|
|
813
952
|
/* 다시 세웠으므로 지난 정지 이유는 사실이 아니다 — 남겨 두면 도는 트윈이 「굶겨서 멈췄다」고 말한다. */
|
|
814
953
|
this.stopNotes.delete(key)
|
|
815
954
|
delete this.recovered[key] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
|
|
955
|
+
/* 원본이 선언한 자극 — 기동을 막지 않는다(추정기와 같은 규율). 못 실었으면 그 사실을 말한다. */
|
|
956
|
+
this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus install failed — ${err?.message ?? err}`))
|
|
816
957
|
|
|
817
958
|
/* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
|
|
818
959
|
getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
|
|
@@ -845,8 +986,8 @@ export class TwinEngine {
|
|
|
845
986
|
})
|
|
846
987
|
inst.unsub = () => sub.unsubscribe()
|
|
847
988
|
|
|
848
|
-
/* 복구 앵커: 레지스트리에 model/kind/status/
|
|
849
|
-
this.register(domainId, id, kind, model, 'running', inst.
|
|
989
|
+
/* 복구 앵커: 레지스트리에 model/kind/status/restartPolicy 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
|
|
990
|
+
this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => console.error('twin register fail', err))
|
|
850
991
|
|
|
851
992
|
/* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
|
|
852
993
|
inst.timer = setInterval(() => this.tickGuarded(domainId, id, runtime), this.TICK_MS)
|
|
@@ -932,7 +1073,7 @@ export class TwinEngine {
|
|
|
932
1073
|
/* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
|
|
933
1074
|
* (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
|
|
934
1075
|
const projector = { apply: (e: CanonicalEnvelope) => kernel.apply(e), snapshot: () => kernel.getSnapshot() }
|
|
935
|
-
const inst: InstanceRuntime = { id, domainId, mode: 'live',
|
|
1076
|
+
const inst: InstanceRuntime = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: new OeeAccumulator(), spaceId: (model as any)?.spaceId, unsub: () => {} }
|
|
936
1077
|
/*
|
|
937
1078
|
* ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ────
|
|
938
1079
|
*
|
|
@@ -960,6 +1101,8 @@ export class TwinEngine {
|
|
|
960
1101
|
this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
|
|
961
1102
|
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() }
|
|
962
1103
|
this.instances[key] = inst
|
|
1104
|
+
/* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(조용한 무시 금지). */
|
|
1105
|
+
this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus check failed — ${err?.message ?? err}`))
|
|
963
1106
|
/*
|
|
964
1107
|
* **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임).
|
|
965
1108
|
*
|
|
@@ -980,7 +1123,7 @@ export class TwinEngine {
|
|
|
980
1123
|
.findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
|
|
981
1124
|
.then(top => (inst.revision = top?.revision ?? 0))
|
|
982
1125
|
.catch(() => (inst.revision = 0))
|
|
983
|
-
this.register(domainId, id, kind, model, 'running', '
|
|
1126
|
+
this.register(domainId, id, kind, model, 'running', 'resync').catch(err => console.error('twin register fail', err))
|
|
984
1127
|
return inst
|
|
985
1128
|
}
|
|
986
1129
|
|
|
@@ -1155,7 +1298,7 @@ export class TwinEngine {
|
|
|
1155
1298
|
kind: string,
|
|
1156
1299
|
model: TwinModelDef,
|
|
1157
1300
|
status: 'running' | 'stopped' = 'running',
|
|
1158
|
-
|
|
1301
|
+
restartPolicy?: RestartPolicy,
|
|
1159
1302
|
origin?: any,
|
|
1160
1303
|
/*
|
|
1161
1304
|
* 사람이 부르는 이름과 **누가 했나** — 업무키를 나눠 둔 대가로 이름을 함께 실어야 한다.
|
|
@@ -1176,10 +1319,15 @@ export class TwinEngine {
|
|
|
1176
1319
|
// 인스턴스↔공간 1급 링크(space #3) — model.spaceId/areaId 를 컬럼으로 승격(dual-write). model JSON 도 유지(커널·복구용).
|
|
1177
1320
|
spaceId: (model as any)?.spaceId ?? existing?.spaceId,
|
|
1178
1321
|
areaId: (model as any)?.areaId ?? existing?.areaId,
|
|
1179
|
-
/*
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1322
|
+
/* 재기동 정책(ADR-0029 §2) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
|
|
1323
|
+
**둘 다 없으면 던진다**: 예전에는 여기서 기본값을 각인했는데, 그 기본값이 「저널 초기화」라서
|
|
1324
|
+
선언을 빠뜨린 프로비저닝이 조용히 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */
|
|
1325
|
+
restartPolicy:
|
|
1326
|
+
restartPolicy ??
|
|
1327
|
+
readRestartPolicy(
|
|
1328
|
+
existing?.restartPolicy,
|
|
1329
|
+
`register("${instanceId}") did not declare a restart policy and the stored row has none`
|
|
1330
|
+
),
|
|
1183
1331
|
/* 용도도 각인한다. 벤치로 뒤집는 것은 `setPurpose` 하나뿐이므로 기존값을 반드시 보존한다
|
|
1184
1332
|
— 여기서 덮으면 재프로비전이 벤치 사본을 운영 트윈으로 되돌린다. */
|
|
1185
1333
|
purpose: existing?.purpose ?? 'operational',
|
|
@@ -1327,6 +1475,14 @@ export class TwinEngine {
|
|
|
1327
1475
|
instanceId: string,
|
|
1328
1476
|
kind: string,
|
|
1329
1477
|
model: TwinModelDef,
|
|
1478
|
+
/**
|
|
1479
|
+
* **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다.
|
|
1480
|
+
*
|
|
1481
|
+
* 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서
|
|
1482
|
+
* 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 조용히 만들었다. 이제 만드는 쪽이
|
|
1483
|
+
* 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다.
|
|
1484
|
+
*/
|
|
1485
|
+
restartPolicy: RestartPolicy,
|
|
1330
1486
|
comment?: string,
|
|
1331
1487
|
origin?: any,
|
|
1332
1488
|
/** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */
|
|
@@ -1351,7 +1507,7 @@ export class TwinEngine {
|
|
|
1351
1507
|
* 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어 접는다(`replaySegments`).
|
|
1352
1508
|
*/
|
|
1353
1509
|
await this.recordStructure(domainId, instanceId, model, comment)
|
|
1354
|
-
await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped',
|
|
1510
|
+
await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped', restartPolicy, origin, meta)
|
|
1355
1511
|
}
|
|
1356
1512
|
|
|
1357
1513
|
/**
|
|
@@ -1464,7 +1620,7 @@ export class TwinEngine {
|
|
|
1464
1620
|
}
|
|
1465
1621
|
|
|
1466
1622
|
/** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */
|
|
1467
|
-
static async startFromRegistry(domainId: string, instanceId: string,
|
|
1623
|
+
static async startFromRegistry(domainId: string, instanceId: string, restartPolicy?: RestartPolicy): Promise<InstanceRuntime> {
|
|
1468
1624
|
const key = runtimeKey(domainId, instanceId)
|
|
1469
1625
|
if (this.instances[key]) return this.instances[key]
|
|
1470
1626
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
@@ -1490,7 +1646,7 @@ export class TwinEngine {
|
|
|
1490
1646
|
/*
|
|
1491
1647
|
* 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
|
|
1492
1648
|
*
|
|
1493
|
-
* 저널을 초기화하고 기동하는
|
|
1649
|
+
* 저널을 초기화하고 기동하는 정책(`reset`)이라면 이 값이 0이라 아무 영향이 없다. 규칙을
|
|
1494
1650
|
* 모드별로 구분하지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
|
|
1495
1651
|
* 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
|
|
1496
1652
|
*/
|
|
@@ -1508,29 +1664,30 @@ export class TwinEngine {
|
|
|
1508
1664
|
메웠는데, 그건 YMS/MES 트윈을 **조용히 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
|
|
1509
1665
|
reg.kind,
|
|
1510
1666
|
reg.model as TwinModelDef,
|
|
1511
|
-
|
|
1667
|
+
/* 저장된 값은 **엄격히** 읽는다 — 모르는 값을 기본값으로 메우면 그 트윈이 조용히 다르게 재기동한다. */
|
|
1668
|
+
restartPolicy ?? readRestartPolicy(reg.restartPolicy, `twin "${instanceId}"`),
|
|
1512
1669
|
reg.purpose,
|
|
1513
1670
|
Number(last?.max ?? 0) || 0
|
|
1514
1671
|
)
|
|
1515
1672
|
}
|
|
1516
1673
|
|
|
1517
1674
|
/**
|
|
1518
|
-
* 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한
|
|
1675
|
+
* 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 restartPolicy 에 따라 재기동 방식을 구분한다.
|
|
1519
1676
|
* 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다.
|
|
1520
|
-
* - '
|
|
1521
|
-
* - '
|
|
1677
|
+
* - 'resync' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
|
|
1678
|
+
* - 'resume' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
|
|
1522
1679
|
* ⚠ GATE(§8 ②③): 커널 상태 직렬화/재개가 아직 없어 진짜 resume 불가 → 실 필요·실부하 시 구현.
|
|
1523
1680
|
* 그때까지는 저널을 보존하되 revision 충돌을 피하려 fresh 로 기동하고, 선언과 구현의 간극을 크게 경고한다.
|
|
1524
|
-
* - '
|
|
1681
|
+
* - 'reset' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동).
|
|
1525
1682
|
* 반환: 기동된 InstanceRuntime.
|
|
1526
1683
|
*/
|
|
1527
|
-
static async bootDeclared(domainId: string, instanceId: string, mode:
|
|
1528
|
-
if (mode === '
|
|
1684
|
+
static async bootDeclared(domainId: string, instanceId: string, mode: RestartPolicy): Promise<InstanceRuntime> {
|
|
1685
|
+
if (mode === 'resync') {
|
|
1529
1686
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
1530
1687
|
if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
|
|
1531
1688
|
return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef)
|
|
1532
1689
|
}
|
|
1533
|
-
if (mode === '
|
|
1690
|
+
if (mode === 'resume') {
|
|
1534
1691
|
/*
|
|
1535
1692
|
* **이어지는 현실** — 저널을 지우지 않는다.
|
|
1536
1693
|
*
|
|
@@ -1543,11 +1700,11 @@ export class TwinEngine {
|
|
|
1543
1700
|
* 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과
|
|
1544
1701
|
* 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다.
|
|
1545
1702
|
*/
|
|
1546
|
-
return this.startFromRegistry(domainId, instanceId, '
|
|
1703
|
+
return this.startFromRegistry(domainId, instanceId, 'resume')
|
|
1547
1704
|
}
|
|
1548
|
-
//
|
|
1705
|
+
// `reset`: seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
|
|
1549
1706
|
await this.resetJournal(domainId, instanceId)
|
|
1550
|
-
return this.startFromRegistry(domainId, instanceId, '
|
|
1707
|
+
return this.startFromRegistry(domainId, instanceId, 'reset')
|
|
1551
1708
|
}
|
|
1552
1709
|
|
|
1553
1710
|
/**
|
|
@@ -1637,7 +1794,7 @@ export class TwinEngine {
|
|
|
1637
1794
|
spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한 현장 공유 — 현장뷰 집약 키(G7)
|
|
1638
1795
|
/* 사람이 읽는 현장 이름 — 없으면 `undefined`(화면이 "이름 없음" 을 알아볼 수 있어야 한다). */
|
|
1639
1796
|
spaceName: (r.spaceId ? spaceNameOf.get(r.spaceId) : undefined) || undefined,
|
|
1640
|
-
|
|
1797
|
+
restartPolicy: r.restartPolicy, // 재기동 정책(ADR-0029 §2) — resync/resume/reset. 선언이므로 비어 있을 수 없다.
|
|
1641
1798
|
purpose: r.purpose, // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님). 저장 시 각인되므로 비지 않는다.
|
|
1642
1799
|
copyOf: r.copyOf ?? undefined,
|
|
1643
1800
|
running: !!this.instances[runtimeKey(domainId, r.instanceId)],
|
|
@@ -1651,7 +1808,7 @@ export class TwinEngine {
|
|
|
1651
1808
|
/* 스스로 멈춘 이유 — 있으면 낸다. 재기동 뒤에는 없다(그때는 「모른다」가 사실이다). */
|
|
1652
1809
|
stopNote: this.stopNotes.get(runtimeKey(domainId, r.instanceId)) || undefined,
|
|
1653
1810
|
liveFeed: liveFeedStateOf({
|
|
1654
|
-
|
|
1811
|
+
restartPolicy: r.restartPolicy,
|
|
1655
1812
|
running: !!this.instances[runtimeKey(domainId, r.instanceId)],
|
|
1656
1813
|
instanceId: r.instanceId
|
|
1657
1814
|
}),
|
|
@@ -1744,6 +1901,8 @@ export class TwinEngine {
|
|
|
1744
1901
|
static async ingestMaster(
|
|
1745
1902
|
domainId: string,
|
|
1746
1903
|
master: ReferenceMaster,
|
|
1904
|
+
/** 만들어지는 트윈의 재기동 정책 — 원본이 정하는 것이 아니라 **만드는 쪽의 선언**이다(ADR-0029 §2). */
|
|
1905
|
+
restartPolicy: RestartPolicy,
|
|
1747
1906
|
into?: { spaceId?: string },
|
|
1748
1907
|
/** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */
|
|
1749
1908
|
actor?: { id: string }
|
|
@@ -1914,7 +2073,7 @@ export class TwinEngine {
|
|
|
1914
2073
|
warnings.push(structureAdopted(shift.rev, shift))
|
|
1915
2074
|
} else {
|
|
1916
2075
|
/* 사이트 이름이 곧 트윈의 이름이다 — 마스터가 이미 말했으므로 지어내지 않고 그대로 싣는다. */
|
|
1917
|
-
await this.provision(domainId, master.source, master.system, model, undefined, master.origin, {
|
|
2076
|
+
await this.provision(domainId, master.source, master.system, model, restartPolicy, undefined, master.origin, {
|
|
1918
2077
|
name: master.siteName,
|
|
1919
2078
|
description: (master as any).description,
|
|
1920
2079
|
actor
|
|
@@ -1960,7 +2119,7 @@ export class TwinEngine {
|
|
|
1960
2119
|
kind: r.kind,
|
|
1961
2120
|
status: r.status,
|
|
1962
2121
|
running: !!this.instances[runtimeKey(domainId, r.instanceId)],
|
|
1963
|
-
|
|
2122
|
+
restartPolicy: r.restartPolicy, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
|
|
1964
2123
|
model: r.model
|
|
1965
2124
|
}
|
|
1966
2125
|
}
|
|
@@ -2652,7 +2811,7 @@ export class TwinEngine {
|
|
|
2652
2811
|
mode: inst.mode ?? 'sim',
|
|
2653
2812
|
domainId: inst.domainId,
|
|
2654
2813
|
domainLabel: inst.domain?.subdomain,
|
|
2655
|
-
|
|
2814
|
+
restartPolicy: inst.restartPolicy,
|
|
2656
2815
|
tickMs: this.TICK_MS,
|
|
2657
2816
|
running: !!inst.timer || inst.mode === 'live',
|
|
2658
2817
|
...(loadSummary(inst.load, this.TICK_MS) ?? { recent: null, total: null, forksCreated: 0, overBudget: 0, budgetMs: this.TICK_MS * 0.5, loadRatio: null })
|
package/server/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { resumeReferenceLiveFeeds } from './service/reference/reference-live.js'
|
|
|
11
11
|
/*
|
|
12
12
|
* 모듈 부팅 — **도는 트윈을 되살린다.**
|
|
13
13
|
*
|
|
14
|
-
* 두 층이 순서대로다: ① 엔진이 등록부의 `running` 행을 선언된 `
|
|
14
|
+
* 두 층이 순서대로다: ① 엔진이 등록부의 `running` 행을 선언된 `restartPolicy` 그대로 되살리고
|
|
15
15
|
* (`bootstrap`), ② 레퍼런스 계층이 미러 트윈의 커넥터 피드를 다시 붙인다. 순서가 뒤집히면 피드가
|
|
16
16
|
* 아직 없는 커널에 계측을 붓게 된다.
|
|
17
17
|
*
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **구동은 원본을 지난다** — 트윈을 우회하지 않는다 (ADR-0029 §8, 2026-08-19).
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 뚫려 있었나 ──────────────────────────────────────────────────────
|
|
5
|
+
* `controlTwinScenario` 가 `TwinEngine.runtime(...).scenario` 를 **직접** 잡았다. 그래서 시뮬만 할 수 있는
|
|
6
|
+
* 일(자극·배속·정지)이 계약 밖의 지름길로 들어왔고, 실 시스템에는 그 자리가 아예 없었다 — ADR-0029 의
|
|
7
|
+
* 판정 문장에 걸리는 상태였다.
|
|
8
|
+
*
|
|
9
|
+
* 이제 길은 하나다: **트윈 → 그 트윈의 원본 → 어댑터가 선언한 `control` 능력.** 선언하지 않은 원본에는
|
|
10
|
+
* 문이 열리지 않고, 그 사실을 코드로 말한다(`not-declared`). 실 WMS 가 정지되지 않는 것은 결손이 아니다.
|
|
11
|
+
*
|
|
12
|
+
* ── 전송은 여기서 묻지 않는다 (§7) ──────────────────────────────────────────
|
|
13
|
+
* 인프로세스 시뮬레이터는 같은 계약을 인프로세스로 이행한다. 나중에 그것이 다른 프로세스로 나가도 이 경로는
|
|
14
|
+
* 바뀌지 않는다 — 「계약을 지나는가」만 보고 「네트워크를 타는가」는 묻지 않는다.
|
|
15
|
+
*/
|
|
16
|
+
import { getRepository } from '@things-factory/shell'
|
|
17
|
+
|
|
18
|
+
import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
19
|
+
import { TwinReference } from './twin-reference.js'
|
|
20
|
+
import { capabilitiesOf, getAdapter, type ControlCommand, type ControlResult, type SiteDescriptor } from './reference-adapter.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 이 트윈의 원본에 구동 명령을 보낸다 — **원본이 없거나 능력을 선언하지 않으면 거절한다.**
|
|
24
|
+
*
|
|
25
|
+
* 거절을 조용히 하지 않는다: 사유 코드를 돌려주고 화면이 그것을 사람 말로 옮긴다. 「아무 일도 일어나지
|
|
26
|
+
* 않았다」가 가장 비싼 답이다.
|
|
27
|
+
*/
|
|
28
|
+
export async function routeControl(domainId: string, instanceId: string, command: ControlCommand): Promise<ControlResult> {
|
|
29
|
+
const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
30
|
+
if (!inst) return { ok: false, code: 'unknown-twin', detail: instanceId }
|
|
31
|
+
|
|
32
|
+
const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
|
|
33
|
+
if (!ref) {
|
|
34
|
+
/* 원본을 모르는 트윈 — 구동할 대상이 없다. 트윈을 직접 흔드는 길은 두지 않는다(그것이 지름길이었다). */
|
|
35
|
+
return { ok: false, code: 'no-source', detail: `twin "${instanceId}" does not remember a source to drive` }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const adapter = getAdapter(ref.adapterType)
|
|
39
|
+
if (!adapter) return { ok: false, code: 'no-adapter', detail: ref.adapterType }
|
|
40
|
+
if (!capabilitiesOf(adapter).includes('control')) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
code: 'not-declared',
|
|
44
|
+
detail: `source "${ref.source}" (${ref.adapterType}) does not declare the control capability — a real system is not paused or seeded, and that is a fact rather than a gap`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* 사이트를 함께 넘긴다 — 원본 하나가 여러 사이트를 낼 수 있다(`discoverSites`). 우리가 아는 것은
|
|
50
|
+
* 이 트윈이 어느 사이트에서 왔는지이고(`origin.siteId`), 모르면 넘기지 않는다(지어내지 않는다).
|
|
51
|
+
*/
|
|
52
|
+
const siteId = (inst.origin as any)?.siteId
|
|
53
|
+
const site: SiteDescriptor | undefined = siteId ? { siteId } : undefined
|
|
54
|
+
return adapter.control!(ref.connectionConfig ?? {}, site, { ...command, instanceId, domainId })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 이 트윈의 원본이 선언한 능력 — 화면이 「무엇을 할 수 있나」를 먼저 알 수 있게. */
|
|
58
|
+
export async function controlCapabilityOf(domainId: string, instanceId: string): Promise<{ source?: string; adapterType?: string; capabilities: string[] }> {
|
|
59
|
+
const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
|
|
60
|
+
if (!ref) return { capabilities: [] }
|
|
61
|
+
return { source: ref.source, adapterType: ref.adapterType, capabilities: capabilitiesOf(getAdapter(ref.adapterType)) }
|
|
62
|
+
}
|