Ctrl K
ComunidadGuías…

src/runner/approval-plans.ts

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

Archivo original ↗Preguntar a KiBot
import { createHash, randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { z } from 'zod';
import { canonicalJson } from '../control-plane/snapshots.ts';
import { atomicJson, immutableJson, readJson } from './durable-json.ts';

export const APPROVAL_PLAN_SCHEMA_VERSION = 'patchpaw.approval-plan.v1';
export const APPROVAL_PLAN_CURRENT_SCHEMA_VERSION = 'patchpaw.approval-plan-current.v1';
export const APPROVAL_PLAN_STATUS_SCHEMA_VERSION = 'patchpaw.approval-plan-status.v1';

export type ApprovalPlanStatus = 'draft' | 'publication_pending' | 'published' | 'approved' | 'stale' | 'superseded';

/** Run result states accepted by the shared Approval Plan boundary. */
export type ApprovalPlanFinishStatus =
  | 'needs_human' | 'budget_exhausted' | 'stale' | 'publication_pending' | 'awaiting_approval'
  | 'custom_completed' | 'review_completed' | 'repair_completed' | 'ci_completed' | 'conflict_completed';

export type ApprovalPlanCompletionStatus = 'custom_completed' | 'review_completed' | 'repair_completed' | 'ci_completed' | 'conflict_completed';

const APPROVAL_PLAN_COMPLETION_STATUSES: ReadonlySet<string> = new Set([
  'custom_completed', 'review_completed', 'repair_completed', 'ci_completed', 'conflict_completed',
]);

export function isApprovalPlanCompletionStatus(value: string): value is ApprovalPlanCompletionStatus {
  return APPROVAL_PLAN_COMPLETION_STATUSES.has(value);
}

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

/**
 * The durable generic approval claim. It is written only after the current Plan, its publication
 * receipt, the verified webhook provenance and the mechanical Git/workspace/snapshot facts have
 * all been validated. Its phase is the only generic approval lifecycle state; the source comment
 * is retired after the claim is durable, so a crash can never require a second `/approval`.
 */
export type ApprovalPlanClaimPhase = 'accepted' | 'running' | 'completed' | 'interrupted';

export interface ApprovalPlanClaim {
  plan_id: string;
  plan_revision: number;
  body_sha256: string;
  source_event_id: string;
  source_comment_id: number;
  source_comment_url?: string;
  source_comment_created_at?: string;
  author: string;
  author_association?: string;
  accepted_at: string;
  claim_run_id?: string;
  phase: ApprovalPlanClaimPhase;
  updated_at: string;
}

/**
 * The logical Plan the rest of the Harness reasons about. The Agent body is an opaque
 * string: it is only trimmed, size-bounded, hashed, and stored. Nothing here parses it.
 */
export interface ApprovalPlan {
  schema_version: typeof APPROVAL_PLAN_SCHEMA_VERSION;
  plan_id: string;
  plan_revision: number;
  body: string;
  body_sha256: string;
  command_id: string;
  command_name: string;
  execution_type: 'custom' | 'review' | 'repair' | 'ci' | 'conflict';
  permission: 'read_write_approval';
  run_id: string;
  execution_id: number;
  pr_head_sha: string;
  pr_head_ref: string;
  pr_head_repo: string;
  current_base_tip_sha: string;
  base_ref: string;
  workspace_path: string;
  workspace_evidence_sha256: string;
  command_snapshot_id: string;
  command_snapshot_sha256: string;
  status: ApprovalPlanStatus;
  created_at: string;
  publication?: ApprovalPlanPublication;
}

/** The immutable half of a Plan: everything that defines which text and mechanical binding was approved. */
export type ApprovalPlanImmutable = Omit<ApprovalPlan, 'status' | 'publication'>;

/** Mutable lifecycle facts live beside the immutable body so a crash cannot rewind them. */
export interface ApprovalPlanStatusRecord {
  schema_version: typeof APPROVAL_PLAN_STATUS_SCHEMA_VERSION;
  plan_id: string;
  plan_revision: number;
  body_sha256: string;
  status: ApprovalPlanStatus;
  publication?: ApprovalPlanPublication;
  /** Durable generic approval claim; only ever present on the current published revision. */
  approval?: ApprovalPlanClaim;
  updated_at: string;
}

export interface ApprovalPlanPointer {
  schema_version: typeof APPROVAL_PLAN_CURRENT_SCHEMA_VERSION;
  plan_id: string;
  plan_revision: number;
  body_sha256: string;
  status: ApprovalPlanStatus;
  plan_path: string;
  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;
  updated_at: string;
}

/** Projection persisted on RunState so a worker entry can find the current Plan cheaply. */
export interface ApprovalPlanStatePointer {
  plan_id: string; plan_revision: number; body_sha256: string;
  status: ApprovalPlanStatus;
  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 interface ApprovalPlanWorkspaceState { plan: ApprovalPlan; pointer: ApprovalPlanPointer; approval?: ApprovalPlanClaim }

const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/i, 'must be a SHA-256 hash');
const timestampSchema = z.string().refine(isUtcTimestamp, 'must be an RFC 3339 UTC timestamp');
const approvalPlanPublicationSchema: z.ZodType<ApprovalPlanPublication> = z.object({
  delivery_id: z.string().min(1), remote_id: z.number().int().positive().optional(),
  remote_url: z.string().min(1).optional(), published_at: timestampSchema.optional(),
});
const approvalPlanClaimSchema: z.ZodType<ApprovalPlanClaim> = z.object({
  plan_id: z.string().min(1), plan_revision: z.number().int().positive(), body_sha256: sha256Schema,
  source_event_id: z.string().min(1), source_comment_id: z.number().int().positive(),
  source_comment_url: z.string().min(1).optional(), source_comment_created_at: timestampSchema.optional(),
  author: z.string().min(1), author_association: z.string().min(1).optional(),
  accepted_at: timestampSchema, claim_run_id: z.string().min(1).optional(),
  phase: z.enum(['accepted', 'running', 'completed', 'interrupted']), updated_at: timestampSchema,
});
const approvalPlanSchema: z.ZodType<ApprovalPlan> = z.object({
  schema_version: z.literal(APPROVAL_PLAN_SCHEMA_VERSION), plan_id: z.string().min(1), plan_revision: z.number().int().positive(),
  body: z.string().min(1).max(512 * 1024).refine(value => value === value.trim(), 'body must be trimmed'), body_sha256: sha256Schema,
  command_id: z.string().min(1), command_name: z.string().min(1), execution_type: z.enum(['custom', 'review', 'repair', 'ci', 'conflict']),
  permission: z.literal('read_write_approval'), run_id: z.string().min(1), execution_id: z.number().int().positive(),
  pr_head_sha: z.string().min(1), pr_head_ref: z.string().min(1), pr_head_repo: z.string().min(1),
  current_base_tip_sha: z.string().min(1), base_ref: z.string().min(1), workspace_path: z.string().min(1),
  workspace_evidence_sha256: sha256Schema, command_snapshot_id: z.string().min(1), command_snapshot_sha256: sha256Schema,
  status: z.enum(['draft', 'publication_pending', 'published', 'approved', 'stale', 'superseded']), created_at: timestampSchema,
  publication: approvalPlanPublicationSchema.optional(),
});
const approvalPlanStatusRecordSchema: z.ZodType<ApprovalPlanStatusRecord> = z.object({
  schema_version: z.literal(APPROVAL_PLAN_STATUS_SCHEMA_VERSION), plan_id: z.string().min(1), plan_revision: z.number().int().positive(),
  body_sha256: sha256Schema, status: z.enum(['draft', 'publication_pending', 'published', 'approved', 'stale', 'superseded']),
  publication: approvalPlanPublicationSchema.optional(), approval: approvalPlanClaimSchema.optional(), updated_at: timestampSchema,
});
const approvalPlanPointerSchema: z.ZodType<ApprovalPlanPointer> = z.object({
  schema_version: z.literal(APPROVAL_PLAN_CURRENT_SCHEMA_VERSION), plan_id: z.string().min(1), plan_revision: z.number().int().positive(),
  body_sha256: sha256Schema, status: z.enum(['draft', 'publication_pending', 'published', 'approved', 'stale', 'superseded']),
  plan_path: z.string().min(1), run_id: z.string().min(1), execution_id: z.number().int().positive(), workspace_path: z.string().min(1),
  pr_head_sha: z.string().min(1), pr_head_ref: z.string().min(1), pr_head_repo: z.string().min(1),
  current_base_tip_sha: z.string().min(1), base_ref: z.string().min(1), workspace_evidence_sha256: sha256Schema,
  command_snapshot_id: z.string().min(1), command_snapshot_sha256: sha256Schema,
  publication_delivery_id: z.string().min(1).optional(), publication_remote_id: z.number().int().positive().optional(),
  publication_remote_url: z.string().min(1).optional(), published_at: timestampSchema.optional(), updated_at: timestampSchema,
});

function parsePersisted<T>(schema: z.ZodType<T>, value: unknown, label: string): T {
  const parsed = schema.safeParse(value);
  if (parsed.success) return parsed.data;
  const detail = parsed.error.issues.map(issue => `${issue.path.join('.') || 'value'}: ${issue.message}`).join('; ');
  throw new Error(`${label} validation failed: ${detail}`);
}

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

function objectValue(value: unknown, label: string): Record<string, unknown> {
  if (!isRecord(value)) throw new Error(`${label} is not an object`);
  return value;
}

/** Lifecycle fields a caller may patch; every other field is immutable identity. */
export type ApprovalPlanMutablePatch =
  Partial<Pick<ApprovalPlan, 'status' | 'publication'>> & { approval?: ApprovalPlanClaim | null };

const MUTABLE_PATCH_FIELDS = new Set(['status', 'publication', 'approval']);

export function approvalPlanDirectory(statePath: string) { return `${statePath}.approval-plans`; }
const planDirectory = approvalPlanDirectory;
export function approvalPlanVersionPath(statePath: string, revision: number) { return join(planDirectory(statePath), `plan-v${revision}.json`); }
export function approvalPlanStatusPath(statePath: string, revision: number) { return join(planDirectory(statePath), `plan-v${revision}.status.json`); }
export function approvalPlanCurrentPath(statePath: string) { return join(planDirectory(statePath), 'plan-current.json'); }
export function approvalPlanDeliverySemanticKey(plan: Pick<ApprovalPlan, 'plan_id' | 'plan_revision' | 'body_sha256'>) {
  return `approval-plan:${plan.plan_id}:v${plan.plan_revision}:${plan.body_sha256}`;
}
export function approvalPlanBodySha256(body: string) { return createHash('sha256').update(body, 'utf8').digest('hex'); }

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

function isUtcTimestamp(value: unknown) {
  return typeof value === 'string' && /T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) && Number.isFinite(Date.parse(value));
}

function immutablePayload(plan: ApprovalPlan): ApprovalPlanImmutable {
  const { status: _status, publication: _publication, ...immutable } = plan;
  return immutable;
}

export function createApprovalPlan(input: Omit<ApprovalPlan, 'schema_version' | 'plan_id' | 'body_sha256' | 'status' | 'created_at'> & { plan_id?: string; created_at?: string; status?: ApprovalPlanStatus }): ApprovalPlan {
  const body = input.body.trim();
  if (!body) throw new Error('Approval Plan body must be non-empty');
  if (body.length > 512 * 1024) throw new Error('Approval Plan body is too large');
  assertRevision(input.plan_revision);
  const body_sha256 = approvalPlanBodySha256(body);
  return { ...input, schema_version: APPROVAL_PLAN_SCHEMA_VERSION, plan_id: input.plan_id ?? `plan-${randomUUID()}`, body,
    body_sha256, status: input.status ?? 'draft', created_at: input.created_at ?? new Date().toISOString() };
}

export function validateApprovalPlan(input: unknown): ApprovalPlan {
  const plan = parsePersisted(approvalPlanSchema, input, 'Approval Plan');
  if (plan.body_sha256 !== approvalPlanBodySha256(plan.body)) throw new Error('Approval Plan body hash does not match');
  return plan;
}

function validateApprovalPlanClaim(input: unknown): ApprovalPlanClaim {
  return parsePersisted(approvalPlanClaimSchema, input, 'Approval Plan claim');
}

function statusRecord(plan: ApprovalPlan, approval?: ApprovalPlanClaim): ApprovalPlanStatusRecord {
  return { schema_version: APPROVAL_PLAN_STATUS_SCHEMA_VERSION, plan_id: plan.plan_id, plan_revision: plan.plan_revision,
    body_sha256: plan.body_sha256, status: plan.status, ...(plan.publication ? { publication: plan.publication } : {}),
    ...(approval ? { approval } : {}), updated_at: new Date().toISOString() };
}

function pointerFromPlan(statePath: string, plan: ApprovalPlan, updatedAt = new Date().toISOString()): ApprovalPlanPointer {
  return { schema_version: APPROVAL_PLAN_CURRENT_SCHEMA_VERSION, plan_id: plan.plan_id, plan_revision: plan.plan_revision,
    body_sha256: plan.body_sha256, status: plan.status, plan_path: approvalPlanVersionPath(statePath, plan.plan_revision), run_id: plan.run_id,
    execution_id: plan.execution_id, workspace_path: plan.workspace_path, pr_head_sha: plan.pr_head_sha, pr_head_ref: plan.pr_head_ref,
    pr_head_repo: plan.pr_head_repo, current_base_tip_sha: plan.current_base_tip_sha, base_ref: plan.base_ref,
    workspace_evidence_sha256: plan.workspace_evidence_sha256, command_snapshot_id: plan.command_snapshot_id,
    command_snapshot_sha256: plan.command_snapshot_sha256,
    ...(plan.publication ? { publication_delivery_id: plan.publication.delivery_id, publication_remote_id: plan.publication.remote_id,
      publication_remote_url: plan.publication.remote_url, published_at: plan.publication.published_at } : {}), updated_at: updatedAt };
}

/**
 * Validate the mutable sidecar against the immutable version before it is trusted. The sidecar
 * already carries plan_id/revision/body_sha256; using them means a sidecar from another plan or
 * revision can never donate its lifecycle (or its approval claim) to this one.
 */
async function readStatusRecord(statePath: string, revision: number, raw: Partial<ApprovalPlan>): Promise<ApprovalPlanStatusRecord | null> {
  const statusValue = await readJson<unknown>(approvalPlanStatusPath(statePath, revision));
  if (statusValue === null) return null;
  const status = parsePersisted(approvalPlanStatusRecordSchema, statusValue, 'Approval Plan status sidecar');
  if (status.plan_id !== raw.plan_id || status.plan_revision !== revision || status.plan_revision !== raw.plan_revision
      || status.body_sha256 !== raw.body_sha256) {
    throw new Error('Approval Plan status sidecar does not match its immutable revision');
  }
  const approval = status.approval ? validateApprovalPlanClaim(status.approval) : undefined;
  if (approval && (approval.plan_id !== raw.plan_id || approval.plan_revision !== revision || approval.body_sha256 !== raw.body_sha256)) {
    throw new Error('Approval Plan approval claim does not match its immutable revision');
  }
  return { ...status, ...(approval ? { approval } : {}) };
}

/**
 * Read the immutable body and merge the validated mutable status sidecar. The immutable version
 * file never carries lifecycle state, so a status/publication update cannot be lost when the body
 * file is already on disk.
 */
export async function readApprovalPlan(statePath: string, revision: number) {
  const rawValue = await readJson<unknown>(approvalPlanVersionPath(statePath, revision));
  if (rawValue === null) return null;
  const raw = objectValue(rawValue, 'Approval Plan');
  const status = await readStatusRecord(statePath, revision, raw);
  const merged = {
    ...raw,
    status: status?.status ?? 'draft',
    ...(status?.publication ? { publication: status.publication } : {}),
  };
  return validateApprovalPlan(merged);
}

export async function readApprovalPlanClaim(statePath: string, revision: number): Promise<ApprovalPlanClaim | null> {
  const rawValue = await readJson<unknown>(approvalPlanVersionPath(statePath, revision));
  if (rawValue === null) return null;
  const raw = objectValue(rawValue, 'Approval Plan');
  const status = await readStatusRecord(statePath, revision, raw);
  return status?.approval ?? null;
}

export async function readCurrentApprovalPlan(statePath: string): Promise<ApprovalPlanWorkspaceState | null> {
  const pointerValue = await readJson<unknown>(approvalPlanCurrentPath(statePath));
  if (pointerValue === null) return null;
  const pointer = parsePersisted(approvalPlanPointerSchema, pointerValue, 'Approval Plan current pointer');
  const plan = await readApprovalPlan(statePath, pointer.plan_revision);
  if (!plan || plan.plan_id !== pointer.plan_id || plan.plan_revision !== pointer.plan_revision || plan.body_sha256 !== pointer.body_sha256) {
    throw new Error('Approval Plan pointer does not resolve to its immutable body');
  }
  // The pointer's mechanical projection must match the immutable version. Its status/publication
  // are only a projection and may legitimately lag a crash between the sidecar and pointer writes,
  // so they are deliberately not treated as identity here.
  const contradiction = pointer.run_id !== plan.run_id || pointer.execution_id !== plan.execution_id
    || pointer.workspace_path !== plan.workspace_path || pointer.pr_head_sha !== plan.pr_head_sha
    || pointer.pr_head_ref !== plan.pr_head_ref || pointer.pr_head_repo !== plan.pr_head_repo
    || pointer.current_base_tip_sha !== plan.current_base_tip_sha || pointer.base_ref !== plan.base_ref
    || pointer.workspace_evidence_sha256 !== plan.workspace_evidence_sha256
    || pointer.command_snapshot_id !== plan.command_snapshot_id || pointer.command_snapshot_sha256 !== plan.command_snapshot_sha256;
  if (contradiction) throw new Error('Approval Plan current pointer contradicts its immutable revision');
  const approval = await readApprovalPlanClaim(statePath, pointer.plan_revision);
  return { plan, pointer, ...(approval ? { approval } : {}) };
}

export function approvalPlanStatePointer(plan: ApprovalPlan): ApprovalPlanStatePointer {
  return { plan_id: plan.plan_id, plan_revision: plan.plan_revision, body_sha256: plan.body_sha256, status: plan.status,
    run_id: plan.run_id, execution_id: plan.execution_id, workspace_path: plan.workspace_path,
    pr_head_sha: plan.pr_head_sha, pr_head_ref: plan.pr_head_ref, pr_head_repo: plan.pr_head_repo,
    current_base_tip_sha: plan.current_base_tip_sha, base_ref: plan.base_ref,
    workspace_evidence_sha256: plan.workspace_evidence_sha256, command_snapshot_id: plan.command_snapshot_id,
    command_snapshot_sha256: plan.command_snapshot_sha256,
    ...(plan.publication ? { publication_delivery_id: plan.publication.delivery_id, publication_remote_id: plan.publication.remote_id,
      publication_remote_url: plan.publication.remote_url, published_at: plan.publication.published_at } : {}) };
}

/**
 * Persist one Plan revision. The immutable envelope is written once and every later save at the
 * same revision must carry a byte-identical immutable payload; only the lifecycle sidecar changes.
 * `lifecycle.approval === undefined` preserves the existing claim, `null` clears it.
 */
export async function saveApprovalPlan(statePath: string, planInput: ApprovalPlan, lifecycle: { approval?: ApprovalPlanClaim | null } = {}) {
  const plan = validateApprovalPlan(planInput);
  const path = approvalPlanVersionPath(statePath, plan.plan_revision);
  const existingValue = await readJson<unknown>(path);
  const existing = existingValue === null ? null : objectValue(existingValue, 'Approval Plan immutable revision');
  const existingStatus = existing ? await readStatusRecord(statePath, plan.plan_revision, existing) : null;
  if (existing) {
    const existingPlan = validateApprovalPlan({ ...existing, status: existingStatus?.status ?? existing.status ?? 'draft',
      ...((existingStatus?.publication ?? existing.publication) ? { publication: existingStatus?.publication ?? existing.publication } : {}) });
    // The full immutable envelope is identity, not just the body hash: Git basis, workspace
    // evidence, snapshot and run identity must never change under the same revision.
    if (canonicalJson(immutablePayload(plan)) !== canonicalJson(immutablePayload(existingPlan))) {
      throw new Error(`Approval Plan revision v${plan.plan_revision} is immutable`);
    }
  } else await immutableJson(path, immutablePayload(plan));
  const approval = lifecycle.approval === undefined ? existingStatus?.approval : (lifecycle.approval ?? undefined);
  if (approval) validateApprovalPlanClaim(approval);
  await atomicJson(approvalPlanStatusPath(statePath, plan.plan_revision), statusRecord(plan, approval));
  await atomicJson(approvalPlanCurrentPath(statePath), pointerFromPlan(statePath, plan));
  const pointer = parsePersisted(approvalPlanPointerSchema, await readJson<unknown>(approvalPlanCurrentPath(statePath)), 'Approval Plan current pointer');
  return { plan, pointer, ...(approval ? { approval } : {}) };
}

export async function updateApprovalPlan(statePath: string, revision: number, patch: ApprovalPlanMutablePatch) {
  for (const key of Object.keys(patch)) {
    if (!MUTABLE_PATCH_FIELDS.has(key)) throw new Error(`Approval Plan patch cannot change immutable field: ${key}`);
  }
  const current = await readApprovalPlan(statePath, revision);
  if (!current) throw new Error(`Approval Plan is missing: v${revision}`);
  const next = validateApprovalPlan({ ...current,
    ...('status' in patch ? { status: patch.status } : {}),
    ...('publication' in patch ? { publication: patch.publication } : {}) });
  return saveApprovalPlan(statePath, next, { approval: patch.approval === undefined ? undefined : patch.approval });
}

export function isUnfinishedApprovalPlanClaim(claim: ApprovalPlanClaim) {
  return claim.phase === 'accepted' || claim.phase === 'running';
}

/**
 * A durable generic approval is runnable work even after its source comment has been retired.
 * The claim only counts while the Plan still carries the approved status; a superseded/stale Plan
 * must never be resumed from a stale claim.
 *
 * A missing pointer is genuinely "no approval work" and returns null. A pointer that exists but
 * fails its mechanical identity checks is an integrity failure and is deliberately propagated:
 * swallowing it here would let a corrupt durable claim masquerade as "nothing pending" and be
 * silently shelved. Callers (runnable wake predicate, recovery) must surface or block on it.
 */
export async function readUnfinishedApprovalPlanClaim(statePath: string) {
  const current = await readCurrentApprovalPlan(statePath);
  if (!current?.approval || current.plan.status !== 'approved' || !isUnfinishedApprovalPlanClaim(current.approval)) return null;
  return { plan: current.plan, pointer: current.pointer, approval: current.approval };
}