Ctrl K
ComunidadGuías…

src/runner/conflict-proposals.ts

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

Archivo original ↗Preguntar a KiBot
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { z } from 'zod';
import { sha256Canonical } from '../control-plane/snapshots.ts';
import { validateRepoRelativePath } from '../workspace/repo-store.ts';
import type { RunState } from './state.ts';
import { readState, writeState } from './state.ts';
import { readPaused, savePaused } from './resume.ts';
import type { OutboundItem } from './communication-types.ts';
import { cancelOutboundDelivery, listOutbound, finalizeDelivery } from './outbound.ts';
import { Trace } from '../harness/trace.ts';
import { patchpawPaths } from '../config/paths.ts';
import { atomicJson, immutableJson, readJson } from './durable-json.ts';
import { applyRunPhase } from './phases.ts';

export const CONFLICT_PROPOSAL_SCHEMA_VERSION = 'patchpaw.conflict-proposal.v1';
export const CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION = 'patchpaw.conflict-proposal-current.v1';
// Stage 07 enables the mechanical approval gate. The gate itself never decides whether a
// proposal is semantically correct; it only verifies the durable proposal, actor, Git facts,
// snapshot and configured permission before handing the retained workspace to the Repair task.
export const CONFLICT_APPROVAL_ENABLED = true;

const text = (max: number) => z.string().trim().min(1).max(max);

export const conflictProposalDraftSchema = z.object({
  summary: text(12_000),
  pr_intent: text(12_000),
  current_base_intent: text(12_000),
  conflicts: z.array(z.object({
    path: text(1_024),
    issue: text(12_000),
    pr_side: text(12_000),
    base_side: text(12_000),
    proposed_resolution: text(12_000),
    disagreement_or_tradeoff: text(12_000),
  })).min(1).max(200),
  affected_files: z.array(text(1_024)).min(1).max(500),
  verification_plan: z.array(text(4_000)).min(1).max(100),
  risks_or_open_questions: z.array(text(12_000)).max(100),
  human_markdown_summary: text(20_000),
});

export type ConflictProposalDraft = z.infer<typeof conflictProposalDraftSchema>;

export interface ConflictProposalBasis {
  pr_head_sha: string;
  /** Added by Stage 07; absent legacy v1 proposals remain readable but cannot be approved. */
  pr_head_ref?: string;
  pr_head_repo?: string;
  current_base_tip_sha: string;
  base_ref: string;
  workspace_evidence_sha256: string;
  command_snapshot_id: string;
  command_snapshot_sha256: string;
}

export interface ConflictProposal {
  schema_version: typeof CONFLICT_PROPOSAL_SCHEMA_VERSION;
  proposal_id: string;
  proposal_revision: number;
  execution_id: string;
  /** The approval/repair execution identity; discussion executions never replace it. */
  repair_execution_id: string;
  /** The run directory containing the original repair snapshot/evidence. */
  repair_run_id: string;
  run_id: string;
  summary: string;
  pr_intent: string;
  current_base_intent: string;
  conflicts: ConflictProposalDraft['conflicts'];
  affected_files: string[];
  verification_plan: string[];
  risks_or_open_questions: string[];
  human_markdown_summary: string;
  basis: ConflictProposalBasis;
  /** A read-only conversation execution/snapshot that produced a revision, when applicable. */
  discussion_execution_id?: string;
  discussion_snapshot_id?: string;
  discussion_snapshot_sha256?: string;
  proposal_hash: string;
  /** Status is kept in a sidecar/current pointer so the version payload remains immutable. */
  status: ConflictProposalStatus;
  created_at: string;
  publication?: ConflictProposalPublication;
}

export type ConflictProposalStatus = 'draft' | 'publication_pending' | 'published' | 'superseded' | 'stale';

export interface ConflictProposalPublication {
  delivery_id: string;
  remote_id?: number;
  remote_url?: string;
  published_at?: string;
}

export interface ConflictProposalPointer {
  schema_version: typeof CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION;
  proposal_id: string;
  proposal_revision: number;
  proposal_hash: string;
  status: ConflictProposalStatus;
  proposal_path: string;
  run_id: string;
  execution_id: string;
  workspace_path: string;
  pr_head_sha: string;
  pr_head_ref?: string;
  pr_head_repo?: string;
  current_base_tip_sha: string;
  base_ref: string;
  workspace_evidence_sha256: string;
  command_snapshot_id: string;
  command_snapshot_sha256: string;
  publication_delivery_id?: string;
  publication_remote_id?: number;
  publication_remote_url?: string;
  published_at?: string;
  updated_at: string;
}

export interface ConflictProposalInput {
  draft: unknown;
  proposalId?: string;
  proposalRevision: number;
  executionId: string;
  runId: string;
  repairRunId?: string;
  basis: ConflictProposalBasis;
  discussion?: {
    discussion_execution_id: string;
    discussion_snapshot_id: string;
    discussion_snapshot_sha256: string;
  };
  createdAt?: string;
}

export interface ConflictProposalWorkspaceState {
  proposal: ConflictProposal;
  pointer: ConflictProposalPointer;
}

const statusValues = new Set<ConflictProposalStatus>(['draft', 'publication_pending', 'published', 'superseded', 'stale']);

function assertRevision(revision: number) {
  if (!Number.isSafeInteger(revision) || revision < 1) throw new Error('Conflict proposal revision must be a positive integer');
}

function assertSha(value: string, label: string) {
  if (!/^[a-f0-9]{7,64}$/i.test(value)) throw new Error(`Conflict proposal ${label} must be a Git object/hash`);
}

function normalizeDraft(input: unknown): ConflictProposalDraft {
  const parsed = conflictProposalDraftSchema.safeParse(input);
  if (!parsed.success) throw new Error(`Invalid conflict proposal: ${parsed.error.issues.map(issue => issue.path.join('.') + ' ' + issue.message).join('; ')}`);
  const draft = parsed.data;
  const affected = new Set<string>();
  for (const file of draft.affected_files) affected.add(validateRepoRelativePath(file));
  const conflicts = draft.conflicts.map(conflict => ({ ...conflict, path: validateRepoRelativePath(conflict.path) }));
  const conflictPaths = new Set(conflicts.map(conflict => conflict.path));
  for (const path of conflictPaths) {
    if (!affected.has(path)) throw new Error(`Conflict proposal affected_files must include conflict path: ${path}`);
  }
  return { ...draft, conflicts, affected_files: [...affected] };
}

function immutablePayload(proposal: Pick<ConflictProposal, keyof ConflictProposal>) {
  const {
    proposal_hash: _hash, status: _status, created_at: _createdAt, publication: _publication,
    ...payload
  } = proposal;
  return payload;
}

export function conflictProposalHash(proposal: Pick<ConflictProposal, keyof ConflictProposal>) {
  return sha256Canonical(immutablePayload(proposal));
}

export function createConflictProposal(input: ConflictProposalInput): ConflictProposal {
  assertRevision(input.proposalRevision);
  if (!input.executionId || !input.runId) throw new Error('Conflict proposal execution and run identity are required');
  if (!input.basis.pr_head_ref || !input.basis.pr_head_repo) throw new Error('Conflict proposal source ref and repository are required');
  assertSha(input.basis.pr_head_sha, 'pr_head_sha');
  assertSha(input.basis.current_base_tip_sha, 'current_base_tip_sha');
  assertSha(input.basis.workspace_evidence_sha256, 'workspace_evidence_sha256');
  assertSha(input.basis.command_snapshot_sha256, 'command_snapshot_sha256');
  const draft = normalizeDraft(input.draft);
  const proposal = {
    schema_version: CONFLICT_PROPOSAL_SCHEMA_VERSION,
    proposal_id: input.proposalId ?? `proposal-${randomUUID()}`,
    proposal_revision: input.proposalRevision,
    execution_id: input.executionId,
    repair_execution_id: input.executionId,
    repair_run_id: input.repairRunId ?? input.runId,
    run_id: input.runId,
    ...draft,
    basis: input.basis,
    ...(input.discussion ?? {}),
    proposal_hash: '',
    status: 'draft' as const,
    created_at: input.createdAt ?? new Date().toISOString(),
  } satisfies ConflictProposal;
  proposal.proposal_hash = conflictProposalHash(proposal);
  return proposal;
}

export function validateConflictProposal(input: unknown): ConflictProposal {
  if (!input || typeof input !== 'object') throw new Error('Conflict proposal is not an object');
  const value = input as Partial<ConflictProposal>;
  if (value.schema_version !== CONFLICT_PROPOSAL_SCHEMA_VERSION) throw new Error('Unsupported conflict proposal schema');
  assertRevision(value.proposal_revision as number);
  if (typeof value.proposal_id !== 'string' || !value.proposal_id || typeof value.execution_id !== 'string' || !value.execution_id ||
      typeof value.repair_execution_id !== 'string' || value.repair_execution_id !== value.execution_id ||
      typeof value.run_id !== 'string' || !value.run_id || typeof value.proposal_hash !== 'string') throw new Error('Conflict proposal identity is incomplete');
  const repairRunId = typeof value.repair_run_id === 'string' && value.repair_run_id ? value.repair_run_id : value.run_id;
  const draft = normalizeDraft(value);
  const basis = value.basis as ConflictProposalBasis | undefined;
  if (!basis?.pr_head_sha || !basis.current_base_tip_sha || !basis.base_ref || !basis.workspace_evidence_sha256 ||
      !basis.command_snapshot_id || !basis.command_snapshot_sha256) throw new Error('Conflict proposal evidence basis is incomplete');
  if ((basis.pr_head_ref === undefined) !== (basis.pr_head_repo === undefined)) throw new Error('Conflict proposal source binding is incomplete');
  assertSha(basis.pr_head_sha, 'pr_head_sha'); assertSha(basis.current_base_tip_sha, 'current_base_tip_sha');
  assertSha(basis.workspace_evidence_sha256, 'workspace_evidence_sha256'); assertSha(basis.command_snapshot_sha256, 'command_snapshot_sha256');
  const discussion = [value.discussion_execution_id, value.discussion_snapshot_id, value.discussion_snapshot_sha256];
  if (discussion.some(item => item !== undefined)) {
    if (discussion.some(item => typeof item !== 'string' || !item)) throw new Error('Conflict proposal discussion snapshot is incomplete');
    assertSha(value.discussion_snapshot_sha256 as string, 'discussion_snapshot_sha256');
  }
  if (!statusValues.has(value.status as ConflictProposalStatus)) throw new Error('Conflict proposal status is invalid');
  if (typeof value.created_at !== 'string' || !value.created_at) throw new Error('Conflict proposal creation time is missing');
  const proposal = { ...value, ...draft, basis, repair_run_id: repairRunId, status: value.status as ConflictProposalStatus } as ConflictProposal;
  const hashMatches = conflictProposalHash(proposal) === proposal.proposal_hash
    || (value.repair_run_id === undefined && conflictProposalHash({ ...proposal, repair_run_id: undefined } as unknown as ConflictProposal) === proposal.proposal_hash);
  if (!hashMatches) throw new Error('Conflict proposal hash does not match its immutable payload');
  return proposal;
}

export function renderConflictProposal(proposal: ConflictProposal) {
  const conflicts = proposal.conflicts.map((conflict, index) => [
    `### 冲突 ${index + 1}:\`${conflict.path}\``,
    `- 问题:${conflict.issue}`,
    `- PR 侧:${conflict.pr_side}`,
    `- 当前 base 侧:${conflict.base_side}`,
    `- 拟议解决:${conflict.proposed_resolution}`,
    `- 分歧/取舍:${conflict.disagreement_or_tradeoff}`,
  ].join('\n')).join('\n\n');
  const list = (values: string[]) => values.map(value => `- ${value}`).join('\n');
  return [
    `## PatchPaw Conflict Proposal v${proposal.proposal_revision}`,
    `提案 hash:\`${proposal.proposal_hash}\``,
    '',
    `### PR 意图\n${proposal.pr_intent}`,
    `### 当前 base 意图\n${proposal.current_base_intent}`,
    `### 总结\n${proposal.summary}`,
    `### 冲突与拟议方向\n${conflicts}`,
    `### 影响文件\n${list(proposal.affected_files)}`,
    `### 验证计划\n${list(proposal.verification_plan)}`,
    `### 风险与待决问题\n${proposal.risks_or_open_questions.length ? list(proposal.risks_or_open_questions) : '- 暂无已知风险或待决问题。'}`,
    `### Agent 面向人类的摘要\n${proposal.human_markdown_summary}`,
    '',
    `依据:PR head \`${proposal.basis.pr_head_sha}\`,current base \`${proposal.basis.current_base_tip_sha}\`,base ref \`${proposal.basis.base_ref}\`。`,
    '以上内容由 Harness 从已验证的结构化提案生成;当前只读阶段不会修改、commit 或 push。',
    '如确认该版本的方向,请在此 PR 新增一条单独评论:`/approval`。普通讨论不会授权修复。',
  ].join('\n\n');
}

export function conflictProposalDirectory(statePath: string) { return `${statePath}.conflict-proposals`; }
export function conflictProposalVersionPath(statePath: string, revision: number) {
  assertRevision(revision);
  return join(conflictProposalDirectory(statePath), `conflict-proposal-v${revision}.json`);
}
function conflictProposalStatusPath(statePath: string, revision: number) {
  return join(conflictProposalDirectory(statePath), `conflict-proposal-v${revision}.status.json`);
}
export function conflictProposalCurrentPath(statePath: string) {
  return join(conflictProposalDirectory(statePath), 'conflict-proposal-current.json');
}

function pointerFromProposal(statePath: string, proposal: ConflictProposal, workspacePath: string, updatedAt = new Date().toISOString()): ConflictProposalPointer {
  return {
    schema_version: CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION,
    proposal_id: proposal.proposal_id, proposal_revision: proposal.proposal_revision, proposal_hash: proposal.proposal_hash,
    status: proposal.status, proposal_path: conflictProposalVersionPath(statePath, proposal.proposal_revision),
    run_id: proposal.run_id, execution_id: proposal.execution_id, workspace_path: workspacePath,
    pr_head_sha: proposal.basis.pr_head_sha, pr_head_ref: proposal.basis.pr_head_ref, pr_head_repo: proposal.basis.pr_head_repo,
    current_base_tip_sha: proposal.basis.current_base_tip_sha, base_ref: proposal.basis.base_ref,
    workspace_evidence_sha256: proposal.basis.workspace_evidence_sha256,
    command_snapshot_id: proposal.basis.command_snapshot_id, command_snapshot_sha256: proposal.basis.command_snapshot_sha256,
    ...(proposal.publication ? {
      publication_delivery_id: proposal.publication.delivery_id, publication_remote_id: proposal.publication.remote_id,
      publication_remote_url: proposal.publication.remote_url, published_at: proposal.publication.published_at,
    } : {}),
    updated_at: updatedAt,
  };
}

export async function readConflictProposal(statePath: string, revision: number): Promise<ConflictProposal | null> {
  const raw = await readJson<ConflictProposal>(conflictProposalVersionPath(statePath, revision));
  if (!raw) return null;
  const proposal = validateConflictProposal(raw);
  const status = await readJson<{ status: ConflictProposalStatus; publication?: ConflictProposalPublication }>(conflictProposalStatusPath(statePath, revision));
  return { ...proposal, ...(status ?? {}) };
}

export async function readCurrentConflictProposal(statePath: string): Promise<ConflictProposalWorkspaceState | null> {
  const pointer = await readJson<ConflictProposalPointer>(conflictProposalCurrentPath(statePath));
  if (!pointer) return null;
  if (pointer.schema_version !== CONFLICT_PROPOSAL_CURRENT_SCHEMA_VERSION) throw new Error('Conflict proposal current pointer schema is unsupported');
  const proposal = await readConflictProposal(statePath, pointer.proposal_revision);
  if (!proposal || proposal.proposal_id !== pointer.proposal_id || proposal.proposal_hash !== pointer.proposal_hash) {
    throw new Error('Conflict proposal current pointer does not resolve to an immutable version');
  }
  return { proposal, pointer };
}

export async function saveConflictProposal(statePath: string, proposal: ConflictProposal, workspacePath: string) {
  const normalized = validateConflictProposal(proposal);
  const versionPath = conflictProposalVersionPath(statePath, normalized.proposal_revision);
  const existing = await readJson<ConflictProposal>(versionPath);
  if (existing) {
    const valid = validateConflictProposal(existing);
    if (valid.proposal_hash !== normalized.proposal_hash || valid.proposal_id !== normalized.proposal_id) {
      throw new Error(`Conflict proposal revision v${normalized.proposal_revision} is immutable and already contains different content`);
    }
  } else {
    await immutableJson(versionPath, { ...normalized, status: 'draft', publication: undefined });
  }
  await atomicJson(conflictProposalStatusPath(statePath, normalized.proposal_revision), { status: normalized.status });
  const current = await readCurrentConflictProposal(statePath);
  if (!current || current.proposal.proposal_revision <= normalized.proposal_revision) {
    await atomicJson(conflictProposalCurrentPath(statePath), pointerFromProposal(statePath, normalized, workspacePath));
  }
  return readCurrentConflictProposal(statePath);
}

export async function markConflictProposalStatus(statePath: string, revision: number, status: ConflictProposalStatus,
  workspacePath: string, publication?: ConflictProposalPublication) {
  const proposal = await readConflictProposal(statePath, revision);
  if (!proposal) throw new Error(`Conflict proposal v${revision} is missing`);
  await atomicJson(conflictProposalStatusPath(statePath, revision), { status, ...(publication ? { publication } : {}) });
  const current = await readCurrentConflictProposal(statePath);
  if (current?.proposal.proposal_revision === revision) {
    const next = { ...proposal, status, ...(publication ? { publication } : {}) };
    await atomicJson(conflictProposalCurrentPath(statePath), pointerFromProposal(statePath, next, workspacePath));
  }
  return readCurrentConflictProposal(statePath);
}

export function proposalPointerForState(proposal: ConflictProposal, workspacePath: string): NonNullable<RunState['conflict_proposal']> {
  return {
    proposal_id: proposal.proposal_id, proposal_revision: proposal.proposal_revision, proposal_hash: proposal.proposal_hash,
    status: proposal.status, run_id: proposal.run_id, execution_id: proposal.execution_id, workspace_path: workspacePath,
    pr_head_sha: proposal.basis.pr_head_sha, pr_head_ref: proposal.basis.pr_head_ref, pr_head_repo: proposal.basis.pr_head_repo,
    current_base_tip_sha: proposal.basis.current_base_tip_sha, base_ref: proposal.basis.base_ref,
    workspace_evidence_sha256: proposal.basis.workspace_evidence_sha256,
    command_snapshot_id: proposal.basis.command_snapshot_id, command_snapshot_sha256: proposal.basis.command_snapshot_sha256,
  };
}

export async function saveProposalState(path: string, proposal: ConflictProposal, workspacePath: string, patch: Partial<RunState> = {}) {
  const current = await readState(path);
  if (current && current.conflict_proposal && current.conflict_proposal.proposal_revision > proposal.proposal_revision) return current;
  const pointer = proposalPointerForState(proposal, workspacePath);
  const next: RunState = applyRunPhase({ ...(current ?? {} as RunState), ...patch, conflict_proposal: pointer,
    active: patch.active ?? current?.active ?? false }, patch.phase ?? current?.phase ?? proposal.status);
  await writeState(path, next);
  return next;
}

export async function updateProposalState(path: string, proposal: ConflictProposal, workspacePath: string, patch: Partial<RunState> = {}) {
  const current = await readState(path);
  if (current?.conflict_proposal && current.conflict_proposal.proposal_hash !== proposal.proposal_hash &&
      current.conflict_proposal.proposal_revision > proposal.proposal_revision) return current;
  return saveProposalState(path, proposal, workspacePath, patch);
}

export async function markCurrentConflictProposalStale(path: string, workspacePath: string, reason: string) {
  const current = await readCurrentConflictProposal(path);
  if (!current) return null;
  await markConflictProposalStatus(path, current.proposal.proposal_revision, 'stale', workspacePath);
  const state = await readState(path);
  if (state?.conflict_proposal?.proposal_hash === current.proposal.proposal_hash) {
    await writeState(path, applyRunPhase({ ...state, active: false, conflict_proposal: { ...state.conflict_proposal, status: 'stale' } }, 'stale'));
  }
  return { ...current, reason };
}

export function proposalDeliverySemanticKey(proposal: ConflictProposal) {
  return `conflict-proposal:${proposal.proposal_id}:v${proposal.proposal_revision}:${proposal.proposal_hash}`;
}

export async function cancelConflictProposalPublication(root: string, repo: string, prNumber: number, proposal: ConflictProposal) {
  const delivery = (await listOutbound(root, { repo, prNumber })).find(item => item.item.semantic_key === proposalDeliverySemanticKey(proposal));
  if (!delivery) return undefined;
  return cancelOutboundDelivery(root, delivery, 'conflict_proposal_superseded');
}

export function proposalPublicationFromOutbound(item: OutboundItem): ConflictProposalPublication {
  return {
    delivery_id: item.delivery_id,
    remote_id: item.receipt?.id,
    remote_url: item.receipt?.html_url,
    published_at: item.receipt?.published_at ?? new Date().toISOString(),
  };
}

/**
 * Reconcile the proposal projection from the durable communication receipt. This is safe to
 * call from a worker retry or the communication scheduler: it never calls a model or grants
 * write access, and it refuses to move a superseded/stale version back to awaiting approval.
 */
export async function reconcileConflictProposalPublication(root: string, statePath: string, repo: string, prNumber: number, finalize = true) {
  const current = await readCurrentConflictProposal(statePath);
  if (!current || !['draft', 'publication_pending', 'published'].includes(current.proposal.status)) return current;
  // /close removes the proposal directory and retires the local generation. If a delayed
  // scheduler sees an old outbox row during that boundary, it may finalize delivery but must
  // not recreate a closed run's state, trace, or retained workspace.
  const stateBefore = await readState(statePath);
  if (stateBefore?.phase === 'closed') return current;
  const delivery = (await listOutbound(root, { repo, prNumber })).find(item => item.item.semantic_key === proposalDeliverySemanticKey(current.proposal));
  if (!delivery || delivery.item.status !== 'delivered' || !delivery.item.receipt) return current;
  const publication = proposalPublicationFromOutbound(delivery.item);
  const updated = current.proposal.status === 'published'
    ? current
    : await markConflictProposalStatus(statePath, current.proposal.proposal_revision, 'published', current.pointer.workspace_path, publication);
  const state = await readState(statePath);
  if (updated && state && state.phase !== 'closed'
      && (state.conflict_proposal?.proposal_hash !== current.proposal.proposal_hash
        || state.conflict_proposal?.status !== 'published' || state.phase !== 'awaiting_approval')) {
    const next: RunState = applyRunPhase({ ...state, active: false,
      conflict_proposal: { ...proposalPointerForState(updated.proposal, current.pointer.workspace_path), status: 'published', publication_delivery_id: publication.delivery_id,
        publication_remote_id: publication.remote_id, publication_remote_url: publication.remote_url, published_at: publication.published_at } }, 'awaiting_approval');
    await writeState(statePath, next);
    const paused = await readPaused(statePath);
    if (paused?.workspace.path === current.pointer.workspace_path) await savePaused(statePath, { ...paused, status: 'awaiting_approval' });
    const dir = join(patchpawPaths(root).runs, current.proposal.run_id);
    const trace = new Trace(dir); trace.save('conflict-proposal-publication.json', { ...publication, proposal_revision: current.proposal.proposal_revision,
      proposal_hash: current.proposal.proposal_hash, status: 'published' }); trace.emit('conflict_proposal_published', publication);
    const resultPath = join(dir, 'result.json');
    const existing = await readJson<Record<string, unknown>>(resultPath);
    if (existing && ['publication_pending', 'draft'].includes(String(existing.status))) {
      trace.save('result.json', { ...existing, status: 'awaiting_approval', proposal_revision: current.proposal.proposal_revision,
        proposal_hash: current.proposal.proposal_hash, publication });
    }
  }
  // Finalization is idempotent and is intentionally performed even when the projection was
  // already repaired by an earlier worker pass.
  if (finalize) await finalizeDelivery(root, delivery);
  return updated;
}

export async function reconcilePendingConflictProposal(root: string, statePath: string, repo: string, prNumber: number) {
  return reconcileConflictProposalPublication(root, statePath, repo, prNumber);
}