tools/delivery

state.ts

tools/delivery/state.ts
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';

import type {
  ResolvedOrchestratorConfig,
  ReviewPolicyStageValue,
  TicketBoundaryMode,
} from './config';
import {
  listLocalBranches,
  listMergedPullRequests,
  listOpenPullRequests,
  listRemoteBranches,
  type PullRequestSummary,
  type Runtime,
} from './platform';
import { parsePlan } from './planning';
import type {
  DeliveryState,
  OrchestratorOptions,
  RunPolicy,
  TicketDefinition,
  TicketState,
  TicketStatus,
} from './types';

// ─── Run-policy divergence helpers ──────────────────────────────────────────

export function detectRunPolicyDivergence(
  persisted: RunPolicy,
  current: RunPolicy,
): string[] {
  const fields: string[] = [];
  if (persisted.ticketBoundaryMode !== current.ticketBoundaryMode) {
    fields.push('ticketBoundaryMode');
  }
  if (persisted.subagentReview !== current.subagentReview) {
    fields.push('subagentReview');
  }
  if (persisted.prReview !== current.prReview) {
    fields.push('prReview');
  }
  return fields;
}

export function formatRunPolicyDivergenceError(
  persisted: RunPolicy,
  current: RunPolicy,
  divergedFields: string[],
  runDeliverInvocation: string,
): string {
  const lines: string[] = [
    'Run-policy divergence detected. The persisted run policy in state.json',
    'differs from the current orchestrator.config.json on these fields:',
    '',
  ];

  for (const field of divergedFields) {
    let persistedValue: string;
    let currentValue: string;

    if (field === 'ticketBoundaryMode') {
      persistedValue = persisted.ticketBoundaryMode;
      currentValue = current.ticketBoundaryMode;
    } else if (field === 'subagentReview') {
      persistedValue = persisted.subagentReview;
      currentValue = current.subagentReview;
    } else {
      persistedValue = persisted.prReview;
      currentValue = current.prReview;
    }

    lines.push(
      `  ${field}: persisted=${persistedValue}  current=${currentValue}`,
    );
  }

  lines.push('');
  lines.push('Add --baseline to your command to resolve, e.g.:');
  lines.push(
    `  ${runDeliverInvocation} --baseline orchestrator   # adopt current repo config`,
  );
  lines.push(
    `  ${runDeliverInvocation} --baseline run-policy     # keep persisted run policy`,
  );

  return lines.join('\n');
}

export function patchRunPolicyWithFlags(
  base: RunPolicy,
  flags: {
    boundaryMode?: TicketBoundaryMode;
    subagentReviewPolicy?: ReviewPolicyStageValue;
    prReviewPolicy?: ReviewPolicyStageValue;
  },
): RunPolicy {
  return {
    ticketBoundaryMode: flags.boundaryMode ?? base.ticketBoundaryMode,
    subagentReview: flags.subagentReviewPolicy ?? base.subagentReview,
    prReview: flags.prReviewPolicy ?? base.prReview,
  };
}

export function deriveRunPolicyFromConfig(
  config: ResolvedOrchestratorConfig,
): RunPolicy {
  return {
    ticketBoundaryMode: config.ticketBoundaryMode,
    subagentReview: config.reviewPolicy.subagentReview,
    prReview: config.reviewPolicy.prReview,
  };
}

export function applyRunPolicyToConfig(
  config: ResolvedOrchestratorConfig,
  runPolicy: RunPolicy,
): ResolvedOrchestratorConfig {
  return {
    ...config,
    ticketBoundaryMode: runPolicy.ticketBoundaryMode,
    reviewPolicy: {
      ...config.reviewPolicy,
      subagentReview: runPolicy.subagentReview,
      prReview: runPolicy.prReview,
    },
  };
}

export function normalizeRunPolicy(
  state: DeliveryState,
  config: ResolvedOrchestratorConfig,
): DeliveryState {
  if (state.runPolicy != null) {
    return state;
  }

  return { ...state, runPolicy: deriveRunPolicyFromConfig(config) };
}

/** Persisted tickets may use legacy status and timestamp keys until re-saved. */
type PersistedTicketFields = Partial<TicketState> & {
  internalReviewCompletedAt?: string;
  postVerifySelfAuditCompletedAt?: string;
  status?: string;
};

function pickVerifiedAt(
  ticket: PersistedTicketFields | undefined,
): string | undefined {
  if (!ticket) {
    return undefined;
  }

  return (
    ticket.verifiedAt ??
    ticket.postVerifySelfAuditCompletedAt ??
    ticket.internalReviewCompletedAt
  );
}

function normalizeLegacyTicketStatus(status: string | undefined): TicketStatus {
  if (
    status === 'internally_reviewed' ||
    status === 'post_verify_self_audit_complete'
  ) {
    return 'verified';
  }

  if (status === 'codex_preflight_complete') {
    return 'subagent_review_complete';
  }

  if (status === undefined) {
    return 'pending';
  }

  return status as TicketStatus;
}

export function normalizeDeliveryStateFromPersisted(
  raw: unknown,
): DeliveryState {
  const root = raw as Record<string, unknown>;
  const rawTickets = root.tickets;

  if (!Array.isArray(rawTickets)) {
    return raw as DeliveryState;
  }

  const tickets = rawTickets.map((entry) => {
    const t = entry as PersistedTicketFields & Record<string, unknown>;
    const next: Record<string, unknown> = { ...t };
    delete next.internalReviewCompletedAt;
    next.status = normalizeLegacyTicketStatus(t.status);
    next.verifiedAt = pickVerifiedAt(t);
    delete next.postVerifySelfAuditCompletedAt;
    delete next.internalReviewCompletedAt;
    return next;
  });

  return { ...root, tickets } as DeliveryState;
}

type LoadPlanContextResult = {
  absoluteStatePath: string;
  inferred: DeliveryState;
  ticketDefinitions: TicketDefinition[];
};

type SyncStateDependencies = {
  cwd: string;
  deliveryBaseBranch: string;
  deriveBranchName: (
    definition: Pick<TicketDefinition, 'id' | 'slug'>,
  ) => string;
  deriveWorktreePath: (cwd: string, ticketId: string) => string;
};

type RepoInferenceDependencies = SyncStateDependencies & {
  runtime: Runtime;
  findExistingBranch: (
    branches: string[],
    definition: TicketDefinition,
  ) => { branch: string; source: 'ticket-id' | 'derived' } | undefined;
};

export async function loadState(
  cwd: string,
  options: OrchestratorOptions,
  dependencies: RepoInferenceDependencies,
): Promise<DeliveryState> {
  const { absoluteStatePath, inferred, ticketDefinitions } =
    await loadPlanContext(cwd, options, dependencies);

  if (!existsSync(absoluteStatePath)) {
    return syncStateFromScratch(
      ticketDefinitions,
      options,
      inferred,
      dependencies,
    );
  }

  const existing = normalizeDeliveryStateFromPersisted(
    JSON.parse(await readFile(absoluteStatePath, 'utf8')),
  );

  return syncStateFromExisting(
    existing,
    ticketDefinitions,
    options,
    inferred,
    dependencies,
  );
}

export async function repairState(
  cwd: string,
  options: OrchestratorOptions,
  dependencies: RepoInferenceDependencies,
): Promise<{
  state: DeliveryState;
  backupPath?: string;
  changes: string[];
  hadExistingState: boolean;
}> {
  const { absoluteStatePath, inferred, ticketDefinitions } =
    await loadPlanContext(cwd, options, dependencies);
  const hadExistingState = existsSync(absoluteStatePath);

  if (!hadExistingState) {
    const repairedState = syncStateFromScratch(
      ticketDefinitions,
      options,
      inferred,
      dependencies,
    );
    await saveState(cwd, repairedState);

    return {
      state: repairedState,
      changes: [
        'No prior state file existed; wrote clean state from repo reality.',
      ],
      hadExistingState: false,
    };
  }

  const existing = normalizeDeliveryStateFromPersisted(
    JSON.parse(await readFile(absoluteStatePath, 'utf8')),
  );
  const repairedState = syncStateFromExisting(
    existing,
    ticketDefinitions,
    options,
    inferred,
    dependencies,
  );
  const changes = summarizeStateDifferences(existing, repairedState);
  let backupPath: string | undefined;

  if (changes.length > 0) {
    backupPath = await backupStateFile(absoluteStatePath);
  }

  await saveState(cwd, repairedState);

  return {
    state: repairedState,
    backupPath: backupPath ? relativeToRepo(cwd, backupPath) : undefined,
    changes:
      changes.length > 0
        ? changes
        : [
            'Saved state already matched repo reality; rewrote normalized state.',
          ],
    hadExistingState: true,
  };
}

export async function saveState(
  cwd: string,
  state: DeliveryState,
): Promise<void> {
  const absoluteStatePath = resolve(cwd, state.statePath);
  await mkdir(dirname(absoluteStatePath), { recursive: true });
  await writeFile(
    absoluteStatePath,
    JSON.stringify(state, null, 2) + '\n',
    'utf8',
  );
}

export function syncStateFromScratch(
  ticketDefinitions: TicketDefinition[],
  options: OrchestratorOptions,
  inferred: DeliveryState | undefined,
  dependencies: SyncStateDependencies,
): DeliveryState {
  return syncStateWithPlan(
    undefined,
    ticketDefinitions,
    options,
    inferred,
    dependencies,
  );
}

export function syncStateFromExisting(
  existing: DeliveryState,
  ticketDefinitions: TicketDefinition[],
  options: OrchestratorOptions,
  inferred: DeliveryState | undefined,
  dependencies: SyncStateDependencies,
): DeliveryState {
  return syncStateWithPlan(
    existing,
    ticketDefinitions,
    options,
    inferred,
    dependencies,
  );
}

function syncStateWithPlan(
  existing: DeliveryState | undefined,
  ticketDefinitions: TicketDefinition[],
  options: OrchestratorOptions,
  inferred: DeliveryState | undefined,
  dependencies: SyncStateDependencies,
): DeliveryState {
  const existingById = new Map(
    existing?.tickets.map((ticket) => [ticket.id, ticket]),
  );
  const inferredById = new Map(
    inferred?.tickets.map((ticket) => [ticket.id, ticket]),
  );

  return {
    planKey: options.planKey,
    planPath: options.planPath,
    statePath: options.statePath,
    reviewsDirPath: options.reviewsDirPath,
    handoffsDirPath: options.handoffsDirPath,
    reviewPollIntervalMinutes: options.reviewPollIntervalMinutes,
    reviewPollMaxWaitMinutes: options.reviewPollMaxWaitMinutes,
    runPolicy: existing?.runPolicy,
    tickets: ticketDefinitions.map((definition, index) => {
      const previous = existingById.get(definition.id);
      const inferredTicket = inferredById.get(definition.id);
      const previousTicket = ticketDefinitions[index - 1];
      const resolvedBranch = selectBranchValue(
        previous?.branch,
        inferredTicket?.branch,
        dependencies.deriveBranchName(definition),
      );
      const inferredBaseBranch =
        index === 0
          ? dependencies.deliveryBaseBranch
          : selectBranchValue(
              existingById.get(previousTicket?.id ?? '')?.branch,
              inferredById.get(previousTicket?.id ?? '')?.branch,
              dependencies.deriveBranchName(previousTicket!),
            );

      return {
        id: definition.id,
        title: definition.title,
        slug: definition.slug,
        ticketFile: definition.ticketFile,
        type: definition.type,
        scope: definition.scope,
        redPolicy: previous?.redPolicy ?? definition.redPolicy,
        status: selectStatusValue(previous?.status, inferredTicket?.status),
        branch: resolvedBranch,
        baseBranch:
          index === 0
            ? dependencies.deliveryBaseBranch
            : selectBranchValue(
                previous?.baseBranch,
                inferredTicket?.baseBranch,
                inferredBaseBranch,
              ),
        worktreePath:
          previous?.worktreePath ??
          inferredTicket?.worktreePath ??
          dependencies.deriveWorktreePath(dependencies.cwd, definition.id),
        handoffPath: previous?.handoffPath ?? inferredTicket?.handoffPath,
        handoffGeneratedAt:
          previous?.handoffGeneratedAt ?? inferredTicket?.handoffGeneratedAt,
        redCommitSha: previous?.redCommitSha ?? inferredTicket?.redCommitSha,
        verifiedAt:
          pickVerifiedAt(previous) ??
          pickVerifiedAt(inferredTicket as PersistedTicketFields | undefined),
        verifyOutcome: previous?.verifyOutcome ?? inferredTicket?.verifyOutcome,
        verifyPatchCommits:
          previous?.verifyPatchCommits ?? inferredTicket?.verifyPatchCommits,
        docOnly: (previous?.docOnly ?? inferredTicket?.docOnly) || undefined,
        subagentReviewOutcome:
          previous?.subagentReviewOutcome ??
          inferredTicket?.subagentReviewOutcome,
        subagentReviewCompletedAt:
          previous?.subagentReviewCompletedAt ??
          inferredTicket?.subagentReviewCompletedAt,
        subagentReviewPatchCommits:
          previous?.subagentReviewPatchCommits ??
          inferredTicket?.subagentReviewPatchCommits,
        subagentReviewAgent:
          previous?.subagentReviewAgent ?? inferredTicket?.subagentReviewAgent,
        subagentRunnerArtifactPath:
          previous?.subagentRunnerArtifactPath ??
          inferredTicket?.subagentRunnerArtifactPath,
        subagentAdversarialPromptPath:
          previous?.subagentAdversarialPromptPath ??
          inferredTicket?.subagentAdversarialPromptPath,
        subagentAdversarialPromptWrittenAt:
          previous?.subagentAdversarialPromptWrittenAt ??
          inferredTicket?.subagentAdversarialPromptWrittenAt,
        prNumber: previous?.prNumber ?? inferredTicket?.prNumber,
        prUrl: previous?.prUrl ?? inferredTicket?.prUrl,
        prOpenedAt: previous?.prOpenedAt ?? inferredTicket?.prOpenedAt,
        reviewFetchArtifactPath:
          previous?.reviewFetchArtifactPath ??
          inferredTicket?.reviewFetchArtifactPath,
        reviewTriageArtifactPath:
          previous?.reviewTriageArtifactPath ??
          inferredTicket?.reviewTriageArtifactPath,
        reviewHeadSha: previous?.reviewHeadSha ?? inferredTicket?.reviewHeadSha,
        reviewRecordedAt:
          previous?.reviewRecordedAt ?? inferredTicket?.reviewRecordedAt,
        reviewOutcome: previous?.reviewOutcome ?? inferredTicket?.reviewOutcome,
      };
    }),
  };
}

export function summarizeStateDifferences(
  existing: DeliveryState,
  repaired: DeliveryState,
): string[] {
  const changes: string[] = [];

  if (existing.planKey !== repaired.planKey) {
    changes.push(`planKey ${existing.planKey} -> ${repaired.planKey}`);
  }

  if (existing.planPath !== repaired.planPath) {
    changes.push(`planPath ${existing.planPath} -> ${repaired.planPath}`);
  }

  const existingById = new Map(
    existing.tickets.map((ticket) => [ticket.id, ticket]),
  );

  for (const repairedTicket of repaired.tickets) {
    const existingTicket = existingById.get(repairedTicket.id);

    if (!existingTicket) {
      changes.push(`${repairedTicket.id}: missing from existing state`);
      continue;
    }

    if (existingTicket.status !== repairedTicket.status) {
      changes.push(
        `${repairedTicket.id}: status ${existingTicket.status} -> ${repairedTicket.status}`,
      );
    }

    if (existingTicket.branch !== repairedTicket.branch) {
      changes.push(
        `${repairedTicket.id}: branch ${existingTicket.branch} -> ${repairedTicket.branch}`,
      );
    }

    if (existingTicket.baseBranch !== repairedTicket.baseBranch) {
      changes.push(
        `${repairedTicket.id}: base ${existingTicket.baseBranch} -> ${repairedTicket.baseBranch}`,
      );
    }

    if (existingTicket.worktreePath !== repairedTicket.worktreePath) {
      changes.push(
        `${repairedTicket.id}: worktree ${existingTicket.worktreePath} -> ${repairedTicket.worktreePath}`,
      );
    }

    if (existingTicket.prUrl !== repairedTicket.prUrl) {
      changes.push(
        `${repairedTicket.id}: pr ${existingTicket.prUrl ?? 'none'} -> ${repairedTicket.prUrl ?? 'none'}`,
      );
    }
  }

  for (const existingTicket of existing.tickets) {
    if (
      !repaired.tickets.find((candidate) => candidate.id === existingTicket.id)
    ) {
      changes.push(
        `${existingTicket.id}: present in existing state but absent after repair`,
      );
    }
  }

  return changes;
}

function inferStateFromRepo(
  cwd: string,
  ticketDefinitions: TicketDefinition[],
  options: OrchestratorOptions,
  dependencies: RepoInferenceDependencies,
): DeliveryState {
  const remoteBranches = listRemoteBranches(cwd, dependencies.runtime);
  const localBranches = listLocalBranches(cwd, dependencies.runtime);
  const openPullRequests = listOpenPullRequests(cwd, dependencies.runtime);
  const mergedPullRequests = listMergedPullRequests(cwd, dependencies.runtime);
  const branchCatalog = [
    ...new Set([
      ...localBranches,
      ...remoteBranches,
      ...openPullRequests.keys(),
      ...mergedPullRequests.keys(),
    ]),
  ];

  const tickets = ticketDefinitions.map((definition, index) => {
    const branch =
      dependencies.findExistingBranch(branchCatalog, definition)?.branch ??
      dependencies.deriveBranchName(definition);
    const baseBranch =
      index === 0
        ? dependencies.deliveryBaseBranch
        : (dependencies.findExistingBranch(
            branchCatalog,
            ticketDefinitions[index - 1]!,
          )?.branch ??
          dependencies.deriveBranchName(ticketDefinitions[index - 1]!));
    const branchExists = branchCatalog.includes(branch);
    const openPr =
      openPullRequests.get(branch) ??
      findPullRequestForTicket(
        openPullRequests,
        definition,
        dependencies.findExistingBranch,
      );
    const mergedPr =
      mergedPullRequests.get(branch) ??
      findPullRequestForTicket(
        mergedPullRequests,
        definition,
        dependencies.findExistingBranch,
      );
    const pr = openPr ?? mergedPr;
    const nextBranch = ticketDefinitions[index + 1]
      ? (dependencies.findExistingBranch(
          branchCatalog,
          ticketDefinitions[index + 1]!,
        )?.branch ??
        dependencies.deriveBranchName(ticketDefinitions[index + 1]!))
      : undefined;
    const nextBranchExists =
      nextBranch !== undefined && branchCatalog.includes(nextBranch);

    let status: TicketStatus = 'pending';

    if (mergedPr || (branchExists && nextBranchExists)) {
      status = 'done';
    } else if (openPr) {
      status = 'in_review';
    } else if (branchExists) {
      status = 'in_progress';
    }

    return {
      ...definition,
      status,
      branch,
      baseBranch,
      worktreePath: dependencies.deriveWorktreePath(cwd, definition.id),
      handoffPath: undefined,
      handoffGeneratedAt: undefined,
      verifyOutcome: undefined,
      verifyPatchCommits: undefined,
      subagentReviewOutcome: undefined,
      subagentReviewCompletedAt: undefined,
      subagentReviewPatchCommits: undefined,
      prNumber: pr?.number,
      prUrl: pr?.url,
      prOpenedAt: undefined,
      reviewFetchArtifactPath: undefined,
      reviewTriageArtifactPath: undefined,
      reviewHeadSha: undefined,
      reviewRecordedAt: undefined,
      reviewOutcome: undefined,
    } satisfies TicketState;
  });

  return {
    planKey: options.planKey,
    planPath: options.planPath,
    statePath: options.statePath,
    reviewsDirPath: options.reviewsDirPath,
    handoffsDirPath: options.handoffsDirPath,
    reviewPollIntervalMinutes: options.reviewPollIntervalMinutes,
    reviewPollMaxWaitMinutes: options.reviewPollMaxWaitMinutes,
    tickets,
  };
}

async function loadPlanContext(
  cwd: string,
  options: OrchestratorOptions,
  dependencies: RepoInferenceDependencies,
): Promise<LoadPlanContextResult> {
  const planMarkdown = await readFile(resolve(cwd, options.planPath), 'utf8');
  const ticketDefinitions = parsePlan(planMarkdown, options.planPath, cwd);
  const absoluteStatePath = resolve(cwd, options.statePath);
  const inferred = inferStateFromRepo(
    cwd,
    ticketDefinitions,
    options,
    dependencies,
  );

  return {
    absoluteStatePath,
    inferred,
    ticketDefinitions,
  };
}

function findPullRequestForTicket(
  pullRequests: Map<string, PullRequestSummary>,
  definition: TicketDefinition,
  findExistingBranch: (
    branches: string[],
    definition: TicketDefinition,
  ) => { branch: string; source: 'ticket-id' | 'derived' } | undefined,
): PullRequestSummary | undefined {
  const match = findExistingBranch(Array.from(pullRequests.keys()), definition);
  return match ? pullRequests.get(match.branch) : undefined;
}

function selectStatusValue(
  currentStatus: TicketStatus | undefined,
  inferredStatus: TicketStatus | undefined,
): TicketStatus {
  if (!currentStatus) {
    return inferredStatus ?? 'pending';
  }

  if (!inferredStatus) {
    return currentStatus;
  }

  return statusRank(inferredStatus) > statusRank(currentStatus)
    ? inferredStatus
    : currentStatus;
}

function statusRank(status: TicketStatus): number {
  switch (status) {
    case 'pending':
      return 0;
    case 'in_progress':
      return 1;
    case 'red_complete':
      return 2;
    case 'verified':
      return 3;
    case 'subagent_review_complete':
      return 4;
    case 'in_review':
      return 5;
    case 'needs_patch':
      return 6;
    case 'operator_input_needed':
      return 7;
    case 'reviewed':
      return 8;
    case 'done':
      return 9;
  }
}

function selectBranchValue(
  currentBranch: string | undefined,
  inferredBranch: string | undefined,
  fallbackBranch: string,
): string {
  if (inferredBranch) {
    return inferredBranch;
  }

  return currentBranch ?? fallbackBranch;
}

async function backupStateFile(absoluteStatePath: string): Promise<string> {
  const backupPath = absoluteStatePath.replace(
    /\.json$/,
    `.stale-${new Date()
      .toISOString()
      .replace(/[-:]/g, '')
      .replace(/\.\d{3}Z$/, 'Z')}.json`,
  );

  await writeFile(
    backupPath,
    await readFile(absoluteStatePath, 'utf8'),
    'utf8',
  );
  return backupPath;
}

function relativeToRepo(cwd: string, absolutePath: string): string {
  return resolve(absolutePath).replace(`${resolve(cwd)}/`, '');
}