Ctrl K
ComunidadGuías…

src/runner/workspace-evidence.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { lstat, readFile, readlink } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { git } from '../workspace/git.ts';
import type { Trace } from '../harness/trace.ts';
import { readArtifact } from './review-lifecycle.ts';
import { canonicalJson } from '../control-plane/snapshots.ts';

// Hash only Git-visible files; never follow symlinks or persist file contents.
async function snapshot(workspace: string) {
  const paths = (await git(workspace, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])).stdout.split('\0').filter(Boolean);
  const files: Record<string, string> = Object.create(null);
  for (const path of new Set(paths)) {
    try {
      const file = join(workspace, path), stat = await lstat(file);
      if (stat.isDirectory()) continue; // Submodule contents are outside this worktree.
      if (!stat.isFile() && !stat.isSymbolicLink()) { files[path] = `special:${stat.mode}`; continue; }
      const content = stat.isSymbolicLink() ? await readlink(file) : await readFile(file);
      files[path] = `${stat.mode & 0o777}:${createHash('sha256').update(content).digest('hex')}`;
    } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
  }
  return files;
}

export interface ConflictWorkspaceEvidenceInput {
  repository: string;
  prNumber: number;
  prHeadSha: string;
  historicalBaseSha: string;
  currentBaseTipSha: string;
  baseRef: string;
  runId: string;
  executionId: string;
  commandSnapshotId: string;
  commandSnapshotSha256: string;
}

export interface ConflictWorkspaceEvidence {
  schema_version: 'patchpaw.workspace-evidence.v1';
  repository: string;
  pr_number: number;
  pr_head_sha: string;
  historical_base_sha: string;
  current_base_tip_sha: string;
  base_ref: string;
  run_id: string;
  execution_id: string;
  command_snapshot_id: string;
  command_snapshot_sha256: string;
  workspace_head: string;
  initial_head: string;
  merge_base: string;
  git_status_porcelain_v2: string;
  git_index: string;
  git_unmerged_index: string;
  merge_head: string | null;
  merge_pending: boolean;
  unresolved_paths: string[];
  pr_diff_paths: string[];
  current_base_affected_paths: string[];
  files: Record<string, string>;
  captured_at: string;
}

export type StoredConflictWorkspaceEvidence = ConflictWorkspaceEvidence & { evidence_sha256?: string };

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function stringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(entry => typeof entry === 'string');
}

/** Validate the persisted evidence envelope before approval logic relies on it. */
export function parseStoredConflictWorkspaceEvidence(value: unknown): StoredConflictWorkspaceEvidence | null {
  if (!isRecord(value) || value.schema_version !== 'patchpaw.workspace-evidence.v1') return null;
  const prNumber = value.pr_number;
  const filesValue = value.files;
  if (typeof value.repository !== 'string' || typeof prNumber !== 'number' || !Number.isSafeInteger(prNumber)
      || typeof value.pr_head_sha !== 'string' || typeof value.historical_base_sha !== 'string'
      || typeof value.current_base_tip_sha !== 'string' || typeof value.base_ref !== 'string'
      || typeof value.run_id !== 'string' || typeof value.execution_id !== 'string'
      || typeof value.command_snapshot_id !== 'string' || typeof value.command_snapshot_sha256 !== 'string'
      || typeof value.workspace_head !== 'string' || typeof value.initial_head !== 'string'
      || typeof value.merge_base !== 'string' || typeof value.git_status_porcelain_v2 !== 'string'
      || typeof value.git_index !== 'string' || typeof value.git_unmerged_index !== 'string'
      || (value.merge_head !== null && typeof value.merge_head !== 'string')
      || typeof value.merge_pending !== 'boolean' || !stringArray(value.unresolved_paths)
      || !stringArray(value.pr_diff_paths) || !stringArray(value.current_base_affected_paths)
      || !isRecord(filesValue) || !Object.values(filesValue).every(entry => typeof entry === 'string')
      || typeof value.captured_at !== 'string'
      || (value.evidence_sha256 !== undefined && typeof value.evidence_sha256 !== 'string')) return null;
  const files: Record<string, string> = {};
  for (const [path, digest] of Object.entries(filesValue)) {
    if (typeof digest !== 'string') return null;
    files[path] = digest;
  }
  return {
    schema_version: 'patchpaw.workspace-evidence.v1', repository: value.repository, pr_number: prNumber,
    pr_head_sha: value.pr_head_sha, historical_base_sha: value.historical_base_sha, current_base_tip_sha: value.current_base_tip_sha,
    base_ref: value.base_ref, run_id: value.run_id, execution_id: value.execution_id,
    command_snapshot_id: value.command_snapshot_id, command_snapshot_sha256: value.command_snapshot_sha256,
    workspace_head: value.workspace_head, initial_head: value.initial_head, merge_base: value.merge_base,
    git_status_porcelain_v2: value.git_status_porcelain_v2, git_index: value.git_index, git_unmerged_index: value.git_unmerged_index,
    merge_head: value.merge_head, merge_pending: value.merge_pending, unresolved_paths: value.unresolved_paths,
    pr_diff_paths: value.pr_diff_paths, current_base_affected_paths: value.current_base_affected_paths, files,
    captured_at: value.captured_at, ...(value.evidence_sha256 ? { evidence_sha256: value.evidence_sha256 } : {}),
  };
}

function lines(value: string) { return value.split('\0').filter(Boolean).length ? value.split('\0').filter(Boolean) : value.trim().split('\n').filter(Boolean); }

export function workspaceEvidenceSha256(evidence: ConflictWorkspaceEvidence) {
  return createHash('sha256').update(canonicalJson(evidence), 'utf8').digest('hex');
}

/**
 * Compare the retained checkout with the last durable evidence before a read-only discussion.
 * Run/execution timestamps and the newly captured evidence hash are intentionally excluded: the
 * safety question is whether the checkout's Git/index/content facts changed underneath us.
 */
export function compareConflictWorkspaceEvidence(previous: ConflictWorkspaceEvidence, current: ConflictWorkspaceEvidence) {
  const fields: (keyof ConflictWorkspaceEvidence)[] = [
    'workspace_head', 'initial_head', 'merge_base', 'git_status_porcelain_v2', 'git_index',
    'git_unmerged_index', 'merge_head', 'merge_pending', 'unresolved_paths', 'pr_diff_paths',
    'current_base_affected_paths', 'files',
  ];
  for (const field of fields) {
    if (canonicalJson(previous[field]) !== canonicalJson(current[field])) return { ok: false as const, reason: `workspace_${field}_changed` };
  }
  return { ok: true as const };
}

export async function captureConflictWorkspaceEvidence(workspace: string, input: ConflictWorkspaceEvidenceInput, trace: Trace, artifactName = 'workspace-evidence.json') {
  const [workspaceHead, mergeBase, status, index, unmerged, mergeHead, prDiff, baseDiff, files] = await Promise.all([
    git(workspace, ['rev-parse', 'HEAD'], trace),
    git(workspace, ['merge-base', input.currentBaseTipSha, 'HEAD'], trace),
    git(workspace, ['status', '--porcelain=v2', '--untracked-files=all'], trace),
    git(workspace, ['ls-files', '-s'], trace),
    git(workspace, ['ls-files', '-u'], trace),
    git(workspace, ['rev-parse', '--verify', 'MERGE_HEAD'], trace, undefined, true),
    git(workspace, ['diff', '--name-only', `${input.currentBaseTipSha}...HEAD`, '-z'], trace),
    git(workspace, ['diff', '--name-only', `${input.historicalBaseSha}..${input.currentBaseTipSha}`, '-z'], trace, undefined, true),
    snapshot(workspace),
  ]);
  const unresolved = lines(unmerged.stdout);
  const evidence: ConflictWorkspaceEvidence = {
    schema_version: 'patchpaw.workspace-evidence.v1', repository: input.repository, pr_number: input.prNumber,
    pr_head_sha: input.prHeadSha, historical_base_sha: input.historicalBaseSha, current_base_tip_sha: input.currentBaseTipSha,
    base_ref: input.baseRef, run_id: input.runId, execution_id: input.executionId,
    command_snapshot_id: input.commandSnapshotId, command_snapshot_sha256: input.commandSnapshotSha256,
    workspace_head: workspaceHead.stdout.trim(), initial_head: input.prHeadSha, merge_base: mergeBase.stdout.trim(),
    git_status_porcelain_v2: status.stdout, git_index: index.stdout, git_unmerged_index: unmerged.stdout,
    merge_head: mergeHead.exitCode === 0 ? mergeHead.stdout.trim() : null, merge_pending: mergeHead.exitCode === 0,
    unresolved_paths: unresolved, pr_diff_paths: lines(prDiff.stdout), current_base_affected_paths: lines(baseDiff.stdout),
    files, captured_at: new Date().toISOString(),
  };
  const hash = workspaceEvidenceSha256(evidence);
  trace.save(artifactName, { ...evidence, evidence_sha256: hash });
  trace.emit('workspace_evidence_captured', { evidence_sha256: hash, unresolved_paths: unresolved, merge_pending: evidence.merge_pending });
  return { evidence, evidenceSha256: hash };
}

export async function captureExecutionBaseline(workspace: string, trace: Trace, conflict?: ConflictWorkspaceEvidenceInput) {
  const files = await snapshot(workspace);
  trace.save('workspace-baseline.json', { execution_id: trace.executionId, files,
    status: (await git(workspace, ['status', '--short'])).stdout });
  return conflict ? captureConflictWorkspaceEvidence(workspace, conflict, trace) : undefined;
}
export async function executionChanges(workspace: string, dir: string) {
  const baseline = await readArtifact(dir, 'workspace-baseline.json');
  if (!baseline) return 'Agent 本轮主动修改:未知(本轮没有文件基线,不能将合并差异归于 Agent)。';
  const current = await snapshot(workspace);
  const changed = [...new Set([...Object.keys(baseline.files), ...Object.keys(current)])]
    .filter(path => baseline.files[path] !== current[path]);
  return `Agent 本轮主动修改:${changed.length} 个文件(按执行开始后的文件净变化统计,含命令或测试产生的变化,不含已撤销修改)。`
    + (changed.length ? `\n${changed.slice(0, 30).map(path => `- ${JSON.stringify(path)}`).join('\n')}` : '');
}