import { twinError } from '../../engine/log.js' import { Arg, Ctx, Mutation, Query, Resolver, Directive } from 'type-graphql' import { validateScenario } from '@operato/ops-contract' import { ScalarObject } from '@things-factory/shell' import { getRepository } from '@things-factory/shell' import { TwinEngine } from '../../engine/index.js' import { TwinAuditEvent } from '../twin-audit/twin-audit-event.js' import { buildCommandAudit } from '../twin-audit/command-audit.js' /* 구동은 원본을 지난다(ADR-0029 §8) — 트윈 런타임을 직접 잡지 않는다. */ import { routeControl, controlCapabilityOf } from '../reference/control-routing.js' /* * 트윈 Command·Scenario 채널 — 상향 mutation. * Command: runtime.dispatch(행위). Scenario: runtime.scenario.load/start/pause/setSpeed(예측 자극). * (State 하향은 twin-state subscription. tick 은 워커.) * * 인가: data 구독 리졸버와 대칭으로, 대상 인스턴스가 **호출자 도메인 소속**인지 검증한다(cross-tenant 커맨드 주입 차단). * command 채널은 sim 상태를 변이하므로 도메인 스코프가 필수. */ @Resolver() export class TwinControlMutation { /** Command 채널 — 오더 hold/resume/release 등. 커널 dispatch 로 위임, ack{accepted,error} 반환. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'dispatch a twin command (order.hold/resume/release …). Returns {accepted, error?}.' }) async dispatchTwinCommand( @Arg('instanceId') instanceId: string, @Arg('command', type => ScalarObject) command: any, @Ctx() context: ResolverContext ): Promise<{ accepted: boolean; error?: string; errorCode?: string; errorParams?: Record }> { const domainId = context.state.domain?.id if (!domainId) return { accepted: false, error: 'no-domain', errorCode: 'no-domain' } /* * 라우팅은 엔진이 한다 — 예전에는 여기서 `inst.runtime.dispatch` 를 곧바로 불렀고, `runtime` 이 * 시뮬에만 있어서 미러 트윈에서는 이 한 줄이 TypeError 로 터졌다(깔끔한 거절이 아니라 실패). * 권위(모드) × 성격(주석/액추에이션) 판정은 한 곳에만 둔다. */ /* 원본을 그대로 감사에 넘긴다 — 무엇이 들어왔는지가 기록의 대상이다(정규화는 감사 모듈이 한다). */ const cmdWithOrigin = command const ack = await TwinEngine.dispatchCommand(domainId, instanceId, command) /* 라이브 구조 변이(resource.add)는 저장 model 에도 반영 — 프로비저닝 편집기·재기동이 추가분을 잃지 않게. */ if (ack?.accepted && command?.type === 'resource.add') { await TwinEngine.syncBoardEquipment(domainId, instanceId).catch(err => twinError('twin syncBoardEquipment fail', err)) } /* * 감사 기록 — 승인 게이트가 앞을 막고, 이 줄이 뒤를 설명한다. 둘 다 있어야 "사람 손을 거쳤다" 를 * 증명할 수 있다. 커널이 거절한 것도 남긴다 — 무엇을 시도했는지가 감사의 절반이다. * * 기록이 실패해도 커맨드 결과는 그대로 돌려준다. 감사가 본업을 막으면 안 되지만, 실패를 삼키지도 * 않는다(로그에 남긴다) — 알리지 않고 안 남으면 나중에 "기록이 없다" 와 "일이 없었다" 를 구분 못 한다. */ try { const audit = buildCommandAudit({ command: cmdWithOrigin, ack, actor: context.state.user, at: new Date() }) const repo = getRepository(TwinAuditEvent) await repo.save(repo.create({ domain: context.state.domain, instanceId, ...audit })) } catch (err) { twinError(`[twin-control] command audit not recorded for "${instanceId}"`, err) } /* 거절 사유는 **언어 중립 코드**로 함께 돌려준다 — 화면이 `twin.cmderr.` 로 사람 말을 만든다. */ return { accepted: !!ack?.accepted, error: ack?.error, errorCode: ack?.errorCode, errorParams: ack?.errorParams } } /** * 구동 채널 — **원본을 지난다**(ADR-0029 §8). * * ── 무엇이 바뀌었나 (2026-08-19) ────────────────────────────────────────── * 예전에는 이 문이 `TwinEngine.runtime(...).scenario` 를 **직접** 잡았다. 시뮬만 할 수 있는 일(자극·배속· * 정지)이 계약 밖의 지름길로 들어온 셈이고, 실 시스템에는 그 자리가 아예 없었다. 이제 길은 하나다: * 트윈 → 그 트윈의 원본 → 어댑터가 **선언한** `control` 능력. 선언하지 않은 원본에는 문이 열리지 않고, * 그 사실을 사유 코드로 말한다(`not-declared`) — 실 WMS 가 정지되지 않는 것은 결손이 아니다. * * 반환을 Boolean 에서 객체로 바꿨다: 예전에는 거절 사유를 실을 자리가 없어 **로그로만** 남겼다. * 화면은 왜 안 되는지 알 수 없었다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: "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? }." }) async controlTwinSource( @Arg('instanceId') instanceId: string, @Arg('action') action: string, @Ctx() context: ResolverContext, @Arg('scenario', type => ScalarObject, { nullable: true }) scenario?: any, @Arg('speed', { nullable: true }) speed?: number ): Promise { const domainId = context.state.domain?.id if (!domainId) return { ok: false, code: 'no-domain' } const allowed = ['stimulus', 'start', 'pause', 'speed', 'reset'] if (!allowed.includes(action)) return { ok: false, code: 'unsupported-action', detail: `one of ${allowed.join(' | ')}` } /* * **잘못된 선언은 문 앞에서 막는다.** 예전에는 검증 없이 실었고, 다음 tick 에서 `rate.meanPerHour` 를 * 읽다 터져 **서버 프로세스가 내려갔다**(클라이언트가 보낸 값 하나로 앱 전체가 죽었다). 규칙은 커널이 * 들고 있다(계약의 주인) — 호스트가 따로 만들면 방언이 생긴다. */ if (action === 'stimulus') { const v = validateScenario(scenario) if (v.ok === false) return { ok: false, code: 'invalid', detail: v.errorCode, params: v.errorParams } } if (action === 'speed' && !(Number(speed) > 0)) return { ok: false, code: 'invalid', detail: 'speed must be greater than 0' } /* * **구동도 사람이 한 일이다 — 남긴다** (ADR-0029 §1 결정 C, 2026-08-19). * * 이 문은 감사를 아예 남기지 않았다. 그런데 자극을 바꾸고 배속을 올리고 원본을 멈추는 것은 그 트윈의 * 사실을 바꾸는 행위다 — 커맨드 채널은 남기는데 이쪽이 비어 있으면, 나중에 「무엇이 이 공장을 * 흔들었나」에 답할 수 없다. * * 결정 C 그대로: 게이트는 여기 남고(위 `@privilege`), **원본의 답을 같은 행에 닫는다.** 답을 못 받으면 * `unknown` 이다 — 성공으로 적지 않는다. */ let result: { ok: boolean; code?: string; detail?: string } try { result = await routeControl(domainId, instanceId, { action: action as any, scenario, speed: speed != null ? Number(speed) : undefined }) } catch (e: any) { result = { ok: false, code: 'failed', detail: e?.message ?? 'control failed' } } try { const audit = buildCommandAudit({ /* 커맨드 채널과 같은 모양으로 적는다 — 감사를 읽는 쪽이 두 어휘를 배우지 않게. */ command: { type: `source.${action}`, origin: 'user', args: { ...(scenario ? { scenario } : {}), ...(speed != null ? { speed } : {}) } }, /* 게이트는 통과했다(여기까지 왔다) — 그 사실과 원본의 답은 다른 칸이다. */ ack: { accepted: true }, source: { answered: typeof result?.ok === 'boolean', ok: result?.ok, code: result?.code }, actor: context.state.user, at: new Date() }) const repo = getRepository(TwinAuditEvent) await repo.save(repo.create({ domain: context.state.domain, instanceId, ...audit })) } catch (err) { /* 기록이 실패해도 결과는 돌려준다 — 다만 삼키지 않는다(「기록 없음」과 「일 없음」을 구별해야 한다). */ twinError(`[twin-control] source control audit not recorded for "${instanceId}"`, err) } return result } /** 이 트윈의 원본이 **무엇을 받을 수 있나** — 화면이 버튼을 열기 전에 묻는 자리. */ @Query(returns => ScalarObject, { 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." }) async twinSourceCapability(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain?.id if (!domainId) return { capabilities: [] } return controlCapabilityOf(domainId, instanceId) } }