feat: add branch follower debug step

This commit is contained in:
Codex
2026-07-03 17:56:08 +00:00
parent b98b131bb7
commit 917cba6659
8 changed files with 486 additions and 8 deletions
+61 -4
View File
@@ -21,10 +21,11 @@ import { sentinelPipelineRunName } from "./hwlab-node-web-sentinel-cicd-shared";
import { transPath } from "./hwlab-node/runtime-common";
import { configRefGraph, resolveConfigRefString } from "./ops/config-refs";
import { renderControllerManifests, renderControllerReconcileJob, waitForJobShell } from "./cicd-controller-render";
import { buildDebugStep, renderDebugStepHuman } from "./cicd-debug";
import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh";
import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle";
import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native";
import type { AdapterSummary, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types";
import {
arrayField,
asRecord,
@@ -44,7 +45,7 @@ const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower";
export function cicdHelp(): unknown {
return {
command: "cicd branch-follower plan|apply|status|run-once|cleanup-state|events|logs",
command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs",
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
usage: [
"bun scripts/cli.ts cicd branch-follower plan",
@@ -53,6 +54,8 @@ 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 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",
"bun scripts/cli.ts cicd branch-follower events --follower agentrun-jd01-v02",
"bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master",
@@ -67,7 +70,7 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
const top = args[0];
if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json");
if (top !== "branch-follower") {
throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|cleanup-state|events|logs");
throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs");
}
const options = parseOptions(args.slice(1));
const command = commandLabel(options);
@@ -82,6 +85,8 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
return renderResult(command, await buildStatus(registry, options), options);
case "run-once":
return renderResult(command, await runOnce(registry, options), options);
case "debug-step":
return renderResult(command, await buildDebugStep(registry, options, { selectFollowers, readK8sState, readAdapterStatus, decideAndMaybeTrigger, writeFollowerState, runKubeScript }), options);
case "cleanup-state":
return renderResult(command, cleanupState(registry, options), options);
case "events":
@@ -97,7 +102,7 @@ function parseOptions(args: string[]): ParsedOptions {
if (actionToken === undefined || isHelpToken(actionToken)) {
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
}
if (!["plan", "apply", "status", "run-once", "cleanup-state", "events", "logs"].includes(actionToken)) {
if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs"].includes(actionToken)) {
throw new Error(`cicd branch-follower unknown action: ${actionToken}`);
}
const action = actionToken as BranchFollowerAction;
@@ -136,6 +141,8 @@ function parseOptions(args: string[]): ParsedOptions {
options.output = "json";
} else if (arg === "--record-state") {
options.recordState = true;
} else if (arg === "--step") {
options.debugStep = debugStepOption(valueOption(rest, ++index, arg));
} else if (arg === "-o" || arg === "--output") {
const value = valueOption(rest, ++index, arg);
if (value !== "json" && value !== "yaml" && value !== "wide" && value !== "text") throw new Error(`${arg} must be json, yaml, wide, or text`);
@@ -157,6 +164,7 @@ function parseOptions(args: string[]): ParsedOptions {
if (options.confirm && options.dryRun) throw new Error("cicd branch-follower accepts only one of --confirm or --dry-run");
if (options.action === "apply" && !options.confirm) options.dryRun = true;
if (options.action === "run-once" && !options.confirm) options.dryRun = true;
if (options.action === "debug-step" && !options.confirm) options.dryRun = true;
if (options.action === "cleanup-state" && !options.confirm) options.dryRun = true;
if (options.action === "run-once" && options.confirm && !options.all && options.followerId === null) {
throw new Error("run-once --confirm requires --all or --follower <id>");
@@ -164,9 +172,17 @@ function parseOptions(args: string[]): ParsedOptions {
if (options.action === "cleanup-state" && options.confirm && !options.all && options.followerId === null) {
throw new Error("cleanup-state --confirm requires --all or --follower <id>");
}
if (options.action === "debug-step" && options.followerId === null) {
throw new Error("debug-step requires --follower <id>");
}
return options;
}
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");
}
function isInClusterRuntime(): boolean {
return Boolean(process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT);
}
@@ -186,6 +202,7 @@ function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOp
full: false,
raw: false,
recordState: false,
debugStep: null,
output: "human",
limit: 20,
tailBytes: 12000,
@@ -599,12 +616,15 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
const previous = readK8sState(registry, options);
const results: FollowerState[] = [];
const stateWriteWarnings: string[] = [];
const stateWrites: Record<string, unknown>[] = [];
for (const follower of selected) {
const oldState = previous.stateByFollower[follower.id] ?? {};
const live = await readAdapterStatus(registry, follower, options);
const state = await decideAndMaybeTrigger(registry, follower, oldState, live, options);
if (!options.dryRun || options.recordState) {
const write = writeFollowerState(registry, state, options);
const writeSummary = stateWriteSummary(follower.id, write);
stateWrites.push(writeSummary);
if (write.exitCode !== 0) {
const warning = `state write failed for ${follower.id}: ${tailText(write.stderr || write.stdout, 300)}`;
state.warnings.push(warning);
@@ -622,6 +642,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
controller: options.inCluster,
registry: registrySummary(registry),
followers: results,
stateWrites,
warnings: stateWriteWarnings,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
@@ -1847,6 +1868,8 @@ function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions):
return {
ok: errors.length === 0,
stateByFollower: stateResult.stateByFollower,
stateMetadata: stateResult.metadata,
stateValueBytes: stateResult.valueBytes,
stateConfigMapPresent: stateResult.present,
deployment: deploymentResult.value,
lease: leaseResult.value,
@@ -1876,20 +1899,42 @@ function kubeConfigMapFollowerState(registry: BranchFollowerRegistry, options: P
return { ok: false, stateByFollower: {}, present: false, error };
}
const parsedStates = asOptionalRecord(parsed.stateByFollower) ?? {};
const metadata = asOptionalRecord(parsed.metadata);
const parsedValueBytes = asOptionalRecord(parsed.valueBytes) ?? {};
const stateByFollower: Record<string, Record<string, unknown>> = {};
const valueBytes: Record<string, number> = {};
for (const follower of registry.followers) {
const state = asOptionalRecord(parsedStates[follower.id]);
if (state !== null) stateByFollower[follower.id] = state;
const bytes = numberOrNull(parsedValueBytes[follower.id]);
if (bytes !== null) valueBytes[follower.id] = bytes;
}
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(String).filter((item) => item.length > 0) : [];
return {
ok: parsed.ok === true && errors.length === 0,
stateByFollower,
metadata,
valueBytes,
present: parsed.present === true,
error: errors.join("; "),
};
}
function stateWriteSummary(followerId: string, result: CommandResult): Record<string, unknown> {
const parsed = result.exitCode === 0 ? parseJsonObject(result.stdout) : null;
return {
follower: followerId,
ok: result.exitCode === 0 && parsed?.ok !== false,
exitCode: result.exitCode,
timedOut: result.timedOut,
beforeResourceVersion: stringOrNull(parsed?.beforeResourceVersion),
afterResourceVersion: stringOrNull(parsed?.afterResourceVersion),
preservedTiming: parsed?.preservedTiming === true,
message: result.exitCode === 0 ? "state patch command completed" : redactText(tailText(result.stderr || result.stdout, 500)),
parsedDownstreamCliOutput: false,
};
}
function removeFollowerStateKeys(registry: BranchFollowerRegistry, options: ParsedOptions, ids: string[]): CommandResult {
const patch = JSON.stringify({ data: Object.fromEntries(ids.map((id) => [id, null])) });
const script = [
@@ -2665,6 +2710,7 @@ function renderHuman(command: string, payload: Record<string, unknown>, options:
if (command.endsWith(" apply")) return renderApplyHuman(payload);
if (command.endsWith(" status")) return renderStatusHuman(payload, options);
if (command.endsWith(" run-once")) return renderRunOnceHuman(payload);
if (command.endsWith(" debug-step")) return renderDebugStepHuman(payload);
if (command.endsWith(" cleanup-state")) return renderCleanupStateHuman(payload);
if (command.endsWith(" events") || command.endsWith(" logs")) return renderDrillDownHuman(payload);
return `${JSON.stringify(payload, null, 2)}\n`;
@@ -2770,6 +2816,7 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
function renderRunOnceHuman(payload: Record<string, unknown>): string {
const followers = arrayRecords(payload.followers);
const stateWrites = arrayRecords(payload.stateWrites);
const rows = followers.map((item) => {
const source = asOptionalRecord(item.source);
const target = asOptionalRecord(item.target);
@@ -2785,11 +2832,21 @@ function renderRunOnceHuman(payload: Record<string, unknown>): string {
});
const next = asOptionalRecord(payload.next);
const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48);
const writeRows = stateWrites.map((item) => [
item.follower,
item.ok === true ? "ok" : "failed",
item.beforeResourceVersion ?? "-",
item.afterResourceVersion ?? "-",
item.preservedTiming === true ? "yes" : "no",
item.exitCode ?? "-",
item.message ?? "-",
]);
return [
`CI/CD BRANCH-FOLLOWER RUN-ONCE (${payload.ok === false ? "blocked" : payload.dryRun === true ? "dry-run" : "ok"})`,
"",
table(["FOLLOWER", "PHASE", "OBSERVED", "TARGET", "TRIGGERED", "IN_FLIGHT", "DECISION"], rows),
timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`,
writeRows.length === 0 ? "" : `\nSTATE WRITES\n${table(["FOLLOWER", "STATUS", "BEFORE_RV", "AFTER_RV", "PRESERVED", "EXIT", "MESSAGE"], writeRows)}`,
"",
"NEXT",
`status: ${next?.status ?? "-"}`,