From 0377935564e737d280ff8bc495ca08fc138ba0bd Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 29 May 2026 05:38:31 +0900 Subject: [PATCH] feat: add reviewer evidence presets (#184) --- runners/agent-runner/src/host-evidence.ts | 5 + .../src/ipc-host-evidence-tool.ts | 2 +- src/db-evidence.ts | 158 ++++++++++++++++++ src/db-scheduled-evidence.test.ts | 120 +++++++++++++ src/host-evidence.test.ts | 22 +++ src/host-evidence.ts | 124 +++++++++++++- 6 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 src/db-scheduled-evidence.test.ts diff --git a/runners/agent-runner/src/host-evidence.ts b/runners/agent-runner/src/host-evidence.ts index cedbdcf..88449b9 100644 --- a/runners/agent-runner/src/host-evidence.ts +++ b/runners/agent-runner/src/host-evidence.ts @@ -4,11 +4,14 @@ import path from 'path'; export const HOST_EVIDENCE_ACTIONS = [ 'ejclaw_service_status', 'ejclaw_service_logs', + 'ejclaw_role_runtime_config', 'ejclaw_deploy_state', 'ejclaw_artifact_metadata', 'db_paired_task_status', 'db_paired_task_flow', 'db_recent_paired_failures', + 'db_recent_scheduled_tasks', + 'db_scheduled_task_runs', 'github_pr_status', 'github_pr_diff_stat', 'github_run_status', @@ -20,6 +23,8 @@ export const DB_EVIDENCE_ACTIONS = [ 'db_paired_task_status', 'db_paired_task_flow', 'db_recent_paired_failures', + 'db_recent_scheduled_tasks', + 'db_scheduled_task_runs', ] as const; export const DEPLOY_EVIDENCE_ACTIONS = [ diff --git a/runners/agent-runner/src/ipc-host-evidence-tool.ts b/runners/agent-runner/src/ipc-host-evidence-tool.ts index 107f182..ae43b20 100644 --- a/runners/agent-runner/src/ipc-host-evidence-tool.ts +++ b/runners/agent-runner/src/ipc-host-evidence-tool.ts @@ -79,7 +79,7 @@ export function registerHostEvidenceTools( 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.', + 'Read host-side deployment evidence through a narrow allowlist. Use this instead of broad shell access when reviewer/arbiter needs service status, role runtime config, deploy state, DB state, GitHub PR state, or artifact metadata.', { action: z .enum(HOST_EVIDENCE_ACTIONS) diff --git a/src/db-evidence.ts b/src/db-evidence.ts index 004cda6..4e50fd9 100644 --- a/src/db-evidence.ts +++ b/src/db-evidence.ts @@ -1,11 +1,16 @@ import type { Database } from 'bun:sqlite'; +import { parseGitHubCiMetadata } from './github-ci.js'; +import { extractWatchCiTarget } from './task-watch-status.js'; + 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', + 'db_recent_scheduled_tasks', + 'db_scheduled_task_runs', ] as const; export type DbEvidenceAction = (typeof DB_EVIDENCE_ACTIONS)[number]; @@ -331,6 +336,155 @@ function runRecentPairedFailures( }); } +function normalizeScheduledTaskEvidenceRow( + row: Record, +): Record { + const prompt = typeof row.prompt === 'string' ? row.prompt : ''; + const metadata = parseGitHubCiMetadata( + typeof row.ci_metadata === 'string' ? row.ci_metadata : null, + ); + + return { + id: row.id, + group_folder: row.group_folder, + chat_jid: row.chat_jid, + agent_type: row.agent_type, + room_role: row.room_role, + ci_provider: row.ci_provider, + ci_repo: metadata?.repo ?? null, + ci_run_id: metadata?.run_id ?? null, + ci_poll_count: metadata?.poll_count ?? null, + ci_consecutive_errors: metadata?.consecutive_errors ?? null, + ci_last_checked_at: metadata?.last_checked_at ?? null, + ci_target: extractWatchCiTarget(prompt), + max_duration_ms: row.max_duration_ms, + status_message_id: row.status_message_id, + status_started_at: row.status_started_at, + schedule_type: row.schedule_type, + schedule_value: row.schedule_value, + next_run: row.next_run, + last_run: row.last_run, + last_result_chars: row.last_result_chars, + status: row.status, + created_at: row.created_at, + prompt_chars: row.prompt_chars, + }; +} + +function runRecentScheduledTasks( + 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 rows = database + .prepare( + ` + SELECT id, + group_folder, + chat_jid, + agent_type, + room_role, + ci_provider, + ci_metadata, + max_duration_ms, + status_message_id, + status_started_at, + prompt, + length(prompt) AS prompt_chars, + schedule_type, + schedule_value, + next_run, + last_run, + length(last_result) AS last_result_chars, + status, + created_at + FROM scheduled_tasks + WHERE ( + created_at >= ? + OR last_run >= ? + OR next_run >= ? + OR status_started_at >= ? + OR status IN ('active', 'paused') + ) + ${groupScope.clause} + ORDER BY COALESCE(last_run, status_started_at, next_run, created_at) DESC + LIMIT ? + `, + ) + .all(cutoff, cutoff, cutoff, cutoff, ...groupScope.params, limit) as Array< + Record + >; + + return stringifyEvidence({ + action: request.action, + window_minutes: minutes, + cutoff, + tasks: rows.map(normalizeScheduledTaskEvidenceRow), + }); +} + +function runScheduledTaskRuns( + 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 taskId = request.taskId + ? normalizeDbEvidenceTaskId(request.taskId) + : null; + const params: SqlBinding[] = [cutoff]; + const clauses = ['logs.run_at >= ?']; + + if (taskId) { + clauses.push('logs.task_id = ?'); + params.push(taskId); + } + if (!scope.isMain) { + clauses.push('tasks.group_folder = ?'); + params.push(scope.sourceGroup); + } + params.push(limit); + + const rows = database + .prepare( + ` + SELECT logs.task_id, + tasks.group_folder, + tasks.chat_jid, + tasks.agent_type, + tasks.room_role, + tasks.ci_provider, + logs.run_at, + logs.duration_ms, + logs.status, + length(logs.result) AS result_chars, + length(logs.error) AS error_chars + FROM task_run_logs logs + LEFT JOIN scheduled_tasks tasks + ON tasks.id = logs.task_id + WHERE ${clauses.join(' AND ')} + ORDER BY logs.run_at DESC, logs.id DESC + LIMIT ? + `, + ) + .all(...params); + + return stringifyEvidence({ + action: request.action, + window_minutes: minutes, + cutoff, + task_id: taskId, + runs: rows, + }); +} + export function runDbEvidenceRequest( database: Database, request: DbEvidenceRequest, @@ -343,5 +497,9 @@ export function runDbEvidenceRequest( return runPairedTaskFlow(database, request, scope); case 'db_recent_paired_failures': return runRecentPairedFailures(database, request, scope); + case 'db_recent_scheduled_tasks': + return runRecentScheduledTasks(database, request, scope); + case 'db_scheduled_task_runs': + return runScheduledTaskRuns(database, request, scope); } } diff --git a/src/db-scheduled-evidence.test.ts b/src/db-scheduled-evidence.test.ts new file mode 100644 index 0000000..26974cf --- /dev/null +++ b/src/db-scheduled-evidence.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; + +import { _initTestDatabase } from './db.js'; +import { requireDatabase } from './db/runtime-database.js'; +import { runDbEvidenceRequest } from './db-evidence.js'; + +function seedScheduledTaskEvidence(): void { + _initTestDatabase(); + const now = new Date().toISOString(); + requireDatabase() + .prepare( + ` + INSERT INTO scheduled_tasks ( + id, group_folder, chat_jid, agent_type, room_role, ci_provider, + ci_metadata, max_duration_ms, status_message_id, status_started_at, + prompt, schedule_type, schedule_value, context_mode, next_run, + last_run, last_result, status, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'watch-1', + 'room-folder', + 'room-1', + 'codex', + 'owner', + 'github', + JSON.stringify({ + repo: 'phj1081/EJClaw', + run_id: 123, + poll_count: 2, + consecutive_errors: 1, + last_checked_at: now, + }), + 300_000, + 'status-msg-1', + now, + '[BACKGROUND CI WATCH]\ntarget=PR #180 checks\nSECRET PROMPT BODY', + 'interval', + '15000', + 'isolated', + now, + now, + 'raw CI result body', + 'active', + now, + ); + requireDatabase() + .prepare( + ` + INSERT INTO task_run_logs ( + task_id, run_at, duration_ms, status, result, error + ) + VALUES (?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'watch-1', + now, + 42, + 'success', + 'raw scheduler result', + 'raw scheduler error', + ); +} + +describe('scheduled task DB evidence presets', () => { + it('returns scheduled task metadata without raw prompt or result bodies', () => { + seedScheduledTaskEvidence(); + + const tasksText = runDbEvidenceRequest( + requireDatabase(), + { action: 'db_recent_scheduled_tasks', minutes: 1440 }, + { sourceGroup: 'room-folder', isMain: false }, + ); + + expect(tasksText).toContain('"ci_repo": "phj1081/EJClaw"'); + expect(tasksText).toContain('"ci_run_id": 123'); + expect(tasksText).toContain('"status_message_id": "status-msg-1"'); + expect(tasksText).toContain('"prompt_chars"'); + expect(tasksText).toContain('"last_result_chars"'); + expect(tasksText).not.toContain('SECRET PROMPT BODY'); + expect(tasksText).not.toContain('raw CI result body'); + }); + + it('returns scheduled run metadata without raw result or error bodies', () => { + seedScheduledTaskEvidence(); + + const runsText = runDbEvidenceRequest( + requireDatabase(), + { + action: 'db_scheduled_task_runs', + taskId: 'watch-1', + minutes: 1440, + }, + { sourceGroup: 'room-folder', isMain: false }, + ); + + expect(runsText).toContain('"duration_ms": 42'); + expect(runsText).toContain('"result_chars"'); + expect(runsText).toContain('"error_chars"'); + expect(runsText).not.toContain('raw scheduler result'); + expect(runsText).not.toContain('raw scheduler error'); + }); + + it('scopes scheduled task evidence for non-main rooms', () => { + seedScheduledTaskEvidence(); + + const scopedOut = JSON.parse( + runDbEvidenceRequest( + requireDatabase(), + { action: 'db_recent_scheduled_tasks', minutes: 1440 }, + { sourceGroup: 'other-folder', isMain: false }, + ), + ) as { tasks: unknown[] }; + + expect(scopedOut.tasks).toEqual([]); + }); +}); diff --git a/src/host-evidence.test.ts b/src/host-evidence.test.ts index 6ac9b71..0b02de6 100644 --- a/src/host-evidence.test.ts +++ b/src/host-evidence.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildRoleRuntimeConfigEvidence, buildHostEvidenceCommand, clampHostEvidenceTailLines, isHostEvidenceAction, @@ -10,7 +11,10 @@ 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('ejclaw_role_runtime_config')).toBe(true); expect(isHostEvidenceAction('db_paired_task_status')).toBe(true); + expect(isHostEvidenceAction('db_recent_scheduled_tasks')).toBe(true); + expect(isHostEvidenceAction('db_scheduled_task_runs')).toBe(true); expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true); expect(isHostEvidenceAction('github_pr_status')).toBe(true); expect(isHostEvidenceAction('github_run_jobs')).toBe(true); @@ -44,4 +48,22 @@ describe('host evidence helpers', () => { expect.arrayContaining(['--user', '-u', 'ejclaw', '-n', '42']), ); }); + + it('returns role runtime config without secret-shaped fields', () => { + const text = buildRoleRuntimeConfigEvidence(); + const parsed = JSON.parse(text) as { + roles: { + owner: { agent_type: string; effective_model: string }; + reviewer: { agent_type: string; effective_model: string }; + arbiter: { agent_type: string | null; effective_model: string | null }; + }; + }; + + expect(parsed.roles.owner.agent_type).toMatch(/^(codex|claude-code)$/); + expect(parsed.roles.reviewer.agent_type).toMatch(/^(codex|claude-code)$/); + expect(text).not.toMatch(/api[_-]?key/i); + expect(text).not.toMatch(/token/i); + expect(text).not.toMatch(/secret/i); + expect(text).not.toMatch(/password/i); + }); }); diff --git a/src/host-evidence.ts b/src/host-evidence.ts index 9b760d4..ee5b4e2 100644 --- a/src/host-evidence.ts +++ b/src/host-evidence.ts @@ -2,7 +2,27 @@ import { execFile } from 'child_process'; import fs from 'fs'; import path from 'path'; -import { DATA_DIR, WEB_DASHBOARD } from './config.js'; +import { + ARBITER_AGENT_TYPE, + ARBITER_MODEL_CONFIG, + ARBITER_SERVICE_ID, + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + CURRENT_RUNTIME_AGENT_TYPE, + DEFAULT_CLAUDE_MODEL, + DEFAULT_CODEX_MODEL, + OWNER_AGENT_TYPE, + OWNER_MODEL_CONFIG, + REVIEWER_AGENT_TYPE, + REVIEWER_MODEL_CONFIG, + REVIEWER_SERVICE_ID_FOR_TYPE, + SERVICE_ID, + SERVICE_SESSION_SCOPE, + DATA_DIR, + WEB_DASHBOARD, +} from './config.js'; +import type { AgentType } from './types.js'; import { collectArtifactMetadata, collectDeployState, @@ -27,6 +47,7 @@ import { resolveGroupIpcPath } from './group-folder.js'; export const HOST_EVIDENCE_ACTIONS = [ 'ejclaw_service_status', 'ejclaw_service_logs', + 'ejclaw_role_runtime_config', ...DEPLOY_EVIDENCE_ACTIONS, ...DB_EVIDENCE_ACTIONS, ...GITHUB_EVIDENCE_ACTIONS, @@ -163,6 +184,95 @@ export function truncateHostEvidenceText(value: string | undefined): string { return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`; } +function effectiveModelForAgentType( + agentType: AgentType | null | undefined, + configuredModel: string | undefined, +): string | null { + if (!agentType) { + return null; + } + if (configuredModel) { + return configuredModel; + } + return agentType === 'claude-code' + ? DEFAULT_CLAUDE_MODEL + : DEFAULT_CODEX_MODEL; +} + +function ownerServiceIdForAgentType(agentType: AgentType): string { + return agentType === 'claude-code' + ? CLAUDE_SERVICE_ID + : CODEX_MAIN_SERVICE_ID; +} + +function buildRoleRuntimeConfigRole( + role: 'owner' | 'reviewer' | 'arbiter', + agentType: AgentType | null | undefined, + modelConfig: { + model?: string; + effort?: string; + fallbackEnabled: boolean; + }, + serviceId: string | null, +): Record { + return { + role, + enabled: role !== 'arbiter' || Boolean(agentType), + agent_type: agentType ?? null, + service_id: serviceId, + configured_model: modelConfig.model ?? null, + effective_model: effectiveModelForAgentType(agentType, modelConfig.model), + effort: modelConfig.effort ?? null, + fallback_enabled: modelConfig.fallbackEnabled, + }; +} + +export function buildRoleRuntimeConfigEvidence(): string { + return JSON.stringify( + { + action: 'ejclaw_role_runtime_config', + current_service: { + service_id: SERVICE_ID, + session_scope: SERVICE_SESSION_SCOPE, + runtime_agent_type: CURRENT_RUNTIME_AGENT_TYPE, + }, + defaults: { + claude_model: DEFAULT_CLAUDE_MODEL, + codex_model: DEFAULT_CODEX_MODEL, + }, + services: { + claude: CLAUDE_SERVICE_ID, + codex_main: CODEX_MAIN_SERVICE_ID, + codex_review: CODEX_REVIEW_SERVICE_ID, + reviewer_for_type: REVIEWER_SERVICE_ID_FOR_TYPE, + arbiter: ARBITER_SERVICE_ID, + }, + roles: { + owner: buildRoleRuntimeConfigRole( + 'owner', + OWNER_AGENT_TYPE, + OWNER_MODEL_CONFIG, + ownerServiceIdForAgentType(OWNER_AGENT_TYPE), + ), + reviewer: buildRoleRuntimeConfigRole( + 'reviewer', + REVIEWER_AGENT_TYPE, + REVIEWER_MODEL_CONFIG, + REVIEWER_SERVICE_ID_FOR_TYPE, + ), + arbiter: buildRoleRuntimeConfigRole( + 'arbiter', + ARBITER_AGENT_TYPE, + ARBITER_MODEL_CONFIG, + ARBITER_SERVICE_ID, + ), + }, + }, + null, + 2, + ); +} + function execFileCapture( file: string, args: string[], @@ -210,6 +320,18 @@ export async function runHostEvidenceRequest( ): Promise { let commandText = ''; try { + if (request.action === 'ejclaw_role_runtime_config') { + commandText = `internal:${request.action}`; + return { + ok: true, + action: request.action, + command: commandText, + stdout: truncateHostEvidenceText(buildRoleRuntimeConfigEvidence()), + stderr: '', + exitCode: 0, + }; + } + if (isDbEvidenceAction(request.action)) { commandText = `internal:${request.action}`; return {