/** * uat-run/orchestrate.ts — PURE decision helpers of the orchestrator. * * The phase sequencing rules live here, unit-testable away from any I/O: * plan freshness (refreshPlan policy × existence × signature drift), the * `ss dev up --json` output parse, and the run-level verdict roll-up. */ import type { PhaseOutcome } from './types.js'; /** Signature shape as carried by plans (zod leaves each SHA optional). */ export interface PartialSignature { nav_sha?: string; rbac_sha?: string; registry_sha?: string; } export type PlanAction = 'generate' | 'regenerate' | 'keep' | 'fail'; /** What to do with the plan, given the policy and the observed state. PURE. */ export function decidePlanAction( refreshPlan: 'auto' | 'always' | 'never', planExists: boolean, /** null = drift not checked (e.g. discovery unavailable). */ signatureFresh: boolean | null, ): PlanAction { if (refreshPlan === 'always') return planExists ? 'regenerate' : 'generate'; if (refreshPlan === 'never') return planExists ? 'keep' : 'fail'; if (!planExists) return 'generate'; if (signatureFresh === false) return 'regenerate'; return 'keep'; } /** Two signatures match ⇔ every SHA equals (missing counts as mismatch only when the other side has one). PURE. */ export function signaturesMatch(a: PartialSignature, b: PartialSignature): boolean { return a.nav_sha === b.nav_sha && a.rbac_sha === b.rbac_sha && a.registry_sha === b.registry_sha; } /** * Does the plan carry a `role_catalog` (schema ≥ 1.1.0)? A plan without one is * NEVER "fresh" — uat-provision joins roles by catalog id, so the plan must be * regenerated under refreshPlan auto/always (and provision fails actionably under * `never`). PURE. */ export function planCarriesRoleCatalog(plan: { role_catalog?: unknown }): boolean { return typeof plan.role_catalog === 'object' && plan.role_catalog !== null; } export interface SsDevUpResult { status: 'ok' | 'error' | 'needs_input' | 'unparseable'; message?: string; } /** Parse the LAST JSON object line of `ss dev up --json` output. PURE. */ export function parseSsDevUp(stdout: string): SsDevUpResult { const lines = stdout .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.startsWith('{')); for (let i = lines.length - 1; i >= 0; i--) { try { const parsed = JSON.parse(lines[i]) as Record; const status = parsed.status; if (status === 'ok' || status === 'error' || status === 'needs_input') { return { status, message: typeof parsed.message === 'string' ? parsed.message : undefined }; } } catch { /* keep scanning upward */ } } return { status: 'unparseable', message: stdout.slice(-400) }; } /** Overall run success: no phase failed (skips are fine). PURE. */ export function runSucceeded(phases: readonly PhaseOutcome[]): boolean { return phases.every((p) => p.status !== 'failed'); }