Ctrl K
ComunidadGuías…

src/runner/entry-lifecycle.ts

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

Archivo original ↗Preguntar a KiBot
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { hasHumanReplies, readHumanReplies } from './human-feedback.ts';
import { parsePRTask } from './command.ts';
import { prepareCloseStart, preparePendingCloseStart, retireApprovalPlanLifecycle, runClose, retryPendingCloseCompletion, hasPendingClose, resumePendingClose, closeRefusalBody } from './close.ts';
import { deferDelivery, deliverImmediately, enqueueCommentDelivery, requeueOldestBlockedDelivery, type OutboundConnection } from './outbound.ts';
import { needsReviewRecovery, readArtifact } from './review-lifecycle.ts';
import { patchpawPaths } from '../config/paths.ts';
import { readState, writeState, type RunState } from './state.ts';
import { reconcileConflictProposalPublication } from './conflict-proposals.ts';
import { recipientLogins } from '../scm/recipients.ts';
import { approvalPlanDirectory } from './approval-plans.ts';

export type RunnerConfig = { appId?: number; privateKey?: string; snapshotRoot: string; root: string; operatorLogin?: string; appSlug?: string; botLogin?: string };
type CloseCarryForward = Pick<RunState, 'closed_through_comment_id' | 'close_start_notice_id' | 'close_comment_id' | 'close_mentions' | 'completion_notice_id' | 'completion_notice_status' | 'pending_close_refusal'>;

export interface EntryLifecycleInput {
  config: RunnerConfig;
  resolveConnection: () => Promise<OutboundConnection>;
  repo: string;
  prNumber: number;
  path: string;
  appSlug?: string;
  botLogin: string;
  projectId?: string;
}

export interface EntryLifecycleResult {
  appSlug?: string;
  botLogin: string;
  closeResult?: Awaited<ReturnType<typeof runClose>>;
  recoveryTarget?: string;
  priorClose?: CloseCarryForward;
  priorConflictProposal?: RunState['conflict_proposal'];
}

export async function prepareEntryLifecycle(input: EntryLifecycleInput): Promise<EntryLifecycleResult> {
  const { config, repo, prNumber, path } = input;
  let { appSlug, botLogin } = input;
  let closeResult: Awaited<ReturnType<typeof runClose>> | undefined;
  let recoveryTarget: string | undefined;
  let priorClose: CloseCarryForward | undefined;
  let priorConflictProposal: RunState['conflict_proposal'];
  const connection = input.resolveConnection;
  const previous = await readState(path);
  // Older releases wrote `closed_at` tombstones without retiring generic ApprovalPlan
  // sidecars/outbox rows. Converge that residue before loading approval claims or accepting a
  // fresh comment. Retirement is idempotent and contains no model, workspace, or SCM write.
  if (previous?.phase === 'closed' && previous.closed_at) {
    await retireApprovalPlanLifecycle(config.root, repo, prNumber, path);
    await rm(approvalPlanDirectory(path), { recursive: true, force: true });
  }
  if (await hasHumanReplies(path)) await requeueOldestBlockedDelivery(config.root, repo, prNumber);
  const handledIds = new Set(previous?.handled_comment_ids ?? []);
  const oldest = (await readHumanReplies(path)).find(comment => comment.comment_id > (previous?.closed_through_comment_id ?? 0) && !handledIds.has(comment.comment_id));
  if (oldest?.body.includes('/close')) {
    const closeSlug = appSlug ?? botLogin.replace(/\[bot\]$/i, '');
    if (closeSlug && parsePRTask(oldest.body, closeSlug) === 'close') {
      const mentions = recipientLogins([oldest.author, config.operatorLogin]);
      const projectId = input.projectId ?? repo;
      const prepared = await prepareCloseStart(config, repo, prNumber, path, { comment_id: oldest.comment_id, mentions, bot_login: botLogin, projectId });
      try {
        const closeConnection = await connection();
        closeResult = await runClose(config, repo, prNumber, path, { comment_id: oldest.comment_id, connection: closeConnection,
          mentions, bot_login: botLogin, projectId });
      } catch (error) {
        if (!prepared.start) throw error;
        const deferred = await deferDelivery(config.root, prepared.start, error);
        closeResult = { status: 'close_start_unpublished', run_ids: prepared.runIds.length, publication: deferred.publication };
      }
    }
  }
  if (!closeResult && await hasPendingClose(path)) {
    const prepared = await preparePendingCloseStart(config, repo, prNumber, path, botLogin, input.projectId);
    try {
      closeResult = await resumePendingClose(config, repo, prNumber, path, await connection(), botLogin, input.projectId);
    } catch (error) {
      if (!prepared?.start) throw error;
      const deferred = await deferDelivery(config.root, prepared.start, error);
      closeResult = { status: 'close_start_unpublished', run_ids: prepared.runIds.length, publication: deferred.publication };
    }
  }
  if (!closeResult && (previous?.completion_notice_status === 'pending' || previous?.pending_close_refusal)) {
    try {
      const client = connection;
      const bot = botLogin;
      if (previous?.completion_notice_status === 'pending') await retryPendingCloseCompletion(path, repo, prNumber, client, config.root, bot);
      const refusal = (await readState(path))?.pending_close_refusal;
      if (refusal) {
        try {
          const stored = await enqueueCommentDelivery({ root: config.root, repo, prNumber, purpose: 'close_refusal',
            semanticKey: 'close-refusal:' + refusal.comment_id, body: closeRefusalBody,
            mentions: recipientLogins([refusal.author]), botLogin: bot,
            source: { project_id: input.projectId ?? repo, comment_id: refusal.comment_id } });
          const delivered = await deliverImmediately(config.root, stored, { ...await client(), botLogin: bot });
          if (delivered.item.status === 'delivered') {
            const cleared = await readState(path);
            if (cleared) await writeState(path, { ...cleared, pending_close_refusal: undefined });
          }
        } catch { /* the refusal stays pending for the next entry */ }
      }
    } catch { /* durable pending markers survive for the next entry */ }
  }
  if (!closeResult) {
    const currentState = await readState(path);
    if (currentState?.conflict_proposal) await reconcileConflictProposalPublication(config.root, path, repo, prNumber);
  }
  if (!closeResult) {
    const current = await readState(path);
    const { closed_through_comment_id, close_start_notice_id, close_comment_id, close_mentions, completion_notice_id, completion_notice_status, pending_close_refusal } = current ?? {};
    if (closed_through_comment_id !== undefined || close_start_notice_id !== undefined || close_comment_id !== undefined
      || close_mentions !== undefined || completion_notice_id !== undefined || completion_notice_status !== undefined || pending_close_refusal !== undefined) {
      priorClose = { closed_through_comment_id, close_start_notice_id, close_comment_id, close_mentions,
        completion_notice_id, completion_notice_status, pending_close_refusal };
    }
    priorConflictProposal = current?.conflict_proposal;
    if (current && await needsReviewRecovery(current, join(patchpawPaths(config.root).runs, current.run_id))) recoveryTarget = current.run_id;
    if (current && await readArtifact(join(patchpawPaths(config.root).runs, current.run_id), 'run-notice.json')
      && (await readArtifact(join(patchpawPaths(config.root).runs, current.run_id), 'notification.json'))?.status !== 'published') recoveryTarget = current.run_id;
  }
  return { appSlug, botLogin, closeResult, recoveryTarget, priorClose, priorConflictProposal };
}