644 lines
29 KiB
TypeScript
644 lines
29 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower debug steps.
|
|
// Responsibility: bounded single-step debugging for branch follower state and decision paths.
|
|
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 { taskRunItems } from "./cicd-taskruns";
|
|
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<AdapterSummary>;
|
|
decideAndMaybeTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, previous: Record<string, unknown>, live: AdapterSummary, options: ParsedOptions): Promise<FollowerState>;
|
|
writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult;
|
|
runKubeScript: KubeScriptRunner;
|
|
}
|
|
|
|
export async function buildDebugStep(registry: BranchFollowerRegistry, options: ParsedOptions, deps: CicdDebugDeps): Promise<Record<string, unknown>> {
|
|
const step = options.debugStep ?? "state-read";
|
|
if (options.followerId === null) throw new Error("debug-step requires --follower <id>");
|
|
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<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);
|
|
}
|
|
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),
|
|
controllerSource,
|
|
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, unknown>): 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 controllerSource = asOptionalRecord(payload.controllerSource);
|
|
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),
|
|
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 ?? "-"}`,
|
|
"",
|
|
].filter((line) => line !== "").join("\n");
|
|
}
|
|
|
|
function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: ParsedOptions, step: BranchFollowerDebugStep, deps: CicdDebugDeps): Record<string, unknown> {
|
|
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 ?? "";
|
|
const compact = compactTargetDebugResult(parsed);
|
|
const ok = result.exitCode === 0 && parsed?.ok !== false;
|
|
const includeTargetTail = !ok || parsed === null;
|
|
const fallbackStateAfter = stateSnapshot(state, followerId);
|
|
return {
|
|
ok,
|
|
action: "debug-step",
|
|
step,
|
|
follower: followerId,
|
|
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,
|
|
target: {
|
|
name: jobName,
|
|
namespace: registry.controller.namespace,
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
parsed: parsed !== null,
|
|
stdoutTail: includeTargetTail ? redactText(tailText(result.stdout, 1000)) : "",
|
|
stderrTail: includeTargetTail ? redactText(tailText(result.stderr, 800)) : "",
|
|
},
|
|
stateAfter: compact?.stateAfter ?? compactStateLike(asOptionalRecord(parsed?.stateAfter) ?? fallbackStateAfter),
|
|
parsedDownstreamCliOutput: false,
|
|
next: debugNext(followerId),
|
|
};
|
|
}
|
|
|
|
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);
|
|
const stateAfter = asOptionalRecord(parsed.stateAfter);
|
|
return {
|
|
ok: parsed.ok === true,
|
|
action: stringOrNull(parsed.action),
|
|
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),
|
|
observedSha: stringOrNull(status.observedSha),
|
|
targetSha: stringOrNull(status.targetSha),
|
|
aligned: status.aligned === true ? true : status.aligned === false ? false : null,
|
|
pipelineRun: stringOrNull(status.pipelineRun),
|
|
message: stringOrNull(status.message),
|
|
gates: asOptionalRecord(status.gates),
|
|
},
|
|
decision: decision === null ? null : {
|
|
phase: stringOrNull(decision.phase),
|
|
observedSha: stringOrNull(decision.observedSha),
|
|
targetSha: stringOrNull(decision.targetSha),
|
|
decision: stringOrNull(decision.decision),
|
|
totalSeconds: numberOrNull(asOptionalRecord(decision.timings)?.totalSeconds),
|
|
totalStatus: stringOrNull(asOptionalRecord(decision.timings)?.totalStatus),
|
|
},
|
|
stateWrite,
|
|
stateAfter: compactStateLike(stateAfter),
|
|
};
|
|
}
|
|
|
|
function compactStateLike(value: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (value === null) return null;
|
|
const metadata = asOptionalRecord(value.metadata);
|
|
return {
|
|
ok: value.ok === true,
|
|
phase: stringOrNull(value.phase),
|
|
observedSha: stringOrNull(value.observedSha),
|
|
targetSha: stringOrNull(value.targetSha),
|
|
totalSeconds: numberOrNull(value.totalSeconds),
|
|
timingStatus: stringOrNull(value.timingStatus),
|
|
resourceVersion: stringOrNull(metadata?.resourceVersion),
|
|
rawStateDiagnostic: asOptionalRecord(value.rawStateDiagnostic),
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
function stateWriteInput(previous: Record<string, unknown>): 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<string, unknown> {
|
|
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<string, unknown> {
|
|
const stateByFollower = asOptionalRecord((read as unknown as Record<string, unknown>).stateByFollower) ?? {};
|
|
const valueBytesByFollower = asOptionalRecord((read as unknown as Record<string, unknown>).stateValueBytes) ?? {};
|
|
const errors = Array.isArray((read as unknown as Record<string, unknown>).errors) ? (read as unknown as Record<string, unknown>).errors as unknown[] : [];
|
|
const state = asOptionalRecord(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: errors.map(String).slice(0, 8),
|
|
metadata: read.stateMetadata,
|
|
valueBytes: numberOrNull(valueBytesByFollower[followerId]),
|
|
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),
|
|
rawStateDiagnostic: asOptionalRecord(state.rawStateDiagnostic),
|
|
};
|
|
}
|
|
|
|
function compactAdapterStatus(live: AdapterSummary): Record<string, unknown> {
|
|
const payload = asOptionalRecord(live.payload);
|
|
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,
|
|
gates: compactStatusGates(payload),
|
|
};
|
|
}
|
|
|
|
function compactStatusGates(payload: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (payload === null) return null;
|
|
const gitMirror = asOptionalRecord(payload.gitMirror);
|
|
const reuseConfig = asOptionalRecord(payload.reuseConfig);
|
|
const tekton = asOptionalRecord(payload.tekton);
|
|
const taskRuns = asOptionalRecord(payload.taskRuns);
|
|
const argo = asOptionalRecord(payload.argo);
|
|
const runtime = asOptionalRecord(payload.runtime);
|
|
return {
|
|
gitMirror: gitMirror === null ? null : {
|
|
ok: gitMirror.ok === true,
|
|
sourceSnapshotReady: gitMirror.sourceSnapshotReady === true,
|
|
pendingFlush: gitMirror.pendingFlush === true,
|
|
githubInSync: gitMirror.githubInSync === true,
|
|
localSource: stringOrNull(gitMirror.localSource),
|
|
githubSource: stringOrNull(gitMirror.githubSource),
|
|
localGitops: stringOrNull(gitMirror.localGitops),
|
|
githubGitops: stringOrNull(gitMirror.githubGitops),
|
|
},
|
|
reuseConfig: reuseConfig === null ? null : {
|
|
ok: reuseConfig.ok === true,
|
|
present: reuseConfig.present === true,
|
|
path: stringOrNull(reuseConfig.path),
|
|
sourceCommit: stringOrNull(reuseConfig.sourceCommit),
|
|
stageRef: stringOrNull(reuseConfig.stageRef),
|
|
sha256: stringOrNull(reuseConfig.sha256),
|
|
serviceCount: numberOrNull(reuseConfig.serviceCount),
|
|
serviceIds: arrayStrings(reuseConfig.serviceIds).slice(0, 16),
|
|
errors: Array.isArray(reuseConfig.errors) ? reuseConfig.errors.map(String).slice(0, 5) : [],
|
|
},
|
|
tekton: tekton === null ? null : {
|
|
name: stringOrNull(tekton.name),
|
|
pipelineRefName: stringOrNull(tekton.pipelineRefName),
|
|
succeeded: tekton.succeeded === true ? true : tekton.succeeded === false ? false : null,
|
|
reason: stringOrNull(tekton.reason),
|
|
startTime: stringOrNull(tekton.startTime),
|
|
completionTime: stringOrNull(tekton.completionTime),
|
|
durationSeconds: numberOrNull(tekton.durationSeconds),
|
|
},
|
|
taskRuns: taskRuns === null ? null : {
|
|
count: numberOrNull(taskRuns.count),
|
|
failedCount: numberOrNull(taskRuns.failedCount),
|
|
activeCount: numberOrNull(taskRuns.activeCount),
|
|
failedItems: taskRunItems(taskRuns, "failed"),
|
|
activeItems: taskRunItems(taskRuns, "active"),
|
|
slowItems: taskRunItems(taskRuns, "slow"),
|
|
},
|
|
argo: argo === null ? null : {
|
|
syncStatus: stringOrNull(argo.syncStatus),
|
|
healthStatus: stringOrNull(argo.healthStatus),
|
|
healthMessage: stringOrNull(argo.healthMessage),
|
|
revision: stringOrNull(argo.revision),
|
|
operationPhase: stringOrNull(argo.operationPhase),
|
|
operationMessage: stringOrNull(argo.operationMessage),
|
|
operationStartedAt: stringOrNull(argo.operationStartedAt),
|
|
operationFinishedAt: stringOrNull(argo.operationFinishedAt),
|
|
operationDurationSeconds: numberOrNull(argo.operationDurationSeconds),
|
|
conditions: Array.isArray(argo.conditions) ? argo.conditions.slice(0, 5) : [],
|
|
nonReadyResources: Array.isArray(argo.nonReadyResources) ? argo.nonReadyResources.slice(0, 5) : [],
|
|
ready: argo.ready === true,
|
|
},
|
|
pipeline: compactPipeline(payload.pipeline),
|
|
refreshEvidence: compactRefreshEvidence(payload.refreshEvidence),
|
|
runtime: runtime === null ? null : {
|
|
ready: runtime.ready === true,
|
|
targetSha: stringOrNull(runtime.targetSha),
|
|
expectedSha: stringOrNull(runtime.expectedSha),
|
|
aligned: runtime.aligned === true ? true : runtime.aligned === false ? false : null,
|
|
},
|
|
errors: Array.isArray(payload.errors) ? payload.errors.map(String).slice(0, 5) : [],
|
|
};
|
|
}
|
|
|
|
function compactPipeline(value: unknown): Record<string, unknown> | null {
|
|
const pipeline = asOptionalRecord(value);
|
|
if (pipeline === null) return null;
|
|
return {
|
|
metadata: asOptionalRecord(pipeline.metadata),
|
|
spec: asOptionalRecord(pipeline.spec),
|
|
};
|
|
}
|
|
|
|
function compactRefreshEvidence(value: unknown): Record<string, unknown> | null {
|
|
const refresh = asOptionalRecord(value);
|
|
if (refresh === null) return null;
|
|
return {
|
|
jobName: stringOrNull(refresh.jobName),
|
|
namespace: stringOrNull(refresh.namespace),
|
|
status: stringOrNull(refresh.status),
|
|
pipeline: stringOrNull(refresh.pipeline) ?? stringOrNull(asOptionalRecord(refresh.apply)?.pipelineName) ?? stringOrNull(asOptionalRecord(refresh.render)?.pipelineName),
|
|
sourceCommit: stringOrNull(refresh.sourceCommit),
|
|
sourceStageRef: stringOrNull(refresh.sourceStageRef),
|
|
elapsedMs: numberOrNull(refresh.elapsedMs),
|
|
render: compactRefreshRender(asOptionalRecord(refresh.render)),
|
|
apply: compactRefreshApply(asOptionalRecord(refresh.apply)),
|
|
sourceAuthority: stringOrNull(refresh.sourceAuthority),
|
|
statusAuthority: stringOrNull(refresh.statusAuthority),
|
|
parsedDownstreamCliOutput: false,
|
|
};
|
|
}
|
|
|
|
function compactRefreshRender(value: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (value === null) return null;
|
|
return {
|
|
pipelineName: stringOrNull(value.pipelineName),
|
|
taskCount: numberOrNull(value.taskCount),
|
|
runtimeReadyTask: compactRefreshRuntimeReady(asOptionalRecord(value.runtimeReadyTask)),
|
|
};
|
|
}
|
|
|
|
function compactRefreshApply(value: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (value === null) return null;
|
|
return {
|
|
pipelineName: stringOrNull(value.pipelineName),
|
|
namespace: stringOrNull(value.namespace),
|
|
resourceVersion: stringOrNull(value.resourceVersion),
|
|
annotations: compactStringMap(asOptionalRecord(value.annotations)),
|
|
labels: compactStringMap(asOptionalRecord(value.labels)),
|
|
degradedReason: stringOrNull(value.degradedReason),
|
|
};
|
|
}
|
|
|
|
function compactRefreshRuntimeReady(value: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (value === null) return null;
|
|
return {
|
|
present: booleanOrNull(value.present),
|
|
name: stringOrNull(value.name),
|
|
runAfter: compactStringArray(value.runAfter, 4),
|
|
when: compactWhenList(value.when, 4),
|
|
};
|
|
}
|
|
|
|
function compactWhenList(value: unknown, limit: number): Array<Record<string, unknown>> {
|
|
return Array.isArray(value)
|
|
? value
|
|
.map((item) => asOptionalRecord(item))
|
|
.filter((item): item is Record<string, unknown> => item !== null)
|
|
.slice(0, limit)
|
|
.map((item) => ({
|
|
input: stringOrNull(item.input),
|
|
operator: stringOrNull(item.operator),
|
|
values: compactStringArray(item.values, 4),
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
function compactStringMap(value: Record<string, unknown> | null): Record<string, string> | null {
|
|
if (value === null) return null;
|
|
const output: Record<string, string> = {};
|
|
for (const [key, item] of Object.entries(value).slice(0, 8)) {
|
|
const text = stringOrNull(item);
|
|
if (text !== null) output[key] = text;
|
|
}
|
|
return Object.keys(output).length === 0 ? null : output;
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
|
|
}
|
|
|
|
function arrayStrings(value: unknown): string[] {
|
|
return Array.isArray(value) ? value.map(String) : [];
|
|
}
|
|
|
|
function compactFollowerDecision(state: FollowerState): Record<string, unknown> {
|
|
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: compactDebugTimings(state.timings),
|
|
};
|
|
}
|
|
|
|
function compactDebugTimings(timings: FollowerState["timings"]): Record<string, unknown> {
|
|
return {
|
|
budgetSeconds: timings.budgetSeconds,
|
|
totalSeconds: timings.totalSeconds,
|
|
totalStatus: timings.totalStatus,
|
|
totalSource: timings.totalSource,
|
|
sourceCommit: timings.sourceCommit,
|
|
startedAt: timings.startedAt,
|
|
finishedAt: timings.finishedAt,
|
|
overBudget: timings.overBudget,
|
|
stages: timings.stages.slice(0, 8).map((stage) => ({
|
|
stage: stage.stage,
|
|
status: stage.status,
|
|
seconds: stage.seconds,
|
|
budgetSeconds: stage.budgetSeconds,
|
|
source: stage.source,
|
|
object: stage.object,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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`,
|
|
};
|
|
}
|
|
|
|
function parseLastJsonObject(text: string): Record<string, unknown> | 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<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : 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 booleanOrNull(value: unknown): boolean | null {
|
|
return value === true ? true : value === false ? false : null;
|
|
}
|
|
|
|
function compactStringArray(value: unknown, limit: number): string[] {
|
|
return Array.isArray(value) ? value.map((item) => stringOrNull(item)).filter((item): item is string => item !== null).slice(0, limit) : [];
|
|
}
|
|
|
|
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);
|
|
}
|