import { Arg, Args, Ctx, Directive, Mutation, Query, Resolver } from 'type-graphql' import { MES_COMMANDS, nextCommandState } from '@operato/ops-contract' import { randomUUID } from 'node:crypto' import { ListParam, ScalarObject, getQueryBuilderFromListParams, getRepository } from '@things-factory/shell' import { askAdvisor } from './actuation-advisor.js' import { approvalGateway } from './approval-gateway.js' import { approve, dispatch, linkApproval, propose, reject } from './command-dispatcher.js' import { actuationReadinessOf, actuationTargetOf, routeActuation } from '../reference/actuation-routing.js' import { actuationOutcomeOf } from './actuation-outcome.js' import { databaseCommandStore } from './command-store.js' import { TwinCommandRecord } from './twin-command.js' import { twinNow } from './twin-now.js' /* * 승인 대기 줄과 그 결정 — **사람이 쓰는 자리**. * * ── 왜 필요한가 (2026-08-31) ───────────────────────────────────────────── * 게이트를 세우고 승인할 화면을 만들지 않았다. 그래서 되돌릴 수 없는 조치는 전부 대기 상태로 쌓이고 * **사람이 승인할 방법이 없었다.** 서버만 있고 사람이 쓸 자리가 없는 상태다. * * ── 왜 조회와 결정을 한 파일에 두나 ────────────────────────────────────── * 대기 줄을 여는 것과 그 줄에서 결정하는 것은 한 화면의 일이다. 나누면 화면이 두 곳을 부르게 되고, * 그중 하나만 권한이 바뀌는 날 목록은 보이는데 결정이 안 되거나 그 반대가 된다. */ @Resolver() export class TwinCommandResolver { /** * 커맨드 목록 — 기본은 **결정을 기다리는 것부터**. * * 정렬을 최근순으로 두지 않는다. 승인 대기 줄에서 사람이 찾는 것은 「새로 온 것」이 아니라 * **「오래 기다린 것」**이다 — 오래 기다린 조치가 뒤로 밀리면 그것이 가장 늦게 처리된다. */ @Query(returns => ScalarObject, { nullable: true, description: 'List actuation commands with the standard list contract. Defaults to the oldest waiting first.' }) async twinCommands( /* 표준 목록 계약은 `@Args` 로 펼쳐 받는다 — `@Arg` 로 받으면 스키마 조립이 실패한다. */ @Args(type => ListParam) params: ListParam, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain?.id if (!domainId) return { items: [], total: 0 } const effective: ListParam = { ...(params ?? ({} as ListParam)), sortings: params?.sortings?.length ? params.sortings : [{ name: 'createdAt', desc: false }] } const qb = getQueryBuilderFromListParams({ repository: getRepository(TwinCommandRecord), params: effective, domain: context.state.domain, alias: 'c', /* 전부 실컬럼이다 — 질의 빌더는 없는 컬럼의 LIKE 를 경고 후 무시한다(인덱스 없는 전체 스캔 방지). */ searchables: ['instanceId', 'type', 'origin', 'state', 'approvalRef', 'decidedBy', 'error'], sortables: ['createdAt', 'updatedAt', 'state', 'type', 'instanceId'], /* 사람이 읽는 속도로 본다. */ defaultLimit: 50, maxLimit: 200 }) const [items, total] = await qb.getManyAndCount() return { items, total } } /** * **이 조치에 대해 트윈이 지금 보는 것과 AI 의견** — 승인 화면이 부른다. * * ── 왜 조회인가 (2026-09-01) ─────────────────────────────────────────── * 결재선에 올릴 때 묻지 않는다. 그 자리는 트랜잭션 안이고 거기서 LLM 을 기다리면 DB 연결 하나를 몇 초 * 붙잡는다. 그리고 **승인하는 사람은 지금 판단한다** — 올린 시각의 의견은 낡았다. * * AI 는 판정하지 않는다. 이 값을 읽어 승인·거절을 막는 코드가 없다. 반대해도 승인되고 동의해도 * 거절된다 — 판정에 쓰면 AI 가 사실상 승인자가 되고, 그러면 사고 뒤에 책임을 물을 자리가 사라진다. */ @Query(returns => ScalarObject, { nullable: true, description: 'What the twin sees now for one command, plus an AI opinion when asked. Advisory only — never gates the decision.' }) async twinActuationOpinion( @Arg('id') id: string, /** * AI 에게도 물을 것인가 — **누르면 묻는다.** * * 기본은 안 묻는다. 화면을 열 때마다 부르면 승인자 다섯 명이 두 번씩 열 때 한 건에 열 번이고, * 대부분의 지시는 평범해서 그중 아홉 번은 같은 말을 다시 받는 것이다. */ @Arg('askAi', { nullable: true }) askAi: boolean, @Arg('locale', { nullable: true }) locale: string, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain?.id if (!domainId) throw new Error('테넌트를 알 수 없습니다') const command = await databaseCommandStore().load(domainId, id) if (!command) throw new Error(`모르는 조치입니다 (${id})`) /* 트윈의 지금 상태는 늘 새로 읽는다 — 계산값이라 싸고, 승인자는 지금을 봐야 한다. */ const now = twinNow(domainId, command.instanceId, command.causeRef) if (!askAi) return { command, twinNow: now } /* * **의견을 저장하지 않는다.** * * 의견은 「지금」에 대한 것이고 승인자마다 여는 시각이 다르다. 저장해서 같은 문장을 돌려주면 그것이 * 오히려 낡은 값이다. 그리고 이 값을 읽어 승인·거절을 막는 코드가 없다 — 판정에 쓰면 AI 가 사실상 * 승인자가 되고, 사고 뒤에 책임을 물을 자리가 사라진다. */ const advice = await askAdvisor({ domainId, command, twinNow: now, ...(locale ? { locale } : {}) }) return { command, twinNow: now, advice } } /** * 조치를 낸다 — **대기 줄에 무언가가 들어오는 유일한 자리.** * * 결재선이 등록된 배포에서는 여기서 바로 결재선에 올라간다(§`ApprovalGateway`). 등록되지 않았으면 * 제안 상태로 남고, 이 리졸버의 직접 승인이 유일한 문이 된다. 어느 쪽인지는 답에 적어 준다 — 화면이 * 「어디서 승인하는가」를 사람에게 말해야 한다. * * 되돌릴 수 있는 조치는 결재선에 올리지 않는다. 계약이 그 판단을 든다(§`requiresApproval`). */ @Directive('@transaction') @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Propose one actuation command against a twin. Raises it for approval when an approval gateway is installed.' }) async proposeTwinCommand( @Arg('instanceId') instanceId: string, @Arg('type') type: string, @Arg('payload', type => ScalarObject, { nullable: true }) payload: any, @Ctx() context: ResolverContext ): Promise { const { domain, user } = context.state const by = String(user?.email ?? user?.name ?? '').trim() if (!by) throw new Error('누가 내는지 알 수 없습니다 — 로그인한 사용자가 없습니다') const store = databaseCommandStore(context.state.tx) const command = await propose(store, domain.id, MES_COMMANDS, { id: randomUUID(), instanceId, type, /* * 사람이 낸 것이다. 규칙·AI 가 내는 길은 아직 없다 — 그 길은 그것을 내는 쪽이 붙인다. * 여기서 인자로 받으면 사람이 「규칙이 냈다」로 적어 승인자를 오판하게 만들 수 있다. */ origin: 'operator', /* 낸 사람을 적는다 — 이 값이 있어야 자기 것을 자기가 승인하는 것을 막을 수 있다. */ proposedBy: by, proposedAt: new Date().toISOString(), ...(payload ? { payload } : {}) }) if (command.reversible === true) { return { command, gateway: null, note: '되돌릴 수 있는 조치입니다 — 승인 없이 넘길 수 있습니다' } } const gateway = approvalGateway() if (!gateway) { return { command, gateway: null, note: '결재선이 설치되지 않은 배포입니다 — 이 목록에서 직접 승인합니다' } } /* * 올리기와 적기가 한 트랜잭션에 있다(`@transaction`). 올렸는데 적지 못하면 결재선에는 올라가 있고 * 커맨드는 직접 결정도 되는 상태가 남는다 — 그때 승인이 두 곳에 갈린다. */ const approvalRef = await gateway.request(command, context) const linked = await linkApproval(store, domain.id, command.id, approvalRef) return { command: linked, gateway: gateway.label, note: `${gateway.label} 에 올렸습니다 — 승인은 그곳에서 합니다` } } /** * **사람이 결재선에 올린다** — 규칙이 낸 조치가 승인 앞에 서는 유일한 길. * * ── 왜 규칙이 직접 올리지 않나 ───────────────────────────────────────── * 업무 목록의 결재선은 **올린 사람 기준**으로 풀린다 — `Myself` · `MyDepartment` · `MySupervisor` 가 * 전부 로그인한 사람을 본다. 배경 루프에는 「나」가 없으므로, 시스템 사용자를 지어내 올리면 그 셋이 * 시스템 사용자를 가리키고 결재선이 뜻을 잃는다. * * 그래서 규칙은 내는 것까지 하고, 여기서 사람이 올린다. **올린 사람이 「이것을 승인자들 앞에 놓았다」의 * 주체**가 되고, 그 사람 기준으로 결재선이 풀린다. * * 올린 사람은 자기가 올린 것을 승인할 수 있다 — 규칙이 낸 조치에는 낸 사람이 없으므로 승인자가 곧 * 두 번째 사람이다. 올린 것과 낸 것은 다르다. */ @Directive('@transaction') @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Raise one proposed command to the approval line. The signed-in user is the submitter.' }) async raiseTwinCommand(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { const { domain, user } = context.state if (!user?.id) throw new Error('누가 올리는지 알 수 없습니다 — 로그인한 사용자가 없습니다') const store = databaseCommandStore(context.state.tx) const command = await store.load(domain.id, id) if (!command) throw new Error(`모르는 조치입니다 (${id})`) if (command.approvalRef) { throw new Error(`이미 결재선에 올라가 있습니다 (${command.approvalRef}) — 승인은 업무 목록에서 합니다`) } if (command.state !== 'proposed') { throw new Error(`${command.state} 상태의 조치는 올릴 수 없습니다 — 이미 결정되었거나 나갔습니다`) } const gateway = approvalGateway() if (!gateway) { throw new Error('결재선이 설치되지 않은 배포입니다 — 이 목록에서 직접 승인합니다') } const approvalRef = await gateway.request(command, context) const linked = await linkApproval(store, domain.id, command.id, approvalRef) return { command: linked, gateway: gateway.label, note: `${gateway.label} 에 올렸습니다 — 승인은 그곳에서 합니다` } } /** * 승인한다 — **누가 승인했는지는 로그인한 사람에서 온다.** * * 인자로 받지 않는다. 받으면 다른 사람 이름으로 승인할 수 있고, 그러면 그 기록이 사고 뒤에 쓸 수 * 없어진다. 승인 기록의 값어치가 거기서 나온다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Approve one actuation command. The approver is the signed-in user.' }) async approveTwinCommand( @Arg('id') id: string, @Arg('note', { nullable: true }) note: string, @Ctx() context: ResolverContext ): Promise { const { domain, user } = context.state const by = String(user?.email ?? user?.name ?? '').trim() if (!by) throw new Error('누가 승인하는지 알 수 없습니다 — 로그인한 사용자가 없습니다') return approve(databaseCommandStore(context.state.tx), domain.id, id, { by, at: new Date().toISOString(), ...(note?.trim() ? { note: note.trim() } : {}) }) } /** * 거절한다 — 여기서 끝난다. * * 사유를 요구한다. 거절은 현장이 기다리던 조치를 취소하는 것이고, 이유 없이 거절되면 낸 쪽이 다시 * 낸다. 그러면 같은 것이 대기 줄에 계속 돌아온다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Reject one actuation command. A reason is required.' }) async rejectTwinCommand( @Arg('id') id: string, @Arg('reason') reason: string, @Ctx() context: ResolverContext ): Promise { const { domain, user } = context.state const by = String(user?.email ?? user?.name ?? '').trim() if (!by) throw new Error('누가 거절하는지 알 수 없습니다 — 로그인한 사용자가 없습니다') if (!reason?.trim()) throw new Error('거절 사유가 필요합니다 — 이유 없이 거절되면 같은 조치가 다시 올라옵니다') return reject(databaseCommandStore(context.state.tx), domain.id, id, { by, at: new Date().toISOString(), note: reason.trim() }) } /** * 어댑터에 넘긴다 — **승인을 지나지 않은 것은 여기서 막힌다.** * * ── 이름이 왜 `dispatchTwinCommand` 가 아닌가 (2026-09-01) ───────────── * 그 이름은 이미 **커널 자극**이 쓴다(`TwinControlMutation.dispatchTwinCommand` — * `order.hold`·`resource.repair` 를 커널에 넣는다). 같은 이름을 두 번 선언하면 type-graphql 이 * 오류 없이 하나만 남기고, 실제로 이 뮤테이션이 통째로 가려져 있었다 — 걸어 보고 알았다. * * 자극과 조치가 다른 어휘라는 것을 계약에서 갈라 놓고(§`propose` 의 거절), 정작 API 이름에서 겹쳐 * 두었다. 여기서는 조치임을 이름에 적는다. * * 화면이 이것을 따로 부르는 이유는, 승인과 넘김이 다른 순간이기 때문이다. 승인한 사람과 넘긴 시각이 * 같아야 할 이유가 없고, 어댑터가 죽어 있으면 승인은 남아 있어야 한다. * * ── 여기가 끊겨 있었다 (2026-09-02) ────────────────────────────────── * 이 자리가 **어댑터를 찾지도 않고** 「구현이 없습니다」를 답했다. MES 커넥터는 그때 이미 `actuate` * 를 다 만들어 두었다. 그 답을 사실로 읽어서 설계 문서에도, 다른 레인에도 그렇게 알렸다 — 둘 다 * 틀렸다. * * 이제 원본을 찾아 그 원본이 선언한 능력으로 판단한다(§`routeActuation`). 보내지 못하면 **무엇이 * 없어서인지** 말한다. 조용히 성공을 답하면 화면은 나간 줄로 읽고, 현장은 오지 않는 지시를 기다린다. * * ── 여기에는 트랜잭션을 걸지 않는다 ────────────────────────────────── * 넘기기 전에 「넘기는 중」을 먼저 적는 것이 이 함수의 요점이다. 한 트랜잭션에 묶으면 그 기록이 * 커밋 전까지 아무에게도 보이지 않고, 넘기는 사이에 프로세스가 죽으면 그대로 사라진다. 그러면 저쪽은 * 받았는데 우리는 안 보낸 줄 알고 다시 보낸다 — 현장이 두 번 움직인다. */ /** * **이 조치를 넘길 수 있나** — 누르기 전에 답한다. * * ── 왜 이 자리가 필요한가 (2026-09-03) ──────────────────────────────────── * 판정 함수 둘을 만들어 두고 **부르는 자리를 안 만들었다**(`actuationTargetOf`· * `actuationReadinessOf`). 그래서 화면이 물을 방법이 없었고, 못 넘기는 조치도 눌러 보고 알게 됐다. * 인티그레이션 레인이 그 사실을 두 번 짚어 줬다 — 자리는 있고 길이 없는 그 부류다. * * 그동안 값이 실제로 없어졌다. 저쪽에 비밀값이 없던 날, 사람이 승인 화면까지 가서 누르고 실패를 * 보고 다시 로그인했다. **승인은 살아 있지만 사람의 시간은 돌아오지 않는다.** * * ── 둘을 함께 답한다 ────────────────────────────────────────────────────── * 화면이 묻는 것은 하나다 — 「지금 넘겨도 되나」. 그 답이 두 겹이라 둘을 함께 싣는다. * * target 보낼 대상이 정해지나 — 망을 타지 않는다(원본·능력·사이트) * readiness 저쪽이 받을 준비가 됐나 — 커넥터가 망을 탈 수 있다 * * **문이 아니다.** 이 답으로 넘김을 막지 않는다(§`actuationReadinessOf`) — 화면이 미리 말하는 * 데까지다. 그래서 조회이고 뮤테이션이 아니다. * * 화면이 **목록을 그릴 때 부르지 말 것.** 커넥터가 저쪽에 물을 수 있어서 목록 한 줄마다 망을 탄다. * 사람이 「넘기기」를 열 때 한 번이다. */ /* * 권한 지시자를 달지 않는다 — 이 파일의 다른 조회 둘과 같다. **쓰기만 게이트를 지난다**(ADR-0027), * 그리고 조회는 테넌트로 좁혀진다. 처음에 `privilege: "query"` 를 달았더니 권한 문장 검사가 * 「정본과 다르다」로 잡았다. */ @Query(returns => ScalarObject, { nullable: true, description: 'Whether one approved actuation can be dispatched now — target and far-side readiness. Advisory, not a gate.' }) async twinActuationReadiness(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain?.id if (!domainId) throw new Error('테넌트를 알 수 없습니다') const command = await databaseCommandStore().load(domainId, id) if (!command) throw new Error(`모르는 조치입니다 (${id})`) /* * 상태를 함께 낸다 — **이 조치가 넘길 수 있는 상태인가**는 저쪽 사정과 다른 물음이다. 이미 * `acked` 인 것은 저쪽이 아무리 준비돼 있어도 다시 넘기지 않는다(종착). */ const dispatchable = nextCommandState(command.state, 'dispatch') !== undefined const [target, readiness] = await Promise.all([ actuationTargetOf(domainId, command.instanceId), actuationReadinessOf(domainId, command.instanceId) ]) return { id, state: command.state, dispatchable, target, readiness } } /* * 권한 지시자를 달지 않는다 — 이 파일의 다른 조회들과 같다(ADR-0027: 쓰기만 게이트를 지난다). */ @Query(returns => ScalarObject, { nullable: true, description: 'What happened to one actuation — for the person who just approved it. Includes whether work remains at the source.' }) async twinActuationOutcome(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain?.id if (!domainId) throw new Error('테넌트를 알 수 없습니다') const command = await databaseCommandStore().load(domainId, id) if (!command) throw new Error(`모르는 조치입니다 (${id})`) /* * **승인 직후에 부르는 자리다.** 발신은 승인 응답이 나간 다음 틱에 일어나므로(§ * `dispatchAfterApproval`), 그 순간에는 아직 `approved` 일 수 있다. 그것을 감추지 않는다 — * 화면이 「보내는 중」을 그리고 짧게 다시 묻는다(UX 레인 규칙: 대략 0.5초·2초 두 번, 그래도 * 안 끝나면 조치 목록으로 안내). * * 여기서 기다리지 않는 이유: 기다리면 이 조회가 저쪽 왕복만큼 멈추고, 사람이 화면을 떠난 * 뒤에도 붙잡는다. */ return actuationOutcomeOf(command, nextCommandState(command.state, 'dispatch') !== undefined) } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Send one approved actuation command to its connector. Not a kernel stimulus.' }) async dispatchTwinActuation(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { const { domain } = context.state /* 트랜잭션을 넘기지 않는다 — 위의 이유다. 지시자가 뒤에 붙어도 이 자리는 바뀌지 않아야 한다. */ return dispatch(databaseCommandStore(), domain.id, id, command => routeActuation(domain.id, command.instanceId, command) ) } }