@things-factory/headless-twin 10.0.18 → 10.0.19

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.
Files changed (43) hide show
  1. package/dist-server/routes.js +12 -1
  2. package/dist-server/routes.js.map +1 -1
  3. package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
  4. package/dist-server/service/actuation/command-dispatcher.js +92 -0
  5. package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
  6. package/dist-server/service/actuation/command-store.d.ts +3 -0
  7. package/dist-server/service/actuation/command-store.js +56 -0
  8. package/dist-server/service/actuation/command-store.js.map +1 -0
  9. package/dist-server/service/actuation/index.d.ts +6 -0
  10. package/dist-server/service/actuation/index.js +11 -0
  11. package/dist-server/service/actuation/index.js.map +1 -0
  12. package/dist-server/service/actuation/twin-command.d.ts +30 -0
  13. package/dist-server/service/actuation/twin-command.js +110 -0
  14. package/dist-server/service/actuation/twin-command.js.map +1 -0
  15. package/dist-server/service/index.d.ts +1 -1
  16. package/dist-server/service/index.js +20 -17
  17. package/dist-server/service/index.js.map +1 -1
  18. package/dist-server/service/reference/hook-contract.d.ts +64 -21
  19. package/dist-server/service/reference/hook-contract.js +134 -22
  20. package/dist-server/service/reference/hook-contract.js.map +1 -1
  21. package/dist-server/service/reference/hook-store.d.ts +3 -0
  22. package/dist-server/service/reference/hook-store.js +28 -0
  23. package/dist-server/service/reference/hook-store.js.map +1 -0
  24. package/dist-server/service/reference/reference-adapter.d.ts +65 -1
  25. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  26. package/dist-server/service/reference/reference-hook.d.ts +42 -6
  27. package/dist-server/service/reference/reference-hook.js +185 -27
  28. package/dist-server/service/reference/reference-hook.js.map +1 -1
  29. package/package.json +7 -7
  30. package/server/routes.ts +12 -1
  31. package/server/service/actuation/command-dispatcher.ts +130 -0
  32. package/server/service/actuation/command-store.ts +62 -0
  33. package/server/service/actuation/index.ts +8 -0
  34. package/server/service/actuation/twin-command.ts +105 -0
  35. package/server/service/index.ts +3 -0
  36. package/server/service/reference/hook-contract.ts +99 -21
  37. package/server/service/reference/hook-store.ts +28 -0
  38. package/server/service/reference/reference-adapter.ts +67 -2
  39. package/server/service/reference/reference-hook.ts +249 -32
  40. package/test/actuation-dispatch.test.ts +196 -0
  41. package/test/hook-sequence.test.ts +333 -0
  42. package/test/reference-hook.test.ts +114 -23
  43. package/tsconfig.tsbuildinfo +1 -1
@@ -42,11 +42,12 @@
42
42
  * 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지
43
43
  * 함께 알립니다.
44
44
  */
45
- import { getRepository } from '@things-factory/shell'
45
+ import { WEBHOOK_STATUS, checkSequence } from '@operato/ops-contract'
46
46
 
47
- import { TwinReference } from './twin-reference.js'
48
- import { getAdapter } from './reference-adapter.js'
49
- import { HOOK_STATUS, producedInstance, secretFromHeaders, secretMatches, type HookOutcome } from './hook-contract.js'
47
+ import { twinWarn } from '../../engine/log.js'
48
+
49
+ import { getAdapter, type InboundBatch } from './reference-adapter.js'
50
+ import { authorizeHook, producedInstance, pushCursorKey, seqOf, withSeq, type HookOutcome } from './hook-contract.js'
50
51
 
51
52
  /**
52
53
  * 훅 하나를 처리한다 — 찾고 · 확인하고 · 커넥터에게 옮기게 하고 · 유입으로 넘긴다.
@@ -60,16 +61,33 @@ export async function handleHook(args: {
60
61
  instanceId: string
61
62
  headers: Record<string, unknown>
62
63
  body: unknown
63
- /** 레코드를 트윈에 넣는다 — 받은 수와 버린 수를 돌려준다. */
64
- ingest: (instanceId: string, records: unknown[]) => { applied: number; duplicates: number; rejected: number }
64
+ /**
65
+ * 받은 바이트 그대로 서명을 확인하려면 이것이 있어야 한다.
66
+ *
67
+ * 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다. 없으면 서명을 쓰는
68
+ * 요청은 거절된다(§`authorizeHook`).
69
+ */
70
+ rawBody?: string
71
+ /** 서명 창을 재는 기준 시각. 부르는 쪽이 준다 — 여기서 읽으면 시험이 시계에 매인다. */
72
+ nowMs?: number
73
+ /** 레코드를 트윈에 넣는다 — 받은 수와 **떨어진 것들**을 돌려준다. */
74
+ ingest: (instanceId: string, records: unknown[]) => IngestResult
75
+ /**
76
+ * 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.**
77
+ *
78
+ * `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한
79
+ * 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린
80
+ * 결함을 이 저장소가 이미 겪었다.
81
+ */
82
+ store: HookStore
65
83
  }): Promise<HookOutcome> {
66
- const { domainId, source, instanceId, headers, body, ingest } = args
84
+ const { domainId, source, instanceId, headers, body, rawBody, ingest } = args
85
+ const nowMs = args.nowMs ?? Date.now()
86
+ const { store } = args
67
87
 
68
- const ref = await getRepository(TwinReference)
69
- .findOne({ where: { domain: { id: domainId }, source } })
70
- .catch(() => null)
88
+ const ref = await store.load(domainId, source)
71
89
  if (!ref) {
72
- return { status: HOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection "${source}"` } }
90
+ return { status: WEBHOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection "${source}"` } }
73
91
  }
74
92
 
75
93
  const cfg: any = (ref as any).connectionConfig ?? {}
@@ -77,21 +95,22 @@ export async function handleHook(args: {
77
95
  if (!expected) {
78
96
  /* 비밀값을 선언하지 않은 연결은 훅을 받지 않는다 — 기본이 거부다. */
79
97
  return {
80
- status: HOOK_STATUS.badSecret,
98
+ status: WEBHOOK_STATUS.badSecret,
81
99
  body: {
82
100
  ok: false,
83
101
  error: `connection "${source}" declares no hookSecret — set one in the connection settings before pushing to it`
84
102
  }
85
103
  }
86
104
  }
87
- if (!secretMatches(secretFromHeaders(headers), expected)) {
88
- return { status: HOOK_STATUS.badSecret, body: { ok: false, error: 'hook secret does not match' } }
105
+ const auth = authorizeHook({ headers, rawBody, secret: expected, nowMs })
106
+ if (auth.ok === false) {
107
+ return { status: WEBHOOK_STATUS.badSecret, body: { ok: false, error: auth.reason } }
89
108
  }
90
109
 
91
110
  const site = producedInstance(ref as any, instanceId)
92
111
  if (!site) {
93
112
  return {
94
- status: HOOK_STATUS.unknownTarget,
113
+ status: WEBHOOK_STATUS.unknownTarget,
95
114
  body: { ok: false, error: `twin "${instanceId}" was not created by connection "${source}"` }
96
115
  }
97
116
  }
@@ -99,47 +118,245 @@ export async function handleHook(args: {
99
118
  const adapter: any = getAdapter(String((ref as any).adapterType ?? ''))
100
119
  if (typeof adapter?.handleInbound !== 'function') {
101
120
  return {
102
- status: HOOK_STATUS.unsupported,
121
+ status: WEBHOOK_STATUS.unsupported,
103
122
  body: { ok: false, error: `connector "${(ref as any).adapterType}" cannot take pushed events (no handleInbound)` }
104
123
  }
105
124
  }
106
125
 
107
- let records: unknown[]
126
+ let batch: InboundBatch
108
127
  try {
109
- records = adapter.handleInbound(cfg, site, body, headers) ?? []
128
+ batch = adapter.handleInbound(cfg, site, body, headers)
110
129
  } catch (e: any) {
111
130
  /* 옮기지 못한 것은 그쪽 모양이 바뀐 것이다 — 다시 보내도 같으므로 재시도를 부르지 않는다. */
112
- return { status: HOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } }
131
+ return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } }
113
132
  }
114
- if (!Array.isArray(records)) {
115
- return { status: HOOK_STATUS.badPayload, body: { ok: false, error: 'the connector did not return a list of records' } }
133
+ const items = batch?.items
134
+ if (!Array.isArray(items)) {
135
+ return {
136
+ status: WEBHOOK_STATUS.badPayload,
137
+ body: { ok: false, error: 'the connector did not return a list of items' }
138
+ }
116
139
  }
140
+
141
+ /*
142
+ * **번호 확인** — 이 자리가 프레임워크인 이유는 밀어 주는 모든 연결에 같은 규율이라야 하기 때문이다.
143
+ * 커넥터마다 만들면 한 곳이 빠지고, 빠진 그 연결에서만 사실이 없어진다.
144
+ *
145
+ * 번호를 매기지 않는 원본(chef · ppms)은 `seq` 를 주지 않고, 그때는 확인하지 않는다. 확인하지 않았다는
146
+ * 것은 응답에 남긴다 — 「확인했고 이상 없다」와 구별되어야 한다.
147
+ */
148
+ const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined
149
+ const withSeqCount = items.filter(i => typeof i?.seq === 'number').length
150
+ if (withSeqCount && withSeqCount !== items.length) {
151
+ /* 섞이면 빠진 것이 있는지 판단할 근거가 없다. 반쯤 확인한 것을 확인했다고 말하지 않는다. */
152
+ return {
153
+ status: WEBHOOK_STATUS.badPayload,
154
+ body: { ok: false, error: `mixed batch — ${withSeqCount} of ${items.length} items carry a sequence number` }
155
+ }
156
+ }
157
+ const numbered = withSeqCount > 0 && scope !== undefined
158
+
159
+ /*
160
+ * **첫 구멍에서 멈춘다.** 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다 — 보내는 쪽은
161
+ * `expectedSeq` 부터 다시 보내고, 뒤엣것은 그때 함께 온다.
162
+ */
163
+ let take = items.length
164
+ let advanced: { scope: string; seq: number } | undefined
165
+ let gap: { expectedSeq: number; lastSeq: number } | undefined
166
+ if (numbered) {
167
+ /* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */
168
+ const key = pushCursorKey(instanceId, scope as string)
169
+ let cursor = seqOf((ref as any).liveCursor, key)
170
+ take = 0
171
+ for (const item of items) {
172
+ let verdict
173
+ try {
174
+ verdict = checkSequence(cursor, item.seq as number)
175
+ } catch (e: any) {
176
+ return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'bad sequence number' } }
177
+ }
178
+ if (verdict.kind === 'gap') {
179
+ gap = { expectedSeq: verdict.expectedSeq, lastSeq: verdict.lastSeq }
180
+ break
181
+ }
182
+ cursor = verdict.lastSeq
183
+ take++
184
+ }
185
+ /* 앞의 것을 하나도 못 받았으면 커서를 올릴 것이 없다. */
186
+ if (take > 0 && typeof cursor === 'number') advanced = { scope: key, seq: cursor }
187
+ }
188
+
189
+ const records = items.slice(0, take).map(i => i.record)
190
+
117
191
  if (!records.length) {
118
- /* 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. */
119
- return { status: HOOK_STATUS.ok, body: { ok: true, offered: 0, applied: 0, duplicates: 0, rejected: 0 } }
192
+ /*
193
+ * 옮길 것이 없는 것은 오류가 아니다 그쪽이 우리와 무관한 것을 보낸 것일 있다. 다만 첫 봉투부터
194
+ * 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다.
195
+ */
196
+ if (gap) return gapOutcome(scope, gap)
197
+ if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq)
198
+ return {
199
+ status: WEBHOOK_STATUS.ok,
200
+ body: { ok: true, offered: 0, applied: 0, duplicates: 0, rejected: [], ...seqBody(scope, advanced, numbered) }
201
+ }
120
202
  }
121
203
 
122
- let result: { applied: number; duplicates: number; rejected: number }
204
+ let result: IngestResult
123
205
  try {
124
206
  result = ingest(instanceId, records)
125
207
  } catch (e: any) {
126
- return { status: HOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } }
208
+ return { status: WEBHOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } }
127
209
  }
210
+ const rejected = result.rejected ?? []
128
211
 
129
- if (result.applied === 0 && result.duplicates === 0 && records.length > 0 && result.rejected === 0) {
212
+ if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0) {
130
213
  /*
131
214
  * 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.
132
- * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다. 그래서 재시도로 풀리지 않는다는 것을 코드로
133
- * 말한다 — 사람이 트윈을 띄워야 한다.
215
+ * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.
216
+ *
217
+ * **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이
218
+ * 뜬 뒤에 그 구간이 비어 있게 된다.
134
219
  */
135
220
  return {
136
- status: HOOK_STATUS.notLive,
137
- body: { ok: false, error: `twin "${instanceId}" is not running live — nothing was applied`, offered: records.length }
221
+ status: WEBHOOK_STATUS.notLive,
222
+ body: {
223
+ ok: false,
224
+ error: `twin "${instanceId}" is not running live — nothing was applied`,
225
+ offered: records.length,
226
+ retryAfterMs: NOT_LIVE_RETRY_MS
227
+ }
228
+ }
229
+ }
230
+
231
+ if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq)
232
+
233
+ /*
234
+ * **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게
235
+ * 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다.
236
+ */
237
+ if (gap) return gapOutcome(scope, gap, rejected, advanced)
238
+
239
+ if (rejected.length) {
240
+ /*
241
+ * **다시 보내라고 하지 않는다.** 떨어진 것은 모양이 틀린 것이라 같은 것을 다시 보내도 또 떨어지고,
242
+ * 함께 간 나머지는 이미 들어갔으므로 두 번 들어간다.
243
+ */
244
+ return {
245
+ status: WEBHOOK_STATUS.partial,
246
+ body: {
247
+ ok: false,
248
+ error: `${rejected.length} of ${records.length} records did not pass validation`,
249
+ offered: records.length,
250
+ applied: result.applied,
251
+ duplicates: result.duplicates,
252
+ rejected,
253
+ ...seqBody(scope, advanced, numbered)
254
+ }
138
255
  }
139
256
  }
140
257
 
141
258
  return {
142
- status: HOOK_STATUS.ok,
143
- body: { ok: true, offered: records.length, applied: result.applied, duplicates: result.duplicates, rejected: result.rejected }
259
+ status: WEBHOOK_STATUS.ok,
260
+ body: {
261
+ ok: true,
262
+ offered: records.length,
263
+ applied: result.applied,
264
+ duplicates: result.duplicates,
265
+ rejected: [],
266
+ ...seqBody(scope, advanced, numbered)
267
+ }
144
268
  }
145
269
  }
270
+
271
+ /**
272
+ * 유입이 돌려주는 것.
273
+ *
274
+ * 떨어진 것을 **수가 아니라 목록으로** 받는다. 수만 받으면 보내는 쪽에 「몇 건이 떨어졌다」밖에 말할 수
275
+ * 없고, 그러면 무엇을 따로 두어야 할지 알 수 없어 배치 전체를 다시 보내거나 전부 버린다.
276
+ */
277
+ export interface IngestResult {
278
+ applied: number
279
+ duplicates: number
280
+ rejected: { record: unknown; errors: string[] }[]
281
+ }
282
+
283
+ /**
284
+ * 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.**
285
+ *
286
+ * `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서
287
+ * 처리량만 먹는다.
288
+ */
289
+ function gapOutcome(
290
+ scope: string | undefined,
291
+ gap: { expectedSeq: number; lastSeq: number },
292
+ rejected: { record: unknown; errors: string[] }[] = [],
293
+ advanced?: { scope: string; seq: number }
294
+ ): HookOutcome {
295
+ return {
296
+ status: WEBHOOK_STATUS.sequenceGap,
297
+ body: {
298
+ ok: false,
299
+ error: 'sequence gap',
300
+ scope,
301
+ expectedSeq: gap.expectedSeq,
302
+ lastSeq: advanced?.seq ?? gap.lastSeq,
303
+ ...(rejected.length ? { rejected } : {})
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 트윈이 안 돌 때 얼마나 쉬라고 할 것인가.
310
+ *
311
+ * 사람이 트윈을 띄워야 풀리므로 초 단위로 다시 두드릴 이유가 없다. 그렇다고 길게 잡으면 트윈이 뜬 뒤
312
+ * 그만큼 늦게 이어진다.
313
+ */
314
+ const NOT_LIVE_RETRY_MS = 30_000
315
+
316
+ /**
317
+ * 응답에 번호를 어떻게 적나 — **확인한 것과 확인하지 않은 것을 구별한다.**
318
+ *
319
+ * 번호를 매기지 않는 원본은 확인할 것이 없다. 그것을 응답에서 「이상 없다」와 같아 보이게 두면, 어느
320
+ * 연결이 연속성을 지키고 있는지 아무도 알 수 없다.
321
+ */
322
+ function seqBody(
323
+ scope: string | undefined,
324
+ advanced: { scope: string; seq: number } | undefined,
325
+ numbered: boolean
326
+ ): Record<string, unknown> {
327
+ if (!numbered) return { sequenceChecked: false }
328
+ return { sequenceChecked: true, scope, lastSeq: advanced?.seq }
329
+ }
330
+
331
+ /**
332
+ * 커서에 번호를 적는다 — **반영한 뒤에만.**
333
+ *
334
+ * 넣기 전에 올리면, 넣다가 실패했을 때 그 번호는 다시 오지 않는다. 보내는 쪽은 200 을 못 받았으니 다시
335
+ * 보내겠지만, 그때는 커서가 이미 지나가 있어 `behind` 로 읽히고 버려진다.
336
+ *
337
+ * 적지 못하는 것은 사실 유입을 무르는 이유가 아니다 — 이미 들어간 사실은 그대로 두고, 다음 번호가 오면
338
+ * `gap` 으로 드러난다. 그래서 실패를 던지지 않고 남긴다.
339
+ */
340
+ async function saveSeq(
341
+ store: HookStore,
342
+ ref: { id: string; liveCursor?: unknown },
343
+ scope: string,
344
+ seq: number
345
+ ): Promise<void> {
346
+ const next = withSeq(ref.liveCursor, scope, seq)
347
+ ref.liveCursor = next
348
+ await store
349
+ .saveCursor(ref.id, next)
350
+ .catch(err => twinWarn(`[twin-hook] 번호를 커서에 적지 못했다 (${scope}=${seq}) — ${err?.message ?? err}`))
351
+ }
352
+
353
+ /**
354
+ * 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.
355
+ *
356
+ * 이 둘만 밖으로 낸다. 나머지는 인자만 보고 답하므로 시험이 데이터베이스를 세우지 않아도 된다.
357
+ */
358
+ export interface HookStore {
359
+ load(domainId: string, source: string): Promise<any | null>
360
+ saveCursor(refId: string, cursor: unknown): Promise<void>
361
+ }
362
+
@@ -0,0 +1,196 @@
1
+ /*
2
+ * **승인을 지나지 않은 조치는 현장에 나가지 못한다** — 배선.
3
+ *
4
+ * ── 왜 배선을 따로 보나 (2026-08-31) ─────────────────────────────────────
5
+ * 승인 판정 자체는 계약이 지킨다(`ops-contract/test/actuation-gate.test.ts`). 여기서 보는 것은
6
+ * **그 판정이 실제 경로에 서 있는가**다 — 어댑터가 불렸는가, 상태가 적혔는가, 다시 세운 뒤에도 승인이
7
+ * 남아 있는가.
8
+ *
9
+ * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 게이트가 그 부류로 나면 결과가
10
+ * 현장의 작업지시다. 그래서 마지막 시험이 **저장본만 남기고 다시 세운다.**
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+
15
+ import type { TwinCommand } from '@operato/ops-contract'
16
+
17
+ import { approve, dispatch, reject, type CommandAck, type CommandStore } from '../server/service/actuation/command-dispatcher.js'
18
+
19
+ const DOMAIN = 'd-1'
20
+ const AT = '2026-08-31T02:00:00.000Z'
21
+ const APPROVAL = { by: 'line-lead', at: AT }
22
+
23
+ const irreversible = (over: Partial<TwinCommand> = {}): TwinCommand => ({
24
+ id: 'cmd-1',
25
+ instanceId: 'twin-a',
26
+ type: 'order.hold',
27
+ origin: 'ai',
28
+ proposedAt: AT,
29
+ state: 'proposed',
30
+ ...over
31
+ })
32
+
33
+ /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */
34
+ function makeStore(initial: TwinCommand): CommandStore & { rows: Map<string, TwinCommand> } {
35
+ const rows = new Map<string, TwinCommand>([[initial.id, initial]])
36
+ return {
37
+ rows,
38
+ load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null),
39
+ /* 저장소를 흉내 내는 것이라 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */
40
+ save: async (domainId, command) => {
41
+ assert.equal(domainId, DOMAIN)
42
+ rows.set(command.id, JSON.parse(JSON.stringify(command)))
43
+ }
44
+ }
45
+ }
46
+
47
+ const ok = (ref = 'JOB-1') => async (): Promise<CommandAck> => ({ ok: true, ref })
48
+
49
+ /* ── 승인 없이는 나가지 못한다 ────────────────────────────────────────────── */
50
+
51
+ test('★ 승인하지 않은 조치는 어댑터에 닿지 않는다', async () => {
52
+ const store = makeStore(irreversible())
53
+ let called = false
54
+
55
+ await assert.rejects(
56
+ dispatch(store, DOMAIN, 'cmd-1', async () => {
57
+ called = true
58
+ return { ok: true }
59
+ }),
60
+ /승인되지 않은/
61
+ )
62
+ assert.equal(called, false, '어댑터가 불렸다 — 게이트가 서지 않았다')
63
+ assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '상태가 움직였다')
64
+ })
65
+
66
+ test('★ 상태만 approved 로 적고 승인 기록이 없으면 막는다 — 저장본을 손으로 고쳐도 열리지 않는다', async () => {
67
+ const store = makeStore(irreversible({ state: 'approved' }))
68
+ let called = false
69
+ await assert.rejects(
70
+ dispatch(store, DOMAIN, 'cmd-1', async () => {
71
+ called = true
72
+ return { ok: true }
73
+ }),
74
+ /승인 기록이 없는/
75
+ )
76
+ assert.equal(called, false)
77
+ })
78
+
79
+ test('★ 승인하면 나간다 — 어댑터가 돌려준 것이 남는다', async () => {
80
+ const store = makeStore(irreversible())
81
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
82
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-77'))
83
+
84
+ assert.equal(settled.state, 'acked')
85
+ assert.equal(settled.dispatchRef, 'JOB-77', '되돌릴 때 무엇을 되돌릴지 말할 수 없게 된다')
86
+ assert.equal(store.rows.get('cmd-1')!.approval?.by, 'line-lead')
87
+ })
88
+
89
+ test('★ 넘기기 전에 「넘기는 중」을 먼저 적는다 — 그 사이에 죽으면 현장이 두 번 움직인다', async () => {
90
+ const store = makeStore(irreversible())
91
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
92
+
93
+ let seenWhileDispatching: string | undefined
94
+ await dispatch(store, DOMAIN, 'cmd-1', async () => {
95
+ seenWhileDispatching = store.rows.get('cmd-1')!.state
96
+ return { ok: true, ref: 'x' }
97
+ })
98
+ assert.equal(seenWhileDispatching, 'dispatched', '어댑터를 부르는 동안 저장본이 아직 approved 였다')
99
+ })
100
+
101
+ /* ── 되돌릴 수 있는 것의 다른 문 ─────────────────────────────────────────── */
102
+
103
+ test('★ 되돌릴 수 있는 조치는 사람 없이 지난다 — 시뮬과 what-if 가 이 길이다', async () => {
104
+ const store = makeStore(irreversible({ reversible: true }))
105
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', ok())
106
+ assert.equal(settled.state, 'acked')
107
+ })
108
+
109
+ test('★ 되돌릴 수 있다고 적지 않은 것은 그 문으로 못 간다 — 모름을 통과로 읽지 않는다', async () => {
110
+ const store = makeStore(irreversible({ reversible: false }))
111
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()))
112
+ })
113
+
114
+ /* ── 실패와 되풀이 ────────────────────────────────────────────────────────── */
115
+
116
+ test('★ 어댑터가 던지면 실패로 남고 이유가 적힌다', async () => {
117
+ const store = makeStore(irreversible())
118
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
119
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => {
120
+ throw new Error('MES 가 응답하지 않는다')
121
+ })
122
+
123
+ assert.equal(settled.state, 'failed')
124
+ assert.match(String(settled.error), /응답하지 않는다/)
125
+ })
126
+
127
+ test('어댑터가 이유 없이 실패해도 빈 칸으로 두지 않는다', async () => {
128
+ const store = makeStore(irreversible())
129
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
130
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false }))
131
+ assert.ok(String(settled.error).length > 0)
132
+ })
133
+
134
+ test('★ 실패한 것은 다시 넘길 수 있다 — 승인을 다시 받지 않는다', async () => {
135
+ const store = makeStore(irreversible())
136
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
137
+ await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '잠시 끊겼다' }))
138
+
139
+ const retried = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-9'))
140
+ assert.equal(retried.state, 'acked')
141
+ assert.equal(retried.dispatchRef, 'JOB-9')
142
+ })
143
+
144
+ test('★ 이미 답을 받은 것은 다시 나가지 않는다 — 두 번 나가면 현장이 두 번 움직인다', async () => {
145
+ const store = makeStore(irreversible())
146
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
147
+ await dispatch(store, DOMAIN, 'cmd-1', ok())
148
+
149
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /넘길 수 없다/)
150
+ })
151
+
152
+ /* ── 거절 ─────────────────────────────────────────────────────────────────── */
153
+
154
+ test('★ 거절하면 끝이다 — 되살아나지 않는다', async () => {
155
+ const store = makeStore(irreversible())
156
+ await reject(store, DOMAIN, 'cmd-1', { by: 'line-lead', at: AT, note: '지금 그 라인은 정지 중' })
157
+
158
+ assert.equal(store.rows.get('cmd-1')!.state, 'rejected')
159
+ await assert.rejects(approve(store, DOMAIN, 'cmd-1', APPROVAL))
160
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()))
161
+ })
162
+
163
+ test('승인을 기다리는 중에도 거절할 수 있다', async () => {
164
+ const store = makeStore(irreversible())
165
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
166
+ await reject(store, DOMAIN, 'cmd-1', { by: 'plant-manager', at: AT })
167
+ assert.equal(store.rows.get('cmd-1')!.state, 'rejected')
168
+ })
169
+
170
+ /* ── 재기동 ───────────────────────────────────────────────────────────────── */
171
+
172
+ test('★ 승인이 재기동을 넘어 산다 — 사람이 승인한 것이 사라지면 안 된다', async () => {
173
+ const first = makeStore(irreversible())
174
+ await approve(first, DOMAIN, 'cmd-1', APPROVAL)
175
+ const saved: TwinCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
176
+
177
+ /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
178
+ const second = makeStore(saved)
179
+ const settled = await dispatch(second, DOMAIN, 'cmd-1', ok('JOB-3'))
180
+
181
+ assert.equal(settled.state, 'acked', '이어받지 못하면 승인이 사라져 던진다')
182
+ assert.equal(settled.approval?.by, 'line-lead')
183
+ })
184
+
185
+ test('★ 재기동이 게이트를 열지 않는다 — 되세운 것도 같은 문을 지난다', async () => {
186
+ /* JSON 에는 승인 표식이 없다. 되세울 때 표식을 그냥 붙이면 재기동이 곧 우회로가 된다. */
187
+ const saved: TwinCommand = JSON.parse(JSON.stringify(irreversible()))
188
+ const store = makeStore(saved)
189
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /승인되지 않은/)
190
+ })
191
+
192
+ test('모르는 커맨드는 조용히 넘어가지 않는다', async () => {
193
+ const store = makeStore(irreversible())
194
+ await assert.rejects(dispatch(store, DOMAIN, 'nope', ok()), /모르는 커맨드/)
195
+ await assert.rejects(approve(store, DOMAIN, 'nope', APPROVAL), /모르는 커맨드/)
196
+ })