/* * **승인된 조치가 원본으로 나가는 길** — 판단은 `actuation-target.ts`, 여기는 배선. * * ── 무엇이 끊겨 있었나 (2026-09-02) ──────────────────────────────────────────── * MES 커넥터는 `actuate` 를 다 만들어 두었다. 서명·접수 판정·초안 상태까지 있었다. 그런데 넘기는 * 리졸버가 **어댑터를 찾지도 않고** 「구현이 없습니다」라고 답했다. 그 문장을 사실로 읽어서 설계 * 문서에도, 다른 레인에도 그렇게 알렸다 — 둘 다 틀렸다. * * 자리는 있고 길이 없는 상태다. 이 저장소에서 가장 자주 나는 결함 부류이고, 조치 경로에서 나면 * 결과가 하나다 — **승인은 쌓이는데 현장에는 아무것도 가지 않는다.** * * ── 왜 구동과 따로 두나 ─────────────────────────────────────────────────────── * 모양이 같아서 한 함수로 묶고 싶어진다. 묶지 않는다. * * 구동은 시뮬레이터를 움직인다 틀리면 시늉이 어긋난다 * 조치는 현장에 작업지시를 낸다 틀리면 만들지 않아도 될 것을 만든다 */ import { getRepository } from '@things-factory/shell' import type { ApprovedCommand } from '@operato/ops-contract' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinReference } from './twin-reference.js' import { twinError } from '../../engine/log.js' import { actuationOutcomePatch } from './actuation-verification.js' import { capabilitiesOf, getAdapter, type ActuationReadiness, type ActuationResult, type SiteDescriptor } from './reference-adapter.js' import { APPROVAL_STANDS, actuationTarget, isActuationAllowed, type ActuationTarget } from './actuation-target.js' /** 어댑터가 답하는 모양 그대로 — 디스패처가 이 값을 커맨드 행에 적는다. */ /** * 조치를 낸 결과 — **어댑터가 답한 것을 좁히지 않고 그대로 나른다.** * * ── 왜 넓혔나 (2026-09-04, 인티그레이션 레인이 찾음) ──────────────────────── * 이 타입이 `ok`·`ref`·`error` 셋만 들고 있었다. 그래서 `routeActuation` 이 어댑터의 답을 * 그대로 되돌리는데 **타입은 `note`·`noteNext`·`retry` 를 버린다고 말했다.** * * 값은 흘렀다 — JS 객체가 키를 안 버리므로. 어제 `dispatch_note` 에 실제로 앉았다. 그런데 * **커넥터를 만드는 사람이 그 칸이 있다는 것을 알 방법이 없었다.** 우리가 어제 하루 종일 * 「자리는 있고 길이 없다」로 부른 것의 타입 버전이다. * * 좁히지 않는다: 세 칸을 다 실어 `CommandAck` 와 같은 모양으로 둔다. 커널이 어느 칸을 어떻게 * 쓰는지는 `dispatch` 가 정하고, 이 자리는 나르기만 한다. */ export interface ActuationAck { ok: boolean /** 저쪽 문서·작업 식별자. 없으면 무엇을 되돌릴지 지목할 수 없다. */ ref?: string /** 받아들였으나 저쪽이 덧붙인 말 — **실패가 아니다.** 원인 쪽 문장. */ note?: string /** 그 말 중 다음에 할 일 한 줄 — 붙여 보내지 않는다. */ noteNext?: string error?: string /** 다시 해서 될 일인가 — 실패에만. 말하지 않으면 일꾼이 집지 않는다. */ retry?: 'again' | 'after-fix' | 'never' } /** * 저장소를 **한 번만** 읽고, 판정과 함께 부를 것까지 돌려준다. * * 판정한 뒤 보낼 때 다시 읽으면 그 사이에 연결 설정이 바뀔 수 있다 — 판정한 커넥터와 실제로 부르는 * 커넥터가 달라진다. 그리고 같은 행을 두 번 읽을 이유가 없다. */ async function resolve(domainId: string, instanceId: string) { const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) const ref = inst ? await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } }) : null const adapter = ref ? getAdapter(ref.adapterType) : undefined const target = actuationTarget({ instanceId, twinExists: !!inst, siteId: (inst?.origin as any)?.siteId, ...(ref ? { ref: { source: ref.source, adapterType: ref.adapterType } } : {}), adapterRegistered: !!adapter, capabilities: capabilitiesOf(adapter), declaresActuate: !!adapter?.capabilities?.includes('actuate'), hasActuateImpl: typeof adapter?.actuate === 'function' }) return { target, ref, adapter } } /** * 이 트윈의 원본에 승인된 조치를 보낸다. * * **거절할 때는 무엇이 없어서인지 말한다.** 「보내지 못했습니다」만 답하면 사람이 연결 설정을 열어야 * 하는지, 커넥터를 고쳐야 하는지, 트윈을 다시 만들어야 하는지 알 수 없다. */ export async function routeActuation( domainId: string, instanceId: string, command: ApprovedCommand ): Promise { const { target, ref, adapter } = await resolve(domainId, instanceId) if (!isActuationAllowed(target)) return { ok: false, error: `${target.detail}. ${APPROVAL_STANDS}` } /* * 여기까지 왔으면 판정이 그 둘의 존재를 이미 확인했다(§`actuationTarget`). 그래도 조용히 지나가지 * 않는다 — 판정과 배선이 어긋나면 그것이 결함이고, `!` 로 덮으면 그 결함이 「아무 일도 일어나지 * 않았다」로 나타난다. */ if (!adapter?.actuate) { return { ok: false, error: `커넥터를 찾았는데 조치를 보낼 자리가 없습니다 (${target.adapterType}). ${APPROVAL_STANDS}` } } const site: SiteDescriptor = { siteId: target.siteId } const result = await adapter.actuate(ref?.connectionConfig ?? {}, site, command) /* * **왕복이 일어났다는 사실을 남긴다** (ADR-0046 ②). * * 설정이 맞는 것과 저쪽이 받는 것은 다르고, 그 차이는 한 번 넘겨 봐야만 안다. 승인 화면이 그것을 * 미리 말하려면 지난 왕복의 결과가 어딘가에 남아 있어야 한다 — 없으면 화면은 매번 「해 봐야 안다」만 * 말한다. * * 두 경로(`dispatchAfterApproval` · `sweepPendingDispatch`)가 다 이 함수를 지나므로 여기 한 곳이면 * 된다. 실패해도 조치 결과는 그대로 돌려준다 — 기록이 본업을 막으면 안 되고, 삼키지도 않는다. */ if (ref) await recordActuationOutcome(ref, result).catch(err => twinError('[reference] actuation state not recorded', err)) return result } /** * 조치 결과를 레퍼런스에 적는다 — **코드만, 문장은 아니다.** * * 거절 사유에 코드가 없으면 `actuationFailure` 를 비워 둔다. 자리를 채우려고 `error` 문장을 옮기면 * `lastError` 가 이미 겪은 것을 반복한다 — 저장된 문장은 번역도 집계도 안 되고, 커넥터가 그 문장을 * 고치는 날 옛 행과 새 행이 다른 말을 한다. **모르는 것은 안 적는다.** */ async function recordActuationOutcome(ref: TwinReference, result: ActuationResult): Promise { await getRepository(TwinReference).update({ id: ref.id }, actuationOutcomePatch(result, new Date().toISOString())) } /** * 이 트윈이 조치를 받을 수 있나 — **넘기기 전에 화면이 먼저 알 수 있게.** * * 승인 화면이 「넘기기」를 보이기 전에 이것을 묻는다. 누를 수 없는 것을 보여 주고 눌렀을 때 거절하는 * 것보다, 왜 못 넘기는지 먼저 말하는 것이 낫다. */ export async function actuationTargetOf(domainId: string, instanceId: string): Promise { return (await resolve(domainId, instanceId)).target } /** * **저쪽이 받을 준비가 됐나** — 넘기기 전에 묻는다. 화면이 사람을 헛걸음시키지 않게. * * ── 왜 `actuationTargetOf` 와 따로인가 ────────────────────────────────────── * 그쪽은 **망을 타지 않는다.** 화면이 목록을 그릴 때마다 부를 수 있어야 하므로 그래야 한다. * 이 함수는 커넥터가 저쪽에 물을 수 있고, 그래서 부르는 시점이 다르다 — 사람이 「넘기기」를 * 열었을 때다. * * ── 답이 문이 아니다 ──────────────────────────────────────────────────────── * **이 답으로 넘김을 막지 않는다.** 막으면 점검이 틀렸거나 잠깐 못 닿은 사이에 성공할 수 있었던 * 조치가 못 나간다. 화면이 미리 말하는 데까지가 이 함수의 일이다. * * 커넥터가 이 얼굴을 안 만들었으면 **「모른다」로 답한다** — 「준비됐다」가 아니다. 없는 확인을 * 통과로 읽으면 이 함수가 있으나 없으나 같아진다. */ export async function actuationReadinessOf(domainId: string, instanceId: string): Promise { const { target, ref, adapter } = await resolve(domainId, instanceId) /* 보낼 대상 자체가 없으면 저쪽에 물을 것도 없다 — 그 사실을 그대로 옮긴다. */ if (!isActuationAllowed(target)) { return { checked: 'local', ready: false, reason: target.detail, code: target.code } } if (!adapter?.actuationReadiness) { /* 확인할 길이 없다. 「준비됐다」로 답하지 않는다 — 그러면 화면이 없는 확인을 통과로 읽는다. */ return { checked: 'local', reason: `커넥터 "${target.adapterType}" 는 저쪽이 받을 준비가 됐는지 알려 주지 않습니다 — 넘겨 보고 알게 됩니다`, code: 'not-checkable' } } try { return await adapter.actuationReadiness(ref?.connectionConfig ?? {}, { siteId: target.siteId }) } catch (e: any) { /* * 점검이 터진 것을 「저쪽이 죽었다」로 옮기지 않는다 — 우리 점검의 실패다. 그리고 던지지 않는다: * 이 함수 때문에 승인 화면이 안 열리면, 알려 주려던 것보다 큰 것을 잃는다. */ return { checked: 'local', reason: `준비 상태를 확인하다 실패했습니다 — ${String(e?.message ?? e).slice(0, 160)}`, code: 'check-failed' } } }