Merge pull request #164 from phj1081/codex/owner/ejclaw
feat: add read-only evidence tools
This commit is contained in:
@@ -4,6 +4,39 @@ import path from 'path';
|
||||
export const HOST_EVIDENCE_ACTIONS = [
|
||||
'ejclaw_service_status',
|
||||
'ejclaw_service_logs',
|
||||
'ejclaw_deploy_state',
|
||||
'ejclaw_artifact_metadata',
|
||||
'db_paired_task_status',
|
||||
'db_paired_task_flow',
|
||||
'db_recent_paired_failures',
|
||||
'github_pr_status',
|
||||
'github_pr_diff_stat',
|
||||
'github_run_status',
|
||||
] as const;
|
||||
|
||||
export const DB_EVIDENCE_ACTIONS = [
|
||||
'db_paired_task_status',
|
||||
'db_paired_task_flow',
|
||||
'db_recent_paired_failures',
|
||||
] as const;
|
||||
|
||||
export const DEPLOY_EVIDENCE_ACTIONS = [
|
||||
'ejclaw_deploy_state',
|
||||
'ejclaw_artifact_metadata',
|
||||
] as const;
|
||||
|
||||
export const GITHUB_EVIDENCE_ACTIONS = [
|
||||
'github_pr_status',
|
||||
'github_pr_diff_stat',
|
||||
'github_run_status',
|
||||
] as const;
|
||||
|
||||
export const ARTIFACT_EVIDENCE_KINDS = [
|
||||
'build_outputs',
|
||||
'dashboard_dist',
|
||||
'runner_dist',
|
||||
'android_debug_apk',
|
||||
'attachments_dir',
|
||||
] as const;
|
||||
|
||||
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
||||
|
||||
188
runners/agent-runner/src/ipc-host-evidence-tool.ts
Normal file
188
runners/agent-runner/src/ipc-host-evidence-tool.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ARTIFACT_EVIDENCE_KINDS,
|
||||
DB_EVIDENCE_ACTIONS,
|
||||
DEPLOY_EVIDENCE_ACTIONS,
|
||||
formatHostEvidenceResponse,
|
||||
GITHUB_EVIDENCE_ACTIONS,
|
||||
HOST_EVIDENCE_ACTIONS,
|
||||
normalizeHostEvidenceTailLines,
|
||||
type HostEvidenceAction,
|
||||
waitForHostEvidenceResponse,
|
||||
} from './host-evidence.js';
|
||||
|
||||
type IpcWriter = (dir: string, data: object) => string;
|
||||
|
||||
interface McpTextToolResult {
|
||||
[key: string]: unknown;
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface RegisterHostEvidenceToolsOptions {
|
||||
server: McpServer;
|
||||
tasksDir: string;
|
||||
responseDir: string;
|
||||
groupFolder: string;
|
||||
writeIpcFile: IpcWriter;
|
||||
}
|
||||
|
||||
async function requestHostEvidence(
|
||||
options: RegisterHostEvidenceToolsOptions,
|
||||
payload: Record<string, unknown> & { action: HostEvidenceAction },
|
||||
): Promise<McpTextToolResult> {
|
||||
const requestId = `host-evidence-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
|
||||
options.writeIpcFile(options.tasksDir, {
|
||||
type: 'host_evidence_request',
|
||||
requestId,
|
||||
...payload,
|
||||
groupFolder: options.groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await waitForHostEvidenceResponse(
|
||||
options.responseDir,
|
||||
requestId,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: formatHostEvidenceResponse(response),
|
||||
},
|
||||
],
|
||||
isError: !response.ok,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHostEvidenceTools(
|
||||
options: RegisterHostEvidenceToolsOptions,
|
||||
): void {
|
||||
const { server } = options;
|
||||
|
||||
server.tool(
|
||||
'read_host_evidence',
|
||||
'Read host-side deployment evidence through a narrow allowlist. Use this instead of broad shell access when reviewer/arbiter needs service status, deploy state, DB state, GitHub PR state, or artifact metadata.',
|
||||
{
|
||||
action: z
|
||||
.enum(HOST_EVIDENCE_ACTIONS)
|
||||
.describe(
|
||||
'Allowlisted evidence action. Prefer specific read_db_evidence/read_deploy_evidence/read_github_evidence helpers when available.',
|
||||
),
|
||||
tail_lines: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe('Only for ejclaw_service_logs. Defaults to 20.'),
|
||||
task_id: z.string().optional().describe('Only for DB task actions.'),
|
||||
minutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1440)
|
||||
.optional()
|
||||
.describe('Only for recent DB evidence. Defaults to 60.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Only for DB evidence row limits. Defaults to 20.'),
|
||||
repo: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Only for GitHub evidence, in owner/repo form.'),
|
||||
pr_number: z.number().int().positive().optional(),
|
||||
run_id: z.number().int().positive().optional(),
|
||||
artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(),
|
||||
},
|
||||
async (args) =>
|
||||
requestHostEvidence(options, {
|
||||
action: args.action,
|
||||
tail_lines:
|
||||
args.action === 'ejclaw_service_logs'
|
||||
? normalizeHostEvidenceTailLines(args.tail_lines)
|
||||
: undefined,
|
||||
task_id: args.task_id,
|
||||
minutes: args.minutes,
|
||||
limit: args.limit,
|
||||
repo: args.repo,
|
||||
pr_number: args.pr_number,
|
||||
run_id: args.run_id,
|
||||
artifact_kind: args.artifact_kind,
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'read_db_evidence',
|
||||
'Read fixed DB evidence presets without arbitrary SQL. Raw message/output bodies are not returned.',
|
||||
{
|
||||
action: z.enum(DB_EVIDENCE_ACTIONS),
|
||||
task_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Required for paired task status/flow actions.'),
|
||||
minutes: z.number().int().min(1).max(1440).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
},
|
||||
async (args) =>
|
||||
requestHostEvidence(options, {
|
||||
action: args.action,
|
||||
task_id: args.task_id,
|
||||
minutes: args.minutes,
|
||||
limit: args.limit,
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'read_deploy_evidence',
|
||||
'Read fixed deploy/artifact evidence without shell access. Returns commit, dirty state, build artifact mtimes, sizes, and hashes where applicable.',
|
||||
{
|
||||
action: z.enum(DEPLOY_EVIDENCE_ACTIONS),
|
||||
artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(),
|
||||
},
|
||||
async (args) =>
|
||||
requestHostEvidence(options, {
|
||||
action: args.action,
|
||||
artifact_kind: args.artifact_kind,
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'read_github_evidence',
|
||||
'Read fixed GitHub PR/run evidence through host gh CLI without broad shell access.',
|
||||
{
|
||||
action: z.enum(GITHUB_EVIDENCE_ACTIONS),
|
||||
repo: z.string().describe('GitHub repository in owner/repo form.'),
|
||||
pr_number: z.number().int().positive().optional(),
|
||||
run_id: z.number().int().positive().optional(),
|
||||
},
|
||||
async (args) =>
|
||||
requestHostEvidence(options, {
|
||||
action: args.action,
|
||||
repo: args.repo,
|
||||
pr_number: args.pr_number,
|
||||
run_id: args.run_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -15,13 +15,7 @@ import {
|
||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
normalizeWatchCiIntervalSeconds,
|
||||
} from './watch-ci.js';
|
||||
import {
|
||||
formatHostEvidenceResponse,
|
||||
HOST_EVIDENCE_ACTIONS,
|
||||
normalizeHostEvidenceTailLines,
|
||||
resolveHostEvidenceResponsesDir,
|
||||
waitForHostEvidenceResponse,
|
||||
} from './host-evidence.js';
|
||||
import { resolveHostEvidenceResponsesDir } from './host-evidence.js';
|
||||
import {
|
||||
computeVerificationSnapshotId,
|
||||
formatVerificationResponse,
|
||||
@@ -30,6 +24,8 @@ import {
|
||||
} from './verification.js';
|
||||
import { resolveIpcDirectories } from './ipc-paths.js';
|
||||
import { buildSendMessageIpcPayload } from './ipc-message.js';
|
||||
import { registerHostEvidenceTools } from './ipc-host-evidence-tool.js';
|
||||
import { registerRepoEvidenceTool } from './ipc-repo-evidence-tool.js';
|
||||
|
||||
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(
|
||||
process.env,
|
||||
@@ -413,69 +409,13 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'read_host_evidence',
|
||||
'Read host-side deployment evidence through a narrow allowlist. Use this instead of broad shell access when reviewer/arbiter needs service status or recent logs.',
|
||||
{
|
||||
action: z
|
||||
.enum(HOST_EVIDENCE_ACTIONS)
|
||||
.describe(
|
||||
'ejclaw_service_status=systemctl --user show ejclaw, ejclaw_service_logs=recent journalctl lines',
|
||||
),
|
||||
tail_lines: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe(
|
||||
'Only for ejclaw_service_logs. Number of recent journal lines to fetch. Defaults to 20.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `host-evidence-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'host_evidence_request',
|
||||
requestId,
|
||||
action: args.action,
|
||||
tail_lines:
|
||||
args.action === 'ejclaw_service_logs'
|
||||
? normalizeHostEvidenceTailLines(args.tail_lines)
|
||||
: undefined,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await waitForHostEvidenceResponse(
|
||||
HOST_EVIDENCE_RESPONSES_DIR,
|
||||
requestId,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: formatHostEvidenceResponse(response),
|
||||
},
|
||||
],
|
||||
isError: !response.ok,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
registerHostEvidenceTools({
|
||||
server,
|
||||
tasksDir: TASKS_DIR,
|
||||
responseDir: HOST_EVIDENCE_RESPONSES_DIR,
|
||||
groupFolder,
|
||||
writeIpcFile,
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'run_verification',
|
||||
@@ -536,6 +476,8 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
registerRepoEvidenceTool(server, REPO_ROOT);
|
||||
|
||||
server.tool(
|
||||
'list_tasks',
|
||||
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
||||
|
||||
53
runners/agent-runner/src/ipc-repo-evidence-tool.ts
Normal file
53
runners/agent-runner/src/ipc-repo-evidence-tool.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
formatRepoEvidenceResponse,
|
||||
REPO_EVIDENCE_ACTIONS,
|
||||
runRepoEvidenceRequestDirect,
|
||||
} from './repo-evidence.js';
|
||||
|
||||
export function registerRepoEvidenceTool(
|
||||
server: McpServer,
|
||||
repoRoot: string,
|
||||
): void {
|
||||
server.tool(
|
||||
'read_repo_evidence',
|
||||
'Read fixed git evidence directly from EJCLAW_WORK_DIR without broad shell access. Use this when reviewer/arbiter needs current branch, dirty state, recent commits, or a specific commit/ref.',
|
||||
{
|
||||
action: z
|
||||
.enum(REPO_EVIDENCE_ACTIONS)
|
||||
.describe(
|
||||
'git_status=current branch and dirty state, git_head=current HEAD, git_recent_log=recent commits, git_show_ref=show a specific ref/commit',
|
||||
),
|
||||
ref: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Only for git_show_ref. Defaults to HEAD.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.optional()
|
||||
.describe('Only for git_recent_log. Defaults to 10, max 30.'),
|
||||
},
|
||||
async (args) => {
|
||||
const response = await runRepoEvidenceRequestDirect(repoRoot, {
|
||||
action: args.action,
|
||||
ref: args.ref,
|
||||
limit: args.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: formatRepoEvidenceResponse(response),
|
||||
},
|
||||
],
|
||||
isError: !response.ok,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
232
runners/agent-runner/src/repo-evidence.ts
Normal file
232
runners/agent-runner/src/repo-evidence.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
export const REPO_EVIDENCE_ACTIONS = [
|
||||
'git_status',
|
||||
'git_head',
|
||||
'git_recent_log',
|
||||
'git_show_ref',
|
||||
] as const;
|
||||
|
||||
export type RepoEvidenceAction = (typeof REPO_EVIDENCE_ACTIONS)[number];
|
||||
|
||||
export interface RepoEvidenceRequest {
|
||||
action: RepoEvidenceAction;
|
||||
ref?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RepoEvidenceResponse {
|
||||
ok: boolean;
|
||||
action: RepoEvidenceAction;
|
||||
command: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
workdir: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RepoEvidenceCommandSpec {
|
||||
file: string;
|
||||
args: string[];
|
||||
commandText: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LOG_LIMIT = 10;
|
||||
const MAX_LOG_LIMIT = 30;
|
||||
const MAX_OUTPUT_CHARS = 16_000;
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/@:{}~^+-]{1,160}$/;
|
||||
|
||||
export function normalizeRepoEvidenceLimit(value?: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_LOG_LIMIT;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return Math.min(Math.max(normalized, 1), MAX_LOG_LIMIT);
|
||||
}
|
||||
|
||||
export function normalizeRepoEvidenceRef(value?: string): string {
|
||||
const ref = value?.trim() || 'HEAD';
|
||||
if (
|
||||
ref.startsWith('-') ||
|
||||
ref.includes('..') ||
|
||||
!SAFE_GIT_REF_PATTERN.test(ref)
|
||||
) {
|
||||
throw new Error(`Unsupported git ref for read-only evidence: ${ref}`);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function buildRepoEvidenceCommand(
|
||||
repoRoot: string,
|
||||
request: RepoEvidenceRequest,
|
||||
): RepoEvidenceCommandSpec {
|
||||
const gitArgs = ['-C', repoRoot];
|
||||
switch (request.action) {
|
||||
case 'git_status': {
|
||||
const args = [...gitArgs, 'status', '--short', '--branch'];
|
||||
return {
|
||||
file: 'git',
|
||||
args,
|
||||
commandText: `git ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'git_head': {
|
||||
const args = [...gitArgs, 'log', '-1', '--oneline', '--decorate'];
|
||||
return {
|
||||
file: 'git',
|
||||
args,
|
||||
commandText: `git ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'git_recent_log': {
|
||||
const limit = normalizeRepoEvidenceLimit(request.limit);
|
||||
const args = [...gitArgs, 'log', `-${limit}`, '--oneline', '--decorate'];
|
||||
return {
|
||||
file: 'git',
|
||||
args,
|
||||
commandText: `git ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'git_show_ref': {
|
||||
const ref = normalizeRepoEvidenceRef(request.ref);
|
||||
const args = [
|
||||
...gitArgs,
|
||||
'show',
|
||||
'--stat',
|
||||
'--oneline',
|
||||
'--decorate',
|
||||
'--no-ext-diff',
|
||||
'--no-renames',
|
||||
ref,
|
||||
];
|
||||
return {
|
||||
file: 'git',
|
||||
args,
|
||||
commandText: `git ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncateRepoEvidenceText(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 runRepoEvidenceRequestDirect(
|
||||
repoRoot: string,
|
||||
request: RepoEvidenceRequest,
|
||||
): Promise<RepoEvidenceResponse> {
|
||||
let command: RepoEvidenceCommandSpec;
|
||||
try {
|
||||
command = buildRepoEvidenceCommand(repoRoot, request);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
action: request.action,
|
||||
command: '',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
workdir: repoRoot,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileCapture(
|
||||
command.file,
|
||||
command.args,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: command.commandText,
|
||||
stdout: truncateRepoEvidenceText(stdout),
|
||||
stderr: truncateRepoEvidenceText(stderr),
|
||||
exitCode: 0,
|
||||
workdir: repoRoot,
|
||||
};
|
||||
} 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: command.commandText,
|
||||
stdout: truncateRepoEvidenceText(stdout),
|
||||
stderr: truncateRepoEvidenceText(stderr),
|
||||
exitCode: extractExitCode(error),
|
||||
workdir: repoRoot,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRepoEvidenceResponse(
|
||||
response: RepoEvidenceResponse,
|
||||
): string {
|
||||
const parts = [
|
||||
`Repo evidence action: ${response.action}`,
|
||||
`Workdir: ${response.workdir}`,
|
||||
`Exit code: ${response.exitCode}`,
|
||||
response.command ? `$ ${response.command}` : null,
|
||||
response.stdout ? response.stdout.trimEnd() : null,
|
||||
response.stderr ? `[stderr]\n${response.stderr.trimEnd()}` : null,
|
||||
response.error ? `[error] ${response.error}` : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
144
runners/agent-runner/test/repo-evidence.test.ts
Normal file
144
runners/agent-runner/test/repo-evidence.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRepoEvidenceCommand,
|
||||
formatRepoEvidenceResponse,
|
||||
normalizeRepoEvidenceLimit,
|
||||
normalizeRepoEvidenceRef,
|
||||
runRepoEvidenceRequestDirect,
|
||||
} from '../src/repo-evidence.js';
|
||||
|
||||
describe('repo evidence helpers', () => {
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-repo-evidence-'));
|
||||
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
|
||||
cwd: repoDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], {
|
||||
cwd: repoDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repoDir, 'README.md'), 'initial\n');
|
||||
execFileSync('git', ['add', 'README.md'], {
|
||||
cwd: repoDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync('git', ['commit', '-m', 'initial commit'], {
|
||||
cwd: repoDir,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('normalizes bounded log limits', () => {
|
||||
expect(normalizeRepoEvidenceLimit()).toBe(10);
|
||||
expect(normalizeRepoEvidenceLimit(0)).toBe(1);
|
||||
expect(normalizeRepoEvidenceLimit(3.8)).toBe(3);
|
||||
expect(normalizeRepoEvidenceLimit(100)).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects refs that can change command behavior or request ranges', () => {
|
||||
expect(normalizeRepoEvidenceRef()).toBe('HEAD');
|
||||
expect(normalizeRepoEvidenceRef(' HEAD~1 ')).toBe('HEAD~1');
|
||||
expect(() => normalizeRepoEvidenceRef('--help')).toThrow(
|
||||
'Unsupported git ref',
|
||||
);
|
||||
expect(() => normalizeRepoEvidenceRef('main..HEAD')).toThrow(
|
||||
'Unsupported git ref',
|
||||
);
|
||||
expect(() => normalizeRepoEvidenceRef('HEAD;rm')).toThrow(
|
||||
'Unsupported git ref',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds fixed git commands without invoking a shell', () => {
|
||||
expect(buildRepoEvidenceCommand(repoDir, { action: 'git_status' })).toEqual(
|
||||
{
|
||||
file: 'git',
|
||||
args: ['-C', repoDir, 'status', '--short', '--branch'],
|
||||
commandText: `git -C ${repoDir} status --short --branch`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
buildRepoEvidenceCommand(repoDir, {
|
||||
action: 'git_recent_log',
|
||||
limit: 100,
|
||||
}).args,
|
||||
).toContain('-30');
|
||||
|
||||
expect(
|
||||
buildRepoEvidenceCommand(repoDir, {
|
||||
action: 'git_show_ref',
|
||||
ref: 'HEAD',
|
||||
}).args,
|
||||
).toEqual([
|
||||
'-C',
|
||||
repoDir,
|
||||
'show',
|
||||
'--stat',
|
||||
'--oneline',
|
||||
'--decorate',
|
||||
'--no-ext-diff',
|
||||
'--no-renames',
|
||||
'HEAD',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads read-only git evidence from the target repository', async () => {
|
||||
fs.appendFileSync(path.join(repoDir, 'README.md'), 'dirty\n');
|
||||
|
||||
const status = await runRepoEvidenceRequestDirect(repoDir, {
|
||||
action: 'git_status',
|
||||
});
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.stdout).toContain('README.md');
|
||||
|
||||
const head = await runRepoEvidenceRequestDirect(repoDir, {
|
||||
action: 'git_head',
|
||||
});
|
||||
expect(head.ok).toBe(true);
|
||||
expect(head.stdout).toContain('initial commit');
|
||||
|
||||
const log = await runRepoEvidenceRequestDirect(repoDir, {
|
||||
action: 'git_recent_log',
|
||||
limit: 2,
|
||||
});
|
||||
expect(log.ok).toBe(true);
|
||||
expect(log.stdout).toContain('initial commit');
|
||||
|
||||
const shown = await runRepoEvidenceRequestDirect(repoDir, {
|
||||
action: 'git_show_ref',
|
||||
ref: 'HEAD',
|
||||
});
|
||||
expect(shown.ok).toBe(true);
|
||||
expect(shown.stdout).toContain('initial commit');
|
||||
expect(shown.stdout).toContain('README.md');
|
||||
});
|
||||
|
||||
it('formats failures into MCP-friendly evidence', async () => {
|
||||
const response = await runRepoEvidenceRequestDirect(repoDir, {
|
||||
action: 'git_show_ref',
|
||||
ref: '--help',
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(false);
|
||||
const text = formatRepoEvidenceResponse(response);
|
||||
expect(text).toContain('Repo evidence action: git_show_ref');
|
||||
expect(text).toContain(`Workdir: ${repoDir}`);
|
||||
expect(text).toContain('Exit code: 1');
|
||||
expect(text).toContain('[error] Unsupported git ref');
|
||||
});
|
||||
});
|
||||
177
src/db-evidence.test.ts
Normal file
177
src/db-evidence.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { _initTestDatabase, insertPairedTurnOutput } from './db.js';
|
||||
import { requireDatabase } from './db/runtime-database.js';
|
||||
import {
|
||||
normalizeDbEvidenceLimit,
|
||||
normalizeDbEvidenceMinutes,
|
||||
normalizeDbEvidenceTaskId,
|
||||
runDbEvidenceRequest,
|
||||
} from './db-evidence.js';
|
||||
|
||||
describe('DB evidence presets', () => {
|
||||
function seedPairedTask(): void {
|
||||
_initTestDatabase();
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_tasks (
|
||||
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
|
||||
owner_agent_type, reviewer_agent_type, arbiter_agent_type,
|
||||
status, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'task-1',
|
||||
'room-1',
|
||||
'room-folder',
|
||||
'codex-main',
|
||||
'claude',
|
||||
'codex',
|
||||
'claude-code',
|
||||
'codex',
|
||||
'active',
|
||||
'2026-05-26T00:00:00.000Z',
|
||||
'2026-05-26T00:10:00.000Z',
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turns (
|
||||
turn_id, task_id, task_updated_at, role, intent_kind, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'turn-1',
|
||||
'task-1',
|
||||
'2026-05-26T00:10:00.000Z',
|
||||
'owner',
|
||||
'owner-turn',
|
||||
'2026-05-26T00:00:01.000Z',
|
||||
'2026-05-26T00:00:02.000Z',
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turn_attempts (
|
||||
attempt_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind,
|
||||
state, executor_service_id, executor_agent_type, active_run_id,
|
||||
created_at, updated_at, completed_at, last_error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'turn-1:attempt:1',
|
||||
'turn-1',
|
||||
1,
|
||||
'task-1',
|
||||
'2026-05-26T00:10:00.000Z',
|
||||
'owner',
|
||||
'owner-turn',
|
||||
'failed',
|
||||
'codex-main',
|
||||
'codex',
|
||||
null,
|
||||
'2026-05-26T00:00:01.000Z',
|
||||
'2026-05-26T00:00:03.000Z',
|
||||
'2026-05-26T00:00:04.000Z',
|
||||
'failure with sk-12345678901234567890',
|
||||
);
|
||||
insertPairedTurnOutput(
|
||||
'task-1',
|
||||
1,
|
||||
'owner',
|
||||
'SECRET USER TEXT SHOULD NOT BE RETURNED',
|
||||
'2026-05-26T00:00:05.000Z',
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO work_items (
|
||||
group_folder, chat_jid, agent_type, service_id, delivery_role,
|
||||
status, start_seq, end_seq, result_payload, delivery_attempts,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'room-folder',
|
||||
'room-1',
|
||||
'codex',
|
||||
'codex-main',
|
||||
'owner',
|
||||
'produced',
|
||||
null,
|
||||
null,
|
||||
'raw delivery body should not be returned',
|
||||
0,
|
||||
'2026-05-26T00:00:06.000Z',
|
||||
'2026-05-26T00:00:06.000Z',
|
||||
);
|
||||
}
|
||||
|
||||
it('normalizes request bounds and validates task ids', () => {
|
||||
expect(normalizeDbEvidenceMinutes()).toBe(60);
|
||||
expect(normalizeDbEvidenceMinutes(0)).toBe(1);
|
||||
expect(normalizeDbEvidenceMinutes(99999)).toBe(1440);
|
||||
expect(normalizeDbEvidenceLimit()).toBe(20);
|
||||
expect(normalizeDbEvidenceLimit(999)).toBe(100);
|
||||
expect(normalizeDbEvidenceTaskId(' task-1 ')).toBe('task-1');
|
||||
expect(() => normalizeDbEvidenceTaskId('../bad task')).toThrow(
|
||||
'Unsupported paired task id',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns task status and flow metadata without raw bodies', () => {
|
||||
seedPairedTask();
|
||||
|
||||
const status = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||
{ sourceGroup: 'room-folder', isMain: false },
|
||||
),
|
||||
) as { task: { id: string; group_folder: string } };
|
||||
expect(status.task.id).toBe('task-1');
|
||||
expect(status.task.group_folder).toBe('room-folder');
|
||||
|
||||
const flowText = runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_flow', taskId: 'task-1' },
|
||||
{ sourceGroup: 'room-folder', isMain: false },
|
||||
);
|
||||
expect(flowText).toContain('"output_chars"');
|
||||
expect(flowText).toContain('"last_error_chars"');
|
||||
expect(flowText).not.toContain('SECRET USER TEXT');
|
||||
expect(flowText).not.toContain('raw delivery body');
|
||||
expect(flowText).not.toContain('sk-12345678901234567890');
|
||||
});
|
||||
|
||||
it('scopes non-main DB evidence to the source group', () => {
|
||||
seedPairedTask();
|
||||
|
||||
const blocked = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||
{ sourceGroup: 'other-folder', isMain: false },
|
||||
),
|
||||
) as { task: unknown };
|
||||
expect(blocked.task).toBeNull();
|
||||
|
||||
const main = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||
{ sourceGroup: 'other-folder', isMain: true },
|
||||
),
|
||||
) as { task: { id: string } };
|
||||
expect(main.task.id).toBe('task-1');
|
||||
});
|
||||
});
|
||||
347
src/db-evidence.ts
Normal file
347
src/db-evidence.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
type SqlBinding = string | number | bigint | boolean | null | Uint8Array;
|
||||
|
||||
export const DB_EVIDENCE_ACTIONS = [
|
||||
'db_paired_task_status',
|
||||
'db_paired_task_flow',
|
||||
'db_recent_paired_failures',
|
||||
] as const;
|
||||
|
||||
export type DbEvidenceAction = (typeof DB_EVIDENCE_ACTIONS)[number];
|
||||
|
||||
export interface DbEvidenceRequest {
|
||||
action: DbEvidenceAction;
|
||||
taskId?: string;
|
||||
minutes?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DbEvidenceScope {
|
||||
sourceGroup: string;
|
||||
isMain: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RECENT_MINUTES = 60;
|
||||
const MAX_RECENT_MINUTES = 24 * 60;
|
||||
const DEFAULT_ROW_LIMIT = 20;
|
||||
const MAX_ROW_LIMIT = 100;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,200}$/;
|
||||
|
||||
export function isDbEvidenceAction(value: unknown): value is DbEvidenceAction {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DB_EVIDENCE_ACTIONS.includes(value as DbEvidenceAction)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDbEvidenceMinutes(value?: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_RECENT_MINUTES;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return Math.min(Math.max(normalized, 1), MAX_RECENT_MINUTES);
|
||||
}
|
||||
|
||||
export function normalizeDbEvidenceLimit(value?: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_ROW_LIMIT;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return Math.min(Math.max(normalized, 1), MAX_ROW_LIMIT);
|
||||
}
|
||||
|
||||
export function normalizeDbEvidenceTaskId(value?: string): string {
|
||||
const taskId = value?.trim();
|
||||
if (!taskId || !TASK_ID_PATTERN.test(taskId)) {
|
||||
throw new Error(`Unsupported paired task id for DB evidence: ${value}`);
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
function stringifyEvidence(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function groupScopeClause(scope: DbEvidenceScope): {
|
||||
clause: string;
|
||||
params: SqlBinding[];
|
||||
} {
|
||||
return scope.isMain
|
||||
? { clause: '', params: [] }
|
||||
: { clause: ' AND group_folder = ?', params: [scope.sourceGroup] };
|
||||
}
|
||||
|
||||
function getScopedTask(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
scope: DbEvidenceScope,
|
||||
): Record<string, unknown> | null {
|
||||
const groupScope = groupScopeClause(scope);
|
||||
const row = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
status,
|
||||
round_trip_count,
|
||||
owner_failure_count,
|
||||
owner_step_done_streak,
|
||||
finalize_step_done_count,
|
||||
task_done_then_user_reopen_count,
|
||||
empty_step_done_streak,
|
||||
arbiter_verdict,
|
||||
arbiter_requested_at,
|
||||
completion_reason,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM paired_tasks
|
||||
WHERE id = ?
|
||||
${groupScope.clause}
|
||||
`,
|
||||
)
|
||||
.get(taskId, ...groupScope.params) as Record<string, unknown> | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
function runPairedTaskStatus(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const taskId = normalizeDbEvidenceTaskId(request.taskId);
|
||||
const task = getScopedTask(database, taskId, scope);
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
task,
|
||||
});
|
||||
}
|
||||
|
||||
function runPairedTaskFlow(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const taskId = normalizeDbEvidenceTaskId(request.taskId);
|
||||
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||
const task = getScopedTask(database, taskId, scope);
|
||||
if (!task) {
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
task: null,
|
||||
turns: [],
|
||||
attempts: [],
|
||||
outputs: [],
|
||||
deliveries: [],
|
||||
});
|
||||
}
|
||||
|
||||
const turns = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT turn_id,
|
||||
role,
|
||||
intent_kind,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM paired_turns
|
||||
WHERE task_id = ?
|
||||
ORDER BY updated_at, created_at, turn_id
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit);
|
||||
|
||||
const attempts = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT attempt_id,
|
||||
parent_attempt_id,
|
||||
turn_id,
|
||||
attempt_no,
|
||||
role,
|
||||
intent_kind,
|
||||
state,
|
||||
executor_agent_type,
|
||||
active_run_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
length(last_error) AS last_error_chars
|
||||
FROM paired_turn_attempts
|
||||
WHERE task_id = ?
|
||||
ORDER BY updated_at, attempt_no, attempt_id
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit);
|
||||
|
||||
const outputs = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT turn_number,
|
||||
role,
|
||||
verdict,
|
||||
created_at,
|
||||
length(output_text) AS output_chars
|
||||
FROM paired_turn_outputs
|
||||
WHERE task_id = ?
|
||||
ORDER BY turn_number, created_at, role
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit);
|
||||
|
||||
const deliveries = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
delivery_attempts,
|
||||
created_at,
|
||||
updated_at,
|
||||
delivered_at,
|
||||
length(result_payload) AS result_payload_chars,
|
||||
length(last_error) AS last_error_chars
|
||||
FROM work_items
|
||||
WHERE chat_jid = ?
|
||||
AND created_at >= ?
|
||||
AND created_at <= datetime(?, '+1 minute')
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(
|
||||
String(task.chat_jid),
|
||||
String(task.created_at),
|
||||
String(task.updated_at),
|
||||
limit,
|
||||
);
|
||||
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
task,
|
||||
turns,
|
||||
attempts,
|
||||
outputs,
|
||||
deliveries,
|
||||
});
|
||||
}
|
||||
|
||||
function runRecentPairedFailures(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const minutes = normalizeDbEvidenceMinutes(request.minutes);
|
||||
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
const groupScope = groupScopeClause(scope);
|
||||
|
||||
const tasks = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
status,
|
||||
owner_failure_count,
|
||||
arbiter_verdict,
|
||||
completion_reason,
|
||||
arbiter_requested_at,
|
||||
updated_at
|
||||
FROM paired_tasks
|
||||
WHERE updated_at >= ?
|
||||
AND (
|
||||
owner_failure_count > 0
|
||||
OR status = 'arbiter_requested'
|
||||
OR arbiter_verdict IS NOT NULL
|
||||
OR completion_reason IS NOT NULL
|
||||
)
|
||||
${groupScope.clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, ...groupScope.params, limit);
|
||||
|
||||
const attempts = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT attempts.task_id,
|
||||
attempts.turn_id,
|
||||
attempts.attempt_no,
|
||||
attempts.role,
|
||||
attempts.intent_kind,
|
||||
attempts.state,
|
||||
attempts.executor_agent_type,
|
||||
attempts.active_run_id,
|
||||
attempts.updated_at,
|
||||
attempts.completed_at,
|
||||
length(attempts.last_error) AS last_error_chars
|
||||
FROM paired_turn_attempts attempts
|
||||
JOIN paired_tasks tasks
|
||||
ON tasks.id = attempts.task_id
|
||||
WHERE attempts.updated_at >= ?
|
||||
AND (
|
||||
attempts.state = 'failed'
|
||||
OR attempts.last_error IS NOT NULL
|
||||
)
|
||||
${scope.isMain ? '' : 'AND tasks.group_folder = ?'}
|
||||
ORDER BY attempts.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
|
||||
|
||||
const deliveryRetries = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
delivery_attempts,
|
||||
updated_at,
|
||||
length(last_error) AS last_error_chars
|
||||
FROM work_items
|
||||
WHERE updated_at >= ?
|
||||
AND status = 'delivery_retry'
|
||||
${scope.isMain ? '' : 'AND group_folder = ?'}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
|
||||
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
window_minutes: minutes,
|
||||
cutoff,
|
||||
tasks,
|
||||
attempts,
|
||||
delivery_retries: deliveryRetries,
|
||||
});
|
||||
}
|
||||
|
||||
export function runDbEvidenceRequest(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
switch (request.action) {
|
||||
case 'db_paired_task_status':
|
||||
return runPairedTaskStatus(database, request, scope);
|
||||
case 'db_paired_task_flow':
|
||||
return runPairedTaskFlow(database, request, scope);
|
||||
case 'db_recent_paired_failures':
|
||||
return runRecentPairedFailures(database, request, scope);
|
||||
}
|
||||
}
|
||||
45
src/deploy-evidence.test.ts
Normal file
45
src/deploy-evidence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectArtifactMetadata,
|
||||
normalizeArtifactEvidenceKind,
|
||||
} from './deploy-evidence.js';
|
||||
|
||||
describe('deploy evidence helpers', () => {
|
||||
it('normalizes fixed artifact kinds', () => {
|
||||
expect(normalizeArtifactEvidenceKind()).toBe('build_outputs');
|
||||
expect(normalizeArtifactEvidenceKind('dashboard_dist')).toBe(
|
||||
'dashboard_dist',
|
||||
);
|
||||
expect(() => normalizeArtifactEvidenceKind('/etc/passwd')).toThrow(
|
||||
'Unsupported artifact evidence kind',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns metadata only for build artifacts', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-deploy-'));
|
||||
const dashboardDir = path.join(root, 'apps', 'dashboard', 'dist');
|
||||
fs.mkdirSync(path.join(dashboardDir, 'assets'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dashboardDir, 'index.html'), '<html></html>');
|
||||
fs.writeFileSync(path.join(dashboardDir, 'assets', 'index.js'), 'secret');
|
||||
|
||||
const text = collectArtifactMetadata(
|
||||
{
|
||||
projectRoot: root,
|
||||
dataDir: path.join(root, 'data'),
|
||||
dashboardStaticDir: dashboardDir,
|
||||
},
|
||||
{ action: 'ejclaw_artifact_metadata', artifactKind: 'dashboard_dist' },
|
||||
);
|
||||
|
||||
expect(text).toContain('"file_count"');
|
||||
expect(text).toContain('"total_bytes"');
|
||||
expect(text).toContain('index.js');
|
||||
expect(text).not.toContain('"secret"');
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
271
src/deploy-evidence.ts
Normal file
271
src/deploy-evidence.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { execFile } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const DEPLOY_EVIDENCE_ACTIONS = [
|
||||
'ejclaw_deploy_state',
|
||||
'ejclaw_artifact_metadata',
|
||||
] as const;
|
||||
|
||||
export const ARTIFACT_EVIDENCE_KINDS = [
|
||||
'build_outputs',
|
||||
'dashboard_dist',
|
||||
'runner_dist',
|
||||
'android_debug_apk',
|
||||
'attachments_dir',
|
||||
] as const;
|
||||
|
||||
export type DeployEvidenceAction = (typeof DEPLOY_EVIDENCE_ACTIONS)[number];
|
||||
export type ArtifactEvidenceKind = (typeof ARTIFACT_EVIDENCE_KINDS)[number];
|
||||
|
||||
export interface DeployEvidenceRequest {
|
||||
action: DeployEvidenceAction;
|
||||
artifactKind?: string;
|
||||
}
|
||||
|
||||
export interface DeployEvidencePaths {
|
||||
projectRoot: string;
|
||||
dataDir: string;
|
||||
dashboardStaticDir: string;
|
||||
}
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||
const MAX_DIR_ENTRIES = 5_000;
|
||||
const MAX_LATEST_FILES = 12;
|
||||
|
||||
export function isDeployEvidenceAction(
|
||||
value: unknown,
|
||||
): value is DeployEvidenceAction {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DEPLOY_EVIDENCE_ACTIONS.includes(value as DeployEvidenceAction)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeArtifactEvidenceKind(
|
||||
value?: string,
|
||||
): ArtifactEvidenceKind {
|
||||
if (!value) return 'build_outputs';
|
||||
if (ARTIFACT_EVIDENCE_KINDS.includes(value as ArtifactEvidenceKind)) {
|
||||
return value as ArtifactEvidenceKind;
|
||||
}
|
||||
throw new Error(`Unsupported artifact evidence kind: ${value}`);
|
||||
}
|
||||
|
||||
function execFileText(file: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: COMMAND_MAX_BUFFER,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const details = stderr?.trim() || stdout?.trim() || error.message;
|
||||
reject(new Error(`${file} ${args.join(' ')} failed: ${details}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function fileMetadata(filePath: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
const base = {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other',
|
||||
size_bytes: stat.size,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
};
|
||||
|
||||
if (!stat.isFile()) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
sha256: sha256File(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function walkDirectory(root: string): Array<{ path: string; stat: fs.Stats }> {
|
||||
if (!fs.existsSync(root)) return [];
|
||||
const pending = [root];
|
||||
const files: Array<{ path: string; stat: fs.Stats }> = [];
|
||||
|
||||
while (pending.length > 0 && files.length < MAX_DIR_ENTRIES) {
|
||||
const current = pending.pop()!;
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
files.push({ path: entryPath, stat: fs.statSync(entryPath) });
|
||||
if (files.length >= MAX_DIR_ENTRIES) break;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function directoryMetadata(root: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(root)) {
|
||||
return {
|
||||
path: root,
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
const files = walkDirectory(root);
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.stat.size, 0);
|
||||
const latestFiles = [...files]
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
|
||||
.slice(0, MAX_LATEST_FILES)
|
||||
.map((file) => ({
|
||||
relative_path: path.relative(root, file.path),
|
||||
size_bytes: file.stat.size,
|
||||
mtime: file.stat.mtime.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
path: root,
|
||||
exists: true,
|
||||
file_count: files.length,
|
||||
total_bytes: totalBytes,
|
||||
truncated: files.length >= MAX_DIR_ENTRIES,
|
||||
latest_files: latestFiles,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOutputArtifacts(
|
||||
paths: DeployEvidencePaths,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
root_dist: fileMetadata(path.join(paths.projectRoot, 'dist', 'index.js')),
|
||||
dashboard_index: fileMetadata(
|
||||
path.join(paths.dashboardStaticDir, 'index.html'),
|
||||
),
|
||||
dashboard_assets: directoryMetadata(
|
||||
path.join(paths.dashboardStaticDir, 'assets'),
|
||||
),
|
||||
agent_runner: fileMetadata(
|
||||
path.join(
|
||||
paths.projectRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'index.js',
|
||||
),
|
||||
),
|
||||
codex_runner: fileMetadata(
|
||||
path.join(
|
||||
paths.projectRoot,
|
||||
'runners',
|
||||
'codex-runner',
|
||||
'dist',
|
||||
'index.js',
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectDeployState(
|
||||
paths: DeployEvidencePaths,
|
||||
): Promise<string> {
|
||||
const [head, logLine, status] = await Promise.all([
|
||||
execFileText('git', ['-C', paths.projectRoot, 'rev-parse', 'HEAD']),
|
||||
execFileText('git', [
|
||||
'-C',
|
||||
paths.projectRoot,
|
||||
'log',
|
||||
'-1',
|
||||
'--oneline',
|
||||
'--decorate',
|
||||
]),
|
||||
execFileText('git', ['-C', paths.projectRoot, 'status', '--short']),
|
||||
]);
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
action: 'ejclaw_deploy_state',
|
||||
project_root: paths.projectRoot,
|
||||
git: {
|
||||
head,
|
||||
log: logLine,
|
||||
dirty: status.length > 0,
|
||||
status_short: status,
|
||||
},
|
||||
artifacts: buildOutputArtifacts(paths),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function collectArtifactMetadata(
|
||||
paths: DeployEvidencePaths,
|
||||
request: DeployEvidenceRequest,
|
||||
): string {
|
||||
const kind = normalizeArtifactEvidenceKind(request.artifactKind);
|
||||
const payload =
|
||||
kind === 'build_outputs'
|
||||
? buildOutputArtifacts(paths)
|
||||
: kind === 'dashboard_dist'
|
||||
? directoryMetadata(paths.dashboardStaticDir)
|
||||
: kind === 'runner_dist'
|
||||
? {
|
||||
agent_runner: directoryMetadata(
|
||||
path.join(paths.projectRoot, 'runners', 'agent-runner', 'dist'),
|
||||
),
|
||||
codex_runner: directoryMetadata(
|
||||
path.join(paths.projectRoot, 'runners', 'codex-runner', 'dist'),
|
||||
),
|
||||
}
|
||||
: kind === 'android_debug_apk'
|
||||
? fileMetadata(
|
||||
path.join(
|
||||
paths.projectRoot,
|
||||
'apps',
|
||||
'android',
|
||||
'app',
|
||||
'build',
|
||||
'outputs',
|
||||
'apk',
|
||||
'debug',
|
||||
'app-debug.apk',
|
||||
),
|
||||
)
|
||||
: directoryMetadata(path.join(paths.dataDir, 'attachments'));
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
action: 'ejclaw_artifact_metadata',
|
||||
artifact_kind: kind,
|
||||
payload,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
46
src/github-evidence.test.ts
Normal file
46
src/github-evidence.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGitHubEvidenceCommand,
|
||||
normalizeGitHubRepo,
|
||||
normalizePositiveInteger,
|
||||
} from './github-evidence.js';
|
||||
|
||||
describe('GitHub evidence helpers', () => {
|
||||
it('validates repo and numeric identifiers', () => {
|
||||
expect(normalizeGitHubRepo('owner/repo')).toBe('owner/repo');
|
||||
expect(() => normalizeGitHubRepo('../repo')).toThrow(
|
||||
'Unsupported GitHub repo',
|
||||
);
|
||||
expect(normalizePositiveInteger(12, 'pr_number')).toBe(12);
|
||||
expect(() => normalizePositiveInteger(0, 'pr_number')).toThrow(
|
||||
'Missing or invalid pr_number',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds fixed gh commands', () => {
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_pr_status',
|
||||
repo: 'owner/repo',
|
||||
prNumber: 164,
|
||||
}).args,
|
||||
).toEqual([
|
||||
'pr',
|
||||
'view',
|
||||
'164',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--json',
|
||||
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_run_status',
|
||||
repo: 'owner/repo',
|
||||
runId: 123,
|
||||
}).args,
|
||||
).toContain('123');
|
||||
});
|
||||
});
|
||||
153
src/github-evidence.ts
Normal file
153
src/github-evidence.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
export const GITHUB_EVIDENCE_ACTIONS = [
|
||||
'github_pr_status',
|
||||
'github_pr_diff_stat',
|
||||
'github_run_status',
|
||||
] as const;
|
||||
|
||||
export type GitHubEvidenceAction = (typeof GITHUB_EVIDENCE_ACTIONS)[number];
|
||||
|
||||
export interface GitHubEvidenceRequest {
|
||||
action: GitHubEvidenceAction;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
runId?: number;
|
||||
}
|
||||
|
||||
interface GitHubCommandSpec {
|
||||
file: string;
|
||||
args: string[];
|
||||
commandText: string;
|
||||
}
|
||||
|
||||
const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const COMMAND_TIMEOUT_MS = 10_000;
|
||||
const COMMAND_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
const MAX_OUTPUT_CHARS = 24_000;
|
||||
|
||||
export function isGitHubEvidenceAction(
|
||||
value: unknown,
|
||||
): value is GitHubEvidenceAction {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
GITHUB_EVIDENCE_ACTIONS.includes(value as GitHubEvidenceAction)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeGitHubRepo(value?: string): string {
|
||||
const repo = value?.trim();
|
||||
if (!repo || repo.includes('..') || !REPO_PATTERN.test(repo)) {
|
||||
throw new Error(`Unsupported GitHub repo for evidence: ${value}`);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
export function normalizePositiveInteger(
|
||||
value: number | undefined,
|
||||
label: string,
|
||||
): number {
|
||||
if (value == null || !Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`Missing or invalid ${label} for GitHub evidence`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildGitHubEvidenceCommand(
|
||||
request: GitHubEvidenceRequest,
|
||||
): GitHubCommandSpec {
|
||||
const repo = normalizeGitHubRepo(request.repo);
|
||||
|
||||
switch (request.action) {
|
||||
case 'github_pr_status': {
|
||||
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
|
||||
const args = [
|
||||
'pr',
|
||||
'view',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
|
||||
];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'github_pr_diff_stat': {
|
||||
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
|
||||
const args = ['pr', 'diff', String(prNumber), '--repo', repo, '--stat'];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'github_run_status': {
|
||||
const runId = normalizePositiveInteger(request.runId, 'run_id');
|
||||
const args = [
|
||||
'run',
|
||||
'view',
|
||||
String(runId),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'status,conclusion,name,displayTitle,url,headBranch,headSha,event,createdAt,updatedAt',
|
||||
];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncateGitHubEvidenceText(value: string | undefined): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= MAX_OUTPUT_CHARS) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
||||
}
|
||||
|
||||
export function runGitHubEvidenceCommand(
|
||||
request: GitHubEvidenceRequest,
|
||||
): Promise<{
|
||||
command: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const command = buildGitHubEvidenceCommand(request);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command.file,
|
||||
command.args,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: COMMAND_MAX_BUFFER,
|
||||
env: process.env,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
Object.assign(error, {
|
||||
command: command.commandText,
|
||||
stdout: truncateGitHubEvidenceText(stdout),
|
||||
stderr: truncateGitHubEvidenceText(stderr),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
command: command.commandText,
|
||||
stdout: truncateGitHubEvidenceText(stdout),
|
||||
stderr: truncateGitHubEvidenceText(stderr),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -89,6 +89,15 @@ describe('host evidence IPC', () => {
|
||||
requestId: 'req-1',
|
||||
action: 'ejclaw_service_status',
|
||||
tailLines: undefined,
|
||||
taskId: undefined,
|
||||
minutes: undefined,
|
||||
limit: undefined,
|
||||
repo: undefined,
|
||||
prNumber: undefined,
|
||||
runId: undefined,
|
||||
artifactKind: undefined,
|
||||
sourceGroup: 'other-group',
|
||||
isMain: false,
|
||||
});
|
||||
expect(response.requestId).toBe('req-1');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
@@ -10,6 +10,9 @@ describe('host evidence helpers', () => {
|
||||
it('recognizes only allowlisted actions', () => {
|
||||
expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('ejclaw_service_logs')).toBe(true);
|
||||
expect(isHostEvidenceAction('db_paired_task_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_pr_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,34 @@ 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];
|
||||
@@ -15,11 +38,25 @@ export interface HostEvidenceRequest {
|
||||
requestId: string;
|
||||
action: HostEvidenceAction;
|
||||
tailLines?: number;
|
||||
taskId?: string;
|
||||
minutes?: number;
|
||||
limit?: number;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
runId?: number;
|
||||
artifactKind?: string;
|
||||
sourceGroup?: string;
|
||||
isMain?: boolean;
|
||||
}
|
||||
|
||||
export interface HostEvidenceResult {
|
||||
ok: boolean;
|
||||
action: HostEvidenceAction | 'ejclaw_room_runtime';
|
||||
action:
|
||||
| HostEvidenceAction
|
||||
| DbEvidenceAction
|
||||
| DeployEvidenceAction
|
||||
| GitHubEvidenceAction
|
||||
| 'ejclaw_room_runtime';
|
||||
command: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
@@ -42,6 +79,7 @@ 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,
|
||||
@@ -108,6 +146,10 @@ export function buildHostEvidenceCommand(
|
||||
commandText: `journalctl ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Host evidence action has no shell command: ${request.action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +206,96 @@ function extractExitCode(error: unknown): number {
|
||||
export async function runHostEvidenceRequest(
|
||||
request: HostEvidenceRequest,
|
||||
): Promise<HostEvidenceResult> {
|
||||
const command = buildHostEvidenceCommand(request);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
@@ -174,7 +303,7 @@ export async function runHostEvidenceRequest(
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: command.commandText,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(stdout),
|
||||
stderr: truncateHostEvidenceText(stderr),
|
||||
exitCode: 0,
|
||||
@@ -192,7 +321,10 @@ export async function runHostEvidenceRequest(
|
||||
return {
|
||||
ok: false,
|
||||
action: request.action,
|
||||
command: command.commandText,
|
||||
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),
|
||||
|
||||
@@ -320,6 +320,16 @@ async function handleHostEvidenceRequest(
|
||||
action: data.action,
|
||||
tailLines:
|
||||
typeof data.tail_lines === 'number' ? data.tail_lines : undefined,
|
||||
taskId: typeof data.task_id === 'string' ? data.task_id : undefined,
|
||||
minutes: typeof data.minutes === 'number' ? data.minutes : undefined,
|
||||
limit: typeof data.limit === 'number' ? data.limit : undefined,
|
||||
repo: typeof data.repo === 'string' ? data.repo : undefined,
|
||||
prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined,
|
||||
runId: typeof data.run_id === 'number' ? data.run_id : undefined,
|
||||
artifactKind:
|
||||
typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined,
|
||||
sourceGroup,
|
||||
isMain,
|
||||
});
|
||||
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
|
||||
@@ -144,6 +144,13 @@ export interface TaskIpcPayload {
|
||||
requestId?: string;
|
||||
action?: string;
|
||||
tail_lines?: number;
|
||||
task_id?: string;
|
||||
minutes?: number;
|
||||
limit?: number;
|
||||
repo?: string;
|
||||
pr_number?: number;
|
||||
run_id?: number;
|
||||
artifact_kind?: string;
|
||||
profile?: string;
|
||||
expected_snapshot_id?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user