관리 daemon의 실행 commit SHA를 NPC 감사 결정에 기록

This commit is contained in:
2026-09-16 08:29:55 +00:00
parent b470e32e81
commit eb680121c3
14 changed files with 104 additions and 11 deletions
@@ -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] }] } },
});
@@ -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;
};
+2
View File
@@ -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,
@@ -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<GeneralTurnHandler> => {
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,
+2
View File
@@ -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,
@@ -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();
});
});
@@ -73,6 +73,7 @@ export type TurnTestHarnessOptions = {
};
worldRef?: { current: InMemoryTurnWorld | null };
onDecisionTrace?: Parameters<typeof createReservedTurnHandler>[0]['onDecisionTrace'];
auditCodeVersion?: string;
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[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,
@@ -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);
@@ -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' },
@@ -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',
@@ -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'),