import { Arg, Ctx, Query, Resolver } from 'type-graphql' import { getRepository, ScalarObject } from '@things-factory/shell' import { TwinEngine } from '../../engine/index.js' import { assessSuite, SUITE_SPEC, type SuiteFacts } from '../../engine/integration-runner.js' import { readSpecCoverage } from '../../engine/spec-coverage.js' import { capabilitiesOf, getAdapter } from '../reference/reference-adapter.js' import { TwinEvent } from '../twin-event/twin-event.js' import { anyJournalRow, countUpTo } from '../twin-event/journal-count.js' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinReference } from '../reference/twin-reference.js' /* * 도입 단계 — **「이 트윈은 어디까지 증명됐나」.** * * `twinIngestHealth` 와 묻는 것이 다르다: 그쪽은 「지금 현장과 맞춰지고 있나」(피드의 건강)이고, * 여기는 「구조·라이브·예측·폐루프 중 어디까지 **증거로** 섰나」다. 체크리스트가 아니라 판정이므로 * 사람이 체크해서 초록으로 만들 수 없다. * * **접속 프로브는 여기서 돌리지 않는다.** 조회마다 고객 시스템을 두드리면 화면을 열 때마다 남의 * 운영계에 부하가 간다. 그래서 `connectOk` 는 `null`(안 돌렸다)이고, 계획의 첫 칸이 접속 확인이 된다. */ @Resolver() export class TwinReadinessQuery { @Query(returns => ScalarObject, { nullable: true, description: 'Which adoption stage this twin has actually reached, judged from evidence rather than a checklist: structure, live, forecast, closed-loop. Each stage reports what blocks it and which signals could not be measured at all — an unmeasured signal never counts as achieved, so a half-run suite cannot claim green. Probes that do not apply to this twin (live probes on a simulation, mapping-warning probes on a template-built twin) come back as notApplicable with a reason, which is neither a pass nor a gap. `forbiddenClaims` lists the claims that must not be made yet (realtime, prediction, automatic control). The connection probe is not run here because a read query should not hit the source system on every page load, so it appears in `planned` as the next step to take. Null if the instance is not in the current domain.' }) async twinAdoptionPath(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id if (!TwinEngine.owns(domainId, instanceId) && !(await TwinEngine.ownsRegistered(domainId, instanceId))) return null const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null const ref = await getRepository(TwinReference) .findOne({ where: { domain: { id: domainId }, source: instanceId } }) .catch(() => null) const mode = TwinEngine.modeOf(domainId, instanceId) const snapshot = TwinEngine.owns(domainId, instanceId) ? TwinEngine.snapshot(domainId, instanceId) : undefined const events = getRepository(TwinEvent) /* * 저널에서 센다 — **재시작에도 남는 자리**다. 인메모리 계기로 세면 재기동마다 0 이 되고, 그 0 이 * 「세었는데 반출이 없었다」로 읽혀 라이브 단계를 거짓으로 막는다. * * `action` 승격 전에 기록된 행은 세어지지 않는다. 그래서 반출이 **한 건도 없고** 승격 이후 유입도 * 없으면 `null`(모른다)로 낸다 — 옛 저널을 「반출 0건」으로 단정하지 않는다. */ /* * **상한까지만 센다** (2026-08-22 실측). 예전에는 넷을 전부 세었고, 저널 358만 행인 트윈에서 한 번이 * 5.32초라 이 화면 하나가 20초를 썼다. 판정에 쓰이는 것은 문턱(≥1)이므로 상한까지의 수로 충분하고, * 승격 여부는 **한 건만 찾으면** 답이 난다(수가 필요 없다). */ const scope = { domain: { id: domainId }, instanceId } const journal = await countUpTo(events, scope) const removalCount = await countUpTo(events, { ...scope, action: 'DELETE' }) const journalTotal = journal.count const removals = removalCount.count const anyPromoted = removals > 0 || (await anyJournalRow(events, { ...scope, action: 'OBSERVE' })) || (await anyJournalRow(events, { ...scope, action: 'ADD' })) const modelLocations = Array.isArray((inst.model as any)?.locations) ? (inst.model as any).locations.length : null const facts: SuiteFacts = { subject: mode === 'live' ? 'live' : mode === 'sim' ? 'sim' : 'unknown', fromReference: !!ref, adapterCapabilities: ref?.adapterType ? capabilitiesOf(getAdapter(ref.adapterType)) : [], connectOk: null, // 조회에서 남의 운영계를 두드리지 않는다 /* 이 레퍼런스가 트윈을 세운 사이트 수 — 발견의 결과다(발견 자체를 다시 돌리지 않는다). */ siteCount: ref ? ((ref.scopeSpec as any)?.produced?.length ?? null) : null, locationCount: snapshot?.locations?.length ?? modelLocations, itemCount: snapshot?.items?.length ?? null, lastWarningCount: ref?.lastWarningCount ?? null, journalTotal, /* 승격 이후 유입이 하나도 없으면 세지 못한 것이다 — 0 으로 단정하지 않는다. */ removalsJournaled: anyPromoted ? removals : null, specCoverage: (() => { const c: any = snapshot ? readSpecCoverage(snapshot) : null if (!c?.operations?.length) return null const total = c.operations.length const declared = c.operations.filter((o: any) => o.duration === 'declared' || o.duration === 'measured').length return { declared, total } })(), forecastRan: null, // 예측 실행 이력을 남기는 자리가 아직 없다 — 모른다고 말한다 feed: 'unknown' } const assessment = assessSuite(facts) return { instanceId, ...assessment, spec: SUITE_SPEC, facts } } }