Merge pull request #164 from phj1081/codex/owner/ejclaw

feat: add read-only evidence tools
This commit is contained in:
Eyejoker
2026-05-26 02:15:16 +09:00
committed by GitHub
17 changed files with 1867 additions and 75 deletions

View File

@@ -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];

View 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,
}),
);
}

View File

@@ -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,
registerHostEvidenceTools({
server,
tasksDir: TASKS_DIR,
responseDir: HOST_EVIDENCE_RESPONSES_DIR,
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,
};
}
},
);
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.",

View 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,
};
},
);
}

View 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');
}

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

View 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
View 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,
);
}

View 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
View 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),
});
},
);
});
}

View File

@@ -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);

View File

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

View File

@@ -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),

View File

@@ -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, {

View File

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