/* * 웜스타트 판정 — **순수**. "기동하는 커널에 직전 상태를 주입할 것인가, 주입한다면 무엇을" 만 정한다. * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다. * * ── 왜 떼어냈나 ───────────────────────────────────────────────────────────── * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 주입해야 할 때 안 주입하면 **있는 재고가 0 으로** * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지 * 말아야 할 벤치에 주입하면 **용량 측정이 오염된다.** 둘 다 알리지 않고 틀리는 종류라 규칙을 고정한다. * * ── 오더를 버리던 전제가 낡았다 (2026-08-05) ──────────────────────────────── * 예전 주석은 "스냅샷의 오더에는 progress 밖에 없어서 역산하면 없는 숫자를 지어내는 셈이므로 * 넘기지 않는다" 였다. 그때는 맞았다. 그러나 커널 `OrderState` 가 **원값을 되찾은 뒤** * (`requested`·`fulfilled`·`lines`) 이 판정만 그대로 남아, 재기동하면 재고는 맞는데 **진행 중 * 주문이 통째로 사라진** 화면이 나왔다. 오류는 나지 않는다 — 그래서 오래 남아 있었다. * * 두 스냅샷 생산 경로가 모두 원값을 담는다: 시뮬은 커널 `snapshot()`, 라이브(미러)는 * `StateProjector` 다. 사람·자산·작업도 같은 스냅샷에 있고 커널이 심을 수 있는데 넘기지 않고 있었다. * 그래서 씨앗을 **관측 스냅샷 전체**로 넓힌다. * * 다만 넓히면서 **들어온 것이 없는 누락을 만들지 않는다.** 원값이 없는 오더는 커널이 걸러 내므로(지어내지 * 않는다), 그 수를 여기서 세어 호출부가 말할 수 있게 한다. 세지 않으면 "주문이 없다" 와 "주문을 * 못 심었다" 가 화면에서 같아진다. */ /** * 커널에 심을 관측 상태 — 구조가 아니라 "무엇이 어디에 얼마나". * * 커널 `hydrateObserved` 가 받는 스냅샷 모양을 그대로 따른다. 여기서 골라 담지 않는 이유: 무엇을 * 심을 수 있는지는 커널이 알고, 그 규칙(고아 작업 제외·이행 완료 오더 제외 등)도 커널에 있다. * 호스트가 미리 골라내면 규칙이 두 곳으로 갈린다. */ export interface ObservedSeed { locations: unknown[] items: unknown[] equipment: unknown[] persons?: unknown[] assets?: unknown[] tasks?: unknown[] orders?: unknown[] /** 확인해 둔 주목 신호 id — 상태에서 파생되지 않아 반드시 함께 넘긴다. */ acked?: string[] /** * 주목 신호가 **처음 성립한 시각** — 확인과 같은 성질이다(계산으로 되살릴 수 없다). * * 넘기지 않으면 재기동 뒤 세 시간째 지속된 조건이 「0초째」로 되살아난다. 방금 찬 자리와 세 시간째 * 막힌 자리는 할 일이 다르므로, 그 값이 거짓이면 화면이 사람을 잘못된 순서로 움직인다. */ attentionSince?: { id: string; since: string }[] } /** 심은 것의 내역 — 로그와 테스트가 같은 숫자를 본다. */ export interface WarmStartCounts { itemCount: number equipmentCount: number personCount: number assetCount: number taskCount: number /** 원값(requested)이 있어 커널이 실제로 심을 수 있는 오더. */ orderCount: number /** 이어받은 확인 처리 수 — 잃으면 확인해 둔 신호가 다시 빨개진다. */ ackedCount: number /** 이어받은 시작 시각 수 — 잃으면 지속된 조건이 모두 「방금」이 된다. */ attentionSinceCount: number /** * 원값이 없어 심지 못하는 오더 — **0 이 아니면 반드시 말한다.** * progress 만 있는 오더는 남은 수량을 알 수 없어 재계획의 근거가 되지 못한다. 지어내지 않고 뺀다. */ ordersWithoutDemand: number /** Finished orders carried folded (ADR-0092) — 0 when the twin had folded nothing. */ foldedOrderCount: number } export type WarmStartPlan = | ({ action: 'hydrate'; seed: ObservedSeed } & WarmStartCounts) /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 주입하면 측정이 오염된다. */ | { action: 'skip'; reason: 'bench' } /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */ | { action: 'skip'; reason: 'no-state' } /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */ | { action: 'skip'; reason: 'unsupported' } /** 스냅샷이 들고 올 수 있는 관측 축 — 어느 것이든 있으면 주입할 가치가 있다. */ interface ObservedState { locations?: unknown[] items?: unknown[] equipment?: unknown[] persons?: unknown[] assets?: unknown[] tasks?: unknown[] orders?: unknown[] foldedOrders?: unknown acked?: string[] attentionSince?: { id: string; since: string }[] } function foldedTotal(f: { counts: Record> }): number { let n = 0 for (const byStatus of Object.values(f.counts)) for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0 return n } /** 관측 상태가 실려 있는지 — 축 하나라도 있으면 상태다(봉투에는 없다). */ const looksLikeState = (x: any): boolean => !!x && typeof x === 'object' && ['locations', 'items', 'equipment', 'orders', 'tasks', 'persons', 'assets', 'acked'].some(k => k in x) /** * 봉투를 벗긴다 — **체크포인트가 겹포장돼 복원이 알리지 않고 죽던 자리.** * * `TwinEngine.snapshot()` 은 라이브에서는 상태를, 시뮬에서는 `runtime.resync()` 봉투(`{revision,state}`)를 * 돌려준다. 그 봉투를 다시 `{revision, state}` 로 감싸 캐시에 넣었으므로, 꺼낸 `state` 는 축이 하나도 * 없는 껍데기였다 → `planWarmStart` 가 "심을 것이 없다" 로 판정하고 **알리지 않고** 넘어갔다. 저널에 수천 건이 * 있고 캐시도 최신인데 트윈이 빈 채로 떴다(오류도 로그도 없다). * * 반환 모양을 통일하는 것이 뿌리 수정이지만, 그 값은 State 구독 페이로드로도 나가므로 클라이언트 계약을 * 건드린다. 그래서 **복원 경로에서만** 벗긴다. 겹겹이 싸여 있어도 벗긴다(과거에 저장된 값이 남아 있다). */ export function unwrapState(x: any, depth = 4): any { if (!x || typeof x !== 'object') return x if (looksLikeState(x)) return x if (depth > 0 && x.state && typeof x.state === 'object') return unwrapState(x.state, depth - 1) return x } const arr = (v: unknown[] | undefined): unknown[] => (Array.isArray(v) ? v : []) /** 원값이 있는 오더인가 — 커널 `hydrateObserved` 의 판정(`requested !== undefined`)과 같은 기준. */ const hasDemand = (o: unknown): boolean => !!o && typeof o === 'object' && (o as { requested?: unknown }).requested !== undefined /** * 무엇을 할지 정한다. * * 오더·작업·사람·자산도 함께 주입한다. 골라내는 규칙은 커널이 들고 있고(이행 완료 오더, 고아 작업), * 여기서는 **심을 수 없는 오더의 수만 센다** — 호출부가 그 사실을 말할 수 있어야 하기 때문이다. */ export function planWarmStart(state: ObservedState | null | undefined, purpose: string | undefined, canHydrate: boolean): WarmStartPlan { /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 주입하지 않는다는 사실이 바뀌지 않는다. */ if (purpose === 'bench') return { action: 'skip', reason: 'bench' } if (!state) return { action: 'skip', reason: 'no-state' } /* 봉투가 들어오면 스스로 벗는다 — 호출부가 한 곳이라도 잊으면 그 경로가 알리지 않고 죽는다(실제로 그랬다). */ state = unwrapState(state) const locations = arr(state.locations) const items = arr(state.items) const equipment = arr(state.equipment) const persons = arr(state.persons) const assets = arr(state.assets) const tasks = arr(state.tasks) const orders = arr(state.orders) /* 확인 처리는 문자열 id 목록이다 — 배열이 아니면 없는 것으로 본다(지어내지 않는다). */ const acked = Array.isArray(state.acked) ? state.acked.filter((x): x is string => typeof x === 'string') : [] /* 시작 시각은 {id, since} 쌍 목록이다 — 모양이 아니면 없는 것으로 본다(지어낸 시각을 심으면 화면이 「세 시간째」라고 거짓을 말한다. 모르면 그 신호는 이어붙는 순간이 새 시작이다). */ const attentionSince = Array.isArray(state.attentionSince) ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string') : [] /* * Folded orders (ADR-0092) — their counts are the only record of the finished orders taken out of the hot * list; nothing re-sends them. The seed used to leave them out, so a restart lost every folded count. * Carried as the kernel wrote them; a value without a `counts` object is not a folded side. */ const f: any = (state as any).foldedOrders const foldedOrders = f && typeof f === 'object' && f.counts && typeof f.counts === 'object' ? f : undefined /* 어느 축도 없으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */ if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !foldedOrders && !acked.length && !attentionSince.length) { return { action: 'skip', reason: 'no-state' } } /* 지원 여부는 마지막에 본다 — 주입할 것이 있는데 주입하지 못하는 상황이라야 경고할 가치가 있다. */ if (!canHydrate) return { action: 'skip', reason: 'unsupported' } const seedable = orders.filter(hasDemand) return { action: 'hydrate', seed: { locations, items, equipment, ...(persons.length ? { persons } : {}), ...(assets.length ? { assets } : {}), ...(tasks.length ? { tasks } : {}), /* 원값 없는 오더는 넘기지 않는다 — 커널도 걸러 내지만, 넘기지 않으면 세어 둔 수와 어긋날 일이 없다. */ ...(seedable.length ? { orders: seedable } : {}), ...(foldedOrders ? { foldedOrders } : {}), /* 확인 처리는 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 그 신호가 없으면 표시할 대상이 없다. */ ...(acked.length ? { acked } : {}), /* 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 커널이 그 신호가 없으면 함께 지운다. */ ...(attentionSince.length ? { attentionSince } : {}) }, itemCount: items.length, equipmentCount: equipment.length, personCount: persons.length, assetCount: assets.length, taskCount: tasks.length, orderCount: seedable.length, foldedOrderCount: foldedOrders ? foldedTotal(foldedOrders) : 0, ordersWithoutDemand: orders.length - seedable.length, ackedCount: acked.length, attentionSinceCount: attentionSince.length } } /* * ── 미러(관측 구동) 재기동의 연속성 ───────────────────────────────────────── * * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 심지 않는다 — 다음 계측·다음 마스터 재읽기가 * 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다(그래서 `startLive` 는 오랫동안 아무것도 * 심지 않았다). * * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 계산해 만든 것들이다: * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각 * · 에너지의 열린 구간 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다 * * 잃으면 **오류 없이 값이 작아진다**(실측 2026-08-18: 재기동 뒤 그 구간의 전력량·피크가 작아지고, 세 * 시간째 지속된 경보가 「0초째」가 됐다). 그래서 축을 갈라 되풀어 주지 않는 것만 이어받는다. * * 무엇을 이어받을지는 여기서 고르고, **어떻게 흡수할지는 커널이 정한다**(`hydrateContinuity`) — 도메인 * 규칙이 호스트로 새면 트윈마다 다른 답이 된다. */ export interface ContinuitySeed { acked?: string[] attentionSince?: { id: string; since: string }[] energy?: unknown /** 접힌 오더(ADR-0092) — 모양은 커널이 안다(호스트는 있나만 본다). */ foldedOrders?: unknown } export interface ContinuityPlan { seed: ContinuitySeed /** 무엇을 이어받았나 — 로그와 시험이 같은 숫자를 본다(0 이면 이어받을 것이 없었다는 사실이다). */ ackedCount: number attentionSinceCount: number /** 에너지 연속성을 이어받았나 — 열린 구간이 있으면 그 구간의 요금 근거가 살아난다. */ energy: boolean /** 접힌 오더를 이어받았나(ADR-0092). */ foldedOrders: boolean } /** 미러가 이어받을 축만 골라낸다 — 관측 축(재고·위치·설비)은 일부러 넣지 않는다. */ export function planLiveContinuity(raw: unknown): ContinuityPlan { const state = raw ? (unwrapState(raw as any) as any) : undefined const acked = Array.isArray(state?.acked) ? state.acked.filter((x: any): x is string => typeof x === 'string') : [] const attentionSince = Array.isArray(state?.attentionSince) ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string') : [] /* 에너지는 커널이 자기 모양을 안다 — 호스트는 「있나」만 본다(골라 담으면 규칙이 두 곳으로 갈린다). */ const energy = state?.energy && typeof state.energy === 'object' ? state.energy : undefined /* 접힌 오더(ADR-0092) — 원천을 다시 읽는 미러도 세는 수와 경계를 잃지 않게(없으면 옛 종결이 뜨거운 줄로 되살아난다). */ const foldedOrders = state?.foldedOrders && typeof state.foldedOrders === 'object' ? state.foldedOrders : undefined return { seed: { ...(acked.length ? { acked } : {}), ...(attentionSince.length ? { attentionSince } : {}), ...(energy ? { energy } : {}), ...(foldedOrders ? { foldedOrders } : {}) }, ackedCount: acked.length, attentionSinceCount: attentionSince.length, energy: !!energy, foldedOrders: !!foldedOrders } }