Ctrl K
ComunidadGuías…

src/runner/communication-finalization.ts

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

Archivo original ↗Preguntar a KiBot
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Trace } from '../harness/trace.ts';
import { disposeWorkspacePath } from '../workspace/repo-store.ts';
import { patchpawPaths } from '../config/paths.ts';
import { readApprovalPlan, updateApprovalPlan } from './approval-plans.ts';
import { applyRunPhase } from './phases.ts';
import { readPaused, savePaused } from './resume.ts';
import { readState, statePath, writeState } from './state.ts';
import type { CommunicationStore } from './communication-store.ts';
import type { OutboundConnection, OutboundItem, StoredItem } from './outbound.ts';

export type FinalizationKind =
  | 'stale_review'
  | 'review'
  | 'run_notice'
  | 'approval_plan'
  | 'legacy_conflict_proposal'
  | 'legacy_conflict_repair'
  | 'close_refusal'
  | 'close_completion'
  | 'close_start'
  | 'none';

function sourceRun(item: Pick<OutboundItem, 'source'>) {
  return typeof item.source.run_id === 'string' ? item.source.run_id : undefined;
}

/** Classify only from durable outbox identity; handlers own all I/O and state transitions. */
export function classifyFinalization(item: Pick<OutboundItem, 'kind' | 'status' | 'purpose' | 'source'>): FinalizationKind {
  const runId = sourceRun(item);
  if (item.kind === 'review' && item.status === 'cancelled_stale') return 'stale_review';
  if (item.status !== 'delivered') return 'none';
  if (item.kind === 'review' && runId) return 'review';
  if (runId && item.purpose === 'run_notice') return 'run_notice';
  if (item.purpose === 'approval_plan') return 'approval_plan';
  if (item.purpose === 'conflict_proposal') return 'legacy_conflict_proposal';
  if (item.purpose === 'conflict_repair') return 'legacy_conflict_repair';
  if (item.purpose === 'close_refusal') return 'close_refusal';
  if (item.purpose === 'close_completion') return 'close_completion';
  if (item.purpose === 'close_start') return 'close_start';
  return 'none';
}

export type FinalizationConnection = OutboundConnection | (() => Promise<OutboundConnection>);

export interface FinalizationConfig {
  root: string;
  legacyHome?: string;
  snapshotRoot: string;
}

export interface FinalizationContext {
  config: FinalizationConfig;
  stored: StoredItem;
  store: CommunicationStore;
  resolveConnection: () => Promise<OutboundConnection | undefined>;
  done: () => Promise<OutboundItem | undefined>;
}

type JsonObject = Record<string, unknown>;

function isJsonObject(value: unknown): value is JsonObject {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

async function json(path: string): Promise<JsonObject | null> {
  try {
    const value: unknown = JSON.parse(await readFile(path, 'utf8'));
    if (!isJsonObject(value)) throw new Error(`Expected JSON object at ${path}`);
    return value;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
    throw error;
  }
}

function stringValue(value: unknown) { return typeof value === 'string' ? value : undefined; }

function runDir(root: string, runId: string) { return join(patchpawPaths(root).runs, runId); }

export async function finalizeStaleReview(context: FinalizationContext) {
  const { config, stored, resolveConnection, done } = context;
  const item = stored.item;
  const source = sourceRun(item);
  if (!source) return done();
  const dir = runDir(config.root, source);
  const review = await json(join(dir, 'review.json'));
  if (!review || await json(join(dir, 'review-stale.json'))) return done();
  let actualHead = '';
  let prState = 'unknown';
  try {
    const resolved = await resolveConnection();
    if (!resolved) throw new Error('SCM connection required for stale Review finalization');
    if (resolved.adapter) {
      const projectId = typeof item.source.project_id === 'string' || typeof item.source.project_id === 'number'
        ? String(item.source.project_id) : item.repo.match(/^gitlab:(.+):project:(.+)$/)?.[2];
      if (!projectId) throw new Error('GitLab stale Review is missing its project id');
      const changeRequest = await resolved.adapter.readChangeRequest(projectId, item.pr_number, { allowClosed: true });
      actualHead = changeRequest.source.sha;
      prState = changeRequest.state;
    } else {
      if (!resolved.client) throw new Error('GitHub client required for stale Review finalization');
      const [owner, repo] = item.repo.split('/');
      const { data } = await resolved.client.rest.pulls.get({ owner, repo, pull_number: item.pr_number });
      actualHead = data.head.sha;
      prState = data.state;
    }
  } catch {
    // The cancellation record remains durable if the remote read is unavailable.
  }
  const trace = new Trace(dir);
  trace.save('review-stale.json', { expected_head: String(item.source.head_sha ?? ''), actual_head: actualHead, pr_state: prState });
  const path = statePath(patchpawPaths(config.root).state, item.repo, item.pr_number);
  const state = await readState(path);
  if (state?.run_id === source) {
    trace.save('result.json', { status: 'review_stale', run_id: source, repo: item.repo, pr_number: item.pr_number,
      final_head_sha: String(item.source.head_sha ?? ''), recovered: true, expected_head: String(item.source.head_sha ?? ''), actual_head: actualHead, pr_state: prState });
    await writeState(path, applyRunPhase({ ...state, active: false }, 'review_stale'));
  }
  return done();
}

export async function finalizeReviewDelivery(context: FinalizationContext) {
  const { config, stored, done } = context;
  const item = stored.item;
  const source = sourceRun(item);
  if (!source) return done();
  const dir = runDir(config.root, source);
  const review = await json(join(dir, 'review.json'));
  if (!review || await json(join(dir, 'review-publication.json'))) return done();
  const headSha = stringValue(review.head_sha);
  if (!headSha) throw new Error('Review artifact is missing head_sha');
  const publication = { ...(item.receipt ?? {}), run_id: source, kind: 'review', recovered: item.source.recovered === 1, reused: item.receipt?.reused ?? false };
  const trace = new Trace(dir);
  trace.save('review-publication.json', publication);
  const manifest = await json(join(dir, 'manifest.json'));
  const path = statePath(patchpawPaths(config.root).state, item.repo, item.pr_number);
  const state = await readState(path);
  if (state?.run_id === source) {
    const existing = await json(join(dir, 'result.json'));
    if (!existing || existing.status !== 'review_completed') trace.save('result.json', { status: 'review_completed', run_id: source, repo: item.repo,
      pr_number: item.pr_number, final_head_sha: headSha, recovered: item.source.recovered === 1, review, publication });
    await writeState(path, applyRunPhase({ ...state, active: false, current_head_sha: headSha }, 'review_completed'));
    const workspacePath = stringValue(manifest?.workspace_path);
    if (workspacePath) {
      try { await disposeWorkspacePath(config.root, item.repo, workspacePath, trace); } catch { /* next lifecycle can retry cleanup */ }
    }
    trace.emit('review_delayed_delivery_finalized', { delivery_id: item.delivery_id, run_id: source });
  } else if (manifest) trace.emit('review_delayed_delivery_preserved', { delivery_id: item.delivery_id, run_id: source, current_run_id: state?.run_id });
  return done();
}

export async function finalizeRunNotice(context: FinalizationContext) {
  const { config, stored, done } = context;
  const item = stored.item;
  const source = sourceRun(item);
  if (!source) return done();
  const dir = runDir(config.root, source);
  if (!await json(join(dir, 'manifest.json')) && !await json(join(dir, 'run-notice.json'))) return done();
  const trace = new Trace(dir);
  trace.save('notification.json', { status: 'published', ...(item.receipt ?? {}), delivery_id: item.delivery_id });
  trace.emit('run_notice_delayed_delivery_finalized', { delivery_id: item.delivery_id });
  return done();
}

export async function finalizeApprovalPlanPublication(context: FinalizationContext) {
  const { config, stored, done } = context;
  const item = stored.item;
  // A Plan body is published through the durable outbox. If the original run could not
  // confirm the remote receipt, this mechanical finalizer records it here so `/approval`
  // can later verify a real publication. The Agent body is never re-read or re-shaped.
  const path = statePath(patchpawPaths(config.root).state, item.repo, item.pr_number);
  const revision = Number(item.source.plan_revision);
  const planId = typeof item.source.plan_id === 'string' ? item.source.plan_id : undefined;
  const bodySha256 = typeof item.source.body_sha256 === 'string' ? item.source.body_sha256 : undefined;
  const plan = Number.isSafeInteger(revision) ? await readApprovalPlan(path, revision) : null;
  if (plan && plan.plan_id === planId && plan.body_sha256 === bodySha256 && plan.status === 'publication_pending') {
    const publication = { delivery_id: item.delivery_id, remote_id: item.receipt?.id, remote_url: item.receipt?.html_url,
      published_at: item.receipt?.published_at };
    await updateApprovalPlan(path, revision, { status: 'published', publication });
    const state = await readState(path);
    if (state?.approval_plan?.plan_id === planId && state.approval_plan.plan_revision === revision) {
      await writeState(path, applyRunPhase({ ...state, active: false,
        approval_plan: { ...state.approval_plan, status: 'published', ...publication } }, 'awaiting_approval'));
    }
    const paused = await readPaused(path);
    if (paused?.status === 'publication_pending') {
      await savePaused(path, { ...paused, status: 'awaiting_approval', pause_phase: 'awaiting_approval' });
    }
    new Trace(runDir(config.root, typeof item.source.run_id === 'string' ? item.source.run_id : '')).emit('approval_plan_publication_finalized',
      { delivery_id: item.delivery_id, plan_id: planId, plan_revision: revision, remote_id: item.receipt?.id });
  }
  return done();
}