feat: add follower controller source debug step

This commit is contained in:
Codex
2026-07-03 19:52:55 +00:00
parent ba09dfa8b2
commit 20a61b47e1
4 changed files with 88 additions and 4 deletions
+3 -2
View File
@@ -56,6 +56,7 @@ export function cicdHelp(): unknown {
"bun scripts/cli.ts cicd branch-follower status --live",
"bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
"bun scripts/cli.ts cicd branch-follower run-once --follower hwlab-jd01-v03 --confirm --wait",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-write --confirm",
"bun scripts/cli.ts cicd branch-follower cleanup-state --follower web-probe-sentinel-master --confirm",
@@ -181,8 +182,8 @@ function parseOptions(args: string[]): ParsedOptions {
}
function debugStepOption(value: string): BranchFollowerDebugStep {
if (value === "state-read" || value === "status-read" || value === "decide" || value === "state-write") return value;
throw new Error("--step must be state-read, status-read, decide, or state-write");
if (value === "state-read" || value === "controller-source" || value === "status-read" || value === "decide" || value === "state-write") return value;
throw new Error("--step must be state-read, controller-source, status-read, decide, or state-write");
}
function isInClusterRuntime(): boolean {
+80 -1
View File
@@ -1,6 +1,9 @@
// 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 { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { runCommand, type CommandResult } from "./command";
import { repoRoot, rootPath } from "./config";
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";
@@ -30,7 +33,11 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
let decided: FollowerState | null = null;
let write: Record<string, unknown> | null = null;
let after: K8sStateRead | null = null;
let controllerSource: Record<string, unknown> | null = null;
if (step === "controller-source") {
controllerSource = controllerSourceSnapshot(registry);
}
if (step === "status-read" || step === "decide") {
live = await deps.readAdapterStatus(registry, follower, options);
}
@@ -69,6 +76,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
execution: "k8s-native-in-cluster",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
controllerSource,
status: live === null ? null : compactAdapterStatus(live),
decision: decided === null ? null : compactFollowerDecision(decided),
stateWrite: write,
@@ -84,6 +92,7 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
const write = asOptionalRecord(payload.stateWrite);
const status = asOptionalRecord(payload.status);
const decision = asOptionalRecord(payload.decision);
const controllerSource = asOptionalRecord(payload.controllerSource);
const target = asOptionalRecord(payload.target);
const next = asOptionalRecord(payload.next);
const rows = [[
@@ -109,11 +118,13 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
`CI/CD BRANCH-FOLLOWER DEBUG-STEP (${payload.ok === false ? "failed" : "ok"})`,
"",
table(["FOLLOWER", "STEP", "EXECUTION", "DRY_RUN", "BEFORE", "AFTER", "BEFORE_SHA", "AFTER_SHA"], rows),
controllerSource === null ? "" : `\nCONTROLLER SOURCE\n${table(["HEAD", "BRANCH", "REGISTRY", "REREAD_MARKER", "FILE_SHA"], [[shortSha(stringOrNull(controllerSource.head)), controllerSource.branch ?? "-", shortSha(stringOrNull(controllerSource.registrySha256)), controllerSource.closeoutRereadMarker === true ? "present" : "missing", shortSha(stringOrNull(asOptionalRecord(controllerSource.branchFollowerFile)?.sha256))]])}`,
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 ?? "-"}`,
`controller-source: ${next?.controllerSource ?? "-"}`,
`status-read: ${next?.statusRead ?? "-"}`,
`decide: ${next?.decide ?? "-"}`,
`state-write: ${next?.stateWrite ?? "-"}`,
@@ -152,6 +163,7 @@ function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: Parsed
execution: "k8s-native-debug-job",
dryRun: !options.confirm,
stateBefore: compact?.stateBefore ?? compactStateLike(asOptionalRecord(parsed?.stateBefore)),
controllerSource: compact?.controllerSource ?? asOptionalRecord(parsed?.controllerSource),
status: compact?.status ?? null,
decision: compact?.decision ?? null,
stateWrite: compact?.stateWrite ?? null,
@@ -173,6 +185,7 @@ function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: Parsed
function compactTargetDebugResult(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
if (parsed === null) return null;
const stateBefore = asOptionalRecord(parsed.stateBefore);
const controllerSource = asOptionalRecord(parsed.controllerSource);
const status = asOptionalRecord(parsed.status);
const decision = asOptionalRecord(parsed.decision);
const stateWrite = asOptionalRecord(parsed.stateWrite);
@@ -183,6 +196,16 @@ function compactTargetDebugResult(parsed: Record<string, unknown> | null): Recor
step: stringOrNull(parsed.step),
follower: stringOrNull(parsed.follower),
stateBefore: compactStateLike(stateBefore),
controllerSource: controllerSource === null ? null : {
ok: controllerSource.ok === true,
repository: stringOrNull(controllerSource.repository),
branch: stringOrNull(controllerSource.branch),
head: stringOrNull(controllerSource.head),
registrySha256: stringOrNull(controllerSource.registrySha256),
closeoutRereadMarker: controllerSource.closeoutRereadMarker === true,
branchFollowerFile: asOptionalRecord(controllerSource.branchFollowerFile),
errors: Array.isArray(controllerSource.errors) ? controllerSource.errors.map(String).slice(0, 5) : [],
},
status: status === null ? null : {
ok: status.ok === true,
phase: stringOrNull(status.phase),
@@ -220,6 +243,61 @@ function compactStateLike(value: Record<string, unknown> | null): Record<string,
};
}
function controllerSourceSnapshot(registry: BranchFollowerRegistry): Record<string, unknown> {
const head = commandText(["git", "rev-parse", "HEAD"]);
const branch = commandText(["git", "symbolic-ref", "--short", "HEAD"]);
const file = fileSnapshot("scripts/src/cicd-branch-follower.ts");
const text = file.text;
const errors = [
head.ok ? null : `git head: ${head.error}`,
branch.ok ? null : `git branch: ${branch.error}`,
file.ok ? null : `branch follower file: ${file.error}`,
].filter((item): item is string => item !== null);
return {
ok: errors.length === 0,
repository: registry.controller.source.repository,
branch: branch.value,
head: head.value,
registrySha256: registry.rawSha256,
controllerConfigMap: registry.controller.configMapName,
closeoutRereadMarker: text.includes("post-closeout status re-read") && text.includes("automaticCloseoutAccelerated"),
branchFollowerFile: {
path: file.path,
present: file.present,
bytes: file.bytes,
sha256: file.sha256,
},
statusAuthority: "target-controller-checkout",
parsedDownstreamCliOutput: false,
errors,
};
}
function commandText(command: string[]): { ok: boolean; value: string | null; error: string | null } {
const result = runCommand(command, repoRoot, { timeoutMs: 5_000 });
const value = result.exitCode === 0 ? result.stdout.trim() : null;
return {
ok: result.exitCode === 0 && value !== null && value.length > 0,
value: value === null || value.length === 0 ? null : value,
error: result.exitCode === 0 ? null : redactText(tailText(result.stderr || result.stdout, 300)),
};
}
function fileSnapshot(path: string): { ok: boolean; path: string; present: boolean; bytes: number | null; sha256: string | null; text: string; error: string | null } {
const absolute = rootPath(path);
if (!existsSync(absolute)) return { ok: false, path, present: false, bytes: null, sha256: null, text: "", error: "missing" };
const text = readFileSync(absolute, "utf8");
return {
ok: true,
path,
present: true,
bytes: Buffer.byteLength(text),
sha256: createHash("sha256").update(text).digest("hex"),
text,
error: null,
};
}
function debugDecisionOptions(options: ParsedOptions): ParsedOptions {
return { ...options, confirm: false, dryRun: true, wait: false, recordState: false };
}
@@ -407,6 +485,7 @@ function compactDebugTimings(timings: FollowerState["timings"]): Record<string,
function debugNext(followerId: string): Record<string, string> {
return {
stateRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-read`,
controllerSource: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step controller-source`,
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`,
+1 -1
View File
@@ -3,7 +3,7 @@
export type OutputMode = "human" | "json" | "yaml";
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs";
export type BranchFollowerDebugStep = "state-read" | "status-read" | "decide" | "state-write";
export type BranchFollowerDebugStep = "state-read" | "controller-source" | "status-read" | "decide" | "state-write";
export type BranchFollowerPhase =
| "Observed"
| "Noop"