import { Arg, Ctx, Mutation, Query, Resolver, Directive, UseMiddleware } from 'type-graphql' import { ScalarObject, SpeakRefusals, getRepository } from '@things-factory/shell' import { TwinEngine } from '../../engine/index.js' /* 정책 읽기는 그 모듈에서 직접 가져온다 — 다른 소비처(`reference-resolver`)와 같은 길이다. */ import { readRestartPolicy } from '../../engine/restart-policy.js' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinEvent } from '../twin-event/twin-event.js' import type { TwinModelDef } from '@operato/ops-contract' /* 도메인 어휘 카탈로그(시스템별 노드 타입) — 커널이 SSOT. 앱/UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지). * 조합은 domain-catalog.ts 가 소유 — 리졸버와 서버측 소비자(board-ai 도구 등)가 같은 형태를 본다. */ import { domainCatalog } from './domain-catalog.js' /* * 트윈 인스턴스 생명주기 채널 — 프로비저닝(상향, ADR-0015). * State/Command/Scenario 는 이미 running 인 인스턴스를 다루지만, 이 채널은 인스턴스 자체를 만들고/설정하고/없앤다. * 생명주기: provision(save, 미기동) → start → stop → delete. 모두 domain 스코프(context.state.domain.id). * model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 **원본이 선언하고** * (`TwinReference.connectionConfig.scenario`) 기동이 싣거나, 원본의 구동 능력으로 태운다(ADR-0029 §4·§8). */ @Resolver() @UseMiddleware(SpeakRefusals) export class TwinLifecycleMutation { /* * `saveTwinInstance` 는 **없앴다**(2026-08-08, ADR-0028). * * 트윈의 구조를 임의의 model 로 덮어쓰는 문이었고, 유일한 소비자는 손저작 편집기였다. * 구조는 원본에서 온다 — 템플릿(`createFromTemplate`)이나 커넥터(`ingestMaster`)가 그 길이다. * 문을 열어 두면 화면만 지워도 다음 소비자가 다시 생긴다. * * 시뮬 트윈을 세우는 것은 여전히 가능하다. 다만 그 입구는 **원본을 만드는 쪽**이지 * 트윈을 고치는 쪽이 아니다(ADR-0029 — 시뮬레이터는 원본의 한 종류). */ /** 기동 — model 를 주면 provision+start, 없으면 레지스트리에 저장된 구조로 start. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Start a twin runtime instance and begin the worker tick. If model is given it provisions first; otherwise it starts from the saved registry model.' }) async startTwinInstance( @Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext, @Arg('kind', { nullable: true }) kind?: string, @Arg('model', type => ScalarObject, { nullable: true }) model?: any, /** 새 시뮬레이션은 재기동 때 저널을 이어야 한다. 엔진에는 이미 필수지만 이 API가 누락해 * zero-to-twin 경로가 기동 단계에서 막혔다. */ @Arg('restartPolicy', { nullable: true }) restartPolicy?: 'resync' | 'resume' | 'reset' ): Promise { const domainId = context.state.domain.id // 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나. if (model) { /* model 를 함께 주는 것은 프로비저닝이다 — 그러면 종류도 말해야 한다. 예전에는 `kind ?? 'wms'` 로 메웠는데, 종류를 빠뜨린 야드/생산 트윈이 **창고로 알리지 않고 기동**했다. */ if (!kind) throw new Error('kind is required when model is given — it decides which domain kernel runs this twin') TwinEngine.start(instanceId, domainId, kind, await TwinEngine.withSpaceTimeBase(model as TwinModelDef, domainId), restartPolicy) } else await TwinEngine.startFromRegistry(domainId, instanceId) return true } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Stop a running twin runtime instance by its id (only if it belongs to the current domain).' }) async stopTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { if (!TwinEngine.owns(context.state.domain.id, instanceId)) return false // 테넌트 격리 — 남의 도메인 트윈 정지 차단 await TwinEngine.stop(context.state.domain.id, instanceId) return true } /** * 이 트윈이 **자기 과거를 어떻게 대할 것인가**를 다시 선언한다 (2026-08-20). * * ── 왜 이 문이 필요한가 ──────────────────────────────────────────────────── * `restartPolicy` 는 사람이 하는 선언인데, 만들 때 한 번 정하면 **고칠 길이 없었다.** 그래서 「씨앗 * 재현용」으로 세운 트윈이 나중에 사람들이 이력을 보는 트윈이 되어도 선언은 `reset` 으로 남았다. * 선언과 쓰임이 어긋난 채로 두면, 선언대로 구현하는 순간(저널 초기화) 이력을 잃는다. * * ── 지금 도는 것에는 손대지 않는다 ───────────────────────────────────────── * 이 값은 **다음 기동**의 규칙이다. 돌고 있는 런타임의 구동 방식을 바꾸려면 정지·기동이 필요하고, * 그 판단은 사람의 것이다(도는 트윈을 알리지 않고 갈아 끼우면 그 사이의 사실이 어디로 갔는지 알 수 없다). * 그래서 결과에 「지금 도는 것과 다르다」는 사실을 함께 낸다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: "Re-declare how a twin treats its own past on the next boot: 'resync' (re-read from the source), 'resume' (carry its own journal forward) or 'reset' (reproduce from the seed, journal cleared). The value is read strictly — an unknown or legacy word is refused with the accepted words named, because a silent default would decide whether a twin's history survives. This changes the declaration only: a running runtime keeps the mode it was started with, and the result says so (appliesAtNextStart) rather than swapping it underneath." }) async setTwinRestartPolicy( @Arg('instanceId') instanceId: string, @Arg('restartPolicy') restartPolicy: string, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain.id /* 값은 **엄격히** 읽는다 — 모르는 말을 기본값으로 메우면 그 트윈의 이력이 남을지가 알리지 않고 결정된다. */ const policy = readRestartPolicy(restartPolicy, `twin "${instanceId}"`) const repo = getRepository(TwinInstance) const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } }) if (!row) throw new Error(`unknown twin "${instanceId}" in this domain — nothing was changed`) const before = row.restartPolicy if (before === policy) return { instanceId, restartPolicy: policy, changed: false, appliesAtNextStart: false } await repo.update({ id: row.id }, { restartPolicy: policy }) /* 도는 런타임의 구동 방식은 그대로다 — 그 사실을 숨기지 않는다. */ const running = !!TwinEngine.metrics(domainId, instanceId) return { instanceId, restartPolicy: policy, previous: before, changed: true, appliesAtNextStart: running } } /** * 트윈의 **이름을 다시 붙인다** (2026-08-26). * * ── 왜 이 문이 필요한가 ──────────────────────────────────────────────────── * 이름은 트윈을 처음 세울 때 한 번 정해지고(`provision`), 다시 읽기는 그것을 건드리지 않는다. 그 * 자체는 안전하다 — 사람이 붙인 이름을 연결이 덮어쓰면 안 된다. 그런데 그래서 **첫날의 이름에 * 갇힌다.** * * 실제로 그런 일이 났다: 태양광 발전소를 붙일 때 커넥터가 아는 이름이 연결된 시스템의 코드 * (`ES202307202202`)뿐이었다. 뒤에 사람이 부르는 이름(`호현에너지`)이 어디 있는지 알아내 커넥터를 * 고쳤는데, 트윈 이름은 그대로였다. **연결된 시스템을 따라가는 트윈에서는 이름이 나중에 좋아진다** — * 커넥터가 나아질 때마다 이 일이 난다. * * 그때 남은 유일한 길이 트윈을 지우고 다시 세우는 것이었다. 그러면 지난 기록이 사라진다 — **이름 * 하나 때문에 사실을 버리는 선택**이 된다. 그래서 이름만 바꾸는 문을 낸다. * * ── 연결이 따라오게 하지 않는다 ──────────────────────────────────────────── * 다시 읽기가 이름을 따라가게 하면 사람이 붙인 이름을 덮어쓴다. 「사람이 손대지 않았으면 따라간다」가 * 옳아 보이지만, 그러려면 「손댔다」는 표시를 새로 만들어야 하고 그것은 이름 하나 때문에 지기에 무거운 * 빚이다. 사람이 바꾸는 길만 낸다. * * ── 빈 이름은 받지 않는다 ────────────────────────────────────────────────── * 이름을 지우는 것과 바꾸는 것은 다른 일이다. 빈 값을 받으면 그 트윈은 목록에서 식별자만 남는데, * 그것을 「이름을 바꿨다」로 보고하면 거짓이다. 지우고 싶으면 그 뜻의 문이 따로 있어야 한다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Rename a twin instance. The name is set when the twin is first provisioned and a re-read never touches it, ' + 'so a twin stays with the name its connector knew on day one — often a code from the connected system. ' + 'This changes the name only; nothing about the twin data, its journal or its runtime is affected. ' + 'An empty name is refused: clearing a name and changing it are different acts.' }) async renameTwinInstance( @Arg('instanceId') instanceId: string, @Arg('name') name: string, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain.id const next = String(name ?? '').trim() if (!next) throw new Error(`a twin name cannot be empty — "${instanceId}" was left as it was`) const repo = getRepository(TwinInstance) const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } }) if (!row) throw new Error(`unknown twin "${instanceId}" in this domain — nothing was changed`) const before = row.name ?? '' if (before === next) return { instanceId, name: next, changed: false } await repo.update({ id: row.id }, { name: next, updater: context.state.user }) /* 도는 런타임은 이름을 쓰지 않는다(식별자로 돈다) — 그래서 정지·기동이 필요하지 않다. */ return { instanceId, name: next, previous: before || null, changed: true } } /** * 폐기 전에 사라질 것을 읽는다. * * 삭제는 레지스트리 행 하나만 없애는 일이 아니다. `TwinEngine.remove` 는 같은 트윈의 저널도 * purge 한다. 그래서 확인 창이 추측한 숫자를 쓰지 않고, **삭제와 같은 domain·instance 범위**를 * 직접 센다. 저널이 비어 있는 트윈도 `0` 과 null 기간을 돌려준다 — 없는 기록을 기간으로 * 꾸며내면 "아무것도 안 지워진다"와 "언제부터인지 모른다"가 섞인다. */ @Query(returns => ScalarObject, { nullable: true, description: 'Describe what deleting one twin would purge before it is deleted: its registry identity and the count plus first/last event time of its journal. Null means this twin is not in the current domain.' }) async twinDeletionPreview(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const instance = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!instance) return null const journal = await getRepository(TwinEvent) .createQueryBuilder('event') .select('COUNT(event.id)', 'count') .addSelect('MIN(event.eventTime)', 'firstEventAt') .addSelect('MAX(event.eventTime)', 'lastEventAt') .where('event.domain = :domainId', { domainId }) .andWhere('event.instanceId = :instanceId', { instanceId }) .getRawOne<{ count?: string | number; firstEventAt?: unknown; lastEventAt?: unknown }>() return { instanceId, name: instance.name ?? null, journalCount: Number(journal?.count ?? 0), firstEventAt: journal?.firstEventAt ?? null, lastEventAt: journal?.lastEventAt ?? null } } /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Delete a twin instance: stop it, remove the registry entry and purge its event journal.' }) async deleteTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { await TwinEngine.remove(context.state.domain.id, instanceId) return true } /** 관리 목록 — 등록된 인스턴스 전체(kind/status/running/revision/노드·무버 수). 데이터 없으면 빈 배열. */ /** * **이 공간의 트윈 id 들** — 한 현실을 여러 렌즈가 비추므로 공간을 물으면 그 렌즈 전부를 답한다. * * 화면이 전체 목록을 받아 걸러 쓰던 규칙을 서버로 모은다. 예측 화면은 같은 확장을 한 파일에서 * **두 번** 복제하고 있었고, 규칙이 흩어지면 한쪽만 고쳐진다. 떠 있지 않은 것도 포함한다 — * 공간에 무엇이 있는지는 기동 여부와 다른 사실이다. */ @Query(returns => [String], { description: 'Ids of the twins that live in a space (all its lenses), running or not.' }) async twinSpaceInstanceIds(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise { return TwinEngine.instanceIdsOfSpace(context.state.domain.id, spaceId) } @Query(returns => [ScalarObject], { description: 'List provisioned twin instances in the current domain with management metadata. Each row also carries `sync`: a one-line answer to "is this twin keeping in step with the floor?" — a verdict (syncing | mapping-blocked | no-traffic | not-connected | not-applicable | unknown), the accepted share of the last closed 10-minute window, and up to 36 closed windows of offered/rejected counts for a sparkline. Reasons and rejected-record samples are deliberately absent here; ask twinIngestHealth for one twin when you need them, because carrying raw records for every row would make this list heavy for no gain. acceptedRatio is null while the first window is still filling — a partially filled window under-counts, and drawing it as zero would read as "nothing passed".' }) async twinInstanceList(@Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const list = await TwinEngine.list(domainId) /* * 동기화 요약을 **같은 응답에 얹는다.** 따로 질의하면 트윈 수만큼 왕복이 생기고(N+1), 그 사이 화면은 * 판정 없는 목록을 먼저 그린다 — 사람이 그 순간을 「정상」으로 읽는다. */ const brief = new Map( TwinEngine.ingestHealthBrief( domainId, list.map((x: any) => x.instanceId) ).map(b => [b.instanceId, b]) ) /* * **쓰기 사실도 같은 응답에 얹는다** (2026-08-20). * * 「이 트윈이 사실을 적고 있나」는 운영자가 트윈 화면에서 묻는 질문인데, 그 답은 벤치(슈퍼유저)의 * 부하 계기판에만 있었다. 유입(`sync`)과 나란히 두는 이유는 둘이 한 사슬이기 때문이다 — 받은 것이 * 있어도 적히지 않으면 저널이 비고, 그러면 성과·시간여행이 함께 빈다. * * 계기는 메모리라 왕복이 없다. **도는 트윈만** 답이 있고, 멈춘 트윈은 `null` 이다(0 이 아니다 — * 「쓰지 않았다」와 「돌지 않는다」는 다른 사실이다). */ return list.map((x: any) => { const m = TwinEngine.metrics(domainId, x.instanceId) return { ...x, sync: brief.get(x.instanceId) ?? null, write: m ? { rate: m.journalRate, journaled: m.journaledTotal, backlog: m.backlog } : null } }) } /** 단건 상세 — 프로비저닝 에디터용 model(구조+layout) 포함. */ @Query(returns => ScalarObject, { nullable: true, description: 'Get one provisioned twin instance with its TwinModelDef (structure + layout) for editing.' }) async twinInstanceDetail(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { return TwinEngine.detail(context.state.domain.id, instanceId) } /** * 라이브(메모리) 인스턴스 id 목록 — 콘솔 셀렉터·포트폴리오용. **현재 도메인만**(테넌트 격리). * 과거엔 Object.keys(TwinEngine.instances) 로 전역 반환 → 다른 도메인 트윈이 종합현황/셀렉터에 새던 버그. * 도메인 스코프 목록(twinInstanceList 와 동일 소스)에서 running 만 추린다. */ @Query(returns => [String], { description: 'List ids of running (in-memory) twin runtime instances in the current domain (tenant-isolated).' }) async twinInstances(@Ctx() context: ResolverContext): Promise { const list = await TwinEngine.list(context.state.domain.id) return list.filter((x: any) => x.running).map((x: any) => x.instanceId) } /** * 도메인 어휘 카탈로그 — 커널(레퍼런스) SSOT 를 그대로 노출. { wms:{label,locationTypes[]}, yms:…, mes:… }. * 프로비저닝 UI 는 이걸 소싱해 노드 타입/시스템을 채운다(앱 내 하드코딩 금지). */ @Query(returns => ScalarObject, { description: 'Domain vocabulary catalog (per-system location location types) sourced from the twin kernel — SSOT for provisioning UI.' }) twinDomainCatalog(): any { return domainCatalog() } }