Files
pikasTech-unidesk/scripts/src/cicd-debug.ts
T
2026-07-04 03:42:47 +00:00

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);
}