From eb680121c3f0317f7ca66262ff7362f8b128fd49 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 08:29:55 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=20daemon=EC=9D=98=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20commit=20SHA=EB=A5=BC=20NPC=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EA=B2=B0=EC=A0=95=EC=97=90=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++ .../securityTransport.integration.test.ts | 8 ++-- app/game-engine/src/playAudit/decision.ts | 6 +++ app/game-engine/src/turn/cli.ts | 2 + .../src/turn/reservedTurnHandler.ts | 5 ++- app/game-engine/src/turn/turnDaemon.ts | 2 + app/game-engine/test/auditCodeVersion.test.ts | 45 +++++++++++++++++++ .../test/helpers/turnTestHarness.ts | 2 + .../test/npcNationWarDeclaration.test.ts | 2 + ...ditDecisionPersistence.integration.test.ts | 14 +++++- .../src/orchestrator/gatewayOrchestrator.ts | 2 + app/gateway-api/test/orchestratorPlan.test.ts | 12 +++++ docs/design/play-audit-implementation.md | 8 ++-- docs/play-audit-operations.md | 3 +- 14 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 app/game-engine/test/auditCodeVersion.test.ts diff --git a/.env.example b/.env.example index 4c3d4706..44d5b700 100644 --- a/.env.example +++ b/.env.example @@ -99,3 +99,7 @@ VITE_BOARD_TIP_URL= VITE_BOARD_PATCH_URL= VITE_OFFICIAL_CHAT_URL= VITE_CASUAL_CHAT_URL= + +# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다. +# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다. +TURN_BUILD_COMMIT_SHA= diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 44cbc4c8..9eb1a332 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2274,7 +2274,7 @@ integration('game API security over HTTP transport', () => { month: 1, tick: 4_320_000_000n, stepCount: 129, - summary: decisionSummary, + summary: { ...decisionSummary, codeVersion: index ? null : 'a'.repeat(40) }, hash: id, })), }); @@ -2309,7 +2309,7 @@ integration('game API security over HTTP transport', () => { result: { data: { coverage: 'PROCEDURES_ONLY', - items: [{ id: decisionIds[0], tick: '4320000000' }], + items: [{ id: decisionIds[0], tick: '4320000000', summary: { codeVersion: 'a'.repeat(40) } }], nextCursor: { tick: '4320000000', id: decisionIds[0] }, }, }, @@ -2323,7 +2323,9 @@ integration('game API security over HTTP transport', () => { cursor: { tick: '4320000000', id: decisionIds[0] }, }) ).body - ).toMatchObject({ result: { data: { items: [{ id: decisionIds[1] }], nextCursor: null } } }); + ).toMatchObject({ + result: { data: { items: [{ id: decisionIds[1], summary: { codeVersion: null } }], nextCursor: null } }, + }); expect((await get('decisionHistory', admin, { ...decisionInput, phase: 'nation' })).body).toMatchObject({ result: { data: { items: [{ id: decisionIds[1] }] } }, }); diff --git a/app/game-engine/src/playAudit/decision.ts b/app/game-engine/src/playAudit/decision.ts index 956a44d0..d0d647d7 100644 --- a/app/game-engine/src/playAudit/decision.ts +++ b/app/game-engine/src/playAudit/decision.ts @@ -32,3 +32,9 @@ export interface PendingAuditDecision { export const auditDecisionIdentity = (serverId: string, generalId: number, tick: number, revision: number) => auditPolicyHash([serverId, generalId, tick, revision]); + +// 확정된 전체 SHA만 받는다. branch/tag/축약 SHA나 미상 값을 현재 checkout에서 추정하지 않는다. +export const normalizeAuditCodeVersion = (value: string | undefined): string | undefined => { + const sha = value?.trim().toLowerCase(); + return sha && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha) && !/^0+$/.test(sha) ? sha : undefined; +}; diff --git a/app/game-engine/src/turn/cli.ts b/app/game-engine/src/turn/cli.ts index 7f2f400c..4f273d36 100644 --- a/app/game-engine/src/turn/cli.ts +++ b/app/game-engine/src/turn/cli.ts @@ -12,6 +12,7 @@ import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemon export interface TurnDaemonCliOptions { profile?: string; profileName?: string; + auditCodeVersion?: string; scenario?: string; databaseUrl?: string; gatewayDatabaseUrl?: string; @@ -84,6 +85,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom createTurnDaemonRuntime({ profile, profileName, + auditCodeVersion: options.auditCodeVersion ?? env.TURN_BUILD_COMMIT_SHA, databaseUrl, gatewayDatabaseUrl, defaultBudget: budget, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 253b1c47..89508f8b 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1,4 +1,4 @@ -import { auditDecisionIdentity, type PendingAuditDecision } from '../playAudit/decision.js'; +import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js'; import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js'; import type { AiDecisionTraceEvent } from './ai/generalAi/trace.js'; import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js'; @@ -934,6 +934,7 @@ export const createReservedTurnHandler = async (options: { actionDurationNs: bigint; }) => void; }): Promise => { + const auditCodeVersion = normalizeAuditCodeVersion(options.auditCodeVersion); const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const)); @@ -1143,7 +1144,7 @@ export const createReservedTurnHandler = async (options: { schemaVersion: 1, coverage: 'PROCEDURES', clockRevision: revision, - codeVersion: options.auditCodeVersion ?? null, + codeVersion: auditCodeVersion ?? null, policyRefs: decisionPolicyRefs.get(phase) ?? {}, requestedAction: first.reservedAction, selectedAction: last.action, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 26d21070..e3d1933f 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -106,6 +106,7 @@ import { applyRuntimeGameSettings } from './runtimeGameSettings.js'; export interface TurnDaemonRuntimeOptions { profile: string; profileName?: string; + auditCodeVersion?: string; databaseUrl: string; gatewayDatabaseUrl?: string; defaultBudget?: TurnRunBudget; @@ -779,6 +780,7 @@ const createTurnDaemonRuntimeWithLease = async ( options.generalTurnHandler ?? (await createReservedTurnHandler({ reservedTurns: reservedTurnStoreHandle!.store, + auditCodeVersion: options.auditCodeVersion, scenarioConfig: snapshot.scenarioConfig, scenarioMeta: snapshot.scenarioMeta, map: snapshot.map, diff --git a/app/game-engine/test/auditCodeVersion.test.ts b/app/game-engine/test/auditCodeVersion.test.ts new file mode 100644 index 00000000..a75bb90c --- /dev/null +++ b/app/game-engine/test/auditCodeVersion.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { normalizeAuditCodeVersion } from '../src/playAudit/decision.js'; +const runtimeFactory = vi.hoisted(() => vi.fn()); +vi.mock('../src/turn/turnDaemon.js', () => ({ createTurnDaemonRuntime: runtimeFactory })); +vi.mock('../src/turn/turnDaemonMemoryReporter.js', () => ({ + createTurnDaemonMemoryReporter: () => ({ report: vi.fn(), stop: vi.fn() }), +})); +import { runTurnDaemonCli } from '../src/turn/cli.js'; + +afterEach(() => { + vi.restoreAllMocks(); + runtimeFactory.mockReset(); +}); +describe('audit runtime build identity', () => { + it.each([undefined, '', 'main', 'abcd1234', '0'.repeat(40), 'a'.repeat(41), 'g'.repeat(40)])( + 'leaves unconfirmed SHA %s unknown', + (value) => { + expect(normalizeAuditCodeVersion(value)).toBeUndefined(); + } + ); + it.each([40, 64])('normalizes an exact %s digit SHA', (length) => { + expect(normalizeAuditCodeVersion(` ${'A'.repeat(length)} `)).toBe('a'.repeat(length)); + }); + it.each([undefined, 'a'.repeat(40)])('passes the startup build identity to the runtime', async (sha) => { + vi.spyOn(process, 'on').mockReturnValue(process); + vi.spyOn(console, 'info').mockImplementation(() => {}); + const close = vi.fn(); + runtimeFactory.mockResolvedValue({ + lifecycle: { start: vi.fn(), stop: vi.fn() }, + close, + }); + await runTurnDaemonCli({ + profile: 'che', + profileName: 'che:fixture', + tickMinutes: 10, + databaseUrl: 'postgresql://fixture/game', + gatewayDatabaseUrl: 'postgresql://fixture/gateway', + env: sha ? { TURN_BUILD_COMMIT_SHA: sha } : {}, + }); + expect(runtimeFactory).toHaveBeenCalledWith( + expect.objectContaining({ auditCodeVersion: sha, profileName: 'che:fixture' }) + ); + expect(close).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index 0539e066..0c48d26b 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -73,6 +73,7 @@ export type TurnTestHarnessOptions = { }; worldRef?: { current: InMemoryTurnWorld | null }; onDecisionTrace?: Parameters[0]['onDecisionTrace']; + auditCodeVersion?: string; onActionResolved?: Parameters[0]['onActionResolved']; onActionProfiled?: Parameters[0]['onActionProfiled']; commandRngFactory?: Parameters[0]['commandRngFactory']; @@ -115,6 +116,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => unitSet: options.snapshot.unitSet, getWorld: () => worldRef.current, onDecisionTrace: options.onDecisionTrace, + auditCodeVersion: options.auditCodeVersion, onActionResolved: options.onActionResolved, onActionProfiled: options.onActionProfiled, commandRngFactory: options.commandRngFactory, diff --git a/app/game-engine/test/npcNationWarDeclaration.test.ts b/app/game-engine/test/npcNationWarDeclaration.test.ts index 96bf8e80..3984e640 100644 --- a/app/game-engine/test/npcNationWarDeclaration.test.ts +++ b/app/game-engine/test/npcNationWarDeclaration.test.ts @@ -250,6 +250,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { const decisionTrace: AiDecisionTraceEvent[] = []; const savedDecisions: PendingAuditDecision[] = []; const { runUntil } = await createTurnTestHarness({ + auditCodeVersion: 'a'.repeat(40), onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined, wrapGeneralTurnHandler: (handler) => ({ execute: (context) => { const result = handler.execute(context); @@ -447,6 +448,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { expect(dispatchCount).toBeGreaterThan(0); if (auditEnabled) { expect(savedDecisions.length).toBeGreaterThan(0); + expect(savedDecisions.every((row) => row.summary.codeVersion === 'a'.repeat(40))).toBe(true); expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length); expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true); expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true); diff --git a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts index 46fd6bd8..086f8671 100644 --- a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts +++ b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts @@ -36,6 +36,7 @@ integration('decision persistence and bounded retention', () => { }); it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => { const decision = draft('decision-one'); + decision.summary.codeVersion = 'a'.repeat(40); await db.$executeRawUnsafe( `CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$` ); @@ -61,7 +62,18 @@ integration('decision persistence and bounded retention', () => { }); await db.$transaction((tx) => persistAuditDecisions(tx, [decision])); const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } }); - expect(header).toMatchObject({ tick: 4_320_000_000n, stepCount: 302 }); + expect(header).toMatchObject({ + tick: 4_320_000_000n, + stepCount: 302, + summary: { codeVersion: 'a'.repeat(40) }, + }); + await expect( + db.$transaction((tx) => + persistAuditDecisions(tx, [ + { ...decision, summary: { ...decision.summary, codeVersion: 'b'.repeat(40) } }, + ]) + ) + ).rejects.toThrow('replay conflict'); const chunks = await db.playAuditDecisionChunk.findMany({ where: { decisionId: decision.id }, orderBy: { ordinal: 'asc' }, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 09eb0af2..96ae60e6 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -600,6 +600,8 @@ export const buildProcessDefinitions = ( ...(turnDaemonNodeOptions ? { NODE_OPTIONS: turnDaemonNodeOptions } : {}), POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'TURN_DAEMON_POSTGRES_POOL_MAX', 2), GAME_ENGINE_ROLE: 'turn-daemon', + // 실행 worktree를 결정한 동일 프로필의 SHA를 전달한다. 부모 환경의 다른 버전은 상속하지 않는다. + TURN_BUILD_COMMIT_SHA: profile.buildCommitSha ?? '', TURN_PROFILE: profile.profile, PROFILE: profile.profile, SCENARIO: profile.currentScenario ?? 'default', diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index dcd79c4e..417ab7a7 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -184,6 +184,17 @@ describe('buildProcessDefinitions', () => { gatewayInternalApiUrl: 'http://127.0.0.1:13000', }; + it('does not inherit a different profile build SHA', () => { + const config = { ...processConfig, baseEnv: { TURN_BUILD_COMMIT_SHA: 'f'.repeat(40) } }; + expect(buildProcessDefinitions(buildProfile(), config).daemon.env.TURN_BUILD_COMMIT_SHA).toBe( + buildProfile().buildCommitSha + ); + expect( + buildProcessDefinitions({ ...buildProfile(), buildCommitSha: undefined }, config).daemon.env + .TURN_BUILD_COMMIT_SHA + ).toBe(''); + }); + it('runs a built profile from its commit worktree', () => { const buildWorkspace = '/srv/sammo/worktrees/0123456789abcdef'; const definitions = buildProcessDefinitions(buildProfile(buildWorkspace), processConfig); @@ -217,6 +228,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); expect(definitions.daemon.env.POSTGRES_POOL_MAX).toBe('2'); + expect(definitions.daemon.env.TURN_BUILD_COMMIT_SHA).toBe(buildProfile().buildCommitSha); expect(definitions.auction).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index e4fa75c0..a5ada100 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -34,8 +34,10 @@ migration58은 기존 장수 인덱스를 `(server, general, year, month, tick, reservedTurnHandler가 기수 identity가 있는 새 AI 실행을 phase별로 모으고 실제 요청/선택/ 실행·성공/대체 결과를 함께 반환한다. 실행 tick은 해당 장수 기준, ID는 serverId·장수·tick· clock revision·phase로 결정한다. 정책 head 참조는 시작 시 확보하며 없는 값은 채우지 않는다. -현재 codeVersion 주입과 유효 정책 합성 상세, 내부 후보 조건은 남아 있어 coverage는 -`PROCEDURES`다. 수동 턴 중 AI를 사용하지 않은 경우 결정 행을 만들지 않는다. +Gateway의 프로필 buildCommitSha→daemon 환경 TURN_BUILD_COMMIT_SHA→CLI→runtime→handler로 +실행 코드 버전을 전달한다. 전체40/64자리 SHA만 인정하며 누락/잘못된 값은 null이다. +handler 생성 시 한 번 정규화하므로 턴마다 Git/DB를 읽지 않는다. 유효 정책 합성 상세와 +내부 후보 조건은 남아 있어 coverage는 `PROCEDURES`다. 수동 턴 중 AI를 사용하지 않은 경우 결정 행을 만들지 않는다. GeneralTurnResult→world pending→capture/restore/peek/ack→기존 fenced DB flush를 연결했다. 요약 hash와 실행/phase unique로 같은 재시도는 중복 없이 통과하고 다른 payload는 실패한다. @@ -63,7 +65,7 @@ GeneralAI와 예약 실행 handler에 선택적 `onDecisionTrace` 관측 경계 초기 관측 단계에서는 default daemon을 켜지 않았다. 이후 아래 저장 경계에서 현재 기수의 새 실행을 수집하도록 연결했다. 이는 R5의 관측 기반일 뿐 완료가 아니다. 불변 결정 ID·기존 정책 참조·실행 결과· pending/rollback·migration·정리·목록/상세 API와 GUI는 위 절에서 연결했다. -후보/조건별 실제 관측값, 합성 유효 정책과 code version 연결은 남는다. +후보/조건별 실제 관측값, 합성 유효 정책의 실제 관측은 남는다. 코드 버전 전달은 위 저장 절에 연결했다. ### 전달 전 DB tick 정밀도 보완 diff --git a/docs/play-audit-operations.md b/docs/play-audit-operations.md index 1f91faf9..e3d7a2bd 100644 --- a/docs/play-audit-operations.md +++ b/docs/play-audit-operations.md @@ -91,8 +91,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway - NPC/유저 자동턴의 개인·수뇌 절차, 정책 차단, RNG utility 결과와 최종 실행 결과는 migration 이후 새 실행부터 저장한다. 후보 내부 조건 전체는 아직 없으며 `PROCEDURES` coverage로 구분한다. 장수 상세의 **NPC 결정 기록 조회**에서 선택 월의 목록과 순서별 상세를 읽는다. 과거 결정은 역산하지 않는다. -- 결정은 당시 확보된 정책 참조를 보존하며 **당시 정책 참조**에서 해당 불변 버전을 바로 조회한다. 합성된 유효 정책 상세와 코드 버전 연결은 - 아직 미완성이다. 코드 버전이 주입되지 않은 실행은 null로 남기며 현재 버전으로 메우지 않는다. +- 결정은 당시 확보된 정책 참조를 보존하며 **당시 정책 참조**에서 해당 불변 버전을 바로 조회한다. 합성된 유효 정책 상세는 아직 미완성이다. Gateway 관리 daemon은 프로필의 buildCommitSha를 새 실행에 기록한다. 수동 daemon은 실행 산출물의 전체 SHA를 TURN_BUILD_COMMIT_SHA로 전달할 수 있다. 미지정/잘못된 SHA의 실행과 기존 null 기록은 현재 버전으로 메우지 않는다. - 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진 버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다. - 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.