feat: add Screeps console monitoring with configurable error handling and shard support
This commit is contained in:
+361
@@ -0,0 +1,361 @@
|
||||
import * as core from "@actions/core";
|
||||
import { DefaultArtifactClient } from "@actions/artifact";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Shard / subscribe-path helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the hostname is the official Screeps server.
|
||||
* Used to decide whether to prefix the subscribe path with a shard name.
|
||||
*
|
||||
* @param {string} hostname - e.g. "screeps.com" or "builder64"
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOfficialServer(hostname) {
|
||||
return hostname === "screeps.com";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the channel path argument passed to socket.subscribe().
|
||||
*
|
||||
* The screeps-api socket automatically prefixes `user:<id>/` when the path
|
||||
* does not match the `type:id` pattern, so we only supply the channel part:
|
||||
* If shard is provided → "<shard>/console"
|
||||
* Official server → "shard0/console" (if no shard provided)
|
||||
* Private server → "console" (if no shard provided)
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @param {string} [shard]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSubscribePath(hostname, shard) {
|
||||
if (shard) return `${shard}/console`;
|
||||
return isOfficialServer(hostname) ? "shard0/console" : "console";
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Detection helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when errorText contains JavaScript stack-frame lines.
|
||||
* Screeps places runtime errors (including stack traces) in event.data.error.
|
||||
* A traceback is identified by lines beginning with four spaces followed by "at ".
|
||||
*
|
||||
* @param {string|null|undefined} errorText - Contents of event.data.error
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function detectTraceback(errorText) {
|
||||
if (!errorText) return false;
|
||||
return /^\s{4}at /m.test(errorText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when any log line contains Screeps console.warn markup.
|
||||
* Screeps wraps console.warn() output in orange or yellow <font> HTML tags.
|
||||
*
|
||||
* @param {string[]|null|undefined} logLines - Contents of event.data.messages.log
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function detectWarning(logLines) {
|
||||
if (!logLines || logLines.length === 0) return false;
|
||||
const warnPattern = /<font\s+color=['"](?:orange|yellow)['"]/i;
|
||||
return logLines.some((line) => warnPattern.test(line));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Output helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds a CI-friendly progress string for core.info().
|
||||
*
|
||||
* @param {number} elapsed - Ticks elapsed since monitoring started
|
||||
* @param {number} total - Total ticks to monitor
|
||||
* @returns {string} - e.g. "[Monitor] 10/50 ticks elapsed..."
|
||||
*/
|
||||
export function buildProgressMessage(elapsed, total) {
|
||||
return `[Monitor] ${elapsed}/${total} ticks elapsed...`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// File / artifact helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Writes an array of log lines to a UTF-8 text file, one line per entry.
|
||||
*
|
||||
* @param {string[]} lines - Lines to write
|
||||
* @param {string} filePath - Absolute path to write to
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeLogFile(lines, filePath) {
|
||||
await fs.promises.writeFile(filePath, lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file as a named workflow artifact using @actions/artifact.
|
||||
* Degrades gracefully to core.warning() if the runner does not support the
|
||||
* artifact service (e.g. a bare self-hosted runner without the service configured).
|
||||
*
|
||||
* @param {string} filePath - Absolute path of the file to upload
|
||||
* @param {string} [artifactName="screeps-console-log"] - Artifact display name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function uploadLogArtifact(
|
||||
filePath,
|
||||
artifactName = "screeps-console-log",
|
||||
) {
|
||||
try {
|
||||
const client = new DefaultArtifactClient();
|
||||
await client.uploadArtifact(
|
||||
artifactName,
|
||||
[filePath],
|
||||
path.dirname(filePath),
|
||||
);
|
||||
core.info(`[Monitor] Console log uploaded as artifact '${artifactName}'.`);
|
||||
} catch (err) {
|
||||
core.warning(
|
||||
`[Monitor] Could not upload console log as artifact: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Console event handler
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Processes a single 'console' WebSocket event from the Screeps socket.
|
||||
* Mutates `state` and `stdoutBuffer` in place; never throws.
|
||||
*
|
||||
* WebSocket event.data shape:
|
||||
* {
|
||||
* messages: {
|
||||
* log: string[], // stdout (console.warn included with HTML markup)
|
||||
* results: string[], // return values of console-evaluated expressions
|
||||
* },
|
||||
* error: string | null, // stderr, runtime errors, tracebacks
|
||||
* }
|
||||
*
|
||||
* Behaviour:
|
||||
* - Warn lines (orange/yellow <font> tags) → always core.warning() (live),
|
||||
* sets state.sawWarningLog, still included in stdoutBuffer / core.info().
|
||||
* - Error field → always core.error() (live), sets state.sawErrorLog.
|
||||
* If a stack frame is detected → also sets state.sawTraceback.
|
||||
* - All stdout lines → core.info() when logToFile=false,
|
||||
* pushed to stdoutBuffer when logToFile=true.
|
||||
*
|
||||
* @param {{ data?: { messages?: { log?: string[], results?: string[] }, error?: string } }} event
|
||||
* @param {{ logToFile: boolean }} opts
|
||||
* @param {string[]} stdoutBuffer - Mutable buffer used in logToFile mode
|
||||
* @param {{ sawTraceback: boolean, sawErrorLog: boolean, sawWarningLog: boolean }} state
|
||||
*/
|
||||
export function handleConsoleEvent(event, opts, stdoutBuffer, state) {
|
||||
const { logToFile } = opts;
|
||||
const data = event?.data ?? {};
|
||||
const logLines = data?.messages?.log ?? [];
|
||||
const results = data?.messages?.results ?? [];
|
||||
const errorText = data?.error ?? null;
|
||||
|
||||
// ── Warn detection (always live regardless of logToFile) ─────────────────
|
||||
if (detectWarning(logLines)) {
|
||||
state.sawWarningLog = true;
|
||||
const warnPattern = /<font\s+color=['"](?:orange|yellow)['"]/i;
|
||||
logLines.filter((l) => warnPattern.test(l)).forEach((l) => core.warning(l));
|
||||
}
|
||||
|
||||
// ── Stdout lines ──────────────────────────────────────────────────────────
|
||||
const allStdout = [...logLines, ...results];
|
||||
if (allStdout.length > 0) {
|
||||
if (logToFile) {
|
||||
stdoutBuffer.push(...allStdout);
|
||||
} else {
|
||||
allStdout.forEach((l) => core.info(l));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error field (always live) ─────────────────────────────────────────────
|
||||
if (errorText) {
|
||||
state.sawErrorLog = true;
|
||||
core.error(errorText);
|
||||
if (detectTraceback(errorText)) {
|
||||
state.sawTraceback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Tick polling
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sleeps for the given number of milliseconds.
|
||||
*
|
||||
* @param {number} ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GET /api/game/time every `intervalMs` milliseconds until the tick
|
||||
* delta (currentTick - startTick) reaches or exceeds `targetTicks`.
|
||||
* Calls `onProgress(elapsed, targetTicks)` on every poll so the caller can
|
||||
* log progress at whatever cadence it chooses.
|
||||
*
|
||||
* @param {import('screeps-api').ScreepsAPI} api
|
||||
* @param {number} startTick - Tick number recorded before monitoring started
|
||||
* @param {number} targetTicks - Stop when (currentTick - startTick) >= this
|
||||
* @param {string|undefined} shard - "shard0" for official, undefined for private
|
||||
* @param {number} intervalMs - Poll interval in milliseconds
|
||||
* @param {(elapsed: number, total: number) => void} onProgress
|
||||
* @returns {Promise<number>} Final elapsed tick count
|
||||
*/
|
||||
export async function pollUntilDone(
|
||||
api,
|
||||
startTick,
|
||||
targetTicks,
|
||||
shard,
|
||||
intervalMs,
|
||||
onProgress,
|
||||
) {
|
||||
let elapsed = 0;
|
||||
while (elapsed < targetTicks) {
|
||||
await sleep(intervalMs);
|
||||
const { time } = await api.time(shard);
|
||||
elapsed = time - startTick;
|
||||
onProgress(elapsed, targetTicks);
|
||||
}
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Main orchestrator
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} MonitorOptions
|
||||
* @property {number} monitor - Number of game ticks to collect.
|
||||
* @property {boolean} logToFile - Buffer stdout to artifact instead of streaming.
|
||||
* @property {'ignore'|'warn'|'fail'} onTraceback - Action on traceback detection.
|
||||
* @property {'ignore'|'warn'|'fail'} onErrorLog - Action on any error-console output.
|
||||
* @property {'ignore'|'warn'|'fail'} onWarningLog - Action on console.warn output.
|
||||
* @property {number} monitorInterval - Print a progress update every N ticks.
|
||||
* @property {string} hostname - Screeps hostname (for shard derivation).
|
||||
* @property {string} [shard] - Optional shard to monitor.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MonitorResult
|
||||
* @property {boolean} sawTraceback - True if a JS stack trace was detected.
|
||||
* @property {boolean} sawErrorLog - True if the error console had any output.
|
||||
* @property {boolean} sawWarningLog - True if console.warn output was detected.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Monitors the Screeps console for a given number of game ticks after a deploy.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Fetch startTick via GET /api/game/time (REST poll).
|
||||
* 2. Connect WebSocket and subscribe to the console channel.
|
||||
* 3. Run the tick-poll loop (500 ms interval) concurrently with the socket
|
||||
* event listener; the poll loop drives the stop condition.
|
||||
* 4. On each 'console' WebSocket event, delegate to handleConsoleEvent().
|
||||
* 5. When poll finishes, disconnect socket cleanly (in a finally block).
|
||||
* 6. If logToFile=true: write buffered stdout to a temp file and upload artifact.
|
||||
* 7. Return MonitorResult.
|
||||
*
|
||||
* @param {import('screeps-api').ScreepsAPI} api
|
||||
* @param {MonitorOptions} opts
|
||||
* @returns {Promise<MonitorResult>}
|
||||
*/
|
||||
export async function monitorConsole(api, opts) {
|
||||
const {
|
||||
monitor,
|
||||
logToFile,
|
||||
monitorInterval,
|
||||
hostname,
|
||||
shard: providedShard,
|
||||
} = opts;
|
||||
|
||||
// Use provided shard, or fall back to shard0 for official, or undefined for private
|
||||
const shard =
|
||||
providedShard || (isOfficialServer(hostname) ? "shard0" : undefined);
|
||||
const subscribePath = buildSubscribePath(hostname, providedShard);
|
||||
|
||||
// Shared mutable state — updated by handleConsoleEvent via event listener
|
||||
const stdoutBuffer = [];
|
||||
const state = {
|
||||
sawTraceback: false,
|
||||
sawErrorLog: false,
|
||||
sawWarningLog: false,
|
||||
};
|
||||
let lastProgressTick = 0;
|
||||
|
||||
// ── Step 1: record starting tick ─────────────────────────────────────────
|
||||
const { time: startTick } = await api.time(shard);
|
||||
|
||||
// ── Step 2: connect socket + subscribe ───────────────────────────────────
|
||||
await api.socket.connect();
|
||||
await api.socket.subscribe(subscribePath, (event) => {
|
||||
handleConsoleEvent(event, opts, stdoutBuffer, state);
|
||||
});
|
||||
|
||||
core.info(
|
||||
`[Monitor] Watching Screeps console for ${monitor} ticks` +
|
||||
(shard ? ` on ${shard}` : "") +
|
||||
"...",
|
||||
);
|
||||
|
||||
// ── Step 3 & 4: tick-poll loop ───────────────────────────────────────────
|
||||
try {
|
||||
await pollUntilDone(
|
||||
api,
|
||||
startTick,
|
||||
monitor,
|
||||
shard,
|
||||
500,
|
||||
(elapsed, total) => {
|
||||
// Print progress at configured interval boundaries
|
||||
if (
|
||||
elapsed > 0 &&
|
||||
elapsed >= lastProgressTick + monitorInterval &&
|
||||
elapsed <= total
|
||||
) {
|
||||
core.info(buildProgressMessage(elapsed, total));
|
||||
lastProgressTick = elapsed;
|
||||
}
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
// ── Step 5: always disconnect cleanly ────────────────────────────────
|
||||
api.socket.disconnect();
|
||||
}
|
||||
|
||||
// ── Step 6: artifact upload ───────────────────────────────────────────────
|
||||
if (logToFile && stdoutBuffer.length > 0) {
|
||||
const tmpFile = path.join(os.tmpdir(), "screeps_console_log.txt");
|
||||
await writeLogFile(stdoutBuffer, tmpFile);
|
||||
await uploadLogArtifact(tmpFile);
|
||||
} else if (logToFile) {
|
||||
core.info(
|
||||
"[Monitor] No stdout lines were collected; skipping artifact upload.",
|
||||
);
|
||||
}
|
||||
|
||||
core.info(
|
||||
`[Monitor] Done. sawTraceback=${state.sawTraceback} sawErrorLog=${state.sawErrorLog} sawWarningLog=${state.sawWarningLog}`,
|
||||
);
|
||||
|
||||
return {
|
||||
sawTraceback: state.sawTraceback,
|
||||
sawErrorLog: state.sawErrorLog,
|
||||
sawWarningLog: state.sawWarningLog,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user