feat: add reviewer evidence presets (#184)
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
120
src/db-scheduled-evidence.test.ts
Normal file
120
src/db-scheduled-evidence.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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(
|
||||
file: string,
|
||||
args: string[],
|
||||
@@ -210,6 +320,18 @@ export async function runHostEvidenceRequest(
|
||||
): Promise<HostEvidenceResult> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user