merge: 최신 원격 main을 사령부 지도 복구에 통합

This commit is contained in:
2026-08-21 02:27:13 +00:00
19 changed files with 577 additions and 53 deletions
+7 -23
View File
@@ -13,6 +13,7 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
buildEquipmentTradeItemOptions,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions,
@@ -283,29 +284,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
});
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
for (const item of moduleBundle.itemModules) {
if (item.buyable) {
const cost = item.cost ?? 0;
const currentSecurity = city?.security ?? 0;
const availability =
currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: general.gold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
}
const items = buildEquipmentTradeItemOptions({
configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0,
generalGold: general.gold,
});
const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({
value: entry.id,
+45
View File
@@ -5,6 +5,8 @@ import {
type NationTurnCommandSpec,
} from '@sammo-ts/logic';
import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod';
import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -103,6 +105,49 @@ export interface TurnCommandInputOptions {
};
}
type EquipmentTradeItemModule = Pick<ItemModule, 'key' | 'slot' | 'name' | 'info' | 'cost' | 'reqSecu' | 'buyable'>;
const plainLegacyInfo = (value: string): string =>
value
.replace(/<br\s*\/?>/giu, ' · ')
.replace(/<[^>]+>/gu, '')
.replace(/\s+/gu, ' ')
.trim();
export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>;
itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number;
generalGold: number;
}): TurnCommandInputOptions['items'] => {
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
for (const item of options.itemModules) {
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
continue;
}
const cost = item.cost ?? 0;
const availability =
options.currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: options.generalGold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
return items;
};
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
export const TURN_COMMAND_NATION_COLORS = [
'#FF0000',
+49 -1
View File
@@ -6,7 +6,24 @@ import {
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
import {
buildEquipmentTradeItemOptions,
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
const buildShopItem = (key: string, name: string) => ({
key,
rawName: name,
name,
info: `${name}<br>설명`,
slot: 'item' as const,
cost: 100,
buyable: true,
consumable: false,
reqSecu: 3000,
unique: false,
});
describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => {
@@ -80,4 +97,35 @@ describe('turn command argument input', () => {
});
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 5000,
generalGold: 1000,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
});
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: {
item: {
che_치료_환약: 1,
event_전투특기_격노: 0,
},
},
},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 2000,
generalGold: 50,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
});
});
@@ -15,6 +15,7 @@ import {
loadActionModuleBundle,
} from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { createRuntimeTrace } from './runtimeTrace.js';
// legacy GameConstBase 기본값
@@ -146,6 +147,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
['maxResourceActionAmount'],
DEFAULT_MAX_RESOURCE_ACTION_AMOUNT
),
purchasableItemKeys: resolveLegacyPurchasableItemKeys(constValues),
};
};
@@ -4,6 +4,7 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
@@ -77,4 +78,18 @@ describe('tracked scenario resources', () => {
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2);
});
it('projects ordinary and explicit secret-item scenario pools into command execution', async () => {
const [ordinaryBlank, secretScenario] = await Promise.all(
[1, 2701].map((scenarioId) => loadScenarioDefinitionById(scenarioId))
);
const ordinaryKeys = buildCommandEnv(ordinaryBlank.config).purchasableItemKeys;
const secretScenarioKeys = buildCommandEnv(secretScenario.config).purchasableItemKeys;
expect(ordinaryKeys?.size).toBe(24);
expect(ordinaryKeys?.has('che_치료_환약')).toBe(true);
expect([...ordinaryKeys!].filter((key) => key.startsWith('event_전투특기_'))).toEqual([]);
expect([...secretScenarioKeys!].filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20);
expect(secretScenarioKeys?.has('event_전투특기_격노')).toBe(true);
});
});
+37 -1
View File
@@ -751,7 +751,13 @@ const chiefCenter = {
npcState: officerLevel === 8 ? 2 : 0,
turnTime: null,
revision: 0,
turns: turns(12),
turns:
officerLevel === 12
? [
{ index: 0, action: 'che_포상', args: { destGeneralId: 2, isGold: false, amount: 300 } },
...turns(11).map((turn) => ({ ...turn, index: turn.index + 1 })),
]
: turns(12),
})),
};
@@ -1519,6 +1525,36 @@ test('enters general and nation command arguments and sends exact values', async
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
});
test('shows full nation command briefs in every chief card', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
const desktopSummary = page.locator('.layout-desktop .chief-card').first().locator('.row-action').first();
await expect(desktopSummary).toHaveText('【관우】 쌀 300 포상');
await expect(desktopSummary).toHaveAttribute('title', '【관우】 쌀 300 포상');
await page.screenshot({ path: test.info().outputPath('chief-card-command-brief-desktop-1200.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 900 });
const mobileSummary = page.locator('.chief-overview .chief-card').first().locator('.row-action').first();
await expect(mobileSummary).toHaveText('【관우】 쌀 300 포상');
await expect(mobileSummary).toHaveAttribute('title', '【관우】 쌀 300 포상');
const geometry = await mobileSummary.evaluate((element) => {
const rect = element.getBoundingClientRect();
const card = element.closest<HTMLElement>('.chief-card');
if (!card) throw new Error('chief card is missing');
return {
width: rect.width,
cardWidth: card.getBoundingClientRect().width,
horizontalOverflow: card.scrollWidth - card.clientWidth,
};
});
expect(geometry.width).toBeLessThanOrEqual(geometry.cardWidth);
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
await page.screenshot({ path: test.info().outputPath('chief-card-command-brief-mobile-500.png'), fullPage: true });
});
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
page,
}, testInfo) => {
+58 -2
View File
@@ -48,6 +48,7 @@ type NavigationFixture = {
accessLimitAfterCalls?: number;
largeCommandTable?: boolean;
draftCommandTable?: boolean;
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
refCommandCategories?: boolean;
currentYear?: number;
currentMonth?: number;
@@ -235,6 +236,7 @@ const draftCommandGroups = [
options: [
{ value: 'horse', label: '명마' },
{ value: 'weapon', label: '무기' },
{ value: 'item', label: '도구' },
],
},
{
@@ -250,7 +252,13 @@ const draftCommandGroups = [
},
];
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({
const commandTableFixture = (
large: boolean,
blockedCount = 0,
refCategories = false,
draftCommands = false,
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>
) => ({
general: draftCommands
? draftCommandGroups
: refCategories
@@ -309,6 +317,7 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
{ value: 'None', label: '없음' },
{ value: '청룡언월도', label: '청룡언월도' },
],
item: equipmentItemOptions ?? [{ value: 'None', label: '없음' }],
}
: {},
context: draftCommands
@@ -595,7 +604,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
state.largeCommandTable === true,
state.commandBlockedCount,
state.refCommandCategories === true,
state.draftCommandTable === true
state.draftCommandTable === true,
state.equipmentItemOptions
),
}
: input.known.commandTable === currentCommandTableRevision
@@ -3455,6 +3465,52 @@ for (const viewport of [
});
}
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
] as const) {
test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
draftCommandTable: true,
equipmentItemOptions: [
{ value: 'None', label: '판매/해제' },
{
value: 'che_치료_환약',
label: '환약',
description: '현재 구입 가능 · 가격 100 · 부상 회복',
},
],
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
};
await installFixture(page, state);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await waitForMain(page);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('item');
const itemSelect = picker.getByLabel('장비', { exact: true });
await expect(itemSelect.locator('option')).toHaveText(['판매/해제', '환약']);
await expect(itemSelect.locator('option', { hasText: '비급' })).toHaveCount(0);
await itemSelect.selectOption('che_치료_환약');
await expect(picker).toContainText('현재 구입 가능 · 가격 100 · 부상 회복');
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewport.width);
await picker.screenshot({ path: test.info().outputPath(`scenario-item-shop-${viewport.name}.png`) });
});
}
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
page,
}) => {
@@ -70,7 +70,7 @@ const handleClick = () => {
<div v-for="row in props.rows" :key="row.index" class="chief-row" :class="{ rest: row.isRest }">
<span class="row-index">#{{ row.index + 1 }}</span>
<span class="row-time">{{ row.time }}</span>
<span class="row-action">{{ row.action }}</span>
<span class="row-action" :title="row.action">{{ row.action }}</span>
</div>
</div>
</article>
@@ -8,6 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
type ChiefTurn = {
@@ -219,7 +220,10 @@ const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
? `${String(turnDate.getUTCHours()).padStart(2, '0')}:${String(turnDate.getUTCMinutes()).padStart(2, '0')}`
: `${String(turnDate.getUTCMinutes()).padStart(2, '0')}:${String(turnDate.getUTCSeconds()).padStart(2, '0')}`
: '--:--';
const actionLabel = labelMap.get(turn.action) ?? turn.action;
const actionLabel =
formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ??
labelMap.get(turn.action) ??
turn.action;
return {
index: turn.index,
time: timeLabel,
@@ -8,29 +8,44 @@ import {
type ProcessDefinition,
} from './processManager.js';
type Pm2Module = typeof Pm2;
export interface Pm2Client {
connect(callback: (error?: Error) => void): void;
disconnect(): void;
list(callback: (error: Error | null, list?: Pm2.ProcessDescription[]) => void): void;
start(options: Pm2.StartOptions, callback: (error?: Error) => void): void;
stop(name: string, callback: (error?: Error) => void): void;
delete(name: string, callback: (error?: Error) => void): void;
}
export interface Pm2ProcessManagerOptions {
loadPm2?: () => Pm2Client;
connectTimeoutMs?: number;
listTimeoutMs?: number;
mutationTimeoutMs?: number;
}
const require = createRequire(import.meta.url);
const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module;
const loadPm2 = (): Pm2Client => require('pm2') as Pm2Client;
const DEFAULT_PM2_CONNECT_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_LIST_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_MUTATION_TIMEOUT_MS = 30_000;
const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
const pm2 = loadPm2();
await new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
timer.unref();
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
return;
}
resolve();
});
);
});
try {
return await handler(pm2);
} finally {
pm2.disconnect();
}
};
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
name: definition.name,
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
});
export class Pm2ProcessManager implements ProcessManager {
private readonly loadPm2: () => Pm2Client;
private readonly connectTimeoutMs: number;
private readonly listTimeoutMs: number;
private readonly mutationTimeoutMs: number;
private sessionTail: Promise<void> = Promise.resolve();
constructor(options: Pm2ProcessManagerOptions = {}) {
this.loadPm2 = options.loadPm2 ?? loadPm2;
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_PM2_CONNECT_TIMEOUT_MS;
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_PM2_LIST_TIMEOUT_MS;
this.mutationTimeoutMs = options.mutationTimeoutMs ?? DEFAULT_PM2_MUTATION_TIMEOUT_MS;
}
private withPm2<T>(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise<T>): Promise<T> {
const task = this.sessionTail.then(async () => {
const pm2 = this.loadPm2();
try {
await withTimeout(
new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}),
this.connectTimeoutMs,
'PM2 connect'
);
return await withTimeout(handler(pm2), timeoutMs, label);
} finally {
pm2.disconnect();
}
});
this.sessionTail = task.then(
() => undefined,
() => undefined
);
return task;
}
async list(): Promise<ManagedProcessInfo[]> {
return withPm2(
return this.withPm2(
'PM2 list',
this.listTimeoutMs,
(pm2) =>
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
pm2.list((error, list) => {
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async start(definition: ProcessDefinition): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 start ${definition.name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.list((listError, list) => {
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async stop(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 stop ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.stop(name, (error) => {
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async delete(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 delete ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.delete(name, (error) => {
+121 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js';
import {
buildPm2StartOptions,
Pm2ProcessManager,
type Pm2Client,
} from '../src/orchestrator/pm2ProcessManager.js';
describe('buildPm2StartOptions', () => {
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => {
@@ -56,3 +60,118 @@ describe('buildPm2StartOptions', () => {
expect(options.env).not.toHaveProperty('args');
});
});
describe('Pm2ProcessManager session recovery', () => {
it('serializes concurrent PM2 sessions so one disconnect cannot interrupt another request', async () => {
const events: string[] = [];
let listCall = 0;
let releaseFirstList: (() => void) | undefined;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
events.push('connect');
callback();
},
disconnect() {
events.push('disconnect');
},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
const currentCall = listCall;
events.push(`list:${currentCall}`);
if (currentCall === 1) {
releaseFirstList = () => callback(null, []);
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({ loadPm2: () => pm2 });
const first = manager.list();
const second = manager.list();
await vi.waitFor(() => expect(events).toEqual(['connect', 'list:1']));
releaseFirstList?.();
await expect(Promise.all([first, second])).resolves.toEqual([[], []]);
expect(events).toEqual(['connect', 'list:1', 'disconnect', 'connect', 'list:2', 'disconnect']);
});
it('times out a lost PM2 callback and lets the next queued session proceed', async () => {
let listCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
callback();
},
disconnect() {},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
if (listCall === 1) {
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
listTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 list timed out after 10ms.');
await expect(manager.list()).resolves.toEqual([]);
});
it('disconnects a timed-out PM2 connection before releasing the serialized session', async () => {
let connectCall = 0;
let disconnectCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
connectCall += 1;
if (connectCall > 1) {
callback();
}
},
disconnect() {
disconnectCall += 1;
},
list(callback: Parameters<Pm2Client['list']>[0]) {
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
connectTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 connect timed out after 10ms.');
expect(disconnectCall).toBe(1);
await expect(manager.list()).resolves.toEqual([]);
expect(disconnectCall).toBe(2);
});
});
+10
View File
@@ -128,3 +128,13 @@ README에 정의된 경로에서 다음 순서로 확인하며, `down --volumes`
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
동일하게 검증합니다.
Gateway 릴리스가 terminal인데 후속 profile 작업이 `QUEUED`, `attempts=0`에서
두 poll 주기 이상 움직이지 않으면 build process를 종료할 문제가 아니라 profile
orchestrator poll 자체를 조사합니다. Gateway 전환 직후 reconcile과 worktree cleanup이
겹쳐도 PM2 Node client session은 직렬화되며, `connect/list` callback이 5초 안에 오지
않으면 해당 scheduled task를 실패시켜 다음 poll로 자동 복구합니다. 이 timeout 전후에
PM2 mutation을 수동 반복하지 말고 active Gateway release row가 없는지, orchestrator
started 로그와 profile operation attempts가 그대로인지 먼저 확인합니다. 제한 복구가
필요하면 현재 PM2 definition의 `sammo:gateway-orchestrator`만 재시작하고 Gateway
API/frontend, release-controller, profile daemon과 container는 유지합니다.