@things-factory/headless-twin 10.1.41 → 10.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/producer-guard.d.ts +13 -0
- package/dist-server/engine/producer-guard.js +62 -0
- package/dist-server/engine/producer-guard.js.map +1 -0
- package/dist-server/engine/twin-engine.js +15 -0
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +3 -0
- package/dist-server/engine/warm-start.js +17 -1
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +1 -1
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-forecast/forecast-metrics.js +26 -2
- package/dist-server/service/twin-forecast/forecast-metrics.js.map +1 -1
- package/package.json +6 -6
- package/server/engine/producer-guard.ts +64 -0
- package/server/engine/twin-engine.ts +15 -0
- package/server/engine/warm-start.ts +20 -1
- package/server/service/reference/reference-resolver.ts +1 -1
- package/server/service/twin-forecast/forecast-metrics.ts +23 -2
- package/test/forecast-metrics.test.ts +44 -1
- package/test/producer-guard.test.ts +66 -0
- package/test/twin-origin-resync.test.ts +35 -16
- package/test/warm-start-seam.test.ts +18 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -44,6 +44,8 @@ export interface WarmStartCounts {
|
|
|
44
44
|
* progress 만 있는 오더는 남은 수량을 알 수 없어 재계획의 근거가 되지 못한다. 지어내지 않고 뺀다.
|
|
45
45
|
*/
|
|
46
46
|
ordersWithoutDemand: number;
|
|
47
|
+
/** Finished orders carried folded (ADR-0092) — 0 when the twin had folded nothing. */
|
|
48
|
+
foldedOrderCount: number;
|
|
47
49
|
}
|
|
48
50
|
export type WarmStartPlan = ({
|
|
49
51
|
action: 'hydrate';
|
|
@@ -73,6 +75,7 @@ interface ObservedState {
|
|
|
73
75
|
assets?: unknown[];
|
|
74
76
|
tasks?: unknown[];
|
|
75
77
|
orders?: unknown[];
|
|
78
|
+
foldedOrders?: unknown;
|
|
76
79
|
acked?: string[];
|
|
77
80
|
attentionSince?: {
|
|
78
81
|
id: string;
|
|
@@ -26,6 +26,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
26
26
|
exports.unwrapState = unwrapState;
|
|
27
27
|
exports.planWarmStart = planWarmStart;
|
|
28
28
|
exports.planLiveContinuity = planLiveContinuity;
|
|
29
|
+
function foldedTotal(f) {
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const byStatus of Object.values(f.counts))
|
|
32
|
+
for (const c of Object.values(byStatus ?? {}))
|
|
33
|
+
n += Number(c) || 0;
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
29
36
|
/** 관측 상태가 실려 있는지 — 축 하나라도 있으면 상태다(봉투에는 없다). */
|
|
30
37
|
const looksLikeState = (x) => !!x && typeof x === 'object' && ['locations', 'items', 'equipment', 'orders', 'tasks', 'persons', 'assets', 'acked'].some(k => k in x);
|
|
31
38
|
/**
|
|
@@ -79,8 +86,15 @@ function planWarmStart(state, purpose, canHydrate) {
|
|
|
79
86
|
const attentionSince = Array.isArray(state.attentionSince)
|
|
80
87
|
? state.attentionSince.filter((x) => !!x && typeof x.id === 'string' && typeof x.since === 'string')
|
|
81
88
|
: [];
|
|
89
|
+
/*
|
|
90
|
+
* Folded orders (ADR-0092) — their counts are the only record of the finished orders taken out of the hot
|
|
91
|
+
* list; nothing re-sends them. The seed used to leave them out, so a restart lost every folded count.
|
|
92
|
+
* Carried as the kernel wrote them; a value without a `counts` object is not a folded side.
|
|
93
|
+
*/
|
|
94
|
+
const f = state.foldedOrders;
|
|
95
|
+
const foldedOrders = f && typeof f === 'object' && f.counts && typeof f.counts === 'object' ? f : undefined;
|
|
82
96
|
/* 어느 축도 없으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
|
|
83
|
-
if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !acked.length && !attentionSince.length) {
|
|
97
|
+
if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !foldedOrders && !acked.length && !attentionSince.length) {
|
|
84
98
|
return { action: 'skip', reason: 'no-state' };
|
|
85
99
|
}
|
|
86
100
|
/* 지원 여부는 마지막에 본다 — 주입할 것이 있는데 주입하지 못하는 상황이라야 경고할 가치가 있다. */
|
|
@@ -98,6 +112,7 @@ function planWarmStart(state, purpose, canHydrate) {
|
|
|
98
112
|
...(tasks.length ? { tasks } : {}),
|
|
99
113
|
/* 원값 없는 오더는 넘기지 않는다 — 커널도 걸러 내지만, 넘기지 않으면 세어 둔 수와 어긋날 일이 없다. */
|
|
100
114
|
...(seedable.length ? { orders: seedable } : {}),
|
|
115
|
+
...(foldedOrders ? { foldedOrders } : {}),
|
|
101
116
|
/* 확인 처리는 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 그 신호가 없으면 표시할 대상이 없다. */
|
|
102
117
|
...(acked.length ? { acked } : {}),
|
|
103
118
|
/* 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 커널이 그 신호가 없으면 함께 지운다. */
|
|
@@ -109,6 +124,7 @@ function planWarmStart(state, purpose, canHydrate) {
|
|
|
109
124
|
assetCount: assets.length,
|
|
110
125
|
taskCount: tasks.length,
|
|
111
126
|
orderCount: seedable.length,
|
|
127
|
+
foldedOrderCount: foldedOrders ? foldedTotal(foldedOrders) : 0,
|
|
112
128
|
ordersWithoutDemand: orders.length - seedable.length,
|
|
113
129
|
ackedCount: acked.length,
|
|
114
130
|
attentionSinceCount: attentionSince.length
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"warm-start.js","sourceRoot":"","sources":["../../server/engine/warm-start.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 웜스타트 판정 — **순수**. \"기동하는 커널에 직전 상태를 주입할 것인가, 주입한다면 무엇을\" 만 정한다.\n * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.\n *\n * ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────\n * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 주입해야 할 때 안 주입하면 **있는 재고가 0 으로**\n * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지\n * 말아야 할 벤치에 주입하면 **용량 측정이 오염된다.** 둘 다 알리지 않고 틀리는 종류라 규칙을 고정한다.\n *\n * ── 오더를 버리던 전제가 낡았다 (2026-08-05) ────────────────────────────────\n * 예전 주석은 \"스냅샷의 오더에는 progress 밖에 없어서 역산하면 없는 숫자를 지어내는 셈이므로\n * 넘기지 않는다\" 였다. 그때는 맞았다. 그러나 커널 `OrderState` 가 **원값을 되찾은 뒤**\n * (`requested`·`fulfilled`·`lines`) 이 판정만 그대로 남아, 재기동하면 재고는 맞는데 **진행 중\n * 주문이 통째로 사라진** 화면이 나왔다. 오류는 나지 않는다 — 그래서 오래 남아 있었다.\n *\n * 두 스냅샷 생산 경로가 모두 원값을 담는다: 시뮬은 커널 `snapshot()`, 라이브(미러)는\n * `StateProjector` 다. 사람·자산·작업도 같은 스냅샷에 있고 커널이 심을 수 있는데 넘기지 않고 있었다.\n * 그래서 씨앗을 **관측 스냅샷 전체**로 넓힌다.\n *\n * 다만 넓히면서 **들어온 것이 없는 누락을 만들지 않는다.** 원값이 없는 오더는 커널이 걸러 내므로(지어내지\n * 않는다), 그 수를 여기서 세어 호출부가 말할 수 있게 한다. 세지 않으면 \"주문이 없다\" 와 \"주문을\n * 못 심었다\" 가 화면에서 같아진다.\n */\n\n/**\n * 커널에 심을 관측 상태 — 구조가 아니라 \"무엇이 어디에 얼마나\".\n *\n * 커널 `hydrateObserved` 가 받는 스냅샷 모양을 그대로 따른다. 여기서 골라 담지 않는 이유: 무엇을\n * 심을 수 있는지는 커널이 알고, 그 규칙(고아 작업 제외·이행 완료 오더 제외 등)도 커널에 있다.\n * 호스트가 미리 골라내면 규칙이 두 곳으로 갈린다.\n */\nexport interface ObservedSeed {\n locations: unknown[]\n items: unknown[]\n equipment: unknown[]\n persons?: unknown[]\n assets?: unknown[]\n tasks?: unknown[]\n orders?: unknown[]\n /** 확인해 둔 주목 신호 id — 상태에서 파생되지 않아 반드시 함께 넘긴다. */\n acked?: string[]\n /**\n * 주목 신호가 **처음 성립한 시각** — 확인과 같은 성질이다(계산으로 되살릴 수 없다).\n *\n * 넘기지 않으면 재기동 뒤 세 시간째 지속된 조건이 「0초째」로 되살아난다. 방금 찬 자리와 세 시간째\n * 막힌 자리는 할 일이 다르므로, 그 값이 거짓이면 화면이 사람을 잘못된 순서로 움직인다.\n */\n attentionSince?: { id: string; since: string }[]\n}\n\n/** 심은 것의 내역 — 로그와 테스트가 같은 숫자를 본다. */\nexport interface WarmStartCounts {\n itemCount: number\n equipmentCount: number\n personCount: number\n assetCount: number\n taskCount: number\n /** 원값(requested)이 있어 커널이 실제로 심을 수 있는 오더. */\n orderCount: number\n /** 이어받은 확인 처리 수 — 잃으면 확인해 둔 신호가 다시 빨개진다. */\n ackedCount: number\n /** 이어받은 시작 시각 수 — 잃으면 지속된 조건이 모두 「방금」이 된다. */\n attentionSinceCount: number\n /**\n * 원값이 없어 심지 못하는 오더 — **0 이 아니면 반드시 말한다.**\n * progress 만 있는 오더는 남은 수량을 알 수 없어 재계획의 근거가 되지 못한다. 지어내지 않고 뺀다.\n */\n ordersWithoutDemand: number\n}\n\nexport type WarmStartPlan =\n | ({ action: 'hydrate'; seed: ObservedSeed } & WarmStartCounts)\n /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 주입하면 측정이 오염된다. */\n | { action: 'skip'; reason: 'bench' }\n /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */\n | { action: 'skip'; reason: 'no-state' }\n /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */\n | { action: 'skip'; reason: 'unsupported' }\n\n/** 스냅샷이 들고 올 수 있는 관측 축 — 어느 것이든 있으면 주입할 가치가 있다. */\ninterface ObservedState {\n locations?: unknown[]\n items?: unknown[]\n equipment?: unknown[]\n persons?: unknown[]\n assets?: unknown[]\n tasks?: unknown[]\n orders?: unknown[]\n acked?: string[]\n attentionSince?: { id: string; since: string }[]\n}\n\n/** 관측 상태가 실려 있는지 — 축 하나라도 있으면 상태다(봉투에는 없다). */\nconst looksLikeState = (x: any): boolean =>\n !!x && typeof x === 'object' && ['locations', 'items', 'equipment', 'orders', 'tasks', 'persons', 'assets', 'acked'].some(k => k in x)\n\n/**\n * 봉투를 벗긴다 — **체크포인트가 겹포장돼 복원이 알리지 않고 죽던 자리.**\n *\n * `TwinEngine.snapshot()` 은 라이브에서는 상태를, 시뮬에서는 `runtime.resync()` 봉투(`{revision,state}`)를\n * 돌려준다. 그 봉투를 다시 `{revision, state}` 로 감싸 캐시에 넣었으므로, 꺼낸 `state` 는 축이 하나도\n * 없는 껍데기였다 → `planWarmStart` 가 \"심을 것이 없다\" 로 판정하고 **알리지 않고** 넘어갔다. 저널에 수천 건이\n * 있고 캐시도 최신인데 트윈이 빈 채로 떴다(오류도 로그도 없다).\n *\n * 반환 모양을 통일하는 것이 뿌리 수정이지만, 그 값은 State 구독 페이로드로도 나가므로 클라이언트 계약을\n * 건드린다. 그래서 **복원 경로에서만** 벗긴다. 겹겹이 싸여 있어도 벗긴다(과거에 저장된 값이 남아 있다).\n */\nexport function unwrapState(x: any, depth = 4): any {\n if (!x || typeof x !== 'object') return x\n if (looksLikeState(x)) return x\n if (depth > 0 && x.state && typeof x.state === 'object') return unwrapState(x.state, depth - 1)\n return x\n}\n\nconst arr = (v: unknown[] | undefined): unknown[] => (Array.isArray(v) ? v : [])\n\n/** 원값이 있는 오더인가 — 커널 `hydrateObserved` 의 판정(`requested !== undefined`)과 같은 기준. */\nconst hasDemand = (o: unknown): boolean =>\n !!o && typeof o === 'object' && (o as { requested?: unknown }).requested !== undefined\n\n/**\n * 무엇을 할지 정한다.\n *\n * 오더·작업·사람·자산도 함께 주입한다. 골라내는 규칙은 커널이 들고 있고(이행 완료 오더, 고아 작업),\n * 여기서는 **심을 수 없는 오더의 수만 센다** — 호출부가 그 사실을 말할 수 있어야 하기 때문이다.\n */\nexport function planWarmStart(state: ObservedState | null | undefined, purpose: string | undefined, canHydrate: boolean): WarmStartPlan {\n /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 주입하지 않는다는 사실이 바뀌지 않는다. */\n if (purpose === 'bench') return { action: 'skip', reason: 'bench' }\n if (!state) return { action: 'skip', reason: 'no-state' }\n /* 봉투가 들어오면 스스로 벗는다 — 호출부가 한 곳이라도 잊으면 그 경로가 알리지 않고 죽는다(실제로 그랬다). */\n state = unwrapState(state)\n\n const locations = arr(state.locations)\n const items = arr(state.items)\n const equipment = arr(state.equipment)\n const persons = arr(state.persons)\n const assets = arr(state.assets)\n const tasks = arr(state.tasks)\n const orders = arr(state.orders)\n /* 확인 처리는 문자열 id 목록이다 — 배열이 아니면 없는 것으로 본다(지어내지 않는다). */\n const acked = Array.isArray(state.acked) ? state.acked.filter((x): x is string => typeof x === 'string') : []\n /* 시작 시각은 {id, since} 쌍 목록이다 — 모양이 아니면 없는 것으로 본다(지어낸 시각을 심으면 화면이\n 「세 시간째」라고 거짓을 말한다. 모르면 그 신호는 이어붙는 순간이 새 시작이다). */\n const attentionSince = Array.isArray(state.attentionSince)\n ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string')\n : []\n\n /* 어느 축도 없으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */\n if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !acked.length && !attentionSince.length) {\n return { action: 'skip', reason: 'no-state' }\n }\n\n /* 지원 여부는 마지막에 본다 — 주입할 것이 있는데 주입하지 못하는 상황이라야 경고할 가치가 있다. */\n if (!canHydrate) return { action: 'skip', reason: 'unsupported' }\n\n const seedable = orders.filter(hasDemand)\n\n return {\n action: 'hydrate',\n seed: {\n locations,\n items,\n equipment,\n ...(persons.length ? { persons } : {}),\n ...(assets.length ? { assets } : {}),\n ...(tasks.length ? { tasks } : {}),\n /* 원값 없는 오더는 넘기지 않는다 — 커널도 걸러 내지만, 넘기지 않으면 세어 둔 수와 어긋날 일이 없다. */\n ...(seedable.length ? { orders: seedable } : {}),\n /* 확인 처리는 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 그 신호가 없으면 표시할 대상이 없다. */\n ...(acked.length ? { acked } : {}),\n /* 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 커널이 그 신호가 없으면 함께 지운다. */\n ...(attentionSince.length ? { attentionSince } : {})\n },\n itemCount: items.length,\n equipmentCount: equipment.length,\n personCount: persons.length,\n assetCount: assets.length,\n taskCount: tasks.length,\n orderCount: seedable.length,\n ordersWithoutDemand: orders.length - seedable.length,\n ackedCount: acked.length,\n attentionSinceCount: attentionSince.length\n }\n}\n\n/*\n * ── 미러(관측 구동) 재기동의 연속성 ─────────────────────────────────────────\n *\n * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 심지 않는다 — 다음 계측·다음 마스터 재읽기가\n * 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다(그래서 `startLive` 는 오랫동안 아무것도\n * 심지 않았다).\n *\n * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 계산해 만든 것들이다:\n * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각\n * · 에너지의 열린 구간 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다\n *\n * 잃으면 **오류 없이 값이 작아진다**(실측 2026-08-18: 재기동 뒤 그 구간의 전력량·피크가 작아지고, 세\n * 시간째 지속된 경보가 「0초째」가 됐다). 그래서 축을 갈라 되풀어 주지 않는 것만 이어받는다.\n *\n * 무엇을 이어받을지는 여기서 고르고, **어떻게 흡수할지는 커널이 정한다**(`hydrateContinuity`) — 도메인\n * 규칙이 호스트로 새면 트윈마다 다른 답이 된다.\n */\nexport interface ContinuitySeed {\n acked?: string[]\n attentionSince?: { id: string; since: string }[]\n energy?: unknown\n}\n\nexport interface ContinuityPlan {\n seed: ContinuitySeed\n /** 무엇을 이어받았나 — 로그와 시험이 같은 숫자를 본다(0 이면 이어받을 것이 없었다는 사실이다). */\n ackedCount: number\n attentionSinceCount: number\n /** 에너지 연속성을 이어받았나 — 열린 구간이 있으면 그 구간의 요금 근거가 살아난다. */\n energy: boolean\n}\n\n/** 미러가 이어받을 축만 골라낸다 — 관측 축(재고·위치·설비)은 일부러 넣지 않는다. */\nexport function planLiveContinuity(raw: unknown): ContinuityPlan {\n const state = raw ? (unwrapState(raw as any) as any) : undefined\n const acked = Array.isArray(state?.acked) ? state.acked.filter((x: any): x is string => typeof x === 'string') : []\n const attentionSince = Array.isArray(state?.attentionSince)\n ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string')\n : []\n /* 에너지는 커널이 자기 모양을 안다 — 호스트는 「있나」만 본다(골라 담으면 규칙이 두 곳으로 갈린다). */\n const energy = state?.energy && typeof state.energy === 'object' ? state.energy : undefined\n return {\n seed: {\n ...(acked.length ? { acked } : {}),\n ...(attentionSince.length ? { attentionSince } : {}),\n ...(energy ? { energy } : {})\n },\n ackedCount: acked.length,\n attentionSinceCount: attentionSince.length,\n energy: !!energy\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"warm-start.js","sourceRoot":"","sources":["../../server/engine/warm-start.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 웜스타트 판정 — **순수**. \"기동하는 커널에 직전 상태를 주입할 것인가, 주입한다면 무엇을\" 만 정한다.\n * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.\n *\n * ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────\n * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 주입해야 할 때 안 주입하면 **있는 재고가 0 으로**\n * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지\n * 말아야 할 벤치에 주입하면 **용량 측정이 오염된다.** 둘 다 알리지 않고 틀리는 종류라 규칙을 고정한다.\n *\n * ── 오더를 버리던 전제가 낡았다 (2026-08-05) ────────────────────────────────\n * 예전 주석은 \"스냅샷의 오더에는 progress 밖에 없어서 역산하면 없는 숫자를 지어내는 셈이므로\n * 넘기지 않는다\" 였다. 그때는 맞았다. 그러나 커널 `OrderState` 가 **원값을 되찾은 뒤**\n * (`requested`·`fulfilled`·`lines`) 이 판정만 그대로 남아, 재기동하면 재고는 맞는데 **진행 중\n * 주문이 통째로 사라진** 화면이 나왔다. 오류는 나지 않는다 — 그래서 오래 남아 있었다.\n *\n * 두 스냅샷 생산 경로가 모두 원값을 담는다: 시뮬은 커널 `snapshot()`, 라이브(미러)는\n * `StateProjector` 다. 사람·자산·작업도 같은 스냅샷에 있고 커널이 심을 수 있는데 넘기지 않고 있었다.\n * 그래서 씨앗을 **관측 스냅샷 전체**로 넓힌다.\n *\n * 다만 넓히면서 **들어온 것이 없는 누락을 만들지 않는다.** 원값이 없는 오더는 커널이 걸러 내므로(지어내지\n * 않는다), 그 수를 여기서 세어 호출부가 말할 수 있게 한다. 세지 않으면 \"주문이 없다\" 와 \"주문을\n * 못 심었다\" 가 화면에서 같아진다.\n */\n\n/**\n * 커널에 심을 관측 상태 — 구조가 아니라 \"무엇이 어디에 얼마나\".\n *\n * 커널 `hydrateObserved` 가 받는 스냅샷 모양을 그대로 따른다. 여기서 골라 담지 않는 이유: 무엇을\n * 심을 수 있는지는 커널이 알고, 그 규칙(고아 작업 제외·이행 완료 오더 제외 등)도 커널에 있다.\n * 호스트가 미리 골라내면 규칙이 두 곳으로 갈린다.\n */\nexport interface ObservedSeed {\n locations: unknown[]\n items: unknown[]\n equipment: unknown[]\n persons?: unknown[]\n assets?: unknown[]\n tasks?: unknown[]\n orders?: unknown[]\n /** 확인해 둔 주목 신호 id — 상태에서 파생되지 않아 반드시 함께 넘긴다. */\n acked?: string[]\n /**\n * 주목 신호가 **처음 성립한 시각** — 확인과 같은 성질이다(계산으로 되살릴 수 없다).\n *\n * 넘기지 않으면 재기동 뒤 세 시간째 지속된 조건이 「0초째」로 되살아난다. 방금 찬 자리와 세 시간째\n * 막힌 자리는 할 일이 다르므로, 그 값이 거짓이면 화면이 사람을 잘못된 순서로 움직인다.\n */\n attentionSince?: { id: string; since: string }[]\n}\n\n/** 심은 것의 내역 — 로그와 테스트가 같은 숫자를 본다. */\nexport interface WarmStartCounts {\n itemCount: number\n equipmentCount: number\n personCount: number\n assetCount: number\n taskCount: number\n /** 원값(requested)이 있어 커널이 실제로 심을 수 있는 오더. */\n orderCount: number\n /** 이어받은 확인 처리 수 — 잃으면 확인해 둔 신호가 다시 빨개진다. */\n ackedCount: number\n /** 이어받은 시작 시각 수 — 잃으면 지속된 조건이 모두 「방금」이 된다. */\n attentionSinceCount: number\n /**\n * 원값이 없어 심지 못하는 오더 — **0 이 아니면 반드시 말한다.**\n * progress 만 있는 오더는 남은 수량을 알 수 없어 재계획의 근거가 되지 못한다. 지어내지 않고 뺀다.\n */\n ordersWithoutDemand: number\n /** Finished orders carried folded (ADR-0092) — 0 when the twin had folded nothing. */\n foldedOrderCount: number\n}\n\nexport type WarmStartPlan =\n | ({ action: 'hydrate'; seed: ObservedSeed } & WarmStartCounts)\n /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 주입하면 측정이 오염된다. */\n | { action: 'skip'; reason: 'bench' }\n /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */\n | { action: 'skip'; reason: 'no-state' }\n /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */\n | { action: 'skip'; reason: 'unsupported' }\n\n/** 스냅샷이 들고 올 수 있는 관측 축 — 어느 것이든 있으면 주입할 가치가 있다. */\ninterface ObservedState {\n locations?: unknown[]\n items?: unknown[]\n equipment?: unknown[]\n persons?: unknown[]\n assets?: unknown[]\n tasks?: unknown[]\n orders?: unknown[]\n foldedOrders?: unknown\n acked?: string[]\n attentionSince?: { id: string; since: string }[]\n}\n\nfunction foldedTotal(f: { counts: Record<string, Record<string, number>> }): number {\n let n = 0\n for (const byStatus of Object.values(f.counts)) for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0\n return n\n}\n\n/** 관측 상태가 실려 있는지 — 축 하나라도 있으면 상태다(봉투에는 없다). */\nconst looksLikeState = (x: any): boolean =>\n !!x && typeof x === 'object' && ['locations', 'items', 'equipment', 'orders', 'tasks', 'persons', 'assets', 'acked'].some(k => k in x)\n\n/**\n * 봉투를 벗긴다 — **체크포인트가 겹포장돼 복원이 알리지 않고 죽던 자리.**\n *\n * `TwinEngine.snapshot()` 은 라이브에서는 상태를, 시뮬에서는 `runtime.resync()` 봉투(`{revision,state}`)를\n * 돌려준다. 그 봉투를 다시 `{revision, state}` 로 감싸 캐시에 넣었으므로, 꺼낸 `state` 는 축이 하나도\n * 없는 껍데기였다 → `planWarmStart` 가 \"심을 것이 없다\" 로 판정하고 **알리지 않고** 넘어갔다. 저널에 수천 건이\n * 있고 캐시도 최신인데 트윈이 빈 채로 떴다(오류도 로그도 없다).\n *\n * 반환 모양을 통일하는 것이 뿌리 수정이지만, 그 값은 State 구독 페이로드로도 나가므로 클라이언트 계약을\n * 건드린다. 그래서 **복원 경로에서만** 벗긴다. 겹겹이 싸여 있어도 벗긴다(과거에 저장된 값이 남아 있다).\n */\nexport function unwrapState(x: any, depth = 4): any {\n if (!x || typeof x !== 'object') return x\n if (looksLikeState(x)) return x\n if (depth > 0 && x.state && typeof x.state === 'object') return unwrapState(x.state, depth - 1)\n return x\n}\n\nconst arr = (v: unknown[] | undefined): unknown[] => (Array.isArray(v) ? v : [])\n\n/** 원값이 있는 오더인가 — 커널 `hydrateObserved` 의 판정(`requested !== undefined`)과 같은 기준. */\nconst hasDemand = (o: unknown): boolean =>\n !!o && typeof o === 'object' && (o as { requested?: unknown }).requested !== undefined\n\n/**\n * 무엇을 할지 정한다.\n *\n * 오더·작업·사람·자산도 함께 주입한다. 골라내는 규칙은 커널이 들고 있고(이행 완료 오더, 고아 작업),\n * 여기서는 **심을 수 없는 오더의 수만 센다** — 호출부가 그 사실을 말할 수 있어야 하기 때문이다.\n */\nexport function planWarmStart(state: ObservedState | null | undefined, purpose: string | undefined, canHydrate: boolean): WarmStartPlan {\n /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 주입하지 않는다는 사실이 바뀌지 않는다. */\n if (purpose === 'bench') return { action: 'skip', reason: 'bench' }\n if (!state) return { action: 'skip', reason: 'no-state' }\n /* 봉투가 들어오면 스스로 벗는다 — 호출부가 한 곳이라도 잊으면 그 경로가 알리지 않고 죽는다(실제로 그랬다). */\n state = unwrapState(state)\n\n const locations = arr(state.locations)\n const items = arr(state.items)\n const equipment = arr(state.equipment)\n const persons = arr(state.persons)\n const assets = arr(state.assets)\n const tasks = arr(state.tasks)\n const orders = arr(state.orders)\n /* 확인 처리는 문자열 id 목록이다 — 배열이 아니면 없는 것으로 본다(지어내지 않는다). */\n const acked = Array.isArray(state.acked) ? state.acked.filter((x): x is string => typeof x === 'string') : []\n /* 시작 시각은 {id, since} 쌍 목록이다 — 모양이 아니면 없는 것으로 본다(지어낸 시각을 심으면 화면이\n 「세 시간째」라고 거짓을 말한다. 모르면 그 신호는 이어붙는 순간이 새 시작이다). */\n const attentionSince = Array.isArray(state.attentionSince)\n ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string')\n : []\n\n /*\n * Folded orders (ADR-0092) — their counts are the only record of the finished orders taken out of the hot\n * list; nothing re-sends them. The seed used to leave them out, so a restart lost every folded count.\n * Carried as the kernel wrote them; a value without a `counts` object is not a folded side.\n */\n const f: any = (state as any).foldedOrders\n const foldedOrders = f && typeof f === 'object' && f.counts && typeof f.counts === 'object' ? f : undefined\n\n /* 어느 축도 없으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */\n if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !foldedOrders && !acked.length && !attentionSince.length) {\n return { action: 'skip', reason: 'no-state' }\n }\n\n /* 지원 여부는 마지막에 본다 — 주입할 것이 있는데 주입하지 못하는 상황이라야 경고할 가치가 있다. */\n if (!canHydrate) return { action: 'skip', reason: 'unsupported' }\n\n const seedable = orders.filter(hasDemand)\n\n return {\n action: 'hydrate',\n seed: {\n locations,\n items,\n equipment,\n ...(persons.length ? { persons } : {}),\n ...(assets.length ? { assets } : {}),\n ...(tasks.length ? { tasks } : {}),\n /* 원값 없는 오더는 넘기지 않는다 — 커널도 걸러 내지만, 넘기지 않으면 세어 둔 수와 어긋날 일이 없다. */\n ...(seedable.length ? { orders: seedable } : {}),\n ...(foldedOrders ? { foldedOrders } : {}),\n /* 확인 처리는 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 그 신호가 없으면 표시할 대상이 없다. */\n ...(acked.length ? { acked } : {}),\n /* 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 커널이 그 신호가 없으면 함께 지운다. */\n ...(attentionSince.length ? { attentionSince } : {})\n },\n itemCount: items.length,\n equipmentCount: equipment.length,\n personCount: persons.length,\n assetCount: assets.length,\n taskCount: tasks.length,\n orderCount: seedable.length,\n foldedOrderCount: foldedOrders ? foldedTotal(foldedOrders) : 0,\n ordersWithoutDemand: orders.length - seedable.length,\n ackedCount: acked.length,\n attentionSinceCount: attentionSince.length\n }\n}\n\n/*\n * ── 미러(관측 구동) 재기동의 연속성 ─────────────────────────────────────────\n *\n * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 심지 않는다 — 다음 계측·다음 마스터 재읽기가\n * 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다(그래서 `startLive` 는 오랫동안 아무것도\n * 심지 않았다).\n *\n * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 계산해 만든 것들이다:\n * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각\n * · 에너지의 열린 구간 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다\n *\n * 잃으면 **오류 없이 값이 작아진다**(실측 2026-08-18: 재기동 뒤 그 구간의 전력량·피크가 작아지고, 세\n * 시간째 지속된 경보가 「0초째」가 됐다). 그래서 축을 갈라 되풀어 주지 않는 것만 이어받는다.\n *\n * 무엇을 이어받을지는 여기서 고르고, **어떻게 흡수할지는 커널이 정한다**(`hydrateContinuity`) — 도메인\n * 규칙이 호스트로 새면 트윈마다 다른 답이 된다.\n */\nexport interface ContinuitySeed {\n acked?: string[]\n attentionSince?: { id: string; since: string }[]\n energy?: unknown\n}\n\nexport interface ContinuityPlan {\n seed: ContinuitySeed\n /** 무엇을 이어받았나 — 로그와 시험이 같은 숫자를 본다(0 이면 이어받을 것이 없었다는 사실이다). */\n ackedCount: number\n attentionSinceCount: number\n /** 에너지 연속성을 이어받았나 — 열린 구간이 있으면 그 구간의 요금 근거가 살아난다. */\n energy: boolean\n}\n\n/** 미러가 이어받을 축만 골라낸다 — 관측 축(재고·위치·설비)은 일부러 넣지 않는다. */\nexport function planLiveContinuity(raw: unknown): ContinuityPlan {\n const state = raw ? (unwrapState(raw as any) as any) : undefined\n const acked = Array.isArray(state?.acked) ? state.acked.filter((x: any): x is string => typeof x === 'string') : []\n const attentionSince = Array.isArray(state?.attentionSince)\n ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string')\n : []\n /* 에너지는 커널이 자기 모양을 안다 — 호스트는 「있나」만 본다(골라 담으면 규칙이 두 곳으로 갈린다). */\n const energy = state?.energy && typeof state.energy === 'object' ? state.energy : undefined\n return {\n seed: {\n ...(acked.length ? { acked } : {}),\n ...(attentionSince.length ? { attentionSince } : {}),\n ...(energy ? { energy } : {})\n },\n ackedCount: acked.length,\n attentionSinceCount: attentionSince.length,\n energy: !!energy\n }\n}\n"]}
|
|
@@ -763,7 +763,7 @@ let TwinReferenceResolver = class TwinReferenceResolver {
|
|
|
763
763
|
*/
|
|
764
764
|
const at = (adapter.masterSteps?.[lastDone] ?? lastStep);
|
|
765
765
|
emit({ siteId: site.siteId, siteIndex, step: at, done: lastDone, total: stepTotal, failed: true, error: e?.message ?? String(e) });
|
|
766
|
-
failed.push({ siteId: site.siteId, error: e?.message ?? 'failed' });
|
|
766
|
+
failed.push({ siteId: site.siteId, error: e?.message ?? 'failed', ...(e?.code ? { errorCode: e.code, errorParams: e.params } : {}) });
|
|
767
767
|
}
|
|
768
768
|
}
|
|
769
769
|
// provenance — 이 레퍼런스가 만든 인스턴스 추적(scopeSpec.produced). 삭제 연쇄·상태 동기·네비게이션 근거.
|