Files
EJClaw/src/host-evidence.ts
2026-05-28 17:07:30 +09:00

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;
}