/* * **구동은 원본을 지난다** — 트윈을 우회하지 않는다 (ADR-0029 §8, 2026-08-19). * * ── 무엇이 뚫려 있었나 ────────────────────────────────────────────────────── * `controlTwinScenario` 가 `TwinEngine.runtime(...).scenario` 를 **직접** 잡았다. 그래서 시뮬만 할 수 있는 * 일(자극·배속·정지)이 계약 밖의 지름길로 들어왔고, 실 시스템에는 그 자리가 아예 없었다 — ADR-0029 의 * 판정 문장에 걸리는 상태였다. * * 이제 길은 하나다: **트윈 → 그 트윈의 원본 → 어댑터가 선언한 `control` 능력.** 선언하지 않은 원본에는 * 문이 열리지 않고, 그 사실을 코드로 말한다(`not-declared`). 실 WMS 가 정지되지 않는 것은 결손이 아니다. * * ── 전송은 여기서 묻지 않는다 (§7) ────────────────────────────────────────── * 인프로세스 시뮬레이터는 같은 계약을 인프로세스로 이행한다. 나중에 그것이 다른 프로세스로 나가도 이 경로는 * 바뀌지 않는다 — 「계약을 지나는가」만 보고 「네트워크를 타는가」는 묻지 않는다. */ import { getRepository } from '@things-factory/shell' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinReference } from './twin-reference.js' import { capabilitiesOf, getAdapter, type ControlCommand, type ControlResult, type SiteDescriptor } from './reference-adapter.js' /** * 이 트윈의 원본에 구동 명령을 보낸다 — **원본이 없거나 능력을 선언하지 않으면 거절한다.** * * 거절을 알리지 않고 하지 않는다: 사유 코드를 돌려주고 화면이 그것을 사람 말로 옮긴다. 「아무 일도 일어나지 * 않았다」가 가장 비싼 답이다. */ export async function routeControl(domainId: string, instanceId: string, command: ControlCommand): Promise { const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return { ok: false, code: 'unknown-twin', detail: instanceId } const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } }) if (!ref) { /* 원본을 모르는 트윈 — 구동할 대상이 없다. 트윈을 직접 흔드는 길은 두지 않는다(그것이 지름길이었다). */ return { ok: false, code: 'no-source', detail: `twin "${instanceId}" does not remember a source to drive` } } const adapter = getAdapter(ref.adapterType) if (!adapter) return { ok: false, code: 'no-adapter', detail: ref.adapterType } if (!capabilitiesOf(adapter).includes('control')) { return { ok: false, code: 'not-declared', 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` } } /* * 사이트를 함께 넘긴다 — 원본 하나가 여러 사이트를 낼 수 있다(`discoverSites`). 우리가 아는 것은 * 이 트윈이 어느 사이트에서 왔는지이고(`origin.siteId`), 모르면 넘기지 않는다(지어내지 않는다). */ const siteId = (inst.origin as any)?.siteId const site: SiteDescriptor | undefined = siteId ? { siteId } : undefined return adapter.control!(ref.connectionConfig ?? {}, site, { ...command, instanceId, domainId }) } /** 이 트윈의 원본이 선언한 능력 — 화면이 「무엇을 할 수 있나」를 먼저 알 수 있게. */ export async function controlCapabilityOf(domainId: string, instanceId: string): Promise<{ source?: string; adapterType?: string; capabilities: string[] }> { const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } }) if (!ref) return { capabilities: [] } return { source: ref.source, adapterType: ref.adapterType, capabilities: capabilitiesOf(getAdapter(ref.adapterType)) } }