Ctrl K
ComunidadGuías…

src/runner/state.ts

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

Archivo original ↗Preguntar a KiBot
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { tryAcquireFileLock } from '../platform/lock.ts';
import { isProcessAlive } from '../platform/process.ts';
import { safeStorageDirectory } from '../scm/identity.ts';
import { isRunPhase, parseRunPhase, type RunPhase } from './phases.ts';

export interface RunState {
  repo: string; pr_number: number; run_id: string; current_head_sha: string;
  phase: RunPhase; repair_attempts: number; last_patchpaw_commit: string | null; waiting_for_ci: boolean;
  active: boolean; pid: number;
  handled_comment_ids?: number[];
  execution_id?: number;
  // `closed_at` is tombstone-only: it records that this local generation ended. It is not carried
  // into a fresh generation. `closed_through_comment_id` is a durable cross-generation high-water
  // mark: comments retired by /close can never execute again even if SCM redelivers them later.
  closed_at?: string;
  closed_through_comment_id?: number;
  close_start_notice_id?: number;
  close_comment_id?: number;
  close_mentions?: string[];
  completion_notice_id?: number;
  completion_notice_status?: 'pending' | 'published' | 'failed';
  // Durable outbox for an active-task /close refusal whose notice could not be published yet.
  // The comment itself is retired immediately in handled_comment_ids; only the notice retries.
  pending_close_refusal?: { comment_id: number; author?: string };
  // The latest Conflict Proposal is an index into immutable proposal versions. The proposal
  // files and communication outbox remain the evidence sources; this pointer is never enough
  // to reconstruct a proposal by itself.
  conflict_proposal?: {
    proposal_id: string; proposal_revision: number; proposal_hash: string;
    status: 'draft' | 'publication_pending' | 'published' | 'superseded' | 'stale';
    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;
  };
  // The generic approval Plan is an immutable, versioned read-only result plus
  // a mutable lifecycle pointer. The files under the run directory remain the
  // evidence source; this projection only locates the current revision.
  approval_plan?: {
    plan_id: string; plan_revision: number; body_sha256: string;
    status: 'draft' | 'publication_pending' | 'published' | 'approved' | 'stale' | 'superseded';
    run_id: string; execution_id: number; 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;
  };
}
export function statePath(root: string, repo: string, number: number) {
  const directory = repo.startsWith('gitlab:') ? safeStorageDirectory(repo) : repo.replace('/', '__');
  return join(root, directory, `pr-${number}.json`);
}
export async function readState(path: string): Promise<RunState | null> {
  try {
    const raw: unknown = JSON.parse(await readFile(path, 'utf8'));
    if (!isRecord(raw)) throw new Error(`Run state at ${path} is not an object`);
    const normalized = { ...raw, phase: parseRunPhase(raw.phase) };
    if (!isRunState(normalized)) throw new Error(`Run state at ${path} has invalid required fields`);
    return normalized;
  }
  catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
}

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

function isRunState(value: unknown): value is RunState {
  if (!isRecord(value)) return false;
  return typeof value.repo === 'string'
    && Number.isSafeInteger(value.pr_number)
    && typeof value.run_id === 'string'
    && typeof value.current_head_sha === 'string'
    && isRunPhase(value.phase)
    && Number.isSafeInteger(value.repair_attempts)
    && (typeof value.last_patchpaw_commit === 'string' || value.last_patchpaw_commit === null)
    && typeof value.waiting_for_ci === 'boolean'
    && typeof value.active === 'boolean'
    && Number.isSafeInteger(value.pid);
}
export async function writeState(path: string, state: RunState) {
  await mkdir(dirname(path), { recursive: true });
  const temporary = `${path}.${randomUUID()}.tmp`;
  await writeFile(temporary, JSON.stringify(state) + '\n'); await rename(temporary, path);
}
export function workerStatus(state: RunState | null): 'idle' | 'running' | 'interrupted' {
  if (!state?.active) return 'idle';
  return pidAlive(state.pid) ? 'running' : 'interrupted';
}
export function pidAlive(pid: number): boolean {
  return isProcessAlive(pid);
}
export async function claimRun(path: string) {
  await mkdir(dirname(path), { recursive: true });
  const lock = `${path}.lock`;
  const releaseLock = await tryAcquireFileLock(`${lock}.guard`, 'exclusive', { reentrant: false });
  if (!releaseLock) return null;
  try {
    // Respect a still-running worker from the previous PID-file implementation.
    let previous: string | null = null;
    try { previous = await readFile(lock, 'utf8'); }
    catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
    if (previous !== null && pidAlive(Number(previous))) { await releaseLock(); return null; }
    await writeFile(lock, String(process.pid));
  } catch (error) { await releaseLock(); throw error; }
  let released = false;
  return async () => {
    if (released) return; released = true;
    try {
      // Do not remove a marker written by a newer owner if cleanup is delayed.
      const owner = await readFile(lock, 'utf8').catch(error => {
        if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
        throw error;
      });
      if (owner?.trim() === String(process.pid)) await unlink(lock).catch(error => {
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
      });
    } finally { await releaseLock(); }
  };
}
export async function savePending(path: string, headSha: string) {
  await mkdir(dirname(path), { recursive: true });
  const temporary = `${path}.pending.${randomUUID()}.tmp`;
  await writeFile(temporary, JSON.stringify({ head_sha: headSha }));
  await rename(temporary, `${path}.pending.json`);
}
export async function takePending(path: string): Promise<string | null> {
  const claimed = `${path}.claimed.${randomUUID()}.json`;
  try {
    await rename(`${path}.pending.json`, claimed);
    const value = JSON.parse(await readFile(claimed, 'utf8'));
    await unlink(claimed); return value.head_sha;
  } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
}