452 lines
12 KiB
TypeScript
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;
|
|
}
|