feat: add reviewer evidence presets (#184)

This commit is contained in:
Eyejoker
2026-05-29 05:38:31 +09:00
committed by GitHub
parent e17e5259d5
commit 0377935564
6 changed files with 429 additions and 2 deletions

View File

@@ -4,11 +4,14 @@ import path from 'path';
export const HOST_EVIDENCE_ACTIONS = [ export const HOST_EVIDENCE_ACTIONS = [
'ejclaw_service_status', 'ejclaw_service_status',
'ejclaw_service_logs', 'ejclaw_service_logs',
'ejclaw_role_runtime_config',
'ejclaw_deploy_state', 'ejclaw_deploy_state',
'ejclaw_artifact_metadata', 'ejclaw_artifact_metadata',
'db_paired_task_status', 'db_paired_task_status',
'db_paired_task_flow', 'db_paired_task_flow',
'db_recent_paired_failures', 'db_recent_paired_failures',
'db_recent_scheduled_tasks',
'db_scheduled_task_runs',
'github_pr_status', 'github_pr_status',
'github_pr_diff_stat', 'github_pr_diff_stat',
'github_run_status', 'github_run_status',
@@ -20,6 +23,8 @@ export const DB_EVIDENCE_ACTIONS = [
'db_paired_task_status', 'db_paired_task_status',
'db_paired_task_flow', 'db_paired_task_flow',
'db_recent_paired_failures', 'db_recent_paired_failures',
'db_recent_scheduled_tasks',
'db_scheduled_task_runs',
] as const; ] as const;
export const DEPLOY_EVIDENCE_ACTIONS = [ export const DEPLOY_EVIDENCE_ACTIONS = [

View File

@@ -79,7 +79,7 @@ export function registerHostEvidenceTools(
server.tool( server.tool(
'read_host_evidence', '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 action: z
.enum(HOST_EVIDENCE_ACTIONS) .enum(HOST_EVIDENCE_ACTIONS)

View File

@@ -1,11 +1,16 @@
import type { Database } from 'bun:sqlite'; 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; type SqlBinding = string | number | bigint | boolean | null | Uint8Array;
export const DB_EVIDENCE_ACTIONS = [ export const DB_EVIDENCE_ACTIONS = [
'db_paired_task_status', 'db_paired_task_status',
'db_paired_task_flow', 'db_paired_task_flow',
'db_recent_paired_failures', 'db_recent_paired_failures',
'db_recent_scheduled_tasks',
'db_scheduled_task_runs',
] as const; ] as const;
export type DbEvidenceAction = (typeof DB_EVIDENCE_ACTIONS)[number]; export type DbEvidenceAction = (typeof DB_EVIDENCE_ACTIONS)[number];
@@ -331,6 +336,155 @@ function runRecentPairedFailures(
}); });
} }
function normalizeScheduledTaskEvidenceRow(
row: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown>
>;
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( export function runDbEvidenceRequest(
database: Database, database: Database,
request: DbEvidenceRequest, request: DbEvidenceRequest,
@@ -343,5 +497,9 @@ export function runDbEvidenceRequest(
return runPairedTaskFlow(database, request, scope); return runPairedTaskFlow(database, request, scope);
case 'db_recent_paired_failures': case 'db_recent_paired_failures':
return runRecentPairedFailures(database, request, scope); 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);
} }
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildRoleRuntimeConfigEvidence,
buildHostEvidenceCommand, buildHostEvidenceCommand,
clampHostEvidenceTailLines, clampHostEvidenceTailLines,
isHostEvidenceAction, isHostEvidenceAction,
@@ -10,7 +11,10 @@ describe('host evidence helpers', () => {
it('recognizes only allowlisted actions', () => { it('recognizes only allowlisted actions', () => {
expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true); expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true);
expect(isHostEvidenceAction('ejclaw_service_logs')).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_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('ejclaw_deploy_state')).toBe(true);
expect(isHostEvidenceAction('github_pr_status')).toBe(true); expect(isHostEvidenceAction('github_pr_status')).toBe(true);
expect(isHostEvidenceAction('github_run_jobs')).toBe(true); expect(isHostEvidenceAction('github_run_jobs')).toBe(true);
@@ -44,4 +48,22 @@ describe('host evidence helpers', () => {
expect.arrayContaining(['--user', '-u', 'ejclaw', '-n', '42']), 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);
});
}); });

View File

@@ -2,7 +2,27 @@ import { execFile } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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 { import {
collectArtifactMetadata, collectArtifactMetadata,
collectDeployState, collectDeployState,
@@ -27,6 +47,7 @@ import { resolveGroupIpcPath } from './group-folder.js';
export const HOST_EVIDENCE_ACTIONS = [ export const HOST_EVIDENCE_ACTIONS = [
'ejclaw_service_status', 'ejclaw_service_status',
'ejclaw_service_logs', 'ejclaw_service_logs',
'ejclaw_role_runtime_config',
...DEPLOY_EVIDENCE_ACTIONS, ...DEPLOY_EVIDENCE_ACTIONS,
...DB_EVIDENCE_ACTIONS, ...DB_EVIDENCE_ACTIONS,
...GITHUB_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]`; return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
} }
function effectiveModelForAgentType(
agentType: AgentType | null | undefined,
configuredModel: string | undefined,
): string | null {
if (!agentType) {
return null;
}
if (configuredModel) {
return configuredModel;
}
return agentType === 'claude-code'
? DEFAULT_CLAUDE_MODEL
: DEFAULT_CODEX_MODEL;
}
function ownerServiceIdForAgentType(agentType: AgentType): string {
return agentType === 'claude-code'
? CLAUDE_SERVICE_ID
: CODEX_MAIN_SERVICE_ID;
}
function buildRoleRuntimeConfigRole(
role: 'owner' | 'reviewer' | 'arbiter',
agentType: AgentType | null | undefined,
modelConfig: {
model?: string;
effort?: string;
fallbackEnabled: boolean;
},
serviceId: string | null,
): Record<string, unknown> {
return {
role,
enabled: role !== 'arbiter' || Boolean(agentType),
agent_type: agentType ?? null,
service_id: serviceId,
configured_model: modelConfig.model ?? null,
effective_model: effectiveModelForAgentType(agentType, modelConfig.model),
effort: modelConfig.effort ?? null,
fallback_enabled: modelConfig.fallbackEnabled,
};
}
export function buildRoleRuntimeConfigEvidence(): string {
return JSON.stringify(
{
action: 'ejclaw_role_runtime_config',
current_service: {
service_id: SERVICE_ID,
session_scope: SERVICE_SESSION_SCOPE,
runtime_agent_type: CURRENT_RUNTIME_AGENT_TYPE,
},
defaults: {
claude_model: DEFAULT_CLAUDE_MODEL,
codex_model: DEFAULT_CODEX_MODEL,
},
services: {
claude: CLAUDE_SERVICE_ID,
codex_main: CODEX_MAIN_SERVICE_ID,
codex_review: CODEX_REVIEW_SERVICE_ID,
reviewer_for_type: REVIEWER_SERVICE_ID_FOR_TYPE,
arbiter: ARBITER_SERVICE_ID,
},
roles: {
owner: buildRoleRuntimeConfigRole(
'owner',
OWNER_AGENT_TYPE,
OWNER_MODEL_CONFIG,
ownerServiceIdForAgentType(OWNER_AGENT_TYPE),
),
reviewer: buildRoleRuntimeConfigRole(
'reviewer',
REVIEWER_AGENT_TYPE,
REVIEWER_MODEL_CONFIG,
REVIEWER_SERVICE_ID_FOR_TYPE,
),
arbiter: buildRoleRuntimeConfigRole(
'arbiter',
ARBITER_AGENT_TYPE,
ARBITER_MODEL_CONFIG,
ARBITER_SERVICE_ID,
),
},
},
null,
2,
);
}
function execFileCapture( function execFileCapture(
file: string, file: string,
args: string[], args: string[],
@@ -210,6 +320,18 @@ export async function runHostEvidenceRequest(
): Promise<HostEvidenceResult> { ): Promise<HostEvidenceResult> {
let commandText = ''; let commandText = '';
try { 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)) { if (isDbEvidenceAction(request.action)) {
commandText = `internal:${request.action}`; commandText = `internal:${request.action}`;
return { return {