Merge pull request #164 from phj1081/codex/owner/ejclaw
feat: add read-only evidence tools
This commit is contained in:
@@ -4,6 +4,39 @@ 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_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;
|
] as const;
|
||||||
|
|
||||||
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
||||||
|
|||||||
188
runners/agent-runner/src/ipc-host-evidence-tool.ts
Normal file
188
runners/agent-runner/src/ipc-host-evidence-tool.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ARTIFACT_EVIDENCE_KINDS,
|
||||||
|
DB_EVIDENCE_ACTIONS,
|
||||||
|
DEPLOY_EVIDENCE_ACTIONS,
|
||||||
|
formatHostEvidenceResponse,
|
||||||
|
GITHUB_EVIDENCE_ACTIONS,
|
||||||
|
HOST_EVIDENCE_ACTIONS,
|
||||||
|
normalizeHostEvidenceTailLines,
|
||||||
|
type HostEvidenceAction,
|
||||||
|
waitForHostEvidenceResponse,
|
||||||
|
} from './host-evidence.js';
|
||||||
|
|
||||||
|
type IpcWriter = (dir: string, data: object) => string;
|
||||||
|
|
||||||
|
interface McpTextToolResult {
|
||||||
|
[key: string]: unknown;
|
||||||
|
content: Array<{ type: 'text'; text: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterHostEvidenceToolsOptions {
|
||||||
|
server: McpServer;
|
||||||
|
tasksDir: string;
|
||||||
|
responseDir: string;
|
||||||
|
groupFolder: string;
|
||||||
|
writeIpcFile: IpcWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestHostEvidence(
|
||||||
|
options: RegisterHostEvidenceToolsOptions,
|
||||||
|
payload: Record<string, unknown> & { action: HostEvidenceAction },
|
||||||
|
): Promise<McpTextToolResult> {
|
||||||
|
const requestId = `host-evidence-${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
|
||||||
|
options.writeIpcFile(options.tasksDir, {
|
||||||
|
type: 'host_evidence_request',
|
||||||
|
requestId,
|
||||||
|
...payload,
|
||||||
|
groupFolder: options.groupFolder,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await waitForHostEvidenceResponse(
|
||||||
|
options.responseDir,
|
||||||
|
requestId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: formatHostEvidenceResponse(response),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: !response.ok,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHostEvidenceTools(
|
||||||
|
options: RegisterHostEvidenceToolsOptions,
|
||||||
|
): void {
|
||||||
|
const { server } = options;
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'read_host_evidence',
|
||||||
|
'Read host-side deployment evidence through a narrow allowlist. Use this instead of broad shell access when reviewer/arbiter needs service status, deploy state, DB state, GitHub PR state, or artifact metadata.',
|
||||||
|
{
|
||||||
|
action: z
|
||||||
|
.enum(HOST_EVIDENCE_ACTIONS)
|
||||||
|
.describe(
|
||||||
|
'Allowlisted evidence action. Prefer specific read_db_evidence/read_deploy_evidence/read_github_evidence helpers when available.',
|
||||||
|
),
|
||||||
|
tail_lines: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.describe('Only for ejclaw_service_logs. Defaults to 20.'),
|
||||||
|
task_id: z.string().optional().describe('Only for DB task actions.'),
|
||||||
|
minutes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(1440)
|
||||||
|
.optional()
|
||||||
|
.describe('Only for recent DB evidence. Defaults to 60.'),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe('Only for DB evidence row limits. Defaults to 20.'),
|
||||||
|
repo: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Only for GitHub evidence, in owner/repo form.'),
|
||||||
|
pr_number: z.number().int().positive().optional(),
|
||||||
|
run_id: z.number().int().positive().optional(),
|
||||||
|
artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(),
|
||||||
|
},
|
||||||
|
async (args) =>
|
||||||
|
requestHostEvidence(options, {
|
||||||
|
action: args.action,
|
||||||
|
tail_lines:
|
||||||
|
args.action === 'ejclaw_service_logs'
|
||||||
|
? normalizeHostEvidenceTailLines(args.tail_lines)
|
||||||
|
: undefined,
|
||||||
|
task_id: args.task_id,
|
||||||
|
minutes: args.minutes,
|
||||||
|
limit: args.limit,
|
||||||
|
repo: args.repo,
|
||||||
|
pr_number: args.pr_number,
|
||||||
|
run_id: args.run_id,
|
||||||
|
artifact_kind: args.artifact_kind,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'read_db_evidence',
|
||||||
|
'Read fixed DB evidence presets without arbitrary SQL. Raw message/output bodies are not returned.',
|
||||||
|
{
|
||||||
|
action: z.enum(DB_EVIDENCE_ACTIONS),
|
||||||
|
task_id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Required for paired task status/flow actions.'),
|
||||||
|
minutes: z.number().int().min(1).max(1440).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
},
|
||||||
|
async (args) =>
|
||||||
|
requestHostEvidence(options, {
|
||||||
|
action: args.action,
|
||||||
|
task_id: args.task_id,
|
||||||
|
minutes: args.minutes,
|
||||||
|
limit: args.limit,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'read_deploy_evidence',
|
||||||
|
'Read fixed deploy/artifact evidence without shell access. Returns commit, dirty state, build artifact mtimes, sizes, and hashes where applicable.',
|
||||||
|
{
|
||||||
|
action: z.enum(DEPLOY_EVIDENCE_ACTIONS),
|
||||||
|
artifact_kind: z.enum(ARTIFACT_EVIDENCE_KINDS).optional(),
|
||||||
|
},
|
||||||
|
async (args) =>
|
||||||
|
requestHostEvidence(options, {
|
||||||
|
action: args.action,
|
||||||
|
artifact_kind: args.artifact_kind,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'read_github_evidence',
|
||||||
|
'Read fixed GitHub PR/run evidence through host gh CLI without broad shell access.',
|
||||||
|
{
|
||||||
|
action: z.enum(GITHUB_EVIDENCE_ACTIONS),
|
||||||
|
repo: z.string().describe('GitHub repository in owner/repo form.'),
|
||||||
|
pr_number: z.number().int().positive().optional(),
|
||||||
|
run_id: z.number().int().positive().optional(),
|
||||||
|
},
|
||||||
|
async (args) =>
|
||||||
|
requestHostEvidence(options, {
|
||||||
|
action: args.action,
|
||||||
|
repo: args.repo,
|
||||||
|
pr_number: args.pr_number,
|
||||||
|
run_id: args.run_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,13 +15,7 @@ import {
|
|||||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
normalizeWatchCiIntervalSeconds,
|
normalizeWatchCiIntervalSeconds,
|
||||||
} from './watch-ci.js';
|
} from './watch-ci.js';
|
||||||
import {
|
import { resolveHostEvidenceResponsesDir } from './host-evidence.js';
|
||||||
formatHostEvidenceResponse,
|
|
||||||
HOST_EVIDENCE_ACTIONS,
|
|
||||||
normalizeHostEvidenceTailLines,
|
|
||||||
resolveHostEvidenceResponsesDir,
|
|
||||||
waitForHostEvidenceResponse,
|
|
||||||
} from './host-evidence.js';
|
|
||||||
import {
|
import {
|
||||||
computeVerificationSnapshotId,
|
computeVerificationSnapshotId,
|
||||||
formatVerificationResponse,
|
formatVerificationResponse,
|
||||||
@@ -30,6 +24,8 @@ import {
|
|||||||
} from './verification.js';
|
} from './verification.js';
|
||||||
import { resolveIpcDirectories } from './ipc-paths.js';
|
import { resolveIpcDirectories } from './ipc-paths.js';
|
||||||
import { buildSendMessageIpcPayload } from './ipc-message.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(
|
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(
|
||||||
process.env,
|
process.env,
|
||||||
@@ -413,69 +409,13 @@ server.tool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
registerHostEvidenceTools({
|
||||||
'read_host_evidence',
|
server,
|
||||||
'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.',
|
tasksDir: TASKS_DIR,
|
||||||
{
|
responseDir: HOST_EVIDENCE_RESPONSES_DIR,
|
||||||
action: z
|
groupFolder,
|
||||||
.enum(HOST_EVIDENCE_ACTIONS)
|
writeIpcFile,
|
||||||
.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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'run_verification',
|
'run_verification',
|
||||||
@@ -536,6 +476,8 @@ server.tool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registerRepoEvidenceTool(server, REPO_ROOT);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_tasks',
|
'list_tasks',
|
||||||
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
||||||
|
|||||||
53
runners/agent-runner/src/ipc-repo-evidence-tool.ts
Normal file
53
runners/agent-runner/src/ipc-repo-evidence-tool.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatRepoEvidenceResponse,
|
||||||
|
REPO_EVIDENCE_ACTIONS,
|
||||||
|
runRepoEvidenceRequestDirect,
|
||||||
|
} from './repo-evidence.js';
|
||||||
|
|
||||||
|
export function registerRepoEvidenceTool(
|
||||||
|
server: McpServer,
|
||||||
|
repoRoot: string,
|
||||||
|
): void {
|
||||||
|
server.tool(
|
||||||
|
'read_repo_evidence',
|
||||||
|
'Read fixed git evidence directly from EJCLAW_WORK_DIR without broad shell access. Use this when reviewer/arbiter needs current branch, dirty state, recent commits, or a specific commit/ref.',
|
||||||
|
{
|
||||||
|
action: z
|
||||||
|
.enum(REPO_EVIDENCE_ACTIONS)
|
||||||
|
.describe(
|
||||||
|
'git_status=current branch and dirty state, git_head=current HEAD, git_recent_log=recent commits, git_show_ref=show a specific ref/commit',
|
||||||
|
),
|
||||||
|
ref: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Only for git_show_ref. Defaults to HEAD.'),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(30)
|
||||||
|
.optional()
|
||||||
|
.describe('Only for git_recent_log. Defaults to 10, max 30.'),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const response = await runRepoEvidenceRequestDirect(repoRoot, {
|
||||||
|
action: args.action,
|
||||||
|
ref: args.ref,
|
||||||
|
limit: args.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: formatRepoEvidenceResponse(response),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: !response.ok,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
232
runners/agent-runner/src/repo-evidence.ts
Normal file
232
runners/agent-runner/src/repo-evidence.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { execFile } from 'child_process';
|
||||||
|
|
||||||
|
export const REPO_EVIDENCE_ACTIONS = [
|
||||||
|
'git_status',
|
||||||
|
'git_head',
|
||||||
|
'git_recent_log',
|
||||||
|
'git_show_ref',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RepoEvidenceAction = (typeof REPO_EVIDENCE_ACTIONS)[number];
|
||||||
|
|
||||||
|
export interface RepoEvidenceRequest {
|
||||||
|
action: RepoEvidenceAction;
|
||||||
|
ref?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoEvidenceResponse {
|
||||||
|
ok: boolean;
|
||||||
|
action: RepoEvidenceAction;
|
||||||
|
command: string;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
workdir: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepoEvidenceCommandSpec {
|
||||||
|
file: string;
|
||||||
|
args: string[];
|
||||||
|
commandText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOG_LIMIT = 10;
|
||||||
|
const MAX_LOG_LIMIT = 30;
|
||||||
|
const MAX_OUTPUT_CHARS = 16_000;
|
||||||
|
const COMMAND_TIMEOUT_MS = 5_000;
|
||||||
|
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||||
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/@:{}~^+-]{1,160}$/;
|
||||||
|
|
||||||
|
export function normalizeRepoEvidenceLimit(value?: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return DEFAULT_LOG_LIMIT;
|
||||||
|
}
|
||||||
|
const normalized = Math.trunc(value as number);
|
||||||
|
return Math.min(Math.max(normalized, 1), MAX_LOG_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRepoEvidenceRef(value?: string): string {
|
||||||
|
const ref = value?.trim() || 'HEAD';
|
||||||
|
if (
|
||||||
|
ref.startsWith('-') ||
|
||||||
|
ref.includes('..') ||
|
||||||
|
!SAFE_GIT_REF_PATTERN.test(ref)
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsupported git ref for read-only evidence: ${ref}`);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRepoEvidenceCommand(
|
||||||
|
repoRoot: string,
|
||||||
|
request: RepoEvidenceRequest,
|
||||||
|
): RepoEvidenceCommandSpec {
|
||||||
|
const gitArgs = ['-C', repoRoot];
|
||||||
|
switch (request.action) {
|
||||||
|
case 'git_status': {
|
||||||
|
const args = [...gitArgs, 'status', '--short', '--branch'];
|
||||||
|
return {
|
||||||
|
file: 'git',
|
||||||
|
args,
|
||||||
|
commandText: `git ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'git_head': {
|
||||||
|
const args = [...gitArgs, 'log', '-1', '--oneline', '--decorate'];
|
||||||
|
return {
|
||||||
|
file: 'git',
|
||||||
|
args,
|
||||||
|
commandText: `git ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'git_recent_log': {
|
||||||
|
const limit = normalizeRepoEvidenceLimit(request.limit);
|
||||||
|
const args = [...gitArgs, 'log', `-${limit}`, '--oneline', '--decorate'];
|
||||||
|
return {
|
||||||
|
file: 'git',
|
||||||
|
args,
|
||||||
|
commandText: `git ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'git_show_ref': {
|
||||||
|
const ref = normalizeRepoEvidenceRef(request.ref);
|
||||||
|
const args = [
|
||||||
|
...gitArgs,
|
||||||
|
'show',
|
||||||
|
'--stat',
|
||||||
|
'--oneline',
|
||||||
|
'--decorate',
|
||||||
|
'--no-ext-diff',
|
||||||
|
'--no-renames',
|
||||||
|
ref,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
file: 'git',
|
||||||
|
args,
|
||||||
|
commandText: `git ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateRepoEvidenceText(value: string | undefined): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (value.length <= MAX_OUTPUT_CHARS) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execFileCapture(
|
||||||
|
file: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
file,
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: COMMAND_TIMEOUT_MS,
|
||||||
|
maxBuffer: COMMAND_MAX_BUFFER,
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(
|
||||||
|
Object.assign(error, {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExitCode(error: unknown): number {
|
||||||
|
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||||
|
const code = (error as { code?: unknown }).code;
|
||||||
|
if (typeof code === 'number') {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRepoEvidenceRequestDirect(
|
||||||
|
repoRoot: string,
|
||||||
|
request: RepoEvidenceRequest,
|
||||||
|
): Promise<RepoEvidenceResponse> {
|
||||||
|
let command: RepoEvidenceCommandSpec;
|
||||||
|
try {
|
||||||
|
command = buildRepoEvidenceCommand(repoRoot, request);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: request.action,
|
||||||
|
command: '',
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 1,
|
||||||
|
workdir: repoRoot,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileCapture(
|
||||||
|
command.file,
|
||||||
|
command.args,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action: request.action,
|
||||||
|
command: command.commandText,
|
||||||
|
stdout: truncateRepoEvidenceText(stdout),
|
||||||
|
stderr: truncateRepoEvidenceText(stderr),
|
||||||
|
exitCode: 0,
|
||||||
|
workdir: repoRoot,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const stdout =
|
||||||
|
typeof error === 'object' && error !== null && 'stdout' in error
|
||||||
|
? String((error as { stdout?: unknown }).stdout ?? '')
|
||||||
|
: '';
|
||||||
|
const stderr =
|
||||||
|
typeof error === 'object' && error !== null && 'stderr' in error
|
||||||
|
? String((error as { stderr?: unknown }).stderr ?? '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: request.action,
|
||||||
|
command: command.commandText,
|
||||||
|
stdout: truncateRepoEvidenceText(stdout),
|
||||||
|
stderr: truncateRepoEvidenceText(stderr),
|
||||||
|
exitCode: extractExitCode(error),
|
||||||
|
workdir: repoRoot,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRepoEvidenceResponse(
|
||||||
|
response: RepoEvidenceResponse,
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
`Repo evidence action: ${response.action}`,
|
||||||
|
`Workdir: ${response.workdir}`,
|
||||||
|
`Exit code: ${response.exitCode}`,
|
||||||
|
response.command ? `$ ${response.command}` : null,
|
||||||
|
response.stdout ? response.stdout.trimEnd() : null,
|
||||||
|
response.stderr ? `[stderr]\n${response.stderr.trimEnd()}` : null,
|
||||||
|
response.error ? `[error] ${response.error}` : null,
|
||||||
|
].filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
144
runners/agent-runner/test/repo-evidence.test.ts
Normal file
144
runners/agent-runner/test/repo-evidence.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildRepoEvidenceCommand,
|
||||||
|
formatRepoEvidenceResponse,
|
||||||
|
normalizeRepoEvidenceLimit,
|
||||||
|
normalizeRepoEvidenceRef,
|
||||||
|
runRepoEvidenceRequestDirect,
|
||||||
|
} from '../src/repo-evidence.js';
|
||||||
|
|
||||||
|
describe('repo evidence helpers', () => {
|
||||||
|
let repoDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-repo-evidence-'));
|
||||||
|
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
|
||||||
|
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
execFileSync('git', ['config', 'user.name', 'Test User'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'README.md'), 'initial\n');
|
||||||
|
execFileSync('git', ['add', 'README.md'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
execFileSync('git', ['commit', '-m', 'initial commit'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes bounded log limits', () => {
|
||||||
|
expect(normalizeRepoEvidenceLimit()).toBe(10);
|
||||||
|
expect(normalizeRepoEvidenceLimit(0)).toBe(1);
|
||||||
|
expect(normalizeRepoEvidenceLimit(3.8)).toBe(3);
|
||||||
|
expect(normalizeRepoEvidenceLimit(100)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects refs that can change command behavior or request ranges', () => {
|
||||||
|
expect(normalizeRepoEvidenceRef()).toBe('HEAD');
|
||||||
|
expect(normalizeRepoEvidenceRef(' HEAD~1 ')).toBe('HEAD~1');
|
||||||
|
expect(() => normalizeRepoEvidenceRef('--help')).toThrow(
|
||||||
|
'Unsupported git ref',
|
||||||
|
);
|
||||||
|
expect(() => normalizeRepoEvidenceRef('main..HEAD')).toThrow(
|
||||||
|
'Unsupported git ref',
|
||||||
|
);
|
||||||
|
expect(() => normalizeRepoEvidenceRef('HEAD;rm')).toThrow(
|
||||||
|
'Unsupported git ref',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds fixed git commands without invoking a shell', () => {
|
||||||
|
expect(buildRepoEvidenceCommand(repoDir, { action: 'git_status' })).toEqual(
|
||||||
|
{
|
||||||
|
file: 'git',
|
||||||
|
args: ['-C', repoDir, 'status', '--short', '--branch'],
|
||||||
|
commandText: `git -C ${repoDir} status --short --branch`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildRepoEvidenceCommand(repoDir, {
|
||||||
|
action: 'git_recent_log',
|
||||||
|
limit: 100,
|
||||||
|
}).args,
|
||||||
|
).toContain('-30');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildRepoEvidenceCommand(repoDir, {
|
||||||
|
action: 'git_show_ref',
|
||||||
|
ref: 'HEAD',
|
||||||
|
}).args,
|
||||||
|
).toEqual([
|
||||||
|
'-C',
|
||||||
|
repoDir,
|
||||||
|
'show',
|
||||||
|
'--stat',
|
||||||
|
'--oneline',
|
||||||
|
'--decorate',
|
||||||
|
'--no-ext-diff',
|
||||||
|
'--no-renames',
|
||||||
|
'HEAD',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads read-only git evidence from the target repository', async () => {
|
||||||
|
fs.appendFileSync(path.join(repoDir, 'README.md'), 'dirty\n');
|
||||||
|
|
||||||
|
const status = await runRepoEvidenceRequestDirect(repoDir, {
|
||||||
|
action: 'git_status',
|
||||||
|
});
|
||||||
|
expect(status.ok).toBe(true);
|
||||||
|
expect(status.stdout).toContain('README.md');
|
||||||
|
|
||||||
|
const head = await runRepoEvidenceRequestDirect(repoDir, {
|
||||||
|
action: 'git_head',
|
||||||
|
});
|
||||||
|
expect(head.ok).toBe(true);
|
||||||
|
expect(head.stdout).toContain('initial commit');
|
||||||
|
|
||||||
|
const log = await runRepoEvidenceRequestDirect(repoDir, {
|
||||||
|
action: 'git_recent_log',
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
expect(log.ok).toBe(true);
|
||||||
|
expect(log.stdout).toContain('initial commit');
|
||||||
|
|
||||||
|
const shown = await runRepoEvidenceRequestDirect(repoDir, {
|
||||||
|
action: 'git_show_ref',
|
||||||
|
ref: 'HEAD',
|
||||||
|
});
|
||||||
|
expect(shown.ok).toBe(true);
|
||||||
|
expect(shown.stdout).toContain('initial commit');
|
||||||
|
expect(shown.stdout).toContain('README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats failures into MCP-friendly evidence', async () => {
|
||||||
|
const response = await runRepoEvidenceRequestDirect(repoDir, {
|
||||||
|
action: 'git_show_ref',
|
||||||
|
ref: '--help',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.ok).toBe(false);
|
||||||
|
const text = formatRepoEvidenceResponse(response);
|
||||||
|
expect(text).toContain('Repo evidence action: git_show_ref');
|
||||||
|
expect(text).toContain(`Workdir: ${repoDir}`);
|
||||||
|
expect(text).toContain('Exit code: 1');
|
||||||
|
expect(text).toContain('[error] Unsupported git ref');
|
||||||
|
});
|
||||||
|
});
|
||||||
177
src/db-evidence.test.ts
Normal file
177
src/db-evidence.test.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { _initTestDatabase, insertPairedTurnOutput } from './db.js';
|
||||||
|
import { requireDatabase } from './db/runtime-database.js';
|
||||||
|
import {
|
||||||
|
normalizeDbEvidenceLimit,
|
||||||
|
normalizeDbEvidenceMinutes,
|
||||||
|
normalizeDbEvidenceTaskId,
|
||||||
|
runDbEvidenceRequest,
|
||||||
|
} from './db-evidence.js';
|
||||||
|
|
||||||
|
describe('DB evidence presets', () => {
|
||||||
|
function seedPairedTask(): void {
|
||||||
|
_initTestDatabase();
|
||||||
|
requireDatabase()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO paired_tasks (
|
||||||
|
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
|
||||||
|
owner_agent_type, reviewer_agent_type, arbiter_agent_type,
|
||||||
|
status, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'task-1',
|
||||||
|
'room-1',
|
||||||
|
'room-folder',
|
||||||
|
'codex-main',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'claude-code',
|
||||||
|
'codex',
|
||||||
|
'active',
|
||||||
|
'2026-05-26T00:00:00.000Z',
|
||||||
|
'2026-05-26T00:10:00.000Z',
|
||||||
|
);
|
||||||
|
requireDatabase()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO paired_turns (
|
||||||
|
turn_id, task_id, task_updated_at, role, intent_kind, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'turn-1',
|
||||||
|
'task-1',
|
||||||
|
'2026-05-26T00:10:00.000Z',
|
||||||
|
'owner',
|
||||||
|
'owner-turn',
|
||||||
|
'2026-05-26T00:00:01.000Z',
|
||||||
|
'2026-05-26T00:00:02.000Z',
|
||||||
|
);
|
||||||
|
requireDatabase()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO paired_turn_attempts (
|
||||||
|
attempt_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind,
|
||||||
|
state, executor_service_id, executor_agent_type, active_run_id,
|
||||||
|
created_at, updated_at, completed_at, last_error
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'turn-1:attempt:1',
|
||||||
|
'turn-1',
|
||||||
|
1,
|
||||||
|
'task-1',
|
||||||
|
'2026-05-26T00:10:00.000Z',
|
||||||
|
'owner',
|
||||||
|
'owner-turn',
|
||||||
|
'failed',
|
||||||
|
'codex-main',
|
||||||
|
'codex',
|
||||||
|
null,
|
||||||
|
'2026-05-26T00:00:01.000Z',
|
||||||
|
'2026-05-26T00:00:03.000Z',
|
||||||
|
'2026-05-26T00:00:04.000Z',
|
||||||
|
'failure with sk-12345678901234567890',
|
||||||
|
);
|
||||||
|
insertPairedTurnOutput(
|
||||||
|
'task-1',
|
||||||
|
1,
|
||||||
|
'owner',
|
||||||
|
'SECRET USER TEXT SHOULD NOT BE RETURNED',
|
||||||
|
'2026-05-26T00:00:05.000Z',
|
||||||
|
);
|
||||||
|
requireDatabase()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO work_items (
|
||||||
|
group_folder, chat_jid, agent_type, service_id, delivery_role,
|
||||||
|
status, start_seq, end_seq, result_payload, delivery_attempts,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'room-folder',
|
||||||
|
'room-1',
|
||||||
|
'codex',
|
||||||
|
'codex-main',
|
||||||
|
'owner',
|
||||||
|
'produced',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
'raw delivery body should not be returned',
|
||||||
|
0,
|
||||||
|
'2026-05-26T00:00:06.000Z',
|
||||||
|
'2026-05-26T00:00:06.000Z',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('normalizes request bounds and validates task ids', () => {
|
||||||
|
expect(normalizeDbEvidenceMinutes()).toBe(60);
|
||||||
|
expect(normalizeDbEvidenceMinutes(0)).toBe(1);
|
||||||
|
expect(normalizeDbEvidenceMinutes(99999)).toBe(1440);
|
||||||
|
expect(normalizeDbEvidenceLimit()).toBe(20);
|
||||||
|
expect(normalizeDbEvidenceLimit(999)).toBe(100);
|
||||||
|
expect(normalizeDbEvidenceTaskId(' task-1 ')).toBe('task-1');
|
||||||
|
expect(() => normalizeDbEvidenceTaskId('../bad task')).toThrow(
|
||||||
|
'Unsupported paired task id',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns task status and flow metadata without raw bodies', () => {
|
||||||
|
seedPairedTask();
|
||||||
|
|
||||||
|
const status = JSON.parse(
|
||||||
|
runDbEvidenceRequest(
|
||||||
|
requireDatabase(),
|
||||||
|
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||||
|
{ sourceGroup: 'room-folder', isMain: false },
|
||||||
|
),
|
||||||
|
) as { task: { id: string; group_folder: string } };
|
||||||
|
expect(status.task.id).toBe('task-1');
|
||||||
|
expect(status.task.group_folder).toBe('room-folder');
|
||||||
|
|
||||||
|
const flowText = runDbEvidenceRequest(
|
||||||
|
requireDatabase(),
|
||||||
|
{ action: 'db_paired_task_flow', taskId: 'task-1' },
|
||||||
|
{ sourceGroup: 'room-folder', isMain: false },
|
||||||
|
);
|
||||||
|
expect(flowText).toContain('"output_chars"');
|
||||||
|
expect(flowText).toContain('"last_error_chars"');
|
||||||
|
expect(flowText).not.toContain('SECRET USER TEXT');
|
||||||
|
expect(flowText).not.toContain('raw delivery body');
|
||||||
|
expect(flowText).not.toContain('sk-12345678901234567890');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes non-main DB evidence to the source group', () => {
|
||||||
|
seedPairedTask();
|
||||||
|
|
||||||
|
const blocked = JSON.parse(
|
||||||
|
runDbEvidenceRequest(
|
||||||
|
requireDatabase(),
|
||||||
|
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||||
|
{ sourceGroup: 'other-folder', isMain: false },
|
||||||
|
),
|
||||||
|
) as { task: unknown };
|
||||||
|
expect(blocked.task).toBeNull();
|
||||||
|
|
||||||
|
const main = JSON.parse(
|
||||||
|
runDbEvidenceRequest(
|
||||||
|
requireDatabase(),
|
||||||
|
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||||
|
{ sourceGroup: 'other-folder', isMain: true },
|
||||||
|
),
|
||||||
|
) as { task: { id: string } };
|
||||||
|
expect(main.task.id).toBe('task-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
347
src/db-evidence.ts
Normal file
347
src/db-evidence.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import type { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
|
type SqlBinding = string | number | bigint | boolean | null | Uint8Array;
|
||||||
|
|
||||||
|
export const DB_EVIDENCE_ACTIONS = [
|
||||||
|
'db_paired_task_status',
|
||||||
|
'db_paired_task_flow',
|
||||||
|
'db_recent_paired_failures',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DbEvidenceAction = (typeof DB_EVIDENCE_ACTIONS)[number];
|
||||||
|
|
||||||
|
export interface DbEvidenceRequest {
|
||||||
|
action: DbEvidenceAction;
|
||||||
|
taskId?: string;
|
||||||
|
minutes?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbEvidenceScope {
|
||||||
|
sourceGroup: string;
|
||||||
|
isMain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RECENT_MINUTES = 60;
|
||||||
|
const MAX_RECENT_MINUTES = 24 * 60;
|
||||||
|
const DEFAULT_ROW_LIMIT = 20;
|
||||||
|
const MAX_ROW_LIMIT = 100;
|
||||||
|
const TASK_ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,200}$/;
|
||||||
|
|
||||||
|
export function isDbEvidenceAction(value: unknown): value is DbEvidenceAction {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
DB_EVIDENCE_ACTIONS.includes(value as DbEvidenceAction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDbEvidenceMinutes(value?: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return DEFAULT_RECENT_MINUTES;
|
||||||
|
}
|
||||||
|
const normalized = Math.trunc(value as number);
|
||||||
|
return Math.min(Math.max(normalized, 1), MAX_RECENT_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDbEvidenceLimit(value?: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return DEFAULT_ROW_LIMIT;
|
||||||
|
}
|
||||||
|
const normalized = Math.trunc(value as number);
|
||||||
|
return Math.min(Math.max(normalized, 1), MAX_ROW_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDbEvidenceTaskId(value?: string): string {
|
||||||
|
const taskId = value?.trim();
|
||||||
|
if (!taskId || !TASK_ID_PATTERN.test(taskId)) {
|
||||||
|
throw new Error(`Unsupported paired task id for DB evidence: ${value}`);
|
||||||
|
}
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyEvidence(value: unknown): string {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupScopeClause(scope: DbEvidenceScope): {
|
||||||
|
clause: string;
|
||||||
|
params: SqlBinding[];
|
||||||
|
} {
|
||||||
|
return scope.isMain
|
||||||
|
? { clause: '', params: [] }
|
||||||
|
: { clause: ' AND group_folder = ?', params: [scope.sourceGroup] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopedTask(
|
||||||
|
database: Database,
|
||||||
|
taskId: string,
|
||||||
|
scope: DbEvidenceScope,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const groupScope = groupScopeClause(scope);
|
||||||
|
const row = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id,
|
||||||
|
group_folder,
|
||||||
|
chat_jid,
|
||||||
|
status,
|
||||||
|
round_trip_count,
|
||||||
|
owner_failure_count,
|
||||||
|
owner_step_done_streak,
|
||||||
|
finalize_step_done_count,
|
||||||
|
task_done_then_user_reopen_count,
|
||||||
|
empty_step_done_streak,
|
||||||
|
arbiter_verdict,
|
||||||
|
arbiter_requested_at,
|
||||||
|
completion_reason,
|
||||||
|
owner_agent_type,
|
||||||
|
reviewer_agent_type,
|
||||||
|
arbiter_agent_type,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM paired_tasks
|
||||||
|
WHERE id = ?
|
||||||
|
${groupScope.clause}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(taskId, ...groupScope.params) as Record<string, unknown> | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPairedTaskStatus(
|
||||||
|
database: Database,
|
||||||
|
request: DbEvidenceRequest,
|
||||||
|
scope: DbEvidenceScope,
|
||||||
|
): string {
|
||||||
|
const taskId = normalizeDbEvidenceTaskId(request.taskId);
|
||||||
|
const task = getScopedTask(database, taskId, scope);
|
||||||
|
return stringifyEvidence({
|
||||||
|
action: request.action,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPairedTaskFlow(
|
||||||
|
database: Database,
|
||||||
|
request: DbEvidenceRequest,
|
||||||
|
scope: DbEvidenceScope,
|
||||||
|
): string {
|
||||||
|
const taskId = normalizeDbEvidenceTaskId(request.taskId);
|
||||||
|
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||||
|
const task = getScopedTask(database, taskId, scope);
|
||||||
|
if (!task) {
|
||||||
|
return stringifyEvidence({
|
||||||
|
action: request.action,
|
||||||
|
task: null,
|
||||||
|
turns: [],
|
||||||
|
attempts: [],
|
||||||
|
outputs: [],
|
||||||
|
deliveries: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const turns = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT turn_id,
|
||||||
|
role,
|
||||||
|
intent_kind,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM paired_turns
|
||||||
|
WHERE task_id = ?
|
||||||
|
ORDER BY updated_at, created_at, turn_id
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(taskId, limit);
|
||||||
|
|
||||||
|
const attempts = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT attempt_id,
|
||||||
|
parent_attempt_id,
|
||||||
|
turn_id,
|
||||||
|
attempt_no,
|
||||||
|
role,
|
||||||
|
intent_kind,
|
||||||
|
state,
|
||||||
|
executor_agent_type,
|
||||||
|
active_run_id,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
completed_at,
|
||||||
|
length(last_error) AS last_error_chars
|
||||||
|
FROM paired_turn_attempts
|
||||||
|
WHERE task_id = ?
|
||||||
|
ORDER BY updated_at, attempt_no, attempt_id
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(taskId, limit);
|
||||||
|
|
||||||
|
const outputs = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT turn_number,
|
||||||
|
role,
|
||||||
|
verdict,
|
||||||
|
created_at,
|
||||||
|
length(output_text) AS output_chars
|
||||||
|
FROM paired_turn_outputs
|
||||||
|
WHERE task_id = ?
|
||||||
|
ORDER BY turn_number, created_at, role
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(taskId, limit);
|
||||||
|
|
||||||
|
const deliveries = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id,
|
||||||
|
agent_type,
|
||||||
|
delivery_role,
|
||||||
|
status,
|
||||||
|
delivery_attempts,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
delivered_at,
|
||||||
|
length(result_payload) AS result_payload_chars,
|
||||||
|
length(last_error) AS last_error_chars
|
||||||
|
FROM work_items
|
||||||
|
WHERE chat_jid = ?
|
||||||
|
AND created_at >= ?
|
||||||
|
AND created_at <= datetime(?, '+1 minute')
|
||||||
|
ORDER BY created_at, id
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
String(task.chat_jid),
|
||||||
|
String(task.created_at),
|
||||||
|
String(task.updated_at),
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
return stringifyEvidence({
|
||||||
|
action: request.action,
|
||||||
|
task,
|
||||||
|
turns,
|
||||||
|
attempts,
|
||||||
|
outputs,
|
||||||
|
deliveries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRecentPairedFailures(
|
||||||
|
database: Database,
|
||||||
|
request: DbEvidenceRequest,
|
||||||
|
scope: DbEvidenceScope,
|
||||||
|
): string {
|
||||||
|
const minutes = normalizeDbEvidenceMinutes(request.minutes);
|
||||||
|
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||||
|
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
|
||||||
|
const groupScope = groupScopeClause(scope);
|
||||||
|
|
||||||
|
const tasks = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id,
|
||||||
|
group_folder,
|
||||||
|
chat_jid,
|
||||||
|
status,
|
||||||
|
owner_failure_count,
|
||||||
|
arbiter_verdict,
|
||||||
|
completion_reason,
|
||||||
|
arbiter_requested_at,
|
||||||
|
updated_at
|
||||||
|
FROM paired_tasks
|
||||||
|
WHERE updated_at >= ?
|
||||||
|
AND (
|
||||||
|
owner_failure_count > 0
|
||||||
|
OR status = 'arbiter_requested'
|
||||||
|
OR arbiter_verdict IS NOT NULL
|
||||||
|
OR completion_reason IS NOT NULL
|
||||||
|
)
|
||||||
|
${groupScope.clause}
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(cutoff, ...groupScope.params, limit);
|
||||||
|
|
||||||
|
const attempts = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT attempts.task_id,
|
||||||
|
attempts.turn_id,
|
||||||
|
attempts.attempt_no,
|
||||||
|
attempts.role,
|
||||||
|
attempts.intent_kind,
|
||||||
|
attempts.state,
|
||||||
|
attempts.executor_agent_type,
|
||||||
|
attempts.active_run_id,
|
||||||
|
attempts.updated_at,
|
||||||
|
attempts.completed_at,
|
||||||
|
length(attempts.last_error) AS last_error_chars
|
||||||
|
FROM paired_turn_attempts attempts
|
||||||
|
JOIN paired_tasks tasks
|
||||||
|
ON tasks.id = attempts.task_id
|
||||||
|
WHERE attempts.updated_at >= ?
|
||||||
|
AND (
|
||||||
|
attempts.state = 'failed'
|
||||||
|
OR attempts.last_error IS NOT NULL
|
||||||
|
)
|
||||||
|
${scope.isMain ? '' : 'AND tasks.group_folder = ?'}
|
||||||
|
ORDER BY attempts.updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
|
||||||
|
|
||||||
|
const deliveryRetries = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id,
|
||||||
|
chat_jid,
|
||||||
|
agent_type,
|
||||||
|
delivery_role,
|
||||||
|
status,
|
||||||
|
delivery_attempts,
|
||||||
|
updated_at,
|
||||||
|
length(last_error) AS last_error_chars
|
||||||
|
FROM work_items
|
||||||
|
WHERE updated_at >= ?
|
||||||
|
AND status = 'delivery_retry'
|
||||||
|
${scope.isMain ? '' : 'AND group_folder = ?'}
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
|
||||||
|
|
||||||
|
return stringifyEvidence({
|
||||||
|
action: request.action,
|
||||||
|
window_minutes: minutes,
|
||||||
|
cutoff,
|
||||||
|
tasks,
|
||||||
|
attempts,
|
||||||
|
delivery_retries: deliveryRetries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runDbEvidenceRequest(
|
||||||
|
database: Database,
|
||||||
|
request: DbEvidenceRequest,
|
||||||
|
scope: DbEvidenceScope,
|
||||||
|
): string {
|
||||||
|
switch (request.action) {
|
||||||
|
case 'db_paired_task_status':
|
||||||
|
return runPairedTaskStatus(database, request, scope);
|
||||||
|
case 'db_paired_task_flow':
|
||||||
|
return runPairedTaskFlow(database, request, scope);
|
||||||
|
case 'db_recent_paired_failures':
|
||||||
|
return runRecentPairedFailures(database, request, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/deploy-evidence.test.ts
Normal file
45
src/deploy-evidence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectArtifactMetadata,
|
||||||
|
normalizeArtifactEvidenceKind,
|
||||||
|
} from './deploy-evidence.js';
|
||||||
|
|
||||||
|
describe('deploy evidence helpers', () => {
|
||||||
|
it('normalizes fixed artifact kinds', () => {
|
||||||
|
expect(normalizeArtifactEvidenceKind()).toBe('build_outputs');
|
||||||
|
expect(normalizeArtifactEvidenceKind('dashboard_dist')).toBe(
|
||||||
|
'dashboard_dist',
|
||||||
|
);
|
||||||
|
expect(() => normalizeArtifactEvidenceKind('/etc/passwd')).toThrow(
|
||||||
|
'Unsupported artifact evidence kind',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns metadata only for build artifacts', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-deploy-'));
|
||||||
|
const dashboardDir = path.join(root, 'apps', 'dashboard', 'dist');
|
||||||
|
fs.mkdirSync(path.join(dashboardDir, 'assets'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dashboardDir, 'index.html'), '<html></html>');
|
||||||
|
fs.writeFileSync(path.join(dashboardDir, 'assets', 'index.js'), 'secret');
|
||||||
|
|
||||||
|
const text = collectArtifactMetadata(
|
||||||
|
{
|
||||||
|
projectRoot: root,
|
||||||
|
dataDir: path.join(root, 'data'),
|
||||||
|
dashboardStaticDir: dashboardDir,
|
||||||
|
},
|
||||||
|
{ action: 'ejclaw_artifact_metadata', artifactKind: 'dashboard_dist' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(text).toContain('"file_count"');
|
||||||
|
expect(text).toContain('"total_bytes"');
|
||||||
|
expect(text).toContain('index.js');
|
||||||
|
expect(text).not.toContain('"secret"');
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
271
src/deploy-evidence.ts
Normal file
271
src/deploy-evidence.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export const DEPLOY_EVIDENCE_ACTIONS = [
|
||||||
|
'ejclaw_deploy_state',
|
||||||
|
'ejclaw_artifact_metadata',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ARTIFACT_EVIDENCE_KINDS = [
|
||||||
|
'build_outputs',
|
||||||
|
'dashboard_dist',
|
||||||
|
'runner_dist',
|
||||||
|
'android_debug_apk',
|
||||||
|
'attachments_dir',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DeployEvidenceAction = (typeof DEPLOY_EVIDENCE_ACTIONS)[number];
|
||||||
|
export type ArtifactEvidenceKind = (typeof ARTIFACT_EVIDENCE_KINDS)[number];
|
||||||
|
|
||||||
|
export interface DeployEvidenceRequest {
|
||||||
|
action: DeployEvidenceAction;
|
||||||
|
artifactKind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployEvidencePaths {
|
||||||
|
projectRoot: string;
|
||||||
|
dataDir: string;
|
||||||
|
dashboardStaticDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_TIMEOUT_MS = 5_000;
|
||||||
|
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||||
|
const MAX_DIR_ENTRIES = 5_000;
|
||||||
|
const MAX_LATEST_FILES = 12;
|
||||||
|
|
||||||
|
export function isDeployEvidenceAction(
|
||||||
|
value: unknown,
|
||||||
|
): value is DeployEvidenceAction {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
DEPLOY_EVIDENCE_ACTIONS.includes(value as DeployEvidenceAction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeArtifactEvidenceKind(
|
||||||
|
value?: string,
|
||||||
|
): ArtifactEvidenceKind {
|
||||||
|
if (!value) return 'build_outputs';
|
||||||
|
if (ARTIFACT_EVIDENCE_KINDS.includes(value as ArtifactEvidenceKind)) {
|
||||||
|
return value as ArtifactEvidenceKind;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported artifact evidence kind: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function execFileText(file: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
file,
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: COMMAND_TIMEOUT_MS,
|
||||||
|
maxBuffer: COMMAND_MAX_BUFFER,
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
const details = stderr?.trim() || stdout?.trim() || error.message;
|
||||||
|
reject(new Error(`${file} ${args.join(' ')} failed: ${details}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout.trim());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(filePath: string): string {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
hash.update(fs.readFileSync(filePath));
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMetadata(filePath: string): Record<string, unknown> {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
exists: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const base = {
|
||||||
|
path: filePath,
|
||||||
|
exists: true,
|
||||||
|
type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other',
|
||||||
|
size_bytes: stat.size,
|
||||||
|
mtime: stat.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
sha256: sha256File(filePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkDirectory(root: string): Array<{ path: string; stat: fs.Stats }> {
|
||||||
|
if (!fs.existsSync(root)) return [];
|
||||||
|
const pending = [root];
|
||||||
|
const files: Array<{ path: string; stat: fs.Stats }> = [];
|
||||||
|
|
||||||
|
while (pending.length > 0 && files.length < MAX_DIR_ENTRIES) {
|
||||||
|
const current = pending.pop()!;
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const entryPath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
pending.push(entryPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
files.push({ path: entryPath, stat: fs.statSync(entryPath) });
|
||||||
|
if (files.length >= MAX_DIR_ENTRIES) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryMetadata(root: string): Record<string, unknown> {
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return {
|
||||||
|
path: root,
|
||||||
|
exists: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const files = walkDirectory(root);
|
||||||
|
const totalBytes = files.reduce((sum, file) => sum + file.stat.size, 0);
|
||||||
|
const latestFiles = [...files]
|
||||||
|
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
|
||||||
|
.slice(0, MAX_LATEST_FILES)
|
||||||
|
.map((file) => ({
|
||||||
|
relative_path: path.relative(root, file.path),
|
||||||
|
size_bytes: file.stat.size,
|
||||||
|
mtime: file.stat.mtime.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: root,
|
||||||
|
exists: true,
|
||||||
|
file_count: files.length,
|
||||||
|
total_bytes: totalBytes,
|
||||||
|
truncated: files.length >= MAX_DIR_ENTRIES,
|
||||||
|
latest_files: latestFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputArtifacts(
|
||||||
|
paths: DeployEvidencePaths,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
root_dist: fileMetadata(path.join(paths.projectRoot, 'dist', 'index.js')),
|
||||||
|
dashboard_index: fileMetadata(
|
||||||
|
path.join(paths.dashboardStaticDir, 'index.html'),
|
||||||
|
),
|
||||||
|
dashboard_assets: directoryMetadata(
|
||||||
|
path.join(paths.dashboardStaticDir, 'assets'),
|
||||||
|
),
|
||||||
|
agent_runner: fileMetadata(
|
||||||
|
path.join(
|
||||||
|
paths.projectRoot,
|
||||||
|
'runners',
|
||||||
|
'agent-runner',
|
||||||
|
'dist',
|
||||||
|
'index.js',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
codex_runner: fileMetadata(
|
||||||
|
path.join(
|
||||||
|
paths.projectRoot,
|
||||||
|
'runners',
|
||||||
|
'codex-runner',
|
||||||
|
'dist',
|
||||||
|
'index.js',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectDeployState(
|
||||||
|
paths: DeployEvidencePaths,
|
||||||
|
): Promise<string> {
|
||||||
|
const [head, logLine, status] = await Promise.all([
|
||||||
|
execFileText('git', ['-C', paths.projectRoot, 'rev-parse', 'HEAD']),
|
||||||
|
execFileText('git', [
|
||||||
|
'-C',
|
||||||
|
paths.projectRoot,
|
||||||
|
'log',
|
||||||
|
'-1',
|
||||||
|
'--oneline',
|
||||||
|
'--decorate',
|
||||||
|
]),
|
||||||
|
execFileText('git', ['-C', paths.projectRoot, 'status', '--short']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
action: 'ejclaw_deploy_state',
|
||||||
|
project_root: paths.projectRoot,
|
||||||
|
git: {
|
||||||
|
head,
|
||||||
|
log: logLine,
|
||||||
|
dirty: status.length > 0,
|
||||||
|
status_short: status,
|
||||||
|
},
|
||||||
|
artifacts: buildOutputArtifacts(paths),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectArtifactMetadata(
|
||||||
|
paths: DeployEvidencePaths,
|
||||||
|
request: DeployEvidenceRequest,
|
||||||
|
): string {
|
||||||
|
const kind = normalizeArtifactEvidenceKind(request.artifactKind);
|
||||||
|
const payload =
|
||||||
|
kind === 'build_outputs'
|
||||||
|
? buildOutputArtifacts(paths)
|
||||||
|
: kind === 'dashboard_dist'
|
||||||
|
? directoryMetadata(paths.dashboardStaticDir)
|
||||||
|
: kind === 'runner_dist'
|
||||||
|
? {
|
||||||
|
agent_runner: directoryMetadata(
|
||||||
|
path.join(paths.projectRoot, 'runners', 'agent-runner', 'dist'),
|
||||||
|
),
|
||||||
|
codex_runner: directoryMetadata(
|
||||||
|
path.join(paths.projectRoot, 'runners', 'codex-runner', 'dist'),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: kind === 'android_debug_apk'
|
||||||
|
? fileMetadata(
|
||||||
|
path.join(
|
||||||
|
paths.projectRoot,
|
||||||
|
'apps',
|
||||||
|
'android',
|
||||||
|
'app',
|
||||||
|
'build',
|
||||||
|
'outputs',
|
||||||
|
'apk',
|
||||||
|
'debug',
|
||||||
|
'app-debug.apk',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: directoryMetadata(path.join(paths.dataDir, 'attachments'));
|
||||||
|
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
action: 'ejclaw_artifact_metadata',
|
||||||
|
artifact_kind: kind,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/github-evidence.test.ts
Normal file
46
src/github-evidence.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildGitHubEvidenceCommand,
|
||||||
|
normalizeGitHubRepo,
|
||||||
|
normalizePositiveInteger,
|
||||||
|
} from './github-evidence.js';
|
||||||
|
|
||||||
|
describe('GitHub evidence helpers', () => {
|
||||||
|
it('validates repo and numeric identifiers', () => {
|
||||||
|
expect(normalizeGitHubRepo('owner/repo')).toBe('owner/repo');
|
||||||
|
expect(() => normalizeGitHubRepo('../repo')).toThrow(
|
||||||
|
'Unsupported GitHub repo',
|
||||||
|
);
|
||||||
|
expect(normalizePositiveInteger(12, 'pr_number')).toBe(12);
|
||||||
|
expect(() => normalizePositiveInteger(0, 'pr_number')).toThrow(
|
||||||
|
'Missing or invalid pr_number',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds fixed gh commands', () => {
|
||||||
|
expect(
|
||||||
|
buildGitHubEvidenceCommand({
|
||||||
|
action: 'github_pr_status',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
prNumber: 164,
|
||||||
|
}).args,
|
||||||
|
).toEqual([
|
||||||
|
'pr',
|
||||||
|
'view',
|
||||||
|
'164',
|
||||||
|
'--repo',
|
||||||
|
'owner/repo',
|
||||||
|
'--json',
|
||||||
|
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildGitHubEvidenceCommand({
|
||||||
|
action: 'github_run_status',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
runId: 123,
|
||||||
|
}).args,
|
||||||
|
).toContain('123');
|
||||||
|
});
|
||||||
|
});
|
||||||
153
src/github-evidence.ts
Normal file
153
src/github-evidence.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { execFile } from 'child_process';
|
||||||
|
|
||||||
|
export const GITHUB_EVIDENCE_ACTIONS = [
|
||||||
|
'github_pr_status',
|
||||||
|
'github_pr_diff_stat',
|
||||||
|
'github_run_status',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GitHubEvidenceAction = (typeof GITHUB_EVIDENCE_ACTIONS)[number];
|
||||||
|
|
||||||
|
export interface GitHubEvidenceRequest {
|
||||||
|
action: GitHubEvidenceAction;
|
||||||
|
repo?: string;
|
||||||
|
prNumber?: number;
|
||||||
|
runId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubCommandSpec {
|
||||||
|
file: string;
|
||||||
|
args: string[];
|
||||||
|
commandText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||||
|
const COMMAND_TIMEOUT_MS = 10_000;
|
||||||
|
const COMMAND_MAX_BUFFER = 2 * 1024 * 1024;
|
||||||
|
const MAX_OUTPUT_CHARS = 24_000;
|
||||||
|
|
||||||
|
export function isGitHubEvidenceAction(
|
||||||
|
value: unknown,
|
||||||
|
): value is GitHubEvidenceAction {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
GITHUB_EVIDENCE_ACTIONS.includes(value as GitHubEvidenceAction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGitHubRepo(value?: string): string {
|
||||||
|
const repo = value?.trim();
|
||||||
|
if (!repo || repo.includes('..') || !REPO_PATTERN.test(repo)) {
|
||||||
|
throw new Error(`Unsupported GitHub repo for evidence: ${value}`);
|
||||||
|
}
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePositiveInteger(
|
||||||
|
value: number | undefined,
|
||||||
|
label: string,
|
||||||
|
): number {
|
||||||
|
if (value == null || !Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`Missing or invalid ${label} for GitHub evidence`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGitHubEvidenceCommand(
|
||||||
|
request: GitHubEvidenceRequest,
|
||||||
|
): GitHubCommandSpec {
|
||||||
|
const repo = normalizeGitHubRepo(request.repo);
|
||||||
|
|
||||||
|
switch (request.action) {
|
||||||
|
case 'github_pr_status': {
|
||||||
|
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
|
||||||
|
const args = [
|
||||||
|
'pr',
|
||||||
|
'view',
|
||||||
|
String(prNumber),
|
||||||
|
'--repo',
|
||||||
|
repo,
|
||||||
|
'--json',
|
||||||
|
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
file: 'gh',
|
||||||
|
args,
|
||||||
|
commandText: `gh ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'github_pr_diff_stat': {
|
||||||
|
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
|
||||||
|
const args = ['pr', 'diff', String(prNumber), '--repo', repo, '--stat'];
|
||||||
|
return {
|
||||||
|
file: 'gh',
|
||||||
|
args,
|
||||||
|
commandText: `gh ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'github_run_status': {
|
||||||
|
const runId = normalizePositiveInteger(request.runId, 'run_id');
|
||||||
|
const args = [
|
||||||
|
'run',
|
||||||
|
'view',
|
||||||
|
String(runId),
|
||||||
|
'--repo',
|
||||||
|
repo,
|
||||||
|
'--json',
|
||||||
|
'status,conclusion,name,displayTitle,url,headBranch,headSha,event,createdAt,updatedAt',
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
file: 'gh',
|
||||||
|
args,
|
||||||
|
commandText: `gh ${args.join(' ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateGitHubEvidenceText(value: string | undefined): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (value.length <= MAX_OUTPUT_CHARS) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runGitHubEvidenceCommand(
|
||||||
|
request: GitHubEvidenceRequest,
|
||||||
|
): Promise<{
|
||||||
|
command: string;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}> {
|
||||||
|
const command = buildGitHubEvidenceCommand(request);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
command.file,
|
||||||
|
command.args,
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: COMMAND_TIMEOUT_MS,
|
||||||
|
maxBuffer: COMMAND_MAX_BUFFER,
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(
|
||||||
|
Object.assign(error, {
|
||||||
|
command: command.commandText,
|
||||||
|
stdout: truncateGitHubEvidenceText(stdout),
|
||||||
|
stderr: truncateGitHubEvidenceText(stderr),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
command: command.commandText,
|
||||||
|
stdout: truncateGitHubEvidenceText(stdout),
|
||||||
|
stderr: truncateGitHubEvidenceText(stderr),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -89,6 +89,15 @@ describe('host evidence IPC', () => {
|
|||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
action: 'ejclaw_service_status',
|
action: 'ejclaw_service_status',
|
||||||
tailLines: undefined,
|
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.requestId).toBe('req-1');
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ 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('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);
|
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,34 @@ 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 {
|
||||||
|
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';
|
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',
|
||||||
|
...DEPLOY_EVIDENCE_ACTIONS,
|
||||||
|
...DB_EVIDENCE_ACTIONS,
|
||||||
|
...GITHUB_EVIDENCE_ACTIONS,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
||||||
@@ -15,11 +38,25 @@ export interface HostEvidenceRequest {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
action: HostEvidenceAction;
|
action: HostEvidenceAction;
|
||||||
tailLines?: number;
|
tailLines?: number;
|
||||||
|
taskId?: string;
|
||||||
|
minutes?: number;
|
||||||
|
limit?: number;
|
||||||
|
repo?: string;
|
||||||
|
prNumber?: number;
|
||||||
|
runId?: number;
|
||||||
|
artifactKind?: string;
|
||||||
|
sourceGroup?: string;
|
||||||
|
isMain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostEvidenceResult {
|
export interface HostEvidenceResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
action: HostEvidenceAction | 'ejclaw_room_runtime';
|
action:
|
||||||
|
| HostEvidenceAction
|
||||||
|
| DbEvidenceAction
|
||||||
|
| DeployEvidenceAction
|
||||||
|
| GitHubEvidenceAction
|
||||||
|
| 'ejclaw_room_runtime';
|
||||||
command: string;
|
command: string;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
@@ -42,6 +79,7 @@ const MAX_LOG_TAIL_LINES = 200;
|
|||||||
const MAX_OUTPUT_CHARS = 16_000;
|
const MAX_OUTPUT_CHARS = 16_000;
|
||||||
const COMMAND_TIMEOUT_MS = 5_000;
|
const COMMAND_TIMEOUT_MS = 5_000;
|
||||||
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||||
|
const PROJECT_ROOT = process.cwd();
|
||||||
|
|
||||||
export function isHostEvidenceAction(
|
export function isHostEvidenceAction(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -108,6 +146,10 @@ export function buildHostEvidenceCommand(
|
|||||||
commandText: `journalctl ${args.join(' ')}`,
|
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(
|
export async function runHostEvidenceRequest(
|
||||||
request: HostEvidenceRequest,
|
request: HostEvidenceRequest,
|
||||||
): Promise<HostEvidenceResult> {
|
): Promise<HostEvidenceResult> {
|
||||||
const command = buildHostEvidenceCommand(request);
|
let commandText = '';
|
||||||
|
|
||||||
try {
|
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(
|
const { stdout, stderr } = await execFileCapture(
|
||||||
command.file,
|
command.file,
|
||||||
command.args,
|
command.args,
|
||||||
@@ -174,7 +303,7 @@ export async function runHostEvidenceRequest(
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
action: request.action,
|
action: request.action,
|
||||||
command: command.commandText,
|
command: commandText,
|
||||||
stdout: truncateHostEvidenceText(stdout),
|
stdout: truncateHostEvidenceText(stdout),
|
||||||
stderr: truncateHostEvidenceText(stderr),
|
stderr: truncateHostEvidenceText(stderr),
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -192,7 +321,10 @@ export async function runHostEvidenceRequest(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
action: request.action,
|
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),
|
stdout: truncateHostEvidenceText(stdout),
|
||||||
stderr: truncateHostEvidenceText(stderr),
|
stderr: truncateHostEvidenceText(stderr),
|
||||||
exitCode: extractExitCode(error),
|
exitCode: extractExitCode(error),
|
||||||
|
|||||||
@@ -320,6 +320,16 @@ async function handleHostEvidenceRequest(
|
|||||||
action: data.action,
|
action: data.action,
|
||||||
tailLines:
|
tailLines:
|
||||||
typeof data.tail_lines === 'number' ? data.tail_lines : undefined,
|
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, {
|
writeHostEvidenceResponse(sourceGroup, {
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ export interface TaskIpcPayload {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
tail_lines?: number;
|
tail_lines?: number;
|
||||||
|
task_id?: string;
|
||||||
|
minutes?: number;
|
||||||
|
limit?: number;
|
||||||
|
repo?: string;
|
||||||
|
pr_number?: number;
|
||||||
|
run_id?: number;
|
||||||
|
artifact_kind?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
expected_snapshot_id?: string;
|
expected_snapshot_id?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user