import type { ReferenceAdapter, SiteDescriptor } from './reference-adapter.js' import type { ReferenceMaster } from './reference-master.js' import { capabilitiesOf } from './reference-adapter.js' import { REFERENCE_STEPS, type ReferenceStep } from './reference-progress.js' /* * **커넥터 점검 하네스** — 실 원본에 붙여 「무엇이 채워지고 무엇이 비는가」를 한 번에 본다. * * ── 왜 필요한가 (2026-08-21) ──────────────────────────────────────────────── * chef v1 커넥터를 만들 때 실 인스턴스(포천)에 붙이는 확인을 매번 손으로 했다 — curl 로 필드를 묻고, * 일회용 스크립트로 마스터를 읽고, 결과를 눈으로 셌다. 그 방식은 세 가지를 잃는다: * · 같은 확인을 다음 커넥터에서 처음부터 다시 만든다. * · 무엇을 확인했는지가 어디에도 남지 않는다. * · **앱을 띄워야 알 수 있는 것**이 되어 버린다 — dev 서버가 남의 것이면 확인 자체가 막힌다. * * 그래서 어댑터 규약만 아는 자리에 둔다. 어댑터는 `testConnection` · `discoverSites` · `fetchMaster` · * `openLiveFeed` 를 갖는 객체이고, 그 넷을 순서대로 부르는 일에 커넥터별 지식이 필요하지 않다. * * ── 무엇을 하지 않는가 ────────────────────────────────────────────────────── * · **판단하지 않는다.** 「합격」을 매기지 않고 센 것을 그대로 낸다 — 무엇이 충분한지는 커넥터마다 * 다르고, 그 판단은 체크리스트 문서가 한다. * · **저장하지 않는다.** 트윈을 만들지도, 커서를 남기지도 않는다. 원본을 읽기만 한다. * · **라이브는 부르지 않는 것이 기본이다.** 첫 주기는 전량 읽기이므로 실 운영 원본에 부담이 된다 * (포천은 로트 99,095 · 투입 290,688 · 재고 141,358건이다). 부를 때는 호출부가 명시한다. */ /** 마스터 한 벌에서 센 것 — 커넥터와 무관한 자리 이름으로만 센다. */ export interface MasterCounts { locations: number areas: number equipment: number materialDefinitions: number operations: number /** 생산 선언이 없으면 `undefined` — 0 과 구별한다(선언 자체가 없는 것과 비어 있는 것은 다르다). */ materials?: number materialsWithHome?: number routes?: number recipes?: number recipesWithRoute?: number locationTypes?: number resourceTypes?: number } export interface ProbeStep { key: ReferenceStep | string detail?: string /** 그 단계가 끝난 시각까지 걸린 밀리초 — 어느 단계가 느린지 보이게 한다. */ elapsedMs: number } export interface ProbeReport { adapter: string /** 어댑터가 선언하고 **구현까지 있는** 능력. 선언만 있는 것은 여기 나오지 않는다. */ capabilities: string[] connection?: { ok: boolean; readiness?: string; notes?: string[]; error?: string } sites?: { siteId: string; name?: string }[] /** 사이트마다 한 벌 — 마스터는 사이트 단위다. */ masters: { siteId: string steps: ProbeStep[] counts?: MasterCounts warnings: string[] space?: unknown identity?: unknown /** 표본 — 눈으로 확인할 몇 건. 전체를 싣지 않는다(원본이 수천 건이다). */ samples?: { recipes?: unknown[]; routes?: unknown[]; locations?: unknown[] } elapsedMs: number error?: string }[] /** 라이브를 부른 경우에만 — 한 주기가 낸 기록을 종류별로 센다. */ live?: { records: number; byShape: Record; samples: unknown[]; elapsedMs: number; error?: string } /** 선언한 단계 중 **한 번도 보고되지 않은** 것. 선언해 두고 보내지 않으면 화면의 진행이 멈춘 것처럼 보인다. */ silentSteps?: string[] /** 선언하지 않은 이름으로 보고한 단계. 표준 낱말 밖의 값은 화면이 번역할 수 없다. */ unknownSteps?: string[] } /** 기록의 모양을 이름 붙인다 — 커널 낱말로만 가린다(커넥터를 알 필요가 없다). */ function shapeOf(r: any): string { if (r?.inputQuantityList || r?.outputQuantityList) return 'transformation' if (r?.taskId) return 'task' if (r?.orderId) return 'order' if (r?.quantityList || r?.epcList) return 'observation' return 'other' } function countMaster(ref: ReferenceMaster): MasterCounts { const m: any = ref const d = m.productionSpec?.definition return { locations: m.locations?.length ?? 0, areas: (m.locations ?? []).filter((l: any) => l.role === 'area').length, equipment: m.equipment?.length ?? 0, materialDefinitions: m.materialDefinitions?.length ?? 0, operations: m.operations?.length ?? 0, ...(d ? { materials: d.materials?.length ?? 0, materialsWithHome: (d.materials ?? []).filter((x: any) => x.locationType).length, routes: d.routes?.length ?? 0, recipes: d.recipes?.length ?? 0, recipesWithRoute: (d.recipes ?? []).filter((x: any) => x.route).length, locationTypes: d.locationTypes?.length ?? 0, resourceTypes: d.resourceTypes?.length ?? 0 } : {}) } } const messageOf = (w: unknown): string => (typeof w === 'string' ? w : `${(w as any)?.code}: ${(w as any)?.message}`) /** * 사람이 읽는 줄로 자른다 — **자랐다는 사실을 함께 적는다.** * * 경고에 값 목록이 붙는다(어느 자리가 걸렀는지). 88건이면 UUID 88개가 한 줄에 쏟아져 보고를 읽을 수 * 없게 된다. 그래서 사람용 출력에서만 자르고, 전체는 보고 객체(`--json`)에 그대로 남긴다. */ const brief = (text: string, max = 300): string => text.length <= max ? text : `${text.slice(0, max)}… (${text.length}자 중 앞 ${max}자 — 전체는 보고 객체에 있다)` /** * 어댑터 한 벌을 실 원본에 붙여 본다. * * 실패를 던지지 않는다 — **어디까지 갔는지가 보고의 내용**이다. 연결이 안 되면 그 사실이, 마스터가 * 실패하면 어느 단계에서 실패했는지가 보고에 남는다(예외로 끝나면 그 정보가 사라진다). */ export async function probeReference(opts: { adapter: ReferenceAdapter config: any /** 특정 사이트만 볼 때. 없으면 `discoverSites` 가 낸 전부. */ siteId?: string /** * 라이브 한 주기를 부를지. **기본은 부르지 않는다** — 첫 주기가 전량 읽기다. * `waitMs` 만큼 기다린 뒤 멈춘다(주기가 끝나지 않았으면 그때까지 받은 것만 센다). */ live?: { waitMs: number; sampleCount?: number } /** 표본 건수 — 기본 3건. */ sampleCount?: number }): Promise { const { adapter, config } = opts const sampleCount = opts.sampleCount ?? 3 const report: ProbeReport = { adapter: adapter.type, capabilities: capabilitiesOf(adapter), masters: [] } if (adapter.testConnection) { try { report.connection = (await adapter.testConnection(config)) as any } catch (e: any) { report.connection = { ok: false, error: e?.message ?? String(e) } } /* 연결이 안 되면 더 가지 않는다 — 그 뒤 실패는 모두 같은 원인의 반복이다. */ if (report.connection && report.connection.ok === false) return report } let sites: SiteDescriptor[] = [] try { sites = adapter.discoverSites ? await adapter.discoverSites(config) : [] } catch (e: any) { report.sites = [] report.masters.push({ siteId: '(사이트를 찾지 못했다)', steps: [], warnings: [], elapsedMs: 0, error: e?.message ?? String(e) }) return report } if (opts.siteId) sites = sites.filter(s => s.siteId === opts.siteId) report.sites = sites.map(s => ({ siteId: s.siteId, name: s.name })) const declared = new Set(adapter.masterSteps ?? []) const reported = new Set() const unknown = new Set() for (const site of sites) { const t0 = Date.now() const steps: ProbeStep[] = [] try { const ref = await adapter.fetchMaster!(config, site, s => { reported.add(String(s.key)) if (!(REFERENCE_STEPS as readonly string[]).includes(String(s.key))) unknown.add(String(s.key)) steps.push({ key: s.key, detail: s.detail, elapsedMs: Date.now() - t0 }) }) const m: any = ref const d = m.productionSpec?.definition report.masters.push({ siteId: site.siteId, steps, counts: countMaster(ref), warnings: (m.warnings ?? []).map(messageOf), space: m.space, identity: m.identity, samples: { recipes: (d?.recipes ?? []).slice(0, sampleCount), routes: (d?.routes ?? []).slice(0, sampleCount), locations: (m.locations ?? []).slice(0, sampleCount) }, elapsedMs: Date.now() - t0 }) } catch (e: any) { /* 어느 단계까지 갔는지를 남긴다 — 그것이 다음에 볼 자리다. */ report.masters.push({ siteId: site.siteId, steps, warnings: [], elapsedMs: Date.now() - t0, error: e?.message ?? String(e) }) } } const silent = [...declared].filter(s => !reported.has(s)) if (silent.length) report.silentSteps = silent if (unknown.size) report.unknownSteps = [...unknown] if (opts.live && adapter.openLiveFeed && sites.length) { const t0 = Date.now() const got: unknown[] = [] try { const stop = adapter.openLiveFeed(config, sites[0], rs => got.push(...rs)) await new Promise(r => setTimeout(r, opts.live.waitMs)) stop() const byShape: Record = {} for (const r of got) byShape[shapeOf(r)] = (byShape[shapeOf(r)] ?? 0) + 1 report.live = { records: got.length, byShape, samples: got.slice(0, opts.live.sampleCount ?? sampleCount), elapsedMs: Date.now() - t0 } } catch (e: any) { report.live = { records: got.length, byShape: {}, samples: [], elapsedMs: Date.now() - t0, error: e?.message ?? String(e) } } } return report } /** 보고를 사람이 읽는 줄로 — 화면이 아니라 터미널·로그가 읽는다. */ export function formatProbeReport(r: ProbeReport): string { const out: string[] = [] out.push(`커넥터 ${r.adapter} · 능력 ${r.capabilities.length ? r.capabilities.join(',') : '(없음)'}`) if (r.connection) { out.push(`연결 ${r.connection.ok ? 'ok' : '실패'}${r.connection.readiness ? ` · 읽기준비 ${r.connection.readiness}` : ''}`) for (const n of r.connection.notes ?? []) out.push(` · ${brief(n)}`) if (r.connection.error) out.push(` · ${r.connection.error}`) } for (const m of r.masters) { out.push(`사이트 ${m.siteId} — ${(m.elapsedMs / 1000).toFixed(1)}초`) for (const s of m.steps) out.push(` [${(s.elapsedMs / 1000).toFixed(1)}s] ${s.key}${s.detail ? ` — ${s.detail}` : ''}`) if (m.error) out.push(` 실패: ${m.error}`) if (m.counts) { const c = m.counts out.push( ` 로케이션 ${c.locations}(존 ${c.areas}) · 설비 ${c.equipment} · 자재정의 ${c.materialDefinitions} · 공정 ${c.operations}` ) if (c.materials !== undefined) { out.push( ` 선언자재 ${c.materials}(보관 로케이션 있는 것 ${c.materialsWithHome}) · 경로 ${c.routes} · 레시피 ${c.recipes}(경로 붙은 것 ${c.recipesWithRoute})` ) } else { out.push(' 생산 선언 없음 — 레시피가 하나도 성립하지 않았다') } } for (const w of m.warnings) out.push(` 경고 ${brief(w)}`) } if (r.silentSteps?.length) out.push(`선언했으나 보고되지 않은 단계: ${r.silentSteps.join(', ')}`) if (r.unknownSteps?.length) out.push(`표준 낱말 밖의 단계 이름: ${r.unknownSteps.join(', ')}`) if (r.live) { out.push( `라이브 한 주기 — 기록 ${r.live.records}건 (${Object.entries(r.live.byShape).map(([k, v]) => `${k} ${v}`).join(' · ') || '없음'}) · ${(r.live.elapsedMs / 1000).toFixed(1)}초` ) if (r.live.error) out.push(` 실패: ${r.live.error}`) } return out.join('\n') }