From dbd869f0edd44eb0d6197f5f2b8de4b6e26b5f8f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 07:27:10 +0000 Subject: [PATCH] Run profiles from commit worktrees --- .env.example | 2 + .../src/orchestrator/gatewayOrchestrator.ts | 74 +++++++++++-------- app/gateway-api/test/orchestratorPlan.test.ts | 65 +++++++++++++++- docs/architecture/runtime.md | 5 ++ docs/e2e-caddy-routing.md | 36 ++++++++- 5 files changed, 148 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 14617c8..79b1b00 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ GATEWAY_API_PORT=13000 GATEWAY_PUBLIC_URL=http://localhost:13000 GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_DB_SCHEMA=public +GATEWAY_WORKSPACE_ROOT=/path/to/core2026 +GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees SESSION_TTL_SECONDS=604800 GAME_SESSION_TTL_SECONDS=21600 OAUTH_SESSION_TTL_SECONDS=600 diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 348acab..d7e4b3a 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -5,7 +5,7 @@ import { type ScenarioInstallOptions } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; 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 { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js'; import type { GitWorkspaceManager } from './workspaceManager.js'; @@ -267,7 +267,7 @@ const parseInstallOptions = ( const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string => `sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`; -const buildProcessDefinitions = ( +export const buildProcessDefinitions = ( profile: GatewayProfileRecord, config: GatewayProcessConfig ): { @@ -277,8 +277,9 @@ const buildProcessDefinitions = ( const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); - const apiCwd = path.join(config.workspaceRoot, 'app', 'game-api'); - const daemonCwd = path.join(config.workspaceRoot, 'app', 'game-engine'); + const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; + 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 daemonScript = path.join(daemonCwd, 'dist', 'index.js'); const apiEnv = { @@ -312,6 +313,37 @@ const buildProcessDefinitions = ( }; }; +export const buildWorkspaceCommands = ( + workspaceRoot: string, + needsInstall: boolean, + env?: Record +): 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): ProfileRuntimeSnapshot[] => profileNames.map((profileName) => { const apiName = buildProcessName(profileName, 'api'); @@ -699,7 +731,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { openAt: openAt ? openAt.toISOString() : 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' }; } catch (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 lastUsedAt = this.now().toISOString(); await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt); - const commands: Array<{ - command: string; - args: string[]; - cwd: string; - env?: Record; - }> = []; - 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, - } + const commands = buildWorkspaceCommands( + workspace.root, + workspace.needsInstall, + this.processConfig.baseEnv ); return this.buildRunner.run(commands); } diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 3e7e299..4505621 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -1,6 +1,27 @@ 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', () => { it('starts missing processes for running profiles', () => { @@ -48,3 +69,45 @@ describe('planProfileReconcile', () => { ).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); + }); +}); diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 04314ca..a524464 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -87,9 +87,14 @@ Gateway runs a lightweight cron loop (setInterval) that: - 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. - 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-engine build`, then marks build success/failure. - 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 diff --git a/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md index 296f81e..b3024f7 100644 --- a/docs/e2e-caddy-routing.md +++ b/docs/e2e-caddy-routing.md @@ -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 @hweApi path /hwe/api /hwe/api/* 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` | | 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` | +| 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 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를 붙여 public path가 올바른 port로 전달되고 prefix가 보존되는지 확인한다. 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를 확인한다. 저장소의 `tools/e2e-routing-check/Caddyfile`은 18080에서 public Caddy