// SPEC: PJ2026-01060703 CI/CD branch follower debug steps. // Responsibility: bounded single-step debugging for branch follower state and decision paths. import type { CommandResult } from "./command"; import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types"; import { renderControllerDebugJob, waitForJobShell } from "./cicd-controller-render"; import { redactText, shQuote } from "./platform-infra-ops-library"; type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult; export interface CicdDebugDeps { selectFollowers(registry: BranchFollowerRegistry, options: ParsedOptions, opts: { includeDisabled: boolean }): FollowerSpec[]; readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sStateRead; readAdapterStatus(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions): Promise; decideAndMaybeTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, previous: Record, live: AdapterSummary, options: ParsedOptions): Promise; writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult; runKubeScript: KubeScriptRunner; } export async function buildDebugStep(registry: BranchFollowerRegistry, options: ParsedOptions, deps: CicdDebugDeps): Promise> { const step = options.debugStep ?? "state-read"; if (options.followerId === null) throw new Error("debug-step requires --follower "); if (!options.inCluster) return runTargetDebugStepJob(registry, options, step, deps); const selected = deps.selectFollowers(registry, options, { includeDisabled: true }); if (selected.length !== 1) throw new Error("debug-step operates on exactly one follower"); const follower = selected[0] as FollowerSpec; const before = deps.readK8sState(registry, options); const previous = before.stateByFollower[follower.id] ?? {}; let live: AdapterSummary | null = null; let decided: FollowerState | null = null; let write: Record | null = null; let after: K8sStateRead | null = null; if (step === "status-read" || step === "decide") { live = await deps.readAdapterStatus(registry, follower, options); } if (step === "decide") { decided = await deps.decideAndMaybeTrigger(registry, follower, previous, live as AdapterSummary, debugDecisionOptions(options)); } if (step === "state-write") { const writeInput = stateWriteInput(previous); if (writeInput === null) { return { ok: false, action: "debug-step", step, follower: follower.id, execution: "k8s-native-in-cluster", dryRun: !options.confirm, stateBefore: stateSnapshot(before, follower.id), stateWrite: { ok: false, skipped: true, reason: "stored-state-missing" }, parsedDownstreamCliOutput: false, }; } if (options.confirm) { const result = deps.writeFollowerState(registry, writeInput, options); write = stateWriteResult(follower.id, result); after = deps.readK8sState(registry, options); } else { write = { ok: true, skipped: true, reason: "dry-run-requires-confirm", input: "stored-state" }; } } return { ok: write === null ? before.ok && (live === null || live.ok) : write.ok === true, action: "debug-step", step, follower: follower.id, execution: "k8s-native-in-cluster", dryRun: !options.confirm, stateBefore: stateSnapshot(before, follower.id), status: live === null ? null : compactAdapterStatus(live), decision: decided === null ? null : compactFollowerDecision(decided), stateWrite: write, stateAfter: after === null ? null : stateSnapshot(after, follower.id), parsedDownstreamCliOutput: false, next: debugNext(follower.id), }; } export function renderDebugStepHuman(payload: Record): string { const before = asOptionalRecord(payload.stateBefore); const after = asOptionalRecord(payload.stateAfter); const write = asOptionalRecord(payload.stateWrite); const status = asOptionalRecord(payload.status); const decision = asOptionalRecord(payload.decision); const target = asOptionalRecord(payload.target); const next = asOptionalRecord(payload.next); const rows = [[ payload.follower ?? "-", payload.step ?? "-", payload.execution ?? "-", payload.dryRun === true ? "true" : "false", before?.phase ?? "-", after?.phase ?? decision?.phase ?? status?.phase ?? "-", shortSha(stringOrNull(before?.observedSha)), shortSha(stringOrNull(after?.observedSha) ?? stringOrNull(decision?.observedSha) ?? stringOrNull(status?.observedSha)), ]]; const writeRows = write === null ? [] : [[ write.ok === true ? "ok" : "failed", write.skipped === true ? "skipped" : "executed", write.input ?? "-", asOptionalRecord(write.patch)?.beforeResourceVersion ?? "-", asOptionalRecord(write.patch)?.afterResourceVersion ?? "-", write.exitCode ?? "-", write.message ?? write.reason ?? "-", ]]; return [ `CI/CD BRANCH-FOLLOWER DEBUG-STEP (${payload.ok === false ? "failed" : "ok"})`, "", table(["FOLLOWER", "STEP", "EXECUTION", "DRY_RUN", "BEFORE", "AFTER", "BEFORE_SHA", "AFTER_SHA"], rows), target === null ? "" : `\nTARGET JOB\n${table(["JOB", "EXIT", "TIMED_OUT", "PARSED"], [[target.name ?? "-", target.exitCode ?? "-", target.timedOut ?? "-", target.parsed === true ? "yes" : "no"]])}`, writeRows.length === 0 ? "" : `\nSTATE WRITE\n${table(["STATUS", "MODE", "INPUT", "BEFORE_RV", "AFTER_RV", "EXIT", "MESSAGE"], writeRows)}`, "", "NEXT", `state-read: ${next?.stateRead ?? "-"}`, `status-read: ${next?.statusRead ?? "-"}`, `decide: ${next?.decide ?? "-"}`, `state-write: ${next?.stateWrite ?? "-"}`, "", ].filter((line) => line !== "").join("\n"); } function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: ParsedOptions, step: BranchFollowerDebugStep, deps: CicdDebugDeps): Record { const timeoutSeconds = options.timeoutSeconds ?? registry.controller.budgets.runOnceSeconds; const jobName = `${registry.controller.deploymentName}-debug-${step}-${Date.now().toString(36)}`.replace(/[^a-z0-9-]+/gu, "-").slice(0, 63); const manifest = renderControllerDebugJob(registry, options, jobName, step, timeoutSeconds); const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`; const script = [ "set -eu", "tmp=$(mktemp)", "base64 -d >\"$tmp\" <<'UNIDESK_CICD_DEBUG_JOB_B64'", Buffer.from(manifestYaml, "utf8").toString("base64"), "UNIDESK_CICD_DEBUG_JOB_B64", `kubectl -n ${shQuote(registry.controller.namespace)} delete job ${shQuote(jobName)} --ignore-not-found=true >/dev/null 2>&1 || true`, `kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp" >/dev/null`, waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds), ].join("\n"); const result = deps.runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000); const parsed = parseLastJsonObject(result.stdout); const state = deps.readK8sState(registry, options); const followerId = options.followerId ?? ""; return { ok: result.exitCode === 0 && parsed?.ok !== false, action: "debug-step", step, follower: followerId, execution: "k8s-native-debug-job", dryRun: !options.confirm, stateBefore: asOptionalRecord(parsed?.stateBefore), status: asOptionalRecord(parsed?.status), decision: asOptionalRecord(parsed?.decision), stateWrite: asOptionalRecord(parsed?.stateWrite), target: { name: jobName, namespace: registry.controller.namespace, exitCode: result.exitCode, timedOut: result.timedOut, parsed: parsed !== null, stdoutTail: redactText(tailText(result.stdout, options.full ? 4000 : 1000)), stderrTail: redactText(tailText(result.stderr, options.full ? 2000 : 800)), }, targetResult: parsed, stateAfter: asOptionalRecord(parsed?.stateAfter) ?? stateSnapshot(state, followerId), parsedDownstreamCliOutput: false, next: debugNext(followerId), }; } function debugDecisionOptions(options: ParsedOptions): ParsedOptions { return { ...options, confirm: false, dryRun: true, wait: false, recordState: false }; } function stateWriteInput(previous: Record): FollowerState | null { if (stringOrNull(previous.id) === null) return null; if (asOptionalRecord(previous.source) === null || asOptionalRecord(previous.target) === null || asOptionalRecord(previous.timings) === null) return null; return previous as unknown as FollowerState; } function stateWriteResult(followerId: string, result: CommandResult): Record { const parsed = parseLastJsonObject(result.stdout); return { ok: result.exitCode === 0 && parsed?.ok !== false, follower: followerId, exitCode: result.exitCode, timedOut: result.timedOut, input: "stored-state", patch: parsed, message: result.exitCode === 0 ? "state patch command completed" : redactText(tailText(result.stderr || result.stdout, 500)), parsedDownstreamCliOutput: false, }; } function stateSnapshot(read: K8sStateRead, followerId: string): Record { const state = read.stateByFollower[followerId] ?? {}; const source = asOptionalRecord(state.source); const target = asOptionalRecord(state.target); const timings = asOptionalRecord(state.timings); return { present: read.stateConfigMapPresent, ok: read.ok, errors: read.errors.slice(0, 8), metadata: read.stateMetadata, valueBytes: read.stateValueBytes[followerId] ?? null, phase: stringOrNull(state.phase), observedSha: stringOrNull(source?.observedSha), targetSha: stringOrNull(target?.targetSha), lastTriggeredSha: stringOrNull(state.lastTriggeredSha), lastSucceededSha: stringOrNull(state.lastSucceededSha), pipelineRun: stringOrNull(state.pipelineRun), inFlightJob: stringOrNull(state.inFlightJob), timingStatus: stringOrNull(timings?.totalStatus), totalSeconds: numberOrNull(timings?.totalSeconds), startedAt: stringOrNull(timings?.startedAt), updatedAt: stringOrNull(state.updatedAt), }; } function compactAdapterStatus(live: AdapterSummary): Record { return { ok: live.ok, phase: live.phase, observedSha: live.observedSha, targetSha: live.targetSha, aligned: live.aligned, pipelineRun: live.pipelineRun, inFlightJob: live.inFlightJob, message: live.message, }; } function compactFollowerDecision(state: FollowerState): Record { return { phase: state.phase, observedSha: state.source.observedSha, targetSha: state.target.targetSha, lastTriggeredSha: state.lastTriggeredSha, lastSucceededSha: state.lastSucceededSha, pipelineRun: state.pipelineRun, inFlightJob: state.inFlightJob, decision: state.decision, timings: state.timings, }; } function debugNext(followerId: string): Record { return { stateRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-read`, statusRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step status-read`, decide: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step decide`, stateWrite: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-write --confirm`, }; } function parseLastJsonObject(text: string): Record | null { const starts: number[] = []; let offset = 0; for (const line of text.split(/\r?\n/u)) { if (line.trimStart().startsWith("{")) starts.push(offset + line.indexOf("{")); offset += line.length + 1; } const end = text.lastIndexOf("}"); if (end < 0) return null; for (let index = starts.length - 1; index >= 0; index -= 1) { const start = starts[index] ?? -1; if (start < 0 || start >= end) continue; try { const parsed = JSON.parse(text.slice(start, end + 1)) as unknown; const record = asOptionalRecord(parsed); if (record !== null) return record; } catch { // Try the previous JSON-looking line. } } return null; } function asOptionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } function numberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function shortSha(value: string | null): string { if (value === null) return "-"; return value.length > 12 ? value.slice(0, 12) : value; } function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string { const normalized = rows.map((row) => headers.map((_, index) => cell(row[index]))); const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0))); const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd(); return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n"); } function cell(value: unknown): string { if (value === null || value === undefined || value === "") return "-"; const text = String(value).replace(/\s+/gu, " "); return text.length > 96 ? `${text.slice(0, 93)}...` : text; } function tailText(text: string, maxChars: number): string { return text.length <= maxChars ? text : text.slice(text.length - maxChars); }