Ctrl K
ComunidadGuías…

src/runner/communication-legacy-reconciliation.ts

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

Archivo original ↗Preguntar a KiBot
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import { closeCompleteBodyFor, closeStartBodyFor } from './close.ts';
import { closeoutMarker } from './closeout-publication.ts';
import { enqueueCommentDelivery, enqueueReviewDelivery } from './outbound.ts';
import { patchpawPaths } from '../config/paths.ts';
import { readState, statePath } from './state.ts';
import { reviewPayloadSchema } from '../tasks/review/result.ts';
import { runNoticeBody, type RunNotice } from './run-notice.ts';
import { recipientLogins } from '../scm/recipients.ts';

export interface LegacyReconciliationConfig {
  root: string;
  operatorLogin?: string;
}

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 numberValue(value: unknown) { return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined; }
function strings(value: unknown) { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; }
function runDir(root: string, runId: string) { return join(patchpawPaths(root).runs, runId); }

const runNoticeSchema = z.object({
  run_id: z.string(),
  head: z.string(),
  status: z.string(),
  phase: z.string(),
  reason: z.string(),
  mentions: z.array(z.string()),
  bot_login: z.string().optional(),
  platform: z.enum(['github', 'gitlab']).optional(),
  failure: z.object({
    code: z.string(), category: z.enum(['provider', 'model', 'scm', 'workspace', 'internal']), retryable: z.boolean(),
    user_action: z.enum(['retry', 'check_configuration', 'human_review', 'inspect_logs']), message: z.string(),
    upstream_status: z.number().optional(), upstream_code: z.union([z.string(), z.number()]).optional(),
    attempts: z.number().optional(), scm_platform: z.enum(['github', 'gitlab']).optional(),
  }).optional(),
}).passthrough();

function parseRunNotice(value: JsonObject): RunNotice {
  return runNoticeSchema.parse(value);
}

async function legacyStatePaths(root: string) {
  const stateRoot = patchpawPaths(root).state;
  let repoKeys: string[];
  try { repoKeys = await readdir(stateRoot); }
  catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; }
  const paths: string[] = [];
  for (const repoKey of repoKeys) {
    let names: string[];
    try { names = await readdir(join(stateRoot, repoKey)); } catch { continue; }
    for (const name of names.filter(value => /^pr-\d+\.json$/.test(value))) paths.push(join(stateRoot, repoKey, name));
  }
  return paths;
}

/** One migration pass for pre-outbox state artifacts. State JSON is authoritative for repo/PR. */
export async function reconcileLegacyOutbox(config: LegacyReconciliationConfig, botLogin?: string) {
  const runsRoot = patchpawPaths(config.root).runs;
  let runs: string[];
  try { runs = await readdir(runsRoot); }
  catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') runs = []; else throw error; }
  for (const runId of runs) {
    const dir = runDir(config.root, runId);
    const manifest = await json(join(dir, 'manifest.json'));
    const repo = stringValue(manifest?.repo);
    const prNumber = numberValue(manifest?.pr_number);
    if (!repo || prNumber === undefined) continue;
    const state = await readState(statePath(patchpawPaths(config.root).state, repo, prNumber));
    const review = await json(join(dir, 'review.json'));
    const publication = await json(join(dir, 'review-publication.json'));
    if (review && !publication && ['review_ready', 'review_publishing'].includes(state?.phase ?? 'review_ready')) {
      const headSha = stringValue(review.head_sha);
      if (!headSha) throw new Error(`Legacy review artifact is missing head_sha: ${dir}`);
      const { head_sha: _headSha, ...reviewPayload } = review;
      const mentions = recipientLogins([stringValue(manifest?.request_author), config.operatorLogin]);
      await enqueueReviewDelivery({ root: config.root, repo, prNumber, semanticKey: `review:${runId}:${headSha}`,
        headSha, review: reviewPayloadSchema.parse(reviewPayload), mentions, botLogin, runId, allowLegacy: true,
        source: { run_id: runId, head_sha: headSha, recovered: 1 } });
    }
    const noticeRaw = await json(join(dir, 'run-notice.json'));
    const notification = await json(join(dir, 'notification.json'));
    if (noticeRaw && notification?.status !== 'published') {
      const notice = parseRunNotice(noticeRaw);
      await enqueueCommentDelivery({ root: config.root, repo, prNumber, purpose: 'run_notice',
        semanticKey: `run-notice:${notice.run_id}`, body: runNoticeBody(notice), mentions: recipientLogins(notice.mentions),
        botLogin: notice.bot_login ?? botLogin,
        legacyMarkers: ['budget_exhausted', 'stopped'].includes(notice.status) ? [closeoutMarker(notice.run_id)] : undefined,
        source: { run_id: notice.run_id, status: notice.status } });
    }
  }

  for (const path of await legacyStatePaths(config.root)) {
    const state = await readState(path);
    if (!state?.repo || !state.pr_number) continue;
    const mentions = recipientLogins(state.close_mentions ?? []);
    if (state.completion_notice_status === 'pending' && state.closed_through_comment_id) {
      await enqueueCommentDelivery({ root: config.root, repo: state.repo, prNumber: state.pr_number, purpose: 'close_completion',
        semanticKey: `close-completion:${state.close_comment_id ?? state.closed_through_comment_id}`, body: closeCompleteBodyFor(state.repo), mentions,
        botLogin, source: { close_comment_id: state.close_comment_id ?? state.closed_through_comment_id } });
    }
    if (state.pending_close_refusal) {
      await enqueueCommentDelivery({ root: config.root, repo: state.repo, prNumber: state.pr_number, purpose: 'close_refusal',
        semanticKey: `close-refusal:${state.pending_close_refusal.comment_id}`,
        body: '当前任务仍在运行,请先 /stop,再执行 /close。',
        mentions: recipientLogins([state.pending_close_refusal.author]), botLogin,
        source: { comment_id: state.pending_close_refusal.comment_id } });
    }
    const journal = await json(`${path}.close.json`);
    if (journal?.status === 'closing' && !journal.start_notice_id) {
      const closeCommentId = typeof journal.close_comment_id === 'string' || typeof journal.close_comment_id === 'number'
        ? journal.close_comment_id : null;
      await enqueueCommentDelivery({ root: config.root, repo: state.repo, prNumber: state.pr_number, purpose: 'close_start',
        semanticKey: `close-start:${String(closeCommentId ?? '')}`, body: closeStartBodyFor(state.repo), mentions: recipientLogins(strings(journal.mentions)), botLogin,
        source: { close_comment_id: closeCommentId } });
    }
  }
}