feat: add verification evidence presets
This commit is contained in:
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user