Run profiles from commit worktrees

This commit is contained in:
2026-07-25 07:27:10 +00:00
parent 838ef16208
commit dbd869f0ed
5 changed files with 148 additions and 34 deletions
+2
View File
@@ -24,6 +24,8 @@ GATEWAY_API_PORT=13000
GATEWAY_PUBLIC_URL=http://localhost:13000 GATEWAY_PUBLIC_URL=http://localhost:13000
GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_REDIS_PREFIX=sammo:gateway
GATEWAY_DB_SCHEMA=public GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
SESSION_TTL_SECONDS=604800 SESSION_TTL_SECONDS=604800
GAME_SESSION_TTL_SECONDS=21600 GAME_SESSION_TTL_SECONDS=21600
OAUTH_SESSION_TTL_SECONDS=600 OAUTH_SESSION_TTL_SECONDS=600
@@ -5,7 +5,7 @@ import { type ScenarioInstallOptions } from '@sammo-ts/game-engine';
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common'; import { isRecord } from '@sammo-ts/common';
import type { BuildRunner } from './buildRunner.js'; import type { BuildCommand, BuildRunner } from './buildRunner.js';
import type { ProcessManager } from './processManager.js'; import type { ProcessManager } from './processManager.js';
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js'; import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
import type { GitWorkspaceManager } from './workspaceManager.js'; import type { GitWorkspaceManager } from './workspaceManager.js';
@@ -267,7 +267,7 @@ const parseInstallOptions = (
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string => const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`; `sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
const buildProcessDefinitions = ( export const buildProcessDefinitions = (
profile: GatewayProfileRecord, profile: GatewayProfileRecord,
config: GatewayProcessConfig config: GatewayProcessConfig
): { ): {
@@ -277,8 +277,9 @@ const buildProcessDefinitions = (
const baseEnv = { ...(config.baseEnv ?? {}) }; const baseEnv = { ...(config.baseEnv ?? {}) };
const apiName = buildProcessName(profile.profileName, 'api'); const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon'); const daemonName = buildProcessName(profile.profileName, 'daemon');
const apiCwd = path.join(config.workspaceRoot, 'app', 'game-api'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
const daemonCwd = path.join(config.workspaceRoot, 'app', 'game-engine'); const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
const apiScript = path.join(apiCwd, 'dist', 'index.js'); const apiScript = path.join(apiCwd, 'dist', 'index.js');
const daemonScript = path.join(daemonCwd, 'dist', 'index.js'); const daemonScript = path.join(daemonCwd, 'dist', 'index.js');
const apiEnv = { const apiEnv = {
@@ -312,6 +313,37 @@ const buildProcessDefinitions = (
}; };
}; };
export const buildWorkspaceCommands = (
workspaceRoot: string,
needsInstall: boolean,
env?: Record<string, string>
): BuildCommand[] => {
const commands: BuildCommand[] = [];
if (needsInstall) {
commands.push({
command: 'pnpm',
args: ['install'],
cwd: workspaceRoot,
env,
});
}
for (const packageName of [
'@sammo-ts/common',
'@sammo-ts/infra',
'@sammo-ts/logic',
'@sammo-ts/game-api',
'@sammo-ts/game-engine',
]) {
commands.push({
command: 'pnpm',
args: ['--filter', packageName, 'build'],
cwd: workspaceRoot,
env,
});
}
return commands;
};
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] => const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
profileNames.map((profileName) => { profileNames.map((profileName) => {
const apiName = buildProcessName(profileName, 'api'); const apiName = buildProcessName(profileName, 'api');
@@ -699,7 +731,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
openAt: openAt ? openAt.toISOString() : null, openAt: openAt ? openAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null, scheduledStartAt: action.scheduledAt ?? null,
}); });
await this.startProfile(activeProfile); const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile;
await this.startProfile(builtProfile);
return { status: 'APPLIED', detail: 'reset completed via rebuild' }; return { status: 'APPLIED', detail: 'reset completed via rebuild' };
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
@@ -756,33 +789,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const workspace = await this.workspaceManager.prepare(commitSha); const workspace = await this.workspaceManager.prepare(commitSha);
const lastUsedAt = this.now().toISOString(); const lastUsedAt = this.now().toISOString();
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt); await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
const commands: Array<{ const commands = buildWorkspaceCommands(
command: string; workspace.root,
args: string[]; workspace.needsInstall,
cwd: string; this.processConfig.baseEnv
env?: Record<string, string>;
}> = [];
if (workspace.needsInstall) {
commands.push({
command: 'pnpm',
args: ['install'],
cwd: workspace.root,
env: this.processConfig.baseEnv,
});
}
commands.push(
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-api', 'build'],
cwd: workspace.root,
env: this.processConfig.baseEnv,
},
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-engine', 'build'],
cwd: workspace.root,
env: this.processConfig.baseEnv,
}
); );
return this.buildRunner.run(commands); return this.buildRunner.run(commands);
} }
+64 -1
View File
@@ -1,6 +1,27 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { planProfileReconcile } from '../src/orchestrator/gatewayOrchestrator.js'; import path from 'node:path';
import {
buildProcessDefinitions,
buildWorkspaceCommands,
planProfileReconcile,
} from '../src/orchestrator/gatewayOrchestrator.js';
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
profileName: 'che:2',
profile: 'che',
scenario: '2',
apiPort: 15003,
status: 'RUNNING',
buildStatus: 'SUCCEEDED',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
buildWorkspace,
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
});
describe('planProfileReconcile', () => { describe('planProfileReconcile', () => {
it('starts missing processes for running profiles', () => { it('starts missing processes for running profiles', () => {
@@ -48,3 +69,45 @@ describe('planProfileReconcile', () => {
).toEqual({ shouldStart: false, shouldStop: false }); ).toEqual({ shouldStart: false, shouldStop: false });
}); });
}); });
describe('buildProcessDefinitions', () => {
const processConfig = {
workspaceRoot: '/srv/sammo/main',
redisKeyPrefix: 'sammo:gateway',
gameTokenSecret: 'test-secret',
};
it('runs a built profile from its commit worktree', () => {
const buildWorkspace = '/srv/sammo/worktrees/0123456789abcdef';
const definitions = buildProcessDefinitions(buildProfile(buildWorkspace), processConfig);
expect(definitions.api.cwd).toBe(path.join(buildWorkspace, 'app', 'game-api'));
expect(definitions.api.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'));
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'));
});
it('keeps main as the runtime for profiles without a commit worktree', () => {
const definitions = buildProcessDefinitions(buildProfile(), processConfig);
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
});
});
describe('buildWorkspaceCommands', () => {
it('installs and builds runtime dependencies before the profile processes', () => {
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
const commands = buildWorkspaceCommands(workspaceRoot, true);
expect(commands.map(({ args }) => args)).toEqual([
['install'],
['--filter', '@sammo-ts/common', 'build'],
['--filter', '@sammo-ts/infra', 'build'],
['--filter', '@sammo-ts/logic', 'build'],
['--filter', '@sammo-ts/game-api', 'build'],
['--filter', '@sammo-ts/game-engine', 'build'],
]);
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
});
});
+5
View File
@@ -87,9 +87,14 @@ Gateway runs a lightweight cron loop (setInterval) that:
- Each workspace stores `lastUsedAt` in DB so cleanup can remove stale worktrees. - Each workspace stores `lastUsedAt` in DB so cleanup can remove stale worktrees.
- Cleanup is invoked manually by admin API and removes worktrees unused for 6+ months. - Cleanup is invoked manually by admin API and removes worktrees unused for 6+ months.
- Build runs `pnpm install` when workspace is created, then executes - Build runs `pnpm install` when workspace is created, then executes
the common, infra, and logic dependency builds before
`pnpm --filter @sammo-ts/game-api build` and `pnpm --filter @sammo-ts/game-api build` and
`pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure. `pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure.
- On success, status moves to `PREOPEN` for reserved builds or stays unchanged for manual builds. - On success, status moves to `PREOPEN` for reserved builds or stays unchanged for manual builds.
- PM2 starts API/daemon from the profile row's `buildWorkspace`, so profiles
pinned to different commits execute the artifacts from their own detached
worktrees. A profile without `buildWorkspace` intentionally falls back to the
main workspace; this is the `hwe`/main compatibility path.
## Current Implementation Status ## Current Implementation Status
+35 -1
View File
@@ -42,6 +42,36 @@ handle /che/* {
} }
} }
redir /kwe /kwe/ 308
@kweApi path /kwe/api /kwe/api/*
handle @kweApi {
reverse_proxy http://172.30.1.54:15005 {
header_up X-Real-IP {remote_host}
header_down -Strict-Transport-Security
}
}
handle /kwe/* {
reverse_proxy http://172.30.1.54:15004 {
header_up X-Real-IP {remote_host}
header_down -Strict-Transport-Security
}
}
redir /twe /twe/ 308
@tweApi path /twe/api /twe/api/*
handle @tweApi {
reverse_proxy http://172.30.1.54:15007 {
header_up X-Real-IP {remote_host}
header_down -Strict-Transport-Security
}
}
handle /twe/* {
reverse_proxy http://172.30.1.54:15006 {
header_up X-Real-IP {remote_host}
header_down -Strict-Transport-Security
}
}
redir /hwe /hwe/ 308 redir /hwe /hwe/ 308
@hweApi path /hwe/api /hwe/api/* @hweApi path /hwe/api /hwe/api/*
handle @hweApi { handle @hweApi {
@@ -69,6 +99,10 @@ strip하지 않으므로 backend의 tRPC/SSE path도 public path 전체를 사
| gateway API | 15001 | `GATEWAY_API_HOST=0.0.0.0`, `GATEWAY_API_PORT=15001`, `GATEWAY_TRPC_PATH=/gateway/api/trpc` | | gateway API | 15001 | `GATEWAY_API_HOST=0.0.0.0`, `GATEWAY_API_PORT=15001`, `GATEWAY_TRPC_PATH=/gateway/api/trpc` |
| che frontend | 15002 | `VITE_APP_BASE_PATH=/che/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/che/api/trpc`, `VITE_GAME_SSE_URL=/che/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=che` | | che frontend | 15002 | `VITE_APP_BASE_PATH=/che/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/che/api/trpc`, `VITE_GAME_SSE_URL=/che/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=che` |
| che API | 15003 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15003`, `GAME_TRPC_PATH=/che/api/trpc`, `GAME_API_EVENTS_PATH=/che/api/events`, `GAME_UPLOAD_PATH=/che/api/uploads`, `PROFILE=che` | | che API | 15003 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15003`, `GAME_TRPC_PATH=/che/api/trpc`, `GAME_API_EVENTS_PATH=/che/api/events`, `GAME_UPLOAD_PATH=/che/api/uploads`, `PROFILE=che` |
| kwe frontend | 15004 | `VITE_APP_BASE_PATH=/kwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/kwe/api/trpc`, `VITE_GAME_SSE_URL=/kwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=kwe` |
| kwe API | 15005 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15005`, `GAME_TRPC_PATH=/kwe/api/trpc`, `GAME_API_EVENTS_PATH=/kwe/api/events`, `GAME_UPLOAD_PATH=/kwe/api/uploads`, `PROFILE=kwe` |
| twe frontend | 15006 | `VITE_APP_BASE_PATH=/twe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/twe/api/trpc`, `VITE_GAME_SSE_URL=/twe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=twe` |
| twe API | 15007 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15007`, `GAME_TRPC_PATH=/twe/api/trpc`, `GAME_API_EVENTS_PATH=/twe/api/events`, `GAME_UPLOAD_PATH=/twe/api/uploads`, `PROFILE=twe` |
| hwe frontend | 15014 | `VITE_APP_BASE_PATH=/hwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/hwe/api/trpc`, `VITE_GAME_SSE_URL=/hwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=hwe` | | hwe frontend | 15014 | `VITE_APP_BASE_PATH=/hwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/hwe/api/trpc`, `VITE_GAME_SSE_URL=/hwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=hwe` |
| hwe API | 15015 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15015`, `GAME_TRPC_PATH=/hwe/api/trpc`, `GAME_API_EVENTS_PATH=/hwe/api/events`, `GAME_UPLOAD_PATH=/hwe/api/uploads`, `PROFILE=hwe` | | hwe API | 15015 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15015`, `GAME_TRPC_PATH=/hwe/api/trpc`, `GAME_API_EVENTS_PATH=/hwe/api/events`, `GAME_UPLOAD_PATH=/hwe/api/uploads`, `PROFILE=hwe` |
@@ -85,7 +119,7 @@ strip하지 않으므로 backend의 tRPC/SSE path도 public path 전체를 사
2. 각 upstream 대신 요청 URI와 listen port를 돌려주는 mock HTTP server를 2. 각 upstream 대신 요청 URI와 listen port를 돌려주는 mock HTTP server를
붙여 public path가 올바른 port로 전달되고 prefix가 보존되는지 확인한다. 붙여 public path가 올바른 port로 전달되고 prefix가 보존되는지 확인한다.
3. frontend를 임시 output directory에 build하고 `index.html`의 module/CSS 3. frontend를 임시 output directory에 build하고 `index.html`의 module/CSS
URL이 각각 `/gateway/`, `/che/`, `/hwe/`로 시작하는지 확인한다. URL이 각각 `/gateway/`, `/che/`, `/kwe/`, `/twe/`, `/hwe/`로 시작하는지 확인한다.
4. bundle에서 외부 API/SSE URL과 `/image` asset base를 확인한다. 4. bundle에서 외부 API/SSE URL과 `/image` asset base를 확인한다.
저장소의 `tools/e2e-routing-check/Caddyfile`은 18080에서 public Caddy 저장소의 `tools/e2e-routing-check/Caddyfile`은 18080에서 public Caddy