Files
EJClaw/src/host-evidence.ts

452 lines
12 KiB
TypeScript

import { execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
ARBITER_AGENT_TYPE,
ARBITER_MODEL_CONFIG,
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
CURRENT_RUNTIME_AGENT_TYPE,
DEFAULT_CLAUDE_MODEL,
DEFAULT_CODEX_MODEL,
OWNER_AGENT_TYPE,
OWNER_MODEL_CONFIG,
REVIEWER_AGENT_TYPE,
REVIEWER_MODEL_CONFIG,
REVIEWER_SERVICE_ID_FOR_TYPE,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
DATA_DIR,
WEB_DASHBOARD,
} from './config.js';
import type { AgentType } from './types.js';
import {
HOST_EVIDENCE_ACTIONS,
isHostEvidenceAction,
type HostEvidenceAction,
} from 'ejclaw-runners-shared';
import {
collectArtifactMetadata,
collectDeployState,
} from './deploy-evidence.js';
import { isDbEvidenceAction, runDbEvidenceRequest } from './db-evidence.js';
import { requireDatabase } from './db/runtime-database.js';
import {
isGitHubEvidenceAction,
runGitHubEvidenceCommand,
} from './github-evidence.js';
import { resolveGroupIpcPath } from './group-folder.js';
export { HOST_EVIDENCE_ACTIONS, isHostEvidenceAction, type HostEvidenceAction };
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 | '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 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 effectiveModelForAgentType(
agentType: AgentType | null | undefined,
configuredModel: string | undefined,
): string | null {
if (!agentType) {
return null;
}
if (configuredModel) {
return configuredModel;
}
return agentType === 'claude-code'
? DEFAULT_CLAUDE_MODEL
: DEFAULT_CODEX_MODEL;
}
function ownerServiceIdForAgentType(agentType: AgentType): string {
return agentType === 'claude-code'
? CLAUDE_SERVICE_ID
: CODEX_MAIN_SERVICE_ID;
}
function buildRoleRuntimeConfigRole(
role: 'owner' | 'reviewer' | 'arbiter',
agentType: AgentType | null | undefined,
modelConfig: {
model?: string;
effort?: string;
fallbackEnabled: boolean;
},
serviceId: string | null,
): Record<string, unknown> {
return {
role,
enabled: role !== 'arbiter' || Boolean(agentType),
agent_type: agentType ?? null,
service_id: serviceId,
configured_model: modelConfig.model ?? null,
effective_model: effectiveModelForAgentType(agentType, modelConfig.model),
effort: modelConfig.effort ?? null,
fallback_enabled: modelConfig.fallbackEnabled,
};
}
export function buildRoleRuntimeConfigEvidence(): string {
return JSON.stringify(
{
action: 'ejclaw_role_runtime_config',
current_service: {
service_id: SERVICE_ID,
session_scope: SERVICE_SESSION_SCOPE,
runtime_agent_type: CURRENT_RUNTIME_AGENT_TYPE,
},
defaults: {
claude_model: DEFAULT_CLAUDE_MODEL,
codex_model: DEFAULT_CODEX_MODEL,
},
services: {
claude: CLAUDE_SERVICE_ID,
codex_main: CODEX_MAIN_SERVICE_ID,
codex_review: CODEX_REVIEW_SERVICE_ID,
reviewer_for_type: REVIEWER_SERVICE_ID_FOR_TYPE,
arbiter: ARBITER_SERVICE_ID,
},
roles: {
owner: buildRoleRuntimeConfigRole(
'owner',
OWNER_AGENT_TYPE,
OWNER_MODEL_CONFIG,
ownerServiceIdForAgentType(OWNER_AGENT_TYPE),
),
reviewer: buildRoleRuntimeConfigRole(
'reviewer',
REVIEWER_AGENT_TYPE,
REVIEWER_MODEL_CONFIG,
REVIEWER_SERVICE_ID_FOR_TYPE,
),
arbiter: buildRoleRuntimeConfigRole(
'arbiter',
ARBITER_AGENT_TYPE,
ARBITER_MODEL_CONFIG,
ARBITER_SERVICE_ID,
),
},
},
null,
2,
);
}
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 (request.action === 'ejclaw_role_runtime_config') {
commandText = `internal:${request.action}`;
return {
ok: true,
action: request.action,
command: commandText,
stdout: truncateHostEvidenceText(buildRoleRuntimeConfigEvidence()),
stderr: '',
exitCode: 0,
};
}
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;
}