357 lines
8.9 KiB
TypeScript
357 lines
8.9 KiB
TypeScript
import { execFile } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { DATA_DIR, WEB_DASHBOARD } from './config.js';
|
|
import {
|
|
collectArtifactMetadata,
|
|
collectDeployState,
|
|
DEPLOY_EVIDENCE_ACTIONS,
|
|
type DeployEvidenceAction,
|
|
} from './deploy-evidence.js';
|
|
import {
|
|
DB_EVIDENCE_ACTIONS,
|
|
isDbEvidenceAction,
|
|
type DbEvidenceAction,
|
|
runDbEvidenceRequest,
|
|
} from './db-evidence.js';
|
|
import { requireDatabase } from './db/runtime-database.js';
|
|
import {
|
|
GITHUB_EVIDENCE_ACTIONS,
|
|
isGitHubEvidenceAction,
|
|
runGitHubEvidenceCommand,
|
|
type GitHubEvidenceAction,
|
|
} from './github-evidence.js';
|
|
import { resolveGroupIpcPath } from './group-folder.js';
|
|
|
|
export const HOST_EVIDENCE_ACTIONS = [
|
|
'ejclaw_service_status',
|
|
'ejclaw_service_logs',
|
|
...DEPLOY_EVIDENCE_ACTIONS,
|
|
...DB_EVIDENCE_ACTIONS,
|
|
...GITHUB_EVIDENCE_ACTIONS,
|
|
] as const;
|
|
|
|
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
|
|
|
export interface HostEvidenceRequest {
|
|
requestId: string;
|
|
action: HostEvidenceAction;
|
|
tailLines?: number;
|
|
taskId?: string;
|
|
minutes?: number;
|
|
limit?: number;
|
|
repo?: string;
|
|
prNumber?: number;
|
|
runId?: number;
|
|
workflowPath?: string;
|
|
ref?: string;
|
|
artifactKind?: string;
|
|
sourceGroup?: string;
|
|
isMain?: boolean;
|
|
}
|
|
|
|
export interface HostEvidenceResult {
|
|
ok: boolean;
|
|
action:
|
|
| HostEvidenceAction
|
|
| DbEvidenceAction
|
|
| DeployEvidenceAction
|
|
| GitHubEvidenceAction
|
|
| 'ejclaw_room_runtime';
|
|
command: string;
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode: number;
|
|
error?: string;
|
|
}
|
|
|
|
export interface HostEvidenceResponse extends HostEvidenceResult {
|
|
requestId: string;
|
|
}
|
|
|
|
interface HostEvidenceCommandSpec {
|
|
file: string;
|
|
args: string[];
|
|
commandText: string;
|
|
}
|
|
|
|
const DEFAULT_LOG_TAIL_LINES = 20;
|
|
const MAX_LOG_TAIL_LINES = 200;
|
|
const MAX_OUTPUT_CHARS = 16_000;
|
|
const COMMAND_TIMEOUT_MS = 5_000;
|
|
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
|
const PROJECT_ROOT = process.cwd();
|
|
|
|
export function isHostEvidenceAction(
|
|
value: unknown,
|
|
): value is HostEvidenceAction {
|
|
return (
|
|
typeof value === 'string' &&
|
|
HOST_EVIDENCE_ACTIONS.includes(value as HostEvidenceAction)
|
|
);
|
|
}
|
|
|
|
export function clampHostEvidenceTailLines(value?: number): number {
|
|
if (!Number.isFinite(value)) {
|
|
return DEFAULT_LOG_TAIL_LINES;
|
|
}
|
|
const normalized = Math.trunc(value as number);
|
|
return Math.min(Math.max(normalized, 1), MAX_LOG_TAIL_LINES);
|
|
}
|
|
|
|
export function buildHostEvidenceCommand(
|
|
request: HostEvidenceRequest,
|
|
): HostEvidenceCommandSpec {
|
|
switch (request.action) {
|
|
case 'ejclaw_service_status': {
|
|
const args = [
|
|
'--user',
|
|
'show',
|
|
'ejclaw',
|
|
'-p',
|
|
'Id',
|
|
'-p',
|
|
'LoadState',
|
|
'-p',
|
|
'ActiveState',
|
|
'-p',
|
|
'SubState',
|
|
'-p',
|
|
'ExecMainCode',
|
|
'-p',
|
|
'ExecMainStatus',
|
|
'-p',
|
|
'ExecMainStartTimestamp',
|
|
'-p',
|
|
'ActiveEnterTimestamp',
|
|
];
|
|
return {
|
|
file: 'systemctl',
|
|
args,
|
|
commandText: `systemctl ${args.join(' ')}`,
|
|
};
|
|
}
|
|
case 'ejclaw_service_logs': {
|
|
const tailLines = clampHostEvidenceTailLines(request.tailLines);
|
|
const args = [
|
|
'--user',
|
|
'-u',
|
|
'ejclaw',
|
|
'--no-pager',
|
|
'-n',
|
|
String(tailLines),
|
|
];
|
|
return {
|
|
file: 'journalctl',
|
|
args,
|
|
commandText: `journalctl ${args.join(' ')}`,
|
|
};
|
|
}
|
|
default:
|
|
throw new Error(
|
|
`Host evidence action has no shell command: ${request.action}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function truncateHostEvidenceText(value: string | undefined): string {
|
|
if (!value) return '';
|
|
if (value.length <= MAX_OUTPUT_CHARS) {
|
|
return value;
|
|
}
|
|
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
|
}
|
|
|
|
function execFileCapture(
|
|
file: string,
|
|
args: string[],
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(
|
|
file,
|
|
args,
|
|
{
|
|
encoding: 'utf8',
|
|
timeout: COMMAND_TIMEOUT_MS,
|
|
maxBuffer: COMMAND_MAX_BUFFER,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(
|
|
Object.assign(error, {
|
|
stdout,
|
|
stderr,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
resolve({
|
|
stdout,
|
|
stderr,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function extractExitCode(error: unknown): number {
|
|
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
const code = (error as { code?: unknown }).code;
|
|
if (typeof code === 'number') {
|
|
return code;
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
export async function runHostEvidenceRequest(
|
|
request: HostEvidenceRequest,
|
|
): Promise<HostEvidenceResult> {
|
|
let commandText = '';
|
|
try {
|
|
if (isDbEvidenceAction(request.action)) {
|
|
commandText = `internal:${request.action}`;
|
|
return {
|
|
ok: true,
|
|
action: request.action,
|
|
command: commandText,
|
|
stdout: truncateHostEvidenceText(
|
|
runDbEvidenceRequest(
|
|
requireDatabase(),
|
|
{
|
|
action: request.action,
|
|
taskId: request.taskId,
|
|
minutes: request.minutes,
|
|
limit: request.limit,
|
|
},
|
|
{
|
|
sourceGroup: request.sourceGroup ?? '',
|
|
isMain: request.isMain === true,
|
|
},
|
|
),
|
|
),
|
|
stderr: '',
|
|
exitCode: 0,
|
|
};
|
|
}
|
|
|
|
if (request.action === 'ejclaw_deploy_state') {
|
|
commandText = `internal:${request.action}`;
|
|
return {
|
|
ok: true,
|
|
action: request.action,
|
|
command: commandText,
|
|
stdout: truncateHostEvidenceText(
|
|
await collectDeployState({
|
|
projectRoot: PROJECT_ROOT,
|
|
dataDir: DATA_DIR,
|
|
dashboardStaticDir: WEB_DASHBOARD.staticDir,
|
|
}),
|
|
),
|
|
stderr: '',
|
|
exitCode: 0,
|
|
};
|
|
}
|
|
|
|
if (request.action === 'ejclaw_artifact_metadata') {
|
|
commandText = `internal:${request.action}`;
|
|
return {
|
|
ok: true,
|
|
action: request.action,
|
|
command: commandText,
|
|
stdout: truncateHostEvidenceText(
|
|
collectArtifactMetadata(
|
|
{
|
|
projectRoot: PROJECT_ROOT,
|
|
dataDir: DATA_DIR,
|
|
dashboardStaticDir: WEB_DASHBOARD.staticDir,
|
|
},
|
|
{
|
|
action: request.action,
|
|
artifactKind: request.artifactKind,
|
|
},
|
|
),
|
|
),
|
|
stderr: '',
|
|
exitCode: 0,
|
|
};
|
|
}
|
|
|
|
if (isGitHubEvidenceAction(request.action)) {
|
|
const githubResult = await runGitHubEvidenceCommand({
|
|
action: request.action,
|
|
repo: request.repo,
|
|
prNumber: request.prNumber,
|
|
runId: request.runId,
|
|
workflowPath: request.workflowPath,
|
|
ref: request.ref,
|
|
});
|
|
commandText = githubResult.command;
|
|
return {
|
|
ok: true,
|
|
action: request.action,
|
|
command: commandText,
|
|
stdout: truncateHostEvidenceText(githubResult.stdout),
|
|
stderr: truncateHostEvidenceText(githubResult.stderr),
|
|
exitCode: 0,
|
|
};
|
|
}
|
|
|
|
const command = buildHostEvidenceCommand(request);
|
|
commandText = command.commandText;
|
|
const { stdout, stderr } = await execFileCapture(
|
|
command.file,
|
|
command.args,
|
|
);
|
|
return {
|
|
ok: true,
|
|
action: request.action,
|
|
command: commandText,
|
|
stdout: truncateHostEvidenceText(stdout),
|
|
stderr: truncateHostEvidenceText(stderr),
|
|
exitCode: 0,
|
|
};
|
|
} catch (error) {
|
|
const stdout =
|
|
typeof error === 'object' && error !== null && 'stdout' in error
|
|
? String((error as { stdout?: unknown }).stdout ?? '')
|
|
: '';
|
|
const stderr =
|
|
typeof error === 'object' && error !== null && 'stderr' in error
|
|
? String((error as { stderr?: unknown }).stderr ?? '')
|
|
: '';
|
|
|
|
return {
|
|
ok: false,
|
|
action: request.action,
|
|
command:
|
|
typeof error === 'object' && error !== null && 'command' in error
|
|
? String((error as { command?: unknown }).command ?? commandText)
|
|
: commandText,
|
|
stdout: truncateHostEvidenceText(stdout),
|
|
stderr: truncateHostEvidenceText(stderr),
|
|
exitCode: extractExitCode(error),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function resolveHostEvidenceResponseDir(groupFolder: string): string {
|
|
return path.join(resolveGroupIpcPath(groupFolder), 'host-evidence-responses');
|
|
}
|
|
|
|
export function writeHostEvidenceResponse(
|
|
groupFolder: string,
|
|
response: HostEvidenceResponse,
|
|
): string {
|
|
const responseDir = resolveHostEvidenceResponseDir(groupFolder);
|
|
fs.mkdirSync(responseDir, { recursive: true });
|
|
|
|
const outputPath = path.join(responseDir, `${response.requestId}.json`);
|
|
const tempPath = `${outputPath}.tmp`;
|
|
fs.writeFileSync(tempPath, JSON.stringify(response, null, 2));
|
|
fs.renameSync(tempPath, outputPath);
|
|
return outputPath;
|
|
}
|