// 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; 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; let controllerSource: Record | 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 { 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 { 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 | null): Record | 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 | null): Record | 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 { 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): 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 stateByFollower = asOptionalRecord((read as unknown as Record).stateByFollower) ?? {}; const valueBytesByFollower = asOptionalRecord((read as unknown as Record).stateValueBytes) ?? {}; const errors = Array.isArray((read as unknown as Record).errors) ? (read as unknown as Record).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 { 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 | null): Record | 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 | null { const pipeline = asOptionalRecord(value); if (pipeline === null) return null; return { metadata: asOptionalRecord(pipeline.metadata), spec: asOptionalRecord(pipeline.spec), }; } function compactRefreshEvidence(value: unknown): Record | 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 | null): Record | null { if (value === null) return null; return { pipelineName: stringOrNull(value.pipelineName), taskCount: numberOrNull(value.taskCount), runtimeReadyTask: compactRefreshRuntimeReady(asOptionalRecord(value.runtimeReadyTask)), }; } function compactRefreshApply(value: Record | null): Record | 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 | null): Record | 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> { return Array.isArray(value) ? value .map((item) => asOptionalRecord(item)) .filter((item): item is Record => item !== null) .slice(0, limit) .map((item) => ({ input: stringOrNull(item.input), operator: stringOrNull(item.operator), values: compactStringArray(item.values, 4), })) : []; } function compactStringMap(value: Record | null): Record | null { if (value === null) return null; const output: Record = {}; 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[] { return Array.isArray(value) ? value.filter((item): item is Record => 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 { 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 { 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 { 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 | 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 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); }