@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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **A twin made by one producer is not rebuilt by another.** Pure.
|
|
3
|
+
*
|
|
4
|
+
* ── What went wrong (2026-09-24) ─────────────────────────────────────────────
|
|
5
|
+
* An import's instance id is the adapter's site id (`master.source`). Importing the warehouse connection
|
|
6
|
+
* `wms-miratek` with site id `miratek` — the same as the plant connection's — landed on the running plant
|
|
7
|
+
* mirror `miratek`, and `ingestMaster` gave it the warehouse structure as a new revision: 16 places,
|
|
8
|
+
* 23 equipment, 17 people and 14 assets gone. The row already said who made it (`origin.source` was
|
|
9
|
+
* `mes-miratek`); nothing asked.
|
|
10
|
+
*
|
|
11
|
+
* ── The rule (chief architect ruling, 2026-09-24) ────────────────────────────
|
|
12
|
+
* Compare producers by identity, not by the whole origin: a reference is kind + source, a template is
|
|
13
|
+
* kind + templateId. The same import carries more or fewer fields at different call sites (siteId,
|
|
14
|
+
* spaceId), and a template re-run has new params — those are the same producer re-reading its own twin.
|
|
15
|
+
*
|
|
16
|
+
* both declared, same producer allowed (resync, re-import, re-run)
|
|
17
|
+
* both declared, different refused, naming the producer that owns it
|
|
18
|
+
* prior undeclared, incoming not refused — nobody can say who made it; delete it first to replace it
|
|
19
|
+
* prior declared, incoming not refused — same reason, the other way
|
|
20
|
+
* neither declared allowed — the demo seed re-ingesting its own twin at boot. Closes when
|
|
21
|
+
* the seed declares a template origin.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const INSTANCE_TAKEN = 'instance-taken-by-another-source'
|
|
25
|
+
|
|
26
|
+
export interface ProducerRefusal {
|
|
27
|
+
code: typeof INSTANCE_TAKEN
|
|
28
|
+
params: { instance: string; producer: string }
|
|
29
|
+
message: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Who produced a twin, as one comparable string — or undefined when the origin says nothing usable. */
|
|
33
|
+
export function producerOf(origin: unknown): string | undefined {
|
|
34
|
+
if (!origin || typeof origin !== 'object') return undefined
|
|
35
|
+
const o = origin as { kind?: unknown; source?: unknown; templateId?: unknown }
|
|
36
|
+
if (o.kind === 'reference' && typeof o.source === 'string' && o.source) return `reference:${o.source}`
|
|
37
|
+
if (o.kind === 'template' && typeof o.templateId === 'string' && o.templateId) return `template:${o.templateId}`
|
|
38
|
+
/* A kind this guard does not know is still somebody — keep it distinct rather than reading it as none. */
|
|
39
|
+
if (typeof o.kind === 'string' && o.kind) return `${o.kind}:${String(o.source ?? o.templateId ?? '')}`
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nameOf(origin: unknown): string {
|
|
44
|
+
const o = (origin ?? {}) as { source?: unknown; templateId?: unknown }
|
|
45
|
+
return String(o.source ?? o.templateId ?? '') || 'undeclared'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** null when `incoming` may rebuild the twin `prior` was made by; otherwise the refusal. */
|
|
49
|
+
export function refuseOverwriteByAnotherProducer(
|
|
50
|
+
instance: string,
|
|
51
|
+
priorOrigin: unknown,
|
|
52
|
+
incomingOrigin: unknown
|
|
53
|
+
): ProducerRefusal | null {
|
|
54
|
+
const had = producerOf(priorOrigin)
|
|
55
|
+
const comes = producerOf(incomingOrigin)
|
|
56
|
+
if (!had && !comes) return null
|
|
57
|
+
if (had && comes && had === comes) return null
|
|
58
|
+
const producer = had ? nameOf(priorOrigin) : 'undeclared'
|
|
59
|
+
return {
|
|
60
|
+
code: INSTANCE_TAKEN,
|
|
61
|
+
params: { instance, producer },
|
|
62
|
+
message: `twin "${instance}" was made by ${producer} — delete it first, or import this site under another id`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -25,6 +25,7 @@ import { planLiveContinuity, planWarmStart, unwrapState } from './warm-start.js'
|
|
|
25
25
|
import { applyDeclarationLayers } from './local-declarations.js'
|
|
26
26
|
import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
|
|
27
27
|
import { TWIN_STATE_TOPIC, twinStatePayload } from './twin-state-channel.js'
|
|
28
|
+
import { refuseOverwriteByAnotherProducer } from './producer-guard.js'
|
|
28
29
|
import { routeCommand } from './command-routing.js'
|
|
29
30
|
import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
30
31
|
/* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
|
|
@@ -1217,6 +1218,7 @@ export class TwinEngine {
|
|
|
1217
1218
|
`${plan.itemCount} item(s)`,
|
|
1218
1219
|
`${plan.equipmentCount} equipment`,
|
|
1219
1220
|
`${plan.orderCount} open order(s)`,
|
|
1221
|
+
...(plan.foldedOrderCount ? [`${plan.foldedOrderCount} folded finished order(s)`] : []),
|
|
1220
1222
|
`${plan.taskCount} task(s)`,
|
|
1221
1223
|
...(plan.personCount ? [`${plan.personCount} person(s)`] : []),
|
|
1222
1224
|
...(plan.assetCount ? [`${plan.assetCount} asset(s)`] : []),
|
|
@@ -3419,6 +3421,19 @@ export class TwinEngine {
|
|
|
3419
3421
|
* 대상이 사라졌으면(구조가 바뀌었다) 그 사실을 경고로 낸다 — 갈 곳 없는 선언을 알리지 않고 버리지 않는다.
|
|
3420
3422
|
*/
|
|
3421
3423
|
const prior = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId: master.source } })
|
|
3424
|
+
/*
|
|
3425
|
+
* A twin another producer made is not rebuilt here (engine/producer-guard.ts). Refused before anything
|
|
3426
|
+
* below writes — `prior` is saved a few lines on, and refusing after that would leave it half-changed.
|
|
3427
|
+
*/
|
|
3428
|
+
if (prior) {
|
|
3429
|
+
const taken = refuseOverwriteByAnotherProducer(master.source, prior.origin, (master as any).origin)
|
|
3430
|
+
if (taken) {
|
|
3431
|
+
const err: any = new Error(taken.message)
|
|
3432
|
+
err.code = taken.code
|
|
3433
|
+
err.params = taken.params
|
|
3434
|
+
throw err
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3422
3437
|
/*
|
|
3423
3438
|
* 겹이 **둘**이다 — 현장(공간)의 것과 이 트윈의 것. 순서가 규율이다: 현장 먼저, 트윈이 나중.
|
|
3424
3439
|
* 요금처럼 현장이 정한 수는 그 현장의 트윈 전부가 같이 읽어야 하고, 한 트윈만 다르게 두는 실험은
|
|
@@ -66,6 +66,8 @@ export interface WarmStartCounts {
|
|
|
66
66
|
* progress 만 있는 오더는 남은 수량을 알 수 없어 재계획의 근거가 되지 못한다. 지어내지 않고 뺀다.
|
|
67
67
|
*/
|
|
68
68
|
ordersWithoutDemand: number
|
|
69
|
+
/** Finished orders carried folded (ADR-0092) — 0 when the twin had folded nothing. */
|
|
70
|
+
foldedOrderCount: number
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export type WarmStartPlan =
|
|
@@ -86,10 +88,17 @@ interface ObservedState {
|
|
|
86
88
|
assets?: unknown[]
|
|
87
89
|
tasks?: unknown[]
|
|
88
90
|
orders?: unknown[]
|
|
91
|
+
foldedOrders?: unknown
|
|
89
92
|
acked?: string[]
|
|
90
93
|
attentionSince?: { id: string; since: string }[]
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
function foldedTotal(f: { counts: Record<string, Record<string, number>> }): number {
|
|
97
|
+
let n = 0
|
|
98
|
+
for (const byStatus of Object.values(f.counts)) for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0
|
|
99
|
+
return n
|
|
100
|
+
}
|
|
101
|
+
|
|
93
102
|
/** 관측 상태가 실려 있는지 — 축 하나라도 있으면 상태다(봉투에는 없다). */
|
|
94
103
|
const looksLikeState = (x: any): boolean =>
|
|
95
104
|
!!x && typeof x === 'object' && ['locations', 'items', 'equipment', 'orders', 'tasks', 'persons', 'assets', 'acked'].some(k => k in x)
|
|
@@ -146,8 +155,16 @@ export function planWarmStart(state: ObservedState | null | undefined, purpose:
|
|
|
146
155
|
? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string')
|
|
147
156
|
: []
|
|
148
157
|
|
|
158
|
+
/*
|
|
159
|
+
* Folded orders (ADR-0092) — their counts are the only record of the finished orders taken out of the hot
|
|
160
|
+
* list; nothing re-sends them. The seed used to leave them out, so a restart lost every folded count.
|
|
161
|
+
* Carried as the kernel wrote them; a value without a `counts` object is not a folded side.
|
|
162
|
+
*/
|
|
163
|
+
const f: any = (state as any).foldedOrders
|
|
164
|
+
const foldedOrders = f && typeof f === 'object' && f.counts && typeof f.counts === 'object' ? f : undefined
|
|
165
|
+
|
|
149
166
|
/* 어느 축도 없으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
|
|
150
|
-
if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !acked.length && !attentionSince.length) {
|
|
167
|
+
if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !foldedOrders && !acked.length && !attentionSince.length) {
|
|
151
168
|
return { action: 'skip', reason: 'no-state' }
|
|
152
169
|
}
|
|
153
170
|
|
|
@@ -167,6 +184,7 @@ export function planWarmStart(state: ObservedState | null | undefined, purpose:
|
|
|
167
184
|
...(tasks.length ? { tasks } : {}),
|
|
168
185
|
/* 원값 없는 오더는 넘기지 않는다 — 커널도 걸러 내지만, 넘기지 않으면 세어 둔 수와 어긋날 일이 없다. */
|
|
169
186
|
...(seedable.length ? { orders: seedable } : {}),
|
|
187
|
+
...(foldedOrders ? { foldedOrders } : {}),
|
|
170
188
|
/* 확인 처리는 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 그 신호가 없으면 표시할 대상이 없다. */
|
|
171
189
|
...(acked.length ? { acked } : {}),
|
|
172
190
|
/* 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 커널이 그 신호가 없으면 함께 지운다. */
|
|
@@ -178,6 +196,7 @@ export function planWarmStart(state: ObservedState | null | undefined, purpose:
|
|
|
178
196
|
assetCount: assets.length,
|
|
179
197
|
taskCount: tasks.length,
|
|
180
198
|
orderCount: seedable.length,
|
|
199
|
+
foldedOrderCount: foldedOrders ? foldedTotal(foldedOrders) : 0,
|
|
181
200
|
ordersWithoutDemand: orders.length - seedable.length,
|
|
182
201
|
ackedCount: acked.length,
|
|
183
202
|
attentionSinceCount: attentionSince.length
|
|
@@ -914,7 +914,7 @@ export class TwinReferenceResolver {
|
|
|
914
914
|
const at = (adapter.masterSteps?.[lastDone] ?? lastStep) as ReferenceProgress['step']
|
|
915
915
|
emit({ siteId: site.siteId, siteIndex, step: at, done: lastDone, total: stepTotal, failed: true, error: e?.message ?? String(e) })
|
|
916
916
|
|
|
917
|
-
failed.push({ siteId: site.siteId, error: e?.message ?? 'failed' })
|
|
917
|
+
failed.push({ siteId: site.siteId, error: e?.message ?? 'failed', ...(e?.code ? { errorCode: e.code, errorParams: e.params } : {}) })
|
|
918
918
|
}
|
|
919
919
|
}
|
|
920
920
|
// provenance — 이 레퍼런스가 만든 인스턴스 추적(scopeSpec.produced). 삭제 연쇄·상태 동기·네비게이션 근거.
|
|
@@ -17,6 +17,27 @@
|
|
|
17
17
|
|
|
18
18
|
import { isFlowOrder, isOrderTerminal } from '@operato/ops-contract'
|
|
19
19
|
|
|
20
|
+
/*
|
|
21
|
+
* ── Folded orders are counted too (ADR-0092 decision 3, ruling 2026-09-24) ─────────────
|
|
22
|
+
*
|
|
23
|
+
* A twin folds finished orders out of its hot list at checkpoint time and keeps only their counts
|
|
24
|
+
* (`foldedOrders.counts`, kind × terminal status, never trimmed). Counting the hot list alone would drop
|
|
25
|
+
* `orders` and `shipped` the moment a fold happens — and "the increase since the fork" alone would still
|
|
26
|
+
* break `twinForecastGapTrend`, whose actual at T+h is read from recovered state: a fold between T and
|
|
27
|
+
* T+h makes the actual increase negative. So both metrics are cumulative including the folded side, and
|
|
28
|
+
* an increase is the difference of the same number. Which folded kinds are flow orders is asked of the
|
|
29
|
+
* contract's `isFlowOrder`, the same as the hot list; `shipped` uses the same word on both sides.
|
|
30
|
+
*/
|
|
31
|
+
function foldedFlowOrders(s: any, status?: string): number {
|
|
32
|
+
let n = 0
|
|
33
|
+
for (const [kind, byStatus] of Object.entries((s?.foldedOrders?.counts ?? {}) as Record<string, Record<string, number>>)) {
|
|
34
|
+
if (!isFlowOrder({ kind })) continue
|
|
35
|
+
if (status) n += Number(byStatus?.[status]) || 0
|
|
36
|
+
else for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0
|
|
37
|
+
}
|
|
38
|
+
return n
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/** 스냅샷 → 수 하나. 값이 없으면 0 이 아니라 **그 지표를 그릴 수 없다**는 뜻이지만, 그래프 계약이
|
|
21
42
|
* 수를 요구하므로 0 을 낸다 — 그래서 에너지 지표는 「없음」이 0 과 헷갈리지 않는 것만 골랐다. */
|
|
22
43
|
export type MetricFn = (s: any) => number
|
|
@@ -32,9 +53,9 @@ export const FORECAST_METRICS: Record<string, MetricFn> = {
|
|
|
32
53
|
* 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름
|
|
33
54
|
* 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다).
|
|
34
55
|
*/
|
|
35
|
-
orders: s => (s?.orders || []).filter(isFlowOrder).length,
|
|
56
|
+
orders: s => (s?.orders || []).filter(isFlowOrder).length + foldedFlowOrders(s),
|
|
36
57
|
// 이행 지표 — 라이브 예측의 핵심 질문("언제 다 나가나·백로그 언제 풀리나").
|
|
37
|
-
shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length,
|
|
58
|
+
shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length + foldedFlowOrders(s, 'shipped'),
|
|
38
59
|
/*
|
|
39
60
|
* ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ─────────────────────────
|
|
40
61
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { test } from 'node:test'
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
9
|
|
|
10
|
-
import { FORECAST_METRICS, metricsForKind } from '../
|
|
10
|
+
import { FORECAST_METRICS, metricsForKind } from '../server/service/twin-forecast/forecast-metrics.ts'
|
|
11
11
|
|
|
12
12
|
test('물류 지표 — 기존 여섯은 그대로 센다', () => {
|
|
13
13
|
const s = {
|
|
@@ -112,3 +112,46 @@ test('목록의 모든 키가 실제로 계산 가능하다 — 화면에 뜨는
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
})
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* ★ Folding does not move the count (ADR-0092 decision 3, ruling 2026-09-24).
|
|
118
|
+
* A twin folds finished orders out of its hot list and keeps kind × status counts. Counting the hot list
|
|
119
|
+
* alone dropped orders/shipped at every fold, and made the gap trend's actual increase negative.
|
|
120
|
+
*/
|
|
121
|
+
const hot = (orders: any[], foldedOrders?: any) => ({ orders, ...(foldedOrders ? { foldedOrders } : {}) })
|
|
122
|
+
|
|
123
|
+
test('folding the finished orders leaves orders and shipped where they were', () => {
|
|
124
|
+
const before = hot([
|
|
125
|
+
{ id: 'o1', kind: 'outbound', status: 'shipped' },
|
|
126
|
+
{ id: 'o2', kind: 'outbound', status: 'cancelled' },
|
|
127
|
+
{ id: 'o3', kind: 'outbound', status: 'picking' },
|
|
128
|
+
{ id: 'r1', kind: 'inbound', status: 'completed' }
|
|
129
|
+
])
|
|
130
|
+
const after = hot([{ id: 'o3', kind: 'outbound', status: 'picking' }], {
|
|
131
|
+
counts: { outbound: { shipped: 1, cancelled: 1 }, inbound: { completed: 1 } },
|
|
132
|
+
boundaryMs: 1
|
|
133
|
+
})
|
|
134
|
+
for (const m of ['orders', 'shipped', 'backlog'] as const) assert.equal(FORECAST_METRICS[m](after), FORECAST_METRICS[m](before), m)
|
|
135
|
+
assert.equal(FORECAST_METRICS.orders(after), 3, 'receiving orders are not counted, folded or not')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a fold between T and T+h keeps the actual increase from going negative', () => {
|
|
139
|
+
const atT = hot([{ kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'picking' }])
|
|
140
|
+
/* by T+h: one more shipped, and the three shipped have been folded */
|
|
141
|
+
const atTh = hot([{ kind: 'outbound', status: 'picking' }], { counts: { outbound: { shipped: 3 } }, boundaryMs: 2 })
|
|
142
|
+
assert.equal(FORECAST_METRICS.shipped(atTh) - FORECAST_METRICS.shipped(atT), 1)
|
|
143
|
+
assert.ok(FORECAST_METRICS.orders(atTh) - FORECAST_METRICS.orders(atT) >= 0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('a twin with nothing folded counts as before', () => {
|
|
147
|
+
const s = hot([{ kind: 'outbound', status: 'shipped' }, { status: 'picking' }])
|
|
148
|
+
assert.equal(FORECAST_METRICS.orders(s), 2)
|
|
149
|
+
assert.equal(FORECAST_METRICS.shipped(s), 1)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('a folded order with no kind is a flow order, as it is in the hot list', () => {
|
|
153
|
+
const folded = hot([], { counts: { '': { shipped: 2 } }, boundaryMs: 1 })
|
|
154
|
+
const unfolded = hot([{ status: 'shipped' }, { status: 'shipped' }])
|
|
155
|
+
assert.equal(FORECAST_METRICS.orders(folded), FORECAST_METRICS.orders(unfolded))
|
|
156
|
+
assert.equal(FORECAST_METRICS.shipped(folded), FORECAST_METRICS.shipped(unfolded))
|
|
157
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ A twin made by one producer is not rebuilt by another (chief architect ruling, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* On 09-24 the warehouse connection `wms-miratek`, imported with site id `miratek`, landed on the running
|
|
5
|
+
* plant mirror `miratek` and replaced its structure. The row already named its producer. These pin the
|
|
6
|
+
* rule, and the last test pins where it runs: before `ingestMaster` writes anything.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
import { INSTANCE_TAKEN, producerOf, refuseOverwriteByAnotherProducer } from '../server/engine/producer-guard.ts'
|
|
14
|
+
|
|
15
|
+
const PLANT = { kind: 'reference', source: 'mes-miratek' }
|
|
16
|
+
|
|
17
|
+
test('a different connection is refused, and the refusal names the connection that made the twin', () => {
|
|
18
|
+
const r = refuseOverwriteByAnotherProducer('miratek', PLANT, { kind: 'reference', source: 'wms-miratek', siteId: 'miratek' })
|
|
19
|
+
assert.equal(r?.code, INSTANCE_TAKEN)
|
|
20
|
+
assert.deepEqual(r?.params, { instance: 'miratek', producer: 'mes-miratek' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('the same producer re-reading its own twin passes, whatever else its origin carries', () => {
|
|
24
|
+
/* Import sites carry { kind, source } at one call and add siteId · spaceId at another (resolver 168 vs 889). */
|
|
25
|
+
assert.equal(refuseOverwriteByAnotherProducer('miratek', PLANT, { ...PLANT, siteId: 'miratek', spaceId: 'miratek' }), null)
|
|
26
|
+
/* Resync hands the stored origin back as it is (masterFromOrigin). */
|
|
27
|
+
assert.equal(refuseOverwriteByAnotherProducer('miratek', { ...PLANT, siteId: 'miratek' }, { ...PLANT, siteId: 'miratek' }), null)
|
|
28
|
+
/* A template re-run with other knobs is the same template. */
|
|
29
|
+
assert.equal(
|
|
30
|
+
refuseOverwriteByAnotherProducer('w1', { kind: 'template', templateId: 'wms-unit-load', params: { racks: 4 } }, { kind: 'template', templateId: 'wms-unit-load', params: { racks: 9 } }),
|
|
31
|
+
null
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a template and a reference are different producers even with the same name', () => {
|
|
36
|
+
assert.equal(refuseOverwriteByAnotherProducer('x', { kind: 'template', templateId: 'x' }, { kind: 'reference', source: 'x' })?.code, INSTANCE_TAKEN)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('(a) a twin nobody can name is not rebuilt by a declared producer', () => {
|
|
40
|
+
const r = refuseOverwriteByAnotherProducer('old', undefined, PLANT)
|
|
41
|
+
assert.deepEqual(r?.params, { instance: 'old', producer: 'undeclared' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('(b) a declared twin is not rebuilt by an undeclared producer', () => {
|
|
45
|
+
assert.deepEqual(refuseOverwriteByAnotherProducer('miratek', PLANT, undefined)?.params, { instance: 'miratek', producer: 'mes-miratek' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('(c) neither declared passes — the demo seed re-ingesting its own twin at boot', () => {
|
|
49
|
+
assert.equal(refuseOverwriteByAnotherProducer('rosarito-mes', undefined, undefined), null)
|
|
50
|
+
assert.equal(refuseOverwriteByAnotherProducer('rosarito-mes', {}, { kind: '' }), null)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a kind the guard does not know is still somebody, not nobody', () => {
|
|
54
|
+
assert.notEqual(producerOf({ kind: 'bench', source: 'hatio-yard' }), undefined)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('ingestMaster refuses before its first write — a refusal after it would leave the twin half-changed', () => {
|
|
58
|
+
const engine = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8')
|
|
59
|
+
const at = engine.indexOf('static async ingestMaster(')
|
|
60
|
+
const body = engine.slice(at, engine.indexOf('\n static ', at + 10))
|
|
61
|
+
const guard = body.indexOf('refuseOverwriteByAnotherProducer(')
|
|
62
|
+
const firstWrite = body.search(/\.save\(|\.update\(|\.insert\(|\.delete\(/)
|
|
63
|
+
assert.ok(guard > 0, 'ingestMaster no longer asks who made the twin')
|
|
64
|
+
assert.ok(firstWrite > 0, 'no write found — this check would pass by finding nothing')
|
|
65
|
+
assert.ok(guard < firstWrite, 'the refusal must come before the first write')
|
|
66
|
+
})
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* · 인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자리에
|
|
11
11
|
* · 원천을 모르는 재프로비전이 **이미 아는 것을 지우지 않는다** — 한 번 지우면 다시 못 읽는다
|
|
12
12
|
* · 못 읽는 트윈은 **못 읽는다고 말한다** — 짐작으로 아무 원본이나 집지 않는다
|
|
13
|
+
*
|
|
14
|
+
* Since 2026-09-24 (chief architect ruling, engine/producer-guard.ts) a re-provision that does not come
|
|
15
|
+
* from the twin's own producer is **refused** rather than applied: a silent one used to keep the origin
|
|
16
|
+
* but replace the structure, and a different producer used to replace both — the second is how the
|
|
17
|
+
* plant mirror `miratek` got the warehouse's structure. The two tests below now pin the refusal, and
|
|
18
|
+
* that the refused twin's origin and structure are untouched.
|
|
13
19
|
*/
|
|
14
20
|
import { test, before, after } from 'node:test'
|
|
15
21
|
import assert from 'node:assert/strict'
|
|
@@ -72,26 +78,39 @@ test('인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자
|
|
|
72
78
|
assert.deepEqual(await originOf(BORN), { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } })
|
|
73
79
|
})
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
const TEMPLATE = { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } }
|
|
82
|
+
const locationsOf = async (instanceId: string) =>
|
|
83
|
+
(await ds.getRepository(TwinInstance).findOne({ where: { instanceId } }))?.model?.locations?.length
|
|
84
|
+
|
|
85
|
+
test('원천을 모르는 재프로비전은 거절된다 — 원천도 구조도 그대로다', async () => {
|
|
76
86
|
/*
|
|
77
|
-
* 이 경로는 실제로 있다 — 원천을 모르는 호출자(스크립트·복구·옛 코드)가 같은 트윈을 다시
|
|
78
|
-
*
|
|
79
|
-
*
|
|
87
|
+
* 이 경로는 실제로 있다 — 원천을 모르는 호출자(스크립트·복구·옛 코드)가 같은 트윈을 다시 인제스트한다.
|
|
88
|
+
* Its origin used to be kept while the structure was replaced by whatever that caller had. It is refused
|
|
89
|
+
* now (ruling (b)): the twin's own producer re-reads it; anything else deletes it first.
|
|
80
90
|
*/
|
|
81
|
-
await TwinEngine.ingestMaster(domainId, master(BORN, 5), 'reset')
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
91
|
+
await assert.rejects(TwinEngine.ingestMaster(domainId, master(BORN, 5), 'reset'), (e: any) => {
|
|
92
|
+
assert.equal(e.code, 'instance-taken-by-another-source')
|
|
93
|
+
assert.deepEqual(e.params, { instance: BORN, producer: 'wh-unit-load' })
|
|
94
|
+
return true
|
|
95
|
+
})
|
|
96
|
+
assert.deepEqual(await originOf(BORN), TEMPLATE)
|
|
97
|
+
assert.equal(await locationsOf(BORN), 4, 'the refused master did not land (dock 1 + bins 3)')
|
|
98
|
+
})
|
|
87
99
|
|
|
88
|
-
|
|
89
|
-
assert.
|
|
100
|
+
test('다른 생산자는 거절된다 — 이름을 대고, 원천도 구조도 그대로다', async () => {
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
TwinEngine.ingestMaster(domainId, master(BORN, 5, { kind: 'reference', source: 'oracle-wms' }), 'reset'),
|
|
103
|
+
(e: any) => e.code === 'instance-taken-by-another-source' && e.params?.producer === 'wh-unit-load'
|
|
104
|
+
)
|
|
105
|
+
assert.deepEqual(await originOf(BORN), TEMPLATE)
|
|
106
|
+
assert.equal(await locationsOf(BORN), 4)
|
|
90
107
|
})
|
|
91
108
|
|
|
92
|
-
test('
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
test('같은 생산자가 다시 읽으면 지나간다 — 노브가 달라도', async () => {
|
|
110
|
+
const again = { ...TEMPLATE, params: { bins: 5 } }
|
|
111
|
+
await TwinEngine.ingestMaster(domainId, master(BORN, 5, again), 'reset')
|
|
112
|
+
assert.deepEqual(await originOf(BORN), again)
|
|
113
|
+
assert.equal(await locationsOf(BORN), 6, 'its own template re-run rebuilds it (dock 1 + bins 5)')
|
|
95
114
|
})
|
|
96
115
|
|
|
97
116
|
test('원천 없이 태어난 트윈은 원천이 없다 — 짐작해 채우지 않는다', async () => {
|
|
@@ -106,7 +125,7 @@ test('조회가 다시 읽을 수 있는지를 먼저 답한다 — 못 하는
|
|
|
106
125
|
|
|
107
126
|
const born = await q.twinModelSummary(BORN, ctx)
|
|
108
127
|
assert.equal(born.canResync, true)
|
|
109
|
-
assert.deepEqual(born.origin, {
|
|
128
|
+
assert.deepEqual(born.origin, { ...TEMPLATE, params: { bins: 5 } })
|
|
110
129
|
|
|
111
130
|
const orphan = await q.twinModelSummary(ORPHAN, ctx)
|
|
112
131
|
assert.equal(orphan.canResync, false, '버튼을 그대로 내면 누를 때마다 실패한다')
|
|
@@ -142,3 +142,21 @@ test('라이브 상태의 확인 처리가 신호에 반영된다 — 라이브
|
|
|
142
142
|
const after = withLiveAttentions({ ...state, acked: [signal.id] }).attentions ?? []
|
|
143
143
|
assert.equal(after.find((a: any) => a.id === signal.id)?.state, 'acknowledged', '확인 처리가 라이브 신호에 반영돼야 한다')
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* ★ Folded orders survive a restart (ADR-0092). Their counts are the only record of the finished orders
|
|
148
|
+
* taken out of the hot list, and the seed used to leave them out — a restart after a fold lost them all.
|
|
149
|
+
*/
|
|
150
|
+
test('the folded side comes back with the twin — nothing re-sends those counts', () => {
|
|
151
|
+
const foldedOrders = { counts: { outbound: { shipped: 40, cancelled: 2 }, inbound: { completed: 55 } }, boundaryMs: 1_700_000_000_000 }
|
|
152
|
+
const { k, plan } = seedKernel({ ...observed([]), foldedOrders } as any)
|
|
153
|
+
assert.equal(plan.foldedOrderCount, 97)
|
|
154
|
+
assert.deepEqual((k as any).getSnapshot().foldedOrders?.counts, foldedOrders.counts)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('a value without counts is not a folded side, and a twin that folded nothing carries none', () => {
|
|
158
|
+
const junk = planWarmStart({ ...observed([]), foldedOrders: { boundaryMs: 1 } } as any, undefined, true)
|
|
159
|
+
assert.equal(junk.action === 'hydrate' && 'foldedOrders' in junk.seed, false)
|
|
160
|
+
const none = planWarmStart(observed([]), undefined, true)
|
|
161
|
+
assert.equal(none.action === 'hydrate' && none.foldedOrderCount, 0)
|
|
162
|
+
})
|