diff --git a/runners/agent-runner/src/host-evidence.ts b/runners/agent-runner/src/host-evidence.ts index ec75586..ef84be9 100644 --- a/runners/agent-runner/src/host-evidence.ts +++ b/runners/agent-runner/src/host-evidence.ts @@ -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]; diff --git a/runners/agent-runner/src/ipc-host-evidence-tool.ts b/runners/agent-runner/src/ipc-host-evidence-tool.ts new file mode 100644 index 0000000..5e5ca5c --- /dev/null +++ b/runners/agent-runner/src/ipc-host-evidence-tool.ts @@ -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 & { action: HostEvidenceAction }, +): Promise { + 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, + }), + ); +} diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 66fb8a7..07c6ce4 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -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.", diff --git a/runners/agent-runner/src/ipc-repo-evidence-tool.ts b/runners/agent-runner/src/ipc-repo-evidence-tool.ts new file mode 100644 index 0000000..66ec2a6 --- /dev/null +++ b/runners/agent-runner/src/ipc-repo-evidence-tool.ts @@ -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, + }; + }, + ); +} diff --git a/runners/agent-runner/src/repo-evidence.ts b/runners/agent-runner/src/repo-evidence.ts new file mode 100644 index 0000000..9780d4d --- /dev/null +++ b/runners/agent-runner/src/repo-evidence.ts @@ -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 { + 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'); +} diff --git a/runners/agent-runner/test/repo-evidence.test.ts b/runners/agent-runner/test/repo-evidence.test.ts new file mode 100644 index 0000000..99090e3 --- /dev/null +++ b/runners/agent-runner/test/repo-evidence.test.ts @@ -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'); + }); +}); diff --git a/src/db-evidence.test.ts b/src/db-evidence.test.ts new file mode 100644 index 0000000..9c02c61 --- /dev/null +++ b/src/db-evidence.test.ts @@ -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'); + }); +}); diff --git a/src/db-evidence.ts b/src/db-evidence.ts new file mode 100644 index 0000000..004cda6 --- /dev/null +++ b/src/db-evidence.ts @@ -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 | 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 | 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); + } +} diff --git a/src/deploy-evidence.test.ts b/src/deploy-evidence.test.ts new file mode 100644 index 0000000..3a80d3d --- /dev/null +++ b/src/deploy-evidence.test.ts @@ -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'), ''); + 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 }); + }); +}); diff --git a/src/deploy-evidence.ts b/src/deploy-evidence.ts new file mode 100644 index 0000000..3da2e61 --- /dev/null +++ b/src/deploy-evidence.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + ); +} diff --git a/src/github-evidence.test.ts b/src/github-evidence.test.ts new file mode 100644 index 0000000..f0ee83f --- /dev/null +++ b/src/github-evidence.test.ts @@ -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'); + }); +}); diff --git a/src/github-evidence.ts b/src/github-evidence.ts new file mode 100644 index 0000000..7808610 --- /dev/null +++ b/src/github-evidence.ts @@ -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), + }); + }, + ); + }); +} diff --git a/src/host-evidence-ipc.test.ts b/src/host-evidence-ipc.test.ts index d6afb03..13ea843 100644 --- a/src/host-evidence-ipc.test.ts +++ b/src/host-evidence-ipc.test.ts @@ -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); diff --git a/src/host-evidence.test.ts b/src/host-evidence.test.ts index 0a4f7f5..e9b6452 100644 --- a/src/host-evidence.test.ts +++ b/src/host-evidence.test.ts @@ -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); }); diff --git a/src/host-evidence.ts b/src/host-evidence.ts index 81623a9..adee13a 100644 --- a/src/host-evidence.ts +++ b/src/host-evidence.ts @@ -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 { - 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), diff --git a/src/ipc-task-processor.ts b/src/ipc-task-processor.ts index 8f7b776..49670b2 100644 --- a/src/ipc-task-processor.ts +++ b/src/ipc-task-processor.ts @@ -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, { diff --git a/src/ipc-types.ts b/src/ipc-types.ts index 8a3af35..5464fac 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -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; }