Files
source-gamepanel/apps/api/src/lib/server-automation.ts
T
hibna c1adb94abb Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
2026-08-02 21:08:12 +03:00

939 lines
26 KiB
TypeScript

import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import type {
GameAutomationRule,
ServerAutomationEvent,
ServerAutomationAction,
ServerAutomationGitHubReleaseExtractAction,
ServerAutomationHttpDirectoryExtractAction,
ServerAutomationInsertBeforeLineAction,
ServerAutomationWriteFileAction,
} from '@source/shared';
import {
daemonReadFile,
daemonSendCommand,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
import {
CS2_PERSISTED_SERVER_CFG_PATH,
CS2_SERVER_CFG_PATH,
DEFAULT_CS2_SERVER_CFG,
} from './managed-config.js';
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
const CS2_GAMEINFO_PATH = '/game/csgo/gameinfo.gi';
const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod';
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
id: CS2_GAMEINFO_INSERT_ACTION_ID,
type: 'insert_before_line',
path: CS2_GAMEINFO_PATH,
line: CS2_GAMEINFO_METAMOD_LINE,
beforePattern: CS2_GAMEINFO_INSERT_BEFORE_PATTERN,
existsPattern: CS2_GAMEINFO_EXISTS_PATTERN,
skipIfExists: true,
};
const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = {
id: 'write-cs2-default-server-config',
type: 'write_file',
path: `/${CS2_SERVER_CFG_PATH}`,
data: DEFAULT_CS2_SERVER_CFG,
};
const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = {
id: 'write-cs2-persisted-server-config',
type: 'write_file',
path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`,
data: DEFAULT_CS2_SERVER_CFG,
};
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
cs2: [
{
id: 'cs2-write-default-server-config',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
],
},
{
id: 'cs2-install-latest-metamod',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{
id: 'install-cs2-metamod',
type: 'http_directory_extract',
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
destination: '/game/csgo',
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
{ ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION },
],
},
{
id: 'cs2-install-latest-counterstrikesharp-runtime',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{
id: 'install-cs2-runtime',
type: 'github_release_extract',
owner: 'roflmuffin',
repo: 'CounterStrikeSharp',
assetNamePatterns: [
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
'^counterstrikesharp-with-runtime.*\\.zip$',
],
destination: '/game/csgo',
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
],
},
],
};
interface ServerAutomationContext {
serverId: string;
serverUuid: string;
gameSlug: string;
event: ServerAutomationEvent;
node: DaemonNodeConnection;
automationRulesRaw: unknown;
force?: boolean;
}
export interface ServerAutomationRunResult {
workflowsMatched: number;
workflowsExecuted: number;
workflowsSkipped: number;
workflowsFailed: number;
actionFailures: number;
failures: ServerAutomationFailure[];
}
interface ExtractedFile {
path: string;
data: Buffer;
}
export interface ServerAutomationFailure {
level: 'action' | 'workflow';
workflowId: string;
actionId?: string;
message: string;
}
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
interface GitHubReleaseResponse {
tag_name: string;
assets: GitHubReleaseAsset[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function readWorkflowId(value: unknown): string | null {
if (!isObject(value)) return null;
const id = value.id;
if (typeof id !== 'string' || id.trim() === '') return null;
return id;
}
function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule {
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
if (workflow.id === 'cs2-write-default-server-config') {
return {
...workflow,
actions: [
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
],
};
}
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
const normalizedActions = workflow.actions.map((action) => {
if (action.type !== 'github_release_extract') return action;
if (action.id !== 'install-cs2-runtime') return action;
const destination = (action.destination ?? '').trim();
if (destination !== '' && destination !== '/') return action;
return {
...action,
destination: '/game/csgo',
};
});
return {
...workflow,
actions: normalizedActions,
};
}
if (workflow.id === 'cs2-install-latest-metamod') {
const hasGameInfoAction = workflow.actions.some(
(action) =>
action.type === 'insert_before_line' &&
(action.id === CS2_GAMEINFO_INSERT_ACTION_ID || action.path === CS2_GAMEINFO_PATH),
);
if (hasGameInfoAction) return workflow;
return {
...workflow,
actions: [...workflow.actions, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }],
};
}
return workflow;
}
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? [];
if (!Array.isArray(raw)) {
return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
const configured = raw as GameAutomationRule[];
if (defaults.length === 0) {
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
const existingIds = new Set(
raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null),
);
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
if (missingDefaults.length === 0) {
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
return [...configured, ...missingDefaults].map((workflow) =>
normalizeWorkflow(gameSlug, workflow),
);
}
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function normalizeArchivePath(path: string, stripComponents = 0): string | null {
const segments = normalizePathSegments(path);
const stripped = segments.slice(Math.max(0, stripComponents));
if (stripped.length === 0) return null;
return stripped.join('/');
}
async function hasMarker(
node: DaemonNodeConnection,
serverUuid: string,
event: ServerAutomationEvent,
workflowId: string,
): Promise<boolean> {
try {
await daemonReadFile(node, serverUuid, markerPath(event, workflowId));
return true;
} catch (error) {
if (isMissingFileError(error)) return false;
throw error;
}
}
async function writeMarker(
node: DaemonNodeConnection,
serverUuid: string,
event: ServerAutomationEvent,
workflowId: string,
payload: Record<string, unknown>,
): Promise<void> {
await daemonWriteFile(
node,
serverUuid,
markerPath(event, workflowId),
JSON.stringify(payload, null, 2),
);
}
function githubHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'SourceGamePanel/1.0',
};
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function compileAssetPatterns(patterns: string[]): RegExp[] {
const compiled: RegExp[] = [];
const seen = new Set<string>();
const tryCompile = (pattern: string) => {
const key = pattern.trim();
if (!key || seen.has(key)) return;
try {
compiled.push(new RegExp(key, 'i'));
seen.add(key);
} catch {
// Ignore invalid regex patterns in configuration.
}
};
for (const pattern of patterns) {
tryCompile(pattern);
// Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\.").
// Collapse double backslashes once and compile a fallback variant.
if (pattern.includes('\\\\')) {
tryCompile(pattern.replace(/\\\\/g, '\\'));
}
}
return compiled;
}
async function fetchLatestRelease(
action: ServerAutomationGitHubReleaseExtractAction,
): Promise<GitHubReleaseResponse> {
const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`;
const response = await fetch(releaseUrl, {
headers: githubHeaders(),
});
if (!response.ok) {
throw new Error(
`GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`,
);
}
const release = (await response.json()) as GitHubReleaseResponse;
if (!Array.isArray(release.assets)) {
throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`);
}
return release;
}
interface DirectoryAssetCandidate {
name: string;
downloadUrl: string;
}
function extractNumberParts(value: string): number[] {
const matches = value.match(/\d+/g);
if (!matches) return [];
return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num));
}
function compareNumberPartsDesc(a: number[], b: number[]): number {
const maxLength = Math.max(a.length, b.length);
for (let i = 0; i < maxLength; i += 1) {
const left = a[i] ?? -1;
const right = b[i] ?? -1;
if (left !== right) {
return right - left;
}
}
return 0;
}
function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate {
const sorted = [...candidates].sort((left, right) => {
const numberDiff = compareNumberPartsDesc(
extractNumberParts(left.name),
extractNumberParts(right.name),
);
if (numberDiff !== 0) return numberDiff;
return right.name.localeCompare(left.name);
});
return sorted[0] ?? candidates[0]!;
}
function extractDirectoryCandidates(
html: string,
indexUrl: string,
assetPattern: RegExp,
): DirectoryAssetCandidate[] {
const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi;
const candidates: DirectoryAssetCandidate[] = [];
let match: RegExpExecArray | null = null;
while ((match = hrefRegex.exec(html)) !== null) {
const href = (match[2] ?? '').trim();
if (!href || href.endsWith('/')) continue;
try {
const resolvedUrl = new URL(href, indexUrl);
const filename = decodeURIComponent(
resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '',
);
if (!filename || !assetPattern.test(filename)) continue;
candidates.push({
name: filename,
downloadUrl: resolvedUrl.toString(),
});
} catch {
// Ignore malformed links.
}
}
return candidates;
}
async function resolveLatestDirectoryAsset(
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<DirectoryAssetCandidate> {
let assetPattern: RegExp;
try {
assetPattern = new RegExp(action.assetNamePattern, 'i');
} catch {
throw new Error(`Invalid assetNamePattern regex for action ${action.id}`);
}
const response = await fetch(action.indexUrl, {
headers: { 'User-Agent': 'SourceGamePanel/1.0' },
});
if (!response.ok) {
throw new Error(
`Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`,
);
}
const html = await response.text();
const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern);
if (candidates.length === 0) {
throw new Error(
`No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`,
);
}
return pickLatestDirectoryAsset(candidates);
}
async function downloadBinary(url: string, maxBytes: number): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
},
redirect: 'follow',
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error('Downloaded artifact is empty');
}
if (buffer.length > maxBytes) {
throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`);
}
return buffer;
} finally {
clearTimeout(timeout);
}
}
async function extractZipFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path, stripComponents);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name, stripComponents);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArtifactFiles(
artifact: Buffer,
assetName: string,
stripComponents = 0,
): Promise<ExtractedFile[]> {
const name = assetName.toLowerCase();
if (name.endsWith('.zip')) {
return extractZipFiles(artifact, stripComponents);
}
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(artifact), stripComponents);
}
if (name.endsWith('.tar')) {
return extractTarFiles(artifact, stripComponents);
}
const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName;
return [{ path: normalized, data: artifact }];
}
async function executeGitHubReleaseExtract(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationGitHubReleaseExtractAction,
): Promise<void> {
const release = await fetchLatestRelease(action);
const patterns = compileAssetPatterns(action.assetNamePatterns);
if (patterns.length === 0) {
throw new Error(`No valid asset regex pattern for action ${action.id}`);
}
const asset = release.assets.find((candidate) =>
patterns.some((pattern) => pattern.test(candidate.name)),
);
if (!asset) {
throw new Error(
`No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`,
);
}
const maxBytes =
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
const files = await extractArtifactFiles(
artifact,
asset.name,
Number(action.stripComponents) || 0,
);
if (files.length === 0) {
throw new Error(`Extracted artifact has no files: ${asset.name}`);
}
const destination = action.destination ?? '/';
for (const file of files) {
const targetPath = joinServerPath(destination, file.path);
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
actionId: action.id,
release: release.tag_name,
asset: asset.name,
filesWritten: files.length,
},
'Automation action completed: github_release_extract',
);
}
async function executeHttpDirectoryExtract(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<void> {
const selectedAsset = await resolveLatestDirectoryAsset(action);
const maxBytes =
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
const files = await extractArtifactFiles(
artifact,
selectedAsset.name,
Number(action.stripComponents) || 0,
);
if (files.length === 0) {
throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`);
}
const destination = action.destination ?? '/';
for (const file of files) {
const targetPath = joinServerPath(destination, file.path);
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
actionId: action.id,
source: action.indexUrl,
asset: selectedAsset.name,
filesWritten: files.length,
},
'Automation action completed: http_directory_extract',
);
}
async function executeInsertBeforeLine(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationInsertBeforeLineAction,
): Promise<void> {
const file = await daemonReadFile(context.node, context.serverUuid, action.path);
const content = file.data.toString('utf8');
const eol = content.includes('\r\n') ? '\r\n' : '\n';
const hasTrailingEol = content.endsWith('\n');
const lines = content.split(/\r?\n/);
if (hasTrailingEol && lines[lines.length - 1] === '') {
lines.pop();
}
const skipIfExists = action.skipIfExists !== false;
if (skipIfExists) {
const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null;
const alreadyExists = lines.some((line) =>
existsRegex ? existsRegex.test(line) : line === action.line,
);
if (alreadyExists) {
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action skipped: line already present',
);
return;
}
}
let beforeRegex: RegExp;
try {
beforeRegex = new RegExp(action.beforePattern);
} catch {
throw new Error(`Invalid beforePattern regex for action ${action.id}`);
}
const insertIndex = lines.findIndex((line) => beforeRegex.test(line));
if (insertIndex < 0) {
throw new Error(
`Could not find insertion point in ${action.path} with pattern: ${action.beforePattern}`,
);
}
const updated = [...lines.slice(0, insertIndex), action.line, ...lines.slice(insertIndex)];
const output = `${updated.join(eol)}${hasTrailingEol ? eol : ''}`;
await daemonWriteFile(context.node, context.serverUuid, action.path, output);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action completed: insert_before_line',
);
}
async function executeAction(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationAction,
): Promise<void> {
switch (action.type) {
case 'github_release_extract': {
await executeGitHubReleaseExtract(app, context, action);
return;
}
case 'http_directory_extract': {
await executeHttpDirectoryExtract(app, context, action);
return;
}
case 'insert_before_line': {
await executeInsertBeforeLine(app, context, action);
return;
}
case 'write_file': {
const payload =
action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data;
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action completed: write_file',
);
return;
}
case 'send_command': {
await daemonSendCommand(context.node, context.serverUuid, action.command);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
command: action.command,
},
'Automation action completed: send_command',
);
return;
}
default: {
const unknownAction = action as { type?: unknown };
throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`);
}
}
}
export async function runServerAutomationEvent(
app: FastifyInstance,
context: ServerAutomationContext,
): Promise<ServerAutomationRunResult> {
const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug)
.filter((rule) => isObject(rule))
.filter((rule) => rule.event === context.event)
.filter((rule) => rule.enabled !== false)
.filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0);
const result: ServerAutomationRunResult = {
workflowsMatched: workflows.length,
workflowsExecuted: 0,
workflowsSkipped: 0,
workflowsFailed: 0,
actionFailures: 0,
failures: [],
};
if (workflows.length === 0) {
return result;
}
for (const workflow of workflows) {
const runOnce = workflow.runOncePerServer !== false;
try {
if (
runOnce &&
!context.force &&
(await hasMarker(context.node, context.serverUuid, context.event, workflow.id))
) {
result.workflowsSkipped += 1;
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Skipping automation workflow (already completed)',
);
continue;
}
for (const action of workflow.actions) {
try {
await executeAction(app, context, action);
} catch (error) {
const message = errorMessage(error);
result.actionFailures += 1;
result.failures.push({
level: 'action',
workflowId: workflow.id,
actionId: action.id,
message,
});
app.log.error(
{
err: error,
errorMessage: message,
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
actionId: action.id,
},
'Automation action failed',
);
if (workflow.continueOnError) {
continue;
}
throw error;
}
}
if (runOnce) {
await writeMarker(context.node, context.serverUuid, context.event, workflow.id, {
workflowId: workflow.id,
event: context.event,
completedAt: new Date().toISOString(),
});
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Automation workflow completed',
);
result.workflowsExecuted += 1;
} catch (error) {
const message = errorMessage(error);
result.workflowsFailed += 1;
result.failures.push({
level: 'workflow',
workflowId: workflow.id,
message,
});
app.log.error(
{
err: error,
errorMessage: message,
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Automation workflow failed',
);
}
}
return result;
}