/* * **관측이 들어올 때 나간 지시와 잇는다** — 폐루프의 배선. 판단은 `effect-match.ts` 가 한다. * * ── 무엇이 실측으로 확인됐나 (2026-09-02) ───────────────────────────────────── * 인티그레이션 레인이 조치 둘을 MES 까지 걸었다. 트윈 쪽 두 행이 **구별되지 않았다.** * * 325e9bb1 acked WO-20260902-004 지시서 붙음 MES 에서 DRAFT * 57428362 acked WO-20260902-005 지시서 못 붙음 MES 에서 DRAFT * * `state`·`dispatch_ref`·`error` 가 셋 다 같다. 그래서 화면은 둘 다 「나갔다」로 읽는데, 둘 다 아직 * 현장에 안 갔다. `acked` 의 뜻은 **「저쪽이 받았다」**까지다. * * 빠져나오는 것은 관측이다 — 저쪽이 그 오더를 현장에 내면 `order.status` 가 우리가 받은 식별자로 * 돌아온다. 그것을 이 파일이 잇는다. * * ── 왜 유입 자리인가 ───────────────────────────────────────────────────────── * 관측의 **시각**이 봉투에만 있다. 나중에 커널 상태에서 읽으면 「지금」밖에 알 수 없고, 그것은 보고된 * 시각이 아니라 우리가 처리한 시각이다. 없는 사실을 만들지 않는다. * * ── 어디서 부르나 ─────────────────────────────────────────────────────────── * * routes.ts 훅으로 밀려 들어온 것 * reference-live.ts 커넥터가 이어 내는 것 · 폴링으로 물어 온 것 * * **백필(`backfill-period-facts.ts`)에서는 부르지 않는다.** 그것은 마감된 구간의 저널을 채우는 * 일이고, 이 자리가 답하는 것은 「방금 보낸 지시가 어떻게 됐나」다. 저널을 고치는 작업이 조치 기록을 * 바꾸게 두면, 백필을 다시 돌릴 때마다 같은 보고가 다시 세어진다(`seen` 이 는다). * * 그래서 지난 기록에서 효과가 확인되는 경우는 지금 다루지 않는다. 필요해지면 그때는 **백필 전용 * 경로**로 만들 일이다 — 몇 번 세었나가 아니라 「그때 이랬다」만 적는 자리다. * * ── 값은 얼마나 드나 ───────────────────────────────────────────────────────── * 오더 보고가 든 묶음마다 질의 하나다. 조치가 하나도 안 나간 도메인에서도 그 질의가 돈다 — 지금 * 규모에서 그 값은 작지만 공짜라고 말하지 않는다. 더 줄이려면 「효과를 기다리는 식별자」를 프로세스가 * 들고 있어야 하고, 그러면 재기동 때 저장소에서 되세우는 자리가 하나 더 생긴다. 그것이 필요해지는 * 시점은 **오더 보고가 초당 수백 건 오는 도메인**이다. */ import { OP_EVENT } from '@operato/ops-contract' import { twinWarn } from '../../engine/log.js' import type { CommandStore } from './command-dispatcher.js' import { matchEffects, refsToLookUp, type OrderObservation } from './effect-match.js' /** 봉투에서 오더 보고만 골라낸다 — 다른 어휘는 보지 않는다. */ export function orderObservationsIn(envelopes: readonly any[]): OrderObservation[] { const out: OrderObservation[] = [] for (const e of envelopes ?? []) { if (e?.eventType !== OP_EVENT.order) continue const data = e?.data if (!data?.orderId) continue /* * 시각은 **봉투의 것**을 쓴다. 보고한 쪽이 말한 시각이고, 우리가 처리한 시각이 아니다. * 없으면 `matchEffects` 가 거른다 — 여기서 「지금」으로 채우지 않는다. */ out.push({ orderId: String(data.orderId), status: String(data.status ?? ''), at: e?.eventTime }) } return out } export interface SettleEffectsResult { /** 효과를 적은 조치 수. */ recorded: number /** 이을 수 없던 보고 수 — 대개 우리가 내지 않은 오더다. */ unmatched: number } /** * 이 묶음의 오더 보고를 나간 지시와 잇는다 — **아무것도 못 이어도 조용히 지난다.** * * 유입 경로에서 부른다. 그래서 **던지지 않는다** — 효과를 적다 실패해서 관측 유입이 멈추면, 고치는 * 동안 트윈이 눈을 감는다. 효과는 다음 보고에 다시 이을 수 있고 관측은 다시 오지 않는다. */ export async function settleEffects(args: { domainId: string envelopes: readonly any[] store: CommandStore }): Promise { const { domainId, envelopes, store } = args const none = { recorded: 0, unmatched: 0 } const observations = orderObservationsIn(envelopes) if (!observations.length) return none /* * 저장소가 두 자리를 안 갖추면 이을 길이 없다. **조용히 지나지 않고 말한다** — 이 상태에서는 * 넘긴 지시가 저쪽에서 어떻게 됐는지 영원히 모르고, 화면은 그 사실을 알 수 없다. */ if (!store.byDispatchRef || !store.recordEffects) { twinWarn( '[actuation] 나간 지시와 관측을 이을 수 없다 — 저장소에 byDispatchRef 나 recordEffects 가 없다. ' + '넘긴 조치가 저쪽에서 무엇이 되었는지 알 수 없는 상태다.' ) return none } try { const byRef = await store.byDispatchRef(domainId, refsToLookUp(observations)) if (!byRef.size) return { recorded: 0, unmatched: observations.length } const { updates, skips } = matchEffects({ observations, byRef: new Map( [...byRef].map(([ref, c]) => [ref, { id: c.id, dispatchRef: ref, ...(c.effect ? { effect: c.effect } : {}) }]) ) }) if (updates.length) await store.recordEffects(domainId, updates) return { recorded: updates.length, unmatched: skips.length } } catch (e: any) { /* 관측 유입을 멈추지 않는다 — 위 §. 다만 조용히 넘기지도 않는다. */ twinWarn(`[actuation] 효과를 잇다 실패했다 — ${String(e?.message ?? e).slice(0, 200)}`) return none } }