Add structured rebuild entrypoint for AstrBot
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
export const REBUILD_EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
SYNC_FAILED: 10,
|
||||
GITEA_SYNC_FAILED: 11,
|
||||
SEAFILE_SYNC_FAILED: 12,
|
||||
SCHEMA_VALIDATION_FAILED: 13,
|
||||
BUILD_FAILED: 20,
|
||||
DEPLOY_FAILED: 30,
|
||||
CONFIG_INVALID: 40,
|
||||
UNKNOWN_ERROR: 50,
|
||||
} as const;
|
||||
|
||||
export type RebuildExitCode =
|
||||
(typeof REBUILD_EXIT_CODES)[keyof typeof REBUILD_EXIT_CODES];
|
||||
|
||||
export function getRebuildExitSymbol(code: number) {
|
||||
const entry = Object.entries(REBUILD_EXIT_CODES).find(([, value]) => value === code);
|
||||
return entry?.[0] ?? 'UNRECOGNIZED_EXIT_CODE';
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import { REBUILD_EXIT_CODES, getRebuildExitSymbol } from './rebuild-codes.ts';
|
||||
|
||||
type RebuildStage = 'sync' | 'build' | 'deploy' | 'bootstrap';
|
||||
|
||||
type StepResult = {
|
||||
ok: boolean;
|
||||
exitCode: number;
|
||||
stage: RebuildStage;
|
||||
durationMs: number;
|
||||
tail: string[];
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const startedAt = new Date();
|
||||
console.log(`[rebuild] start ${startedAt.toISOString()}`);
|
||||
|
||||
const syncResult = await runNpmStep({
|
||||
stage: 'sync',
|
||||
script: 'content:sync',
|
||||
failureCode: REBUILD_EXIT_CODES.SYNC_FAILED,
|
||||
});
|
||||
|
||||
if (!syncResult.ok) {
|
||||
return finish(syncResult, startedAt);
|
||||
}
|
||||
|
||||
const buildResult = await runNpmStep({
|
||||
stage: 'build',
|
||||
script: 'build',
|
||||
failureCode: REBUILD_EXIT_CODES.BUILD_FAILED,
|
||||
});
|
||||
|
||||
return finish(buildResult, startedAt);
|
||||
}
|
||||
|
||||
async function runNpmStep(input: {
|
||||
stage: RebuildStage;
|
||||
script: string;
|
||||
failureCode: number;
|
||||
}): Promise<StepResult> {
|
||||
console.log(`[rebuild] stage=${input.stage} command="npm run ${input.script}"`);
|
||||
|
||||
const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const startedAt = Date.now();
|
||||
const tailBuffer = createTailBuffer(40);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['run', input.script], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
process.stdout.write(text);
|
||||
tailBuffer.push(text);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
process.stderr.write(text);
|
||||
tailBuffer.push(text);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const exitCode = classifySpawnError(error);
|
||||
tailBuffer.push(`[rebuild] failed to spawn command: ${error.message}\n`);
|
||||
|
||||
resolve({
|
||||
ok: false,
|
||||
exitCode,
|
||||
stage: input.stage,
|
||||
durationMs,
|
||||
tail: tailBuffer.read(),
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const normalizedExitCode =
|
||||
code === 0 ? REBUILD_EXIT_CODES.SUCCESS : normalizeStageExitCode(input.failureCode, code);
|
||||
|
||||
resolve({
|
||||
ok: normalizedExitCode === REBUILD_EXIT_CODES.SUCCESS,
|
||||
exitCode: normalizedExitCode,
|
||||
stage: input.stage,
|
||||
durationMs,
|
||||
tail: tailBuffer.read(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function finish(result: StepResult, startedAt: Date) {
|
||||
const finishedAt = new Date();
|
||||
const payload = {
|
||||
ok: result.ok,
|
||||
stage: result.stage,
|
||||
code: result.exitCode,
|
||||
symbol: getRebuildExitSymbol(result.exitCode),
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||
failedStepDurationMs: result.durationMs,
|
||||
logTail: result.tail,
|
||||
};
|
||||
|
||||
console.log(`REBUILD_RESULT ${JSON.stringify(payload)}`);
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
`[rebuild] failed stage=${result.stage} code=${result.exitCode} symbol=${payload.symbol}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`[rebuild] success code=0 symbol=${payload.symbol}`);
|
||||
}
|
||||
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
function normalizeStageExitCode(fallbackCode: number, childCode: number | null) {
|
||||
if (childCode == null) {
|
||||
return fallbackCode;
|
||||
}
|
||||
|
||||
if (Object.values(REBUILD_EXIT_CODES).includes(childCode as never)) {
|
||||
return childCode;
|
||||
}
|
||||
|
||||
return fallbackCode;
|
||||
}
|
||||
|
||||
function classifySpawnError(error: NodeJS.ErrnoException) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return REBUILD_EXIT_CODES.CONFIG_INVALID;
|
||||
}
|
||||
|
||||
return REBUILD_EXIT_CODES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
function createTailBuffer(maxLines: number) {
|
||||
const lines: string[] = [];
|
||||
|
||||
return {
|
||||
push(chunk: string) {
|
||||
const normalized = chunk.replace(/\r\n/g, '\n');
|
||||
for (const line of normalized.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
while (lines.length > maxLines) {
|
||||
lines.shift();
|
||||
}
|
||||
},
|
||||
read() {
|
||||
return [...lines];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[rebuild] unhandled failure');
|
||||
console.error(message);
|
||||
console.log(
|
||||
`REBUILD_RESULT ${JSON.stringify({
|
||||
ok: false,
|
||||
stage: 'bootstrap',
|
||||
code: REBUILD_EXIT_CODES.UNKNOWN_ERROR,
|
||||
symbol: getRebuildExitSymbol(REBUILD_EXIT_CODES.UNKNOWN_ERROR),
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
failedStepDurationMs: 0,
|
||||
logTail: [message],
|
||||
})}`,
|
||||
);
|
||||
process.exitCode = REBUILD_EXIT_CODES.UNKNOWN_ERROR;
|
||||
});
|
||||
+24
-1
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts';
|
||||
import { fetchSeafileSnapshot } from './fetch-seafile.ts';
|
||||
import { REBUILD_EXIT_CODES, getRebuildExitSymbol } from './rebuild-codes.ts';
|
||||
import {
|
||||
generatedProjectListSchema,
|
||||
generatedShareListSchema,
|
||||
@@ -203,7 +204,29 @@ async function writeJson(filePath: string, data: unknown) {
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const exitCode = classifySyncExitCode(error);
|
||||
console.error('[sync-content] failed');
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
console.error(
|
||||
`[sync-content] exit code ${exitCode} (${getRebuildExitSymbol(exitCode)})`,
|
||||
);
|
||||
process.exitCode = exitCode;
|
||||
});
|
||||
|
||||
function classifySyncExitCode(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (error instanceof SyntaxError || message.includes('[data-schema]')) {
|
||||
return REBUILD_EXIT_CODES.SCHEMA_VALIDATION_FAILED;
|
||||
}
|
||||
|
||||
if (message.includes('[fetch-gitea]')) {
|
||||
return REBUILD_EXIT_CODES.GITEA_SYNC_FAILED;
|
||||
}
|
||||
|
||||
if (message.includes('[fetch-seafile]')) {
|
||||
return REBUILD_EXIT_CODES.SEAFILE_SYNC_FAILED;
|
||||
}
|
||||
|
||||
return REBUILD_EXIT_CODES.SYNC_FAILED;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user