@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
|
@@ -52,6 +52,56 @@ export interface AdapterMeta {
|
|
|
52
52
|
docUrl?: string // grounding='vendor-doc' 의 근거 문서 URL(출처 추적)
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 원본이 **선언하는 능력** — 「이 원본으로 무엇을 할 수 있나」 (ADR-0029 §8, 2026-08-19).
|
|
57
|
+
*
|
|
58
|
+
* ── 왜 능력으로 선언하나 ────────────────────────────────────────────────────
|
|
59
|
+
* 실 WMS 는 스스로 사실을 낸다. 시뮬레이터는 **구동해 줘야** 움직인다 — 자극·속도·정지·초기화. 그런데
|
|
60
|
+
* 계약에 그 자리가 없어서 통제가 `TwinEngine` 안에 뚫려 있었다(`controlTwinScenario` 가 런타임의 시나리오
|
|
61
|
+
* 엔진을 직접 잡았다). 그러면 ADR-0029 의 판정 문장(*"실물이 같은 계약으로 할 수 있는가"*)에 걸린다:
|
|
62
|
+
* 실 WMS 는 일시정지될 수 없으니 그 통제면은 영원히 「어댑터 규약의 결손」으로 남고, 규칙이 그 면을
|
|
63
|
+
* **숨게** 만든다.
|
|
64
|
+
*
|
|
65
|
+
* 그래서 뒤집는다: 통제는 **선언된 능력**이다. 선언하지 않은 원본에는 그 문이 **아예 열리지 않는다** —
|
|
66
|
+
* 그것이 결손이 아니라 **사실**이다(실 WMS 는 정지되지 않는다). 이 레포가 이미 그 규율로 굴러간다
|
|
67
|
+
* (`capability-contract`: concrete 강결합 대신 능력 선언).
|
|
68
|
+
*
|
|
69
|
+
* · `live` — 돌아가는 피드를 낸다(`openLiveFeed`).
|
|
70
|
+
* · `control` — 구동을 받는다(`control`) — 자극·속도·정지·초기화.
|
|
71
|
+
*/
|
|
72
|
+
export type ReferenceCapability = 'live' | 'control'
|
|
73
|
+
|
|
74
|
+
/** 원본에 보내는 구동 명령 — **시뮬레이터를 움직이는 말**이지 트윈을 고치는 말이 아니다. */
|
|
75
|
+
export interface ControlCommand {
|
|
76
|
+
/**
|
|
77
|
+
* · `stimulus` — 자극을 싣는다(`scenario`). 선언은 원본 설정에 살고 이 명령은 지금 태우는 것이다.
|
|
78
|
+
* · `start`·`pause` — 구동을 켜고 멈춘다.
|
|
79
|
+
* · `speed` — 시각의 배속(`speed`).
|
|
80
|
+
* · `reset` — 씨앗부터 다시(원본의 상태를 되돌린다 — 트윈의 저널은 재기동 정책이 정한다).
|
|
81
|
+
*/
|
|
82
|
+
action: 'stimulus' | 'start' | 'pause' | 'speed' | 'reset'
|
|
83
|
+
/** 자극 선언 — `action: 'stimulus'` 에만. 커널 규약(`validateScenario`)으로 검사된 값이어야 한다. */
|
|
84
|
+
scenario?: any
|
|
85
|
+
/** 배속 — `action: 'speed'` 에만. */
|
|
86
|
+
speed?: number
|
|
87
|
+
/**
|
|
88
|
+
* 어느 트윈을 위한 구동인가 — 원본이 여러 트윈에 먹일 수 있으므로 대상을 함께 말한다.
|
|
89
|
+
*
|
|
90
|
+
* 인프로세스 시뮬레이터는 이 둘로 자기 런타임을 찾는다(테넌트까지 있어야 같은 id 의 두 트윈이 갈린다).
|
|
91
|
+
* 원격 원본은 대개 무시한다(자기 세계가 하나다).
|
|
92
|
+
*/
|
|
93
|
+
instanceId?: string
|
|
94
|
+
domainId?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 구동 결과 — **왜 안 됐는지**를 코드로 말한다(화면이 옮긴다). */
|
|
98
|
+
export interface ControlResult {
|
|
99
|
+
ok: boolean
|
|
100
|
+
/** 언어중립 사유 — `not-declared`(능력 없음) · `unsupported-action` · `invalid`(선언 거절) · `no-target`. */
|
|
101
|
+
code?: string
|
|
102
|
+
detail?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
55
105
|
export interface ReferenceAdapter {
|
|
56
106
|
/** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */
|
|
57
107
|
type: string
|
|
@@ -65,6 +115,34 @@ export interface ReferenceAdapter {
|
|
|
65
115
|
fetchMaster(cfg: ConnectionConfig, site: SiteDescriptor): Promise<ReferenceMaster>
|
|
66
116
|
/** (선택) 사이트별 실 이벤트 스트림 — 없으면 sim. onRecords 로 레코드 push, unsubscribe 반환. */
|
|
67
117
|
openLiveFeed?(cfg: ConnectionConfig, site: SiteDescriptor, onRecords: (records: unknown[]) => void): () => void
|
|
118
|
+
/**
|
|
119
|
+
* 이 원본이 **선언하는 능력** — 없으면 「구조만 준다」는 뜻이다(ADR-0029 §8).
|
|
120
|
+
*
|
|
121
|
+
* 선언과 구현이 어긋나지 않게 `capabilitiesOf` 가 둘을 함께 본다: `control` 을 선언했는데 `control`
|
|
122
|
+
* 구현이 없으면 그 능력은 **없는 것으로 읽는다**(선언만 있는 능력은 사용자에게 거짓말이 된다).
|
|
123
|
+
*/
|
|
124
|
+
capabilities?: ReferenceCapability[]
|
|
125
|
+
/**
|
|
126
|
+
* (선택) 구동 — 자극·속도·정지·초기화. **`capabilities` 에 `control` 을 선언한 원본만** 가진다.
|
|
127
|
+
*
|
|
128
|
+
* 실 시스템은 이것을 갖지 않는다(실 WMS 는 일시정지되지 않는다). 그 사실이 결손이 아니라 이 축의 요점이다.
|
|
129
|
+
*/
|
|
130
|
+
control?(cfg: ConnectionConfig, site: SiteDescriptor | undefined, command: ControlCommand): Promise<ControlResult>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 이 원본이 **실제로 할 수 있는 것** — 선언과 구현의 교집합.
|
|
135
|
+
*
|
|
136
|
+
* 선언만 있고 구현이 없으면 화면이 「할 수 있다」고 말한 뒤 아무 일도 일어나지 않는다. 반대로 구현이
|
|
137
|
+
* 있는데 선언이 없으면 그 능력은 **의도적으로 닫아 둔 것**이므로 열지 않는다 — 선언이 권위다.
|
|
138
|
+
*/
|
|
139
|
+
export function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] {
|
|
140
|
+
if (!adapter) return []
|
|
141
|
+
const declared = new Set(adapter.capabilities ?? [])
|
|
142
|
+
const out: ReferenceCapability[] = []
|
|
143
|
+
if (declared.has('live') && typeof adapter.openLiveFeed === 'function') out.push('live')
|
|
144
|
+
if (declared.has('control') && typeof adapter.control === 'function') out.push('control')
|
|
145
|
+
return out
|
|
68
146
|
}
|
|
69
147
|
|
|
70
148
|
/* 어댑터 "타입" 레지스트리(전역·인메모리) — 레퍼런스 데이터는 DB(TwinReference), 어댑터 동작은 여기. */
|
|
@@ -6,7 +6,7 @@ import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
|
6
6
|
import { TwinReference } from './twin-reference.js'
|
|
7
7
|
|
|
8
8
|
/*
|
|
9
|
-
* 커넥터 기반 라이브 피드 결선(reference → live, §0 프레임 ①
|
|
9
|
+
* 커넥터 기반 라이브 피드 결선(reference → live, §0 프레임 ① restartPolicy='resync').
|
|
10
10
|
* 흐름: adapter.openLiveFeed(cfg, site, records) → ingestCanonicalRecords(정규→봉투) → TwinEngine.ingestLive(projector 미러).
|
|
11
11
|
* 커넥터가 "벤더 이벤트 → 정규 레코드" 매핑을 소유(코드)한다 — preset 기반 startLiveFeed(operato-twin, http-get+jsonata)와
|
|
12
12
|
* 대칭이되, 매핑 위치만 다르다. 실 시스템 전환은 connectionConfig.baseUrl 교체뿐(코드 경로 동일).
|
|
@@ -33,7 +33,7 @@ export async function startReferenceLiveFeed(
|
|
|
33
33
|
const det = await TwinEngine.detail(domainId, instanceId)
|
|
34
34
|
if (!det?.model) throw new Error(`instance "${instanceId}" not provisioned (no model) — ingest master before live start`)
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// 관측 기동 — 커널 tick 없이 projector 가 실 이벤트를 반영한다(startLive 가 restartPolicy='resync' 로 적는다).
|
|
37
37
|
// 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.
|
|
38
38
|
TwinEngine.startLive(instanceId, domainId, det.kind, await TwinEngine.withSpaceTimeBase(det.model, domainId))
|
|
39
39
|
|
|
@@ -121,7 +121,7 @@ export async function resumeReferenceLiveFeeds(): Promise<{ resumed: string[]; f
|
|
|
121
121
|
where: produced.map((p: any) => ({ domain: { id: domainId }, instanceId: p.instanceId })) as any
|
|
122
122
|
})
|
|
123
123
|
const live = produced.filter((p: any) =>
|
|
124
|
-
rows.some(r => r.instanceId === p.instanceId && r.status === 'running' && r.
|
|
124
|
+
rows.some(r => r.instanceId === p.instanceId && r.status === 'running' && r.restartPolicy === 'resync')
|
|
125
125
|
)
|
|
126
126
|
if (!live.length) continue
|
|
127
127
|
|
|
@@ -3,6 +3,7 @@ import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'
|
|
|
3
3
|
import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
4
4
|
|
|
5
5
|
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
import { readRestartPolicy } from '../../engine/restart-policy.js'
|
|
6
7
|
import { readBoardEquipment } from '@operato/twin-kernel'
|
|
7
8
|
|
|
8
9
|
import { applyKnobDefaults, filledKnobKeys } from './knob-defaults.js'
|
|
@@ -140,9 +141,13 @@ export class TwinReferenceResolver {
|
|
|
140
141
|
|
|
141
142
|
/** 소스를 인제스트 → 공간 + 인스턴스 생성(provision). start 면 기동까지. */
|
|
142
143
|
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
143
|
-
@Mutation(returns => ScalarObject, {
|
|
144
|
+
@Mutation(returns => ScalarObject, {
|
|
145
|
+
description:
|
|
146
|
+
"Ingest a registered reference source into a twin (space + instance). Optionally start it. restartPolicy declares how the new twin treats its own past on reboot: 'resync' (read the source again) | 'resume' (continue its journal) | 'reset' (wipe and replay from the seed). It has no default — a twin cannot exist without the declaration."
|
|
147
|
+
})
|
|
144
148
|
async importFromReference(
|
|
145
149
|
@Arg('source') source: string,
|
|
150
|
+
@Arg('restartPolicy') restartPolicy: string,
|
|
146
151
|
@Ctx() context: ResolverContext,
|
|
147
152
|
@Arg('start', { nullable: true }) start?: boolean
|
|
148
153
|
): Promise<any> {
|
|
@@ -153,7 +158,13 @@ export class TwinReferenceResolver {
|
|
|
153
158
|
/* 어디서 왔는지 남긴다 — 이것이 없으면 이 트윈은 두 번 다시 읽을 수 없다.
|
|
154
159
|
이 경로는 **메모리에 등록된 마스터 하나**를 그대로 쓰므로 사이트 서술자가 없다(사이트 정체를
|
|
155
160
|
적을 것이 없다). 다시 읽기도 같은 등록본을 집으므로 사이트를 찾을 필요가 없다. */
|
|
156
|
-
const res = await TwinEngine.ingestMaster(
|
|
161
|
+
const res = await TwinEngine.ingestMaster(
|
|
162
|
+
domainId,
|
|
163
|
+
{ ...master, origin: { kind: 'reference', source } },
|
|
164
|
+
readRestartPolicy(restartPolicy, 'importFromReference'),
|
|
165
|
+
undefined,
|
|
166
|
+
context.state.user
|
|
167
|
+
)
|
|
157
168
|
if (start) await TwinEngine.startFromRegistry(domainId, res.instanceId)
|
|
158
169
|
return { ok: true, instanceId: res.instanceId, spaceId: res.spaceId, warnings: res.warnings }
|
|
159
170
|
} catch (e: any) {
|
|
@@ -204,7 +215,14 @@ export class TwinReferenceResolver {
|
|
|
204
215
|
* 기본 공간으로 옮겨 간다 — 조용히 현장을 떠나고 그 현장의 값도 잃는다).
|
|
205
216
|
*/
|
|
206
217
|
const keepSpace = origin.spaceId || inst.spaceId
|
|
207
|
-
|
|
218
|
+
/* 다시 읽기는 **구조만** 새로 그린다 — 재기동 정책은 그 트윈의 선언이므로 그대로 지킨다. */
|
|
219
|
+
const res = await TwinEngine.ingestMaster(
|
|
220
|
+
domainId,
|
|
221
|
+
master,
|
|
222
|
+
readRestartPolicy(inst.restartPolicy, `resync of twin "${instanceId}"`),
|
|
223
|
+
keepSpace ? { spaceId: keepSpace } : undefined,
|
|
224
|
+
context.state.user
|
|
225
|
+
)
|
|
208
226
|
return { ok: true, instanceId: res.instanceId, spaceId: res.spaceId, warnings: res.warnings }
|
|
209
227
|
} catch (e: any) {
|
|
210
228
|
return refusal(context, e, 'resync failed')
|
|
@@ -273,10 +291,14 @@ export class TwinReferenceResolver {
|
|
|
273
291
|
|
|
274
292
|
/** 템플릿 + 노브 → 합성 마스터 → 인제스트(+기동+시나리오). 레퍼런스 import 와 같은 경로, 원천만 합성. */
|
|
275
293
|
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
276
|
-
@Mutation(returns => ScalarObject, {
|
|
294
|
+
@Mutation(returns => ScalarObject, {
|
|
295
|
+
description:
|
|
296
|
+
"Instantiate a twin template with params → synthetic master → ingest (+start+scenario). restartPolicy declares how the new twin treats its own past on reboot ('resync' | 'resume' | 'reset') — there is no default. Returns {ok, instanceId, spaceId}."
|
|
297
|
+
})
|
|
277
298
|
async createFromTemplate(
|
|
278
299
|
@Arg('templateId') templateId: string,
|
|
279
300
|
@Arg('params', type => ScalarObject) params: any,
|
|
301
|
+
@Arg('restartPolicy') restartPolicy: string,
|
|
280
302
|
@Ctx() context: ResolverContext,
|
|
281
303
|
@Arg('start', { nullable: true }) start?: boolean,
|
|
282
304
|
@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
|
|
@@ -310,7 +332,13 @@ export class TwinReferenceResolver {
|
|
|
310
332
|
master.origin = { kind: 'template', templateId, params: effective, ...(spaceId ? { spaceId } : {}) }
|
|
311
333
|
/* 공간 선택은 템플릿을 거치지 않는다 — 템플릿마다 이 노브를 알고 있어야 한다면 새 템플릿이
|
|
312
334
|
조용히 무시하게 된다. 합성된 마스터에 한 곳에서 얹는다(레퍼런스 인제스트와 같은 경로). */
|
|
313
|
-
const res = await TwinEngine.ingestMaster(
|
|
335
|
+
const res = await TwinEngine.ingestMaster(
|
|
336
|
+
domainId,
|
|
337
|
+
master,
|
|
338
|
+
readRestartPolicy(restartPolicy, `createFromTemplate("${templateId}")`),
|
|
339
|
+
{ spaceId },
|
|
340
|
+
context.state.user
|
|
341
|
+
)
|
|
314
342
|
if (start !== false) {
|
|
315
343
|
await TwinEngine.startFromRegistry(domainId, res.instanceId)
|
|
316
344
|
if (scenario) {
|
|
@@ -545,14 +573,18 @@ export class TwinReferenceResolver {
|
|
|
545
573
|
* 넣는 것도 거절(`refuseImportSpace`) — 서로 다른 현장이 조용히 합쳐지는 것을 막는다.
|
|
546
574
|
*/
|
|
547
575
|
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
548
|
-
@Mutation(returns => ScalarObject, {
|
|
576
|
+
@Mutation(returns => ScalarObject, {
|
|
577
|
+
description:
|
|
578
|
+
"Create twins from a reference: discover sites → filter by siteIds (if given) → fetch master(s) → provision. spaceId co-locates the twin into an existing space (one site only). start=false to only provision; live=true to run as a mirror (adapter openLiveFeed → live projector) instead of sim. restartPolicy declares how each new twin treats its own past on reboot ('resync' | 'resume' | 'reset'); omit it and live=true implies 'resync' — a mirror reads the source again — while a simulation must declare 'resume' or 'reset' for itself."
|
|
579
|
+
})
|
|
549
580
|
async importTwinReference(
|
|
550
581
|
@Arg('source') source: string,
|
|
551
582
|
@Ctx() context: ResolverContext,
|
|
552
583
|
@Arg('siteIds', type => [String], { nullable: true }) siteIds?: string[],
|
|
553
584
|
@Arg('start', { nullable: true }) start?: boolean,
|
|
554
585
|
@Arg('live', { nullable: true }) live?: boolean,
|
|
555
|
-
@Arg('spaceId', { nullable: true, description: 'Existing space to co-locate this twin into (add a lens to a site). Only when importing a single site. Omit to give each site its own space.' }) spaceId?: string
|
|
586
|
+
@Arg('spaceId', { nullable: true, description: 'Existing space to co-locate this twin into (add a lens to a site). Only when importing a single site. Omit to give each site its own space.' }) spaceId?: string,
|
|
587
|
+
@Arg('restartPolicy', { nullable: true, description: "How each new twin treats its own past on reboot: 'resync' | 'resume' | 'reset'. Required unless live=true (a mirror is 'resync' by definition)." }) restartPolicy?: string
|
|
556
588
|
): Promise<any> {
|
|
557
589
|
const domainId = context.state.domain.id
|
|
558
590
|
const repo = getRepository(TwinReference)
|
|
@@ -574,9 +606,16 @@ export class TwinReferenceResolver {
|
|
|
574
606
|
const master = await adapter.fetchMaster(cfg, site)
|
|
575
607
|
/* 어느 레퍼런스의 **어느 사이트**에서 왔는지 남긴다 — 다시 읽기가 같은 어댑터·같은 사이트로 돌아오려면
|
|
576
608
|
사이트 정체가 있어야 한다(`source` 는 참조 이름이라 사이트 전부에 같다). */
|
|
609
|
+
/*
|
|
610
|
+
* 정책은 **선언이거나 의도에서 곧바로 따라오는 것**이다: `live=true` 는 미러를 띄우겠다는 뜻이고
|
|
611
|
+
* 미러는 정의상 `resync` 다(외부가 진실이므로 우리 과거를 이어 붙이지 않는다). 시뮬로 띄우려면
|
|
612
|
+
* 스스로 선언해야 한다 — 그 자리에서 우리가 고르면 재기동마다 이력을 지우는 트윈이 조용히 생긴다.
|
|
613
|
+
*/
|
|
614
|
+
const policy = readRestartPolicy(restartPolicy ?? (live ? 'resync' : undefined), `importTwinReference("${source}")`)
|
|
577
615
|
const res = await TwinEngine.ingestMaster(
|
|
578
616
|
domainId,
|
|
579
617
|
{ ...master, origin: { kind: 'reference', source, siteId: site.siteId, ...(spaceId ? { spaceId } : {}) } },
|
|
618
|
+
policy,
|
|
580
619
|
{ spaceId },
|
|
581
620
|
context.state.user
|
|
582
621
|
)
|
|
@@ -17,6 +17,19 @@ export type CommandAuditInput = {
|
|
|
17
17
|
command: any
|
|
18
18
|
/** 커널 판정. */
|
|
19
19
|
ack: { accepted?: boolean; errorCode?: string; error?: string }
|
|
20
|
+
/**
|
|
21
|
+
* **원본(어댑터)이 준 답** — 허락과 일어남 사이에 끼어 있는 것을 같은 기록에 닫는다 (ADR-0029 §1 결정 C).
|
|
22
|
+
*
|
|
23
|
+
* ── 왜 이 칸이 필요한가 ──────────────────────────────────────────────────
|
|
24
|
+
* 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다 — 어댑터가 조용히 실패하면 원장에는 승인만
|
|
25
|
+
* 남는다. 그래서 어댑터의 답을 **같은 행**에 되적는다.
|
|
26
|
+
*
|
|
27
|
+
* 셋을 구별한다:
|
|
28
|
+
* · 없음(`undefined`) — **원본에 묻지 않았다**(커널이 받은 명령이다). 「모른다」와 다르다.
|
|
29
|
+
* · `{ answered: false }` — 물었는데 **답을 못 받았다** → `unknown` 으로 남긴다. 성공으로 적지 않는다.
|
|
30
|
+
* · `{ answered: true, ok, code? }` — 원본이 답했다(적용·거절 모두 사실이다).
|
|
31
|
+
*/
|
|
32
|
+
source?: { answered: boolean; ok?: boolean; code?: string }
|
|
20
33
|
/** 세션이 아는 사용자 — **서버만 채운다.** */
|
|
21
34
|
actor?: { id?: string; email?: string; name?: string }
|
|
22
35
|
/** 서버 시각. 호출부가 주입해 테스트가 시간을 고정할 수 있게 한다. */
|
|
@@ -35,6 +48,13 @@ export type CommandAuditRow = {
|
|
|
35
48
|
actorName?: string
|
|
36
49
|
accepted: boolean
|
|
37
50
|
errorCode?: string
|
|
51
|
+
/**
|
|
52
|
+
* 원본이 그래서 어떻게 했나 — `applied` · `refused` · `unknown`(물었지만 답이 없다).
|
|
53
|
+
* **없으면 묻지 않은 것이다**(커널이 받은 명령). 0 이나 성공으로 채우지 않는다.
|
|
54
|
+
*/
|
|
55
|
+
sourceOutcome?: 'applied' | 'refused' | 'unknown'
|
|
56
|
+
/** 원본이 준 사유 코드 — 있으면 그대로. 우리가 지어내지 않는다. */
|
|
57
|
+
sourceCode?: string
|
|
38
58
|
args?: any
|
|
39
59
|
argsTruncated: boolean
|
|
40
60
|
}
|
|
@@ -48,7 +68,7 @@ export function normalizeOrigin(v: unknown): CommandOrigin {
|
|
|
48
68
|
const ARGS_MAX = 4000
|
|
49
69
|
|
|
50
70
|
export function buildCommandAudit(input: CommandAuditInput): CommandAuditRow {
|
|
51
|
-
const { command, ack, actor, at } = input
|
|
71
|
+
const { command, ack, actor, at, source } = input
|
|
52
72
|
const argsText = safeJson(command?.args)
|
|
53
73
|
const truncated = argsText !== null && argsText.length > ARGS_MAX
|
|
54
74
|
return {
|
|
@@ -57,6 +77,8 @@ export function buildCommandAudit(input: CommandAuditInput): CommandAuditRow {
|
|
|
57
77
|
commandType: str(command?.type),
|
|
58
78
|
origin: normalizeOrigin(command?.origin),
|
|
59
79
|
reason: str(command?.reason),
|
|
80
|
+
/* 원본의 답 — 묻지 않았으면 칸을 만들지 않는다(모름과 구별한다). */
|
|
81
|
+
...(source ? { sourceOutcome: sourceOutcomeOf(source), ...(source.code ? { sourceCode: source.code } : {}) } : {}),
|
|
60
82
|
/* 신원 — 세션 값만. 커맨드에 실려 온 신원 주장은 쳐다보지 않는다. */
|
|
61
83
|
actorId: str(actor?.id),
|
|
62
84
|
actorEmail: str(actor?.email),
|
|
@@ -79,3 +101,14 @@ function safeJson(v: unknown): string | null {
|
|
|
79
101
|
return null /* 순환 참조 등 — 못 재면 자르지 않는다(원본을 그대로 둔다) */
|
|
80
102
|
}
|
|
81
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 원본의 답을 세 값으로 — **답이 없으면 `unknown`**(성공으로 적지 않는다).
|
|
107
|
+
*
|
|
108
|
+
* 이 함수가 있는 이유: 「물었지만 답이 없다」를 실패로도 성공으로도 적을 수 없다. 실패로 적으면 일어난 일을
|
|
109
|
+
* 없던 일로 만들고, 성공으로 적으면 없던 일을 일어난 일로 만든다. 셋째 값이 사실이다.
|
|
110
|
+
*/
|
|
111
|
+
export function sourceOutcomeOf(source: { answered: boolean; ok?: boolean }): 'applied' | 'refused' | 'unknown' {
|
|
112
|
+
if (!source.answered || typeof source.ok !== 'boolean') return 'unknown'
|
|
113
|
+
return source.ok ? 'applied' : 'refused'
|
|
114
|
+
}
|
|
@@ -99,6 +99,26 @@ export class TwinAuditEvent {
|
|
|
99
99
|
reason?: string
|
|
100
100
|
|
|
101
101
|
/* ── 신원: 세션에서만 온다 ── */
|
|
102
|
+
/*
|
|
103
|
+
* **원본이 그래서 어떻게 했나** — 허락과 일어남 사이에 끼어 있는 것을 같은 행에 닫는다
|
|
104
|
+
* (ADR-0029 §1 결정 C, 2026-08-19).
|
|
105
|
+
*
|
|
106
|
+
* 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다 — 어댑터가 조용히 실패하면 원장에는 승인만
|
|
107
|
+
* 남는다. 그래서 어댑터의 답을 되적는다.
|
|
108
|
+
*
|
|
109
|
+
* · 비어 있음 — **원본에 묻지 않았다**(커널이 받은 명령이다). 「모른다」와 다르다.
|
|
110
|
+
* · `unknown` — 물었는데 **답을 못 받았다**. 성공으로 적지 않는다.
|
|
111
|
+
* · `applied` · `refused` — 원본이 답했다(둘 다 사실이다).
|
|
112
|
+
*/
|
|
113
|
+
@Column({ nullable: true })
|
|
114
|
+
@Field({ nullable: true, description: "What the source did with it: 'applied' | 'refused' | 'unknown' (asked but no answer). Empty means the source was not asked — the kernel took the command." })
|
|
115
|
+
sourceOutcome?: string
|
|
116
|
+
|
|
117
|
+
/** 원본이 준 사유 코드 — 있으면 그대로 적는다(우리가 지어내지 않는다). */
|
|
118
|
+
@Column({ nullable: true })
|
|
119
|
+
@Field({ nullable: true, description: 'Reason code the source returned, as it gave it.' })
|
|
120
|
+
sourceCode?: string
|
|
121
|
+
|
|
102
122
|
@Column({ nullable: true })
|
|
103
123
|
@Field({ nullable: true, description: 'Approving user id, stamped by the server from the session.' })
|
|
104
124
|
actorId?: string
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Arg, Ctx, Mutation, Resolver, Directive} from 'type-graphql'
|
|
1
|
+
import { Arg, Ctx, Mutation, Query, Resolver, Directive } from 'type-graphql'
|
|
2
2
|
|
|
3
3
|
import { validateScenario } from '@operato/twin-kernel'
|
|
4
4
|
|
|
@@ -9,6 +9,8 @@ import { getRepository } from '@things-factory/shell'
|
|
|
9
9
|
import { TwinEngine } from '../../engine/index.js'
|
|
10
10
|
import { TwinAuditEvent } from '../twin-audit/twin-audit-event.js'
|
|
11
11
|
import { buildCommandAudit } from '../twin-audit/command-audit.js'
|
|
12
|
+
/* 구동은 원본을 지난다(ADR-0029 §8) — 트윈 런타임을 직접 잡지 않는다. */
|
|
13
|
+
import { routeControl, controlCapabilityOf } from '../reference/control-routing.js'
|
|
12
14
|
|
|
13
15
|
/*
|
|
14
16
|
* 트윈 Command·Scenario 채널 — 상향 mutation.
|
|
@@ -61,42 +63,87 @@ export class TwinControlMutation {
|
|
|
61
63
|
return { accepted: !!ack?.accepted, error: ack?.error, errorCode: ack?.errorCode, errorParams: ack?.errorParams }
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* 구동 채널 — **원본을 지난다**(ADR-0029 §8).
|
|
68
|
+
*
|
|
69
|
+
* ── 무엇이 바뀌었나 (2026-08-19) ──────────────────────────────────────────
|
|
70
|
+
* 예전에는 이 문이 `TwinEngine.runtime(...).scenario` 를 **직접** 잡았다. 시뮬만 할 수 있는 일(자극·배속·
|
|
71
|
+
* 정지)이 계약 밖의 지름길로 들어온 셈이고, 실 시스템에는 그 자리가 아예 없었다. 이제 길은 하나다:
|
|
72
|
+
* 트윈 → 그 트윈의 원본 → 어댑터가 **선언한** `control` 능력. 선언하지 않은 원본에는 문이 열리지 않고,
|
|
73
|
+
* 그 사실을 사유 코드로 말한다(`not-declared`) — 실 WMS 가 정지되지 않는 것은 결손이 아니다.
|
|
74
|
+
*
|
|
75
|
+
* 반환을 Boolean 에서 객체로 바꿨다: 예전에는 거절 사유를 실을 자리가 없어 **로그로만** 남겼다.
|
|
76
|
+
* 화면은 왜 안 되는지 알 수 없었다.
|
|
77
|
+
*/
|
|
65
78
|
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
66
|
-
@Mutation(returns =>
|
|
67
|
-
|
|
79
|
+
@Mutation(returns => ScalarObject, {
|
|
80
|
+
description:
|
|
81
|
+
"Drive the source behind a twin: stimulus | start | pause | speed | reset. The command goes through the source adapter's declared control capability — a source that does not declare it refuses with code 'not-declared' (a real system is not paused or seeded, and that is a fact rather than a gap). Returns { ok, code?, detail? }."
|
|
82
|
+
})
|
|
83
|
+
async controlTwinSource(
|
|
68
84
|
@Arg('instanceId') instanceId: string,
|
|
69
85
|
@Arg('action') action: string,
|
|
70
86
|
@Ctx() context: ResolverContext,
|
|
71
|
-
@Arg('scenario', type => ScalarObject, { nullable: true }) scenario?: any
|
|
72
|
-
|
|
87
|
+
@Arg('scenario', type => ScalarObject, { nullable: true }) scenario?: any,
|
|
88
|
+
@Arg('speed', { nullable: true }) speed?: number
|
|
89
|
+
): Promise<any> {
|
|
73
90
|
const domainId = context.state.domain?.id
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
if (!domainId) return { ok: false, code: 'no-domain' }
|
|
92
|
+
const allowed = ['stimulus', 'start', 'pause', 'speed', 'reset']
|
|
93
|
+
if (!allowed.includes(action)) return { ok: false, code: 'unsupported-action', detail: `one of ${allowed.join(' | ')}` }
|
|
77
94
|
/*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
95
|
+
* **잘못된 선언은 문 앞에서 막는다.** 예전에는 검증 없이 실었고, 다음 tick 에서 `rate.meanPerHour` 를
|
|
96
|
+
* 읽다 터져 **서버 프로세스가 내려갔다**(클라이언트가 보낸 값 하나로 앱 전체가 죽었다). 규칙은 커널이
|
|
97
|
+
* 들고 있다(계약의 주인) — 호스트가 따로 만들면 방언이 생긴다.
|
|
80
98
|
*/
|
|
81
|
-
if (
|
|
99
|
+
if (action === 'stimulus') {
|
|
100
|
+
const v = validateScenario(scenario)
|
|
101
|
+
if (v.ok === false) return { ok: false, code: 'invalid', detail: v.errorCode, params: v.errorParams }
|
|
102
|
+
}
|
|
103
|
+
if (action === 'speed' && !(Number(speed) > 0)) return { ok: false, code: 'invalid', detail: 'speed must be greater than 0' }
|
|
104
|
+
|
|
82
105
|
/*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
106
|
+
* **구동도 사람이 한 일이다 — 남긴다** (ADR-0029 §1 결정 C, 2026-08-19).
|
|
107
|
+
*
|
|
108
|
+
* 이 문은 감사를 아예 남기지 않았다. 그런데 자극을 바꾸고 배속을 올리고 원본을 멈추는 것은 그 트윈의
|
|
109
|
+
* 사실을 바꾸는 행위다 — 커맨드 채널은 남기는데 이쪽이 비어 있으면, 나중에 「무엇이 이 공장을
|
|
110
|
+
* 흔들었나」에 답할 수 없다.
|
|
111
|
+
*
|
|
112
|
+
* 결정 C 그대로: 게이트는 여기 남고(위 `@privilege`), **원본의 답을 같은 행에 닫는다.** 답을 못 받으면
|
|
113
|
+
* `unknown` 이다 — 성공으로 적지 않는다.
|
|
86
114
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return false
|
|
93
|
-
}
|
|
115
|
+
let result: { ok: boolean; code?: string; detail?: string }
|
|
116
|
+
try {
|
|
117
|
+
result = await routeControl(domainId, instanceId, { action: action as any, scenario, speed: speed != null ? Number(speed) : undefined })
|
|
118
|
+
} catch (e: any) {
|
|
119
|
+
result = { ok: false, code: 'failed', detail: e?.message ?? 'control failed' }
|
|
94
120
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
try {
|
|
122
|
+
const audit = buildCommandAudit({
|
|
123
|
+
/* 커맨드 채널과 같은 모양으로 적는다 — 감사를 읽는 쪽이 두 어휘를 배우지 않게. */
|
|
124
|
+
command: { type: `source.${action}`, origin: 'user', args: { ...(scenario ? { scenario } : {}), ...(speed != null ? { speed } : {}) } },
|
|
125
|
+
/* 게이트는 통과했다(여기까지 왔다) — 그 사실과 원본의 답은 다른 칸이다. */
|
|
126
|
+
ack: { accepted: true },
|
|
127
|
+
source: { answered: typeof result?.ok === 'boolean', ok: result?.ok, code: result?.code },
|
|
128
|
+
actor: context.state.user,
|
|
129
|
+
at: new Date()
|
|
130
|
+
})
|
|
131
|
+
const repo = getRepository(TwinAuditEvent)
|
|
132
|
+
await repo.save(repo.create({ domain: context.state.domain, instanceId, ...audit }))
|
|
133
|
+
} catch (err) {
|
|
134
|
+
/* 기록이 실패해도 결과는 돌려준다 — 다만 삼키지 않는다(「기록 없음」과 「일 없음」을 구별해야 한다). */
|
|
135
|
+
console.error(`[twin-control] source control audit not recorded for "${instanceId}"`, err)
|
|
136
|
+
}
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 이 트윈의 원본이 **무엇을 받을 수 있나** — 화면이 버튼을 열기 전에 묻는 자리. */
|
|
141
|
+
@Query(returns => ScalarObject, {
|
|
142
|
+
description: "What the source behind this twin can do: its declared capabilities ('live', 'control'). Empty when the twin has no source or its adapter declares nothing."
|
|
143
|
+
})
|
|
144
|
+
async twinSourceCapability(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<any> {
|
|
145
|
+
const domainId = context.state.domain?.id
|
|
146
|
+
if (!domainId) return { capabilities: [] }
|
|
147
|
+
return controlCapabilityOf(domainId, instanceId)
|
|
101
148
|
}
|
|
102
149
|
}
|
|
@@ -61,19 +61,25 @@ export class TwinInstance {
|
|
|
61
61
|
kind: string
|
|
62
62
|
|
|
63
63
|
/*
|
|
64
|
-
*
|
|
65
|
-
* 'mirror' = 현실은 외부 실물 시스템. 트윈=순수 렌즈, 재기동 시 실물서 재동기(startLive).
|
|
66
|
-
* 'sim-world' = 트윈이 낳은 타임라인이 곧 진실이며 이어진다 → 재기동 시 저널서 resume(현실 지속).
|
|
67
|
-
* 'sim-experiment' = seed 재현형. 재기동 = 같은 우주 재실행(저널 reset, revision 0) — 백지 시작이 버그가 아니라 선언된 거동.
|
|
64
|
+
* **재기동 정책** — 이 트윈은 다시 세워질 때 자기 과거를 어떻게 대하나(ADR-0029 §2).
|
|
68
65
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
66
|
+
* 'resync' = 외부 실물에서 다시 읽는다(원본이 진실이므로 우리 과거를 이어 붙이지 않는다 → startLive).
|
|
67
|
+
* 'resume' = 저널을 이어 세운다(이 트윈이 낳은 타임라인이 곧 사실이다).
|
|
68
|
+
* 'reset' = 저널을 비우고 씨앗부터 다시 돌린다(재현 가능한 실험 — 백지 시작이 선언된 거동이다).
|
|
69
|
+
*
|
|
70
|
+
* ── 왜 트윈에 남았나 (옛 이름 `realityMode`) ────────────────────────────────
|
|
71
|
+
* 「현실이 어디서 오나」는 ADR-0029 가 **원본의 종류**로 옮긴 물음이다(`TwinReference.adapterType`) —
|
|
72
|
+
* 트윈은 한 종류이고 언제나 원본을 반영한다. 그런데 코드가 이 축으로 실제로 가른 것은 **재기동 거동
|
|
73
|
+
* 하나**였고, 그것은 원본의 성질이 아니라 **트윈과 자기 과거의 관계**다: 같은 시뮬레이터 원본을 보는 두
|
|
74
|
+
* 트윈이 하나는 이어가고 하나는 매번 처음부터 재현할 수 있다. 그래서 축은 남고 이름만 바뀌었다.
|
|
75
|
+
*
|
|
76
|
+
* **비어 있을 수 없고, 기본값도 없다.** 예전에는 미선언이 조용히 `sim-experiment`(=저널 초기화)로
|
|
77
|
+
* 떨어졌다 — 값이 하나 어긋나는 순간 이력을 지우는 길이다. 이제 읽는 쪽이 **던진다**
|
|
78
|
+
* (`engine/restart-policy.ts`). 선언은 프로비저닝하는 쪽의 몫이다.
|
|
73
79
|
*/
|
|
74
80
|
@Column()
|
|
75
|
-
@Field({ description: "
|
|
76
|
-
|
|
81
|
+
@Field({ description: "Restart policy: 'resync' (read again from the external source) | 'resume' (continue this twin's own journal) | 'reset' (wipe and replay from the seed). No default — an unspecified policy is refused." })
|
|
82
|
+
restartPolicy: string
|
|
77
83
|
|
|
78
84
|
/* 읽을 때 어휘 세대를 흡수한다(`nodes`/`movers`/`equipmentList` → `locations`/`equipment`). (vocabulary-guard: allow — 세대 이름 없이는 설명할 수 없다.)
|
|
79
85
|
자리마다 리더를 쓰게 하면 다음에 추가되는 자리가 또 빠지고, 빠진 것은 오류가 아니라 **빈 공장**이라
|
|
@@ -14,7 +14,8 @@ import { domainCatalog } from './domain-catalog.js'
|
|
|
14
14
|
* 트윈 인스턴스 생명주기 채널 — 프로비저닝(상향, ADR-0015).
|
|
15
15
|
* State/Command/Scenario 는 이미 running 인 인스턴스를 다루지만, 이 채널은 인스턴스 자체를 만들고/설정하고/없앤다.
|
|
16
16
|
* 생명주기: provision(save, 미기동) → start → stop → delete. 모두 domain 스코프(context.state.domain.id).
|
|
17
|
-
* model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은
|
|
17
|
+
* model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 **원본이 선언하고**
|
|
18
|
+
* (`TwinReference.connectionConfig.scenario`) 기동이 싣거나, 원본의 구동 능력으로 태운다(ADR-0029 §4·§8).
|
|
18
19
|
*/
|
|
19
20
|
@Resolver()
|
|
20
21
|
export class TwinLifecycleMutation {
|
|
@@ -374,7 +374,7 @@ export class TwinModelQuery {
|
|
|
374
374
|
})(),
|
|
375
375
|
description: inst.description ?? null,
|
|
376
376
|
kind: inst.kind,
|
|
377
|
-
|
|
377
|
+
restartPolicy: inst.restartPolicy,
|
|
378
378
|
source: ref
|
|
379
379
|
? { referenceId: ref.source, adapterType: ref.adapterType, status: ref.status, lastSyncedAt: ref.lastSyncedAt }
|
|
380
380
|
: null,
|
|
@@ -63,8 +63,8 @@ before(async () => {
|
|
|
63
63
|
const d = await ds.getRepository(Domain).save({ name: 'adopt-test', subdomain: 'adopt-test' } as any)
|
|
64
64
|
domainId = d.id
|
|
65
65
|
|
|
66
|
-
await TwinEngine.ingestMaster(domainId, master(MIRROR, 3, 2))
|
|
67
|
-
await TwinEngine.ingestMaster(domainId, master(SIM, 3, 2))
|
|
66
|
+
await TwinEngine.ingestMaster(domainId, master(MIRROR, 3, 2), 'reset')
|
|
67
|
+
await TwinEngine.ingestMaster(domainId, master(SIM, 3, 2), 'reset')
|
|
68
68
|
})
|
|
69
69
|
|
|
70
70
|
after(async () => {
|
|
@@ -74,10 +74,10 @@ after(async () => {
|
|
|
74
74
|
|
|
75
75
|
test('돌고 있는 미러는 멈추지 않고 갈아탄다 — 세 층이 함께 움직인다', async () => {
|
|
76
76
|
/* 미러 기동은 선언 기반 부팅이 가른다 — `startFromRegistry` 는 시뮬 경로다(같은 함수가 아니다). */
|
|
77
|
-
const rt = await TwinEngine.bootDeclared(domainId, MIRROR, '
|
|
77
|
+
const rt = await TwinEngine.bootDeclared(domainId, MIRROR, 'resync')
|
|
78
78
|
assert.equal(rt.mode, 'live', '미러로 떠야 이 검사가 뜻이 있다')
|
|
79
79
|
|
|
80
|
-
const res = await TwinEngine.ingestMaster(domainId, master(MIRROR, 7, 2))
|
|
80
|
+
const res = await TwinEngine.ingestMaster(domainId, master(MIRROR, 7, 2), 'reset')
|
|
81
81
|
|
|
82
82
|
/* ① 리비전 — 이후 이벤트가 달고 다닐 번호가 올라간다 */
|
|
83
83
|
const revs = await ds.getRepository(TwinStructure).find({ where: { instanceId: MIRROR }, order: { rev: 'ASC' } })
|
|
@@ -101,7 +101,7 @@ test('돌고 있는 미러는 멈추지 않고 갈아탄다 — 세 층이 함
|
|
|
101
101
|
})
|
|
102
102
|
|
|
103
103
|
test('사라진 설비를 수로 알린다 — 자리가 수천인 현장에서 직접 세어 보게 하지 않는다', async () => {
|
|
104
|
-
const res = await TwinEngine.ingestMaster(domainId, master(MIRROR, 7, 1)) // 지게차 2 → 1
|
|
104
|
+
const res = await TwinEngine.ingestMaster(domainId, master(MIRROR, 7, 1), 'reset') // 지게차 2 → 1
|
|
105
105
|
const adopted = res.warnings.find((w: any) => w.code === 'structure-adopted')
|
|
106
106
|
assert.equal(adopted.params.equipmentDropped, 1)
|
|
107
107
|
|
|
@@ -111,10 +111,10 @@ test('사라진 설비를 수로 알린다 — 자리가 수천인 현장에서
|
|
|
111
111
|
})
|
|
112
112
|
|
|
113
113
|
test('돌고 있는 시뮬레이션은 여전히 거절한다 — 조건이 바뀐 실험은 다른 실험이다', async () => {
|
|
114
|
-
await TwinEngine.bootDeclared(domainId, SIM, '
|
|
114
|
+
await TwinEngine.bootDeclared(domainId, SIM, 'reset')
|
|
115
115
|
const before = (await ds.getRepository(TwinStructure).find({ where: { instanceId: SIM } })).length
|
|
116
116
|
|
|
117
|
-
await assert.rejects(() => TwinEngine.ingestMaster(domainId, master(SIM, 9, 2)), /is running/)
|
|
117
|
+
await assert.rejects(() => TwinEngine.ingestMaster(domainId, master(SIM, 9, 2), 'reset'), /is running/)
|
|
118
118
|
|
|
119
119
|
assert.equal(
|
|
120
120
|
(await ds.getRepository(TwinStructure).find({ where: { instanceId: SIM } })).length,
|
package/test/boot-resume.test.ts
CHANGED
|
@@ -43,14 +43,14 @@ test('부팅이 런타임까지 되살린다 — 상태 복구만으로는 도
|
|
|
43
43
|
|
|
44
44
|
test('모드를 지어내지 않는다 — 미러였던 것은 미러로 되살린다', () => {
|
|
45
45
|
const body = methodOf(engine, 'resumeRow')
|
|
46
|
-
assert.match(body, /
|
|
46
|
+
assert.match(body, /restartPolicy === 'resync'/, '선언된 모드를 보고 갈라야 한다')
|
|
47
47
|
assert.match(body, /this\.startLive\(/, '미러는 관측 구동으로 세운다')
|
|
48
48
|
assert.match(body, /this\.startFromRegistry\(/, '시뮬은 등록부의 모드 그대로 세운다')
|
|
49
49
|
/*
|
|
50
50
|
* 미러 가지 안을 그대로 떠서 본다 — 미러를 시뮬로 세우면 관측 트윈이 **스스로 물건을 옮긴다**
|
|
51
51
|
* (없던 움직임을 만드는 것이다). 단어의 거리로 재면 로그·주석에 걸려 헛돈다.
|
|
52
52
|
*/
|
|
53
|
-
const at = body.indexOf("
|
|
53
|
+
const at = body.indexOf("restartPolicy === 'resync'")
|
|
54
54
|
const mirrorBranch = body.slice(at, body.indexOf('} else {', at))
|
|
55
55
|
assert.match(mirrorBranch, /startLive\(/, '미러 가지는 관측 구동으로 세워야 한다')
|
|
56
56
|
assert.equal(mirrorBranch.includes('startFromRegistry'), false, '미러 가지에서 시뮬 기동을 부른다 — 없던 움직임을 만든다')
|
|
@@ -72,7 +72,7 @@ test('벤치 사본은 무인으로 되살아나지 않는다 — 실험은 재
|
|
|
72
72
|
|
|
73
73
|
test('피드 재개는 등록부가 「도는 미러」라고 말하는 것만 — 멈춘 것을 다시 켜지 않는다', () => {
|
|
74
74
|
assert.match(live, /export async function resumeReferenceLiveFeeds/, '재개 함수가 있어야 한다')
|
|
75
|
-
assert.match(live, /r\.status === 'running' && r\.
|
|
75
|
+
assert.match(live, /r\.status === 'running' && r\.restartPolicy === 'resync'/, '도는 미러만 골라야 한다')
|
|
76
76
|
assert.match(live, /discoverSites/, '사이트 서술자는 다시 찾아서 얻는다(hint 를 남기지 않았다)')
|
|
77
77
|
/* 붙지 않은 미러는 「도는데 아무것도 오지 않는」 트윈이다 — 조용히 넘기지 않는다. */
|
|
78
78
|
assert.match(live, /NOT reattached/, '못 붙인 것은 이유와 함께 남겨야 한다')
|