/* * 규칙을 만들고 끄는 자리, 그리고 **지금 한 번 돌려 보는 자리**. * * ── 왜 「지금 돌린다」가 있나 ──────────────────────────────────────────── * 규칙을 만든 사람이 가장 먼저 묻는 것은 「이게 걸리나」다. 주기를 기다려 보라고 하면 그 사이에 무엇이 * 잘못됐는지 알 수 없고, 조건이 그때까지 살아 있을지도 모른다. 돌려 보면 **무엇을 냈고 무엇을 왜 안 * 냈는지**가 그 자리에서 답으로 온다. */ import { Arg, Args, Ctx, Directive, Mutation, Query, Resolver } from 'type-graphql' import { CMD, MES_COMMANDS } from '@operato/ops-contract' import { ListParam, ScalarObject, getQueryBuilderFromListParams, getRepository } from '@things-factory/shell' import { TwinEngine } from '../../engine/twin-engine.js' import { TwinActuationRule } from './actuation-rule.js' import { commandTypeGap } from './rule-evaluate.js' import { runRulesOnce } from './rule-runner.js' import { databaseCommandStore } from './command-store.js' import { rulesOf, noteRun, attentionsOf, modeOf } from './rule-wiring.js' @Resolver() export class TwinActuationRuleResolver { @Query(returns => ScalarObject, { nullable: true, description: 'List actuation rules with the standard list contract.' }) async twinActuationRules( @Args(type => ListParam) params: ListParam, @Ctx() context: ResolverContext ): Promise { const domain = context.state.domain if (!domain?.id) return { items: [], total: 0 } const qb = getQueryBuilderFromListParams({ repository: getRepository(TwinActuationRule), params, domain, alias: 'r', searchables: ['instanceId', 'name', 'attentionKind', 'commandType', 'lastSkipReason'], sortables: ['createdAt', 'updatedAt', 'instanceId', 'attentionKind', 'commandType', 'lastRunAt'], defaultLimit: 50, maxLimit: 200 }) const [items, total] = await qb.getManyAndCount() return { items, total } } /** * 규칙을 세우거나 고친다. * * **낼 수 없는 종류는 저장하지 않는다.** 저장은 되는데 안 도는 규칙이 생기면 사람은 규칙이 걸린 줄로 * 읽는다. 판정과 같은 검사를 지난다(§`commandTypeGap`) — 두 벌이면 어긋난다. */ @Directive('@transaction') @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Create or update one actuation rule.' }) async saveTwinActuationRule( @Arg('patch', type => ScalarObject) patch: any, @Ctx() context: ResolverContext ): Promise { const { domain, user, tx } = context.state const instanceId = String(patch?.instanceId ?? '').trim() if (!instanceId) throw new Error('어느 트윈의 규칙인지 말해야 합니다 — 전역 규칙은 두지 않습니다') const attentionKind = String(patch?.attentionKind ?? '').trim() if (!attentionKind) throw new Error('어느 관측에 걸 것인지 말해야 합니다') const commandType = String(patch?.commandType ?? '').trim() const gap = commandTypeGap(commandType, MES_COMMANDS, Object.values(CMD) as string[]) if (gap) throw new Error(gap) const repo = getRepository(TwinActuationRule, tx) const saved = await repo.save({ ...(patch.id ? { id: patch.id } : {}), domain, instanceId, name: patch.name ?? null, attentionKind, minSeverity: patch.minSeverity ?? null, minHeldMs: patch.minHeldMs ?? null, /* 비워 두면 기본값(한 시간)이 걸린다. 0 은 「기다리지 않는다」는 뜻이라 그대로 존중한다. */ cooldownMs: patch.cooldownMs ?? null, commandType, payloadTemplate: patch.payloadTemplate ?? null, /* 비어 있음을 「끔」으로 읽지 않는다 — 새로 만든 규칙은 켜져 있는 것이 사람의 뜻이다. */ enabled: patch.enabled === false ? false : true, ...(patch.id ? {} : { creator: user }), updater: user } as any) return saved } @Directive('@transaction') @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Delete one actuation rule.' }) async deleteTwinActuationRule(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { const { domain, tx } = context.state const result = await getRepository(TwinActuationRule, tx).delete({ id, domain: { id: domain.id } } as any) if (!result.affected) throw new Error(`모르는 규칙입니다 (${id})`) return { deleted: id } } /** * **지금 한 번 돌린다** — 무엇을 냈고 무엇을 왜 안 냈는지 그대로 답한다. * * 낸 조치는 제안 상태로 남는다. 결재선에 올리는 것은 사람이 따로 한다(§`raiseTwinCommand`) — 규칙이 * 올리지 않는 이유가 여기에도 그대로 적용된다(결재선은 올린 사람 기준으로 풀린다). */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Evaluate actuation rules once now and report what happened.' }) async runTwinActuationRules( @Arg('instanceId', { nullable: true }) instanceId: string, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain?.id if (!domainId) throw new Error('테넌트를 알 수 없습니다') /* * 트랜잭션을 넘기지 않는다 — 이 자리는 여러 트윈을 돌고, 한 트윈이 실패해도 앞의 트윈이 낸 것은 * 남아야 한다. 한 덩어리로 묶으면 마지막 트윈의 실패가 앞의 결과를 전부 물린다. */ const store = databaseCommandStore() const owned = TwinEngine.ownedTwins(domainId).filter(t => !instanceId || t.instanceId === instanceId) if (instanceId && !owned.length) { throw new Error(`이 서버가 들고 있지 않은 트윈입니다 (${instanceId}) — 멈춰 있거나 다른 서버가 돌고 있습니다`) } const reports = await runRulesOnce({ ownedTwins: () => owned, rulesOf: twin => rulesOf(twin), attentionsOf, modeOf, store, specs: MES_COMMANDS, nowMs: () => Date.now(), noteRun }) return { reports, proposed: reports.reduce((n, r) => n + r.proposed, 0) } } }